From 3c09e384fabb4c8e22c9eb3223387a17b6aa60e5 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:38:38 +0300 Subject: [PATCH 01/73] =?UTF-8?q?=D0=9C=D1=83=D0=BB=D1=8C=D1=82=D0=B8?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB,=20=D1=81?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=201:=20=D0=BD=D0=B5=D0=B9=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20backend-=D1=80=D0=B5=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=80=20+=20CLI=20backend-status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фундамент этапа 1: Hysteria 2 и AmneziaWG 2.0 не должны притворяться Xray-путями. Состояние бэкендов — /usr/local/etc/xrayebator/backends.json (BACKENDS_ROOT переопределяем для тестов, паттерн P2-fix); клиентские креденшелы бэкендов будут жить в профиле (.backends) как единый источник для revoke/expire, серверные конфиги регенерируются из профилей. - хелперы реестра: ensure/installed/field/set (мутации через safe_jq_write, jq-опции строго до фильтра), port_busy_udp, unit_state - _backend_status_json + диспетчер CLI backend-status (только JSON на stdout) - _backend_apply_profile_lifecycle — единая точка жизненного цикла (created|deleted|revoked|expired|restored); вызовы подключаются в срезах 3/5 вместе с реальными обработчиками, сейчас безопасный no-op - validation/test-backend-registry.sh: source-режим + временный BACKENDS_ROOT/LOCK_FILE; тест поймал реальный баг set -u unbound variable в local out/prev одной строкой (bash 5.2) — исправлено разнесением - help и CLAUDE.md синхронизированы Меню намеренно не тронуто: пункты 11/12/13 добавляются вместе со своими бэкендами из-за строгой нумерации в test-main-menu-numbering.sh. --- CLAUDE.md | 3 +- validation/test-backend-registry.sh | 90 +++++++++++++++++ xrayebator | 146 ++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 validation/test-backend-registry.sh diff --git a/CLAUDE.md b/CLAUDE.md index c8d8aae6..5d884c93 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/`** — 29 Bash test scripts, including `test-main-readiness-regressions.sh`, covering migrations, VLESS URL generation, transaction safety, dedup, firewall, menu numbering, the backend registry, 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. - **`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. @@ -150,6 +150,7 @@ 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, `backends//` runtime state, root 700/600); `/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`). 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/validation/test-backend-registry.sh b/validation/test-backend-registry.sh new file mode 100644 index 00000000..b8fb41c7 --- /dev/null +++ b/validation/test-backend-registry.sh @@ -0,0 +1,90 @@ +#!/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" + +# 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/xrayebator b/xrayebator index a951f63c..a5dd6fce 100755 --- a/xrayebator +++ b/xrayebator @@ -42,6 +42,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. @@ -1888,6 +1894,141 @@ close_firewall_port() { 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, ...}}. + +# Готовит корень/каталог бэкендов и пустой реестр. Идемпотентен. +_backend_ensure_registry() { + if [[ ! -d "$BACKENDS_ROOT" ]]; then + mkdir -p "$BACKENDS_ROOT" || return 1 + chmod 700 "$BACKENDS_ROOT" 2>/dev/null + fi + if [[ ! -d "$BACKENDS_DIR" ]]; then + mkdir -p "$BACKENDS_DIR" || return 1 + chmod 700 "$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 600 "$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 +} + +# Значение поля реестра бэкенда (пусто, если поля/бэкенда нет). +_backend_field() { + local type=$1 field=$2 + [[ -s "$BACKENDS_REGISTRY_FILE" ]] || { echo ""; return 0; } + jq -r --arg t "$type" --arg f "$field" '.[$t][$f] // empty' "$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 port unit state + for type in hysteria2 awg; do + prev="$out" + if _backend_installed "$type"; then installed="true"; else installed="false"; fi + port=$(_backend_field "$type" "port") + [[ "$port" =~ ^[0-9]+$ ]] || port="" + unit=$(_backend_field "$type" "unit") + state=$(_backend_unit_state "$unit") + out=$(jq -n \ + --argjson base "$prev" \ + --arg t "$type" \ + --argjson installed "$installed" \ + --argjson port "${port:-null}" \ + --arg unit "$unit" \ + --arg state "$state" \ + '$base + {($t): ({installed: $installed, state: $state} + (if $port != null then {port: $port} else {} end) + (if $unit != "" then {unit: $unit} else {} end))}' 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 +# ═══════════════════════════════════════════════════════════ + # Check whether a profile has route array profile_has_routes() { local profile_file=$1 @@ -12634,6 +12775,9 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then shift bypass_command "$@" ;; + backend-status) + _backend_status_json + ;; ""|menu) main_menu ;; @@ -12664,11 +12808,13 @@ 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 "" 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 ;; From 839a7ec457e6db3716dce572a3fa6c405d9a0673 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:57:37 +0300 Subject: [PATCH 02/73] =?UTF-8?q?=D0=9C=D1=83=D0=BB=D1=8C=D1=82=D0=B8?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB,=20=D1=81?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=202:=20backend=20Hysteria=202=20(install/uni?= =?UTF-8?q?nstall/status,=20=D0=BC=D0=B5=D0=BD=D1=8E=2011)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Первый не-Xray backend. Поставка зеркалит update_xray_core: GitHub API HyNetworks/hysteria (тег app/vX.Y.Z, sha256 из поля digest API-ответа) -> /usr/local/bin/hysteria + self-test. Свой юнит hysteria-server.service (не get.hy2.sh): выделенный пользователь hysteria, CAP_NET_BIND_SERVICE, Restart=on-failure — та же модель доверия, что у xray.service; плюс sysctl-буферы QUIC. TLS адаптивный: LE-серт подписки копируется в backends/hysteria с certbot deploy-hook на продление (клиенты insecure=0), иначе self-signed (insecure=1). masquerade 404 в обеих модах — proxy-маскарад только после живой проверки в срезе 3. Гранты per-profile (userpass) — срез 3, в yaml пока users: {}. - CLI hysteria2-install [--port P] / hysteria2-uninstall / hysteria2-status: только JSON на stdout (человеческий вывод в stderr), rc=2 от воркера = already/not installed -> {already:true} - install атомарен: скачивание во tmp + sha256 + self-test, откат артефактов при неудаче старта; реестр пишется последним шагом — записи только у живого бэкенда - _hysteria2_safe_restart: бэкап server.yaml -> restart -> откат при неудаче (у hysteria нет -test) - тест поймал потерю полей version/tls_mode в backend-status: статус теперь прокидывает всю запись реестра, state ставится живой проверкой - меню 11 + осознанное расширение test-main-menu-numbering до 1..11 --- CLAUDE.md | 3 +- validation/test-hysteria2-lifecycle.sh | 84 ++++ validation/test-main-menu-numbering.sh | 3 +- xrayebator | 506 ++++++++++++++++++++++++- 4 files changed, 588 insertions(+), 8 deletions(-) create mode 100644 validation/test-hysteria2-lifecycle.sh diff --git a/CLAUDE.md b/CLAUDE.md index 5d884c93..6bb66e4f 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/`** — 29 Bash test scripts, including `test-main-readiness-regressions.sh`, covering migrations, VLESS URL generation, transaction safety, dedup, firewall, menu numbering, the backend registry, 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/`** — 30 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 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. - **`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. @@ -151,6 +151,7 @@ Apart from the interactive menu (`sudo xrayebator`), the script exposes subcomma - `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, `backends//` runtime state, root 700/600); `/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]` / `hysteria2-uninstall` / `hysteria2-status` — Hysteria 2 UDP backend (multi-protocol slice 2, 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` (masquerade 404; `auth: userpass` with an empty users map until slice 3 wires per-profile grants). 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 (human output is redirected to stderr); exit code `2` from the workhorse means "already installed / not installed" and the wrapper reports `already: true`. Registry `sub_body` flag is the subscription kill-switch for slice 3. Full install/uninstall with real network/systemd is verified in the live spike, not CI; CI covers the renderers, registry integration and dispatch wiring. 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/validation/test-hysteria2-lifecycle.sh b/validation/test-hysteria2-lifecycle.sh new file mode 100644 index 00000000..c244c271 --- /dev/null +++ b/validation/test-hysteria2-lifecycle.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Тест backend Hysteria 2 — срез 2 (2026-10-04). +# Функционально проверяются чистые функции (render server.yaml / systemd-unit, +# arch-маппинг, TLS-детект без LE-маркеров), интеграция с реестром и +# hysteria2-status. Полный install/uninstall с сетью, openssl и systemd +# покрывается живым спайком на VPS (срез 7) — CI лишён этих условий. +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" +export LOCK_FILE="$TMP_ROOT/xrayebator.lock" + +# shellcheck disable=SC1091 +source ./xrayebator || fail "source ./xrayebator failed" + +# 1) render server.yaml +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 '^ users: {}$' "$yaml" || fail "yaml users empty map" +grep -q '^ type: 404$' "$yaml" || fail "yaml masquerade 404" +pass "server.yaml rendered with expected structure" + +# 2) render systemd-unit: модель безопасности как у xray.service +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 '^CapabilityBoundingSet=CAP_NET_BIND_SERVICE$' "$unit" || fail "unit CapabilityBoundingSet" +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)" + +# 3) arch-маппинг +arch=$(_hysteria2_asset_arch) || fail "asset arch" +case "$arch" in amd64|arm64|arm|386) ;; *) fail "unexpected arch: $arch" ;; esac +pass "asset arch resolves: $arch" + +# 4) TLS-детект в чистой среде (нет маркеров подписки) → selfsigned +[[ "$(_hysteria2_tls_mode_detect)" == "selfsigned" ]] || fail "tls detect expected selfsigned" +pass "tls mode defaults to selfsigned without subscription markers" + +# 5) реестр + hysteria2-status JSON +_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" +status="$(hysteria2_status_command)" || fail "hysteria2_status_command" +jq -e '.ok == true and + .backend.installed == true and + .backend.version == "v2.12.3" and + .backend.port == 443 and + (.binary == "absent" or .binary == "present")' <<<"$status" >/dev/null \ + || fail "status JSON shape: $status" +pass "hysteria2-status JSON shape correct" + +# 6) uninstall при отсутствии установки → rc 2 (wrapper переводит в already:true) +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)" +pass "uninstall is a safe no-op (rc 2) when not installed" + +# 7) статика: CLI-диспетчер и меню +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 ' 11) hysteria2_menu ;;' xrayebator || fail "menu dispatch 11 missing" +grep -Fq 'hysteria2_menu() {' xrayebator || fail "hysteria2_menu definition missing" +pass "CLI dispatch and menu wiring present" + +echo "" +pass "hysteria2 backend (slice 2): all checks green" diff --git a/validation/test-main-menu-numbering.sh b/validation/test-main-menu-numbering.sh index 49dc03e5..26e387d6 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" ]] \ || fail "displayed main-menu actions are not consecutive: ${displayed_numbers[*]}" expected_dispatch=( @@ -29,6 +29,7 @@ expected_dispatch=( "8) cascade_mode_menu ;;" "9) install_selfsteal_stub_menu ;;" "10) setup_outbound_server_menu ;;" + "11) hysteria2_menu ;;" "0) exit 0 ;;" ) diff --git a/xrayebator b/xrayebator index a5dd6fce..3dff24ed 100755 --- a/xrayebator +++ b/xrayebator @@ -1987,22 +1987,23 @@ _backend_status_json() { fi local out="{}" local prev="$out" - local type installed port unit state + 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 - port=$(_backend_field "$type" "port") - [[ "$port" =~ ^[0-9]+$ ]] || port="" 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" \ - --argjson port "${port:-null}" \ - --arg unit "$unit" \ --arg state "$state" \ - '$base + {($t): ({installed: $installed, state: $state} + (if $port != null then {port: $port} else {} end) + (if $unit != "" then {unit: $unit} else {} end))}' 2>/dev/null) + '$base + {($t): ($entry + {installed: $installed, state: $state})}' 2>/dev/null) [[ -n "$out" ]] || out="$prev" done echo "{\"ok\":true,\"backends\":$out}" @@ -2029,6 +2030,482 @@ _backend_apply_profile_lifecycle() { # 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). +_hysteria2_render_server_yaml() { + local out_file=$1 port=$2 cert=$3 key=$4 + 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 +} + +# Безопасный рестарт 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 + + _hysteria2_render_server_yaml "$HYSTERIA2_DIR/server.yaml" "$port" \ + "$HYSTERIA2_DIR/cert.pem" "$HYSTERIA2_DIR/key.pem" + _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" + 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 + } + + 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 "${YELLOW}ℹ Выдача доступа профилям (гранты) подключается в следующем срезе —${NC}" + echo -e "${YELLOW} сейчас подключиться клиентом ещё нельзя.${NC}" + 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 + while [[ $# -gt 0 ]]; do + case "$1" in + --port) + [[ $# -ge 2 ]] || { echo '{"ok":false,"error":"--port requires value"}' >&2; return 1; } + port=$2; shift 2 ;; + *) + echo "{\"ok\":false,\"error\":\"unknown argument: $1\"}" >&2 + return 1 ;; + esac + done + local rc=0 + exec 3>&1; exec 1>&2 + _hysteria2_install "$port" || 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}' +} + +# Меню 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} Удалить" + 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_uninstall + echo -n -e "${YELLOW}Нажмите Enter для продолжения...${NC}" + read -r + ;; + 0) return 0 ;; + *) echo -e "${RED}Неверный выбор${NC}"; sleep 1 ;; + esac + done +} +# ═══════════════════════════════════════════════════════════ +# END BACKEND: HYSTERIA 2 +# ═══════════════════════════════════════════════════════════ + # Check whether a profile has route array profile_has_routes() { local profile_file=$1 @@ -7216,6 +7693,9 @@ 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 "" echo -e "${RED} 0)${NC} Выход" echo "" echo -e "${BLUE}╚════════════════════════════════════════╝${NC}" @@ -7232,6 +7712,7 @@ main_menu() { 8) cascade_mode_menu ;; 9) install_selfsteal_stub_menu ;; 10) setup_outbound_server_menu ;; + 11) hysteria2_menu ;; 0) exit 0 ;; *) echo -e "${RED}Неверный выбор${NC}"; sleep 1 ;; esac @@ -12778,6 +13259,16 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then backend-status) _backend_status_json ;; + hysteria2-install) + shift + hysteria2_install_command "$@" + ;; + hysteria2-uninstall) + hysteria2_uninstall_command + ;; + hysteria2-status) + hysteria2_status_command + ;; ""|menu) main_menu ;; @@ -12809,6 +13300,9 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then 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 "" echo -e "${YELLOW}Файлы:${NC}" echo -e " config: /usr/local/etc/xray/config.json" From ef6455cc027db91cf1ff502ef63a586aa29fe001 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:22:54 +0300 Subject: [PATCH 03/73] =?UTF-8?q?=D0=9C=D1=83=D0=BB=D1=8C=D1=82=D0=B8?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB,=20=D1=81?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=203:=20=D0=B3=D1=80=D0=B0=D0=BD=D1=82=D1=8B?= =?UTF-8?q?=20Hysteria=202=20per-profile=20+=20=D0=BF=D0=BE=D0=B4=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=B0=20+=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=B6=D0=B8=D0=B7=D0=BD=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D1=86=D0=B8=D0=BA=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Профиль — единственный источник креденшелов: .backends.hysteria2 {username, password} в profile JSON; server.yaml всегда РЕГЕНЕРИРУЕТСЯ из профилей (_hysteria2_regen_config: компактная users-карта, фильтр expire_disabled), применяется через _hysteria2_apply_config с откатом при неудачном рестарте. Благодаря фильтру события expired/restored — это просто реген: продление срока автоматически возвращает грант, снапшоты не нужны. Lifecycle-хук подключён во все точки после успешной Xray-части: create_profile и _profile_cli_create_one (created), profile_delete (deleted), profile_revoke (revoked = ротация пароля), _expire_disable/_expire_enable (expired/restored). Массовая выдача: интерактивный вопрос при install, CLI --grant-all; один реген на всех. Подписка: hysteria2-ссылка (_hysteria2_profile_link_pure, insecure из tls_mode) добавляется в ОБА тела — generic v2rayNG и HAPP (решение пользователя); флаг sub_body в реестре — выключатель без рестарта сервиса (реестр читается на каждый запрос). Миграция subhttp_hysteria2_2026 регенерирует handler на живых серверах, зарегистрирована в main_menu И quickstart (паритет). Инфраструктурные находки: (1) subhttp работает от пользователя xray — реестр переведён на 644 root (без секретов, читается сервисом), секреты в 750-каталогах; (2) PROFILES_DIR переопределяем для тестов (P2-fix); (3) users-карта в yaml — компактный JSON (jq -sc). hysteria2-grant CLI + пункт меню 'Ссылка hysteria2' с QR. Живой тест HAPP-тела с hysteria2-строкой — срез 7. --- CLAUDE.md | 5 +- validation/test-hysteria2-lifecycle.sh | 127 ++++++++-- xrayebator | 335 +++++++++++++++++++++++-- 3 files changed, 433 insertions(+), 34 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6bb66e4f..69d94b7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,8 +150,9 @@ 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, `backends//` runtime state, root 700/600); `/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]` / `hysteria2-uninstall` / `hysteria2-status` — Hysteria 2 UDP backend (multi-protocol slice 2, 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` (masquerade 404; `auth: userpass` with an empty users map until slice 3 wires per-profile grants). 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 (human output is redirected to stderr); exit code `2` from the workhorse means "already installed / not installed" and the wrapper reports `already: true`. Registry `sub_body` flag is the subscription kill-switch for slice 3. Full install/uninstall with real network/systemd is verified in the live spike, not CI; CI covers the renderers, registry integration and dispatch wiring. +- `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` — 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), `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. 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/validation/test-hysteria2-lifecycle.sh b/validation/test-hysteria2-lifecycle.sh index c244c271..c9764373 100644 --- a/validation/test-hysteria2-lifecycle.sh +++ b/validation/test-hysteria2-lifecycle.sh @@ -1,9 +1,11 @@ #!/usr/bin/env bash -# Тест backend Hysteria 2 — срез 2 (2026-10-04). +# Тест backend Hysteria 2 — срезы 2+3 (2026-10-04). # Функционально проверяются чистые функции (render server.yaml / systemd-unit, -# arch-маппинг, TLS-детект без LE-маркеров), интеграция с реестром и -# hysteria2-status. Полный install/uninstall с сетью, openssl и systemd -# покрывается живым спайком на VPS (срез 7) — CI лишён этих условий. +# 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")/.." @@ -11,16 +13,33 @@ 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" -# 1) render server.yaml +# ── Срез 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" @@ -31,54 +50,130 @@ grep -q '^ users: {}$' "$yaml" || fail "yaml users empty map" grep -q '^ type: 404$' "$yaml" || fail "yaml masquerade 404" pass "server.yaml rendered with expected structure" -# 2) render systemd-unit: модель безопасности как у xray.service +_hysteria2_render_server_yaml "$yaml" 8443 "/c" "/k" '{"alice":"pw1"}' || fail "render yaml users" +grep -q '^ users: {"alice":"pw1"}$' "$yaml" || fail "yaml users 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 '^CapabilityBoundingSet=CAP_NET_BIND_SERVICE$' "$unit" || fail "unit CapabilityBoundingSet" 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)" -# 3) arch-маппинг arch=$(_hysteria2_asset_arch) || fail "asset arch" case "$arch" in amd64|arm64|arm|386) ;; *) fail "unexpected arch: $arch" ;; esac pass "asset arch resolves: $arch" -# 4) TLS-детект в чистой среде (нет маркеров подписки) → selfsigned [[ "$(_hysteria2_tls_mode_detect)" == "selfsigned" ]] || fail "tls detect expected selfsigned" pass "tls mode defaults to selfsigned without subscription markers" -# 5) реестр + hysteria2-status JSON +# ── Реестр + статус ── + _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" +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" +grep -q '^ users: {}$' "$HYSTERIA2_DIR/server.yaml" || fail "expired: alice still in users map" +pass "event expired removes disabled profile grant from users map" + +# 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" + +# 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 ── -# 6) uninstall при отсутствии установки → rc 2 (wrapper переводит в already:true) 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)" -pass "uninstall is a safe no-op (rc 2) when not installed" +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" + +# ── Статика: диспетчер, меню, миграция, подписка ── -# 7) статика: CLI-диспетчер и меню 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" -pass "CLI dispatch and menu wiring present" +[[ "$(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 (slice 2): all checks green" +pass "hysteria2 backend (slices 2+3): all checks green" diff --git a/xrayebator b/xrayebator index 3dff24ed..03fd5488 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" @@ -1902,16 +1904,18 @@ close_firewall_port() { # Клиентские креденшелы бэкендов живут в профилях (.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 700 "$BACKENDS_ROOT" 2>/dev/null + chmod 755 "$BACKENDS_ROOT" 2>/dev/null fi if [[ ! -d "$BACKENDS_DIR" ]]; then mkdir -p "$BACKENDS_DIR" || return 1 - chmod 700 "$BACKENDS_DIR" 2>/dev/null + 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 @@ -1923,7 +1927,7 @@ _backend_ensure_registry() { return 1 fi mv "${BACKENDS_REGISTRY_FILE}.tmp.$$" "$BACKENDS_REGISTRY_FILE" || return 1 - chmod 600 "$BACKENDS_REGISTRY_FILE" 2>/dev/null + chmod 644 "$BACKENDS_REGISTRY_FILE" 2>/dev/null fi return 0 } @@ -2090,8 +2094,9 @@ _hysteria2_fetch_release_info() { } # Рендер серверного конфига (чистая функция — тестируется в CI). +# $5 = users-карта компактным JSON ({"user":"pass"}), по умолчанию пустая. _hysteria2_render_server_yaml() { - local out_file=$1 port=$2 cert=$3 key=$4 + local out_file=$1 port=$2 cert=$3 key=$4 users=${5:-'{}'} cat > "$out_file" </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" +} + +# Применение нового 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="{}" + 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) + _hysteria2_grant_profile "$name" || return 1 + ;; + 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|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) + [[ -n "$name" ]] && uri="${uri}#${name}" + 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 @@ -2349,6 +2581,19 @@ EOF 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}" @@ -2357,8 +2602,7 @@ EOF echo -e "${BLUE}Версия:${NC} $version" echo -e "${BLUE}Порт:${NC} UDP $port" echo -e "${BLUE}TLS:${NC} $tls_mode" - echo -e "${YELLOW}ℹ Выдача доступа профилям (гранты) подключается в следующем срезе —${NC}" - echo -e "${YELLOW} сейчас подключиться клиентом ещё нельзя.${NC}" + echo -e "${BLUE}Грантов выдано:${NC} $(_hysteria2_grants_count)" return 0 } @@ -2402,12 +2646,14 @@ _hysteria2_uninstall() { # CLI: hysteria2-install [--port P]. Только JSON на stdout. hysteria2_install_command() { - local port=443 + 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 ;; @@ -2415,7 +2661,7 @@ hysteria2_install_command() { done local rc=0 exec 3>&1; exec 1>&2 - _hysteria2_install "$port" || rc=$? + _hysteria2_install "$port" "$grant_mode" || rc=$? exec 1>&3 if [[ $rc == 0 ]]; then jq -n '{ok: true}' @@ -2471,7 +2717,8 @@ hysteria2_menu() { echo "" echo -e "${CYAN} 1)${NC} Установить (UDP-порт, по умолчанию 443)" echo -e "${CYAN} 2)${NC} Статус" - echo -e "${CYAN} 3)${NC} Удалить" + echo -e "${CYAN} 3)${NC} Ссылка hysteria2 для профиля" + echo -e "${CYAN} 4)${NC} Удалить" echo -e "${RED} 0)${NC} Назад" echo "" echo -n -e "${YELLOW}Выберите действие: ${NC}" @@ -2493,6 +2740,11 @@ hysteria2_menu() { 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 @@ -6131,6 +6383,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 @@ -6259,16 +6519,24 @@ 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" + # Мультибэкенд: 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 @@ -7570,6 +7838,21 @@ happ_subscription_menu() { 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. @@ -7625,6 +7908,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 вывода. @@ -8195,6 +8479,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 } @@ -8718,6 +9004,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 } @@ -8758,6 +9047,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 } @@ -11176,6 +11467,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 @@ -11938,6 +12230,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}" @@ -12029,6 +12323,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 } @@ -12145,6 +12441,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 @@ -13269,6 +13567,10 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then hysteria2-status) hysteria2_status_command ;; + hysteria2-grant) + shift + hysteria2_grant_command "$@" + ;; ""|menu) main_menu ;; @@ -13303,6 +13605,7 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then 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 "" echo -e "${YELLOW}Файлы:${NC}" echo -e " config: /usr/local/etc/xray/config.json" From 23932aeeb8b731c9cf0fb956df9a40ae83a759ca Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:36:00 +0300 Subject: [PATCH 04/73] =?UTF-8?q?=D0=9C=D1=83=D0=BB=D1=8C=D1=82=D0=B8?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB,=20=D1=81?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=204:=20backend=20AmneziaWG=202.0=20(kernel?= =?UTF-8?q?=20DKMS,=20install/uninstall,=20=D0=BC=D0=B5=D0=BD=D1=8E=2012/1?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Спайк по README amneziawg закрыл главное допущение: официальный путь — PPA amnezia/ppa + apt install amneziawg (DKMS-модуль + tools), для Debian — ручная строка репо; фолбэк — сборка из исходников, где для ядра >=5.6 нужен ПОЛНЫЙ linux-source (headers недостаточно) с линком kernel-symlink в дерево модуля. Junk-параметры подтверждены по спеке: Jc 1..128, Jmin ручная сборка -> приёмка modprobe+lsmod+awg show awg0; реестр последним шагом; неудача = чистый откат артефактов (без полу-состояний) - интерфейс awg0 через awg-quick@awg0, конфиг 0600, Address 10.8.1.1/24, MTU 1280, PostUp/PostDown MASQUERADE на iface default-route (детект при install, литералом в конфиг) - ip_forward через /etc/sysctl.d/99-xrayebator-awg.conf; порт — случайный высокий 20000-59999 с проверкой занятости (ss) и конфликта с hysteria-портом - uninstall гасит интерфейс и убирает артефакты, пакеты/модуль остаются в системе (честно пишется в выводе) - меню 12 (AmneziaWG) и 13 (Статус бэкендов) + CLI awg-install/uninstall/status (JSON-гигиена, rc=2 -> already) - test-awg-lifecycle.sh (31-й): junk-параметры в спеке 5/5 прогонов, рендер конфига, keygen через fake awg, реестр/статус, статика диспетчера; полный install с сетью+DKMS — живой спайк среза 7 - нумерация меню расширена осознанно до 1..13 --- CLAUDE.md | 3 +- validation/test-awg-lifecycle.sh | 111 +++++++ validation/test-main-menu-numbering.sh | 4 +- xrayebator | 402 +++++++++++++++++++++++++ 4 files changed, 518 insertions(+), 2 deletions(-) create mode 100644 validation/test-awg-lifecycle.sh diff --git a/CLAUDE.md b/CLAUDE.md index 69d94b7f..2eee145f 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/`** — 30 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 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/`** — 31 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, 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. - **`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. @@ -153,6 +153,7 @@ Apart from the interactive menu (`sudo xrayebator`), the script exposes subcomma - `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` — 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), `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` / `awg-uninstall` / `awg-status` — AmneziaWG 2.0 system-VPN backend (multi-protocol slice 4, 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, `Address 10.8.1.1/24`, MTU 1280, PostUp MASQUERADE on the default-route iface, no peers until slice 5). Junk params are generated per the AWG 2.0 spec: Jc 1..128, Jmin&1; exec 1>&2 ... exec 1>&3`). Keep it that way when editing. diff --git a/validation/test-awg-lifecycle.sh b/validation/test-awg-lifecycle.sh new file mode 100644 index 00000000..9078c4fe --- /dev/null +++ b/validation/test-awg-lifecycle.sh @@ -0,0 +1,111 @@ +#!/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" + +# shellcheck disable=SC1091 +source ./xrayebator || fail "source ./xrayebator failed" + +# 1) junk-параметры: диапазоны и уникальность (5 прогонов) +for _ in 1 2 3 4 5; do + junk=$(_awg_gen_junk) + jq -e ' + .Jc >= 1 and .Jc <= 128 and + .Jmin >= 8 and .Jmin < .Jmax and .Jmax <= 1280 and + .S1 >= 15 and .S1 <= 150 and .S2 >= 15 and .S2 <= 150 and + (.S1 + 56) != .S2 and + .H1 >= 5 and .H2 >= 5 and .H3 >= 5 and .H4 >= 5 and + ([.H1, .H2, .H3, .H4] | (length == ([unique[]] | length))) + ' <<<"$junk" >/dev/null || fail "junk params out of spec: $junk" +done +pass "junk params generated within AWG 2.0 spec (5/5 runs)" + +# 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) рендер серверного конфига +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 "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" +pass "server conf rendered (Address/keys/junk/PostUp-MASQUERADE, no peers yet)" + +# 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) uninstall при отсутствии установки → rc 2 (safe no-op) +safe_jq_write --arg t awg 'del(.[$t])' "$BACKENDS_REGISTRY_FILE" || fail "registry del" +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 ' 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 'backend_status_menu() {' xrayebator || fail "backend_status_menu definition missing" +pass "CLI dispatch and menu wiring present (12/13)" + +echo "" +pass "amneziawg backend (slice 4): all checks green" diff --git a/validation/test-main-menu-numbering.sh b/validation/test-main-menu-numbering.sh index 26e387d6..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 11" ]] \ +[[ "${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=( @@ -30,6 +30,8 @@ expected_dispatch=( "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/xrayebator b/xrayebator index 03fd5488..057cfaf5 100755 --- a/xrayebator +++ b/xrayebator @@ -2700,6 +2700,67 @@ hysteria2_status_command() { echo "$full" | jq --arg bin "$bin_state" '{ok: .ok, binary: $bin, backend: .backends.hysteria2}' } +# Меню 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} Удалить" + 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_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 @@ -2758,6 +2819,331 @@ hysteria2_menu() { # 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 + 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] добавляет реген. +_awg_render_server_conf() { + local out_file=$1 port=$2 privkey=$3 params=$4 iface=$5 + local jc jmin jmax s1 s2 h1 h2 h3 h4 + 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") + h1=$(jq -r '.H1' <<<"$params"); h2=$(jq -r '.H2' <<<"$params") + h3=$(jq -r '.H3' <<<"$params"); h4=$(jq -r '.H4' <<<"$params") + 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 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 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) Интерфейс + 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) = $p' --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) = $m' --argjson mtu "$AWG_MTU" && + _backend_set awg '(.awg.junk) = $j' --argjson junk "$junk" && + _backend_set awg '(.awg.iface) = $i' --arg i "$iface" && + _backend_set awg '(.awg.sub_body) = false' || { + echo -e "${RED}✗ Не удалось записать реестр бэкендов${NC}" + systemctl disable --now "$AWG_UNIT" 2>/dev/null + rm -rf "$AWG_DIR" "$AWG_SYSCTL_FILE" + return 1 + } + + 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 "${YELLOW}ℹ Выдача peer-конфигов профилям — следующий срез.${NC}" + 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 -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 +} + +# CLI: awg-install / awg-uninstall / awg-status. Только JSON на stdout. +awg_install_command() { + local rc=0 + exec 3>&1; exec 1>&2 + _awg_install || 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}' +} + +# ═══════════════════════════════════════════════════════════ +# END BACKEND: AMNEZIAWG +# ═══════════════════════════════════════════════════════════ + # Check whether a profile has route array profile_has_routes() { local profile_file=$1 @@ -7979,6 +8365,8 @@ main_menu() { 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 "" @@ -7997,6 +8385,8 @@ main_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 @@ -13571,6 +13961,15 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then shift hysteria2_grant_command "$@" ;; + awg-install) + awg_install_command + ;; + awg-uninstall) + awg_uninstall_command + ;; + awg-status) + awg_status_command + ;; ""|menu) main_menu ;; @@ -13606,6 +14005,9 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then 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 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 "" echo -e "${YELLOW}Файлы:${NC}" echo -e " config: /usr/local/etc/xray/config.json" From ccfa30664274236e46e9f540550acd2d3e4db02e Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:49:18 +0300 Subject: [PATCH 05/73] =?UTF-8?q?=D0=9C=D1=83=D0=BB=D1=8C=D1=82=D0=B8?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB,=20=D1=81?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=205:=20peer-=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=20AmneziaWG=20+=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=B9=20.conf=20+=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B6=D0=B8=D0=B7=D0=BD=D0=B5=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=86=D0=B8=D0=BA=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Профиль — источник правды: .backends.awg {client_private_key, client_public_key, preshared_key, address} ; awg0.conf [Peer]-секции регенерируются из профилей (фильтр expire_disabled) и применяются рестартом интерфейса с откатом. Осознанный компромисс: рестарт коротко рвёт туннель всех peer-ов — гранты/отзывы редки, для этапа 1 приемлемо; живые awg set-мутации без рестарта — кандидат этапа 2. - аллокация адресов: первый свободный .2..254 с учётом уже выданных в профилях (.2 -> .3 проверено тестом) - события через общий lifecycle-хук диспетчеризуются автоматически: created (грант), revoked (ротация ключей с сохранением адреса — старые клиентские конфиги перестают подключаться), deleted/expired/restored (реген; restored возвращает peer после продления) - клиентский .conf (_awg_client_conf_pure): клиентская тройка, серверный pubkey/endpoint, junk-параметры копируются с сервера (спека: должны совпадать кроме Jc/Jmin/Jmax), AllowedIPs full-tunnel, Keepalive 25 - CLI awg-grant/awg-conf (JSON, conf в поле для GUI) + пункт меню 'Конфиг клиента' с QR; --grant-all при install - тест расширен до 12 проверок: адреса, peer-секции, форма клиентского конфига, все события; caught: no-op uninstall должен учитывать существование conf-файла --- CLAUDE.md | 3 +- validation/test-awg-lifecycle.sh | 76 ++++++- xrayebator | 341 ++++++++++++++++++++++++++++++- 3 files changed, 414 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2eee145f..e65f489c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,7 +153,8 @@ Apart from the interactive menu (`sudo xrayebator`), the script exposes subcomma - `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` — 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), `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` / `awg-uninstall` / `awg-status` — AmneziaWG 2.0 system-VPN backend (multi-protocol slice 4, 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, `Address 10.8.1.1/24`, MTU 1280, PostUp MASQUERADE on the default-route iface, no peers until slice 5). Junk params are generated per the AWG 2.0 spec: Jc 1..128, Jmin&1; exec 1>&2 ... exec 1>&3`). Keep it that way when editing. diff --git a/validation/test-awg-lifecycle.sh b/validation/test-awg-lifecycle.sh index 9078c4fe..969b90c0 100644 --- a/validation/test-awg-lifecycle.sh +++ b/validation/test-awg-lifecycle.sh @@ -41,6 +41,7 @@ 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" @@ -91,8 +92,74 @@ jq -e '.ok == true and .backend.installed == true and .backend.port == 51820 and || fail "awg status shape: $status" pass "awg-status JSON shape correct" -# 5) uninstall при отсутствии установки → rc 2 (safe no-op) +# ── Срез 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 = "))' <<<"$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 "PublicKey = $(jq -r '.public_key' "$AWG_PARAMS_FILE")" "$TMP_ROOT/carol-client.conf" \ + || fail "client conf server pubkey mismatch" +pass "client .conf carries keys, junk params, server peer and endpoint" + +# второй профиль → следующий адрес .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" + +# 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" @@ -101,11 +168,16 @@ pass "awg uninstall is a safe no-op (rc 2) when not installed" 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" -pass "CLI dispatch and menu wiring present (12/13)" +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/xrayebator b/xrayebator index 057cfaf5..ff543888 100755 --- a/xrayebator +++ b/xrayebator @@ -2733,7 +2733,8 @@ awg_menu() { echo "" echo -e "${CYAN} 1)${NC} Установить (kernel DKMS, случайный высокий UDP-порт)" echo -e "${CYAN} 2)${NC} Статус" - echo -e "${CYAN} 3)${NC} Удалить" + echo -e "${CYAN} 3)${NC} Конфиг клиента (peer)" + echo -e "${CYAN} 4)${NC} Удалить" echo -e "${RED} 0)${NC} Назад" echo "" echo -n -e "${YELLOW}Выберите действие: ${NC}" @@ -2751,6 +2752,11 @@ awg_menu() { read -r ;; 3) + awg_conf_menu + echo -n -e "${YELLOW}Нажмите Enter для продолжения...${NC}" + read -r + ;; + 4) _awg_uninstall echo -n -e "${YELLOW}Нажмите Enter для продолжения...${NC}" read -r @@ -3065,6 +3071,18 @@ EOF return 1 } + # Peer-гранты существующим профилям (срез 5). Режимы $2: prompt/grant_all/no_prompt. + local grant_mode=${2:-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}" @@ -3072,7 +3090,7 @@ EOF echo "" echo -e "${BLUE}Интерфейс:${NC} $AWG_IFACE, порт UDP $port, подсеть $AWG_SUBNET" echo -e "${BLUE}Junk-параметры:${NC} $(jq -c '.junk' "$AWG_PARAMS_FILE")" - echo -e "${YELLOW}ℹ Выдача peer-конфигов профилям — следующий срез.${NC}" + echo -e "${BLUE}Peer-ов выдано:${NC} $(_awg_grants_count)" return 0 } @@ -3101,11 +3119,27 @@ _awg_uninstall() { 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 || rc=$? + _awg_install "$grant_mode" || rc=$? exec 1>&3 if [[ $rc == 0 ]]; then jq -n '{ok: true}' @@ -3140,6 +3174,297 @@ awg_status_command() { echo "$full" | jq '{ok: .ok, backend: .backends.awg}' } +# ── Срез 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 +} + +# Применение нового 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 "${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 + rm -f "$backup" + return 1 +} + +# Регенерация 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" || 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 +} + +# Грант 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 +} + +# Обработчик событий жизненного цикла (через _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) + _awg_grant_profile "$name" || return 1 + ;; + 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|expired|restored) + [[ -f "$profile_file" ]] || return 0 + _awg_regen_config || return 1 + ;; + *) + return 0 + ;; + esac + return 0 +} + +# 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 + 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 + 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" +} + +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}' +} + +# Меню: клиентский .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 # ═══════════════════════════════════════════════════════════ @@ -13970,6 +14295,14 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then awg-status) awg_status_command ;; + awg-grant) + shift + awg_grant_command "$@" + ;; + awg-conf) + shift + awg_conf_command "$@" + ;; ""|menu) main_menu ;; @@ -14008,6 +14341,8 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then 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 "" echo -e "${YELLOW}Файлы:${NC}" echo -e " config: /usr/local/etc/xray/config.json" From 56554da0cf55c2bd53d640525e5149035b259256 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:53:12 +0300 Subject: [PATCH 06/73] =?UTF-8?q?=D0=9C=D1=83=D0=BB=D1=8C=D1=82=D0=B8?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB,=20=D1=81?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=206:=20=D0=B0=D0=B4=D0=B4=D0=B8=D1=82=D0=B8?= =?UTF-8?q?=D0=B2=D0=BD=D1=8B=D0=B5=20backends-=D0=BF=D0=BE=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=B2=20inspect=20=D0=B8=20profiles=20(GUI-=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BA=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GUI (этап 2) будет читать бэкенды из тех же JSON, что уже потребляет: profiles_command добавляет объект .backends профиля (гранты hysteria2/awg — креденшелы уровня оператора, как уже выданный sub_token), inspect_command — секрет-фри сводку реестра {installed, version, port} для импорта существующего сервера. STRICT read-only сохранён: inspect читает реестр только если файл существует и не создаёт его (гвардится test-quickstart-email-and-inspect.sh — прогнан, зелёный). Старые клиенты/GUI поля просто игнорируют — аддитивность. --- CLAUDE.md | 4 ++-- xrayebator | 22 ++++++++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e65f489c..0b8574e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,10 +136,10 @@ 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 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 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`). - `xrayebator profile-delete --name NAME` — delete a profile, emits `{"ok":true,"name":"..."}`. Inbound/firewall cleanup happens automatically. - `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. diff --git a/xrayebator b/xrayebator index ff543888..1171a472 100755 --- a/xrayebator +++ b/xrayebator @@ -12110,6 +12110,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" \ @@ -12122,12 +12133,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 } @@ -12728,6 +12740,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" @@ -12741,12 +12758,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 From 43f5e46ad5d15fd11773c945b5b6a6bf343e0001 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:32:04 +0300 Subject: [PATCH 07/73] =?UTF-8?q?=D0=96=D0=B8=D0=B2=D0=BE=D0=B9=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=20(2.26.125.147):=204=20=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D1=81=D0=B0=20Hysteria=202,=20=D0=BD=D0=B0=D0=B9=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BD=D0=B0=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=BC=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Срез 7, первая половина: сервер Ubuntu 24.04 (ядро 5.15, 16 ядер) — install.sh (канонический main) + перезапись менеджера dev-версией + quickstart --without-email (подписка 8443, IP-серт certbot 5.8, 7-маршрутный профиль). Затем hysteria2-install --grant-all. Поймано и починено: 1. CHDIR Permission denied юнита: каталог backends/hysteria получал 750, но оставался root:root — добавлен chown root:hysteria на каталог (права группы без смены владельца не работают). 2. 'empty auth userpass': hysteria отвергает ПУСТУЮ userpass-карту — введён placeholder-пользователь _xrayebator_placeholder (случайный пароль в 0600-файле, в карте только пока нет грантов). 3. ГЛАВНОЕ: поле auth-карты называется userpass, а не users — с 'users:' hysteria игнорировал карту и падал с 'empty auth userpass' независимо от содержимого. Проверено живым запуском: server up and running. 4. В HAPP-теле hysteria2-строка слипалась с последним vless:// (command substitution съедает trailing newline) — printf '%s\n' в HAPP-ветке; handler перегенерирован, тела проверены curl'ом. Результат на сервере: hysteria v2.12.3 активна на UDP/443, TLS=le (IP-серт), users-карта {happ: <грант>}, hysteria2:// строка в обоих телах подписки, флаг sub_body=true. --- validation/test-hysteria2-lifecycle.sh | 11 ++++--- xrayebator | 42 ++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/validation/test-hysteria2-lifecycle.sh b/validation/test-hysteria2-lifecycle.sh index c9764373..744e6ad0 100644 --- a/validation/test-hysteria2-lifecycle.sh +++ b/validation/test-hysteria2-lifecycle.sh @@ -46,12 +46,12 @@ 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 '^ users: {}$' "$yaml" || fail "yaml users empty map" +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 '^ users: {"alice":"pw1"}$' "$yaml" || fail "yaml users map inline" +grep -q '^ userpass: {"alice":"pw1"}$' "$yaml" || fail "yaml userpass map inline" pass "server.yaml accepts users map parameter" unit="$TMP_ROOT/hysteria-server.service" @@ -123,8 +123,11 @@ 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" -grep -q '^ users: {}$' "$HYSTERIA2_DIR/server.yaml" || fail "expired: alice still in users map" -pass "event expired removes disabled profile grant from users map" +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" diff --git a/xrayebator b/xrayebator index 1171a472..e88e9cce 100755 --- a/xrayebator +++ b/xrayebator @@ -2106,9 +2106,11 @@ tls: key: ${key} # Per-profile гранты: карта собрана из .backends.hysteria2 живых профилей. +# ВАЖНО: поле называется userpass (не users!) — с "users" hysteria падает +# с "empty auth userpass" (живой тест 2.26.125.147). auth: type: userpass - users: ${users} + userpass: ${users} masquerade: type: 404 @@ -2183,6 +2185,20 @@ _hysteria2_grants_count() { 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() { @@ -2231,6 +2247,12 @@ _hysteria2_regen_config() { 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 @@ -2537,8 +2559,17 @@ EOF 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" + "$HYSTERIA2_DIR/cert.pem" "$HYSTERIA2_DIR/key.pem" \ + "$(jq -cn --arg p "$placeholder_pass" '{_xrayebator_placeholder: $p}')" _hysteria2_apply_sysctl echo -e "${CYAN} → Устанавливаю бинарник и юнит...${NC}" @@ -2549,6 +2580,9 @@ EOF _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 @@ -7242,7 +7276,9 @@ else # 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 From 71e662c10e15e13de8df6f32de3c7644eebc80c8 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:27:00 +0300 Subject: [PATCH 08/73] =?UTF-8?q?=D0=96=D0=B8=D0=B2=D0=BE=D0=B9=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=20(2.26.125.147):=205=20=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=20AmneziaWG,=20=D0=BD=D0=B0=D0=B9=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BD=D0=B0=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=BC=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AWG установлен и проверен live на Ubuntu 24.04 / ядре 5.15: PPA недоступен для этой серии -> ручная сборка сработала (модуль в lsmod, tools v3.1), интерфейс awg0 поднялся, peer профиля виден в awg show, ip_forward+MASQUERADE работают. Поймано и починено: 1. Юнит awg-quick@awg0 ищет конфиг в /etc/amnezia/amneziawg/ — наш файл линкуется туда symlink'ом (источник правды остаётся в backends/awg/), uninstall снимает линк. 2. Mismatch имён jq-переменных в _backend_set: port/mtu/junk в awg-цепочке (expr \/\/\ против аргументов port/mtu/junk) —Registry-запись молча обрывалась на втором поле. Все 13 мест выверены grep'ом. 3. Откат при неудаче записи реестра оставлял висящий symlink и полу-запись .awg — теперь чистит и то, и другое (проверено: backend-status после отката чист). 4. Диспетчер awg-install не пробрасывал \$@ — CLI-флаги (например --grant-all) молча терялись. 5. _awg_install читал режим грантов из \, хотя у AWG нет аргумента порта — режим всегда падал в prompt. Теперь \ (у hysteria — \, там порт первый). Полный жизненный цикл подтверждён live: revoke ротирует пароль hysteria и ключи AWG peer-а синхронно в обоих конфигах; expire снимает грант и peer (placeholder в userpass-карте держит конфиг валидным); restore возвращает оба; подписка с ротированным токеном отдаёт 200 и hysteria2-строку в обоих телах. --- xrayebator | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/xrayebator b/xrayebator index e88e9cce..f26f0c6c 100755 --- a/xrayebator +++ b/xrayebator @@ -3071,6 +3071,10 @@ EOF 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}" @@ -3092,21 +3096,25 @@ EOF # 6) Реестр — последним шагом _backend_set awg '(.awg.installed) = true' && _backend_set awg '(.awg.version) = $v' --arg v "kernel-dkms" && - _backend_set awg '(.awg.port) = $p' --argjson port "$port" && + _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) = $m' --argjson mtu "$AWG_MTU" && - _backend_set awg '(.awg.junk) = $j' --argjson junk "$junk" && + _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' || { 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). Режимы $2: prompt/grant_all/no_prompt. - local grant_mode=${2:-prompt} + # 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 @@ -3140,6 +3148,7 @@ _awg_uninstall() { 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" @@ -14341,7 +14350,8 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then hysteria2_grant_command "$@" ;; awg-install) - awg_install_command + shift + awg_install_command "$@" ;; awg-uninstall) awg_uninstall_command From 087c5cb4ddf262e78384d459ba204db4f0a42670 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:58:57 +0300 Subject: [PATCH 09/73] =?UTF-8?q?=D0=94=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F:=20=D0=BC=D1=83=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D0=B8=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=8D=D1=82=D0=B0=D0=BF=201?= =?UTF-8?q?=20+=20AWG=203.1=20(en/ru/zh)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Синхронизация всех доков с мультибэкенд-обновлениями и добавление раздела AWG 3.1 по статье Хабра (meshroom, 'AmneziaWG: от 2.0 к 3.1') с учётом специфики пассивного анализа ТСПУ/DPI. - architecture (en/ru/zh): дерево состояния с /usr/local/etc/xrayebator и новыми юнитами; секция Multi-protocol backends (реестр, lifecycle через профиль как источник правды, hysteria2/awg-модели); hysteria2-строка в обоих телах подписки - configuration (en/ru/zh): меню 1..13 (восстановлен потерянный пункт 7 bypass), все новые CLI-команды, секция Multi-protocol backends с таблицей 'должны совпадать побайтово vs локальные' и правилами миграции на 3.x; отремонтирован сломанный заголовок секции Bypass (висел текст без заголовка во всех трёх языках) - security (en/ru/zh): модель безопасности бэкендов — registry 644 без секретов (читается subhttp от xray), hysteria root:hysteria 750/0640, awg 700/0600, deploy-hook, placeholder-credential, ip_forward/MASQUERADE - testing (en/ru/zh): 28 -> 31 скрипт + три новых сюиты с описанием, граница 'CI с моками vs live-спайк' - troubleshooting (en/ru/zh): четыре новых раздела — Hysteria не стартует (userpass/CHDIR/placeholder), AWG без latest handshake (чек-лист must-match из статьи, версии клиентов AmneziaVPN >= 5.0.1.5), установка AWG (PPA/linux-source/symlink), отсутствие hysteria2-строки в подписке (sub_body) - README (en/ru/zh): чипы hysteria2/amneziawg + абзац о мультипротокольном этапе - CLAUDE.md синхронизирован ранее (31 тест, все команды) --- README.md | 9 ++-- README.ru.md | 8 ++- README.zh-CN.md | 7 ++- docs/architecture.md | 37 +++++++++++++- docs/configuration.md | 91 ++++++++++++++++++++++++++++++++++- docs/ru/architecture.md | 37 +++++++++++++- docs/ru/configuration.md | 91 ++++++++++++++++++++++++++++++++++- docs/ru/security.md | 21 ++++++++ docs/ru/testing.md | 9 ++-- docs/ru/troubleshooting.md | 63 ++++++++++++++++++++++++ docs/security.md | 21 ++++++++ docs/testing.md | 17 +++++-- docs/troubleshooting.md | 63 ++++++++++++++++++++++++ docs/zh-CN/architecture.md | 33 ++++++++++++- docs/zh-CN/configuration.md | 82 ++++++++++++++++++++++++++++++- docs/zh-CN/security.md | 19 ++++++++ docs/zh-CN/testing.md | 9 ++-- docs/zh-CN/troubleshooting.md | 55 +++++++++++++++++++++ 18 files changed, 646 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index a126fe12..a6f90e1e 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 (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..fafa4343 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 +(системный VPN) — пункты меню 11–13, гранты per-profile включены в общий жизненный цикл +revoke/expire. Актуальная серверная линия — 3.0; опциональное Electron-десктоп-приложение имеет отдельную версию.

