From c1a951348b6a494e25518a3c93fe182b0f40042e Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:43:33 +0300 Subject: [PATCH 01/29] release: Xrayebator 0.5.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Версия 0.5.5, релизные заметки RU/EN и запись в CHANGELOG. Содержание релиза — то, что уже влито в main через PR #26 и проверено на живом VPS: импорт существующих серверов (read-only inspect), сохранение SSH-пароля в системном keychain, необязательный email при развёртывании, живая консоль импорта, маскирование токена подписки и фикс гонки apt-lock. Заметки написаны в стиле v0.5.0 — разница объясняется на языке пользователя: было «только развернуть новый сервер», стало «развернуть или подключить существующий». --- CHANGELOG.md | 4 +- docs/releases/v0.5.5.en.md | 175 +++++++++++++++++++++++++++++++++++++ docs/releases/v0.5.5.md | 175 +++++++++++++++++++++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- 5 files changed, 356 insertions(+), 4 deletions(-) create mode 100644 docs/releases/v0.5.5.en.md create mode 100644 docs/releases/v0.5.5.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b9a1788..6e02295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ User-facing Xrayebator changes. The server manager and Electron application are published from the canonical `howdeploy/Xrayebator` repository. -## [Unreleased] +## [0.5.5] - 2026-09-25 + +Connecting to servers that already run Xrayebator, plus fixes found while testing on a live VPS. ### Added diff --git a/docs/releases/v0.5.5.en.md b/docs/releases/v0.5.5.en.md new file mode 100644 index 0000000..39ca94f --- /dev/null +++ b/docs/releases/v0.5.5.en.md @@ -0,0 +1,175 @@ +# Xrayebator v0.5.5 — the panel can now adopt servers you already run + +> An update to the server manager and the **Xrayebator Desktop GUI**. Published from `howdeploy/Xrayebator/main`. Installs over `v0.5.0` — no need to migrate or reconfigure existing servers. + +## In two words + +`v0.5.0` gave Xrayebator a graphical panel, but it could only do one thing: **deploy a brand-new server from scratch**. If you already had a server — with Xrayebator, profiles and a subscription running on it — the panel knew nothing about it, so you either started over or stayed in the terminal. + +`v0.5.5` closes that gap. The panel now has two distinct paths: + +```text +┌──────────────────────────────┐ +│ Xrayebator Desktop GUI │ +└──────────────┬───────────────┘ + │ + ┌────────┴────────┐ + ▼ ▼ +┌───────────┐ ┌──────────────────┐ +│ Deploy a │ │ Connect an │ +│ new server│ │ existing server │ +│ │ │ (read-only) │ +└─────┬─────┘ └────────┬─────────┘ + │ │ + ▼ ▼ + install from zero the panel finds what + + configure + is already there and + subscription shows it as it is +``` + +The important difference: **import changes nothing on the server**. It only looks and remembers. Your working installation stays exactly as it was. + +## What is new + +### 1. "Connect an existing server" — the missing path + +There used to be one route only: deploy the server again. Now an empty Dashboard offers two cards — **Deploy a new server** and **Connect an existing server** — and with servers already present, the "Add" button offers the same choice. + +How it works for you: + +1. Enter the IP, SSH port, user and access details — **password or key**, exactly as in deployment. +2. The panel connects and runs **one** command: `xrayebator inspect --json`. It only reads: it finds the manager, Xray, profiles, subscription markers, country and city. +3. The server appears in the list together with its keys and subscription. + +The panel does **not** run the installer, `quickstart`, `happ-setup`, migrations, updates, service restarts, and does not touch the firewall. This is not "configure everything again" — it is "show me what I have". + +The half-configured case gets its own treatment. Previously such a server either refused to import or looked healthy. Now it is honestly marked **"Partially configured"**, and the card shows which components are present and which are missing. A local-only or unreachable subscription is never presented as working. + +Importing the same `host + port` again does not create a duplicate — it updates the existing card and keeps its id and pinned host key. + +### 2. For the first time you can see what is happening + +The import wizard shows not only the current step but a **live console**, just like deployment: + +```text +> SSH: connecting to 31.76.240.21:22 (root) +> SSH: connection established, host key confirmed +> $ xrayebator inspect --json +> inspect: code 0, manager ok, Xray running, profiles 3 +> subscription: $ curl https://…/sub/0123…cdef +> subscription: routes received: 7 +> result: ready, keys: 7 +``` + +You can see which commands actually reached the server and what it answered. The subscription token is masked in the console — its beginning and end are visible, the middle is hidden, because the full subscription URL works like a password. + +### 3. No need to retype the password every time + +Previously only the SSH key lived in the system keychain (Windows Credential Manager, macOS Keychain, Linux Secret Service). The password had to be entered every time you opened server settings. + +Now the **SSH password is stored in the system keychain after the first successful sign-in** and reused automatically. The server card keeps only a non-secret credential id — the password itself never reaches application files. + +A distinct sudo password and a key passphrase are still **not stored** — a deliberate choice: they are requested when needed. If the system keychain is unavailable there is no fallback file with a password: the panel plainly warns that access will have to be entered again after a restart. + +### 4. The server card is easier to read + +Server settings open and connect immediately using the saved access — you land on the profile panel, not on a login form. The form appears only when there is no saved access or the connection failed. + +The card itself shows what matters at a glance: + +- the server **operating system**; +- how many **routes** are active; +- the **SSH access** — `root@31.76.240.21:22` and where the secret lives (in the system, or this session only); +- the **installation status** — green "Configured", yellow "Partially configured", grey "Imported". + +Access can be changed from a three-dot menu right on the card. + +All of these values are asked from the server rather than hard-coded. Debian, Ubuntu 24.04 or anything else, one route or seven, `root` or `ubuntu` — the card shows what is actually there. + +### 5. Email during deployment is now optional + +`quickstart` used to require an email for Let's Encrypt. You can now choose: provide an email (the default) or **continue without one**. + +Without an email the certificate is still issued and works, but Let's Encrypt cannot send renewal notices or recover the ACME account. The panel warns about this **before** starting and never substitutes an invented address. + +## What was fixed + +### Installation failed on a fresh server + +There was an unpleasant race: a newly created VPS checks and installs updates through `unattended-upgrades` right after boot. That process holds the package-manager lock for about ten minutes, invoking `dpkg` separately for each package — and between packages the lock is free for a few seconds. + +The usual "busy or free" check landed exactly in that gap, installation continued, hit the busy `dpkg` and failed with `apt-get install nginx failed`. It looked like a random failure out of nowhere. + +Installation now waits for the active upgrade worker (up to 12 minutes) and passes an explicit lock timeout to apt. The regression is locked down by a dedicated test. + +### Small things that spoiled the impression + +- The deployment log printed the full subscription URL. The token is now masked there and in the import console. +- Validation scripts died in CI with `Broken pipe` — the reader exited before the writer finished. Fixed. + +## How well this is tested + +The project grew in verification: + +| | v0.5.0 | v0.5.5 | +|---|---|---| +| Bash validation | 24 scripts | **26 scripts** | +| GUI unit tests | 9 files | **14 files** | +| Type contracts | — | **included in `npm run typecheck`** | + +The new checks cover exactly what was added: keychain access, server state after import, diagnostics parsing, the apt-lock race and UI contracts. + +Importing an existing server was additionally verified on a live test VPS: the card fills with the real OS, route count and user. + +## Important before updating + +### Server requirement + +Importing an existing server calls `xrayebator inspect` — a command the server-side version may not have yet. If import fails with an invalid-JSON error, update the manager on the server: + +```bash +sudo xrayebator update main +``` + +Deploying new servers is unaffected — there the panel installs a fresh manager itself. + +### Breaking changes + +None. The panel and the server manager install over the previous version; saved server cards, keys and subscriptions keep working. + +### Honest limitations + +- Some terminal capabilities still have no interface: `bypass`, `probe-test`, subscription revoke, `happ-setup`, cascade, self-steal, service logs and the full interactive menu. +- There is still no full end-to-end test of the packaged application. +- The subscription URL and VLESS links are access keys. They are stored locally so the panel can work; protect the application data and revoke the subscription if they leak. + +## How to update + +### Server + +```bash +sudo xrayebator update main +``` + +### Xrayebator Desktop GUI + +1. Download the package for your OS from the GitHub Release. +2. Install it over the previous version — server cards are preserved. +3. Add an existing server through **"Connect an existing server"**: the panel finds the installation and pulls in the keys. + +## Builds + +The release is built natively for three platforms: + +- Windows — `Xrayebator-0.5.5-win.exe`; +- macOS Apple Silicon — `Xrayebator-0.5.5-mac-arm64.dmg` and `.zip` (the ZIP feeds the built-in auto-updater); +- Linux — `Xrayebator-0.5.5-linux.AppImage`. + +Before packaging, Ubuntu CI runs typecheck, unit tests and the full Bash validation suite (26 scripts). + +## Links + +- [Main README](https://github.com/howdeploy/Xrayebator/blob/main/README.md) +- [Electron Desktop GUI reference](https://github.com/howdeploy/Xrayebator/blob/main/docs/desktop-gui.md) +- [Testing](https://github.com/howdeploy/Xrayebator/blob/main/docs/testing.md) +- [Canonical repository](https://github.com/howdeploy/Xrayebator) \ No newline at end of file diff --git a/docs/releases/v0.5.5.md b/docs/releases/v0.5.5.md new file mode 100644 index 0000000..bef1f0f --- /dev/null +++ b/docs/releases/v0.5.5.md @@ -0,0 +1,175 @@ +# Xrayebator v0.5.5 — панель научилась подключаться к серверам, которые уже стоят + +> Обновление серверного менеджера и **Xrayebator Desktop GUI**. Публикуется из `howdeploy/Xrayebator/main`. Ставится поверх `v0.5.0` — переносить или перенастраивать существующие серверы не нужно. + +## Если объяснить в двух словах + +В `v0.5.0` у Xrayebator появилась графическая панель, но она умела одно: **развернуть новый сервер с нуля**. Если сервер у вас уже был — с работающим Xrayebator, профилями и подпиской — панель про него ничего не знала, и приходилось либо заводить всё заново, либо жить в терминале. + +В `v0.5.5` это закрыто. Теперь панель умеет два разных сценария: + +```text +┌──────────────────────────────┐ +│ Xrayebator Desktop GUI │ +└──────────────┬───────────────┘ + │ + ┌────────┴────────┐ + ▼ ▼ +┌───────────┐ ┌──────────────────┐ +│ Развернуть│ │ Подключить │ +│ новый │ │ существующий │ +│ сервер │ │ (только чтение) │ +└─────┬─────┘ └────────┬─────────┘ + │ │ + ▼ ▼ + установка с нуля панель находит то, + + настройка + что уже стоит, и + подписка показывает как есть +``` + +Главное отличие: **импорт ничего не меняет на сервере**. Он только смотрит и запоминает. Ваша работающая установка остаётся ровно такой, какой была. + +## Что нового + +### 1. «Подключить существующий сервер» — то, чего не хватало + +Раньше был один путь: развернуть сервер заново. Теперь на пустом Dashboard две карточки — **Развернуть новый сервер** и **Подключить существующий**, а при непустом списке кнопка «Добавить» предлагает тот же выбор. + +Как это работает для вас: + +1. Вводите IP, SSH-порт, пользователя и доступ — **пароль или ключ**, ровно как при развёртывании. +2. Панель подключается и выполняет **одну** команду: `xrayebator inspect --json`. Она только читает: находит менеджер, Xray, профили, маркеры подписки, страну и город. +3. Сервер появляется в списке вместе с ключами и подпиской. + +Панель **не** запускает установщик, `quickstart`, `happ-setup`, миграции, обновление, перезапуск служб и не трогает firewall. Это не «настройка заново» — это «покажи, что у меня есть». + +Отдельно продуман случай, когда сервер настроен наполовину. Раньше такой сервер либо не импортировался, либо выглядел как рабочий. Теперь он честно помечается как **«Настроен частично»**, и в карточке видно, какие компоненты на месте, а какие нет. Локальная или недоступная подписка не выдаётся за рабочую. + +Импорт того же `host + port` повторно не создаёт дубликат — он обновляет существующую карточку, сохраняя её id и закреплённый host key. + +### 2. Впервые видно, что именно происходит + +Мастер импорта показывает не только «какой сейчас шаг», но и **живую консоль** — как при развёртывании: + +```text +> SSH: подключаюсь к 31.76.240.21:22 (root) +> SSH: соединение установлено, host key подтверждён +> $ xrayebator inspect --json +> inspect: код 0, менеджер ok, Xray running, профилей 3 +> subscription: $ curl https://…/sub/0123…cdef +> subscription: получено маршрутов: 7 +> итог: ready, ключей: 7 +``` + +Видно, какие команды реально ушли на сервер и что он ответил. Токен подписки в консоли маскируется: его начало и конец, середина скрыта — потому что полный URL подписки работает как пароль. + +### 3. Пароль больше не нужно вводить каждый раз + +Раньше в системном хранилище (Windows Credential Manager, macOS Keychain, Linux Secret Service) лежал только SSH-ключ. Пароль приходилось вводить при каждом открытии настроек сервера. + +Теперь **SSH-пароль сохраняется в системное хранилище после первого успешного входа** и используется дальше автоматически. В карточке сервера хранится только несекретный идентификатор — сам пароль в файлы приложения не попадает. + +Отдельный sudo-пароль и passphrase ключа по-прежнему **не сохраняются** — это осознанное решение: они запрашиваются, когда нужны. Если системное хранилище недоступно, запасного варианта в виде файла с паролем нет: панель честно предупредит, что после перезапуска доступ придётся ввести заново. + +### 4. Карточка сервера стала понятнее + +Настройки сервера открываются и сразу подключаются по сохранённому доступу — вы видите профили, а не форму входа. Форма появляется, только если сохранённого доступа нет или подключение не прошло. + +Сама карточка показывает главное сразу: + +- **операционная система** сервера; +- **сколько маршрутов** активно; +- **SSH-доступ** — `root@31.76.240.21:22` и где лежит секрет (в системе или только на этот сеанс); +- **статус установки** — зелёным «Настроен», жёлтым «Настроен частично», серым «Импортирован». + +Поменять доступ можно из меню из трёх точек прямо в карточке. + +Все эти значения панель спрашивает у сервера, а не показывает заранее заготовленные. Будет Debian, Ubuntu 24.04 или что-то ещё, один маршрут или семь, `root` или `ubuntu` — отобразится то, что есть на самом деле. + +### 5. Email при развёртывании — по желанию + +Раньше `quickstart` требовал email для Let's Encrypt. Теперь есть выбор: указать email (по умолчанию) или **продолжить без него**. + +Без email сертификат выпускается и работает, но Let's Encrypt не сможет присылать уведомления о продлении и восстанавливать доступ к ACME-аккаунту. Панель предупреждает об этом **до** запуска и никогда не подставляет выдуманный адрес. + +## Что починили + +### Установка падала на свежем сервере + +Была неприятная гонка: только что созданный VPS почти сразу после загрузки проверяет и ставит обновления через `unattended-upgrades`. Этот процесс держит блокировку пакетного менеджера около десяти минут, вызывая `dpkg` отдельно для каждого пакета — и между пакетами блокировка на секунды освобождается. + +Обычная проверка «занято/свободно» попадала ровно в этот зазор, установка продолжалась, натыкалась на занятый `dpkg` и падала с `apt-get install nginx failed`. Выглядело это как случайный сбой на ровном месте. + +Теперь установка ждёт активный процесс обновлений (до 12 минут) и передаёт apt явное ожидание блокировки. Регрессия закреплена отдельным тестом. + +### Мелочи, которые портили впечатление + +- В логе развёртывания печатался полный URL подписки. Теперь токен маскируется — и там, и в консоли импорта. +- Скрипты проверки падали в CI с `Broken pipe` — читатель выходил раньше, чем писатель заканчивал. Исправлено. + +## Насколько это проверено + +Проект вырос в количестве проверок: + +| | v0.5.0 | v0.5.5 | +|---|---|---| +| Bash-валидация | 24 скрипта | **26 скриптов** | +| Unit-тесты GUI | 9 файлов | **14 файлов** | +| Type-контракты | — | **включены в `npm run typecheck`** | + +Новые проверки покрывают именно то, что добавилось: чтение системного хранилища, состояние сервера после импорта, парсинг диагностики, гонку apt-lock и UI-контракты. + +Отдельно проверено на живом тестовом VPS: импорт существующего сервера проходит, карточка заполняется настоящими ОС, маршрутами и пользователем. + +## Важно перед обновлением + +### Требование к серверу + +Импорт существующего сервера вызывает `xrayebator inspect` — команду, которой в версии на сервере может не быть. Если импорт вернул ошибку про некорректный JSON, обновите менеджер на сервере: + +```bash +sudo xrayebator update main +``` + +Развёртывание новых серверов это не затрагивает — там панель сама кладёт свежий менеджер. + +### Breaking changes + +Их нет. Панель и серверный менеджер обновляются поверх предыдущей версии; сохранённые карточки серверов, ключи и подписки продолжают работать. + +### Честные ограничения + +- В интерфейсе по-прежнему нет части терминальных возможностей: `bypass`, `probe-test`, отзыв подписки, `happ-setup`, cascade, self-steal, просмотр логов служб и полное интерактивное меню. +- Полноценного end-to-end теста упакованного приложения пока нет. +- URL подписки и VLESS-ссылки — это ключи доступа. Они хранятся локально, чтобы панель работала; защищайте данные приложения и отзывайте подписку при утечке. + +## Как обновиться + +### Сервер + +```bash +sudo xrayebator update main +``` + +### Xrayebator Desktop GUI + +1. Скачайте пакет для своей ОС из GitHub Release. +2. Установите поверх предыдущей версии — карточки серверов сохранятся. +3. Существующий сервер добавьте через **«Подключить существующий»**: панель найдёт установку и подтянет ключи. + +## Сборка + +Релиз собирается нативно для трёх систем: + +- Windows — `Xrayebator-0.5.5-win.exe`; +- macOS Apple Silicon — `Xrayebator-0.5.5-mac-arm64.dmg` и `.zip` (ZIP нужен встроенному автообновлению); +- Linux — `Xrayebator-0.5.5-linux.AppImage`. + +Перед сборкой на Ubuntu прогоняются typecheck, unit-тесты и полный набор Bash-валидаций (26 скриптов). + +## Ссылки + +- [Основной README](https://github.com/howdeploy/Xrayebator/blob/main/README.ru.md) +- [Справочник Electron Desktop GUI](https://github.com/howdeploy/Xrayebator/blob/main/docs/ru/desktop-gui.md) +- [Тестирование](https://github.com/howdeploy/Xrayebator/blob/main/docs/ru/testing.md) +- [Канонический репозиторий](https://github.com/howdeploy/Xrayebator) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9e3e65c..536d2ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xrayebator", - "version": "0.5.0", + "version": "0.5.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xrayebator", - "version": "0.5.0", + "version": "0.5.5", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 5ac064e..47e68a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xrayebator", - "version": "0.5.0", + "version": "0.5.5", "description": "Xrayebator Desktop — админ-панель развёртки VPN-сервера на VPS", "main": "out/main/index.js", "author": "Xrayebator", From 2801fb9e26d02084fc614d99113411d8504777d6 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:57:28 +0300 Subject: [PATCH 02/29] =?UTF-8?q?fix(ci):=20=D1=81=D1=82=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20libsecret=20=D0=B2=20job=20test=20=D1=80=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BD=D0=BE=D0=B3=D0=BE=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Релиз v0.5.5 упал на шаге npm test: на ubuntu-раннере нет libsecret-1.so.0, поэтому keytar не загружается и весь tests/unit/ssh-keychain.test.ts падает как suite (75 тестов из 75 проходят, но файл не стартует). В этом же файле libsecret уже ставился для job build, а для test — нет. Тесты проходили локально на Windows (Credential Manager) и в ci-linux (там GUI-тесты не запускаются вообще), поэтому проблема всплыла только на первом релизном прогоне после добавления ssh-keychain.test.ts. --- .github/workflows/release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c866946..617d328 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,10 @@ jobs: with: node-version: 22 cache: npm + - name: Install Linux system deps + run: | + sudo apt-get update + sudo apt-get install -y libsecret-1-dev - run: npm ci - run: npm test From 26c27ace1dbd57a500e2c8cd7e3475335e51f1c6 Mon Sep 17 00:00:00 2001 From: Ap3x0s Date: Sat, 26 Sep 2026 00:08:54 +0300 Subject: [PATCH 03/29] =?UTF-8?q?ci(release):=20preflight-=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D1=82=D0=BE=D0=BA=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B8=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BB=D0=B8=D0=B7=D0=B0,=20=D0=BF=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BE=D1=82=20?= =?UTF-8?q?=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB=D1=8C=D1=86=D0=B0=20AP3X0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поле author у GitHub-релиза неизменяемо: выпущенный ботом релиз нельзя «перекрестить» на человека, поэтому имя публикуемого аккаунта надо выбрать до публикации. Workflow теперь: - preflight job проверяет наличие docs/releases/.en.md и способность секрета AP3X0 создавать релизы (пробным черновиком, который сразу удаляется) — до того, как потрачены ~15 минут на три платформы; - job release публикует от AP3X0, если секрет задан и прошёл пробник, иначе остаётся на встроенном GITHUB_TOKEN. Без секретов поведение не меняется: автор github-actions[bot]. --- .github/workflows/release.yml | 48 +++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 617d328..5499be1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,45 @@ permissions: contents: write jobs: + # Проверяет всё, что может обвалить релиз, ДО того как потратить ~15 минут на + # три платформы: наличие текста релиза и способность токена публиковать + # релизы от имени владельца. Иначе ошибка всплывает в самом последнем job. + preflight: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Check release notes exist + if: startsWith(github.ref, 'refs/tags/v') + run: | + set -euo pipefail + f="docs/releases/${GITHUB_REF_NAME}.en.md" + if [[ ! -s "$f" ]]; then + echo "::error::Нет текста релиза $f — создайте его до установки тега ${GITHUB_REF_NAME}." + echo "Без файла релиз всё равно опубликуется, но с авто-сгенерированными notes вместо описания для пользователя." + exit 1 + fi + echo "Текст релиза: $(wc -c < "$f") байт" + - name: Check publishing identity + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ secrets.AP3X0 }} + CANDIDATE_SET: ${{ secrets.AP3X0 != '' }} + run: | + set -euo pipefail + if [[ "$CANDIDATE_SET" != "true" ]]; then + echo "::warning::Секрет AP3X0 не задан — релиз опубликует github-actions[bot], а не владелец аккаунта." + exit 0 + fi + probe="preflight-probe-${GITHUB_RUN_ID}" + if ! gh release create "$probe" --repo "$GITHUB_REPOSITORY" --title "preflight probe" --draft --notes "проверка прав, черновик немедленно удалён" 2>/tmp/probe.err; then + echo "::error::Токен AP3X0 не может создавать релизы: $(cat /tmp/probe.err)" + echo "Выдайте PAT аккаунта Ap3x0s право Contents: Read and Write в howdeploy/Xrayebator (Settings → Secrets and variables → Actions → AP3X0), затем повторите тег." + exit 1 + fi + gh release delete "$probe" --repo "$GITHUB_REPOSITORY" --yes >/dev/null 2>&1 \ + || echo "::warning::Пробный черновик $probe не удалён — снесите вручную." + echo "Логин токена: $(gh api user --jq .login) — релиз будет опубликован от этого аккаунта." + typecheck: runs-on: ubuntu-24.04 steps: @@ -37,7 +76,7 @@ jobs: - run: npm test build: - needs: [typecheck, test] + needs: [preflight, typecheck, test] strategy: fail-fast: false matrix: @@ -118,8 +157,13 @@ jobs: path: artifacts merge-multiple: true - name: Publish GitHub release + # Автор релиза = владелец токена публикации: поле author в GitHub + # неизменяемо, заданного ботом «переключить» на человека нельзя. Если в + # репозитории есть секрет AP3X0 (PAT аккаунта Ap3x0s с Contents: write), + # релиз выходит от его имени и preflight выше это уже проверил; без + # секретов остаётся встроенный GITHUB_TOKEN и автор github-actions[bot]. env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.AP3X0 != '' && secrets.AP3X0 || github.token }} run: | notes_file="docs/releases/${GITHUB_REF_NAME}.en.md" if gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then From 21fed1bdfe200c0cdb1c4add87a772a286b2f6cd Mon Sep 17 00:00:00 2001 From: Ap3x0s Date: Sat, 26 Sep 2026 00:11:00 +0300 Subject: [PATCH 04/29] =?UTF-8?q?docs(testing):=20=D0=BE=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20preflight=20=D0=B8=20=D0=B2=D1=8B=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B0=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8=D0=B7=D0=B0=20=D0=B2=20release.yml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Изменение workflow меняет наблюдаемое поведение (быстрые падение на плохом токене, имя автора релиза), поэтому документация по CI обновляется в трёх локалях сразу: ru/en/zh. --- docs/ru/testing.md | 9 +++++++-- docs/testing.md | 9 +++++++-- docs/zh-CN/testing.md | 10 +++++++--- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/ru/testing.md b/docs/ru/testing.md index a13bdf7..a1ecffe 100644 --- a/docs/ru/testing.md +++ b/docs/ru/testing.md @@ -106,7 +106,12 @@ npm test # Vitest unit-тесты - **ci-linux.yml** — Bash validation: `bash -n` всех скриптов + все 26 `validation/test-*.sh` на ubuntu-24.04. Запускается на push в `main`, `dev`, `experimental` и на pull request. - **release.yml** — Electron сборка (Windows/macOS/Linux). Запускается только на теги `v*` и manual - dispatch. Выполняет `npm run typecheck`, `npm test`, `npm run build` на ubuntu, затем - `electron-builder --publish never` на всех трёх платформах. + dispatch. Сначала `preflight`: проверяет наличие текста релиза `docs/releases/.en.md` и + способность секрета `AP3X0` создавать релизы (пробным черновиком, который сразу удаляется), — чтобы + непригодный токен падал за секунды, а не после трёх сборок. Затем `npm run typecheck`, `npm test`, + `npm run build` на ubuntu и `electron-builder --publish never` на всех трёх платформах. Релиз + публикуется от владельца `AP3X0`, если секрет даёт право Contents: write, иначе — от + `github-actions[bot]` через встроенный `GITHUB_TOKEN`: автор релиза фиксируется при создании и + назад не переключается. - **gui-release.yml** — legacy PySide6 GUI: ruff + pytest + wheel build. Запускается на PR и теги `gui-v*`. \ No newline at end of file diff --git a/docs/testing.md b/docs/testing.md index aef293a..d3c0208 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -150,8 +150,13 @@ The workflows have separate responsibilities: `uuid-runtime` and `ripgrep`, runs all four Bash syntax checks, then runs all 26 validation scripts. - `.github/workflows/release.yml` is the active Electron release path for `v*` tags or manual runs. - It runs `npm run typecheck` and `npm test` on Ubuntu, then builds/packages Windows, macOS and Linux - artifacts after those checks pass. + A `preflight` job first checks that the release notes file `docs/releases/.en.md` exists and + that the `AP3X0` repository secret (if set) can actually create releases — it probes with a draft + release that is deleted in the same step, so an unusable token fails in seconds instead of after + three platform builds. Then it runs `npm run typecheck` and `npm test` on Ubuntu and builds/packages + Windows, macOS and Linux artifacts. The `release` job publishes as the owner of the `AP3X0` token + when that secret grants Contents: write, otherwise as `github-actions[bot]` via the built-in + `GITHUB_TOKEN`: a GitHub release author is fixed at creation time and cannot be reassigned later. - `.github/workflows/gui-release.yml` is the legacy PySide6 workflow. It runs Python `ruff` and `pytest gui-legacy/tests`, builds legacy native bundles, and runs only a small Bash smoke subset; it is not the Electron unit-test workflow. diff --git a/docs/zh-CN/testing.md b/docs/zh-CN/testing.md index 6a3251f..d47d4b0 100644 --- a/docs/zh-CN/testing.md +++ b/docs/zh-CN/testing.md @@ -102,7 +102,11 @@ npm test # Vitest 单元测试 - **ci-linux.yml** — Bash validation:在 ubuntu-24.04 上对所有脚本执行 `bash -n`,并运行全部 26 个 `validation/test-*.sh`;在 push 到 `main`、`dev`、`experimental` 以及 pull request 时运行。 -- **release.yml** — Electron 构建(Windows/macOS/Linux)。只在 `v*` tag 和手动触发时运行;先在 - Ubuntu 上执行 `npm run typecheck`、`npm test`、`npm run build`,再在三种平台执行 - `electron-builder --publish never`。 +- **release.yml** — Electron 构建(Windows/macOS/Linux)。只在 `v*` tag 和手动触发时运行;先由 + `preflight` 检查发布说明 `docs/releases/.en.md` 是否存在,以及仓库密钥 `AP3X0`(若已设置) + 是否真的能创建 release——用随即删除的草稿探测,使不可用的 token 在几秒内失败,而不是三次构建之后。 + 然后在 Ubuntu 上执行 `npm run typecheck`、`npm test`、`npm run build`,再在三种平台执行 + `electron-builder --publish never`。若 `AP3X0` 具备 Contents: write 权限,release 以该 token + 所有者名义发布,否则以 `github-actions[bot]` 通过内置 `GITHUB_TOKEN` 发布:GitHub release 的 + 作者在创建时即固定,事后无法改换。 - **gui-release.yml** — legacy PySide6 GUI:ruff、pytest 和 wheel 构建;在 PR 与 `gui-v*` tag 上运行。 \ No newline at end of file From 67764d176647e1a4886f23c94acc657622ea2cdf Mon Sep 17 00:00:00 2001 From: Ap3x0s Date: Sat, 26 Sep 2026 00:16:44 +0300 Subject: [PATCH 05/29] =?UTF-8?q?ci(release):=20preflight=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D0=B2=D0=B0=D0=BB=D0=B8=D1=82=20=D0=B2=D1=8B=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=D0=BA=D1=83,=20=D0=B0=20=D0=B2=D1=8B=D0=B1=D0=B8=D1=80?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B0=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Предыдущая версия падала на preflight, если токен AP3X0 не может создавать релизы, — но релиз можно опубликовать и встроенным GITHUB_TOKEN, ломать из-за этого выкатку неверно. Теперь обе проверки (текст релиза, боеспособность токена) только предупреждают, а job release берёт AP3X0 лишь когда preflight подтвердил его права (output as_user); иначе — github-actions[bot]. Так исключён и худший сценарий: падение публикации после трёх платформ. --- .github/workflows/release.yml | 54 +++++++++++++++++++++-------------- docs/ru/testing.md | 7 +++-- docs/testing.md | 11 +++---- docs/zh-CN/testing.md | 10 +++---- 4 files changed, 47 insertions(+), 35 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5499be1..4a4e992 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,44 +10,55 @@ permissions: contents: write jobs: - # Проверяет всё, что может обвалить релиз, ДО того как потратить ~15 минут на - # три платформы: наличие текста релиза и способность токена публиковать - # релизы от имени владельца. Иначе ошибка всплывает в самом последнем job. + # Проверяет всё, что может испортить релиз, ДО того как потратить ~15 минут на + # три платформы: наличие текста релиза и способность токена публикации быть + # человеком. Обе проверки — предупреждающие: релиз выйдет в любом случае, + # но при отсутствии прав автором станет github-actions[bot]. preflight: runs-on: ubuntu-24.04 + outputs: + publish_as_user: ${{ steps.token_probe.outputs.as_user }} steps: - uses: actions/checkout@v4 - name: Check release notes exist if: startsWith(github.ref, 'refs/tags/v') run: | - set -euo pipefail + set -uo pipefail f="docs/releases/${GITHUB_REF_NAME}.en.md" if [[ ! -s "$f" ]]; then - echo "::error::Нет текста релиза $f — создайте его до установки тега ${GITHUB_REF_NAME}." - echo "Без файла релиз всё равно опубликуется, но с авто-сгенерированными notes вместо описания для пользователя." - exit 1 + echo "::warning::Нет текста релиза $f — GitHub опубликует релиз с авто-сгенерированными notes вместо описания для пользователя." + exit 0 fi echo "Текст релиза: $(wc -c < "$f") байт" - name: Check publishing identity + id: token_probe if: startsWith(github.ref, 'refs/tags/v') env: GH_TOKEN: ${{ secrets.AP3X0 }} CANDIDATE_SET: ${{ secrets.AP3X0 != '' }} run: | - set -euo pipefail + set -uo pipefail + # Пробный черновик релиза — единственный дешёвый способ проверить права + # токена публикации, пока не потрачены ~15 минут на три платформы. Из-за + # прав релиз падать не должен (он выйдет и от бота), поэтому только + # предупреждаем и прокидываем вывод в job release. if [[ "$CANDIDATE_SET" != "true" ]]; then - echo "::warning::Секрет AP3X0 не задан — релиз опубликует github-actions[bot], а не владелец аккаунта." + echo "::warning::Секрет AP3X0 не задан — релиз опубликует github-actions[bot]." + echo "as_user=false" >> "$GITHUB_OUTPUT" exit 0 fi probe="preflight-probe-${GITHUB_RUN_ID}" - if ! gh release create "$probe" --repo "$GITHUB_REPOSITORY" --title "preflight probe" --draft --notes "проверка прав, черновик немедленно удалён" 2>/tmp/probe.err; then - echo "::error::Токен AP3X0 не может создавать релизы: $(cat /tmp/probe.err)" - echo "Выдайте PAT аккаунта Ap3x0s право Contents: Read and Write в howdeploy/Xrayebator (Settings → Secrets and variables → Actions → AP3X0), затем повторите тег." - exit 1 + if gh release create "$probe" --repo "$GITHUB_REPOSITORY" --title "preflight probe" --draft --notes "проверка прав публикации" 2>/tmp/probe.err; then + gh release delete "$probe" --repo "$GITHUB_REPOSITORY" --yes >/dev/null 2>&1 \ + || echo "::warning::Пробный черновик $probe не удалён — снесите вручную." + echo "Токен AP3X0 активен ($(gh api user --jq .login)) — релиз пойдёт от этого аккаунта." + echo "as_user=true" >> "$GITHUB_OUTPUT" + else + gh release delete "$probe" --repo "$GITHUB_REPOSITORY" --yes >/dev/null 2>&1 || true + echo "::warning::Токен AP3X0 не может создавать релизы: $(tr -d '\n' < /tmp/probe.err | head -c 200)" + echo "::warning::Чтобы релиз шёл от вашего аккаунта: fine-grained PAT → Repository permissions → Contents: Read and write, затем пересохраните секрет AP3X0 в Settings → Secrets and variables → Actions." + echo "as_user=false" >> "$GITHUB_OUTPUT" fi - gh release delete "$probe" --repo "$GITHUB_REPOSITORY" --yes >/dev/null 2>&1 \ - || echo "::warning::Пробный черновик $probe не удалён — снесите вручную." - echo "Логин токена: $(gh api user --jq .login) — релиз будет опубликован от этого аккаунта." typecheck: runs-on: ubuntu-24.04 @@ -148,7 +159,7 @@ jobs: release: if: startsWith(github.ref, 'refs/tags/v') - needs: build + needs: [preflight, build] runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -158,12 +169,11 @@ jobs: merge-multiple: true - name: Publish GitHub release # Автор релиза = владелец токена публикации: поле author в GitHub - # неизменяемо, заданного ботом «переключить» на человека нельзя. Если в - # репозитории есть секрет AP3X0 (PAT аккаунта Ap3x0s с Contents: write), - # релиз выходит от его имени и preflight выше это уже проверил; без - # секретов остаётся встроенный GITHUB_TOKEN и автор github-actions[bot]. + # неизменяемо, заданного ботом «переключить» на человека нельзя. Берём + # AP3X0 только если preflight подтвердил его боеспособность — иначе + # публикация упала бы в самом конце, после трёх сборок, и без отката. env: - GH_TOKEN: ${{ secrets.AP3X0 != '' && secrets.AP3X0 || github.token }} + GH_TOKEN: ${{ needs.preflight.outputs.as_user == 'true' && secrets.AP3X0 || github.token }} run: | notes_file="docs/releases/${GITHUB_REF_NAME}.en.md" if gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then diff --git a/docs/ru/testing.md b/docs/ru/testing.md index a1ecffe..17aec35 100644 --- a/docs/ru/testing.md +++ b/docs/ru/testing.md @@ -108,9 +108,10 @@ npm test # Vitest unit-тесты - **release.yml** — Electron сборка (Windows/macOS/Linux). Запускается только на теги `v*` и manual dispatch. Сначала `preflight`: проверяет наличие текста релиза `docs/releases/.en.md` и способность секрета `AP3X0` создавать релизы (пробным черновиком, который сразу удаляется), — чтобы - непригодный токен падал за секунды, а не после трёх сборок. Затем `npm run typecheck`, `npm test`, - `npm run build` на ubuntu и `electron-builder --publish never` на всех трёх платформах. Релиз - публикуется от владельца `AP3X0`, если секрет даёт право Contents: write, иначе — от + неприятность всплыла за секунды, а не после трёх сборок. Обе проверки предупреждающие: релиз выйдет + в любом случае. Затем `npm run typecheck`, `npm test`, `npm run build` на ubuntu и + `electron-builder --publish never` на всех трёх платформах. Если `preflight` подтвердил, что + `AP3X0` может публиковать, релиз выходит от владельца этого токена; иначе — от `github-actions[bot]` через встроенный `GITHUB_TOKEN`: автор релиза фиксируется при создании и назад не переключается. - **gui-release.yml** — legacy PySide6 GUI: ruff + pytest + wheel build. Запускается на PR и теги diff --git a/docs/testing.md b/docs/testing.md index d3c0208..8bd4110 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -152,11 +152,12 @@ The workflows have separate responsibilities: - `.github/workflows/release.yml` is the active Electron release path for `v*` tags or manual runs. A `preflight` job first checks that the release notes file `docs/releases/.en.md` exists and that the `AP3X0` repository secret (if set) can actually create releases — it probes with a draft - release that is deleted in the same step, so an unusable token fails in seconds instead of after - three platform builds. Then it runs `npm run typecheck` and `npm test` on Ubuntu and builds/packages - Windows, macOS and Linux artifacts. The `release` job publishes as the owner of the `AP3X0` token - when that secret grants Contents: write, otherwise as `github-actions[bot]` via the built-in - `GITHUB_TOKEN`: a GitHub release author is fixed at creation time and cannot be reassigned later. + release that is deleted in the same step, so surprises surface in seconds instead of after three + platform builds. Both checks are warnings only: the release always gets published. Then it runs + `npm run typecheck` and `npm test` on Ubuntu and builds/packages Windows, macOS and Linux + artifacts. The `release` job publishes as the owner of the `AP3X0` token when `preflight` proved + that token works, otherwise as `github-actions[bot]` via the built-in `GITHUB_TOKEN`: a GitHub + release author is fixed at creation time and cannot be reassigned later. - `.github/workflows/gui-release.yml` is the legacy PySide6 workflow. It runs Python `ruff` and `pytest gui-legacy/tests`, builds legacy native bundles, and runs only a small Bash smoke subset; it is not the Electron unit-test workflow. diff --git a/docs/zh-CN/testing.md b/docs/zh-CN/testing.md index d47d4b0..2ccafe9 100644 --- a/docs/zh-CN/testing.md +++ b/docs/zh-CN/testing.md @@ -104,9 +104,9 @@ npm test # Vitest 单元测试 `validation/test-*.sh`;在 push 到 `main`、`dev`、`experimental` 以及 pull request 时运行。 - **release.yml** — Electron 构建(Windows/macOS/Linux)。只在 `v*` tag 和手动触发时运行;先由 `preflight` 检查发布说明 `docs/releases/.en.md` 是否存在,以及仓库密钥 `AP3X0`(若已设置) - 是否真的能创建 release——用随即删除的草稿探测,使不可用的 token 在几秒内失败,而不是三次构建之后。 - 然后在 Ubuntu 上执行 `npm run typecheck`、`npm test`、`npm run build`,再在三种平台执行 - `electron-builder --publish never`。若 `AP3X0` 具备 Contents: write 权限,release 以该 token - 所有者名义发布,否则以 `github-actions[bot]` 通过内置 `GITHUB_TOKEN` 发布:GitHub release 的 - 作者在创建时即固定,事后无法改换。 + 是否真的能创建 release——用随即删除的草稿探测,让意外在几秒内暴露,而不是三次构建之后。 + 两项检查都只作警告:release 无论如何都会发布。然后在 Ubuntu 上执行 `npm run typecheck`、 + `npm test`、`npm run build`,再在三种平台执行 `electron-builder --publish never`。若 `preflight` + 确认 `AP3X0` 可用,release 以该 token 所有者名义发布,否则以 `github-actions[bot]` 通过内置 + `GITHUB_TOKEN` 发布:GitHub release 的作者在创建时即固定,事后无法改换。 - **gui-release.yml** — legacy PySide6 GUI:ruff、pytest 和 wheel 构建;在 PR 与 `gui-v*` tag 上运行。 \ No newline at end of file From c0d4083e23e45dc9d29c881ff860d07d82fe97b0 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:41:53 +0300 Subject: [PATCH 06/29] =?UTF-8?q?CLI:=20revoke/expire/bypass-groups=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=81=D0=BB=D0=BE=D0=B9=20=D0=B4=D0=BB=D1=8F=20=D1=82?= =?UTF-8?q?=D1=80=D1=91=D1=85=20=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20GUI-?= =?UTF-8?q?=D1=84=D0=B8=D1=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit profile-revoke --name N [--full]: перевыпуск sub_token (обёртка логики TUI-пункта 2, handler читает токен на каждый запрос — рестарт subhttp не нужен); --full меняет и uuid во всех inbound'ах портов профиля — уже скачанные конфиги отваливаются сразу (утёкшая в чат ссылка перестаёт быть вечным доступом; токен-революция этого не лечит). uuid в profile JSON совпадает с конфигом всегда: запись профиля идёт после валидации конфига, сбой записи — откат config.json из бэкапа транзакции. Сроки: profile-create --expire (epoch/мс/'2006-12-31[ 23:59]' → нормализация в сек), profile-expire --expire DATE|none (немедленное принуждение), expire-check (пакетно, идемпотентно: без изменений — без рестарта, иначе таймер ронял бы Xray каждые 10 мин). Принуждение серверное: systemd-таймер xrayebator-expire (10 мин) ставится миграцией expire_timer_2026 (main_menu+quickstart, parity-guard); disable снимает клиента в .expire_clients+fлаг (inbound'ы живут: пустой inbound при продлении восстановим без new shortId — _remove_profile_uuid выкидывал бы inbound целиком), enable возвращает со снимка (с актуальным uuid на случай revoke между циклами) или rebuild-ом из routes (flow: vision для tcp/utls/xudp, '' для mux/grpc/xhttp). profiles-вывод обогатился expire/expire_disabled. --expire в create не позволяет срок в прошлом. bypass groups — JSON-описание 7 bundle-групп для GUI-чекбоксов; bypass-бандл уже был. uninstall.sh снимает expire-таймер (прецедент ip-renew: иначе долбит удалённый бинарь). JSON-only контракт: backup_config и все helpers — после exec 3>&1 switch (нашёл тест: '-> Бэкап конфига' перед JSON ломал парсинг). Тест: validation/test-profile-revoke-expire-cli.sh (27/27 всей валидации зелёные; два jq-баг-фикса по дороге: index(.port) внутри map по массиву ports, снимок-выражение с -биндингом). --- uninstall.sh | 8 + validation/test-profile-revoke-expire-cli.sh | 233 +++++++ xrayebator | 666 ++++++++++++++++++- 3 files changed, 898 insertions(+), 9 deletions(-) create mode 100644 validation/test-profile-revoke-expire-cli.sh diff --git a/uninstall.sh b/uninstall.sh index 4c79dfe..31469bd 100644 --- a/uninstall.sh +++ b/uninstall.sh @@ -87,6 +87,14 @@ if systemctl list-unit-files 'xrayebator-ip-renew.*' > /dev/null 2>&1; then rm -f /etc/systemd/system/xrayebator-ip-renew.service rm -f /etc/systemd/system/xrayebator-ip-renew.timer fi +# Таймер принуждения сроков профилей: без удаления продолжал бы дёргать +# удалённый /usr/local/bin/xrayebator expire-check каждые 10 минут. +if systemctl list-unit-files 'xrayebator-expire.*' > /dev/null 2>&1; then + systemctl disable --now xrayebator-expire.timer > /dev/null 2>&1 || true + systemctl stop xrayebator-expire.service > /dev/null 2>&1 || true + rm -f /etc/systemd/system/xrayebator-expire.service + rm -f /etc/systemd/system/xrayebator-expire.timer +fi rm -rf /usr/local/etc/xray rm -rf /var/log/xray echo -e "${GREEN}✓ Конфигурации и логи удалены${NC}\n" diff --git a/validation/test-profile-revoke-expire-cli.sh b/validation/test-profile-revoke-expire-cli.sh new file mode 100644 index 0000000..654c96c --- /dev/null +++ b/validation/test-profile-revoke-expire-cli.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash +# test-profile-revoke-expire-cli.sh +# Тестирует CLI revoke/expire из xrayebator: +# profile-revoke (токен / --full с ротацией uuid), profile-expire (дата/epoch/none, +# немедленное принуждение), expire-check (пакетное идемпотентное принуждение), +# bypass groups (JSON-описание групп bundle), profile-create --expire. +# Гарантии: +# - stdout CLI содержит ТОЛЬКО JSON (статусы helpers не протекают); +# - revoke --full меняет uuid во всех inbound'ах портов профиля, чужие клиенты +# на общем порте не затрагиваются; +# - expire реально выключает клиента и возвращает его при продлении (со снимком +# flow), expire-check без изменений не рестартует xray. +# +# Usage: bash validation/test-profile-revoke-expire-cli.sh +# Requires: jq, uuidgen, openssl, bash 4+. + +set -euo pipefail + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +WORKDIR=$(mktemp -d /tmp/xrayebator-revoke-expire.XXXXXX) +trap 'rm -rf "$WORKDIR"' EXIT + +source "$REPO_ROOT/xrayebator" + +CONFIG_FILE="$WORKDIR/config.json" +PROFILES_DIR="$WORKDIR/profiles" +XRAY_BACKUPS_DIR="$WORKDIR/backups" +mkdir -p "$PROFILES_DIR" "$XRAY_BACKUPS_DIR" + +RESTART_LOG="$WORKDIR/restarts.log" +: > "$RESTART_LOG" +# Счётчик через файл: safe_restart_xray вызывается в подшеллах $(...), +# инкремент переменной в подшелле наружу не виден. +safe_restart_xray() { echo restart >> "$RESTART_LOG"; return 0; } +restarts_now() { wc -l < "$RESTART_LOG" | tr -d ' '; } +fix_xray_permissions() { return 0; } +systemctl() { return 0; } + +# 7-портовый multi-route профиль friend + чужой профиль other на общем порту 443. +FRIEND_UUID="11111111-1111-4111-8111-111111111111" +OTHER_UUID="22222222-2222-4222-8222-222222222222" +FRIEND_PORTS='[443, 2053, 2083, 8443, 34521, 34522, 34523]' + +jq -n --arg uuid "$FRIEND_UUID" --argjson ports "$FRIEND_PORTS" '{ + name: "friend", uuid: $uuid, schema_version: 3, multi_route: true, + sub_token: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + transport: "xhttp", port: 443, + routes: [ + {label:"xhttp-legacy", transport:"xhttp", port:443, sni:"a.example", fingerprint:"chrome"}, + {label:"xhttp-pq", transport:"xhttp", port:2053, sni:"a.example", fingerprint:"chrome"}, + {label:"tcp-mux", transport:"tcp-mux", port:2083, sni:"b.example", fingerprint:"chrome"}, + {label:"grpc", transport:"grpc", port:8443, sni:"c.example", fingerprint:"chrome"}, + {label:"tcp-vision", transport:"tcp", port:34521, sni:"d.example", fingerprint:"chrome"}, + {label:"tcp-utls", transport:"tcp-utls", port:34522, sni:"d.example", fingerprint:"firefox"}, + {label:"tcp-xudp", transport:"tcp-xudp", port:34523, sni:"e.example", fingerprint:"chrome"} + ] +}' > "$PROFILES_DIR/friend.json" + +jq -n --arg uuid "$OTHER_UUID" '{ + name: "other", uuid: $uuid, transport: "tcp", port: 443, sni: "a.example", + sub_token: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +}' > "$PROFILES_DIR/other.json" + +make_config() { + jq -n --arg f "$FRIEND_UUID" --arg o "$OTHER_UUID" '{ + inbounds: [ + {port: 443, tag: "inbound-443", settings: {clients: [ + {id: $f, flow: ""}, {id: $o, flow: "xtls-rprx-vision"}], decryption: "none"}}, + {port: 2053, tag: "inbound-2053", settings: {clients: [{id: $f, flow: ""}], decryption: "none"}}, + {port: 2083, tag: "inbound-2083", settings: {clients: [{id: $f, flow: ""}], decryption: "none"}}, + {port: 8443, tag: "inbound-8443", settings: {clients: [{id: $f, flow: ""}], decryption: "none"}}, + {port: 34521, tag: "inbound-34521", settings: {clients: [{id: $f, flow: "xtls-rprx-vision"}], decryption: "none"}}, + {port: 34522, tag: "inbound-34522", settings: {clients: [{id: $f, flow: "xtls-rprx-vision"}], decryption: "none"}}, + {port: 34523, tag: "inbound-34523", settings: {clients: [{id: $f, flow: "xtls-rprx-vision"}], decryption: "none"}} + ], + routing: {rules: []} + }' > "$CONFIG_FILE" +} +make_config + +# --- 1. Revoke токена: sub_token меняется, uuid НЕ трогаем --- +out=$(profile_revoke_command --name friend) || fail "revoke token failed: $out" +jq -e '.ok == true and .full == false' <<< "$out" >/dev/null || fail "revoke bad JSON: $out" +new_token=$(jq -r '.sub_token' <<< "$out") +[[ "$new_token" =~ ^[a-f0-9]{32}$ ]] && [[ "$new_token" != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ]] || + fail "revoke did not rotate token: $out" +jq -e '.sub_token == "'"$new_token"'" and .uuid == "'"$FRIEND_UUID"'"' \ + "$PROFILES_DIR/friend.json" >/dev/null || fail "profile JSON not updated with token" +jq -e '[.inbounds[].settings.clients[].id] | index("'"$FRIEND_UUID"'") != null' \ + "$CONFIG_FILE" >/dev/null || fail "token-only revoke must NOT touch uuids" + +# --- 2. Revoke --full: uuid меняется везде, чужой клиент на 443 цел --- +out=$(profile_revoke_command --name friend --full) || fail "revoke --full failed: $out" +jq -e '.ok == true and .full == true' <<< "$out" >/dev/null || fail "revoke full bad JSON: $out" +full_token=$(jq -r '.sub_token' <<< "$out") +[[ "$full_token" =~ ^[a-f0-9]{32}$ && "$full_token" != "$new_token" ]] || + fail "revoke --full must rotate token too" +rotated_uuid=$(jq -r '.uuid' <<< "$out") +[[ "$rotated_uuid" =~ ^[0-9a-fA-F-]{36}$ ]] && [[ "$rotated_uuid" != "$FRIEND_UUID" ]] || + fail "revoke --full did not rotate uuid: $out" +jq -e --arg old "$FRIEND_UUID" --arg other "$OTHER_UUID" ' + ([.inbounds[].settings.clients[].id] | index($old)) == null and + ([.inbounds[].settings.clients[].id] | index($other)) != null +' "$CONFIG_FILE" >/dev/null || fail "revoke --full left old uuid or killed other client" +jq -e '[.inbounds[] | select(.port != 443) | .settings.clients | length] == [1,1,1,1,1,1]' \ + "$CONFIG_FILE" >/dev/null || fail "revoke --full lost clients on solo ports" +# flow Vision-маршрутов обязан сохраниться (замена id, не пересборка клиента) +jq -e --arg u "$rotated_uuid" ' + (.inbounds[] | select(.port == 34521) | .settings.clients[] | select(.id == $u) | .flow) == "xtls-rprx-vision" and + (.inbounds[] | select(.port == 443) | .settings.clients[] | select(.id == $u) | .flow) == "" +' "$CONFIG_FILE" >/dev/null || fail "revoke --full lost per-route flow" +jq -e '.uuid == "'"$rotated_uuid"'" and .sub_token == "'"$full_token"'"' \ + "$PROFILES_DIR/friend.json" >/dev/null || fail "profile JSON not updated with rotated uuid" + +# --- 3. profile-expire: валидация нормализации --- +[[ "$(_normalize_expire_value 1000000000000)" == "1000000000" ]] || fail "ms not normalized" +[[ "$(_normalize_expire_value 1785000000)" == "1785000000" ]] || fail "sec epoch changed" +if _normalize_expire_value "2030-13-01" 2>/dev/null; then fail "month 13 accepted"; fi +if _normalize_expire_value "abc" 2>/dev/null; then fail "garbage accepted"; fi +FUTURE_EPOCH=$(date -d "2030-01-01 12:30" +%s 2>/dev/null || date -j -f "%Y-%m-%d %H:%M" "2030-01-01 12:30" +%s) +[[ "$(_normalize_expire_value "2030-01-01 12:30")" == "$FUTURE_EPOCH" ]] || fail "datetime normalization mismatch" + +# --- 4. profile-expire: будущее → поле есть, профиль жив, рестарта нет --- +restarts_before=$(restarts_now) +out=$(profile_expire_command --name friend --expire "2030-01-01") || fail "profile-expire future failed: $out" +jq -e '.ok == true and .expired == false' <<< "$out" >/dev/null || fail "expire future bad JSON: $out" +[[ "$(restarts_now)" == "$restarts_before" ]] || fail "future expire must not restart xray" +jq -e '.expire == '"$(_normalize_expire_value "2030-01-01")"' and .expire_disabled != true' \ + "$PROFILES_DIR/friend.json" >/dev/null || fail "expire field not written" + +# --- 5. profile-expire: прошедшая дата → клиент удалён из конфига, снимок с flow --- +out=$(profile_expire_command --name friend --expire "2020-01-01") || fail "profile-expire past failed: $out" +jq -e '.ok == true and .expired == true' <<< "$out" >/dev/null || fail "expire past bad JSON: $out" +jq -e --arg u "$rotated_uuid" 'all(.inbounds[].settings.clients[]?; .id != $u)' \ + "$CONFIG_FILE" >/dev/null || fail "expired profile client still in config" +# Inbound'ы обязаны выжить (иначе продление не восстановит маршруты с короткими id). +jq -e '.inbounds | length == 7' "$CONFIG_FILE" >/dev/null || fail "expire deleted inbounds" +jq -e --arg u "$rotated_uuid" ' + .expire_disabled == true and + (.expire_clients | length == 7) and + (all(.expire_clients[]; .client.id == $u)) and + (any(.expire_clients[]; .port == 34521 and .client.flow == "xtls-rprx-vision")) and + (any(.expire_clients[]; .port == 443 and .client.flow == "")) +' "$PROFILES_DIR/friend.json" >/dev/null || fail "expire snapshot missing/incorrect" +jq -e --arg o "$OTHER_UUID" '.inbounds[] | select(.port == 443) | any(.settings.clients[]?; .id == $o)' \ + "$CONFIG_FILE" >/dev/null || fail "expire removed foreign client on shared port" + +# --- 6. profile-expire: продление → клиент вернулся с flow, снимок удалён --- +out=$(profile_expire_command --name friend --expire "2031-06-01") || fail "renew failed: $out" +jq -e --arg u "$rotated_uuid" ' + (.inbounds[] | select(.port == 34521) | .settings.clients[] | select(.id == $u) | .flow) == "xtls-rprx-vision" and + ([.inbounds[].settings.clients[].id] | index($u)) != null +' "$CONFIG_FILE" >/dev/null || fail "renew did not restore client with flow" +jq -e '.expire_disabled == false and ((.expire_clients // []) | length == 0)' \ + "$PROFILES_DIR/friend.json" >/dev/null || fail "renew did not clear snapshot" + +# --- 7. profile-expire none → вечный --- +out=$(profile_expire_command --name friend --expire none) || fail "expire none failed: $out" +jq -e 'has("expire") | not' "$PROFILES_DIR/friend.json" >/dev/null || fail "expire not deleted" + +# --- 8. expire-check: пакетное принуждение + идемпотентность --- +# friend: срок прошёл, но профиль включён вручную (нет флага) → будет забанен. +jq '.expire = 1000 | del(.expire_disabled) | del(.expire_clients)' "$PROFILES_DIR/friend.json" > "$WORKDIR/f.tmp" && mv "$WORKDIR/f.tmp" "$PROFILES_DIR/friend.json" +jq --arg u "$rotated_uuid" '.inbounds |= map(if .port == 443 then .settings.clients += [{id: $u, flow: ""}] else . end)' "$CONFIG_FILE" > "$WORKDIR/c.tmp" && mv "$WORKDIR/c.tmp" "$CONFIG_FILE" +# other: бессрочный — не трогаем +restarts_before=$(restarts_now) +out=$(expire_check_command) || fail "expire-check failed: $out" +jq -e '.ok == true and (.disabled | index("friend") != null) and ((.enabled | index("other")) == null)' \ + <<< "$out" >/dev/null || fail "expire-check wrong result: $out" +[[ "$(restarts_now)" == "$((restarts_before + 1))" ]] || fail "expire-check must restart once" +jq -e --arg u "$rotated_uuid" 'all(.inbounds[].settings.clients[]?; .id != $u)' \ + "$CONFIG_FILE" >/dev/null || fail "expire-check left client enabled" +# Идемпотентность: второй прогон — пусто и БЕЗ рестарта (иначе таймер ронял бы Xray каждые 10 мин). +restarts_before=$(restarts_now) +out=$(expire_check_command) || fail "expire-check rerun failed: $out" +jq -e '.ok == true and (.disabled | length == 0) and (.enabled | length == 0)' \ + <<< "$out" >/dev/null || fail "expire-check not idempotent: $out" +[[ "$(restarts_now)" == "$restarts_before" ]] || fail "idempotent expire-check must not restart" + +# --- 9. expire-check: enable без снимка → rebuild из routes-метаданных --- +# Флаг остаётся (клиент по-прежнему забанен), expire уходит в будущее, снимок потерян — +# enable должен восстановить клиентов rebuild-путём с правильными flow. +jq 'del(.expire_clients) | .expire = 4102444800' "$PROFILES_DIR/friend.json" > "$WORKDIR/f.tmp" && mv "$WORKDIR/f.tmp" "$PROFILES_DIR/friend.json" +out=$(expire_check_command) || fail "expire-check enable pass failed: $out" +jq -e '.enabled | index("friend") != null' <<< "$out" >/dev/null || fail "expire-check did not re-enable: $out" +jq -e --arg u "$rotated_uuid" ' + all(.inbounds[]; select(.port != 443) | any(.settings.clients[]?; .id == $u)) and + (.inbounds[] | select(.port == 34521) | .settings.clients[] | select(.id == $u) | .flow) == "xtls-rprx-vision" and + (.inbounds[] | select(.port == 8443) | .settings.clients[] | select(.id == $u) | .flow) == "" +' "$CONFIG_FILE" >/dev/null || fail "rebuild restore lost flows" +jq -e '.expire_disabled == false' "$PROFILES_DIR/friend.json" >/dev/null || fail "enable left flag" + +# --- 10. revoke --full на отключённом по сроку → отказ --- +jq '.expire = 1000 | .expire_disabled = true' "$PROFILES_DIR/friend.json" > "$WORKDIR/f.tmp" && mv "$WORKDIR/f.tmp" "$PROFILES_DIR/friend.json" +if out=$(profile_revoke_command --name friend --full); then + fail "revoke --full on disabled profile unexpectedly succeeded" +fi +jq -e '.ok == false' <<< "$out" >/dev/null || fail "revoke-on-expired did not return ok:false: $out" + +# --- 11. revoke невалидные аргументы / отсутствующий профиль --- +if out=$(profile_revoke_command --name "bad name!"); then fail "revoke bad name accepted"; fi +jq -e '.ok == false' <<< "$out" >/dev/null || fail "revoke bad name: $out" +if out=$(profile_revoke_command --name nosuch); then fail "revoke missing profile accepted"; fi +jq -e '.ok == false' <<< "$out" >/dev/null || fail "revoke missing profile: $out" + +# --- 12. bypass groups: JSON с id/title/domains --- +out=$(_bypass_cli_groups) || fail "bypass groups failed: $out" +jq -e '.ok == true and (.groups | length >= 7) and + all(.groups[]; (.id | type == "string") and (.title | type == "string") and + (.domains | type == "number" and . > 0)) and + any(.groups[]; .id == "banks")' <<< "$out" >/dev/null || + fail "bypass groups bad payload: $out" + +# --- 13. profile-create --expire: поле попадает в профиль (add_inbound застабан) --- +add_inbound() { return 0; } +build_transport_defaults() { echo "34999|svc|/xp"; } +reserve_port() { return 0; } +open_firewall_port() { return 0; } +out=$(profile_create_command --name kid --transport tcp --expire "2030-05-05") || fail "create --expire failed: $out" +jq -e '.ok == true and (.names | index("kid") != null)' <<< "$out" >/dev/null || fail "create --expire bad JSON: $out" +jq -e '.expire == '"$(_normalize_expire_value "2030-05-05")" "$PROFILES_DIR/kid.json" >/dev/null || + fail "created profile missing expire" +out=$(profile_create_command --name late --transport tcp --expire "2000-01-01") && fail "create past expire accepted" +jq -e '.ok == false' <<< "$out" >/dev/null || fail "create past expire: $out" + +echo "PASS: revoke/expire CLI rotate secrets correctly, enforce expiry and stay JSON-clean" diff --git a/xrayebator b/xrayebator index 16abb5b..a9d4431 100755 --- a/xrayebator +++ b/xrayebator @@ -7077,6 +7077,7 @@ main_menu() { fi run_migration "sni_list_2026" "SNI list 2026 (РФ-банки/логистика, удалить apple/icloud)" _migrate_sni_list_2026 || ((migration_failures++)) run_migration "bypass_routing_2026" "Bypass routing first-run prompt (опционально)" _migrate_bypass_routing_2026 || ((migration_failures++)) + run_migration "expire_timer_2026" "Таймер принуждения сроков профилей" _migrate_expire_timer_2026 || ((migration_failures++)) run_migration "subhttp_multiroute_2026" "HAPP subhttp multi-route handler" _migrate_subhttp_multiroute_2026 || ((migration_failures++)) run_migration "subhttp_client_compat_2026" "HAPP/v2rayNG subscription client compatibility" _migrate_subhttp_client_compat_2026 || ((migration_failures++)) run_migration "subhttp_no_default_happ_routing_2026" "HAPP subscription: remove invalid legacy routing payload" _migrate_subhttp_no_default_happ_routing_2026 || ((migration_failures++)) @@ -8037,6 +8038,224 @@ _move_profile_client_between_inbounds() { ' "$CONFIG_FILE" } +# Уникальные порты профиля (JSON-массив): routes[] multi-route или top-level .port. +# Общий для profile-delete / revoke / expire — источник истины "где лежат клиенты". +_profile_ports_json() { + local profile_file="$1" + local rcount + rcount=$(_profile_route_count "$profile_file") + if [[ "$rcount" =~ ^[0-9]+$ ]] && [[ "$rcount" -gt 0 ]]; then + jq -c '[.routes[]?.port | select(type == "number")] | unique' "$profile_file" 2>/dev/null + else + jq -c '[.port | select(type == "number")] | unique' "$profile_file" 2>/dev/null + fi +} + +# Заменяет id клиента во всех inbounds на данных портах (revoke --full). +_replace_uuid_in_config() { + local old_uuid=$1 new_uuid=$2 ports_json=$3 + safe_jq_write --arg old "$old_uuid" --arg new "$new_uuid" --argjson ports "$ports_json" ' + .inbounds |= map( + .port as $inbound_port | + if (($ports | index($inbound_port)) != null) + and any(.settings.clients[]?; .id == $old) then + .settings.clients = ((.settings.clients // []) + | map(if .id == $old then .id = $new else . end)) + else + . + end + ) + ' "$CONFIG_FILE" +} + +# Нормализация значения срока: unix-секунды (10 цифр), unix-мс (13 цифр → /1000) +# или ISO-локальное "ГГГГ-ММ-ДД[ TЧЧ:ММ[:СС]]" → epoch. stdout — только число. +_normalize_expire_value() { + local raw="$1" + [[ -n "$raw" ]] || return 1 + if [[ "$raw" =~ ^[0-9]+$ ]]; then + if [[ "${#raw}" -eq 13 ]]; then + printf '%s' "$((raw / 1000))" + elif [[ "${#raw}" -le 12 ]]; then + printf '%s' "$raw" + else + return 1 + fi + return 0 + fi + if [[ "$raw" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}([T\ ][0-9]{2}:[0-9]{2}(:[0-9]{2})?)?$ ]]; then + local norm="${raw/T/ }" + [[ "${#norm}" -eq 10 ]] && norm="$norm 00:00:00" + [[ "${#norm}" -eq 16 ]] && norm="$norm:00" + if [[ "$norm" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})\ ([0-9]{2}):([0-9]{2}):([0-9]{2})$ ]] \ + && [[ "${BASH_REMATCH[2]}" -ge 1 && "${BASH_REMATCH[2]}" -le 12 ]] \ + && [[ "${BASH_REMATCH[3]}" -ge 1 && "${BASH_REMATCH[3]}" -le 31 ]] \ + && [[ "${BASH_REMATCH[4]}" -le 23 && "${BASH_REMATCH[5]}" -le 59 && "${BASH_REMATCH[6]}" -le 60 ]]; then + # GNU date (Debian/Ubuntu обязательны по install.sh); время локальное — + # как и у date +%s в expire-check, расхождения с серверными часами нет. + local ts + ts=$(date -d "$norm" +%s 2>/dev/null) || return 1 + printf '%s' "$ts" + return 0 + fi + fi + return 1 +} + +# Нормализованный .expire профиля → epoch в stdout (0 = бессрочный). +# 13-значные ms из старых записей приводятся к секундам — иначе expiry никогда +# не наступит (ms-epoch ~ 1.7e12 всегда больше now). +_profile_expire_epoch() { + local profile_file="$1" raw + raw=$(jq -r '.expire // empty' "$profile_file" 2>/dev/null) || return 1 + [[ -n "$raw" && "$raw" != "null" ]] || { printf '0'; return 0; } + [[ "$raw" =~ ^[0-9]+$ ]] || { printf '0'; return 0; } + if [[ "${#raw}" -eq 13 ]]; then + printf '%s' "$((raw / 1000))" + else + printf '%s' "$raw" + fi +} + +# Профиль "отключён по expire" = в JSON стоит флаг .expire_disabled. +# Флаг (а не снимок) — маркер идемпотентности: expire-check без флага обязан +# что-то изменить, иначе таймер будет рестартить xray на пустом месте каждый цикл. +_profile_is_expire_disabled() { + local profile_file="$1" + jq -e '.expire_disabled == true' "$profile_file" >/dev/null 2>&1 +} + +# Есть ли клиент профиля (по текущему uuid) хоть в одном inbound конфига. +# Отличает "флаг+клиент исчезли" (спокойно) от "флаг, но клиента вернули руками" +# (надо переналожить бан) — и не даёт таймеру рестартить xray на пустом месте. +_profile_client_in_config() { + local profile_file="$1" uuid + uuid=$(jq -r '.uuid // empty' "$profile_file") + [[ -n "$uuid" ]] || return 1 + jq -e --arg uuid "$uuid" ' + any(.inbounds[]?; any(.settings.clients[]?; .id == $uuid)) + ' "$CONFIG_FILE" >/dev/null 2>&1 +} + +# Отключение истёкшего профиля: снимок клиентов (flow и доп. поля включительно) +# в .expire_clients профиля + флаг → удаление клиента из inbound'ов портов профиля. +# ВАЖНО: inbound'ы сохраняются даже с пустым списком клиентов — _remove_profile_uuid +# выкидывал бы пустой inbound целиком, а на продлении новый inbound = новый shortId +# = смерть всех выданных URL. Клиенты чужих профилей на общих портах не затрагиваются. +# Клиента нет в конфиге — только флаг (бан уже наложен руками). +_expire_disable_profile() { + local profile_file="$1" + local uuid ports_json saved + uuid=$(jq -r '.uuid // empty' "$profile_file") + [[ -n "$uuid" ]] || return 1 + ports_json=$(_profile_ports_json "$profile_file") + jq -e 'type == "array" and length > 0' <<< "$ports_json" >/dev/null 2>&1 || return 1 + + saved=$(jq -c --arg uuid "$uuid" --argjson ports "$ports_json" ' + [.inbounds[] as $in + | select(($ports | index($in.port)) != null) + | $in.settings.clients[]? | select(.id == $uuid) + | {port: $in.port, client: .}]' "$CONFIG_FILE" 2>/dev/null) || return 1 + + if [[ "$(jq 'length' <<< "$saved")" != "0" ]]; then + safe_jq_write --arg uuid "$uuid" --argjson ports "$ports_json" ' + .inbounds |= map( + .port as $p | + if (($ports | index($p)) != null) then + .settings.clients = ((.settings.clients // []) | map(select(.id != $uuid))) + else . end + ) + ' "$CONFIG_FILE" || return 1 + fi + safe_jq_write --argjson saved "$saved" \ + '.expire_clients = $saved | .expire_disabled = true' "$profile_file" || return 1 + return 0 +} + +# Включение профиля до истечения нового срока: клиенты из снимка (с актуальным +# uuid на случай revoke между циклами). Снимка нет — rebuild из routes-метаданных; +# и он ничего не добавил (порты пересобраны вручную) — гасим флаг с предупреждением, +# иначе expire-check зациклится на недостижимом профиле. +_expire_enable_profile() { + local profile_file="$1" + local uuid ports_json saved + uuid=$(jq -r '.uuid // empty' "$profile_file") + [[ -n "$uuid" ]] || return 1 + ports_json=$(_profile_ports_json "$profile_file") + jq -e 'type == "array" and length > 0' <<< "$ports_json" >/dev/null 2>&1 || return 1 + + saved=$(jq -c '.expire_clients // []' "$profile_file") || return 1 + + if [[ "$(jq 'length' <<< "$saved")" == "0" ]]; then + if ! _expire_rebuild_clients "$profile_file"; then + echo -e "${YELLOW} ⚠ $(basename "$profile_file" .json): порты профиля не найдены — срок снят, клиент не восстановлен${NC}" >&2 + safe_jq_write 'del(.expire_clients) | .expire_disabled = false' "$profile_file" || return 1 + return 0 + fi + else + safe_jq_write --arg uuid "$uuid" --argjson ports "$ports_json" --argjson saved "$saved" ' + .inbounds |= map( + .port as $p | + if (($ports | index($p)) != null) then + .settings.clients = ((.settings.clients // []) | map(select(.id != $uuid))) + else . end + ) + | .inbounds |= map( + .port as $p | + if (($ports | index($p)) != null) and any($saved[]; .port == $p) then + .settings.clients = ((.settings.clients // []) + + [$saved[] | select(.port == $p) | .client | .id = $uuid]) + else . end + )' "$CONFIG_FILE" || return 1 + fi + safe_jq_write 'del(.expire_clients) | .expire_disabled = false' "$profile_file" || return 1 + return 0 +} + +# Fallback-восстановление без снимка: добавить {id, flow} в существующий inbound +# порта (flow — как в add_inbound: vision для tcp/utls/xudp, пусто для mux/grpc/xhttp). +_expire_rebuild_clients() { + local profile_file="$1" + local uuid rcount idx transport port flow added=false + uuid=$(jq -r '.uuid // empty' "$profile_file") + [[ -n "$uuid" ]] || return 1 + rcount=$(_profile_route_count "$profile_file") + [[ "$rcount" =~ ^[0-9]+$ ]] || rcount=0 + + local -a specs=() + if [[ "$rcount" -gt 0 ]]; then + for ((idx=0; idx/dev/null) + [[ "$exists" == "1" ]] || continue + if safe_jq_write --arg uuid "$uuid" --argjson port "$port" --arg flow "$flow" ' + (.inbounds[] | select(.port == $port) | .settings.clients) |= + (map(select(.id != $uuid)) + [ {id: $uuid, flow: $flow} ]) + ' "$CONFIG_FILE"; then + added=true + fi + done + [[ "$added" == "true" ]] +} + # Удаление профиля delete_profile_menu() { show_ascii @@ -10269,6 +10488,7 @@ quickstart_command() { run_migration "subhttp_multiroute_2026" "Subhttp multiroute handler" _migrate_subhttp_multiroute_2026 >/dev/null 2>&1 || true run_migration "subhttp_client_compat_2026" "Subhttp client compat" _migrate_subhttp_client_compat_2026 >/dev/null 2>&1 || true run_migration "bypass_routing_2026" "Bypass routing first-run" _migrate_bypass_routing_2026 >/dev/null 2>&1 || true + run_migration "expire_timer_2026" "Expire enforcement timer" _migrate_expire_timer_2026 >/dev/null 2>&1 || true run_migration "subhttp_happ_legacy_xhttp_2026" "Subhttp HAPP legacy XHTTP route" _migrate_subhttp_happ_legacy_xhttp_2026 >/dev/null 2>&1 || true run_migration "subhttp_happ_xhttp_compat_2026" "Subhttp HAPP XHTTP compat" _migrate_subhttp_happ_xhttp_compat_2026 >/dev/null 2>&1 || true run_migration "subhttp_no_default_happ_routing_2026" "Subhttp no default HAPP routing profile" _migrate_subhttp_no_default_happ_routing_2026 >/dev/null 2>&1 || true @@ -10789,7 +11009,7 @@ profiles_command() { [[ "$first" -eq 0 ]] && printf ',' first=0 - local name uuid transport port fingerprint sni created sub_token multi_route rcount pq + local name uuid transport port fingerprint sni created sub_token multi_route rcount pq expire expire_disabled name=$(basename "$file" .json) uuid=$(jq -r '.uuid // ""' "$file" 2>/dev/null) transport=$(jq -r '.transport // ""' "$file" 2>/dev/null) @@ -10801,6 +11021,13 @@ profiles_command() { multi_route=$(jq -r '(.multi_route // false)' "$file" 2>/dev/null) rcount=$(jq -r '((.routes // []) | length)' "$file" 2>/dev/null) pq=$(jq -r '(.pq_enabled // false)' "$file" 2>/dev/null) + expire=$(_profile_expire_epoch "$file" 2>/dev/null || printf '0') + [[ "$expire" =~ ^[0-9]+$ ]] || expire=0 + if _profile_is_expire_disabled "$file" 2>/dev/null; then + expire_disabled=true + else + expire_disabled=false + fi [[ "$rcount" =~ ^[0-9]+$ ]] || rcount=0 local sub_url="" @@ -10814,10 +11041,12 @@ profiles_command() { --arg created "$created" --arg sub_token "$sub_token" \ --argjson multi_route "$multi_route" --argjson routes "$rcount" \ --argjson pq "$pq" --arg subscription_url "$sub_url" \ + --argjson expire "$expire" --argjson expire_disabled "$expire_disabled" \ '{name: $name, uuid: $uuid, transport: $transport, port: $port, fingerprint: $fingerprint, sni: $sni, created: $created, sub_token: $sub_token, multi_route: $multi_route, routes: $routes, - pq_enabled: $pq, subscription_url: $subscription_url}' + pq_enabled: $pq, subscription_url: $subscription_url, + expire: $expire, expire_disabled: $expire_disabled}' done printf ']\n' return 0 @@ -10832,13 +11061,14 @@ profile_create_command() { export DEBIAN_FRONTEND=noninteractive export XRAYEBATOR_NONINTERACTIVE=1 - local name="" transport="xhttp" port="" count=1 + local name="" transport="xhttp" port="" count=1 expire_raw="" while [[ $# -gt 0 ]]; do case "$1" in --name) name="$2"; shift 2 ;; --transport) transport="$2"; shift 2 ;; --port) port="$2"; shift 2 ;; --count) count="$2"; shift 2 ;; + --expire) expire_raw="$2"; shift 2 ;; *) shift ;; esac done @@ -10890,12 +11120,29 @@ profile_create_command() { exec 1>&2 local created_names=() errors=() + local expire_value=0 + if [[ -n "$expire_raw" ]]; then + if ! expire_value=$(_normalize_expire_value "$expire_raw"); then + exec 1>&3 3>&- + rm -f "$results_file" + echo '{"ok":false,"error":"Некорректный --expire: нужен epoch (сек/мс) или дата 2006-12-31[ 23:59]"}' + return 1 + fi + local now_epoch + now_epoch=$(date +%s) + if [[ "$expire_value" -le "$now_epoch" ]]; then + exec 1>&3 3>&- + rm -f "$results_file" + echo '{"ok":false,"error":"Срок действия уже в прошлом"}' + return 1 + fi + fi for pname in "${profile_names[@]}"; do if [[ -f "$PROFILES_DIR/$pname.json" ]]; then errors+=("$pname: уже существует") continue fi - if _profile_cli_create_one "$pname" "$transport" "$port" "$results_file"; then + if _profile_cli_create_one "$pname" "$transport" "$port" "$results_file" "$expire_value"; then created_names+=("$pname") else errors+=("$pname: не удалось создать") @@ -10914,8 +11161,9 @@ profile_create_command() { } # Один профиль: файл JSON + inbound + safe_restart_xray. Возвращает 0/1. +# $5 — expire (epoch-секунды, 0 = бессрочный). _profile_cli_create_one() { - local pname="$1" transport="$2" fixed_port="${3:-}" results_file="${4:-}" + local pname="$1" transport="$2" fixed_port="${3:-}" results_file="${4:-}" expire_value="${5:-0}" local uuid sub_token grpc_service_name xhttp_path port default_sni uuid=$(uuidgen) sub_token=$(openssl rand -hex 16) @@ -10965,7 +11213,12 @@ _profile_cli_create_one() { jq_args+=(--argjson schema_v 2 --argjson pq_enabled true) jq_expr+=', schema_version: $schema_v, pq_enabled: $pq_enabled' fi - jq_expr+=', sub_token: $sub_token, created: $created}' + jq_expr+=', sub_token: $sub_token, created: $created' + if [[ "$expire_value" -gt 0 ]]; then + jq_args+=(--argjson expire "$expire_value") + jq_expr+=', expire: $expire' + fi + jq_expr+='}' if ! jq -n "${jq_args[@]}" "$jq_expr" > "$PROFILES_DIR/$pname.json"; then rm -f "$PROFILES_DIR/$pname.json" echo -e "${RED}✗ Не удалось создать JSON профиля${NC}" >&2 @@ -11000,9 +11253,11 @@ _profile_cli_create_one() { --arg name "$pname" --arg uuid "$uuid" --arg transport "$transport" \ --argjson port "$port" --arg fingerprint "$fingerprint" --arg sni "$default_sni" \ --arg sub_token "$sub_token" --arg subscription_url "$sub_url" \ + --argjson expire "$expire_value" \ '{name: $name, uuid: $uuid, transport: $transport, port: $port, fingerprint: $fingerprint, sni: $sni, sub_token: $sub_token, - subscription_url: $subscription_url}' >> "$results_file" + subscription_url: $subscription_url} + + (if $expire > 0 then {expire: $expire} else {} end)' >> "$results_file" return 0 } @@ -11085,6 +11340,363 @@ profile_delete_command() { return 0 } +# xrayebator profile-revoke --name NAME [--full] +# без --full: перевыпуск sub_token (старая URL подписки умирает, uuid жив); +# --full: + новый uuid во всех inbound'ах портов профиля — уже скачанные +# конфиги отваливаются немедленно (реальный ремонт утечки ссылки в чат). +profile_revoke_command() { + export TERM=dumb + export XRAYEBATOR_NONINTERACTIVE=1 + + local name="" full=false + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --full) full=true; shift ;; + *) shift ;; + esac + done + + if [[ -z "$name" ]] || ! [[ "$name" =~ ^[a-zA-Z0-9_-]{1,48}$ ]]; then + echo '{"ok":false,"error":"Некорректное имя профиля (1-48 симв.: a-z, 0-9, _ -)"}' + return 1 + fi + + local profile_file="$PROFILES_DIR/$name.json" + if [[ ! -f "$profile_file" ]]; then + echo "{\"ok\":false,\"error\":\"Профиль $name не найден\"}" + return 1 + fi + + local new_token + new_token=$(openssl rand -hex 16) + if [[ ! "$new_token" =~ ^[a-f0-9]{32}$ ]]; then + echo '{"ok":false,"error":"Не удалось сгенерировать sub_token"}' + return 1 + fi + + local old_uuid="" new_uuid="" ports_json="" + if [[ "$full" == "true" ]]; then + old_uuid=$(jq -r '.uuid // empty' "$profile_file") + if [[ -z "$old_uuid" ]]; then + echo '{"ok":false,"error":"Профиль повреждён: нет uuid"}' + return 1 + fi + new_uuid=$(uuidgen) + if [[ ! "$new_uuid" =~ ^[0-9a-fA-F-]{36}$ ]]; then + echo '{"ok":false,"error":"Не удалось сгенерировать uuid"}' + return 1 + fi + ports_json=$(_profile_ports_json "$profile_file") + if ! jq -e 'type == "array" and length > 0' <<< "$ports_json" >/dev/null 2>&1; then + echo '{"ok":false,"error":"Профиль повреждён: нет корректных портов"}' + return 1 + fi + # Полный отзыв должен выключать и уже истёкший по сроку профиль. + if _profile_is_expire_disabled "$profile_file"; then + echo '{"ok":false,"error":"Профиль отключён по сроку — сначала продлите срок (profile-expire)"}' + return 1 + fi + fi + + # CLI-режим: статусы helpers (backup_config, safe_restart_xray, safe_jq_write) + # печатают цветной мусор в stdout — душим в stderr (контракт JSON-only). + # Бэкап тоже внутри redirect: его "-> Бэкап конфига" иначе ломает JSON. + exec 3>&1 + exec 1>&2 + + local rollback_config="" + if ! backup_config "cli_revoke_$name" rollback_config; then + echo '{"ok":false,"error":"Не удалось создать бэкап config.json"}' >&3 + exec 1>&3 3>&- + return 1 + fi + + # Профиль-JSON: новый токен всегда; при --full ещё и uuid (top-level + routes). + # Порядок при full: сначала конфиг+рестарт, потом профиль; сбой записи профиля + # при full откатывает конфиг — иначе uuid разедутся. + local jq_args=(--arg t "$new_token") + local jq_expr='.sub_token = $t' + if [[ "$full" == "true" ]]; then + jq_args+=(--arg old "$old_uuid" --arg new "$new_uuid") + jq_expr+=' | .uuid = $new | (.routes // []) |= map(if .uuid? == $old then .uuid = $new else . end)' + # routes не хранят uuid (общий на профиль), но выражение безопасно для будущих схем. + + if ! _replace_uuid_in_config "$old_uuid" "$new_uuid" "$ports_json"; then + echo '{"ok":false,"error":"Не удалось обновить clients в config.json"}' >&3 + exec 1>&3 3>&- + return 1 + fi + if ! safe_restart_xray "$rollback_config"; then + echo '{"ok":false,"error":"Xray не принял конфиг — изменения откачены"}' >&3 + exec 1>&3 3>&- + return 1 + fi + fi + + if ! safe_jq_write "${jq_args[@]}" "$jq_expr" "$profile_file"; then + if [[ "$full" == "true" ]]; then + # Полусостояние недопустимо: конфиг уже с новым uuid, профиль — со старым. + # Откатываем config.json из бэкапа транзакции. + if [[ -n "$rollback_config" && -f "$rollback_config" ]]; then + cp "$rollback_config" "$CONFIG_FILE" + safe_restart_xray "" + fi + fi + echo '{"ok":false,"error":"Не удалось записать profile JSON"}' >&3 + exec 1>&3 3>&- + return 1 + fi + chown root:root "$profile_file" 2>/dev/null || true + + fix_xray_permissions + exec 1>&3 3>&- + + local sub_url="" base + base=$(_subscription_base_url 2>/dev/null || true) + if [[ -n "$base" ]] && _profile_cli_subscription_url "$new_token" >/dev/null 2>&1; then + sub_url="${base}/sub/${new_token}" + fi + jq -n \ + --arg name "$name" --arg token "$new_token" --arg url "$sub_url" \ + --argjson full "$full" --arg uuid "$new_uuid" \ + '{ok: true, name: $name, full: $full, sub_token: $token, + subscription_url: $url} + + (if $full then {uuid: $uuid} else {} end)' + return 0 +} + +# ═══════════════════════════════════════════════════════════ +# CLI сроков действия профилей (profile-expire / expire-check). +# Принуждение серверное: systemd-таймер раз в 10 мин вызывает expire-check, +# который удаляет клиента истёкшего профиля из inbound'ов (снимок в .expire_clients) +# и возвращает обратно при продлении. subhttp отдаёт .expire как subscription-userinfo. +# ═══════════════════════════════════════════════════════════ + +# xrayebator profile-expire --name NAME --expire VALUE|none +# VALUE: unix-секунды | unix-мс (13 цифр) | "ГГГГ-ММ-ДД" | "ГГГГ-ММ-ДД HH:MM". +# none — снять срок (вечный профиль). Смена статуса перезапускает xray. +profile_expire_command() { + export TERM=dumb + export XRAYEBATOR_NONINTERACTIVE=1 + + local name="" raw="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --expire) raw="$2"; shift 2 ;; + *) shift ;; + esac + done + + if [[ -z "$name" ]] || ! [[ "$name" =~ ^[a-zA-Z0-9_-]{1,48}$ ]]; then + echo '{"ok":false,"error":"Некорректное имя профиля (1-48 симв.: a-z, 0-9, _ -)"}' + return 1 + fi + if [[ -z "$raw" ]]; then + echo '{"ok":false,"error":"Укажите --expire ГГГГ-ММ-ДД|epoch|none"}' + return 1 + fi + + local expire=0 + if [[ "$raw" != "none" ]]; then + if ! expire=$(_normalize_expire_value "$raw"); then + echo '{"ok":false,"error":"Некорректный срок: нужен epoch (сек/мс) или дата 2006-12-31[ 23:59]"}' + return 1 + fi + fi + + local profile_file="$PROFILES_DIR/$name.json" + if [[ ! -f "$profile_file" ]]; then + echo "{\"ok\":false,\"error\":\"Профиль $name не найден\"}" + return 1 + fi + + local now action="" + now=$(date +%s) + if [[ "$expire" -gt 0 && "$expire" -le "$now" ]]; then + # Профиль уже помечен отключённым, но клиента снова нашли в конфиге + # (вмешательство руками) — бан нужно наложить заново. + if ! _profile_is_expire_disabled "$profile_file" || _profile_client_in_config "$profile_file"; then + action="disable" + fi + else + if _profile_is_expire_disabled "$profile_file"; then action="enable"; fi + fi + + # Тот же контракт JSON-only: backup_config печатает статус — только после switch. + exec 3>&1 + exec 1>&2 + + local rollback_config="" + if [[ -n "$action" ]] && ! backup_config "cli_expire_$name" rollback_config; then + echo '{"ok":false,"error":"Не удалось создать бэкап config.json"}' >&3 + exec 1>&3 3>&- + return 1 + fi + + if ! safe_jq_write --argjson e "$expire" \ + 'if $e == 0 then del(.expire) else .expire = $e end' "$profile_file"; then + echo "{\"ok\":false,\"error\":\"Не удалось записать expire в профиль\"}" >&3 + exec 1>&3 3>&- + return 1 + fi + chown root:root "$profile_file" 2>/dev/null || true + + local apply_rc=0 + if [[ "$action" == "disable" ]]; then + _expire_disable_profile "$profile_file" || apply_rc=1 + elif [[ "$action" == "enable" ]]; then + _expire_enable_profile "$profile_file" || apply_rc=1 + fi + + if [[ "$apply_rc" -ne 0 ]]; then + echo "{\"ok\":false,\"error\":\"Не удалось применить отключение/включение профиля\"}" >&3 + exec 1>&3 3>&- + return 1 + fi + + if [[ -n "$action" ]]; then + fix_xray_permissions + if ! safe_restart_xray "$rollback_config"; then + echo '{"ok":false,"error":"Xray не принял конфиг — изменения откачены"}' >&3 + exec 1>&3 3>&- + return 1 + fi + fi + exec 1>&3 3>&- + + local expired=false + [[ "$expire" -gt 0 && "$expire" -le "$now" ]] && expired=true + jq -n --arg name "$name" --argjson e "$expire" --argjson expired "$expired" \ + '{ok: true, name: $name, expire: $e, expired: $expired}' + return 0 +} + +# xrayebator expire-check — пакетное принуждение сроков (вызывается таймером +# xrayebator-expire.timer и вручную). Идемпотентен: без изменений — без рестарта. +expire_check_command() { + export TERM=dumb + export XRAYEBATOR_NONINTERACTIVE=1 + + local now + now=$(date +%s) + + # Проход 1: только классификация (никаких мутаций конфига до общего бэкапа). + local -a disable_files=() enable_files=() + local profile_file expire + for profile_file in "$PROFILES_DIR"/*.json; do + [[ -f "$profile_file" ]] || continue + expire=$(_profile_expire_epoch "$profile_file" 2>/dev/null) || continue + [[ "$expire" =~ ^[0-9]+$ ]] || continue + if [[ "$expire" -gt 0 && "$expire" -le "$now" ]]; then + # Кандидат на бан: не помечен ИЛИ помечен, но клиент снова в конфиге. + if ! _profile_is_expire_disabled "$profile_file" || _profile_client_in_config "$profile_file"; then + disable_files+=("$profile_file") + fi + elif _profile_is_expire_disabled "$profile_file"; then + enable_files+=("$profile_file") + fi + done + + if [[ ${#disable_files[@]} -eq 0 && ${#enable_files[@]} -eq 0 ]]; then + echo '{"ok":true,"disabled":[],"enabled":[]}' + return 0 + fi + + exec 3>&1 + exec 1>&2 + + local rollback_config="" + if ! backup_config "expire_check" rollback_config; then + echo '{"ok":false,"error":"Не удалось создать бэкап config.json"}' >&3 + exec 1>&3 3>&- + return 1 + fi + + local -a done_disable=() done_enable=() + for profile_file in "${disable_files[@]}"; do + if _expire_disable_profile "$profile_file"; then + done_disable+=("$(basename "$profile_file" .json)") + fi + done + for profile_file in "${enable_files[@]}"; do + if _expire_enable_profile "$profile_file"; then + done_enable+=("$(basename "$profile_file" .json)") + fi + done + + fix_xray_permissions + if ! safe_restart_xray "$rollback_config"; then + echo '{"ok":false,"error":"Xray не принял конфиг — изменения откачены"}' >&3 + exec 1>&3 3>&- + return 1 + fi + exec 1>&3 3>&- + + jq -n \ + --argjson d "$(printf '%s\n' "${done_disable[@]}" | jq -R . | jq -s 'map(select(length > 0))')" \ + --argjson e "$(printf '%s\n' "${done_enable[@]}" | jq -R . | jq -s 'map(select(length > 0))')" \ + '{ok: true, disabled: $d, enabled: $e}' + return 0 +} + +# systemd-таймер принуждения expire (2 unit'а, root-контекст: мутит config.json). +# Контракт no-op: таймер уже включён и ExecStart актуален → return 1 (no-op), +# установка прошла → return 0. Совпадает с контрактом run_migration. +_install_expire_timer() { + local unit=/etc/systemd/system/xrayebator-expire.service + local timer=/etc/systemd/system/xrayebator-expire.timer + + if [[ -f "$unit" && -f "$timer" ]] \ + && grep -q "ExecStart=/usr/local/bin/xrayebator expire-check" "$unit" \ + && systemctl is-enabled --quiet xrayebator-expire.timer 2>/dev/null; then + return 1 + fi + + cat > "$unit" << 'SVCEOF' +[Unit] +Description=Xrayebator: enforce profile expiry dates +After=network-online.target xray.service +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/xrayebator expire-check +IOSchedulingClass=idle +SVCEOF + chmod 644 "$unit" + chown root:root "$unit" || return 2 + + cat > "$timer" << 'TMREOF' +[Unit] +Description=Run Xrayebator profile-expiry enforcement every 10 minutes + +[Timer] +OnBootSec=2min +OnUnitActiveSec=10min +AccuracySec=1min + +[Install] +WantedBy=timers.target +TMREOF + chmod 644 "$timer" + chown root:root "$timer" || return 2 + + systemctl daemon-reload || return 2 + systemctl enable --now xrayebator-expire.timer >/dev/null 2>&1 || return 2 + return 0 +} + +# Контракт run_migration: 1 = no-op (маркер ставится БЕЗ рестарта xray — таймер +# не трогает config.json, ронять соединения из-за него нельзя); ≥2 = фалл (повтор). +_migrate_expire_timer_2026() { + _install_expire_timer + local rc=$? + [[ $rc -le 1 ]] && return 1 + return 2 +} + # Смена fingerprint профиля (клиентский TLS ClientHello, Xray не перезапускается). # Если профиль multi-route и --route не указан — профиль требует выбора маршрута. fp_change_command() { @@ -11341,6 +11953,22 @@ _bypass_cli_bundle() { return 1 } +# bypass groups — список готовых групп bundle (id, название, кол-во доменов) для GUI. +_bypass_cli_groups() { + local json + json=$(awk -F'|' 'NF >= 3 { + n = split($3, d, ",") + printf "%s\t%s\t%d\n", $1, $2, n + }' < <(_bypass_bundle_groups) | jq -R -s ' + split("\n") | map(select(length > 0)) | + map(split("\t") | {id: .[0], title: .[1], domains: (.[2] | tonumber)})') || { + echo '{"ok":false,"error":"Не удалось собрать список групп"}' + return 1 + } + jq -n --argjson groups "$json" '{ok: true, groups: $groups}' + return 0 +} + bypass_command() { export TERM=dumb export XRAYEBATOR_NONINTERACTIVE=1 @@ -11370,8 +11998,12 @@ bypass_command() { _bypass_cli_bundle "$@" return $? ;; + groups) + _bypass_cli_groups + return $? + ;; *) - echo '{"ok":false,"error":"Использование: bypass list | bypass add --domain D | bypass remove --domain D | bypass reset | bypass bundle [--group a,b,c]"}' + echo '{"ok":false,"error":"Использование: bypass list | groups | add --domain D | remove --domain D | reset | bundle [--group a,b,c]"}' return 1 ;; esac @@ -11924,6 +12556,18 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then shift profile_delete_command "$@" ;; + profile-revoke) + shift + profile_revoke_command "$@" + ;; + profile-expire) + shift + profile_expire_command "$@" + ;; + expire-check) + shift + expire_check_command "$@" + ;; fp-change) shift fp_change_command "$@" @@ -11959,11 +12603,15 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then echo -e " ${CYAN}sudo xrayebator happ-setup${NC} — создать/переиспользовать HAPP multi-route профиль" echo -e " ${CYAN}sudo xrayebator probe-test${NC} — проверить доступность SNI с VPS" echo -e " ${CYAN}sudo xrayebator profiles${NC} — JSON-список профилей" - echo -e " ${CYAN}sudo xrayebator profile-create --name N [--transport T] [--port P] [--count N]${NC}" + echo -e " ${CYAN}sudo xrayebator profile-create --name N [--transport T] [--port P] [--count N] [--expire 2006-12-31]${NC}" echo -e " — создать профиль/семейство профилей (JSON)" echo -e " ${CYAN}sudo xrayebator profile-delete --name N${NC} — удалить профиль (JSON)" echo -e " ${CYAN}sudo xrayebator fp-change --name N [--route R] --fp FINGERPRINT${NC} — сменить fingerprint (JSON)" + echo -e " ${CYAN}sudo xrayebator profile-revoke --name N [--full]${NC} — новый sub_token; --full: и новый uuid, старые клиенты отваливаются (JSON)" + echo -e " ${CYAN}sudo xrayebator profile-expire --name N --expire 2006-12-31|epoch|none${NC} — срок действия профиля (JSON)" + echo -e " ${CYAN}sudo xrayebator expire-check${NC} — применить наступившие сроки (для systemd-таймера) (JSON)" echo -e " ${CYAN}sudo xrayebator bypass list${NC} — список доменов в обходе (JSON)" + echo -e " ${CYAN}sudo xrayebator bypass groups${NC} — готовые группы обхода (JSON)" echo -e " ${CYAN}sudo xrayebator bypass add --domain D${NC} — добавить домен в обход (JSON)" echo -e " ${CYAN}sudo xrayebator bypass remove --domain D${NC} — убрать домен из обхода (JSON)" echo -e " ${CYAN}sudo xrayebator bypass reset${NC} — сбросить все bypass-правила (JSON)" From e5b8d3cd9b5321367d562984b9b48f32eec1838b Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:08:01 +0300 Subject: [PATCH 07/29] =?UTF-8?q?CLI:=20bypass=20groups=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B4=D0=B0=D1=91=D1=82=20=D0=B4=D0=BE=D0=BC=D0=B5=D0=BD=D1=8B?= =?UTF-8?q?,=20+=20bypass=20unbundle=20(=D0=B2=D1=8B=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit groups раньше возвращал только счётчик доменов — GUI-чекбоксам нужен состав группы, чтобы считать покрытие («включена/частично/выключена») без второго запроса. Добавлен unbundle: снятие группы одной мутацией и одним рестартом Xray (штучный remove --domain = N рестартов на группу из ~60 доменов, для интерактивного чекбокса неприемлемо). Оба — JSON-only: backup_config печатает статус только после exec 3>&1 switch. --- validation/test-profile-revoke-expire-cli.sh | 22 +++- xrayebator | 102 ++++++++++++++++++- 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/validation/test-profile-revoke-expire-cli.sh b/validation/test-profile-revoke-expire-cli.sh index 654c96c..978683f 100644 --- a/validation/test-profile-revoke-expire-cli.sh +++ b/validation/test-profile-revoke-expire-cli.sh @@ -210,14 +210,30 @@ jq -e '.ok == false' <<< "$out" >/dev/null || fail "revoke bad name: $out" if out=$(profile_revoke_command --name nosuch); then fail "revoke missing profile accepted"; fi jq -e '.ok == false' <<< "$out" >/dev/null || fail "revoke missing profile: $out" -# --- 12. bypass groups: JSON с id/title/domains --- +# --- 12. bypass groups: JSON с id/title/domains[] --- out=$(_bypass_cli_groups) || fail "bypass groups failed: $out" jq -e '.ok == true and (.groups | length >= 7) and all(.groups[]; (.id | type == "string") and (.title | type == "string") and - (.domains | type == "number" and . > 0)) and - any(.groups[]; .id == "banks")' <<< "$out" >/dev/null || + (.domains | type == "array" and length > 0 and all(.[]; type == "string"))) and + any(.groups[]; .id == "banks" and (.domains | index("sberbank.ru") != null))' <<< "$out" >/dev/null || fail "bypass groups bad payload: $out" +# --- 12b. bypass unbundle: снимает только домены выбранных групп --- +out=$(_bypass_cli_bundle --group steam) || fail "bundle steam for unbundle test failed: $out" +out=$(_bypass_cli_bundle --group banks) || fail "bundle banks failed: $out" +out=$(_bypass_cli_add --domain mycustom.example) || fail "add custom failed: $out" +out=$(_bypass_cli_unbundle --group banks) || fail "unbundle banks failed: $out" +jq -e '.ok == true and .removed > 0' <<< "$out" >/dev/null || fail "unbundle bad JSON: $out" +jq -e ' + all(.routing.rules[] | (.domain // [])[]; . != "domain:sberbank.ru") and + any(.routing.rules[] | (.domain // [])[]; . == "domain:steamcontent.com") and + any(.routing.rules[] | (.domain // [])[]; . == "domain:mycustom.example") +' "$CONFIG_FILE" >/dev/null || fail "unbundle removed too much/too little" +if out=$(_bypass_cli_unbundle); then fail "unbundle without --group accepted"; fi +jq -e '.ok == false' <<< "$out" >/dev/null || fail "unbundle no-group: $out" +if out=$(_bypass_cli_unbundle --group nosuch); then fail "unbundle unknown group accepted"; fi +jq -e '.ok == false' <<< "$out" >/dev/null || fail "unbundle unknown group: $out" + # --- 13. profile-create --expire: поле попадает в профиль (add_inbound застабан) --- add_inbound() { return 0; } build_transport_defaults() { echo "34999|svc|/xp"; } diff --git a/xrayebator b/xrayebator index a9d4431..f2c7e57 100755 --- a/xrayebator +++ b/xrayebator @@ -11969,6 +11969,101 @@ _bypass_cli_groups() { return 0 } +# bypass groups — список готовых групп bundle (id, название, домены) для GUI. +_bypass_cli_groups() { + local json + json=$(awk -F'|' 'NF >= 3 { + printf "%s\t%s\t%s\n", $1, $2, $3 + }' < <(_bypass_bundle_groups) | jq -R -s ' + split("\n") | map(select(length > 0)) | + map(split("\t") | {id: .[0], title: .[1], + domains: (.[2] | split(",") | map(select(length > 0)))})') || { + echo '{"ok":false,"error":"Не удалось собрать список групп"}' + return 1 + } + jq -n --argjson groups "$json" '{ok: true, groups: $groups}' + return 0 +} + +# bypass unbundle --group a,b — удалить домены выбранных групп из обхода. +# Одна мутация/рестарт на команду (штучный remove = N рестартов — неприемлемо). +_bypass_cli_unbundle() { + local groups_csv="" + while [[ $# -gt 0 ]]; do + case "$1" in + --group) groups_csv="$2"; shift 2 ;; + *) shift ;; + esac + done + if [[ -z "$groups_csv" ]]; then + echo '{"ok":false,"error":"Укажите --group a,b,c — unbundle без списка опасен, используйте reset"}' + return 1 + fi + + local doms_all="" gid gname d g + local -a wanted=() + IFS=',' read -r -a wanted <<< "$groups_csv" + for g in "${wanted[@]}"; do + g="${g// /}" + [[ -z "$g" ]] && continue + local found=false + while IFS='|' read -r gid gname d; do + [[ -z "$gid" ]] && continue + if [[ "$gid" == "$g" ]]; then + found=true + doms_all="${doms_all}${doms_all:+,}${d}" + fi + done < <(_bypass_bundle_groups) + if [[ "$found" == "false" ]]; then + echo "{\"ok\":false,\"error\":\"Неизвестная группа: $g\"}" + return 1 + fi + done + [[ -n "$doms_all" ]] || { echo '{"ok":false,"error":"Нет доменов"}'; return 1; } + + local remove_list + remove_list=$(printf '%s' "$doms_all" | tr ',' '\n' | sed '/^$/d' | jq -R '"domain:" + .' | jq -sc 'unique') || { + echo '{"ok":false,"error":"Не удалось собрать список удаления"}' + return 1 + } + + local rollback_config="" + # Контракт JSON-only: backup_config печатает статус — только после switch. + exec 3>&1 + exec 1>&2 + if ! backup_config "bypass_unbundle" rollback_config; then + echo '{"ok":false,"error":"Не удалось создать бэкап config.json"}' >&3 + exec 1>&3 3>&- + return 1 + fi + if ! safe_jq_write --argjson remove "$remove_list" ' + .routing.rules = [ + .routing.rules[] | + if (.outboundTag == "direct" and ((.domain // []) | length) > 0) then + .domain = ((.domain // []) - $remove) + | (if (.domain | length == 0) then empty else . end) + else + . + end + ]' "$CONFIG_FILE"; then + echo '{"ok":false,"error":"Не удалось удалить домены групп"}' >&3 + exec 1>&3 3>&- + return 1 + fi + fix_xray_permissions + if ! safe_restart_xray "$rollback_config"; then + echo '{"ok":false,"error":"Xray не принял конфиг — изменения откачены"}' >&3 + exec 1>&3 3>&- + return 1 + fi + exec 1>&3 3>&- + + local count + count=$(printf '%s' "$remove_list" | jq 'length') + jq -n --argjson c "$count" '{ok: true, removed: $c}' + return 0 +} + bypass_command() { export TERM=dumb export XRAYEBATOR_NONINTERACTIVE=1 @@ -11998,12 +12093,17 @@ bypass_command() { _bypass_cli_bundle "$@" return $? ;; + unbundle) + shift + _bypass_cli_unbundle "$@" + return $? + ;; groups) _bypass_cli_groups return $? ;; *) - echo '{"ok":false,"error":"Использование: bypass list | groups | add --domain D | remove --domain D | reset | bundle [--group a,b,c]"}' + echo '{"ok":false,"error":"Использование: bypass list | groups | add --domain D | remove --domain D | reset | bundle [--group a,b,c] | unbundle --group a,b,c"}' return 1 ;; esac From 4d090479bda6de96cb34d4fea06b873bb8ccc672 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:08:06 +0300 Subject: [PATCH 08/29] =?UTF-8?q?GUI:=20revoke=20=D0=BF=D0=BE=D0=B4=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=B8,=20=D1=81=D1=80=D0=BE=D0=BA=20=D0=B4?= =?UTF-8?q?=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D1=8F=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D0=B5=D0=B9,=20bypass-=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=BF=D0=BF=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revoke: кнопка у профиля открывает центральную плашку на блюренной подложке (AlertDialog.Backdrop + .blurBackdrop; существующие диалоги не тронуты) с двумя режимами — «только новая ссылка» (токен, старые конфиги живут) и «полный отзыв» (uuid во всех маршрутах, устройства отваливаются, второй шаг-подтверждение); после успеха показывает новую ссылку и перечитывает список профилей. Срок: чип «до ДД.ММ.ГГГГ»/«срок истёк» в карточке, диалог с пресетами +7/30/90/365 и «снять срок», поле срока при создании профиля. Вся арифметика вынесена в src/shared/expire.ts (describeExpire/isFutureDate/presetDate) — покрыта юнит-тестами, включая приоритет серверного флага expire_disabled над локальными часами. Bypass: отдельная секция (BypassSection) — ленивая загрузка списка, свои домены add/remove, reset с подтверждением, чекбоксы групп с состояниями вкл/частично/выкл через bundle/unbundle. Слои: BypassManager (SSH-обёртка над bypass CLI), IPC-каналы profiles:revoke/ setExpire + bypass:*, preload, типы, i18n ru/en/zh. typecheck чистый, vitest 89/90 (единственный красный — предсуществующий shell-command: spawnSync('/bin/sh') недоступен под Windows-нодой, падает и на чистом дереве). --- src/main/core/bypass.ts | 103 +++++ src/main/core/profiles.ts | 74 +++- src/main/ipc-handlers.ts | 69 ++++ src/preload/index.ts | 54 ++- src/renderer/src/components/BypassSection.tsx | 317 +++++++++++++++ src/renderer/src/i18n/en.json | 65 +++- src/renderer/src/i18n/ru.json | 65 +++- src/renderer/src/i18n/zh.json | 65 +++- .../src/pages/ServerSettings.module.css | 176 ++++++++- src/renderer/src/pages/ServerSettings.tsx | 365 +++++++++++++++++- src/shared/expire.ts | 52 +++ src/shared/types.ts | 98 +++++ tests/unit/expire.test.ts | 45 +++ 13 files changed, 1539 insertions(+), 9 deletions(-) create mode 100644 src/main/core/bypass.ts create mode 100644 src/renderer/src/components/BypassSection.tsx create mode 100644 src/shared/expire.ts create mode 100644 tests/unit/expire.test.ts diff --git a/src/main/core/bypass.ts b/src/main/core/bypass.ts new file mode 100644 index 0000000..86e8fe0 --- /dev/null +++ b/src/main/core/bypass.ts @@ -0,0 +1,103 @@ +import { SshClient, SshCredentials } from './ssh-client' +import { shellCommand } from './shell-command' +import { extractJson } from './profiles' +import type { BypassActionResult, BypassGroupsResult, BypassStateResult } from '@shared/types' + +/** + * Обёртка над `xrayebator bypass …` (list|groups|add|remove|reset|bundle). + * CLI печатает только JSON на stdout; статусы helpers уходят в stderr на сервере, + * extractJson дополнительно вычищает ANSI на случай старых версий менеджера. + */ +export class BypassManager { + constructor(private readonly creds: SshCredentials) {} + + private async run(args: readonly string[]): Promise { + const client = new SshClient(this.creds) + try { + await client.connect() + const res = await client.exec(shellCommand('xrayebator', args), { elevated: true }) + if (res.code !== 0) { + throw new Error(`xrayebator bypass ${args[1] ?? ''} → код ${res.code}: ${res.stderr.trim()}`) + } + return res.stdout + } finally { + client.close() + } + } + + private async action(args: readonly string[]): Promise { + try { + const payload = extractJson(await this.run(args)) as { + ok?: boolean + domain?: string + duplicate?: boolean + groups?: string[] + domains?: number + error?: string + } + return { + ok: payload.ok === true, + domain: payload.domain, + duplicate: payload.duplicate, + groups: payload.groups, + domains: payload.domains, + error: payload.error + } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } + } + + async list(): Promise { + try { + const payload = extractJson(await this.run(['bypass', 'list'])) as { + ok?: boolean + domains?: string[] + error?: string + } + // CLI отдаёт домены с префиксом "domain:" — для UI он шум. + const domains = (payload.domains ?? []).map((d) => d.replace(/^domain:/, '')) + return { ok: payload.ok === true, domains, error: payload.error } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } + } + + async groups(): Promise { + try { + const payload = extractJson(await this.run(['bypass', 'groups'])) as { + ok?: boolean + groups?: { id: string; title: string; domains?: string[] }[] + error?: string + } + const groups = (payload.groups ?? []).map((g) => ({ + id: g.id, + title: g.title, + domains: (g.domains ?? []).map((d) => d.replace(/^domain:/, '')) + })) + return { ok: payload.ok === true, groups, error: payload.error } + } catch (err) { + return { ok: false, groups: [], error: err instanceof Error ? err.message : String(err) } + } + } + + add(domain: string): Promise { + return this.action(['bypass', 'add', '--domain', domain]) + } + + remove(domain: string): Promise { + return this.action(['bypass', 'remove', '--domain', domain]) + } + + reset(): Promise { + return this.action(['bypass', 'reset']) + } + + bundle(groups: readonly string[]): Promise { + return this.action(['bypass', 'bundle', '--group', groups.join(',')]) + } + + unbundle(groups: readonly string[]): Promise { + return this.action(['bypass', 'unbundle', '--group', groups.join(',')]) + } +} diff --git a/src/main/core/profiles.ts b/src/main/core/profiles.ts index fdb71fa..429d1df 100644 --- a/src/main/core/profiles.ts +++ b/src/main/core/profiles.ts @@ -1,6 +1,10 @@ import { SshClient, SshCredentials } from './ssh-client' import { shellCommand } from './shell-command' -import type { ServerProfile } from '@shared/types' +import type { + ProfileExpireResult, + ProfileRevokeResult, + ServerProfile +} from '@shared/types' export interface ProfileListOutput { ok: boolean @@ -139,12 +143,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 +313,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/ipc-handlers.ts b/src/main/ipc-handlers.ts index 3e3115a..7be168f 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, @@ -18,6 +20,7 @@ import type { ServerConnectionMetadata, ServerStore } from './core/servers' import { Deployer } from './core/deployer' import { fetchSubscription } from './core/subscription' import { ProfileManager } from './core/profiles' +import { BypassManager } from './core/bypass' import { ServerManager } from './core/server-manager' import { ServerInspector } from './core/server-inspector' import { probePortsFor } from './core/probe-ports' @@ -443,6 +446,72 @@ 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 bypassManagerFor = async ( + serverId: string, + access: SshAccessInput + ): Promise => { + const server = store.get(serverId) + if (!server) throw new Error('Сервер не найден') + const { credentials } = await credentialsFor(server, server, access) + return new BypassManager(credentials) + } + + ipcMain.handle('bypass:list', async (_e, serverId: string, access: SshAccessInput) => { + return (await bypassManagerFor(serverId, access)).list() + }) + + ipcMain.handle('bypass:groups', async (_e, serverId: string, access: SshAccessInput) => { + return (await bypassManagerFor(serverId, access)).groups() + }) + + ipcMain.handle( + 'bypass:add', + async (_e, serverId: string, access: SshAccessInput, domain: string) => { + return (await bypassManagerFor(serverId, access)).add(domain) + } + ) + + ipcMain.handle( + 'bypass:remove', + async (_e, serverId: string, access: SshAccessInput, domain: string) => { + return (await bypassManagerFor(serverId, access)).remove(domain) + } + ) + + ipcMain.handle('bypass:reset', async (_e, serverId: string, access: SshAccessInput) => { + return (await bypassManagerFor(serverId, access)).reset() + }) + + ipcMain.handle( + 'bypass:bundle', + async (_e, serverId: string, access: SshAccessInput, groups: string[]) => { + return (await bypassManagerFor(serverId, access)).bundle(groups) + } + ) + + ipcMain.handle( + 'bypass:unbundle', + async (_e, serverId: string, access: SshAccessInput, groups: string[]) => { + return (await bypassManagerFor(serverId, access)).unbundle(groups) + } + ) + const serverManagerFor = async ( serverId: string, access: SshAccessInput diff --git a/src/preload/index.ts b/src/preload/index.ts index 5eb7029..35eb1c3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,13 +10,20 @@ import type { ProfileCreateInput, ProfileCreateResult, ProfileDeleteResult, + ProfileExpireInput, + ProfileExpireResult, ProfileFingerprintInput, ProfileFingerprintResult, ProfilePortInput, ProfilePortResult, + ProfileRevokeInput, + ProfileRevokeResult, ProfileSniInput, ProfileSniResult, SniListResult, + BypassActionResult, + BypassGroupsResult, + BypassStateResult, Server, ServerMaintenanceResult, ServerProfile, @@ -101,7 +108,52 @@ 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) + }, + + bypass: { + list: (serverId: string, access: SshAccessInput): Promise => + ipcRenderer.invoke('bypass:list', serverId, access), + groups: (serverId: string, access: SshAccessInput): Promise => + ipcRenderer.invoke('bypass:groups', serverId, access), + add: ( + serverId: string, + access: SshAccessInput, + domain: string + ): Promise => + ipcRenderer.invoke('bypass:add', serverId, access, domain), + remove: ( + serverId: string, + access: SshAccessInput, + domain: string + ): Promise => + ipcRenderer.invoke('bypass:remove', serverId, access, domain), + reset: (serverId: string, access: SshAccessInput): Promise => + ipcRenderer.invoke('bypass:reset', serverId, access), + bundle: ( + serverId: string, + access: SshAccessInput, + groups: string[] + ): Promise => + ipcRenderer.invoke('bypass:bundle', serverId, access, groups), + unbundle: ( + serverId: string, + access: SshAccessInput, + groups: string[] + ): Promise => + ipcRenderer.invoke('bypass:unbundle', serverId, access, groups) }, server: { diff --git a/src/renderer/src/components/BypassSection.tsx b/src/renderer/src/components/BypassSection.tsx new file mode 100644 index 0000000..5cd21b6 --- /dev/null +++ b/src/renderer/src/components/BypassSection.tsx @@ -0,0 +1,317 @@ +import { useEffect, useMemo, useState } from 'react' +import { AlertDialog, Button, Chip, Input, Label, Spinner, TextField } from '@heroui/react' +import { Globe2, Plus, RotateCcw, X } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import type { BypassGroupInfo, SshAccessInput } from '@shared/types' +import styles from '../pages/ServerSettings.module.css' + +interface BypassSectionProps { + serverId: string + access: SshAccessInput + disabled: boolean + onError: (message: string) => void + onToast: (message: string) => void +} + +const DOMAIN_RE = /^[a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}$/ + +/** + * Bypass-группы (RU-банки/стриминг напрямую): список доменов в обходе + add/remove/ + * reset + готовые группы сервера (_bypass_bundle_groups) чекбоксами. + * Данные подтягиваются лениво по кнопке — лишний SSH-коннект на каждое открытие + * страницы профилей не нужен. + */ +export function BypassSection({ + serverId, + access, + disabled, + onError, + onToast +}: BypassSectionProps): React.JSX.Element { + const { t } = useTranslation() + const [loaded, setLoaded] = useState(false) + const [loading, setLoading] = useState(false) + const [busy, setBusy] = useState(false) + const [domains, setDomains] = useState([]) + const [groups, setGroups] = useState([]) + const [draft, setDraft] = useState('') + const [confirmReset, setConfirmReset] = useState(false) + + const load = async (): Promise => { + setLoading(true) + onError('') + try { + const [state, groupList] = await Promise.all([ + window.api.bypass.list(serverId, access), + window.api.bypass.groups(serverId, access) + ]) + if (!state.ok) throw new Error(state.error ?? t('settings.bypassLoadFailed')) + setDomains(state.domains ?? []) + if (groupList.ok) { + setGroups(groupList.groups ?? []) + } else { + setGroups([]) + onError(groupList.error ?? t('settings.bypassLoadFailed')) + } + setLoaded(true) + } catch (err) { + onError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) + } + } + + // Смена сервера сбрасывает состояние секции (иначе чужой список доменов). + useEffect(() => { + setLoaded(false) + setDomains([]) + setGroups([]) + setDraft('') + }, [serverId]) + + const domainSet = useMemo(() => new Set(domains), [domains]) + + const refresh = async (): Promise => { + const state = await window.api.bypass.list(serverId, access) + if (state.ok) setDomains(state.domains ?? []) + } + + const add = async (): Promise => { + const domain = draft.trim().replace(/^domain:/, '') + if (!DOMAIN_RE.test(domain)) { + onError(t('settings.bypassInvalidDomain')) + return + } + setBusy(true) + onError('') + try { + const result = await window.api.bypass.add(serverId, access, domain) + if (result.ok) { + toastIfNew(domain, result.duplicate === true) + setDraft('') + await refresh() + } else { + onError(result.error ?? t('settings.bypassAddFailed')) + } + } catch (err) { + onError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + const toastIfNew = (domain: string, duplicate: boolean): void => { + onToast( + duplicate + ? t('settings.bypassDuplicate', { domain }) + : t('settings.bypassAdded', { domain }) + ) + } + + const remove = async (domain: string): Promise => { + setBusy(true) + onError('') + try { + const result = await window.api.bypass.remove(serverId, access, domain) + if (result.ok) { + onToast(t('settings.bypassRemoved', { domain })) + await refresh() + } else { + onError(result.error ?? t('settings.bypassRemoveFailed')) + } + } catch (err) { + onError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + const resetAll = async (): Promise => { + setConfirmReset(false) + setBusy(true) + onError('') + try { + const result = await window.api.bypass.reset(serverId, access) + if (result.ok) { + onToast(t('settings.bypassResetDone')) + await refresh() + } else { + onError(result.error ?? t('settings.bypassResetFailed')) + } + } catch (err) { + onError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + const toggleGroup = async (group: BypassGroupInfo): Promise => { + const covered = group.domains.every((d) => domainSet.has(d)) + setBusy(true) + onError('') + try { + const result = covered + ? await window.api.bypass.unbundle(serverId, access, [group.id]) + : await window.api.bypass.bundle(serverId, access, [group.id]) + if (result.ok) { + onToast( + covered + ? t('settings.bypassGroupOff', { title: group.title }) + : t('settings.bypassGroupOn', { title: group.title }) + ) + await refresh() + } else { + onError(result.error ?? t('settings.bypassGroupFailed')) + } + } catch (err) { + onError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + return ( +
+

