diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e02295..c321323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ User-facing Xrayebator changes. The server manager and Electron application are published from the canonical `howdeploy/Xrayebator` repository. +## [Unreleased] + +### Added + +- Subscription revocation in Server settings: a per-profile button opens a menu with two modes. "New link only" reissues the `sub_token`; "full revocation" also rotates the uuid in every inbound of the profile, so devices that already downloaded the configuration are cut off immediately — the only way to actually close access through a leaked link. +- Profile expiry dates: `profile-create --expire`, `profile-expire`, the `expire` field in the profile JSON, a date chip and editor in the GUI, plus server-side enforcement — a `xrayebator-expire.timer` systemd unit runs `xrayebator expire-check` every 10 minutes and switches an expired profile off (the client is removed from the inbounds and restored on renewal). +- The subscription-userinfo `expire` header now has a UI path: HAPP and other clients display the date handed out by the subscription. + +### Fixed + +- Expired or disabled profiles now receive `410 Gone` instead of subscription routes. +- A date-only profile expiry now includes the selected day through `23:59:59` in the server's local timezone; explicit times use that timezone as entered. The server returns its own `expire_date` for GUI display, so a desktop in a different timezone still shows the selected server-local calendar day (covered by a UTC−7 server / UTC+14 client regression test). Date/time fields with leading zeroes such as September (`09`) are accepted correctly. + ## [0.5.5] - 2026-09-25 Connecting to servers that already run Xrayebator, plus fixes found while testing on a live VPS. diff --git a/CLAUDE.md b/CLAUDE.md index 26b512a..823403d 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/`** — 26 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 CLIs, 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/`** — 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, 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. @@ -139,14 +139,17 @@ Apart from the interactive menu (`sudo xrayebator`), the script exposes subcomma - `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 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). -- `xrayebator profile-create --name NAME [--transport T] [--port P] [--count N]` — create 1..N profiles non-interactively (names `name`, `name-2`, ...). Emits `{"ok":true,"names":[...],"errors":[...]}`; `ok` stays `true` even when some profiles already exist (they land in `errors`). +- `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`. +- `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. +- `xrayebator profile-expire --name NAME --expire DATE|epoch|none` — set/extend/clear a profile expiry (`.expire` epoch seconds); date-only expiry is inclusive through `23:59:59` in server-local time; an explicit time is also interpreted in server-local time. Applied immediately (an expired profile loses its client, an extension restores it). +- `xrayebator expire-check` — batch enforcement of every due expiry; idempotent (no changes ⇒ no Xray restart), driven by `xrayebator-expire.timer` every 10 minutes. Disabling keeps the inbounds alive (a fresh inbound would change the shortId and kill issued URLs) and snapshots clients into `.expire_clients` for restoration. - `xrayebator fp-change --name NAME [--route R] --fp FINGERPRINT` — change the fingerprint for a profile (client-side, no Xray restart), emits JSON. - `xrayebator sni-change --name NAME [--route R] --sni SNI` — change the SNI for a profile; updates all profiles on the same port (`update_all_profiles_on_port()`), emits JSON. - `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). +- `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. CLI JSON hygiene: `profile-create`/`profile-delete` **must** print only JSON on stdout. The shared helpers (`backup_config`, `add_inbound`, `open_firewall_port`, `safe_restart_xray`, `close_firewall_port`) print colored status lines that would corrupt the parse, so the CLI paths redirect stdout→stderr around those calls (`exec 3>&1; exec 1>&2 ... exec 1>&3`). Keep it that way when editing. diff --git a/README.md b/README.md index 4d282ff..4f022e7 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,6 @@ vless:// list — HAPP receives 6 of the profile's 7 routes ▼ Reality inbound on a port in 30000-60000 (User=xray, CAP_NET_BIND_SERVICE) │ - ├─ domain in an enabled bypass group ──► freedom (direct, no VPN) │ └─ all other tcp/udp ─────────────────► direct OR cascade-upstream ──► foreign VPS @@ -294,12 +293,13 @@ What the GUI can do: | Add server | Deploy a new VPS with an explicit email choice: `quickstart --email` or `quickstart --without-email`; save the server and public subscription | | Connect existing | Import a recognized Xrayebator installation over SSH (password or key) using read-only `xrayebator inspect --json`; partial installs are saved with diagnostics, without automatic repair | | Server keys | Refresh the public subscription, copy the URL, show `vless://` links and QR codes | -| Server settings | SSH access by password or private key, direct root or sudo; list/create/delete profiles, change fingerprint, SNI and port, plus update or uninstall Xrayebator on the server | +| Server settings | SSH access by password or private key, direct root or sudo; list/create/delete profiles, change fingerprint, SNI and port, revoke the subscription, set a profile expiry date, plus update or uninstall Xrayebator on the server. Expiry dates are displayed in the server's timezone even when the desktop uses another one | -Root + password is the one-click default; key authentication and sudo are optional. A selected private key and a successfully used SSH login password are stored in the operating-system keychain via `keytar` and reused across later SSH operations and app restarts; the server card keeps only their non-secret credential ids and display name. A distinct sudo password and an encrypted-key passphrase are never persisted and are requested again when needed. If the OS keychain is unavailable, there is no plaintext fallback: the secret remains in main-process memory for the current app session and the UI warns that it must be entered again after restart. The app also persists the `subscription_url`, fetched `vless://` links and pinned SSH host-key fingerprint. The subscription URL and VLESS links are bearer/client credentials: protect local app data and revoke the subscription through the terminal workflow after a leak. +Root + password is the one-click default; key authentication and sudo are optional. A selected private key and a successfully used SSH login password are stored in the operating-system keychain via `keytar` and reused across later SSH operations and app restarts; the server card keeps only their non-secret credential ids and display name. A distinct sudo password and an encrypted-key passphrase are never persisted and are requested again when needed. If the OS keychain is unavailable, there is no plaintext fallback: the secret remains in main-process memory for the current app session and the UI warns that it must be entered again after restart. The app also persists the `subscription_url`, fetched `vless://` links and pinned SSH host-key fingerprint. The subscription URL and VLESS links are bearer/client credentials: protect local app data and revoke the subscription from Server settings (full revocation rotates the key as well) after a leak. -The GUI exposes only a subset of the terminal menu. Bypass, `probe-test`, subscription revoke, -`happ-setup`, cascade, self-steal and service logs/status remain terminal-only. See +The GUI exposes only a subset of the terminal menu. Bypass, `probe-test`, `happ-setup`, cascade, +self-steal and service logs/status remain terminal-only; profile expiry dates and subscription +revocation are available in Server settings. See [Electron Desktop GUI](docs/desktop-gui.md) for the complete boundary, security model and packaging details. Build and run in the development mode: diff --git a/README.ru.md b/README.ru.md index 6186c4b..db7f257 100644 --- a/README.ru.md +++ b/README.ru.md @@ -152,7 +152,6 @@ xrayebator-sub.service 127.0.0.1:8080 ▼ Reality-инбаунд на порту 30000-60000 (User=xray, CAP_NET_BIND_SERVICE) │ - ├─ домен из включённой bypass-группы ──► freedom (напрямую, без VPN) │ └─ весь остальной tcp/udp ────────────► direct ИЛИ cascade-upstream ──► зарубежный VPS @@ -290,12 +289,13 @@ xrayebator (bash) ──► /usr/local/etc/xray/ | Добавить сервер | Развернуть VPS с явным выбором email: `quickstart --email` или `quickstart --without-email`; сохранить сервер и публичную подписку | | Подключить существующий | Импорт распознанной установки Xrayebator по SSH (пароль или ключ) через read-only `xrayebator inspect --json`; частичные установки сохраняются с диагностикой без автоисправления | | Ключи сервера | Обновить публичную подписку, скопировать URL, показать ссылки `vless://` и QR-коды | -| Настройки сервера | SSH по паролю или приватному ключу, прямой root или sudo; список/создание/удаление профилей, смена fingerprint, SNI и порта, обновление или удаление Xrayebator | +| Настройки сервера | SSH по паролю или приватному ключу, прямой root или sudo; список/создание/удаление профилей, смена fingerprint, SNI и порта, отзыв подписки, срок действия профиля, обновление или удаление Xrayebator; дата срока показывается по часовой зоне сервера даже при другом поясе на компьютере | -Выбранные приватный ключ и успешно использованный SSH-пароль сохраняются через `keytar` в системном keychain и повторно используются при следующих SSH-операциях и после перезапуска приложения; в карточке сервера хранятся только несекретные credential id и отображаемое имя ключа. Отдельный sudo-пароль и passphrase зашифрованного ключа не сохраняются и запрашиваются заново. Если системный keychain недоступен, plaintext-фолбека нет: секрет остаётся в памяти main process до завершения текущего сеанса, а GUI предупреждает, что после перезапуска его нужно ввести снова. Также локально сохраняются `subscription_url`, ссылки `vless://` и закреплённый SSH host-key fingerprint. Это bearer/client credentials: защищайте локальные данные и при утечке отзывайте подписку через терминальный workflow. +Выбранные приватный ключ и успешно использованный SSH-пароль сохраняются через `keytar` в системном keychain и повторно используются при следующих SSH-операциях и после перезапуска приложения; в карточке сервера хранятся только несекретные credential id и отображаемое имя ключа. Отдельный sudo-пароль и passphrase зашифрованного ключа не сохраняются и запрашиваются заново. Если системный keychain недоступен, plaintext-фолбека нет: секрет остаётся в памяти main process до завершения текущего сеанса, а GUI предупреждает, что после перезапуска его нужно ввести снова. Также локально сохраняются `subscription_url`, ссылки `vless://` и закреплённый SSH host-key fingerprint. Это bearer/client credentials: защищайте локальные данные и при утечке отзывайте подписку в настройках сервера (полный отзыв меняет и ключ). -GUI предоставляет только подмножество терминального меню. Bypass, `probe-test`, revoke подписки, -`happ-setup`, каскад, self-steal и логи/статус сервисов остаются терминальными операциями. Полная +GUI предоставляет только подмножество терминального меню. `probe-test`, `happ-setup`, каскад, +self-steal и логи/статус сервисов остаются терминальными операциями; сроки действия профилей +и отзыв подписки доступны в настройках сервера. Полная граница возможностей, security model и packaging описаны в [справочнике Electron GUI](docs/ru/desktop-gui.md). Сборка и запуск в dev-режиме: diff --git a/README.zh-CN.md b/README.zh-CN.md index 488cb65..ed05b34 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -278,12 +278,12 @@ GUI 的功能: | 添加服务器 | 显式选择是否提供 email:`quickstart --email` 或 `quickstart --without-email`;保存服务器与公网订阅 | | 连接现有服务器 | 通过 SSH(密码或密钥)和只读 `xrayebator inspect --json` 导入已识别的 Xrayebator;部分安装会连同诊断状态保存,不自动修复 | | 服务器密钥 | 刷新公网订阅、复制链接、显示 `vless://` 链接与二维码 | -| 服务器设置 | 使用 SSH 密码或私钥、直接 root 或 sudo:列出/创建/删除配置档,修改指纹、SNI 和端口,以及更新或卸载服务器上的 Xrayebator | +| 服务器设置 | 使用 SSH 密码或私钥、直接 root 或 sudo:列出/创建/删除配置档,修改指纹、SNI 和端口,吊销订阅,设置配置档有效期,以及更新或卸载服务器上的 Xrayebator;即使桌面与 VPS 时区不同,有效期日期也按服务器本地日期显示 | -选中的私钥与成功登录时使用过的 SSH 密码都会通过 `keytar` 保存在操作系统钥匙串中,之后的 SSH 操作和应用重启均可复用;服务器卡片只保存非敏感的 credential id 和显示文件名。单独的 sudo 密码与加密私钥口令不会持久化,需要时重新输入。系统钥匙串不可用时不会写入明文回退文件:密钥仅保留在 main process 内存中直到当前会话结束,界面会提示重启后需重新输入。应用还会保存 `subscription_url`、获取到的 `vless://` 链接和固定的 SSH host-key fingerprint。这些是 bearer/client credentials:请保护本地应用数据,泄露后通过终端 workflow 吊销订阅。 +选中的私钥与成功登录时使用过的 SSH 密码都会通过 `keytar` 保存在操作系统钥匙串中,之后的 SSH 操作和应用重启均可复用;服务器卡片只保存非敏感的 credential id 和显示文件名。单独的 sudo 密码与加密私钥口令不会持久化,需要时重新输入。系统钥匙串不可用时不会写入明文回退文件:密钥仅保留在 main process 内存中直到当前会话结束,界面会提示重启后需重新输入。应用还会保存 `subscription_url`、获取到的 `vless://` 链接和固定的 SSH host-key fingerprint。这些是 bearer/client credentials:请保护本地应用数据,泄露后在服务器设置中吊销订阅(完全吊销会同时更换密钥)。 -GUI 只暴露终端菜单的一个子集。bypass、`probe-test`、订阅吊销、`happ-setup`、级联、self-steal -以及服务日志/状态仍需从终端执行。完整的 Electron GUI 边界、安全模型与打包说明见 +GUI 只暴露终端菜单的一个子集。`probe-test`、`happ-setup`、级联、self-steal +以及服务日志/状态仍需从终端执行;配置档有效期和订阅吊销可在服务器设置中使用。完整的 Electron GUI 边界、安全模型与打包说明见 [Electron 桌面 GUI](docs/zh-CN/desktop-gui.md)。 开发模式下的构建与运行: diff --git a/docs/architecture.md b/docs/architecture.md index a3fdbeb..b7b92af 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 and subscription token +├── profiles/.json # profile metadata, subscription token, expiry (.expire) ├── upstreams/cascade.json # cascade upstream parameters ├── backups/ # config backups made before runtime mutations ├── .private_key / .public_key # Reality keys @@ -110,7 +110,13 @@ choice. The non-interactive `quickstart --email
` and `quickstart --wit provision nginx, certificate and markers on `8443`, then emit JSON containing `subscription_url` for that endpoint. Without an email, Certbot is explicitly told to register without an ACME contact; renewal notices and email-based recovery are unavailable. The token is stored in the profile as `sub_token`; -revoke rotates it and invalidates the previous URL. +revoke rotates it and invalidates the previous URL, while a full revoke (`profile-revoke --full`) also +changes the uuid in every inbound of the profile so already-downloaded configurations stop connecting. +A profile expiry lives in `expire` (epoch seconds) and is enforced by the `xrayebator-expire.timer` +systemd timer through `xrayebator expire-check` every 10 minutes. A date entered without a time is +inclusive through `23:59:59` in the server's local timezone; an explicit time uses that timezone too. The +`profiles` JSON also carries `expire_date` in that timezone, so the GUI does not have to infer the server's +date from the client's local rendering of an epoch timestamp. A newly provisioned standard HAPP managed profile has `schema_version: 3` and seven routes, including `xhttp-legacy` and `xhttp-pq`. The published HAPP connection list contains six VLESS diff --git a/docs/configuration.md b/docs/configuration.md index c703ccc..096116c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -4,7 +4,6 @@ Sections: [Prerequisites](#prerequisites-and-tested-systems) · [Environment variables](#installer-environment-variables) · [Firewall and host networking](#firewall-and-host-networking) · [Main menu](#main-menu) · -[Commands](#commands) · [Desktop GUI](#desktop-gui) · [Bypass routing](#bypass-routing) · [Cascade](#cascade-and-upstream-nodes) · [Self-steal](#custom-domain-and-self-steal-stub) · [Domain and DNS](#domain-and-dns) --- @@ -94,7 +93,6 @@ 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 directly instead of through the VPN | | `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 | @@ -116,9 +114,12 @@ is a client-side profile/route setting; changing it does not restart Xray or alt | `sudo xrayebator quickstart --without-email` | Same new-server path without an ACME contact email; Certbot uses `--register-unsafely-without-email`, so no renewal notices or email-based account recovery are available | | `sudo xrayebator inspect --json` | Read-only GUI import probe: reports manager, Xray, profile and subscription markers without installing, migrating or changing services/configuration | | `sudo xrayebator happ-setup` | Reduced existing-install HAPP path: ensures the subscription service and a usable multi-route profile, but does not replace the endpoint prerequisite; when `.subscription_domain` or `.subscription_port` is missing, it verifies a real public TLS endpoint before writing markers and otherwise fails | -| `sudo xrayebator profiles` | Print all server profiles as a JSON array for the desktop GUI Server Settings page | -| `sudo xrayebator profile-create --name NAME [--transport tcp\|tcp-utls\|tcp-xudp\|tcp-mux\|grpc\|xhttp] [--port P] [--count N]` | Create one or more profiles non-interactively; prints `{"ok":true,"names":[...],"errors":[...]}` | +| `sudo xrayebator profiles` | Print all server profiles as a JSON array for the desktop GUI Server Settings page; expiry includes both epoch seconds (`expire`) for enforcement and the server-local calendar date (`expire_date`) for display, so clients in another timezone still see the selected date | +| `sudo xrayebator profile-create --name NAME [--transport tcp\|tcp-utls\|tcp-xudp\|tcp-mux\|grpc\|xhttp] [--port P] [--count N] [--expire DATE]` | Create one or more profiles non-interactively; `--expire` accepts `YYYY-MM-DD[ HH:MM]`, epoch seconds or 13-digit milliseconds. A date without time is inclusive through `23:59:59` in the server's local timezone; an explicit time uses that server-local time. Past expiries are rejected; prints `{"ok":true,"names":[...],"errors":[...]}` | | `sudo xrayebator profile-delete --name NAME` | Delete a profile non-interactively; prints `{"ok":true,"name":"..."}` | +| `sudo xrayebator profile-revoke --name NAME [--full]` | Reissue the subscription link: new `sub_token`; with `--full` also a new uuid in every inbound of the profile (already-downloaded configs are cut off); prints JSON | +| `sudo xrayebator profile-expire --name NAME --expire DATE\|epoch\|none` | Set, extend or remove a profile expiry; a date without time is inclusive through `23:59:59` in the server's local timezone, while an explicit time is used as given in that timezone; applied immediately (a passed expiry removes the client, an extension restores it); prints JSON | +| `sudo xrayebator expire-check` | Apply every due expiry in one batch; idempotent and never restarts Xray without changes. Driven by the `xrayebator-expire.timer` unit every 10 minutes | | `sudo xrayebator fp-change --name NAME [--route R] --fp FINGERPRINT` | Change the client fingerprint for one profile route; prints JSON | | `sudo xrayebator sni-change --name NAME [--route R] --sni SNI` | Change the shared inbound SNI and synchronise profiles on that port; prints JSON | | `sudo xrayebator sni-list` | Print SNI candidates grouped by category for the GUI SNI dialog; prints JSON | @@ -174,9 +175,8 @@ profile when `xhttp-legacy`, `xhttp-pq` or the expected seven-route shape is mis The active Electron app is a CLI front-end over SSH, not a complete replacement for the terminal menu. It deploys with `quickstart`, refreshes the saved `subscription_url`, and exposes profile -list/create/delete plus selected SNI, fingerprint, port, update and uninstall operations. Bypass, -probe, revoke, HAPP setup, cascade, self-steal, the interactive menu and service diagnostics remain -server-side operations. +SNI, fingerprint, port, update and uninstall operations. `probe-test`, HAPP setup, cascade, +self-steal, the interactive menu and service diagnostics remain server-side operations. See [Electron Desktop GUI](desktop-gui.md) for the complete command mapping, security boundary, packaging and test details. @@ -191,9 +191,7 @@ npm test npm run typecheck ``` -## Bypass routing -Bypass adds Xray routing rules so selected domains go straight out through `freedom` instead of the VPN. The `domain -> direct` rules sit above the catch-all, so they keep working with the cascade enabled. diff --git a/docs/desktop-gui.md b/docs/desktop-gui.md index 2c6add2..4b5865a 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. +Server keys refreshes the subscription from the saved `subscription_url` and displays the returned VLESS routes. Each VLESS link can be copied or rendered as a QR code; the subscription URL can also be copied, and the page offers a copy-all action. This page does not create a separate server-side subscription or rotate a subscription token (rotation lives on the profile cards in Server settings). ### Server settings @@ -64,7 +64,7 @@ SNI and port are inbound-level settings: changing them can affect every profile The GUI supports SSH password authentication or a private key, with either direct `root` execution or elevated commands through `sudo`. A private key is selected through the native Electron file dialog; the main process reads the bytes, stores them in the operating-system keychain via `keytar` (Windows Credential Manager, macOS Keychain, or Linux Secret Service), and returns to the renderer only a non-secret credential id plus the display file name. The key is reused across later operations and app restarts without re-picking the file. If the OS keychain is unavailable, the key is kept only in main-process memory for the current app session and the UI warns that reuse after restart is unavailable; there is no plaintext fallback on disk. -The SSH login password is persisted to the operating-system keychain after the first successful authentication and reused across later operations and app restarts; the server card stores only its non-secret credential id. A distinct sudo password and an encrypted-key passphrase are never persisted — they are asked again when needed. Private-key bytes and password values never cross the preload boundary: the renderer receives only credential ids and display names. `electron-store` persists the server card and connection preferences, the subscription URL, and the fetched VLESS links (bearer/client credentials), as well as the username, authentication method, privilege mode, credential ids, display key name, installation diagnostics, and the SHA-256 SSH host-key pin. Protect the local application data; if the subscription URL or VLESS links leak, revoke the subscription through the terminal workflow. A later fingerprint mismatch fails closed before commands are executed; an intentional server reinstall requires an explicit host-key reset in Server settings. Removing the last card that references a credential deletes the matching keychain entry; shared references are preserved. +The SSH login password is persisted to the operating-system keychain after the first successful authentication and reused across later operations and app restarts; the server card stores only its non-secret credential id. A distinct sudo password and an encrypted-key passphrase are never persisted — they are asked again when needed. Private-key bytes and password values never cross the preload boundary: the renderer receives only credential ids and display names. `electron-store` persists the server card and connection preferences, the subscription URL, and the fetched VLESS links (bearer/client credentials), as well as the username, authentication method, privilege mode, credential ids, display key name, installation diagnostics, and the SHA-256 SSH host-key pin. Protect the local application data; if the subscription URL or VLESS links leak, revoke the subscription from Server settings (full revocation rotates the key as well). A later fingerprint mismatch fails closed before commands are executed; an intentional server reinstall requires an explicit host-key reset in Server settings. Removing the last card that references a credential deletes the matching keychain entry; shared references are preserved. The Electron boundary includes the following protections: @@ -80,12 +80,15 @@ The profile API exposed by the GUI maps to these Bash CLI commands: ```text xrayebator profiles -xrayebator profile-create --name NAME [--transport T] [--port P] [--count N] +xrayebator profile-create --name NAME [--transport T] [--port P] [--count N] [--expire DATE] xrayebator profile-delete --name NAME +xrayebator profile-revoke --name NAME [--full] +xrayebator profile-expire --name NAME --expire DATE|epoch|none xrayebator fp-change --name NAME [--route R] --fp FINGERPRINT xrayebator sni-change --name NAME [--route R] --sni SNI xrayebator sni-list 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] ``` Deployment additionally invokes: @@ -106,9 +109,7 @@ The result consumed by the GUI uses `subscription_url`; the GUI then fetches tha The active Electron GUI does **not** expose the following server features: ```text -bypass probe-test -revoke happ-setup cascade self-steal @@ -116,7 +117,12 @@ interactive terminal menu service logs/status ``` -In particular, the Dashboard reachability dot must not be read as access to `probe-test`, and the keys page must not be read as access to `revoke`. +In particular, the Dashboard reachability dot must not be read as access to `probe-test`. + +Server settings additionally provides: + +- **Subscription revocation** — a per-profile button opens a menu with two modes. "New link only" reissues the `sub_token` (the previous URL stops working; routes and keys stay the same). "Full revocation" also rotates the uuid in every inbound of the profile, so devices that already downloaded the config are cut off immediately — the only way to actually close access through a leaked link. +- **Profile expiry** — the date travels to the client in the subscription header and is enforced server-side: the `xrayebator-expire.timer` systemd timer runs `xrayebator expire-check` every 10 minutes and removes the client from the inbounds once the expiry timestamp passes. A date selected without a time is inclusive through `23:59:59` in the server's local timezone. The GUI displays the server's calendar date from `expire_date`, so it stays correct even when the desktop and VPS use different timezones. Extending the date restores the same client. An expiry can also be set at profile-creation time. ## Deployment protocol @@ -178,4 +184,4 @@ The active desktop implementation is `src/`. A `gui-v*` artifact or a passing `g - There are no React/Electron runtime integration tests. The Electron side has unit tests, TypeScript checks, build checks, and manual/live-server validation, but no test that boots the full packaged renderer and main-process flow together. - One Vitest test uses POSIX `/bin/sh` (`tests/unit/shell-command.test.ts`). On Windows this is a known caveat; Linux is the source of truth for that shell-specific test. - The auto-updater is packaged-only, uses the GitHub provider configured for `howdeploy`, and follows auto-download/install-on-quit behavior; `npm run dev` does not simulate a release update. -- The GUI intentionally exposes only the command surface documented above. Use the Bash `xrayebator` interface or server-side commands for bypass, probing, subscription revocation, HAPP setup, cascade, self-steal, terminal menu, and service logs/status. +- The GUI intentionally exposes only the command surface documented above. Use the Bash `xrayebator` interface or server-side commands for probing, HAPP setup, cascade, self-steal, terminal menu, and service logs/status. diff --git a/docs/ru/architecture.md b/docs/ru/architecture.md index 88ea8a5..e2f7f64 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 # метаданные профиля и токен подписки +├── profiles/.json # метаданные профиля, токен подписки, срок действия (.expire) ├── upstreams/cascade.json # параметры upstream каскада ├── backups/ # бэкапы конфига перед runtime-мутациями ├── .private_key / .public_key # ключи Reality @@ -105,7 +105,13 @@ http://127.0.0.1:8080/sub/ # local-only запасной выбор. Нон-интерактивные IP-TLS пути `quickstart --email
` и `quickstart --without-email` создают nginx, сертификат и маркеры на `8443`, затем возвращают JSON с `subscription_url` для endpoint. Без email Certbot регистрирует ACME-аккаунт без контактного адреса: уведомления о продлении и восстановление -по email недоступны. Токен хранится в профиле как `sub_token`; revoke меняет его и аннулирует старый URL. +по email недоступны. Токен хранится в профиле как `sub_token`; revoke меняет его и аннулирует старый URL, +а полный отзыв (`profile-revoke --full`) дополнительно меняет uuid во всех inbound'ах профиля, поэтому +уже скачанные конфигурации перестают подключаться. Срок действия профиля хранится как `expire` (epoch-секунды) +и принуждается серверным таймером `xrayebator-expire.timer` → `xrayebator expire-check` каждые 10 минут. +Дата без времени включительна до `23:59:59` по локальным часам сервера; явно заданное время трактуется +в той же временной зоне. JSON команды `profiles` также содержит `expire_date` в зоне сервера, чтобы GUI +не восстанавливал её из epoch по часовому поясу компьютера пользователя. Новый стандартный managed HAPP-профиль — schema-v3 профиль из семи маршрутов, включая `xhttp-legacy` и post-quantum XHTTP route. Публикуемый список HAPP содержит шесть VLESS-маршрутов, потому что diff --git a/docs/ru/configuration.md b/docs/ru/configuration.md index 38ff00b..2493630 100644 --- a/docs/ru/configuration.md +++ b/docs/ru/configuration.md @@ -5,7 +5,6 @@ Разделы: [Требования и проверенные системы](#требования-и-проверенные-системы) · [Переменные окружения установщика](#переменные-окружения-установщика) · [Firewall и параметры хоста](#firewall-и-параметры-хоста) · [Главное меню](#главное-меню) · -[Команды](#команды) · [Десктоп-GUI](#десктоп-gui) · [Bypass routing](#bypass-routing) · [Каскад](#каскад-и-upstream-ноды) · [Self-steal](#собственный-домен-и-self-steal-заглушка) · [Домен и DNS](#домен-и-dns) @@ -96,7 +95,6 @@ legacy-файлы и блоки, ранее созданные Xrayebator, и с | `4` | Управление профилем: SNI, клиентский fingerprint, порт и продвинутые настройки | | `5` | Обновить отдельный профиль до post-quantum XHTTP + Reality | | `6` | Подписка HAPP: настройка публичного/локального publishing, URL/QR, revoke и HAPP-настройки; managed профиль имеет 7 маршрутов, публикуемый список — 6 | -| `7` | Bypass routing: выбранные домены напрямую, не через VPN | | `8` | Каскад и upstream-ноды | | `9` | Собственный домен и self-steal заглушка | | `10` | Поднять outbound-сервер, чтобы другой VPS мог использовать этот как зарубежную ноду каскада | @@ -119,9 +117,12 @@ legacy-файлы и блоки, ранее созданные Xrayebator, и с | `sudo xrayebator quickstart --without-email` | Тот же путь развёртывания без ACME email; Certbot регистрирует аккаунт с `--register-unsafely-without-email`, поэтому уведомления и восстановление аккаунта по email недоступны | | `sudo xrayebator inspect --json` | Read-only проверка установки для GUI-импорта: возвращает состояние Xray, профилей и маркеров подписки; не запускает установку, миграции или изменения конфигурации | | `sudo xrayebator happ-setup` | Сокращённый re-entry на существующей установке: проверяет subscription service и usable multi-route profile; при отсутствии markers проверяет IP-TLS endpoint на `8443`, но не фабрикует markers | -| `sudo xrayebator profiles` | Вывести все профили сервера JSON-массивом (для «Настроек сервера» GUI) | -| `sudo xrayebator profile-create --name ИМЯ [--transport tcp\|tcp-utls\|tcp-xudp\|tcp-mux\|grpc\|xhttp] [--port P] [--count N]` | Создать профили без интерактива; `{"ok":true,"names":[...],"errors":[...]}` | +| `sudo xrayebator profiles` | Вывести все профили сервера JSON-массивом (для «Настроек сервера» GUI); срок содержит epoch-секунды (`expire`) для принуждения и календарную дату в зоне сервера (`expire_date`) для отображения, поэтому клиент с другой временной зоной видит выбранную дату | +| `sudo xrayebator profile-create --name ИМЯ [--transport tcp\|tcp-utls\|tcp-xudp\|tcp-mux\|grpc\|xhttp] [--port P] [--count N] [--expire ДАТА]` | Создать профили без интерактива; `--expire` принимает `ГГГГ-ММ-ДД[ ЧЧ:ММ]`, epoch-секунды или 13-значные миллисекунды. Дата без времени действует включительно до `23:59:59` по локальным часам сервера; указанное время трактуется в той же зоне. Уже прошедший срок отклоняется; `{"ok":true,"names":[...],"errors":[...]}` | | `sudo xrayebator profile-delete --name ИМЯ` | Удалить профиль без интерактива; `{"ok":true,"name":"..."}` | +| `sudo xrayebator profile-revoke --name ИМЯ [--full]` | Перевыпустить ссылку подписки: новый `sub_token`; с `--full` — ещё и новый uuid во всех inbound'ах профиля (уже скачанные конфиги отваливаются); JSON | +| `sudo xrayebator profile-expire --name ИМЯ --expire ДАТА\|epoch\|none` | Задать, продлить или снять срок действия профиля; дата без времени действует включительно до `23:59:59` по локальным часам сервера, указанное время используется как есть в той же зоне; применение немедленное (истёкший срок сразу снимает клиента, продление возвращает); JSON | +| `sudo xrayebator expire-check` | Применить все наступившие сроки пакетно; идемпотентно, без изменений не перезапускает Xray. Вызывается таймером `xrayebator-expire.timer` каждые 10 минут | | `sudo xrayebator fp-change --name ИМЯ [--route R] --fp ОТПЕЧАТОК` | Сменить клиентский fingerprint для одного маршрута профиля; JSON | | `sudo xrayebator sni-change --name ИМЯ [--route R] --sni SNI` | Сменить общий inbound SNI и синхронизировать профили на этом порту; JSON | | `sudo xrayebator sni-list` | Вывести SNI-кандидаты по категориям для GUI; JSON | @@ -150,8 +151,8 @@ legacy-файлы и блоки, ранее созданные Xrayebator, и с Активное Electron-приложение — это CLI-фронтенд поверх SSH, а не полная замена терминальному меню. Оно выполняет деплой через `quickstart`, обновляет сохранённый `subscription_url` и предоставляет -операции списка/создания/удаления профилей плюс выбранные SNI, fingerprint, port, update и uninstall. -Bypass, probe, revoke, HAPP setup, cascade, self-steal, интерактивное меню и диагностика сервиса +и группы, а также выбранные SNI, fingerprint, port, update и uninstall. +`probe-test`, HAPP setup, cascade, self-steal, интерактивное меню и диагностика сервиса остаются серверными операциями. См. [Electron Desktop GUI](desktop-gui.md) для полного описания команд, границы безопасности, сборки @@ -167,9 +168,7 @@ npm test npm run typecheck ``` -## Bypass routing -Bypass добавляет правила в Xray routing, чтобы выбранные домены шли через `freedom` напрямую, не через VPN. Правила `domain -> direct` стоят выше catch-all, поэтому продолжают работать и при включённом каскаде. diff --git a/docs/ru/desktop-gui.md b/docs/ru/desktop-gui.md index 4a742af..c432235 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 keys обновляет подписку по сохранённому `subscription_url` и показывает полученные VLESS-маршруты. Каждый VLESS-ключ можно скопировать или показать как QR-код; URL подписки также копируется, есть действие «скопировать всё». Эта страница не создаёт отдельную подписку на сервере и не меняет токен подписки (перевыпуск — на карточках профилей в Server settings). ### Server settings @@ -81,12 +81,15 @@ GUI поддерживает SSH-аутентификацию по паролю ```text xrayebator profiles -xrayebator profile-create --name NAME [--transport T] [--port P] [--count N] +xrayebator profile-create --name NAME [--transport T] [--port P] [--count N] [--expire DATE] xrayebator profile-delete --name NAME +xrayebator profile-revoke --name NAME [--full] +xrayebator profile-expire --name NAME --expire DATE|epoch|none xrayebator fp-change --name NAME [--route R] --fp FINGERPRINT xrayebator sni-change --name NAME [--route R] --sni SNI xrayebator sni-list 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] ``` При развёртывании дополнительно вызывается один из вариантов: @@ -107,9 +110,7 @@ GUI использует поле `subscription_url` из результата, Активный Electron-GUI **не предоставляет** следующие возможности сервера: ```text -bypass probe-test -revoke happ-setup cascade self-steal @@ -117,7 +118,12 @@ self-steal логи/статус сервисов ``` -В частности, точка доступности на Dashboard не означает наличие доступа к `probe-test`, а страница ключей не предоставляет `revoke`. +В частности, точка доступности на Dashboard не означает наличие доступа к `probe-test`. + +На странице Server settings доступны, помимо смены SNI/fingerprint/порта: + +- **Revoke подписки** — кнопка у профиля открывает меню с двумя режимами. «Только новая ссылка» перевыпускает `sub_token` (прежняя URL перестаёт работать, маршруты и ключи те же). «Полный отзыв» дополнительно меняет uuid во всех inbound'ах профиля: устройства, уже скачавшие конфиг, отключаются немедленно — это единственный способ реально закрыть доступ по утёкшей ссылке. +- **Срок действия профиля** — дата передаётся клиенту в заголовке подписки и принудительно применяется сервером: systemd-таймер `xrayebator-expire.timer` раз в 10 минут вызывает `xrayebator expire-check` и снимает клиента с inbound'ов после наступления срока. Дата без времени действует включительно до `23:59:59` по локальным часам сервера. GUI получает календарную дату сервера в `expire_date`, поэтому она не сдвигается, даже если временные зоны компьютера и VPS различаются. Продление возвращает того же клиента. Срок также можно задать сразу при создании профиля. ## Протокол развёртывания @@ -179,4 +185,3 @@ Electron packaging использует GitHub provider, настроенный - Нет React/Electron runtime integration tests. Для Electron есть unit-тесты, TypeScript-проверки, build-проверки и ручная проверка на живом сервере, но нет теста, который одновременно запускает полный packaged renderer и main process. - Один Vitest-тест использует POSIX `/bin/sh` (`tests/unit/shell-command.test.ts`). В Windows это известное ограничение; источником истины для shell-специфичного теста является Linux. - Auto-updater работает только в packaged build, использует GitHub provider, настроенный для `howdeploy`, и работает в режиме auto-download/install-on-quit; `npm run dev` не симулирует обновление релиза. -- GUI намеренно предоставляет только описанную выше поверхность команд. Для bypass, probe-test, отзыва подписки, HAPP setup, cascade, self-steal, terminal menu и логов/статуса сервисов используйте Bash-интерфейс `xrayebator` или серверные команды. diff --git a/docs/ru/security.md b/docs/ru/security.md index d798e0b..4bce9b1 100644 --- a/docs/ru/security.md +++ b/docs/ru/security.md @@ -49,9 +49,10 @@ URL подписки — bearer-credential. Он не публичен, но л - токен 32 hex-символа, генерируется `openssl rand -hex 16`; - `/sub/` без валидного токена возвращает одинаковый `404`; - профиль без живых маршрутов возвращает `410` и не выдаёт маршруты; +- профиль с истёкшим или отключённым сроком тоже возвращает `410 Gone` (`Profile expired or disabled`) — маршруты не отдаются вовсе, даже если клиент ещё не обновил подписку; - nginx добавляет `Cache-Control: no-store` и rate-limit на location подписки; - корень `/` и пути вне `/sub/` возвращают `404`; -- `Revoke` меняет `sub_token`, старый URL перестаёт работать. +- `Revoke` меняет `sub_token`, старый URL перестаёт работать; полный отзыв (`profile-revoke --full`) дополнительно меняет uuid во всех inbound'ах профиля, поэтому уже скачанные конфигурации перестают подключаться. Что остаётся на операторе: @@ -110,7 +111,7 @@ TCPKeepAlive yes Если системный keychain недоступен, plaintext-фолбека на диске нет: ключ остаётся только в памяти main process до выхода из приложения, а GUI предупреждает, что после перезапуска ключ придётся выбрать снова. SSH-пароль сохраняется в системный keychain только после первого успешного входа и далее переиспользуется; отдельный sudo-пароль и passphrase зашифрованного ключа не сохраняются и вводятся заново при необходимости. -`electron-store` сохраняет карточку сервера и настройки подключения, credential id и имя ключа, диагностику установки, `subscription_url`, полученные `vless://`-ссылки и SHA-256 pin SSH host key. URL подписки и VLESS-ссылки — bearer-credentials: защищайте локальные данные Electron-приложения и отзывайте подписку при утечке. При удалении последней карточки, ссылающейся на credential, соответствующая запись keychain удаляется; общая запись остаётся, пока на неё ссылается другая карточка. +`electron-store` сохраняет карточку сервера и настройки подключения, credential id и имя ключа, диагностику установки, `subscription_url`, полученные `vless://`-ссылки и SHA-256 pin SSH host key. URL подписки и VLESS-ссылки — bearer-credentials: защищайте локальные данные Electron-приложения и отзывайте подписку при утечке (в настройках сервера; полный отзыв меняет и клиентский ключ, поэтому уже скачанные конфиги перестают работать). При удалении последней карточки, ссылающейся на credential, соответствующая запись keychain удаляется; общая запись остаётся, пока на неё ссылается другая карточка. Импорт по SSH распознаёт только установку Xrayebator и по умолчанию выполняет read-only диагностику; частично настроенный сервер сохраняется с честными статусами без автоматического исправления. Email при развёртывании можно не указывать: тогда Certbot использует `--register-unsafely-without-email`, поэтому уведомления о продлении и восстановление ACME-аккаунта будут недоступны. GUI предупреждает об этом до запуска. diff --git a/docs/ru/testing.md b/docs/ru/testing.md index 17aec35..496b2d7 100644 --- a/docs/ru/testing.md +++ b/docs/ru/testing.md @@ -38,8 +38,9 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done | `test-main-menu-numbering.sh` | Нумерацию пунктов меню и их соответствие обработчикам | | `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-port-change-cli.sh` | CLI `port-change`: сценарии unit/shared/move, неверный порт, multi-route `--route` | | `test-bypass-cli.sh` | CLI `bypass`: JSON stdout, routing-правила, add с проверкой SNI | +| `test-port-change-cli.sh` | CLI `port-change`: сценарии unit/shared/move, неверный порт, multi-route `--route` | +| `test-profile-revoke-expire-cli.sh` | CLI `profile-revoke` (только токен / ротация uuid с сохранением чужих клиентов на общем порту) и сроки профилей (отключение, возврат из снимка, rebuild без снимка, идемпотентный `expire-check`) | | `test-apt-lock-race.sh` | Гонка apt-lock: `DPkg::Lock::Timeout` при установках, учёт воркера `unattended-upgrade` и бюджет 12 минут в quickstart | | `test-quickstart-email-and-inspect.sh` | Явный email-режим `quickstart` (`--without-email` без фиктивного адреса) и read-only инварианты `inspect --json` | | `test-quickstart-migration-parity.sh` | `quickstart` гоняет те же критичные миграции, что и `main_menu` | diff --git a/docs/ru/troubleshooting.md b/docs/ru/troubleshooting.md index a8974f7..4b7fd5f 100644 --- a/docs/ru/troubleshooting.md +++ b/docs/ru/troubleshooting.md @@ -108,6 +108,24 @@ Fingerprint — клиентский по выбранному профилю/м маршруты. Серверная подписка меняется сразу, но HAPP нужно обновить принудительно или дождаться очередного автообновления. +## Подписка возвращает 410, хотя профиль выглядит рабочим + +Профиль отключён по сроку действия: в profile JSON стоит `.expire_disabled: true` либо `.expire` +(epoch-секунды) уже в прошлом. Принуждение выполняет systemd-таймер `xrayebator-expire.timer` +(каждые 10 минут, `xrayebator expire-check`): он снимает клиента с inbound'ов, поэтому подключиться +нельзя даже по уже скачанной ссылке, а подписка отвечает `410 Gone` с телом `Profile expired or +disabled`. Если при выборе срока указана только дата (например, 30 сентября), профиль действует весь +этот день и истекает в `23:59:59` по локальным часам сервера; явное время из CLI также трактуется в +часовом поясе сервера. Продлите срок или снимите его: + +```bash +sudo xrayebator profile-expire --name ИМЯ --expire 2026-12-31 # продлить +sudo xrayebator profile-expire --name ИМЯ --expire none # сделать бессрочным +``` + +Продление возвращает того же клиента (тот же uuid), поэтому устройствам не нужно переимпортировать +подписку. В GUI то же самое делается кнопкой «Срок» на карточке профиля. + ## На сервере есть старые профили, но они не работают Если profile JSON указывает на порты, которых уже нет в `config.json`, это устаревший профиль. Новая @@ -143,8 +161,9 @@ Server Settings в Electron-GUI вызывает `xrayebator update `: ветки и обновляет Xray-core. Это не `xrayebator-update [branch]`, полный lifecycle-updater. Запустите последний из SSH-терминала, когда нужно обновить данные, интеграцию подписки и все lifecycle-шаги. -GUI намеренно предоставляет только подмножество Bash-меню. Используйте терминал для bypass, probing, -revoke, HAPP setup, cascade, self-steal и журналов/статуса сервиса. +GUI намеренно предоставляет только подмножество Bash-меню. Используйте терминал для `probe-test`, +HAPP setup, каскада, self-steal и журналов/статуса сервиса; сроки действия профилей и отзыв +подписки настраиваются в разделе «Настройки сервера», bypass остаётся терминальной/CLI-операцией. ## Electron unit-тест падает на Windows diff --git a/docs/security.md b/docs/security.md index 86f3d6e..a2ff878 100644 --- a/docs/security.md +++ b/docs/security.md @@ -51,9 +51,13 @@ Already handled server-side: - `/sub/` without a valid token returns an identical `404`; - a profile with no live routes returns `410` and serves no routes; a partially stale multi-route profile can still return its remaining live routes with `200`; +- an expired or disabled profile also returns `410 Gone` (`Profile expired or disabled`) and serves + no routes at all, even before the client refreshes its subscription; - nginx adds `Cache-Control: no-store` and rate-limits the subscription location; - the root path and paths outside `/sub/` return `404`; -- `Revoke` rotates `sub_token`, so the old URL stops working. +- `Revoke` rotates `sub_token`, so the old URL stops working; a full revocation + (`profile-revoke --full`) also rotates the uuid in every inbound of the profile, so + already-downloaded configurations stop connecting. Left to the operator: @@ -126,7 +130,8 @@ The GUI does persist the server metadata needed to return to a server, including username, authentication method, privilege mode, credential ids, display key name and installation diagnostics, plus preferences, the `subscription_url`, fetched `vless://` links and the SHA-256 SSH host-key pin. The subscription URL and VLESS links are bearer credentials, so protect the local -Electron application data and revoke the subscription if they leak. Removing the last server card that +Electron application data and revoke the subscription (Server settings) if they leak; a full +revocation rotates the client key as well, so already-downloaded configs stop working. Removing the last server card that references a credential deletes that keychain entry; entries shared with another card are kept. Existing and imported servers can also be managed read-only: importing over SSH recognizes Xrayebator diff --git a/docs/testing.md b/docs/testing.md index 8bd4110..ee87251 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -43,7 +43,7 @@ promise that installer and updater paths behave identically. | `test-audit-functional.sh` | Functional P0/P1 regressions from the HowDeploy integration audit | | `test-audit-privilege-regressions.sh` | Privilege boundaries, certificate ownership, rollback and HAPP setup regressions | | `test-bbr-removal-migration.sh` | Safe removal of the retired BBR/TCP tuning | -| `test-bypass-cli.sh` | Bypass CLI JSON output and routing rule updates | +| `test-profile-revoke-expire-cli.sh` | Subscription revoke (token-only and uuid rotation, foreign clients on shared ports survive) and profile expiry (disable/enable, rebuild without a snapshot, idempotent `expire-check`) | | `test-cascade-routing.sh` | Cascade routing configuration | | `test-cascade-upstream-import.sh` | Cascade upstream import from a VLESS link | | `test-dead-stealth-route-pruning.sh` | Pruning dead stealth routes | @@ -55,6 +55,7 @@ promise that installer and updater paths behave identically. | `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 | +| `test-bypass-cli.sh` | Bypass CLI JSON output and routing rule updates | | `test-project-update-rollback.sh` | Rollback of a failed project update | | `test-apt-lock-race.sh` | apt-lock race: `DPkg::Lock::Timeout` on installs, the unattended-upgrade worker check, and the 12-minute quickstart budget | | `test-quickstart-email-and-inspect.sh` | Explicit `quickstart` email mode (`--without-email` without a fake address) and the read-only invariants of `inspect --json` | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a42dfb2..8be2244 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -131,6 +131,24 @@ reconnect. Fingerprint is different: it is client-side per selected profile/rout Xray and does not alter other routes. The server-side subscription changes immediately, but HAPP still needs a forced refresh or its next automatic one. +## The subscription returns 410 even though the profile looks alive + +The profile is switched off by its expiry: the profile JSON carries `.expire_disabled: true`, or +`.expire` (epoch seconds) is already in the past. Enforcement is done by the `xrayebator-expire.timer` +systemd unit (every 10 minutes, `xrayebator expire-check`): it removes the client from the inbounds, +so even an already-downloaded link cannot connect, and the subscription answers `410 Gone` with the +body `Profile expired or disabled`. A date-only expiry (for example, September 30) includes the +whole selected day and ends at `23:59:59` in the server's local timezone; explicit CLI times use +that same timezone. Extend or clear the date: + +```bash +sudo xrayebator profile-expire --name NAME --expire 2026-12-31 # extend +sudo xrayebator profile-expire --name NAME --expire none # make unlimited +``` + +Renewal restores the very same client (same uuid), so devices do not need to re-import the +subscription. In the GUI the same action lives behind the “Expiry” button on the profile card. + ## Old profiles exist on the server but do not work If the profile JSON points at ports that no longer exist in `config.json`, the profile is stale. @@ -168,8 +186,9 @@ from the canonical raw branch and then updates Xray-core. It is not the same as silently use that marker. Run the latter from an SSH terminal when you need the broader lifecycle sequence, and verify Xray, DNS and the subscription endpoint/service afterwards. -The GUI intentionally exposes only a subset of the Bash menu. Use the terminal for bypass, probing, -subscription revocation, HAPP setup, cascade, self-steal and service logs/status. +The GUI intentionally exposes only a subset of the Bash menu. Use the terminal for `probe-test`, +HAPP setup, cascade, self-steal and service logs/status; profile expiry dates and subscription +revocation are handled in Server settings, while bypass stays a terminal/CLI operation. ## The Electron unit test fails on Windows diff --git a/docs/zh-CN/architecture.md b/docs/zh-CN/architecture.md index cae0fb6..356c534 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 # 配置档元数据和订阅令牌 +├── profiles/.json # 配置档元数据、订阅令牌与有效期(.expire) ├── upstreams/cascade.json # 级联上游参数 ├── backups/ # 运行时改动前的配置备份 ├── .private_key / .public_key # Reality 密钥 @@ -98,7 +98,7 @@ http://127.0.0.1:8080/sub/ # 仅本地回退 ``` 交互式 HAPP 设置可以选择公共端口,`_subscription_base_url` 会保留这一选择。非交互式的 -`quickstart --email
` 和 `quickstart --without-email` IP-TLS 流程会在 `8443` 配置 nginx、证书和标记,然后返回该 endpoint 的 `subscription_url`。不提供邮箱时,Certbot 会在没有 ACME 联系地址的情况下注册,因此无法接收续期通知或通过邮箱恢复。令牌以 `sub_token` 形式存储在配置档中;执行 revoke 会轮换令牌并使之前的 URL 失效。 +`quickstart --email
` 和 `quickstart --without-email` IP-TLS 流程会在 `8443` 配置 nginx、证书和标记,然后返回该 endpoint 的 `subscription_url`。不提供邮箱时,Certbot 会在没有 ACME 联系地址的情况下注册,因此无法接收续期通知或通过邮箱恢复。令牌以 `sub_token` 形式存储在配置档中;执行 revoke 会轮换令牌并使之前的 URL 失效,而完全吊销(`profile-revoke --full`)还会更换该配置档所有 inbound 中的 uuid,因此已下载的配置无法再连接。配置档有效期保存在 `expire`(epoch 秒)中,由 systemd 定时器 `xrayebator-expire.timer` 每 10 分钟通过 `xrayebator expire-check` 强制执行。只输入日期时,有效期包含服务器本地时区当天,至 `23:59:59` 到期;显式指定的时间也按服务器本地时区解释。`profiles` JSON 还会提供服务器时区中的 `expire_date`,GUI 无需按客户端本地时区从 epoch 时间戳反推出日期。 新创建的标准托管 HAPP 配置档是 schema-v3 七路由配置档,包括 `xhttp-legacy` 和后量子 XHTTP 路由。 发布的 HAPP 连接列表包含六个 VLESS 路由,因为 PQ 路由仍可通过原始/配置档路径访问。助手也可能 diff --git a/docs/zh-CN/configuration.md b/docs/zh-CN/configuration.md index 1d035bd..8a2f9b5 100644 --- a/docs/zh-CN/configuration.md +++ b/docs/zh-CN/configuration.md @@ -3,7 +3,6 @@ [← 返回 README](../../README.zh-CN.md) · [English](../configuration.md) · [Русский](../ru/configuration.md) 章节:[前置条件](#前置条件与已测试系统) · [环境变量](#安装脚本的环境变量) · [防火墙与主机网络设置](#防火墙与主机网络设置) · -[主菜单](#主菜单) · [命令](#命令) · [桌面图形界面](#桌面图形界面) · [分流路由](#分流路由) · [级联](#级联与上游节点) · [Self-steal](#自有域名与-self-steal-挡板) · [域名与 DNS](#域名与-dns) @@ -82,7 +81,6 @@ Xrayebator 不会更改主机的 TCP 拥塞控制算法,也不会写入或应 | `4` | 管理配置档:SNI、指纹、端口、advanced | | `5` | 将单个配置档升级到 PQ XHTTP | | `6` | HAPP 订阅:7 条线路的配置档、public TLS、链接、二维码、吊销 | -| `7` | 分流路由:域名直连,绕过 VPN | | `8` | 级联与上游节点 | | `9` | 自有域名与 self-steal 挡板 | | `10` | 部署出站服务器,使本 VPS 成为级联的境外节点 | @@ -104,9 +102,12 @@ Xrayebator 不会更改主机的 TCP 拥塞控制算法,也不会写入或应 | `sudo xrayebator quickstart --without-email` | 相同的一次性部署路径,但不提供 ACME 联系邮箱;Certbot 使用 `--register-unsafely-without-email`,因此没有续期通知或邮箱恢复 | | `sudo xrayebator inspect --json` | GUI 导入时使用的只读安装检查:返回 Xray、配置档和订阅标记状态;不会安装、迁移或修改配置 | | `sudo xrayebator happ-setup` | 已有安装的精简 HAPP 路径:确保订阅服务和可用的多线路配置档;缺少订阅域或端口标记时,会先验证 `8443` 的产品 IP-TLS endpoint,否则失败 | -| `sudo xrayebator profiles` | 以 JSON 数组输出服务器全部配置档(供桌面 GUI「服务器设置」页使用) | -| `sudo xrayebator profile-create --name 名称 [--transport tcp\|tcp-utls\|tcp-xudp\|tcp-mux\|grpc\|xhttp] [--port P] [--count N]` | 非交互式创建单个或多个配置档,打印 `{"ok":true,"names":[...],"errors":[...]}` | +| `sudo xrayebator profiles` | 以 JSON 数组输出服务器全部配置档(供桌面 GUI「服务器设置」页使用);有效期同时包含用于服务端执行的 epoch 秒(`expire`)和用于界面显示的服务器本地日历日期(`expire_date`),因此不同时区的客户端也会显示用户选择的日期 | +| `sudo xrayebator profile-create --name 名称 [--transport tcp\|tcp-utls\|tcp-xudp\|tcp-mux\|grpc\|xhttp] [--port P] [--count N] [--expire 日期]` | 非交互式创建单个或多个配置档;`--expire` 接受 `YYYY-MM-DD[ HH:MM]`、epoch 秒或 13 位毫秒。只选日期时,配置档在服务器本地时区的当天 `23:59:59` 之前(含该秒)有效;指定时间时按同一服务器时区执行。过去的有效期会被拒绝,打印 `{"ok":true,"names":[...],"errors":[...]}` | | `sudo xrayebator profile-delete --name 名称` | 非交互式删除配置档,打印 `{"ok":true,"name":"..."}` | +| `sudo xrayebator profile-revoke --name 名称 [--full]` | 重新签发订阅链接:新的 `sub_token`;带 `--full` 时还会更换该配置档所有 inbound 中的 uuid(已下载的配置立即失效),打印 JSON | +| `sudo xrayebator profile-expire --name 名称 --expire 日期\|epoch\|none` | 设置、延长或取消配置档有效期;只输入日期时,在服务器本地时区的当天 `23:59:59`(含)到期,显式输入的时间按同一时区原样使用;立即生效(已过期会移除客户端,延长则恢复),打印 JSON | +| `sudo xrayebator expire-check` | 批量应用所有已到期的有效期;幂等,无变化时不重启 Xray。由 `xrayebator-expire.timer` 每 10 分钟触发 | | `sudo xrayebator fp-change --name 名称 [--route R] --fp 指纹` | 修改配置档的指纹,打印 JSON 结果 | | `sudo xrayebator sni-change --name 名称 [--route R] --sni SNI` | 修改配置档的 SNI,并同步更新同一端口上的所有配置档,打印 JSON 结果 | | `sudo xrayebator sni-list` | 按类别列出 `sni_list.txt` 中的候选 SNI,打印 JSON 结果(供桌面 GUI 的 SNI 对话框使用) | @@ -154,9 +155,7 @@ npm test # Vitest 单元测试 npm run typecheck # TypeScript 检查 ``` -## 分流路由 -分流会在 Xray routing 中加入规则,让选定域名经 `freedom` 直连而不走 VPN。 `domain -> direct` 规则位于兜底规则之上,因此在启用级联时仍然生效。 默认组合包中的分组: diff --git a/docs/zh-CN/desktop-gui.md b/docs/zh-CN/desktop-gui.md index 5c52b48..fa62761 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 keys 会从保存的 `subscription_url` 刷新订阅,并显示返回的 VLESS 线路。每条 VLESS 链接都可以复制或生成二维码;订阅 URL 也可以复制,页面还提供复制全部内容的操作。此页面不会在服务器上创建独立订阅,也不会轮换订阅令牌(轮换在 Server settings 的配置档卡片上进行)。 ### Server settings @@ -65,7 +65,7 @@ SNI 和端口属于 inbound 级别的设置:修改它们可能影响共享该 GUI 支持 SSH 密码认证或私钥认证,并支持直接以 `root` 执行或通过 `sudo` 提升权限。私钥通过 Electron 原生文件对话框选择;main process 读取字节并通过 `keytar` 保存到操作系统钥匙串(Windows Credential Manager、macOS Keychain 或 Linux Secret Service),renderer 只收到非敏感 credential id 和显示文件名。之后的 SSH 操作以及应用重启后都可以复用该密钥。 -如果系统钥匙串不可用,应用不会在磁盘上创建明文回退副本:密钥只保留在 main process 内存中,直到应用退出;界面会提示重启后需要重新选择。SSH 登录密码在首次成功认证后保存到系统钥匙串,之后的操作和应用重启均可复用;服务器卡片只保存非敏感 credential id。单独的 sudo 密码和加密私钥口令不会持久化,需要时重新输入。私钥字节和密码值都不会越过 preload boundary:renderer 只收到 credential id 和显示名。`electron-store` 会保存服务器卡片、连接偏好、credential id、显示文件名、安装诊断、订阅 URL、已获取的 VLESS 链接(bearer/client credentials)和 SSH host-key SHA-256 pin(TOFU)。请保护本地应用数据;若订阅 URL 或 VLESS 链接泄露,请通过终端 workflow 吊销订阅。删除引用某个 credential 的最后一张服务器卡片时会删除对应钥匙串记录;其他卡片仍引用时会保留。 +如果系统钥匙串不可用,应用不会在磁盘上创建明文回退副本:密钥只保留在 main process 内存中,直到应用退出;界面会提示重启后需要重新选择。SSH 登录密码在首次成功认证后保存到系统钥匙串,之后的操作和应用重启均可复用;服务器卡片只保存非敏感 credential id。单独的 sudo 密码和加密私钥口令不会持久化,需要时重新输入。私钥字节和密码值都不会越过 preload boundary:renderer 只收到 credential id 和显示名。`electron-store` 会保存服务器卡片、连接偏好、credential id、显示文件名、安装诊断、订阅 URL、已获取的 VLESS 链接(bearer/client credentials)和 SSH host-key SHA-256 pin(TOFU)。请保护本地应用数据;若订阅 URL 或 VLESS 链接泄露,请在 Server settings 中吊销订阅(「完全吊销」会同时更换密钥)。删除引用某个 credential 的最后一张服务器卡片时会删除对应钥匙串记录;其他卡片仍引用时会保留。 Electron 边界包含以下保护措施: @@ -81,12 +81,15 @@ GUI 暴露的配置档 API 对应以下 Bash CLI 命令: ```text xrayebator profiles -xrayebator profile-create --name NAME [--transport T] [--port P] [--count N] +xrayebator profile-create --name NAME [--transport T] [--port P] [--count N] [--expire DATE] xrayebator profile-delete --name NAME +xrayebator profile-revoke --name NAME [--full] +xrayebator profile-expire --name NAME --expire DATE|epoch|none xrayebator fp-change --name NAME [--route R] --fp FINGERPRINT xrayebator sni-change --name NAME [--route R] --sni SNI xrayebator sni-list 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] ``` 部署流程会调用以下命令之一: @@ -107,9 +110,7 @@ GUI 使用结果中的 `subscription_url`,随后通过该 URL 获取 VLESS 密 当前 Electron GUI **不暴露**以下服务器功能: ```text -bypass probe-test -revoke happ-setup cascade self-steal @@ -117,7 +118,12 @@ self-steal 服务日志/状态 ``` -尤其要注意,Dashboard 的可达性状态点不等于可以使用 `probe-test`,密钥页面也不等于可以使用 `revoke`。 +尤其要注意,Dashboard 的可达性状态点不等于可以使用 `probe-test`。 + +Server settings 还提供: + +- **订阅吊销** —— 每个配置档旁的按钮会打开一个菜单,包含两种模式。「仅更换链接」会重新签发 `sub_token`(旧 URL 立即失效,线路和密钥不变)。「完全吊销」还会更换该配置档所有 inbound 中的 uuid,已经下载过配置的设备会立刻断开——这是真正关闭泄露链接访问权限的唯一方式。 +- **配置档有效期** —— 日期通过订阅响应头下发给客户端,并由服务器强制执行:`xrayebator-expire.timer` 定时器每 10 分钟运行一次 `xrayebator expire-check`,到期后把客户端从 inbound 中移除。只选日期时,在服务器本地时区的当天 `23:59:59`(含)到期。GUI 使用 `expire_date` 中服务器的日历日期,因此桌面与 VPS 时区不同时也不会显示错一天。延长后恢复同一个客户端。创建配置档时也可以直接设置有效期。 ## 部署协议 @@ -179,4 +185,4 @@ Electron packaging 使用为 `howdeploy/Xrayebator` 配置的 GitHub provider。 - 没有 React/Electron runtime integration tests。Electron 具有 unit tests、TypeScript 检查、build 检查和真实服务器手工验证,但没有同时启动完整 packaged renderer 与 main process 流程的测试。 - 有一个 Vitest 测试使用 POSIX `/bin/sh`(`tests/unit/shell-command.test.ts`)。这是 Windows 上的已知限制;该 shell 专用测试以 Linux 为事实来源。 - Auto-updater 仅在 packaged build 中运行,使用为 `howdeploy` 配置的 GitHub provider,并采用 auto-download/install-on-quit 行为;`npm run dev` 不会模拟发布更新。 -- GUI 有意只暴露上面记录的命令范围。需要 bypass、probe-test、订阅撤销、HAPP setup、cascade、self-steal、terminal menu 或服务日志/状态时,请使用 Bash `xrayebator` 界面或服务器端命令。 +- GUI 有意只暴露上面记录的命令范围。需要 probe-test、HAPP setup、cascade、self-steal、terminal menu 或服务日志/状态时,请使用 Bash `xrayebator` 界面或服务器端命令。 diff --git a/docs/zh-CN/security.md b/docs/zh-CN/security.md index 294eecc..7d442dd 100644 --- a/docs/zh-CN/security.md +++ b/docs/zh-CN/security.md @@ -44,9 +44,10 @@ Xray 以系统用户 `xray` 运行。Drop-in - 32 位十六进制令牌,由 `openssl rand -hex 16` 生成; - 没有有效令牌访问 `/sub/` 一律返回相同的 `404`; - 没有活跃线路的配置档返回 `410`,不提供线路; +- 已过期或被停用的配置档同样返回 `410 Gone`(`Profile expired or disabled`),完全不提供线路——即使用户端尚未刷新订阅; - nginx 添加 `Cache-Control: no-store`,并为订阅 location 限流; - 根路径与 `/sub/` 之外的路径返回 `404`; -- `Revoke` 轮换 `sub_token`,旧 URL 失效。 +- `Revoke` 轮换 `sub_token`,旧 URL 失效;完全吊销(`profile-revoke --full`)还会更换该配置档所有 inbound 中的 uuid,因此已下载的配置无法再连接。 运维人员需要注意: @@ -103,7 +104,7 @@ TCPKeepAlive yes 如果系统钥匙串不可用,应用不会在磁盘上保存明文回退副本:密钥只保留在 main process 内存中直到应用退出,界面会提示重启后需要重新选择。SSH 登录密码仅在首次成功登录后保存到系统钥匙串并可继续复用;单独的 sudo 密码和加密私钥口令不持久化,需要时重新输入。 -GUI 会保存返回服务器所需的元数据:主机、SSH 端口、用户名、认证方式、权限模式、credential id(密码与私钥)、密钥显示名、安装诊断、偏好、`subscription_url`、获取到的 `vless://` 链接以及 SHA-256 SSH host-key pin。订阅 URL 和 VLESS 链接是 bearer credentials,因此请保护本地 Electron 应用数据,泄露后吊销订阅。删除最后一张引用某 credential 的服务器卡片时会删除钥匙串记录;若其他卡片仍引用则保留。 +GUI 会保存返回服务器所需的元数据:主机、SSH 端口、用户名、认证方式、权限模式、credential id(密码与私钥)、密钥显示名、安装诊断、偏好、`subscription_url`、获取到的 `vless://` 链接以及 SHA-256 SSH host-key pin。订阅 URL 和 VLESS 链接是 bearer credentials,因此请保护本地 Electron 应用数据,泄露后在服务器设置中吊销订阅(完全吊销会同时更换客户端密钥,已下载的配置随即失效)。删除最后一张引用某 credential 的服务器卡片时会删除钥匙串记录;若其他卡片仍引用则保留。 SSH 导入只识别 Xrayebator,并默认只执行只读诊断;部分配置的服务器会按实际状态导入,不会自动修复。部署时 email 可选;不填写时 Certbot 使用 `--register-unsafely-without-email`,因此没有续期通知和 ACME 账户邮箱恢复,GUI 会在部署前说明。 diff --git a/docs/zh-CN/testing.md b/docs/zh-CN/testing.md index 2ccafe9..7188996 100644 --- a/docs/zh-CN/testing.md +++ b/docs/zh-CN/testing.md @@ -37,8 +37,9 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done | `test-main-menu-numbering.sh` | 主菜单条目编号连续并与处理函数一致 | | `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-port-change-cli.sh` | `port-change` CLI:unit/shared/move 入站场景、无效端口、缺少配置档、多线路 `--route` | | `test-bypass-cli.sh` | `bypass` CLI:JSON 输出、路由规则更新、带 SNI 探测的 add | +| `test-port-change-cli.sh` | `port-change` CLI:unit/shared/move 入站场景、无效端口、缺少配置档、多线路 `--route` | +| `test-profile-revoke-expire-cli.sh` | `profile-revoke`(仅令牌 / 轮换 uuid 且保留共享端口上的其他客户端)与配置档有效期(停用、从快照恢复、无快照重建、幂等的 `expire-check`) | | `test-apt-lock-race.sh` | apt-lock 竞态:安装命令携带 `DPkg::Lock::Timeout`、检测 `unattended-upgrade` 工作进程、quickstart 12 分钟预算 | | `test-quickstart-email-and-inspect.sh` | `quickstart` 的显式 email 模式(`--without-email` 不使用虚假地址)以及 `inspect --json` 的只读不变量 | | `test-quickstart-migration-parity.sh` | `quickstart` 执行与 `main_menu` 相同的关键迁移 | diff --git a/docs/zh-CN/troubleshooting.md b/docs/zh-CN/troubleshooting.md index 8c00ea2..1de4588 100644 --- a/docs/zh-CN/troubleshooting.md +++ b/docs/zh-CN/troubleshooting.md @@ -81,6 +81,22 @@ HAPP 兼容的 XHTTP 候选必须是 `xhttp-legacy`,而不是 PQ 线路。更 请在客户端刷新订阅,或重新获取原始线路。SNI 和端口是共享入站的设置,因此修改它们可能影响该端口上的所有配置档,通常需要客户端重新连接。指纹是所选配置档/线路的客户端参数,不会重启 Xray,也不会修改其他线路。服务端订阅会在同一链接上立即更新,但 HAPP 仍需要强制刷新或等待下一次自动更新。 +## 订阅返回 410,但配置档看起来是正常的 + +该配置档因有效期被停用:profile JSON 中带有 `.expire_disabled: true`,或者 `.expire` +(epoch 秒)已经过期。强制执行由 systemd 定时器 `xrayebator-expire.timer` 完成(每 10 分钟运行 +`xrayebator expire-check`):它会把客户端从 inbound 中移除,因此即使已经下载的链接也无法连接, +订阅会返回 `410 Gone`,正文为 `Profile expired or disabled`。只选日期(例如 9 月 30 日)时, +配置档在当天仍有效,并于服务器本地时区的 `23:59:59` 到期;CLI 显式指定的时间也按服务器时区解释。 +延长或取消有效期: + +```bash +sudo xrayebator profile-expire --name 名称 --expire 2026-12-31 # 延长 +sudo xrayebator profile-expire --name 名称 --expire none # 设为永久 +``` + +延长后会恢复同一个客户端(同一 uuid),因此设备无需重新导入订阅。在 GUI 中同样的操作位于配置档卡片上的「有效期」按钮。 + ## 服务器上有旧配置档但无法使用 如果配置档 JSON 指向的端口已经不在 `config.json` 中,说明配置档已过期。新订阅不会提供这些线路。只有当配置档已经没有任何存活线路时,旧令牌才会返回 `410 Gone`;部分过期的多线路配置档仍可能以 `200` 返回剩余的存活线路。请重新创建配置档,或通过终端菜单修复存活入站;不要发布指向失效端口的链接。 @@ -105,7 +121,7 @@ Xrayebator 3.0 还会一次性删除旧版本安装的 UDP/443 阻断规则。 Electron GUI 的 Server Settings 调用 `xrayebator update `:从该分支 self-update 管理器并更新 Xray-core。它不同于完整的 `xrayebator-update [branch]` lifecycle updater。需要刷新数据、订阅集成和全部 lifecycle 步骤时,请从 SSH 终端运行后者。 -GUI 有意只暴露 Bash 菜单的一个子集。bypass、探测、订阅吊销、HAPP setup、级联、self-steal 以及服务日志/状态仍需从终端执行。 +GUI 有意只暴露 Bash 菜单的一个子集。`probe-test`、HAPP setup、级联、self-steal 以及服务日志/状态仍需从终端执行;配置档有效期、订阅吊销和分流分组可在服务器设置中完成。 ## Electron 单元测试在 Windows 上失败 diff --git a/src/main/core/cli-failure.ts b/src/main/core/cli-failure.ts new file mode 100644 index 0000000..b7175c9 --- /dev/null +++ b/src/main/core/cli-failure.ts @@ -0,0 +1,60 @@ +/** + * Диагностика падений серверного CLI. + * + * Зачем отдельный модуль: старый сервер (например, релизная ветка без новых + * команд) на неизвестную команду печатает «✗ Неизвестная команда: …» в stdout + * и выходит с кодом 1, оставляя stderr пустым. Раньше GUI показывал в этом + * случае «код 1: » без объяснения — пользователь видел пустую ошибку. + */ + +export interface CliFailure { + code: number + stdout: string + stderr: string +} + +/** ANSI-мусор менеджера в выводе (цвета статусов) для читаемой ошибки. */ +export function stripAnsi(value: string): string { + return value.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '') +} + +/** + * Человекочитаемая причина падения: сначала stderr (там настоящие ошибки), + * затем значимая строка из stdout (там менеджер печатает «Неизвестная команда»). + * Возвращает '' если объяснения нет — вызывающий добавит своё. + */ +export function describeFailure(res: CliFailure): string { + const stderr = stripAnsi(res.stderr).trim() + if (stderr) return firstMeaningfulLine(stderr) + + const stdout = stripAnsi(res.stdout) + const known = stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => + /Неизвестная команда|Использование:|не найден|Некорректн|повреждён|✗|error/i.test(line) + ) + if (known.length > 0) return known[known.length - 1] + + return firstMeaningfulLine(stdout) +} + +/** + * Подсказка для самого частого случая: GUI новее сервера. + * Появляется, когда команда есть в приложении, но отсутствует на сервере. + */ +export function isUnknownCommandFailure(res: CliFailure): boolean { + return /Неизвестная команда/i.test(stripAnsi(res.stdout) + stripAnsi(res.stderr)) +} + +export function unknownCommandHint(): string { + return 'Сервер работает на более старой версии Xrayebator: обновите его (кнопка «Обновить Xrayebator» или `sudo xrayebator update <ветка>`) и повторите.' +} + +function firstMeaningfulLine(value: string): string { + const line = value + .split('\n') + .map((l) => l.trim()) + .find((l) => l.length > 0) + return (line ?? '').slice(0, 300) +} \ No newline at end of file diff --git a/src/main/core/profiles.ts b/src/main/core/profiles.ts index fdb71fa..1ddd9a0 100644 --- a/src/main/core/profiles.ts +++ b/src/main/core/profiles.ts @@ -1,6 +1,11 @@ import { SshClient, SshCredentials } from './ssh-client' import { shellCommand } from './shell-command' -import type { ServerProfile } from '@shared/types' +import { describeFailure, isUnknownCommandFailure, unknownCommandHint } from './cli-failure' +import type { + ProfileExpireResult, + ProfileRevokeResult, + ServerProfile +} from '@shared/types' export interface ProfileListOutput { ok: boolean @@ -118,7 +123,13 @@ export class ProfileManager { const command = shellCommand('xrayebator', args) const res = await client.exec(command, { elevated: true }) if (res.code !== 0) { - throw new Error(`xrayebator ${args[0] ?? ''} → код ${res.code}: ${res.stderr.trim()}`) + // Менеджер печатает причину в stdout (команда неизвестна, профиль не найден), + // stderr при этом часто пуст — без разбора ошибка выглядела как «код 1: ». + const reason = describeFailure(res) + const hint = isUnknownCommandFailure(res) ? ` ${unknownCommandHint()}` : '' + throw new Error( + `xrayebator ${args[0] ?? ''} → код ${res.code}${reason ? `: ${reason}` : ''}.${hint}` + ) } return res.stdout } finally { @@ -139,12 +150,13 @@ export class ProfileManager { } async create( - input: { name: string; transport: string; port?: number; count?: number } + input: { name: string; transport: string; port?: number; count?: number; expire?: string } ): Promise { try { const args = ['profile-create', '--name', input.name, '--transport', input.transport] if (input.port) args.push('--port', String(input.port)) if (input.count && input.count > 1) args.push('--count', String(input.count)) + if (input.expire) args.push('--expire', input.expire) const stdout = await this.run(args) const payload = extractJson(stdout) as { ok?: boolean @@ -308,4 +320,69 @@ export class ProfileManager { return { ok: false, error: message } } } + + async revoke(input: { name: string; full: boolean }): Promise { + try { + const args = ['profile-revoke', '--name', input.name] + if (input.full) args.push('--full') + const stdout = await this.run(args) + const payload = extractJson(stdout) as { + ok?: boolean + name?: string + full?: boolean + sub_token?: string + uuid?: string + subscription_url?: string + error?: string + } + return { + ok: payload.ok === true, + name: payload.name, + full: payload.full, + sub_token: payload.sub_token, + uuid: payload.uuid, + subscription_url: payload.subscription_url, + error: payload.error + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } + } + + async setExpire(input: { + name: string + expire: string | number | null + }): Promise { + try { + const value = + input.expire === null || input.expire === undefined || input.expire === '' + ? 'none' + : String(input.expire) + const stdout = await this.run([ + 'profile-expire', + '--name', + input.name, + '--expire', + value + ]) + const payload = extractJson(stdout) as { + ok?: boolean + name?: string + expire?: number + expired?: boolean + error?: string + } + return { + ok: payload.ok === true, + name: payload.name, + expire: payload.expire, + expired: payload.expired, + error: payload.error + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } + } } diff --git a/src/main/core/server-manager.ts b/src/main/core/server-manager.ts index 9b832d6..2ab8f26 100644 --- a/src/main/core/server-manager.ts +++ b/src/main/core/server-manager.ts @@ -34,6 +34,9 @@ export class ServerManager { */ async update(): Promise { const client = new SshClient(this.creds) + // Ветка берётся из .current_branch на сервере (её закрепляет + // `xrayebator update `); main — только дефолт для серверов, + // где ветка ещё не закреплена. let branch = 'main' try { await client.connect() diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 3e3115a..8d70ade 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -7,8 +7,10 @@ import type { ImportServerPayload, ImportStep, ProfileCreateInput, + ProfileExpireInput, ProfileFingerprintInput, ProfilePortInput, + ProfileRevokeInput, ProfileSniInput, Server, ServerMaintenanceResult, @@ -443,6 +445,22 @@ export function registerIpcHandlers({ store }: IpcContext): void { } ) + ipcMain.handle( + 'profiles:revoke', + async (_e, serverId: string, access: SshAccessInput, input: ProfileRevokeInput) => { + const manager = await profileManagerFor(serverId, access) + return manager.revoke(input) + } + ) + + ipcMain.handle( + 'profiles:setExpire', + async (_e, serverId: string, access: SshAccessInput, input: ProfileExpireInput) => { + const manager = await profileManagerFor(serverId, access) + return manager.setExpire(input) + } + ) + const serverManagerFor = async ( serverId: string, access: SshAccessInput diff --git a/src/preload/index.ts b/src/preload/index.ts index 5eb7029..9951448 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,10 +10,14 @@ import type { ProfileCreateInput, ProfileCreateResult, ProfileDeleteResult, + ProfileExpireInput, + ProfileExpireResult, ProfileFingerprintInput, ProfileFingerprintResult, ProfilePortInput, ProfilePortResult, + ProfileRevokeInput, + ProfileRevokeResult, ProfileSniInput, ProfileSniResult, SniListResult, @@ -101,7 +105,19 @@ const api: ElectronAPI = { access: SshAccessInput, input: ProfilePortInput ): Promise => - ipcRenderer.invoke('profiles:changePort', serverId, access, input) + ipcRenderer.invoke('profiles:changePort', serverId, access, input), + revoke: ( + serverId: string, + access: SshAccessInput, + input: ProfileRevokeInput + ): Promise => + ipcRenderer.invoke('profiles:revoke', serverId, access, input), + setExpire: ( + serverId: string, + access: SshAccessInput, + input: ProfileExpireInput + ): Promise => + ipcRenderer.invoke('profiles:setExpire', serverId, access, input) }, server: { diff --git a/src/renderer/src/components/CalendarPicker.module.css b/src/renderer/src/components/CalendarPicker.module.css new file mode 100644 index 0000000..7a7822d --- /dev/null +++ b/src/renderer/src/components/CalendarPicker.module.css @@ -0,0 +1,156 @@ +/* Календарь выбора срока действия — в теме приложения, а не системный date-input. */ +.root { + /* Размер квадратной ячейки дня. Одна переменная на всю сетку: из неё же + считается резерв высоты под 6 недель, поэтому геометрия не расходится. */ + --day-size: 44px; + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 12px; + user-select: none; + /* Растёт вниз по содержимому; шапка зафиксирована, поэтому верх не смещается. */ + display: flex; + flex-direction: column; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; + /* Фиксированная высота шапки: название месяца в разных локалях/месяцах + занимает разную высоту, из-за чего стрелки «прыгали» по вертикали. */ + height: 28px; + flex-shrink: 0; +} + +.monthLabel { + font-size: 13px; + font-weight: 600; + color: var(--text-0); + flex: 1; + text-align: center; + line-height: 28px; + /* Длинные названия не переносятся и не толкают стрелки. */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.navBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg-2); + color: var(--text-1); + cursor: pointer; + transition: + border-color 0.15s ease, + color 0.15s ease; +} + +.navBtn:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.navBtn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; + margin-bottom: 4px; + /* Строка дней недели тоже фиксирована: «Пн/Втр/Ср» разной ширины не должны + менять высоту, иначе сетка дней уезжает вниз при смене локали. */ + height: 20px; + flex-shrink: 0; +} + +.weekday { + text-align: center; + font-size: 11px; + color: var(--text-2); + line-height: 20px; + white-space: nowrap; + overflow: hidden; +} + +.grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; + /* Квадраты центрируются в колонках — между днями воздух. */ + justify-items: center; + align-content: start; + /* Высота НЕ резервируется: месяц с 5 неделями занимает 5 строк, с 6 — 6. + Пустой зоны внизу нет. Шестая неделя раскрывается ВНИЗ и не сдвигает + верхнюю часть (шапку со стрелками и уже показанные дни), потому что + диалог прижат к верху через placement="top" — он растёт только вниз. */ +} + +/* + * Квадратный бокс даты — прежняя «яблочная» геометрия. Сторона квадрата + * задана переменной --day-size (44px); строка сетки принимает её высоту, + * поэтому переполнения не возникает. + */ +.day, +.emptyCell { + width: var(--day-size); + height: var(--day-size); + min-width: 0; +} + +.day { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; + color: var(--text-1); + font-size: 13px; + font-family: var(--font); + cursor: pointer; + transition: + background 0.12s ease, + border-color 0.12s ease, + color 0.12s ease; +} + +.day:hover:not(:disabled) { + background: var(--bg-2); + border-color: var(--border); + color: var(--text-0); +} + +.day:disabled { + color: var(--text-2); + opacity: 0.35; + cursor: not-allowed; +} + +.dayToday { + border-color: var(--accent); + color: var(--accent); +} + +.daySelected { + background: var(--accent); + border-color: var(--accent); + color: #0d1117; + font-weight: 600; +} + +.daySelected:hover:not(:disabled) { + background: var(--accent); + color: #0d1117; +} \ No newline at end of file diff --git a/src/renderer/src/components/CalendarPicker.tsx b/src/renderer/src/components/CalendarPicker.tsx new file mode 100644 index 0000000..006c8ac --- /dev/null +++ b/src/renderer/src/components/CalendarPicker.tsx @@ -0,0 +1,110 @@ +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { + canGoBack, + isBefore, + monthCells, + monthLabel, + parseIso, + shiftMonth, + todayIso, + weekdayLabels +} from '@shared/calendar' +import styles from './CalendarPicker.module.css' + +interface CalendarPickerProps { + /** Выбранная дата в ISO-виде 'ГГГГ-ММ-ДД' ('' = не выбрана). */ + value: string + onChange: (isoDate: string) => void + disabled?: boolean + /** Нижняя граница (ISO). По умолчанию — сегодня: прошлое выбирать нельзя. */ + min?: string +} + +/** + * Календарь в теме приложения вместо системного date-input. + * Прошлые даты недоступны: срок в прошлом сервер всё равно отклонит. + */ +export function CalendarPicker({ + value, + onChange, + disabled = false, + min +}: CalendarPickerProps): React.JSX.Element { + const { i18n } = useTranslation() + const locale = i18n.language || 'ru' + const minIso = min ?? todayIso() + + const selected = parseIso(value) + const [cursor, setCursor] = useState<{ year: number; month0: number }>(() => { + const base = selected ?? new Date() + return { year: base.getFullYear(), month0: base.getMonth() } + }) + + const cells = useMemo(() => monthCells(cursor.year, cursor.month0), [cursor]) + const label = useMemo(() => monthLabel(cursor.year, cursor.month0, locale), [cursor, locale]) + const weekdays = useMemo(() => weekdayLabels(locale), [locale]) + const today = todayIso() + + const move = (delta: number): void => { + const [year, month0] = shiftMonth(cursor.year, cursor.month0, delta) + setCursor({ year, month0 }) + } + + return ( +
+
+ + {label} + +
+ +
+ {weekdays.map((wd, i) => ( + + {wd} + + ))} +
+ +
+ {cells.map((iso, idx) => { + if (!iso) return + const past = isBefore(iso, minIso) + const isSelected = iso === value + const isToday = iso === today + return ( + + ) + })} +
+
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index e1f6a32..c354324 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -235,6 +235,47 @@ "yandex_cdn": "Yandex CDN", "foreign": "Foreign (may not always pass)", "fallback": "Fallback (often probed)" - } + }, + "serverTooOld": "The server runs an older Xrayebator: expiry dates and bypass are unavailable. Update it with the «Update Xrayebator» button.", + "revokeBtn": "Revoke", + "revokeTitle": "Revoke subscription", + "revokeHint": "The subscription URL is a password: whoever gets it downloads every route. Choose what to reissue.", + "revokeTokenTitle": "New link only", + "revokeTokenDesc": "The old URL stops working; routes and keys stay the same. Anyone who already downloaded the config keeps connecting.", + "revokeFullTitle": "Full revocation", + "revokeFullDesc": "New link plus a new key (uuid) in every route. All devices disconnect and must import the subscription again.", + "revokeFullWarning": "Full revocation drops connections immediately — both for whoever got the leaked link and for your own devices.", + "revokeFullSecondConfirm": "This cannot be undone: the old key is gone for good, only a new one can be issued. Continue?", + "revokeFullConfirm": "Yes, revoke fully", + "revoking": "Revoking…", + "revokeDone": "Subscription «{{name}}» reissued. New link:", + "revokeCopyNew": "Copy the new link", + "revoked": "Subscription link of «{{name}}» reissued", + "revokedFull": "Full revocation of «{{name}}»: new key and link", + "revokeFailed": "Failed to revoke the subscription", + "expireBtn": "Expiry", + "expireTitle": "Expiry date", + "expireHintBody": "The date travels to the client inside the subscription and is enforced by the server: once it passes, the profile is switched off even if the client never refreshed.", + "expireCurrent": "Now: {{value}}", + "expireNever": "no expiry", + "expireSelect": "Quick pick", + "expirePreset": "+{{count}} d", + "expireSave": "Save expiry", + "expireSaving": "Saving…", + "expireClear": "Remove expiry", + "expireClearHint": "The profile becomes unlimited; access is not interrupted.", + "expireChanged": "Expiry of «{{name}}» set to {{date}}", + "expireCleared": "Expiry of «{{name}}» removed: the profile is unlimited", + "expireFailed": "Failed to save the expiry date", + "expireEnforced": "The server checks expiry every 10 minutes.", + "expireUntil": "until {{date}}", + "expireExpired": "expired", + "expireHint": "The profile will be switched off automatically on this date", + "expireHintSoon": "Expires within days — extend it, otherwise the profile is switched off automatically", + "expireHintExpired": "Expired — the server disabled this profile. Extend the date to restore access.", + "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}}" } } diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index ea1a3ec..9f8e445 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -236,6 +236,47 @@ "yandex_cdn": "Yandex CDN", "foreign": "Иностранные (не всегда проходят)", "fallback": "Запасные (часто под зондированием)" - } + }, + "serverTooOld": "Сервер работает на более старой версии Xrayebator: сроки действия и обход недоступны. Обновите его кнопкой «Обновить Xrayebator».", + "revokeBtn": "Revoke", + "revokeTitle": "Отзыв подписки", + "revokeHint": "Ссылка подписки — это пароль: кто её получил, тот скачает все маршруты. Выберите, что перевыпустить.", + "revokeTokenTitle": "Только новая ссылка", + "revokeTokenDesc": "Старая ссылка перестаёт работать, маршруты и ключи остаются те же. Кто уже скачал конфиг — продолжит подключаться.", + "revokeFullTitle": "Полный отзыв", + "revokeFullDesc": "Новая ссылка + новый ключ (uuid) во всех маршрутах. Все устройства отключатся и должны заново импортировать подписку.", + "revokeFullWarning": "Полный отзыв разрывает соединения немедленно: и тех, кому ссылка утекла, и ваши собственные устройства.", + "revokeFullSecondConfirm": "Это необратимо: вернуть прежний ключ нельзя, только выдать новый. Продолжить?", + "revokeFullConfirm": "Да, отозвать полностью", + "revoking": "Отзываю…", + "revokeDone": "Подписка «{{name}}» перевыпущена. Новая ссылка:", + "revokeCopyNew": "Скопировать новую ссылку", + "revoked": "Ссылка подписки «{{name}}» перевыпущена", + "revokedFull": "Полный отзыв «{{name}}»: ключ и ссылка новые", + "revokeFailed": "Не удалось отозвать подписку", + "expireBtn": "Срок", + "expireTitle": "Срок действия", + "expireHintBody": "Дата передаётся клиенту в подписке и принудительно применяется сервером: по истечении профиль отключается, даже если клиент не обновился.", + "expireCurrent": "Сейчас: {{value}}", + "expireNever": "бессрочно", + "expireSelect": "Быстрый выбор", + "expirePreset": "+{{count}} дн.", + "expireSave": "Сохранить срок", + "expireSaving": "Сохраняю…", + "expireClear": "Снять срок", + "expireClearHint": "Профиль станет бессрочным, доступ не прерывается.", + "expireChanged": "Срок «{{name}}» — до {{date}}", + "expireCleared": "Срок «{{name}}» снят: профиль бессрочный", + "expireFailed": "Не удалось сохранить срок", + "expireEnforced": "Проверка выполняется на сервере каждые 10 минут.", + "expireUntil": "до {{date}}", + "expireExpired": "срок истёк", + "expireHint": "Профиль отключится автоматически в эту дату", + "expireHintSoon": "Срок истекает в ближайшие дни — продлите, иначе профиль отключится автоматически", + "expireHintExpired": "Срок истёк — профиль отключён сервером. Продлите срок, чтобы вернуть доступ.", + "createExpire": "Срок действия", + "createExpireSet": "Срок: {{date}}", + "createExpireHint": "Профиль отключится автоматически в выбранную дату. Можно оставить бессрочным.", + "expirePicked": "Выбрано: {{date}}" } } diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index e4c80d8..72925a3 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -203,7 +203,7 @@ "fpRemember": "更改后请在 HAPP 或其他客户端中刷新订阅。", "changeSni": "更改 SNI", "changingSni": "正在更改 SNI…", -"sniBtn": "SNI", + "sniBtn": "SNI", "fpBtn": "Fingerprint", "portBtn": "端口", "loadSni": "加载列表", @@ -234,6 +234,47 @@ "yandex_cdn": "Yandex CDN", "foreign": "国外(不一定总是可用)", "fallback": "备用(常被探测)" - } + }, + "serverTooOld": "服务器运行的是较旧的 Xrayebator:有效期与分流功能不可用。请点击「更新 Xrayebator」升级。", + "revokeBtn": "吊销", + "revokeTitle": "吊销订阅", + "revokeHint": "订阅链接就是密码:拿到它的人可以下载全部线路。请选择要重新签发的内容。", + "revokeTokenTitle": "仅更换链接", + "revokeTokenDesc": "旧链接立即失效,线路和密钥不变。已经下载配置的设备仍可继续连接。", + "revokeFullTitle": "完全吊销", + "revokeFullDesc": "新链接,并且所有线路换成新密钥(uuid)。所有设备都会断开,必须重新导入订阅。", + "revokeFullWarning": "完全吊销会立即中断连接:无论是拿到泄露链接的人,还是你自己的设备。", + "revokeFullSecondConfirm": "此操作不可撤销:旧密钥无法恢复,只能签发新的。是否继续?", + "revokeFullConfirm": "是,完全吊销", + "revoking": "正在吊销…", + "revokeDone": "订阅「{{name}}」已重新签发。新链接:", + "revokeCopyNew": "复制新链接", + "revoked": "「{{name}}」的订阅链接已重新签发", + "revokedFull": "「{{name}}」已完全吊销:密钥和链接都是新的", + "revokeFailed": "无法吊销订阅", + "expireBtn": "有效期", + "expireTitle": "有效期", + "expireHintBody": "日期会随订阅下发给客户端,并由服务器强制执行:到期后即使用户端未刷新,配置也会被停用。", + "expireCurrent": "当前:{{value}}", + "expireNever": "永久", + "expireSelect": "快速选择", + "expirePreset": "+{{count}} 天", + "expireSave": "保存有效期", + "expireSaving": "正在保存…", + "expireClear": "取消有效期", + "expireClearHint": "配置将变为永久有效,不会中断访问。", + "expireChanged": "「{{name}}」的有效期至 {{date}}", + "expireCleared": "已取消「{{name}}」的有效期:配置永久有效", + "expireFailed": "无法保存有效期", + "expireEnforced": "服务器每 10 分钟检查一次有效期。", + "expireUntil": "至 {{date}}", + "expireExpired": "已过期", + "expireHint": "该配置将在这一天自动停用", + "expireHintSoon": "即将到期——请及时延长,否则配置会被服务器自动停用", + "expireHintExpired": "已过期——服务器已停用该配置。延长有效期即可恢复访问。", + "createExpire": "有效期", + "createExpireSet": "有效期:{{date}}", + "createExpireHint": "配置将在所选日期自动停用。留空表示永久有效。", + "expirePicked": "已选择:{{date}}" } } diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index ce6638a..c59431c 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -61,8 +61,6 @@ } } - - .header { display: flex; align-items: center; @@ -472,14 +470,136 @@ .confirmDialog { background: var(--bg-2); border: 1px solid var(--border); - border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); + border-radius: var(--radius-lg); padding: 24px; max-width: 420px; width: 100%; outline: none; } +/* + * Blur-подложка новых плашек (revoke/expire). Класс навешивается точечно через + * className у AlertDialog.Backdrop — остальные диалоги приложения не меняются. + */ +.blurBackdrop { + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} + +.revokeWide { + max-width: 480px; +} + +.revokeOptions { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 4px; +} + +.revokeOption { + display: flex; + flex-direction: column; + gap: 4px; + text-align: left; + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px 14px; + cursor: pointer; + transition: border-color 0.15s ease; + color: var(--text-1); +} + +.revokeOption:hover:not(:disabled) { + border-color: var(--accent); +} + +.revokeOptionDanger { + border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); +} + +.revokeOptionTitle { + font-size: 14px; + font-weight: 600; + color: var(--text-0); + display: flex; + align-items: center; + gap: 8px; +} + +.revokeOptionDesc { + font-size: 12px; + line-height: 1.45; + color: var(--text-2); +} + +.revokeNewUrl { + margin-top: 10px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--success); + word-break: break-all; + user-select: text; +} + +.expireChip { + font-family: var(--font-mono); +} + +.expireChipDanger { + border-color: color-mix(in srgb, var(--danger) 60%, var(--border)) !important; + color: var(--danger) !important; +} + +.expireChipWarn { + border-color: color-mix(in srgb, var(--warning) 60%, var(--border)) !important; + color: var(--warning) !important; +} + +.inlineRow { + display: flex; + gap: 8px; + align-items: flex-end; + flex-wrap: wrap; +} + +/* Сервер старой версии: новые возможности недоступны до обновления менеджера. */ +.serverTooOld { + font-size: 12px; + line-height: 1.5; + color: var(--warning); + background: color-mix(in srgb, var(--warning) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--warning) 40%, var(--border)); + border-radius: 10px; + padding: 8px 12px; + margin-bottom: 10px; +} + +.createExpireBtn { + flex-shrink: 0; +} + +/* Снятие срока — отдельное действие в теле диалога, не в футере. */ +.expireClearRow { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border); + flex-wrap: wrap; +} + +.expireClearHint { + font-size: 12px; + line-height: 1.4; + color: var(--text-2); + flex: 1; + min-width: 140px; +} + .fpHint { font-size: 13px; line-height: 1.5; @@ -559,11 +679,11 @@ color: var(--text-2); } -.bypassLoadRow { +.loadRow { padding: 4px 0; } -.bypassDomainField { +.fieldWide { flex: 1; } diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index 9404ad0..0f2bcb0 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -12,11 +12,17 @@ import { EthernetPort, Copy, Download, - Check + Check, + ShieldOff, + CalendarClock, + CalendarX } from 'lucide-react' import { useTranslation } from 'react-i18next' import type { 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' +import { CalendarPicker } from '../components/CalendarPicker' import { shouldAutoConnectServer } from './server-access' import styles from './ServerSettings.module.css' @@ -89,6 +95,8 @@ export function ServerSettings({ const [transport, setTransport] = useState('xhttp') const [count, setCount] = useState('1') const [creating, setCreating] = useState(false) + const [createExpire, setCreateExpire] = useState('') + const [createExpireOpen, setCreateExpireOpen] = useState(false) const [updating, setUpdating] = useState(false) const [uninstalling, setUninstalling] = useState(false) @@ -115,6 +123,17 @@ export function ServerSettings({ const [portBusy, setPortBusy] = useState(false) const [portDone, setPortDone] = useState(false) + // Revoke: выбор «только ссылка» / «полный отзыв» → подтверждение → выполнение. + const [revokeTarget, setRevokeTarget] = useState(null) + const [revokeStep, setRevokeStep] = useState<'choose' | 'confirm' | 'done'>('choose') + const [revokeBusy, setRevokeBusy] = useState(false) + const [revokeUrl, setRevokeUrl] = useState(null) + + const [expireTarget, setExpireTarget] = useState(null) + const [expireDate, setExpireDate] = useState('') + const [expireBusy, setExpireBusy] = useState(false) + const [expireDone, setExpireDone] = useState(false) + const connected = profiles !== null const accessReady = isSshAccessReady(access) @@ -187,7 +206,10 @@ export function ServerSettings({ const result = await window.api.profiles.create(server.id, access, { name: name.trim(), transport, - count: Math.min(Math.max(Number(count) || 1, 1), 50) + count: Math.min(Math.max(Number(count) || 1, 1), 50), + ...(createExpire && isFutureDate(createExpire, Date.now()) + ? { expire: createExpire } + : {}) }) if (result.ok && result.names.length > 0) { createdCount = result.names.length @@ -354,6 +376,123 @@ export function ServerSettings({ toastText(t('settings.copied')) } + const openRevoke = (profile: ServerProfile): void => { + setRevokeTarget(profile) + setRevokeStep('choose') + setRevokeUrl(null) + } + + const runRevoke = async (mode: 'token' | 'full'): Promise => { + if (!revokeTarget || !accessReady) return + setRevokeBusy(true) + setError(null) + try { + const result = await window.api.profiles.revoke(server.id, access, { + name: revokeTarget.name, + full: mode === 'full' + }) + if (result.ok) { + setRevokeUrl(result.subscription_url ?? null) + setRevokeStep('done') + toastText( + t(mode === 'full' ? 'settings.revokedFull' : 'settings.revoked', { + name: revokeTarget.name + }) + ) + const fresh = await window.api.profiles.list(server.id, access) + setProfiles(fresh.profiles ?? []) + } else { + setError(result.error ?? t('settings.revokeFailed')) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setRevokeBusy(false) + } + } + + const openExpire = (profile: ServerProfile): void => { + setExpireTarget(profile) + setExpireDate( + profile.expire + ? describeExpire(profile.expire, false, Date.now(), profile.expire_date).date + : '' + ) + setExpireDone(false) + } + + const applyExpire = async (value: string | null): Promise => { + if (!expireTarget || !accessReady) return + setExpireBusy(true) + setError(null) + try { + const result = await window.api.profiles.setExpire(server.id, access, { + name: expireTarget.name, + expire: value + }) + if (result.ok) { + toastText( + value + ? t('settings.expireChanged', { name: expireTarget.name, date: value }) + : t('settings.expireCleared', { name: expireTarget.name }) + ) + const fresh = await window.api.profiles.list(server.id, access) + setProfiles(fresh.profiles ?? []) + setExpireDone(true) + setTimeout(() => { + setExpireTarget(null) + setExpireDone(false) + }, 400) + } else { + setError(result.error ?? t('settings.expireFailed')) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setExpireBusy(false) + } + } + + /** Чип срока в карточке профиля: дата, «истёк», «скоро истекает» или ничего. */ + const expireChip = (profile: ServerProfile): React.JSX.Element | null => { + const now = Date.now() + const info = describeExpire( + profile.expire ?? 0, + profile.expire_disabled === true, + now, + profile.expire_date + ) + if (info.status === 'none') return null + const expired = info.status === 'expired' + // Предупреждаем за 3 дня: время продлить срок до автоотключения сервером. + const soon = !expired && profile.expire * 1000 - now < 3 * 24 * 60 * 60 * 1000 + const tone = expired ? styles.expireChipDanger : soon ? styles.expireChipWarn : '' + return ( + + {expired + ? t('settings.expireExpired') + : t('settings.expireUntil', { date: info.date })} + + ) + } + + /** + * Сервер старой версии не отдаёт поле expire: управление сроками недоступно. + * Без этой проверки GUI молча показывал «бессрочно» — как будто срока нет. + */ + const expireSupported = (profiles ?? []).some((p) => p.expire_supported === true) + const updateServer = async (): Promise => { if (!accessReady) return setBusy(true) @@ -523,6 +662,18 @@ export function ServerSettings({ onChange={(e) => setCount(e.target.value)} /> + )} + + {profile.subscription_url && ( + + )} {profile.multi_route ? ( @@ -720,6 +895,275 @@ export function ServerSettings({ + { + if (!open && !revokeBusy) setRevokeTarget(null) + }} + > + + + + + + + + + {t('settings.revokeTitle')} — {revokeTarget?.name ?? ''} + + + + {revokeStep === 'choose' && ( + <> +

{t('settings.revokeHint')}

+
+ + +
+ + )} + + {revokeStep === 'confirm' && ( + <> +

{t('settings.revokeFullWarning')}

+

{t('settings.revokeFullSecondConfirm')}

+ + )} + + {revokeStep === 'done' && ( + <> +

+ {t('settings.revokeDone', { name: revokeTarget?.name ?? '' })} +

+ {revokeUrl &&

{revokeUrl}

} + {revokeUrl && ( + + )} + + )} +
+ + {revokeStep === 'done' ? ( + + ) : ( + <> + + {revokeStep === 'confirm' && ( + + )} + + )} + +
+
+
+
+ + { + if (!open && !expireBusy) setExpireTarget(null) + }} + > + + {/* placement="top": диалог прижат к верху. Иначе при центрировании + шапка календаря уезжает вверх/вниз при смене месяца (5 недель ↔ 6), + и стрелки «прыгают» под курсором. */} + + + + + {t('settings.expireTitle')} — {expireTarget?.name ?? ''} + + + +

{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} +
+ + {/* Отмена — слева и всегда только закрывает диалог, без действий. */} + + + +
+
+
+
+ + { + if (!open) setCreateExpireOpen(false) + }} + > + + {/* placement="top": диалог прижат к верху, поэтому шестая неделя + календаря раскрывается ВНИЗ и не сдвигает шапку со стрелками. */} + + + + {t('settings.createExpire')} + + +

{t('settings.createExpireHint')}

+ +
+ + {createExpire ? ( + + ) : null} + + +
+
+
+
+ { @@ -863,7 +1307,7 @@ export function ServerSettings({ ))} ) : ( -
+