diff --git a/README.zh-CN.md b/README.zh-CN.md index fbb64663..f28466d1 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(系统级 VPN)后端——菜单项 11–13,每配置档授权纳入统一的 +revoke/expire 生命周期。当前服务器版本线为 3.0;可选 Electron 桌面应用单独版本化。

diff --git a/docs/architecture.md b/docs/architecture.md index 98ac09bf..b3586bec 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 ``` @@ -132,7 +140,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..96323416 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. @@ -129,6 +133,16 @@ is a client-side profile/route setting; changing it does not restart Xray or alt | `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 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 per the AWG spec, `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-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 | @@ -191,8 +205,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 +225,77 @@ 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 are generated per the AWG spec: +`Jc` 1..128, `Jmin < Jmax ≤ 1280`, `S1`/`S2` 15..150 with `S1+56 ≠ S2`, `H1`–`H4` unique in +5..2147483647. 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. 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 generates the 2.0-era parameter set and emits no 3.x-only keys; a 3.x engine reads such a +config with the 3.x features off, so the stack is upgrade-safe. Enabling 3.x later is a deliberate +migration, and it is exactly the migration that 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 +(`HeaderProtectionKey`), 3.1 adds `RandomTrailers` (removes the fixed-length handshake signature) +and optional `DisableCookies` (removes the cookie-reply pattern, at the cost of the built-in +anti-amplification defence). Migration rules from the upstream docs: + +1. binaries first — a 3.1 engine reads 2.0/3.0-era configs with missing keys treated as off; +2. `S1`–`S4` must be ≥ 12 in 3.x (Xrayebator generates 15–150, already valid); +3. `HeaderProtectionKey` and `RandomTrailers` must match byte-for-byte; enabling them on a live + server instantly breaks every previously issued client config — plan a window and reissue; +4. clients need AmneziaVPN ≥ 5.0.1.5 for the 3.1 keys; older apps may refuse to import the config + at all — keep a separate non-3.1 server for them; +5. verify by `awg show`: no `latest handshake` means the must-match group disagrees; a handshake + without traffic points to `awg-quick`/routing/firewall instead. + ## 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/ru/architecture.md b/docs/ru/architecture.md index 3f9d78ce..ec140d7f 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 ``` @@ -124,7 +132,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..b836422d 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 и не меняет другие маршруты. @@ -132,6 +136,16 @@ legacy-файлы и блоки, ранее созданные Xrayebator, и с | `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 awg-install [--grant-all]` | Установить системный VPN-бэкенд AmneziaWG 2.0: kernel-модуль через DKMS (PPA `amnezia/ppa`, фолбэк — сборка), интерфейс `awg0` на случайном высоком UDP-порту, junk-параметры по спеке AWG, `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-update [branch]` | Запустить полный `update.sh` lifecycle update; без аргумента — интерактивный выбор ветки | | `sudo xrayebator-uninstall` | Снять сервис и конфигурацию | @@ -168,7 +182,10 @@ npm test npm run typecheck ``` +## Bypass routing +Bypass routing отправляет выбранные домены мимо туннеля: такой трафик идёт `direct`, остальное +продолжает ходить через VPN. Правила `domain -> direct` стоят выше catch-all, поэтому продолжают работать и при включённом каскаде. @@ -186,6 +203,78 @@ 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-параметры генерируются по спеке AWG: `Jc` 1..128, +`Jmin < Jmax ≤ 1280`, `S1`/`S2` 15..150 с `S1+56 ≠ S2`, `H1`–`H4` уникальные в 5..2147483647. +Каждый профиль-пир получает ключевую пару, preshared key и первый свободный адрес; клиентский +`.conf` (пункт меню 12 или `awg-conf --name N`) несёт full-tunnel `AllowedIPs` и серверные +junk-параметры. Изменения 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 генерирует набор параметров эпохи 2.0 и не эмитит 3.x-ключи; движок 3.x читает такой +конфиг с выключенными 3.x-фичами — стек безопасен к обновлению. Включение 3.x позже — осознанная +миграция, и именно она важна против российского DPI/анализа ТСПУ: у обычного WireGuard хендшейки +имеют фиксированные длины пакетов и открытые байты типов заголовков — пассивная классификация +работает без разбора содержимого. AWG 3.0 шифрует заголовок (`HeaderProtectionKey`), 3.1 добавляет +`RandomTrailers` (убирает сигнатуру фиксированной длины) и опциональный `DisableCookies` (убирает +паттерн cookie-ответа ценой встроенной защиты от amplification). Правила миграции из upstream-доков: + +1. сначала бинарники — движок 3.1 читает конфиги эпохи 2.0/3.0, отсутствующие ключи = выключено; +2. `S1`–`S4` в 3.x должны быть ≥ 12 (Xrayebator генерирует 15–150 — уже валидно); +3. `HeaderProtectionKey` и `RandomTrailers` должны совпадать побайтово; включение их на живом + сервере мгновенно ломает все ранее выданные клиентские конфиги — планируйте окно и + перевыпускайте клиентов; +4. клиентам нужен AmneziaVPN ≥ 5.0.1.5 для ключей 3.1 — старые приложения могут отказаться + импортировать конфиг целиком — держите для них отдельный сервер без 3.1-флагов; +5. проверка по `awg show`: нет `latest handshake` — расходится группа «должны совпадать»; есть + хендшейк, но нет трафика — ищите в `awg-quick`/маршрутизации/firewall. + ## Каскад и upstream-ноды Каскад — серверный режим outbound и routing, а не новый профиль клиента. Клиент продолжает 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..7ca5a9d5 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/` лежат 31 статический и локальный регрессионный тест: | Тест | Что проверяет | |---|---| @@ -36,7 +36,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, pure-строитель `hysteria2://`, проводка диспетчера и подписки | +| `test-awg-lifecycle.sh` | Бэкенд AmneziaWG: диапазоны junk-параметров по спеке, генерация ключей, рендер серверного конфига (PostUp MASQUERADE, без peer-ов при установке), аллокация адресов, содержимое клиентского `.conf`, события revoke/expire/restore, диспетчер 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 | @@ -105,7 +108,7 @@ npm test # Vitest unit-тесты Три независимых workflow: -- **ci-linux.yml** — Bash validation: `bash -n` всех скриптов + все 28 `validation/test-*.sh` на +- **ci-linux.yml** — Bash validation: `bash -n` всех скриптов + все 31 `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..361e5c2a 100644 --- a/docs/ru/troubleshooting.md +++ b/docs/ru/troubleshooting.md @@ -206,6 +206,69 @@ 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 их не эмитит, значит несовпадение означает смешение с чужим или + ручным конфигом; +- версия клиентского приложения: конфиги с `RandomTrailers` требуют AmneziaVPN ≥ 5.0.1.5; более + старые могут отказаться импортировать конфиг целиком. + +Затем разделите зоны отладки: `awg show awg0` на сервере — если peer в списке, хендшейк есть, а +трафика нет, проблема в зоне `awg-quick`/маршрутизации/firewall (`ip_forward`, интерфейс +MASQUERADE), а не в параметрах протокола. + +## Установка 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`. 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..f632ca32 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 31 scripts. Run every `validation/test-*.sh`; the current set is: | Script | What it checks | |---|---| @@ -51,7 +51,10 @@ promise that installer and updater paths behave identically. | `test-happ-subscription-static.sh` | Static HAPP subscription handler behavior | | `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, pure `hysteria2://` builder, CLI dispatch and subscription wiring | +| `test-awg-lifecycle.sh` | AmneziaWG backend: junk-parameter spec ranges, key generation, server conf renderer (PostUp MASQUERADE, no peers at install), peer address allocation, client `.conf` contents, revoke/expire/restore events, 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 | @@ -106,6 +109,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 +160,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 31 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..35d0963f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -231,6 +231,69 @@ 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 does not emit them, so a mismatch means a config 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. + +## 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. diff --git a/docs/zh-CN/architecture.md b/docs/zh-CN/architecture.md index 37954d8d..d0838485 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 ``` @@ -107,7 +115,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..cd19a06f 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) Подключиться по профилю` 重新获取原始线路。 @@ -117,6 +121,16 @@ Xrayebator 不会更改主机的 TCP 拥塞控制算法,也不会写入或应 | `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 awg-install [--grant-all]` | 安装 AmneziaWG 2.0 系统级 VPN 后端:DKMS 内核模块(首选 `amnezia/ppa` PPA,回退源码编译)、`awg0` 接口与随机高位 UDP 端口、符合 AWG 规范的 junk 参数、`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-update [branch]` | 运行完整的 `update.sh` 生命周期更新;无参数时显示 `.current_branch` 并打开交互式分支选择,有参数时使用该分支 | | `sudo xrayebator-uninstall` | 移除服务与配置 | @@ -155,7 +169,9 @@ npm test # Vitest 单元测试 npm run typecheck # TypeScript 检查 ``` +## 分流路由(Bypass) +分流路由让选中的域名绕过隧道:匹配的流量直连(direct),其余流量继续走 VPN。 `domain -> direct` 规则位于兜底规则之上,因此在启用级联时仍然生效。 默认组合包中的分组: @@ -172,6 +188,70 @@ 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 参数按 AWG 规范生成:`Jc` +1..128、`Jmin < Jmax ≤ 1280`、`S1`/`S2` 15..150 且 `S1+56 ≠ S2`、`H1`–`H4` 在 5..2147483647 内 +互不相同。每个配置档 peer 获得密钥对、预共享密钥与首个空闲地址;客户端 `.conf`(菜单项 12 或 +`awg-conf --name N`)携带全隧道 `AllowedIPs` 与服务端 junk 参数。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 生成 2.0 时代的参数集,不输出 3.x 专属键;3.x 引擎读取此类配置时视 3.x 特性为 +关闭——栈对升级是安全的。之后启用 3.x 是一次有意的迁移,也正是对抗俄罗斯 DPI/ТСПУ 分析的 +关键:普通 WireGuard 握手具有固定的包长和明文头部类型字节,被动分类无需检查载荷即可完成。 +AWG 3.0 加密头部(`HeaderProtectionKey`);3.1 增加 `RandomTrailers`(消除固定长度握手签名) +与可选 `DisableCookies`(消除 cookie 应答模式,代价是失去内建的防放大防御)。上游文档给出的 +迁移规则: + +1. 先升二进制——3.1 引擎按「缺键即关闭」读取 2.0/3.0 时代的配置; +2. 3.x 要求 `S1`–`S4` ≥ 12(Xrayebator 生成 15–150,已经合法); +3. `HeaderProtectionKey` 与 `RandomTrailers` 必须逐字节一致;在运行中的服务器上启用它们会 + 立即打断所有已发出的客户端配置——预留窗口并重新签发; +4. 客户端需要 AmneziaVPN ≥ 5.0.1.5 才支持 3.1 键,更旧的客户端可能直接拒绝导入——为它们 + 保留一台未启用 3.1 的服务器; +5. 用 `awg show` 验证:没有 `latest handshake` 说明「必须一致」组不一致;有握手无流量则去查 + `awg-quick`/路由/防火墙。 + ## 级联与上游节点 级联是服务端的出站与路由模式,而不是新的客户端配置档。客户端仍然连接当前 VPS: 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..90bcfbd2 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/` 中有 31 个静态与本地回归测试: | 测试 | 检查内容 | |---|---| @@ -35,7 +35,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 探测、授权/吊销/过期-恢复事件、纯函数 `hysteria2://` 构造器、CLI 分发与订阅接线 | +| `test-awg-lifecycle.sh` | AmneziaWG 后端:junk 参数规范区间、密钥生成、服务端配置渲染(PostUp MASQUERADE、安装时无 peer)、peer 地址分配、客户端 `.conf` 内容、revoke/expire/restore 事件、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 | @@ -102,7 +105,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`,并运行全部 31 个 `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..776fce3b 100644 --- a/docs/zh-CN/troubleshooting.md +++ b/docs/zh-CN/troubleshooting.md @@ -155,6 +155,61 @@ 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 不输出它们,若出现不匹配,说明混入了第三方或手工配置; +- 客户端应用版本:带 `RandomTrailers` 的配置需要 AmneziaVPN ≥ 5.0.1.5;更旧的客户端可能直接 + 拒绝导入。 + +然后区分调试区域:服务器上的 `awg show awg0` ——若 peer 在列、握手存在但没有流量,问题在 +`awg-quick`/路由/防火墙区域(`ip_forward`、MASQUERADE 接口),而不是协议参数。 + +## 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` 锁定。 From 878b25cc18c64e8fe1e3b53f749715f1bd60a3dd Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:30:16 +0300 Subject: [PATCH 10/73] =?UTF-8?q?AWG=203.1=20=D1=80=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD:=20HeaderProtectionKey=20+?= =?UTF-8?q?=20RandomTrailers=20+=20S3/S4,=20=D1=82=D1=83=D0=BC=D0=B1=D0=BB?= =?UTF-8?q?=D0=B5=D1=80=20awg-31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вопрос 'реализован ли 3.1' закрыт: раньше мы ставили бинари поколения 3.1, но генератор эмитил только набор 2.0. По исходникам kernel-модуля (master: 'feat: awg 3.1 features', 'prevent HeaderProtectionKey from setting if any Sx less then 12') модуль поддерживает 3.1 — недостающим был только наш генератор. - _awg_gen_junk: S1-S4 все >=12 (12..150, уникальные; спека 3.x), HeaderProtectionKey (openssl rand -base64 32, общий секрет сервер/клиенты) - рендеры server/client: S3/S4 всегда, HPK + RandomTrailers=on при three_enabled, DisableCookies=on опционально (по умолчанию off — сохраняем anti-amplification) - awg-31 --on|--off [--disable-cookies] + пункт меню 12: тумблер для живых установок; _awg_ensure_31_params догенеряет S3/S4/HPK в старый server-params; смена = реген интерфейса с откатом + явное предупреждение о перевыпуске всех клиентских .conf (AmneziaVPN >= 5.0.1.5) - live (2.26.125.147): awg-31 --on -> awg0.conf с S3/S4/HPK/RandomTrailers принят модулем (awg show), клиентский .conf содержит те же 3.1-ключи Поймано и починено: 1. _backend_field с jq '// empty' проглатывал легитимный boolean false (three_enabled=false читался как пусто и дефолтил в true) — заменено на tostring с guard; тест-регресс добавлен 2. условные printf рендера писали в stdout вместо файла конфига 3. парсер awg (kernel UAPI) принимает булевы только on/off — 'RandomTrailers = true' отвергается ('Boolean value is neither on/off nor 0/1'); в доках и CLAUDE.md зафиксировано 4. формат булевых в docs (en/ru/zh) обновлён: реализация 3.1 вместо 'не эмитит 3.x-ключи' --- CLAUDE.md | 2 +- docs/configuration.md | 30 ++-- docs/ru/configuration.md | 27 ++-- docs/ru/troubleshooting.md | 5 +- docs/troubleshooting.md | 5 +- docs/zh-CN/configuration.md | 22 +-- docs/zh-CN/troubleshooting.md | 3 +- validation/test-awg-lifecycle.sh | 60 ++++++-- validation/test-backend-registry.sh | 6 + xrayebator | 219 +++++++++++++++++++++++++--- 10 files changed, 290 insertions(+), 89 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0b8574e9..603d9e81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,7 +153,7 @@ Apart from the interactive menu (`sudo xrayebator`), the script exposes subcomma - `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` — 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), `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` — AmneziaWG 2.0 system-VPN backend (multi-protocol slices 4–5, 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). Junk params are generated per the AWG 2.0 spec: Jc 1..128, Jmin&1; exec 1>&2 ... exec 1>&3`). Keep it that way when editing. diff --git a/docs/configuration.md b/docs/configuration.md index 96323416..f8c9a77d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -278,23 +278,19 @@ Per the upstream protocol split, AmneziaWG parameters divide into two groups: | 3.0: `HeaderProtectionKey` | 3.0: `ContentPaddingAddition`, `Rekey*`, `Reject*`, `Keepalive*`, `MaxHandshakeAttempts` (integer or `"a-b"` range) | | 3.1: `RandomTrailers` | 3.1: `DisableCookies` | -Xrayebator generates the 2.0-era parameter set and emits no 3.x-only keys; a 3.x engine reads such a -config with the 3.x features off, so the stack is upgrade-safe. Enabling 3.x later is a deliberate -migration, and it is exactly the migration that 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 -(`HeaderProtectionKey`), 3.1 adds `RandomTrailers` (removes the fixed-length handshake signature) -and optional `DisableCookies` (removes the cookie-reply pattern, at the cost of the built-in -anti-amplification defence). Migration rules from the upstream docs: - -1. binaries first — a 3.1 engine reads 2.0/3.0-era configs with missing keys treated as off; -2. `S1`–`S4` must be ≥ 12 in 3.x (Xrayebator generates 15–150, already valid); -3. `HeaderProtectionKey` and `RandomTrailers` must match byte-for-byte; enabling them on a live - server instantly breaks every previously issued client config — plan a window and reissue; -4. clients need AmneziaVPN ≥ 5.0.1.5 for the 3.1 keys; older apps may refuse to import the config - at all — keep a separate non-3.1 server for them; -5. verify by `awg show`: no `latest handshake` means the must-match group disagrees; a handshake - without traffic points to `awg-quick`/routing/firewall instead. +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. ## Cascade and upstream nodes diff --git a/docs/ru/configuration.md b/docs/ru/configuration.md index b836422d..de59933a 100644 --- a/docs/ru/configuration.md +++ b/docs/ru/configuration.md @@ -257,23 +257,16 @@ junk-параметры. Изменения peer-ов перезапускают | 3.0: `HeaderProtectionKey` | 3.0: `ContentPaddingAddition`, `Rekey*`, `Reject*`, `Keepalive*`, `MaxHandshakeAttempts` (число или диапазон `"a-b"`) | | 3.1: `RandomTrailers` | 3.1: `DisableCookies` | -Xrayebator генерирует набор параметров эпохи 2.0 и не эмитит 3.x-ключи; движок 3.x читает такой -конфиг с выключенными 3.x-фичами — стек безопасен к обновлению. Включение 3.x позже — осознанная -миграция, и именно она важна против российского DPI/анализа ТСПУ: у обычного WireGuard хендшейки -имеют фиксированные длины пакетов и открытые байты типов заголовков — пассивная классификация -работает без разбора содержимого. AWG 3.0 шифрует заголовок (`HeaderProtectionKey`), 3.1 добавляет -`RandomTrailers` (убирает сигнатуру фиксированной длины) и опциональный `DisableCookies` (убирает -паттерн cookie-ответа ценой встроенной защиты от amplification). Правила миграции из upstream-доков: - -1. сначала бинарники — движок 3.1 читает конфиги эпохи 2.0/3.0, отсутствующие ключи = выключено; -2. `S1`–`S4` в 3.x должны быть ≥ 12 (Xrayebator генерирует 15–150 — уже валидно); -3. `HeaderProtectionKey` и `RandomTrailers` должны совпадать побайтово; включение их на живом - сервере мгновенно ломает все ранее выданные клиентские конфиги — планируйте окно и - перевыпускайте клиентов; -4. клиентам нужен AmneziaVPN ≥ 5.0.1.5 для ключей 3.1 — старые приложения могут отказаться - импортировать конфиг целиком — держите для них отдельный сервер без 3.1-флагов; -5. проверка по `awg show`: нет `latest handshake` — расходится группа «должны совпадать»; есть - хендшейк, но нет трафика — ищите в `awg-quick`/маршрутизации/firewall. +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. ## Каскад и upstream-ноды diff --git a/docs/ru/troubleshooting.md b/docs/ru/troubleshooting.md index 361e5c2a..04e6244e 100644 --- a/docs/ru/troubleshooting.md +++ b/docs/ru/troubleshooting.md @@ -236,8 +236,9 @@ AmneziaWG сообщает о несовпадении параметров мо - `S1`–`S4` (≥ 12 для движков 3.x), `H1`–`H4`; - 3.x-ключи, если они есть на другой стороне: `HeaderProtectionKey` (3.0) и `RandomTrailers` (3.1) - совпадают побайтово — Xrayebator их не эмитит, значит несовпадение означает смешение с чужим или - ручным конфигом; + совпадают побайтово. Xrayebator эмитит их только в режиме 3.1 (`awg-31 --on`, для новых установок + включён по умолчанию); несовпадение обычно означает, что клиентский `.conf` старше переключения + на 3.1 — перекачайте его — либо смешан с чужим/ручным конфигом; - версия клиентского приложения: конфиги с `RandomTrailers` требуют AmneziaVPN ≥ 5.0.1.5; более старые могут отказаться импортировать конфиг целиком. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 35d0963f..87db301d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -260,8 +260,9 @@ Check the must-match group — these must be identical on the server and in ever - `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 does not emit them, so a mismatch means a config was mixed - with a hand-made or third-party one; + 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. diff --git a/docs/zh-CN/configuration.md b/docs/zh-CN/configuration.md index cd19a06f..014dc197 100644 --- a/docs/zh-CN/configuration.md +++ b/docs/zh-CN/configuration.md @@ -236,21 +236,13 @@ Hysteria 拒绝空的 userpass 映射。客户端链接形如 | 3.0:`HeaderProtectionKey` | 3.0:`ContentPaddingAddition`、`Rekey*`、`Reject*`、`Keepalive*`、`MaxHandshakeAttempts`(整数或 `"a-b"` 区间) | | 3.1:`RandomTrailers` | 3.1:`DisableCookies` | -Xrayebator 生成 2.0 时代的参数集,不输出 3.x 专属键;3.x 引擎读取此类配置时视 3.x 特性为 -关闭——栈对升级是安全的。之后启用 3.x 是一次有意的迁移,也正是对抗俄罗斯 DPI/ТСПУ 分析的 -关键:普通 WireGuard 握手具有固定的包长和明文头部类型字节,被动分类无需检查载荷即可完成。 -AWG 3.0 加密头部(`HeaderProtectionKey`);3.1 增加 `RandomTrailers`(消除固定长度握手签名) -与可选 `DisableCookies`(消除 cookie 应答模式,代价是失去内建的防放大防御)。上游文档给出的 -迁移规则: - -1. 先升二进制——3.1 引擎按「缺键即关闭」读取 2.0/3.0 时代的配置; -2. 3.x 要求 `S1`–`S4` ≥ 12(Xrayebator 生成 15–150,已经合法); -3. `HeaderProtectionKey` 与 `RandomTrailers` 必须逐字节一致;在运行中的服务器上启用它们会 - 立即打断所有已发出的客户端配置——预留窗口并重新签发; -4. 客户端需要 AmneziaVPN ≥ 5.0.1.5 才支持 3.1 键,更旧的客户端可能直接拒绝导入——为它们 - 保留一台未启用 3.1 的服务器; -5. 用 `awg show` 验证:没有 `latest handshake` 说明「必须一致」组不一致;有握手无流量则去查 - `awg-quick`/路由/防火墙。 +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`/路由/防火墙。 ## 级联与上游节点 diff --git a/docs/zh-CN/troubleshooting.md b/docs/zh-CN/troubleshooting.md index 776fce3b..ff609ec5 100644 --- a/docs/zh-CN/troubleshooting.md +++ b/docs/zh-CN/troubleshooting.md @@ -181,7 +181,8 @@ AmneziaWG 对参数不匹配保持沉默:peer 只是永远不出现握手。 - `S1`–`S4`(3.x 引擎要求 ≥ 12)、`H1`–`H4`; - 对端存在的任何 3.x 键:`HeaderProtectionKey`(3.0)与 `RandomTrailers`(3.1)必须逐字节 - 一致——Xrayebator 不输出它们,若出现不匹配,说明混入了第三方或手工配置; + 一致。Xrayebator 仅在 3.1 模式(`awg-31 --on`,新安装默认开启)下输出它们;不匹配通常意味着 + 客户端 `.conf` 早于 3.1 切换——请重新下载——或混入了第三方/手工配置; - 客户端应用版本:带 `RandomTrailers` 的配置需要 AmneziaVPN ≥ 5.0.1.5;更旧的客户端可能直接 拒绝导入。 diff --git a/validation/test-awg-lifecycle.sh b/validation/test-awg-lifecycle.sh index 969b90c0..9dec1523 100644 --- a/validation/test-awg-lifecycle.sh +++ b/validation/test-awg-lifecycle.sh @@ -46,19 +46,30 @@ export XRAYEBATOR_SERVER_ADDR_OVERRIDE="203.0.113.10" # shellcheck disable=SC1091 source ./xrayebator || fail "source ./xrayebator failed" -# 1) junk-параметры: диапазоны и уникальность (5 прогонов) +# 1) junk-параметры: диапазоны и уникальность (5 прогонов), включая 3.1-поля for _ in 1 2 3 4 5; do junk=$(_awg_gen_junk) jq -e ' .Jc >= 1 and .Jc <= 128 and .Jmin >= 8 and .Jmin < .Jmax and .Jmax <= 1280 and - .S1 >= 15 and .S1 <= 150 and .S2 >= 15 and .S2 <= 150 and + .S1 >= 12 and .S1 <= 150 and .S2 >= 12 and .S2 <= 150 and + .S3 >= 12 and .S3 <= 150 and .S4 >= 12 and .S4 <= 150 and + ([.S1, .S2, .S3, .S4] | (length == ([unique[]] | length))) and (.S1 + 56) != .S2 and .H1 >= 5 and .H2 >= 5 and .H3 >= 5 and .H4 >= 5 and - ([.H1, .H2, .H3, .H4] | (length == ([unique[]] | length))) - ' <<<"$junk" >/dev/null || fail "junk params out of spec: $junk" + ([.H1, .H2, .H3, .H4] | (length == ([unique[]] | length))) and + (.HeaderProtectionKey | length == 44 and endswith("=")) + ' <<<"$junk" >/dev/null || fail "junk/3.1 params out of spec: $junk" done -pass "junk params generated within AWG 2.0 spec (5/5 runs)" +pass "junk + AWG 3.1 params (S1-S4>=12, HeaderProtectionKey) within spec (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" @@ -66,20 +77,31 @@ read -r priv pub psk <<<"$keys" [[ -n "$priv" && -n "$pub" && -n "$psk" ]] || fail "keys empty" pass "awg genkey/pubkey/genpsk produce key triple" -# 3) рендер серверного конфига +# 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" +_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" -pass "server conf rendered (Address/keys/junk/PostUp-MASQUERADE, no peers yet)" +! 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" @@ -115,14 +137,32 @@ jq -e '.ok == true and .name == "carol" 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 = "))' <<<"$conf" >/dev/null \ + (.conf | contains("PresharedKey = ")) and + (.conf | contains("HeaderProtectionKey = ")) and + (.conf | contains("RandomTrailers = on"))' <<<"$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" -pass "client .conf carries keys, junk params, server peer and endpoint" +! 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" diff --git a/validation/test-backend-registry.sh b/validation/test-backend-registry.sh index b8fb41c7..3ee0e99a 100644 --- a/validation/test-backend-registry.sh +++ b/validation/test-backend-registry.sh @@ -62,6 +62,12 @@ _backend_apply_profile_lifecycle "some-profile" expired || fail "lifecycle expi _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 ' diff --git a/xrayebator b/xrayebator index f26f0c6c..de6360ce 100755 --- a/xrayebator +++ b/xrayebator @@ -1940,10 +1940,14 @@ _backend_installed() { } # Значение поля реестра бэкенда (пусто, если поля/бэкенда нет). +# ВАЖНО: не 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] // empty' "$BACKENDS_REGISTRY_FILE" 2>/dev/null + 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). @@ -2768,7 +2772,15 @@ awg_menu() { echo -e "${CYAN} 1)${NC} Установить (kernel DKMS, случайный высокий UDP-порт)" echo -e "${CYAN} 2)${NC} Статус" echo -e "${CYAN} 3)${NC} Конфиг клиента (peer)" - echo -e "${CYAN} 4)${NC} Удалить" + 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}" @@ -2791,6 +2803,15 @@ awg_menu() { 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 @@ -2883,24 +2904,51 @@ AWG_SYSCTL_FILE="/etc/sysctl.d/99-xrayebator-awg.conf" AWG_REPO_MODULE="amnezia-vpn/amneziawg-linux-kernel-module" AWG_REPO_TOOLS="amnezia-vpn/amneziawg-tools" -# Случайные junk-параметры в пределах спеки AWG 2.0 (см. шапку секции). +# Случайные параметры AWG, включая 3.1 (HeaderProtectionKey, S3/S4). +# Спека: Jc 1..128 (рек. 4..12), Jmin/dev/null) || return 1 jq -n --argjson jc "$jc" --argjson jmin "$jmin" --argjson jmax "$jmax" \ - --argjson s1 "$s1" --argjson s2 "$s2" \ + --argjson s1 "$s1" --argjson s2 "$s2" --argjson s3 "$s3" --argjson s4 "$s4" \ --argjson h1 "$h1" --argjson h2 "$h2" --argjson h3 "$h3" --argjson h4 "$h4" \ - '{Jc:$jc, Jmin:$jmin, Jmax:$jmax, S1:$s1, S2:$s2, H1:$h1, H2:$h2, H3:$h3, 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). @@ -2933,15 +2981,23 @@ _awg_public_iface() { # Рендер серверного интерфейсного конфига (чистая функция — тестируется в 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 - local jc jmin jmax s1 s2 h1 h2 h3 h4 + 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") - cat > "$out_file" < "$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 rm -f /etc/amnezia/amneziawg/awg0.conf @@ -3217,6 +3285,78 @@ awg_status_command() { 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, @@ -3238,6 +3378,16 @@ _awg_next_address() { return 1 } +# Набор 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 @@ -3281,7 +3431,8 @@ _awg_regen_config() { 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" || return 1 + _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 @@ -3387,7 +3538,7 @@ _awg_client_conf_pure() { [[ -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 + 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) @@ -3400,7 +3551,13 @@ _awg_client_conf_pure() { [[ -n "$spub" && "$port" =~ ^[0-9]+$ ]] || return 1 host=$(_connection_server_address 2>/dev/null) || return 1 [[ -n "$host" ]] || return 1 - cat < Date: Sun, 4 Oct 2026 05:15:17 +0300 Subject: [PATCH 11/73] =?UTF-8?q?GUI=20=D1=8D=D1=82=D0=B0=D0=BF=202,=20?= =?UTF-8?q?=D1=81=D1=80=D0=B5=D0=B7=20A:=20main-=D0=B0=D0=B4=D0=B0=D0=BF?= =?UTF-8?q?=D1=82=D0=B5=D1=80=D1=8B=20=D0=BC=D1=83=D0=BB=D1=8C=D1=82=D0=B8?= =?UTF-8?q?=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4=D0=B0=20(IPC=20backends:*)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GUI ничего не придумывает про сервер — только вызывает JSON-команды. Новый BackendManager повторяет паттерн ProfileManager (run + extractJson + describeFailure) и даёт IPC-каналы: backends:status (реестр), backends:hysteria2Grant/awgGrant (гранты per-профиль), backends:awgConf (клиентский .conf для AmneziaVPN — секреты уровня оператора), backends:awg31 (тумблер 3.1 с предупреждением о перевыпуске). Модель этапа 2 (согласована): бэкенды живут одновременно, доступ транспорта определяется грантами конкретного профиля, глобальных режимов запуска нет. - shared/types: ProfileBackends в ServerProfile (аддитивно), BackendEntry/BackendStatusResult/AwgConfResult/BackendToggleResult, секция backends в ElectronAPI - чистые парсеры parseBackendStatus/parseAwgConf вынесены отдельно и покрыты тестами (7): полный статус, пустой реестр, ANSI-загрязнение stdout, ошибка реестра, 3.1-ключи в .conf, отсутствие гранта - typecheck 0; vitest: 112 зелёных, единственный красный — задокументированный Windows-only shell-command (/bin/sh), CI на Ubuntu авторитетен - UI-экраны (срезы B/C) — следующие коммиты; renderer пока не менялся --- src/main/core/backend-manager.ts | 152 +++++++++++++++++++++++++++++ src/main/ipc-handlers.ts | 55 +++++++++++ src/preload/index.ts | 25 +++++ src/shared/types.ts | 108 ++++++++++++++++++++ tests/unit/backend-manager.test.ts | 115 ++++++++++++++++++++++ 5 files changed, 455 insertions(+) create mode 100644 src/main/core/backend-manager.ts create mode 100644 tests/unit/backend-manager.test.ts diff --git a/src/main/core/backend-manager.ts b/src/main/core/backend-manager.ts new file mode 100644 index 00000000..13db7e79 --- /dev/null +++ b/src/main/core/backend-manager.ts @@ -0,0 +1,152 @@ +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, + BackendStatusResult, + BackendToggleResult +} 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 } + } + } + + /** Выдать/обновить 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/ipc-handlers.ts b/src/main/ipc-handlers.ts index 8d70adeb..92c4da27 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -20,6 +20,7 @@ import type { ServerConnectionMetadata, ServerStore } from './core/servers' import { Deployer } from './core/deployer' import { fetchSubscription } from './core/subscription' 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' @@ -461,6 +462,60 @@ 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) + } + + 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) + return manager.awgGrant(name) + } + ) + + 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') + return result + } + ) + + ipcMain.handle( + 'backends:awg31', + async (_e, serverId: string, access: SshAccessInput, on: boolean) => { + const manager = await backendManagerFor(serverId, access) + return manager.awg31(Boolean(on)) + } + ) + const serverManagerFor = async ( serverId: string, access: SshAccessInput diff --git a/src/preload/index.ts b/src/preload/index.ts index 9951448d..73bef55a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,9 @@ import { contextBridge, ipcRenderer } from 'electron' import type { + AwgConfResult, + BackendGrantResult, + BackendStatusResult, + BackendToggleResult, DeployEvent, DeployStartPayload, ElectronAPI, @@ -120,6 +124,27 @@ 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), + awgGrant: ( + serverId: string, + access: SshAccessInput, + name: string + ): Promise => + ipcRenderer.invoke('backends:awgGrant', serverId, access, name), + 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, diff --git a/src/shared/types.ts b/src/shared/types.ts index e8f25f22..c855518d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -116,6 +116,80 @@ export interface SubscriptionResult { keys: VlessLink[] } +/** Грант 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 +} + +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 { name: string uuid: string @@ -141,6 +215,11 @@ export interface ServerProfile { * показывал бы бессрочность там, где управление сроком недоступно. */ expire_supported?: boolean + /** + * Мультипротокольный этап: гранты опциональных бэкендов. Пустой объект или + * отсутствие поля — профиль не имеет доступа к hysteria2/awg. + */ + backends?: ProfileBackends } export interface ProfileCreateInput { @@ -374,4 +453,33 @@ export interface ElectronAPI { access: SshAccessInput ) => Promise } + backends: { + /** Статус всех бэкендов из реестра сервера (backend-status). */ + status: ( + serverId: string, + access: SshAccessInput + ) => Promise + hysteria2Grant: ( + serverId: string, + access: SshAccessInput, + name: string + ) => Promise + awgGrant: ( + serverId: string, + access: SshAccessInput, + name: string + ) => 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/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-гранта') + }) +}) From 88f0f3f83bfe87c70a733b9a8a2ae778f71fa6dd Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:36:07 +0300 Subject: [PATCH 12/73] =?UTF-8?q?GUI=20=D1=8D=D1=82=D0=B0=D0=BF=202,=20?= =?UTF-8?q?=D1=81=D1=80=D0=B5=D0=B7=20B:=20=D1=81=D0=B5=D0=BA=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=C2=AB=D0=9C=D1=83=D0=BB=D1=8C=D1=82=D0=B8=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4=D1=8B=C2=BB=20=D0=B2=20Ser?= =?UTF-8?q?ver=20Settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карточки Hysteria 2 и AmneziaWG 3.1: статус (active/port/версия/режим 3.1), установка и удаление с диалогами подтверждения, тумблер 3.1 (явное предупреждение о перевыпуске .conf), kill-switch hysteria2-строк в подписке. У карточки AWG — пометка пользователя: «работает через клиент AmneziaVPN, а НЕ через V2Ray-клиентов (HAPP)». Серверная часть: новый CLI hysteria2-subbody --on|--off (kill-switch без рестартов — handler перечитывает реестр на каждый запрос); main-адаптеры hysteria2Install/Uninstall, awgInstall/Uninstall, hysteria2Subbody; IPC backends:hysteria2*/awg*; preload-секция расширена. Отказоустойчивость UI: backend-status недоступен/неизвестен на старом сервере -> секция показывает «обновите менеджер», не ломая страницу (load оборачивает загрузку бэкендов в отдельный try). i18n ru/en/zh — 22 новых ключа. --- src/main/core/backend-manager.ts | 79 +++++- src/main/ipc-handlers.ts | 34 +++ src/preload/index.ts | 27 ++ src/renderer/src/i18n/en.json | 24 +- src/renderer/src/i18n/ru.json | 24 +- src/renderer/src/i18n/zh.json | 24 +- .../src/pages/ServerSettings.module.css | 50 ++++ src/renderer/src/pages/ServerSettings.tsx | 262 +++++++++++++++++- src/shared/types.ts | 39 +++ xrayebator | 31 +++ 10 files changed, 588 insertions(+), 6 deletions(-) diff --git a/src/main/core/backend-manager.ts b/src/main/core/backend-manager.ts index 13db7e79..a3d5357f 100644 --- a/src/main/core/backend-manager.ts +++ b/src/main/core/backend-manager.ts @@ -5,8 +5,10 @@ import { extractJson } from './profiles' import type { AwgConfResult, BackendGrantResult, + BackendSimpleResult, BackendStatusResult, - BackendToggleResult + BackendToggleResult, + Hysteria2SubbodyResult } from '@shared/types' /** @@ -102,6 +104,81 @@ export class BackendManager { } } + /** + * Установка 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 } + } + } + /** Выдать/обновить AWG peer-грант профилю (keypair + PSK + адрес). */ async awgGrant(name: string): Promise { try { diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 92c4da27..a46f8a9c 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -516,6 +516,40 @@ export function registerIpcHandlers({ store }: IpcContext): void { } ) + 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) + return manager.awgUninstall() + }) + + ipcMain.handle( + 'backends:hysteria2Subbody', + async (_e, serverId: string, access: SshAccessInput, on: boolean) => { + const manager = await backendManagerFor(serverId, access) + return manager.hysteria2Subbody(Boolean(on)) + } + ) + const serverManagerFor = async ( serverId: string, access: SshAccessInput diff --git a/src/preload/index.ts b/src/preload/index.ts index 73bef55a..35bbec26 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,9 +2,11 @@ import { contextBridge, ipcRenderer } from 'electron' import type { AwgConfResult, BackendGrantResult, + BackendSimpleResult, BackendStatusResult, BackendToggleResult, DeployEvent, + Hysteria2SubbodyResult, DeployStartPayload, ElectronAPI, ImportProgressEvent, @@ -133,12 +135,37 @@ const api: ElectronAPI = { 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), 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 => diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index c354324e..9e20fd0f 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -276,6 +276,28 @@ "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 — fast UDP", + "backendsHystNote": "A hysteria2 line is added to the HAPP/v2rayNG subscription automatically. Works in HAPP, Hiddify, v2rayN.", + "backendsAwg": "AmneziaWG 3.1 — system VPN", + "backendsAwgNote": "NOTE: runs through the AmneziaVPN client, NOT V2Ray clients (HAPP). The config is delivered as text and QR; full tunnel.", + "backendsActive": "Active", + "backendsNotInstalled": "Not installed", + "backendsInstall": "Install", + "backendsInstalling": "Installing…", + "backendsUninstall": "Uninstall", + "backendsUninstalling": "Removing…", + "backendsSubBodyOn": "hysteria2 in subscription: on — turn off", + "backendsSubBodyOff": "hysteria2 in subscription: off — turn on", + "backends31On": "3.1: on — turn off", + "backends31Off": "3.1: off — turn on", + "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." } } diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index 9f8e4452..c32c9b84 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -277,6 +277,28 @@ "createExpire": "Срок действия", "createExpireSet": "Срок: {{date}}", "createExpireHint": "Профиль отключится автоматически в выбранную дату. Можно оставить бессрочным.", - "expirePicked": "Выбрано: {{date}}" + "expirePicked": "Выбрано: {{date}}", + "backendsTitle": "Мультипротокольные бэкенды", + "backendsHint": "Дополнительные транспорты на том же сервере. Работают одновременно с Xray; доступ транспорта определяется грантами профиля.", + "backendsUnsupported": "Сервер работает на старой версии менеджера — обновите его (кнопка выше), чтобы управлять бэкендами.", + "backendsHyst": "Hysteria 2 — быстрый UDP", + "backendsHystNote": "Строка hysteria2 добавляется в подписку HAPP/v2rayNG автоматически. Работает в HAPP, Hiddify, v2rayN.", + "backendsAwg": "AmneziaWG 3.1 — системный VPN", + "backendsAwgNote": "ВНИМАНИЕ: работает через клиент AmneziaVPN, а НЕ через V2Ray-клиентов (HAPP). Конфиг выдаётся текстом и QR, полная маршрутизация.", + "backendsActive": "Активен", + "backendsNotInstalled": "Не установлен", + "backendsInstall": "Установить", + "backendsInstalling": "Установка…", + "backendsUninstall": "Удалить", + "backendsUninstalling": "Удаление…", + "backendsSubBodyOn": "hysteria2 в подписке: вкл — выключить", + "backendsSubBodyOff": "hysteria2 в подписке: выкл — включить", + "backends31On": "3.1: вкл — выключить", + "backends31Off": "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 нужно будет скачать заново." } } diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index 72925a3e..2e39e849 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -275,6 +275,28 @@ "createExpire": "有效期", "createExpireSet": "有效期:{{date}}", "createExpireHint": "配置将在所选日期自动停用。留空表示永久有效。", - "expirePicked": "已选择:{{date}}" + "expirePicked": "已选择:{{date}}", + "backendsTitle": "多协议后端", + "backendsHint": "同一服务器上的额外传输。与 Xray 并行运行;传输访问由配置档授权决定。", + "backendsUnsupported": "该服务器运行旧版管理器——请先更新(上方按钮),然后即可管理后端。", + "backendsHyst": "Hysteria 2 — 高速 UDP", + "backendsHystNote": "hysteria2 行会自动加入 HAPP/v2rayNG 订阅。适用于 HAPP、Hiddify、v2rayN。", + "backendsAwg": "AmneziaWG 3.1 — 系统级 VPN", + "backendsAwgNote": "注意:通过 AmneziaVPN 客户端运行,而非 V2Ray 客户端(HAPP)。配置以文本和二维码方式交付;全隧道。", + "backendsActive": "运行中", + "backendsNotInstalled": "未安装", + "backendsInstall": "安装", + "backendsInstalling": "安装中…", + "backendsUninstall": "卸载", + "backendsUninstalling": "卸载中…", + "backendsSubBodyOn": "订阅中的 hysteria2:开 — 关闭", + "backendsSubBodyOff": "订阅中的 hysteria2:关 — 开启", + "backends31On": "3.1:开 — 关闭", + "backends31Off": "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 需重新下载。" } } diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index c59431c4..136afb4f 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -112,6 +112,56 @@ padding: 18px 20px; } +/* --- Мультипротокольные бэкенды (этап 2) --- */ +.backendRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 12px 0; + border-top: 1px solid var(--border); +} + +.backendRow:first-of-type { + border-top: none; + padding-top: 4px; +} + +.backendMeta { + flex: 1 1 260px; + min-width: 0; +} + +.backendName { + font-size: 14px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.backendState { + font-size: 12px; + color: var(--text-2); + margin-top: 2px; +} + +.backendStateOk { + color: var(--success, #2fb344); +} + +.backendNote { + margin: 8px 0 0; + font-size: 12px; + color: var(--text-2); +} + +.backendActions { + display: flex; + align-items: center; + gap: 8px; +} + .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..b074d06c 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -15,10 +15,18 @@ import { Check, ShieldOff, CalendarClock, - CalendarX + CalendarX, + Zap, + Shield } 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 { isSshAccessReady, SshAccessForm } from '../components/SshAccessForm' @@ -83,6 +91,12 @@ export function ServerSettings({ const [busy, setBusy] = useState(false) const autoConnectStarted = useRef(false) const [profiles, setProfiles] = useState(null) + const [backends, setBackends] = useState(null) + const [backendsError, setBackendsError] = useState(null) + const [backendBusy, setBackendBusy] = useState(null) + const [confirmBackend, setConfirmBackend] = useState< + 'hysteria2-uninstall' | 'awg-uninstall' | 'awg31-on' | 'awg31-off' | null + >(null) const [error, setError] = useState(null) const [toast, setToast] = useState(null) const [hostKeyFingerprint, setHostKeyFingerprint] = useState( @@ -162,6 +176,15 @@ export function ServerSettings({ try { const result = await window.api.profiles.list(server.id, access) setProfiles(result.profiles ?? []) + // Мультипротокольные бэкенды: необязательная секция. На сервере старой + // версии backend-status неизвестен — показываем «не поддерживается». + try { + setBackends(await window.api.backends.status(server.id, access)) + setBackendsError(null) + } catch (err) { + setBackends(null) + setBackendsError(err instanceof Error ? err.message : String(err)) + } const refreshed = await window.api.servers.get(server.id) setHostKeyFingerprint(refreshed?.hostKeyFingerprint ?? null) if (refreshed) { @@ -513,6 +536,73 @@ export function ServerSettings({ } } + 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)) + } + const forgetHostKey = async (): Promise => { setHostKeyResetBusy(true) setError(null) @@ -549,6 +639,9 @@ export function ServerSettings({ } } + 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 @@ -849,10 +942,175 @@ export function ServerSettings({ ))} + +
+