+ + {t('settings.bypassTitle')} +

+

{t('settings.bypassHint')}

+ + {!loaded ? ( +
+ +
+ ) : ( +
+ {groups.length > 0 && ( +
+ {t('settings.bypassGroups')} +
+ {groups.map((group) => { + const covered = group.domains.every((d) => domainSet.has(d)) + const partial = !covered && group.domains.some((d) => domainSet.has(d)) + const label = covered + ? t('settings.bypassGroupStateOn') + : partial + ? t('settings.bypassGroupStatePartial') + : t('settings.bypassGroupStateOff') + return ( + + ) + })} +
+
+ )} + +
+ + + setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void add() + }} + /> + + + + + {t('settings.bypassCount', { count: domains.length })} + +
+ + {domains.length === 0 ? ( +

{t('settings.bypassEmpty')}

+ ) : ( +
+ {domains.map((domain) => ( + + {domain} + + + ))} +
+ )} +
+ )} + + { + if (!open && !busy) setConfirmReset(false) + }} + > + + + + + + + + {t('settings.bypassResetTitle')} + + {t('settings.bypassResetBody')} + + + + + + + + +
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index e1f6a32..d677d29 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -235,6 +235,69 @@ "yandex_cdn": "Yandex CDN", "foreign": "Foreign (may not always pass)", "fallback": "Fallback (often probed)" - } + }, + "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", + "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", + "expireHintExpired": "Expired — the server disabled this profile. Extend the date to restore access.", + "createExpire": "Expiry date", + "bypassTitle": "Bypass routing", + "bypassHint": "Listed domains go direct, skipping the tunnel: Russian banks, government services and streaming do not block foreign IPs. Ready groups are toggles; add your own domain manually.", + "bypassLoad": "Load list", + "bypassLoading": "Loading…", + "bypassLoadFailed": "Failed to load bypass rules", + "bypassGroups": "Ready groups", + "bypassGroupStateOn": "on", + "bypassGroupStatePartial": "partial", + "bypassGroupStateOff": "off", + "bypassAddLabel": "Custom domain", + "bypassAdd": "Add", + "bypassAddFailed": "Failed to add the domain", + "bypassAdded": "Domain {{domain}} added to bypass", + "bypassDuplicate": "Domain {{domain}} was already bypassed", + "bypassInvalidDomain": "Invalid domain: expected something like example.ru", + "bypassReset": "Reset all", + "bypassResetTitle": "Remove all bypass rules?", + "bypassResetBody": "Only domains added to the bypass list are removed. The server's base routing rules (ads and private subnets blocking) stay intact.", + "bypassResetDone": "All bypass rules removed", + "bypassResetFailed": "Failed to reset bypass rules", + "bypassCount": "domains: {{count}}", + "bypassEmpty": "The list is empty: all traffic goes through the tunnel.", + "bypassRemoveTitle": "Remove from bypass", + "bypassRemoved": "Domain {{domain}} removed from bypass", + "bypassRemoveFailed": "Failed to remove the domain", + "bypassGroupOn": "Group «{{title}}» enabled", + "bypassGroupOff": "Group «{{title}}» disabled", + "bypassGroupFailed": "Failed to toggle the group" } } diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index ea1a3ec..c25b403 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -236,6 +236,69 @@ "yandex_cdn": "Yandex CDN", "foreign": "Иностранные (не всегда проходят)", "fallback": "Запасные (часто под зондированием)" - } + }, + "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": "Снять срок", + "expireChanged": "Срок «{{name}}» — до {{date}}", + "expireCleared": "Срок «{{name}}» снят: профиль бессрочный", + "expireFailed": "Не удалось сохранить срок", + "expireEnforced": "Проверка выполняется на сервере каждые 10 минут.", + "expireUntil": "до {{date}}", + "expireExpired": "срок истёк", + "expireHint": "Профиль отключится автоматически в эту дату", + "expireHintExpired": "Срок истёк — профиль отключён сервером. Продлите срок, чтобы вернуть доступ.", + "createExpire": "Срок действия", + "bypassTitle": "Обход VPN (Bypass)", + "bypassHint": "Домены из списка идут напрямую, минуя туннель: российские банки, госуслуги и стриминг не блокируют зарубежные IP. Готовые группы — чекбоксами, свой домен — вручную.", + "bypassLoad": "Загрузить список", + "bypassLoading": "Загружаю…", + "bypassLoadFailed": "Не удалось получить bypass-правила", + "bypassGroups": "Готовые группы", + "bypassGroupStateOn": "включена", + "bypassGroupStatePartial": "частично", + "bypassGroupStateOff": "выключена", + "bypassAddLabel": "Свой домен", + "bypassAdd": "Добавить", + "bypassAddFailed": "Не удалось добавить домен", + "bypassAdded": "Домен {{domain}} добавлен в обход", + "bypassDuplicate": "Домен {{domain}} уже был в обходе", + "bypassInvalidDomain": "Некорректный домен: нужен вид example.ru", + "bypassReset": "Сбросить всё", + "bypassResetTitle": "Удалить все bypass-правила?", + "bypassResetBody": "Будут удалены только домены, добавленные в обход. Базовые правила маршрутизации сервера (блокировка рекламы, приватных подсетей) останутся.", + "bypassResetDone": "Все bypass-правила удалены", + "bypassResetFailed": "Не удалось сбросить bypass-правила", + "bypassCount": "доменов: {{count}}", + "bypassEmpty": "Список пуст: весь трафик идёт через туннель.", + "bypassRemoveTitle": "Убрать из обхода", + "bypassRemoved": "Домен {{domain}} убран из обхода", + "bypassRemoveFailed": "Не удалось убрать домен", + "bypassGroupOn": "Группа «{{title}}» включена", + "bypassGroupOff": "Группа «{{title}}» выключена", + "bypassGroupFailed": "Не удалось переключить группу" } } diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index e4c80d8..297d3fb 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -234,6 +234,69 @@ "yandex_cdn": "Yandex CDN", "foreign": "国外(不一定总是可用)", "fallback": "备用(常被探测)" - } + }, + "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": "取消有效期", + "expireChanged": "「{{name}}」的有效期至 {{date}}", + "expireCleared": "已取消「{{name}}」的有效期:配置永久有效", + "expireFailed": "无法保存有效期", + "expireEnforced": "服务器每 10 分钟检查一次有效期。", + "expireUntil": "至 {{date}}", + "expireExpired": "已过期", + "expireHint": "该配置将在这一天自动停用", + "expireHintExpired": "已过期——服务器已停用该配置。延长有效期即可恢复访问。", + "createExpire": "有效期", + "bypassTitle": "绕过 VPN(分流)", + "bypassHint": "列表中的域名直连、不走隧道:俄罗斯银行、政务服务和流媒体不会封锁境外 IP。内置分组用开关切换,自有域名手动添加。", + "bypassLoad": "加载列表", + "bypassLoading": "正在加载…", + "bypassLoadFailed": "无法获取分流规则", + "bypassGroups": "内置分组", + "bypassGroupStateOn": "已启用", + "bypassGroupStatePartial": "部分启用", + "bypassGroupStateOff": "已关闭", + "bypassAddLabel": "自定义域名", + "bypassAdd": "添加", + "bypassAddFailed": "无法添加域名", + "bypassAdded": "域名 {{domain}} 已加入直连", + "bypassDuplicate": "域名 {{domain}} 已在直连列表中", + "bypassInvalidDomain": "域名无效:应为 example.ru 这样的形式", + "bypassReset": "全部重置", + "bypassResetTitle": "删除全部分流规则?", + "bypassResetBody": "只会删除加入直连的域名,服务器的基础路由规则(广告与私有网段拦截)会保留。", + "bypassResetDone": "已删除全部分流规则", + "bypassResetFailed": "无法重置分流规则", + "bypassCount": "域名数:{{count}}", + "bypassEmpty": "列表为空:所有流量都经过隧道。", + "bypassRemoveTitle": "从直连中移除", + "bypassRemoved": "域名 {{domain}} 已从直连中移除", + "bypassRemoveFailed": "无法移除域名", + "bypassGroupOn": "分组「{{title}}」已启用", + "bypassGroupOff": "分组「{{title}}」已关闭", + "bypassGroupFailed": "无法切换分组" } } diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index ce6638a..950826c 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -472,14 +472,188 @@ .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; +} + +.bypassSection { + display: flex; + flex-direction: column; + gap: 14px; +} + +.bypassGroups { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 8px; +} + +.bypassGroupCard { + display: flex; + flex-direction: column; + gap: 2px; + text-align: left; + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; + cursor: pointer; + color: var(--text-1); + transition: border-color 0.15s ease; +} + +.bypassGroupCard:hover:not(:disabled) { + border-color: var(--accent); +} + +.bypassGroupCardActive { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, var(--bg-1)); +} + +.bypassGroupTitle { + font-size: 13px; + font-weight: 600; + color: var(--text-0); +} + +.bypassGroupCount { + font-size: 11px; + color: var(--text-2); +} + +.bypassDomainList { + display: flex; + flex-wrap: wrap; + gap: 6px; + max-height: 180px; + overflow-y: auto; +} + +.bypassDomainItem { + display: inline-flex; + align-items: center; + gap: 4px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-1); + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: 8px; + padding: 3px 6px 3px 8px; +} + +.bypassDomainRemove { + display: inline-flex; + align-items: center; + background: none; + border: none; + color: var(--text-2); + cursor: pointer; + padding: 2px; + border-radius: 4px; +} + +.bypassDomainRemove:hover { + color: var(--danger); +} + +.bypassRow { + display: flex; + gap: 8px; + align-items: flex-end; + flex-wrap: wrap; +} + +.bypassEmpty { + font-size: 12px; + color: var(--text-2); +} + .fpHint { font-size: 13px; line-height: 1.5; diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index 9404ad0..b748acd 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -12,11 +12,16 @@ 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 { isSshAccessReady, SshAccessForm } from '../components/SshAccessForm' +import { BypassSection } from '../components/BypassSection' import { shouldAutoConnectServer } from './server-access' import styles from './ServerSettings.module.css' @@ -89,6 +94,7 @@ export function ServerSettings({ const [transport, setTransport] = useState('xhttp') const [count, setCount] = useState('1') const [creating, setCreating] = useState(false) + const [createExpire, setCreateExpire] = useState('') const [updating, setUpdating] = useState(false) const [uninstalling, setUninstalling] = useState(false) @@ -115,6 +121,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 +204,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 +374,98 @@ 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()).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 info = describeExpire(profile.expire ?? 0, profile.expire_disabled === true, Date.now()) + if (info.status === 'none') return null + const danger = info.status === 'expired' + return ( + + {danger + ? t('settings.expireExpired') + : t('settings.expireUntil', { date: info.date })} + + ) + } + const updateServer = async (): Promise => { if (!accessReady) return setBusy(true) @@ -523,6 +635,15 @@ export function ServerSettings({ onChange={(e) => setCount(e.target.value)} /> + + + setCreateExpire(e.target.value)} + /> + )} + + {profile.subscription_url && ( + + )} {profile.multi_route ? ( @@ -674,6 +816,14 @@ export function ServerSettings({ ))} + + )} @@ -720,6 +870,217 @@ 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) + }} + > + + + + + + {t('settings.expireTitle')} — {expireTarget?.name ?? ''} + + + +

{t('settings.expireHintBody')}

+ {expireTarget && ( +

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

+ )} +
+ {t('settings.expireSelect')} +
+ {[7, 30, 90, 365].map((d) => ( + + ))} +
+
+
+ + setExpireDate(e.target.value)} + /> + +
+

{t('settings.expireEnforced')}

+
+ + + {expireTarget?.expire ? ( + + ) : null} + + +
+
+
+
+ { diff --git a/src/shared/expire.ts b/src/shared/expire.ts new file mode 100644 index 0000000..4d0f376 --- /dev/null +++ b/src/shared/expire.ts @@ -0,0 +1,52 @@ +/** + * Чистые хелперы отображения/валидации срока действия профиля. + * Сервер (xrayebator CLI) нормализует и принудит expire — здесь только + * предвычисление подписи для UI и разбор date-input в ISO-строку. + */ + +export type ExpireStatus = 'none' | 'active' | 'expired' + +export interface ExpireInfo { + status: ExpireStatus + /** '2026-11-15' для дата-виджетов; '' если срок снят. */ + date: string +} + +const DAY_MS = 86_400_000 + +function isValidIsoDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false + const [y, m, d] = value.split('-').map(Number) + if (!y || !m || !d || m < 1 || m > 12 || d < 1 || d > 31) return false + const dt = new Date(`${value}T00:00:00Z`) + return !Number.isNaN(dt.getTime()) && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d +} + +/** + * epoch-секунды из profile JSON → статус + дата (локальная). + * disabled (флаг серверного принуждения) всегда важнее сравнения с now. + */ +export function describeExpire(expire: number, disabled: boolean, nowMs: number): ExpireInfo { + if (!expire || expire <= 0) { + return { status: 'none', date: '' } + } + const date = new Date(expire * 1000).toISOString().slice(0, 10) + if (disabled || expire * 1000 <= nowMs) { + return { status: 'expired', date } + } + return { status: 'active', date } +} + +/** ISO-дата валидна и строго в будущем (день считается от полуночи локального ТЗ). */ +export function isFutureDate(value: string, nowMs: number): boolean { + if (!isValidIsoDate(value)) return false + const dayStart = new Date(`${value}T00:00:00`).getTime() + return dayStart > nowMs +} + +/** Пресеты «через N дней» для диалога срока (локальная дата, как в виджете). */ +export function presetDate(daysFromNow: number, nowMs: number): string { + const d = new Date(nowMs + daysFromNow * DAY_MS) + const pad = (n: number): string => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} diff --git a/src/shared/types.ts b/src/shared/types.ts index dffbb3f..18bf2e7 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -129,6 +129,10 @@ export interface ServerProfile { routes: number pq_enabled: boolean subscription_url: string + /** epoch-секунды истечения; 0 = бессрочный. Принуждается серверным таймером. */ + expire: number + /** true — профиль сейчас отключён по сроку (клиент снят с inbound'ов). */ + expire_disabled: boolean } export interface ProfileCreateInput { @@ -136,6 +140,8 @@ export interface ProfileCreateInput { transport: string port?: number count?: number + /** ISO-дата 'ГГГГ-ММ-ДД' или epoch-секунды; undefined = бессрочный. */ + expire?: string } export interface ProfileCreateResult { @@ -203,6 +209,67 @@ export interface ProfilePortResult { error?: string } +export interface ProfileRevokeInput { + name: string + /** true — новый uuid тоже (старые клиенты отваливаются немедленно). */ + full: boolean +} + +export interface ProfileRevokeResult { + ok: boolean + name?: string + full?: boolean + sub_token?: string + uuid?: string + subscription_url?: string + error?: string +} + +export interface ProfileExpireInput { + name: string + /** ISO 'ГГГГ-ММ-ДД' или epoch-секунды; null/undefined → 'none' (снять срок). */ + expire: string | number | null +} + +export interface ProfileExpireResult { + ok: boolean + name?: string + expire?: number + expired?: boolean + error?: string +} + +export interface BypassStateResult { + ok: boolean + domains?: string[] + error?: string +} + +export interface BypassGroupInfo { + id: string + title: string + /** Домены группы без префикса domain: — GUI рисует чекбоксы и считает покрытие. */ + domains: string[] +} + +export interface BypassGroupsResult { + ok: boolean + groups?: BypassGroupInfo[] + error?: string +} + +export interface BypassActionResult { + ok: boolean + domain?: string + duplicate?: boolean + groups?: string[] + /** bundle: число добавленных доменов */ + domains?: number + /** unbundle: число удалённых доменов */ + removed?: number + error?: string +} + export interface SniEntry { sni: string category: string @@ -309,6 +376,37 @@ export interface ElectronAPI { access: SshAccessInput, input: ProfilePortInput ) => Promise + revoke: ( + serverId: string, + access: SshAccessInput, + input: ProfileRevokeInput + ) => Promise + setExpire: ( + serverId: string, + access: SshAccessInput, + input: ProfileExpireInput + ) => Promise + } + bypass: { + list: (serverId: string, access: SshAccessInput) => Promise + groups: (serverId: string, access: SshAccessInput) => Promise + add: (serverId: string, access: SshAccessInput, domain: string) => Promise + remove: ( + serverId: string, + access: SshAccessInput, + domain: string + ) => Promise + reset: (serverId: string, access: SshAccessInput) => Promise + bundle: ( + serverId: string, + access: SshAccessInput, + groups: string[] + ) => Promise + unbundle: ( + serverId: string, + access: SshAccessInput, + groups: string[] + ) => Promise } server: { update: ( diff --git a/tests/unit/expire.test.ts b/tests/unit/expire.test.ts new file mode 100644 index 0000000..52732ca --- /dev/null +++ b/tests/unit/expire.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { describeExpire, isFutureDate, presetDate } from '../../src/shared/expire' + +const NOW = new Date('2026-09-28T12:00:00Z').getTime() +const DAY = 86_400_000 + +describe('describeExpire', () => { + it('0 → бессрочный без даты', () => { + expect(describeExpire(0, false, NOW)).toEqual({ status: 'none', date: '' }) + }) + + it('будущий срок → active с ISO-датой', () => { + const info = describeExpire(Math.floor((NOW + 10 * DAY) / 1000), false, NOW) + expect(info.status).toBe('active') + expect(info.date).toMatch(/^\d{4}-\d{2}-\d{2}$/) + }) + + it('прошедший срок → expired', () => { + const info = describeExpire(Math.floor((NOW - 1000) / 1000), false, NOW) + expect(info.status).toBe('expired') + }) + + it('флаг серверного отключения важнее времени (revoke/enable race)', () => { + const future = Math.floor((NOW + 100 * DAY) / 1000) + expect(describeExpire(future, true, NOW).status).toBe('expired') + }) +}) + +describe('isFutureDate', () => { + it('принимает только корректные будущие ISO-даты', () => { + expect(isFutureDate('2030-01-01', NOW)).toBe(true) + expect(isFutureDate('2026-09-28', NOW)).toBe(false) + expect(isFutureDate('2020-05-05', NOW)).toBe(false) + expect(isFutureDate('2026-13-01', NOW)).toBe(false) + expect(isFutureDate('2026-02-30', NOW)).toBe(false) + expect(isFutureDate('', NOW)).toBe(false) + expect(isFutureDate('01.01.2030', NOW)).toBe(false) + }) +}) + +describe('presetDate', () => { + it('прибавляет дни и отдаёт локальную ISO-дату (полуденный якорь — вне DST-переходов)', () => { + expect(presetDate(30, new Date('2026-06-01T12:00:00').getTime())).toBe('2026-07-01') + }) +}) From df6cff9a8584a8754ae97cc32105beeb5105e5e4 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:10:47 +0300 Subject: [PATCH 09/29] =?UTF-8?q?Docs:=20revoke/=D1=81=D1=80=D0=BE=D0=BA/b?= =?UTF-8?q?ypass=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20?= =?UTF-8?q?=C2=AB=D0=BD=D0=B5=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=C2=BB=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=20GUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit desktop-gui (ru/en/zh): из списка «GUI не предоставляет» убраны bypass и revoke (теперь есть), добавлено описание трёх новых возможностей Server settings — двухрежимный revoke, срок действия с серверным принуждением, bypass-группы; обновлён список CLI-команд, на которые отображается профильный API. configuration (ru/en/zh): в справочник добавлены profile-revoke, profile-expire, expire-check (таймер), bypass groups/unbundle и --expire у profile-create. Заодно поправлены два места, где доки отправляли за revoke «в терминал»: electron-store секция (ru/en) и ремарка про Server keys — перевыпуск токена теперь явно указан на карточках профилей. --- docs/configuration.md | 7 ++++++- docs/desktop-gui.md | 21 ++++++++++++++------- docs/ru/configuration.md | 7 ++++++- docs/ru/desktop-gui.md | 17 ++++++++++++----- docs/zh-CN/configuration.md | 7 ++++++- docs/zh-CN/desktop-gui.md | 21 ++++++++++++++------- 6 files changed, 58 insertions(+), 22 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index c703ccc..814b6ab 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -117,8 +117,11 @@ is a client-side profile/route setting; changing it does not restart Xray or alt | `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 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]` or epoch seconds (a past date is 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; applied immediately (a passed date 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 | @@ -128,6 +131,8 @@ is a client-side profile/route setting; changing it does not restart Xray or alt | `sudo xrayebator bypass remove --domain D` | Remove a domain from bypass rules | | `sudo xrayebator bypass reset` | Clear all custom bypass rules | | `sudo xrayebator bypass bundle [--group a,b,c]` | Apply the default bypass groups; without `--group`, apply all groups | +| `sudo xrayebator bypass unbundle --group a,b,c` | Remove the domains of the selected groups in a single mutation and a single Xray restart | +| `sudo xrayebator bypass groups` | List the ready bypass groups with their domain sets (JSON) | | `sudo xrayebator-update [branch]` | Run the full `update.sh` project lifecycle update; without a branch, display `.current_branch` and open the interactive branch selector; with a branch, use that explicit branch | | `sudo xrayebator-uninstall` | Remove the service and installation | diff --git a/docs/desktop-gui.md b/docs/desktop-gui.md index 2c6add2..50184fd 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|groups|add --domain D|remove --domain D|reset|bundle --group a,b,c|unbundle --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,13 @@ 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 date passes. Extending the date restores it. An expiry can also be set at profile-creation time. +- **Bypass groups** — the list of domains routed directly around the tunnel (RU banks, government services, streaming): custom domains are added and removed individually, the server's ready-made groups toggle as checkboxes, and a full reset is available. ## Deployment protocol @@ -178,4 +185,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/configuration.md b/docs/ru/configuration.md index 38ff00b..d71cba6 100644 --- a/docs/ru/configuration.md +++ b/docs/ru/configuration.md @@ -120,8 +120,11 @@ legacy-файлы и блоки, ранее созданные Xrayebator, и с | `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 profile-create --name ИМЯ [--transport tcp\|tcp-utls\|tcp-xudp\|tcp-mux\|grpc\|xhttp] [--port P] [--count N] [--expire ДАТА]` | Создать профили без интерактива; `--expire` принимает `ГГГГ-ММ-ДД[ ЧЧ:ММ]` или epoch-секунды (прошлое отклоняется); `{"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` | Задать, продлить или снять срок действия профиля; применение немедленное (истёкший срок сразу снимает клиента, продление возвращает); 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 | @@ -131,6 +134,8 @@ legacy-файлы и блоки, ранее созданные Xrayebator, и с | `sudo xrayebator bypass remove --domain D` | Убрать домен из bypass-правил | | `sudo xrayebator bypass reset` | Сбросить все кастомные bypass-правила | | `sudo xrayebator bypass bundle [--group a,b,c]` | Применить дефолтные группы bypass; без `--group` — все группы | +| `sudo xrayebator bypass unbundle --group a,b,c` | Снять домены выбранных групп с обхода одной мутацией и одним рестартом Xray | +| `sudo xrayebator bypass groups` | Вывести готовые группы bypass с составом доменов (JSON) | | `sudo xrayebator-update [branch]` | Запустить полный `update.sh` lifecycle update; без аргумента — интерактивный выбор ветки | | `sudo xrayebator-uninstall` | Снять сервис и конфигурацию | diff --git a/docs/ru/desktop-gui.md b/docs/ru/desktop-gui.md index 4a742af..d1da008 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|groups|add --domain D|remove --domain D|reset|bundle --group a,b,c|unbundle --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,13 @@ 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'ов по истечении срока. Продление возвращает клиента обратно. Срок также можно задать сразу при создании профиля. +- **Bypass-группы** — список доменов, идущих напрямую мимо туннеля (RU-банки, госуслуги, стриминг): свои домены добавляются и удаляются поштучно, готовые группы сервера включаются и выключаются чекбоксами, есть полный сброс. ## Протокол развёртывания diff --git a/docs/zh-CN/configuration.md b/docs/zh-CN/configuration.md index 1d035bd..ce05c33 100644 --- a/docs/zh-CN/configuration.md +++ b/docs/zh-CN/configuration.md @@ -105,8 +105,11 @@ Xrayebator 不会更改主机的 TCP 拥塞控制算法,也不会写入或应 | `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 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 秒(过去的时间会被拒绝),打印 `{"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` | 设置、延长或取消配置档有效期;立即生效(已过期会移除客户端,延长则恢复),打印 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 对话框使用) | @@ -116,6 +119,8 @@ Xrayebator 不会更改主机的 TCP 拥塞控制算法,也不会写入或应 | `sudo xrayebator bypass remove --domain D` | 从分流规则移除一个域名(JSON) | | `sudo xrayebator bypass reset` | 清空所有自定义分流规则(JSON) | | `sudo xrayebator bypass bundle [--group a,b,c]` | 应用默认分流分组;不带 `--group` 时重新应用全部分组(JSON) | +| `sudo xrayebator bypass unbundle --group a,b,c` | 以单次变更和单次 Xray 重启移除所选分组的域名(JSON) | +| `sudo xrayebator bypass groups` | 列出内置分流分组及其域名集合(JSON) | | `sudo xrayebator-update [branch]` | 运行完整的 `update.sh` 生命周期更新;无参数时显示 `.current_branch` 并打开交互式分支选择,有参数时使用该分支 | | `sudo xrayebator-uninstall` | 移除服务与配置 | diff --git a/docs/zh-CN/desktop-gui.md b/docs/zh-CN/desktop-gui.md index 5c52b48..c35a7fc 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|groups|add --domain D|remove --domain D|reset|bundle --group a,b,c|unbundle --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,13 @@ self-steal 服务日志/状态 ``` -尤其要注意,Dashboard 的可达性状态点不等于可以使用 `probe-test`,密钥页面也不等于可以使用 `revoke`。 +尤其要注意,Dashboard 的可达性状态点不等于可以使用 `probe-test`。 + +Server settings 还提供: + +- **订阅吊销** —— 每个配置档旁的按钮会打开一个菜单,包含两种模式。「仅更换链接」会重新签发 `sub_token`(旧 URL 立即失效,线路和密钥不变)。「完全吊销」还会更换该配置档所有 inbound 中的 uuid,已经下载过配置的设备会立刻断开——这是真正关闭泄露链接访问权限的唯一方式。 +- **配置档有效期** —— 日期通过订阅响应头下发给客户端,并由服务器强制执行:`xrayebator-expire.timer` 定时器每 10 分钟运行一次 `xrayebator expire-check`,到期后把客户端从 inbound 中移除。延长日期即可恢复。创建配置档时也可以直接设置有效期。 +- **分流分组** —— 绕过隧道直连的域名列表(俄罗斯银行、政务服务、流媒体):自有域名可逐个添加和删除,服务器内置分组用开关切换,并提供完全重置。 ## 部署协议 @@ -179,4 +186,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` 界面或服务器端命令。 From 091dffe8e5f9b699dc5dce039588f05ca23d9d12 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:41:53 +0300 Subject: [PATCH 10/29] =?UTF-8?q?CLI:=20revoke/expire/bypass-groups=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=81=D0=BB=D0=BE=D0=B9=20=D0=B4=D0=BB=D1=8F=20=D1=82?= =?UTF-8?q?=D1=80=D1=91=D1=85=20=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20GUI-?= =?UTF-8?q?=D1=84=D0=B8=D1=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit profile-revoke --name N [--full]: перевыпуск sub_token (обёртка логики TUI-пункта 2, handler читает токен на каждый запрос — рестарт subhttp не нужен); --full меняет и uuid во всех inbound'ах портов профиля — уже скачанные конфиги отваливаются сразу (утёкшая в чат ссылка перестаёт быть вечным доступом; токен-революция этого не лечит). uuid в profile JSON совпадает с конфигом всегда: запись профиля идёт после валидации конфига, сбой записи — откат config.json из бэкапа транзакции. Сроки: profile-create --expire (epoch/мс/'2006-12-31[ 23:59]' → нормализация в сек), profile-expire --expire DATE|none (немедленное принуждение), expire-check (пакетно, идемпотентно: без изменений — без рестарта, иначе таймер ронял бы Xray каждые 10 мин). Принуждение серверное: systemd-таймер xrayebator-expire (10 мин) ставится миграцией expire_timer_2026 (main_menu+quickstart, parity-guard); disable снимает клиента в .expire_clients+fлаг (inbound'ы живут: пустой inbound при продлении восстановим без new shortId — _remove_profile_uuid выкидывал бы inbound целиком), enable возвращает со снимка (с актуальным uuid на случай revoke между циклами) или rebuild-ом из routes (flow: vision для tcp/utls/xudp, '' для mux/grpc/xhttp). profiles-вывод обогатился expire/expire_disabled. --expire в create не позволяет срок в прошлом. bypass groups — JSON-описание 7 bundle-групп для GUI-чекбоксов; bypass-бандл уже был. uninstall.sh снимает expire-таймер (прецедент ip-renew: иначе долбит удалённый бинарь). JSON-only контракт: backup_config и все helpers — после exec 3>&1 switch (нашёл тест: '-> Бэкап конфига' перед JSON ломал парсинг). Тест: validation/test-profile-revoke-expire-cli.sh (27/27 всей валидации зелёные; два jq-баг-фикса по дороге: index(.port) внутри map по массиву ports, снимок-выражение с -биндингом). (cherry picked from commit c0d4083e23e45dc9d29c881ff860d07d82fe97b0) --- uninstall.sh | 8 + validation/test-profile-revoke-expire-cli.sh | 233 +++++++ xrayebator | 666 ++++++++++++++++++- 3 files changed, 898 insertions(+), 9 deletions(-) create mode 100644 validation/test-profile-revoke-expire-cli.sh diff --git a/uninstall.sh b/uninstall.sh index 4c79dfe..31469bd 100644 --- a/uninstall.sh +++ b/uninstall.sh @@ -87,6 +87,14 @@ if systemctl list-unit-files 'xrayebator-ip-renew.*' > /dev/null 2>&1; then rm -f /etc/systemd/system/xrayebator-ip-renew.service rm -f /etc/systemd/system/xrayebator-ip-renew.timer fi +# Таймер принуждения сроков профилей: без удаления продолжал бы дёргать +# удалённый /usr/local/bin/xrayebator expire-check каждые 10 минут. +if systemctl list-unit-files 'xrayebator-expire.*' > /dev/null 2>&1; then + systemctl disable --now xrayebator-expire.timer > /dev/null 2>&1 || true + systemctl stop xrayebator-expire.service > /dev/null 2>&1 || true + rm -f /etc/systemd/system/xrayebator-expire.service + rm -f /etc/systemd/system/xrayebator-expire.timer +fi rm -rf /usr/local/etc/xray rm -rf /var/log/xray echo -e "${GREEN}✓ Конфигурации и логи удалены${NC}\n" diff --git a/validation/test-profile-revoke-expire-cli.sh b/validation/test-profile-revoke-expire-cli.sh new file mode 100644 index 0000000..654c96c --- /dev/null +++ b/validation/test-profile-revoke-expire-cli.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash +# test-profile-revoke-expire-cli.sh +# Тестирует CLI revoke/expire из xrayebator: +# profile-revoke (токен / --full с ротацией uuid), profile-expire (дата/epoch/none, +# немедленное принуждение), expire-check (пакетное идемпотентное принуждение), +# bypass groups (JSON-описание групп bundle), profile-create --expire. +# Гарантии: +# - stdout CLI содержит ТОЛЬКО JSON (статусы helpers не протекают); +# - revoke --full меняет uuid во всех inbound'ах портов профиля, чужие клиенты +# на общем порте не затрагиваются; +# - expire реально выключает клиента и возвращает его при продлении (со снимком +# flow), expire-check без изменений не рестартует xray. +# +# Usage: bash validation/test-profile-revoke-expire-cli.sh +# Requires: jq, uuidgen, openssl, bash 4+. + +set -euo pipefail + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +WORKDIR=$(mktemp -d /tmp/xrayebator-revoke-expire.XXXXXX) +trap 'rm -rf "$WORKDIR"' EXIT + +source "$REPO_ROOT/xrayebator" + +CONFIG_FILE="$WORKDIR/config.json" +PROFILES_DIR="$WORKDIR/profiles" +XRAY_BACKUPS_DIR="$WORKDIR/backups" +mkdir -p "$PROFILES_DIR" "$XRAY_BACKUPS_DIR" + +RESTART_LOG="$WORKDIR/restarts.log" +: > "$RESTART_LOG" +# Счётчик через файл: safe_restart_xray вызывается в подшеллах $(...), +# инкремент переменной в подшелле наружу не виден. +safe_restart_xray() { echo restart >> "$RESTART_LOG"; return 0; } +restarts_now() { wc -l < "$RESTART_LOG" | tr -d ' '; } +fix_xray_permissions() { return 0; } +systemctl() { return 0; } + +# 7-портовый multi-route профиль friend + чужой профиль other на общем порту 443. +FRIEND_UUID="11111111-1111-4111-8111-111111111111" +OTHER_UUID="22222222-2222-4222-8222-222222222222" +FRIEND_PORTS='[443, 2053, 2083, 8443, 34521, 34522, 34523]' + +jq -n --arg uuid "$FRIEND_UUID" --argjson ports "$FRIEND_PORTS" '{ + name: "friend", uuid: $uuid, schema_version: 3, multi_route: true, + sub_token: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + transport: "xhttp", port: 443, + routes: [ + {label:"xhttp-legacy", transport:"xhttp", port:443, sni:"a.example", fingerprint:"chrome"}, + {label:"xhttp-pq", transport:"xhttp", port:2053, sni:"a.example", fingerprint:"chrome"}, + {label:"tcp-mux", transport:"tcp-mux", port:2083, sni:"b.example", fingerprint:"chrome"}, + {label:"grpc", transport:"grpc", port:8443, sni:"c.example", fingerprint:"chrome"}, + {label:"tcp-vision", transport:"tcp", port:34521, sni:"d.example", fingerprint:"chrome"}, + {label:"tcp-utls", transport:"tcp-utls", port:34522, sni:"d.example", fingerprint:"firefox"}, + {label:"tcp-xudp", transport:"tcp-xudp", port:34523, sni:"e.example", fingerprint:"chrome"} + ] +}' > "$PROFILES_DIR/friend.json" + +jq -n --arg uuid "$OTHER_UUID" '{ + name: "other", uuid: $uuid, transport: "tcp", port: 443, sni: "a.example", + sub_token: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +}' > "$PROFILES_DIR/other.json" + +make_config() { + jq -n --arg f "$FRIEND_UUID" --arg o "$OTHER_UUID" '{ + inbounds: [ + {port: 443, tag: "inbound-443", settings: {clients: [ + {id: $f, flow: ""}, {id: $o, flow: "xtls-rprx-vision"}], decryption: "none"}}, + {port: 2053, tag: "inbound-2053", settings: {clients: [{id: $f, flow: ""}], decryption: "none"}}, + {port: 2083, tag: "inbound-2083", settings: {clients: [{id: $f, flow: ""}], decryption: "none"}}, + {port: 8443, tag: "inbound-8443", settings: {clients: [{id: $f, flow: ""}], decryption: "none"}}, + {port: 34521, tag: "inbound-34521", settings: {clients: [{id: $f, flow: "xtls-rprx-vision"}], decryption: "none"}}, + {port: 34522, tag: "inbound-34522", settings: {clients: [{id: $f, flow: "xtls-rprx-vision"}], decryption: "none"}}, + {port: 34523, tag: "inbound-34523", settings: {clients: [{id: $f, flow: "xtls-rprx-vision"}], decryption: "none"}} + ], + routing: {rules: []} + }' > "$CONFIG_FILE" +} +make_config + +# --- 1. Revoke токена: sub_token меняется, uuid НЕ трогаем --- +out=$(profile_revoke_command --name friend) || fail "revoke token failed: $out" +jq -e '.ok == true and .full == false' <<< "$out" >/dev/null || fail "revoke bad JSON: $out" +new_token=$(jq -r '.sub_token' <<< "$out") +[[ "$new_token" =~ ^[a-f0-9]{32}$ ]] && [[ "$new_token" != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ]] || + fail "revoke did not rotate token: $out" +jq -e '.sub_token == "'"$new_token"'" and .uuid == "'"$FRIEND_UUID"'"' \ + "$PROFILES_DIR/friend.json" >/dev/null || fail "profile JSON not updated with token" +jq -e '[.inbounds[].settings.clients[].id] | index("'"$FRIEND_UUID"'") != null' \ + "$CONFIG_FILE" >/dev/null || fail "token-only revoke must NOT touch uuids" + +# --- 2. Revoke --full: uuid меняется везде, чужой клиент на 443 цел --- +out=$(profile_revoke_command --name friend --full) || fail "revoke --full failed: $out" +jq -e '.ok == true and .full == true' <<< "$out" >/dev/null || fail "revoke full bad JSON: $out" +full_token=$(jq -r '.sub_token' <<< "$out") +[[ "$full_token" =~ ^[a-f0-9]{32}$ && "$full_token" != "$new_token" ]] || + fail "revoke --full must rotate token too" +rotated_uuid=$(jq -r '.uuid' <<< "$out") +[[ "$rotated_uuid" =~ ^[0-9a-fA-F-]{36}$ ]] && [[ "$rotated_uuid" != "$FRIEND_UUID" ]] || + fail "revoke --full did not rotate uuid: $out" +jq -e --arg old "$FRIEND_UUID" --arg other "$OTHER_UUID" ' + ([.inbounds[].settings.clients[].id] | index($old)) == null and + ([.inbounds[].settings.clients[].id] | index($other)) != null +' "$CONFIG_FILE" >/dev/null || fail "revoke --full left old uuid or killed other client" +jq -e '[.inbounds[] | select(.port != 443) | .settings.clients | length] == [1,1,1,1,1,1]' \ + "$CONFIG_FILE" >/dev/null || fail "revoke --full lost clients on solo ports" +# flow Vision-маршрутов обязан сохраниться (замена id, не пересборка клиента) +jq -e --arg u "$rotated_uuid" ' + (.inbounds[] | select(.port == 34521) | .settings.clients[] | select(.id == $u) | .flow) == "xtls-rprx-vision" and + (.inbounds[] | select(.port == 443) | .settings.clients[] | select(.id == $u) | .flow) == "" +' "$CONFIG_FILE" >/dev/null || fail "revoke --full lost per-route flow" +jq -e '.uuid == "'"$rotated_uuid"'" and .sub_token == "'"$full_token"'"' \ + "$PROFILES_DIR/friend.json" >/dev/null || fail "profile JSON not updated with rotated uuid" + +# --- 3. profile-expire: валидация нормализации --- +[[ "$(_normalize_expire_value 1000000000000)" == "1000000000" ]] || fail "ms not normalized" +[[ "$(_normalize_expire_value 1785000000)" == "1785000000" ]] || fail "sec epoch changed" +if _normalize_expire_value "2030-13-01" 2>/dev/null; then fail "month 13 accepted"; fi +if _normalize_expire_value "abc" 2>/dev/null; then fail "garbage accepted"; fi +FUTURE_EPOCH=$(date -d "2030-01-01 12:30" +%s 2>/dev/null || date -j -f "%Y-%m-%d %H:%M" "2030-01-01 12:30" +%s) +[[ "$(_normalize_expire_value "2030-01-01 12:30")" == "$FUTURE_EPOCH" ]] || fail "datetime normalization mismatch" + +# --- 4. profile-expire: будущее → поле есть, профиль жив, рестарта нет --- +restarts_before=$(restarts_now) +out=$(profile_expire_command --name friend --expire "2030-01-01") || fail "profile-expire future failed: $out" +jq -e '.ok == true and .expired == false' <<< "$out" >/dev/null || fail "expire future bad JSON: $out" +[[ "$(restarts_now)" == "$restarts_before" ]] || fail "future expire must not restart xray" +jq -e '.expire == '"$(_normalize_expire_value "2030-01-01")"' and .expire_disabled != true' \ + "$PROFILES_DIR/friend.json" >/dev/null || fail "expire field not written" + +# --- 5. profile-expire: прошедшая дата → клиент удалён из конфига, снимок с flow --- +out=$(profile_expire_command --name friend --expire "2020-01-01") || fail "profile-expire past failed: $out" +jq -e '.ok == true and .expired == true' <<< "$out" >/dev/null || fail "expire past bad JSON: $out" +jq -e --arg u "$rotated_uuid" 'all(.inbounds[].settings.clients[]?; .id != $u)' \ + "$CONFIG_FILE" >/dev/null || fail "expired profile client still in config" +# Inbound'ы обязаны выжить (иначе продление не восстановит маршруты с короткими id). +jq -e '.inbounds | length == 7' "$CONFIG_FILE" >/dev/null || fail "expire deleted inbounds" +jq -e --arg u "$rotated_uuid" ' + .expire_disabled == true and + (.expire_clients | length == 7) and + (all(.expire_clients[]; .client.id == $u)) and + (any(.expire_clients[]; .port == 34521 and .client.flow == "xtls-rprx-vision")) and + (any(.expire_clients[]; .port == 443 and .client.flow == "")) +' "$PROFILES_DIR/friend.json" >/dev/null || fail "expire snapshot missing/incorrect" +jq -e --arg o "$OTHER_UUID" '.inbounds[] | select(.port == 443) | any(.settings.clients[]?; .id == $o)' \ + "$CONFIG_FILE" >/dev/null || fail "expire removed foreign client on shared port" + +# --- 6. profile-expire: продление → клиент вернулся с flow, снимок удалён --- +out=$(profile_expire_command --name friend --expire "2031-06-01") || fail "renew failed: $out" +jq -e --arg u "$rotated_uuid" ' + (.inbounds[] | select(.port == 34521) | .settings.clients[] | select(.id == $u) | .flow) == "xtls-rprx-vision" and + ([.inbounds[].settings.clients[].id] | index($u)) != null +' "$CONFIG_FILE" >/dev/null || fail "renew did not restore client with flow" +jq -e '.expire_disabled == false and ((.expire_clients // []) | length == 0)' \ + "$PROFILES_DIR/friend.json" >/dev/null || fail "renew did not clear snapshot" + +# --- 7. profile-expire none → вечный --- +out=$(profile_expire_command --name friend --expire none) || fail "expire none failed: $out" +jq -e 'has("expire") | not' "$PROFILES_DIR/friend.json" >/dev/null || fail "expire not deleted" + +# --- 8. expire-check: пакетное принуждение + идемпотентность --- +# friend: срок прошёл, но профиль включён вручную (нет флага) → будет забанен. +jq '.expire = 1000 | del(.expire_disabled) | del(.expire_clients)' "$PROFILES_DIR/friend.json" > "$WORKDIR/f.tmp" && mv "$WORKDIR/f.tmp" "$PROFILES_DIR/friend.json" +jq --arg u "$rotated_uuid" '.inbounds |= map(if .port == 443 then .settings.clients += [{id: $u, flow: ""}] else . end)' "$CONFIG_FILE" > "$WORKDIR/c.tmp" && mv "$WORKDIR/c.tmp" "$CONFIG_FILE" +# other: бессрочный — не трогаем +restarts_before=$(restarts_now) +out=$(expire_check_command) || fail "expire-check failed: $out" +jq -e '.ok == true and (.disabled | index("friend") != null) and ((.enabled | index("other")) == null)' \ + <<< "$out" >/dev/null || fail "expire-check wrong result: $out" +[[ "$(restarts_now)" == "$((restarts_before + 1))" ]] || fail "expire-check must restart once" +jq -e --arg u "$rotated_uuid" 'all(.inbounds[].settings.clients[]?; .id != $u)' \ + "$CONFIG_FILE" >/dev/null || fail "expire-check left client enabled" +# Идемпотентность: второй прогон — пусто и БЕЗ рестарта (иначе таймер ронял бы Xray каждые 10 мин). +restarts_before=$(restarts_now) +out=$(expire_check_command) || fail "expire-check rerun failed: $out" +jq -e '.ok == true and (.disabled | length == 0) and (.enabled | length == 0)' \ + <<< "$out" >/dev/null || fail "expire-check not idempotent: $out" +[[ "$(restarts_now)" == "$restarts_before" ]] || fail "idempotent expire-check must not restart" + +# --- 9. expire-check: enable без снимка → rebuild из routes-метаданных --- +# Флаг остаётся (клиент по-прежнему забанен), expire уходит в будущее, снимок потерян — +# enable должен восстановить клиентов rebuild-путём с правильными flow. +jq 'del(.expire_clients) | .expire = 4102444800' "$PROFILES_DIR/friend.json" > "$WORKDIR/f.tmp" && mv "$WORKDIR/f.tmp" "$PROFILES_DIR/friend.json" +out=$(expire_check_command) || fail "expire-check enable pass failed: $out" +jq -e '.enabled | index("friend") != null' <<< "$out" >/dev/null || fail "expire-check did not re-enable: $out" +jq -e --arg u "$rotated_uuid" ' + all(.inbounds[]; select(.port != 443) | any(.settings.clients[]?; .id == $u)) and + (.inbounds[] | select(.port == 34521) | .settings.clients[] | select(.id == $u) | .flow) == "xtls-rprx-vision" and + (.inbounds[] | select(.port == 8443) | .settings.clients[] | select(.id == $u) | .flow) == "" +' "$CONFIG_FILE" >/dev/null || fail "rebuild restore lost flows" +jq -e '.expire_disabled == false' "$PROFILES_DIR/friend.json" >/dev/null || fail "enable left flag" + +# --- 10. revoke --full на отключённом по сроку → отказ --- +jq '.expire = 1000 | .expire_disabled = true' "$PROFILES_DIR/friend.json" > "$WORKDIR/f.tmp" && mv "$WORKDIR/f.tmp" "$PROFILES_DIR/friend.json" +if out=$(profile_revoke_command --name friend --full); then + fail "revoke --full on disabled profile unexpectedly succeeded" +fi +jq -e '.ok == false' <<< "$out" >/dev/null || fail "revoke-on-expired did not return ok:false: $out" + +# --- 11. revoke невалидные аргументы / отсутствующий профиль --- +if out=$(profile_revoke_command --name "bad name!"); then fail "revoke bad name accepted"; fi +jq -e '.ok == false' <<< "$out" >/dev/null || fail "revoke bad name: $out" +if out=$(profile_revoke_command --name nosuch); then fail "revoke missing profile accepted"; fi +jq -e '.ok == false' <<< "$out" >/dev/null || fail "revoke missing profile: $out" + +# --- 12. bypass groups: JSON с id/title/domains --- +out=$(_bypass_cli_groups) || fail "bypass groups failed: $out" +jq -e '.ok == true and (.groups | length >= 7) and + all(.groups[]; (.id | type == "string") and (.title | type == "string") and + (.domains | type == "number" and . > 0)) and + any(.groups[]; .id == "banks")' <<< "$out" >/dev/null || + fail "bypass groups bad payload: $out" + +# --- 13. profile-create --expire: поле попадает в профиль (add_inbound застабан) --- +add_inbound() { return 0; } +build_transport_defaults() { echo "34999|svc|/xp"; } +reserve_port() { return 0; } +open_firewall_port() { return 0; } +out=$(profile_create_command --name kid --transport tcp --expire "2030-05-05") || fail "create --expire failed: $out" +jq -e '.ok == true and (.names | index("kid") != null)' <<< "$out" >/dev/null || fail "create --expire bad JSON: $out" +jq -e '.expire == '"$(_normalize_expire_value "2030-05-05")" "$PROFILES_DIR/kid.json" >/dev/null || + fail "created profile missing expire" +out=$(profile_create_command --name late --transport tcp --expire "2000-01-01") && fail "create past expire accepted" +jq -e '.ok == false' <<< "$out" >/dev/null || fail "create past expire: $out" + +echo "PASS: revoke/expire CLI rotate secrets correctly, enforce expiry and stay JSON-clean" diff --git a/xrayebator b/xrayebator index 16abb5b..a9d4431 100755 --- a/xrayebator +++ b/xrayebator @@ -7077,6 +7077,7 @@ main_menu() { fi run_migration "sni_list_2026" "SNI list 2026 (РФ-банки/логистика, удалить apple/icloud)" _migrate_sni_list_2026 || ((migration_failures++)) run_migration "bypass_routing_2026" "Bypass routing first-run prompt (опционально)" _migrate_bypass_routing_2026 || ((migration_failures++)) + run_migration "expire_timer_2026" "Таймер принуждения сроков профилей" _migrate_expire_timer_2026 || ((migration_failures++)) run_migration "subhttp_multiroute_2026" "HAPP subhttp multi-route handler" _migrate_subhttp_multiroute_2026 || ((migration_failures++)) run_migration "subhttp_client_compat_2026" "HAPP/v2rayNG subscription client compatibility" _migrate_subhttp_client_compat_2026 || ((migration_failures++)) run_migration "subhttp_no_default_happ_routing_2026" "HAPP subscription: remove invalid legacy routing payload" _migrate_subhttp_no_default_happ_routing_2026 || ((migration_failures++)) @@ -8037,6 +8038,224 @@ _move_profile_client_between_inbounds() { ' "$CONFIG_FILE" } +# Уникальные порты профиля (JSON-массив): routes[] multi-route или top-level .port. +# Общий для profile-delete / revoke / expire — источник истины "где лежат клиенты". +_profile_ports_json() { + local profile_file="$1" + local rcount + rcount=$(_profile_route_count "$profile_file") + if [[ "$rcount" =~ ^[0-9]+$ ]] && [[ "$rcount" -gt 0 ]]; then + jq -c '[.routes[]?.port | select(type == "number")] | unique' "$profile_file" 2>/dev/null + else + jq -c '[.port | select(type == "number")] | unique' "$profile_file" 2>/dev/null + fi +} + +# Заменяет id клиента во всех inbounds на данных портах (revoke --full). +_replace_uuid_in_config() { + local old_uuid=$1 new_uuid=$2 ports_json=$3 + safe_jq_write --arg old "$old_uuid" --arg new "$new_uuid" --argjson ports "$ports_json" ' + .inbounds |= map( + .port as $inbound_port | + if (($ports | index($inbound_port)) != null) + and any(.settings.clients[]?; .id == $old) then + .settings.clients = ((.settings.clients // []) + | map(if .id == $old then .id = $new else . end)) + else + . + end + ) + ' "$CONFIG_FILE" +} + +# Нормализация значения срока: unix-секунды (10 цифр), unix-мс (13 цифр → /1000) +# или ISO-локальное "ГГГГ-ММ-ДД[ TЧЧ:ММ[:СС]]" → epoch. stdout — только число. +_normalize_expire_value() { + local raw="$1" + [[ -n "$raw" ]] || return 1 + if [[ "$raw" =~ ^[0-9]+$ ]]; then + if [[ "${#raw}" -eq 13 ]]; then + printf '%s' "$((raw / 1000))" + elif [[ "${#raw}" -le 12 ]]; then + printf '%s' "$raw" + else + return 1 + fi + return 0 + fi + if [[ "$raw" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}([T\ ][0-9]{2}:[0-9]{2}(:[0-9]{2})?)?$ ]]; then + local norm="${raw/T/ }" + [[ "${#norm}" -eq 10 ]] && norm="$norm 00:00:00" + [[ "${#norm}" -eq 16 ]] && norm="$norm:00" + if [[ "$norm" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})\ ([0-9]{2}):([0-9]{2}):([0-9]{2})$ ]] \ + && [[ "${BASH_REMATCH[2]}" -ge 1 && "${BASH_REMATCH[2]}" -le 12 ]] \ + && [[ "${BASH_REMATCH[3]}" -ge 1 && "${BASH_REMATCH[3]}" -le 31 ]] \ + && [[ "${BASH_REMATCH[4]}" -le 23 && "${BASH_REMATCH[5]}" -le 59 && "${BASH_REMATCH[6]}" -le 60 ]]; then + # GNU date (Debian/Ubuntu обязательны по install.sh); время локальное — + # как и у date +%s в expire-check, расхождения с серверными часами нет. + local ts + ts=$(date -d "$norm" +%s 2>/dev/null) || return 1 + printf '%s' "$ts" + return 0 + fi + fi + return 1 +} + +# Нормализованный .expire профиля → epoch в stdout (0 = бессрочный). +# 13-значные ms из старых записей приводятся к секундам — иначе expiry никогда +# не наступит (ms-epoch ~ 1.7e12 всегда больше now). +_profile_expire_epoch() { + local profile_file="$1" raw + raw=$(jq -r '.expire // empty' "$profile_file" 2>/dev/null) || return 1 + [[ -n "$raw" && "$raw" != "null" ]] || { printf '0'; return 0; } + [[ "$raw" =~ ^[0-9]+$ ]] || { printf '0'; return 0; } + if [[ "${#raw}" -eq 13 ]]; then + printf '%s' "$((raw / 1000))" + else + printf '%s' "$raw" + fi +} + +# Профиль "отключён по expire" = в JSON стоит флаг .expire_disabled. +# Флаг (а не снимок) — маркер идемпотентности: expire-check без флага обязан +# что-то изменить, иначе таймер будет рестартить xray на пустом месте каждый цикл. +_profile_is_expire_disabled() { + local profile_file="$1" + jq -e '.expire_disabled == true' "$profile_file" >/dev/null 2>&1 +} + +# Есть ли клиент профиля (по текущему uuid) хоть в одном inbound конфига. +# Отличает "флаг+клиент исчезли" (спокойно) от "флаг, но клиента вернули руками" +# (надо переналожить бан) — и не даёт таймеру рестартить xray на пустом месте. +_profile_client_in_config() { + local profile_file="$1" uuid + uuid=$(jq -r '.uuid // empty' "$profile_file") + [[ -n "$uuid" ]] || return 1 + jq -e --arg uuid "$uuid" ' + any(.inbounds[]?; any(.settings.clients[]?; .id == $uuid)) + ' "$CONFIG_FILE" >/dev/null 2>&1 +} + +# Отключение истёкшего профиля: снимок клиентов (flow и доп. поля включительно) +# в .expire_clients профиля + флаг → удаление клиента из inbound'ов портов профиля. +# ВАЖНО: inbound'ы сохраняются даже с пустым списком клиентов — _remove_profile_uuid +# выкидывал бы пустой inbound целиком, а на продлении новый inbound = новый shortId +# = смерть всех выданных URL. Клиенты чужих профилей на общих портах не затрагиваются. +# Клиента нет в конфиге — только флаг (бан уже наложен руками). +_expire_disable_profile() { + local profile_file="$1" + local uuid ports_json saved + uuid=$(jq -r '.uuid // empty' "$profile_file") + [[ -n "$uuid" ]] || return 1 + ports_json=$(_profile_ports_json "$profile_file") + jq -e 'type == "array" and length > 0' <<< "$ports_json" >/dev/null 2>&1 || return 1 + + saved=$(jq -c --arg uuid "$uuid" --argjson ports "$ports_json" ' + [.inbounds[] as $in + | select(($ports | index($in.port)) != null) + | $in.settings.clients[]? | select(.id == $uuid) + | {port: $in.port, client: .}]' "$CONFIG_FILE" 2>/dev/null) || return 1 + + if [[ "$(jq 'length' <<< "$saved")" != "0" ]]; then + safe_jq_write --arg uuid "$uuid" --argjson ports "$ports_json" ' + .inbounds |= map( + .port as $p | + if (($ports | index($p)) != null) then + .settings.clients = ((.settings.clients // []) | map(select(.id != $uuid))) + else . end + ) + ' "$CONFIG_FILE" || return 1 + fi + safe_jq_write --argjson saved "$saved" \ + '.expire_clients = $saved | .expire_disabled = true' "$profile_file" || return 1 + return 0 +} + +# Включение профиля до истечения нового срока: клиенты из снимка (с актуальным +# uuid на случай revoke между циклами). Снимка нет — rebuild из routes-метаданных; +# и он ничего не добавил (порты пересобраны вручную) — гасим флаг с предупреждением, +# иначе expire-check зациклится на недостижимом профиле. +_expire_enable_profile() { + local profile_file="$1" + local uuid ports_json saved + uuid=$(jq -r '.uuid // empty' "$profile_file") + [[ -n "$uuid" ]] || return 1 + ports_json=$(_profile_ports_json "$profile_file") + jq -e 'type == "array" and length > 0' <<< "$ports_json" >/dev/null 2>&1 || return 1 + + saved=$(jq -c '.expire_clients // []' "$profile_file") || return 1 + + if [[ "$(jq 'length' <<< "$saved")" == "0" ]]; then + if ! _expire_rebuild_clients "$profile_file"; then + echo -e "${YELLOW} ⚠ $(basename "$profile_file" .json): порты профиля не найдены — срок снят, клиент не восстановлен${NC}" >&2 + safe_jq_write 'del(.expire_clients) | .expire_disabled = false' "$profile_file" || return 1 + return 0 + fi + else + safe_jq_write --arg uuid "$uuid" --argjson ports "$ports_json" --argjson saved "$saved" ' + .inbounds |= map( + .port as $p | + if (($ports | index($p)) != null) then + .settings.clients = ((.settings.clients // []) | map(select(.id != $uuid))) + else . end + ) + | .inbounds |= map( + .port as $p | + if (($ports | index($p)) != null) and any($saved[]; .port == $p) then + .settings.clients = ((.settings.clients // []) + + [$saved[] | select(.port == $p) | .client | .id = $uuid]) + else . end + )' "$CONFIG_FILE" || return 1 + fi + safe_jq_write 'del(.expire_clients) | .expire_disabled = false' "$profile_file" || return 1 + return 0 +} + +# Fallback-восстановление без снимка: добавить {id, flow} в существующий inbound +# порта (flow — как в add_inbound: vision для tcp/utls/xudp, пусто для mux/grpc/xhttp). +_expire_rebuild_clients() { + local profile_file="$1" + local uuid rcount idx transport port flow added=false + uuid=$(jq -r '.uuid // empty' "$profile_file") + [[ -n "$uuid" ]] || return 1 + rcount=$(_profile_route_count "$profile_file") + [[ "$rcount" =~ ^[0-9]+$ ]] || rcount=0 + + local -a specs=() + if [[ "$rcount" -gt 0 ]]; then + for ((idx=0; idx/dev/null) + [[ "$exists" == "1" ]] || continue + if safe_jq_write --arg uuid "$uuid" --argjson port "$port" --arg flow "$flow" ' + (.inbounds[] | select(.port == $port) | .settings.clients) |= + (map(select(.id != $uuid)) + [ {id: $uuid, flow: $flow} ]) + ' "$CONFIG_FILE"; then + added=true + fi + done + [[ "$added" == "true" ]] +} + # Удаление профиля delete_profile_menu() { show_ascii @@ -10269,6 +10488,7 @@ quickstart_command() { run_migration "subhttp_multiroute_2026" "Subhttp multiroute handler" _migrate_subhttp_multiroute_2026 >/dev/null 2>&1 || true run_migration "subhttp_client_compat_2026" "Subhttp client compat" _migrate_subhttp_client_compat_2026 >/dev/null 2>&1 || true run_migration "bypass_routing_2026" "Bypass routing first-run" _migrate_bypass_routing_2026 >/dev/null 2>&1 || true + run_migration "expire_timer_2026" "Expire enforcement timer" _migrate_expire_timer_2026 >/dev/null 2>&1 || true run_migration "subhttp_happ_legacy_xhttp_2026" "Subhttp HAPP legacy XHTTP route" _migrate_subhttp_happ_legacy_xhttp_2026 >/dev/null 2>&1 || true run_migration "subhttp_happ_xhttp_compat_2026" "Subhttp HAPP XHTTP compat" _migrate_subhttp_happ_xhttp_compat_2026 >/dev/null 2>&1 || true run_migration "subhttp_no_default_happ_routing_2026" "Subhttp no default HAPP routing profile" _migrate_subhttp_no_default_happ_routing_2026 >/dev/null 2>&1 || true @@ -10789,7 +11009,7 @@ profiles_command() { [[ "$first" -eq 0 ]] && printf ',' first=0 - local name uuid transport port fingerprint sni created sub_token multi_route rcount pq + local name uuid transport port fingerprint sni created sub_token multi_route rcount pq expire expire_disabled name=$(basename "$file" .json) uuid=$(jq -r '.uuid // ""' "$file" 2>/dev/null) transport=$(jq -r '.transport // ""' "$file" 2>/dev/null) @@ -10801,6 +11021,13 @@ profiles_command() { multi_route=$(jq -r '(.multi_route // false)' "$file" 2>/dev/null) rcount=$(jq -r '((.routes // []) | length)' "$file" 2>/dev/null) pq=$(jq -r '(.pq_enabled // false)' "$file" 2>/dev/null) + expire=$(_profile_expire_epoch "$file" 2>/dev/null || printf '0') + [[ "$expire" =~ ^[0-9]+$ ]] || expire=0 + if _profile_is_expire_disabled "$file" 2>/dev/null; then + expire_disabled=true + else + expire_disabled=false + fi [[ "$rcount" =~ ^[0-9]+$ ]] || rcount=0 local sub_url="" @@ -10814,10 +11041,12 @@ profiles_command() { --arg created "$created" --arg sub_token "$sub_token" \ --argjson multi_route "$multi_route" --argjson routes "$rcount" \ --argjson pq "$pq" --arg subscription_url "$sub_url" \ + --argjson expire "$expire" --argjson expire_disabled "$expire_disabled" \ '{name: $name, uuid: $uuid, transport: $transport, port: $port, fingerprint: $fingerprint, sni: $sni, created: $created, sub_token: $sub_token, multi_route: $multi_route, routes: $routes, - pq_enabled: $pq, subscription_url: $subscription_url}' + pq_enabled: $pq, subscription_url: $subscription_url, + expire: $expire, expire_disabled: $expire_disabled}' done printf ']\n' return 0 @@ -10832,13 +11061,14 @@ profile_create_command() { export DEBIAN_FRONTEND=noninteractive export XRAYEBATOR_NONINTERACTIVE=1 - local name="" transport="xhttp" port="" count=1 + local name="" transport="xhttp" port="" count=1 expire_raw="" while [[ $# -gt 0 ]]; do case "$1" in --name) name="$2"; shift 2 ;; --transport) transport="$2"; shift 2 ;; --port) port="$2"; shift 2 ;; --count) count="$2"; shift 2 ;; + --expire) expire_raw="$2"; shift 2 ;; *) shift ;; esac done @@ -10890,12 +11120,29 @@ profile_create_command() { exec 1>&2 local created_names=() errors=() + local expire_value=0 + if [[ -n "$expire_raw" ]]; then + if ! expire_value=$(_normalize_expire_value "$expire_raw"); then + exec 1>&3 3>&- + rm -f "$results_file" + echo '{"ok":false,"error":"Некорректный --expire: нужен epoch (сек/мс) или дата 2006-12-31[ 23:59]"}' + return 1 + fi + local now_epoch + now_epoch=$(date +%s) + if [[ "$expire_value" -le "$now_epoch" ]]; then + exec 1>&3 3>&- + rm -f "$results_file" + echo '{"ok":false,"error":"Срок действия уже в прошлом"}' + return 1 + fi + fi for pname in "${profile_names[@]}"; do if [[ -f "$PROFILES_DIR/$pname.json" ]]; then errors+=("$pname: уже существует") continue fi - if _profile_cli_create_one "$pname" "$transport" "$port" "$results_file"; then + if _profile_cli_create_one "$pname" "$transport" "$port" "$results_file" "$expire_value"; then created_names+=("$pname") else errors+=("$pname: не удалось создать") @@ -10914,8 +11161,9 @@ profile_create_command() { } # Один профиль: файл JSON + inbound + safe_restart_xray. Возвращает 0/1. +# $5 — expire (epoch-секунды, 0 = бессрочный). _profile_cli_create_one() { - local pname="$1" transport="$2" fixed_port="${3:-}" results_file="${4:-}" + local pname="$1" transport="$2" fixed_port="${3:-}" results_file="${4:-}" expire_value="${5:-0}" local uuid sub_token grpc_service_name xhttp_path port default_sni uuid=$(uuidgen) sub_token=$(openssl rand -hex 16) @@ -10965,7 +11213,12 @@ _profile_cli_create_one() { jq_args+=(--argjson schema_v 2 --argjson pq_enabled true) jq_expr+=', schema_version: $schema_v, pq_enabled: $pq_enabled' fi - jq_expr+=', sub_token: $sub_token, created: $created}' + jq_expr+=', sub_token: $sub_token, created: $created' + if [[ "$expire_value" -gt 0 ]]; then + jq_args+=(--argjson expire "$expire_value") + jq_expr+=', expire: $expire' + fi + jq_expr+='}' if ! jq -n "${jq_args[@]}" "$jq_expr" > "$PROFILES_DIR/$pname.json"; then rm -f "$PROFILES_DIR/$pname.json" echo -e "${RED}✗ Не удалось создать JSON профиля${NC}" >&2 @@ -11000,9 +11253,11 @@ _profile_cli_create_one() { --arg name "$pname" --arg uuid "$uuid" --arg transport "$transport" \ --argjson port "$port" --arg fingerprint "$fingerprint" --arg sni "$default_sni" \ --arg sub_token "$sub_token" --arg subscription_url "$sub_url" \ + --argjson expire "$expire_value" \ '{name: $name, uuid: $uuid, transport: $transport, port: $port, fingerprint: $fingerprint, sni: $sni, sub_token: $sub_token, - subscription_url: $subscription_url}' >> "$results_file" + subscription_url: $subscription_url} + + (if $expire > 0 then {expire: $expire} else {} end)' >> "$results_file" return 0 } @@ -11085,6 +11340,363 @@ profile_delete_command() { return 0 } +# xrayebator profile-revoke --name NAME [--full] +# без --full: перевыпуск sub_token (старая URL подписки умирает, uuid жив); +# --full: + новый uuid во всех inbound'ах портов профиля — уже скачанные +# конфиги отваливаются немедленно (реальный ремонт утечки ссылки в чат). +profile_revoke_command() { + export TERM=dumb + export XRAYEBATOR_NONINTERACTIVE=1 + + local name="" full=false + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --full) full=true; shift ;; + *) shift ;; + esac + done + + if [[ -z "$name" ]] || ! [[ "$name" =~ ^[a-zA-Z0-9_-]{1,48}$ ]]; then + echo '{"ok":false,"error":"Некорректное имя профиля (1-48 симв.: a-z, 0-9, _ -)"}' + return 1 + fi + + local profile_file="$PROFILES_DIR/$name.json" + if [[ ! -f "$profile_file" ]]; then + echo "{\"ok\":false,\"error\":\"Профиль $name не найден\"}" + return 1 + fi + + local new_token + new_token=$(openssl rand -hex 16) + if [[ ! "$new_token" =~ ^[a-f0-9]{32}$ ]]; then + echo '{"ok":false,"error":"Не удалось сгенерировать sub_token"}' + return 1 + fi + + local old_uuid="" new_uuid="" ports_json="" + if [[ "$full" == "true" ]]; then + old_uuid=$(jq -r '.uuid // empty' "$profile_file") + if [[ -z "$old_uuid" ]]; then + echo '{"ok":false,"error":"Профиль повреждён: нет uuid"}' + return 1 + fi + new_uuid=$(uuidgen) + if [[ ! "$new_uuid" =~ ^[0-9a-fA-F-]{36}$ ]]; then + echo '{"ok":false,"error":"Не удалось сгенерировать uuid"}' + return 1 + fi + ports_json=$(_profile_ports_json "$profile_file") + if ! jq -e 'type == "array" and length > 0' <<< "$ports_json" >/dev/null 2>&1; then + echo '{"ok":false,"error":"Профиль повреждён: нет корректных портов"}' + return 1 + fi + # Полный отзыв должен выключать и уже истёкший по сроку профиль. + if _profile_is_expire_disabled "$profile_file"; then + echo '{"ok":false,"error":"Профиль отключён по сроку — сначала продлите срок (profile-expire)"}' + return 1 + fi + fi + + # CLI-режим: статусы helpers (backup_config, safe_restart_xray, safe_jq_write) + # печатают цветной мусор в stdout — душим в stderr (контракт JSON-only). + # Бэкап тоже внутри redirect: его "-> Бэкап конфига" иначе ломает JSON. + exec 3>&1 + exec 1>&2 + + local rollback_config="" + if ! backup_config "cli_revoke_$name" rollback_config; then + echo '{"ok":false,"error":"Не удалось создать бэкап config.json"}' >&3 + exec 1>&3 3>&- + return 1 + fi + + # Профиль-JSON: новый токен всегда; при --full ещё и uuid (top-level + routes). + # Порядок при full: сначала конфиг+рестарт, потом профиль; сбой записи профиля + # при full откатывает конфиг — иначе uuid разедутся. + local jq_args=(--arg t "$new_token") + local jq_expr='.sub_token = $t' + if [[ "$full" == "true" ]]; then + jq_args+=(--arg old "$old_uuid" --arg new "$new_uuid") + jq_expr+=' | .uuid = $new | (.routes // []) |= map(if .uuid? == $old then .uuid = $new else . end)' + # routes не хранят uuid (общий на профиль), но выражение безопасно для будущих схем. + + if ! _replace_uuid_in_config "$old_uuid" "$new_uuid" "$ports_json"; then + echo '{"ok":false,"error":"Не удалось обновить clients в config.json"}' >&3 + exec 1>&3 3>&- + return 1 + fi + if ! safe_restart_xray "$rollback_config"; then + echo '{"ok":false,"error":"Xray не принял конфиг — изменения откачены"}' >&3 + exec 1>&3 3>&- + return 1 + fi + fi + + if ! safe_jq_write "${jq_args[@]}" "$jq_expr" "$profile_file"; then + if [[ "$full" == "true" ]]; then + # Полусостояние недопустимо: конфиг уже с новым uuid, профиль — со старым. + # Откатываем config.json из бэкапа транзакции. + if [[ -n "$rollback_config" && -f "$rollback_config" ]]; then + cp "$rollback_config" "$CONFIG_FILE" + safe_restart_xray "" + fi + fi + echo '{"ok":false,"error":"Не удалось записать profile JSON"}' >&3 + exec 1>&3 3>&- + return 1 + fi + chown root:root "$profile_file" 2>/dev/null || true + + fix_xray_permissions + exec 1>&3 3>&- + + local sub_url="" base + base=$(_subscription_base_url 2>/dev/null || true) + if [[ -n "$base" ]] && _profile_cli_subscription_url "$new_token" >/dev/null 2>&1; then + sub_url="${base}/sub/${new_token}" + fi + jq -n \ + --arg name "$name" --arg token "$new_token" --arg url "$sub_url" \ + --argjson full "$full" --arg uuid "$new_uuid" \ + '{ok: true, name: $name, full: $full, sub_token: $token, + subscription_url: $url} + + (if $full then {uuid: $uuid} else {} end)' + return 0 +} + +# ═══════════════════════════════════════════════════════════ +# CLI сроков действия профилей (profile-expire / expire-check). +# Принуждение серверное: systemd-таймер раз в 10 мин вызывает expire-check, +# который удаляет клиента истёкшего профиля из inbound'ов (снимок в .expire_clients) +# и возвращает обратно при продлении. subhttp отдаёт .expire как subscription-userinfo. +# ═══════════════════════════════════════════════════════════ + +# xrayebator profile-expire --name NAME --expire VALUE|none +# VALUE: unix-секунды | unix-мс (13 цифр) | "ГГГГ-ММ-ДД" | "ГГГГ-ММ-ДД HH:MM". +# none — снять срок (вечный профиль). Смена статуса перезапускает xray. +profile_expire_command() { + export TERM=dumb + export XRAYEBATOR_NONINTERACTIVE=1 + + local name="" raw="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --expire) raw="$2"; shift 2 ;; + *) shift ;; + esac + done + + if [[ -z "$name" ]] || ! [[ "$name" =~ ^[a-zA-Z0-9_-]{1,48}$ ]]; then + echo '{"ok":false,"error":"Некорректное имя профиля (1-48 симв.: a-z, 0-9, _ -)"}' + return 1 + fi + if [[ -z "$raw" ]]; then + echo '{"ok":false,"error":"Укажите --expire ГГГГ-ММ-ДД|epoch|none"}' + return 1 + fi + + local expire=0 + if [[ "$raw" != "none" ]]; then + if ! expire=$(_normalize_expire_value "$raw"); then + echo '{"ok":false,"error":"Некорректный срок: нужен epoch (сек/мс) или дата 2006-12-31[ 23:59]"}' + return 1 + fi + fi + + local profile_file="$PROFILES_DIR/$name.json" + if [[ ! -f "$profile_file" ]]; then + echo "{\"ok\":false,\"error\":\"Профиль $name не найден\"}" + return 1 + fi + + local now action="" + now=$(date +%s) + if [[ "$expire" -gt 0 && "$expire" -le "$now" ]]; then + # Профиль уже помечен отключённым, но клиента снова нашли в конфиге + # (вмешательство руками) — бан нужно наложить заново. + if ! _profile_is_expire_disabled "$profile_file" || _profile_client_in_config "$profile_file"; then + action="disable" + fi + else + if _profile_is_expire_disabled "$profile_file"; then action="enable"; fi + fi + + # Тот же контракт JSON-only: backup_config печатает статус — только после switch. + exec 3>&1 + exec 1>&2 + + local rollback_config="" + if [[ -n "$action" ]] && ! backup_config "cli_expire_$name" rollback_config; then + echo '{"ok":false,"error":"Не удалось создать бэкап config.json"}' >&3 + exec 1>&3 3>&- + return 1 + fi + + if ! safe_jq_write --argjson e "$expire" \ + 'if $e == 0 then del(.expire) else .expire = $e end' "$profile_file"; then + echo "{\"ok\":false,\"error\":\"Не удалось записать expire в профиль\"}" >&3 + exec 1>&3 3>&- + return 1 + fi + chown root:root "$profile_file" 2>/dev/null || true + + local apply_rc=0 + if [[ "$action" == "disable" ]]; then + _expire_disable_profile "$profile_file" || apply_rc=1 + elif [[ "$action" == "enable" ]]; then + _expire_enable_profile "$profile_file" || apply_rc=1 + fi + + if [[ "$apply_rc" -ne 0 ]]; then + echo "{\"ok\":false,\"error\":\"Не удалось применить отключение/включение профиля\"}" >&3 + exec 1>&3 3>&- + return 1 + fi + + if [[ -n "$action" ]]; then + fix_xray_permissions + if ! safe_restart_xray "$rollback_config"; then + echo '{"ok":false,"error":"Xray не принял конфиг — изменения откачены"}' >&3 + exec 1>&3 3>&- + return 1 + fi + fi + exec 1>&3 3>&- + + local expired=false + [[ "$expire" -gt 0 && "$expire" -le "$now" ]] && expired=true + jq -n --arg name "$name" --argjson e "$expire" --argjson expired "$expired" \ + '{ok: true, name: $name, expire: $e, expired: $expired}' + return 0 +} + +# xrayebator expire-check — пакетное принуждение сроков (вызывается таймером +# xrayebator-expire.timer и вручную). Идемпотентен: без изменений — без рестарта. +expire_check_command() { + export TERM=dumb + export XRAYEBATOR_NONINTERACTIVE=1 + + local now + now=$(date +%s) + + # Проход 1: только классификация (никаких мутаций конфига до общего бэкапа). + local -a disable_files=() enable_files=() + local profile_file expire + for profile_file in "$PROFILES_DIR"/*.json; do + [[ -f "$profile_file" ]] || continue + expire=$(_profile_expire_epoch "$profile_file" 2>/dev/null) || continue + [[ "$expire" =~ ^[0-9]+$ ]] || continue + if [[ "$expire" -gt 0 && "$expire" -le "$now" ]]; then + # Кандидат на бан: не помечен ИЛИ помечен, но клиент снова в конфиге. + if ! _profile_is_expire_disabled "$profile_file" || _profile_client_in_config "$profile_file"; then + disable_files+=("$profile_file") + fi + elif _profile_is_expire_disabled "$profile_file"; then + enable_files+=("$profile_file") + fi + done + + if [[ ${#disable_files[@]} -eq 0 && ${#enable_files[@]} -eq 0 ]]; then + echo '{"ok":true,"disabled":[],"enabled":[]}' + return 0 + fi + + exec 3>&1 + exec 1>&2 + + local rollback_config="" + if ! backup_config "expire_check" rollback_config; then + echo '{"ok":false,"error":"Не удалось создать бэкап config.json"}' >&3 + exec 1>&3 3>&- + return 1 + fi + + local -a done_disable=() done_enable=() + for profile_file in "${disable_files[@]}"; do + if _expire_disable_profile "$profile_file"; then + done_disable+=("$(basename "$profile_file" .json)") + fi + done + for profile_file in "${enable_files[@]}"; do + if _expire_enable_profile "$profile_file"; then + done_enable+=("$(basename "$profile_file" .json)") + fi + done + + fix_xray_permissions + if ! safe_restart_xray "$rollback_config"; then + echo '{"ok":false,"error":"Xray не принял конфиг — изменения откачены"}' >&3 + exec 1>&3 3>&- + return 1 + fi + exec 1>&3 3>&- + + jq -n \ + --argjson d "$(printf '%s\n' "${done_disable[@]}" | jq -R . | jq -s 'map(select(length > 0))')" \ + --argjson e "$(printf '%s\n' "${done_enable[@]}" | jq -R . | jq -s 'map(select(length > 0))')" \ + '{ok: true, disabled: $d, enabled: $e}' + return 0 +} + +# systemd-таймер принуждения expire (2 unit'а, root-контекст: мутит config.json). +# Контракт no-op: таймер уже включён и ExecStart актуален → return 1 (no-op), +# установка прошла → return 0. Совпадает с контрактом run_migration. +_install_expire_timer() { + local unit=/etc/systemd/system/xrayebator-expire.service + local timer=/etc/systemd/system/xrayebator-expire.timer + + if [[ -f "$unit" && -f "$timer" ]] \ + && grep -q "ExecStart=/usr/local/bin/xrayebator expire-check" "$unit" \ + && systemctl is-enabled --quiet xrayebator-expire.timer 2>/dev/null; then + return 1 + fi + + cat > "$unit" << 'SVCEOF' +[Unit] +Description=Xrayebator: enforce profile expiry dates +After=network-online.target xray.service +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/xrayebator expire-check +IOSchedulingClass=idle +SVCEOF + chmod 644 "$unit" + chown root:root "$unit" || return 2 + + cat > "$timer" << 'TMREOF' +[Unit] +Description=Run Xrayebator profile-expiry enforcement every 10 minutes + +[Timer] +OnBootSec=2min +OnUnitActiveSec=10min +AccuracySec=1min + +[Install] +WantedBy=timers.target +TMREOF + chmod 644 "$timer" + chown root:root "$timer" || return 2 + + systemctl daemon-reload || return 2 + systemctl enable --now xrayebator-expire.timer >/dev/null 2>&1 || return 2 + return 0 +} + +# Контракт run_migration: 1 = no-op (маркер ставится БЕЗ рестарта xray — таймер +# не трогает config.json, ронять соединения из-за него нельзя); ≥2 = фалл (повтор). +_migrate_expire_timer_2026() { + _install_expire_timer + local rc=$? + [[ $rc -le 1 ]] && return 1 + return 2 +} + # Смена fingerprint профиля (клиентский TLS ClientHello, Xray не перезапускается). # Если профиль multi-route и --route не указан — профиль требует выбора маршрута. fp_change_command() { @@ -11341,6 +11953,22 @@ _bypass_cli_bundle() { return 1 } +# bypass groups — список готовых групп bundle (id, название, кол-во доменов) для GUI. +_bypass_cli_groups() { + local json + json=$(awk -F'|' 'NF >= 3 { + n = split($3, d, ",") + printf "%s\t%s\t%d\n", $1, $2, n + }' < <(_bypass_bundle_groups) | jq -R -s ' + split("\n") | map(select(length > 0)) | + map(split("\t") | {id: .[0], title: .[1], domains: (.[2] | tonumber)})') || { + echo '{"ok":false,"error":"Не удалось собрать список групп"}' + return 1 + } + jq -n --argjson groups "$json" '{ok: true, groups: $groups}' + return 0 +} + bypass_command() { export TERM=dumb export XRAYEBATOR_NONINTERACTIVE=1 @@ -11370,8 +11998,12 @@ bypass_command() { _bypass_cli_bundle "$@" return $? ;; + groups) + _bypass_cli_groups + return $? + ;; *) - echo '{"ok":false,"error":"Использование: bypass list | bypass add --domain D | bypass remove --domain D | bypass reset | bypass bundle [--group a,b,c]"}' + echo '{"ok":false,"error":"Использование: bypass list | groups | add --domain D | remove --domain D | reset | bundle [--group a,b,c]"}' return 1 ;; esac @@ -11924,6 +12556,18 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then shift profile_delete_command "$@" ;; + profile-revoke) + shift + profile_revoke_command "$@" + ;; + profile-expire) + shift + profile_expire_command "$@" + ;; + expire-check) + shift + expire_check_command "$@" + ;; fp-change) shift fp_change_command "$@" @@ -11959,11 +12603,15 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then echo -e " ${CYAN}sudo xrayebator happ-setup${NC} — создать/переиспользовать HAPP multi-route профиль" echo -e " ${CYAN}sudo xrayebator probe-test${NC} — проверить доступность SNI с VPS" echo -e " ${CYAN}sudo xrayebator profiles${NC} — JSON-список профилей" - echo -e " ${CYAN}sudo xrayebator profile-create --name N [--transport T] [--port P] [--count N]${NC}" + echo -e " ${CYAN}sudo xrayebator profile-create --name N [--transport T] [--port P] [--count N] [--expire 2006-12-31]${NC}" echo -e " — создать профиль/семейство профилей (JSON)" echo -e " ${CYAN}sudo xrayebator profile-delete --name N${NC} — удалить профиль (JSON)" echo -e " ${CYAN}sudo xrayebator fp-change --name N [--route R] --fp FINGERPRINT${NC} — сменить fingerprint (JSON)" + echo -e " ${CYAN}sudo xrayebator profile-revoke --name N [--full]${NC} — новый sub_token; --full: и новый uuid, старые клиенты отваливаются (JSON)" + echo -e " ${CYAN}sudo xrayebator profile-expire --name N --expire 2006-12-31|epoch|none${NC} — срок действия профиля (JSON)" + echo -e " ${CYAN}sudo xrayebator expire-check${NC} — применить наступившие сроки (для systemd-таймера) (JSON)" echo -e " ${CYAN}sudo xrayebator bypass list${NC} — список доменов в обходе (JSON)" + echo -e " ${CYAN}sudo xrayebator bypass groups${NC} — готовые группы обхода (JSON)" echo -e " ${CYAN}sudo xrayebator bypass add --domain D${NC} — добавить домен в обход (JSON)" echo -e " ${CYAN}sudo xrayebator bypass remove --domain D${NC} — убрать домен из обхода (JSON)" echo -e " ${CYAN}sudo xrayebator bypass reset${NC} — сбросить все bypass-правила (JSON)" From df78876c1461460a896f47b3a95390511636cf6b Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:08:01 +0300 Subject: [PATCH 11/29] =?UTF-8?q?CLI:=20bypass=20groups=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B4=D0=B0=D1=91=D1=82=20=D0=B4=D0=BE=D0=BC=D0=B5=D0=BD=D1=8B?= =?UTF-8?q?,=20+=20bypass=20unbundle=20(=D0=B2=D1=8B=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit groups раньше возвращал только счётчик доменов — GUI-чекбоксам нужен состав группы, чтобы считать покрытие («включена/частично/выключена») без второго запроса. Добавлен unbundle: снятие группы одной мутацией и одним рестартом Xray (штучный remove --domain = N рестартов на группу из ~60 доменов, для интерактивного чекбокса неприемлемо). Оба — JSON-only: backup_config печатает статус только после exec 3>&1 switch. (cherry picked from commit e5b8d3cd9b5321367d562984b9b48f32eec1838b) --- validation/test-profile-revoke-expire-cli.sh | 22 +++- xrayebator | 102 ++++++++++++++++++- 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/validation/test-profile-revoke-expire-cli.sh b/validation/test-profile-revoke-expire-cli.sh index 654c96c..978683f 100644 --- a/validation/test-profile-revoke-expire-cli.sh +++ b/validation/test-profile-revoke-expire-cli.sh @@ -210,14 +210,30 @@ jq -e '.ok == false' <<< "$out" >/dev/null || fail "revoke bad name: $out" if out=$(profile_revoke_command --name nosuch); then fail "revoke missing profile accepted"; fi jq -e '.ok == false' <<< "$out" >/dev/null || fail "revoke missing profile: $out" -# --- 12. bypass groups: JSON с id/title/domains --- +# --- 12. bypass groups: JSON с id/title/domains[] --- out=$(_bypass_cli_groups) || fail "bypass groups failed: $out" jq -e '.ok == true and (.groups | length >= 7) and all(.groups[]; (.id | type == "string") and (.title | type == "string") and - (.domains | type == "number" and . > 0)) and - any(.groups[]; .id == "banks")' <<< "$out" >/dev/null || + (.domains | type == "array" and length > 0 and all(.[]; type == "string"))) and + any(.groups[]; .id == "banks" and (.domains | index("sberbank.ru") != null))' <<< "$out" >/dev/null || fail "bypass groups bad payload: $out" +# --- 12b. bypass unbundle: снимает только домены выбранных групп --- +out=$(_bypass_cli_bundle --group steam) || fail "bundle steam for unbundle test failed: $out" +out=$(_bypass_cli_bundle --group banks) || fail "bundle banks failed: $out" +out=$(_bypass_cli_add --domain mycustom.example) || fail "add custom failed: $out" +out=$(_bypass_cli_unbundle --group banks) || fail "unbundle banks failed: $out" +jq -e '.ok == true and .removed > 0' <<< "$out" >/dev/null || fail "unbundle bad JSON: $out" +jq -e ' + all(.routing.rules[] | (.domain // [])[]; . != "domain:sberbank.ru") and + any(.routing.rules[] | (.domain // [])[]; . == "domain:steamcontent.com") and + any(.routing.rules[] | (.domain // [])[]; . == "domain:mycustom.example") +' "$CONFIG_FILE" >/dev/null || fail "unbundle removed too much/too little" +if out=$(_bypass_cli_unbundle); then fail "unbundle without --group accepted"; fi +jq -e '.ok == false' <<< "$out" >/dev/null || fail "unbundle no-group: $out" +if out=$(_bypass_cli_unbundle --group nosuch); then fail "unbundle unknown group accepted"; fi +jq -e '.ok == false' <<< "$out" >/dev/null || fail "unbundle unknown group: $out" + # --- 13. profile-create --expire: поле попадает в профиль (add_inbound застабан) --- add_inbound() { return 0; } build_transport_defaults() { echo "34999|svc|/xp"; } diff --git a/xrayebator b/xrayebator index a9d4431..f2c7e57 100755 --- a/xrayebator +++ b/xrayebator @@ -11969,6 +11969,101 @@ _bypass_cli_groups() { return 0 } +# bypass groups — список готовых групп bundle (id, название, домены) для GUI. +_bypass_cli_groups() { + local json + json=$(awk -F'|' 'NF >= 3 { + printf "%s\t%s\t%s\n", $1, $2, $3 + }' < <(_bypass_bundle_groups) | jq -R -s ' + split("\n") | map(select(length > 0)) | + map(split("\t") | {id: .[0], title: .[1], + domains: (.[2] | split(",") | map(select(length > 0)))})') || { + echo '{"ok":false,"error":"Не удалось собрать список групп"}' + return 1 + } + jq -n --argjson groups "$json" '{ok: true, groups: $groups}' + return 0 +} + +# bypass unbundle --group a,b — удалить домены выбранных групп из обхода. +# Одна мутация/рестарт на команду (штучный remove = N рестартов — неприемлемо). +_bypass_cli_unbundle() { + local groups_csv="" + while [[ $# -gt 0 ]]; do + case "$1" in + --group) groups_csv="$2"; shift 2 ;; + *) shift ;; + esac + done + if [[ -z "$groups_csv" ]]; then + echo '{"ok":false,"error":"Укажите --group a,b,c — unbundle без списка опасен, используйте reset"}' + return 1 + fi + + local doms_all="" gid gname d g + local -a wanted=() + IFS=',' read -r -a wanted <<< "$groups_csv" + for g in "${wanted[@]}"; do + g="${g// /}" + [[ -z "$g" ]] && continue + local found=false + while IFS='|' read -r gid gname d; do + [[ -z "$gid" ]] && continue + if [[ "$gid" == "$g" ]]; then + found=true + doms_all="${doms_all}${doms_all:+,}${d}" + fi + done < <(_bypass_bundle_groups) + if [[ "$found" == "false" ]]; then + echo "{\"ok\":false,\"error\":\"Неизвестная группа: $g\"}" + return 1 + fi + done + [[ -n "$doms_all" ]] || { echo '{"ok":false,"error":"Нет доменов"}'; return 1; } + + local remove_list + remove_list=$(printf '%s' "$doms_all" | tr ',' '\n' | sed '/^$/d' | jq -R '"domain:" + .' | jq -sc 'unique') || { + echo '{"ok":false,"error":"Не удалось собрать список удаления"}' + return 1 + } + + local rollback_config="" + # Контракт JSON-only: backup_config печатает статус — только после switch. + exec 3>&1 + exec 1>&2 + if ! backup_config "bypass_unbundle" rollback_config; then + echo '{"ok":false,"error":"Не удалось создать бэкап config.json"}' >&3 + exec 1>&3 3>&- + return 1 + fi + if ! safe_jq_write --argjson remove "$remove_list" ' + .routing.rules = [ + .routing.rules[] | + if (.outboundTag == "direct" and ((.domain // []) | length) > 0) then + .domain = ((.domain // []) - $remove) + | (if (.domain | length == 0) then empty else . end) + else + . + end + ]' "$CONFIG_FILE"; then + echo '{"ok":false,"error":"Не удалось удалить домены групп"}' >&3 + exec 1>&3 3>&- + return 1 + fi + fix_xray_permissions + if ! safe_restart_xray "$rollback_config"; then + echo '{"ok":false,"error":"Xray не принял конфиг — изменения откачены"}' >&3 + exec 1>&3 3>&- + return 1 + fi + exec 1>&3 3>&- + + local count + count=$(printf '%s' "$remove_list" | jq 'length') + jq -n --argjson c "$count" '{ok: true, removed: $c}' + return 0 +} + bypass_command() { export TERM=dumb export XRAYEBATOR_NONINTERACTIVE=1 @@ -11998,12 +12093,17 @@ bypass_command() { _bypass_cli_bundle "$@" return $? ;; + unbundle) + shift + _bypass_cli_unbundle "$@" + return $? + ;; groups) _bypass_cli_groups return $? ;; *) - echo '{"ok":false,"error":"Использование: bypass list | groups | add --domain D | remove --domain D | reset | bundle [--group a,b,c]"}' + echo '{"ok":false,"error":"Использование: bypass list | groups | add --domain D | remove --domain D | reset | bundle [--group a,b,c] | unbundle --group a,b,c"}' return 1 ;; esac From ad0d0faf4686bce5ac802a8b24e412505e65e5b5 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:08:06 +0300 Subject: [PATCH 12/29] =?UTF-8?q?GUI:=20revoke=20=D0=BF=D0=BE=D0=B4=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=B8,=20=D1=81=D1=80=D0=BE=D0=BA=20=D0=B4?= =?UTF-8?q?=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D1=8F=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D0=B5=D0=B9,=20bypass-=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=BF=D0=BF=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revoke: кнопка у профиля открывает центральную плашку на блюренной подложке (AlertDialog.Backdrop + .blurBackdrop; существующие диалоги не тронуты) с двумя режимами — «только новая ссылка» (токен, старые конфиги живут) и «полный отзыв» (uuid во всех маршрутах, устройства отваливаются, второй шаг-подтверждение); после успеха показывает новую ссылку и перечитывает список профилей. Срок: чип «до ДД.ММ.ГГГГ»/«срок истёк» в карточке, диалог с пресетами +7/30/90/365 и «снять срок», поле срока при создании профиля. Вся арифметика вынесена в src/shared/expire.ts (describeExpire/isFutureDate/presetDate) — покрыта юнит-тестами, включая приоритет серверного флага expire_disabled над локальными часами. Bypass: отдельная секция (BypassSection) — ленивая загрузка списка, свои домены add/remove, reset с подтверждением, чекбоксы групп с состояниями вкл/частично/выкл через bundle/unbundle. Слои: BypassManager (SSH-обёртка над bypass CLI), IPC-каналы profiles:revoke/ setExpire + bypass:*, preload, типы, i18n ru/en/zh. typecheck чистый, vitest 89/90 (единственный красный — предсуществующий shell-command: spawnSync('/bin/sh') недоступен под Windows-нодой, падает и на чистом дереве). (cherry picked from commit 4d090479bda6de96cb34d4fea06b873bb8ccc672) --- src/main/core/bypass.ts | 103 +++++ src/main/core/profiles.ts | 74 +++- src/main/ipc-handlers.ts | 69 ++++ src/preload/index.ts | 54 ++- src/renderer/src/components/BypassSection.tsx | 317 +++++++++++++++ src/renderer/src/i18n/en.json | 65 +++- src/renderer/src/i18n/ru.json | 65 +++- src/renderer/src/i18n/zh.json | 65 +++- .../src/pages/ServerSettings.module.css | 176 ++++++++- src/renderer/src/pages/ServerSettings.tsx | 365 +++++++++++++++++- src/shared/expire.ts | 52 +++ src/shared/types.ts | 98 +++++ tests/unit/expire.test.ts | 45 +++ 13 files changed, 1539 insertions(+), 9 deletions(-) create mode 100644 src/main/core/bypass.ts create mode 100644 src/renderer/src/components/BypassSection.tsx create mode 100644 src/shared/expire.ts create mode 100644 tests/unit/expire.test.ts diff --git a/src/main/core/bypass.ts b/src/main/core/bypass.ts new file mode 100644 index 0000000..86e8fe0 --- /dev/null +++ b/src/main/core/bypass.ts @@ -0,0 +1,103 @@ +import { SshClient, SshCredentials } from './ssh-client' +import { shellCommand } from './shell-command' +import { extractJson } from './profiles' +import type { BypassActionResult, BypassGroupsResult, BypassStateResult } from '@shared/types' + +/** + * Обёртка над `xrayebator bypass …` (list|groups|add|remove|reset|bundle). + * CLI печатает только JSON на stdout; статусы helpers уходят в stderr на сервере, + * extractJson дополнительно вычищает ANSI на случай старых версий менеджера. + */ +export class BypassManager { + constructor(private readonly creds: SshCredentials) {} + + private async run(args: readonly string[]): Promise { + const client = new SshClient(this.creds) + try { + await client.connect() + const res = await client.exec(shellCommand('xrayebator', args), { elevated: true }) + if (res.code !== 0) { + throw new Error(`xrayebator bypass ${args[1] ?? ''} → код ${res.code}: ${res.stderr.trim()}`) + } + return res.stdout + } finally { + client.close() + } + } + + private async action(args: readonly string[]): Promise { + try { + const payload = extractJson(await this.run(args)) as { + ok?: boolean + domain?: string + duplicate?: boolean + groups?: string[] + domains?: number + error?: string + } + return { + ok: payload.ok === true, + domain: payload.domain, + duplicate: payload.duplicate, + groups: payload.groups, + domains: payload.domains, + error: payload.error + } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } + } + + async list(): Promise { + try { + const payload = extractJson(await this.run(['bypass', 'list'])) as { + ok?: boolean + domains?: string[] + error?: string + } + // CLI отдаёт домены с префиксом "domain:" — для UI он шум. + const domains = (payload.domains ?? []).map((d) => d.replace(/^domain:/, '')) + return { ok: payload.ok === true, domains, error: payload.error } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } + } + + async groups(): Promise { + try { + const payload = extractJson(await this.run(['bypass', 'groups'])) as { + ok?: boolean + groups?: { id: string; title: string; domains?: string[] }[] + error?: string + } + const groups = (payload.groups ?? []).map((g) => ({ + id: g.id, + title: g.title, + domains: (g.domains ?? []).map((d) => d.replace(/^domain:/, '')) + })) + return { ok: payload.ok === true, groups, error: payload.error } + } catch (err) { + return { ok: false, groups: [], error: err instanceof Error ? err.message : String(err) } + } + } + + add(domain: string): Promise { + return this.action(['bypass', 'add', '--domain', domain]) + } + + remove(domain: string): Promise { + return this.action(['bypass', 'remove', '--domain', domain]) + } + + reset(): Promise { + return this.action(['bypass', 'reset']) + } + + bundle(groups: readonly string[]): Promise { + return this.action(['bypass', 'bundle', '--group', groups.join(',')]) + } + + unbundle(groups: readonly string[]): Promise { + return this.action(['bypass', 'unbundle', '--group', groups.join(',')]) + } +} diff --git a/src/main/core/profiles.ts b/src/main/core/profiles.ts index fdb71fa..429d1df 100644 --- a/src/main/core/profiles.ts +++ b/src/main/core/profiles.ts @@ -1,6 +1,10 @@ import { SshClient, SshCredentials } from './ssh-client' import { shellCommand } from './shell-command' -import type { ServerProfile } from '@shared/types' +import type { + ProfileExpireResult, + ProfileRevokeResult, + ServerProfile +} from '@shared/types' export interface ProfileListOutput { ok: boolean @@ -139,12 +143,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 +313,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/ipc-handlers.ts b/src/main/ipc-handlers.ts index 3e3115a..7be168f 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, @@ -18,6 +20,7 @@ import type { ServerConnectionMetadata, ServerStore } from './core/servers' import { Deployer } from './core/deployer' import { fetchSubscription } from './core/subscription' import { ProfileManager } from './core/profiles' +import { BypassManager } from './core/bypass' import { ServerManager } from './core/server-manager' import { ServerInspector } from './core/server-inspector' import { probePortsFor } from './core/probe-ports' @@ -443,6 +446,72 @@ 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 bypassManagerFor = async ( + serverId: string, + access: SshAccessInput + ): Promise => { + const server = store.get(serverId) + if (!server) throw new Error('Сервер не найден') + const { credentials } = await credentialsFor(server, server, access) + return new BypassManager(credentials) + } + + ipcMain.handle('bypass:list', async (_e, serverId: string, access: SshAccessInput) => { + return (await bypassManagerFor(serverId, access)).list() + }) + + ipcMain.handle('bypass:groups', async (_e, serverId: string, access: SshAccessInput) => { + return (await bypassManagerFor(serverId, access)).groups() + }) + + ipcMain.handle( + 'bypass:add', + async (_e, serverId: string, access: SshAccessInput, domain: string) => { + return (await bypassManagerFor(serverId, access)).add(domain) + } + ) + + ipcMain.handle( + 'bypass:remove', + async (_e, serverId: string, access: SshAccessInput, domain: string) => { + return (await bypassManagerFor(serverId, access)).remove(domain) + } + ) + + ipcMain.handle('bypass:reset', async (_e, serverId: string, access: SshAccessInput) => { + return (await bypassManagerFor(serverId, access)).reset() + }) + + ipcMain.handle( + 'bypass:bundle', + async (_e, serverId: string, access: SshAccessInput, groups: string[]) => { + return (await bypassManagerFor(serverId, access)).bundle(groups) + } + ) + + ipcMain.handle( + 'bypass:unbundle', + async (_e, serverId: string, access: SshAccessInput, groups: string[]) => { + return (await bypassManagerFor(serverId, access)).unbundle(groups) + } + ) + const serverManagerFor = async ( serverId: string, access: SshAccessInput diff --git a/src/preload/index.ts b/src/preload/index.ts index 5eb7029..35eb1c3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,13 +10,20 @@ import type { ProfileCreateInput, ProfileCreateResult, ProfileDeleteResult, + ProfileExpireInput, + ProfileExpireResult, ProfileFingerprintInput, ProfileFingerprintResult, ProfilePortInput, ProfilePortResult, + ProfileRevokeInput, + ProfileRevokeResult, ProfileSniInput, ProfileSniResult, SniListResult, + BypassActionResult, + BypassGroupsResult, + BypassStateResult, Server, ServerMaintenanceResult, ServerProfile, @@ -101,7 +108,52 @@ 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) + }, + + bypass: { + list: (serverId: string, access: SshAccessInput): Promise => + ipcRenderer.invoke('bypass:list', serverId, access), + groups: (serverId: string, access: SshAccessInput): Promise => + ipcRenderer.invoke('bypass:groups', serverId, access), + add: ( + serverId: string, + access: SshAccessInput, + domain: string + ): Promise => + ipcRenderer.invoke('bypass:add', serverId, access, domain), + remove: ( + serverId: string, + access: SshAccessInput, + domain: string + ): Promise => + ipcRenderer.invoke('bypass:remove', serverId, access, domain), + reset: (serverId: string, access: SshAccessInput): Promise => + ipcRenderer.invoke('bypass:reset', serverId, access), + bundle: ( + serverId: string, + access: SshAccessInput, + groups: string[] + ): Promise => + ipcRenderer.invoke('bypass:bundle', serverId, access, groups), + unbundle: ( + serverId: string, + access: SshAccessInput, + groups: string[] + ): Promise => + ipcRenderer.invoke('bypass:unbundle', serverId, access, groups) }, server: { diff --git a/src/renderer/src/components/BypassSection.tsx b/src/renderer/src/components/BypassSection.tsx new file mode 100644 index 0000000..5cd21b6 --- /dev/null +++ b/src/renderer/src/components/BypassSection.tsx @@ -0,0 +1,317 @@ +import { useEffect, useMemo, useState } from 'react' +import { AlertDialog, Button, Chip, Input, Label, Spinner, TextField } from '@heroui/react' +import { Globe2, Plus, RotateCcw, X } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import type { BypassGroupInfo, SshAccessInput } from '@shared/types' +import styles from '../pages/ServerSettings.module.css' + +interface BypassSectionProps { + serverId: string + access: SshAccessInput + disabled: boolean + onError: (message: string) => void + onToast: (message: string) => void +} + +const DOMAIN_RE = /^[a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}$/ + +/** + * Bypass-группы (RU-банки/стриминг напрямую): список доменов в обходе + add/remove/ + * reset + готовые группы сервера (_bypass_bundle_groups) чекбоксами. + * Данные подтягиваются лениво по кнопке — лишний SSH-коннект на каждое открытие + * страницы профилей не нужен. + */ +export function BypassSection({ + serverId, + access, + disabled, + onError, + onToast +}: BypassSectionProps): React.JSX.Element { + const { t } = useTranslation() + const [loaded, setLoaded] = useState(false) + const [loading, setLoading] = useState(false) + const [busy, setBusy] = useState(false) + const [domains, setDomains] = useState([]) + const [groups, setGroups] = useState([]) + const [draft, setDraft] = useState('') + const [confirmReset, setConfirmReset] = useState(false) + + const load = async (): Promise => { + setLoading(true) + onError('') + try { + const [state, groupList] = await Promise.all([ + window.api.bypass.list(serverId, access), + window.api.bypass.groups(serverId, access) + ]) + if (!state.ok) throw new Error(state.error ?? t('settings.bypassLoadFailed')) + setDomains(state.domains ?? []) + if (groupList.ok) { + setGroups(groupList.groups ?? []) + } else { + setGroups([]) + onError(groupList.error ?? t('settings.bypassLoadFailed')) + } + setLoaded(true) + } catch (err) { + onError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) + } + } + + // Смена сервера сбрасывает состояние секции (иначе чужой список доменов). + useEffect(() => { + setLoaded(false) + setDomains([]) + setGroups([]) + setDraft('') + }, [serverId]) + + const domainSet = useMemo(() => new Set(domains), [domains]) + + const refresh = async (): Promise => { + const state = await window.api.bypass.list(serverId, access) + if (state.ok) setDomains(state.domains ?? []) + } + + const add = async (): Promise => { + const domain = draft.trim().replace(/^domain:/, '') + if (!DOMAIN_RE.test(domain)) { + onError(t('settings.bypassInvalidDomain')) + return + } + setBusy(true) + onError('') + try { + const result = await window.api.bypass.add(serverId, access, domain) + if (result.ok) { + toastIfNew(domain, result.duplicate === true) + setDraft('') + await refresh() + } else { + onError(result.error ?? t('settings.bypassAddFailed')) + } + } catch (err) { + onError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + const toastIfNew = (domain: string, duplicate: boolean): void => { + onToast( + duplicate + ? t('settings.bypassDuplicate', { domain }) + : t('settings.bypassAdded', { domain }) + ) + } + + const remove = async (domain: string): Promise => { + setBusy(true) + onError('') + try { + const result = await window.api.bypass.remove(serverId, access, domain) + if (result.ok) { + onToast(t('settings.bypassRemoved', { domain })) + await refresh() + } else { + onError(result.error ?? t('settings.bypassRemoveFailed')) + } + } catch (err) { + onError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + const resetAll = async (): Promise => { + setConfirmReset(false) + setBusy(true) + onError('') + try { + const result = await window.api.bypass.reset(serverId, access) + if (result.ok) { + onToast(t('settings.bypassResetDone')) + await refresh() + } else { + onError(result.error ?? t('settings.bypassResetFailed')) + } + } catch (err) { + onError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + const toggleGroup = async (group: BypassGroupInfo): Promise => { + const covered = group.domains.every((d) => domainSet.has(d)) + setBusy(true) + onError('') + try { + const result = covered + ? await window.api.bypass.unbundle(serverId, access, [group.id]) + : await window.api.bypass.bundle(serverId, access, [group.id]) + if (result.ok) { + onToast( + covered + ? t('settings.bypassGroupOff', { title: group.title }) + : t('settings.bypassGroupOn', { title: group.title }) + ) + await refresh() + } else { + onError(result.error ?? t('settings.bypassGroupFailed')) + } + } catch (err) { + onError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + return ( +
+

+ + {t('settings.bypassTitle')} +

+

{t('settings.bypassHint')}

+ + {!loaded ? ( +
+ +
+ ) : ( +
+ {groups.length > 0 && ( +
+ {t('settings.bypassGroups')} +
+ {groups.map((group) => { + const covered = group.domains.every((d) => domainSet.has(d)) + const partial = !covered && group.domains.some((d) => domainSet.has(d)) + const label = covered + ? t('settings.bypassGroupStateOn') + : partial + ? t('settings.bypassGroupStatePartial') + : t('settings.bypassGroupStateOff') + return ( + + ) + })} +
+
+ )} + +
+ + + setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void add() + }} + /> + + + + + {t('settings.bypassCount', { count: domains.length })} + +
+ + {domains.length === 0 ? ( +

{t('settings.bypassEmpty')}

+ ) : ( +
+ {domains.map((domain) => ( + + {domain} + + + ))} +
+ )} +
+ )} + + { + if (!open && !busy) setConfirmReset(false) + }} + > + + + + + + + + {t('settings.bypassResetTitle')} + + {t('settings.bypassResetBody')} + + + + + + + + +
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index e1f6a32..d677d29 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -235,6 +235,69 @@ "yandex_cdn": "Yandex CDN", "foreign": "Foreign (may not always pass)", "fallback": "Fallback (often probed)" - } + }, + "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", + "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", + "expireHintExpired": "Expired — the server disabled this profile. Extend the date to restore access.", + "createExpire": "Expiry date", + "bypassTitle": "Bypass routing", + "bypassHint": "Listed domains go direct, skipping the tunnel: Russian banks, government services and streaming do not block foreign IPs. Ready groups are toggles; add your own domain manually.", + "bypassLoad": "Load list", + "bypassLoading": "Loading…", + "bypassLoadFailed": "Failed to load bypass rules", + "bypassGroups": "Ready groups", + "bypassGroupStateOn": "on", + "bypassGroupStatePartial": "partial", + "bypassGroupStateOff": "off", + "bypassAddLabel": "Custom domain", + "bypassAdd": "Add", + "bypassAddFailed": "Failed to add the domain", + "bypassAdded": "Domain {{domain}} added to bypass", + "bypassDuplicate": "Domain {{domain}} was already bypassed", + "bypassInvalidDomain": "Invalid domain: expected something like example.ru", + "bypassReset": "Reset all", + "bypassResetTitle": "Remove all bypass rules?", + "bypassResetBody": "Only domains added to the bypass list are removed. The server's base routing rules (ads and private subnets blocking) stay intact.", + "bypassResetDone": "All bypass rules removed", + "bypassResetFailed": "Failed to reset bypass rules", + "bypassCount": "domains: {{count}}", + "bypassEmpty": "The list is empty: all traffic goes through the tunnel.", + "bypassRemoveTitle": "Remove from bypass", + "bypassRemoved": "Domain {{domain}} removed from bypass", + "bypassRemoveFailed": "Failed to remove the domain", + "bypassGroupOn": "Group «{{title}}» enabled", + "bypassGroupOff": "Group «{{title}}» disabled", + "bypassGroupFailed": "Failed to toggle the group" } } diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index ea1a3ec..c25b403 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -236,6 +236,69 @@ "yandex_cdn": "Yandex CDN", "foreign": "Иностранные (не всегда проходят)", "fallback": "Запасные (часто под зондированием)" - } + }, + "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": "Снять срок", + "expireChanged": "Срок «{{name}}» — до {{date}}", + "expireCleared": "Срок «{{name}}» снят: профиль бессрочный", + "expireFailed": "Не удалось сохранить срок", + "expireEnforced": "Проверка выполняется на сервере каждые 10 минут.", + "expireUntil": "до {{date}}", + "expireExpired": "срок истёк", + "expireHint": "Профиль отключится автоматически в эту дату", + "expireHintExpired": "Срок истёк — профиль отключён сервером. Продлите срок, чтобы вернуть доступ.", + "createExpire": "Срок действия", + "bypassTitle": "Обход VPN (Bypass)", + "bypassHint": "Домены из списка идут напрямую, минуя туннель: российские банки, госуслуги и стриминг не блокируют зарубежные IP. Готовые группы — чекбоксами, свой домен — вручную.", + "bypassLoad": "Загрузить список", + "bypassLoading": "Загружаю…", + "bypassLoadFailed": "Не удалось получить bypass-правила", + "bypassGroups": "Готовые группы", + "bypassGroupStateOn": "включена", + "bypassGroupStatePartial": "частично", + "bypassGroupStateOff": "выключена", + "bypassAddLabel": "Свой домен", + "bypassAdd": "Добавить", + "bypassAddFailed": "Не удалось добавить домен", + "bypassAdded": "Домен {{domain}} добавлен в обход", + "bypassDuplicate": "Домен {{domain}} уже был в обходе", + "bypassInvalidDomain": "Некорректный домен: нужен вид example.ru", + "bypassReset": "Сбросить всё", + "bypassResetTitle": "Удалить все bypass-правила?", + "bypassResetBody": "Будут удалены только домены, добавленные в обход. Базовые правила маршрутизации сервера (блокировка рекламы, приватных подсетей) останутся.", + "bypassResetDone": "Все bypass-правила удалены", + "bypassResetFailed": "Не удалось сбросить bypass-правила", + "bypassCount": "доменов: {{count}}", + "bypassEmpty": "Список пуст: весь трафик идёт через туннель.", + "bypassRemoveTitle": "Убрать из обхода", + "bypassRemoved": "Домен {{domain}} убран из обхода", + "bypassRemoveFailed": "Не удалось убрать домен", + "bypassGroupOn": "Группа «{{title}}» включена", + "bypassGroupOff": "Группа «{{title}}» выключена", + "bypassGroupFailed": "Не удалось переключить группу" } } diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index e4c80d8..297d3fb 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -234,6 +234,69 @@ "yandex_cdn": "Yandex CDN", "foreign": "国外(不一定总是可用)", "fallback": "备用(常被探测)" - } + }, + "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": "取消有效期", + "expireChanged": "「{{name}}」的有效期至 {{date}}", + "expireCleared": "已取消「{{name}}」的有效期:配置永久有效", + "expireFailed": "无法保存有效期", + "expireEnforced": "服务器每 10 分钟检查一次有效期。", + "expireUntil": "至 {{date}}", + "expireExpired": "已过期", + "expireHint": "该配置将在这一天自动停用", + "expireHintExpired": "已过期——服务器已停用该配置。延长有效期即可恢复访问。", + "createExpire": "有效期", + "bypassTitle": "绕过 VPN(分流)", + "bypassHint": "列表中的域名直连、不走隧道:俄罗斯银行、政务服务和流媒体不会封锁境外 IP。内置分组用开关切换,自有域名手动添加。", + "bypassLoad": "加载列表", + "bypassLoading": "正在加载…", + "bypassLoadFailed": "无法获取分流规则", + "bypassGroups": "内置分组", + "bypassGroupStateOn": "已启用", + "bypassGroupStatePartial": "部分启用", + "bypassGroupStateOff": "已关闭", + "bypassAddLabel": "自定义域名", + "bypassAdd": "添加", + "bypassAddFailed": "无法添加域名", + "bypassAdded": "域名 {{domain}} 已加入直连", + "bypassDuplicate": "域名 {{domain}} 已在直连列表中", + "bypassInvalidDomain": "域名无效:应为 example.ru 这样的形式", + "bypassReset": "全部重置", + "bypassResetTitle": "删除全部分流规则?", + "bypassResetBody": "只会删除加入直连的域名,服务器的基础路由规则(广告与私有网段拦截)会保留。", + "bypassResetDone": "已删除全部分流规则", + "bypassResetFailed": "无法重置分流规则", + "bypassCount": "域名数:{{count}}", + "bypassEmpty": "列表为空:所有流量都经过隧道。", + "bypassRemoveTitle": "从直连中移除", + "bypassRemoved": "域名 {{domain}} 已从直连中移除", + "bypassRemoveFailed": "无法移除域名", + "bypassGroupOn": "分组「{{title}}」已启用", + "bypassGroupOff": "分组「{{title}}」已关闭", + "bypassGroupFailed": "无法切换分组" } } diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index ce6638a..950826c 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -472,14 +472,188 @@ .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; +} + +.bypassSection { + display: flex; + flex-direction: column; + gap: 14px; +} + +.bypassGroups { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 8px; +} + +.bypassGroupCard { + display: flex; + flex-direction: column; + gap: 2px; + text-align: left; + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; + cursor: pointer; + color: var(--text-1); + transition: border-color 0.15s ease; +} + +.bypassGroupCard:hover:not(:disabled) { + border-color: var(--accent); +} + +.bypassGroupCardActive { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, var(--bg-1)); +} + +.bypassGroupTitle { + font-size: 13px; + font-weight: 600; + color: var(--text-0); +} + +.bypassGroupCount { + font-size: 11px; + color: var(--text-2); +} + +.bypassDomainList { + display: flex; + flex-wrap: wrap; + gap: 6px; + max-height: 180px; + overflow-y: auto; +} + +.bypassDomainItem { + display: inline-flex; + align-items: center; + gap: 4px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-1); + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: 8px; + padding: 3px 6px 3px 8px; +} + +.bypassDomainRemove { + display: inline-flex; + align-items: center; + background: none; + border: none; + color: var(--text-2); + cursor: pointer; + padding: 2px; + border-radius: 4px; +} + +.bypassDomainRemove:hover { + color: var(--danger); +} + +.bypassRow { + display: flex; + gap: 8px; + align-items: flex-end; + flex-wrap: wrap; +} + +.bypassEmpty { + font-size: 12px; + color: var(--text-2); +} + .fpHint { font-size: 13px; line-height: 1.5; diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index 9404ad0..b748acd 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -12,11 +12,16 @@ 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 { isSshAccessReady, SshAccessForm } from '../components/SshAccessForm' +import { BypassSection } from '../components/BypassSection' import { shouldAutoConnectServer } from './server-access' import styles from './ServerSettings.module.css' @@ -89,6 +94,7 @@ export function ServerSettings({ const [transport, setTransport] = useState('xhttp') const [count, setCount] = useState('1') const [creating, setCreating] = useState(false) + const [createExpire, setCreateExpire] = useState('') const [updating, setUpdating] = useState(false) const [uninstalling, setUninstalling] = useState(false) @@ -115,6 +121,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 +204,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 +374,98 @@ 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()).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 info = describeExpire(profile.expire ?? 0, profile.expire_disabled === true, Date.now()) + if (info.status === 'none') return null + const danger = info.status === 'expired' + return ( + + {danger + ? t('settings.expireExpired') + : t('settings.expireUntil', { date: info.date })} + + ) + } + const updateServer = async (): Promise => { if (!accessReady) return setBusy(true) @@ -523,6 +635,15 @@ export function ServerSettings({ onChange={(e) => setCount(e.target.value)} /> + + + setCreateExpire(e.target.value)} + /> + )} + + {profile.subscription_url && ( + + )} {profile.multi_route ? ( @@ -674,6 +816,14 @@ export function ServerSettings({ ))} + + )} @@ -720,6 +870,217 @@ 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) + }} + > + + + + + + {t('settings.expireTitle')} — {expireTarget?.name ?? ''} + + + +