{t('settings.backendsTitle')}

+

{t('settings.backendsHint')}

+ {backendsError !== null ? ( +

{t('settings.backendsUnsupported')}

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

{t('settings.backendsHystNote')}

+
+
+ {hyst?.installed ? ( + <> + + + + ) : ( + + )} +
+
+ +
+
+
+ + {t('settings.backendsAwg')} +
+
+ {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')} + + + + + + + + + + { diff --git a/src/shared/types.ts b/src/shared/types.ts index c855518d..87bdd238 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -175,6 +175,19 @@ export interface BackendGrantResult { 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 AwgConfResult { ok: boolean name?: string @@ -464,11 +477,37 @@ export interface ElectronAPI { 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 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, diff --git a/xrayebator b/xrayebator index de6360ce..02b3e060 100755 --- a/xrayebator +++ b/xrayebator @@ -2738,6 +2738,32 @@ hysteria2_status_command() { 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}' +} + # Меню 13: сводный статус всех бэкендов. backend_status_menu() { while true; do @@ -14515,6 +14541,10 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then shift hysteria2_grant_command "$@" ;; + hysteria2-subbody) + shift + hysteria2_subbody_command "$@" + ;; awg-install) shift awg_install_command "$@" @@ -14572,6 +14602,7 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then 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 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)" From f727c2ec859692d2ed98bef46d1a82c501163f36 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:42:25 +0300 Subject: [PATCH 13/73] =?UTF-8?q?Hysteria=202:=20=D1=84=D0=BB=D0=B0=D0=B3?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D1=8B=20=D0=B2=20=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8=20(=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D1=8B=D0=B9=20=D0=B2=D0=B8=D0=B4=20=D1=81=20VLESS?= =?UTF-8?q?=20=D0=B2=20HAPP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hysteria2-строка в подписке подписывалась голым именем профиля (#happ) и выглядела в HAPP иначе, чем VLESS-маршруты (🇩🇪 Germany · happ-tcp-xudp). _hysteria2_profile_link_pure теперь повторяет именование _generate_vless_url_pure: префикс 🇩🇪 Germany · из geo-маркера (флаг + страна; нет маркера — голое имя, как у VLESS) и URL-кодирование фрагмента через jq @uri. Живой тест: подписка отдаёт #🇩🇪 Germany · happ. --- xrayebator | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/xrayebator b/xrayebator index 02b3e060..9cb992ce 100755 --- a/xrayebator +++ b/xrayebator @@ -2360,7 +2360,23 @@ _hysteria2_profile_link_pure() { 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) - [[ -n "$name" ]] && uri="${uri}#${name}" + 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" } From 86ea17d12d51dae62d592dd2f2b80b122c39c9b4 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:54:47 +0300 Subject: [PATCH 14/73] =?UTF-8?q?GUI=20=D1=8D=D1=82=D0=B0=D0=BF=202,=20?= =?UTF-8?q?=D1=81=D1=80=D0=B5=D0=B7=20C:=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=B9=20=D0=B1=D1=8D?= =?UTF-8?q?=D0=BA=D0=B5=D0=BD=D0=B4=D0=BE=D0=B2=20=D1=83=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=84=D0=B8=D0=BB=D1=8F=20(=D1=82=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=D1=82=20+=20QR=20+=20=D0=B3=D1=80=D0=B0=D0=BD=D1=82=D1=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кнопка «Ключи» у каждого профиля в Server Settings открывает диалог: Hysteria 2 — hysteria2-ссылка (Chip HYSTERIA2·UDP, копирование, QR; пометка: работает в HAPP/Hiddify/v2rayN) и AmneziaWG — полный клиентский .conf (Chip AMNEZIAWG 3.1·UDP, pre-блок, копирование, QR; пометка пользователя: «AmneziaVPN — НЕ V2Ray/HAPP, импорт в AmneziaWG/AmneziaVPN, full-tunnel, >= 5.0.1.5»). Нет гранта — в диалоге кнопка «Выдать грант» (hysteria2-grant/awg-grant) с автоматическим обновлением ключей. Это закрывает цикл выдачи AWG-ключей из GUI без CLI — тестовый путь пользователя для проверки AWG из РФ. - новый CLI hysteria2-link --name N (JSON {ok,name,link}) — серверный билдер ссылки с флагом страны - QR рендерится локально (qrcode toDataURL, Esc/клик закрывают) — серверный qrencode не нужен - путь доставки AWG: GUI-диалог вместо ручного SSH — как договорились (текст+QR, без скачивания файла) --- src/main/core/backend-manager.ts | 23 ++ src/main/ipc-handlers.ts | 11 + src/preload/index.ts | 7 + src/renderer/src/i18n/en.json | 7 +- src/renderer/src/i18n/ru.json | 7 +- src/renderer/src/i18n/zh.json | 7 +- .../src/pages/ServerSettings.module.css | 53 +++++ src/renderer/src/pages/ServerSettings.tsx | 213 ++++++++++++++++++ src/shared/types.ts | 14 ++ xrayebator | 26 +++ 10 files changed, 365 insertions(+), 3 deletions(-) diff --git a/src/main/core/backend-manager.ts b/src/main/core/backend-manager.ts index a3d5357f..2e710a3c 100644 --- a/src/main/core/backend-manager.ts +++ b/src/main/core/backend-manager.ts @@ -8,6 +8,7 @@ import type { BackendSimpleResult, BackendStatusResult, BackendToggleResult, + Hysteria2LinkResult, Hysteria2SubbodyResult } from '@shared/types' @@ -179,6 +180,28 @@ export class BackendManager { } } + /** 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 { diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index a46f8a9c..2bca2edf 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -550,6 +550,17 @@ export function registerIpcHandlers({ store }: IpcContext): void { } ) + 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 diff --git a/src/preload/index.ts b/src/preload/index.ts index 35bbec26..9169579e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,6 +6,7 @@ import type { BackendStatusResult, BackendToggleResult, DeployEvent, + Hysteria2LinkResult, Hysteria2SubbodyResult, DeployStartPayload, ElectronAPI, @@ -152,6 +153,12 @@ const api: ElectronAPI = { 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, diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 9e20fd0f..e29dea43 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -298,6 +298,11 @@ "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." + "backendsConfirm31Off": "Disable AWG 3.1 mode? Configs return to the 2.0 format; every client .conf must be re-downloaded.", + "beKeysBtn": "Keys", + "beKeysTitle": "Backend keys", + "beKeysHystNote": "UDP transport: works in HAPP, Hiddify, v2rayN. The line is already in the subscription — this key is for direct import.", + "beKeysAwgNote": "AmneziaVPN — NOT V2Ray/HAPP: import the config into the AmneziaWG/AmneziaVPN app. Full tunnel, clients AmneziaVPN ≥ 5.0.1.5.", + "beKeysGrant": "Issue grant" } } diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index c32c9b84..3aa64373 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -299,6 +299,11 @@ "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 нужно будет скачать заново." + "backendsConfirm31Off": "Выключить режим AWG 3.1? Конфиги вернутся к формату 2.0; все клиентские .conf нужно будет скачать заново.", + "beKeysBtn": "Ключи", + "beKeysTitle": "Ключи бэкендов", + "beKeysHystNote": "UDP-транспорт: работает в HAPP, Hiddify, v2rayN. Строка уже добавлена в подписку — этот ключ для прямого импорта.", + "beKeysAwgNote": "AmneziaVPN — НЕ V2Ray/HAPP: импортируйте конфиг в приложение AmneziaWG/AmneziaVPN. Full-tunnel, клиенты AmneziaVPN ≥ 5.0.1.5.", + "beKeysGrant": "Выдать грант" } } diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index 2e39e849..c8d988cc 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -297,6 +297,11 @@ "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 需重新下载。" + "backendsConfirm31Off": "停用 AWG 3.1 模式?配置恢复 2.0 格式;所有客户端 .conf 需重新下载。", + "beKeysBtn": "密钥", + "beKeysTitle": "后端密钥", + "beKeysHystNote": "UDP 传输:适用于 HAPP、Hiddify、v2rayN。该行已在订阅中——此密钥用于直接导入。", + "beKeysAwgNote": "AmneziaVPN —— 非 V2Ray/HAPP:请将配置导入 AmneziaWG/AmneziaVPN 应用。全隧道,客户端需 AmneziaVPN ≥ 5.0.1.5。", + "beKeysGrant": "签发授权" } } diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index 136afb4f..bac877a6 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -162,6 +162,59 @@ gap: 8px; } +/* --- Диалог ключей бэкендов (срез 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 b074d06c..2be05681 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 QRCode from 'qrcode' import { Button, TextField, Label, Input, Chip, Spinner, AlertDialog } from '@heroui/react' import { Settings2, @@ -97,6 +98,15 @@ export function ServerSettings({ const [confirmBackend, setConfirmBackend] = useState< 'hysteria2-uninstall' | 'awg-uninstall' | 'awg31-on' | 'awg31-off' | null >(null) + // Срез C: диалог ключей бэкендов у профиля. + const [keysTarget, setKeysTarget] = useState(null) + const [keysHystLink, setKeysHystLink] = useState(null) + const [keysHystErr, setKeysHystErr] = useState(null) + const [keysAwgConf, setKeysAwgConf] = useState(null) + const [keysAwgErr, setKeysAwgErr] = useState(null) + const [keysBusy, setKeysBusy] = useState(false) + const [keysQrUrl, setKeysQrUrl] = useState(null) + const [keysQrData, setKeysQrData] = useState(null) const [error, setError] = useState(null) const [toast, setToast] = useState(null) const [hostKeyFingerprint, setHostKeyFingerprint] = useState( @@ -210,6 +220,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')) @@ -603,6 +622,63 @@ export function ServerSettings({ return runBackendAction('awg31', () => window.api.backends.awg31(server.id, access, on)) } + // ─── Срез C: диалог ключей бэкендов ─── + const fetchBackendKeys = async (name: string): Promise => { + setKeysHystLink(null) + setKeysHystErr(null) + setKeysAwgConf(null) + setKeysAwgErr(null) + const hyst = await window.api.backends.hysteria2Link(server.id, access, name).catch( + (err: unknown): string => (err instanceof Error ? err.message : String(err)) + ) + if (typeof hyst === 'string') setKeysHystErr(hyst) + else setKeysHystLink(hyst.link ?? null) + const conf = await window.api.backends.awgConf(server.id, access, name).catch( + (err: unknown): string => (err instanceof Error ? err.message : String(err)) + ) + if (typeof conf === 'string') setKeysAwgErr(conf) + else setKeysAwgConf(conf.conf ?? null) + } + + const openBackendKeys = (profile: ServerProfile): void => { + setKeysTarget(profile) + setKeysBusy(true) + void fetchBackendKeys(profile.name).finally(() => setKeysBusy(false)) + } + + const closeBackendKeys = (): void => { + setKeysTarget(null) + setKeysHystLink(null) + setKeysAwgConf(null) + setKeysQrUrl(null) + } + + const grantBackend = async (kind: 'hysteria2' | 'awg'): Promise => { + if (!keysTarget) return + setKeysBusy(true) + try { + if (kind === 'hysteria2') await window.api.backends.hysteria2Grant(server.id, access, keysTarget.name) + else await window.api.backends.awgGrant(server.id, access, keysTarget.name) + await fetchBackendKeys(keysTarget.name) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setKeysBusy(false) + } + } + + const copyKeysText = async (text: string): Promise => { + await navigator.clipboard.writeText(text) + toastText(t('settings.copied')) + } + + 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) @@ -721,6 +797,18 @@ export function ServerSettings({ {error &&
{t('settings.error')}: {error}
} {toast &&
{toast}
} + {keysQrUrl && ( +
setKeysQrUrl(null)} + > + {keysQrData ? QR : null} +
+ )} + {connected && ( <>
)} + + + + + ) : keysHystErr ? ( + <> +

{keysHystErr}

+ + + ) : null} + + +
+
+ + AMNEZIAWG 3.1 · UDP + +
+

{t('settings.beKeysAwgNote')}