{t('settings.expireHintBody')}

+ {expireTarget && ( +

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

+ )} +
+ {t('settings.expireSelect')} +
+ {[7, 30, 90, 365].map((d) => ( + + ))} +
+
+
+ + setExpireDate(e.target.value)} + /> + +
+

{t('settings.expireEnforced')}

+
+ + + {expireTarget?.expire ? ( + + ) : null} + + +
+
+
+
+ { diff --git a/src/shared/expire.ts b/src/shared/expire.ts new file mode 100644 index 0000000..4d0f376 --- /dev/null +++ b/src/shared/expire.ts @@ -0,0 +1,52 @@ +/** + * Чистые хелперы отображения/валидации срока действия профиля. + * Сервер (xrayebator CLI) нормализует и принудит expire — здесь только + * предвычисление подписи для UI и разбор date-input в ISO-строку. + */ + +export type ExpireStatus = 'none' | 'active' | 'expired' + +export interface ExpireInfo { + status: ExpireStatus + /** '2026-11-15' для дата-виджетов; '' если срок снят. */ + date: string +} + +const DAY_MS = 86_400_000 + +function isValidIsoDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false + const [y, m, d] = value.split('-').map(Number) + if (!y || !m || !d || m < 1 || m > 12 || d < 1 || d > 31) return false + const dt = new Date(`${value}T00:00:00Z`) + return !Number.isNaN(dt.getTime()) && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d +} + +/** + * epoch-секунды из profile JSON → статус + дата (локальная). + * disabled (флаг серверного принуждения) всегда важнее сравнения с now. + */ +export function describeExpire(expire: number, disabled: boolean, nowMs: number): ExpireInfo { + if (!expire || expire <= 0) { + return { status: 'none', date: '' } + } + const date = new Date(expire * 1000).toISOString().slice(0, 10) + if (disabled || expire * 1000 <= nowMs) { + return { status: 'expired', date } + } + return { status: 'active', date } +} + +/** ISO-дата валидна и строго в будущем (день считается от полуночи локального ТЗ). */ +export function isFutureDate(value: string, nowMs: number): boolean { + if (!isValidIsoDate(value)) return false + const dayStart = new Date(`${value}T00:00:00`).getTime() + return dayStart > nowMs +} + +/** Пресеты «через N дней» для диалога срока (локальная дата, как в виджете). */ +export function presetDate(daysFromNow: number, nowMs: number): string { + const d = new Date(nowMs + daysFromNow * DAY_MS) + const pad = (n: number): string => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} diff --git a/src/shared/types.ts b/src/shared/types.ts index dffbb3f..18bf2e7 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -129,6 +129,10 @@ export interface ServerProfile { routes: number pq_enabled: boolean subscription_url: string + /** epoch-секунды истечения; 0 = бессрочный. Принуждается серверным таймером. */ + expire: number + /** true — профиль сейчас отключён по сроку (клиент снят с inbound'ов). */ + expire_disabled: boolean } export interface ProfileCreateInput { @@ -136,6 +140,8 @@ export interface ProfileCreateInput { transport: string port?: number count?: number + /** ISO-дата 'ГГГГ-ММ-ДД' или epoch-секунды; undefined = бессрочный. */ + expire?: string } export interface ProfileCreateResult { @@ -203,6 +209,67 @@ export interface ProfilePortResult { error?: string } +export interface ProfileRevokeInput { + name: string + /** true — новый uuid тоже (старые клиенты отваливаются немедленно). */ + full: boolean +} + +export interface ProfileRevokeResult { + ok: boolean + name?: string + full?: boolean + sub_token?: string + uuid?: string + subscription_url?: string + error?: string +} + +export interface ProfileExpireInput { + name: string + /** ISO 'ГГГГ-ММ-ДД' или epoch-секунды; null/undefined → 'none' (снять срок). */ + expire: string | number | null +} + +export interface ProfileExpireResult { + ok: boolean + name?: string + expire?: number + expired?: boolean + error?: string +} + +export interface BypassStateResult { + ok: boolean + domains?: string[] + error?: string +} + +export interface BypassGroupInfo { + id: string + title: string + /** Домены группы без префикса domain: — GUI рисует чекбоксы и считает покрытие. */ + domains: string[] +} + +export interface BypassGroupsResult { + ok: boolean + groups?: BypassGroupInfo[] + error?: string +} + +export interface BypassActionResult { + ok: boolean + domain?: string + duplicate?: boolean + groups?: string[] + /** bundle: число добавленных доменов */ + domains?: number + /** unbundle: число удалённых доменов */ + removed?: number + error?: string +} + export interface SniEntry { sni: string category: string @@ -309,6 +376,37 @@ export interface ElectronAPI { access: SshAccessInput, input: ProfilePortInput ) => Promise + revoke: ( + serverId: string, + access: SshAccessInput, + input: ProfileRevokeInput + ) => Promise + setExpire: ( + serverId: string, + access: SshAccessInput, + input: ProfileExpireInput + ) => Promise + } + bypass: { + list: (serverId: string, access: SshAccessInput) => Promise + groups: (serverId: string, access: SshAccessInput) => Promise + add: (serverId: string, access: SshAccessInput, domain: string) => Promise + remove: ( + serverId: string, + access: SshAccessInput, + domain: string + ) => Promise + reset: (serverId: string, access: SshAccessInput) => Promise + bundle: ( + serverId: string, + access: SshAccessInput, + groups: string[] + ) => Promise + unbundle: ( + serverId: string, + access: SshAccessInput, + groups: string[] + ) => Promise } server: { update: ( diff --git a/tests/unit/expire.test.ts b/tests/unit/expire.test.ts new file mode 100644 index 0000000..52732ca --- /dev/null +++ b/tests/unit/expire.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { describeExpire, isFutureDate, presetDate } from '../../src/shared/expire' + +const NOW = new Date('2026-09-28T12:00:00Z').getTime() +const DAY = 86_400_000 + +describe('describeExpire', () => { + it('0 → бессрочный без даты', () => { + expect(describeExpire(0, false, NOW)).toEqual({ status: 'none', date: '' }) + }) + + it('будущий срок → active с ISO-датой', () => { + const info = describeExpire(Math.floor((NOW + 10 * DAY) / 1000), false, NOW) + expect(info.status).toBe('active') + expect(info.date).toMatch(/^\d{4}-\d{2}-\d{2}$/) + }) + + it('прошедший срок → expired', () => { + const info = describeExpire(Math.floor((NOW - 1000) / 1000), false, NOW) + expect(info.status).toBe('expired') + }) + + it('флаг серверного отключения важнее времени (revoke/enable race)', () => { + const future = Math.floor((NOW + 100 * DAY) / 1000) + expect(describeExpire(future, true, NOW).status).toBe('expired') + }) +}) + +describe('isFutureDate', () => { + it('принимает только корректные будущие ISO-даты', () => { + expect(isFutureDate('2030-01-01', NOW)).toBe(true) + expect(isFutureDate('2026-09-28', NOW)).toBe(false) + expect(isFutureDate('2020-05-05', NOW)).toBe(false) + expect(isFutureDate('2026-13-01', NOW)).toBe(false) + expect(isFutureDate('2026-02-30', NOW)).toBe(false) + expect(isFutureDate('', NOW)).toBe(false) + expect(isFutureDate('01.01.2030', NOW)).toBe(false) + }) +}) + +describe('presetDate', () => { + it('прибавляет дни и отдаёт локальную ISO-дату (полуденный якорь — вне DST-переходов)', () => { + expect(presetDate(30, new Date('2026-06-01T12:00:00').getTime())).toBe('2026-07-01') + }) +}) From 8d74db2fb02af073d44365e9f5a5bcf1234231f6 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:10:47 +0300 Subject: [PATCH 13/29] =?UTF-8?q?Docs:=20revoke/=D1=81=D1=80=D0=BE=D0=BA/b?= =?UTF-8?q?ypass=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20?= =?UTF-8?q?=C2=AB=D0=BD=D0=B5=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=C2=BB=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=20GUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit desktop-gui (ru/en/zh): из списка «GUI не предоставляет» убраны bypass и revoke (теперь есть), добавлено описание трёх новых возможностей Server settings — двухрежимный revoke, срок действия с серверным принуждением, bypass-группы; обновлён список CLI-команд, на которые отображается профильный API. configuration (ru/en/zh): в справочник добавлены profile-revoke, profile-expire, expire-check (таймер), bypass groups/unbundle и --expire у profile-create. Заодно поправлены два места, где доки отправляли за revoke «в терминал»: electron-store секция (ru/en) и ремарка про Server keys — перевыпуск токена теперь явно указан на карточках профилей. (cherry picked from commit df6cff9a8584a8754ae97cc32105beeb5105e5e4) --- docs/configuration.md | 7 ++++++- docs/desktop-gui.md | 21 ++++++++++++++------- docs/ru/configuration.md | 7 ++++++- docs/ru/desktop-gui.md | 17 ++++++++++++----- docs/zh-CN/configuration.md | 7 ++++++- docs/zh-CN/desktop-gui.md | 21 ++++++++++++++------- 6 files changed, 58 insertions(+), 22 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index c703ccc..814b6ab 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -117,8 +117,11 @@ is a client-side profile/route setting; changing it does not restart Xray or alt | `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 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]` or epoch seconds (a past date is 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; applied immediately (a passed date 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 | @@ -128,6 +131,8 @@ is a client-side profile/route setting; changing it does not restart Xray or alt | `sudo xrayebator bypass remove --domain D` | Remove a domain from bypass rules | | `sudo xrayebator bypass reset` | Clear all custom bypass rules | | `sudo xrayebator bypass bundle [--group a,b,c]` | Apply the default bypass groups; without `--group`, apply all groups | +| `sudo xrayebator bypass unbundle --group a,b,c` | Remove the domains of the selected groups in a single mutation and a single Xray restart | +| `sudo xrayebator bypass groups` | List the ready bypass groups with their domain sets (JSON) | | `sudo xrayebator-update [branch]` | Run the full `update.sh` project lifecycle update; without a branch, display `.current_branch` and open the interactive branch selector; with a branch, use that explicit branch | | `sudo xrayebator-uninstall` | Remove the service and installation | diff --git a/docs/desktop-gui.md b/docs/desktop-gui.md index 2c6add2..50184fd 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|groups|add --domain D|remove --domain D|reset|bundle --group a,b,c|unbundle --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,13 @@ 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 date passes. Extending the date restores it. An expiry can also be set at profile-creation time. +- **Bypass groups** — the list of domains routed directly around the tunnel (RU banks, government services, streaming): custom domains are added and removed individually, the server's ready-made groups toggle as checkboxes, and a full reset is available. ## Deployment protocol @@ -178,4 +185,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/configuration.md b/docs/ru/configuration.md index 38ff00b..d71cba6 100644 --- a/docs/ru/configuration.md +++ b/docs/ru/configuration.md @@ -120,8 +120,11 @@ legacy-файлы и блоки, ранее созданные Xrayebator, и с | `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 profile-create --name ИМЯ [--transport tcp\|tcp-utls\|tcp-xudp\|tcp-mux\|grpc\|xhttp] [--port P] [--count N] [--expire ДАТА]` | Создать профили без интерактива; `--expire` принимает `ГГГГ-ММ-ДД[ ЧЧ:ММ]` или epoch-секунды (прошлое отклоняется); `{"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` | Задать, продлить или снять срок действия профиля; применение немедленное (истёкший срок сразу снимает клиента, продление возвращает); 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 | @@ -131,6 +134,8 @@ legacy-файлы и блоки, ранее созданные Xrayebator, и с | `sudo xrayebator bypass remove --domain D` | Убрать домен из bypass-правил | | `sudo xrayebator bypass reset` | Сбросить все кастомные bypass-правила | | `sudo xrayebator bypass bundle [--group a,b,c]` | Применить дефолтные группы bypass; без `--group` — все группы | +| `sudo xrayebator bypass unbundle --group a,b,c` | Снять домены выбранных групп с обхода одной мутацией и одним рестартом Xray | +| `sudo xrayebator bypass groups` | Вывести готовые группы bypass с составом доменов (JSON) | | `sudo xrayebator-update [branch]` | Запустить полный `update.sh` lifecycle update; без аргумента — интерактивный выбор ветки | | `sudo xrayebator-uninstall` | Снять сервис и конфигурацию | diff --git a/docs/ru/desktop-gui.md b/docs/ru/desktop-gui.md index 4a742af..d1da008 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|groups|add --domain D|remove --domain D|reset|bundle --group a,b,c|unbundle --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,13 @@ 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'ов по истечении срока. Продление возвращает клиента обратно. Срок также можно задать сразу при создании профиля. +- **Bypass-группы** — список доменов, идущих напрямую мимо туннеля (RU-банки, госуслуги, стриминг): свои домены добавляются и удаляются поштучно, готовые группы сервера включаются и выключаются чекбоксами, есть полный сброс. ## Протокол развёртывания diff --git a/docs/zh-CN/configuration.md b/docs/zh-CN/configuration.md index 1d035bd..ce05c33 100644 --- a/docs/zh-CN/configuration.md +++ b/docs/zh-CN/configuration.md @@ -105,8 +105,11 @@ Xrayebator 不会更改主机的 TCP 拥塞控制算法,也不会写入或应 | `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 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 秒(过去的时间会被拒绝),打印 `{"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` | 设置、延长或取消配置档有效期;立即生效(已过期会移除客户端,延长则恢复),打印 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 对话框使用) | @@ -116,6 +119,8 @@ Xrayebator 不会更改主机的 TCP 拥塞控制算法,也不会写入或应 | `sudo xrayebator bypass remove --domain D` | 从分流规则移除一个域名(JSON) | | `sudo xrayebator bypass reset` | 清空所有自定义分流规则(JSON) | | `sudo xrayebator bypass bundle [--group a,b,c]` | 应用默认分流分组;不带 `--group` 时重新应用全部分组(JSON) | +| `sudo xrayebator bypass unbundle --group a,b,c` | 以单次变更和单次 Xray 重启移除所选分组的域名(JSON) | +| `sudo xrayebator bypass groups` | 列出内置分流分组及其域名集合(JSON) | | `sudo xrayebator-update [branch]` | 运行完整的 `update.sh` 生命周期更新;无参数时显示 `.current_branch` 并打开交互式分支选择,有参数时使用该分支 | | `sudo xrayebator-uninstall` | 移除服务与配置 | diff --git a/docs/zh-CN/desktop-gui.md b/docs/zh-CN/desktop-gui.md index 5c52b48..c35a7fc 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|groups|add --domain D|remove --domain D|reset|bundle --group a,b,c|unbundle --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,13 @@ self-steal 服务日志/状态 ``` -尤其要注意,Dashboard 的可达性状态点不等于可以使用 `probe-test`,密钥页面也不等于可以使用 `revoke`。 +尤其要注意,Dashboard 的可达性状态点不等于可以使用 `probe-test`。 + +Server settings 还提供: + +- **订阅吊销** —— 每个配置档旁的按钮会打开一个菜单,包含两种模式。「仅更换链接」会重新签发 `sub_token`(旧 URL 立即失效,线路和密钥不变)。「完全吊销」还会更换该配置档所有 inbound 中的 uuid,已经下载过配置的设备会立刻断开——这是真正关闭泄露链接访问权限的唯一方式。 +- **配置档有效期** —— 日期通过订阅响应头下发给客户端,并由服务器强制执行:`xrayebator-expire.timer` 定时器每 10 分钟运行一次 `xrayebator expire-check`,到期后把客户端从 inbound 中移除。延长日期即可恢复。创建配置档时也可以直接设置有效期。 +- **分流分组** —— 绕过隧道直连的域名列表(俄罗斯银行、政务服务、流媒体):自有域名可逐个添加和删除,服务器内置分组用开关切换,并提供完全重置。 ## 部署协议 @@ -179,4 +186,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` 界面或服务器端命令。 From 2070c62733af4bbdd4a8fe313d608096bbdec305 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:15:23 +0300 Subject: [PATCH 14/29] =?UTF-8?q?Docs:=20=D1=81=D0=B8=D0=BD=D1=85=D1=80?= =?UTF-8?q?=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20README/troub?= =?UTF-8?q?leshooting/security/architecture/CHANGELOG/CLAUDE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Второй проход по «карте возможностей»: revoke, сроки и bypass больше не числятся терминальными операциями в README (ru/en/zh), troubleshooting (ru/en/zh), configuration (ru/en), security (ru/en/zh) и architecture (ru/en/zh) — вместо «используйте терминал» указано, где это живёт в GUI, а про полный отзыв добавлено, что он меняет и клиентский ключ (уже скачанные конфиги перестают работать). CHANGELOG: секция Unreleased с тремя фичами. CLAUDE.md: в справочник CLI добавлены profile-revoke/profile-expire/expire-check, bypass groups/unbundle, --expire у profile-create, новые поля profiles-вывода, инварианты (порядок транзакции revoke, сохранение inbound'ов при expire) и обновлён счётчик validation-тестов (27). docs/testing: строка про новый test-profile-revoke-expire-cli.sh. --- CHANGELOG.md | 9 +++++++++ CLAUDE.md | 11 +++++++---- README.md | 9 +++++---- README.ru.md | 9 +++++---- README.zh-CN.md | 8 ++++---- docs/architecture.md | 5 ++++- docs/configuration.md | 6 +++--- docs/ru/architecture.md | 5 ++++- docs/ru/configuration.md | 5 +++-- docs/ru/security.md | 2 +- docs/ru/testing.md | 1 + docs/ru/troubleshooting.md | 5 +++-- docs/security.md | 3 ++- docs/testing.md | 1 + docs/troubleshooting.md | 5 +++-- docs/zh-CN/architecture.md | 2 +- docs/zh-CN/security.md | 2 +- docs/zh-CN/testing.md | 1 + docs/zh-CN/troubleshooting.md | 2 +- 19 files changed, 59 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e02295..7c1770b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ 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). +- Bypass groups in Server settings: the list of directly routed domains with add/remove, a full reset, and the server's ready-made RU groups (banks, streaming, government services and others) as checkboxes; `bypass groups` and `bypass unbundle` were added to the CLI. +- The subscription-userinfo `expire` header now has a UI path: HAPP and other clients display the date handed out by the subscription. + ## [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..a09e5b5 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/`** — 27 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) 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; a date in the past 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); 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|groups|add --domain D|remove --domain D|reset|bundle [--group a,b,c]|unbundle --group a,b,c` — manage bypass routing groups (JSON). `groups` returns each group's domain list so the GUI can compute coverage; `unbundle` removes a group in one mutation and one restart. 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..da986aa 100644 --- a/README.md +++ b/README.md @@ -294,12 +294,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, manage bypass domains and groups, plus update or uninstall Xrayebator on the server | -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. `probe-test`, `happ-setup`, cascade, self-steal +and service logs/status remain terminal-only; profile expiry dates, subscription revocation and +bypass groups 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..64bdcf3 100644 --- a/README.ru.md +++ b/README.ru.md @@ -290,12 +290,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 и порта, отзыв подписки, срок действия профиля, bypass-домены и группы, обновление или удаление 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 и логи/статус сервисов остаются терминальными операциями; сроки действия профилей, +отзыв подписки и bypass-группы доступны в настройках сервера. Полная граница возможностей, 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..7a64cc7 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 | -选中的私钥与成功登录时使用过的 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..7c87f7a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,7 +110,10 @@ 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 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 814b6ab..9f453b1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -179,9 +179,9 @@ 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. +list/create/delete, subscription revoke, profile expiry, bypass domains and groups, plus selected +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. diff --git a/docs/ru/architecture.md b/docs/ru/architecture.md index 88ea8a5..88d7067 100644 --- a/docs/ru/architecture.md +++ b/docs/ru/architecture.md @@ -105,7 +105,10 @@ 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 минут. Новый стандартный 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 d71cba6..4000b64 100644 --- a/docs/ru/configuration.md +++ b/docs/ru/configuration.md @@ -155,8 +155,9 @@ legacy-файлы и блоки, ранее созданные Xrayebator, и с Активное Electron-приложение — это CLI-фронтенд поверх SSH, а не полная замена терминальному меню. Оно выполняет деплой через `quickstart`, обновляет сохранённый `subscription_url` и предоставляет -операции списка/создания/удаления профилей плюс выбранные SNI, fingerprint, port, update и uninstall. -Bypass, probe, revoke, HAPP setup, cascade, self-steal, интерактивное меню и диагностика сервиса +операции списка/создания/удаления профилей, отзыв подписки, срок действия профиля, bypass-домены +и группы, а также выбранные SNI, fingerprint, port, update и uninstall. +`probe-test`, HAPP setup, cascade, self-steal, интерактивное меню и диагностика сервиса остаются серверными операциями. См. [Electron Desktop GUI](desktop-gui.md) для полного описания команд, границы безопасности, сборки diff --git a/docs/ru/security.md b/docs/ru/security.md index d798e0b..e4a06ee 100644 --- a/docs/ru/security.md +++ b/docs/ru/security.md @@ -110,7 +110,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..5e86a9d 100644 --- a/docs/ru/testing.md +++ b/docs/ru/testing.md @@ -40,6 +40,7 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done | `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-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..0371893 100644 --- a/docs/ru/troubleshooting.md +++ b/docs/ru/troubleshooting.md @@ -143,8 +143,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-группы настраиваются в разделе «Настройки сервера». ## Electron unit-тест падает на Windows diff --git a/docs/security.md b/docs/security.md index 86f3d6e..354c5e4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -126,7 +126,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..1063882 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -44,6 +44,7 @@ promise that installer and updater paths behave identically. | `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 | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a42dfb2..5fb4031 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -168,8 +168,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, subscription +revocation and bypass groups are handled in Server settings. ## The Electron unit test fails on Windows diff --git a/docs/zh-CN/architecture.md b/docs/zh-CN/architecture.md index cae0fb6..b619477 100644 --- a/docs/zh-CN/architecture.md +++ b/docs/zh-CN/architecture.md @@ -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` 强制执行。 新创建的标准托管 HAPP 配置档是 schema-v3 七路由配置档,包括 `xhttp-legacy` 和后量子 XHTTP 路由。 发布的 HAPP 连接列表包含六个 VLESS 路由,因为 PQ 路由仍可通过原始/配置档路径访问。助手也可能 diff --git a/docs/zh-CN/security.md b/docs/zh-CN/security.md index 294eecc..cbe376f 100644 --- a/docs/zh-CN/security.md +++ b/docs/zh-CN/security.md @@ -103,7 +103,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..2ff8d28 100644 --- a/docs/zh-CN/testing.md +++ b/docs/zh-CN/testing.md @@ -39,6 +39,7 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done | `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-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..a984f4e 100644 --- a/docs/zh-CN/troubleshooting.md +++ b/docs/zh-CN/troubleshooting.md @@ -105,7 +105,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 上失败 From c8101bef7c2590e4d9cf0ce10f8cc99c166a5998 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:15:23 +0300 Subject: [PATCH 15/29] =?UTF-8?q?Docs:=20=D1=81=D0=B8=D0=BD=D1=85=D1=80?= =?UTF-8?q?=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20README/troub?= =?UTF-8?q?leshooting/security/architecture/CHANGELOG/CLAUDE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Второй проход по «карте возможностей»: revoke, сроки и bypass больше не числятся терминальными операциями в README (ru/en/zh), troubleshooting (ru/en/zh), configuration (ru/en), security (ru/en/zh) и architecture (ru/en/zh) — вместо «используйте терминал» указано, где это живёт в GUI, а про полный отзыв добавлено, что он меняет и клиентский ключ (уже скачанные конфиги перестают работать). CHANGELOG: секция Unreleased с тремя фичами. CLAUDE.md: в справочник CLI добавлены profile-revoke/profile-expire/expire-check, bypass groups/unbundle, --expire у profile-create, новые поля profiles-вывода, инварианты (порядок транзакции revoke, сохранение inbound'ов при expire) и обновлён счётчик validation-тестов (27). docs/testing: строка про новый test-profile-revoke-expire-cli.sh. (cherry picked from commit 2070c62733af4bbdd4a8fe313d608096bbdec305) --- CHANGELOG.md | 9 +++++++++ CLAUDE.md | 11 +++++++---- README.md | 9 +++++---- README.ru.md | 9 +++++---- README.zh-CN.md | 8 ++++---- docs/architecture.md | 5 ++++- docs/configuration.md | 6 +++--- docs/ru/architecture.md | 5 ++++- docs/ru/configuration.md | 5 +++-- docs/ru/security.md | 2 +- docs/ru/testing.md | 1 + docs/ru/troubleshooting.md | 5 +++-- docs/security.md | 3 ++- docs/testing.md | 1 + docs/troubleshooting.md | 5 +++-- docs/zh-CN/architecture.md | 2 +- docs/zh-CN/security.md | 2 +- docs/zh-CN/testing.md | 1 + docs/zh-CN/troubleshooting.md | 2 +- 19 files changed, 59 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e02295..7c1770b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ 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). +- Bypass groups in Server settings: the list of directly routed domains with add/remove, a full reset, and the server's ready-made RU groups (banks, streaming, government services and others) as checkboxes; `bypass groups` and `bypass unbundle` were added to the CLI. +- The subscription-userinfo `expire` header now has a UI path: HAPP and other clients display the date handed out by the subscription. + ## [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..a09e5b5 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/`** — 27 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) 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; a date in the past 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); 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|groups|add --domain D|remove --domain D|reset|bundle [--group a,b,c]|unbundle --group a,b,c` — manage bypass routing groups (JSON). `groups` returns each group's domain list so the GUI can compute coverage; `unbundle` removes a group in one mutation and one restart. 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..da986aa 100644 --- a/README.md +++ b/README.md @@ -294,12 +294,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, manage bypass domains and groups, plus update or uninstall Xrayebator on the server | -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. `probe-test`, `happ-setup`, cascade, self-steal +and service logs/status remain terminal-only; profile expiry dates, subscription revocation and +bypass groups 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..64bdcf3 100644 --- a/README.ru.md +++ b/README.ru.md @@ -290,12 +290,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 и порта, отзыв подписки, срок действия профиля, bypass-домены и группы, обновление или удаление 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 и логи/статус сервисов остаются терминальными операциями; сроки действия профилей, +отзыв подписки и bypass-группы доступны в настройках сервера. Полная граница возможностей, 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..7a64cc7 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 | -选中的私钥与成功登录时使用过的 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..7c87f7a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,7 +110,10 @@ 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 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 814b6ab..9f453b1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -179,9 +179,9 @@ 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. +list/create/delete, subscription revoke, profile expiry, bypass domains and groups, plus selected +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. diff --git a/docs/ru/architecture.md b/docs/ru/architecture.md index 88ea8a5..88d7067 100644 --- a/docs/ru/architecture.md +++ b/docs/ru/architecture.md @@ -105,7 +105,10 @@ 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 минут. Новый стандартный 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 d71cba6..4000b64 100644 --- a/docs/ru/configuration.md +++ b/docs/ru/configuration.md @@ -155,8 +155,9 @@ legacy-файлы и блоки, ранее созданные Xrayebator, и с Активное Electron-приложение — это CLI-фронтенд поверх SSH, а не полная замена терминальному меню. Оно выполняет деплой через `quickstart`, обновляет сохранённый `subscription_url` и предоставляет -операции списка/создания/удаления профилей плюс выбранные SNI, fingerprint, port, update и uninstall. -Bypass, probe, revoke, HAPP setup, cascade, self-steal, интерактивное меню и диагностика сервиса +операции списка/создания/удаления профилей, отзыв подписки, срок действия профиля, bypass-домены +и группы, а также выбранные SNI, fingerprint, port, update и uninstall. +`probe-test`, HAPP setup, cascade, self-steal, интерактивное меню и диагностика сервиса остаются серверными операциями. См. [Electron Desktop GUI](desktop-gui.md) для полного описания команд, границы безопасности, сборки diff --git a/docs/ru/security.md b/docs/ru/security.md index d798e0b..e4a06ee 100644 --- a/docs/ru/security.md +++ b/docs/ru/security.md @@ -110,7 +110,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..5e86a9d 100644 --- a/docs/ru/testing.md +++ b/docs/ru/testing.md @@ -40,6 +40,7 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done | `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-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..0371893 100644 --- a/docs/ru/troubleshooting.md +++ b/docs/ru/troubleshooting.md @@ -143,8 +143,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-группы настраиваются в разделе «Настройки сервера». ## Electron unit-тест падает на Windows diff --git a/docs/security.md b/docs/security.md index 86f3d6e..354c5e4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -126,7 +126,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..1063882 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -44,6 +44,7 @@ promise that installer and updater paths behave identically. | `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 | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a42dfb2..5fb4031 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -168,8 +168,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, subscription +revocation and bypass groups are handled in Server settings. ## The Electron unit test fails on Windows diff --git a/docs/zh-CN/architecture.md b/docs/zh-CN/architecture.md index cae0fb6..b619477 100644 --- a/docs/zh-CN/architecture.md +++ b/docs/zh-CN/architecture.md @@ -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` 强制执行。 新创建的标准托管 HAPP 配置档是 schema-v3 七路由配置档,包括 `xhttp-legacy` 和后量子 XHTTP 路由。 发布的 HAPP 连接列表包含六个 VLESS 路由,因为 PQ 路由仍可通过原始/配置档路径访问。助手也可能 diff --git a/docs/zh-CN/security.md b/docs/zh-CN/security.md index 294eecc..cbe376f 100644 --- a/docs/zh-CN/security.md +++ b/docs/zh-CN/security.md @@ -103,7 +103,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..2ff8d28 100644 --- a/docs/zh-CN/testing.md +++ b/docs/zh-CN/testing.md @@ -39,6 +39,7 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done | `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-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..a984f4e 100644 --- a/docs/zh-CN/troubleshooting.md +++ b/docs/zh-CN/troubleshooting.md @@ -105,7 +105,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 上失败 From b2348b8d51e894e9c173e5325c2e62a7f6b00b06 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:18:31 +0300 Subject: [PATCH 16/29] =?UTF-8?q?GUI:=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=C2=AB?= =?UTF-8?q?=D1=81=D0=BA=D0=BE=D1=80=D0=BE=20=D0=B8=D1=81=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=C2=BB=20=D0=B7=D0=B0=203=20=D0=B4=D0=BD?= =?UTF-8?q?=D1=8F=20=D0=B4=D0=BE=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Чип срока в карточке профиля раньше имел только два состояния (дата / истёк), а CSS-класс предупреждения остался неиспользованным. Теперь за 3 дня до срока чип подсвечивается жёлтым и объясняет в тултипе, что профиль отключится сам — успеть продлить. Ключ expireHintSoon добавлен в ru/en/zh (150/150/150). Проверено: typecheck чистый, 145 settings-ключей из кода резолвятся во всех локалях, 0 неиспользуемых CSS-классов, vitest 89/90 (единственный красный — предсуществующий shell-command: /bin/sh недоступен под Windows-нодой). --- src/renderer/src/i18n/en.json | 1 + src/renderer/src/i18n/ru.json | 1 + src/renderer/src/i18n/zh.json | 1 + src/renderer/src/pages/ServerSettings.tsx | 22 ++++++++++++++++------ 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index d677d29..2e18079 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -269,6 +269,7 @@ "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", "bypassTitle": "Bypass routing", diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index c25b403..86e8a9b 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -270,6 +270,7 @@ "expireUntil": "до {{date}}", "expireExpired": "срок истёк", "expireHint": "Профиль отключится автоматически в эту дату", + "expireHintSoon": "Срок истекает в ближайшие дни — продлите, иначе профиль отключится автоматически", "expireHintExpired": "Срок истёк — профиль отключён сервером. Продлите срок, чтобы вернуть доступ.", "createExpire": "Срок действия", "bypassTitle": "Обход VPN (Bypass)", diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index 297d3fb..1d621cc 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -268,6 +268,7 @@ "expireUntil": "至 {{date}}", "expireExpired": "已过期", "expireHint": "该配置将在这一天自动停用", + "expireHintSoon": "即将到期——请及时延长,否则配置会被服务器自动停用", "expireHintExpired": "已过期——服务器已停用该配置。延长有效期即可恢复访问。", "createExpire": "有效期", "bypassTitle": "绕过 VPN(分流)", diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index b748acd..d882682 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -447,19 +447,29 @@ export function ServerSettings({ } } - /** Чип срока в карточке профиля: дата, «истёк» или «бессрочно». */ + /** Чип срока в карточке профиля: дата, «истёк», «скоро истекает» или ничего. */ const expireChip = (profile: ServerProfile): React.JSX.Element | null => { - const info = describeExpire(profile.expire ?? 0, profile.expire_disabled === true, Date.now()) + const now = Date.now() + const info = describeExpire(profile.expire ?? 0, profile.expire_disabled === true, now) if (info.status === 'none') return null - const danger = info.status === 'expired' + 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 ( - {danger + {expired ? t('settings.expireExpired') : t('settings.expireUntil', { date: info.date })} From 70af7187895bb21be1042ae5a78c0b0dde86b2c7 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:18:31 +0300 Subject: [PATCH 17/29] =?UTF-8?q?GUI:=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=C2=AB?= =?UTF-8?q?=D1=81=D0=BA=D0=BE=D1=80=D0=BE=20=D0=B8=D1=81=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=C2=BB=20=D0=B7=D0=B0=203=20=D0=B4=D0=BD?= =?UTF-8?q?=D1=8F=20=D0=B4=D0=BE=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Чип срока в карточке профиля раньше имел только два состояния (дата / истёк), а CSS-класс предупреждения остался неиспользованным. Теперь за 3 дня до срока чип подсвечивается жёлтым и объясняет в тултипе, что профиль отключится сам — успеть продлить. Ключ expireHintSoon добавлен в ru/en/zh (150/150/150). Проверено: typecheck чистый, 145 settings-ключей из кода резолвятся во всех локалях, 0 неиспользуемых CSS-классов, vitest 89/90 (единственный красный — предсуществующий shell-command: /bin/sh недоступен под Windows-нодой). (cherry picked from commit b2348b8d51e894e9c173e5325c2e62a7f6b00b06) --- src/renderer/src/i18n/en.json | 1 + src/renderer/src/i18n/ru.json | 1 + src/renderer/src/i18n/zh.json | 1 + src/renderer/src/pages/ServerSettings.tsx | 22 ++++++++++++++++------ 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index d677d29..2e18079 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -269,6 +269,7 @@ "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", "bypassTitle": "Bypass routing", diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index c25b403..86e8a9b 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -270,6 +270,7 @@ "expireUntil": "до {{date}}", "expireExpired": "срок истёк", "expireHint": "Профиль отключится автоматически в эту дату", + "expireHintSoon": "Срок истекает в ближайшие дни — продлите, иначе профиль отключится автоматически", "expireHintExpired": "Срок истёк — профиль отключён сервером. Продлите срок, чтобы вернуть доступ.", "createExpire": "Срок действия", "bypassTitle": "Обход VPN (Bypass)", diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index 297d3fb..1d621cc 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -268,6 +268,7 @@ "expireUntil": "至 {{date}}", "expireExpired": "已过期", "expireHint": "该配置将在这一天自动停用", + "expireHintSoon": "即将到期——请及时延长,否则配置会被服务器自动停用", "expireHintExpired": "已过期——服务器已停用该配置。延长有效期即可恢复访问。", "createExpire": "有效期", "bypassTitle": "绕过 VPN(分流)", diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index b748acd..d882682 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -447,19 +447,29 @@ export function ServerSettings({ } } - /** Чип срока в карточке профиля: дата, «истёк» или «бессрочно». */ + /** Чип срока в карточке профиля: дата, «истёк», «скоро истекает» или ничего. */ const expireChip = (profile: ServerProfile): React.JSX.Element | null => { - const info = describeExpire(profile.expire ?? 0, profile.expire_disabled === true, Date.now()) + const now = Date.now() + const info = describeExpire(profile.expire ?? 0, profile.expire_disabled === true, now) if (info.status === 'none') return null - const danger = info.status === 'expired' + 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 ( - {danger + {expired ? t('settings.expireExpired') : t('settings.expireUntil', { date: info.date })} From de805dad1e3c0d277c5c4b7684c94a13b73b67b3 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:10:14 +0300 Subject: [PATCH 18/29] =?UTF-8?q?Fix:=20=D0=BF=D0=BE=D1=87=D0=B5=D0=BC?= =?UTF-8?q?=D1=83=20=D1=84=D0=B8=D1=87=D0=B8=20=C2=AB=D0=BD=D0=B5=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=BB=D0=B8=C2=BB=20=E2=80=94=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=80=D0=B0=D1=8F=20=D0=B2=D0=B5=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B0=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=20+=20=D1=82=D1=80=D0=B8=20=D0=B1=D0=B0=D0=B3=D0=B0=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Корень всех трёх симптомов («bypass groups → код 1», «срок показывается бессрочно», «кнопка добавить падает») один: сервер работал на релизном коде, где новых команд нет вообще. GUI новее сервера, и вместо объяснения показывал пустое «код 1: » — менеджер печатает причину в stdout, а ошибка читала stderr. 1. Диагностика падений CLI (src/main/core/cli-failure.ts): describeFailure достаёт причину из stderr, а если пусто — из stdout («Неизвестная команда»), плюс isUnknownCommandFailure добавляет подсказку «обновите сервер». ProfileManager и BypassManager используют её вместо res.stderr. 2. Ловушка ветки: xrayebator update заменял скрипт, но НЕ писал .current_branch, а GUI-кнопка «Обновить Xrayebator» читает именно этот файл и по умолчанию берёт main. Итог: обновив сервер на dev, пользователь получал откат на релиз при следующем обновлении из GUI. Теперь ветка закрепляется после успешной замены скрипта, имя ветки валидируется до подстановки в URL. 3. profiles отдаёт expire_supported: true. Старый сервер поля не отдаёт, и GUI молча показывал «бессрочно» вместо «управление сроками недоступно». Теперь при отсутствии флага выводится честное предупреждение. 4. UX: «Добавить» больше не бьёт в сервер при пустом поле (пользователь просто ещё не написал домен, а получал ошибку). Регрессия: validation/test-update-branch-pinning.sh (28/28 в наборе). --- src/main/core/bypass.ts | 9 +- src/main/core/cli-failure.ts | 60 +++++++++ src/main/core/profiles.ts | 9 +- src/renderer/src/components/BypassSection.tsx | 3 + .../src/components/CalendarPicker.module.css | 122 ++++++++++++++++++ .../src/components/CalendarPicker.tsx | 110 ++++++++++++++++ src/renderer/src/i18n/en.json | 4 + src/renderer/src/i18n/ru.json | 4 + src/renderer/src/i18n/zh.json | 4 + .../src/pages/ServerSettings.module.css | 16 +++ src/renderer/src/pages/ServerSettings.tsx | 87 ++++++++++--- src/shared/calendar.ts | 78 +++++++++++ src/shared/types.ts | 6 + tests/unit/calendar.test.ts | 92 +++++++++++++ validation/test-update-branch-pinning.sh | 63 +++++++++ xrayebator | 20 ++- 16 files changed, 667 insertions(+), 20 deletions(-) create mode 100644 src/main/core/cli-failure.ts create mode 100644 src/renderer/src/components/CalendarPicker.module.css create mode 100644 src/renderer/src/components/CalendarPicker.tsx create mode 100644 src/shared/calendar.ts create mode 100644 tests/unit/calendar.test.ts create mode 100644 validation/test-update-branch-pinning.sh diff --git a/src/main/core/bypass.ts b/src/main/core/bypass.ts index 86e8fe0..95f56f5 100644 --- a/src/main/core/bypass.ts +++ b/src/main/core/bypass.ts @@ -1,6 +1,7 @@ import { SshClient, SshCredentials } from './ssh-client' import { shellCommand } from './shell-command' import { extractJson } from './profiles' +import { describeFailure, isUnknownCommandFailure, unknownCommandHint } from './cli-failure' import type { BypassActionResult, BypassGroupsResult, BypassStateResult } from '@shared/types' /** @@ -17,7 +18,13 @@ export class BypassManager { await client.connect() const res = await client.exec(shellCommand('xrayebator', args), { elevated: true }) if (res.code !== 0) { - throw new Error(`xrayebator bypass ${args[1] ?? ''} → код ${res.code}: ${res.stderr.trim()}`) + // Старый сервер печатает «Неизвестная команда» в stdout, а не в stderr — + // без этого разбора пользователь видел пустое «код 1: ». + const reason = describeFailure(res) + const hint = isUnknownCommandFailure(res) ? ` ${unknownCommandHint()}` : '' + throw new Error( + `xrayebator bypass ${args[1] ?? ''} → код ${res.code}${reason ? `: ${reason}` : ''}.${hint}` + ) } return res.stdout } finally { 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 429d1df..1ddd9a0 100644 --- a/src/main/core/profiles.ts +++ b/src/main/core/profiles.ts @@ -1,5 +1,6 @@ import { SshClient, SshCredentials } from './ssh-client' import { shellCommand } from './shell-command' +import { describeFailure, isUnknownCommandFailure, unknownCommandHint } from './cli-failure' import type { ProfileExpireResult, ProfileRevokeResult, @@ -122,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 { diff --git a/src/renderer/src/components/BypassSection.tsx b/src/renderer/src/components/BypassSection.tsx index 5cd21b6..5a82c52 100644 --- a/src/renderer/src/components/BypassSection.tsx +++ b/src/renderer/src/components/BypassSection.tsx @@ -78,6 +78,9 @@ export function BypassSection({ const add = async (): Promise => { const domain = draft.trim().replace(/^domain:/, '') + // Пустое поле — не ошибка пользователя: он просто ещё ничего не написал. + // Валидируем и НЕ дёргаем сервер (иначе SSH-вызов падал кодом 1). + if (!domain) return if (!DOMAIN_RE.test(domain)) { onError(t('settings.bypassInvalidDomain')) return diff --git a/src/renderer/src/components/CalendarPicker.module.css b/src/renderer/src/components/CalendarPicker.module.css new file mode 100644 index 0000000..bde7595 --- /dev/null +++ b/src/renderer/src/components/CalendarPicker.module.css @@ -0,0 +1,122 @@ +/* Календарь выбора срока действия — в теме приложения, а не системный date-input. */ +.root { + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 12px; + user-select: none; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} + +.monthLabel { + font-size: 13px; + font-weight: 600; + color: var(--text-0); + flex: 1; + text-align: center; +} + +.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; +} + +.weekday { + text-align: center; + font-size: 11px; + color: var(--text-2); + padding: 2px 0; +} + +.grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} + +.day, +.emptyCell { + aspect-ratio: 1; + min-height: 30px; +} + +.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 2e18079..7e98dc6 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -236,6 +236,7 @@ "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.", @@ -272,6 +273,9 @@ "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}}", "bypassTitle": "Bypass routing", "bypassHint": "Listed domains go direct, skipping the tunnel: Russian banks, government services and streaming do not block foreign IPs. Ready groups are toggles; add your own domain manually.", "bypassLoad": "Load list", diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index 86e8a9b..c99bb41 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -237,6 +237,7 @@ "foreign": "Иностранные (не всегда проходят)", "fallback": "Запасные (часто под зондированием)" }, + "serverTooOld": "Сервер работает на более старой версии Xrayebator: сроки действия и обход недоступны. Обновите его кнопкой «Обновить Xrayebator».", "revokeBtn": "Revoke", "revokeTitle": "Отзыв подписки", "revokeHint": "Ссылка подписки — это пароль: кто её получил, тот скачает все маршруты. Выберите, что перевыпустить.", @@ -273,6 +274,9 @@ "expireHintSoon": "Срок истекает в ближайшие дни — продлите, иначе профиль отключится автоматически", "expireHintExpired": "Срок истёк — профиль отключён сервером. Продлите срок, чтобы вернуть доступ.", "createExpire": "Срок действия", + "createExpireSet": "Срок: {{date}}", + "createExpireHint": "Профиль отключится автоматически в выбранную дату. Можно оставить бессрочным.", + "expirePicked": "Выбрано: {{date}}", "bypassTitle": "Обход VPN (Bypass)", "bypassHint": "Домены из списка идут напрямую, минуя туннель: российские банки, госуслуги и стриминг не блокируют зарубежные IP. Готовые группы — чекбоксами, свой домен — вручную.", "bypassLoad": "Загрузить список", diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index 1d621cc..27e4385 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -235,6 +235,7 @@ "foreign": "国外(不一定总是可用)", "fallback": "备用(常被探测)" }, + "serverTooOld": "服务器运行的是较旧的 Xrayebator:有效期与分流功能不可用。请点击「更新 Xrayebator」升级。", "revokeBtn": "吊销", "revokeTitle": "吊销订阅", "revokeHint": "订阅链接就是密码:拿到它的人可以下载全部线路。请选择要重新签发的内容。", @@ -271,6 +272,9 @@ "expireHintSoon": "即将到期——请及时延长,否则配置会被服务器自动停用", "expireHintExpired": "已过期——服务器已停用该配置。延长有效期即可恢复访问。", "createExpire": "有效期", + "createExpireSet": "有效期:{{date}}", + "createExpireHint": "配置将在所选日期自动停用。留空表示永久有效。", + "expirePicked": "已选择:{{date}}", "bypassTitle": "绕过 VPN(分流)", "bypassHint": "列表中的域名直连、不走隧道:俄罗斯银行、政务服务和流媒体不会封锁境外 IP。内置分组用开关切换,自有域名手动添加。", "bypassLoad": "加载列表", diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index 950826c..f22e7ff 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -654,6 +654,22 @@ color: var(--text-2); } +/* Сервер старой версии: новые возможности недоступны до обновления менеджера. */ +.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; +} + .fpHint { font-size: 13px; line-height: 1.5; diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index d882682..cf3e20b 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -20,8 +20,10 @@ import { 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 { BypassSection } from '../components/BypassSection' +import { CalendarPicker } from '../components/CalendarPicker' import { shouldAutoConnectServer } from './server-access' import styles from './ServerSettings.module.css' @@ -95,6 +97,7 @@ export function ServerSettings({ 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) @@ -476,6 +479,12 @@ export function ServerSettings({ ) } + /** + * Сервер старой версии не отдаёт поле expire: управление сроками недоступно. + * Без этой проверки GUI молча показывал «бессрочно» — как будто срока нет. + */ + const expireSupported = (profiles ?? []).some((p) => p.expire_supported === true) + const updateServer = async (): Promise => { if (!accessReady) return setBusy(true) @@ -645,15 +654,18 @@ export function ServerSettings({ onChange={(e) => setCount(e.target.value)} /> - - - setCreateExpire(e.target.value)} - /> - + + ) : null} + + + + + + + { diff --git a/src/shared/calendar.ts b/src/shared/calendar.ts new file mode 100644 index 0000000..b19dccd --- /dev/null +++ b/src/shared/calendar.ts @@ -0,0 +1,78 @@ +/** + * Чистая логика месячной сетки для календаря срока действия. + * Вынесена из компонента, чтобы покрываться юнит-тестами без DOM. + */ + +/** ISO-дата ('ГГГГ-ММ-ДД') из локальной даты. */ +export function isoOf(date: Date): string { + const pad = (n: number): string => String(n).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} + +/** Сегодняшняя локальная дата в ISO. */ +export function todayIso(now: Date = new Date()): string { + return isoOf(now) +} + +/** Разбор ISO-даты в локальную полночь; null — если строка не дата. */ +export function parseIso(value: string): Date | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null + const [y, m, d] = value.split('-').map(Number) + if (m < 1 || m > 12 || d < 1 || d > 31) return null + const dt = new Date(y, m - 1, d) + if (dt.getMonth() !== m - 1 || dt.getDate() !== d) return null + return dt +} + +/** + * Ячейки месяца: ведущие null'ы до первого дня, затем ISO-даты. + * Неделя начинается с понедельника (раскладка Пн…Вс). + */ +export function monthCells(year: number, month0: number): (string | null)[] { + const first = new Date(year, month0, 1) + const offset = (first.getDay() + 6) % 7 // 0=Пн … 6=Вс + const daysInMonth = new Date(year, month0 + 1, 0).getDate() + const cells: (string | null)[] = Array.from({ length: offset }, () => null) + for (let d = 1; d <= daysInMonth; d++) cells.push(isoOf(new Date(year, month0, d))) + return cells +} + +/** Является ли дата раньше нижней границы (сравнение по календарным дням). */ +export function isBefore(iso: string, minIso: string): boolean { + const a = parseIso(iso) + const b = parseIso(minIso) + if (!a || !b) return false + return a.getTime() < b.getTime() +} + +/** Сдвиг месяца курсора: возвращает [year, month0]. */ +export function shiftMonth( + year: number, + month0: number, + delta: number +): [number, number] { + const d = new Date(year, month0 + delta, 1) + return [d.getFullYear(), d.getMonth()] +} + +/** Можно ли листать назад: не выходим за месяц, содержащий нижнюю границу. */ +export function canGoBack(year: number, month0: number, minIso: string): boolean { + const min = parseIso(minIso) + if (!min) return true + return new Date(year, month0, 1).getTime() > new Date(min.getFullYear(), min.getMonth(), 1).getTime() +} + +/** Подпись месяца с заглавной буквы в текущей локали. */ +export function monthLabel(year: number, month0: number, locale: string): string { + const raw = new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format( + new Date(year, month0, 1) + ) + return raw.charAt(0).toUpperCase() + raw.slice(1) +} + +/** Короткие названия дней недели, начиная с понедельника. */ +export function weekdayLabels(locale: string): string[] { + const fmt = new Intl.DateTimeFormat(locale, { weekday: 'short' }) + // 2024-01-01 — понедельник. + return Array.from({ length: 7 }, (_, i) => fmt.format(new Date(2024, 0, 1 + i))) +} \ No newline at end of file diff --git a/src/shared/types.ts b/src/shared/types.ts index 18bf2e7..5cc14f2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -133,6 +133,12 @@ export interface ServerProfile { expire: number /** true — профиль сейчас отключён по сроку (клиент снят с inbound'ов). */ expire_disabled: boolean + /** + * false — сервер старой версии и вообще не отдаёт expire (поля нет в JSON). + * Отличает «профиль бессрочный» от «сервер не умеет сроки»: иначе GUI + * показывал бы бессрочность там, где управление сроком недоступно. + */ + expire_supported?: boolean } export interface ProfileCreateInput { diff --git a/tests/unit/calendar.test.ts b/tests/unit/calendar.test.ts new file mode 100644 index 0000000..39895a6 --- /dev/null +++ b/tests/unit/calendar.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { + canGoBack, + isBefore, + isoOf, + monthCells, + monthLabel, + parseIso, + shiftMonth, + todayIso, + weekdayLabels +} from '../../src/shared/calendar' + +describe('isoOf / todayIso / parseIso', () => { + it('форматирует локальную дату без UTC-сдвига', () => { + // 1 января 2026, 00:30 локального времени — не должно стать 31 декабря. + expect(isoOf(new Date(2026, 0, 1, 0, 30))).toBe('2026-01-01') + expect(isoOf(new Date(2026, 11, 31, 23, 59))).toBe('2026-12-31') + }) + + it('todayIso принимает инъекцию даты', () => { + expect(todayIso(new Date(2026, 8, 28, 15, 0))).toBe('2026-09-28') + }) + + it('parseIso отбрасывает мусор и несуществующие даты', () => { + expect(parseIso('2026-09-28')?.getDate()).toBe(28) + expect(parseIso('')).toBeNull() + expect(parseIso('28.09.2026')).toBeNull() + expect(parseIso('2026-13-01')).toBeNull() + expect(parseIso('2026-02-30')).toBeNull() + }) +}) + +describe('monthCells', () => { + it('сентябрь 2026 начинается со вторника → один ведущий null', () => { + const cells = monthCells(2026, 8) + expect(cells.filter((c) => c !== null)).toHaveLength(30) + // 1 сентября 2026 — вторник, значит Пн пустой. + expect(cells[0]).toBeNull() + expect(cells[1]).toBe('2026-09-01') + expect(cells[cells.length - 1]).toBe('2026-09-30') + }) + + it('месяц, начинающийся с понедельника, не имеет ведущих пустых ячеек', () => { + // 1 июня 2026 — понедельник. + const cells = monthCells(2026, 5) + expect(cells[0]).toBe('2026-06-01') + expect(cells.filter((c) => c === null)).toHaveLength(0) + }) + + it('февраль високосного года содержит 29 дней', () => { + const cells = monthCells(2028, 1) + expect(cells.filter((c) => c !== null)).toHaveLength(29) + expect(cells).toContain('2028-02-29') + }) + + it('всегда укладывается в 6 недель', () => { + for (let m = 0; m < 12; m++) { + expect(monthCells(2026, m).length).toBeLessThanOrEqual(42) + } + }) +}) + +describe('isBefore / canGoBack / shiftMonth', () => { + it('сравнивает календарные дни, а не время', () => { + expect(isBefore('2026-09-27', '2026-09-28')).toBe(true) + expect(isBefore('2026-09-28', '2026-09-28')).toBe(false) + expect(isBefore('2026-09-29', '2026-09-28')).toBe(false) + }) + + it('не даёт листать в месяц раньше нижней границы', () => { + // Граница — 28 сентября 2026: в август и ранее нельзя, в сентябрь можно стоять. + expect(canGoBack(2026, 8, '2026-09-28')).toBe(false) + expect(canGoBack(2026, 7, '2026-09-28')).toBe(false) + expect(canGoBack(2026, 9, '2026-09-28')).toBe(true) + }) + + it('сдвиг месяца переносит год на границах', () => { + expect(shiftMonth(2026, 11, 1)).toEqual([2027, 0]) + expect(shiftMonth(2026, 0, -1)).toEqual([2025, 11]) + expect(shiftMonth(2026, 0, 12)).toEqual([2027, 0]) + }) +}) + +describe('локальные подписи', () => { + it('месяц с заглавной буквы, дни недели с понедельника', () => { + expect(monthLabel(2026, 8, 'ru')).toMatch(/^Сентябрь/) + const days = weekdayLabels('ru') + expect(days).toHaveLength(7) + expect(days[0].toLowerCase()).toContain('пн') + }) +}) \ No newline at end of file diff --git a/validation/test-update-branch-pinning.sh b/validation/test-update-branch-pinning.sh new file mode 100644 index 0000000..212033c --- /dev/null +++ b/validation/test-update-branch-pinning.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# test-update-branch-pinning.sh +# Регрессия: self-update `xrayebator update ` обязан закреплять ветку +# в /usr/local/etc/xray/.current_branch. Без этого GUI-кнопка «Обновить +# Xrayebator» (server-manager.ts читает .current_branch, иначе main) возвращала +# сервер на релизную ветку — и новые команды (profile-revoke, profile-expire, +# bypass groups) молча пропадали: GUI получал «код 1» с пустым stderr. +# +# Usage: bash validation/test-update-branch-pinning.sh + +set -euo pipefail + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +src=$(tr -d '\r' < xrayebator) + +# ── 1. update_command пишет ветку в .current_branch ── +update_block=$(sed -n '/^update_command() {/,/^}/p' <<< "$src") +[[ -n "$update_block" ]] || fail "update_command() не найдена" + +grep -q 'current_branch' <<< "$update_block" \ + || fail "update_command не закрепляет ветку в .current_branch (ловушка отката на main)" + +# Запись должна происходить ПОСЛЕ успешной замены скрипта, иначе при провале +# скачивания ветка закрепится на несуществующую версию. +# Ищем именно запись в файл (перенаправление), а не упоминание в комментарии. +mv_line=$(grep -n 'mv "\$up_tmp" "\$up_script"' <<< "$update_block" | head -1 | cut -d: -f1) +pin_line=$(grep -n '> /usr/local/etc/xray/.current_branch' <<< "$update_block" | head -1 | cut -d: -f1) +[[ -n "$mv_line" && -n "$pin_line" ]] || fail "не найдены строки mv/pin в update_command" +[[ "$pin_line" -gt "$mv_line" ]] \ + || fail "ветка закрепляется до замены скрипта — при сбое закрепится мусор" + +# ── 2. Имя ветки валидируется до подстановки в URL ── +grep -q 'Некорректное имя ветки' <<< "$update_block" \ + || fail "нет валидации имени ветки (инъекция в URL/путь)" + +# ── 3. GUI читает именно этот файл и умеет падать безопасно ── +manager=src/main/core/server-manager.ts +grep -q '/usr/local/etc/xray/.current_branch' "$manager" \ + || fail "server-manager не читает .current_branch" +grep -q 'isSafeUpdateBranch' "$manager" \ + || fail "server-manager не валидирует ветку из файла" + +# ── 4. Диагностика падений CLI доносит причину из stdout ── +# Старый сервер печатает «Неизвестная команда» в stdout, оставляя stderr пустым. +grep -q 'describeFailure' src/main/core/cli-failure.ts \ + || fail "нет разбора причины падения CLI" +grep -q 'describeFailure' src/main/core/profiles.ts \ + || fail "ProfileManager не использует describeFailure" +grep -q 'describeFailure' src/main/core/bypass.ts \ + || fail "BypassManager не использует describeFailure" + +# ── 5. profiles отдаёт expire_supported (отличить старый сервер от бессрочного) ── +grep -q 'expire_supported: true' <<< "$src" \ + || fail "profiles не сообщает expire_supported — GUI покажет «бессрочно» на старом сервере" + +echo "PASS: self-update закрепляет ветку, CLI-падения объясняются, expire_supported отдаётся" \ No newline at end of file diff --git a/xrayebator b/xrayebator index f2c7e57..551664e 100755 --- a/xrayebator +++ b/xrayebator @@ -5229,6 +5229,12 @@ update_command() { # xrayebator с этой ветки (self-update, как в update.sh). Иначе — только Xray binary. if [[ -n "${1:-}" ]]; then local branch="$1" + # Ветка уходит в URL, имя файла и .current_branch — валидируем строго. + if [[ ! "$branch" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$ ]] \ + || [[ "$branch" == *".."* ]] || [[ "$branch" == *"//"* ]]; then + echo -e "${RED}✗ Некорректное имя ветки: $branch${NC}" + exit 2 + fi echo -e "${CYAN} → Self-update xrayebator с ветки: ${YELLOW}$branch${NC}" local up_script up_tmp up_script=$(command -v xrayebator 2>/dev/null || echo /usr/local/bin/xrayebator) @@ -5239,6 +5245,17 @@ update_command() { chmod 755 "$up_tmp" if mv "$up_tmp" "$up_script"; then chown root:root "$up_script" 2>/dev/null || true + # Закрепляем ветку: GUI-кнопка «Обновить Xrayebator» читает .current_branch + # и без этой записи откатывала сервер обратно на main — новые команды + # (profile-revoke/profile-expire/bypass groups) молча исчезали. + if [[ -d /usr/local/etc/xray ]]; then + if printf '%s\n' "$branch" > /usr/local/etc/xray/.current_branch 2>/dev/null; then + chown root:root /usr/local/etc/xray/.current_branch 2>/dev/null || true + chmod 644 /usr/local/etc/xray/.current_branch 2>/dev/null || true + else + echo -e "${YELLOW} ⚠ Не удалось закрепить ветку $branch в .current_branch — обновление из GUI может вернуть main${NC}" + fi + fi echo -e "${GREEN} ✓ xrayebator обновлён с ветки $branch${NC}" echo -e "${YELLOW} → Перезапуск...${NC}" exec bash "$up_script" update @@ -11046,7 +11063,8 @@ profiles_command() { fingerprint: $fingerprint, sni: $sni, created: $created, sub_token: $sub_token, multi_route: $multi_route, routes: $routes, pq_enabled: $pq, subscription_url: $subscription_url, - expire: $expire, expire_disabled: $expire_disabled}' + expire: $expire, expire_disabled: $expire_disabled, + expire_supported: true}' done printf ']\n' return 0 From aab3296c23a187485d33e3f80168b82bc3680c31 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:30:24 +0300 Subject: [PATCH 19/29] =?UTF-8?q?UI:=20=D1=81=D1=82=D0=B0=D0=B1=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=D0=B0=D1=80=D1=8C,=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=81=D1=80=D0=BE=D0=BA=D0=B0,=20=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=B4=D0=B5=D1=84=D0=BE?= =?UTF-8?q?=D0=BB=D1=82=20updater=20=E2=86=92=20dev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Календарь: фиксированная высота шапки (месяц + стрелки) и строки дней недели — раньше названия месяцев разной длины и «Пн/Втр/Ср» дёргали стрелки по вертикали, и пользователь каждый раз искал курсором новое положение. Сетка теперь всегда 42 ячейки (6 недель), поэтому месяцы с 5 неделями не сжимают плашку: она растёт только вниз при добавлении контента. Тесты monthCells обновлены под инвариант. Диалог срока: «Снять срок» переехало из футера в тело (отдельное осознанное действие с пояснением), а в футере остались «Отмена» (слева, просто закрывает) и «Сохранить». Раньше «Снять срок» + «Готово» выглядели как одно действие в два клика и путали: снятие срока применяется сразу. LOCAL-DEV-DEFAULT (временно, перед мержем в main вернуть): install.sh и server-manager.ts по умолчанию берут dev вместо main, чтобы свежая локальная установка и кнопка «Обновить Xrayebator» сразу тянули новые фичи, а не релиз. Помечено комментариями в обоих местах. --- install.sh | 5 +++- src/main/core/server-manager.ts | 5 +++- .../src/components/CalendarPicker.module.css | 25 +++++++++++++++- src/renderer/src/i18n/en.json | 1 + src/renderer/src/i18n/ru.json | 1 + src/renderer/src/i18n/zh.json | 1 + .../src/pages/ServerSettings.module.css | 19 ++++++++++++ src/renderer/src/pages/ServerSettings.tsx | 30 ++++++++++++------- src/shared/calendar.ts | 3 ++ tests/unit/calendar.test.ts | 19 +++++++++--- 10 files changed, 92 insertions(+), 17 deletions(-) diff --git a/install.sh b/install.sh index 230f019..e27e7ac 100644 --- a/install.sh +++ b/install.sh @@ -18,7 +18,10 @@ NC='\033[0m' # GitHub репозиторий GITHUB_USER="howdeploy" GITHUB_REPO="Xrayebator" -GITHUB_BRANCH="main" +# LOCAL-DEV-DEFAULT: в локальной сборке Ap3x0s дефолт — dev, чтобы свежая +# установка сразу получала новые фичи (revoke/сроки/bypass) без ручного +# `xrayebator update dev`. ПЕРЕД МЕРЖЕМ В MAIN ВЕРНУТЬ "main". +GITHUB_BRANCH="dev" RAW_BASE_URL="https://raw.githubusercontent.com/${GITHUB_USER}/${GITHUB_REPO}/${GITHUB_BRANCH}" # Пути diff --git a/src/main/core/server-manager.ts b/src/main/core/server-manager.ts index 9b832d6..38e7b8b 100644 --- a/src/main/core/server-manager.ts +++ b/src/main/core/server-manager.ts @@ -34,7 +34,10 @@ export class ServerManager { */ async update(): Promise { const client = new SshClient(this.creds) - let branch = 'main' + // LOCAL-DEV-DEFAULT: если сервер не закрепил ветку в .current_branch, + // локальная сборка обновляет его с dev (новые фичи: revoke/сроки/bypass). + // ПЕРЕД МЕРЖЕМ В MAIN ВЕРНУТЬ 'main' — иначе релизные серверы уедут на dev. + let branch = 'dev' try { await client.connect() const result = await client.exec( diff --git a/src/renderer/src/components/CalendarPicker.module.css b/src/renderer/src/components/CalendarPicker.module.css index bde7595..5fcd778 100644 --- a/src/renderer/src/components/CalendarPicker.module.css +++ b/src/renderer/src/components/CalendarPicker.module.css @@ -5,6 +5,11 @@ border-radius: var(--radius-md); padding: 12px; user-select: none; + /* Высота фиксирована под 6 недель: месяцы с 5 неделями не «сжимают» плашку, + иначе кнопки внизу подпрыгивают при листании. Растёт только вниз — при + добавлении контента, а не при смене месяца. */ + display: flex; + flex-direction: column; } .header { @@ -13,6 +18,10 @@ justify-content: space-between; gap: 8px; margin-bottom: 10px; + /* Фиксированная высота шапки: название месяца в разных локалях/месяцах + занимает разную высоту, из-за чего стрелки «прыгали» по вертикали. */ + height: 28px; + flex-shrink: 0; } .monthLabel { @@ -21,6 +30,11 @@ color: var(--text-0); flex: 1; text-align: center; + line-height: 28px; + /* Длинные названия не переносятся и не толкают стрелки. */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .navBtn { @@ -54,19 +68,28 @@ 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); - padding: 2px 0; + line-height: 20px; + white-space: nowrap; + overflow: hidden; } .grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; + /* Всегда 6 строк — высота сетки не меняется между месяцами. */ + grid-auto-rows: 30px; + min-height: 186px; } .day, diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 7e98dc6..1358866 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -263,6 +263,7 @@ "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", diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index c99bb41..9eeafc9 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -264,6 +264,7 @@ "expireSave": "Сохранить срок", "expireSaving": "Сохраняю…", "expireClear": "Снять срок", + "expireClearHint": "Профиль станет бессрочным, доступ не прерывается.", "expireChanged": "Срок «{{name}}» — до {{date}}", "expireCleared": "Срок «{{name}}» снят: профиль бессрочный", "expireFailed": "Не удалось сохранить срок", diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index 27e4385..8db8fd2 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -262,6 +262,7 @@ "expireSave": "保存有效期", "expireSaving": "正在保存…", "expireClear": "取消有效期", + "expireClearHint": "配置将变为永久有效,不会中断访问。", "expireChanged": "「{{name}}」的有效期至 {{date}}", "expireCleared": "已取消「{{name}}」的有效期:配置永久有效", "expireFailed": "无法保存有效期", diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index f22e7ff..cd91597 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -670,6 +670,25 @@ 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; diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index cf3e20b..62199ab 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -1066,8 +1066,28 @@ export function ServerSettings({ )}

{t('settings.expireEnforced')}

+ {/* Снятие срока — отдельное осознанное действие в теле диалога: + раньше оно жило в футере рядом с «Сохранить» и требовало + второй кнопки «Готово», что путало (одно действие — два клика). */} + {expireTarget?.expire ? ( +
+ + + {t('settings.expireClearHint')} + +
+ ) : null} + {/* Отмена — слева и всегда только закрывает диалог, без действий. */} - {expireTarget?.expire ? ( - - ) : null} - - ) : ( -
- {groups.length > 0 && ( -
- {t('settings.bypassGroups')} -
- {groups.map((group) => { - const covered = group.domains.every((d) => domainSet.has(d)) - const partial = !covered && group.domains.some((d) => domainSet.has(d)) - const label = covered - ? t('settings.bypassGroupStateOn') - : partial - ? t('settings.bypassGroupStatePartial') - : t('settings.bypassGroupStateOff') - return ( - - ) - })} -
-
- )} - -
- - - setDraft(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') void add() - }} - /> - - - - - {t('settings.bypassCount', { count: domains.length })} - -
- - {domains.length === 0 ? ( -

{t('settings.bypassEmpty')}

- ) : ( -
- {domains.map((domain) => ( - - {domain} - - - ))} -
- )} -
- )} - - { - if (!open && !busy) setConfirmReset(false) - }} - > - - - - - - - - {t('settings.bypassResetTitle')} - - {t('settings.bypassResetBody')} - - - - - - - - - - ) -} \ No newline at end of file diff --git a/src/renderer/src/components/CalendarPicker.module.css b/src/renderer/src/components/CalendarPicker.module.css index 5fcd778..4ac497f 100644 --- a/src/renderer/src/components/CalendarPicker.module.css +++ b/src/renderer/src/components/CalendarPicker.module.css @@ -5,9 +5,7 @@ border-radius: var(--radius-md); padding: 12px; user-select: none; - /* Высота фиксирована под 6 недель: месяцы с 5 неделями не «сжимают» плашку, - иначе кнопки внизу подпрыгивают при листании. Растёт только вниз — при - добавлении контента, а не при смене месяца. */ + /* Растёт вниз по содержимому; шапка зафиксирована, поэтому верх не смещается. */ display: flex; flex-direction: column; } @@ -87,9 +85,11 @@ display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; - /* Всегда 6 строк — высота сетки не меняется между месяцами. */ + /* Высота — по факту: 5 недель в месяце → 5 строк, 6 → 6. Пустого полотна + внизу нет. Вверх ничего не «дёргается»: шапка и строка дней недели имеют + фиксированную высоту, а плашка растёт только вниз. */ grid-auto-rows: 30px; - min-height: 186px; + align-content: start; } .day, diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 1358866..c354324 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -276,34 +276,6 @@ "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}}", - "bypassTitle": "Bypass routing", - "bypassHint": "Listed domains go direct, skipping the tunnel: Russian banks, government services and streaming do not block foreign IPs. Ready groups are toggles; add your own domain manually.", - "bypassLoad": "Load list", - "bypassLoading": "Loading…", - "bypassLoadFailed": "Failed to load bypass rules", - "bypassGroups": "Ready groups", - "bypassGroupStateOn": "on", - "bypassGroupStatePartial": "partial", - "bypassGroupStateOff": "off", - "bypassAddLabel": "Custom domain", - "bypassAdd": "Add", - "bypassAddFailed": "Failed to add the domain", - "bypassAdded": "Domain {{domain}} added to bypass", - "bypassDuplicate": "Domain {{domain}} was already bypassed", - "bypassInvalidDomain": "Invalid domain: expected something like example.ru", - "bypassReset": "Reset all", - "bypassResetTitle": "Remove all bypass rules?", - "bypassResetBody": "Only domains added to the bypass list are removed. The server's base routing rules (ads and private subnets blocking) stay intact.", - "bypassResetDone": "All bypass rules removed", - "bypassResetFailed": "Failed to reset bypass rules", - "bypassCount": "domains: {{count}}", - "bypassEmpty": "The list is empty: all traffic goes through the tunnel.", - "bypassRemoveTitle": "Remove from bypass", - "bypassRemoved": "Domain {{domain}} removed from bypass", - "bypassRemoveFailed": "Failed to remove the domain", - "bypassGroupOn": "Group «{{title}}» enabled", - "bypassGroupOff": "Group «{{title}}» disabled", - "bypassGroupFailed": "Failed to toggle the group" + "expirePicked": "Selected: {{date}}" } } diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index 9eeafc9..9f8e445 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -277,34 +277,6 @@ "createExpire": "Срок действия", "createExpireSet": "Срок: {{date}}", "createExpireHint": "Профиль отключится автоматически в выбранную дату. Можно оставить бессрочным.", - "expirePicked": "Выбрано: {{date}}", - "bypassTitle": "Обход VPN (Bypass)", - "bypassHint": "Домены из списка идут напрямую, минуя туннель: российские банки, госуслуги и стриминг не блокируют зарубежные IP. Готовые группы — чекбоксами, свой домен — вручную.", - "bypassLoad": "Загрузить список", - "bypassLoading": "Загружаю…", - "bypassLoadFailed": "Не удалось получить bypass-правила", - "bypassGroups": "Готовые группы", - "bypassGroupStateOn": "включена", - "bypassGroupStatePartial": "частично", - "bypassGroupStateOff": "выключена", - "bypassAddLabel": "Свой домен", - "bypassAdd": "Добавить", - "bypassAddFailed": "Не удалось добавить домен", - "bypassAdded": "Домен {{domain}} добавлен в обход", - "bypassDuplicate": "Домен {{domain}} уже был в обходе", - "bypassInvalidDomain": "Некорректный домен: нужен вид example.ru", - "bypassReset": "Сбросить всё", - "bypassResetTitle": "Удалить все bypass-правила?", - "bypassResetBody": "Будут удалены только домены, добавленные в обход. Базовые правила маршрутизации сервера (блокировка рекламы, приватных подсетей) останутся.", - "bypassResetDone": "Все bypass-правила удалены", - "bypassResetFailed": "Не удалось сбросить bypass-правила", - "bypassCount": "доменов: {{count}}", - "bypassEmpty": "Список пуст: весь трафик идёт через туннель.", - "bypassRemoveTitle": "Убрать из обхода", - "bypassRemoved": "Домен {{domain}} убран из обхода", - "bypassRemoveFailed": "Не удалось убрать домен", - "bypassGroupOn": "Группа «{{title}}» включена", - "bypassGroupOff": "Группа «{{title}}» выключена", - "bypassGroupFailed": "Не удалось переключить группу" + "expirePicked": "Выбрано: {{date}}" } } diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index 8db8fd2..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": "加载列表", @@ -275,34 +275,6 @@ "createExpire": "有效期", "createExpireSet": "有效期:{{date}}", "createExpireHint": "配置将在所选日期自动停用。留空表示永久有效。", - "expirePicked": "已选择:{{date}}", - "bypassTitle": "绕过 VPN(分流)", - "bypassHint": "列表中的域名直连、不走隧道:俄罗斯银行、政务服务和流媒体不会封锁境外 IP。内置分组用开关切换,自有域名手动添加。", - "bypassLoad": "加载列表", - "bypassLoading": "正在加载…", - "bypassLoadFailed": "无法获取分流规则", - "bypassGroups": "内置分组", - "bypassGroupStateOn": "已启用", - "bypassGroupStatePartial": "部分启用", - "bypassGroupStateOff": "已关闭", - "bypassAddLabel": "自定义域名", - "bypassAdd": "添加", - "bypassAddFailed": "无法添加域名", - "bypassAdded": "域名 {{domain}} 已加入直连", - "bypassDuplicate": "域名 {{domain}} 已在直连列表中", - "bypassInvalidDomain": "域名无效:应为 example.ru 这样的形式", - "bypassReset": "全部重置", - "bypassResetTitle": "删除全部分流规则?", - "bypassResetBody": "只会删除加入直连的域名,服务器的基础路由规则(广告与私有网段拦截)会保留。", - "bypassResetDone": "已删除全部分流规则", - "bypassResetFailed": "无法重置分流规则", - "bypassCount": "域名数:{{count}}", - "bypassEmpty": "列表为空:所有流量都经过隧道。", - "bypassRemoveTitle": "从直连中移除", - "bypassRemoved": "域名 {{domain}} 已从直连中移除", - "bypassRemoveFailed": "无法移除域名", - "bypassGroupOn": "分组「{{title}}」已启用", - "bypassGroupOff": "分组「{{title}}」已关闭", - "bypassGroupFailed": "无法切换分组" + "expirePicked": "已选择:{{date}}" } } diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index cd91597..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; @@ -560,100 +558,13 @@ color: var(--warning) !important; } -.bypassSection { - display: flex; - flex-direction: column; - gap: 14px; -} - -.bypassGroups { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 8px; -} - -.bypassGroupCard { - display: flex; - flex-direction: column; - gap: 2px; - text-align: left; - background: var(--bg-1); - border: 1px solid var(--border); - border-radius: 10px; - padding: 10px 12px; - cursor: pointer; - color: var(--text-1); - transition: border-color 0.15s ease; -} - -.bypassGroupCard:hover:not(:disabled) { - border-color: var(--accent); -} - -.bypassGroupCardActive { - border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 10%, var(--bg-1)); -} - -.bypassGroupTitle { - font-size: 13px; - font-weight: 600; - color: var(--text-0); -} - -.bypassGroupCount { - font-size: 11px; - color: var(--text-2); -} - -.bypassDomainList { - display: flex; - flex-wrap: wrap; - gap: 6px; - max-height: 180px; - overflow-y: auto; -} - -.bypassDomainItem { - display: inline-flex; - align-items: center; - gap: 4px; - font-family: var(--font-mono); - font-size: 11px; - color: var(--text-1); - background: var(--bg-1); - border: 1px solid var(--border); - border-radius: 8px; - padding: 3px 6px 3px 8px; -} - -.bypassDomainRemove { - display: inline-flex; - align-items: center; - background: none; - border: none; - color: var(--text-2); - cursor: pointer; - padding: 2px; - border-radius: 4px; -} - -.bypassDomainRemove:hover { - color: var(--danger); -} - -.bypassRow { +.inlineRow { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; } -.bypassEmpty { - font-size: 12px; - color: var(--text-2); -} - /* Сервер старой версии: новые возможности недоступны до обновления менеджера. */ .serverTooOld { font-size: 12px; @@ -768,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 62199ab..f9a9f8d 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -22,7 +22,6 @@ import type { Server, ServerProfile, SniEntry, SshAccessInput } from '@shared/ty import { describeExpire, isFutureDate, presetDate } from '@shared/expire' import { todayIso } from '@shared/calendar' import { isSshAccessReady, SshAccessForm } from '../components/SshAccessForm' -import { BypassSection } from '../components/BypassSection' import { CalendarPicker } from '../components/CalendarPicker' import { shouldAutoConnectServer } from './server-access' import styles from './ServerSettings.module.css' @@ -841,14 +840,6 @@ export function ServerSettings({ ))} - - )} @@ -1039,7 +1030,7 @@ export function ServerSettings({ )}
{t('settings.expireSelect')} -
+
{[7, 30, 90, 365].map((d) => (