+ {keysAwgConf ? ( + <> +
{keysAwgConf}
+
+ + +
+ + ) : keysAwgErr ? ( + <> +

{keysAwgErr}

+ + + ) : null} +
+ + + + + + + + + { diff --git a/src/shared/types.ts b/src/shared/types.ts index 87bdd238..561d05fa 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -188,6 +188,14 @@ export interface Hysteria2SubbodyResult { 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 @@ -493,6 +501,12 @@ export interface ElectronAPI { access: SshAccessInput, on: boolean ) => Promise + /** hysteria2:// ссылка профиля (требует грант и установленный бэкенд). */ + hysteria2Link: ( + serverId: string, + access: SshAccessInput, + name: string + ) => Promise awgGrant: ( serverId: string, access: SshAccessInput, diff --git a/xrayebator b/xrayebator index 9cb992ce..d4ef3332 100755 --- a/xrayebator +++ b/xrayebator @@ -2780,6 +2780,27 @@ hysteria2_subbody_command() { 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 @@ -14561,6 +14582,10 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then shift hysteria2_subbody_command "$@" ;; + hysteria2-link) + shift + hysteria2_link_command "$@" + ;; awg-install) shift awg_install_command "$@" @@ -14619,6 +14644,7 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then 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)" From 2b3d2c75695d3a78d96dd3ee1090e5038a809ede Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 06:00:55 +0300 Subject: [PATCH 15/73] =?UTF-8?q?GUI=20=D1=8D=D1=82=D0=B0=D0=BF=202,=20?= =?UTF-8?q?=D1=81=D1=80=D0=B5=D0=B7=20D:=20=D1=81=D0=B0=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B8=D1=80=D1=83?= =?UTF-8?q?=D1=8E=D1=89=D0=B0=D1=8F=D1=81=D1=8F=20=D1=88=D0=B0=D0=BF=D0=BA?= =?UTF-8?q?=D0=B0=20.conf=20+=20=D0=B4=D0=BE=D0=BA=D0=B8=20=D0=BE=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B4=D0=B0=D1=87=D0=B5=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B5?= =?UTF-8?q?=D0=B9=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20GUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Клиентский .conf AWG начинается с шапки-пометки (требование этапа 2): транспорт AmneziaWG работает НЕ через V2Ray-клиенты (HAPP/v2rayNG), а через AmneziaVPN/AmneziaWG; имя профиля и напоминание перекачать конфиг после смены ключей. В приложении AmneziaVPN содержимое конфига видно — пометка едет вместе с файлом; в само чужое приложение мы не лазим. Доки (en/ru/zh, configuration.md): AWG-секция дополнена выдачей ключей через GUI («Ключи» в Server Settings: hysteria2-ссылка и AWG .conf текстом с QR + выдача гранта в один клик). Тест awg-lifecycle проверяет шапку (startswith '# Xrayebator:'). Live: шапка видна в awg-conf --name happ на 2.26.125.147. --- docs/configuration.md | 7 +++++-- docs/ru/configuration.md | 5 ++++- docs/zh-CN/configuration.md | 5 ++++- validation/test-awg-lifecycle.sh | 4 +++- xrayebator | 5 +++++ 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index f8c9a77d..49355063 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -265,8 +265,11 @@ and MASQUERADE on the default-route interface. Junk parameters are generated per `Jc` 1..128, `Jmin < Jmax ≤ 1280`, `S1`/`S2` 15..150 with `S1+56 ≠ S2`, `H1`–`H4` unique in 5..2147483647. 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. Peer changes restart the interface with rollback — a brief tunnel blip -for all peers, acceptable because grants and revokes are rare. +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. 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/ТСПУ diff --git a/docs/ru/configuration.md b/docs/ru/configuration.md index de59933a..6cabd66a 100644 --- a/docs/ru/configuration.md +++ b/docs/ru/configuration.md @@ -244,7 +244,10 @@ MASQUERADE на интерфейсе default-route. Junk-параметры ге `Jmin < Jmax ≤ 1280`, `S1`/`S2` 15..150 с `S1+56 ≠ S2`, `H1`–`H4` уникальные в 5..2147483647. Каждый профиль-пир получает ключевую пару, preshared key и первый свободный адрес; клиентский `.conf` (пункт меню 12 или `awg-conf --name N`) несёт full-tunnel `AllowedIPs` и серверные -junk-параметры. Изменения peer-ов перезапускают интерфейс с откатом — короткий обрыв туннеля для +junk-параметры, и начинается с самодокументирующейся шапки: профиль работает через +AmneziaVPN/AmneziaWG, а не V2Ray-клиенты (HAPP). В десктоп-GUI те же ключи выдаются per-профиль +кнопкой «Ключи» в Server Settings: ссылка Hysteria 2 и AWG `.conf` текстом с QR и выдачей гранта +в один клик. Изменения peer-ов перезапускают интерфейс с откатом — короткий обрыв туннеля для всех peer-ов, приемлемо, поскольку гранты и отзывы редки. ### AWG 2.0 vs 3.x — и почему 3.1 важна против DPI/ТСПУ diff --git a/docs/zh-CN/configuration.md b/docs/zh-CN/configuration.md index 014dc197..e9f28601 100644 --- a/docs/zh-CN/configuration.md +++ b/docs/zh-CN/configuration.md @@ -223,7 +223,10 @@ Hysteria 拒绝空的 userpass 映射。客户端链接形如 `10.8.1.0/24`、`ip_forward` 与默认路由接口上的 MASQUERADE。junk 参数按 AWG 规范生成:`Jc` 1..128、`Jmin < Jmax ≤ 1280`、`S1`/`S2` 15..150 且 `S1+56 ≠ S2`、`H1`–`H4` 在 5..2147483647 内 互不相同。每个配置档 peer 获得密钥对、预共享密钥与首个空闲地址;客户端 `.conf`(菜单项 12 或 -`awg-conf --name N`)携带全隧道 `AllowedIPs` 与服务端 junk 参数。peer 变更会带回滚地重启接口 +`awg-conf --name N`)携带全隧道 `AllowedIPs` 与服务端 junk 参数,开头带有自述性头部:该配置档 +通过 AmneziaVPN/AmneziaWG 运行,而非 V2Ray 客户端(HAPP)。桌面 GUI 中,相同的密钥通过 +Server Settings 的「密钥」按钮按配置档交付:Hysteria 2 链接与 AWG `.conf` 以文本+二维码呈现, +并支持一键签发授权。peer 变更会带回滚地重启接口 ——所有 peer 的隧道会短暂中断;授权与吊销是低频操作,第一阶段可接受。 ### AWG 2.0 与 3.x 的差异——以及 3.1 为何对 DPI/ТСПУ 更重要 diff --git a/validation/test-awg-lifecycle.sh b/validation/test-awg-lifecycle.sh index 9dec1523..2efa5c14 100644 --- a/validation/test-awg-lifecycle.sh +++ b/validation/test-awg-lifecycle.sh @@ -139,7 +139,9 @@ jq -e '.ok == true and .name == "carol" and (.conf | contains("PersistentKeepalive = 25")) and (.conf | contains("PresharedKey = ")) and (.conf | contains("HeaderProtectionKey = ")) and - (.conf | contains("RandomTrailers = on"))' <<<"$conf" >/dev/null \ + (.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" \ diff --git a/xrayebator b/xrayebator index d4ef3332..ad484f3c 100755 --- a/xrayebator +++ b/xrayebator @@ -3620,7 +3620,12 @@ _awg_client_conf_pure() { local fe_rt=$(jq -r '.rt // false' <<<"$features") local hpk=$(jq -r '.HeaderProtectionKey // empty' <<<"$junk") { + # Самодокументирующаяся шапка (этап 2, срез D): в AmneziaVPN видно, что + # профиль живёт вне V2Ray/HAPP-мира и how to перекачать конфиг. cat < Date: Sun, 4 Oct 2026 13:14:51 +0300 Subject: [PATCH 16/73] =?UTF-8?q?GUI=20=D1=8D=D1=82=D0=B0=D0=BF=202:=20?= =?UTF-8?q?=D0=B2=D0=B8=D0=B7=D1=83=D0=B0=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=B5=D0=BA=20?= =?UTF-8?q?=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4=D0=BE=D0=B2=20(=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=84=D0=B8=D0=B4=D0=B1=D0=B5=D0=BA=D1=83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фидбек: карточки были похожи только цветом, между ними торчала полоска-разделитель, QR с AWG-конфигом не импортировался в приложение. - каждый бэкенд теперь в СВОЁМ боксе (backendCard: bg-2/border/radius-16 — стилистика остальных карточек приложения), сетка auto-fit 340px - полоска border-top между рядами убрана - иконки в цветных бейджах (Zap — жёлтый для hysteria, Shield — голубой для AWG) + подзаголовки ('быстрый UDP · QUIC' / 'системный VPN · full-tunnel') - тексты переписаны короче и по делу; у AWG-ноты явно: клиент AmneziaWG/AmneziaVPN, НЕ V2Ray/HAPP - QR для AWG теперь из конфига БЕЗ комментариев-шапки: часть приложений спотыкается при импорте с камеры о строки до [Interface] (текст и копирование остаются с шапкой) typecheck 0, unit-тесты зелёные. --- src/renderer/src/i18n/en.json | 10 +- src/renderer/src/i18n/ru.json | 10 +- src/renderer/src/i18n/zh.json | 10 +- .../src/pages/ServerSettings.module.css | 63 ++++++++--- src/renderer/src/pages/ServerSettings.tsx | 101 +++++++++++------- 5 files changed, 128 insertions(+), 66 deletions(-) diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index e29dea43..3b1fb446 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -280,10 +280,12 @@ "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 — fast UDP", - "backendsHystNote": "A hysteria2 line is added to the HAPP/v2rayNG subscription automatically. Works in HAPP, Hiddify, v2rayN.", - "backendsAwg": "AmneziaWG 3.1 — system VPN", - "backendsAwgNote": "NOTE: runs through the AmneziaVPN client, NOT V2Ray clients (HAPP). The config is delivered as text and QR; full tunnel.", + "backendsHyst": "Hysteria 2", + "backendsHystSub": "fast UDP transport · QUIC", + "backendsHystNote": "The key is added to the HAPP/v2rayNG subscription automatically. Clients: HAPP, Hiddify, v2rayN.", + "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", "backendsInstall": "Install", diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index 3aa64373..e0e8baf5 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -281,10 +281,12 @@ "backendsTitle": "Мультипротокольные бэкенды", "backendsHint": "Дополнительные транспорты на том же сервере. Работают одновременно с Xray; доступ транспорта определяется грантами профиля.", "backendsUnsupported": "Сервер работает на старой версии менеджера — обновите его (кнопка выше), чтобы управлять бэкендами.", - "backendsHyst": "Hysteria 2 — быстрый UDP", - "backendsHystNote": "Строка hysteria2 добавляется в подписку HAPP/v2rayNG автоматически. Работает в HAPP, Hiddify, v2rayN.", - "backendsAwg": "AmneziaWG 3.1 — системный VPN", - "backendsAwgNote": "ВНИМАНИЕ: работает через клиент AmneziaVPN, а НЕ через V2Ray-клиентов (HAPP). Конфиг выдаётся текстом и QR, полная маршрутизация.", + "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": "Не установлен", "backendsInstall": "Установить", diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index c8d988cc..74824861 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -279,10 +279,12 @@ "backendsTitle": "多协议后端", "backendsHint": "同一服务器上的额外传输。与 Xray 并行运行;传输访问由配置档授权决定。", "backendsUnsupported": "该服务器运行旧版管理器——请先更新(上方按钮),然后即可管理后端。", - "backendsHyst": "Hysteria 2 — 高速 UDP", - "backendsHystNote": "hysteria2 行会自动加入 HAPP/v2rayNG 订阅。适用于 HAPP、Hiddify、v2rayN。", - "backendsAwg": "AmneziaWG 3.1 — 系统级 VPN", - "backendsAwgNote": "注意:通过 AmneziaVPN 客户端运行,而非 V2Ray 客户端(HAPP)。配置以文本和二维码方式交付;全隧道。", + "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": "未安装", "backendsInstall": "安装", diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index bac877a6..c3126f0f 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -113,37 +113,69 @@ } /* --- Мультипротокольные бэкенды (этап 2) --- */ -.backendRow { +.backendsGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 14px; +} + +/* Каждый бэкенд — в собственном боксе, стилистически как остальные карточки. */ +.backendCard { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 16px; + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.backendHead { display: flex; - flex-wrap: wrap; align-items: center; gap: 10px; - padding: 12px 0; - border-top: 1px solid var(--border); } -.backendRow:first-of-type { - border-top: none; - padding-top: 4px; +.backendIcon { + width: 34px; + height: 34px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + flex: none; +} + +.backendIconHyst { + background: rgba(250, 204, 21, 0.14); + color: #facc15; +} + +.backendIconAwg { + background: rgba(56, 189, 248, 0.14); + color: #38bdf8; } -.backendMeta { - flex: 1 1 260px; +.backendTitle { + display: flex; + flex-direction: column; min-width: 0; } .backendName { font-size: 14px; font-weight: 600; - display: flex; - align-items: center; - gap: 8px; + line-height: 1.2; +} + +.backendSub { + font-size: 12px; + color: var(--text-2); } .backendState { font-size: 12px; color: var(--text-2); - margin-top: 2px; } .backendStateOk { @@ -151,15 +183,18 @@ } .backendNote { - margin: 8px 0 0; + margin: 0; font-size: 12px; color: var(--text-2); + line-height: 1.5; } .backendActions { display: flex; + flex-wrap: wrap; align-items: center; gap: 8px; + margin-top: auto; } /* --- Диалог ключей бэкендов (срез C) --- */ diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index 2be05681..d391ea3e 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -679,6 +679,17 @@ export function ServerSettings({ setKeysQrData(dataUrl) } + /** + * QR для импорта в AmneziaWG-клиент: без комментариев-шапки — часть + * приложений спотыкается о строки перед [Interface] при импорте с камеры. + */ + const awgQrPayload = (conf: string): string => + conf + .split('\n') + .filter((line) => !line.trim().startsWith('#')) + .join('\n') + .trim() + const forgetHostKey = async (): Promise => { setHostKeyResetBusy(true) setError(null) @@ -1040,34 +1051,41 @@ export function ServerSettings({ ))}
-
+

{t('settings.backendsTitle')}

{t('settings.backendsHint')}

{backendsError !== null ? ( -

{t('settings.backendsUnsupported')}

+
+

{t('settings.backendsUnsupported')}

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

{t('settings.backendsHystNote')}

+
+ {hyst?.installed + ? `${t('settings.backendsActive')} · UDP ${hyst.port ?? '—'} · ${ + hyst.version ?? '' + }` + : t('settings.backendsNotInstalled')} +
+

{t('settings.backendsHystNote')}

{hyst?.installed ? ( <> @@ -1102,27 +1120,30 @@ export function ServerSettings({
-
-
-
- - {t('settings.backendsAwg')} +
+
+
+
-
- {awgEntry?.installed - ? `${t('settings.backendsActive')} · UDP ${awgEntry.port ?? '—'} · ${ - awgEntry.three_enabled - ? t('settings.backends31On') - : t('settings.backends31Off') - }` - : t('settings.backendsNotInstalled')} +
+
{t('settings.backendsAwg')}
+
{t('settings.backendsAwgSub')}
-

{t('settings.backendsAwgNote')}

+
+ {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 ? ( <> @@ -1158,7 +1179,7 @@ export function ServerSettings({ )}
- +
)}
@@ -1250,7 +1271,7 @@ export function ServerSettings({ From e6c11ad5120e0150731bf1d49972c9636783d485 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:52:22 +0300 Subject: [PATCH 17/73] =?UTF-8?q?GUI=20=D1=8D=D1=82=D0=B0=D0=BF=202:=20?= =?UTF-8?q?=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4-=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=B0=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D0=B5=20=C2=AB=D0=9A=D0=BB=D1=8E=D1=87=D0=B8=C2=BB=20(hy?= =?UTF-8?q?steria2=20=D0=B8=D0=B7=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=20+=20AWG-=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3?= =?UTF-8?q?=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пользователь не видел hysteria2/AWG «в самих ключах» — страница Ключей парсила из подписки только vless://. Теперь: - subscription.ts: extractHysteria2Links() — hysteria2:// из HAPP- и generic-тел (включая base64-блоки); fetchSubscription возвращает {keys, hysteria2Links}; deployer/inspector-вызовы адаптированы - страница Ключей: hysteria2-карточки (chip HYSTERIA2 · UDP :порт, копирование+QR — QR сканируется HAPP/Hiddify напрямую) и AWG-карточки per-profile (полный .conf с 3.1-ключами, копирование + QR без комментариев-шапки для совместимости импорта) - AWG-конфиги тянутся по SSH с доступом из сохранённой карточки сервера (shouldAutoConnectServer гейтит секцию — секрет должен лежать в keychain) - ServerSettings: карточки бэкендов поставлены одна под одной (фидбек «совместить 1 под 1»), подчёркнутые «ВНИМАНИЕ»-нотки заменены на нейтральные - i18n keys.* ×3 языка typecheck 0; vitest: только задокументированный Windows-only shell-command; bash -n чистый --- src/main/core/deployer.ts | 3 +- src/main/core/subscription.ts | 44 ++++- src/main/ipc-handlers.ts | 7 +- src/renderer/src/i18n/en.json | 6 +- src/renderer/src/i18n/ru.json | 6 +- src/renderer/src/i18n/zh.json | 6 +- src/renderer/src/pages/ServerKeys.module.css | 23 +++ src/renderer/src/pages/ServerKeys.tsx | 155 +++++++++++++++++- .../src/pages/ServerSettings.module.css | 2 +- src/shared/types.ts | 2 + 10 files changed, 242 insertions(+), 12 deletions(-) diff --git a/src/main/core/deployer.ts b/src/main/core/deployer.ts index c69aa63c..9501d158 100644 --- a/src/main/core/deployer.ts +++ b/src/main/core/deployer.ts @@ -182,7 +182,8 @@ export class Deployer { this.onStep('save', 'Сохраняю результат...') const subUrl = payload.subscription_url - const keys = subUrl ? await fetchSubscription(subUrl) : [] + const fetched = subUrl ? await fetchSubscription(subUrl) : { keys: [], hysteria2Links: [] } + const keys = fetched.keys if (!keys.length && subUrl) { throw new Error('Subscription вернул пустой список ключей') } 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 2bca2edf..f3e2ac8a 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -292,9 +292,9 @@ export function registerIpcHandlers({ store }: IpcContext): void { ipcMain.handle('subscription:fetch', async (_e, serverId: string) => { const server = store.get(serverId) if (!server) throw new Error('Сервер не найден') - const keys = await fetchSubscription(server.subscriptionUrl) + const { keys, hysteria2Links } = await fetchSubscription(server.subscriptionUrl) store.updateKeys(serverId, keys) - return { serverId, subscriptionUrl: server.subscriptionUrl, keys } + return { serverId, subscriptionUrl: server.subscriptionUrl, keys, hysteria2Links } }) ipcMain.handle('servers:import', async (event, payload: ImportServerPayload) => { @@ -320,7 +320,8 @@ export function registerIpcHandlers({ store }: IpcContext): void { credentials, async (url) => { emitStep('subscription') - return fetchSubscription(url) + const fetched = await fetchSubscription(url) + return fetched.keys }, emitLog ) diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 3b1fb446..35dbf173 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -128,7 +128,11 @@ "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.", + "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." }, "settings": { "title": "Server profiles", diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index e0e8baf5..d301cf64 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -129,7 +129,11 @@ "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 один раз." }, "settings": { "title": "Профили сервера", diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index 74824861..3829e31f 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -127,7 +127,11 @@ "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。" }, "settings": { "title": "服务器配置文件", diff --git a/src/renderer/src/pages/ServerKeys.module.css b/src/renderer/src/pages/ServerKeys.module.css index 64519e14..5d035259 100644 --- a/src/renderer/src/pages/ServerKeys.module.css +++ b/src/renderer/src/pages/ServerKeys.module.css @@ -79,6 +79,29 @@ 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; +} + .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..10db6b78 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 { + BackendStatusResult, + Server, + ServerProfile, + SshAccessInput, + VlessLink +} from '@shared/types' import { vlessPort } from '@shared/vless' +import { shouldAutoConnectServer } from './server-access' import styles from './ServerKeys.module.css' interface ServerKeysProps { @@ -14,12 +21,31 @@ interface ServerKeysProps { export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Element { const { t } = useTranslation() const [keys, setKeys] = useState(server.keys ?? []) + const [hysteria2Links, setHysteria2Links] = useState([]) 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 [backends, setBackends] = useState(null) + const [awgConfs, setAwgConfs] = 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' + } + const backendKeysAvailable = shouldAutoConnectServer(server) + useEffect(() => { let cancelled = false if (!server.subscriptionUrl) return @@ -29,6 +55,7 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme .then((result) => { if (cancelled) return setKeys(result.keys) + setHysteria2Links(result.hysteria2Links ?? []) setSubscriptionUrl(result.subscriptionUrl) }) .catch(() => { @@ -42,6 +69,45 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme } }, [server.id]) + useEffect(() => { + let cancelled = false + if (!backendKeysAvailable) return + setBeBusy(true) + ;(async (): Promise => { + try { + const pl = await window.api.profiles.list(server.id, storedAccess) + if (cancelled) return + const list = pl.profiles ?? [] + setProfiles(list) + const bs = await window.api.backends.status(server.id, storedAccess) + if (cancelled) return + setBackends(bs) + const confs: Record = {} + for (const profile of list) { + if (!profile.backends?.awg) continue + if (bs.backends.awg?.state !== 'active') break + const conf = await window.api.backends + .awgConf(server.id, storedAccess, profile.name) + .catch(() => null) + if (cancelled) return + if (conf?.ok && conf.conf) confs[profile.name] = conf.conf + } + if (!cancelled) setAwgConfs(confs) + } catch { + if (!cancelled) { + setProfiles([]) + setBackends(null) + } + } finally { + if (!cancelled) setBeBusy(false) + } + })() + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [server.id, backendKeysAvailable]) + useEffect(() => { if (!qrUrl) return const onEsc = (e: KeyboardEvent): void => { @@ -69,6 +135,22 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme setQrData(dataUrl) } + /** QR для импорта в AmneziaWG-клиент: без комментариев-шапки. */ + const awgQrPayload = (conf: string): string => + conf + .split('\n') + .filter((line) => !line.trim().startsWith('#')) + .join('\n') + .trim() + + const hysteria2Port = (link: string): string => { + const m = link.match(/@[^/:]+:(\d+)/) + return m ? m[1] : '' + } + + const awgProfiles = profiles.filter((p) => p.backends?.awg) + const awgActive = backends?.backends.awg?.state === 'active' + return (
@@ -109,6 +191,69 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme
))} + {hysteria2Links.map((link) => ( +
+
+ + HYSTERIA2 · UDP :{hysteria2Port(link)} + +
+

{t('keys.backendsHystNote')}

+
+ {link} +
+
+ + +
+
+ ))} + + {awgProfiles.map((profile) => ( +
+
+ + AMNEZIAWG 3.1 · {profile.name} + +
+

{t('keys.backendsAwgNote')}

+ {awgActive && awgConfs[profile.name] ? ( + <> +
{awgConfs[profile.name]}
+
+ + +
+ + ) : ( +
{t('keys.backendsNotReady')}
+ )} +
+ ))} + + + {beBusy && ( +
+ +
+ )} + {subscriptionUrl && (
@@ -133,7 +278,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 c3126f0f..cbf7b95e 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -115,7 +115,7 @@ /* --- Мультипротокольные бэкенды (этап 2) --- */ .backendsGrid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + grid-template-columns: 1fr; gap: 14px; } diff --git a/src/shared/types.ts b/src/shared/types.ts index 561d05fa..145db56e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -114,6 +114,8 @@ export interface SubscriptionResult { serverId: string subscriptionUrl: string keys: VlessLink[] + /** Строки hysteria2:// из тела подписки (UDP-бэкенд). */ + hysteria2Links?: string[] } /** Грант Hysteria 2 в профиле (username = имя профиля, password = 32 hex). */ From 0b4bcd36d172c3dc818e29c3778506db85d188b3 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:48:14 +0300 Subject: [PATCH 18/73] =?UTF-8?q?GUI=20=D1=8D=D1=82=D0=B0=D0=BF=202,=20?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D1=84=D0=B8=D0=B4=D0=B1=D0=B5=D0=BA=D1=83:=20prefetch+?= =?UTF-8?q?=D0=BA=D1=8D=D1=88,=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20=D0=B1?= =?UTF-8?q?=D0=BE=D0=BA=D1=81,=20AWG=20=D0=BA=D0=B0=D0=BA=20=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=20(=D0=BD=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8?= =?UTF-8?q?=D0=B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фидбек: протоколы грузятся в реальном времени (ожидание), карточки не совмещены, AWG выглядит конфигом, а не ключом; hysteria перекрывается amnezia. - загрузка бэкендов ПАРАЛЛЕЛЬНО с профилями (Promise.all в load) + скелетоны-заглушки вместо пустоты - карточки бэкендов объединены в ОДИН общий бокс (listCard), блоки протоколов один под другим с отступами (полосок нет) - кэш awg-conf в main-process (инвалидация при смене грантов/режима 3.1/uninstall): страница Ключей открывается мгновенно при повторном заходе, как с обычными коннекторами - AWG теперь показывается как КЛЮЧ: поля Endpoint и Адрес (parseAwgClientConf из нового shared/awg.ts), кнопки — копирование конфига и QR (QR без комментариев-шапки для совместимости импорта); простыня конфига убрана - hysteria2-карточки на странице Ключей — из подписки, грузятся вместе с vless (extractHysteria2Links); deployer/inspector адаптированы к новому возврату fetchSubscription - shared/awg.ts + тесты (10/10: shared + backend-manager) typecheck 0; vitest — только задокументированный Windows-only shell-command; bash -n чистый --- src/main/ipc-handlers.ts | 28 +++++- src/renderer/src/i18n/en.json | 8 +- src/renderer/src/i18n/ru.json | 8 +- src/renderer/src/i18n/zh.json | 8 +- src/renderer/src/pages/ServerKeys.module.css | 25 ++++++ src/renderer/src/pages/ServerKeys.tsx | 89 +++++++++++-------- .../src/pages/ServerSettings.module.css | 71 +++++++++++---- src/renderer/src/pages/ServerSettings.tsx | 73 ++++++++------- src/shared/awg.ts | 27 ++++++ tests/unit/awg-shared.test.ts | 43 +++++++++ 10 files changed, 287 insertions(+), 93 deletions(-) create mode 100644 src/shared/awg.ts create mode 100644 tests/unit/awg-shared.test.ts diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index f3e2ac8a..0ece1322 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -4,6 +4,7 @@ import { basename, resolve } from 'node:path' import { randomUUID } from 'node:crypto' import { readFileSync, statSync } from 'node:fs' import type { + AwgConfResult, ImportServerPayload, ImportStep, ProfileCreateInput, @@ -473,6 +474,17 @@ export function registerIpcHandlers({ store }: IpcContext): void { return new BackendManager(credentials) } + // Кэш AWG-конфигов: страница «Ключи» открывается мгновенно при повторном + // заходе (как обычные коннекторы, хранящиеся в приложении). Инвалидация — + // при смене грантов/режима 3.1/удалении бэкенда. + const awgConfCache = new Map() + const awgConfCacheKey = (serverId: string, name: string): string => `${serverId}::${name}` + const invalidateAwgConf = (serverId: string): void => { + for (const key of awgConfCache.keys()) { + if (key.startsWith(`${serverId}::`)) awgConfCache.delete(key) + } + } + ipcMain.handle('backends:status', async (_e, serverId: string, access: SshAccessInput) => { const manager = await backendManagerFor(serverId, access) const result = await manager.status() @@ -494,7 +506,9 @@ export function registerIpcHandlers({ store }: IpcContext): void { async (_e, serverId: string, access: SshAccessInput, name: string) => { if (!name) throw new Error('Не указано имя профиля') const manager = await backendManagerFor(serverId, access) - return manager.awgGrant(name) + const result = await manager.awgGrant(name) + if (result.ok) invalidateAwgConf(serverId) + return result } ) @@ -502,9 +516,13 @@ export function registerIpcHandlers({ store }: IpcContext): void { 'backends:awgConf', async (_e, serverId: string, access: SshAccessInput, name: string) => { if (!name) throw new Error('Не указано имя профиля') + const cacheKey = awgConfCacheKey(serverId, name) + const cached = awgConfCache.get(cacheKey) + if (cached) return cached const manager = await backendManagerFor(serverId, access) const result = await manager.awgConf(name) if (!result.ok) throw new Error(result.error ?? 'Не удалось получить конфиг AWG') + awgConfCache.set(cacheKey, result) return result } ) @@ -513,7 +531,9 @@ export function registerIpcHandlers({ store }: IpcContext): void { 'backends:awg31', async (_e, serverId: string, access: SshAccessInput, on: boolean) => { const manager = await backendManagerFor(serverId, access) - return manager.awg31(Boolean(on)) + const result = await manager.awg31(Boolean(on)) + if (result.ok) invalidateAwgConf(serverId) + return result } ) @@ -540,7 +560,9 @@ export function registerIpcHandlers({ store }: IpcContext): void { ipcMain.handle('backends:awgUninstall', async (_e, serverId: string, access: SshAccessInput) => { const manager = await backendManagerFor(serverId, access) - return manager.awgUninstall() + const result = await manager.awgUninstall() + if (result.ok) invalidateAwgConf(serverId) + return result }) ipcMain.handle( diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 35dbf173..7fe83568 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -132,7 +132,9 @@ "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.", "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." + "backendsNoAccess": "AmneziaWG keys require saved server access (password/key in the keychain) — open Server Settings once.", + "backendsEndpoint": "Endpoint", + "backendsAddress": "Address" }, "settings": { "title": "Server profiles", @@ -309,6 +311,8 @@ "beKeysTitle": "Backend keys", "beKeysHystNote": "UDP transport: works in HAPP, Hiddify, v2rayN. The line is already in the subscription — this key is for direct import.", "beKeysAwgNote": "AmneziaVPN — NOT V2Ray/HAPP: import the config into the AmneziaWG/AmneziaVPN app. Full tunnel, clients AmneziaVPN ≥ 5.0.1.5.", - "beKeysGrant": "Issue grant" + "beKeysGrant": "Issue grant", + "beKeysEndpoint": "Endpoint", + "beKeysAddress": "Address" } } diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index d301cf64..c412bf21 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -133,7 +133,9 @@ "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 один раз." + "backendsNoAccess": "Для ключей AmneziaWG нужен сохранённый доступ к серверу (пароль/ключ в keychain) — зайдите в Server Settings один раз.", + "backendsEndpoint": "Endpoint", + "backendsAddress": "Адрес" }, "settings": { "title": "Профили сервера", @@ -310,6 +312,8 @@ "beKeysTitle": "Ключи бэкендов", "beKeysHystNote": "UDP-транспорт: работает в HAPP, Hiddify, v2rayN. Строка уже добавлена в подписку — этот ключ для прямого импорта.", "beKeysAwgNote": "AmneziaVPN — НЕ V2Ray/HAPP: импортируйте конфиг в приложение AmneziaWG/AmneziaVPN. Full-tunnel, клиенты AmneziaVPN ≥ 5.0.1.5.", - "beKeysGrant": "Выдать грант" + "beKeysGrant": "Выдать грант", + "beKeysEndpoint": "Endpoint", + "beKeysAddress": "Адрес" } } diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index 3829e31f..8824dbe1 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -131,7 +131,9 @@ "backendsHystNote": "UDP 传输:该行已在订阅中。客户端:HAPP、Hiddify、v2rayN。", "backendsAwgNote": "AmneziaWG 3.1 —— 非 V2Ray/HAPP:请将配置导入 AmneziaWG/AmneziaVPN 应用。全隧道,AmneziaVPN ≥ 5.0.1.5。", "backendsNotReady": "后端尚未激活或配置不可用——请在服务器设置中检查状态。", - "backendsNoAccess": "AmneziaWG 密钥需要已保存的服务器访问凭据(keychain 中的密码/密钥)——请先打开 Server Settings。" + "backendsNoAccess": "AmneziaWG 密钥需要已保存的服务器访问凭据(keychain 中的密码/密钥)——请先打开 Server Settings。", + "backendsEndpoint": "Endpoint", + "backendsAddress": "地址" }, "settings": { "title": "服务器配置文件", @@ -308,6 +310,8 @@ "beKeysTitle": "后端密钥", "beKeysHystNote": "UDP 传输:适用于 HAPP、Hiddify、v2rayN。该行已在订阅中——此密钥用于直接导入。", "beKeysAwgNote": "AmneziaVPN —— 非 V2Ray/HAPP:请将配置导入 AmneziaWG/AmneziaVPN 应用。全隧道,客户端需 AmneziaVPN ≥ 5.0.1.5。", - "beKeysGrant": "签发授权" + "beKeysGrant": "签发授权", + "beKeysEndpoint": "Endpoint", + "beKeysAddress": "地址" } } diff --git a/src/renderer/src/pages/ServerKeys.module.css b/src/renderer/src/pages/ServerKeys.module.css index 5d035259..47f655f1 100644 --- a/src/renderer/src/pages/ServerKeys.module.css +++ b/src/renderer/src/pages/ServerKeys.module.css @@ -102,6 +102,31 @@ 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 10db6b78..cfa575c8 100644 --- a/src/renderer/src/pages/ServerKeys.tsx +++ b/src/renderer/src/pages/ServerKeys.tsx @@ -10,6 +10,7 @@ import type { VlessLink } from '@shared/types' import { vlessPort } from '@shared/vless' +import { parseAwgClientConf, stripAwgComments } from '@shared/awg' import { shouldAutoConnectServer } from './server-access' import styles from './ServerKeys.module.css' @@ -136,12 +137,7 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme } /** QR для импорта в AmneziaWG-клиент: без комментариев-шапки. */ - const awgQrPayload = (conf: string): string => - conf - .split('\n') - .filter((line) => !line.trim().startsWith('#')) - .join('\n') - .trim() + const awgQrPayload = (conf: string): string => stripAwgComments(conf) const hysteria2Port = (link: string): string => { const m = link.match(/@[^/:]+:(\d+)/) @@ -213,39 +209,56 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme
))} - {awgProfiles.map((profile) => ( -
-
- - AMNEZIAWG 3.1 · {profile.name} - + {awgProfiles.map((profile) => { + const conf = awgConfs[profile.name] + const fields = conf ? parseAwgClientConf(conf) : null + return ( +
+
+ + AMNEZIAWG 3.1 · {profile.name} + +
+

{t('keys.backendsAwgNote')}

+ {awgActive && fields ? ( + <> +
+
+ + {t('keys.backendsEndpoint')} + + {fields.endpoint} +
+
+ + {t('keys.backendsAddress')} + + {fields.address} +
+
+
+ + +
+ + ) : ( +
{t('keys.backendsNotReady')}
+ )}
-

{t('keys.backendsAwgNote')}

- {awgActive && awgConfs[profile.name] ? ( - <> -
{awgConfs[profile.name]}
-
- - -
- - ) : ( -
{t('keys.backendsNotReady')}
- )} -
- ))} + ) + })} {beBusy && ( diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index cbf7b95e..b91b2b8f 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -113,27 +113,20 @@ } /* --- Мультипротокольные бэкенды (этап 2) --- */ -.backendsGrid { - display: grid; - grid-template-columns: 1fr; - gap: 14px; +/* Один общий бокс, внутри — блоки протоколов один под другим. */ +.backendBlock { + padding: 6px 0 14px; } -/* Каждый бэкенд — в собственном боксе, стилистически как остальные карточки. */ -.backendCard { - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: 16px; - padding: 16px 18px; - display: flex; - flex-direction: column; - gap: 10px; +.backendBlock + .backendBlock { + margin-top: 10px; } .backendHead { display: flex; align-items: center; gap: 10px; + margin-bottom: 8px; } .backendIcon { @@ -176,6 +169,7 @@ .backendState { font-size: 12px; color: var(--text-2); + margin-bottom: 6px; } .backendStateOk { @@ -183,7 +177,7 @@ } .backendNote { - margin: 0; + margin: 0 0 10px; font-size: 12px; color: var(--text-2); line-height: 1.5; @@ -194,7 +188,54 @@ flex-wrap: wrap; align-items: center; gap: 8px; - margin-top: auto; +} + +.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) --- */ diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index d391ea3e..a923fdeb 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -30,6 +30,7 @@ import type { } from '@shared/types' import { describeExpire, isFutureDate, presetDate } from '@shared/expire' import { todayIso } from '@shared/calendar' +import { parseAwgClientConf, stripAwgComments } from '@shared/awg' import { isSshAccessReady, SshAccessForm } from '../components/SshAccessForm' import { CalendarPicker } from '../components/CalendarPicker' import { shouldAutoConnectServer } from './server-access' @@ -93,6 +94,7 @@ export function ServerSettings({ 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< @@ -184,17 +186,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 ?? []) - // Мультипротокольные бэкенды: необязательная секция. На сервере старой - // версии backend-status неизвестен — показываем «не поддерживается». - try { - setBackends(await window.api.backends.status(server.id, access)) - setBackendsError(null) - } catch (err) { - setBackends(null) - setBackendsError(err instanceof Error ? err.message : String(err)) - } + await backendsP const refreshed = await window.api.servers.get(server.id) setHostKeyFingerprint(refreshed?.hostKeyFingerprint ?? null) if (refreshed) { @@ -679,17 +686,6 @@ export function ServerSettings({ setKeysQrData(dataUrl) } - /** - * QR для импорта в AmneziaWG-клиент: без комментариев-шапки — часть - * приложений спотыкается о строки перед [Interface] при импорте с камеры. - */ - const awgQrPayload = (conf: string): string => - conf - .split('\n') - .filter((line) => !line.trim().startsWith('#')) - .join('\n') - .trim() - const forgetHostKey = async (): Promise => { setHostKeyResetBusy(true) setError(null) @@ -728,6 +724,7 @@ export function ServerSettings({ const hyst = backends?.backends.hysteria2 const awgEntry = backends?.backends.awg + const keysAwgFields = keysAwgConf ? parseAwgClientConf(keysAwgConf) : null const transportLabel = (profile: ServerProfile): string => profile.multi_route ? `${profile.transport} · ${profile.routes} ${t('settings.routes')}` : profile.transport @@ -1055,16 +1052,17 @@ export function ServerSettings({

{t('settings.backendsTitle')}

{t('settings.backendsHint')}

{backendsError !== null ? ( -
+

{t('settings.backendsUnsupported')}

- ) : backends === null ? ( -
- + ) : backendsLoading || backends === null ? ( +
+
+
) : ( -
-
+
+
@@ -1120,7 +1118,7 @@ export function ServerSettings({
-
+
@@ -1256,14 +1254,27 @@ export function ServerSettings({

{t('settings.beKeysAwgNote')}

- {keysAwgConf ? ( + {keysAwgFields ? ( <> -
{keysAwgConf}
+
+
+ + {t('settings.beKeysEndpoint')} + + {keysAwgFields.endpoint} +
+
+ + {t('settings.beKeysAddress')} + + {keysAwgFields.address} +
+
diff --git a/src/shared/awg.ts b/src/shared/awg.ts new file mode 100644 index 00000000..bb4ba51a --- /dev/null +++ b/src/shared/awg.ts @@ -0,0 +1,27 @@ +/** + * Утилиты разбора клиентского конфига AmneziaWG для GUI. + * GUI показывает конфиг как структурированный «ключ» (Endpoint/Адрес), + * а не простыню текста; полный текст нужен для QR и копирования. + */ + +/** Убирает комментарии-шапку — часть приложений спотыкается о строки до [Interface]. */ +export function stripAwgComments(conf: string): string { + return conf + .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 } +} diff --git a/tests/unit/awg-shared.test.ts b/tests/unit/awg-shared.test.ts new file mode 100644 index 00000000..b9ea2711 --- /dev/null +++ b/tests/unit/awg-shared.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { + parseAwgClientConf, + 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('') + }) +}) From 5caffb126c1265c663a9d01e1f1d926ee808a4fd Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:27:58 +0300 Subject: [PATCH 19/73] =?UTF-8?q?GUI=20=D1=8D=D1=82=D0=B0=D0=BF=202:=20?= =?UTF-8?q?=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4-=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B5=D1=80=D1=81=D0=B8=D1=81=D1=82=D1=8F=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=B8=20=D0=B3=D1=80=D1=83=D0=B7=D1=8F=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BB=D0=BB=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=20(=D1=80=D0=B0=D0=B2=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=82=D0=B0=D0=B9=D0=BC=D0=B8=D0=BD=D0=B3=20=D1=81=20vless)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фидбек: hysteria/awg подгружаются в реальном времени без сохранения, каждый раз и в разный тайминг; должны — как vless, ~моментально и из приложения. - Server record: hysteria2Keys (строки из подписки) и awgConfs (per-profile) персистятся в store; normalizeServer даёт дефолты; store.updateBackendKeys — мерж/очистка - subscription:fetch пишет hysteria2Keys рядом с vless-ключами - backends:awgConf пишет конфиг в карточку сервера (store вместо in-memory Map); awgGrant/awg31/awgUninstall очищают кэш при ротации - страница Ключей: мгновенная инициализация из server.hysteria2Keys/awgConfs, затем тихое фоновое обновление; ВСЕ запросы — одним Promise.all (profiles+backends+awgConf×N) — равный тайминг, без каскада - карточки бэкендов в Server Settings: один общий бокс, блоки протоколов 1-под-1 (фидбек), параллельная загрузка + скелетоны - AWG как ключ: поля Endpoint/Адрес (shared/awg.ts parseAwgClientConf), QR/копирование — конфиг без комментариев-шапки - fetchSubscription -> {keys, hysteria2Links}; deployer/inspector адаптированы typecheck 0; vitest — только задокументированный Windows-only shell-command; bash -n чистый --- src/main/core/servers.ts | 27 +++++++++++++++++- src/main/ipc-handlers.ts | 39 +++++++++++++------------ src/renderer/src/pages/ServerKeys.tsx | 41 ++++++++++++++++----------- src/shared/types.ts | 7 +++++ 4 files changed, 77 insertions(+), 37 deletions(-) diff --git a/src/main/core/servers.ts b/src/main/core/servers.ts index 89fc41c6..a06232a6 100644 --- a/src/main/core/servers.ts +++ b/src/main/core/servers.ts @@ -34,6 +34,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 @@ -64,7 +69,9 @@ function normalizeServer(server: StoredServer, hostKeys: Record) 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 ?? {} } } @@ -158,6 +165,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/ipc-handlers.ts b/src/main/ipc-handlers.ts index 0ece1322..7318af96 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -4,7 +4,6 @@ import { basename, resolve } from 'node:path' import { randomUUID } from 'node:crypto' import { readFileSync, statSync } from 'node:fs' import type { - AwgConfResult, ImportServerPayload, ImportStep, ProfileCreateInput, @@ -295,7 +294,14 @@ export function registerIpcHandlers({ store }: IpcContext): void { if (!server) throw new Error('Сервер не найден') const { keys, hysteria2Links } = await fetchSubscription(server.subscriptionUrl) store.updateKeys(serverId, keys) - return { serverId, subscriptionUrl: server.subscriptionUrl, keys, hysteria2Links } + // 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) => { @@ -474,17 +480,8 @@ export function registerIpcHandlers({ store }: IpcContext): void { return new BackendManager(credentials) } - // Кэш AWG-конфигов: страница «Ключи» открывается мгновенно при повторном - // заходе (как обычные коннекторы, хранящиеся в приложении). Инвалидация — - // при смене грантов/режима 3.1/удалении бэкенда. - const awgConfCache = new Map() - const awgConfCacheKey = (serverId: string, name: string): string => `${serverId}::${name}` - const invalidateAwgConf = (serverId: string): void => { - for (const key of awgConfCache.keys()) { - if (key.startsWith(`${serverId}::`)) awgConfCache.delete(key) - } - } - + // Кэш AWG-конфигов персистится в карточке сервера (server.awgConfs) — + // страница «Ключей» показывает их мгновенно и обновляет в фоне. ipcMain.handle('backends:status', async (_e, serverId: string, access: SshAccessInput) => { const manager = await backendManagerFor(serverId, access) const result = await manager.status() @@ -507,7 +504,7 @@ export function registerIpcHandlers({ store }: IpcContext): void { if (!name) throw new Error('Не указано имя профиля') const manager = await backendManagerFor(serverId, access) const result = await manager.awgGrant(name) - if (result.ok) invalidateAwgConf(serverId) + if (result.ok) store.updateBackendKeys(serverId, { awgConfs: {} }) return result } ) @@ -516,13 +513,15 @@ export function registerIpcHandlers({ store }: IpcContext): void { 'backends:awgConf', async (_e, serverId: string, access: SshAccessInput, name: string) => { if (!name) throw new Error('Не указано имя профиля') - const cacheKey = awgConfCacheKey(serverId, name) - const cached = awgConfCache.get(cacheKey) - if (cached) return cached const manager = await backendManagerFor(serverId, access) const result = await manager.awgConf(name) if (!result.ok) throw new Error(result.error ?? 'Не удалось получить конфиг AWG') - awgConfCache.set(cacheKey, result) + if (result.conf) { + const current = store.get(serverId) + store.updateBackendKeys(serverId, { + awgConfs: { ...(current?.awgConfs ?? {}), [name]: result.conf } + }) + } return result } ) @@ -532,7 +531,7 @@ export function registerIpcHandlers({ store }: IpcContext): void { async (_e, serverId: string, access: SshAccessInput, on: boolean) => { const manager = await backendManagerFor(serverId, access) const result = await manager.awg31(Boolean(on)) - if (result.ok) invalidateAwgConf(serverId) + if (result.ok) store.updateBackendKeys(serverId, { awgConfs: {} }) return result } ) @@ -561,7 +560,7 @@ export function registerIpcHandlers({ store }: IpcContext): void { ipcMain.handle('backends:awgUninstall', async (_e, serverId: string, access: SshAccessInput) => { const manager = await backendManagerFor(serverId, access) const result = await manager.awgUninstall() - if (result.ok) invalidateAwgConf(serverId) + if (result.ok) store.updateBackendKeys(serverId, { awgConfs: {} }) return result }) diff --git a/src/renderer/src/pages/ServerKeys.tsx b/src/renderer/src/pages/ServerKeys.tsx index cfa575c8..9b867b2b 100644 --- a/src/renderer/src/pages/ServerKeys.tsx +++ b/src/renderer/src/pages/ServerKeys.tsx @@ -22,7 +22,9 @@ interface ServerKeysProps { export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Element { const { t } = useTranslation() const [keys, setKeys] = useState(server.keys ?? []) - const [hysteria2Links, setHysteria2Links] = useState([]) + // Бэкенд-ключи инициализируются из карточки сервера — мгновенно, как 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) @@ -32,7 +34,6 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme // Мультипротокольные бэкенды: hysteria2 приходит из подписки, AWG — по SSH. const [profiles, setProfiles] = useState([]) const [backends, setBackends] = useState(null) - const [awgConfs, setAwgConfs] = useState>({}) const [beBusy, setBeBusy] = useState(false) // Доступ из сохранённой карточки сервера (секрет — в keychain). @@ -56,7 +57,7 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme .then((result) => { if (cancelled) return setKeys(result.keys) - setHysteria2Links(result.hysteria2Links ?? []) + setHysteria2Links(result.hysteria2Links ?? server.hysteria2Keys ?? []) setSubscriptionUrl(result.subscriptionUrl) }) .catch(() => { @@ -76,24 +77,32 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme setBeBusy(true) ;(async (): Promise => { try { - const pl = await window.api.profiles.list(server.id, storedAccess) + // Всё параллельно одним Promise.all — равный тайминг с vless-ключами. + const [pl, bs] = await Promise.all([ + window.api.profiles.list(server.id, storedAccess), + window.api.backends.status(server.id, storedAccess) + ]) if (cancelled) return const list = pl.profiles ?? [] setProfiles(list) - const bs = await window.api.backends.status(server.id, storedAccess) - if (cancelled) return setBackends(bs) - const confs: Record = {} - for (const profile of list) { - if (!profile.backends?.awg) continue - if (bs.backends.awg?.state !== 'active') break - const conf = await window.api.backends - .awgConf(server.id, storedAccess, profile.name) - .catch(() => null) - if (cancelled) return - if (conf?.ok && conf.conf) confs[profile.name] = conf.conf + const awgActive = bs.backends.awg?.state === 'active' + const targets = awgActive ? 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 + const merged: Record = { ...(server.awgConfs ?? {}) } + for (const [name, conf] of confEntries) { + if (conf) merged[name] = conf + else delete merged[name] } - if (!cancelled) setAwgConfs(confs) + setAwgConfs(merged) } catch { if (!cancelled) { setProfiles([]) diff --git a/src/shared/types.ts b/src/shared/types.ts index 145db56e..212be032 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -91,6 +91,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 From abbb80d30c37cb733c778130b00753650c097202 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:45:33 +0300 Subject: [PATCH 20/73] =?UTF-8?q?GUI=20=D1=8D=D1=82=D0=B0=D0=BF=202:=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20=D0=9A=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B5=D0=B9=20=D0=BA=D1=80=D0=B0=D1=81=D0=B8=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=B8=D0=B7=20=D1=81=D1=82=D0=BE=D1=80=D0=B0=20?= =?UTF-8?q?=D1=81=D1=80=D0=B0=D0=B7=D1=83=20(=D1=83=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B5=D0=BD=D0=B0=20=D1=83=D1=81=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=88=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE=D0=BF=D1=81?= =?UTF-8?q?=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фидбек: бэкенд-ключи 'не сохраняются после первой загрузки' и грузятся с разнобоем. Корень: ServerKeys инициализировался из пропсы server — устаревшего снимка Dashboard'а, где ещё нет hysteria2Keys/awgConfs, плюс два отдельных эффекта давали каскад. Единый эффект: (1) мгновенная отрисовка из ПЕРСИСТЕНТНОГО стора через servers.get — пропса больше не источник правды; (2) фоновое обновление одним Promise.all: подписка + profiles/backends + awgConf×N параллельно — равный тайминг с vless, без скелетон-мигания при повторных заходах (данные уже в сторе). typecheck 0; vitest — только задокументированный Windows-only shell-command; bash -n чистый --- src/renderer/src/pages/ServerKeys.tsx | 84 ++++++++++++++++----------- 1 file changed, 51 insertions(+), 33 deletions(-) diff --git a/src/renderer/src/pages/ServerKeys.tsx b/src/renderer/src/pages/ServerKeys.tsx index 9b867b2b..f5566404 100644 --- a/src/renderer/src/pages/ServerKeys.tsx +++ b/src/renderer/src/pages/ServerKeys.tsx @@ -46,38 +46,47 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme privateKeyPersisted: server.privateKeyPersisted ?? false, privilegeMode: server.privilegeMode ?? 'root' } - const backendKeysAvailable = shouldAutoConnectServer(server) useEffect(() => { let cancelled = false - if (!server.subscriptionUrl) return - setLoading(true) - window.api.subscription - .fetch(server.id) - .then((result) => { - if (cancelled) return - setKeys(result.keys) - setHysteria2Links(result.hysteria2Links ?? server.hysteria2Keys ?? []) - 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) + // стора нет — остаёмся на пропсе }) - return () => { - cancelled = true - } - }, [server.id]) - useEffect(() => { - let cancelled = false - if (!backendKeysAvailable) return - setBeBusy(true) - ;(async (): Promise => { + // 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 { - // Всё параллельно одним Promise.all — равный тайминг с vless-ключами. const [pl, bs] = await Promise.all([ window.api.profiles.list(server.id, storedAccess), window.api.backends.status(server.id, storedAccess) @@ -88,6 +97,7 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme setBackends(bs) const awgActive = bs.backends.awg?.state === 'active' const targets = awgActive ? list.filter((p) => p.backends?.awg) : [] + // Конфы — тоже параллельно (кэш в сторе делает повторные открытия мгновенными). const confEntries = await Promise.all( targets.map(async (p) => { const conf = await window.api.backends @@ -97,26 +107,34 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme }) ) if (cancelled) return - const merged: Record = { ...(server.awgConfs ?? {}) } - for (const [name, conf] of confEntries) { - if (conf) merged[name] = conf - else delete merged[name] - } - setAwgConfs(merged) + setAwgConfs((current) => { + const merged: Record = { ...current } + for (const [name, conf] of confEntries) { + if (conf) merged[name] = conf + else delete merged[name] + } + return merged + }) } catch { if (!cancelled) { setProfiles([]) setBackends(null) } - } finally { - if (!cancelled) setBeBusy(false) } })() + + 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, backendKeysAvailable]) + }, [server.id]) useEffect(() => { if (!qrUrl) return From 9f8c16ef9d4d7463df0d3a0df9afa1a78534a64f Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:10:56 +0300 Subject: [PATCH 21/73] =?UTF-8?q?GUI=20=D1=8D=D1=82=D0=B0=D0=BF=202:=20AWG?= =?UTF-8?q?-=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=20=E2=80=94=20last-known-?= =?UTF-8?q?good,=20=D1=81=D0=BF=D0=B8=D0=BD=D0=BD=D0=B5=D1=80=20=D0=B2?= =?UTF-8?q?=D0=BD=D1=83=D1=82=D1=80=D0=B8=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B8,=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20sync=20=D1=81=D0=BE=20=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фидбек: AWG-конф 'не сохраняется' + спиннер висит после загрузки под карточкой. - неудачный refetch больше не стирает сохранённый конфиг (last known good: merge только по успешным) - после пакета загрузки — финальная синхронизация состояния со стором (servers.get): состояние == персистентные данные, расхождений нет - спиннер перенесён СТРАНИЦУ -> ВНУТРЬ карточки AWG: показывается только пока у конкретной карточки нет конфига и идёт загрузка; после загрузки ничего лишнего под карточкой не остаётся typecheck 0; vitest — только задокументированный Windows-only shell-command; bash -n чистый --- src/renderer/src/pages/ServerKeys.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/pages/ServerKeys.tsx b/src/renderer/src/pages/ServerKeys.tsx index f5566404..658ce318 100644 --- a/src/renderer/src/pages/ServerKeys.tsx +++ b/src/renderer/src/pages/ServerKeys.tsx @@ -110,11 +110,14 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme setAwgConfs((current) => { const merged: Record = { ...current } for (const [name, conf] of confEntries) { + // Неудачный refetch НЕ стирает сохранённый конфиг (last known good). if (conf) merged[name] = conf - else delete merged[name] } return merged }) + // Финальная синхронизация со стором: состояние == персистентные данные. + const fresh = await window.api.servers.get(server.id) + if (!cancelled && fresh?.awgConfs) setAwgConfs(fresh.awgConfs) } catch { if (!cancelled) { setProfiles([]) @@ -280,6 +283,8 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme
+ ) : beBusy ? ( + ) : (
{t('keys.backendsNotReady')}
)} @@ -287,13 +292,6 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme ) })} - - {beBusy && ( -
- -
- )} - {subscriptionUrl && (
From 7f6821b9f01d7683402708323917154d14a40801 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:29:32 +0300 Subject: [PATCH 22/73] =?UTF-8?q?GUI=20=D1=8D=D1=82=D0=B0=D0=BF=202:=20AWG?= =?UTF-8?q?-=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B5=D1=80=D1=8F=D1=82=D1=81=D1=8F=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D1=81=D1=82=D0=BE=D1=80=D0=B0=20=D0=BC=D0=B3=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D0=BD=D0=BD=D0=BE=20(=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=B3=D0=B5=D0=B9=D1=82=D0=B0=20backends.status)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фидбек: AWG-конфиг появлялся только после SSH-ответа backends.status+profiles (~2 сек), хотя конфиг уже лежит в сторе. Карточки AWG на странице Ключей теперь строятся из Object.keys(awgConfs) стора напрямую — рисуются с первой отрисовкой (мгновенно), SSH-refresh догоняет тихо. Гейт awgActive убран: факт наличия конфига в сторе достаточен (uninstall очищает конфиги через стор). backends.status со страницы Ключей убран совсем — минус одно SSH-звено; profiles.list остаётся для имён granted-профилей без конфига (спиннер внутри карточки). typecheck 0; vitest — только задокументированный Windows-only shell-command; bash -n чистый --- src/renderer/src/pages/ServerKeys.tsx | 43 ++++++++++++++------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/src/renderer/src/pages/ServerKeys.tsx b/src/renderer/src/pages/ServerKeys.tsx index 658ce318..7fba82b6 100644 --- a/src/renderer/src/pages/ServerKeys.tsx +++ b/src/renderer/src/pages/ServerKeys.tsx @@ -3,7 +3,6 @@ import QRCode from 'qrcode' import { Button, Chip, Spinner } from '@heroui/react' import { useTranslation } from 'react-i18next' import type { - BackendStatusResult, Server, ServerProfile, SshAccessInput, @@ -33,7 +32,6 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme // Мультипротокольные бэкенды: hysteria2 приходит из подписки, AWG — по SSH. const [profiles, setProfiles] = useState([]) - const [backends, setBackends] = useState(null) const [beBusy, setBeBusy] = useState(false) // Доступ из сохранённой карточки сервера (секрет — в keychain). @@ -87,17 +85,15 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme const backendP = (async (): Promise => { if (!beReady) return try { - const [pl, bs] = await Promise.all([ - window.api.profiles.list(server.id, storedAccess), - window.api.backends.status(server.id, storedAccess) + const [pl] = await Promise.all([ + window.api.profiles.list(server.id, storedAccess) ]) if (cancelled) return const list = pl.profiles ?? [] setProfiles(list) - setBackends(bs) - const awgActive = bs.backends.awg?.state === 'active' - const targets = awgActive ? list.filter((p) => p.backends?.awg) : [] - // Конфы — тоже параллельно (кэш в сторе делает повторные открытия мгновенными). + // Конфы 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 @@ -119,10 +115,7 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme const fresh = await window.api.servers.get(server.id) if (!cancelled && fresh?.awgConfs) setAwgConfs(fresh.awgConfs) } catch { - if (!cancelled) { - setProfiles([]) - setBackends(null) - } + if (!cancelled) setProfiles([]) } })() @@ -174,8 +167,14 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme return m ? m[1] : '' } - const awgProfiles = profiles.filter((p) => p.backends?.awg) - const awgActive = backends?.backends.awg?.state === 'active' + // Карточки AWG: из стора (мгновенно, до ответа SSH) + granted-профили, у + // которых конфига ещё нет (для них — спиннер внутри карточки). + const awgNames = [ + ...new Set([ + ...Object.keys(awgConfs), + ...profiles.filter((p) => p.backends?.awg).map((p) => p.name) + ]) + ] return (
@@ -239,18 +238,20 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme
))} - {awgProfiles.map((profile) => { - const conf = awgConfs[profile.name] + {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 · {profile.name} + AMNEZIAWG 3.1 · {name}

{t('keys.backendsAwgNote')}

- {awgActive && fields ? ( + {fields ? ( <>
@@ -283,7 +284,7 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme
- ) : beBusy ? ( + ) : granted && beBusy ? ( ) : (
{t('keys.backendsNotReady')}
From 6eeb96ce2ee5f34483087536e3d97d41745c00f5 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:08:06 +0300 Subject: [PATCH 23/73] =?UTF-8?q?GUI=20=D1=8D=D1=82=D0=B0=D0=BF=202:=20?= =?UTF-8?q?=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4=D1=8B=20=E2=80=94=20=D0=B2?= =?UTF-8?q?=D0=B5=D1=81=D1=8C=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=20=D0=B2?= =?UTF-8?q?=D0=BD=D1=83=D1=82=D1=80=D0=B8=20=D0=B1=D0=BE=D0=BA=D1=81=D0=B0?= =?UTF-8?q?,=20=D0=BD=D0=BE=D1=82=D1=8B=20=D0=B2=D0=BE=20=D0=B2=D0=BD?= =?UTF-8?q?=D1=83=D1=82=D1=80=D0=B5=D0=BD=D0=BD=D0=B8=D1=85=20=D0=B7=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BC=D0=BD=D1=91=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4-=D0=B1=D0=BE=D0=BA=D1=81=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фидбек: заголовок/hint секции висели вне бокса, ноты протоколов — просто текстом под статусом. Теперь: заголовок и hint перенесены ВНУТРЬ общего бокса (текста вне боксов нет), описательные ноты Hysteria/Amnezia обёрнуты во внутренний более затемнённый под-бокс (backendInfo, как затемнённые inner-box у ключей) — визуально отделены от действий. Справочно: последняя версия AmneziaWG подтверждена GitHub API — v3.1.20260812 (tools), генерация протокола 3.1; 3.2 не существует. Live-сервер 2.26.125.147 уже на ней. typecheck 0; vitest — только задокументированный Windows-only shell-command; bash -n чистый --- .../src/pages/ServerSettings.module.css | 9 +- src/renderer/src/pages/ServerSettings.tsx | 244 +++++++++--------- 2 files changed, 132 insertions(+), 121 deletions(-) diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index b91b2b8f..f19cfc4e 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -176,8 +176,15 @@ color: var(--success, #2fb344); } +.backendInfo { + background: var(--bg-1, rgba(127, 127, 127, 0.1)); + border-radius: 10px; + padding: 10px 12px; + margin-bottom: 10px; +} + .backendNote { - margin: 0 0 10px; + margin: 0; font-size: 12px; color: var(--text-2); line-height: 1.5; diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index a923fdeb..fab266a1 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -1049,136 +1049,140 @@ export function ServerSettings({
-

{t('settings.backendsTitle')}

-

{t('settings.backendsHint')}

- {backendsError !== null ? ( -
+
+

{t('settings.backendsTitle')}

+

{t('settings.backendsHint')}

+ {backendsError !== null ? (

{t('settings.backendsUnsupported')}

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

{t('settings.backendsHystNote')}

-
- {hyst?.installed ? ( - <> - - + + + ) : ( + - - ) : ( - - )} + )} +
-
-
-
-
- -
-
-
{t('settings.backendsAwg')}
-
{t('settings.backendsAwgSub')}
+
+
+
+ +
+
+
{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 ? ( - <> - -
+
+

{t('settings.backendsAwgNote')}

+
+
+ {awgEntry?.installed ? ( + <> + + + + ) : ( + - - ) : ( - - )} + )} +
-
-
- )} + + )} +
)} From 75af549b657afa1fc3b0f5166a73025b6bc18ae0 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:25:05 +0300 Subject: [PATCH 24/73] =?UTF-8?q?GUI=20=D1=8D=D1=82=D0=B0=D0=BF=202:=20?= =?UTF-8?q?=D0=B5=D0=B4=D0=B8=D0=BD=D0=B0=D1=8F=20=D0=B2=D0=BD=D1=83=D1=82?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D0=BD=D1=8F=D1=8F=20=D0=B2=D1=8B=D0=B5=D0=BC?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BD=D0=B0=20=D0=BE=D0=B1=D0=B0=20=D0=B1=D1=8D?= =?UTF-8?q?=D0=BA=D0=B5=D0=BD=D0=B4=D0=B0=20(=D0=BF=D0=BE=20=D1=84=D0=B8?= =?UTF-8?q?=D0=B4=D0=B1=D0=B5=D0=BA=D1=83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вместо двух маленьких под-боксов под ноты — ОДНА большая затемнённая выемка (backendsInner, стиль inner-box ключей), в которой лежат оба блока протоколов: сверху заголовок/hint секции, под ними выемка с Hysteria и AmneziaWG один под другим. Ноты — обычным текстом внутри выемки. Внешнее округление — существующий сероватый listCard. typecheck 0; vitest — только задокументированный Windows-only shell-command; bash -n чистый --- .../src/pages/ServerSettings.module.css | 16 +- src/renderer/src/pages/ServerSettings.tsx | 212 +++++++++--------- 2 files changed, 117 insertions(+), 111 deletions(-) diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index f19cfc4e..4f2ca3cd 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -176,11 +176,19 @@ color: var(--success, #2fb344); } -.backendInfo { +/* Единая внутренняя выемка на ОБА бэкенда — как затемнённые inner-box ключей. */ +.backendsInner { background: var(--bg-1, rgba(127, 127, 127, 0.1)); - border-radius: 10px; - padding: 10px 12px; - margin-bottom: 10px; + border-radius: 12px; + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 18px; +} + +.backendBlock { + display: flex; + flex-direction: column; } .backendNote { diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index fab266a1..02a6ad3a 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -1061,123 +1061,121 @@ export function ServerSettings({ ) : ( <> -
-
-
- +
+
+
+
+ +
+
+
{t('settings.backendsHyst')}
+
{t('settings.backendsHystSub')}
+
-
-
{t('settings.backendsHyst')}
-
{t('settings.backendsHystSub')}
+
+ {hyst?.installed + ? `${t('settings.backendsActive')} · UDP ${hyst.port ?? '—'} · ${ + hyst.version ?? '' + }` + : t('settings.backendsNotInstalled')}
-
-
- {hyst?.installed - ? `${t('settings.backendsActive')} · UDP ${hyst.port ?? '—'} · ${ - hyst.version ?? '' - }` - : t('settings.backendsNotInstalled')} -
-

{t('settings.backendsHystNote')}

-
-
- {hyst?.installed ? ( - <> - + + + ) : ( + - - - ) : ( - - )} + )} +
-
-
-
-
- +
+
+
+ +
+
+
{t('settings.backendsAwg')}
+
{t('settings.backendsAwgSub')}
+
-
-
{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')}
-
-
- {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 ? ( - <> - + + + ) : ( + - - - ) : ( - - )} + )} +
From 8df3ee27b2a7c79e473f111de3ad5b636a968274 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:08:27 +0300 Subject: [PATCH 25/73] =?UTF-8?q?=D0=94=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F:=20=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D1=82=20=D1=81=20=D1=8D=D1=82=D0=B0=D0=BF?= =?UTF-8?q?=D0=BE=D0=BC=202=20=D0=BC=D1=83=D0=BB=D1=8C=D1=82=D0=B8=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=D0=B0=20(CLI-=D1=82?= =?UTF-8?q?=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=8B,=20GUI,=20=D0=B4=D0=B8?= =?UTF-8?q?=D0=B0=D0=B3=D0=BD=D0=BE=D1=81=D1=82=D0=B8=D0=BA=D0=B0=20AWG)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - configuration.md (en/ru/zh): в CLI-таблицах отсутствовали команды, которые есть в диспетчере: hysteria2-subbody, hysteria2-link, awg-31; GUI-секции дополнены панелью бэкендов (установка/ключи/килл-свитч/3.1) - desktop-gui.md (en/ru/zh): страница «Ключи» — карточки бэкендов из персистентного стора (Endpoint/Address полями, QR без служебной шапки, параллельная загрузка); Server Settings — пул бэкенд-действий; блок backend-команд в списке exposed - troubleshooting.md (en/ru/zh): в секции «нет latest handshake» — диагностика сетевого пути: AWG-сокет в ядре невидим для ss, tcpdump -l (без -l блочный буфер), пустой захват = оператор/DPI, пакеты есть а handshake нет = версия приложения или awg-31 --off fallback. Отработано на живом 2.26.125.147 (пробы РФ->45467 дошли) - ru configuration.md: починена оборванная фраза «предоставляет / и группы» - CLAUDE.md: CLI-список Hysteria 2 дополнен subbody/link --- CLAUDE.md | 2 +- docs/configuration.md | 8 +++++++- docs/desktop-gui.md | 14 ++++++++++++-- docs/ru/configuration.md | 7 ++++++- docs/ru/desktop-gui.md | 14 ++++++++++++-- docs/ru/troubleshooting.md | 15 +++++++++++++++ docs/troubleshooting.md | 16 ++++++++++++++++ docs/zh-CN/configuration.md | 7 +++++-- docs/zh-CN/desktop-gui.md | 14 ++++++++++++-- docs/zh-CN/troubleshooting.md | 13 +++++++++++++ 10 files changed, 99 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 603d9e81..7807fc72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,7 +151,7 @@ Apart from the interactive menu (`sudo xrayebator`), the script exposes subcomma - `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` — 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`. +- `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), `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 follow the AWG spec: Jc 1..128, Jmin '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` при перенаправлении в +файл блочно-буферизует вывод — ранние пакеты могут не появиться до сброса буфера. + ## Установка AWG падает - Основной путь — PPA `amnezia/ppa`; на сериях дистрибутива без сборок PPA Xrayebator уходит в diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 87db301d..628e07ab 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -270,6 +270,22 @@ Then split the debug zones: `awg show awg0` on the server — if the peer is lis 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. + ## AWG install fails - The primary path is the `amnezia/ppa` PPA; on distribution series without PPA builds Xrayebator diff --git a/docs/zh-CN/configuration.md b/docs/zh-CN/configuration.md index e9f28601..b448d1c3 100644 --- a/docs/zh-CN/configuration.md +++ b/docs/zh-CN/configuration.md @@ -126,11 +126,14 @@ Xrayebator 不会更改主机的 TCP 拥塞控制算法,也不会写入或应 | `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 端口、符合 AWG 规范的 junk 参数、`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` | 移除服务与配置 | @@ -155,8 +158,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` 键中。构建与运行: diff --git a/docs/zh-CN/desktop-gui.md b/docs/zh-CN/desktop-gui.md index cd396968..75730902 100644 --- a/docs/zh-CN/desktop-gui.md +++ b/docs/zh-CN/desktop-gui.md @@ -44,7 +44,7 @@ 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://` 链接,复制+二维码),以及一张 AmneziaWG 卡片——客户端 `.conf`,其中 `Endpoint`/`Address` 以键值字段呈现,去掉服务注释头的完整 `.conf` 以文本和二维码提供,可导入 AmneziaVPN/AmneziaWG。后端卡片立即从持久化存储渲染,并与 VLESS 线路并行刷新;spinner 位于卡片内部,仅在数据获取期间可见。 ### Server settings @@ -57,7 +57,9 @@ Server settings 先通过 SSH 认证。如果卡片中已有系统钥匙串保 - 修改配置档线路的端口,或选择随机端口; - 更新服务器安装; - 确认后卸载服务器安装; -- 在明确确认后重置固定的 SSH host key。 +- 在明确确认后重置固定的 SSH host key; +- 管理多协议后端(与菜单项 11–13 对齐):合并面板显示 Hysteria 2 与 AmneziaWG 状态(版本、端口、TLS 模式 / 接口、junk 摘要),提供安装/卸载、订阅中 `hysteria2://` 行的总开关和 AWG 3.1 切换——后两者均带明确确认; +- 按配置档签发后端密钥:「密钥」操作打开对话框,包含 Hysteria 2 链接(文本+二维码)与 AmneziaWG 客户端 `.conf`(文本+二维码,`Endpoint`/`Address` 单独成字段);首次打开对话框时按需在服务器上创建授权。 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/troubleshooting.md b/docs/zh-CN/troubleshooting.md index ff609ec5..b5931da5 100644 --- a/docs/zh-CN/troubleshooting.md +++ b/docs/zh-CN/troubleshooting.md @@ -189,6 +189,19 @@ AmneziaWG 对参数不匹配保持沉默:peer 只是永远不出现握手。 然后区分调试区域:服务器上的 `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` 会按块 +缓冲输出——早期数据包在缓冲区刷新前可能不可见。 + ## AWG 安装失败 - 首选路径是 `amnezia/ppa` PPA;在没有对应发行版构建的系列上,Xrayebator 回退到源码编译。内核 From 72b41301d7491986631f4186f7f14506dab5f9d5 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:57:30 +0300 Subject: [PATCH 26/73] =?UTF-8?q?GUI:=20=D1=8D=D0=BA=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=20AWG-=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=20=D0=B2=20?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=D0=BD=D0=BE=D0=BC=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=20vpn://=20=D0=B4=D0=BB=D1=8F=20AmneziaVPN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Корень: телефон шлёт junk с собственными параметрами приложения (5 пакетов/попытку при наших Jc=10 — tcpdump 2.26.125.147), т.е. сырой .conf при импорте в AmneziaVPN теряет junk-группу. По исходникам amnezia-client (importController.cpp) правильная доставка — контейнер amnezia-awg с last_config, куда импортёр переносит все AWG-ключи (awgProtocolKeys); сырой текст не гарантирует этого пути. - shared/awg.ts: parseAwgConfMap + buildAwgVpnConfig + buildAwgVpnUrl — чистый Builder структуры extractWireGuardConfig (hostName/port/ client_priv_key/psk_key/server_pub_key/allowed_ips + AWG-ключи), vpn:// + base64url без qCompress (импортёр деликатно принимает несжатое тело — проверено по исходникам dev-ветки) - Ключи (ServerKeys) и диалог ключей (ServerSettings): вторая кнопка QR «AmneziaVPN» рядом с QR для AmneziaWG; чип без жёсткой версии - i18n ru/en/zh: keys.backendsQrVpn - vitest: 7 тестов (структура, base64url-чистота, кириллица, минимум) --- src/renderer/src/i18n/en.json | 3 +- src/renderer/src/i18n/ru.json | 3 +- src/renderer/src/i18n/zh.json | 3 +- src/renderer/src/pages/ServerKeys.tsx | 11 +- src/renderer/src/pages/ServerSettings.tsx | 13 ++- src/shared/awg.ts | 118 +++++++++++++++++++++- tests/unit/awg-shared.test.ts | 92 +++++++++++++++++ 7 files changed, 235 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 7fe83568..c399df48 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -134,7 +134,8 @@ "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" + "backendsAddress": "Address", + "backendsQrVpn": "QR · AmneziaVPN" }, "settings": { "title": "Server profiles", diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index c412bf21..e4f1602a 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -135,7 +135,8 @@ "backendsNotReady": "Бэкенд ещё не активен или конфиг недоступен — проверьте статус в настройках сервера.", "backendsNoAccess": "Для ключей AmneziaWG нужен сохранённый доступ к серверу (пароль/ключ в keychain) — зайдите в Server Settings один раз.", "backendsEndpoint": "Endpoint", - "backendsAddress": "Адрес" + "backendsAddress": "Адрес", + "backendsQrVpn": "QR · AmneziaVPN" }, "settings": { "title": "Профили сервера", diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index 8824dbe1..a6e2b7ba 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -133,7 +133,8 @@ "backendsNotReady": "后端尚未激活或配置不可用——请在服务器设置中检查状态。", "backendsNoAccess": "AmneziaWG 密钥需要已保存的服务器访问凭据(keychain 中的密码/密钥)——请先打开 Server Settings。", "backendsEndpoint": "Endpoint", - "backendsAddress": "地址" + "backendsAddress": "地址", + "backendsQrVpn": "QR · AmneziaVPN" }, "settings": { "title": "服务器配置文件", diff --git a/src/renderer/src/pages/ServerKeys.tsx b/src/renderer/src/pages/ServerKeys.tsx index 7fba82b6..56a3c9b4 100644 --- a/src/renderer/src/pages/ServerKeys.tsx +++ b/src/renderer/src/pages/ServerKeys.tsx @@ -9,7 +9,7 @@ import type { VlessLink } from '@shared/types' import { vlessPort } from '@shared/vless' -import { parseAwgClientConf, stripAwgComments } from '@shared/awg' +import { buildAwgVpnUrl, parseAwgClientConf, stripAwgComments } from '@shared/awg' import { shouldAutoConnectServer } from './server-access' import styles from './ServerKeys.module.css' @@ -247,7 +247,7 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme
- AMNEZIAWG 3.1 · {name} + AMNEZIAWG · {name}

{t('keys.backendsAwgNote')}

@@ -282,6 +282,13 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme > {t('keys.qr')} +
) : granted && beBusy ? ( diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index 02a6ad3a..5293de4d 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -30,7 +30,7 @@ import type { } from '@shared/types' import { describeExpire, isFutureDate, presetDate } from '@shared/expire' import { todayIso } from '@shared/calendar' -import { parseAwgClientConf, stripAwgComments } from '@shared/awg' +import { buildAwgVpnUrl, parseAwgClientConf, stripAwgComments } from '@shared/awg' import { isSshAccessReady, SshAccessForm } from '../components/SshAccessForm' import { CalendarPicker } from '../components/CalendarPicker' import { shouldAutoConnectServer } from './server-access' @@ -1252,7 +1252,7 @@ export function ServerSettings({
- AMNEZIAWG 3.1 · UDP + AMNEZIAWG · UDP

{t('settings.beKeysAwgNote')}

@@ -1288,6 +1288,15 @@ export function ServerSettings({ > {t('keys.qr')} +
) : keysAwgErr ? ( diff --git a/src/shared/awg.ts b/src/shared/awg.ts index bb4ba51a..9eebefe2 100644 --- a/src/shared/awg.ts +++ b/src/shared/awg.ts @@ -4,9 +4,10 @@ * а не простыню текста; полный текст нужен для QR и копирования. */ -/** Убирает комментарии-шапку — часть приложений спотыкается о строки до [Interface]. */ +/** Убирает комментарии-шапку и нормализует CRLF — часть приложений спотыкается о строки до [Interface]. */ export function stripAwgComments(conf: string): string { return conf + .replace(/\r\n/g, '\n') .split('\n') .filter((line) => !line.trim().startsWith('#')) .join('\n') @@ -25,3 +26,118 @@ export function parseAwgClientConf(conf: string): AwgClientKeyFields { 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 + +/** + * Строит JSON-объект в родном для AmneziaVPN формате (контейнер amnezia-awg + * с last_config) — структура повторяет extractWireGuardConfig из importController: + * только в этом виде приложение гарантированно переносит junk-параметры в туннель. + */ +export function buildAwgVpnConfig( + conf: string, + description?: string +): Record { + const map = parseAwgConfMap(stripAwgComments(conf)) + const endpoint = map['Endpoint'] ?? '' + const [host = '', port = ''] = endpoint.split(':') + + const lastConfig: Record = { + config: stripAwgComments(conf), + hostName: host, + port: Number(port) || 51820, + } + if (map['PrivateKey']) lastConfig['client_priv_key'] = map['PrivateKey'] + if (map['Address']) lastConfig['client_ip'] = map['Address'] + const psk = map['PresharedKey'] ?? map['PreSharedKey'] + if (psk) lastConfig['psk_key'] = psk + if (map['PublicKey']) lastConfig['server_pub_key'] = map['PublicKey'] + if (map['MTU']) lastConfig['mtu'] = map['MTU'] + if (map['PersistentKeepalive']) lastConfig['persistent_keep_alive'] = map['PersistentKeepalive'] + if (map['AllowedIPs']) { + lastConfig['allowed_ips'] = map['AllowedIPs'] + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + } + for (const key of AWG_PROTOCOL_KEYS) { + if (map[key]) lastConfig[key] = map[key] + } + + const container: Record = { + container: 'amnezia-awg', + awg: { + last_config: JSON.stringify(lastConfig), + isThirdPartyConfig: true, + port, + transport_proto: 'udp', + }, + } + + const root: Record = { + containers: [container], + defaultContainer: 'amnezia-awg', + 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 +} + +function toBase64Url(json: string): string { + const bytes = new TextEncoder().encode(json) + let bin = '' + for (const b of bytes) bin += String.fromCharCode(b) + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** + * Родная ссылка-импорт для AmneziaVPN: vpn:// + base64url(JSON). + * Без qCompress — импортёр деликатно принимает и несжатое тело + * (qUncompress возвращает пусто → используется сырой base64). + */ +export function buildAwgVpnUrl(conf: string, description?: string): string { + return 'vpn://' + toBase64Url(JSON.stringify(buildAwgVpnConfig(conf, description))) +} diff --git a/tests/unit/awg-shared.test.ts b/tests/unit/awg-shared.test.ts index b9ea2711..174982c4 100644 --- a/tests/unit/awg-shared.test.ts +++ b/tests/unit/awg-shared.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' import { + buildAwgVpnConfig, + buildAwgVpnUrl, parseAwgClientConf, + parseAwgConfMap, stripAwgComments } from '../../src/shared/awg' @@ -41,3 +44,92 @@ describe('parseAwgClientConf', () => { 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 = 10', + 'Jmin = 130', + 'Jmax = 452', + 'S1 = 103', + 'S2 = 72', + 'H1 = 464563321', + 'H2 = 722945769', + 'H3 = 789953666', + 'H4 = 191492462', + '', + '[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('10') + 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-awg и полным last_config', () => { + const url = buildAwgVpnUrl(FULL_CONF, 'happ') + 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) + + const payload = JSON.parse( + Buffer.from(payload64, 'base64').toString('utf8') + ) as Record + expect(payload['defaultContainer']).toBe('amnezia-awg') + 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-awg') + const awg = containers[0]['awg'] as Record + expect(awg['isThirdPartyConfig']).toBe(true) + expect(awg['transport_proto']).toBe('udp') + + const last = JSON.parse(awg['last_config'] as string) as Record + expect(last['Jc']).toBe('10') + expect(last['client_priv_key']).toBe('PRIVKEYBASE64=') + 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('#') + }) + + it('выживает кириллицу в имени профиля (UTF-8 → base64url)', () => { + const url = buildAwgVpnUrl(FULL_CONF, 'Тест-профиля') + const payload = JSON.parse( + Buffer.from(url.slice(6), 'base64').toString('utf8') + ) as Record + expect(payload['description']).toBe('Тест-профиля') + }) + + it('возводит структуру без AWG-ключей (чистый conf)', () => { + 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-awg') + expect('HeaderProtectionKey' in JSON.parse(((cfg['containers'] as Array>)[0]['awg'] as Record)['last_config'] as string)).toBe(false) + }) +}) From 113ca124f34951cfd8d46832070263371c457c89 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:50:02 +0300 Subject: [PATCH 27/73] =?UTF-8?q?GUI:=20protocol=5Fversion=20=D0=B2=20vpn:?= =?UTF-8?q?//=20=E2=80=94=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=81=D1=87=D0=B8=D1=82=D0=B0=D0=BB=D0=BE?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=20legacy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit error 1000 + предупреждение «старая версия»: last_config без явной версии протокола AmneziaVPN трактует как legacy и запускает туннель не тем поколением. Добавлен protocol_version в last_config и контейнер (значения из их protocolConstants: 1.5/2/3.1), версия выводится из содержимого конфига: HPK/RandomTrailers -> 3.1, I1-I5/ContentPadding -> 2, иначе 1.5. detectAwgVersion экспортирован и протестирован. --- src/shared/awg.ts | 14 ++++++++++++++ tests/unit/awg-shared.test.ts | 16 ++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/shared/awg.ts b/src/shared/awg.ts index 9eebefe2..46a195cd 100644 --- a/src/shared/awg.ts +++ b/src/shared/awg.ts @@ -66,6 +66,17 @@ const AWG_PROTOCOL_KEYS = [ 'DisableCookies', ] as const +/** + * Версия протокола AWG по содержимому конфига (значения из protocolConstants + * AmneziaVPN: awgV1_5/awgV2/awgV3). Приложение без явной версии считает конфиг + * legacy («старая версия») и запускает туннель не тем поколением параметров. + */ +export function detectAwgVersion(map: Record): string { + if (map['HeaderProtectionKey'] || map['RandomTrailers']) return '3.1' + if (['I1', 'I2', 'I3', 'I4', 'I5', 'ContentPaddingAddition'].some((k) => map[k])) return '2' + return '1.5' +} + /** * Строит JSON-объект в родном для AmneziaVPN формате (контейнер amnezia-awg * с last_config) — структура повторяет extractWireGuardConfig из importController: @@ -100,6 +111,8 @@ export function buildAwgVpnConfig( for (const key of AWG_PROTOCOL_KEYS) { if (map[key]) lastConfig[key] = map[key] } + const version = detectAwgVersion(map) + lastConfig['protocol_version'] = version const container: Record = { container: 'amnezia-awg', @@ -108,6 +121,7 @@ export function buildAwgVpnConfig( isThirdPartyConfig: true, port, transport_proto: 'udp', + protocol_version: version, }, } diff --git a/tests/unit/awg-shared.test.ts b/tests/unit/awg-shared.test.ts index 174982c4..c8453cd1 100644 --- a/tests/unit/awg-shared.test.ts +++ b/tests/unit/awg-shared.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { buildAwgVpnConfig, buildAwgVpnUrl, + detectAwgVersion, parseAwgClientConf, parseAwgConfMap, stripAwgComments @@ -109,6 +110,21 @@ describe('buildAwgVpnUrl', () => { expect(last['port']).toBe(45467) expect(last['allowed_ips']).toEqual(['0.0.0.0/0', '::/0']) expect(last['config']).not.toContain('#') + expect(last['protocol_version']).toBe('1.5') + }) + + it('определяет версию 3.1 по HPK/RandomTrailers и 2 по I1', () => { + expect(detectAwgVersion({ HeaderProtectionKey: 'x=', RandomTrailers: 'on' })).toBe('3.1') + expect(detectAwgVersion({ I1: '' })).toBe('2') + expect(detectAwgVersion({ Jc: '10' })).toBe('1.5') + const conf31 = FULL_CONF + '\nHeaderProtectionKey = HPK=\nRandomTrailers = on' + const cfg = buildAwgVpnConfig(conf31) + const awg = (cfg['containers'] as Array>)[0]['awg'] as Record< + string, + unknown + > + expect(awg['protocol_version']).toBe('3.1') + expect(JSON.parse(awg['last_config'] as string)['protocol_version']).toBe('3.1') }) it('выживает кириллицу в имени профиля (UTF-8 → base64url)', () => { From 4a0ceb3516d59bae8f0756d07e29bcfc861883d0 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:06:41 +0300 Subject: [PATCH 28/73] =?UTF-8?q?GUI:=20vpn://=20=D0=B2=20=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=BD=D0=BE=D0=BC=20shape=20AmneziaWG-=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D0=B9=D0=BD=D0=B5=D1=80=D0=B0=20(server-level=20ju?= =?UTF-8?q?nk=20+=20amnezia-awg2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По исходникам amnezia-client (containerUtils.cpp + AwgProtocolConfig::toJson): их формат кладёт серверные junk-параметры (Jc/S1/H1/HPK/...) на уровень объекта 'awg' рядом с last_config (модель сервера читает их оттуда), специальный junk I1..I5 пишет безусловно (пустые строки валидны), а новый контейнер называется 'amnezia-awg2'. Наш прошлый формат с junk только внутри last_config оставлял серверную часть модели пустой, из-за чего сервис терял client_ip (error 1000, подтверждено логом телефона). --- src/shared/awg.ts | 33 +++++++++++++++++++++++++++------ tests/unit/awg-shared.test.ts | 10 +++++++--- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/shared/awg.ts b/src/shared/awg.ts index 46a195cd..8d2cb404 100644 --- a/src/shared/awg.ts +++ b/src/shared/awg.ts @@ -114,20 +114,41 @@ export function buildAwgVpnConfig( const version = detectAwgVersion(map) lastConfig['protocol_version'] = version + // Серверные поля — НА УРОВНЕ объекта "awg" (как AwgServerConfig::toJson в + // amnezia-client): модель сервера читает их отсюда, а не из last_config. + // I1..I5 их toJson пишет безусловно (пустые строки — валидно). + 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'] + const container: Record = { - container: 'amnezia-awg', + container: 'amnezia-awg2', awg: { - last_config: JSON.stringify(lastConfig), + ...serverLevel, isThirdPartyConfig: true, - port, - transport_proto: 'udp', - protocol_version: version, + last_config: JSON.stringify(lastConfig), }, } const root: Record = { containers: [container], - defaultContainer: 'amnezia-awg', + defaultContainer: 'amnezia-awg2', description: description ?? 'AmneziaWG', hostName: host, } diff --git a/tests/unit/awg-shared.test.ts b/tests/unit/awg-shared.test.ts index c8453cd1..5ce1e14a 100644 --- a/tests/unit/awg-shared.test.ts +++ b/tests/unit/awg-shared.test.ts @@ -92,13 +92,17 @@ describe('buildAwgVpnUrl', () => { const payload = JSON.parse( Buffer.from(payload64, 'base64').toString('utf8') ) as Record - expect(payload['defaultContainer']).toBe('amnezia-awg') + 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-awg') + expect(containers[0]['container']).toBe('amnezia-awg2') const awg = containers[0]['awg'] as Record + // серверные junk-поля дублируются на уровне awg-объекта (AwgServerConfig::toJson) + expect(awg['Jc']).toBe('10') + expect(awg['S1']).toBe('103') + expect(awg['I1']).toBe('') expect(awg['isThirdPartyConfig']).toBe(true) expect(awg['transport_proto']).toBe('udp') @@ -145,7 +149,7 @@ describe('buildAwgVpnUrl', () => { 'Endpoint = 1.2.3.4:51820' ].join('\n') const cfg = buildAwgVpnConfig(minimal) - expect(cfg['defaultContainer']).toBe('amnezia-awg') + expect(cfg['defaultContainer']).toBe('amnezia-awg2') expect('HeaderProtectionKey' in JSON.parse(((cfg['containers'] as Array>)[0]['awg'] as Record)['last_config'] as string)).toBe(false) }) }) From 2c9198bb4898c6ca184ef9bce54d2ea47254bca8 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:19:12 +0300 Subject: [PATCH 29/73] =?UTF-8?q?GUI:=20vpn://=201-=D0=B2-1=20=D1=81=20?= =?UTF-8?q?=D1=8D=D0=BA=D1=81=D0=BF=D0=BE=D1=80=D1=82=D0=BE=D0=BC=20Amnezi?= =?UTF-8?q?aVPN=20(range-=D1=82=D0=B0=D0=B9=D0=BC=D0=B5=D1=80=D1=8B,=20cli?= =?UTF-8?q?ent=5Fpub,=20qCompress)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Эталон — реальный экспорт рабочего docker-сервера (LV): серверные junk на уровне awg + range-таймеры go-туннеля (Rekey/Keepalive/Reject/Max- Handshake 100-120/3-7/150-180/5-15/15-20) + ContentPaddingAddition в JSON (не kernel), client_ip без маски, client_pub_key+clientId в last_config, subnet_address, persistent_keep_alive, qCompress-payload. Симуляция конвейера 5.0.1.5 (import→model→service-config) подтвердила прохождение client_ip; error 1000 у пользователя — следствие отсутствия серверной части модели в прошлых форматах. --- src/shared/awg.ts | 76 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 55 insertions(+), 21 deletions(-) diff --git a/src/shared/awg.ts b/src/shared/awg.ts index 8d2cb404..8920692c 100644 --- a/src/shared/awg.ts +++ b/src/shared/awg.ts @@ -78,45 +78,63 @@ export function detectAwgVersion(map: Record): string { } /** - * Строит JSON-объект в родном для AmneziaVPN формате (контейнер amnezia-awg - * с last_config) — структура повторяет extractWireGuardConfig из importController: - * только в этом виде приложение гарантированно переносит junk-параметры в туннель. + * Строит 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 + description?: string, + opts?: { clientPubKey?: string; mtu?: string } ): Record { const map = parseAwgConfMap(stripAwgComments(conf)) const endpoint = map['Endpoint'] ?? '' const [host = '', port = ''] = endpoint.split(':') + const version = detectAwgVersion(map) + 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 (map['Address']) lastConfig['client_ip'] = map['Address'] + 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['MTU']) lastConfig['mtu'] = map['MTU'] - if (map['PersistentKeepalive']) lastConfig['persistent_keep_alive'] = map['PersistentKeepalive'] 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 (map[key]) lastConfig[key] = map[key] + if (key === 'RandomTrailers') lastConfig[key] = map[key] ?? 'on' + else if (map[key]) lastConfig[key] = map[key] } - const version = detectAwgVersion(map) + // range-таймеры go-туннеля — дефолты Amnezia (не влияют на handshake) + lastConfig['persistent_keep_alive'] = map['PersistentKeepalive'] ?? '25' + if (version === '3.1') { + 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" (как AwgServerConfig::toJson в - // amnezia-client): модель сервера читает их отсюда, а не из last_config. - // I1..I5 их toJson пишет безусловно (пустые строки — валидно). + // серверный уровень "awg" — тот же набор junk + subnet_address (как в экспорте) const serverLevel: Record = { port, transport_proto: 'udp', @@ -136,14 +154,21 @@ export function buildAwgVpnConfig( if (map['HeaderProtectionKey']) serverLevel['HeaderProtectionKey'] = map['HeaderProtectionKey'] if (map['RandomTrailers']) serverLevel['RandomTrailers'] = map['RandomTrailers'] if (map['DisableCookies']) serverLevel['DisableCookies'] = map['DisableCookies'] + if (version === '3.1') { + 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, - isThirdPartyConfig: true, - last_config: JSON.stringify(lastConfig), - }, + awg: serverLevel, } const root: Record = { @@ -170,9 +195,18 @@ function toBase64Url(json: string): string { /** * Родная ссылка-импорт для AmneziaVPN: vpn:// + base64url(JSON). - * Без qCompress — импортёр деликатно принимает и несжатое тело - * (qUncompress возвращает пусто → используется сырой base64). + * GUI строит несжатый payload — импортёр принимает и его (фоллбек + * extractConfigFromData при неудачном qUncompress). Сжатый вариант + * (qCompress: 4 байта BE-размера + zlib) выдаёт build-vpn-url.mjs. */ -export function buildAwgVpnUrl(conf: string, description?: string): string { - return 'vpn://' + toBase64Url(JSON.stringify(buildAwgVpnConfig(conf, description))) +export function buildAwgVpnUrl( + conf: string, + description?: string, + opts?: { clientPubKey?: string; mtu?: string } +): string { + const json = JSON.stringify(buildAwgVpnConfig(conf, description, opts)) + const bytes = new TextEncoder().encode(json) + let bin = '' + for (const b of bytes) bin += String.fromCharCode(b) + return 'vpn://' + btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } From de9319782eaa90371d577908fc648871f0fa2c9d Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:40:20 +0300 Subject: [PATCH 30/73] =?UTF-8?q?AWG:=20junk-=D0=B4=D0=B8=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=20=D0=BE=D0=B4=D0=B8=D0=BD=20=D0=B2=20=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD=20=D1=81=20Amnezia=20+=20=D1=81=D0=B8=D0=BD?= =?UTF-8?q?=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Живое подтверждение (телефон, LTE, AmneziaVPN 5.0.1.5): наш кастомный junk (Jc=10, крупные пакеты 130-452, случайные H1-H4) go-туннель приложения применяет не целиком — всплески по 5 пакетов против ожидаемых ~11, handshake не собирается. LV-сервер Amnezia с дефолтным диалектом с того же телефона работает. - _awg_gen_junk: Jc=5, Jmin=10, Jmax=50, H1-H4=1..4 (WG-магия), S1/S2 rand 12..150, S3/S4 rand 12..64 (лимиты их валидатора), уникальность и S1+56≠S2 сохранены; HPK/RandomTrailers без изменений (3.1-маскировка полная) - test-awg-lifecycle: ассерты на дефолтный диалект Amnezia - docs en/ru/zh: junk = дефолты Amnezia; awg-install row; testing.md; новый кейс troubleshooting «AmneziaVPN error 1000 / No value for client_ip» (нативный shape vpn://, QR · AmneziaVPN); CLAUDE.md Своя логика осталась там, где ей место: Hysteria 2 и VLESS/Reality. AWG теперь = 1:1 с Amnezia + наши HPK/RT и выдача ключей через GUI. --- CLAUDE.md | 2 +- docs/configuration.md | 16 +++++++++++----- docs/ru/configuration.md | 14 ++++++++++---- docs/ru/testing.md | 2 +- docs/ru/troubleshooting.md | 11 +++++++++++ docs/testing.md | 2 +- docs/troubleshooting.md | 11 +++++++++++ docs/zh-CN/configuration.md | 14 +++++++++----- docs/zh-CN/testing.md | 2 +- docs/zh-CN/troubleshooting.md | 10 ++++++++++ validation/test-awg-lifecycle.sh | 13 ++++++------- xrayebator | 31 ++++++++++++++----------------- 12 files changed, 86 insertions(+), 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7807fc72..e1c14cd5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,7 +153,7 @@ Apart from the interactive menu (`sudo xrayebator`), the script exposes subcomma - `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), `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 follow the AWG spec: Jc 1..128, Jmin&1; exec 1>&2 ... exec 1>&3`). Keep it that way when editing. diff --git a/docs/configuration.md b/docs/configuration.md index 0602cc3a..19648aaf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -140,7 +140,7 @@ is a client-side profile/route setting; changing it does not restart Xray or alt | `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 per the AWG spec, `ip_forward` + MASQUERADE; prints JSON | +| `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 | @@ -267,14 +267,20 @@ parser objects. `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 are generated per the AWG spec: -`Jc` 1..128, `Jmin < Jmax ≤ 1280`, `S1`/`S2` 15..150 with `S1+56 ≠ S2`, `H1`–`H4` unique in -5..2147483647. Each profile peer gets a keypair, a preshared key and the first free address; the +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. Peer changes restart the interface with +`.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/ТСПУ diff --git a/docs/ru/configuration.md b/docs/ru/configuration.md index d8705aa9..3ea0e7a9 100644 --- a/docs/ru/configuration.md +++ b/docs/ru/configuration.md @@ -143,7 +143,7 @@ legacy-файлы и блоки, ранее созданные Xrayebator, и с | `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-параметры по спеке AWG, `ip_forward` + MASQUERADE; JSON | +| `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 | @@ -245,14 +245,20 @@ generic); флаг `sub_body` в реестре — выключатель на `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-параметры генерируются по спеке AWG: `Jc` 1..128, -`Jmin < Jmax ≤ 1280`, `S1`/`S2` 15..150 с `S1+56 ≠ S2`, `H1`–`H4` уникальные в 5..2147483647. +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 и выдачей гранта -в один клик. Изменения peer-ов перезапускают интерфейс с откатом — короткий обрыв туннеля для +в один клик. Код «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/ТСПУ diff --git a/docs/ru/testing.md b/docs/ru/testing.md index 7ca5a9d5..c815805c 100644 --- a/docs/ru/testing.md +++ b/docs/ru/testing.md @@ -39,7 +39,7 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done | `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, pure-строитель `hysteria2://`, проводка диспетчера и подписки | -| `test-awg-lifecycle.sh` | Бэкенд AmneziaWG: диапазоны junk-параметров по спеке, генерация ключей, рендер серверного конфига (PostUp MASQUERADE, без peer-ов при установке), аллокация адресов, содержимое клиентского `.conf`, события revoke/expire/restore, диспетчер CLI | +| `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, диспетчер 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 | diff --git a/docs/ru/troubleshooting.md b/docs/ru/troubleshooting.md index be83135d..aa4ade93 100644 --- a/docs/ru/troubleshooting.md +++ b/docs/ru/troubleshooting.md @@ -261,6 +261,17 @@ UDP 443 проходит, а высокий случайный UDP-порт мо переимпортируйте для проверки 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 уходит в diff --git a/docs/testing.md b/docs/testing.md index f632ca32..643668a3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -54,7 +54,7 @@ promise that installer and updater paths behave identically. | `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, pure `hysteria2://` builder, CLI dispatch and subscription wiring | -| `test-awg-lifecycle.sh` | AmneziaWG backend: junk-parameter spec ranges, key generation, server conf renderer (PostUp MASQUERADE, no peers at install), peer address allocation, client `.conf` contents, revoke/expire/restore events, CLI dispatch | +| `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, 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 | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 628e07ab..8a62ef40 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -286,6 +286,17 @@ packets but `awg show` still reports no handshake → the client is sending garb 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 diff --git a/docs/zh-CN/configuration.md b/docs/zh-CN/configuration.md index b448d1c3..c00c9099 100644 --- a/docs/zh-CN/configuration.md +++ b/docs/zh-CN/configuration.md @@ -128,7 +128,7 @@ Xrayebator 不会更改主机的 TCP 拥塞控制算法,也不会写入或应 | `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 端口、符合 AWG 规范的 junk 参数、`ip_forward` + MASQUERADE;JSON | +| `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 | @@ -223,13 +223,17 @@ Hysteria 拒绝空的 userpass 映射。客户端链接形如 `awg-install` 通过 DKMS 编译内核模块(首选 `amnezia/ppa` PPA;回退为源码编译,内核 ≥ 5.6 需要 完整 `linux-source`),并通过 `awg-quick@awg0` 拉起 `awg0` 接口:随机高位 UDP 端口、网段 -`10.8.1.0/24`、`ip_forward` 与默认路由接口上的 MASQUERADE。junk 参数按 AWG 规范生成:`Jc` -1..128、`Jmin < Jmax ≤ 1280`、`S1`/`S2` 15..150 且 `S1+56 ≠ S2`、`H1`–`H4` 在 5..2147483647 内 -互不相同。每个配置档 peer 获得密钥对、预共享密钥与首个空闲地址;客户端 `.conf`(菜单项 12 或 +`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` 以文本+二维码呈现, -并支持一键签发授权。peer 变更会带回滚地重启接口 +并支持一键签发授权。「QR · AmneziaVPN」二维码以应用原生 `vpn://` 形态携带配置(`amnezia-awg2` +容器、容器级服务器 junk 字段、`protocol_version`、压缩 payload)——直接把裸 `.conf` 粘进 +AmneziaVPN 无法完整到达其隧道,因此该应用推荐使用此码;普通二维码保留给独立版 AmneziaWG +客户端。peer 变更会带回滚地重启接口 ——所有 peer 的隧道会短暂中断;授权与吊销是低频操作,第一阶段可接受。 ### AWG 2.0 与 3.x 的差异——以及 3.1 为何对 DPI/ТСПУ 更重要 diff --git a/docs/zh-CN/testing.md b/docs/zh-CN/testing.md index 90bcfbd2..fc105012 100644 --- a/docs/zh-CN/testing.md +++ b/docs/zh-CN/testing.md @@ -38,7 +38,7 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done | `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 探测、授权/吊销/过期-恢复事件、纯函数 `hysteria2://` 构造器、CLI 分发与订阅接线 | -| `test-awg-lifecycle.sh` | AmneziaWG 后端:junk 参数规范区间、密钥生成、服务端配置渲染(PostUp MASQUERADE、安装时无 peer)、peer 地址分配、客户端 `.conf` 内容、revoke/expire/restore 事件、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 事件、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 | diff --git a/docs/zh-CN/troubleshooting.md b/docs/zh-CN/troubleshooting.md index b5931da5..4a095c83 100644 --- a/docs/zh-CN/troubleshooting.md +++ b/docs/zh-CN/troubleshooting.md @@ -202,6 +202,16 @@ tcpdump -l -ni 'udp port ' `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 回退到源码编译。内核 diff --git a/validation/test-awg-lifecycle.sh b/validation/test-awg-lifecycle.sh index 2efa5c14..ee5718e6 100644 --- a/validation/test-awg-lifecycle.sh +++ b/validation/test-awg-lifecycle.sh @@ -46,22 +46,21 @@ export XRAYEBATOR_SERVER_ADDR_OVERRIDE="203.0.113.10" # shellcheck disable=SC1091 source ./xrayebator || fail "source ./xrayebator failed" -# 1) junk-параметры: диапазоны и уникальность (5 прогонов), включая 3.1-поля +# 1) junk-параметры: дефолтный диалект Amnezia (совместимость с их мобильными +# клиентами) + уникальность S1..S4 (5 прогонов), включая 3.1-поля for _ in 1 2 3 4 5; do junk=$(_awg_gen_junk) jq -e ' - .Jc >= 1 and .Jc <= 128 and - .Jmin >= 8 and .Jmin < .Jmax and .Jmax <= 1280 and + .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 <= 150 and .S4 >= 12 and .S4 <= 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 >= 5 and .H2 >= 5 and .H3 >= 5 and .H4 >= 5 and - ([.H1, .H2, .H3, .H4] | (length == ([unique[]] | length))) 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 + AWG 3.1 params (S1-S4>=12, HeaderProtectionKey) within spec (5/5 runs)" +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") diff --git a/xrayebator b/xrayebator index ad484f3c..1a05178e 100755 --- a/xrayebator +++ b/xrayebator @@ -2967,27 +2967,24 @@ AWG_SYSCTL_FILE="/etc/sysctl.d/99-xrayebator-awg.conf" AWG_REPO_MODULE="amnezia-vpn/amneziawg-linux-kernel-module" AWG_REPO_TOOLS="amnezia-vpn/amneziawg-tools" -# Случайные параметры AWG, включая 3.1 (HeaderProtectionKey, S3/S4). -# Спека: Jc 1..128 (рек. 4..12), 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" \ From d1948ab76bd2eef3c0b6c87e771bb7f02d45e710 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:57:25 +0300 Subject: [PATCH 31/73] =?UTF-8?q?GUI:=20=C2=ABQR=20=C2=B7=20AmneziaVPN?= =?UTF-8?q?=C2=BB=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=91=D1=82=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=87=D0=B8=D0=B9=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82?= =?UTF-8?q?,=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=203.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Формат, который подключился на телефоне, теперь строит сам GUI: - qCompress-payload (CompressionStream('deflate') + 4 байта BE-размера) — тот же контейнер, что их «Поделиться»; - client_pub_key + clientId из профиля (backends.awg.client_public_key); - protocol_version жёстко 3.1 (detectAwgVersion с ветками 1.5/2 удалён — Xrayebator выдаёт ключи только в 3.1); - range-таймеры и ContentPaddingAddition всегда; - чипы снова «AMNEZIAWG 3.1». server/awg.ts: buildAwgVpnUrl async (renderer: CompressionStream доступен). Тесты: qCompress-roundtrip через zlib inflate, 8/8; typecheck чистый. Доки en/ru/zh: два QR на AWG-карточке (standalone vs AmneziaVPN). --- docs/desktop-gui.md | 2 +- docs/ru/desktop-gui.md | 2 +- docs/zh-CN/desktop-gui.md | 2 +- src/renderer/src/pages/ServerKeys.tsx | 10 ++- src/renderer/src/pages/ServerSettings.tsx | 10 ++- src/shared/awg.ts | 77 ++++++++++---------- tests/unit/awg-shared.test.ts | 87 +++++++++++++---------- 7 files changed, 107 insertions(+), 83 deletions(-) diff --git a/docs/desktop-gui.md b/docs/desktop-gui.md index e623da95..6e1f51b9 100644 --- a/docs/desktop-gui.md +++ b/docs/desktop-gui.md @@ -43,7 +43,7 @@ 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). 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), 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. +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), 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 diff --git a/docs/ru/desktop-gui.md b/docs/ru/desktop-gui.md index 2a068b4f..f0b5a061 100644 --- a/docs/ru/desktop-gui.md +++ b/docs/ru/desktop-gui.md @@ -44,7 +44,7 @@ GUI показывает лог и состояние шагов, но для в Мастер показывает индекс шагов и живую консоль реально выполненной работы: SSH-подключение, вызов `xrayebator inspect --json`, полученные статусы компонентов, проверку подписки и итог. URL подписки — bearer credential, поэтому его токен маскируется (`…`) до попадания в консоль; пароли и байты ключей туда не попадают вовсе. ### Server keys -Server keys обновляет подписку по сохранённому `subscription_url` и показывает полученные VLESS-маршруты. Каждый VLESS-ключ можно скопировать или показать как QR-код; URL подписки также копируется, есть действие «скопировать всё». Эта страница не создаёт отдельную подписку на сервере и не меняет токен подписки (перевыпуск — на карточках профилей в Server settings). Если на сервере есть мультипротокольные бэкенды, страница также показывает их ключи из персистентного локального стора: карточка Hysteria 2 на каждый профиль с грантом (ссылка `hysteria2://` — копия + QR) и карточка AmneziaWG на каждый профиль с грантом — клиентский `.conf`, где `Endpoint`/`Адрес` выведены отдельными полями, а полный `.conf` без служебной шапки доступен текстом и QR для импорта в AmneziaVPN/AmneziaWG. Карточки бэкендов рисуются из стора мгновенно и обновляются параллельно с VLESS-маршрутами; спиннер живёт внутри карточки и виден, только пока её данные загружаются. +Server keys обновляет подписку по сохранённому `subscription_url` и показывает полученные VLESS-маршруты. Каждый VLESS-ключ можно скопировать или показать как QR-код; URL подписки также копируется, есть действие «скопировать всё». Эта страница не создаёт отдельную подписку на сервере и не меняет токен подписки (перевыпуск — на карточках профилей в Server settings). Если на сервере есть мультипротокольные бэкенды, страница также показывает их ключи из персистентного локального стора: карточка Hysteria 2 на каждый профиль с грантом (ссылка `hysteria2://` — копия + QR) и карточка 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 diff --git a/docs/zh-CN/desktop-gui.md b/docs/zh-CN/desktop-gui.md index 75730902..3b95ef1a 100644 --- a/docs/zh-CN/desktop-gui.md +++ b/docs/zh-CN/desktop-gui.md @@ -44,7 +44,7 @@ GUI 会显示部署日志和步骤状态,但进行中的部署没有 IPC 取 向导显示步骤索引和实际执行工作的实时控制台:SSH 连接、`xrayebator inspect --json` 调用、返回的组件状态、订阅探测和最终结果。订阅 URL 是 bearer credential,因此其令牌在进入控制台前会被遮蔽(`…`);密码和密钥字节完全不会出现在其中。 ### Server keys -Server keys 会从保存的 `subscription_url` 刷新订阅,并显示返回的 VLESS 线路。每条 VLESS 链接都可以复制或生成二维码;订阅 URL 也可以复制,页面还提供复制全部内容的操作。此页面不会在服务器上创建独立订阅,也不会轮换订阅令牌(轮换在 Server settings 的配置档卡片上进行)。当服务器存在多协议后端时,页面还会从持久化本地存储中显示它们的密钥:每个持有授权的配置档一张 Hysteria 2 卡片(`hysteria2://` 链接,复制+二维码),以及一张 AmneziaWG 卡片——客户端 `.conf`,其中 `Endpoint`/`Address` 以键值字段呈现,去掉服务注释头的完整 `.conf` 以文本和二维码提供,可导入 AmneziaVPN/AmneziaWG。后端卡片立即从持久化存储渲染,并与 VLESS 线路并行刷新;spinner 位于卡片内部,仅在数据获取期间可见。 +Server keys 会从保存的 `subscription_url` 刷新订阅,并显示返回的 VLESS 线路。每条 VLESS 链接都可以复制或生成二维码;订阅 URL 也可以复制,页面还提供复制全部内容的操作。此页面不会在服务器上创建独立订阅,也不会轮换订阅令牌(轮换在 Server settings 的配置档卡片上进行)。当服务器存在多协议后端时,页面还会从持久化本地存储中显示它们的密钥:每个持有授权的配置档一张 Hysteria 2 卡片(`hysteria2://` 链接,复制+二维码),以及一张 AmneziaWG 卡片——客户端 `.conf`,其中 `Endpoint`/`Address` 以键值字段呈现,去掉服务注释头的完整 `.conf` 以文本和二维码提供,可导入 AmneziaVPN/AmneziaWG。后端卡片立即从持久化存储渲染,并与 VLESS 线路并行刷新;spinner 位于卡片内部,仅在数据获取期间可见。AWG 卡片有两个二维码:普通二维码用于独立版 AmneziaWG 客户端,「QR · AmneziaVPN」则是应用原生压缩 `vpn://` 形态(`amnezia-awg2` 容器、服务器级 junk 字段、`protocol_version` 3.1),是完整版 AmneziaVPN 的推荐导入路径。 ### Server settings diff --git a/src/renderer/src/pages/ServerKeys.tsx b/src/renderer/src/pages/ServerKeys.tsx index 56a3c9b4..e05a6f82 100644 --- a/src/renderer/src/pages/ServerKeys.tsx +++ b/src/renderer/src/pages/ServerKeys.tsx @@ -247,7 +247,7 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme
- AMNEZIAWG · {name} + AMNEZIAWG 3.1 · {name}

{t('keys.backendsAwgNote')}

@@ -285,7 +285,13 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index 5293de4d..5040a651 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -1252,7 +1252,7 @@ export function ServerSettings({
- AMNEZIAWG · UDP + AMNEZIAWG 3.1 · UDP

{t('settings.beKeysAwgNote')}

@@ -1291,8 +1291,12 @@ export function ServerSettings({ ) : ( - ) : ( - + ) + + 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] : '' @@ -216,27 +288,35 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme
))} - {hysteria2Links.map((link) => ( -
-
- - HYSTERIA2 · UDP :{hysteria2Port(link)} - -
-

{t('keys.backendsHystNote')}

-
- {link} -
-
- - + {hysteria2Links.map((link) => { + const user = hystUserFromLink(link) + const profile = profiles.find((p) => p.name === user) + return ( +
+
+ + HYSTERIA2 · UDP :{hysteria2Port(link)} + +
+

{t('keys.backendsHystNote')}

+ {expiryLine(profile)} +
+ {link} +
+
+ + + {user && profile?.backends?.hysteria2 + ? rotateButton(`hyst:${user}`, () => rotateHysteria(user)) + : null} +
-
- ))} + ) + })} {awgNames.map((name) => { const conf = awgConfs[name] @@ -251,6 +331,7 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme

{t('keys.backendsAwgNote')}

+ {expiryLine(profile)} {fields ? ( <>
@@ -295,6 +376,7 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme > {t('keys.backendsQrVpn')} + {rotateButton(`awg:${name}`, () => rotateAwg(name))}
) : granted && beBusy ? ( diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index c470a35f..dcc1c5d0 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -99,7 +99,13 @@ export function ServerSettings({ const [backendsError, setBackendsError] = useState(null) const [backendBusy, setBackendBusy] = useState(null) const [confirmBackend, setConfirmBackend] = useState< - 'hysteria2-uninstall' | 'awg-uninstall' | 'awg31-on' | 'awg31-off' | null + | 'hysteria2-uninstall' + | 'awg-uninstall' + | 'awg31-on' + | 'awg31-off' + | 'hysteria2-regrant' + | 'awg-regrant' + | null >(null) // Срез C: диалог ключей бэкендов у профиля. const [keysTarget, setKeysTarget] = useState(null) @@ -1206,6 +1212,11 @@ export function ServerSettings({ {keysBusy ? : null} +

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

@@ -1233,6 +1244,14 @@ export function ServerSettings({ > {t('keys.qr')} +
) : keysHystErr ? ( @@ -1302,6 +1321,14 @@ export function ServerSettings({ > {t('keys.backendsQrVpn')} +
) : keysAwgErr ? ( @@ -1349,6 +1376,8 @@ export function ServerSettings({ {confirmBackend === 'awg-uninstall' && t('settings.backendsConfirmAwg')} {confirmBackend === 'awg31-on' && t('settings.backendsConfirm31On')} {confirmBackend === 'awg31-off' && t('settings.backendsConfirm31Off')} + {confirmBackend === 'hysteria2-regrant' && t('settings.backendsConfirmReissueHyst')} + {confirmBackend === 'awg-regrant' && t('settings.backendsConfirmReissueAwg')}
+ ))} + +
+ ) + const hysteria2Port = (link: string): string => { const m = link.match(/@[^/:]+:(\d+)/) return m ? m[1] : '' @@ -300,6 +343,7 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme

{t('keys.backendsHystNote')}

{expiryLine(profile)} + {profile ? expiryPresets(user) : null}
{link}
@@ -332,6 +376,7 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme

{t('keys.backendsAwgNote')}

{expiryLine(profile)} + {profile ? expiryPresets(name) : null} {fields ? ( <>
diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index dcc1c5d0..9329b45e 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -113,6 +113,7 @@ export function ServerSettings({ const [keysHystErr, setKeysHystErr] = useState(null) const [keysAwgConf, setKeysAwgConf] = useState(null) const [keysAwgErr, setKeysAwgErr] = useState(null) + const [keysExpiryDate, setKeysExpiryDate] = useState(null) const [keysBusy, setKeysBusy] = useState(false) const [keysQrUrl, setKeysQrUrl] = useState(null) const [keysQrData, setKeysQrData] = useState(null) @@ -656,6 +657,7 @@ export function ServerSettings({ const openBackendKeys = (profile: ServerProfile): void => { setKeysTarget(profile) + setKeysExpiryDate(profile.expire_date ?? null) setKeysBusy(true) void fetchBackendKeys(profile.name).finally(() => setKeysBusy(false)) } @@ -665,6 +667,28 @@ export function ServerSettings({ setKeysHystLink(null) setKeysAwgConf(null) setKeysQrUrl(null) + setKeysExpiryDate(null) + } + + // Срок в диалоге ключей: профильный expiry управляет всеми ключами профиля + // (vless + hysteria2 + awg) транзитивно через choke point. + const applyKeysExpire = async (days: number | null): Promise => { + if (!keysTarget) return + setKeysBusy(true) + try { + const value = days ? presetDate(days, Date.now()) : null + await window.api.profiles.setExpire(server.id, access, { + name: keysTarget.name, + expire: value + }) + setKeysExpiryDate(value) + const fresh = await window.api.profiles.list(server.id, access) + setProfiles(fresh.profiles ?? []) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setKeysBusy(false) + } } const grantBackend = async (kind: 'hysteria2' | 'awg'): Promise => { @@ -1213,10 +1237,31 @@ export function ServerSettings({ {keysBusy ? : null}

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

+
+ {[7, 30, 90, 365].map((d) => ( + + ))} + +
diff --git a/xrayebator b/xrayebator index c42917b2..7075680c 100755 --- a/xrayebator +++ b/xrayebator @@ -5192,14 +5192,14 @@ _migrate_sni_list_2026() { # Строки обязаны совпадать с shipped sni_list.txt байт-в-байт: иначе grep -Fxq # ниже считает список устаревшим и гоняет переписывание файла с рестартом Xray - # на пустом месте. Приоритеты — по замеру 03.09.2026: логистика 2, - # крупный РФ e-commerce и банки 4 (их SNI режется ТСПУ). + # на пустом месте. Приоритеты — по замеру 03.09.2026 (MegaFon) с оговоркой: + # e-commerce/банки понижены до 2, но не выброшены (на других операторах живут). local -a to_add_p1=( - "online.uralsib.ru|ru_whitelist|4" - "online.vtb.ru|ru_whitelist|4" + "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|4" + "www.avito.ru|ru_whitelist|2" ) local -a to_add_p3=( "github.com|foreign|3" @@ -11180,15 +11180,16 @@ change_sni_menu() { fi declare -A sni_desc - # Описания — по замеру 03.09.2026 (MegaFon, Москва): см. шапку sni_list.txt. - sni_desc["www.ozon.ru"]="Замер 03.09.2026: TLS не проходит, не использовать" + # Описания — по замеру 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"]="Замер 03.09.2026: TLS не проходит, не использовать" + 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"]="Замер 03.09.2026: TLS не проходит, не использовать" + sni_desc["www.tinkoff.ru"]="Замер 03.09 (MegaFon): не прошли — перепроверьте" sni_desc["www.alfabank.ru"]="Банк, запасной вариант" sni_desc["www.nspk.ru"]="Нац. платёжная система, сложно блокировать" sni_desc["sbp.nspk.ru"]="СБП, идеально для МТС" @@ -11198,11 +11199,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 — прошёл замер 03.09.2026" + 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"]="Прошёл замер 03.09.2026, хорош для gRPC" + sni_desc["www.cloudflare.com"]="Прошли замер 03.09.2026, хорош для gRPC" sni_desc["aws.amazon.com"]="AWS CDN, запасной вариант для gRPC" echo -e "${YELLOW}ТОП-20 доменов для маскировки:${NC}\n" From 3f26b6126b0d045e290d43157f8b042c9a626a00 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:47:20 +0300 Subject: [PATCH 42/73] =?UTF-8?q?GUI:=20Revoke=20=D0=B8=20=D0=A1=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B0=D1=85=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4?= =?UTF-8?q?=D0=BE=D0=B2=20=E2=80=94=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5?= =?UTF-8?q?=D1=82=20=D1=81=20VLESS-=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На карточках Hysteria 2 и AmneziaWG в Server Settings добавлены кнопки Срок (CalendarClock) и Revoke (ShieldOff) — как на VLESS-профилях. - Срок: открывает обычный диалог срока; при нескольких профилях с грантом — выбор профиля в диалоге (expireChooser), при одном — сразу на него. Профильный expiry транзитивно гасит hysteria2-юзера и AWG-peer'а. - Revoke: перевыпуск всех креденшелов бэкенда (грант повторно выдаётся каждому профилю с грантом; Hysteria ротирует пароль, AWG — пару ключей). - Из мест выдачи ключей (Server Keys, диалог «Ключи») контролы Срока и Перевыпуска убраны: выдача — отдельно, управление — на карточках бэкендов и профиля. Информационная строка «Срок: …» на карточках ключей осталась. - i18n en/ru/zh: backendsExpiryBtn, backendsExpiryChoose, backendsConfirmRevokeHystAll/AwgAll - Убран keysExpiryDate/applyKeysExpire из диалога (вернулась статичная строка срока из профиля), profiles fallback при null --- src/renderer/src/i18n/en.json | 4 + src/renderer/src/i18n/ru.json | 4 + src/renderer/src/i18n/zh.json | 4 + src/renderer/src/pages/ServerKeys.tsx | 103 -------- src/renderer/src/pages/ServerSettings.tsx | 296 ++++++++++++---------- 5 files changed, 178 insertions(+), 233 deletions(-) diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index cc8705cf..729d5842 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -318,6 +318,10 @@ "backendsConfirm31Off": "Disable AWG 3.1 mode? Configs return to the 2.0 format; every client .conf must be re-downloaded.", "backendsConfirmReissueHyst": "Re-issue the Hysteria 2 credential? The old password and link stop working.", "backendsConfirmReissueAwg": "Re-issue the AWG peer keys? The old .conf stops working.", + "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.", "beKeysBtn": "Keys", "beKeysTitle": "Backend keys", "beKeysHystNote": "UDP transport: works in HAPP, Hiddify, v2rayN. The line is already in the subscription — this key is for direct import.", diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index 7630dc19..4017f458 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -319,6 +319,10 @@ "backendsConfirm31Off": "Выключить режим AWG 3.1? Конфиги вернутся к формату 2.0; все клиентские .conf нужно будет скачать заново.", "backendsConfirmReissueHyst": "Перевыпустить креденшел Hysteria 2? Старый пароль и ссылка перестанут работать.", "backendsConfirmReissueAwg": "Перевыпустить ключи AWG peer'а? Старый .conf перестанет работать.", + "backendsExpiryBtn": "Срок", + "backendsExpiryChoose": "Выберите профиль, для которого меняется срок:", + "backendsConfirmRevokeHystAll": "Перевыпустить все ключи Hysteria 2? Старые пароли перестанут работать.", + "backendsConfirmRevokeAwgAll": "Перевыпустить ключи всех AWG-peer'ов? Старые .conf перестанут работать.", "beKeysBtn": "Ключи", "beKeysTitle": "Ключи бэкендов", "beKeysHystNote": "UDP-транспорт: работает в HAPP, Hiddify, v2rayN. Строка уже добавлена в подписку — этот ключ для прямого импорта.", diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index 8df21784..cda0647a 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -317,6 +317,10 @@ "backendsConfirm31Off": "停用 AWG 3.1 模式?配置恢复 2.0 格式;所有客户端 .conf 需重新下载。", "backendsConfirmReissueHyst": "重新签发 Hysteria 2 凭据?旧密码与链接将失效。", "backendsConfirmReissueAwg": "重新签发 AWG peer 密钥?旧 .conf 将失效。", + "backendsExpiryBtn": "有效期", + "backendsExpiryChoose": "选择要更改有效期的配置档:", + "backendsConfirmRevokeHystAll": "重新签发所有 Hysteria 2 密钥?旧密码将失效。", + "backendsConfirmRevokeAwgAll": "重新签发所有 AWG peer 密钥?旧 .conf 将失效。", "beKeysBtn": "密钥", "beKeysTitle": "后端密钥", "beKeysHystNote": "UDP 传输:适用于 HAPP、Hiddify、v2rayN。该行已在订阅中——此密钥用于直接导入。", diff --git a/src/renderer/src/pages/ServerKeys.tsx b/src/renderer/src/pages/ServerKeys.tsx index 33b6a0da..5c4f9ce3 100644 --- a/src/renderer/src/pages/ServerKeys.tsx +++ b/src/renderer/src/pages/ServerKeys.tsx @@ -9,7 +9,6 @@ import type { VlessLink } from '@shared/types' import { vlessPort } from '@shared/vless' -import { presetDate } from '@shared/expire' import { buildAwgVpnUrl, parseAwgClientConf, stripAwgComments } from '@shared/awg' import { shouldAutoConnectServer } from './server-access' import styles from './ServerKeys.module.css' @@ -34,9 +33,6 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme // Мультипротокольные бэкенды: hysteria2 приходит из подписки, AWG — по SSH. const [profiles, setProfiles] = useState([]) const [beBusy, setBeBusy] = useState(false) - // Перевыпуск креденшелов бэкендов (ротация пароля/ключей peer'а). - const [rotating, setRotating] = useState(null) - const [confirmRotate, setConfirmRotate] = useState(null) // Доступ из сохранённой карточки сервера (секрет — в keychain). const storedAccess: SshAccessInput = { @@ -166,42 +162,6 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme /** QR для импорта в AmneziaWG-клиент: без комментариев-шапки. */ const awgQrPayload = (conf: string): string => stripAwgComments(conf) - const refreshSubscription = async (): Promise => { - const result = await window.api.subscription.fetch(server.id) - setKeys(result.keys) - setHysteria2Links(result.hysteria2Links ?? []) - setSubscriptionUrl(result.subscriptionUrl) - } - - // Перевыпуск = повторный grant: Hysteria ротирует пароль, AWG — пару ключей - // peer'а (адрес сохраняется). Старые ключи перестают работать. - const rotateHysteria = async (name: string): Promise => { - setRotating(`hyst:${name}`) - try { - await window.api.backends.hysteria2Grant(server.id, storedAccess, name) - await refreshSubscription() - } finally { - setRotating(null) - setConfirmRotate(null) - } - } - - const rotateAwg = async (name: string): Promise => { - setRotating(`awg:${name}`) - try { - await window.api.backends.awgGrant(server.id, storedAccess, name) - const conf = await window.api.backends - .awgConf(server.id, storedAccess, name) - .catch(() => null) - if (conf?.ok && conf.conf) { - setAwgConfs((current) => ({ ...current, [name]: conf.conf ?? '' })) - } - } finally { - setRotating(null) - setConfirmRotate(null) - } - } - const hystUserFromLink = (link: string): string => { const user = link.match(/^hysteria2:\/\/([^:]+):/)?.[1] ?? '' try { @@ -211,21 +171,6 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme } } - const rotateButton = (key: string, rotate: () => Promise): React.JSX.Element => ( - - ) - const expiryLine = (profile: ServerProfile | undefined): React.JSX.Element | null => profile ? (

@@ -235,48 +180,6 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme

) : null - // Срок для бэкенд-ключей = срок профиля (транзитивно через choke point: - // истёкший профиль теряет и hysteria2-юзера, и AWG-peer). Пресеты — чтобы - // управлять сроком прямо с карточки ключа, не ища профиль в настройках. - const setBackendExpiry = async (name: string, days: number | null): Promise => { - setRotating(`exp:${name}`) - try { - await window.api.profiles.setExpire( - server.id, - storedAccess, - { name, expire: days ? presetDate(days, Date.now()) : null } - ) - const pl = await window.api.profiles.list(server.id, storedAccess).catch(() => null) - if (pl?.profiles) setProfiles(pl.profiles) - } finally { - setRotating(null) - } - } - - const expiryPresets = (name: string): React.JSX.Element => ( -
- {[7, 30, 90].map((d) => ( - - ))} - -
- ) - const hysteria2Port = (link: string): string => { const m = link.match(/@[^/:]+:(\d+)/) return m ? m[1] : '' @@ -343,7 +246,6 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme

{t('keys.backendsHystNote')}

{expiryLine(profile)} - {profile ? expiryPresets(user) : null}
{link}
@@ -354,9 +256,6 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme - {user && profile?.backends?.hysteria2 - ? rotateButton(`hyst:${user}`, () => rotateHysteria(user)) - : null}
) @@ -376,7 +275,6 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme

{t('keys.backendsAwgNote')}

{expiryLine(profile)} - {profile ? expiryPresets(name) : null} {fields ? ( <>
@@ -421,7 +319,6 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme > {t('keys.backendsQrVpn')} - {rotateButton(`awg:${name}`, () => rotateAwg(name))}
) : granted && beBusy ? ( diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index 9329b45e..09902c20 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -105,6 +105,8 @@ export function ServerSettings({ | 'awg31-off' | 'hysteria2-regrant' | 'awg-regrant' + | 'hysteria2-revoke-all' + | 'awg-revoke-all' | null >(null) // Срез C: диалог ключей бэкендов у профиля. @@ -113,7 +115,6 @@ export function ServerSettings({ const [keysHystErr, setKeysHystErr] = useState(null) const [keysAwgConf, setKeysAwgConf] = useState(null) const [keysAwgErr, setKeysAwgErr] = useState(null) - const [keysExpiryDate, setKeysExpiryDate] = useState(null) const [keysBusy, setKeysBusy] = useState(false) const [keysQrUrl, setKeysQrUrl] = useState(null) const [keysQrData, setKeysQrData] = useState(null) @@ -164,6 +165,7 @@ export function ServerSettings({ const [revokeUrl, setRevokeUrl] = useState(null) const [expireTarget, setExpireTarget] = useState(null) + const [expireChooser, setExpireChooser] = useState(null) const [expireDate, setExpireDate] = useState('') const [expireBusy, setExpireBusy] = useState(false) const [expireDone, setExpireDone] = useState(false) @@ -637,6 +639,36 @@ export function ServerSettings({ 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) + } + } + // ─── Срез C: диалог ключей бэкендов ─── const fetchBackendKeys = async (name: string): Promise => { setKeysHystLink(null) @@ -657,7 +689,6 @@ export function ServerSettings({ const openBackendKeys = (profile: ServerProfile): void => { setKeysTarget(profile) - setKeysExpiryDate(profile.expire_date ?? null) setKeysBusy(true) void fetchBackendKeys(profile.name).finally(() => setKeysBusy(false)) } @@ -667,28 +698,6 @@ export function ServerSettings({ setKeysHystLink(null) setKeysAwgConf(null) setKeysQrUrl(null) - setKeysExpiryDate(null) - } - - // Срок в диалоге ключей: профильный expiry управляет всеми ключами профиля - // (vless + hysteria2 + awg) транзитивно через choke point. - const applyKeysExpire = async (days: number | null): Promise => { - if (!keysTarget) return - setKeysBusy(true) - try { - const value = days ? presetDate(days, Date.now()) : null - await window.api.profiles.setExpire(server.id, access, { - name: keysTarget.name, - expire: value - }) - setKeysExpiryDate(value) - const fresh = await window.api.profiles.list(server.id, access) - setProfiles(fresh.profiles ?? []) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setKeysBusy(false) - } } const grantBackend = async (kind: 'hysteria2' | 'awg'): Promise => { @@ -1128,6 +1137,24 @@ export function ServerSettings({ ? t('settings.backendsSubBodyOn') : t('settings.backendsSubBodyOff')} + + + - ))} - -
@@ -1289,14 +1308,6 @@ export function ServerSettings({ > {t('keys.qr')} -
) : keysHystErr ? ( @@ -1366,14 +1377,6 @@ export function ServerSettings({ > {t('keys.backendsQrVpn')} -
) : keysAwgErr ? ( @@ -1423,6 +1426,9 @@ export function ServerSettings({ {confirmBackend === 'awg31-off' && t('settings.backendsConfirm31Off')} {confirmBackend === 'hysteria2-regrant' && t('settings.backendsConfirmReissueHyst')} {confirmBackend === 'awg-regrant' && t('settings.backendsConfirmReissueAwg')} + {confirmBackend === 'hysteria2-revoke-all' && + t('settings.backendsConfirmRevokeHystAll')} + {confirmBackend === 'awg-revoke-all' && t('settings.backendsConfirmRevokeAwgAll')} - ))} -
- -
- - {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} + + )} {/* Отмена — слева и всегда только закрывает диалог, без действий. */} From d17a91646c30093eb430fbd428bcb56ac850758b Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:43:36 +0300 Subject: [PATCH 43/73] =?UTF-8?q?nginx:=20=D0=B1=D1=83=D1=84=D0=B5=D1=80?= =?UTF-8?q?=D1=8B=2032k=20=D0=B2=20IP-TLS=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=20(=D1=85=D0=B2=D0=BE=D1=81=D1=82=20PR=20#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Корень: фикс «upstream sent too big header» из PR #21-#23 лёг только в два domain-TLS шаблона (location /sub/, ~8091 и ~8331). Быстрый IP-TLS шаблон (quickstart, listen 8443 ssl по LE shortlived IP-сертификату, location /) проксирует тот же 8080 без буферов — живая проверка на 2.26.125.147 показала именно его (vhost без proxy_buffer_size после регенерации). HAPP routing-профиль (~258 доменов) через такой вхост получил бы 502. Фикс: тот же трио proxy_buffer_size 32k / proxy_buffers 8 32k / proxy_busy_buffers_size 64k в IP-TLS шаблоне; HTTP-шаблоны не трогал — они отдают только ACME-challenge и 404, проксирования там нет. Проверки: bash -n OK; test-happ-client-routing (ассерт >=2 вхождений) OK; test-happ-subscription-static OK. Живая верификация: регенерация вхоста на 2.26.125.147, nginx -t внутри пути установки, подписка через 8443. --- xrayebator | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/xrayebator b/xrayebator index 7075680c..2d98ce84 100755 --- a/xrayebator +++ b/xrayebator @@ -12971,6 +12971,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; From 71f6bfc0c29d55ea861988653d94949acc11d116 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:26:03 +0300 Subject: [PATCH 44/73] =?UTF-8?q?SNI:=20=D0=B4=D0=B5=D1=84=D0=BE=D0=BB?= =?UTF-8?q?=D1=82=20=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D0=B5=D0=B9=20=E2=80=94=20www.cloudflare.com?= =?UTF-8?q?=20(=D1=81=D0=B0=D0=BC=D1=8B=D0=B9=20=D1=81=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=B8=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мотивация: cloudflare — глобальный anycast, всегда TLS1.3, не режется ТСПУ, прошёл замер 03.09 (MegaFon) с приоритетом 1. Решение 2026-10-06 ставить его дефолтом вместо api-maps.yandex.ru. Изменения: - sni_list.txt: www.cloudflare.com — первая незакомментированная строка (она и есть дефолт по контракту _default_sni_for_transport); шапка дополнена явным указанием выбора. - install.sh: аварийный список в том же порядке (cloudflare первым). - xrayebator: жёсткий фолбэк tcp-дефолта api-maps.yandex.ru -> www.cloudflare.com (файл списка обычно перекрывает фолбэк, но пусть оба согласованы). grpc-дефолт уже был cloudflare. Живой прецедент: 2.26.125.147 — все 7 инбаундов и все маршруты профилей переведены на www.cloudflare.com транзакционным sni-change; Reality снаружи отдаёт настоящий серт CN=www.cloudflare.com (GTS WE1), TLS1.3 на всех портах, xray 26.3.27 запущен без ошибок, подписка отдаёт sni=www.cloudflare.com во всех vless-ссылках. Проверки: bash -n xrayebator/install.sh; полный validation-сьют 33/33; внешний openssl-тест всех 7 портов с SNI www.cloudflare.com. --- install.sh | 5 +++-- sni_list.txt | 6 ++++-- xrayebator | 7 ++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/install.sh b/install.sh index 8b6c6078..46b6f767 100644 --- a/install.sh +++ b/install.sh @@ -1119,14 +1119,15 @@ else echo -e "${YELLOW}⚠ Не удалось загрузить список SNI, создаю базовый...${NC}" cat > "${DATA_DIR}/sni_list.txt" << 'EOF' # Аварийный список: не удалось скачать sni_list.txt из репозитория. -# Порядок по замеру 03.09.2026 (MegaFon, Москва) — перепроверьте: probe test. +# Первая строка = 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.cloudflare.com|foreign|1 www.lovelive-anime.jp|foreign|1 speller.yandex.net|yandex_cdn|2 www.gosuslugi.ru|ru_whitelist|2 diff --git a/sni_list.txt b/sni_list.txt index 30f51f7f..a66eb572 100644 --- a/sni_list.txt +++ b/sni_list.txt @@ -24,19 +24,21 @@ # cdn-telegram.org — не резолвится # # Первая незакомментированная строка файла = SNI по умолчанию для новых -# профилей (_default_sni_for_transport), первые 20 строк = меню выбора SNI. +# профилей (_default_sni_for_transport) — стоит www.cloudflare.com (выбор +# 2026-10-06: самый стабильный, глобальный anycast); первые 20 строк = меню +# выбора SNI. # ═══════════════════════════════════════════════════════════ # ПРИОРИТЕТ 1 — прошли замер 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.cloudflare.com|foreign|1 www.lovelive-anime.jp|foreign|1 # ═══════════════════════════════════════════════════════════ diff --git a/xrayebator b/xrayebator index 2d98ce84..dff96eba 100755 --- a/xrayebator +++ b/xrayebator @@ -6311,9 +6311,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) - # Замер 03.09.2026 (MegaFon): SNI по умолчанию должен быть из - # прошедших замер — www.ozon.ru режется ТСПУ. - [[ -z "$from_list" ]] && from_list="api-maps.yandex.ru" + # Дефолт 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 From b21217ea91637ea5a2e4c408a79fbc096e9700d1 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:51:22 +0300 Subject: [PATCH 45/73] =?UTF-8?q?=D0=94=D0=BE=D0=BA=D0=B8:=20=D1=81=D0=B8?= =?UTF-8?q?=D0=BD=D0=BA=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20SNI-=D0=B4?= =?UTF-8?q?=D0=B5=D1=84=D0=BE=D0=BB=D1=82=D0=B0=20=D0=B8=20IP-TLS=20=D0=B1?= =?UTF-8?q?=D1=83=D1=84=D0=B5=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Аудит синхронизации en/ru/zh после 71f6bfc и d17a916. Найдено и исправлено: 1. configuration.md ×3: buffer-текст привязывал 32k только к `location /sub/` — d17a916 поднял буферы и в IP-mode vhost (quickstart, `location /`); формулировка обобщена на «вхост подписки, оба режима». 2. configuration.md ×3: строка sni-list дополнена контрактом из 71f6bfc — первая незакомментированная строка sni_list.txt = SNI по умолчанию для новых профилей (www.cloudflare.com). 3. README ×3: «AmneziaWG 2.0» → «AmneziaWG 2.0/3.1» — 3.1 давно реализована и включена по умолчанию (секции «2.0 vs 3.x» в доках уже были). 4. CLAUDE.md: 31 → 33 Bash-теста (реальное число в validation/), в перечне покрытий добавлен HAPP client-routing generator. Проверено: устаревших утверждений о приоритетах/дефолтах SNI в доках не было; architecture.md не детализирует junk/буферы; исторические числа в CHANGELOG/releases не тронуты (они про свой период). --- CLAUDE.md | 2 +- README.md | 2 +- README.ru.md | 2 +- README.zh-CN.md | 2 +- docs/configuration.md | 7 ++++--- docs/ru/configuration.md | 7 ++++--- docs/zh-CN/configuration.md | 7 ++++--- 7 files changed, 16 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e1c14cd5..0d1a447f 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/`** — 31 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, 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/`** — 33 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 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. diff --git a/README.md b/README.md index a6f90e1e..96e8bc1d 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Xrayebator installs Xray-core, brings up Reality inbounds on random ports, and p 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. As of the multi-protocol stage, optional -Hysteria 2 (fast UDP) and AmneziaWG 2.0 (system VPN) backends can be installed on the same VPS from +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 fafa4343..466d5d87 100644 --- a/README.ru.md +++ b/README.ru.md @@ -30,7 +30,7 @@ Xrayebator ставит Xray-core, поднимает Reality-инбаунды создаёт стандартный schema-v3 HAPP-профиль из семи маршрутов. Существующий семимаршрутный профиль может быть переиспользован при наличии живых маршрутов, поэтому при отладке label/schema проверяйте profile JSON. Клиент получает маршруты одной HTTPS-ссылкой подписки. Начиная с мультипротокольного -этапа, на тот же VPS опционально ставятся бэкенды Hysteria 2 (быстрый UDP) и AmneziaWG 2.0 +этапа, на тот же 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 f28466d1..b99c5b90 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -29,7 +29,7 @@ Xrayebator 安装 Xray-core,在随机端口上建立 Reality 入站,并为新安装创建包含七条线路的标准 schema-v3 HAPP 配置档。已有七线路配置档在满足存活线路数量时可能被复用,因此排查 label 或 schema 时请检查配置档 JSON。客户端通过一条 HTTPS 订阅链接获取线路。自多协议阶段起,同一台 VPS 可选装 -Hysteria 2(高速 UDP)与 AmneziaWG 2.0(系统级 VPN)后端——菜单项 11–13,每配置档授权纳入统一的 +Hysteria 2(高速 UDP)与 AmneziaWG 2.0/3.1(系统级 VPN)后端——菜单项 11–13,每配置档授权纳入统一的 revoke/expire 生命周期。当前服务器版本线为 3.0;可选 Electron 桌面应用单独版本化。

diff --git a/docs/configuration.md b/docs/configuration.md index cd52e5b8..499bbb76 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -126,7 +126,7 @@ is a client-side profile/route setting; changing it does not restart Xray or alt | `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 | @@ -319,8 +319,9 @@ without a cascade the catch-all outbound is already `direct`, so bypass cannot c 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 `location /sub/` raises it (`proxy_buffer_size 32k`); -if you run your own reverse proxy in front of the subscription, set `proxy_buffer_size` there too, +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 diff --git a/docs/ru/configuration.md b/docs/ru/configuration.md index ef2a719a..cfdf9a1a 100644 --- a/docs/ru/configuration.md +++ b/docs/ru/configuration.md @@ -129,7 +129,7 @@ legacy-файлы и блоки, ранее созданные Xrayebator, и с | `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-правила | @@ -294,8 +294,9 @@ Xrayebator реализует набор 3.1: junk-параметры плюс ` Не пускать домашний трафик в туннель можно только профилем клиента. Профиль уезжает клиенту в заголовке ответа, поэтому большой список `DirectSites` может перерасти -дефолтный 4k-буфер nginx. В генерируемом `location /sub/` буфер поднят (`proxy_buffer_size 32k`); -если перед подпиской стоит свой реверс-прокси, `proxy_buffer_size` нужно поднять и там, иначе nginx +дефолтный 4k-буфер nginx. В генерируемом вхосте подписки буфер поднят (`proxy_buffer_size 32k`) — +и в доменном `location /sub/`, и в IP-режиме (quickstart, `location /`); если перед подпиской стоит +свой реверс-прокси, `proxy_buffer_size` нужно поднять и там, иначе nginx ответит 502 и напишет в лог `upstream sent too big header`. ### Генерация из меню diff --git a/docs/zh-CN/configuration.md b/docs/zh-CN/configuration.md index 3f04e4ab..61108f07 100644 --- a/docs/zh-CN/configuration.md +++ b/docs/zh-CN/configuration.md @@ -114,7 +114,7 @@ Xrayebator 不会更改主机的 TCP 拥塞控制算法,也不会写入或应 | `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) | @@ -263,8 +263,9 @@ Xrayebator 实现了 AWG 3.1 特性集:junk 参数加 `S1`–`S4`(≥ 12) 送进隧道,目标站点看到的依旧是 VPS 的地址。在没有级联的节点上,兜底 outbound 本来就是 `direct`, 所以分流路由无法改变俄罗斯站点看到的结果。只有客户端配置才能让本国流量不进入隧道。 -配置通过响应头下发给客户端,因此较大的 `DirectSites` 列表可能超出 nginx 默认的 4k 代理缓冲区。生成的 -`location /sub/` 已调高该值(`proxy_buffer_size 32k`);如果订阅前面还有你自己的反向代理,也需要在那里 +配置通过响应头下发给客户端,因此较大的 `DirectSites` 列表可能超出 nginx 默认的 4k 代理缓冲区。生成的订阅 +nginx 配置已调高该值(`proxy_buffer_size 32k`),域名模式的 `location /sub/` 与 IP 模式(quickstart, +`location /`)均已覆盖;如果订阅前面还有你自己的反向代理,也需要在那里 调高 `proxy_buffer_size`,否则 nginx 会返回 502 并在日志中记录 `upstream sent too big header`。 ### 从菜单生成 From d33fdb2a65960d47b53e10c8e89a618ef4327080 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:30:57 +0300 Subject: [PATCH 46/73] =?UTF-8?q?GUI:=20=D0=BC=D1=83=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D0=B8=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B2=20=C2=AB=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8C=C2=BB=20+?= =?UTF-8?q?=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4-=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Запрос пользователя на компоновку Server Settings: 1. В боксе «Создать профиль» под сеткой VLESS-транспортов — подзаголовок «Мультипротокольные бэкенды» и две широкие карточки (Hysteria 2, AmneziaWG 3.1) с логотипами и описаниями в стиле protocolHint. Клик создаёт профиль через обычный profiles.create (имя/количество/срок/транспорт — из общей формы) и сразу выдаёт ключ бэкенда (hysteria2Grant/awgGrant). Если бэкенд не установлен — внятная ошибка с указанием на раздел ниже (установку сознательно не прячем в клик: DKMS-сборка AWG долгая). Fallback-перечитывание списка — как в обычном create (профили могли создаться даже при ошибке парсинга). Частичный отказ выдачи ключей показывается отдельным сообщением. 2. Бокс «Мультипротокольные бэкенды» остаётся (серверные операции: install/uninstall, вкл/выкл, Revoke всех, Срок) и помечается бейджем «в разработке · запасной» — по решению пользователя он запасной, а основной путь — карточки в профилях. 3. В списке профилей: у профиля с ключом бэкенда появляются отдельные карточки Hysteria 2 / AmneziaWG 3.1 (дубликаты стиля бэкенд-блока, разделённые по протоколам): логотип, имя профиля, статус-чип, Ссылка/Конфиг + QR (+ QR · AmneziaVPN для AWG через buildAwgVpnUrl), Срок (профильный expiry — он же гасит ключ бэкенда) и Перевыпустить (profile-revoke — ротация и sub_token, и креденшелов бэкенда через lifecycle-хук). Кнопки выдачи дизейблятся, когда бэкенд не active. i18n: новые ключи settings.backends{DevBadge,CreateHint,CreateHystDesc, CreateAwgDesc,CreateNotInstalled,Created,CreatedPartial,CardLink,CardConf} в ru/en/zh; остальные подписи переиспользованы (backendsTitle, keys.qr, keys.backendsQrVpn, backendsExpiryBtn, backendsReissue, backendsActive). Проверки: npm run typecheck ✓; vitest 119/120 — единственный фейл shell-command.test.ts воспроизводится на чистом HEAD (Windows: spawn stderr = null), к изменениям не относится. Слово «грант» в UI не используется — по фидбеку пользователя. --- src/renderer/src/i18n/en.json | 9 + src/renderer/src/i18n/ru.json | 9 + src/renderer/src/i18n/zh.json | 9 + .../src/pages/ServerSettings.module.css | 98 ++++++ src/renderer/src/pages/ServerSettings.tsx | 310 +++++++++++++++++- 5 files changed, 434 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 729d5842..fcd8ce30 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -301,6 +301,15 @@ "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": "WIP · fallback", + "backendsCreateHint": "The profile is created as usual: name, count, expiry and transport are the fields above. The key is issued for the selected protocol.", + "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", + "backendsCardLink": "Link", + "backendsCardConf": "Config", "backendsInstall": "Install", "backendsInstalling": "Installing…", "backendsUninstall": "Uninstall", diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index 4017f458..e3e2e496 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -302,6 +302,15 @@ "backendsAwgNote": "Клиент — приложение AmneziaWG/AmneziaVPN, НЕ V2Ray/HAPP. Импорт .conf или QR. AmneziaVPN ≥ 5.0.1.5.", "backendsActive": "Активен", "backendsNotInstalled": "Не установлен", + "backendsDevBadge": "в разработке · запасной", + "backendsCreateHint": "Профиль создаётся как обычно: имя, количество, срок и транспорт — поля выше. Ключ выдаётся для выбранного протокола.", + "backendsCreateHystDesc": "Быстрый UDP-бэкенд на QUIC: ключ добавляется в подписку, одна ссылка — все устройства", + "backendsCreateAwgDesc": "Системный VPN на ядре AmneziaWG 3.1: полный туннель устройства, один ключ — одно устройство", + "backendsCreateNotInstalled": "Бэкенд не установлен — установите его в разделе «Мультипротокольные бэкенды» ниже", + "backendsCreated": "Профили созданы, ключ бэкенда выдан: {{count}}", + "backendsCreatedPartial": "Профили созданы, но часть ключей не выдана — проверьте раздел бэкендов", + "backendsCardLink": "Ссылка", + "backendsCardConf": "Конфиг", "backendsInstall": "Установить", "backendsInstalling": "Установка…", "backendsUninstall": "Удалить", diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index cda0647a..c39b3343 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -300,6 +300,15 @@ "backendsAwgNote": "客户端为 AmneziaWG/AmneziaVPN 应用,而非 V2Ray/HAPP。导入 .conf 或二维码。AmneziaVPN ≥ 5.0.1.5。", "backendsActive": "运行中", "backendsNotInstalled": "未安装", + "backendsDevBadge": "开发中 · 备用", + "backendsCreateHint": "配置档照常创建:名称、数量、有效期和传输方式在上方字段中。密钥按所选协议签发。", + "backendsCreateHystDesc": "基于 QUIC 的高速 UDP 后端:密钥自动加入订阅,一条链接——所有设备", + "backendsCreateAwgDesc": "基于 AmneziaWG 3.1 内核的系统级 VPN:整机隧道,一个密钥——一台设备", + "backendsCreateNotInstalled": "后端未安装——请在下方“多协议后端”部分安装", + "backendsCreated": "配置档已创建,后端密钥已签发:{{count}}", + "backendsCreatedPartial": "配置档已创建,但部分密钥未签发——请检查后端部分", + "backendsCardLink": "链接", + "backendsCardConf": "配置", "backendsInstall": "安装", "backendsInstalling": "安装中…", "backendsUninstall": "卸载", diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index 8088269b..33d5b42a 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -219,6 +219,104 @@ 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); +} + +/* --- Карточки бэкенд-профилей в списке (дубликаты стиля бэкенд-блока) --- */ +.backendProfileCard { + margin-top: 10px; + padding: 12px 14px; + border-radius: 12px; + background: var(--bg-1, rgba(127, 127, 127, 0.1)); + 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; diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index 09902c20..55fd2318 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -130,6 +130,7 @@ export function ServerSettings({ const [transport, setTransport] = useState('xhttp') const [count, setCount] = useState('1') const [creating, setCreating] = useState(false) + const [backendCreateBusy, setBackendCreateBusy] = useState<'hysteria2' | 'awg' | null>(null) const [createExpire, setCreateExpire] = useState('') const [createExpireOpen, setCreateExpireOpen] = useState(false) @@ -762,6 +763,148 @@ 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 + } + if (!backends?.backends[kind]?.installed) { + setError(t('settings.backendsCreateNotInstalled')) + return + } + setBusy(true) + setBackendCreateBusy(kind) + 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, + 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('') + } else if (createdNames.length > 0) { + setError(t('settings.backendsCreatedPartial')) + } else if (failedMessage) { + setError(failedMessage) + } + setBusy(false) + setBackendCreateBusy(null) + } + + // Ключи бэкенд-профиля: карточки в списке профилей (дубликаты стиля + // бэкенд-блока, разделённые по протоколам). + 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 copyProfileHystLink = async (profile: ServerProfile): Promise => { + try { + await copyKeysText(await profileHystLink(profile)) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + } + + 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 copyProfileAwgConf = async (profile: ServerProfile): Promise => { + try { + await copyKeysText(stripAwgComments(await profileAwgConf(profile))) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + } + + 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 keysAwgFields = keysAwgConf ? parseAwgClientConf(keysAwgConf) : null @@ -962,6 +1105,50 @@ export function ServerSettings({ })}
+
+ {t('settings.backendsTitle')} +

{t('settings.backendsCreateHint')}

+
+ + +
+
@@ -1084,13 +1271,134 @@ export function ServerSettings({ )} + {profile.backends?.hysteria2 && ( +
+
+ + Hysteria 2 + +
+
{t('settings.backendsHyst')}
+
{profile.name} · UDP
+
+ + {hyst?.state === 'active' + ? t('settings.backendsActive') + : t('settings.backendsNotInstalled')} + +
+
+ + + + +
+
+ )} + {profile.backends?.awg && ( +
+
+ + AmneziaWG + +
+
{t('settings.backendsAwg')}
+
{profile.name} · AWG 3.1
+
+ + {awgEntry?.state === 'active' + ? t('settings.backendsActive') + : t('settings.backendsNotInstalled')} + +
+
+ + + + + +
+
+ )} ))}
-

{t('settings.backendsTitle')}

+

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

{t('settings.backendsHint')}

{backendsError !== null ? (

{t('settings.backendsUnsupported')}

From 461e6306d22b7c711736be808c3ef7dfdf978085 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:36:46 +0300 Subject: [PATCH 47/73] =?UTF-8?q?GUI:=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4-=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D0=B5=D0=B9=20=E2=80=94=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=81=D0=B2=D0=BE=D1=8E=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D1=83,=20=D0=B0=20=D0=BD=D0=B5=20=D1=82=D1=80?= =?UTF-8?q?=D0=B5=D1=82=D1=8C=D0=B8=D0=BC=20=D1=8D=D0=BB=D0=B5=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=BC=20=D0=B2=20=D1=80=D1=8F=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Корень: .profileCard — flex-строка (main | actions, space-between); две новые .backendProfileCard стали её третьим/четвёртым элементом, легли справа от кнопок, распирили карточку и растянули страницу — пользователь видел «отдельные квадратные профили» вне бокса. Фикс: flex-wrap: wrap у .profileCard (main+actions остаются на первой строке, при узком окне actions аккуратно уходят вниз) + flex-basis: 100% у .backendProfileCard — каждая бэкенд-карточка занимает собственную строку внутри карточки профиля. На узких экранах перенос теперь работает и для обычных детей, что само по себе улучшение. --- src/renderer/src/pages/ServerSettings.module.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index 33d5b42a..7cc8b2dc 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -282,8 +282,12 @@ } /* --- Карточки бэкенд-профилей в списке (дубликаты стиля бэкенд-блока) --- */ +/* flex-basis: 100% + flex-wrap у .profileCard: карточка уходит на свою + строку под main/actions, а не встаёт третьим элементом в ряд. */ .backendProfileCard { - margin-top: 10px; + flex: none; + flex-basis: 100%; + margin-top: 2px; padding: 12px 14px; border-radius: 12px; background: var(--bg-1, rgba(127, 127, 127, 0.1)); @@ -656,6 +660,7 @@ .profileCard { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; From b17fdcbab4a013a0d16703e057ee0acb543883e7 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:54:37 +0300 Subject: [PATCH 48/73] =?UTF-8?q?GUI:=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4-=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D0=B5=D0=B9=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=84=D0=B8=D0=B4=D0=B1=D0=B5=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revoke показывал сырой i18n-ключ settings.backendsReissue (ключа нет — Перевыпустить живёт в keys.backendsReissue): кнопка светила литерал 'settings.backendsReissue'. Исправлено на keys.backendsReissue. - Кнопки копирования Ссылка/Конфиг убраны — копирование делается в Ключах (решение пользователя); остались QR (+ QR · AmneziaVPN). - Чип «Активен» убран: у других карточек в списке статус-надписей нет, визуального паритета не было. Неактивный бэкенд по-прежнему гасит кнопки выдачи. - Добавлена кнопка Удалить (с тем же protected-гвардом multi_route, что у основной карточки) — бэкенд-карточка вела себя как неполноценный профиль без удаления. - Мёртвые i18n-ключи settings.backendsCardLink/CardConf удалены из ru/en/zh; обработчики copyProfile* удалены (noUnusedLocals). Проверки: typecheck чисто; vitest 119/120 — фейл только известный Windows-флейк shell-command (stderr=null на чистом HEAD). --- src/renderer/src/i18n/en.json | 2 - src/renderer/src/i18n/ru.json | 2 - src/renderer/src/i18n/zh.json | 2 - src/renderer/src/pages/ServerSettings.tsx | 86 +++++++++++------------ 4 files changed, 40 insertions(+), 52 deletions(-) diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index fcd8ce30..92d6cb3f 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -308,8 +308,6 @@ "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", - "backendsCardLink": "Link", - "backendsCardConf": "Config", "backendsInstall": "Install", "backendsInstalling": "Installing…", "backendsUninstall": "Uninstall", diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index e3e2e496..19c35005 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -309,8 +309,6 @@ "backendsCreateNotInstalled": "Бэкенд не установлен — установите его в разделе «Мультипротокольные бэкенды» ниже", "backendsCreated": "Профили созданы, ключ бэкенда выдан: {{count}}", "backendsCreatedPartial": "Профили созданы, но часть ключей не выдана — проверьте раздел бэкендов", - "backendsCardLink": "Ссылка", - "backendsCardConf": "Конфиг", "backendsInstall": "Установить", "backendsInstalling": "Установка…", "backendsUninstall": "Удалить", diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index c39b3343..bffce4f5 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -307,8 +307,6 @@ "backendsCreateNotInstalled": "后端未安装——请在下方“多协议后端”部分安装", "backendsCreated": "配置档已创建,后端密钥已签发:{{count}}", "backendsCreatedPartial": "配置档已创建,但部分密钥未签发——请检查后端部分", - "backendsCardLink": "链接", - "backendsCardConf": "配置", "backendsInstall": "安装", "backendsInstalling": "安装中…", "backendsUninstall": "卸载", diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index 55fd2318..cdbb9a57 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -854,14 +854,6 @@ export function ServerSettings({ return res.link } - const copyProfileHystLink = async (profile: ServerProfile): Promise => { - try { - await copyKeysText(await profileHystLink(profile)) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } - } - const qrProfileHyst = async (profile: ServerProfile): Promise => { try { await showKeysQr(await profileHystLink(profile)) @@ -876,14 +868,6 @@ export function ServerSettings({ return res.conf } - const copyProfileAwgConf = async (profile: ServerProfile): Promise => { - try { - await copyKeysText(stripAwgComments(await profileAwgConf(profile))) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } - } - const qrProfileAwg = async (profile: ServerProfile): Promise => { try { await showKeysQr(stripAwgComments(await profileAwgConf(profile))) @@ -1281,22 +1265,8 @@ export function ServerSettings({
{t('settings.backendsHyst')}
{profile.name} · UDP
- - {hyst?.state === 'active' - ? t('settings.backendsActive') - : t('settings.backendsNotInstalled')} -
- + {profile.multi_route ? ( + + + {t('settings.mainProfile')} + + ) : ( + + )}
)} @@ -1336,22 +1325,8 @@ export function ServerSettings({
{t('settings.backendsAwg')}
{profile.name} · AWG 3.1
- - {awgEntry?.state === 'active' - ? t('settings.backendsActive') - : t('settings.backendsNotInstalled')} -
- + {profile.multi_route ? ( + + + {t('settings.mainProfile')} + + ) : ( + + )}
)} From 623560953f1e7d5c2f371ee8749a5a50effed7fe Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:17:24 +0300 Subject: [PATCH 49/73] =?UTF-8?q?GUI:=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4-=D0=B1=D0=BE=D0=BA=D1=81=D1=8B=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D0=B5=D0=B9=20=E2=80=94=20=D0=BE=D1=82=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=81=D0=B8=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=D0=B8,=20=D0=BC=D0=B5=D1=82=D0=BA=D0=B0=20Re?= =?UTF-8?q?voke,=20=D0=B1=D0=B5=D0=B7=20=D0=B7=D0=B0=D0=BC=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По фидбеку пользователя: 1. «Основной профиль»-замок убран с бэкенд-боксов — они не основные; на multi_route просто нет кнопки Удалить (защита сохранена), без поясняющей плашки. 2. Метка кнопки унифицирована с основными карточками: settings.revokeBtn («Revoke») вместо keys.backendsReissue («Перевыпустить») — раньше основной профиль назывался Revoke, бэкенд — Перевыпустить, разнобой. 3. Главное: бэкенд-боксы больше не вложены в карточку профиля — это отдельные сиблинги во Fragment (как доп. протоколы рядом с основным боксом): профиль-VLESS, ниже отдельный тёмный бокс Hysteria 2, ниже отдельный бокс AmneziaWG 3.1, каждый с бортом/фоном карточки. flex-wrap/basis хак из предыдущего фикса откатан за ненадобностью. Проверки: typecheck чисто; vitest 119/120 (известный Windows-флейк shell-command, не связан). --- .../src/pages/ServerSettings.module.css | 15 ++++----- src/renderer/src/pages/ServerSettings.tsx | 32 ++++++------------- 2 files changed, 15 insertions(+), 32 deletions(-) diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index 7cc8b2dc..b0b088d8 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -281,16 +281,14 @@ color: var(--text-2); } -/* --- Карточки бэкенд-профилей в списке (дубликаты стиля бэкенд-блока) --- */ -/* flex-basis: 100% + flex-wrap у .profileCard: карточка уходит на свою - строку под main/actions, а не встаёт третьим элементом в ряд. */ +/* --- Бэкенд-боксы профилей в списке --- */ +/* Самостоятельные боксы уровня карточки профиля (сиблинги во Fragment), + а не вложенные sub-rows: «основной бокс + боксы протоколов рядом». */ .backendProfileCard { - flex: none; - flex-basis: 100%; - margin-top: 2px; - padding: 12px 14px; + background: var(--bg-1); + border: 1px solid var(--border); border-radius: 12px; - background: var(--bg-1, rgba(127, 127, 127, 0.1)); + padding: 12px 16px; display: flex; flex-direction: column; gap: 10px; @@ -660,7 +658,6 @@ .profileCard { display: flex; - flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index cdbb9a57..d59b46c8 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -1,4 +1,4 @@ -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 { @@ -1142,7 +1142,8 @@ export function ServerSettings({ )} {profiles!.length === 0 &&
{t('settings.empty')}
} {profiles!.map((profile) => ( -
+ +
{profile.name} @@ -1255,6 +1256,7 @@ export function ServerSettings({ )}
+
{profile.backends?.hysteria2 && (
@@ -1291,17 +1293,9 @@ export function ServerSettings({ onPress={() => openRevoke(profile)} > - {t('keys.backendsReissue')} + {t('settings.revokeBtn')} - {profile.multi_route ? ( - - - {t('settings.mainProfile')} - - ) : ( + {!profile.multi_route && ( - {profile.multi_route ? ( - - - {t('settings.mainProfile')} - - ) : ( + {!profile.multi_route && (
)} -
+ ))}
From 4fa5d9ec16222e1785df0abf04c499337504c20e Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:47:33 +0300 Subject: [PATCH 50/73] =?UTF-8?q?GUI:=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4-=D0=B1=D0=BE=D0=BA=D1=81=D1=8B=20=E2=80=94=20=D0=A3?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=B8=D1=82=D1=8C=20=D0=B2=D1=81=D0=B5=D0=B3?= =?UTF-8?q?=D0=B4=D0=B0,=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D1=8C=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По фидбеку пользователя: 1. Кнопка Удалить не была видна: я прятал её за гвардом multi_route, а бэкенд-боксы висят на основном happ-профиле. Теперь Удалить показывается всегда — как у других профилей; подтверждение удаления остаётся. 2. Подзаголовок «happ · UDP» читался как название протокола («почему пишется Happ?» — Hysteria работает и в Hiddify/других v2ray-клиентах, а Amnezia в HAPP вообще не подходит). Заменено на протокольные подписи из бэкенд-бокса: settings.backendsHystSub / backendsAwgSub. Проверки: typecheck чисто; vitest 119/120 (известный Windows-флейк). --- src/renderer/src/pages/ServerSettings.tsx | 48 +++++++++++------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index d59b46c8..1d3d8c73 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -1265,7 +1265,9 @@ export function ServerSettings({
{t('settings.backendsHyst')}
-
{profile.name} · UDP
+
+ {t('settings.backendsHystSub')} +
@@ -1295,17 +1297,15 @@ export function ServerSettings({ {t('settings.revokeBtn')} - {!profile.multi_route && ( - - )} +
)} @@ -1317,7 +1317,9 @@ export function ServerSettings({
{t('settings.backendsAwg')}
-
{profile.name} · AWG 3.1
+
+ {t('settings.backendsAwgSub')} +
@@ -1355,17 +1357,15 @@ export function ServerSettings({ {t('settings.revokeBtn')} - {!profile.multi_route && ( - - )} +
)} From e0b80ccc5bbacdcd1a5209da2370aeee12061ad5 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:02:57 +0300 Subject: [PATCH 51/73] =?UTF-8?q?GUI:=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4-=D0=B1=D0=BE=D0=BA=D1=81=D1=8B=20=E2=80=94=20=D0=B7?= =?UTF-8?q?=D0=B5=D0=BB=D1=91=D0=BD=D0=B0=D1=8F=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D0=B0=20?= =?UTF-8?q?=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=C2=AB=D1=81=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D0=BC=D0=BD=D1=8B=D0=B9=20VPN=20=C2=B7=20full-tunn?= =?UTF-8?q?el=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По фидбеку: «системный VPN · full-tunnel» — не фишка, которую надо выводить; пользователь хочет идентификаторы соединения и живость: - Hysteria 2: «Активен · SNI » (SNI берётся из backend-status, поле sni у BackendEntry — синхронно, без дозвонов; если пусто — «Активен · UDP ») - AmneziaWG: SNI нет по природе протокола — «Активен · UDP » - неактивный бэкенд — серое «Не установлен», как в нижнем боксе Стиль строки — тот же зелёный backendState/Ok, что в секции «Мультипротокольные бэкенды». Проверки: typecheck чисто; vitest 119/120 (известный Windows-флейк). --- src/renderer/src/pages/ServerSettings.tsx | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index 1d3d8c73..d6957873 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -1265,8 +1265,16 @@ export function ServerSettings({
{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')}
@@ -1317,8 +1325,14 @@ export function ServerSettings({
{t('settings.backendsAwg')}
-
- {t('settings.backendsAwgSub')} +
+ {awgEntry?.state === 'active' + ? `${t('settings.backendsActive')} · UDP ${awgEntry.port ?? '—'}` + : t('settings.backendsNotInstalled')}
From cc23585841149a15ce9f51d19f4d1d0a3267f299 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:35:38 +0300 Subject: [PATCH 52/73] =?UTF-8?q?GUI:=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20=C2=AB=D0=9A?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B8=C2=BB=20=D0=B8=20=D0=B5=D1=91=20=D0=B4?= =?UTF-8?q?=D0=B8=D0=B0=D0=BB=D0=BE=D0=B3=20=E2=80=94=20=D0=BA=D0=BE=D0=BF?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B2=20=D0=9A=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Решение пользователя: функция выдачи/копирования ключей жила в трёх местах (диалог на карточке профиля, бэкенд-боксы, страница Ключей) — дублирование. Удалены: - кнопка «Ключи» (beKeysBtn) из карточек профилей Server Settings; - сам диалог ключей бэкендов (AlertDialog keysTarget) со состояниями keysTarget/keysHyst*/keysAwg*/keysBusy и обработчиками fetchBackendKeys/openBackendKeys/closeBackendKeys/grantBackend/ copyKeysText/keysAwgFields; из импортов выпал parseAwgClientConf; - мёртвые ветки hysteria2-regrant/awg-regrant в confirmBackend (сеттеров не существовало после рефакторинга Среза C) + члены union-типа; - мёртвые i18n-ключи ru/en/zh: settings.beKeys* (7 шт), backendsConfirmReissue{Hyst,Awg}. Осталось: QR-кнопки на бэкенд-боксах (showKeysQr/keysQrUrl — используются), Срок/Revoke/Удалить на боксах, полное копирование — только страница Ключей. Проверки: typecheck чисто (пойман и исправлен дубль keysQr-состояний); vitest 119/120 — известный Windows-флейк shell-command. --- src/renderer/src/i18n/en.json | 11 +- src/renderer/src/i18n/ru.json | 11 +- src/renderer/src/i18n/zh.json | 11 +- src/renderer/src/pages/ServerSettings.tsx | 219 +--------------------- 4 files changed, 6 insertions(+), 246 deletions(-) diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 92d6cb3f..c7c303b7 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -323,18 +323,9 @@ "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.", - "backendsConfirmReissueHyst": "Re-issue the Hysteria 2 credential? The old password and link stop working.", - "backendsConfirmReissueAwg": "Re-issue the AWG peer keys? The old .conf stops working.", "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.", - "beKeysBtn": "Keys", - "beKeysTitle": "Backend keys", - "beKeysHystNote": "UDP transport: works in HAPP, Hiddify, v2rayN. The line is already in the subscription — this key is for direct import.", - "beKeysAwgNote": "AmneziaVPN — NOT V2Ray/HAPP: import the config into the AmneziaWG/AmneziaVPN app. Full tunnel, clients AmneziaVPN ≥ 5.0.1.5. One key — one device.", - "beKeysGrant": "Issue grant", - "beKeysEndpoint": "Endpoint", - "beKeysAddress": "Address" + "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 19c35005..5c53cc9a 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -324,18 +324,9 @@ "backendsConfirmAwg": "Удалить AmneziaWG? Интерфейс, конфиг и правило firewall будут сняты (пакеты и модуль останутся в системе). Все peer-подключения прекратятся.", "backendsConfirm31On": "Включить режим AWG 3.1 (HeaderProtectionKey + RandomTrailers)? Все выданные клиентские .conf нужно будет скачать заново. Клиентам нужен AmneziaVPN ≥ 5.0.1.5.", "backendsConfirm31Off": "Выключить режим AWG 3.1? Конфиги вернутся к формату 2.0; все клиентские .conf нужно будет скачать заново.", - "backendsConfirmReissueHyst": "Перевыпустить креденшел Hysteria 2? Старый пароль и ссылка перестанут работать.", - "backendsConfirmReissueAwg": "Перевыпустить ключи AWG peer'а? Старый .conf перестанет работать.", "backendsExpiryBtn": "Срок", "backendsExpiryChoose": "Выберите профиль, для которого меняется срок:", "backendsConfirmRevokeHystAll": "Перевыпустить все ключи Hysteria 2? Старые пароли перестанут работать.", - "backendsConfirmRevokeAwgAll": "Перевыпустить ключи всех AWG-peer'ов? Старые .conf перестанут работать.", - "beKeysBtn": "Ключи", - "beKeysTitle": "Ключи бэкендов", - "beKeysHystNote": "UDP-транспорт: работает в HAPP, Hiddify, v2rayN. Строка уже добавлена в подписку — этот ключ для прямого импорта.", - "beKeysAwgNote": "AmneziaVPN — НЕ V2Ray/HAPP: импортируйте конфиг в приложение AmneziaWG/AmneziaVPN. Full-tunnel, клиенты AmneziaVPN ≥ 5.0.1.5. Один ключ — одно устройство.", - "beKeysGrant": "Выдать грант", - "beKeysEndpoint": "Endpoint", - "beKeysAddress": "Адрес" + "backendsConfirmRevokeAwgAll": "Перевыпустить ключи всех AWG-peer'ов? Старые .conf перестанут работать." } } diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index bffce4f5..04d58eaa 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -322,18 +322,9 @@ "backendsConfirmAwg": "移除 AmneziaWG?接口、配置与防火墙规则将被拆除(软件包与模块保留在系统中)。所有 peer 连接中断。", "backendsConfirm31On": "启用 AWG 3.1 模式(HeaderProtectionKey + RandomTrailers)?所有已发出的客户端 .conf 需重新下载。客户端需 AmneziaVPN ≥ 5.0.1.5。", "backendsConfirm31Off": "停用 AWG 3.1 模式?配置恢复 2.0 格式;所有客户端 .conf 需重新下载。", - "backendsConfirmReissueHyst": "重新签发 Hysteria 2 凭据?旧密码与链接将失效。", - "backendsConfirmReissueAwg": "重新签发 AWG peer 密钥?旧 .conf 将失效。", "backendsExpiryBtn": "有效期", "backendsExpiryChoose": "选择要更改有效期的配置档:", "backendsConfirmRevokeHystAll": "重新签发所有 Hysteria 2 密钥?旧密码将失效。", - "backendsConfirmRevokeAwgAll": "重新签发所有 AWG peer 密钥?旧 .conf 将失效。", - "beKeysBtn": "密钥", - "beKeysTitle": "后端密钥", - "beKeysHystNote": "UDP 传输:适用于 HAPP、Hiddify、v2rayN。该行已在订阅中——此密钥用于直接导入。", - "beKeysAwgNote": "AmneziaVPN —— 非 V2Ray/HAPP:请将配置导入 AmneziaWG/AmneziaVPN 应用。全隧道,客户端需 AmneziaVPN ≥ 5.0.1.5。一个密钥 = 一台设备。", - "beKeysGrant": "签发授权", - "beKeysEndpoint": "Endpoint", - "beKeysAddress": "地址" + "backendsConfirmRevokeAwgAll": "重新签发所有 AWG peer 密钥?旧 .conf 将失效。" } } diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index d6957873..812c894f 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -29,7 +29,7 @@ import type { } from '@shared/types' import { describeExpire, isFutureDate, presetDate } from '@shared/expire' import { todayIso } from '@shared/calendar' -import { buildAwgVpnUrl, parseAwgClientConf, stripAwgComments } from '@shared/awg' +import { buildAwgVpnUrl, stripAwgComments } from '@shared/awg' import { isSshAccessReady, SshAccessForm } from '../components/SshAccessForm' import { CalendarPicker } from '../components/CalendarPicker' import { shouldAutoConnectServer } from './server-access' @@ -103,19 +103,12 @@ export function ServerSettings({ | 'awg-uninstall' | 'awg31-on' | 'awg31-off' - | 'hysteria2-regrant' - | 'awg-regrant' | 'hysteria2-revoke-all' | 'awg-revoke-all' | null >(null) - // Срез C: диалог ключей бэкендов у профиля. - const [keysTarget, setKeysTarget] = useState(null) - const [keysHystLink, setKeysHystLink] = useState(null) - const [keysHystErr, setKeysHystErr] = useState(null) - const [keysAwgConf, setKeysAwgConf] = useState(null) - const [keysAwgErr, setKeysAwgErr] = useState(null) - const [keysBusy, setKeysBusy] = useState(false) + // QR-модалка выдачи ключей бэкенд-боксов. Диалог «Ключи» убран: + // полное копирование только на странице Ключей (решение 2026-10-06). const [keysQrUrl, setKeysQrUrl] = useState(null) const [keysQrData, setKeysQrData] = useState(null) const [error, setError] = useState(null) @@ -670,56 +663,6 @@ export function ServerSettings({ } } - // ─── Срез C: диалог ключей бэкендов ─── - const fetchBackendKeys = async (name: string): Promise => { - setKeysHystLink(null) - setKeysHystErr(null) - setKeysAwgConf(null) - setKeysAwgErr(null) - const hyst = await window.api.backends.hysteria2Link(server.id, access, name).catch( - (err: unknown): string => (err instanceof Error ? err.message : String(err)) - ) - if (typeof hyst === 'string') setKeysHystErr(hyst) - else setKeysHystLink(hyst.link ?? null) - const conf = await window.api.backends.awgConf(server.id, access, name).catch( - (err: unknown): string => (err instanceof Error ? err.message : String(err)) - ) - if (typeof conf === 'string') setKeysAwgErr(conf) - else setKeysAwgConf(conf.conf ?? null) - } - - const openBackendKeys = (profile: ServerProfile): void => { - setKeysTarget(profile) - setKeysBusy(true) - void fetchBackendKeys(profile.name).finally(() => setKeysBusy(false)) - } - - const closeBackendKeys = (): void => { - setKeysTarget(null) - setKeysHystLink(null) - setKeysAwgConf(null) - setKeysQrUrl(null) - } - - const grantBackend = async (kind: 'hysteria2' | 'awg'): Promise => { - if (!keysTarget) return - setKeysBusy(true) - try { - if (kind === 'hysteria2') await window.api.backends.hysteria2Grant(server.id, access, keysTarget.name) - else await window.api.backends.awgGrant(server.id, access, keysTarget.name) - await fetchBackendKeys(keysTarget.name) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setKeysBusy(false) - } - } - - const copyKeysText = async (text: string): Promise => { - await navigator.clipboard.writeText(text) - toastText(t('settings.copied')) - } - const showKeysQr = async (data: string): Promise => { setKeysQrUrl(data) setKeysQrData(null) @@ -891,7 +834,6 @@ export function ServerSettings({ const hyst = backends?.backends.hysteria2 const awgEntry = backends?.backends.awg - const keysAwgFields = keysAwgConf ? parseAwgClientConf(keysAwgConf) : null const transportLabel = (profile: ServerProfile): string => profile.multi_route ? `${profile.transport} · ${profile.routes} ${t('settings.routes')}` : profile.transport @@ -1210,15 +1152,6 @@ export function ServerSettings({ {t('settings.copy')} )} - - - - - ) : keysHystErr ? ( - <> -

{keysHystErr}

- - - ) : null} - - -
-
- - AMNEZIAWG 3.1 · UDP - -
-

{t('settings.beKeysAwgNote')}

- {keysAwgFields ? ( - <> -
-
- - {t('settings.beKeysEndpoint')} - - {keysAwgFields.endpoint} -
-
- - {t('settings.beKeysAddress')} - - {keysAwgFields.address} -
-
-
- - - -
- - ) : keysAwgErr ? ( - <> -

{keysAwgErr}

- - - ) : null} -
- - - - - - - -
- { @@ -1726,8 +1517,6 @@ export function ServerSettings({ {confirmBackend === 'awg-uninstall' && t('settings.backendsConfirmAwg')} {confirmBackend === 'awg31-on' && t('settings.backendsConfirm31On')} {confirmBackend === 'awg31-off' && t('settings.backendsConfirm31Off')} - {confirmBackend === 'hysteria2-regrant' && t('settings.backendsConfirmReissueHyst')} - {confirmBackend === 'awg-regrant' && t('settings.backendsConfirmReissueAwg')} {confirmBackend === 'hysteria2-revoke-all' && t('settings.backendsConfirmRevokeHystAll')} {confirmBackend === 'awg-revoke-all' && t('settings.backendsConfirmRevokeAwgAll')} @@ -1743,8 +1532,6 @@ export function ServerSettings({ else if (confirmBackend === 'awg-uninstall') void uninstallAwg() else if (confirmBackend === 'awg31-on') void toggle31(true) else if (confirmBackend === 'awg31-off') void toggle31(false) - else if (confirmBackend === 'hysteria2-regrant') void grantBackend('hysteria2') - else if (confirmBackend === 'awg-regrant') void grantBackend('awg') else if (confirmBackend === 'hysteria2-revoke-all') void revokeBackendKeys('hysteria2') else if (confirmBackend === 'awg-revoke-all') void revokeBackendKeys('awg') setConfirmBackend(null) From c2f746a53882a723b3c421d258a06726e3aba44b Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:34:52 +0300 Subject: [PATCH 53/73] =?UTF-8?q?CLI:=20delete=20=E2=80=94=20=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D1=8B=D0=B9=20JSON=20=D0=B2=20stdout=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B3=D0=B5=D0=BD=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=D0=BE=D0=B2=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Найдено живой приёмкой кнопок GUI на 2.26.125.147: 1. profile-delete печатал цветную строку backup_config в stdout ПЕРЕД redirect-ом (exec 1>&2 стоял после вызова) — GUI получал 'color-line + JSON' и ломал парс: кнопка Удалить показывала ошибку при фактически успешном удалении. Redirect поднят выше backup_config, её ошибка уходит в >&3 как положено. 2. _awg_on_profile_event/_hysteria2_on_profile_event: кейс deleted гвардил [[ -f profile_file ]], но вызывающий удаляет файл ДО lifecycle-вызова — реген молча пропускался, и осиротевший peer (awg0.conf) / пароль (server.yaml) оставались навсегда: удалённый пользователь сохранял доступ к VPN. deleted отделён от expired|restored и регенит безусловно. Регрессионные кейсы в test-awg-lifecycle (carol: rm -> event deleted -> peer снят, активный peer цел) и test-hysteria2-lifecycle (cleo: rm -> event deleted -> грант исчез из users-map, alice не задета). Проверки: bash -n; оба lifecycle-теста зелёные; полный сьют 33/33. --- validation/test-awg-lifecycle.sh | 9 ++++++++ validation/test-hysteria2-lifecycle.sh | 13 +++++++++++ xrayebator | 32 +++++++++++++++++++------- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/validation/test-awg-lifecycle.sh b/validation/test-awg-lifecycle.sh index ee5718e6..67159836 100644 --- a/validation/test-awg-lifecycle.sh +++ b/validation/test-awg-lifecycle.sh @@ -196,6 +196,15 @@ _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" + # grant без backend — безопасный no-op проверяется ниже вместе с uninstall # ── Отсутствие установки: безопасные no-op ── diff --git a/validation/test-hysteria2-lifecycle.sh b/validation/test-hysteria2-lifecycle.sh index 744e6ad0..74ba5219 100644 --- a/validation/test-hysteria2-lifecycle.sh +++ b/validation/test-hysteria2-lifecycle.sh @@ -135,6 +135,19 @@ _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" + # 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"}' \ diff --git a/xrayebator b/xrayebator index dff96eba..f9e50753 100755 --- a/xrayebator +++ b/xrayebator @@ -2341,7 +2341,14 @@ _hysteria2_on_profile_event() { '.backends.hysteria2.password = $p' "$profile_file" || return 1 _hysteria2_regen_config || return 1 ;; - deleted|expired|restored) + deleted) + # Аналогично AWG: файл профиля удалён вызывающим ДО lifecycle — + # реген users-карты обязан пройти без удалённого гранта, иначе + # осиротевший пароль остаётся в server.yaml и удалённый + # пользователь сохраняет доступ. + _hysteria2_regen_config || return 1 + ;; + expired|restored) [[ -f "$profile_file" ]] || return 0 _hysteria2_regen_config || return 1 ;; @@ -3594,7 +3601,14 @@ _awg_on_profile_event() { client_public_key: $cb, preshared_key: $ps})' "$profile_file" || return 1 _awg_regen_config || return 1 ;; - deleted|expired|restored) + 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 ;; @@ -13495,17 +13509,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>&- From 29b36026840a94c057673ab7c945811cccee98ab Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:01:33 +0300 Subject: [PATCH 54/73] =?UTF-8?q?GUI:=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4-?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D0=B5=D0=B9=20=E2=80=94=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BA=20=D1=83=20VLESS=20(=D0=B2=D1=8B=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=20+=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0),=20=D0=B8?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По фидбеку пользователя после живой проверки: 1. Анимация: клик по карточке Hysteria/AWG дёргал синий спиннер в самой карточке. Теперь создание включает общий creating — весь бокс «Создать профиль» получает жёлтую обводку (createCardBusy) и штатный индикатор, ровно как у VLESS-протоколов. 2. Семантика: клик по карточке больше НЕ создаёт профиль немедленно (раньше получалось 2 ключа: VLESS по выбранному транспорту + бэкенд по клику). Карточки Hysteria 2 / AmneziaWG 3.1 стали радиовыбором в общем селекте с транспортами (выбор транспорта сбрасывает бэкенд и наоборот, активная карточка подсвечивается accent-ом), профиль создаётся только кнопкой «Создать профиль». У бэкенд-профиля подкапотный транспорт — xhttp. 3. Имена профилей: бэкенд-боксы в Server Settings озаглавлены именем профиля (Hysteria 2/AWG — в подписи с дескриптором), на странице Ключей в чип Hysteria-карточки добавлено имя профиля (user из ссылки) — раньше по чипу было невозможно отличить ключи разных профилей. i18n: backendsCreateHint переписан под модель «выбери и нажми» (ru/en/zh). Проверки: typecheck чисто; vitest 119/120 (известный Windows-флейк shell-command, к изменениям не относится). --- src/renderer/src/i18n/en.json | 2 +- src/renderer/src/i18n/ru.json | 2 +- src/renderer/src/i18n/zh.json | 2 +- src/renderer/src/pages/ServerKeys.tsx | 2 +- .../src/pages/ServerSettings.module.css | 10 ++++ src/renderer/src/pages/ServerSettings.tsx | 59 +++++++++++-------- 6 files changed, 50 insertions(+), 27 deletions(-) diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index c7c303b7..07d58621 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -302,7 +302,7 @@ "backendsActive": "Active", "backendsNotInstalled": "Not installed", "backendsDevBadge": "WIP · fallback", - "backendsCreateHint": "The profile is created as usual: name, count, expiry and transport are the fields above. The key is issued for the selected protocol.", + "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", diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index 5c53cc9a..85cb7575 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -303,7 +303,7 @@ "backendsActive": "Активен", "backendsNotInstalled": "Не установлен", "backendsDevBadge": "в разработке · запасной", - "backendsCreateHint": "Профиль создаётся как обычно: имя, количество, срок и транспорт — поля выше. Ключ выдаётся для выбранного протокола.", + "backendsCreateHint": "Выберите протокол — обычный VLESS или мультипротокольный — и нажмите «Создать профиль». Имя, количество и срок — поля выше.", "backendsCreateHystDesc": "Быстрый UDP-бэкенд на QUIC: ключ добавляется в подписку, одна ссылка — все устройства", "backendsCreateAwgDesc": "Системный VPN на ядре AmneziaWG 3.1: полный туннель устройства, один ключ — одно устройство", "backendsCreateNotInstalled": "Бэкенд не установлен — установите его в разделе «Мультипротокольные бэкенды» ниже", diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index 04d58eaa..40c7f7e9 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -301,7 +301,7 @@ "backendsActive": "运行中", "backendsNotInstalled": "未安装", "backendsDevBadge": "开发中 · 备用", - "backendsCreateHint": "配置档照常创建:名称、数量、有效期和传输方式在上方字段中。密钥按所选协议签发。", + "backendsCreateHint": "选择协议——普通 VLESS 或多协议——然后点击“创建配置档”。名称、数量和有效期在上方字段中。", "backendsCreateHystDesc": "基于 QUIC 的高速 UDP 后端:密钥自动加入订阅,一条链接——所有设备", "backendsCreateAwgDesc": "基于 AmneziaWG 3.1 内核的系统级 VPN:整机隧道,一个密钥——一台设备", "backendsCreateNotInstalled": "后端未安装——请在下方“多协议后端”部分安装", diff --git a/src/renderer/src/pages/ServerKeys.tsx b/src/renderer/src/pages/ServerKeys.tsx index 5c4f9ce3..7f55ceae 100644 --- a/src/renderer/src/pages/ServerKeys.tsx +++ b/src/renderer/src/pages/ServerKeys.tsx @@ -241,7 +241,7 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme
- HYSTERIA2 · UDP :{hysteria2Port(link)} + HYSTERIA2 · {user} · UDP :{hysteria2Port(link)}

{t('keys.backendsHystNote')}

diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index b0b088d8..6878b87b 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -281,6 +281,16 @@ 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: «основной бокс + боксы протоколов рядом». */ diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index 812c894f..cd85673c 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -123,7 +123,9 @@ export function ServerSettings({ const [transport, setTransport] = useState('xhttp') const [count, setCount] = useState('1') const [creating, setCreating] = useState(false) - const [backendCreateBusy, setBackendCreateBusy] = useState<'hysteria2' | 'awg' | null>(null) + // Выбор мультипротокольного бэкенда в «Создать профиль»: клик по карточке + // = только выбор (как у VLESS-транспортов), создание — кнопкой «Создать профиль». + const [backendSel, setBackendSel] = useState<'hysteria2' | 'awg' | null>(null) const [createExpire, setCreateExpire] = useState('') const [createExpireOpen, setCreateExpireOpen] = useState(false) @@ -723,7 +725,7 @@ export function ServerSettings({ return } setBusy(true) - setBackendCreateBusy(kind) + setCreating(true) setError(null) const beforeNames = new Set((profiles ?? []).map((p) => p.name)) let createdNames: string[] = [] @@ -731,7 +733,7 @@ export function ServerSettings({ try { const result = await window.api.profiles.create(server.id, access, { name: name.trim(), - transport, + transport: 'xhttp', count: Math.min(Math.max(Number(count) || 1, 1), 50), ...(createExpire && isFutureDate(createExpire, Date.now()) ? { expire: createExpire } @@ -780,13 +782,14 @@ export function ServerSettings({ 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) - setBackendCreateBusy(null) + setCreating(false) } // Ключи бэкенд-профиля: карточки в списке профилей (дубликаты стиля @@ -977,7 +980,10 @@ export function ServerSettings({ size="lg" className={styles.createBtn} isDisabled={busy || !accessReady || !name.trim()} - onPress={create} + onPress={() => { + if (backendSel) void createBackendProfiles(backendSel) + else void create() + }} > {busy && } {t('settings.createBtn')} @@ -1004,7 +1010,7 @@ export function ServerSettings({

{t('settings.protocolPrompt')}

{PROTOCOLS.map((proto) => { - const active = transport === proto.id + const active = transport === proto.id && backendSel === null return ( {revokeStep === 'confirm' && ( +
+ + {updateMenuOpen && ( +
+ + + +
+ )} +