From 49a1ec8d20344317a397eb2ac195d1415e75fc83 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:03:25 +0300 Subject: [PATCH 01/35] =?UTF-8?q?docs:=20=D0=B7=D0=B0=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B4=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D0=B9=D0=BD=20onboarding=20GUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-09-23-server-onboarding-design.md | 324 ++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-23-server-onboarding-design.md diff --git a/docs/superpowers/specs/2026-09-23-server-onboarding-design.md b/docs/superpowers/specs/2026-09-23-server-onboarding-design.md new file mode 100644 index 0000000..15946e1 --- /dev/null +++ b/docs/superpowers/specs/2026-09-23-server-onboarding-design.md @@ -0,0 +1,324 @@ +# Дизайн расширенного добавления серверов в Electron GUI + +## Статус + +Утверждён пользователем для реализации в ветке `dev`. + +Перед началом работы локальная ветка `dev` была fast-forward-синхронизирована с актуальной `main` (`dbedd4c`). Изменения этой задачи должны остаться локальными до отдельного подтверждения push. + +## Цель + +Сделать первый запуск Xrayebator понятным для двух разных ситуаций: + +1. пользователь хочет установить Xrayebator на чистый VPS; +2. пользователь уже установил Xrayebator на VPS и хочет только добавить этот сервер в GUI. + +Дополнительно нужно убрать безусловное требование email из первого сценария и дать пользователю безопасно повторно использовать выбранный SSH-ключ после перезапуска приложения. + +## Решения и границы + +### Поддерживаемая установка для импорта + +Импорт поддерживает только установку, распознаваемую как Xrayebator. Произвольные конфигурации Xray и сторонние панели не импортируются автоматически. + +Признаки и состояние проверяются серверной командой Xrayebator в read-only режиме. Наличие файла с похожим именем само по себе не считается достаточным признаком. + +### Поведение неполной установки + +Неполная или частично сломанная установка импортируется с диагностическим статусом. Импорт не запускает `quickstart`, `happ-setup`, установщик, обновление или исправление конфигурации без отдельного действия пользователя. + +Карточка должна честно показывать состояние компонентов, например: + +- менеджер найден; +- Xray работает; +- профили найдены; +- публичная подписка недоступна. + +Если публичный subscription URL не подтверждён, GUI не сохраняет мёртвый URL как рабочий и не показывает фиктивные ключи. Страница настроек всё равно доступна для работы с найденными профилями по SSH. + +### Email + +Email не является требованием SSH, Xray или профилей. Он нужен только Certbot/ACME как контакт для уведомлений и восстановления аккаунта. + +В сценарии нового сервера GUI предлагает два взаимоисключающих режима: + +- **Указать email** — поле email, выбранное по умолчанию; +- **Продолжить без email** — явное предупреждение об отсутствии уведомлений и контакта восстановления. + +Серверный non-interactive CLI получает один из явных вариантов: + +```text +xrayebator quickstart --email user@example.com +xrayebator quickstart --without-email +``` + +Второй вариант передаёт Certbot флаг `--register-unsafely-without-email`, не подставляет фиктивный адрес и не записывает email в конфигурацию. Интерактивные терминальные сценарии с собственными email-промптами не меняются этой задачей. + +### SSH-аутентификация + +Для импорта существующего сервера в UI предлагается только приватный SSH-ключ. В развёртывании нового сервера сохраняются оба текущих варианта: пароль и ключ. + +Пароли SSH и sudo остаются session-only. Passphrase приватного ключа не сохраняется и после перезапуска приложения запрашивается заново, если ключ её требует. + +### Локальное хранение SSH-ключа + +После выбора приватного ключа через native Electron file dialog main process: + +1. проверяет размер и тип файла; +2. читает байты только в main process; +3. сохраняет байты в системное хранилище через `keytar`; +4. возвращает renderer только непривилегированный идентификатор записи и отображаемое имя файла; +5. передаёт байты в `ssh2` только на время операции. + +`keytar` использует системный механизм ОС: Windows Credential Manager, macOS Keychain или Secret Service на Linux. Ключ не сохраняется в `electron-store`, renderer state, URL, shell-команду или логи. + +Если системное хранилище недоступно, приложение не делает небезопасный plaintext-фолбек. Текущая операция может завершиться с ключом из выбранного файла, но GUI сообщает, что автоматическое повторное использование не включено и при следующем запуске потребуется выбрать ключ снова. + +### Host key + +Существующий TOFU host-key pinning сохраняется без ослабления: + +- fingerprint принимается только после успешной аутентификации; +- последующее несовпадение блокирует SSH до выполнения удалённых команд; +- сброс pin возможен только отдельным подтверждённым действием. + +## UX и навигация + +### Пустой Dashboard + +Вместо одной неоднозначной кнопки показываются две равноправные карточки: + +1. **Развернуть новый сервер** + - пояснение: «Установить Xrayebator на чистый VPS»; +2. **Подключить существующий** + - пояснение: «Найти установленный Xrayebator и открыть его панель». + +Формулировки намеренно описывают действие, а не предполагают, что сервер уже существует внутри приложения. + +На Dashboard с уже сохранёнными карточками кнопка `+ Добавить` открывает тот же выбор двух сценариев, не дублируя отдельные непереводимые действия. + +После успешного импорта сервер сохраняется и открывается страница Server Settings: это сразу показывает найденную панель и состояние профилей. Пользователь может вернуться на Dashboard и открыть Keys, если подписка действительно доступна. + +### Новый сервер + +Текущий мастер развёртывания сохраняется, но поле email заменяется блоком выбора режима. Кнопка запуска недоступна, если: + +- не заполнен host; +- не выбран валидный SSH-доступ; +- выбран режим email, но адрес пуст или некорректен. + +При режиме без email кнопка доступна после заполнения SSH и показывает предупреждение рядом с выбором, а не скрывает последствия. + +### Существующий сервер + +Новый мастер импорта содержит: + +- host/IP; +- SSH-порт; +- имя SSH-пользователя; +- native выбор приватного SSH-ключа; +- root или sudo режим; +- прогресс read-only диагностики. + +Парольный способ в этом мастере не предлагается. + +Прогресс импорта: + +1. SSH-подключение и проверка host key; +2. проверка, что удалённая команда является Xrayebator; +3. read-only инспекция Xray, профилей и subscription service; +4. проверка доступности публичной подписки, если URL существует; +5. сохранение карточки и keychain-ссылки. + +Ошибки должны различать: + +- SSH не прошёл аутентификацию; +- host key изменился; +- Xrayebator не найден или не распознан; +- установка распознана, но отдельный компонент недоступен; +- keychain не поддерживается или временно недоступен. + +## Архитектура + +### Общая модель сервера + +Публичная модель `Server` расширяется диагностическим состоянием, но не содержит байты ключа и внутренний keychain secret: + +```text +setupStatus: ready | partial | unknown +serverDiagnostics: + manager: detected | missing | invalid | unknown + xray: running | stopped | missing | unknown + profiles: available | empty | missing | unknown + subscription: public | localOnly | missing | unreachable | unknown + inspectedAt: ISO timestamp +privateKeyName: отображаемое имя, опционально +``` + +Внутренняя запись `electron-store` дополнительно содержит непрозрачный `privateKeyCredentialId`. Этот идентификатор не является секретом и не заменяет host-key pin. Байты ключа находятся только в keychain. + +Текущий `privateKeyPath` поддерживается для миграции старых записей. При следующей успешной операции по ранее выбранному пути ключ переносится в keychain; plaintext-файл пользователя не перемещается и не удаляется. + +### Keychain adapter + +Создаётся отдельный main-process модуль `ssh-keychain` с узким интерфейсом: + +```text +save(credentialId, privateKeyBytes) +load(credentialId) -> Buffer +remove(credentialId) +``` + +Сервис keytar фиксирован приложением, account идентифицирует случайный credential id. Adapter: + +- ограничивает размер ключа тем же лимитом, что и текущая проверка файла; +- хранит base64 только внутри keytar API, если API принимает строку; +- возвращает копию Buffer для SSH; +- очищает временные буферы после ошибки/закрытия операции; +- не логирует id, содержимое или passphrase. + +Удаление карточки удаляет keychain-запись только если на неё больше не ссылается другой сервер. + +### SSH credential resolution + +`credentialsFor` становится асинхронным. Порядок разрешения ключа: + +1. выбранный в текущей форме keychain id; +2. keychain id сохранённого сервера; +3. одобренный legacy path из текущей сессии; +4. ошибка с предложением выбрать ключ. + +Passphrase берётся только из текущей формы. После `SshClient.close()` приватный Buffer заполняется нулями, как и сейчас. + +Renderer никогда не передаёт произвольный путь как способ обойти native dialog. Для legacy path сохраняется текущий `approvedPrivateKeyPaths` guard. + +### Серверная read-only инспекция + +В `xrayebator` добавляется отдельная JSON-команда, например: + +```text +xrayebator inspect --json +``` + +Она не должна вызывать миграции, изменять файлы, перезапускать сервисы, устанавливать пакеты, открывать firewall или создавать профили. + +Команда проверяет и возвращает минимальный JSON-контракт: + +- распознана ли установка Xrayebator; +- ОС; +- наличие Xray и `config.json`; +- состояние `xray.service`; +- наличие профилей и число профилей/маршрутов; +- наличие и состояние `xrayebator-sub.service`; +- subscription mode и markers; +- subscription URL только для валидного профиля и непустой базы; +- сохранённые geo-поля, если они уже есть. + +Статус публичного URL дополнительно подтверждается main process через существующий `fetchSubscription`. Локальный URL (`127.0.0.1`, `localhost`) не считается публичным и не превращается в рабочий ключ для desktop-клиента. + +### Импорт и сохранение + +`ServerInspector` подключается тем же `SshClient`, использует тот же host-key pinning и выполняет только инспекционные команды. Результат нормализуется в публичную модель `Server`. + +При наличии публичного URL main process пытается получить subscription и сохраняет только успешно полученные ключи. При неудаче карточка остаётся импортированной, но получает `unreachable`/`partial` состояние. + +Импорт по тому же host и SSH-порту не создаёт дубликат. Существующая карточка обновляется диагностикой, профилями, URL/ключами и connection metadata; пользовательские server id и host-key pin сохраняются. + +## IPC-контракты + +Добавляются типизированные методы: + +```text +servers:import(payload) -> ImportResult +ssh:selectPrivateKey() -> { credentialId, name } | null +ssh:removeCredential(credentialId) -> void // только для очистки orphan/staged записей +``` + +Существующий `deploy:start` получает явный `emailMode` и опциональный `email` вместо безусловной строки. + +`ImportResult` возвращает сохранённый `serverId`, диагностику и список ключей. Секреты SSH и keychain bytes в IPC-ответы не входят. + +Все операции профилей, update и uninstall используют один resolver, поэтому сохранённый ключ работает одинаково для Server Settings, обновления и удаления. + +## Ошибки и отказоустойчивость + +- Ошибка keychain не приводит к записи ключа в plaintext. +- Ошибка fetch subscription не откатывает успешный read-only импорт. +- Ошибка сохранения карточки удаляет только временную keychain-запись, если она ещё не привязана к серверу. +- Ошибка host-key pin останавливает импорт до любой диагностики. +- Невалидный JSON inspection считается ошибкой протокола, а не частичной установкой. +- Серверная команда инспекции печатает JSON только в stdout; диагностический текст идёт в stderr. +- Новые server-side JSON-команды не должны ломать существующие CLI-парсеры `extractJson`. +- Повторный импорт идемпотентен по host+port. + +## Тестирование + +### Unit-тесты TypeScript + +Добавить тесты для: + +- выбора quickstart аргументов с email и без email; +- валидации пустого/некорректного email только в режиме `provided`; +- keychain adapter с mock `keytar` (save/load/remove, ошибка keychain, очистка Buffer); +- разрешения credentials: keychain id, legacy path, отсутствие доступа; +- сохранения/нормализации диагностического статуса; +- разбора inspection JSON и выбора subscription URL; +- upsert импорта без дубликата; +- удаления keychain-секрета только после исчезновения последней ссылки; +- UI-facing payload не содержит private-key bytes и passphrase. + +### Bash validation + +Добавить проверки, что: + +- `quickstart_command` принимает `--without-email`; +- без email используется `--register-unsafely-without-email`; +- с email сохраняется `-m email`; +- fake email не подставляется; +- inspect-команда присутствует и не запускает установочные/мутационные пути; +- JSON stdout инспекции не загрязняется цветным статусом. + +### Существующие проверки + +Запустить: + +```text +bash -n xrayebator +bash -n install.sh +bash -n update.sh +bash -n uninstall.sh +npm run typecheck +npm test +npm run build +``` + +Известное локальное ограничение Windows-теста с `/bin/sh` сохраняется и должно быть явно отражено в отчёте; Linux CI остаётся источником истины для этого теста. + +## Документация + +Обновить синхронно: + +- `docs/desktop-gui.md`; +- `docs/ru/desktop-gui.md`; +- `docs/zh-CN/desktop-gui.md`; +- `docs/security.md`; +- `docs/ru/security.md`; +- при необходимости README-разделы о GUI. + +Документация должна прямо указывать: + +- email опционален только при явном выборе режима; +- отсутствие email отключает ACME-уведомления; +- импорт только Xrayebator и read-only по умолчанию; +- неполная установка импортируется с ограничениями; +- ключ хранится в системном keychain, passphrase не хранится; +- подписка и VLESS-ссылки остаются bearer credentials и требуют защиты локальных данных. + +## Не входит в задачу + +- импорт произвольных Xray-конфигураций; +- автоматическое исправление или переустановка импортированной установки; +- сохранение SSH-паролей или passphrase; +- хранение приватного ключа в `electron-store` или plaintext-файле приложения; +- разработка полноценного интерактивного терминала; +- изменение существующей защиты серверных профилей, firewall, rollback или host-key pinning вне необходимого read-only API. From b4f523570fe99d847c7df656c9e4deda8297dc53 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:12:58 +0300 Subject: [PATCH 02/35] =?UTF-8?q?docs:=20=D1=81=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BB=D0=B0=D0=BD=20onboardin?= =?UTF-8?q?g=20GUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-09-23-server-onboarding.md | 916 ++++++++++++++++++ 1 file changed, 916 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-23-server-onboarding.md diff --git a/docs/superpowers/plans/2026-09-23-server-onboarding.md b/docs/superpowers/plans/2026-09-23-server-onboarding.md new file mode 100644 index 0000000..8825d8e --- /dev/null +++ b/docs/superpowers/plans/2026-09-23-server-onboarding.md @@ -0,0 +1,916 @@ +# Расширенное добавление серверов — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** В локальной ветке `dev`, уже синхронизированной с `main`, добавить необязательный email при новом развёртывании, отдельный read-only импорт установленного Xrayebator и безопасное повторное использование SSH-ключей через системный keychain. + +**Architecture:** Разделить задачу на четыре границы: pure helpers/contracts, keychain-backed credential resolution, server-side CLI/inspection, и Electron IPC/renderer. Существующие `SshClient`, host-key pinning, Bash-операции профилей и subscription fetch остаются источниками истины; новый импорт выполняет только read-only диагностику и делает idempotent upsert карточки по `host + port`. + +**Tech Stack:** Bash + jq + systemd CLI; Electron main/preload; React 19 + TypeScript; `ssh2`; `electron-store`; `keytar`; Vitest; CSS Modules; i18next (`ru`, `en`, `zh`). + +## Global Constraints + +- Работа ведётся только в локальной ветке `dev`; перед началом она уже fast-forward-синхронизирована с актуальной `main` на `dbedd4c`. +- Push в `origin/dev` или `upstream/dev` не выполнять без отдельного подтверждения пользователя. +- Импорт поддерживает только распознаваемую установку Xrayebator; произвольный Xray не импортировать автоматически. +- Импорт не запускает `quickstart`, `happ-setup`, установщик, обновление, миграции, restart, firewall или исправление конфигурации. +- Email выбирается явно: режим `provided` вызывает `quickstart --email`, режим `without` вызывает `quickstart --without-email`; фиктивный email не подставлять. +- Приватный ключ хранится только в системном keychain через `keytar`; не записывать байты ключа в `electron-store`, renderer state, URL, shell command или логи. +- Passphrase и SSH/sudo-пароли не сохранять; passphrase вводится заново, когда ключ этого требует. +- Существующий host-key TOFU pinning не ослаблять: fingerprint закреплять только после успешной аутентификации, mismatch блокирует команды. +- При недоступном keychain не использовать plaintext-фолбек. +- Удалённые команды строить через существующий `shellCommand`/`shellQuote`; JSON stdout server-side CLI не загрязнять статусами. +- Все jq-изменения серверного `xrayebator` выполнять через существующие safe-write/rollback правила; read-only inspect не должен менять файлы. +- Bash-комментарии и сообщения остаются на русском; identifiers и TypeScript API — на английском; UI переводится во все три locale-файла. +- Сохранять текущий legacy `privateKeyPath` как временный migration fallback; не удалять пользовательский файл ключа автоматически. +- Изменения коммитить логическими блоками; после каждого блока запускать его тесты и делать локальный commit с причиной изменения. + +--- + +## Карта файлов и границы + +### Создать + +- `src/main/core/ssh-keychain.ts` — минимальный main-process adapter над `keytar`, base64-кодирование, очистка Buffer и ошибки хранения. +- `src/main/core/server-inspector.ts` — SSH read-only инспектор, parser/normalizer inspection JSON и проверка публичной подписки. +- `src/renderer/src/pages/ImportServer.tsx` — UI мастера подключения существующей установки. +- `src/renderer/src/pages/ImportServer.module.css` — стили мастера импорта. +- `tests/unit/ssh-keychain.test.ts` — mock-тесты keychain adapter. +- `tests/unit/server-inspector.test.ts` — тесты parser/normalizer и статусов диагностики. +- `validation/test-quickstart-email-and-inspect.sh` — статические Bash-регрессии email-флагов и read-only inspect. + +### Изменить + +- `src/shared/types.ts` — email mode, keychain reference, diagnostics, import/deploy IPC contracts. +- `src/main/core/servers.ts` — persisted credential id, diagnostic state, same-endpoint upsert и безопасное удаление ссылок. +- `src/main/core/ssh-access.ts` — resolver с keychain-loaded Buffer и сохранением legacy path guard. +- `src/main/core/deployer.ts` — optional email validation/command arguments. +- `src/main/ipc-handlers.ts` — async credentials, keychain selection/import/remove, diagnostics/import handlers. +- `src/preload/index.ts` — expose typed methods for new IPC contracts. +- `src/renderer/src/App.tsx` — views `choice`/`import` и navigation. +- `src/renderer/src/pages/Dashboard.tsx`/`.module.css` — две onboarding-карточки и выбор при непустом Dashboard. +- `src/renderer/src/pages/AddServer.tsx`/`.module.css` — email mode selector и optional payload. +- `src/renderer/src/components/SshAccessForm.tsx`/`.module.css` — keychain reference, сохранённое имя ключа и import-only mode. +- `src/renderer/src/pages/ServerSettings.tsx` — использование сохранённого keychain credentials без повторного выбора и диагностика. +- `src/renderer/src/i18n/ru.json`, `en.json`, `zh.json` — все новые UI/error strings. +- `xrayebator` — `quickstart --without-email`, `inspect --json`, JSON schema и dispatch/help. +- `docs/desktop-gui.md`, `docs/ru/desktop-gui.md`, `docs/zh-CN/desktop-gui.md` — новые сценарии и protocol. +- `docs/security.md`, `docs/ru/security.md`, `docs/zh-CN/security.md` — keychain/passphrase/security boundary. + +### Только проверить + +- `package.json`/`package-lock.json` — `keytar` уже существует, новую зависимость не добавлять. +- `src/main/core/ssh-client.ts` — сохранить Buffer wipe в `close()` и host-key behavior; изменить только при необходимости для тестируемого resolver. +- `tests/unit/ssh-access.test.ts`, `ssh-client.test.ts`, `server-manager.test.ts` — расширить, не ломая существующие сценарии. + +--- + +## Task 1: Контракты email, diagnostics и keychain reference + +**Files:** +- Modify: `src/shared/types.ts` +- Test: `tests/unit/server-store.test.ts` (создать) + +**Interfaces:** +- Produces `EmailMode = 'provided' | 'without'`. +- Produces `PrivateKeyReference { credentialId: string; name: string }`. +- Produces `ServerDiagnostics` and `ServerSetupStatus`. +- Extends `Server` with optional `setupStatus`, `diagnostics`, `privateKeyName`, `privateKeyCredentialId`. +- Extends `SshAccessInput` with optional `privateKeyCredentialId` while retaining `privateKeyPath` for legacy migration. +- Changes `DeployStartPayload` to `{ host, port, emailMode, email?: string, access }`. +- Adds `ImportServerPayload`, `ImportResult`, `InspectionSnapshot`. + +- [ ] **Step 1: Write failing type-level/store test** + +Добавить тестовые fixture-объекты, которые компилируются только при наличии новых полей: + +```ts +const partialDiagnostics: ServerDiagnostics = { + manager: 'detected', + xray: 'running', + profiles: 'available', + subscription: 'unreachable', + inspectedAt: '2026-09-23T00:00:00.000Z' +} + +const importPayload: ImportServerPayload = { + host: '203.0.113.10', + port: 22, + access: { + username: 'root', + authMethod: 'privateKey', + privateKeyCredentialId: 'cred-1', + privilegeMode: 'root' + } +} + +expect(importPayload.access.authMethod).toBe('privateKey') +expect(partialDiagnostics.subscription).toBe('unreachable') +``` + +- [ ] **Step 2: Run the focused test to verify missing contracts fail** + +Run: `npx vitest run tests/unit/server-store.test.ts` + +Expected: FAIL because `ServerDiagnostics`/`ImportServerPayload`/`privateKeyCredentialId` do not exist. + +- [ ] **Step 3: Add the shared contracts** + +В `src/shared/types.ts` добавить точные union types: + +```ts +export type EmailMode = 'provided' | 'without' +export type ServerSetupStatus = 'ready' | 'partial' | 'unknown' +export type DiagnosticState = 'detected' | 'missing' | 'invalid' | 'unknown' +export type XrayState = 'running' | 'stopped' | 'missing' | 'unknown' +export type ProfilesState = 'available' | 'empty' | 'missing' | 'unknown' +export type SubscriptionState = 'public' | 'localOnly' | 'missing' | 'unreachable' | 'unknown' + +export interface PrivateKeyReference { + credentialId: string + name: string +} + +export interface ServerDiagnostics { + manager: DiagnosticState + xray: XrayState + profiles: ProfilesState + subscription: SubscriptionState + inspectedAt: string +} +``` + +Добавить `privateKeyCredentialId?: string | null`, `privateKeyName?: string | null`, `setupStatus?: ServerSetupStatus`, `diagnostics?: ServerDiagnostics | null` в `Server`; `privateKeyCredentialId?: string` в `SshAccessInput`; заменить deploy email на `emailMode` и optional `email`; добавить import/inspection interfaces и `ElectronAPI.ssh.selectPrivateKey` новый результат. + +- [ ] **Step 4: Run focused tests and typecheck** + +Run: `npx vitest run tests/unit/server-store.test.ts` и `npm run typecheck` + +Expected: PASS for the fixture test; typecheck may still report missing implementations only if API signatures are changed prematurely. Fix only contract references in this task. + +- [ ] **Step 5: Commit** + +```text +git add src/shared/types.ts tests/unit/server-store.test.ts +git commit -m "feat: описать контракты onboarding и диагностики" +``` + +--- + +## Task 2: Системный keychain adapter + +**Files:** +- Create: `src/main/core/ssh-keychain.ts` +- Create: `tests/unit/ssh-keychain.test.ts` +- Modify: `src/main/core/ssh-access.ts` only if shared constants are extracted + +**Interfaces:** +- Produces `SshKeychain` with `save(credentialId: string, key: Buffer): Promise`, `load(credentialId: string): Promise`, `remove(credentialId: string): Promise`. +- Produces `createSshKeychain(keytarModule = keytar): SshKeychain` for dependency injection in tests. +- Uses service name `com.xrayebator.gui.ssh-key` and base64 account payload; no plaintext key in electron-store. + +- [ ] **Step 1: Write failing mock tests** + +```ts +it('сохраняет base64 в keytar и возвращает копию Buffer', async () => { + const keytar = { setPassword: vi.fn(), getPassword: vi.fn().mockResolvedValue(Buffer.from('key').toString('base64')), deletePassword: vi.fn() } + const keychain = createSshKeychain(keytar) + await keychain.save('cred-1', Buffer.from('private-key')) + expect(keytar.setPassword).toHaveBeenCalledWith( + 'com.xrayebator.gui.ssh-key', + 'cred-1', + Buffer.from('private-key').toString('base64') + ) + const loaded = await keychain.load('cred-1') + expect(loaded?.toString()).toBe('key') + expect(loaded).not.toBe(Buffer.from('key')) +}) + +it('не сохраняет ключ при размере выше лимита и пробрасывает ошибку keychain', async () => { + const keytar = { setPassword: vi.fn().mockRejectedValue(new Error('keychain unavailable')) } + await expect(createSshKeychain(keytar).save('cred-1', Buffer.alloc(1024 * 1024 + 1))).rejects.toThrow('слишком большой') + await expect(createSshKeychain(keytar).save('cred-1', Buffer.from('key'))).rejects.toThrow('keychain unavailable') +}) + +it('удаляет только указанную запись', async () => { + const keytar = { setPassword: vi.fn(), getPassword: vi.fn(), deletePassword: vi.fn().mockResolvedValue(true) } + await createSshKeychain(keytar).remove('cred-1') + expect(keytar.deletePassword).toHaveBeenCalledWith('com.xrayebator.gui.ssh-key', 'cred-1') +}) +``` + +- [ ] **Step 2: Run test to confirm failure** + +Run: `npx vitest run tests/unit/ssh-keychain.test.ts` + +Expected: FAIL because `ssh-keychain.ts` is absent. + +- [ ] **Step 3: Implement adapter** + +```ts +import keytar from 'keytar' + +const SERVICE = 'com.xrayebator.gui.ssh-key' +const MAX_PRIVATE_KEY_BYTES = 1024 * 1024 + +type KeytarLike = Pick + +export interface SshKeychain { + save(credentialId: string, key: Buffer): Promise + load(credentialId: string): Promise + remove(credentialId: string): Promise +} + +export function createSshKeychain(api: KeytarLike = keytar): SshKeychain { + const validateId = (id: string): void => { + if (!/^[A-Za-z0-9_-]{8,128}$/.test(id)) throw new Error('Некорректный идентификатор SSH-ключа') + } + return { + async save(id, key) { + validateId(id) + if (key.length < 1 || key.length > MAX_PRIVATE_KEY_BYTES) throw new Error('Приватный SSH-ключ слишком большой') + await api.setPassword(SERVICE, id, key.toString('base64')) + }, + async load(id) { + validateId(id) + const encoded = await api.getPassword(SERVICE, id) + if (!encoded) return null + const key = Buffer.from(encoded, 'base64') + if (key.length < 1 || key.length > MAX_PRIVATE_KEY_BYTES) { + key.fill(0) + throw new Error('В keychain записан некорректный SSH-ключ') + } + return key + }, + async remove(id) { + validateId(id) + await api.deletePassword(SERVICE, id) + } + } +} +``` + +В production-объекте keychain id генерировать `randomUUID().replace(/-/g, '')`, чтобы пройти валидацию. Не логировать id или key contents. + +- [ ] **Step 4: Run focused tests and build** + +Run: `npx vitest run tests/unit/ssh-keychain.test.ts` и `npm run typecheck` + +Expected: PASS; native `keytar` не вызывается в unit tests благодаря injection. + +- [ ] **Step 5: Commit** + +```text +git add src/main/core/ssh-keychain.ts tests/unit/ssh-keychain.test.ts +git commit -m "feat: хранить SSH-ключи в системном keychain" +``` + +--- + +## Task 3: Store migration, key references and idempotent import upsert + +**Files:** +- Modify: `src/main/core/servers.ts` +- Modify: `tests/unit/server-store.test.ts` + +**Interfaces:** +- `ServerConnectionMetadata` gains `privateKeyCredentialId?: string | null` and `privateKeyName?: string | null`. +- `ServerStore` gains `findByEndpoint(host, port)`, `upsertImported(input, connection): StoredServer`, `countCredentialReferences(credentialId, exceptId?)`, `clearCredentialReference(id)`. +- `remove` remains synchronous for store mutation; IPC performs keychain cleanup after checking reference count. + +- [ ] **Step 1: Add failing store tests** + +```ts +it('upsert импортирует один endpoint без дубликата', () => { + const store = createTestServerStore() + const first = store.upsertImported({ host: 'server.example', port: 22, name: 'server.example', diagnostics: partialDiagnostics }, connection) + const second = store.upsertImported({ host: 'server.example', port: 22, name: 'renamed', diagnostics: partialDiagnostics }, connection) + expect(second.id).toBe(first.id) + expect(store.list()).toHaveLength(1) + expect(store.get(first.id)?.name).toBe('renamed') +}) + +it('не удаляет credential reference, если его использует другой сервер', () => { + const store = createTestServerStore() + store.add({ ...baseServer, privateKeyCredentialId: 'cred-1' }) + store.add({ ...baseServer, host: 'other.example', privateKeyCredentialId: 'cred-1' }) + expect(store.countCredentialReferences('cred-1')).toBe(2) + expect(store.countCredentialReferences('cred-1', store.list()[0].id)).toBe(1) +}) +``` + +- [ ] **Step 2: Run tests and confirm failure** + +Run: `npx vitest run tests/unit/server-store.test.ts` + +Expected: FAIL because methods are absent. + +- [ ] **Step 3: Implement schema-safe store changes** + +Расширить `StoredServer` через `Omit`, нормализовать старые записи: + +```ts +privateKeyCredentialId: server.privateKeyCredentialId ?? null, +privateKeyName: server.privateKeyName ?? null, +setupStatus: server.setupStatus ?? (server.subscriptionUrl ? 'ready' : 'unknown'), +diagnostics: server.diagnostics ?? null +``` + +`upsertImported` ищет `host.toLowerCase() + port`, сохраняет прежний `id`, `createdAt`, `hostKeyFingerprint`, обновляет diagnostic/server metadata и не затирает существующие keys/subscription URL пустыми значениями. `updateConnection` сохраняет `privateKeyCredentialId` и name. + +- [ ] **Step 4: Run focused tests and typecheck** + +Run: `npx vitest run tests/unit/server-store.test.ts` и `npm run typecheck`. + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```text +git add src/main/core/servers.ts tests/unit/server-store.test.ts +git commit -m "feat: сделать импорт сервера идемпотентным" +``` + +--- + +## Task 4: Асинхронное разрешение SSH credentials + +**Files:** +- Modify: `src/main/core/ssh-access.ts` +- Modify: `src/main/ipc-handlers.ts` +- Modify: `src/shared/types.ts` if resolver types need export +- Modify: `tests/unit/ssh-access.test.ts` +- Modify: `tests/unit/ssh-client.test.ts` only if Buffer wipe assertion is added + +**Interfaces:** +- `createSshCredentials` remains pure/synchronous for file-path inputs. +- Add `createSshCredentialsFromKey(target, access, keyBuffer, options): SshCredentials` to avoid mixing keychain I/O into validation. +- Add async main helper `resolvePrivateKey(access, server, approvedPaths, keychain): Promise<{ access: SshAccessInput; privateKey?: Buffer }>`. +- `credentialsFor` becomes `async` and returns `{ credentials, access }` after keychain load. + +- [ ] **Step 1: Add failing resolver tests** + +```ts +it('берёт ключ из keychain reference, не требуя path', async () => { + const keychain = { load: vi.fn().mockResolvedValue(Buffer.from('key')), save: vi.fn(), remove: vi.fn() } + const resolved = await resolvePrivateKey({ username: 'root', authMethod: 'privateKey', privateKeyCredentialId: 'cred-1', privilegeMode: 'root' }, server, new Set(), keychain) + expect(resolved.privateKey?.toString()).toBe('key') + expect(keychain.load).toHaveBeenCalledWith('cred-1') +}) + +it('не принимает произвольный renderer path', async () => { + await expect(resolvePrivateKey({ username: 'root', authMethod: 'privateKey', privateKeyPath: 'C:/secret', privilegeMode: 'root' }, server, new Set(), keychain)).rejects.toThrow('выбран через диалог') +}) + +it('passphrase не попадает в keychain resolver', async () => { + const access = { username: 'root', authMethod: 'privateKey', privateKeyCredentialId: 'cred-1', passphrase: 'one-time', privilegeMode: 'root' as const } + const resolved = await resolvePrivateKey(access, server, new Set(), keychain) + expect(resolved.access.passphrase).toBe('one-time') + expect(keychain.save).not.toHaveBeenCalled() +}) +``` + +- [ ] **Step 2: Run focused tests to confirm failure** + +Run: `npx vitest run tests/unit/ssh-access.test.ts` + +Expected: FAIL because async key resolver is absent. + +- [ ] **Step 3: Implement resolution order** + +Порядок: `access.privateKeyCredentialId`, затем `server.privateKeyCredentialId`, затем approved legacy `privateKeyPath`. Для keychain bytes вызвать `createSshCredentialsFromKey`; для legacy path — существующий `createSshCredentials`. Если keychain возвращает `null`, сообщить «SSH-ключ не найден в системном хранилище; выберите его заново» и не читать произвольный path. + +В `ipc-handlers.ts` создать один `const keychain = createSshKeychain()` на регистрацию handlers. `ssh:selectPrivateKey` после native dialog читает/валидирует файл в main, генерирует credential id, сохраняет bytes в keychain и возвращает `{ credentialId, name }`; при ошибке не возвращать path как сохранённый secret. Для текущей операции renderer передаёт credential id, а main загружает bytes. + +После успешного auth `store.updateConnection` сохраняет только id/name/method/privilege. `SshClient.close()` оставляет существующий `privateKey?.fill(0)`. + +- [ ] **Step 4: Run SSH tests, typecheck and build** + +Run: `npx vitest run tests/unit/ssh-access.test.ts tests/unit/ssh-client.test.ts`, `npm run typecheck`, `npm run build`. + +Expected: PASS; known unrelated Windows `/bin/sh` test не смешивать с focused run. + +- [ ] **Step 5: Commit** + +```text +git add src/main/core/ssh-access.ts src/main/ipc-handlers.ts src/shared/types.ts tests/unit/ssh-access.test.ts tests/unit/ssh-client.test.ts +git commit -m "feat: разрешать SSH-ключ из системного хранилища" +``` + +--- + +## Task 5: Optional email в server-side quickstart + +**Files:** +- Modify: `xrayebator` +- Create: `validation/test-quickstart-email-and-inspect.sh` (inspect part is completed in Task 6; add email assertions now) + +**Interfaces:** +- `quickstart_command` accepts `--email VALUE` and `--without-email`. +- `--email` validates `^[^@]+@[^@]+\.[^@]+$`. +- `--without-email` sets `email_mode=without` and never passes `-m`. +- Certbot command in quickstart contains `--register-unsafely-without-email` only in without mode. + +- [ ] **Step 1: Write static failing Bash checks** + +В validation script проверять: + +```bash +quickstart_block=$(tr -d '\r' < xrayebator | sed -n '/^quickstart_command()/,/^happ_setup_command()/p') +grep -Fq -- '--without-email' <<< "$quickstart_block" +grep -Fq -- '--register-unsafely-without-email' <<< "$quickstart_block" +grep -Fq -- ' -m "$email" ' <<< "$quickstart_block" +! grep -Fq 'email="noreply@' <<< "$quickstart_block" +``` + +До реализации тест должен завершиться ненулевым кодом. + +- [ ] **Step 2: Run the failing validation** + +Run: `bash validation/test-quickstart-email-and-inspect.sh` + +Expected: FAIL on missing `--without-email`. + +- [ ] **Step 3: Implement explicit email mode** + +В начале `quickstart_command` добавить: + +```bash +local email="" email_mode="provided" +while [[ $# -gt 0 ]]; do + case "$1" in + --email) email="$2"; email_mode="provided"; shift 2 ;; + --without-email) email=""; email_mode="without"; shift ;; + *) shift ;; + esac +done +if [[ "$email_mode" == "provided" ]] && { [[ -z "$email" ]] || ! [[ "$email" =~ ^[^@]+@[^@]+\.[^@]+$ ]]; }; then + echo '{"ok":false,"error":"Некорректный email"}' + return 1 +fi +``` + +Около certbot-вызова собрать `certbot_email_args=()` и добавить либо `-m "$email"`, либо `--register-unsafely-without-email`; вызвать `"$certbot_bin" certonly ... "${certbot_email_args[@]}" ...`. Не применять изменение к интерактивным domain/self-steal меню. + +Обновить help dispatch с двумя примерами. + +- [ ] **Step 4: Run syntax and validation** + +Run: `bash -n xrayebator` и `bash validation/test-quickstart-email-and-inspect.sh`. + +Expected: PASS for email assertions. + +- [ ] **Step 5: Commit** + +```text +git add xrayebator validation/test-quickstart-email-and-inspect.sh +git commit -m "feat: разрешить quickstart без email" +``` + +--- + +## Task 6: Read-only server inspection CLI и inspector adapter + +**Files:** +- Modify: `xrayebator` +- Modify: `src/main/core/server-inspector.ts` +- Create: `tests/unit/server-inspector.test.ts` +- Modify: `validation/test-quickstart-email-and-inspect.sh` + +**Interfaces:** +- Remote command: `xrayebator inspect --json`. +- JSON fields: `ok`, `recognized`, `os`, `manager`, `xray`, `profiles`, `profile_count`, `route_count`, `subscription_installed`, `subscription_mode`, `subscription_domain`, `subscription_port`, `subscription_url`, `country`, `city`, `flag`, `error`. +- `ServerInspector.inspect(): Promise` executes one elevated read-only command and parses JSON. +- `normalizeInspection(snapshot, fetchedKeys): ServerDiagnostics + Partial` is pure. + +- [ ] **Step 1: Write parser and static safety tests first** + +```ts +it('нормализует готовую публичную установку', () => { + const result = normalizeInspection({ ok: true, recognized: true, xray: 'running', profiles: 'available', subscription_mode: 'ip_tls', subscription_url: 'https://203.0.113.10:8443/sub/token', profile_count: 1, route_count: 7, ...baseInspection }, [{ name: 'route', url: 'vless://x', transport: 'xhttp' }]) + expect(result.setupStatus).toBe('ready') + expect(result.subscriptionUrl).toContain('https://') + expect(result.routesCount).toBe(1) +}) + +it('не считает local-only URL рабочей подпиской', () => { + const result = normalizeInspection({ ...baseInspection, subscription_mode: 'local_only', subscription_url: 'http://127.0.0.1:8080/sub/token' }, []) + expect(result.setupStatus).toBe('partial') + expect(result.subscriptionUrl).toBe('') + expect(result.diagnostics?.subscription).toBe('localOnly') +}) +``` + +В validation добавить проверки: + +```bash +inspect_block=$(tr -d '\r' < xrayebator | sed -n '/^inspect_command()/,/^quickstart_command()/p') +grep -Fq 'inspect --json' <(tr -d '\r' < xrayebator) +grep -Fq 'systemctl is-active' <<< "$inspect_block" +! grep -Eq 'apt-get|safe_jq_write|systemctl restart|open_firewall_port|install_subscription_server' <<< "$inspect_block" +``` + +- [ ] **Step 2: Run failing tests** + +Run: `npx vitest run tests/unit/server-inspector.test.ts` и `bash validation/test-quickstart-email-and-inspect.sh`. + +Expected: FAIL because parser/CLI are absent. + +- [ ] **Step 3: Implement Bash `inspect_command`** + +Добавить функцию перед `quickstart_command`, которая: + +1. выставляет `TERM=dumb`, `XRAYEBATOR_NONINTERACTIVE=1`; +2. проверяет `/usr/local/bin/xrayebator` через текущий dispatch only after command is already running (сам факт запуска — marker, дополнительно проверить `/usr/local/etc/xray`); +3. читает `/etc/os-release`, `command -v xray`, `config.json`, `profiles/*.json`; +4. получает service states только через `systemctl is-active --quiet` без restart; +5. читает существующие subscription markers и строит URL только при валидном token/base; +6. печатает один `jq -n` JSON в stdout; +7. всё диагностическое сообщение отправляет в stderr. + +Добавить dispatch case `inspect) inspect_command "$@" ;;` и help. Не вызывать migration/helper, который пишет состояние. + +- [ ] **Step 4: Implement TypeScript inspector and parser** + +`ServerInspector` использует `SshClient`, выполняет `shellCommand('xrayebator', ['inspect', '--json'])` с `{ elevated: true }`, вызывает `extractJson` из `profiles.ts`, проверяет `recognized`, и в `finally` закрывает client. При наличии непустого non-local URL вызывает injected `fetchSubscription`; fetch error переводит subscription в `unreachable`, но не отменяет import. + +`normalizeInspection` не принимает `127.0.0.1`, `localhost`, `http://` или `local_only` за public URL. `ready` только если manager detected, xray running, profiles available и subscription public; иначе `partial`, а при manager missing/invalid — `unknown`/ошибка import. + +- [ ] **Step 5: Run validation and unit tests** + +Run: `bash -n xrayebator`, `bash validation/test-quickstart-email-and-inspect.sh`, `npx vitest run tests/unit/server-inspector.test.ts`, `npm run typecheck`. + +Expected: PASS; inspect test must prove no mutating command strings appear in the function. + +- [ ] **Step 6: Commit** + +```text +git add xrayebator src/main/core/server-inspector.ts tests/unit/server-inspector.test.ts validation/test-quickstart-email-and-inspect.sh +git commit -m "feat: добавить read-only диагностику Xrayebator" +``` + +--- + +## Task 7: Deployer optional email and main-process import IPC + +**Files:** +- Modify: `src/main/core/deployer.ts` +- Modify: `src/main/ipc-handlers.ts` +- Modify: `src/preload/index.ts` +- Modify: `src/shared/types.ts` +- Modify: `tests/unit/deployer.test.ts` (создать) +- Modify: `tests/unit/ipc-contracts.test.ts` (создать, pure payload tests) + +**Interfaces:** +- `DeployInput = { emailMode: EmailMode; email?: string; credentials }`. +- `Deployer.deploy` validates email only for `provided`, then invokes `quickstart --email` or `quickstart --without-email`. +- `ServerInspector` is constructed with credentials and injected subscription fetch. +- IPC `servers:import` returns `ImportResult`; no private key bytes in payload/response. + +- [ ] **Step 1: Write failing deploy argument tests** + +```ts +it('строит quickstart с email', () => expect(buildQuickstartArgs({ emailMode: 'provided', email: 'a@example.com' })).toEqual(['quickstart', '--email', 'a@example.com'])) +it('строит quickstart без email', () => expect(buildQuickstartArgs({ emailMode: 'without' })).toEqual(['quickstart', '--without-email'])) +it('отклоняет email только в provided mode', () => expect(() => buildQuickstartArgs({ emailMode: 'provided', email: '' })).toThrow('email')) +``` + +Добавить pure `buildQuickstartArgs` export из deployer, чтобы не мокать SSH весь unit test. + +- [ ] **Step 2: Run failing test** + +Run: `npx vitest run tests/unit/deployer.test.ts` + +Expected: FAIL because helper/signature absent. + +- [ ] **Step 3: Implement deploy command selection** + +В `Deployer.deploy` убрать unconditional `if (!input.email)`; использовать `buildQuickstartArgs`, передать args через существующий `shellCommand`, сохранить parsing/result behavior. `emailMode='without'` не должен иметь `email` в логах. + +- [ ] **Step 4: Add async IPC keychain/import handlers** + +В `registerIpcHandlers`: + +- создать keychain adapter; +- сделать `credentialsFor` async; +- `ssh:selectPrivateKey` сохраняет bytes и возвращает только `{ credentialId, name }`; +- `servers:import` валидирует `authMethod === 'privateKey'`, вызывает `ServerInspector`, выполняет optional subscription fetch, upsert store, сохраняет connection metadata и emits/returns result; +- не сохранять imported server при `recognized=false`; +- при частичной установке сохранить карточку без мёртвого subscription URL; +- при duplicate endpoint обновить одну карточку; +- `servers:remove` после store removal удаляет credential из keychain только при `countCredentialReferences === 0`; +- сохранить существующее host-key cleanup. + +Expose in preload: + +```ts +ssh: { selectPrivateKey: () => Promise } +servers: { import: (payload: ImportServerPayload) => Promise } +``` + +- [ ] **Step 5: Run tests, typecheck and build** + +Run: `npx vitest run tests/unit/deployer.test.ts tests/unit/ipc-contracts.test.ts tests/unit/ssh-access.test.ts`, `npm run typecheck`, `npm run build`. + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```text +git add src/main/core/deployer.ts src/main/ipc-handlers.ts src/preload/index.ts src/shared/types.ts tests/unit/deployer.test.ts tests/unit/ipc-contracts.test.ts + git commit -m "feat: подключить email mode и импорт через IPC" +``` + +--- + +## Task 8: UI выбора сценария и optional email + +**Files:** +- Modify: `src/renderer/src/App.tsx` +- Modify: `src/renderer/src/pages/Dashboard.tsx` +- Modify: `src/renderer/src/pages/Dashboard.module.css` +- Modify: `src/renderer/src/pages/AddServer.tsx` +- Modify: `src/renderer/src/pages/AddServer.module.css` +- Modify: `src/renderer/src/i18n/ru.json`, `en.json`, `zh.json` +- Create/modify: `tests/unit/ui-contracts.test.ts` only for pure email readiness helper if useful + +**Interfaces:** +- App view union gains `{ name: 'choice' }` and `{ name: 'import' }`. +- Dashboard receives `onAdd`, which opens choice; empty dashboard renders two cards. +- AddServer uses `EmailMode`, passes `{ emailMode, email: emailMode === 'provided' ? trimmed : undefined }`. + +- [ ] **Step 1: Add pure failing readiness test** + +Export `isDeployReady(form, access)` and test: + +```ts +expect(isDeployReady({ host: 'vps', port: '22', emailMode: 'without', email: '' }, readyAccess)).toBe(true) +expect(isDeployReady({ host: 'vps', port: '22', emailMode: 'provided', email: '' }, readyAccess)).toBe(false) +expect(isDeployReady({ host: 'vps', port: '22', emailMode: 'provided', email: 'bad' }, readyAccess)).toBe(false) +``` + +- [ ] **Step 2: Run focused test and confirm failure** + +Run: `npx vitest run tests/unit/ui-contracts.test.ts` + +Expected: FAIL until helper exists. + +- [ ] **Step 3: Implement navigation and Dashboard cards** + +В `App.tsx` добавить `choice` view с двумя buttons/cards и `import` view. Успешный import добавляет/заменяет server in state и открывает `settings`, а не `keys`. + +В `Dashboard.tsx`: + +- при `servers.length === 0` показывать две карточки; +- при наличии серверов верхняя `+ Add` открывает choice; +- использовать icons `Rocket`, `Link2`, `Server` из lucide; +- сохранить существующие cards/actions/delete dialog. + +В CSS сделать обе карточки одинакового веса, primary accent только у «Развернуть», secondary outline у «Подключить существующий», responsive one-column under 760px; не использовать отдельный чёрный/новый brand color. + +- [ ] **Step 4: Implement email mode selector in AddServer** + +Заменить unconditional email field на two-option accessible radio-like buttons. Default `provided`; `without` renders warning panel. `isDisabled` uses `isDeployReady`. Ensure `startDeploy` emits optional email, not empty string. + +Добавить localization keys: + +```json +"deploy": { + "emailMode": "Email for Let's Encrypt", + "emailProvided": "Add an email", + "emailProvidedHint": "Recommended for renewal and account notices", + "emailWithout": "Continue without email", + "emailWithoutHint": "Certificate works, but Let's Encrypt cannot send notices or recovery mail", + "withoutEmailWarning": "Without an email, renewal notices and ACME account recovery are unavailable." +} +``` + +Перевести эти ключи естественно в `ru.json` и `zh.json`; не оставлять русские тексты в EN/中文. + +- [ ] **Step 5: Run tests, typecheck and build** + +Run: `npx vitest run tests/unit/ui-contracts.test.ts`, `npm run typecheck`, `npm run build`. + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```text +git add src/renderer/src/App.tsx src/renderer/src/pages/Dashboard.tsx src/renderer/src/pages/Dashboard.module.css src/renderer/src/pages/AddServer.tsx src/renderer/src/pages/AddServer.module.css src/renderer/src/i18n tests/unit/ui-contracts.test.ts +git commit -m "feat: разделить сценарии нового и существующего сервера" +``` + +--- + +## Task 9: Мастер импорта и повторный доступ к Server Settings + +**Files:** +- Create: `src/renderer/src/pages/ImportServer.tsx` +- Create: `src/renderer/src/pages/ImportServer.module.css` +- Modify: `src/renderer/src/components/SshAccessForm.tsx` +- Modify: `src/renderer/src/components/SshAccessForm.module.css` +- Modify: `src/renderer/src/pages/ServerSettings.tsx` +- Modify: `src/renderer/src/pages/ServerKeys.tsx` +- Modify: `src/renderer/src/i18n/ru.json`, `en.json`, `zh.json` + +**Interfaces:** +- Import form always sets `authMethod: 'privateKey'`, uses `privateKeyCredentialId`, and passes root/sudo mode. +- `SshAccessForm` props gain `allowedAuthMethods?: SshAuthMethod[]`, `keyReference?: PrivateKeyReference | null`, `onKeySelected?: (reference) => void`. +- Server Settings initializes saved key reference, calls API without path/password, and only asks passphrase when user supplies it for the current operation. + +- [ ] **Step 1: Add failing pure import payload tests** + +```ts +it('import payload не содержит password/privateKey bytes', () => { + const payload = buildImportPayload({ host: 'vps', port: '22', access: { username: 'root', privateKeyCredentialId: 'cred-1', privilegeMode: 'root' } }) + expect(payload.access.authMethod).toBe('privateKey') + expect(payload.access).not.toHaveProperty('password') + expect(payload.access).not.toHaveProperty('privateKey') +}) +``` + +- [ ] **Step 2: Run failing test** + +Run: `npx vitest run tests/unit/ui-contracts.test.ts` + +Expected: FAIL until import helper/form exists. + +- [ ] **Step 3: Implement ImportServer form** + +Форма повторяет host/port layout AddServer, но использует `SshAccessForm` с `allowedAuthMethods={['privateKey']}`. При выборе ключа `window.api.ssh.selectPrivateKey()` возвращает `{ credentialId, name }`; renderer хранит только reference. Кнопка disabled без host, username, credential id или valid port. + +При submit вызвать `window.api.servers.import`, показать этапы SSH → recognize → inspect → subscription → save, ошибки вывести без секретов. При `result.serverId` запросить `servers.get`, вызвать `onDone(server)`. + +- [ ] **Step 4: Update SshAccessForm and ServerSettings** + +Вместо отображения только `privateKeyPath` показывать `privateKeyName`/basename; сохранять `privateKeyCredentialId` в access state. Для старых server cards разрешить path fallback только после native dialog approval. При `load`, `create`, `update`, `uninstall`, profile actions использовать saved credential id и не требовать повторного выбора файла. + +Обновить note: + +- ключ хранится в системном keychain; +- passphrase не сохраняется; +- парольный доступ остаётся session-only; +- если keychain запись потеряна, предложить выбрать ключ заново. + +В `ServerKeys` при `subscriptionUrl === ''` показывать `keys.none`/diagnostic message вместо попытки fetch пустого URL. + +- [ ] **Step 5: Run typecheck/build and focused tests** + +Run: `npx vitest run tests/unit/ui-contracts.test.ts tests/unit/ssh-access.test.ts`, `npm run typecheck`, `npm run build`. + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```text +git add src/renderer/src/pages/ImportServer.tsx src/renderer/src/pages/ImportServer.module.css src/renderer/src/components/SshAccessForm.tsx src/renderer/src/components/SshAccessForm.module.css src/renderer/src/pages/ServerSettings.tsx src/renderer/src/pages/ServerKeys.tsx src/renderer/src/i18n +git commit -m "feat: добавить мастер импорта существующего сервера" +``` + +--- + +## Task 10: Diagnostics в карточке и документация + +**Files:** +- Modify: `src/renderer/src/pages/Dashboard.tsx`/`.module.css` +- Modify: `src/renderer/src/pages/ServerSettings.tsx`/`.module.css` +- Modify: `src/renderer/src/i18n/ru.json`, `en.json`, `zh.json` +- Modify: `docs/desktop-gui.md` +- Modify: `docs/ru/desktop-gui.md` +- Modify: `docs/zh-CN/desktop-gui.md` +- Modify: `docs/security.md` +- Modify: `docs/ru/security.md` +- Modify: `docs/zh-CN/security.md` + +**Interfaces:** +- Dashboard displays `ready`/`partial` state without hiding existing actions. +- Settings displays component diagnostics and inspected timestamp. + +- [ ] **Step 1: Add diagnostics copy and test fixture** + +Добавить localization keys for manager/xray/profiles/subscription states in all locales and a test ensuring every new RU key exists in EN and ZH. Test should compare a fixed list of paths, not arbitrary JSON shape. + +- [ ] **Step 2: Render non-invasive status** + +В server card добавить compact status chip: + +- ready → success “Ready”; +- partial → warning “Partially configured”; +- unknown → neutral “Imported”. + +В settings после подключения показать diagnostic list; не делать автоматических repair calls. Existing buttons update/uninstall remain explicit actions. + +- [ ] **Step 3: Update docs** + +В docs описать: + +- две onboarding cards и import scope; +- read-only inspection and partial state; +- optional email and ACME consequences; +- `quickstart --without-email`; +- keytar/system keychain boundary, no plaintext fallback, passphrase session-only; +- subscription URL/VLESS links remain bearer credentials; +- legacy GUI keyring claims do not describe active Electron GUI. + +Синхронизировать смысл во всех трёх языках; command names и security guarantees не переводить по-разному. + +- [ ] **Step 4: Run locale/docs checks** + +Run: `npm run typecheck`, `npm run build`, `git diff --check`. + +Expected: PASS; no missing translation key test. + +- [ ] **Step 5: Commit** + +```text +git add src/renderer/src/pages/Dashboard.tsx src/renderer/src/pages/Dashboard.module.css src/renderer/src/pages/ServerSettings.tsx src/renderer/src/pages/ServerSettings.module.css src/renderer/src/i18n docs/desktop-gui.md docs/ru/desktop-gui.md docs/zh-CN/desktop-gui.md docs/security.md docs/ru/security.md docs/zh-CN/security.md +git commit -m "docs: описать импорт и безопасное хранение SSH-ключей" +``` + +--- + +## Task 11: Полная верификация и security audit + +**Files:** +- Modify only files with verified test failures. +- Add no feature code in this task. + +- [ ] **Step 1: Verify repository and branch** + +Run: + +```text +git status --short --branch +git log --oneline --decorate -12 +git diff upstream/main...HEAD --stat +``` + +Expected: branch is `dev`, no uncommitted changes before verification, no push performed, and history contains the design plus logical implementation commits. + +- [ ] **Step 2: Run Bash syntax and validation** + +Run: + +```text +bash -n xrayebator +bash -n install.sh +bash -n update.sh +bash -n uninstall.sh +bash validation/test-quickstart-email-and-inspect.sh +bash validation/test-quickstart-migration-parity.sh +bash validation/test-quickstart-subscription-port.sh +bash validation/test-main-readiness-regressions.sh +``` + +Expected: all selected Bash checks pass. + +- [ ] **Step 3: Run TypeScript checks and tests** + +Run: + +```text +npm run typecheck +npm test +npm run build +``` + +Expected: typecheck/build pass. On Windows, if `tests/unit/shell-command.test.ts` fails solely because `/bin/sh` is absent, record the existing `38 passed / 1 failed` caveat and do not weaken the test; Linux CI remains authoritative. + +- [ ] **Step 4: Perform manual secret audit** + +Run searches: + +```text +grep -R "privateKey" src/main src/preload src/shared src/renderer --include='*.ts' --include='*.tsx' +grep -R "setPassword\|getPassword\|deletePassword" src/main --include='*.ts' +grep -R "console\.log\|onLog" src/main/core --include='*.ts' +``` + +Confirm manually: + +- only keychain adapter calls `keytar`; +- no private-key Buffer crosses preload/renderer; +- no passphrase is persisted by store or keychain; +- `SshClient.close()` wipes the operation Buffer; +- keychain failure has no plaintext fallback; +- inspect does not mutate server state; +- host-key mismatch returns before command execution; +- subscription fetch failure creates partial state, not a fake URL. + +- [ ] **Step 5: Review final diff and commit any verified fix** + +Run `git diff upstream/main...HEAD --check` and inspect every changed file. If a concrete defect is found, add a focused regression test first, fix it, rerun the relevant command, and commit with the root cause. + +- [ ] **Step 6: Final local status report** + +Use `get_goal`, verify all objective conditions, then update the goal to `complete` only if tests, typecheck/build, docs and local commits are complete. Report commit hashes, test caveats, branch synchronization point and explicitly state that no push was performed. + +--- + +## Plan self-review + +- **Spec coverage:** Tasks 1–4 cover contracts, keychain, store migration and SSH reuse; Tasks 5–6 cover optional email and read-only inspect; Tasks 7–9 cover IPC and both UI flows; Task 10 covers diagnostics/locales/docs; Task 11 covers full verification/security audit. +- **No placeholders:** There are no `TBD`, `TODO`, or unspecified implementation branches; each task defines files, interfaces, tests, commands and commit message. +- **Type consistency:** `PrivateKeyReference`, `EmailMode`, `ServerDiagnostics`, `ImportServerPayload`, `ImportResult`, and `SshKeychain` are introduced before consumers; later tasks use the same names and fields. +- **Safety check:** No task stores passphrase, SSH password, or private-key bytes outside keychain/main-process operation memory; import remains read-only and local-only URLs are never treated as public credentials. +- **Scope check:** The plan intentionally excludes arbitrary Xray import, auto-repair and terminal features; these are outside the approved design. From 4b9d26e6a93e1ccdc9792447293851f49055bc61 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:37:44 +0300 Subject: [PATCH 03/35] =?UTF-8?q?feat:=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D1=8B=20onboarding=20=D0=B8=20=D0=B4=D0=B8=D0=B0=D0=B3=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/types.ts | 77 ++++++++++++++++++++++++++++++--- tests/unit/server-store.test.ts | 31 +++++++++++++ 2 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 tests/unit/server-store.test.ts diff --git a/src/shared/types.ts b/src/shared/types.ts index ffe07fd..bb4c7d4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2,21 +2,80 @@ export type SshAuthMethod = 'password' | 'privateKey' export type SshPrivilegeMode = 'root' | 'sudo' +export type EmailMode = 'provided' | 'without' + +export type ServerSetupStatus = 'ready' | 'partial' | 'unknown' + +export type DiagnosticState = 'detected' | 'missing' | 'invalid' | 'unknown' + +export type XrayState = 'running' | 'stopped' | 'missing' | 'unknown' + +export type ProfilesState = 'available' | 'empty' | 'missing' | 'unknown' + +export type SubscriptionState = 'public' | 'localOnly' | 'missing' | 'unreachable' | 'unknown' + +export interface PrivateKeyReference { + credentialId: string + name: string +} + +/** Legacy file-picker response retained until keychain IPC is wired end-to-end. */ +export interface PrivateKeySelection { + path: string + name: string +} + +export interface ServerDiagnostics { + manager: DiagnosticState + xray: XrayState + profiles: ProfilesState + subscription: SubscriptionState + inspectedAt: string +} + +export interface InspectionSnapshot { + ok: boolean + recognized: boolean + os: string | null + manager: DiagnosticState + xray: XrayState + profiles: ProfilesState + profile_count: number + route_count: number + subscription_installed: boolean + subscription_mode: string | null + subscription_domain: string | null + subscription_port: number | null + subscription_url: string | null + country: string | null + city: string | null + flag: string | null + error?: string +} + +export interface ImportServerPayload { + host: string + port: number + access: SshAccessInput +} + +export interface ImportResult { + serverId: string + diagnostics: ServerDiagnostics + keys: VlessLink[] +} + export interface SshAccessInput { username: string authMethod: SshAuthMethod password?: string privateKeyPath?: string + privateKeyCredentialId?: string passphrase?: string privilegeMode: SshPrivilegeMode sudoPassword?: string } -export interface PrivateKeySelection { - path: string - name: string -} - export interface Server { id: string name: string @@ -34,6 +93,10 @@ export interface Server { authMethod?: SshAuthMethod privilegeMode?: SshPrivilegeMode privateKeyPath?: string | null + privateKeyName?: string | null + privateKeyCredentialId?: string | null + setupStatus?: ServerSetupStatus + diagnostics?: ServerDiagnostics | null hostKeyFingerprint?: string | null } @@ -168,7 +231,9 @@ export type DeployStatus = 'pending' | 'running' | 'done' | 'error' export interface DeployStartPayload { host: string port: number - email: string + /** Optional only while the existing renderer migrates to explicit email selection. */ + emailMode?: EmailMode + email?: string access: SshAccessInput } diff --git a/tests/unit/server-store.test.ts b/tests/unit/server-store.test.ts new file mode 100644 index 0000000..0519024 --- /dev/null +++ b/tests/unit/server-store.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import type { + ImportServerPayload, + ServerDiagnostics +} from '../../src/shared/types' + +const partialDiagnostics: ServerDiagnostics = { + manager: 'detected', + xray: 'running', + profiles: 'available', + subscription: 'unreachable', + inspectedAt: '2026-09-23T00:00:00.000Z' +} + +const importPayload: ImportServerPayload = { + host: '203.0.113.10', + port: 22, + access: { + username: 'root', + authMethod: 'privateKey', + privateKeyCredentialId: 'cred-1', + privilegeMode: 'root' + } +} + +describe('shared onboarding contracts', () => { + it('accepts diagnostics and import fixtures', () => { + expect(importPayload.access.authMethod).toBe('privateKey') + expect(partialDiagnostics.subscription).toBe('unreachable') + }) +}) From 8868d1feececb30474a624d5f7efd4290e18ee2e Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:51:15 +0300 Subject: [PATCH 04/35] =?UTF-8?q?feat:=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20SSH-=D0=BA=D0=BB=D1=8E=D1=87=D0=B8=20=D0=B2=20?= =?UTF-8?q?=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=BD=D0=BE=D0=BC=20keycha?= =?UTF-8?q?in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/core/ssh-keychain.ts | 61 ++++++++++++++++++++++++ tests/unit/ssh-keychain.test.ts | 82 +++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 src/main/core/ssh-keychain.ts create mode 100644 tests/unit/ssh-keychain.test.ts diff --git a/src/main/core/ssh-keychain.ts b/src/main/core/ssh-keychain.ts new file mode 100644 index 0000000..74311ce --- /dev/null +++ b/src/main/core/ssh-keychain.ts @@ -0,0 +1,61 @@ +import keytar from 'keytar' + +export const SSH_KEYCHAIN_SERVICE = 'com.xrayebator.gui.ssh-key' +export const MAX_PRIVATE_KEY_BYTES = 1024 * 1024 + +export interface KeychainApi { + getPassword(service: string, account: string): Promise + setPassword(service: string, account: string, password: string): Promise + deletePassword(service: string, account: string): Promise +} + +export interface SshKeychain { + save(credentialId: string, key: Buffer): Promise + load(credentialId: string): Promise + remove(credentialId: string): Promise +} + +function validateCredentialId(credentialId: string): void { + if (!/^[A-Za-z0-9_-]{8,128}$/.test(credentialId)) { + throw new Error('Некорректный идентификатор SSH-ключа') + } +} + +function decodeKey(encoded: string): Buffer { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) { + throw new Error('SSH-ключ в системном хранилище повреждён') + } + const key = Buffer.from(encoded, 'base64') + if (key.length < 1) { + key.fill(0) + throw new Error('SSH-ключ в системном хранилище повреждён') + } + if (key.length > MAX_PRIVATE_KEY_BYTES) { + key.fill(0) + throw new Error('SSH-ключ в системном хранилище слишком большой') + } + return key +} + +export function createSshKeychain(api: KeychainApi = keytar): SshKeychain { + return { + async save(credentialId, key) { + validateCredentialId(credentialId) + if (key.length < 1 || key.length > MAX_PRIVATE_KEY_BYTES) { + throw new Error('Приватный SSH-ключ пустой или слишком большой') + } + await api.setPassword(SSH_KEYCHAIN_SERVICE, credentialId, key.toString('base64')) + }, + + async load(credentialId) { + validateCredentialId(credentialId) + const encoded = await api.getPassword(SSH_KEYCHAIN_SERVICE, credentialId) + return encoded === null ? null : decodeKey(encoded) + }, + + async remove(credentialId) { + validateCredentialId(credentialId) + await api.deletePassword(SSH_KEYCHAIN_SERVICE, credentialId) + } + } +} diff --git a/tests/unit/ssh-keychain.test.ts b/tests/unit/ssh-keychain.test.ts new file mode 100644 index 0000000..33b9462 --- /dev/null +++ b/tests/unit/ssh-keychain.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest' +import type { KeychainApi } from '../../src/main/core/ssh-keychain' +import { createSshKeychain } from '../../src/main/core/ssh-keychain' + +function mockApi(overrides: Partial = {}): KeychainApi { + return { + setPassword: vi.fn().mockResolvedValue(undefined), + getPassword: vi.fn().mockResolvedValue(null), + deletePassword: vi.fn().mockResolvedValue(true), + ...overrides + } +} + +describe('SSH keychain', () => { + it('saves a key as base64 and returns a fresh Buffer on load', async () => { + const key = Buffer.from('-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n') + const api = mockApi({ + getPassword: vi.fn().mockResolvedValue(key.toString('base64')) + }) + const keychain = createSshKeychain(api) + + await keychain.save('credential_12345678', key) + const loaded = await keychain.load('credential_12345678') + + expect(api.setPassword).toHaveBeenCalledWith( + 'com.xrayebator.gui.ssh-key', + 'credential_12345678', + key.toString('base64') + ) + expect(loaded?.toString()).toBe(key.toString()) + expect(loaded).not.toBe(key) + expect(key.toString()).toContain('OPENSSH PRIVATE KEY') + }) + + it('rejects an oversized key before writing it to the OS keychain', async () => { + const api = mockApi() + const keychain = createSshKeychain(api) + + await expect(keychain.save('credential_12345678', Buffer.alloc(1024 * 1024 + 1))).rejects.toThrow( + 'слишком большой' + ) + expect(api.setPassword).not.toHaveBeenCalled() + }) + + it('rejects malformed or oversized data returned by the keychain', async () => { + const malformed = mockApi({ getPassword: vi.fn().mockResolvedValue('not base64!') }) + const keychain = createSshKeychain(malformed) + await expect(keychain.load('credential_12345678')).rejects.toThrow('повреждён') + + const oversizedValue = Buffer.alloc(1024 * 1024 + 1).toString('base64') + const oversized = mockApi({ getPassword: vi.fn().mockResolvedValue(oversizedValue) }) + await expect(createSshKeychain(oversized).load('credential_12345678')).rejects.toThrow( + 'слишком большой' + ) + }) + + it('returns null for a missing item and removes only the requested item', async () => { + const api = mockApi({ + getPassword: vi.fn().mockResolvedValue(null), + deletePassword: vi.fn().mockResolvedValue(true) + }) + const keychain = createSshKeychain(api) + + await expect(keychain.load('credential_12345678')).resolves.toBeNull() + await keychain.remove('credential_12345678') + + expect(api.deletePassword).toHaveBeenCalledWith( + 'com.xrayebator.gui.ssh-key', + 'credential_12345678' + ) + }) + + it('rejects malformed credential IDs and propagates keychain errors', async () => { + const api = mockApi({ setPassword: vi.fn().mockRejectedValue(new Error('keychain locked')) }) + const keychain = createSshKeychain(api) + + await expect(keychain.load('../bad')).rejects.toThrow('идентификатор') + await expect(keychain.save('credential_12345678', Buffer.from('key'))).rejects.toThrow( + 'keychain locked' + ) + }) +}) From 8003e6e764480fd5d7e2704e77061b3c2200020b Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:55:03 +0300 Subject: [PATCH 05/35] =?UTF-8?q?feat:=20=D1=81=D0=B4=D0=B5=D0=BB=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0=20=D0=B8=D0=B4=D0=B5=D0=BC?= =?UTF-8?q?=D0=BF=D0=BE=D1=82=D0=B5=D0=BD=D1=82=D0=BD=D1=8B=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/core/servers.ts | 83 +++++++++++++++++++++++- tests/unit/server-store.test.ts | 109 +++++++++++++++++++++++++++++++- 2 files changed, 190 insertions(+), 2 deletions(-) diff --git a/src/main/core/servers.ts b/src/main/core/servers.ts index cc95c4d..0c3f9cf 100644 --- a/src/main/core/servers.ts +++ b/src/main/core/servers.ts @@ -17,14 +17,23 @@ export interface ServerConnectionMetadata { authMethod: SshAuthMethod privilegeMode: SshPrivilegeMode privateKeyPath?: string | null + privateKeyCredentialId?: string | null + privateKeyName?: string | null } export interface ServerStore { list: () => StoredServer[] get: (id: string) => StoredServer | undefined + findByEndpoint: (host: string, port: number) => StoredServer | undefined add: (input: Omit) => StoredServer + upsertImported: ( + input: Omit, + connection: ServerConnectionMetadata + ) => StoredServer updateKeys: (id: string, keys: VlessLink[]) => StoredServer | undefined updateConnection: (id: string, input: ServerConnectionMetadata) => StoredServer | undefined + countCredentialReferences: (credentialId: string, exceptId?: string) => number + clearCredentialReference: (id: string) => StoredServer | undefined getHostKey: (host: string, port: number) => string | undefined trustHostKey: (host: string, port: number, fingerprint: string) => void forgetHostKey: (host: string, port: number) => void @@ -41,6 +50,10 @@ function normalizeServer(server: StoredServer, hostKeys: Record) authMethod: server.authMethod ?? 'password', privilegeMode: server.privilegeMode ?? 'root', privateKeyPath: server.privateKeyPath ?? null, + privateKeyName: server.privateKeyName ?? null, + privateKeyCredentialId: server.privateKeyCredentialId ?? null, + setupStatus: server.setupStatus ?? (server.subscriptionUrl ? 'ready' : 'unknown'), + diagnostics: server.diagnostics ?? null, hostKeyFingerprint: hostKeys[hostKeyId(server.host, server.port)] ?? server.hostKeyFingerprint ?? null, keys: server.keys ?? [] @@ -64,6 +77,13 @@ export function createServerStore(): ServerStore { return server ? normalizeServer(server, store.get('hostKeys')) : undefined }, + findByEndpoint(host: string, port: number): StoredServer | undefined { + const server = store.get('servers').find( + (candidate) => candidate.host.toLowerCase() === host.toLowerCase() && candidate.port === port + ) + return server ? normalizeServer(server, store.get('hostKeys')) : undefined + }, + add(input: Omit): StoredServer { const server = normalizeServer({ ...input, @@ -75,6 +95,46 @@ export function createServerStore(): ServerStore { return server }, + upsertImported( + input: Omit, + connection: ServerConnectionMetadata + ): StoredServer { + const servers = store.get('servers') + const index = servers.findIndex( + (candidate) => + candidate.host.toLowerCase() === input.host.toLowerCase() && candidate.port === input.port + ) + const existing = index >= 0 ? servers[index] : undefined + const existingNormalized = existing + ? normalizeServer(existing, store.get('hostKeys')) + : undefined + const imported: StoredServer = normalizeServer({ + ...input, + name: input.name || existing?.name || input.host, + username: connection.username, + authMethod: connection.authMethod, + privilegeMode: connection.privilegeMode, + privateKeyPath: + connection.privateKeyPath !== undefined + ? connection.privateKeyPath + : existing?.privateKeyPath ?? null, + privateKeyName: connection.privateKeyName ?? existing?.privateKeyName ?? null, + privateKeyCredentialId: + connection.privateKeyCredentialId ?? existing?.privateKeyCredentialId ?? null, + subscriptionUrl: input.subscriptionUrl || existing?.subscriptionUrl || '', + keys: input.keys?.length ? input.keys : existing?.keys ?? [], + routesCount: input.routesCount ?? existing?.routesCount ?? null, + id: existing?.id ?? randomUUID(), + createdAt: existing?.createdAt ?? new Date().toISOString(), + hostKeyFingerprint: existingNormalized?.hostKeyFingerprint ?? input.hostKeyFingerprint ?? null + }, store.get('hostKeys')) + const next = [...servers] + if (index >= 0) next[index] = imported + else next.push(imported) + store.set('servers', next) + return imported + }, + updateKeys(id: string, keys: VlessLink[]): StoredServer | undefined { const servers = store.get('servers') const idx = servers.findIndex((s) => s.id === id) @@ -95,7 +155,10 @@ export function createServerStore(): ServerStore { username: input.username, authMethod: input.authMethod, privilegeMode: input.privilegeMode, - privateKeyPath: input.privateKeyPath ?? servers[idx].privateKeyPath ?? null + privateKeyPath: input.privateKeyPath ?? servers[idx].privateKeyPath ?? null, + privateKeyCredentialId: + input.privateKeyCredentialId ?? servers[idx].privateKeyCredentialId ?? null, + privateKeyName: input.privateKeyName ?? servers[idx].privateKeyName ?? null } const next = [...servers] next[idx] = updated @@ -103,6 +166,24 @@ export function createServerStore(): ServerStore { return normalizeServer(updated, store.get('hostKeys')) }, + countCredentialReferences(credentialId: string, exceptId?: string): number { + return store + .get('servers') + .filter((server) => server.id !== exceptId && server.privateKeyCredentialId === credentialId) + .length + }, + + clearCredentialReference(id: string): StoredServer | undefined { + const servers = store.get('servers') + const index = servers.findIndex((server) => server.id === id) + if (index === -1) return undefined + const updated = { ...servers[index], privateKeyCredentialId: null } + const next = [...servers] + next[index] = updated + store.set('servers', next) + return normalizeServer(updated, store.get('hostKeys')) + }, + getHostKey(host: string, port: number): string | undefined { return store.get('hostKeys')[hostKeyId(host, port)] }, diff --git a/tests/unit/server-store.test.ts b/tests/unit/server-store.test.ts index 0519024..5df1432 100644 --- a/tests/unit/server-store.test.ts +++ b/tests/unit/server-store.test.ts @@ -1,9 +1,38 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, expectTypeOf, it, vi } from 'vitest' import type { + DeployStartPayload, + ElectronAPI, ImportServerPayload, + PrivateKeyReference, + Server, ServerDiagnostics } from '../../src/shared/types' +const fakeStore = vi.hoisted(() => ({ + data: { servers: [] as unknown[], hostKeys: {} as Record } +})) + +vi.mock('electron-store', () => ({ + default: class FakeStore { + constructor(options: { defaults: { servers: unknown[]; hostKeys: Record } }) { + fakeStore.data = { + servers: [...options.defaults.servers], + hostKeys: { ...options.defaults.hostKeys } + } + } + + get(key: 'servers' | 'hostKeys'): unknown { + return fakeStore.data[key] + } + + set(key: 'servers' | 'hostKeys', value: unknown): void { + fakeStore.data[key] = value as never + } + } +})) + +import { createServerStore } from '../../src/main/core/servers' + const partialDiagnostics: ServerDiagnostics = { manager: 'detected', xray: 'running', @@ -23,9 +52,87 @@ const importPayload: ImportServerPayload = { } } +const connection = { + username: 'root', + authMethod: 'privateKey' as const, + privilegeMode: 'root' as const, + privateKeyCredentialId: 'credential_12345678', + privateKeyName: 'id_ed25519' +} + +const baseServer: Omit = { + name: 'initial', + host: 'server.example', + port: 22, + username: 'root', + os: 'Debian', + country: 'Germany', + city: 'Berlin', + flag: '🇩🇪', + routesCount: 1, + subscriptionUrl: 'https://server.example/sub/token', + keys: [{ name: 'main', url: 'vless://main', transport: 'xhttp' }], + authMethod: 'privateKey', + privilegeMode: 'root', + privateKeyCredentialId: 'credential_12345678', + privateKeyName: 'id_ed25519', + hostKeyFingerprint: 'SHA256:original' +} + +describe('server store onboarding metadata', () => { + it('upserts imported endpoints without duplicates and preserves valid saved credentials', () => { + const store = createServerStore() + const initial = store.add(baseServer) + const createdAt = initial.createdAt + + const imported = store.upsertImported({ + ...baseServer, + name: 'renamed', + host: 'SERVER.EXAMPLE', + subscriptionUrl: '', + keys: [], + setupStatus: 'partial', + diagnostics: partialDiagnostics + }, connection) + + expect(imported.id).toBe(initial.id) + expect(imported.createdAt).toBe(createdAt) + expect(imported.name).toBe('renamed') + expect(imported.subscriptionUrl).toBe(baseServer.subscriptionUrl) + expect(imported.keys).toEqual(baseServer.keys) + expect(imported.privateKeyCredentialId).toBe(baseServer.privateKeyCredentialId) + expect(imported.hostKeyFingerprint).toBe(baseServer.hostKeyFingerprint) + expect(store.list()).toHaveLength(1) + }) + + it('counts shared credential references and clears only the selected server reference', () => { + const store = createServerStore() + const first = store.add(baseServer) + const second = store.add({ ...baseServer, host: 'other.example' }) + + expect(store.countCredentialReferences('credential_12345678')).toBe(2) + expect(store.countCredentialReferences('credential_12345678', first.id)).toBe(1) + + store.clearCredentialReference(first.id) + + expect(store.get(first.id)?.privateKeyCredentialId).toBeNull() + expect(store.get(second.id)?.privateKeyCredentialId).toBe('credential_12345678') + expect(store.countCredentialReferences('credential_12345678')).toBe(1) + }) +}) + describe('shared onboarding contracts', () => { it('accepts diagnostics and import fixtures', () => { expect(importPayload.access.authMethod).toBe('privateKey') expect(partialDiagnostics.subscription).toBe('unreachable') }) + + it('requires an explicit email mode for deployment', () => { + expectTypeOf().toEqualTypeOf<'provided' | 'without'>() + }) + + it('selects keys through a credential reference rather than returning file paths', () => { + expectTypeOf>>() + .toEqualTypeOf() + }) }) From c62134a2f722b16f886e939ec59fe10093a53c9f Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:50:11 +0300 Subject: [PATCH 06/35] =?UTF-8?q?feat:=20=D1=80=D0=B0=D0=B7=D1=80=D0=B5?= =?UTF-8?q?=D1=88=D0=B0=D1=82=D1=8C=20SSH-=D0=BA=D0=BB=D1=8E=D1=87=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8=D1=89?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/core/servers.ts | 6 +- src/main/core/ssh-access.ts | 66 ++++++++- src/main/ipc-handlers.ts | 112 +++++++++++---- src/preload/index.ts | 8 +- src/renderer/src/components/SshAccessForm.tsx | 6 +- src/renderer/src/pages/AddServer.tsx | 1 + src/shared/types.ts | 16 +-- tests/type-contracts/onboarding-contracts.ts | 50 +++++++ tests/unit/ssh-access.test.ts | 135 +++++++++++++++++- 9 files changed, 356 insertions(+), 44 deletions(-) create mode 100644 tests/type-contracts/onboarding-contracts.ts diff --git a/src/main/core/servers.ts b/src/main/core/servers.ts index 0c3f9cf..fbf998f 100644 --- a/src/main/core/servers.ts +++ b/src/main/core/servers.ts @@ -19,6 +19,7 @@ export interface ServerConnectionMetadata { privateKeyPath?: string | null privateKeyCredentialId?: string | null privateKeyName?: string | null + privateKeyPersisted?: boolean | null } export interface ServerStore { @@ -52,6 +53,7 @@ function normalizeServer(server: StoredServer, hostKeys: Record) privateKeyPath: server.privateKeyPath ?? null, privateKeyName: server.privateKeyName ?? null, privateKeyCredentialId: server.privateKeyCredentialId ?? null, + privateKeyPersisted: server.privateKeyPersisted ?? null, setupStatus: server.setupStatus ?? (server.subscriptionUrl ? 'ready' : 'unknown'), diagnostics: server.diagnostics ?? null, hostKeyFingerprint: @@ -119,6 +121,7 @@ export function createServerStore(): ServerStore { ? connection.privateKeyPath : existing?.privateKeyPath ?? null, privateKeyName: connection.privateKeyName ?? existing?.privateKeyName ?? null, + privateKeyPersisted: connection.privateKeyPersisted ?? existing?.privateKeyPersisted ?? null, privateKeyCredentialId: connection.privateKeyCredentialId ?? existing?.privateKeyCredentialId ?? null, subscriptionUrl: input.subscriptionUrl || existing?.subscriptionUrl || '', @@ -158,7 +161,8 @@ export function createServerStore(): ServerStore { privateKeyPath: input.privateKeyPath ?? servers[idx].privateKeyPath ?? null, privateKeyCredentialId: input.privateKeyCredentialId ?? servers[idx].privateKeyCredentialId ?? null, - privateKeyName: input.privateKeyName ?? servers[idx].privateKeyName ?? null + privateKeyName: input.privateKeyName ?? servers[idx].privateKeyName ?? null, + privateKeyPersisted: input.privateKeyPersisted ?? servers[idx].privateKeyPersisted ?? null } const next = [...servers] next[idx] = updated diff --git a/src/main/core/ssh-access.ts b/src/main/core/ssh-access.ts index a377b37..39fe17b 100644 --- a/src/main/core/ssh-access.ts +++ b/src/main/core/ssh-access.ts @@ -1,6 +1,7 @@ import { readFileSync, statSync } from 'node:fs' import { resolve } from 'node:path' -import type { SshAccessInput } from '@shared/types' +import type { Server, SshAccessInput } from '@shared/types' +import type { SshKeychain } from './ssh-keychain' import type { SshCredentials } from './ssh-client' const MAX_PRIVATE_KEY_BYTES = 1024 * 1024 @@ -17,6 +18,59 @@ export interface SshCredentialOptions { fallbackPrivateKeyPath?: string | null onHostKeyTrusted?: (fingerprint: string) => void onAuthenticated?: () => void + privateKey?: Buffer +} + +export interface ResolvedSshAccess { + access: SshAccessInput + privateKey?: Buffer +} + +export async function resolvePrivateKey( + input: SshAccessInput, + server: Pick | null, + approvedPrivateKeyPaths: ReadonlySet, + keychain: SshKeychain +): Promise { + const access = normalizeSshAccess(input, server?.privateKeyPath) + // Любая заявленная renderer'ом path обязана быть одобрена dialog'ом — проверяем до + // всех остальных веток, чтобы произвольный путь не использовался как fallback. + if (access.privateKeyPath) { + const requestedPath = resolve(access.privateKeyPath) + if (!approvedPrivateKeyPaths.has(requestedPath)) { + throw new Error('SSH-ключ должен быть выбран через диалог приложения') + } + } + if (access.authMethod !== 'privateKey') return { access } + + const credentialId = access.privateKeyCredentialId ?? server?.privateKeyCredentialId ?? undefined + if (credentialId) { + const privateKey = await keychain.load(credentialId) + if (privateKey) { + access.privateKeyCredentialId = credentialId + access.privateKeyName = access.privateKeyName ?? server?.privateKeyName ?? undefined + if (access.privateKeyPersisted === undefined) { + access.privateKeyPersisted = server?.privateKeyPersisted ?? true + } + return { access, privateKey } + } + // Ключевой материал недоступен; разрешаем только одобренный legacy-путь ниже. + } + + const approvedPath = access.privateKeyPath ? resolve(access.privateKeyPath) : undefined + if (!approvedPath) { + throw new Error( + credentialId + ? 'SSH-ключ не найден в системном хранилище; выберите его заново' + : 'Не выбран приватный SSH-ключ' + ) + } + const stat = statSync(approvedPath) + if (!stat.isFile() || stat.size < 1 || stat.size > MAX_PRIVATE_KEY_BYTES) { + throw new Error('Файл приватного SSH-ключа пустой или слишком большой') + } + access.privateKeyPersisted = false + return { access, privateKey: readFileSync(approvedPath) } } export function normalizeSshAccess( @@ -28,6 +82,9 @@ export function normalizeSshAccess( authMethod: access.authMethod, password: access.password, privateKeyPath: access.privateKeyPath?.trim() || fallbackPrivateKeyPath || undefined, + privateKeyCredentialId: access.privateKeyCredentialId, + privateKeyName: access.privateKeyName, + privateKeyPersisted: access.privateKeyPersisted, passphrase: access.passphrase, privilegeMode: access.privilegeMode, sudoPassword: access.sudoPassword @@ -77,6 +134,11 @@ export function createSshCredentials( let privateKey: Buffer | undefined if (access.authMethod === 'password') { if (!access.password) throw new Error('Не указан SSH-пароль') + } else if (options.privateKey) { + if (options.privateKey.length < 1 || options.privateKey.length > MAX_PRIVATE_KEY_BYTES) { + throw new Error('SSH-ключ в системном хранилище пустой или слишком большой') + } + privateKey = options.privateKey } else { if (!approvedKeyPath) throw new Error('Не выбран приватный SSH-ключ') const stat = statSync(approvedKeyPath) @@ -96,7 +158,7 @@ export function createSshCredentials( port: target.port, username: access.username, password: access.authMethod === 'password' ? access.password : undefined, - privateKey, + privateKey: options.privateKey ?? privateKey, passphrase: access.authMethod === 'privateKey' ? access.passphrase : undefined, privilegeMode: access.privilegeMode, sudoPassword, diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 6321ec6..fb3a45e 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -1,6 +1,8 @@ -import { ipcMain, BrowserWindow, dialog } from 'electron' +import { app, ipcMain, BrowserWindow, dialog } from 'electron' import net from 'node:net' import { basename, resolve } from 'node:path' +import { randomUUID } from 'node:crypto' +import { readFileSync, statSync } from 'node:fs' import type { ProfileCreateInput, ProfileFingerprintInput, @@ -16,7 +18,8 @@ import { fetchSubscription } from './core/subscription' import { ProfileManager } from './core/profiles' import { ServerManager } from './core/server-manager' import { probePortsFor } from './core/probe-ports' -import { createSshCredentials, normalizeSshAccess } from './core/ssh-access' +import { createSshCredentials, normalizeSshAccess, resolvePrivateKey } from './core/ssh-access' +import { createSshKeychain, MAX_PRIVATE_KEY_BYTES } from './core/ssh-keychain' import type { SshCredentials } from './core/ssh-client' interface IpcContext { @@ -29,32 +32,81 @@ export function registerIpcHandlers({ store }: IpcContext): void { if (server.privateKeyPath) approvedPrivateKeyPaths.add(resolve(server.privateKeyPath)) } + const keychain = createSshKeychain() + const transientKeys = new Map() + const credentialStore = { + save: keychain.save, + load: async (credentialId: string): Promise => { + // Fallback key (OS keychain unavailable): one in-memory copy for the whole + // app session, issued as a fresh copy per SSH operation. The original is + // never returned to the renderer and is dropped at app exit. + const transient = transientKeys.get(credentialId) + if (transient) return Buffer.from(transient) + return keychain.load(credentialId) + }, + remove: keychain.remove + } + app.on('before-quit', () => { + for (const key of transientKeys.values()) key.fill(0) + transientKeys.clear() + }) + ipcMain.handle('ssh:selectPrivateKey', async () => { const selection = await dialog.showOpenDialog({ title: 'Выберите приватный SSH-ключ', properties: ['openFile', 'dontAddToRecent'] }) if (selection.canceled || selection.filePaths.length === 0) return null + const path = resolve(selection.filePaths[0]) - approvedPrivateKeyPaths.add(path) - return { path, name: basename(path) } + const stat = statSync(path) + if (!stat.isFile() || stat.size < 1 || stat.size > MAX_PRIVATE_KEY_BYTES) { + throw new Error('Файл приватного SSH-ключа пустой или слишком большой') + } + + const temporaryKey = readFileSync(path) + const credentialId = randomUUID().replace(/-/g, '') + try { + await keychain.save(credentialId, temporaryKey) + return { credentialId, name: basename(path), persisted: true } + } catch (error) { + // Keychain unavailable: permit this one operation through the approved path only. + // The UI is told that reuse is unavailable, and no plaintext copy is stored. + const temporaryCredentialId = randomUUID().replace(/-/g, '') + transientKeys.set(temporaryCredentialId, Buffer.from(temporaryKey)) + return { credentialId: temporaryCredentialId, name: basename(path), persisted: false } + } finally { + temporaryKey.fill(0) + } }) - const credentialsFor = ( - server: Pick | null, + const credentialsFor = async ( + server: Pick< + Server, + | 'id' + | 'host' + | 'port' + | 'privateKeyPath' + | 'privateKeyCredentialId' + | 'privateKeyName' + | 'hostKeyFingerprint' + > | null, target: { host: string; port: number }, accessInput: SshAccessInput - ): { credentials: SshCredentials; access: SshAccessInput } => { + ): Promise<{ credentials: SshCredentials; access: SshAccessInput }> => { if (!accessInput || typeof accessInput !== 'object') { throw new Error('Не указаны параметры SSH-доступа') } - const access = normalizeSshAccess(accessInput, server?.privateKeyPath) + const resolved = await resolvePrivateKey(accessInput, server, approvedPrivateKeyPaths, credentialStore) + const privateKey = resolved.privateKey + const access = normalizeSshAccess(resolved.access, server?.privateKeyPath) const expectedHostKey = store.getHostKey(target.host, target.port) ?? server?.hostKeyFingerprint ?? undefined const credentials = createSshCredentials(target, access, { approvedPrivateKeyPaths, expectedHostKeyFingerprint: expectedHostKey, fallbackPrivateKeyPath: server?.privateKeyPath, + privateKey, onHostKeyTrusted: (fingerprint) => { store.trustHostKey(target.host, target.port, fingerprint) }, @@ -65,7 +117,10 @@ export function registerIpcHandlers({ store }: IpcContext): void { username: access.username, authMethod: access.authMethod, privilegeMode: access.privilegeMode, - privateKeyPath: access.privateKeyPath ?? null + privateKeyPath: access.privateKeyPersisted ? null : access.privateKeyPath ?? null, + privateKeyCredentialId: access.privateKeyCredentialId ?? null, + privateKeyName: access.privateKeyName ?? null, + privateKeyPersisted: access.privateKeyPersisted ?? null }) } : undefined @@ -119,7 +174,7 @@ export function registerIpcHandlers({ store }: IpcContext): void { ;(async () => { try { const target = { host: payload.host, port: payload.port } - const { credentials, access } = credentialsFor(null, target, payload.access) + const { credentials, access } = await credentialsFor(null, target, payload.access) const result = await deployer.deploy({ email: payload.email, credentials @@ -139,7 +194,10 @@ export function registerIpcHandlers({ store }: IpcContext): void { keys: result.keys, authMethod: access.authMethod, privilegeMode: access.privilegeMode, - privateKeyPath: access.privateKeyPath ?? null, + privateKeyPath: access.privateKeyPersisted ? null : access.privateKeyPath ?? null, + privateKeyCredentialId: access.privateKeyCredentialId ?? null, + privateKeyName: access.privateKeyName ?? null, + privateKeyPersisted: access.privateKeyPersisted ?? null, hostKeyFingerprint: store.getHostKey(payload.host, payload.port) ?? null }) @@ -168,15 +226,18 @@ export function registerIpcHandlers({ store }: IpcContext): void { return { serverId, subscriptionUrl: server.subscriptionUrl, keys } }) - const profileManagerFor = (serverId: string, access: SshAccessInput): ProfileManager => { + const profileManagerFor = async ( + serverId: string, + access: SshAccessInput + ): Promise => { const server = store.get(serverId) if (!server) throw new Error('Сервер не найден') - const { credentials } = credentialsFor(server, server, access) + const { credentials } = await credentialsFor(server, server, access) return new ProfileManager(credentials) } ipcMain.handle('profiles:list', async (_e, serverId: string, access: SshAccessInput) => { - const manager = profileManagerFor(serverId, access) + const manager = await profileManagerFor(serverId, access) const result = await manager.list() if (!result.ok) throw new Error(result.error ?? 'Не удалось получить список профилей') return result @@ -190,7 +251,7 @@ export function registerIpcHandlers({ store }: IpcContext): void { access: SshAccessInput, input: ProfileCreateInput ) => { - const manager = profileManagerFor(serverId, access) + const manager = await profileManagerFor(serverId, access) return manager.create(input) } ) @@ -198,7 +259,7 @@ export function registerIpcHandlers({ store }: IpcContext): void { ipcMain.handle( 'profiles:remove', async (_e, serverId: string, access: SshAccessInput, name: string) => { - const manager = profileManagerFor(serverId, access) + const manager = await profileManagerFor(serverId, access) return manager.remove(name) } ) @@ -211,7 +272,7 @@ export function registerIpcHandlers({ store }: IpcContext): void { access: SshAccessInput, input: ProfileFingerprintInput ) => { - const manager = profileManagerFor(serverId, access) + const manager = await profileManagerFor(serverId, access) return manager.changeFingerprint(input) } ) @@ -224,13 +285,13 @@ export function registerIpcHandlers({ store }: IpcContext): void { access: SshAccessInput, input: ProfileSniInput ) => { - const manager = profileManagerFor(serverId, access) + const manager = await profileManagerFor(serverId, access) return manager.changeSni(input) } ) ipcMain.handle('profiles:sniList', async (_e, serverId: string, access: SshAccessInput) => { - const manager = profileManagerFor(serverId, access) + const manager = await profileManagerFor(serverId, access) return manager.sniList() }) @@ -242,29 +303,32 @@ export function registerIpcHandlers({ store }: IpcContext): void { access: SshAccessInput, input: ProfilePortInput ) => { - const manager = profileManagerFor(serverId, access) + const manager = await profileManagerFor(serverId, access) return manager.changePort(input) } ) - const serverManagerFor = (serverId: string, access: SshAccessInput): ServerManager => { + const serverManagerFor = async ( + serverId: string, + access: SshAccessInput + ): Promise => { const server = store.get(serverId) if (!server) throw new Error('Сервер не найден') - const { credentials } = credentialsFor(server, server, access) + const { credentials } = await credentialsFor(server, server, access) return new ServerManager(credentials) } ipcMain.handle( 'server:update', async (_e, serverId: string, access: SshAccessInput): Promise => { - return serverManagerFor(serverId, access).update() + return (await serverManagerFor(serverId, access)).update() } ) ipcMain.handle( 'server:uninstall', async (_e, serverId: string, access: SshAccessInput): Promise => { - return serverManagerFor(serverId, access).uninstall() + return (await serverManagerFor(serverId, access)).uninstall() } ) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 6833731..1079625 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3,7 +3,9 @@ import type { DeployEvent, DeployStartPayload, ElectronAPI, - PrivateKeySelection, + PrivateKeyReference, + ImportResult, + ImportServerPayload, ProfileCreateInput, ProfileCreateResult, ProfileDeleteResult, @@ -23,12 +25,14 @@ import type { const api: ElectronAPI = { ssh: { - selectPrivateKey: (): Promise => + selectPrivateKey: (): Promise => ipcRenderer.invoke('ssh:selectPrivateKey') }, servers: { list: (): Promise => ipcRenderer.invoke('servers:list'), + import: (payload: ImportServerPayload): Promise => + ipcRenderer.invoke('servers:import', payload), remove: (id: string): Promise => ipcRenderer.invoke('servers:remove', id), get: (id: string): Promise => ipcRenderer.invoke('servers:get', id), check: (id: string): Promise => ipcRenderer.invoke('servers:check', id), diff --git a/src/renderer/src/components/SshAccessForm.tsx b/src/renderer/src/components/SshAccessForm.tsx index 6f381d9..9e9dea1 100644 --- a/src/renderer/src/components/SshAccessForm.tsx +++ b/src/renderer/src/components/SshAccessForm.tsx @@ -16,7 +16,7 @@ interface SshAccessFormProps { export function isSshAccessReady(access: SshAccessInput): boolean { if (!access.username.trim()) return false if (access.authMethod === 'password') return Boolean(access.password) - return Boolean(access.privateKeyPath) + return Boolean(access.privateKeyCredentialId || access.privateKeyPath) } export function SshAccessForm({ @@ -28,7 +28,7 @@ export function SshAccessForm({ }: SshAccessFormProps): React.JSX.Element { const { t } = useTranslation() const [keyError, setKeyError] = useState(null) - const keyName = value.privateKeyPath?.split(/[\\/]/).pop() + const keyName = value.privateKeyName ?? value.privateKeyPath?.split(/[\\/]/).pop() const update = (patch: Partial): void => { onChange({ ...value, ...patch }) @@ -38,7 +38,7 @@ export function SshAccessForm({ setKeyError(null) try { const selection = await window.api.ssh.selectPrivateKey() - if (selection) update({ privateKeyPath: selection.path }) + if (selection) update({ privateKeyCredentialId: selection.credentialId, privateKeyName: selection.name }) } catch (error) { setKeyError(error instanceof Error ? error.message : String(error)) } diff --git a/src/renderer/src/pages/AddServer.tsx b/src/renderer/src/pages/AddServer.tsx index 70b2348..3cf88c7 100644 --- a/src/renderer/src/pages/AddServer.tsx +++ b/src/renderer/src/pages/AddServer.tsx @@ -94,6 +94,7 @@ export function AddServer({ onDone, onBack }: AddServerProps): React.JSX.Element window.api.deploy.start({ host: form.host.trim(), port: Number(form.port) || 22, + emailMode: 'provided', email: form.email.trim(), access }) diff --git a/src/shared/types.ts b/src/shared/types.ts index bb4c7d4..483f1cc 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -17,12 +17,7 @@ export type SubscriptionState = 'public' | 'localOnly' | 'missing' | 'unreachabl export interface PrivateKeyReference { credentialId: string name: string -} - -/** Legacy file-picker response retained until keychain IPC is wired end-to-end. */ -export interface PrivateKeySelection { - path: string - name: string + persisted?: boolean } export interface ServerDiagnostics { @@ -71,6 +66,8 @@ export interface SshAccessInput { password?: string privateKeyPath?: string privateKeyCredentialId?: string + privateKeyName?: string + privateKeyPersisted?: boolean passphrase?: string privilegeMode: SshPrivilegeMode sudoPassword?: string @@ -95,6 +92,7 @@ export interface Server { privateKeyPath?: string | null privateKeyName?: string | null privateKeyCredentialId?: string | null + privateKeyPersisted?: boolean | null setupStatus?: ServerSetupStatus diagnostics?: ServerDiagnostics | null hostKeyFingerprint?: string | null @@ -231,8 +229,7 @@ export type DeployStatus = 'pending' | 'running' | 'done' | 'error' export interface DeployStartPayload { host: string port: number - /** Optional only while the existing renderer migrates to explicit email selection. */ - emailMode?: EmailMode + emailMode: EmailMode email?: string access: SshAccessInput } @@ -251,10 +248,11 @@ export type DeployEvent = export interface ElectronAPI { ssh: { - selectPrivateKey: () => Promise + selectPrivateKey: () => Promise } servers: { list: () => Promise + import: (payload: ImportServerPayload) => Promise remove: (id: string) => Promise get: (id: string) => Promise check: (id: string) => Promise diff --git a/tests/type-contracts/onboarding-contracts.ts b/tests/type-contracts/onboarding-contracts.ts new file mode 100644 index 0000000..a80723e --- /dev/null +++ b/tests/type-contracts/onboarding-contracts.ts @@ -0,0 +1,50 @@ +import type { + DeployStartPayload, + ElectronAPI, + ImportResult, + ImportServerPayload, + PrivateKeyReference, + Server, + ServerDiagnostics +} from '../../src/shared/types' + +type Assert = T +type IsRequired = {} extends Pick ? false : true +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false + +// These aliases fail compilation if onboarding APIs become optional or leak paths. +type EmailModeIsRequired = Assert> +type KeyPickerReturnsReference = Assert< + Equal>, PrivateKeyReference | null> +> +type ImportApiIsExposed = Assert< + Equal Promise> +> +type ServerStoresCredentialState = Assert<'privateKeyPersisted' extends keyof Server ? true : false> + +const diagnostics: ServerDiagnostics = { + manager: 'detected', + xray: 'running', + profiles: 'available', + subscription: 'unreachable', + inspectedAt: '2026-09-23T00:00:00.000Z' +} + +const importPayload: ImportServerPayload = { + host: '203.0.113.10', + port: 22, + access: { + username: 'root', + authMethod: 'privateKey', + privateKeyCredentialId: 'cred-1', + privilegeMode: 'root' + } +} + +const fixtures: [ServerDiagnostics, ImportServerPayload] = [diagnostics, importPayload] +void fixtures + +type OnboardingContractAssertions = [EmailModeIsRequired, KeyPickerReturnsReference, ImportApiIsExposed, ServerStoresCredentialState] +void (undefined as unknown as OnboardingContractAssertions) diff --git a/tests/unit/ssh-access.test.ts b/tests/unit/ssh-access.test.ts index df858d4..7b07770 100644 --- a/tests/unit/ssh-access.test.ts +++ b/tests/unit/ssh-access.test.ts @@ -1,9 +1,10 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { createSshCredentials } from '../../src/main/core/ssh-access' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createSshCredentials, resolvePrivateKey } from '../../src/main/core/ssh-access' import { formatHostKeyFingerprint } from '../../src/main/core/ssh-client' +import type { Server } from '../../src/shared/types' const temporaryDirectories: string[] = [] @@ -13,6 +14,30 @@ afterEach(() => { } }) +const storedServer: Server = { + id: 'server-1', + name: 'server.example', + host: 'server.example', + port: 22, + username: 'root', + os: null, + country: null, + city: null, + flag: null, + createdAt: '2026-09-23T00:00:00.000Z', + routesCount: null, + subscriptionUrl: '', + keys: [], + privateKeyCredentialId: 'stored_credential_1234', + privateKeyPath: null +} + +const emptyKeychain = { + save: async () => {}, + load: async () => null, + remove: async () => {} +} + describe('SSH access', () => { it('использует SSH-пароль как sudo-пароль по умолчанию', () => { const credentials = createSshCredentials( @@ -85,7 +110,111 @@ describe('SSH access', () => { ).toThrow('должен быть выбран через диалог приложения') }) - it('форматирует sha256 host key как OpenSSH fingerprint', () => { + it('resolves a selected key from keychain without a path', async () => { + const key = Buffer.from('private-key') + const keychain = { ...emptyKeychain, load: vi.fn().mockResolvedValue(key) } + const resolved = await resolvePrivateKey( + { + username: 'root', + authMethod: 'privateKey', + privateKeyCredentialId: 'current_credential_1234', + privilegeMode: 'root' + }, + storedServer, + new Set(), + keychain + ) + + expect(resolved.privateKey).toEqual(key) + expect(keychain.load).toHaveBeenCalledWith('current_credential_1234') + }) + + it('falls back to the saved key reference but rejects an arbitrary path', async () => { + const key = Buffer.from('private-key') + const keychain = { ...emptyKeychain, load: vi.fn().mockResolvedValue(key) } + const resolved = await resolvePrivateKey( + { + username: 'root', + authMethod: 'privateKey', + privilegeMode: 'root' + }, + storedServer, + new Set(), + keychain + ) + expect(keychain.load).toHaveBeenCalledWith('stored_credential_1234') + expect(resolved.privateKey).toEqual(key) + + await expect( + resolvePrivateKey( + { + username: 'root', + authMethod: 'privateKey', + privateKeyPath: 'C:/private/id_ed25519', + privilegeMode: 'root' + }, + storedServer, + new Set(), + emptyKeychain + ) + ).rejects.toThrow('выбран через диалог') + }) + + it('requires reselect when the saved credential is missing and never stores passphrase', async () => { + const keychain = { ...emptyKeychain, load: vi.fn().mockResolvedValue(null), save: vi.fn() } + await expect( + resolvePrivateKey( + { + username: 'root', + authMethod: 'privateKey', + privateKeyCredentialId: 'missing_credential_1234', + passphrase: 'one-time', + privilegeMode: 'root' + }, + storedServer, + new Set(), + keychain + ) + ).rejects.toThrow('не найден в системном хранилище') + expect(keychain.save).not.toHaveBeenCalled() + }) + + it('marks legacy file fallback as non-persisted and keeps approved credential reuse persisted', async () => { + const directory = mkdtempSync(join(tmpdir(), 'xrayebator-key-fallback-')) + temporaryDirectories.push(directory) + const path = resolve(directory, 'id_ed25519') + writeFileSync(path, '-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----\n') + + const fallback = await resolvePrivateKey( + { + username: 'root', + authMethod: 'privateKey', + privateKeyCredentialId: 'expired_credential_123', + privateKeyPath: path, + privilegeMode: 'root' + }, + null, + new Set([path]), + emptyKeychain + ) + expect(fallback.privateKey?.toString()).toContain('OPENSSH PRIVATE KEY') + expect(fallback.access.privateKeyPersisted).toBe(false) + + const reused = await resolvePrivateKey( + { + username: 'root', + authMethod: 'privateKey', + privateKeyCredentialId: 'stored_credential_1234', + privilegeMode: 'root' + }, + storedServer, + new Set(), + { ...emptyKeychain, load: vi.fn().mockResolvedValue(Buffer.from('key')) } + ) + expect(reused.access.privateKeyPersisted).toBe(true) + }) + + it('formats sha256 host key as OpenSSH fingerprint', () => { expect(formatHostKeyFingerprint('00'.repeat(32))).toBe( `SHA256:${Buffer.alloc(32).toString('base64').replace(/=+$/, '')}` ) From 564337e638b09c792a7319ae0b613dbc60d46751 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:53:43 +0300 Subject: [PATCH 07/35] =?UTF-8?q?feat:=20=D1=80=D0=B0=D0=B7=D1=80=D0=B5?= =?UTF-8?q?=D1=88=D0=B8=D1=82=D1=8C=20quickstart=20=D0=B1=D0=B5=D0=B7=20em?= =?UTF-8?q?ail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test-quickstart-email-and-inspect.sh | 37 +++++++++++++++++++ xrayebator | 21 +++++++++-- 2 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 validation/test-quickstart-email-and-inspect.sh diff --git a/validation/test-quickstart-email-and-inspect.sh b/validation/test-quickstart-email-and-inspect.sh new file mode 100644 index 0000000..c8445ef --- /dev/null +++ b/validation/test-quickstart-email-and-inspect.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Regression test: quickstart --without-email и read-only inspect. +# 1) quickstart_command принимает --without-email и НЕ требует email в этом режиме; +# 2) без email Certbot получает --register-unsafely-without-email и НЕ получает -m; +# 3) с email поведение не меняется (-m "$email" остаётся); +# 4) фиктивный email не подставляется. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +fail() { + echo "✗ FAIL: $*" >&2 + exit 1 +} + +quickstart_block=$(tr -d '\r' < xrayebator | sed -n '/^quickstart_command() {$/,/^happ_setup_command() {$/p') +[[ -n "$quickstart_block" ]] || fail "quickstart_command block not found" + +grep -Fq -- '--without-email' <<< "$quickstart_block" \ + || fail "quickstart_command не принимает --without-email" +grep -Fq -- '--register-unsafely-without-email' <<< "$quickstart_block" \ + || fail "quickstart без email должен использовать --register-unsafely-without-email" +grep -Fq -- '-m "$email"' <<< "$quickstart_block" \ + || fail "quickstart с email должен по-прежнему передавать -m \"$email\"" +grep -Fq -- '"${certbot_email_args[@]}"' <<< "$quickstart_block" \ + || fail "certbot email аргументы должны собираться в массив и передаваться единой точкой" +if grep -Eq 'email="(noreply|no-reply|admin)@' <<< "$quickstart_block"; then + fail "найден фиктивный email-fallback" +fi + +# Валидация email обязана применяться только в provided-режиме. +if grep -Eq '^\s*if \[\[ -z "\$email" \]\]' <<< "$quickstart_block"; then + fail "жёсткая проверка '-z \"\$email\"' вне email_mode — сломает --without-email" +fi + +echo "✓ quickstart поддерживает --without-email без фиктивного адреса" diff --git a/xrayebator b/xrayebator index b9ef055..5473315 100755 --- a/xrayebator +++ b/xrayebator @@ -10100,15 +10100,19 @@ uninstall_adguard_home() { # CLI dispatch (Phase 5, REQ-B02). Subcommand-style. quickstart_command() { - local email="" + local email="" email_mode="provided" while [[ $# -gt 0 ]]; do case "$1" in - --email) email="$2"; shift 2 ;; + --email) email="$2"; email_mode="provided"; shift 2 ;; + --without-email) email=""; email_mode="without"; shift ;; *) shift ;; esac done - if [[ -z "$email" ]] || ! [[ "$email" =~ ^[^@]+@[^@]+\.[^@]+$ ]]; then + # email нужен только Certbot (ACME-контакт для уведомлений/восстановления). + # В режиме without передаём --register-unsafely-without-email вместо + # подстановки фиктивного адреса. + if [[ "$email_mode" == "provided" ]] && { [[ -z "$email" ]] || ! [[ "$email" =~ ^[^@]+@[^@]+\.[^@]+$ ]]; }; then echo '{"ok":false,"error":"Некорректный email (нужен для Let'\''s Encrypt)"}' return 1 fi @@ -10370,13 +10374,21 @@ NGINXHTTP if [[ "$cert_expiry_ok" -eq 0 ]]; then # Получить TLS сертификат echo " -> Получение TLS сертификата..." >&2 + # ACME-контакт: с email — -m, без — явный unsafely-without-email (без фиктивного адреса). + local certbot_email_args=() + if [[ "$email_mode" == "provided" ]]; then + certbot_email_args=(-m "$email") + else + certbot_email_args=(--register-unsafely-without-email) + echo " -> Email не указан: регистрация ACME без контактного адреса (уведомлений не будет)" >&2 + fi if ! "$certbot_bin" certonly \ --cert-name "$server_ip" \ --preferred-profile shortlived \ --webroot --webroot-path "$webroot" \ --ip-address "$server_ip" \ --non-interactive --agree-tos \ - -m "$email" \ + "${certbot_email_args[@]}" \ --deploy-hook "systemctl reload nginx" \ >/tmp/certbot-ip.log 2>&1; then local certbot_reason @@ -11809,6 +11821,7 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then echo -e " ${CYAN}sudo xrayebator${NC} — открыть интерактивное меню" echo -e " ${CYAN}sudo xrayebator update [branch]${NC} — обновить Xray-core (main/dev/experimental)" echo -e " ${CYAN}sudo xrayebator quickstart --email EMAIL${NC} — авторазвёртывание: подписка+IP-TLS+HAPP profile" + echo -e " ${CYAN}sudo xrayebator quickstart --without-email${NC} — то же, но ACME-регистрация без email (без уведомлений)" 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-список профилей" From c0aa7641a3d84c7f0ac571278eafaeb532c1dfa7 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:59:50 +0300 Subject: [PATCH 08/35] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20read-only=20=D0=B4=D0=B8=D0=B0=D0=B3=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=D0=BA=D1=83=20Xrayebator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test-quickstart-email-and-inspect.sh | 24 ++++ xrayebator | 103 ++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/validation/test-quickstart-email-and-inspect.sh b/validation/test-quickstart-email-and-inspect.sh index c8445ef..74a2731 100644 --- a/validation/test-quickstart-email-and-inspect.sh +++ b/validation/test-quickstart-email-and-inspect.sh @@ -35,3 +35,27 @@ if grep -Eq '^\s*if \[\[ -z "\$email" \]\]' <<< "$quickstart_block"; then fi echo "✓ quickstart поддерживает --without-email без фиктивного адреса" + +# ── inspect: read-only диагностика для GUI-импорта ── +inspect_block=$(tr -d '\r' < xrayebator | awk '/^inspect_command\(\) \{$/{f=1} f&&/^quickstart_command\(\) \{$/{exit} f') +[[ -n "$inspect_block" ]] || fail "inspect_command block not found" + +# Диспетч должен открывать subcommand. +grep -Eq '^[[:space:]]*inspect\)' <(tr -d '\r' < xrayebator) \ + || fail "dispatch не регистрирует inspect" + +# JSON печатается через jq -n (одним блоком), диагностику — в stderr. +grep -Fq 'jq -n' <<< "$inspect_block" \ + || fail "inspect_command должен печатать JSON через jq -n" +if grep -Eq 'echo "\{[\"a-z]' <<< "$inspect_block"; then + fail "inspect_command печатает JSON вручную вместо jq -n" +fi + +# Read-only инвариант: никаких mutation-хелперов/пакетных/сетевых изменений. +if grep -Eq 'apt-get|safe_jq_write|systemctl (restart|enable|stop|start)|open_firewall_port|close_firewall_port|install_subscription_server|backup_config|quickstart_command|happ_setup|run_migration|touch |rm -|mv |> "/usr|>> "/usr' <<< "$inspect_block"; then + echo "Найден mutation-вызов в inspect_command:" + grep -En 'apt-get|safe_jq_write|systemctl (restart|enable|stop|start)|open_firewall_port|install_subscription_server|backup_config|quickstart_command|happ_setup|run_migration|touch |rm -|mv ' <<< "$inspect_block" || true + fail "inspect_command обязан быть read-only" +fi + +echo "✓ inspect зарегистрирован, печатает JSON и остаётся read-only" diff --git a/xrayebator b/xrayebator index 5473315..bf9c8aa 100755 --- a/xrayebator +++ b/xrayebator @@ -10099,6 +10099,104 @@ uninstall_adguard_home() { } # CLI dispatch (Phase 5, REQ-B02). Subcommand-style. + +# xrayebator inspect --json — read-only диагностика установки для GUI-импорта. +# Никаких изменений состояния: только чтение маркеров, профилей и статуса сервисов. +inspect_command() { + export TERM=dumb + export XRAYEBATOR_NONINTERACTIVE=1 + + local state_dir="/usr/local/etc/xray" + local manager_binary="/usr/local/bin/xrayebator" + + if [[ ! -d "$state_dir" ]]; then + jq -n --arg error "Установка Xrayebator не распознана: каталог состояния отсутствует" \ + '{ok:false, recognized:false, error:$error}' + return 1 + fi + + local manager="missing" + [[ -x "$manager_binary" ]] && manager="detected" + + local os="" + os=$(awk -F'"' '/^PRETTY_NAME=/{print $2; exit}' /etc/os-release 2>/dev/null) + + local xray="missing" + if command -v /usr/local/bin/xray &>/dev/null || command -v xray &>/dev/null; then + if systemctl is-active --quiet xray.service 2>/dev/null; then + xray="running" + else + xray="stopped" + fi + fi + + local profile_count=0 route_count=0 rc + local profile_file + for profile_file in "$PROFILES_DIR"/*.json; do + [[ -f "$profile_file" ]] || continue + profile_count=$((profile_count + 1)) + rc=$(jq -r '((.routes // []) | length)' "$profile_file" 2>/dev/null || echo 0) + [[ "$rc" =~ ^[0-9]+$ ]] || rc=0 + route_count=$((route_count + rc)) + done + + local profiles="missing" + if [[ -d "$PROFILES_DIR" ]]; then + if [[ "$profile_count" -gt 0 ]]; then profiles="available"; else profiles="empty"; fi + fi + + local sub_installed=false + [[ -f "$state_dir/.subscription_installed" ]] && sub_installed=true + local sub_mode="" sub_domain="" sub_port="" + [[ -f "$state_dir/.subscription_mode" ]] && sub_mode=$(tr -d '\r\n' < "$state_dir/.subscription_mode" 2>/dev/null) + [[ -f "$state_dir/.subscription_domain" ]] && sub_domain=$(tr -d '\r\n' < "$state_dir/.subscription_domain" 2>/dev/null) + [[ -f "$state_dir/.subscription_port" ]] && sub_port=$(tr -d '\r\n' < "$state_dir/.subscription_port" 2>/dev/null) + + local sub_service="stopped" + systemctl is-active --quiet xrayebator-sub.service 2>/dev/null && sub_service="running" + + local sub_token="" t + if [[ "$sub_installed" == true ]]; then + for profile_file in "$PROFILES_DIR"/*.json; do + [[ -f "$profile_file" ]] || continue + t=$(jq -r '.sub_token // empty' "$profile_file" 2>/dev/null) + if [[ "$t" =~ ^[a-f0-9]{32}$ ]]; then sub_token="$t"; break; fi + done + fi + local sub_url="" + [[ -n "$sub_token" ]] && sub_url=$(_profile_cli_subscription_url "$sub_token" 2>/dev/null || true) + + local sub_port_json="null" + [[ "$sub_port" =~ ^[0-9]+$ ]] && sub_port_json="$sub_port" + + local geo_country="" geo_city="" flag="" + if [[ -f "$SERVER_COUNTRY_FILE" ]]; then + geo_country=$(_geo_country_name) + geo_city=$(_geo_city) + flag=$(_geo_flag_emoji "$(_geo_country_code)") + fi + + jq -n \ + --argjson ok true --argjson recognized true \ + --arg os "$os" --arg manager "$manager" --arg xray "$xray" \ + --arg profiles "$profiles" \ + --argjson profile_count "$profile_count" --argjson route_count "$route_count" \ + --argjson subscription_installed "$sub_installed" \ + --arg subscription_mode "${sub_mode:-unknown}" \ + --arg subscription_domain "$sub_domain" \ + --argjson subscription_port "$sub_port_json" \ + --arg subscription_service "$sub_service" \ + --arg subscription_url "$sub_url" \ + --arg country "$geo_country" --arg city "$geo_city" --arg flag "$flag" \ + '{ok:$ok, recognized:$recognized, os:$os, manager:$manager, xray:$xray, + profiles:$profiles, profile_count:$profile_count, route_count:$route_count, + subscription_installed:$subscription_installed, subscription_mode:$subscription_mode, + subscription_domain:$subscription_domain, subscription_port:$subscription_port, + subscription_service:$subscription_service, subscription_url:$subscription_url, + country:$country, city:$city, flag:$flag}' + return 0 +} + quickstart_command() { local email="" email_mode="provided" while [[ $# -gt 0 ]]; do @@ -11771,6 +11869,10 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then shift quickstart_command "$@" ;; + inspect) + shift + inspect_command "$@" + ;; happ-setup) shift happ_setup_command "$@" @@ -11822,6 +11924,7 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then echo -e " ${CYAN}sudo xrayebator update [branch]${NC} — обновить Xray-core (main/dev/experimental)" echo -e " ${CYAN}sudo xrayebator quickstart --email EMAIL${NC} — авторазвёртывание: подписка+IP-TLS+HAPP profile" echo -e " ${CYAN}sudo xrayebator quickstart --without-email${NC} — то же, но ACME-регистрация без email (без уведомлений)" + echo -e " ${CYAN}sudo xrayebator inspect --json${NC} — read-only диагностика установки (для GUI-импорта)" 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-список профилей" From 0fc69a34c15b7be4bfd21a7e2efa677a05c7163a Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:02:08 +0300 Subject: [PATCH 09/35] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B8=D1=82=D1=8C=20=D0=B8=D0=BD=D1=81=D0=BF=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D1=80=20=D1=81=D1=83=D1=89=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B2=D1=83=D1=8E=D1=89=D0=B8=D1=85=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/core/server-inspector.ts | 159 ++++++++++++++++++++++++++++ src/shared/types.ts | 1 + tests/unit/server-inspector.test.ts | 96 +++++++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 src/main/core/server-inspector.ts create mode 100644 tests/unit/server-inspector.test.ts diff --git a/src/main/core/server-inspector.ts b/src/main/core/server-inspector.ts new file mode 100644 index 0000000..c7e202d --- /dev/null +++ b/src/main/core/server-inspector.ts @@ -0,0 +1,159 @@ +import { SshClient, SshCredentials } from './ssh-client' +import { shellCommand } from './shell-command' +import { extractJson } from './profiles' +import type { + DiagnosticState, + InspectionSnapshot, + ProfilesState, + ServerDiagnostics, + ServerSetupStatus, + SubscriptionState, + VlessLink, + XrayState +} from '@shared/types' + +/** Результат проверки публичной подписки: ключи, null (не проверялась/недоступна). */ +export type SubscriptionProbe = VlessLink[] | null | 'unreachable' + +export interface NormalizedInspection { + setupStatus: ServerSetupStatus + subscriptionUrl: string + keys: VlessLink[] + routesCount: number | null + os: string | null + country: string | null + city: string | null + flag: string | null + diagnostics: ServerDiagnostics +} + +function isLocalSubscriptionUrl(url: string): boolean { + return ( + /^\[?127\.0\.0\.1\]?$/.test(url) || + /\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/)/.test(url) || + url.startsWith('http://') + ) +} + +/** Публичный URL, который GUI может пробовать; null — probe не выполнять. */ +export function subscriptionProbeTarget(snapshot: InspectionSnapshot): string | null { + const url = snapshot.subscription_url?.trim() ?? '' + if (!url) return null + if (snapshot.subscription_mode === 'local_only') return null + if (isLocalSubscriptionUrl(url)) return null + if (!url.startsWith('https://')) return null + return url +} + +function subscriptionState( + snapshot: InspectionSnapshot, + probe: SubscriptionProbe +): SubscriptionState { + if (!snapshot.subscription_installed) return 'missing' + const publicUrl = subscriptionProbeTarget(snapshot) !== null + if (!publicUrl) { + return snapshot.subscription_mode === 'local_only' ? 'localOnly' : 'missing' + } + if (probe === null) return 'public' + if (probe === 'unreachable' || probe.length === 0) return 'unreachable' + return 'public' +} + +export function normalizeInspection( + snapshot: InspectionSnapshot, + probe: SubscriptionProbe +): NormalizedInspection { + if (!snapshot.ok || !snapshot.recognized) { + throw new Error( + snapshot.error ?? 'На сервере не распознана установка Xrayebator — импорт недоступен' + ) + } + if (snapshot.manager !== 'detected') { + throw new Error('Xrayebator (менеджер) не найден на сервере — импорт недоступен') + } + + const subscription = subscriptionState(snapshot, probe) + const publicUrl = subscriptionProbeTarget(snapshot) ?? '' + const keys = Array.isArray(probe) ? probe : [] + + const diagnostics: ServerDiagnostics = { + manager: snapshot.manager as DiagnosticState, + xray: snapshot.xray as XrayState, + profiles: snapshot.profiles as ProfilesState, + subscription, + inspectedAt: new Date().toISOString() + } + + const ready = + diagnostics.manager === 'detected' && + diagnostics.xray === 'running' && + diagnostics.profiles === 'available' && + subscription === 'public' && + keys.length > 0 + + // Публичный, но недосягаемый URL сохраняем как metadata — карточка помнит + // endpoint; Keys-страница ориентируется на diagnostics.subscription. + const subscriptionUrl = + subscription === 'localOnly' || subscription === 'missing' ? '' : publicUrl + + return { + setupStatus: ready ? 'ready' : 'partial', + subscriptionUrl, + keys, + routesCount: snapshot.profile_count > 0 ? snapshot.profile_count : null, + os: snapshot.os || null, + country: snapshot.country || null, + city: snapshot.city || null, + flag: snapshot.flag || null, + diagnostics + } +} + +/** + * Read-only инспектор существующей установки для GUI-импорта. + * Выполняет ТОЛЬКО `xrayebator inspect --json`; никаких mutation-команд. + */ +export class ServerInspector { + constructor( + private readonly creds: SshCredentials, + private readonly probeSubscription: (url: string) => Promise = async () => [] + ) {} + + async inspect(): Promise { + const client = new SshClient(this.creds) + try { + await client.connect() + const res = await client.exec(shellCommand('xrayebator', ['inspect', '--json']), { + elevated: true + }) + let snapshot: InspectionSnapshot + try { + snapshot = extractJson(res.stdout) as InspectionSnapshot + } catch (err) { + throw new Error( + `Сервер не вернул корректный JSON диагностики (код ${res.code}): ` + + `${err instanceof Error ? err.message : String(err)}` + ) + } + if (!snapshot.ok) { + throw new Error(snapshot.error ?? 'Xrayebator на сервере не распознан') + } + + const publicUrl = subscriptionProbeTarget(snapshot) + let probe: SubscriptionProbe = null + if (publicUrl) { + try { + const keys = await this.probeSubscription(publicUrl) + probe = keys && keys.length > 0 ? keys : 'unreachable' + } catch { + probe = 'unreachable' + } + } else { + probe = null + } + return normalizeInspection(snapshot, probe) + } finally { + client.close() + } + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 483f1cc..6f8a213 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -41,6 +41,7 @@ export interface InspectionSnapshot { subscription_mode: string | null subscription_domain: string | null subscription_port: number | null + subscription_service?: string | null subscription_url: string | null country: string | null city: string | null diff --git a/tests/unit/server-inspector.test.ts b/tests/unit/server-inspector.test.ts new file mode 100644 index 0000000..15f5fa6 --- /dev/null +++ b/tests/unit/server-inspector.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import type { InspectionSnapshot, VlessLink } from '../../src/shared/types' +import { normalizeInspection, subscriptionProbeTarget } from '../../src/main/core/server-inspector' + +const baseInspection: InspectionSnapshot = { + ok: true, + recognized: true, + os: 'Debian GNU/Linux 12', + manager: 'detected', + xray: 'running', + profiles: 'available', + profile_count: 1, + route_count: 7, + subscription_installed: true, + subscription_mode: 'ip_tls', + subscription_domain: '203.0.113.10', + subscription_port: 8443, + subscription_url: 'https://203.0.113.10:8443/sub/token', + country: 'Germany', + city: 'Frankfurt', + flag: 'DE' +} + +const routes: VlessLink[] = [{ name: 'route', url: 'vless://x', transport: 'xhttp' }] + +describe('normalizeInspection', () => { + it('normalizes a ready public installation', () => { + const result = normalizeInspection(baseInspection, routes) + expect(result.setupStatus).toBe('ready') + expect(result.subscriptionUrl).toBe('https://203.0.113.10:8443/sub/token') + expect(result.keys).toHaveLength(1) + expect(result.routesCount).toBe(1) + expect(result.diagnostics.subscription).toBe('public') + expect(result.diagnostics.manager).toBe('detected') + expect(result.os).toBe('Debian GNU/Linux 12') + expect(result.country).toBe('Germany') + }) + + it('does not treat local-only URLs as working subscriptions', () => { + const result = normalizeInspection( + { + ...baseInspection, + subscription_mode: 'local_only', + subscription_url: 'http://127.0.0.1:8080/sub/token' + }, + null + ) + expect(result.setupStatus).toBe('partial') + expect(result.subscriptionUrl).toBe('') + expect(result.keys).toEqual([]) + expect(result.diagnostics.subscription).toBe('localOnly') + }) + + it('keeps import successful but partial when the public URL is unreachable', () => { + const result = normalizeInspection(baseInspection, 'unreachable') + expect(result.setupStatus).toBe('partial') + expect(result.subscriptionUrl).toBe('https://203.0.113.10:8443/sub/token') + expect(result.keys).toEqual([]) + expect(result.diagnostics.subscription).toBe('unreachable') + }) + + it('reports partial for stopped Xray or empty profiles', () => { + expect( + normalizeInspection({ ...baseInspection, xray: 'stopped' }, routes).setupStatus + ).toBe('partial') + expect( + normalizeInspection({ ...baseInspection, profiles: 'empty', profile_count: 0 }, routes) + .setupStatus + ).toBe('partial') + }) + + it('refuses to import unrecognized installations', () => { + expect(() => + normalizeInspection( + { ...baseInspection, recognized: false, manager: 'missing' }, + null + ) + ).toThrow() + }) + + it('skips probing without a public URL', () => { + expect( + subscriptionProbeTarget({ ...baseInspection, subscription_url: null }) + ).toBeNull() + expect(subscriptionProbeTarget(baseInspection)).toBe( + 'https://203.0.113.10:8443/sub/token' + ) + expect( + subscriptionProbeTarget({ + ...baseInspection, + subscription_mode: 'local_only', + subscription_url: 'http://127.0.0.1:8080/sub/token' + }) + ).toBeNull() + }) +}) From fbceb159bc9f4eb7cb8369b52d509e6a3475cdc1 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:05:54 +0300 Subject: [PATCH 10/35] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B8=D1=82=D1=8C=20email=20mode=20=D0=B8=20=D0=B8?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D1=80=D1=82=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20IPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/core/deployer.ts | 25 ++++++++++---- src/main/ipc-handlers.ts | 67 +++++++++++++++++++++++++++++++++++-- src/shared/types.ts | 1 + tests/unit/deployer.test.ts | 34 +++++++++++++++++++ 4 files changed, 119 insertions(+), 8 deletions(-) create mode 100644 tests/unit/deployer.test.ts diff --git a/src/main/core/deployer.ts b/src/main/core/deployer.ts index 8d9a8cd..eaf7155 100644 --- a/src/main/core/deployer.ts +++ b/src/main/core/deployer.ts @@ -4,13 +4,14 @@ import { app } from 'electron' import { SshClient, SshCredentials } from './ssh-client' import { shellCommand, shellQuote } from './shell-command' import { fetchSubscription } from './subscription' -import type { DeployStep, VlessLink } from '@shared/types' +import type { DeployStep, EmailMode, VlessLink } from '@shared/types' export type DeployStepListener = (step: DeployStep, message: string) => void export type DeployLogListener = (text: string) => void export interface DeployInput { - email: string + emailMode: EmailMode + email?: string credentials: SshCredentials } @@ -72,6 +73,20 @@ function extractInstallFailure(stdout: string, stderr: string): string { return tail.length > 240 ? `${tail.slice(0, 237)}...` : tail } +/** + * Собирает аргументы quickstart: с email — `--email`, без — `--without-email`. + * В without-режиме присланный email игнорируется: фиктивный адрес не подставляется, + * `-m` не передаётся (серверный Certbot сам использует --register-unsafely-without-email). + */ +export function buildQuickstartArgs(input: { emailMode: EmailMode; email?: string }): string[] { + if (input.emailMode === 'without') return ['quickstart', '--without-email'] + const email = (input.email ?? '').trim() + if (!email || email.length > 254 || /[\r\n\0]/.test(email) || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { + throw new Error('Некорректный email') + } + return ['quickstart', '--email', email] +} + export class Deployer { constructor( private readonly onStep: DeployStepListener, @@ -79,9 +94,7 @@ export class Deployer { ) {} async deploy(input: DeployInput): Promise { - if (!input.email || input.email.length > 254 || /[\r\n\0]/.test(input.email)) { - throw new Error('Некорректный email') - } + const quickstartArgs = buildQuickstartArgs(input) const client = new SshClient(input.credentials) try { @@ -145,7 +158,7 @@ export class Deployer { this.onStep('quickstart', 'Запускаю quickstart...') const quick = await client.exec( - shellCommand('xrayebator', ['quickstart', '--email', input.email]), + shellCommand('xrayebator', quickstartArgs), { elevated: true } ) if (quick.code !== 0) { diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index fb3a45e..0bdff2a 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -4,6 +4,7 @@ import { basename, resolve } from 'node:path' import { randomUUID } from 'node:crypto' import { readFileSync, statSync } from 'node:fs' import type { + ImportServerPayload, ProfileCreateInput, ProfileFingerprintInput, ProfilePortInput, @@ -12,11 +13,12 @@ import type { ServerMaintenanceResult, SshAccessInput } from '@shared/types' -import type { ServerStore } from './core/servers' +import type { ServerConnectionMetadata, ServerStore } from './core/servers' import { Deployer } from './core/deployer' import { fetchSubscription } from './core/subscription' import { ProfileManager } from './core/profiles' import { ServerManager } from './core/server-manager' +import { ServerInspector } from './core/server-inspector' import { probePortsFor } from './core/probe-ports' import { createSshCredentials, normalizeSshAccess, resolvePrivateKey } from './core/ssh-access' import { createSshKeychain, MAX_PRIVATE_KEY_BYTES } from './core/ssh-keychain' @@ -130,9 +132,26 @@ export function registerIpcHandlers({ store }: IpcContext): void { ipcMain.handle('servers:list', (): Server[] => store.list()) ipcMain.handle('servers:get', (_e, id: string): Server | null => store.get(id) ?? null) - ipcMain.handle('servers:remove', (_e, id: string): void => { + ipcMain.handle('servers:remove', async (_e, id: string): Promise => { const server = store.get(id) if (!server || !store.remove(id)) return + const credentialId = server.privateKeyCredentialId + if (credentialId) { + // Ключ удаляем из keychain только когда на него не ссылается другая карточка. + if (store.countCredentialReferences(credentialId) === 0) { + const transient = transientKeys.get(credentialId) + if (transient) { + transient.fill(0) + transientKeys.delete(credentialId) + } else { + try { + await keychain.remove(credentialId) + } catch { + // Ключ уже отсутствует или хранилище недоступно — карточка всё равно удалена. + } + } + } + } const sameEndpointRemains = store .list() .some( @@ -176,6 +195,7 @@ export function registerIpcHandlers({ store }: IpcContext): void { const target = { host: payload.host, port: payload.port } const { credentials, access } = await credentialsFor(null, target, payload.access) const result = await deployer.deploy({ + emailMode: payload.emailMode === 'without' ? 'without' : 'provided', email: payload.email, credentials }) @@ -226,6 +246,49 @@ export function registerIpcHandlers({ store }: IpcContext): void { return { serverId, subscriptionUrl: server.subscriptionUrl, keys } }) + ipcMain.handle('servers:import', async (_e, payload: ImportServerPayload) => { + if (!payload || typeof payload !== 'object') throw new Error('Некорректный запрос импорта') + if (!payload.access || payload.access.authMethod !== 'privateKey') { + throw new Error('Импорт существующего сервера доступен только по SSH-ключу') + } + const target = { host: payload.host, port: payload.port } + const { credentials, access } = await credentialsFor(null, target, payload.access) + const inspector = new ServerInspector(credentials, (url) => fetchSubscription(url)) + const result = await inspector.inspect() + + const connection: ServerConnectionMetadata = { + username: access.username, + authMethod: access.authMethod, + privilegeMode: access.privilegeMode, + privateKeyPath: access.privateKeyPersisted ? null : access.privateKeyPath ?? null, + privateKeyCredentialId: access.privateKeyCredentialId ?? null, + privateKeyName: access.privateKeyName ?? null, + privateKeyPersisted: access.privateKeyPersisted ?? null + } + + const server = store.upsertImported( + { + name: payload.host, + host: payload.host, + port: payload.port, + username: access.username, + os: result.os, + country: result.country, + city: result.city, + flag: result.flag, + routesCount: result.routesCount, + subscriptionUrl: result.subscriptionUrl, + keys: result.keys, + setupStatus: result.setupStatus, + diagnostics: result.diagnostics, + hostKeyFingerprint: store.getHostKey(payload.host, payload.port) ?? null + }, + connection + ) + + return { serverId: server.id, diagnostics: result.diagnostics, keys: result.keys } + }) + const profileManagerFor = async ( serverId: string, access: SshAccessInput diff --git a/src/shared/types.ts b/src/shared/types.ts index 6f8a213..0a86b08 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -68,6 +68,7 @@ export interface SshAccessInput { privateKeyPath?: string privateKeyCredentialId?: string privateKeyName?: string + /** true — ключ в системном keychain; false — только выбранный файл в текущей сессии. */ privateKeyPersisted?: boolean passphrase?: string privilegeMode: SshPrivilegeMode diff --git a/tests/unit/deployer.test.ts b/tests/unit/deployer.test.ts new file mode 100644 index 0000000..719f75f --- /dev/null +++ b/tests/unit/deployer.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { buildQuickstartArgs } from '../../src/main/core/deployer' + +describe('quickstart argument builder', () => { + it('passes the email when the mode is provided', () => { + expect(buildQuickstartArgs({ emailMode: 'provided', email: 'a@example.com' })).toEqual([ + 'quickstart', + '--email', + 'a@example.com' + ]) + }) + + it('passes --without-email when the mode is without', () => { + expect(buildQuickstartArgs({ emailMode: 'without' })).toEqual(['quickstart', '--without-email']) + }) + + it('ignores an email payload in without mode (no fake address, no -m)', () => { + expect(buildQuickstartArgs({ emailMode: 'without', email: 'leftover@example.com' })).toEqual([ + 'quickstart', + '--without-email' + ]) + }) + + it('rejects an empty or invalid email only in provided mode', () => { + expect(() => buildQuickstartArgs({ emailMode: 'provided', email: '' })).toThrow(/email/i) + expect(() => buildQuickstartArgs({ emailMode: 'provided', email: 'not-an-email' })).toThrow( + /email/i + ) + expect(() => buildQuickstartArgs({ emailMode: 'provided', email: 'a@b.c\nevil' })).toThrow( + /email/i + ) + expect(() => buildQuickstartArgs({ emailMode: 'provided' })).toThrow(/email/i) + }) +}) From 6065c925f1030f4eaef02c6939de4cf7fa9a2eda Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:17:04 +0300 Subject: [PATCH 11/35] =?UTF-8?q?feat:=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D1=8C=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=BD=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=B8=20?= =?UTF-8?q?=D1=81=D1=83=D1=89=D0=B5=D1=81=D1=82=D0=B2=D1=83=D1=8E=D1=89?= =?UTF-8?q?=D0=B5=D0=B3=D0=BE=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/ipc-handlers.ts | 22 +- src/preload/index.ts | 7 + src/renderer/src/App.tsx | 24 ++- src/renderer/src/components/SshAccessForm.tsx | 88 ++++---- src/renderer/src/i18n/en.json | 28 +++ src/renderer/src/i18n/ru.json | 28 +++ src/renderer/src/i18n/zh.json | 28 +++ src/renderer/src/pages/AddServer.module.css | 91 ++++++++ src/renderer/src/pages/AddServer.tsx | 77 +++++-- src/renderer/src/pages/Dashboard.module.css | 76 ++++++- src/renderer/src/pages/Dashboard.tsx | 68 ++++-- .../src/pages/ImportServer.module.css | 196 ++++++++++++++++++ src/renderer/src/pages/ImportServer.tsx | 155 ++++++++++++++ src/renderer/src/pages/ServerSettings.tsx | 3 + src/renderer/src/pages/deploy-readiness.ts | 34 +++ src/shared/types.ts | 8 + tests/unit/ui-contracts.test.ts | 54 +++++ 17 files changed, 900 insertions(+), 87 deletions(-) create mode 100644 src/renderer/src/pages/ImportServer.module.css create mode 100644 src/renderer/src/pages/ImportServer.tsx create mode 100644 src/renderer/src/pages/deploy-readiness.ts create mode 100644 tests/unit/ui-contracts.test.ts diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 0bdff2a..d89f4f6 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -5,6 +5,7 @@ import { randomUUID } from 'node:crypto' import { readFileSync, statSync } from 'node:fs' import type { ImportServerPayload, + ImportStep, ProfileCreateInput, ProfileFingerprintInput, ProfilePortInput, @@ -71,9 +72,9 @@ export function registerIpcHandlers({ store }: IpcContext): void { try { await keychain.save(credentialId, temporaryKey) return { credentialId, name: basename(path), persisted: true } - } catch (error) { - // Keychain unavailable: permit this one operation through the approved path only. - // The UI is told that reuse is unavailable, and no plaintext copy is stored. + } catch { + // Keychain unavailable: keep a session-only in-memory copy (never returned to + // the renderer, never written to disk); the UI is told reuse is unavailable. const temporaryCredentialId = randomUUID().replace(/-/g, '') transientKeys.set(temporaryCredentialId, Buffer.from(temporaryKey)) return { credentialId: temporaryCredentialId, name: basename(path), persisted: false } @@ -246,15 +247,26 @@ export function registerIpcHandlers({ store }: IpcContext): void { return { serverId, subscriptionUrl: server.subscriptionUrl, keys } }) - ipcMain.handle('servers:import', async (_e, payload: ImportServerPayload) => { + ipcMain.handle('servers:import', async (event, payload: ImportServerPayload) => { if (!payload || typeof payload !== 'object') throw new Error('Некорректный запрос импорта') if (!payload.access || payload.access.authMethod !== 'privateKey') { throw new Error('Импорт существующего сервера доступен только по SSH-ключу') } + const emitStep = (step: ImportStep): void => { + if (!event.sender.isDestroyed()) { + event.sender.send('servers:importEvent', { step }) + } + } + emitStep('ssh') const target = { host: payload.host, port: payload.port } const { credentials, access } = await credentialsFor(null, target, payload.access) - const inspector = new ServerInspector(credentials, (url) => fetchSubscription(url)) + emitStep('inspect') + const inspector = new ServerInspector(credentials, async (url) => { + emitStep('subscription') + return fetchSubscription(url) + }) const result = await inspector.inspect() + emitStep('save') const connection: ServerConnectionMetadata = { username: access.username, diff --git a/src/preload/index.ts b/src/preload/index.ts index 1079625..5eb7029 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3,6 +3,7 @@ import type { DeployEvent, DeployStartPayload, ElectronAPI, + ImportProgressEvent, PrivateKeyReference, ImportResult, ImportServerPayload, @@ -33,6 +34,12 @@ const api: ElectronAPI = { list: (): Promise => ipcRenderer.invoke('servers:list'), import: (payload: ImportServerPayload): Promise => ipcRenderer.invoke('servers:import', payload), + onImportEvent: (callback: (event: ImportProgressEvent) => void): (() => void) => { + const listener = (_e: Electron.IpcRendererEvent, event: ImportProgressEvent): void => + callback(event) + ipcRenderer.on('servers:importEvent', listener) + return () => ipcRenderer.removeListener('servers:importEvent', listener) + }, remove: (id: string): Promise => ipcRenderer.invoke('servers:remove', id), get: (id: string): Promise => ipcRenderer.invoke('servers:get', id), check: (id: string): Promise => ipcRenderer.invoke('servers:check', id), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9934a0e..0bad6d5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { Dashboard } from './pages/Dashboard' import { AddServer } from './pages/AddServer' +import { ImportServer } from './pages/ImportServer' import { ServerKeys } from './pages/ServerKeys' import { ServerSettings } from './pages/ServerSettings' import type { Server } from '@shared/types' @@ -8,6 +9,7 @@ import type { Server } from '@shared/types' type View = | { name: 'dashboard' } | { name: 'add' } + | { name: 'import' } | { name: 'keys'; server: Server } | { name: 'settings'; server: Server } @@ -19,11 +21,18 @@ export default function App(): React.JSX.Element { window.api.servers.list().then(setServers) }, []) + const upsertServer = (server: Server): void => { + setServers((prev) => { + const others = prev.filter((s) => s.id !== server.id) + return [...others, server] + }) + } + if (view.name === 'add') { return ( { - setServers((prev) => [...prev, server]) + upsertServer(server) setView({ name: 'keys', server }) }} onBack={() => setView({ name: 'dashboard' })} @@ -31,6 +40,18 @@ export default function App(): React.JSX.Element { ) } + if (view.name === 'import') { + return ( + { + upsertServer(server) + setView({ name: 'settings', server }) + }} + onBack={() => setView({ name: 'dashboard' })} + /> + ) + } + if (view.name === 'keys') { return ( setView({ name: 'add' })} + onImport={() => setView({ name: 'import' })} onOpen={(server) => setView({ name: 'keys', server })} onSettings={(server) => setView({ name: 'settings', server })} onRemove={async (id) => { diff --git a/src/renderer/src/components/SshAccessForm.tsx b/src/renderer/src/components/SshAccessForm.tsx index 9e9dea1..9b2fd91 100644 --- a/src/renderer/src/components/SshAccessForm.tsx +++ b/src/renderer/src/components/SshAccessForm.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { Button, Input, Label, TextField } from '@heroui/react' import { FileKey2, KeyRound, LockKeyhole, ShieldCheck, ShieldQuestion } from 'lucide-react' import { useTranslation } from 'react-i18next' -import type { SshAccessInput } from '@shared/types' +import type { SshAccessInput, SshAuthMethod } from '@shared/types' import styles from './SshAccessForm.module.css' interface SshAccessFormProps { @@ -11,6 +11,8 @@ interface SshAccessFormProps { disabled?: boolean hostKeyFingerprint?: string | null onForgetHostKey?: () => void + /** Ограничивает выбор способа входа (например, только ключ для импорта). */ + allowedAuthMethods?: SshAuthMethod[] } export function isSshAccessReady(access: SshAccessInput): boolean { @@ -24,7 +26,8 @@ export function SshAccessForm({ onChange, disabled = false, hostKeyFingerprint, - onForgetHostKey + onForgetHostKey, + allowedAuthMethods = ['password', 'privateKey'] }: SshAccessFormProps): React.JSX.Element { const { t } = useTranslation() const [keyError, setKeyError] = useState(null) @@ -38,7 +41,13 @@ export function SshAccessForm({ setKeyError(null) try { const selection = await window.api.ssh.selectPrivateKey() - if (selection) update({ privateKeyCredentialId: selection.credentialId, privateKeyName: selection.name }) + if (selection) + update({ + authMethod: 'privateKey', + privateKeyCredentialId: selection.credentialId, + privateKeyName: selection.name, + privateKeyPersisted: selection.persisted !== false + }) } catch (error) { setKeyError(error instanceof Error ? error.message : String(error)) } @@ -57,41 +66,43 @@ export function SshAccessForm({ /> -
- {t('sshAccess.authMethod')} -
- - + {allowedAuthMethods.length > 1 && ( +
+ {t('sshAccess.authMethod')} +
+ + +
-
+ )} {value.authMethod === 'password' ? ( @@ -116,6 +127,9 @@ export function SshAccessForm({ {keyName ?? t('sshAccess.noKey')}
+ {keyName && value.privateKeyPersisted === false && ( + {t('sshAccess.keyNotPersisted')} + )} svg { + flex: 0 0 auto; + margin-top: 1px; +} + +.choice > span { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.choice strong { + color: var(--text-0); + font-size: 13px; + font-weight: 600; +} + +.choice small { + color: var(--text-2); + font-size: 11px; + line-height: 1.35; +} + +.choiceActive { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 9%, var(--bg-1)); + box-shadow: inset 0 0 0 1px var(--accent); + color: var(--accent); +} + +.warning { + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid color-mix(in srgb, var(--warning) 45%, transparent); + background: color-mix(in srgb, var(--warning) 10%, var(--bg-1)); + color: var(--warning); + font-size: 12px; + line-height: 1.45; +} + +@media (max-width: 760px) { + .choiceGrid { + grid-template-columns: 1fr; + } +} + .error { padding: 12px 14px; border-radius: var(--radius-sm); diff --git a/src/renderer/src/pages/AddServer.tsx b/src/renderer/src/pages/AddServer.tsx index 3cf88c7..1f443ff 100644 --- a/src/renderer/src/pages/AddServer.tsx +++ b/src/renderer/src/pages/AddServer.tsx @@ -1,9 +1,16 @@ import { useEffect, useRef, useState } from 'react' import { Button, TextField, Label, Input, Spinner } from '@heroui/react' -import { CheckCircle2, Circle } from 'lucide-react' +import { CheckCircle2, Circle, Mail, MailX } from 'lucide-react' import { useTranslation } from 'react-i18next' -import type { DeployEvent, DeployStep, Server, SshAccessInput } from '@shared/types' -import { isSshAccessReady, SshAccessForm } from '../components/SshAccessForm' +import type { + DeployEvent, + DeployStep, + EmailMode, + Server, + SshAccessInput +} from '@shared/types' +import { SshAccessForm } from '../components/SshAccessForm' +import { buildDeployPayload, isDeployReady } from './deploy-readiness' import styles from './AddServer.module.css' interface AddServerProps { @@ -24,6 +31,7 @@ const STEP_ORDER: DeployStep[] = [ interface FormState { host: string port: string + emailMode: EmailMode email: string } @@ -32,6 +40,7 @@ export function AddServer({ onDone, onBack }: AddServerProps): React.JSX.Element const [form, setForm] = useState({ host: '', port: '22', + emailMode: 'provided', email: '' }) const [access, setAccess] = useState({ @@ -91,18 +100,12 @@ export function AddServer({ onDone, onBack }: AddServerProps): React.JSX.Element currentStepRef.current = null setCurrentStep(null) setDeploying(true) - window.api.deploy.start({ - host: form.host.trim(), - port: Number(form.port) || 22, - emailMode: 'provided', - email: form.email.trim(), - access - }) + window.api.deploy.start(buildDeployPayload(form, access)) } const input = ( label: string, - key: keyof FormState, + key: Extract, placeholder = '', type: 'text' | 'password' | 'email' = 'text' ): React.JSX.Element => ( @@ -110,7 +113,7 @@ export function AddServer({ onDone, onBack }: AddServerProps): React.JSX.Element setForm((f) => ({ ...f, [key]: e.target.value }))} @@ -137,19 +140,55 @@ export function AddServer({ onDone, onBack }: AddServerProps): React.JSX.Element {input(t('deploy.host'), 'host', '185.23.xx.xx')} {input(t('deploy.port'), 'port', '22')} - {input(t('deploy.email'), 'email', 'user@example.com', 'email')} + +
+ {t('deploy.emailMode')} +
+ + +
+
+ + {form.emailMode === 'provided' ? ( + input(t('deploy.email'), 'email', 'user@example.com', 'email') + ) : ( +
{t('deploy.withoutEmailWarning')}
+ )} + + + + {t('dashboard.add')} + + + + + + + {t('dashboard.addDeploy')} + + + + {t('dashboard.addImport')} + + + + )} @@ -152,15 +177,30 @@ export function Dashboard({ ))} {servers.length === 0 && ( - +
+ + +
)} diff --git a/src/renderer/src/pages/ImportServer.module.css b/src/renderer/src/pages/ImportServer.module.css new file mode 100644 index 0000000..ae16c31 --- /dev/null +++ b/src/renderer/src/pages/ImportServer.module.css @@ -0,0 +1,196 @@ +.root { + height: 100%; + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 32px; + display: flex; + flex-direction: column; + gap: 24px; + overflow-y: auto; +} + +.header { + display: flex; + align-items: center; + gap: 12px; +} + +.title { + font-size: 24px; + font-weight: 600; + line-height: 1; + margin: 0; + display: flex; + align-items: center; +} + +.body { + display: grid; + grid-template-columns: 480px minmax(0, 1fr); + gap: 16px; + align-items: stretch; +} + +.form { + display: flex; + flex-direction: column; + gap: 18px; +} + +.form :global(input:focus), +.form :global(input:focus-visible) { + outline: none; + box-shadow: inset 0 0 0 2px var(--accent); +} + +.hint { + margin: 0; + font-size: 13px; + line-height: 1.5; + color: var(--text-2); +} + +.importBtn { + margin-top: 8px; + font-weight: 600; +} + +.note { + font-size: 12px; + line-height: 1.45; + color: var(--text-2); +} + +.error { + padding: 12px 14px; + border-radius: var(--radius-sm); + background: rgba(248, 81, 73, 0.12); + border: 1px solid rgba(248, 81, 73, 0.4); + color: var(--danger); + font-size: 13px; +} + +.status { + display: flex; + flex-direction: column; + gap: 16px; + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: 16px; + padding: 20px; + align-self: stretch; +} + +.statusTitle { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-2); + opacity: 0.8; +} + +.steps { + list-style: none; + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0; +} + +.step { + display: flex; + flex-direction: column; + gap: 2px; + padding: 12px; + border-radius: 10px; + background: var(--bg-2); + border: 1px solid var(--border); + font-size: 14px; + color: var(--text-2); + transition: color 0.3s, border-color 0.3s; +} + +.stepRow { + display: flex; + align-items: center; + gap: 10px; +} + +.stepIcon { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.stepTodo { + color: var(--text-2); + opacity: 0.4; + flex-shrink: 0; +} + +.stepDot { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--warning); + box-shadow: 0 0 10px var(--warning); + animation: breathing 1.6s ease-in-out infinite; + flex-shrink: 0; +} + +@keyframes breathing { + 0% { + transform: scale(0.75); + opacity: 0.7; + } + 50% { + transform: scale(1); + opacity: 1; + } + 100% { + transform: scale(0.75); + opacity: 0.7; + } +} + +.stepCheck { + color: var(--success); + animation: popIn 0.35s ease; +} + +@keyframes popIn { + 0% { + opacity: 0; + transform: scale(0.5) rotate(-12deg); + } + 60% { + transform: scale(1.1) rotate(3deg); + } + 100% { + opacity: 1; + transform: scale(1) rotate(0deg); + } +} + +.step.done { + color: var(--success); + border-color: rgba(63, 185, 80, 0.35); +} + +.step.active { + color: var(--text-0); + border-color: var(--warning); + background: color-mix(in srgb, var(--warning) 8%, var(--bg-2)); +} + +@media (max-width: 900px) { + .body { + grid-template-columns: 1fr; + } +} diff --git a/src/renderer/src/pages/ImportServer.tsx b/src/renderer/src/pages/ImportServer.tsx new file mode 100644 index 0000000..af947c4 --- /dev/null +++ b/src/renderer/src/pages/ImportServer.tsx @@ -0,0 +1,155 @@ +import { useEffect, useState } from 'react' +import { Button, TextField, Label, Input, Spinner } from '@heroui/react' +import { CheckCircle2, Circle } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import type { ImportStep, Server, SshAccessInput } from '@shared/types' +import { SshAccessForm } from '../components/SshAccessForm' +import styles from './ImportServer.module.css' + +interface ImportServerProps { + onDone: (server: Server) => void + onBack: () => void +} + +const STEP_ORDER: ImportStep[] = ['ssh', 'inspect', 'subscription', 'save'] + +interface FormState { + host: string + port: string +} + +export function ImportServer({ onDone, onBack }: ImportServerProps): React.JSX.Element { + const { t } = useTranslation() + const [form, setForm] = useState({ host: '', port: '22' }) + const [access, setAccess] = useState({ + username: 'root', + authMethod: 'privateKey', + privilegeMode: 'root' + }) + const [running, setRunning] = useState(false) + const [currentStep, setCurrentStep] = useState(null) + const [error, setError] = useState(null) + + // Main process отправляет каждый шаг только при реальном входе в эту фазу. + useEffect(() => { + if (!running) return + return window.api.servers.onImportEvent((event) => setCurrentStep(event.step)) + }, [running]) + + const keyReady = access.authMethod === 'privateKey' && Boolean(access.privateKeyCredentialId) + const ready = form.host.trim().length > 0 && access.username.trim().length > 0 && keyReady + + const startImport = (): void => { + setError(null) + setRunning(true) + setCurrentStep(null) + window.api.servers + .import({ + host: form.host.trim(), + port: Number(form.port) || 22, + access + }) + .then((result) => window.api.servers.get(result.serverId)) + .then((server) => { + if (!server) throw new Error(t('import.notSaved')) + onDone(server) + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : String(err)) + setRunning(false) + }) + } + + return ( +
+
+ +

{t('import.title')}

+
+ +
+
+

{t('import.hint')}

+ + + setForm((f) => ({ ...f, host: e.target.value }))} + /> + + + + setForm((f) => ({ ...f, port: e.target.value }))} + /> + + + + + + {!keyReady && !running && ( +
{t('import.selectKeyNote')}
+ )} + {error && ( +
+ {t('deploy.error')}: {error} +
+ )} +
+ +
+
{t('import.progress')}
+
    + {STEP_ORDER.map((step) => { + const currentIndex = currentStep ? STEP_ORDER.indexOf(currentStep) : -1 + const state = + currentIndex > STEP_ORDER.indexOf(step) + ? 'done' + : currentStep === step + ? 'active' + : 'todo' + return ( +
  1. +
    + + {state === 'done' ? ( + + ) : state === 'active' ? ( + + ) : ( + + )} + + {t(`import.steps.${step}`)} +
    +
  2. + ) + })} +
+
+
+
+ ) +} diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index c8cd672..00e90a0 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -59,6 +59,9 @@ export function ServerSettings({ server, onBack }: ServerSettingsProps): React.J authMethod: server.authMethod ?? 'password', password: '', privateKeyPath: server.privateKeyPath ?? undefined, + privateKeyCredentialId: server.privateKeyCredentialId ?? undefined, + privateKeyName: server.privateKeyName ?? undefined, + privateKeyPersisted: server.privateKeyPersisted ?? undefined, passphrase: '', privilegeMode: server.privilegeMode ?? 'root', sudoPassword: '' diff --git a/src/renderer/src/pages/deploy-readiness.ts b/src/renderer/src/pages/deploy-readiness.ts new file mode 100644 index 0000000..9095a66 --- /dev/null +++ b/src/renderer/src/pages/deploy-readiness.ts @@ -0,0 +1,34 @@ +import type { DeployStartPayload, EmailMode, SshAccessInput } from '@shared/types' +import { isSshAccessReady } from '../components/SshAccessForm' + +export interface DeployFormState { + host: string + port: string + emailMode: EmailMode + email: string +} + +export function isValidEmailFormat(email: string): boolean { + return /^[^@\s]+@[^@\s]+\.[^@\s]{2,}$/.test(email.trim()) && !/[\r\n]/.test(email) +} + +export function isDeployReady(form: DeployFormState, access: SshAccessInput): boolean { + if (!form.host.trim()) return false + if (!isSshAccessReady(access)) return false + if (form.emailMode === 'provided') return isValidEmailFormat(form.email) + return true +} + +export function buildDeployPayload( + form: DeployFormState, + access: SshAccessInput +): Omit { + const trimmedEmail = form.email.trim() + return { + host: form.host.trim(), + port: Number(form.port) || 22, + emailMode: form.emailMode, + ...(form.emailMode === 'provided' ? { email: trimmedEmail } : {}), + access + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 0a86b08..794f929 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -228,6 +228,13 @@ export type DeployStep = export type DeployStatus = 'pending' | 'running' | 'done' | 'error' +/** Фазы read-only импорта: main process шлёт шаг только при реальном входе в фазу. */ +export type ImportStep = 'ssh' | 'inspect' | 'subscription' | 'save' + +export interface ImportProgressEvent { + step: ImportStep +} + export interface DeployStartPayload { host: string port: number @@ -255,6 +262,7 @@ export interface ElectronAPI { servers: { list: () => Promise import: (payload: ImportServerPayload) => Promise + onImportEvent: (callback: (event: ImportProgressEvent) => void) => () => void remove: (id: string) => Promise get: (id: string) => Promise check: (id: string) => Promise diff --git a/tests/unit/ui-contracts.test.ts b/tests/unit/ui-contracts.test.ts new file mode 100644 index 0000000..4fe6a7f --- /dev/null +++ b/tests/unit/ui-contracts.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { buildDeployPayload, isDeployReady } from '../../src/renderer/src/pages/deploy-readiness' +import type { SshAccessInput } from '../../src/shared/types' + +const readyAccess: SshAccessInput = { + username: 'root', + authMethod: 'password', + password: 'secret', + privilegeMode: 'root' +} + +describe('deploy readiness', () => { + it('allows deploying without email only in the explicit without mode', () => { + expect( + isDeployReady({ host: 'vps', port: '22', emailMode: 'without', email: '' }, readyAccess) + ).toBe(true) + }) + + it('requires a valid email in provided mode', () => { + expect( + isDeployReady({ host: 'vps', port: '22', emailMode: 'provided', email: '' }, readyAccess) + ).toBe(false) + expect( + isDeployReady({ host: 'vps', port: '22', emailMode: 'provided', email: 'bad' }, readyAccess) + ).toBe(false) + expect( + isDeployReady( + { host: 'vps', port: '22', emailMode: 'provided', email: 'a@b.co' }, + readyAccess + ) + ).toBe(true) + }) + + it('requires host and ssh access regardless of email mode', () => { + expect( + isDeployReady({ host: '', port: '22', emailMode: 'without', email: '' }, readyAccess) + ).toBe(false) + expect( + isDeployReady( + { host: 'vps', port: '22', emailMode: 'without', email: '' }, + { username: '', authMethod: 'password', password: 'x', privilegeMode: 'root' } + ) + ).toBe(false) + }) + + it('omits the email from the payload in without mode', () => { + const payload = buildDeployPayload( + { host: ' vps.example ', port: '2222', emailMode: 'without', email: 'leftover@x.io' }, + readyAccess + ) + expect(payload).toMatchObject({ host: 'vps.example', port: 2222, emailMode: 'without' }) + expect(payload.email).toBeUndefined() + }) +}) From 9156d425d2d50be56260db128bd978ec3d6b270a Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:55:59 +0300 Subject: [PATCH 12/35] =?UTF-8?q?docs:=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=20=D0=B8=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0=D1=81=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C=20SSH=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 +++----- README.ru.md | 13 +++----- README.zh-CN.md | 12 +++---- docs/architecture.md | 8 +++-- docs/configuration.md | 6 ++++ docs/desktop-gui.md | 27 +++++++++++----- docs/ru/architecture.md | 7 ++-- docs/ru/configuration.md | 2 ++ docs/ru/desktop-gui.md | 30 +++++++++++------ docs/ru/security.md | 25 ++++++--------- docs/security.md | 32 ++++++++++++------- docs/zh-CN/architecture.md | 3 +- docs/zh-CN/configuration.md | 2 ++ docs/zh-CN/desktop-gui.md | 30 +++++++++++------ docs/zh-CN/security.md | 17 ++++------ src/renderer/src/i18n/en.json | 23 +++++++++++++ src/renderer/src/i18n/ru.json | 23 +++++++++++++ src/renderer/src/i18n/zh.json | 23 +++++++++++++ src/renderer/src/pages/Dashboard.tsx | 9 ++++++ .../src/pages/ServerSettings.module.css | 23 +++++++++++++ src/renderer/src/pages/ServerSettings.tsx | 15 +++++++++ 21 files changed, 248 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index 60fad7a..d49f45f 100644 --- a/README.md +++ b/README.md @@ -290,17 +290,13 @@ What the GUI can do: | Page | Operations | |---|---| -| Dashboard | Server cards with reachability status, open, settings, delete; language switch | -| Add server | Deploy a new VPS: upload `install.sh` + `xrayebator`, run the install, place the binary, run `quickstart --email`, save the server and the `subscription_url` | -| Server keys | Refresh the subscription, copy the URL, show `vless://` links and QR codes | +| Dashboard | Server cards with reachability and installation status; an empty screen offers “Deploy a new server” or “Connect an existing server”; language switch | +| 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 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 | -Root + password is the one-click default; key authentication and sudo are optional. SSH passwords, -sudo passwords, key passphrases and private-key contents stay only in renderer memory for the active -form/session and are sent to the main process per operation. The app persists the server card, -connection preferences, `subscription_url`, fetched `vless://` links and the 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 is stored in the operating-system keychain via `keytar` and reused across later SSH operations and app restarts; only its credential id and display name are stored with the server card. SSH passwords, sudo passwords and key passphrases are never persisted; encrypted-key passphrases are requested again when needed. If the OS keychain is unavailable, there is no plaintext fallback: the selected key remains in main-process memory for the current app session and the UI warns that it must be selected 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. 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 diff --git a/README.ru.md b/README.ru.md index 17162ea..a4a06f6 100644 --- a/README.ru.md +++ b/README.ru.md @@ -286,16 +286,13 @@ xrayebator (bash) ──► /usr/local/etc/xray/ | Страница | Операции | |---|---| -| Dashboard | Карточки серверов со статусом доступности: открыть, настройки, удалить; переключатель языка | -| Добавить сервер | Развернуть новый VPS: загрузить `install.sh` + `xrayebator`, запустить установку, положить бинарь, выполнить `quickstart --email`, сохранить сервер и `subscription_url` | -| Ключи сервера | Обновить подписку, скопировать URL, показать ссылки `vless://` и QR-коды | +| Dashboard | Карточки серверов со статусом доступности и установки; пустой экран предлагает «Развернуть новый сервер» или «Подключить существующий»; переключатель языка | +| Добавить сервер | Развернуть VPS с явным выбором email: `quickstart --email` или `quickstart --without-email`; сохранить сервер и публичную подписку | +| Подключить существующий | Импорт распознанной установки Xrayebator по SSH-ключу через read-only `xrayebator inspect --json`; частичные установки сохраняются с диагностикой без автоисправления | +| Ключи сервера | Обновить публичную подписку, скопировать URL, показать ссылки `vless://` и QR-коды | | Настройки сервера | SSH по паролю или приватному ключу, прямой root или sudo; список/создание/удаление профилей, смена fingerprint, SNI и порта, обновление или удаление Xrayebator | -SSH-пароли, sudo-пароли, passphrase и содержимое приватного ключа живут только в активной форме/операции -и не сохраняются. Локально сохраняются карточка сервера, настройки подключения, `subscription_url`, -полученные ссылки `vless://` и закреплённый SSH host-key fingerprint. URL подписки и VLESS-ссылки — -bearer/client credentials: защищайте локальные данные приложения и после утечки отзывайте подписку -через терминальный workflow. +Выбранный приватный ключ сохраняется в системном keychain через `keytar` и повторно используется при следующих SSH-операциях и после перезапуска приложения; в карточке сервера хранятся только credential id и отображаемое имя ключа. SSH-пароли, sudo-пароли и passphrase не сохраняются; passphrase зашифрованного ключа вводится заново. Если системный keychain недоступен, plaintext-фолбека нет: ключ остаётся в памяти main process до завершения текущего сеанса, а GUI предупреждает, что после перезапуска его нужно выбрать снова. Также локально сохраняются `subscription_url`, ссылки `vless://` и закреплённый SSH host-key fingerprint. Это bearer/client credentials: защищайте локальные данные и при утечке отзывайте подписку через терминальный workflow. GUI предоставляет только подмножество терминального меню. Bypass, `probe-test`, revoke подписки, `happ-setup`, каскад, self-steal и логи/статус сервисов остаются терминальными операциями. Полная diff --git a/README.zh-CN.md b/README.zh-CN.md index ace96fe..94f2a6a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -274,15 +274,13 @@ GUI 的功能: | 页面 | 操作 | |---|---| -| Dashboard | 服务器卡片与连通状态:打开、设置、删除;语言切换 | -| 添加服务器 | 部署新 VPS:上传 `install.sh` 与 `xrayebator`,运行安装,放置二进制,执行 `quickstart --email`,保存服务器与 `subscription_url` | -| 服务器密钥 | 刷新订阅、复制链接、显示 `vless://` 链接与二维码 | +| Dashboard | 服务器卡片与连通/安装状态;空页面提供“部署新服务器”或“连接现有服务器”;语言切换 | +| 添加服务器 | 显式选择是否提供 email:`quickstart --email` 或 `quickstart --without-email`;保存服务器与公网订阅 | +| 连接现有服务器 | 通过 SSH 密钥和只读 `xrayebator inspect --json` 导入已识别的 Xrayebator;部分安装会连同诊断状态保存,不自动修复 | +| 服务器密钥 | 刷新公网订阅、复制链接、显示 `vless://` 链接与二维码 | | 服务器设置 | 使用 SSH 密码或私钥、直接 root 或 sudo:列出/创建/删除配置档,修改指纹、SNI 和端口,以及更新或卸载服务器上的 Xrayebator | -SSH 密码、sudo 密码、私钥口令和私钥内容只在当前表单/操作期间保存在内存中,不会持久化。 -本地会保存服务器卡片、连接偏好、`subscription_url`、获取到的 `vless://` 链接和固定的 SSH host-key -fingerprint。订阅 URL 和 VLESS 链接属于 bearer/client credentials:请保护本地应用数据,泄露后 -通过终端 workflow 吊销订阅。 +选中的私钥通过 `keytar` 保存在操作系统钥匙串中,可在之后的 SSH 操作和应用重启后复用;服务器卡片只保存 credential id 和显示文件名。SSH 密码、sudo 密码和私钥口令不会持久化;加密密钥的口令需要重新输入。系统钥匙串不可用时不会写入明文回退文件:密钥仅保留在 main process 内存中直到当前会话结束,界面会提示重启后需重新选择。应用还会保存 `subscription_url`、获取到的 `vless://` 链接和固定的 SSH host-key fingerprint。这些是 bearer/client credentials:请保护本地应用数据,泄露后通过终端 workflow 吊销订阅。 GUI 只暴露终端菜单的一个子集。bypass、`probe-test`、订阅吊销、`happ-setup`、级联、self-steal 以及服务日志/状态仍需从终端执行。完整的 Electron GUI 边界、安全模型与打包说明见 diff --git a/docs/architecture.md b/docs/architecture.md index 70ba8e2..a3fdbeb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -106,9 +106,11 @@ http://127.0.0.1:8080/sub/ # local-only fallback ``` The interactive HAPP setup can select the public port and `_subscription_base_url` preserves that -choice. The non-interactive `quickstart --email
` IP-TLS path currently provisions nginx, -certificate and markers on `8443`, then emits JSON containing `subscription_url` for that endpoint. -The token is stored in the profile as `sub_token`; revoke rotates it and invalidates the previous URL. +choice. The non-interactive `quickstart --email
` and `quickstart --without-email` IP-TLS paths +provision nginx, certificate and markers on `8443`, then emit JSON containing `subscription_url` for that +endpoint. Without an email, Certbot is explicitly told to register without an ACME contact; renewal +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. 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 06c9072..c703ccc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -113,6 +113,8 @@ is a client-side profile/route setting; changing it does not restart Xray or alt | `sudo xrayebator update ` | Self-update the manager from the canonical raw repository branch, continue with the new script, then update Xray-core | | `sudo xrayebator probe-test` | Check SNI reachability from the VPS before switching | | `sudo xrayebator quickstart --email
` | One-shot deploy path used by the desktop GUI: runs the broad setup/migration path, provisions the current IP-TLS endpoint on `8443`, and creates a standard HAPP profile with `schema_version: 3` and 7 routes; emits JSON with `subscription_url` | +| `sudo xrayebator quickstart --without-email` | Same new-server path without an ACME contact email; Certbot uses `--register-unsafely-without-email`, so no renewal notices or email-based account recovery are available | +| `sudo xrayebator inspect --json` | Read-only GUI import probe: reports manager, Xray, profile and subscription markers without installing, migrating or changing services/configuration | | `sudo xrayebator happ-setup` | Reduced existing-install HAPP path: ensures the subscription service and a usable multi-route profile, but does not replace the endpoint prerequisite; when `.subscription_domain` or `.subscription_port` is missing, it verifies a real public TLS endpoint before writing markers and otherwise fails | | `sudo xrayebator profiles` | Print all server profiles as a JSON array for the desktop GUI Server Settings page | | `sudo xrayebator profile-create --name NAME [--transport tcp\|tcp-utls\|tcp-xudp\|tcp-mux\|grpc\|xhttp] [--port P] [--count N]` | Create one or more profiles non-interactively; prints `{"ok":true,"names":[...],"errors":[...]}` | @@ -152,6 +154,10 @@ or reuses the managed HAPP profile. A newly created standard profile uses `schem routes, including `xhttp-legacy` and `xhttp-pq`. Migration calls in this non-interactive path are best-effort; verify markers, the profile JSON and service status after deployment. +`quickstart --without-email` performs the same broad setup and endpoint provisioning as the email form, but registers the ACME account with `--register-unsafely-without-email`; Certbot renewal notices and email-based account recovery are unavailable. + +`inspect --json` is the GUI's read-only import probe. It reports whether this is an Xrayebator installation, Xray/profile/service markers and saved subscription metadata; it does not run migrations, create profiles, change configuration, restart services or edit firewall rules. + `happ-setup` is the reduced path for an existing installation. It runs only the critical migrations, restores the subscription service and ensures a multi-route profile; it is not a replacement for initial endpoint provisioning. If `.subscription_domain` or `.subscription_port` is missing, diff --git a/docs/desktop-gui.md b/docs/desktop-gui.md index 383c209..5ad1069 100644 --- a/docs/desktop-gui.md +++ b/docs/desktop-gui.md @@ -19,22 +19,26 @@ The renderer reaches privileged operations only through the narrow preload `cont ### Dashboard -Dashboard displays the saved server cards, a reachability status dot, location/OS and route metadata, and actions for keys, settings, and removing a local server card. Its reachability check is a bounded TCP check performed by the Electron main process; it is not the server-side `probe-test` command. The language selector switches between `RU`, `EN`, and `中文`. +Dashboard displays the saved server cards, a reachability status dot, an installation status chip (`Configured`, `Partially configured`, or `Imported`), location/OS and route metadata, and actions for keys, settings, and removing a local server card. On an empty Dashboard the operator chooses between two scenarios: **Deploy a new server** (install Xrayebator on a clean VPS) and **Connect an existing server** (find an installed Xrayebator over SSH and open its panel without touching the installation). With servers already present, `+ Add` opens the same choice. Its reachability check is a bounded TCP check performed by the Electron main process; it is not the server-side `probe-test` command. The language selector switches between `RU`, `EN`, and `中文`. ### Add server -Add server accepts the VPS host and SSH port, SSH access details, and an email for `quickstart`. The deployment progress is shown as these steps: +Add server accepts the VPS host and SSH port, SSH access details, and an explicit email choice for `quickstart`: either **Provide an email** (default, shown as a field) or **Continue without email** (a warning explains that Let's Encrypt renewal notices and ACME account recovery are unavailable). The deployment progress is shown as these steps: 1. connect over SSH and verify elevated access; 2. inspect `/etc/os-release`; 3. create a temporary `/tmp/xrayebator-` directory and upload `install.sh` and `xrayebator`; 4. run `bash install.sh` with the selected privileges; 5. install the uploaded manager binary at `/usr/local/bin/xrayebator`; -6. run `xrayebator quickstart --email `; +6. run `xrayebator quickstart --email ` or `xrayebator quickstart --without-email` — the second form passes `--register-unsafely-without-email` to Certbot and never substitutes a fake address; 7. read the JSON result, including `subscription_url`, fetch the subscription, and save the server metadata and keys locally. The GUI shows deployment logs and step status, but it does not provide a cancellation channel for an in-flight deployment. +### Connect existing server (import) + +Import accepts host, SSH port, SSH user, and a private key (key authentication only), then runs a strictly read-only `xrayebator inspect --json` over the same SSH stack: it recognizes Xrayebator installations only, and never runs `quickstart`, `happ-setup`, installers, updates, migrations, restarts, or firewall changes. A partially configured installation is still imported with honest component statuses (manager / Xray / profiles / subscription); a local-only or unreachable subscription is stored as such and no dead URL is presented as working. Importing the same `host + port` again updates the existing card instead of creating a duplicate; the server id and host-key pin survive the update. After a successful import the app opens Server Settings directly. + ### 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. @@ -56,11 +60,9 @@ SNI and port are inbound-level settings: changing them can affect every profile ## SSH and security -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 rejects an arbitrary path that was not approved by that dialog. +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. -SSH passwords, sudo passwords, key passphrases, and private-key bytes are not persisted. They exist only in the active form/operation and are passed to the main process when needed. `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, selected key path, 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. - -`keytar` is present in `package.json`, but the active Electron GUI does not use it to store SSH passwords or passphrases in an operating-system keychain. +SSH passwords, sudo passwords, and key passphrases are never persisted — the passphrase is asked again for each session when the key is encrypted. `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 id, 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 keychain entry; shared references are preserved. The Electron boundary includes the following protections: @@ -88,6 +90,13 @@ Deployment additionally invokes: ```text xrayebator quickstart --email EMAIL +xrayebator quickstart --without-email +``` + +Import invokes exactly one read-only command: + +```text +xrayebator inspect --json ``` The result consumed by the GUI uses `subscription_url`; the GUI then fetches that URL to obtain the VLESS keys. Server Settings also invokes the update operation (`xrayebator update `) and can upload and run `uninstall.sh` for removal. These are controlled operations, not an interactive shell. @@ -119,8 +128,10 @@ SSH connect + host-key verification ├─ SFTP upload: install.sh, xrayebator ├─ elevated `bash install.sh` ├─ elevated install → /usr/local/bin/xrayebator - ├─ elevated `xrayebator quickstart --email EMAIL` + ├─ elevated `xrayebator quickstart --email EMAIL` or `--without-email` └─ parse `subscription_url` → fetch subscription → persist the server card, connection preferences, subscription URL, and fetched VLESS links + +For an existing installation, the import flow instead runs the read-only `xrayebator inspect --json`, fetches the subscription only when a public HTTPS endpoint is reported, and saves the detected state without repairing or updating the VPS. ``` Remote commands are assembled with shell-safe argument quoting. For sudo access, the secret is supplied via stdin while the command itself is kept separate. The GUI closes the SSH client after each operation and clears the in-memory private-key buffer when the client closes. diff --git a/docs/ru/architecture.md b/docs/ru/architecture.md index 0d592c9..88ea8a5 100644 --- a/docs/ru/architecture.md +++ b/docs/ru/architecture.md @@ -102,9 +102,10 @@ http://127.0.0.1:8080/sub/ # local-only запасной ``` Интерактивная настройка HAPP может выбрать публичный порт, а `_subscription_base_url` сохраняет этот -выбор. Нон-интерактивный IP-TLS flow `quickstart --email
` сейчас создаёт nginx, сертификат и -маркеры на `8443`, затем возвращает JSON с `subscription_url` для этого endpoint. Токен хранится в -профиле как `sub_token`; revoke меняет токен и аннулирует предыдущий URL. +выбор. Нон-интерактивные IP-TLS пути `quickstart --email
` и `quickstart --without-email` +создают nginx, сертификат и маркеры на `8443`, затем возвращают JSON с `subscription_url` для endpoint. +Без email Certbot регистрирует ACME-аккаунт без контактного адреса: уведомления о продлении и восстановление +по email недоступны. Токен хранится в профиле как `sub_token`; revoke меняет его и аннулирует старый URL. Новый стандартный 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 2ca5a88..38ff00b 100644 --- a/docs/ru/configuration.md +++ b/docs/ru/configuration.md @@ -116,6 +116,8 @@ legacy-файлы и блоки, ранее созданные Xrayebator, и с | `sudo xrayebator update ` | Self-update менеджера из canonical raw-репозитория (ветка branch), продолжить новым скриптом, затем обновить Xray-core | | `sudo xrayebator probe-test` | Проверить SNI reachability с VPS | | `sudo xrayebator quickstart --email <адрес>` | Путь одноразового деплоя (используется GUI): broad setup/migration, IP-TLS endpoint на `8443` и стандартный schema-v3 HAPP-профиль из 7 маршрутов; выводит JSON с `subscription_url`. Migration calls best-effort, проверяйте итоговый профиль и сервисы | +| `sudo xrayebator quickstart --without-email` | Тот же путь развёртывания без ACME email; Certbot регистрирует аккаунт с `--register-unsafely-without-email`, поэтому уведомления и восстановление аккаунта по email недоступны | +| `sudo xrayebator 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":[...]}` | diff --git a/docs/ru/desktop-gui.md b/docs/ru/desktop-gui.md index 8bac214..f4cc391 100644 --- a/docs/ru/desktop-gui.md +++ b/docs/ru/desktop-gui.md @@ -19,22 +19,27 @@ Renderer получает привилегированные операции т ### Dashboard -Dashboard показывает сохранённые карточки серверов, точку доступности, регион/ОС и количество маршрутов, а также действия для ключей, настроек и удаления локальной карточки сервера. Проверка доступности — ограниченная TCP-проверка в Electron main process; это не серверная команда `probe-test`. Переключатель языка выбирает `RU`, `EN` или `中文`. +Dashboard показывает сохранённые карточки серверов, точку доступности, чип статуса установки («Настроен», «Настроен частично» или «Импортирован»), регион/ОС и количество маршрутов, а также действия для ключей, настроек и удаления локальной карточки сервера. На пустом Dashboard оператор выбирает один из двух сценариев: **Развернуть новый сервер** (установить Xrayebator на чистый VPS) и **Подключить существующий сервер** (найти установленный Xrayebator по SSH и открыть его панель, не затрагивая установку). Если серверы уже есть, `+ Add` открывает тот же выбор. Проверка доступности — ограниченная TCP-проверка в Electron main process; это не серверная команда `probe-test`. Переключатель языка выбирает `RU`, `EN` или `中文`. ### Add server -Add server принимает host и SSH-порт VPS, параметры SSH-доступа и email для `quickstart`. Прогресс развёртывания показывается такими шагами: +Add server принимает host и SSH-порт VPS, параметры SSH-доступа и явный выбор email для `quickstart`: либо **Указать email** (выбрано по умолчанию, адрес вводится в поле), либо **Продолжить без email**. При выборе второго варианта GUI предупреждает, что Let's Encrypt не сможет присылать уведомления о продлении и восстанавливать ACME-аккаунт. В этом режиме запускается `xrayebator quickstart --without-email`, а Certbot получает `--register-unsafely-without-email`; фиктивный адрес не подставляется. + +Прогресс развёртывания: 1. подключение по SSH и проверка повышенных прав; 2. чтение `/etc/os-release`; 3. создание временного каталога `/tmp/xrayebator-` и загрузка `install.sh` и `xrayebator`; 4. запуск `bash install.sh` с выбранными правами; 5. установка загруженного бинарника менеджера в `/usr/local/bin/xrayebator`; -6. запуск `xrayebator quickstart --email `; +6. запуск `xrayebator quickstart --email ` или `xrayebator quickstart --without-email`; 7. разбор JSON с `subscription_url`, загрузка подписки и локальное сохранение метаданных сервера и ключей. GUI показывает лог и состояние шагов, но для выполняющегося развёртывания IPC-канала отмены нет. +### Подключить существующий сервер (импорт) + +Мастер импорта принимает host, SSH-порт и пользователя; вход разрешён только по приватному SSH-ключу. GUI выполняет по SSH `xrayebator inspect --json` — это read-only диагностика, которая распознаёт только Xrayebator. Импорт не запускает установщик, `quickstart`, `happ-setup`, миграции, обновление, перезапуск служб, изменение firewall или конфигурации. Частичная установка всё равно добавляется с отдельными статусами менеджера, Xray, профилей и подписки; локальная или недоступная подписка не выдаётся за рабочую. Повторный импорт того же `host + port` обновляет существующую карточку, не меняя её id и host-key pin. После импорта открывается Server settings. ### Server keys Server keys обновляет подписку по сохранённому `subscription_url` и показывает полученные VLESS-маршруты. Каждый VLESS-ключ можно скопировать или показать как QR-код; URL подписки также копируется, есть действие «скопировать всё». Эта страница не создаёт отдельную подписку на сервере и не меняет токен подписки. @@ -56,11 +61,9 @@ SNI и порт — параметры уровня inbound: их изменен ## SSH и безопасность -GUI поддерживает SSH-аутентификацию по паролю или приватному ключу, а команды с повышенными правами можно выполнять напрямую из-под `root` или через `sudo`. Приватный ключ выбирается через нативный диалог Electron; main process отклоняет произвольный путь, который не был одобрен этим диалогом. +GUI поддерживает SSH-аутентификацию по паролю или приватному ключу, а команды с повышенными правами можно выполнять напрямую из-под `root` или через `sudo`. Ключ выбирается через нативный диалог Electron; main process читает его байты и сохраняет через `keytar` в системное хранилище ОС (Windows Credential Manager, macOS Keychain или Linux Secret Service). Renderer получает только непривилегированный credential id и отображаемое имя — байты ключа не пересекают preload boundary. После этого ключ можно повторно использовать для операций и после перезапуска приложения. -SSH-пароли, sudo-пароли, passphrase ключей и байты приватного ключа не сохраняются. Они существуют только в активной форме/операции и передаются main process по необходимости. `electron-store` сохраняет карточку сервера и настройки подключения, URL подписки и полученные VLESS-ссылки (bearer/client credentials), имя пользователя, способ аутентификации, режим привилегий, выбранный путь к ключу и SHA-256 host-key pin после первого успешного подключения (TOFU). Защищайте локальные данные приложения; если URL подписки или VLESS-ссылки утекли, отзовите подписку через терминальный workflow. При последующем несовпадении fingerprint подключение прекращается до выполнения команд; после осознанной переустановки сервера pin можно явно сбросить в Server settings. - -`keytar` есть в `package.json` среди зависимостей, но активный Electron-GUI пока не использует его для хранения SSH-паролей или passphrase в системном keychain. +Если системное хранилище недоступно, plaintext-файл не создаётся: ключ хранится только в памяти main process до выхода из приложения, а GUI предупреждает, что после перезапуска его понадобится выбрать снова. SSH-пароли, sudo-пароли и passphrase ключей не сохраняются; passphrase зашифрованного ключа вводится заново при необходимости. `electron-store` хранит карточку и настройки сервера, credential id/имя ключа, диагностику, URL подписки, VLESS-ссылки (bearer/client credentials) и SHA-256 host-key pin после первого успешного подключения (TOFU). Защищайте локальные данные приложения; при утечке URL или VLESS-ссылок отзовите подписку. При удалении последней карточки, ссылающейся на credential, запись keychain удаляется; если на неё ссылается другая карточка, запись сохраняется. Несовпадение host-key fingerprint блокирует команды; после осознанной переустановки VPS pin можно явно сбросить в Server settings. Граница Electron включает следующие меры защиты: @@ -84,10 +87,17 @@ xrayebator sni-list xrayebator port-change --name NAME [--route R] --port PORT|random ``` -При развёртывании дополнительно вызывается: +При развёртывании дополнительно вызывается один из вариантов: ```text xrayebator quickstart --email EMAIL +xrayebator quickstart --without-email +``` + +При импорте выполняется только read-only команда: + +```text +xrayebator inspect --json ``` GUI использует поле `subscription_url` из результата, затем получает по этому URL VLESS-ключи. Server settings также вызывает операцию обновления (`xrayebator update `) и для удаления может загрузить и запустить `uninstall.sh`. Это контролируемые операции, а не интерактивная shell-сессия. @@ -119,8 +129,10 @@ SSH connect + проверка host key ├─ SFTP upload: install.sh, xrayebator ├─ elevated `bash install.sh` ├─ elevated install → /usr/local/bin/xrayebator - ├─ elevated `xrayebator quickstart --email EMAIL` + ├─ elevated `xrayebator quickstart --email EMAIL` или `--without-email` └─ parse `subscription_url` → fetch subscription → сохранить карточку сервера, настройки подключения, URL подписки и полученные VLESS-ссылки + +Для существующей установки импорт вместо этого выполняет read-only `xrayebator inspect --json`, проверяет подписку только при наличии публичного HTTPS endpoint и сохраняет найденное состояние без исправления или обновления VPS. ``` Удалённые команды строятся с безопасным quoting аргументов shell. При доступе через sudo секрет передаётся через stdin отдельно от команды. После каждой операции GUI закрывает SSH-клиент и очищает буфер приватного ключа в памяти при его закрытии. diff --git a/docs/ru/security.md b/docs/ru/security.md index bea772a..bd2cca9 100644 --- a/docs/ru/security.md +++ b/docs/ru/security.md @@ -104,21 +104,14 @@ TCPKeepAlive yes ## Пароли в десктоп-GUI -Активный Electron-GUI поддерживает SSH по паролю и приватному ключу, с прямым root или sudo. Пароли, -sudo-пароли, passphrase и содержимое приватного ключа живут только в активной форме/операции и не -сохраняются. Приватный ключ читается только после выбора через нативный файловый dialog Electron. - -GUI сохраняет метаданные сервера, необходимые для возврата к серверу: хост, SSH-порт, -имя пользователя, способ аутентификации, режим привилегий и выбранный путь к ключу. Он также -сохраняет настройки, `subscription_url`, полученные `vless://`-ссылки и SHA-256 pin SSH host key. -URL подписки и VLESS-ссылки — bearer-credentials, поэтому защищайте локальные данные Electron-приложения -и отзывайте подписку при утечке. - -SSH host key работает по TOFU после первой успешной аутентификации. Fingerprint затем закрепляется; при -последующем несовпадении подключение останавливается до выполнения команд. После осознанной переустановки -VPS явно сбросьте pin в Server Settings и подтвердите новый ключ при следующем успешном подключении. - -`keytar` указан в `package.json`, но активный Electron-GUI не использует его для хранения SSH-паролей -или passphrase в системном keychain. Секреты остаются session-only. +Активный Electron-GUI поддерживает SSH по паролю и приватному ключу, с прямым root или sudo. Выбранный через нативный dialog приватный ключ main process сохраняет через `keytar` в системное хранилище ОС (Windows Credential Manager, macOS Keychain или Linux Secret Service). Поэтому ключ можно повторно использовать для SSH-операций и после перезапуска приложения. Renderer получает только несекретный credential id и отображаемое имя файла; байты приватного ключа не переходят через preload boundary. + +Если системный keychain недоступен, plaintext-фолбека на диске нет: ключ остаётся только в памяти main process до выхода из приложения, а GUI предупреждает, что после перезапуска ключ придётся выбрать снова. SSH-пароли, sudo-пароли и passphrase не сохраняются; passphrase зашифрованного ключа вводится заново при необходимости. + +`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 предупреждает об этом до запуска. + +SSH host key работает по TOFU после первой успешной аутентификации. Fingerprint затем закрепляется; при последующем несовпадении подключение останавливается до выполнения команд. После осознанной переустановки VPS явно сбросьте pin в Server Settings и подтвердите новый ключ при следующем успешном подключении. См. [Electron Desktop GUI](desktop-gui.md) для полной границы Electron, карты команд и деталей сборки. \ No newline at end of file diff --git a/docs/security.md b/docs/security.md index 6175c7d..e9329b5 100644 --- a/docs/security.md +++ b/docs/security.md @@ -108,23 +108,33 @@ TCPKeepAlive yes ## Desktop GUI credentials -The active Electron GUI supports SSH passwords and private keys, with direct-root or sudo execution. -Passwords, sudo passwords, key passphrases and private-key bytes are kept only in the active form or -operation; they are not persisted. A private key is read only after it was selected through the -native Electron file dialog. +The active Electron GUI supports SSH passwords and private keys, with direct-root or sudo execution. A +private key selected through the native Electron file dialog is read by the main process and stored in +the operating-system keychain via `keytar` (Windows Credential Manager, macOS Keychain, Linux Secret +Service), so the same key can be reused for later operations and after app restarts without selecting +the file again. When the OS keychain is unavailable there is deliberately no plaintext fallback: the +key survives only in main-process memory for the current session and the UI warns that re-selection +will be required after restart. + +SSH passwords, sudo passwords and key passphrases are never persisted; an encrypted key's passphrase +is requested again in each session. Private-key bytes never cross the preload boundary — the renderer +receives only a non-secret credential id and the display file name. The GUI does persist the server metadata needed to return to a server, including the host, SSH port, -username, authentication method, privilege mode and selected key path. It also persists 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. +username, authentication method, privilege mode, credential id, 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 +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 +installations only and never reconfigures a server implicitly. An email is optional during deployment: +without one, Certbot registers the ACME account with `--register-unsafely-without-email`, so renewal +notices and account recovery are unavailable — both consequences are shown in the UI before deploying. SSH host keys use trust on first successful authentication. The fingerprint is then pinned; any later mismatch fails closed before commands are executed. After an intentional VPS reinstall, explicitly reset the pin in Server Settings and confirm the new key on the next successful connection. -`keytar` is listed in `package.json`, but the active Electron GUI does not use it to store SSH -passwords or passphrases in an operating-system keychain. The secrets remain session-only. - See [Electron Desktop GUI](desktop-gui.md) for the complete Electron boundary, command adapters and packaging details. diff --git a/docs/zh-CN/architecture.md b/docs/zh-CN/architecture.md index 6ec4e00..cae0fb6 100644 --- a/docs/zh-CN/architecture.md +++ b/docs/zh-CN/architecture.md @@ -98,8 +98,7 @@ http://127.0.0.1:8080/sub/ # 仅本地回退 ``` 交互式 HAPP 设置可以选择公共端口,`_subscription_base_url` 会保留这一选择。非交互式的 -`quickstart --email
` IP-TLS 流程目前在 `8443` 配置 nginx、证书和标记,然后返回指向该 -endpoint 的 `subscription_url`。令牌以 `sub_token` 形式存储在配置档中;执行 revoke 会轮换令牌并使之前的 URL 失效。 +`quickstart --email
` 和 `quickstart --without-email` IP-TLS 流程会在 `8443` 配置 nginx、证书和标记,然后返回该 endpoint 的 `subscription_url`。不提供邮箱时,Certbot 会在没有 ACME 联系地址的情况下注册,因此无法接收续期通知或通过邮箱恢复。令牌以 `sub_token` 形式存储在配置档中;执行 revoke 会轮换令牌并使之前的 URL 失效。 新创建的标准托管 HAPP 配置档是 schema-v3 七路由配置档,包括 `xhttp-legacy` 和后量子 XHTTP 路由。 发布的 HAPP 连接列表包含六个 VLESS 路由,因为 PQ 路由仍可通过原始/配置档路径访问。助手也可能 diff --git a/docs/zh-CN/configuration.md b/docs/zh-CN/configuration.md index 6ab519b..1d035bd 100644 --- a/docs/zh-CN/configuration.md +++ b/docs/zh-CN/configuration.md @@ -101,6 +101,8 @@ Xrayebator 不会更改主机的 TCP 拥塞控制算法,也不会写入或应 | `sudo xrayebator update ` | 从规范 raw 仓库分支 self-update 管理器,继续使用新脚本,然后更新 Xray-core | | `sudo xrayebator probe-test` | 更换 SNI 前,从 VPS 检查其可达性 | | `sudo xrayebator quickstart --email <邮箱>` | 桌面 GUI 使用的一次性部署路径:执行广泛设置/迁移,在 `8443` 配置 IP-TLS endpoint,创建带 `schema_version: 3` 和 7 条线路的标准 HAPP 配置档;输出带 `subscription_url` 的 JSON。非交互迁移是 best-effort,请检查最终配置档与服务 | +| `sudo xrayebator quickstart --without-email` | 相同的一次性部署路径,但不提供 ACME 联系邮箱;Certbot 使用 `--register-unsafely-without-email`,因此没有续期通知或邮箱恢复 | +| `sudo xrayebator 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":[...]}` | diff --git a/docs/zh-CN/desktop-gui.md b/docs/zh-CN/desktop-gui.md index b7915af..7a33869 100644 --- a/docs/zh-CN/desktop-gui.md +++ b/docs/zh-CN/desktop-gui.md @@ -19,22 +19,27 @@ renderer 只能通过精简的 preload `contextBridge` API 访问特权操作。 ### Dashboard -Dashboard 显示已保存的服务器卡片、可达性状态点、位置/OS 与线路元数据,以及密钥、设置和删除本地服务器卡片的操作。可达性检查由 Electron main process 执行,是有时间限制的 TCP 检查;它不是服务端的 `probe-test` 命令。语言选择器可以切换 `RU`、`EN` 和 `中文`。 +Dashboard 显示已保存的服务器卡片、可达性状态点、安装状态标签(“已配置”“配置不完整”或“已导入”)、位置/OS 与线路元数据,以及密钥、设置和删除本地服务器卡片的操作。空 Dashboard 提供两个并列场景:**部署新服务器**(在干净 VPS 上安装 Xrayebator)和 **连接现有服务器**(通过 SSH 查找已安装的 Xrayebator 并打开面板,不改动现有安装)。已有服务器时,`+ Add` 会打开同样的场景选择。可达性检查由 Electron main process 执行,是有时间限制的 TCP 检查;它不是服务端的 `probe-test` 命令。语言选择器可以切换 `RU`、`EN` 和 `中文`。 ### Add server -Add server 接收 VPS host 和 SSH 端口、SSH 访问参数以及用于 `quickstart` 的邮箱。部署进度按以下步骤显示: +Add server 接收 VPS host 和 SSH 端口、SSH 访问参数,并要求明确选择 `quickstart` 的 email 模式:**填写邮箱**(默认,显示输入框)或 **不使用邮箱继续**。选择不使用邮箱时,界面会提醒用户:Let's Encrypt 不会发送续期通知,也无法通过邮箱恢复 ACME 账户。该模式运行 `xrayebator quickstart --without-email`,向 Certbot 传递 `--register-unsafely-without-email`,不会使用虚构邮箱地址。 + +部署进度按以下步骤显示: 1. 通过 SSH 连接并验证提升后的权限; 2. 读取 `/etc/os-release`; 3. 创建临时目录 `/tmp/xrayebator-`,上传 `install.sh` 和 `xrayebator`; 4. 使用所选权限运行 `bash install.sh`; 5. 将上传的管理器二进制安装到 `/usr/local/bin/xrayebator`; -6. 运行 `xrayebator quickstart --email `; +6. 运行 `xrayebator quickstart --email ` 或 `xrayebator quickstart --without-email`; 7. 解析包含 `subscription_url` 的 JSON 结果,获取订阅,并在本地保存服务器元数据与密钥。 GUI 会显示部署日志和步骤状态,但进行中的部署没有 IPC 取消通道。 +### 连接现有服务器(导入) + +导入向导接收 host、SSH 端口和用户名,并且只允许使用私有 SSH 密钥认证。GUI 通过 SSH 执行只读命令 `xrayebator inspect --json`,仅识别 Xrayebator 安装;不会自动运行安装程序、`quickstart`、`happ-setup`、迁移、更新、服务重启、防火墙修改或配置变更。部分配置的安装仍会导入,并分别显示 manager、Xray、配置档和订阅状态;仅本地或不可达的订阅不会被标记为可用。再次导入相同的 `host + port` 会更新原卡片,不会产生重复项,并保留 server id 和 host-key pin。成功导入后会直接打开 Server settings。 ### Server keys Server keys 会从保存的 `subscription_url` 刷新订阅,并显示返回的 VLESS 线路。每条 VLESS 链接都可以复制或生成二维码;订阅 URL 也可以复制,页面还提供复制全部内容的操作。此页面不会在服务器上创建独立订阅,也不会轮换订阅令牌。 @@ -56,11 +61,9 @@ SNI 和端口属于 inbound 级别的设置:修改它们可能影响共享该 ## SSH 与安全 -GUI 支持 SSH 密码认证或私钥认证,并支持直接以 `root` 执行或通过 `sudo` 提升权限。私钥通过 Electron 原生文件对话框选择;main process 会拒绝未经该对话框批准的任意路径。 +GUI 支持 SSH 密码认证或私钥认证,并支持直接以 `root` 执行或通过 `sudo` 提升权限。私钥通过 Electron 原生文件对话框选择;main process 读取字节并通过 `keytar` 保存到操作系统钥匙串(Windows Credential Manager、macOS Keychain 或 Linux Secret Service),renderer 只收到非敏感 credential id 和显示文件名。之后的 SSH 操作以及应用重启后都可以复用该密钥。 -SSH 密码、sudo 密码、私钥口令和私钥字节都不会持久化。它们只存在于当前表单/操作中,并在需要时传给 main process。`electron-store` 会保存服务器卡片和连接偏好、订阅 URL、已获取的 VLESS 链接(bearer/client credentials)、用户名、认证方式、权限模式、所选密钥路径,以及首次成功连接后保存的 SSH host-key SHA-256 pin(TOFU)。请保护本地应用数据;如果订阅 URL 或 VLESS 链接泄露,请通过终端 workflow 吊销订阅。之后 fingerprint 不匹配时,会在执行命令前失败;有意重装服务器时,必须在 Server settings 中明确重置 host-key pin。 - -`keytar` 存在于 `package.json` 依赖中,但当前 Electron GUI 尚未使用它把 SSH 密码或私钥口令存入操作系统钥匙串。 +如果系统钥匙串不可用,应用不会在磁盘上创建明文回退副本:密钥只保留在 main process 内存中,直到应用退出;界面会提示重启后需要重新选择。SSH 密码、sudo 密码和私钥口令不会持久化;加密私钥的口令需要在新会话中重新输入。私钥字节不会越过 preload boundary。`electron-store` 会保存服务器卡片、连接偏好、credential id、显示文件名、安装诊断、订阅 URL、已获取的 VLESS 链接(bearer/client credentials)和 SSH host-key SHA-256 pin(TOFU)。请保护本地应用数据;若订阅 URL 或 VLESS 链接泄露,请通过终端 workflow 吊销订阅。删除引用某个 credential 的最后一张服务器卡片时会删除对应钥匙串记录;其他卡片仍引用时会保留。 Electron 边界包含以下保护措施: @@ -84,10 +87,17 @@ xrayebator sni-list xrayebator port-change --name NAME [--route R] --port PORT|random ``` -部署流程还会调用: +部署流程会调用以下命令之一: ```text xrayebator quickstart --email EMAIL +xrayebator quickstart --without-email +``` + +导入流程只调用只读诊断命令: + +```text +xrayebator inspect --json ``` GUI 使用结果中的 `subscription_url`,随后通过该 URL 获取 VLESS 密钥。Server settings 还会调用更新操作(`xrayebator update `),卸载时可以上传并运行 `uninstall.sh`。这些都是受控操作,不是交互式 shell 会话。 @@ -119,8 +129,10 @@ SSH connect + host-key verification ├─ SFTP upload: install.sh, xrayebator ├─ elevated `bash install.sh` ├─ elevated install → /usr/local/bin/xrayebator - ├─ elevated `xrayebator quickstart --email EMAIL` + ├─ elevated `xrayebator quickstart --email EMAIL` 或 `--without-email` └─ parse `subscription_url` → fetch subscription → 保存服务器卡片、连接偏好、订阅 URL 和已获取的 VLESS 链接 + +导入现有安装时,流程改为只读执行 `xrayebator inspect --json`;仅当检测到公网 HTTPS endpoint 时才检查订阅,并保存检测到的状态,不会自动修复或更新 VPS。 ``` 远程命令使用安全的 shell 参数 quoting 构造。使用 sudo 时,机密通过 stdin 与命令分开传递。每次操作结束后 GUI 都会关闭 SSH 客户端,并在关闭时清理内存中的私钥缓冲区。 diff --git a/docs/zh-CN/security.md b/docs/zh-CN/security.md index a8e7ab7..dd460d4 100644 --- a/docs/zh-CN/security.md +++ b/docs/zh-CN/security.md @@ -98,19 +98,14 @@ TCPKeepAlive yes ## 桌面 GUI 凭据 -活跃的 Electron GUI 支持 SSH 密码和私钥,并可选择直接 root 或 sudo。SSH 密码、sudo 密码、 -私钥口令和私钥字节只存在于当前表单/操作中,不会持久化。私钥只有在通过 Electron 原生文件 -对话框选择后才会被读取。 +活跃的 Electron GUI 支持 SSH 密码和私钥,并可选择直接 root 或 sudo。通过 Electron 原生文件对话框选择的私钥由 main process 读取,并通过 `keytar` 保存到操作系统钥匙串(Windows Credential Manager、macOS Keychain 或 Linux Secret Service),因此后续 SSH 操作和应用重启后都可以复用。Renderer 只收到非敏感 credential id 和显示文件名;私钥字节不会跨越 preload boundary。 -GUI 会保存返回服务器所需的服务器元数据:主机、SSH 端口、用户名、认证方式、权限模式和所选 -密钥路径。它还会保存偏好、`subscription_url`、获取到的 `vless://` 链接以及 SHA-256 SSH host-key -pin。订阅 URL 和 VLESS 链接是 bearer credentials,因此请保护本地 Electron 应用数据,泄露后 -吊销订阅。 +如果系统钥匙串不可用,应用不会在磁盘上保存明文回退副本:密钥只保留在 main process 内存中直到应用退出,界面会提示重启后需要重新选择。SSH 密码、sudo 密码和私钥口令都不持久化;加密私钥的口令在需要时重新输入。 -SSH host key 在首次成功认证后按 TOFU 固定。之后指纹不匹配时,会在执行命令前失败。有意重装 -VPS 后,在 Server Settings 中显式重置 pin,并在下一次成功连接时确认新密钥。 +GUI 会保存返回服务器所需的元数据:主机、SSH 端口、用户名、认证方式、权限模式、credential id、密钥显示名、安装诊断、偏好、`subscription_url`、获取到的 `vless://` 链接以及 SHA-256 SSH host-key pin。订阅 URL 和 VLESS 链接是 bearer credentials,因此请保护本地 Electron 应用数据,泄露后吊销订阅。删除最后一张引用某 credential 的服务器卡片时会删除钥匙串记录;若其他卡片仍引用则保留。 -`keytar` 位于 `package.json` 依赖中,但活跃 Electron GUI 不使用它在系统钥匙串中保存 SSH 密码 -或私钥口令。机密仍是 session-only。 +SSH 导入只识别 Xrayebator,并默认只执行只读诊断;部分配置的服务器会按实际状态导入,不会自动修复。部署时 email 可选;不填写时 Certbot 使用 `--register-unsafely-without-email`,因此没有续期通知和 ACME 账户邮箱恢复,GUI 会在部署前说明。 + +SSH host key 在首次成功认证后按 TOFU 固定。之后指纹不匹配时,会在执行命令前失败。有意重装 VPS 后,在 Server Settings 中显式重置 pin,并在下一次成功连接时确认新密钥。 参见 [Electron 桌面 GUI](desktop-gui.md) 了解完整的 Electron 边界、命令适配器和打包详情。 \ No newline at end of file diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 82c0971..07123bb 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -11,6 +11,11 @@ "onboardDeployHint": "Install Xrayebator on a clean VPS", "onboardImportTitle": "Connect an existing server", "onboardImportHint": "Find an installed Xrayebator and open its panel", + "setup": { + "ready": "Configured", + "partial": "Partially configured", + "unknown": "Imported" + }, "empty": "Add your first server", "routes_one": "{{count}} route", "routes_other": "{{count}} routes", @@ -119,6 +124,24 @@ }, "settings": { "title": "Server profiles", + "diagnostics": { + "title": "Installation status", + "manager": "Manager", + "xray": "Xray", + "profiles": "Profiles", + "subscription": "Subscription", + "detected": "found", + "missing": "not found", + "invalid": "broken", + "running": "running", + "stopped": "stopped", + "available": "profiles exist", + "empty": "no profiles", + "public": "public HTTPS", + "localOnly": "local only", + "unreachable": "not responding", + "unknown": "unknown" + }, "language": "Interface language", "hint": "Choose how to access the server. Root + password remains the fast default; SSH keys and sudo are also supported.", "sshPassword": "SSH password", diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index 836580c..19099e1 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -11,6 +11,11 @@ "onboardDeployHint": "Установить Xrayebator на чистый VPS", "onboardImportTitle": "Подключить существующий", "onboardImportHint": "Найти установленный Xrayebator и открыть его панель", + "setup": { + "ready": "Настроен", + "partial": "Настроен частично", + "unknown": "Импортирован" + }, "empty": "Добавьте первый сервер", "routes_one": "{{count}} маршрут", "routes_few": "{{count}} маршрута", @@ -120,6 +125,24 @@ }, "settings": { "title": "Профили сервера", + "diagnostics": { + "title": "Состояние установки", + "manager": "Менеджер", + "xray": "Xray", + "profiles": "Профили", + "subscription": "Подписка", + "detected": "найден", + "missing": "не найден", + "invalid": "повреждён", + "running": "запущен", + "stopped": "остановлен", + "available": "есть профили", + "empty": "профилей нет", + "public": "публичная HTTPS", + "localOnly": "только локально", + "unreachable": "не отвечает", + "unknown": "неизвестно" + }, "language": "Язык интерфейса", "hint": "Выберите способ SSH-доступа. Root + пароль остаётся быстрым вариантом по умолчанию; также поддерживаются SSH-ключи и sudo.", "sshPassword": "SSH-пароль", diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index b5d9f9f..134dfa7 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -11,6 +11,11 @@ "onboardDeployHint": "在纯净的 VPS 上安装 Xrayebator", "onboardImportTitle": "连接现有服务器", "onboardImportHint": "查找已安装的 Xrayebator 并打开其控制面板", + "setup": { + "ready": "已配置", + "partial": "配置不完整", + "unknown": "已导入" + }, "empty": "添加第一台服务器", "routes_other": "{{count}} 条路线", "keys": "密钥", @@ -118,6 +123,24 @@ }, "settings": { "title": "服务器配置文件", + "diagnostics": { + "title": "安装状态", + "manager": "管理器", + "xray": "Xray", + "profiles": "配置文件", + "subscription": "订阅", + "detected": "已找到", + "missing": "未找到", + "invalid": "已损坏", + "running": "运行中", + "stopped": "已停止", + "available": "存在配置文件", + "empty": "没有配置文件", + "public": "公网 HTTPS", + "localOnly": "仅本地", + "unreachable": "无响应", + "unknown": "未知" + }, "language": "界面语言", "hint": "选择服务器访问方式。Root + 密码仍是默认快捷方式,同时支持 SSH 密钥和 sudo。", "sshPassword": "SSH 密码", diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index b83f227..2eecd99 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -142,6 +142,15 @@ export function Dashboard({ {server.name}
+ + {t(`dashboard.setup.${server.setupStatus ?? 'unknown'}`)} + {server.country || '—'} diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index ce6638a..fdab1c3 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -105,6 +105,29 @@ gap: 20px; } +.diagnosticsCard { + display: flex; + flex-direction: column; + gap: 10px; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 16px; + padding: 18px 20px; +} + +.diagnosticsGrid { + display: grid; + grid-template-columns: auto 1fr auto 1fr; + gap: 6px 14px; + font-size: 13px; + color: var(--text-2); +} + +.diagnosticsGrid span:nth-child(odd) { + color: var(--text-1); + font-weight: 500; +} + .connectCard, .createCard, .listCard { diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index 00e90a0..b35a5c2 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -447,6 +447,21 @@ export function ServerSettings({ server, onBack }: ServerSettingsProps): React.J
+ {server.diagnostics && ( +
+

{t('settings.diagnostics.title')}

+
+ {t('settings.diagnostics.manager')} + {t(`settings.diagnostics.${server.diagnostics.manager}`)} + {t('settings.diagnostics.xray')} + {t(`settings.diagnostics.${server.diagnostics.xray}`)} + {t('settings.diagnostics.profiles')} + {t(`settings.diagnostics.${server.diagnostics.profiles}`)} + {t('settings.diagnostics.subscription')} + {t(`settings.diagnostics.${server.diagnostics.subscription}`)} +
+
+ )}

{t('settings.hint')} From 2dc1adc07f090c2b3a8ab25949072748d46556f8 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:49:10 +0300 Subject: [PATCH 13/35] =?UTF-8?q?test:=20=D0=B2=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20onboarding=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BA=D1=82=D1=8B=20=D0=B2=20typecheck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- tsconfig.contracts.json | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 tsconfig.contracts.json diff --git a/package.json b/package.json index 54d834a..5ac064e 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dev": "electron-vite dev", "build": "electron-vite build", "preview": "electron-vite preview", - "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", + "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json && tsc --noEmit -p tsconfig.contracts.json", "test": "vitest run", "test:watch": "vitest", "package": "electron-vite build && electron-builder --win", diff --git a/tsconfig.contracts.json b/tsconfig.contracts.json new file mode 100644 index 0000000..5c45c23 --- /dev/null +++ b/tsconfig.contracts.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2023"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true + }, + "include": ["tests/type-contracts/**/*.ts", "src/shared/**/*.ts"] +} From cf2605cd088198468495dcfcd7975847fee198f1 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:20:28 +0300 Subject: [PATCH 14/35] =?UTF-8?q?fix:=20=D1=83=D0=B1=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20SIGPIPE=20=D0=B8=D0=B7=20email/inspect=20validation=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tr | awk/sed с ранним exit обрывал pipe на Ubuntu: set -euo pipefail превращал 'tr: write error: Broken pipe' в FAIL (CI run 35892796234), хотя в Git Bash тот же код проходил. Читаем tr-вывод целиком в переменную и режем блоки из here-string, где досрочный выход приёмника безвреден. --- validation/test-quickstart-email-and-inspect.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/validation/test-quickstart-email-and-inspect.sh b/validation/test-quickstart-email-and-inspect.sh index 74a2731..350f521 100644 --- a/validation/test-quickstart-email-and-inspect.sh +++ b/validation/test-quickstart-email-and-inspect.sh @@ -14,7 +14,11 @@ fail() { exit 1 } -quickstart_block=$(tr -d '\r' < xrayebator | sed -n '/^quickstart_command() {$/,/^happ_setup_command() {$/p') +# Полностью читаем источник в переменную: awk/sed с ранним exit в管道 роняют tr +# на SIGPIPE (pipefail в CI), а here-string безотказен. +source_text=$(tr -d '\r' < xrayebator) + +quickstart_block=$(sed -n '/^quickstart_command() {$/,/^happ_setup_command() {$/p' <<< "$source_text") [[ -n "$quickstart_block" ]] || fail "quickstart_command block not found" grep -Fq -- '--without-email' <<< "$quickstart_block" \ @@ -37,11 +41,11 @@ fi echo "✓ quickstart поддерживает --without-email без фиктивного адреса" # ── inspect: read-only диагностика для GUI-импорта ── -inspect_block=$(tr -d '\r' < xrayebator | awk '/^inspect_command\(\) \{$/{f=1} f&&/^quickstart_command\(\) \{$/{exit} f') +inspect_block=$(awk '/^inspect_command\(\) \{$/{f=1} f&&/^quickstart_command\(\) \{$/{exit} f' <<< "$source_text") [[ -n "$inspect_block" ]] || fail "inspect_command block not found" # Диспетч должен открывать subcommand. -grep -Eq '^[[:space:]]*inspect\)' <(tr -d '\r' < xrayebator) \ +grep -Eq '^[[:space:]]*inspect\)' <<< "$source_text" \ || fail "dispatch не регистрирует inspect" # JSON печатается через jq -n (одним блоком), диагностику — в stderr. From 33b5d90bb457a7970001c400f48bfa58b31eb8dd Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:22:05 +0300 Subject: [PATCH 15/35] =?UTF-8?q?docs:=20=D1=81=D0=B2=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20=D1=81=20=D1=84=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=BC=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=80=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После email/inspect валидации и onboarding unit-тестов count'ы устарели: 24 -> 25 validation-скриптов, 9 -> 14 unit-файлов, плюс строки таблиц и пояснение, что npm run typecheck проверяет и tests/type-contracts (Vitest-транспиляция регрессии типов не ловит). Убраны и устаревшие '39 тестов' / 'checkout on main'. --- CLAUDE.md | 7 ++++--- README.md | 5 +++-- README.ru.md | 5 +++-- README.zh-CN.md | 5 +++-- docs/ru/testing.md | 16 ++++++++++++---- docs/testing.md | 19 +++++++++++++++---- docs/zh-CN/testing.md | 16 ++++++++++++---- 7 files changed, 52 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 007effd..e910318 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/`** — 24 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 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/`** — 25 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 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. @@ -127,7 +127,7 @@ Do NOT use raw `jq ... > temp && mv temp file` — always go through `safe_jq_wr - `main` — stable, releases every 1-2 months - `dev` — quick fixes, weekly or biweekly - `experimental` — latest features, several times per week -- This checkout is currently on `main`; do not assume `experimental` is the working branch. +- This checkout is currently on `dev`; do not assume `experimental` is the working branch. ## CLI commands @@ -135,7 +135,8 @@ Apart from the interactive menu (`sudo xrayebator`), the script exposes subcomma - `xrayebator update` — update only the Xray-core binary; `xrayebator update ` self-updates the manager from the canonical raw branch and then updates Xray-core. - `xrayebator-update [branch]` — separate full `update.sh` lifecycle workflow; without a branch it displays `.current_branch` and opens interactive branch selection. -- `xrayebator quickstart --email ` — UI CLI used by the desktop app: runs the broad setup/migration path, provisions the subscription endpoint, creates a standard **schema-v3 multi-route** HAPP profile (7 routes including `xhttp-legacy`), and prints JSON with `subscription_url`. The implementation currently tolerates migration failures in this non-interactive path; verify the resulting profile and services after deployment. +- `xrayebator quickstart --email ` — UI CLI used by the desktop app: runs the broad setup/migration path, provisions the subscription endpoint, creates a standard **schema-v3 multi-route** HAPP profile (7 routes including `xhttp-legacy`), and prints JSON with `subscription_url`. The implementation currently tolerates migration failures in this non-interactive path; verify the resulting profile and services after deployment. Email mode is explicit: `quickstart --without-email` runs the same path but registers Certbot/ACME with `--register-unsafely-without-email` (no renewal notices, no email-based account recovery; never substitute a fake address). The GUI passes exactly one of the two forms. +- `xrayebator inspect --json` — read-only install probe for the GUI "connect existing server" import: reports manager/Xray/profiles/subscription markers as one JSON object on stdout (diagnostics to stderr only). It must not migrate, install, restart, open firewall or write anything; `validation/test-quickstart-email-and-inspect.sh` guards these invariants statically. - `xrayebator 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). diff --git a/README.md b/README.md index d49f45f..0317d39 100644 --- a/README.md +++ b/README.md @@ -310,8 +310,9 @@ npm run dev # Electron + Vite dev server npm run build # compile the renderer and the main process ``` -Electron checks: `npm test` runs the 9 unit files in `tests/`; `npm run typecheck` checks the -TypeScript surface. `npm run build` produces the app bundle. On native Windows, the POSIX-only +Electron checks: `npm test` runs the 14 unit files in `tests/`; `npm run typecheck` checks the +TypeScript surface, including the strict onboarding contracts in `tests/type-contracts/`. +`npm run build` produces the app bundle. On native Windows, the POSIX-only `tests/unit/shell-command.test.ts` may fail because `/bin/sh` is absent; Linux CI is the source of truth. See [Testing](docs/testing.md#desktop-gui) and [Electron Desktop GUI](docs/desktop-gui.md). diff --git a/README.ru.md b/README.ru.md index a4a06f6..df40d00 100644 --- a/README.ru.md +++ b/README.ru.md @@ -306,8 +306,9 @@ npm run dev # Electron + Vite dev server npm run build # скомпилировать renderer и main process ``` -Проверки Electron: `npm test` гоняет 9 unit-файлов из `tests/`; `npm run typecheck` проверяет -TypeScript, а `npm run build` собирает приложение. На нативном Windows POSIX-тест +Проверки Electron: `npm test` гоняет 14 unit-файлов из `tests/`; `npm run typecheck` проверяет +TypeScript, включая строгие onboarding-контракты из `tests/type-contracts/`, а `npm run build` +собирает приложение. На нативном Windows POSIX-тест `tests/unit/shell-command.test.ts` может падать из-за отсутствия `/bin/sh`; источник истины — Linux CI. См. [Тестирование](docs/ru/testing.md#десктоп-gui) и [справочник Electron GUI](docs/ru/desktop-gui.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 94f2a6a..f50e966 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -294,8 +294,9 @@ npm run dev # Electron + Vite dev server npm run build # 编译 renderer 与 main process ``` -Electron 检查:`npm test` 运行 `tests/` 中的 9 个单元测试文件;`npm run typecheck` 检查 TypeScript, -`npm run build` 构建应用。在原生 Windows 上,POSIX 专用测试 `tests/unit/shell-command.test.ts` +Electron 检查:`npm test` 运行 `tests/` 中的 14 个单元测试文件;`npm run typecheck` 检查 TypeScript, +包含 `tests/type-contracts/` 中严格的 onboarding 契约;`npm run build` 构建应用。在原生 Windows 上,POSIX 专用测试 +`tests/unit/shell-command.test.ts` 可能因缺少 `/bin/sh` 而失败;Linux CI 是事实来源。参见[测试](docs/zh-CN/testing.md#桌面图形界面) 和 [Electron 桌面 GUI](docs/zh-CN/desktop-gui.md)。 diff --git a/docs/ru/testing.md b/docs/ru/testing.md index ec585b3..221acd6 100644 --- a/docs/ru/testing.md +++ b/docs/ru/testing.md @@ -16,7 +16,7 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done ## Что покрывают тесты -В `validation/` лежат 24 статических и локальных регрессионных теста: +В `validation/` лежат 25 статических и локальных регрессионных тестов: | Тест | Что проверяет | |---|---| @@ -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-quickstart-email-and-inspect.sh` | Явный email-режим `quickstart` (`--without-email` без фиктивного адреса) и read-only инварианты `inspect --json` | | `test-quickstart-migration-parity.sh` | `quickstart` гоняет те же критичные миграции, что и `main_menu` | | `test-quickstart-subscription-port.sh` | `quickstart` использует canonical helper базы подписки и не возвращается к несвязанному hardcode URL | | `test-audit-functional.sh` | Функциональные regression-проверки аудита HowDeploy (P0/P1) | @@ -82,19 +83,26 @@ npm test # Vitest unit-тесты | `tests/unit/countryFlag.test.ts` | Флаг страны для карточек серверов | | `tests/unit/vless.test.ts` | Парсинг `vless://` URL | | `tests/unit/shell-command.test.ts` | POSIX-безопасное quoting аргументов shell | -| `tests/unit/ssh-access.test.ts` | Валидация параметров SSH-доступа | +| `tests/unit/ssh-access.test.ts` | Валидация параметров SSH-доступа и порядок разрешения ключа из keychain | | `tests/unit/ssh-client.test.ts` | SSH-соединение и host-key verification | +| `tests/unit/ssh-keychain.test.ts` | Сохранение/чтение/удаление ключей в системном keychain с guard'ами размера (mock keytar) | | `tests/unit/server-manager.test.ts` | Валидация безопасных веток для обновления | +| `tests/unit/server-store.test.ts` | Идемпотентный импорт upsert по host+port, подсчёт ссылок на credential | +| `tests/unit/server-inspector.test.ts` | Нормализация диагностики: публичная vs local-only/unreachable подписка, partial- и refuse-import состояния | +| `tests/unit/deployer.test.ts` | Аргументы quickstart: режимы provided/without email без фиктивного адреса | +| `tests/unit/ui-contracts.test.ts` | Готовность deployment и формирование payload при выборе email-режима | + +`npm run typecheck` дополнительно проверяет `tsconfig.contracts.json` — он компилирует строгие onboarding-контракты из `tests/type-contracts/` (обязательный `emailMode`, keychain-ссылка в выборщике ключа, открытый import API); Vitest-транспиляция такие регрессии типов не ловит. Примечание: `tests/unit/shell-command.test.ts` намеренно вызывает `/bin/sh` и завершается ошибкой (`status=null`) на Windows, где POSIX `/bin/sh` отсутствует. Полный suite следует запускать на Linux -(включая CI Ubuntu в `release.yml`), где все 39 тестов проходят. +(включая CI Ubuntu в `release.yml`), где все тесты проходят. ## CI workflow Три независимых workflow: -- **ci-linux.yml** — Bash validation: `bash -n` всех скриптов + все 24 `validation/test-*.sh` на +- **ci-linux.yml** — Bash validation: `bash -n` всех скриптов + все 25 `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, затем diff --git a/docs/testing.md b/docs/testing.md index b10a95b..6ee1bf7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -36,7 +36,7 @@ promise that installer and updater paths behave identically. ## Validation suite -`validation/` contains exactly 24 scripts. Run every `validation/test-*.sh`; the current set is: +`validation/` contains exactly 25 scripts. Run every `validation/test-*.sh`; the current set is: | Script | What it checks | |---|---| @@ -56,6 +56,7 @@ promise that installer and updater paths behave identically. | `test-multiroute-argument-preservation.sh` | Preservation of multiroute transport arguments | | `test-port-change-cli.sh` | Port-change CLI scenarios, firewall moves and route selection | | `test-project-update-rollback.sh` | Rollback of a failed project update | +| `test-quickstart-email-and-inspect.sh` | Explicit `quickstart` email mode (`--without-email` without a fake address) and the read-only invariants of `inspect --json` | | `test-quickstart-migration-parity.sh` | Parity between quickstart and main-menu migrations | | `test-quickstart-subscription-port.sh` | Ensures quickstart uses the canonical subscription base helper and does not regress to an unrelated hardcoded URL | | `test-sni-change-cli.sh` | SNI-change JSON output, transport fields, profile sync and rollback | @@ -104,20 +105,30 @@ change the default policy, while uninstall should remove only rules recorded as ## Electron GUI unit tests -The active Electron GUI has exactly nine Vitest unit files: +The active Electron GUI has exactly fourteen Vitest unit files: | Test | What it checks | |---|---| | `tests/unit/countryFlag.test.ts` | Country-flag lookup used by server cards | +| `tests/unit/deployer.test.ts` | Quickstart argument building: provided/without email mode, no fake address, validation only in provided mode | | `tests/unit/extractJson.test.ts` | JSON extraction from noisy `xrayebator` command output | | `tests/unit/probe-ports.test.ts` | Ports used by the Dashboard reachability probe | +| `tests/unit/server-inspector.test.ts` | Inspection normalization: public vs local-only/unreachable subscription, partial and refused-import states | | `tests/unit/server-manager.test.ts` | Safe update-branch validation | +| `tests/unit/server-store.test.ts` | Idempotent import upsert by host+port, credential-reference counting | | `tests/unit/shell-command.test.ts` | POSIX shell quoting and sudo command construction | -| `tests/unit/ssh-access.test.ts` | SSH credential validation and approved key access | +| `tests/unit/ssh-access.test.ts` | SSH credential validation, approved key access and the keychain resolution order | | `tests/unit/ssh-client.test.ts` | Host-key trust, authentication and mismatch handling | +| `tests/unit/ssh-keychain.test.ts` | System-keychain save/load/remove of private keys with size/corruption guards (mock keytar) | | `tests/unit/subscription.test.ts` | Subscription parsing, VLESS extraction and HTTP errors | +| `tests/unit/ui-contracts.test.ts` | Deploy readiness and payload construction for the email-mode choice | | `tests/unit/vless.test.ts` | Port extraction from IPv4 and IPv6 VLESS URLs | +`npm run typecheck` compiles `tsconfig.node.json`, `tsconfig.web.json` and `tsconfig.contracts.json` — +the last project actually type-checks the strict onboarding contracts under +`tests/type-contracts/` (required `emailMode`, keychain-reference key picker, exposed import API), +because Vitest transpilation alone would not catch a regressed type. + Run the Electron checks with: ```bash @@ -135,7 +146,7 @@ provide useful local coverage, while CI runs the Electron typecheck and unit sui The workflows have separate responsibilities: - `.github/workflows/ci-linux.yml` is the Bash core gate on `ubuntu-24.04`: it installs `jq`, - `uuid-runtime` and `ripgrep`, runs all four Bash syntax checks, then runs all 24 validation + `uuid-runtime` and `ripgrep`, runs all four Bash syntax checks, then runs all 25 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 diff --git a/docs/zh-CN/testing.md b/docs/zh-CN/testing.md index 6e0783f..43023ca 100644 --- a/docs/zh-CN/testing.md +++ b/docs/zh-CN/testing.md @@ -15,7 +15,7 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done ## 测试覆盖范围 -`validation/` 中有 24 个静态与本地回归测试: +`validation/` 中有 25 个静态与本地回归测试: | 测试 | 检查内容 | |---|---| @@ -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-quickstart-email-and-inspect.sh` | `quickstart` 的显式 email 模式(`--without-email` 不使用虚假地址)以及 `inspect --json` 的只读不变量 | | `test-quickstart-migration-parity.sh` | `quickstart` 执行与 `main_menu` 相同的关键迁移 | | `test-quickstart-subscription-port.sh` | 确认 `quickstart` 使用规范的订阅基础地址 helper,不回退到无关的硬编码 URL | | `test-audit-functional.sh` | HowDeploy 审计(P0/P1)的功能回归检查 | @@ -79,19 +80,26 @@ npm test # Vitest 单元测试 | `tests/unit/countryFlag.test.ts` | 服务器卡片的国家旗帜 | | `tests/unit/vless.test.ts` | `vless://` URL 解析 | | `tests/unit/shell-command.test.ts` | POSIX 安全的 shell 参数 quoting | -| `tests/unit/ssh-access.test.ts` | SSH 访问参数验证 | +| `tests/unit/ssh-access.test.ts` | SSH 访问参数验证与 keychain 密钥解析顺序 | | `tests/unit/ssh-client.test.ts` | SSH 连接与 host-key verification | +| `tests/unit/ssh-keychain.test.ts` | 系统钥匙串的私钥保存/读取/删除及大小防护(mock keytar) | | `tests/unit/server-manager.test.ts` | 更新分支安全性验证 | +| `tests/unit/server-store.test.ts` | 按 host+port 的幂等导入 upsert 与 credential 引用计数 | +| `tests/unit/server-inspector.test.ts` | 诊断规范化:公网与仅本地/不可达订阅、partial 及拒绝导入状态 | +| `tests/unit/deployer.test.ts` | quickstart 参数构建:provided/without email 模式,不使用虚假地址 | +| `tests/unit/ui-contracts.test.ts` | email 模式选择下的部署就绪判断与 payload 构造 | + +`npm run typecheck` 还会检查 `tsconfig.contracts.json`,其中编译 `tests/type-contracts/` 中严格的 onboarding 契约(必需的 `emailMode`、keychain 引用密钥选择器、已暴露的 import API);仅靠 Vitest 转译无法捕获这类类型回归。 说明:`tests/unit/shell-command.test.ts` 有意调用 `/bin/sh`,在没有 POSIX `/bin/sh` 的 Windows 上会失败(`status=null`)。完整 suite 应在 Linux(包括 `release.yml` 的 Ubuntu job)运行;Linux -上 39 个测试全部通过。 +上全部测试通过。 ## CI workflow 三个独立 workflow: -- **ci-linux.yml** — Bash validation:在 ubuntu-24.04 上对所有脚本执行 `bash -n`,并运行全部 24 个 +- **ci-linux.yml** — Bash validation:在 ubuntu-24.04 上对所有脚本执行 `bash -n`,并运行全部 25 个 `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`,再在三种平台执行 From 3539bc91c27f13d09ef9d7126a594a5e03789f8d Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:05:20 +0300 Subject: [PATCH 16/35] =?UTF-8?q?feat:=20=D1=80=D0=B0=D0=B7=D1=80=D0=B5?= =?UTF-8?q?=D1=88=D0=B8=D1=82=D1=8C=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82?= =?UTF-8?q?=D0=B5=20=D1=81=D1=83=D1=89=D0=B5=D1=81=D1=82=D0=B2=D1=83=D1=8E?= =?UTF-8?q?=D1=89=D0=B5=D0=B3=D0=BE=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Первоначальное ограничение 'только ключ' пользователь отверг на ручном тесте: мастер импорта должен давать тот же выбор пароль/ключ, что и страница развёртывания. Убран main-process guard по authMethod, форма использует общий isSshAccessReady, подсказка про ключ показывается только в режиме ключа. Read-only инвариант импорта (inspect --json, без repair) не затронут; спецификация и переводы обновлены. --- README.md | 2 +- README.ru.md | 2 +- README.zh-CN.md | 2 +- docs/desktop-gui.md | 2 +- docs/ru/desktop-gui.md | 2 +- .../2026-09-23-server-onboarding-design.md | 2 +- docs/zh-CN/desktop-gui.md | 2 +- src/main/ipc-handlers.ts | 4 ++-- src/renderer/src/i18n/en.json | 2 +- src/renderer/src/i18n/ru.json | 2 +- src/renderer/src/i18n/zh.json | 2 +- src/renderer/src/pages/ImportServer.tsx | 23 ++++++++----------- 12 files changed, 22 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 0317d39..57735af 100644 --- a/README.md +++ b/README.md @@ -292,7 +292,7 @@ What the GUI can do: |---|---| | Dashboard | Server cards with reachability and installation status; an empty screen offers “Deploy a new server” or “Connect an existing server”; language switch | | 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 key using read-only `xrayebator inspect --json`; partial installs are saved with diagnostics, without automatic repair | +| 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 | diff --git a/README.ru.md b/README.ru.md index df40d00..82eae44 100644 --- a/README.ru.md +++ b/README.ru.md @@ -288,7 +288,7 @@ xrayebator (bash) ──► /usr/local/etc/xray/ |---|---| | Dashboard | Карточки серверов со статусом доступности и установки; пустой экран предлагает «Развернуть новый сервер» или «Подключить существующий»; переключатель языка | | Добавить сервер | Развернуть VPS с явным выбором email: `quickstart --email` или `quickstart --without-email`; сохранить сервер и публичную подписку | -| Подключить существующий | Импорт распознанной установки Xrayebator по SSH-ключу через read-only `xrayebator inspect --json`; частичные установки сохраняются с диагностикой без автоисправления | +| Подключить существующий | Импорт распознанной установки Xrayebator по SSH (пароль или ключ) через read-only `xrayebator inspect --json`; частичные установки сохраняются с диагностикой без автоисправления | | Ключи сервера | Обновить публичную подписку, скопировать URL, показать ссылки `vless://` и QR-коды | | Настройки сервера | SSH по паролю или приватному ключу, прямой root или sudo; список/создание/удаление профилей, смена fingerprint, SNI и порта, обновление или удаление Xrayebator | diff --git a/README.zh-CN.md b/README.zh-CN.md index f50e966..5c743a9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -276,7 +276,7 @@ GUI 的功能: |---|---| | Dashboard | 服务器卡片与连通/安装状态;空页面提供“部署新服务器”或“连接现有服务器”;语言切换 | | 添加服务器 | 显式选择是否提供 email:`quickstart --email` 或 `quickstart --without-email`;保存服务器与公网订阅 | -| 连接现有服务器 | 通过 SSH 密钥和只读 `xrayebator inspect --json` 导入已识别的 Xrayebator;部分安装会连同诊断状态保存,不自动修复 | +| 连接现有服务器 | 通过 SSH(密码或密钥)和只读 `xrayebator inspect --json` 导入已识别的 Xrayebator;部分安装会连同诊断状态保存,不自动修复 | | 服务器密钥 | 刷新公网订阅、复制链接、显示 `vless://` 链接与二维码 | | 服务器设置 | 使用 SSH 密码或私钥、直接 root 或 sudo:列出/创建/删除配置档,修改指纹、SNI 和端口,以及更新或卸载服务器上的 Xrayebator | diff --git a/docs/desktop-gui.md b/docs/desktop-gui.md index 5ad1069..eec27b6 100644 --- a/docs/desktop-gui.md +++ b/docs/desktop-gui.md @@ -37,7 +37,7 @@ The GUI shows deployment logs and step status, but it does not provide a cancell ### Connect existing server (import) -Import accepts host, SSH port, SSH user, and a private key (key authentication only), then runs a strictly read-only `xrayebator inspect --json` over the same SSH stack: it recognizes Xrayebator installations only, and never runs `quickstart`, `happ-setup`, installers, updates, migrations, restarts, or firewall changes. A partially configured installation is still imported with honest component statuses (manager / Xray / profiles / subscription); a local-only or unreachable subscription is stored as such and no dead URL is presented as working. Importing the same `host + port` again updates the existing card instead of creating a duplicate; the server id and host-key pin survive the update. After a successful import the app opens Server Settings directly. +Import accepts host, SSH port, SSH user and SSH access details (password or private key, the same choice as in Add server), then runs a strictly read-only `xrayebator inspect --json` over the same SSH stack: it recognizes Xrayebator installations only, and never runs `quickstart`, `happ-setup`, installers, updates, migrations, restarts, or firewall changes. A partially configured installation is still imported with honest component statuses (manager / Xray / profiles / subscription); a local-only or unreachable subscription is stored as such and no dead URL is presented as working. Importing the same `host + port` again updates the existing card instead of creating a duplicate; the server id and host-key pin survive the update. After a successful import the app opens Server Settings directly. ### Server keys diff --git a/docs/ru/desktop-gui.md b/docs/ru/desktop-gui.md index f4cc391..8ec2f9d 100644 --- a/docs/ru/desktop-gui.md +++ b/docs/ru/desktop-gui.md @@ -39,7 +39,7 @@ GUI показывает лог и состояние шагов, но для в ### Подключить существующий сервер (импорт) -Мастер импорта принимает host, SSH-порт и пользователя; вход разрешён только по приватному SSH-ключу. GUI выполняет по SSH `xrayebator inspect --json` — это read-only диагностика, которая распознаёт только Xrayebator. Импорт не запускает установщик, `quickstart`, `happ-setup`, миграции, обновление, перезапуск служб, изменение firewall или конфигурации. Частичная установка всё равно добавляется с отдельными статусами менеджера, Xray, профилей и подписки; локальная или недоступная подписка не выдаётся за рабочую. Повторный импорт того же `host + port` обновляет существующую карточку, не меняя её id и host-key pin. После импорта открывается Server settings. +Мастер импорта принимает host, SSH-порт и пользователя и предлагает те же способы SSH-доступа, что и страница добавления нового сервера: пароль или приватный ключ (ключ сохраняется в системном keychain и переиспользуется). GUI выполняет по SSH `xrayebator inspect --json` — это read-only диагностика, которая распознаёт только Xrayebator. Импорт не запускает установщик, `quickstart`, `happ-setup`, миграции, обновление, перезапуск служб, изменение firewall или конфигурации. Частичная установка всё равно добавляется с отдельными статусами менеджера, Xray, профилей и подписки; локальная или недоступная подписка не выдаётся за рабочую. Повторный импорт того же `host + port` обновляет существующую карточку, не меняя её id и host-key pin. После импорта открывается Server settings. ### Server keys Server keys обновляет подписку по сохранённому `subscription_url` и показывает полученные VLESS-маршруты. Каждый VLESS-ключ можно скопировать или показать как QR-код; URL подписки также копируется, есть действие «скопировать всё». Эта страница не создаёт отдельную подписку на сервере и не меняет токен подписки. diff --git a/docs/superpowers/specs/2026-09-23-server-onboarding-design.md b/docs/superpowers/specs/2026-09-23-server-onboarding-design.md index 15946e1..54c0401 100644 --- a/docs/superpowers/specs/2026-09-23-server-onboarding-design.md +++ b/docs/superpowers/specs/2026-09-23-server-onboarding-design.md @@ -56,7 +56,7 @@ xrayebator quickstart --without-email ### SSH-аутентификация -Для импорта существующего сервера в UI предлагается только приватный SSH-ключ. В развёртывании нового сервера сохраняются оба текущих варианта: пароль и ключ. +Для импорта существующего сервера GUI предлагает те же способы доступа, что и развёртывание нового: пароль или приватный SSH-ключ. (Первоначальная редакция спецификации ограничивала импорт только ключом; по итогам ручного теста пользователь потребовал тот же выбор пароль/ключ, что и на странице первого добавления. Read-only характер импорта от этого не меняется.) Пароли SSH и sudo остаются session-only. Passphrase приватного ключа не сохраняется и после перезапуска приложения запрашивается заново, если ключ её требует. diff --git a/docs/zh-CN/desktop-gui.md b/docs/zh-CN/desktop-gui.md index 7a33869..f982f8f 100644 --- a/docs/zh-CN/desktop-gui.md +++ b/docs/zh-CN/desktop-gui.md @@ -39,7 +39,7 @@ GUI 会显示部署日志和步骤状态,但进行中的部署没有 IPC 取 ### 连接现有服务器(导入) -导入向导接收 host、SSH 端口和用户名,并且只允许使用私有 SSH 密钥认证。GUI 通过 SSH 执行只读命令 `xrayebator inspect --json`,仅识别 Xrayebator 安装;不会自动运行安装程序、`quickstart`、`happ-setup`、迁移、更新、服务重启、防火墙修改或配置变更。部分配置的安装仍会导入,并分别显示 manager、Xray、配置档和订阅状态;仅本地或不可达的订阅不会被标记为可用。再次导入相同的 `host + port` 会更新原卡片,不会产生重复项,并保留 server id 和 host-key pin。成功导入后会直接打开 Server settings。 +导入向导接收 host、SSH 端口和用户名,并提供与部署新服务器页面相同的 SSH 访问方式:密码或私钥(私钥通过 `keytar` 保存在系统钥匙串中并可复用)。GUI 通过 SSH 执行只读命令 `xrayebator inspect --json`,仅识别 Xrayebator 安装;不会自动运行安装程序、`quickstart`、`happ-setup`、迁移、更新、服务重启、防火墙修改或配置变更。部分配置的安装仍会导入,并分别显示 manager、Xray、配置档和订阅状态;仅本地或不可达的订阅不会被标记为可用。再次导入相同的 `host + port` 会更新原卡片,不会产生重复项,并保留 server id 和 host-key pin。成功导入后会直接打开 Server settings。 ### Server keys Server keys 会从保存的 `subscription_url` 刷新订阅,并显示返回的 VLESS 线路。每条 VLESS 链接都可以复制或生成二维码;订阅 URL 也可以复制,页面还提供复制全部内容的操作。此页面不会在服务器上创建独立订阅,也不会轮换订阅令牌。 diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index d89f4f6..71e6e6b 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -249,8 +249,8 @@ export function registerIpcHandlers({ store }: IpcContext): void { ipcMain.handle('servers:import', async (event, payload: ImportServerPayload) => { if (!payload || typeof payload !== 'object') throw new Error('Некорректный запрос импорта') - if (!payload.access || payload.access.authMethod !== 'privateKey') { - throw new Error('Импорт существующего сервера доступен только по SSH-ключу') + if (!payload.access) { + throw new Error('Не указаны параметры SSH-доступа') } const emitStep = (step: ImportStep): void => { if (!event.sender.isDestroyed()) { diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 07123bb..9af757d 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -76,7 +76,7 @@ "running": "Connecting...", "progress": "Diagnostics progress", "notSaved": "The server was not saved", - "selectKeyNote": "Select a private SSH key — import works only via a key.", + "selectKeyNote": "Select a private SSH key to connect with key authentication.", "steps": { "ssh": "SSH connection and host key check", "inspect": "Finding and diagnosing Xrayebator", diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index 19099e1..a1c9e5b 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -77,7 +77,7 @@ "running": "Подключение...", "progress": "Ход диагностики", "notSaved": "Сервер не был сохранён", - "selectKeyNote": "Выберите приватный SSH-ключ — импорт доступен только по ключу.", + "selectKeyNote": "Выберите приватный SSH-ключ, чтобы подключиться по ключу.", "steps": { "ssh": "SSH-подключение и проверка host key", "inspect": "Поиск и диагностика Xrayebator", diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index 134dfa7..a368fb8 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -75,7 +75,7 @@ "running": "连接中...", "progress": "诊断进度", "notSaved": "服务器未保存", - "selectKeyNote": "请选择私有 SSH 密钥 — 仅支持通过密钥导入。", + "selectKeyNote": "请选择私有 SSH 密钥以使用密钥连接。", "steps": { "ssh": "SSH 连接与 host key 检查", "inspect": "查找并诊断 Xrayebator", diff --git a/src/renderer/src/pages/ImportServer.tsx b/src/renderer/src/pages/ImportServer.tsx index af947c4..81b8b2e 100644 --- a/src/renderer/src/pages/ImportServer.tsx +++ b/src/renderer/src/pages/ImportServer.tsx @@ -3,7 +3,7 @@ import { Button, TextField, Label, Input, Spinner } from '@heroui/react' import { CheckCircle2, Circle } from 'lucide-react' import { useTranslation } from 'react-i18next' import type { ImportStep, Server, SshAccessInput } from '@shared/types' -import { SshAccessForm } from '../components/SshAccessForm' +import { SshAccessForm, isSshAccessReady } from '../components/SshAccessForm' import styles from './ImportServer.module.css' interface ImportServerProps { @@ -23,7 +23,8 @@ export function ImportServer({ onDone, onBack }: ImportServerProps): React.JSX.E const [form, setForm] = useState({ host: '', port: '22' }) const [access, setAccess] = useState({ username: 'root', - authMethod: 'privateKey', + authMethod: 'password', + password: '', privilegeMode: 'root' }) const [running, setRunning] = useState(false) @@ -36,8 +37,7 @@ export function ImportServer({ onDone, onBack }: ImportServerProps): React.JSX.E return window.api.servers.onImportEvent((event) => setCurrentStep(event.step)) }, [running]) - const keyReady = access.authMethod === 'privateKey' && Boolean(access.privateKeyCredentialId) - const ready = form.host.trim().length > 0 && access.username.trim().length > 0 && keyReady + const ready = form.host.trim().length > 0 && isSshAccessReady(access) const startImport = (): void => { setError(null) @@ -90,12 +90,7 @@ export function ImportServer({ onDone, onBack }: ImportServerProps): React.JSX.E onChange={(e) => setForm((f) => ({ ...f, port: e.target.value }))} /> - +

)} -
-

- {t('settings.hint')} -

- setConfirmHostKeyReset(true)} - /> -
- {!connected ? ( + {connected ? ( +
+
+ +
+ {t('settings.accessConnected')} + {access.authMethod === 'password' && access.passwordPersisted === false && ( + + {t('settings.passwordNotPersisted')} + + )} +
+ +
+
+ ) : ( +
+

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

+ setConfirmHostKeyReset(true)} + /> +
- ) : ( - - )} -
-

{t('settings.passwordNote')}

-
+
+

{t('settings.passwordNote')}

+
+ )} {error &&
{t('settings.error')}: {error}
} {toast &&
{toast}
} diff --git a/src/renderer/src/pages/server-access.ts b/src/renderer/src/pages/server-access.ts new file mode 100644 index 0000000..6d3f279 --- /dev/null +++ b/src/renderer/src/pages/server-access.ts @@ -0,0 +1,17 @@ +import type { SshAuthMethod } from '@shared/types' + +export interface SavedServerAccess { + authMethod?: SshAuthMethod + passwordCredentialId?: string | null + privateKeyCredentialId?: string | null + privateKeyPersisted?: boolean | null +} + +/** Auto-connect only when a secret is known to live in the OS keychain. */ +export function shouldAutoConnectServer(server: SavedServerAccess): boolean { + if (server.authMethod === 'password') return Boolean(server.passwordCredentialId) + if (server.authMethod === 'privateKey') { + return Boolean(server.privateKeyCredentialId) && server.privateKeyPersisted !== false + } + return false +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 794f929..5e1791e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -65,6 +65,8 @@ export interface SshAccessInput { username: string authMethod: SshAuthMethod password?: string + passwordCredentialId?: string + passwordPersisted?: boolean privateKeyPath?: string privateKeyCredentialId?: string privateKeyName?: string @@ -95,6 +97,8 @@ export interface Server { privateKeyName?: string | null privateKeyCredentialId?: string | null privateKeyPersisted?: boolean | null + passwordCredentialId?: string | null + passwordPersisted?: boolean | null setupStatus?: ServerSetupStatus diagnostics?: ServerDiagnostics | null hostKeyFingerprint?: string | null diff --git a/tests/type-contracts/onboarding-contracts.ts b/tests/type-contracts/onboarding-contracts.ts index a80723e..378fcea 100644 --- a/tests/type-contracts/onboarding-contracts.ts +++ b/tests/type-contracts/onboarding-contracts.ts @@ -22,7 +22,10 @@ type KeyPickerReturnsReference = Assert< type ImportApiIsExposed = Assert< Equal Promise> > -type ServerStoresCredentialState = Assert<'privateKeyPersisted' extends keyof Server ? true : false> +type ServerStoresCredentialState = Assert< + 'privateKeyPersisted' extends keyof Server ? true : false +> +type ServerStoresPasswordCredential = Assert<'passwordCredentialId' extends keyof Server ? true : false> const diagnostics: ServerDiagnostics = { manager: 'detected', @@ -46,5 +49,11 @@ const importPayload: ImportServerPayload = { const fixtures: [ServerDiagnostics, ImportServerPayload] = [diagnostics, importPayload] void fixtures -type OnboardingContractAssertions = [EmailModeIsRequired, KeyPickerReturnsReference, ImportApiIsExposed, ServerStoresCredentialState] +type OnboardingContractAssertions = [ + EmailModeIsRequired, + KeyPickerReturnsReference, + ImportApiIsExposed, + ServerStoresCredentialState, + ServerStoresPasswordCredential +] void (undefined as unknown as OnboardingContractAssertions) diff --git a/tests/unit/server-store.test.ts b/tests/unit/server-store.test.ts index 5df1432..2e4ab4f 100644 --- a/tests/unit/server-store.test.ts +++ b/tests/unit/server-store.test.ts @@ -105,6 +105,63 @@ describe('server store onboarding metadata', () => { expect(store.list()).toHaveLength(1) }) + it('preserves a saved SSH password credential when an import has no new password', () => { + const store = createServerStore() + const initial = store.add({ + ...baseServer, + passwordCredentialId: 'password_credential_1234' + } as Omit) + + const updated = store.upsertImported( + { ...baseServer, subscriptionUrl: '', keys: [] }, + connection + ) + + expect(updated.id).toBe(initial.id) + expect((updated as Server & { passwordCredentialId?: string | null }).passwordCredentialId).toBe( + 'password_credential_1234' + ) + }) + + it('clears a stale password credential when the keychain cannot persist a replacement', () => { + const store = createServerStore() + const initial = store.add({ + ...baseServer, + passwordCredentialId: 'password_credential_1234', + passwordPersisted: true + } as Omit) + + const updated = store.updateConnection(initial.id, { + username: 'root', + authMethod: 'password', + privilegeMode: 'root', + passwordCredentialId: null, + passwordPersisted: false + }) + + expect(updated?.passwordCredentialId).toBeNull() + expect(updated?.passwordPersisted).toBe(false) + }) + + it('clears a stale SSH password reference after the keychain fails to save a replacement', () => { + const store = createServerStore() + const initial = store.add({ + ...baseServer, + passwordCredentialId: 'password_credential_1234', + passwordPersisted: true + } as Omit) + const updated = store.updateConnection(initial.id, { + username: 'root', + authMethod: 'password', + privilegeMode: 'root', + passwordCredentialId: null, + passwordPersisted: false + }) + + expect(updated?.passwordCredentialId).toBeNull() + expect(updated?.passwordPersisted).toBe(false) + }) + it('counts shared credential references and clears only the selected server reference', () => { const store = createServerStore() const first = store.add(baseServer) diff --git a/tests/unit/ssh-access.test.ts b/tests/unit/ssh-access.test.ts index 7b07770..848e663 100644 --- a/tests/unit/ssh-access.test.ts +++ b/tests/unit/ssh-access.test.ts @@ -2,7 +2,11 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { createSshCredentials, resolvePrivateKey } from '../../src/main/core/ssh-access' +import { + createSshCredentials, + resolvePrivateKey, + resolveStoredSshPassword +} from '../../src/main/core/ssh-access' import { formatHostKeyFingerprint } from '../../src/main/core/ssh-client' import type { Server } from '../../src/shared/types' @@ -219,4 +223,43 @@ describe('SSH access', () => { `SHA256:${Buffer.alloc(32).toString('base64').replace(/=+$/, '')}` ) }) + + it('loads the saved SSH password when the form is empty', async () => { + const passwordStore = { load: vi.fn().mockResolvedValue('saved-secret') } + const access = await resolveStoredSshPassword( + { username: 'root', authMethod: 'password', privilegeMode: 'root' }, + { ...storedServer, passwordCredentialId: 'password_credential_1234' }, + passwordStore + ) + expect(passwordStore.load).toHaveBeenCalledWith('password_credential_1234') + expect(access.password).toBe('saved-secret') + expect(access.passwordPersisted).toBe(true) + }) + + it('prefers a newly entered SSH password over a saved credential', async () => { + const passwordStore = { load: vi.fn().mockResolvedValue('old-secret') } + const access = await resolveStoredSshPassword( + { + username: 'root', + authMethod: 'password', + password: 'new-secret', + privilegeMode: 'root' + }, + { ...storedServer, passwordCredentialId: 'password_credential_1234' }, + passwordStore + ) + expect(passwordStore.load).not.toHaveBeenCalled() + expect(access.password).toBe('new-secret') + }) + + it('asks for the password again when the saved keychain entry is missing', async () => { + const passwordStore = { load: vi.fn().mockResolvedValue(null) } + await expect( + resolveStoredSshPassword( + { username: 'root', authMethod: 'password', privilegeMode: 'root' }, + { ...storedServer, passwordCredentialId: 'missing_password_1234' }, + passwordStore + ) + ).rejects.toThrow('не найден в системном хранилище') + }) }) diff --git a/tests/unit/ssh-client.test.ts b/tests/unit/ssh-client.test.ts index 308465a..858b08a 100644 --- a/tests/unit/ssh-client.test.ts +++ b/tests/unit/ssh-client.test.ts @@ -66,6 +66,34 @@ describe('SSH host key verification', () => { expect(trusted).not.toHaveBeenCalled() }) + it('waits for async post-authentication persistence before resolving connect', async () => { + sshState.mode = 'ready' + let releaseCallback: (() => void) | undefined + const callbackGate = new Promise((resolve) => { + releaseCallback = resolve + }) + let connectResolved = false + const client = new SshClient({ + host: 'server.example', + port: 22, + username: 'root', + password: 'secret', + privilegeMode: 'root', + onAuthenticated: async () => callbackGate + }) + + const connected = client.connect().then(() => { + connectResolved = true + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(connectResolved).toBe(false) + + releaseCallback?.() + await connected + expect(connectResolved).toBe(true) + client.close() + }) + it('останавливает подключение при смене закреплённого fingerprint', async () => { sshState.mode = 'ready' const client = new SshClient({ diff --git a/tests/unit/ssh-keychain.test.ts b/tests/unit/ssh-keychain.test.ts index 33b9462..47b6cee 100644 --- a/tests/unit/ssh-keychain.test.ts +++ b/tests/unit/ssh-keychain.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { KeychainApi } from '../../src/main/core/ssh-keychain' -import { createSshKeychain } from '../../src/main/core/ssh-keychain' +import { createSshKeychain, createSshPasswordStore } from '../../src/main/core/ssh-keychain' function mockApi(overrides: Partial = {}): KeychainApi { return { @@ -80,3 +80,58 @@ describe('SSH keychain', () => { ) }) }) + +describe('SSH password store', () => { + it('stores and reloads only the SSH password in a separate keychain namespace', async () => { + const entries = new Map() + const api = mockApi({ + setPassword: vi.fn(async (service, account, value) => { + entries.set(`${service}:${account}`, value) + }), + getPassword: vi.fn(async (service, account) => entries.get(`${service}:${account}`) ?? null) + }) + const store = createSshPasswordStore(api) + + await store.save('password_12345678', 's3cret') + const loaded = await store.load('password_12345678') + + expect(api.setPassword).toHaveBeenCalledWith( + 'com.xrayebator.gui.ssh-password', + 'password_12345678', + 's3cret' + ) + expect(loaded).toBe('s3cret') + }) + + it('rejects line breaks but allows ordinary r and n characters', async () => { + const api = mockApi() + const store = createSshPasswordStore(api) + await expect(store.save('password_12345678', 'spring')).resolves.toBeUndefined() + await expect(store.save('password_12345678', 'line1\nline2')).rejects.toThrow('перевод строки') + }) + + it('ignores an empty password and removes only the requested item', async () => { + const api = mockApi({ deletePassword: vi.fn().mockResolvedValue(true) }) + const store = createSshPasswordStore(api) + + await store.save('password_12345678', '') + expect(api.setPassword).not.toHaveBeenCalled() + + await store.load('password_12345678') + expect(api.getPassword).toHaveBeenCalledWith( + 'com.xrayebator.gui.ssh-password', + 'password_12345678' + ) + + await store.remove('password_12345678') + expect(api.deletePassword).toHaveBeenCalledWith( + 'com.xrayebator.gui.ssh-password', + 'password_12345678' + ) + }) + + it('rejects malformed credential IDs', async () => { + const store = createSshPasswordStore(mockApi()) + await expect(store.load('../bad')).rejects.toThrow('идентификатор') + }) +}) diff --git a/tests/unit/ui-contracts.test.ts b/tests/unit/ui-contracts.test.ts index 4fe6a7f..3c254c6 100644 --- a/tests/unit/ui-contracts.test.ts +++ b/tests/unit/ui-contracts.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' +import { isSshAccessReady, setTypedSshPassword } from '../../src/renderer/src/components/SshAccessForm' import { buildDeployPayload, isDeployReady } from '../../src/renderer/src/pages/deploy-readiness' +import { shouldAutoConnectServer } from '../../src/renderer/src/pages/server-access' import type { SshAccessInput } from '../../src/shared/types' const readyAccess: SshAccessInput = { @@ -10,6 +12,17 @@ const readyAccess: SshAccessInput = { } describe('deploy readiness', () => { + it('treats a keychain-backed SSH password as complete access', () => { + expect( + isSshAccessReady({ + username: 'root', + authMethod: 'password', + passwordCredentialId: 'password_credential_1234', + privilegeMode: 'root' + }) + ).toBe(true) + }) + it('allows deploying without email only in the explicit without mode', () => { expect( isDeployReady({ host: 'vps', port: '22', emailMode: 'without', email: '' }, readyAccess) @@ -43,6 +56,46 @@ describe('deploy readiness', () => { ).toBe(false) }) + it('marks a manually typed password as needing re-persistence', () => { + const stored: SshAccessInput = { + username: 'root', + authMethod: 'password', + passwordCredentialId: 'password_credential_1234', + passwordPersisted: true, + privilegeMode: 'root' + } + const typed = setTypedSshPassword(stored, 'fresh-pass') + expect(typed.password).toBe('fresh-pass') + // A hand-typed password must overwrite the keychain entry, not be skipped as "already saved". + expect(typed.passwordPersisted).toBe(false) + // Clearing the field falls back to the saved credential untouched. + expect(setTypedSshPassword(stored, '').passwordPersisted).toBe(true) + }) + + it('auto-connects only when a persisted credential is available', () => { + expect( + shouldAutoConnectServer({ + authMethod: 'password', + passwordCredentialId: 'password_credential_1234' + }) + ).toBe(true) + expect( + shouldAutoConnectServer({ + authMethod: 'privateKey', + privateKeyCredentialId: 'key_credential_1234', + privateKeyPersisted: true + }) + ).toBe(true) + expect( + shouldAutoConnectServer({ + authMethod: 'privateKey', + privateKeyCredentialId: 'session_key_1234', + privateKeyPersisted: false + }) + ).toBe(false) + expect(shouldAutoConnectServer({ authMethod: 'password' })).toBe(false) + }) + it('omits the email from the payload in without mode', () => { const payload = buildDeployPayload( { host: ' vps.example ', port: '2222', emailMode: 'without', email: 'leftover@x.io' }, From ac2d0b7e7e1f6dd9c6a8ee8db3b874101c32e80b Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:39:37 +0300 Subject: [PATCH 21/35] =?UTF-8?q?docs:=20=D0=BE=D1=82=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20SSH-=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=20=D0=B2=20keychain=20=D0=B8=20auto-connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Старые формулировки («пароли никогда не сохраняются») противоречили новой реализации. README/GUI/security на трёх языках описывают: SSH-пароль сохраняется в OS keychain после первого успешного входа и переиспользуется; отдельный sudo-пароль и passphrase не сохраняются; Server Settings подключается автоматически, а форма доступа сворачивается в статус. --- README.md | 2 +- README.ru.md | 2 +- README.zh-CN.md | 2 +- docs/desktop-gui.md | 4 ++-- docs/ru/desktop-gui.md | 4 ++-- docs/ru/security.md | 2 +- docs/security.md | 8 ++++---- docs/zh-CN/desktop-gui.md | 4 ++-- docs/zh-CN/security.md | 4 ++-- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 57735af..4d282ff 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ What the GUI can do: | 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 | -Root + password is the one-click default; key authentication and sudo are optional. A selected private key is stored in the operating-system keychain via `keytar` and reused across later SSH operations and app restarts; only its credential id and display name are stored with the server card. SSH passwords, sudo passwords and key passphrases are never persisted; encrypted-key passphrases are requested again when needed. If the OS keychain is unavailable, there is no plaintext fallback: the selected key remains in main-process memory for the current app session and the UI warns that it must be selected 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 through the terminal workflow 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 diff --git a/README.ru.md b/README.ru.md index 82eae44..6186c4b 100644 --- a/README.ru.md +++ b/README.ru.md @@ -292,7 +292,7 @@ xrayebator (bash) ──► /usr/local/etc/xray/ | Ключи сервера | Обновить публичную подписку, скопировать URL, показать ссылки `vless://` и QR-коды | | Настройки сервера | SSH по паролю или приватному ключу, прямой root или sudo; список/создание/удаление профилей, смена fingerprint, SNI и порта, обновление или удаление Xrayebator | -Выбранный приватный ключ сохраняется в системном keychain через `keytar` и повторно используется при следующих SSH-операциях и после перезапуска приложения; в карточке сервера хранятся только credential id и отображаемое имя ключа. SSH-пароли, sudo-пароли и passphrase не сохраняются; 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: защищайте локальные данные и при утечке отзывайте подписку через терминальный workflow. GUI предоставляет только подмножество терминального меню. Bypass, `probe-test`, revoke подписки, `happ-setup`, каскад, self-steal и логи/статус сервисов остаются терминальными операциями. Полная diff --git a/README.zh-CN.md b/README.zh-CN.md index 5c743a9..488cb65 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -280,7 +280,7 @@ GUI 的功能: | 服务器密钥 | 刷新公网订阅、复制链接、显示 `vless://` 链接与二维码 | | 服务器设置 | 使用 SSH 密码或私钥、直接 root 或 sudo:列出/创建/删除配置档,修改指纹、SNI 和端口,以及更新或卸载服务器上的 Xrayebator | -选中的私钥通过 `keytar` 保存在操作系统钥匙串中,可在之后的 SSH 操作和应用重启后复用;服务器卡片只保存 credential id 和显示文件名。SSH 密码、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:请保护本地应用数据,泄露后通过终端 workflow 吊销订阅。 GUI 只暴露终端菜单的一个子集。bypass、`probe-test`、订阅吊销、`happ-setup`、级联、self-steal 以及服务日志/状态仍需从终端执行。完整的 Electron GUI 边界、安全模型与打包说明见 diff --git a/docs/desktop-gui.md b/docs/desktop-gui.md index eec27b6..9087050 100644 --- a/docs/desktop-gui.md +++ b/docs/desktop-gui.md @@ -45,7 +45,7 @@ Server keys refreshes the subscription from the saved `subscription_url` and dis ### Server settings -Server settings first authenticates over SSH and can then: +Server settings first authenticates over SSH. When the card already has a keychain-backed SSH password or a persisted private key, the page attempts to connect automatically; after success the access form collapses to a compact "access confirmed" status with an explicit "Change access" action. Once connected, the page can: - list existing profiles; - create one or more profiles and delete profiles; @@ -62,7 +62,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. -SSH passwords, sudo passwords, and key passphrases are never persisted — the passphrase is asked again for each session when the key is encrypted. `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 id, 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 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 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 Electron boundary includes the following protections: diff --git a/docs/ru/desktop-gui.md b/docs/ru/desktop-gui.md index 8ec2f9d..b722b39 100644 --- a/docs/ru/desktop-gui.md +++ b/docs/ru/desktop-gui.md @@ -46,7 +46,7 @@ Server keys обновляет подписку по сохранённому `s ### Server settings -Server settings сначала аутентифицируется по SSH, после чего позволяет: +Server settings сначала аутентифицируется по SSH. Если у карточки уже есть SSH-пароль или persistent-ключ в системном keychain, страница подключается автоматически; после успеха форма доступа сворачивается в компактный статус «доступ подтверждён» с явным действием «Изменить доступ». После подключения страница позволяет: - получить список существующих профилей; - создать один или несколько профилей и удалить профили; @@ -63,7 +63,7 @@ SNI и порт — параметры уровня inbound: их изменен GUI поддерживает SSH-аутентификацию по паролю или приватному ключу, а команды с повышенными правами можно выполнять напрямую из-под `root` или через `sudo`. Ключ выбирается через нативный диалог Electron; main process читает его байты и сохраняет через `keytar` в системное хранилище ОС (Windows Credential Manager, macOS Keychain или Linux Secret Service). Renderer получает только непривилегированный credential id и отображаемое имя — байты ключа не пересекают preload boundary. После этого ключ можно повторно использовать для операций и после перезапуска приложения. -Если системное хранилище недоступно, plaintext-файл не создаётся: ключ хранится только в памяти main process до выхода из приложения, а GUI предупреждает, что после перезапуска его понадобится выбрать снова. SSH-пароли, sudo-пароли и passphrase ключей не сохраняются; passphrase зашифрованного ключа вводится заново при необходимости. `electron-store` хранит карточку и настройки сервера, credential id/имя ключа, диагностику, URL подписки, VLESS-ссылки (bearer/client credentials) и SHA-256 host-key pin после первого успешного подключения (TOFU). Защищайте локальные данные приложения; при утечке URL или VLESS-ссылок отзовите подписку. При удалении последней карточки, ссылающейся на credential, запись keychain удаляется; если на неё ссылается другая карточка, запись сохраняется. Несовпадение host-key fingerprint блокирует команды; после осознанной переустановки VPS pin можно явно сбросить в Server settings. +Если системное хранилище недоступно, plaintext-файл не создаётся: ключ хранится только в памяти main process до выхода из приложения, а GUI предупреждает, что после перезапуска его понадобится выбрать снова. SSH-пароль сохраняется в системный keychain после первого успешного входа и переиспользуется при следующих операциях и после перезапуска; в карточке хранится только несекретный credential id. Отдельный sudo-пароль и passphrase зашифрованного ключа не сохраняются и запрашиваются заново. Байты ключей и значения паролей не пересекают preload boundary: renderer получает только credential id и отображаемые имена. `electron-store` хранит карточку и настройки сервера, credential id/имя ключа, диагностику, URL подписки, VLESS-ссылки (bearer/client credentials) и SHA-256 host-key pin после первого успешного подключения (TOFU). Защищайте локальные данные приложения; при утечке URL или VLESS-ссылок отзовите подписку. При удалении последней карточки, ссылающейся на credential, запись keychain удаляется; если на неё ссылается другая карточка, запись сохраняется. Несовпадение host-key fingerprint блокирует команды; после осознанной переустановки VPS pin можно явно сбросить в Server settings. Граница Electron включает следующие меры защиты: diff --git a/docs/ru/security.md b/docs/ru/security.md index bd2cca9..9ed2acc 100644 --- a/docs/ru/security.md +++ b/docs/ru/security.md @@ -106,7 +106,7 @@ TCPKeepAlive yes Активный Electron-GUI поддерживает SSH по паролю и приватному ключу, с прямым root или sudo. Выбранный через нативный dialog приватный ключ main process сохраняет через `keytar` в системное хранилище ОС (Windows Credential Manager, macOS Keychain или Linux Secret Service). Поэтому ключ можно повторно использовать для SSH-операций и после перезапуска приложения. Renderer получает только несекретный credential id и отображаемое имя файла; байты приватного ключа не переходят через preload boundary. -Если системный keychain недоступен, plaintext-фолбека на диске нет: ключ остаётся только в памяти main process до выхода из приложения, а GUI предупреждает, что после перезапуска ключ придётся выбрать снова. SSH-пароли, sudo-пароли и passphrase не сохраняются; passphrase зашифрованного ключа вводится заново при необходимости. +Если системный 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 удаляется; общая запись остаётся, пока на неё ссылается другая карточка. diff --git a/docs/security.md b/docs/security.md index e9329b5..154bb71 100644 --- a/docs/security.md +++ b/docs/security.md @@ -116,12 +116,12 @@ the file again. When the OS keychain is unavailable there is deliberately no pla key survives only in main-process memory for the current session and the UI warns that re-selection will be required after restart. -SSH passwords, sudo passwords and key passphrases are never persisted; an encrypted key's passphrase -is requested again in each session. Private-key bytes never cross the preload boundary — the renderer -receives only a non-secret credential id and the display file name. +The SSH login password and a selected private key are persisted to the operating-system keychain after successful authentication — the password only after the first successful sign-in, the key when it is picked through the native file dialog — and are reused for later operations and after app restarts. When the OS keychain is unavailable there is deliberately no plaintext fallback: the key survives only in main-process memory for the current session and the UI warns that re-selection will be required after restart. + +A distinct sudo password and an encrypted key's passphrase are never persisted; they are requested again in each session. Credentials and password values never cross the preload boundary — the renderer receives only non-secret credential ids and the display key name. The GUI does persist the server metadata needed to return to a server, including the host, SSH port, -username, authentication method, privilege mode, credential id, display key name and installation +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 diff --git a/docs/zh-CN/desktop-gui.md b/docs/zh-CN/desktop-gui.md index f982f8f..2799748 100644 --- a/docs/zh-CN/desktop-gui.md +++ b/docs/zh-CN/desktop-gui.md @@ -46,7 +46,7 @@ Server keys 会从保存的 `subscription_url` 刷新订阅,并显示返回的 ### Server settings -Server settings 先通过 SSH 认证,然后可以: +Server settings 先通过 SSH 认证。如果卡片中已有系统钥匙串保存的 SSH 密码或持久化私钥,页面会自动尝试连接;成功后访问表单收起为简洁的“访问已确认”状态,并提供显式的“更改访问方式”操作。连接后可以: - 列出已有配置档; - 创建一个或多个配置档并删除配置档; @@ -63,7 +63,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 密码、sudo 密码和私钥口令不会持久化;加密私钥的口令需要在新会话中重新输入。私钥字节不会越过 preload boundary。`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 链接泄露,请通过终端 workflow 吊销订阅。删除引用某个 credential 的最后一张服务器卡片时会删除对应钥匙串记录;其他卡片仍引用时会保留。 Electron 边界包含以下保护措施: diff --git a/docs/zh-CN/security.md b/docs/zh-CN/security.md index dd460d4..4034c80 100644 --- a/docs/zh-CN/security.md +++ b/docs/zh-CN/security.md @@ -100,9 +100,9 @@ TCPKeepAlive yes 活跃的 Electron GUI 支持 SSH 密码和私钥,并可选择直接 root 或 sudo。通过 Electron 原生文件对话框选择的私钥由 main process 读取,并通过 `keytar` 保存到操作系统钥匙串(Windows Credential Manager、macOS Keychain 或 Linux Secret Service),因此后续 SSH 操作和应用重启后都可以复用。Renderer 只收到非敏感 credential id 和显示文件名;私钥字节不会跨越 preload boundary。 -如果系统钥匙串不可用,应用不会在磁盘上保存明文回退副本:密钥只保留在 main process 内存中直到应用退出,界面会提示重启后需要重新选择。SSH 密码、sudo 密码和私钥口令都不持久化;加密私钥的口令在需要时重新输入。 +如果系统钥匙串不可用,应用不会在磁盘上保存明文回退副本:密钥只保留在 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 会在部署前说明。 From 87e2c154cd88f7722274336ecc6ae88b2cfcb25e Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:39:37 +0300 Subject: [PATCH 22/35] =?UTF-8?q?docs:=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20apt-lock=20=D1=80=D0=B5=D0=B3=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D1=81=D0=B8=D1=8E=20=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлена секция quickstart «apt-get install nginx failed» с корнем (per-package dpkg у unattended-upgrades и зазор flock между пакетами) и контрактами (worker check, 12-мин бюджет, DPkg::Lock::Timeout=180). Счётчик validation-скриптов 25->26, новая строка test-apt-lock-race.sh в трёх testing-доках и CLAUDE.md. --- CLAUDE.md | 2 +- docs/ru/testing.md | 5 +++-- docs/ru/troubleshooting.md | 4 ++++ docs/testing.md | 5 +++-- docs/troubleshooting.md | 4 ++++ docs/zh-CN/testing.md | 5 +++-- docs/zh-CN/troubleshooting.md | 4 ++++ 7 files changed, 22 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e910318..26b512a 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/`** — 25 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 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/`** — 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. - **`gui-legacy/tests/`** — 16 pytest modules covering SSH, deploy, connection, subscription and TUN runtime (legacy PySide6 GUI). Run with the GUI venv: `gui-legacy/.venv/Scripts/python -m pytest gui-legacy/tests`. - **GUI (Electron)** — Vitest unit tests in `tests/`: `npm test`, plus `npm run typecheck`. - **CI** — `.github/workflows/ci-linux.yml` runs the full `validation/` suite; `.github/workflows/gui-release.yml` runs `ruff` + `pytest gui-legacy/tests` and builds Windows/macOS bundles; `.github/workflows/release.yml` ships the Electron app. diff --git a/docs/ru/testing.md b/docs/ru/testing.md index 221acd6..a13bdf7 100644 --- a/docs/ru/testing.md +++ b/docs/ru/testing.md @@ -16,7 +16,7 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done ## Что покрывают тесты -В `validation/` лежат 25 статических и локальных регрессионных тестов: +В `validation/` лежат 26 статических и локальных регрессионных тестов: | Тест | Что проверяет | |---|---| @@ -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-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` | | `test-quickstart-subscription-port.sh` | `quickstart` использует canonical helper базы подписки и не возвращается к несвязанному hardcode URL | @@ -102,7 +103,7 @@ npm test # Vitest unit-тесты Три независимых workflow: -- **ci-linux.yml** — Bash validation: `bash -n` всех скриптов + все 25 `validation/test-*.sh` на +- **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, затем diff --git a/docs/ru/troubleshooting.md b/docs/ru/troubleshooting.md index 90310e2..a8974f7 100644 --- a/docs/ru/troubleshooting.md +++ b/docs/ru/troubleshooting.md @@ -187,6 +187,10 @@ sudo systemctl restart sshd Несколько профилей одновременно использовать можно: разные подписки, SNI, порты и маршруты дают больше вариантов обхода блокировок, но делят общие ресурсы VPS. +## quickstart падает с «apt-get install nginx failed» + +На свежем Ubuntu-VPS `unattended-upgrades` может держать apt/dpkg lock около 10 минут и вызывать `dpkg` отдельно для каждого пакета, поэтому простая проверка lock проскакивает в зазор между пакетами. quickstart теперь дополнительно ждёт активного воркера `unattended-upgrade` (бюджет 12 минут) и передаёт `apt-get install` опцию `-o DPkg::Lock::Timeout=180`. Повторите деплой, когда quickstart сообщит, что apt занят дольше бюджета, или дождитесь окончания очереди. Поведение закреплено в `validation/test-apt-lock-race.sh`. + ## Вылезла ошибка при установке или работе Скопируйте текст ошибки из терминала целиком. Если проблема в коде Xrayebator — открывайте issue. \ No newline at end of file diff --git a/docs/testing.md b/docs/testing.md index 6ee1bf7..aef293a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -36,7 +36,7 @@ promise that installer and updater paths behave identically. ## Validation suite -`validation/` contains exactly 25 scripts. Run every `validation/test-*.sh`; the current set is: +`validation/` contains exactly 26 scripts. Run every `validation/test-*.sh`; the current set is: | Script | What it checks | |---|---| @@ -56,6 +56,7 @@ promise that installer and updater paths behave identically. | `test-multiroute-argument-preservation.sh` | Preservation of multiroute transport arguments | | `test-port-change-cli.sh` | Port-change CLI scenarios, firewall moves and route selection | | `test-project-update-rollback.sh` | Rollback of a failed project update | +| `test-apt-lock-race.sh` | apt-lock race: `DPkg::Lock::Timeout` on installs, the unattended-upgrade worker check, and the 12-minute quickstart budget | | `test-quickstart-email-and-inspect.sh` | Explicit `quickstart` email mode (`--without-email` without a fake address) and the read-only invariants of `inspect --json` | | `test-quickstart-migration-parity.sh` | Parity between quickstart and main-menu migrations | | `test-quickstart-subscription-port.sh` | Ensures quickstart uses the canonical subscription base helper and does not regress to an unrelated hardcoded URL | @@ -146,7 +147,7 @@ provide useful local coverage, while CI runs the Electron typecheck and unit sui The workflows have separate responsibilities: - `.github/workflows/ci-linux.yml` is the Bash core gate on `ubuntu-24.04`: it installs `jq`, - `uuid-runtime` and `ripgrep`, runs all four Bash syntax checks, then runs all 25 validation + `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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b9ed728..a42dfb2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -212,6 +212,10 @@ count and provider limits. Grow the user count gradually and watch the load. Multiple profiles can be used at once: different subscriptions, SNIs, ports and routes give more options for bypassing blocks, but they share the same VPS resources. +## Quickstart fails with `apt-get install nginx failed` + +On a freshly provisioned Ubuntu VPS, `unattended-upgrades` may hold the apt/dpkg lock for ~10 minutes and invoke `dpkg` separately for every package, so a plain flock check slips into the gap between packages. The quickstart path now also waits for an active `unattended-upgrade` worker (12-minute budget) and passes `-o DPkg::Lock::Timeout=180` to `apt-get install`. Rerun the deployment when it reports the lock is still busy, or wait for the queue to finish. `validation/test-apt-lock-race.sh` locks these behaviors. + ## An error appeared during installation or use Copy the full error text from the terminal. For installation failures, include the relevant diff --git a/docs/zh-CN/testing.md b/docs/zh-CN/testing.md index 43023ca..6a3251f 100644 --- a/docs/zh-CN/testing.md +++ b/docs/zh-CN/testing.md @@ -15,7 +15,7 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done ## 测试覆盖范围 -`validation/` 中有 25 个静态与本地回归测试: +`validation/` 中有 26 个静态与本地回归测试: | 测试 | 检查内容 | |---|---| @@ -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-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` 相同的关键迁移 | | `test-quickstart-subscription-port.sh` | 确认 `quickstart` 使用规范的订阅基础地址 helper,不回退到无关的硬编码 URL | @@ -99,7 +100,7 @@ npm test # Vitest 单元测试 三个独立 workflow: -- **ci-linux.yml** — Bash validation:在 ubuntu-24.04 上对所有脚本执行 `bash -n`,并运行全部 25 个 +- **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`,再在三种平台执行 diff --git a/docs/zh-CN/troubleshooting.md b/docs/zh-CN/troubleshooting.md index 3a076ad..8c00ea2 100644 --- a/docs/zh-CN/troubleshooting.md +++ b/docs/zh-CN/troubleshooting.md @@ -139,6 +139,10 @@ sudo systemctl restart sshd 多个配置档可以同时使用:不同的订阅、SNI、端口和线路提供了更多绕过封锁的选择,但它们共享同一台 VPS 的资源。 +## quickstart 报错 `apt-get install nginx failed` + +新装的 Ubuntu VPS 上,`unattended-upgrades` 可能持有 apt/dpkg 锁约 10 分钟,并且对每个包单独调用 `dpkg`,因此简单的锁检查会从包与包之间的空隙漏过。quickstart 现在会额外等待活跃的 `unattended-upgrade` 进程(12 分钟预算),并给 `apt-get install` 传入 `-o DPkg::Lock::Timeout=180`。当 quickstart 提示 apt 超过预算仍被占用时,请稍后重试部署,或等更新队列结束。该行为由 `validation/test-apt-lock-race.sh` 锁定。 + ## 安装或使用过程中出现报错 请完整复制终端中的报错文本。如果问题出在 Xrayebator 代码上,请提交 issue。 \ No newline at end of file From 4ce997d40e8fd3c470cfc129e6d5b436c35b9a53 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:19:14 +0300 Subject: [PATCH 23/35] =?UTF-8?q?feat:=20=D0=B6=D0=B8=D0=B2=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D1=8C=20=D0=B2=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D0=B5=D1=80=D0=B5=20=D0=B8=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=D0=B0=20=D1=81=D1=83=D1=89=D0=B5=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D1=83=D1=8E=D1=89=D0=B5=D0=B3=D0=BE=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Импорт показывал только справа индекс фаз, без консоли — в отличие от развёртывания, где видно, какие команды реально уходят на сервер. Теперь ServerInspector пишет строки хода работ (SSH connect, \$ xrayebator inspect --json, код, статусы компонентов, probe подписки, итог) через onLog, IPC шлёт их как { log } на тот же канал servers:importEvent, а ImportServer рендерит консоль под индексом фаз по образцу DeployServer. Событие импорта стало union { step } | { log }; preload форвардит как есть. URL подписки — bearer credential, поэтому перед выводом в консоль токен маскируется maskSubscriptionUrl (TDD: тест падал до реализации). --- src/main/core/server-inspector.ts | 33 +++++++++++-- src/main/ipc-handlers.ts | 18 +++++-- .../src/pages/ImportServer.module.css | 47 +++++++++++++++++++ src/renderer/src/pages/ImportServer.tsx | 32 +++++++++++-- src/shared/types.ts | 5 +- tests/unit/server-inspector.test.ts | 14 +++++- 6 files changed, 133 insertions(+), 16 deletions(-) diff --git a/src/main/core/server-inspector.ts b/src/main/core/server-inspector.ts index c7e202d..b79542b 100644 --- a/src/main/core/server-inspector.ts +++ b/src/main/core/server-inspector.ts @@ -35,6 +35,11 @@ function isLocalSubscriptionUrl(url: string): boolean { ) } +/** URL подписки — bearer credential: перед выводом в консоль токен маскируется. */ +export function maskSubscriptionUrl(url: string): string { + return url.replace(/\/sub\/([a-f0-9]{4})[a-f0-9]+([a-f0-9]{4})(?=$|[/?#])/i, '/sub/$1…$2') +} + /** Публичный URL, который GUI может пробовать; null — probe не выполнять. */ export function subscriptionProbeTarget(snapshot: InspectionSnapshot): string | null { const url = snapshot.subscription_url?.trim() ?? '' @@ -112,17 +117,22 @@ export function normalizeInspection( /** * Read-only инспектор существующей установки для GUI-импорта. * Выполняет ТОЛЬКО `xrayebator inspect --json`; никаких mutation-команд. + * onLog получает строки консоли (пароли/токены маскируются до отправки). */ export class ServerInspector { constructor( private readonly creds: SshCredentials, - private readonly probeSubscription: (url: string) => Promise = async () => [] + private readonly probeSubscription: (url: string) => Promise = async () => [], + private readonly onLog: (text: string) => void = () => {} ) {} async inspect(): Promise { const client = new SshClient(this.creds) + this.onLog(`SSH: подключаюсь к ${this.creds.host}:${this.creds.port} (${this.creds.username})`) try { await client.connect() + this.onLog('SSH: соединение установлено, host key подтверждён') + this.onLog('$ xrayebator inspect --json') const res = await client.exec(shellCommand('xrayebator', ['inspect', '--json']), { elevated: true }) @@ -135,6 +145,7 @@ export class ServerInspector { `${err instanceof Error ? err.message : String(err)}` ) } + this.onLog(`inspect: код ${res.code}, менеджер ${snapshot.manager}, Xray ${snapshot.xray}, профилей ${snapshot.profile_count}`) if (!snapshot.ok) { throw new Error(snapshot.error ?? 'Xrayebator на сервере не распознан') } @@ -142,16 +153,30 @@ export class ServerInspector { const publicUrl = subscriptionProbeTarget(snapshot) let probe: SubscriptionProbe = null if (publicUrl) { + this.onLog(`subscription: $ curl ${maskSubscriptionUrl(publicUrl)}`) try { const keys = await this.probeSubscription(publicUrl) - probe = keys && keys.length > 0 ? keys : 'unreachable' - } catch { + if (keys && keys.length > 0) { + probe = keys + this.onLog(`subscription: получено маршрутов: ${keys.length}`) + } else { + probe = 'unreachable' + this.onLog('subscription: публичный endpoint не вернул маршрутов') + } + } catch (err) { probe = 'unreachable' + this.onLog(`subscription: проверка не прошла (${err instanceof Error ? err.message : String(err)})`) } } else { probe = null + this.onLog('subscription: публичный HTTPS endpoint не обнаружен — пропускаю проверку') } - return normalizeInspection(snapshot, probe) + const normalized = normalizeInspection(snapshot, probe) + this.onLog(`итог: ${normalized.setupStatus}, ключей: ${normalized.keys.length}`) + return normalized + } catch (err) { + this.onLog(`ошибка: ${err instanceof Error ? err.message : String(err)}`) + throw err } finally { client.close() } diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index d94a959..3e3115a 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -304,17 +304,27 @@ export function registerIpcHandlers({ store }: IpcContext): void { event.sender.send('servers:importEvent', { step }) } } + const emitLog = (text: string): void => { + if (!event.sender.isDestroyed()) { + event.sender.send('servers:importEvent', { log: text }) + } + } emitStep('ssh') const target = { host: payload.host, port: payload.port } const { credentials, access } = await credentialsFor(null, target, payload.access) emitStep('inspect') - const inspector = new ServerInspector(credentials, async (url) => { - emitStep('subscription') - return fetchSubscription(url) - }) + const inspector = new ServerInspector( + credentials, + async (url) => { + emitStep('subscription') + return fetchSubscription(url) + }, + emitLog + ) const result = await inspector.inspect() await persistSshPassword(access) emitStep('save') + emitLog('card: сервер сохранён в списке, открываю панель настроек') const connection: ServerConnectionMetadata = { username: access.username, diff --git a/src/renderer/src/pages/ImportServer.module.css b/src/renderer/src/pages/ImportServer.module.css index ae16c31..d4aa9f7 100644 --- a/src/renderer/src/pages/ImportServer.module.css +++ b/src/renderer/src/pages/ImportServer.module.css @@ -189,6 +189,53 @@ background: color-mix(in srgb, var(--warning) 8%, var(--bg-2)); } +.logTitle { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-2); + opacity: 0.8; +} + +.logPanel { + min-height: 120px; + max-height: 240px; + overflow-y: auto; + padding: 12px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 12px; +} + +.logLine { + color: var(--text-1); + opacity: 0.85; + line-height: 1.5; + word-break: break-all; + animation: fadeInUp 0.3s ease; +} + +.logError { + color: var(--danger); + line-height: 1.5; + word-break: break-all; + animation: fadeInUp 0.3s ease; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + @media (max-width: 900px) { .body { grid-template-columns: 1fr; diff --git a/src/renderer/src/pages/ImportServer.tsx b/src/renderer/src/pages/ImportServer.tsx index 81b8b2e..abfdd46 100644 --- a/src/renderer/src/pages/ImportServer.tsx +++ b/src/renderer/src/pages/ImportServer.tsx @@ -1,8 +1,8 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Button, TextField, Label, Input, Spinner } from '@heroui/react' import { CheckCircle2, Circle } from 'lucide-react' import { useTranslation } from 'react-i18next' -import type { ImportStep, Server, SshAccessInput } from '@shared/types' +import type { ImportProgressEvent, ImportStep, Server, SshAccessInput } from '@shared/types' import { SshAccessForm, isSshAccessReady } from '../components/SshAccessForm' import styles from './ImportServer.module.css' @@ -29,18 +29,29 @@ export function ImportServer({ onDone, onBack }: ImportServerProps): React.JSX.E }) const [running, setRunning] = useState(false) const [currentStep, setCurrentStep] = useState(null) + const [log, setLog] = useState([]) const [error, setError] = useState(null) + const logPanelRef = useRef(null) - // Main process отправляет каждый шаг только при реальном входе в эту фазу. + useEffect(() => { + const panel = logPanelRef.current + if (panel) panel.scrollTop = panel.scrollHeight + }, [log, error]) + + // Main process отправляет переходы фаз и строки консоли только при реальном ходе работ. useEffect(() => { if (!running) return - return window.api.servers.onImportEvent((event) => setCurrentStep(event.step)) + return window.api.servers.onImportEvent((event: ImportProgressEvent) => { + if ('step' in event) setCurrentStep(event.step) + else setLog((prev) => [...prev, event.log]) + }) }, [running]) const ready = form.host.trim().length > 0 && isSshAccessReady(access) const startImport = (): void => { setError(null) + setLog([]) setRunning(true) setCurrentStep(null) window.api.servers @@ -145,6 +156,19 @@ export function ImportServer({ onDone, onBack }: ImportServerProps): React.JSX.E ) })} + +
{t('deploy.log')}
+
+ {log.length === 0 && !error && ( +
{t('deploy.waiting')}
+ )} + {log.map((line, i) => ( +
+ > {line} +
+ ))} + {error &&
> {error}
} +
diff --git a/src/shared/types.ts b/src/shared/types.ts index 5e1791e..dffbb3f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -235,9 +235,8 @@ export type DeployStatus = 'pending' | 'running' | 'done' | 'error' /** Фазы read-only импорта: main process шлёт шаг только при реальном входе в фазу. */ export type ImportStep = 'ssh' | 'inspect' | 'subscription' | 'save' -export interface ImportProgressEvent { - step: ImportStep -} +/** Событие импорта: переход фазы либо строка консоли (секреты уже замаскированы в main). */ +export type ImportProgressEvent = { step: ImportStep } | { log: string } export interface DeployStartPayload { host: string diff --git a/tests/unit/server-inspector.test.ts b/tests/unit/server-inspector.test.ts index 15f5fa6..6f559ab 100644 --- a/tests/unit/server-inspector.test.ts +++ b/tests/unit/server-inspector.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import type { InspectionSnapshot, VlessLink } from '../../src/shared/types' -import { normalizeInspection, subscriptionProbeTarget } from '../../src/main/core/server-inspector' +import { + maskSubscriptionUrl, + normalizeInspection, + subscriptionProbeTarget +} from '../../src/main/core/server-inspector' const baseInspection: InspectionSnapshot = { ok: true, @@ -78,6 +82,14 @@ describe('normalizeInspection', () => { ).toThrow() }) + it('masks the subscription token before the URL can reach any console', () => { + expect(maskSubscriptionUrl('https://203.0.113.10:8443/sub/0123456789abcdef0123456789abcdef')).toBe( + 'https://203.0.113.10:8443/sub/0123…cdef' + ) + // без токена в URL — без изменений + expect(maskSubscriptionUrl('https://example.com')).toBe('https://example.com') + }) + it('skips probing without a public URL', () => { expect( subscriptionProbeTarget({ ...baseInspection, subscription_url: null }) From d7f08caf9c7d029082c7ee7e4dbe8ec97d2dfe5b Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:19:14 +0300 Subject: [PATCH 24/35] =?UTF-8?q?ui:=20=D0=BF=D0=BE=D0=BD=D1=8F=D1=82?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20?= =?UTF-8?q?=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=B0=D0=B1=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BA=D1=82=D0=BD=D0=BE=D0=B3=D0=BE=20=C2=AB=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=83=D0=BF=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B4=D1=91=D0=BD=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit «Доступ к серверу подтверждён» не объяснял, что это значит. Статус-карточка Server Settings теперь показывает фактический вход user@host:port и строку о том, где лежит секрет и что будет при следующем открытии: пароль/ключ сохранены в системном keychain (авто-вход), ключ только на этот сеанс, секрет не сохранён. Предупреждающий тон — для не-персистентных случаев. Ключи во всех трёх локалях. --- src/renderer/src/i18n/en.json | 6 +++- src/renderer/src/i18n/ru.json | 6 +++- src/renderer/src/i18n/zh.json | 6 +++- .../src/pages/ServerSettings.module.css | 6 ++++ src/renderer/src/pages/ServerSettings.tsx | 31 +++++++++++++++---- 5 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index f77878e..0a5eaaa 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -146,7 +146,11 @@ "hint": "Choose how to access the server. Root + password remains the fast default; SSH keys and sudo are also supported.", "sshPassword": "SSH password", "passwordNote": "The SSH password is stored in the operating-system keychain only after a successful sign-in and is never written to app files. A distinct sudo password and key passphrase are not stored and must be entered again.", - "accessConnected": "Server access confirmed", + "accessConnected": "SSH sign-in complete", + "accessSecretPassword": "the password is stored in the system keychain — it will not be requested next time", + "accessSecretKey": "the private key is stored in the system keychain — sign-in is automatic", + "accessSecretSessionKey": "the key is available only in this session: pick it again after a restart", + "accessSecretNone": "no secret is stored: you will need to enter access again next time", "changeAccess": "Change access", "passwordNotPersisted": "The system keychain is unavailable: the password remains in memory only until the app restarts.", "connect": "Connect", diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index c12d019..e9f08d8 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -147,7 +147,11 @@ "hint": "Выберите способ SSH-доступа. Root + пароль остаётся быстрым вариантом по умолчанию; также поддерживаются SSH-ключи и sudo.", "sshPassword": "SSH-пароль", "passwordNote": "SSH-пароль сохраняется в системном keychain только после успешного входа и не записывается в файлы приложения. Отдельный sudo-пароль и passphrase ключа не сохраняются и запрашиваются заново.", - "accessConnected": "Доступ к серверу подтверждён", + "accessConnected": "SSH-вход выполнен", + "accessSecretPassword": "пароль сохранён в системном хранилище — при следующем открытии он запрашиваться не будет", + "accessSecretKey": "приватный ключ сохранён в системном хранилище — вход выполняется автоматически", + "accessSecretSessionKey": "ключ доступен только в этом сеансе: после перезапуска его придётся выбрать снова", + "accessSecretNone": "секрет не сохранён: при следующем открытии потребуется ввести доступ", "changeAccess": "Изменить доступ", "passwordNotPersisted": "Системное хранилище недоступно: пароль останется в памяти только до перезапуска приложения.", "connect": "Подключиться", diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index dbe303c..be20354 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -145,7 +145,11 @@ "hint": "选择服务器访问方式。Root + 密码仍是默认快捷方式,同时支持 SSH 密钥和 sudo。", "sshPassword": "SSH 密码", "passwordNote": "SSH 密码仅在成功登录后保存到操作系统钥匙串,不会写入应用文件。单独的 sudo 密码和私钥口令不会保存,需要时重新输入。", - "accessConnected": "服务器访问已确认", + "accessConnected": "SSH 登录已完成", + "accessSecretPassword": "密码已保存到系统钥匙串——下次打开时不会再要求输入", + "accessSecretKey": "私钥已保存到系统钥匙串——将自动登录", + "accessSecretSessionKey": "该密钥仅在本次会话中可用:重启后需重新选择", + "accessSecretNone": "未保存任何凭据:下次打开时需要重新输入访问信息", "changeAccess": "更改访问方式", "passwordNotPersisted": "系统钥匙串不可用:密码仅保留在内存中,应用重启后需重新输入。", "connect": "连接", diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index 2c428d9..b1a00fa 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -137,6 +137,12 @@ color: var(--text-0); } +.accessStatusMeta { + font-size: 12px; + line-height: 1.4; + color: var(--text-2); +} + .accessStatusWarning { font-size: 12px; line-height: 1.4; diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index 8f9ea5c..d5015ae 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -112,6 +112,18 @@ export function ServerSettings({ server, onBack }: ServerSettingsProps): React.J const connected = profiles !== null const accessReady = isSshAccessReady(access) + const accessSecretKey = + access.authMethod === 'password' + ? access.passwordPersisted === false + ? 'settings.passwordNotPersisted' + : access.passwordPersisted === true + ? 'settings.accessSecretPassword' + : 'settings.accessSecretNone' + : access.privateKeyCredentialId + ? access.privateKeyPersisted === false + ? 'settings.accessSecretSessionKey' + : 'settings.accessSecretKey' + : 'settings.accessSecretNone' const futureNames = useMemo(() => { const base = name.trim() || 'phone-1' @@ -483,12 +495,19 @@ export function ServerSettings({ server, onBack }: ServerSettingsProps): React.J
- {t('settings.accessConnected')} - {access.authMethod === 'password' && access.passwordPersisted === false && ( - - {t('settings.passwordNotPersisted')} - - )} + + {t('settings.accessConnected')}: {access.username}@{server.host}:{server.port} + + + {t(accessSecretKey)} +
- -
+
+
- - ))} + ) + })} {servers.length === 0 && (
diff --git a/src/renderer/src/pages/server-access.ts b/src/renderer/src/pages/server-access.ts index 6d3f279..de2daf0 100644 --- a/src/renderer/src/pages/server-access.ts +++ b/src/renderer/src/pages/server-access.ts @@ -2,9 +2,13 @@ import type { SshAuthMethod } from '@shared/types' export interface SavedServerAccess { authMethod?: SshAuthMethod + username?: string | null + host?: string | null + port?: number | null passwordCredentialId?: string | null privateKeyCredentialId?: string | null privateKeyPersisted?: boolean | null + passwordPersisted?: boolean | null } /** Auto-connect only when a secret is known to live in the OS keychain. */ @@ -15,3 +19,32 @@ export function shouldAutoConnectServer(server: SavedServerAccess): boolean { } return false } + +/** Где сейчас лежит секрет доступа — ключ i18n для подписи в карточке сервера. */ +export type AccessSecretState = 'passwordSaved' | 'keySaved' | 'sessionOnly' | 'notSaved' + +export interface AccessSummary { + /** root@host:port — фактический SSH-endpoint входа. */ + endpoint: string + secret: AccessSecretState +} + +/** Компактное описание доступа для карточки сервера на дашборде. */ +export function accessSummary(server: SavedServerAccess): AccessSummary { + const endpoint = `${server.username ?? ''}@${server.host ?? ''}:${server.port ?? 22}` + if (server.authMethod === 'privateKey') { + const secret: AccessSecretState = !server.privateKeyCredentialId + ? 'notSaved' + : server.privateKeyPersisted === false + ? 'sessionOnly' + : 'keySaved' + return { endpoint, secret } + } + // password (default) + const secret: AccessSecretState = !server.passwordCredentialId + ? 'notSaved' + : server.passwordPersisted === false + ? 'sessionOnly' + : 'passwordSaved' + return { endpoint, secret } +} diff --git a/tests/unit/ui-contracts.test.ts b/tests/unit/ui-contracts.test.ts index 3c254c6..f7e4012 100644 --- a/tests/unit/ui-contracts.test.ts +++ b/tests/unit/ui-contracts.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { isSshAccessReady, setTypedSshPassword } from '../../src/renderer/src/components/SshAccessForm' import { buildDeployPayload, isDeployReady } from '../../src/renderer/src/pages/deploy-readiness' -import { shouldAutoConnectServer } from '../../src/renderer/src/pages/server-access' +import { shouldAutoConnectServer, accessSummary } from '../../src/renderer/src/pages/server-access' import type { SshAccessInput } from '../../src/shared/types' const readyAccess: SshAccessInput = { @@ -96,6 +96,42 @@ describe('deploy readiness', () => { expect(shouldAutoConnectServer({ authMethod: 'password' })).toBe(false) }) + it('summarizes where the saved SSH secret lives for the server card', () => { + expect( + accessSummary({ + username: 'root', + host: '203.0.113.10', + port: 22, + authMethod: 'password', + passwordCredentialId: 'pw1', + passwordPersisted: true + }) + ).toEqual({ endpoint: 'root@203.0.113.10:22', secret: 'passwordSaved' }) + expect( + accessSummary({ + username: 'ubuntu', + host: 'example.com', + port: 2222, + authMethod: 'privateKey', + privateKeyCredentialId: 'k1', + privateKeyPersisted: false + }) + ).toEqual({ endpoint: 'ubuntu@example.com:2222', secret: 'sessionOnly' }) + expect( + accessSummary({ username: 'root', host: 'h', port: 22, authMethod: 'password' }) + ).toEqual({ endpoint: 'root@h:22', secret: 'notSaved' }) + expect( + accessSummary({ + username: 'root', + host: 'h', + port: 22, + authMethod: 'privateKey', + privateKeyCredentialId: 'k1', + privateKeyPersisted: true + }) + ).toEqual({ endpoint: 'root@h:22', secret: 'keySaved' }) + }) + it('omits the email from the payload in without mode', () => { const payload = buildDeployPayload( { host: ' vps.example ', port: '2222', emailMode: 'without', email: 'leftover@x.io' }, From cb8bee0942ad6aa06f4c29ed4df95f75f8c642fa Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:18:23 +0300 Subject: [PATCH 27/35] =?UTF-8?q?ui:=20=D1=83=D0=B1=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=83=20=C2=AB?= =?UTF-8?q?=D0=A1=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5=20=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B8=C2=BB=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D0=B5=D0=B9=20?= =?UTF-8?q?=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Состояние установки и так показывается в консоли развёртывания/импорта, а в карточке остаётся компактный чип setup status. Дублирующая 4-строчная таблица только загроможждала страницу профилей. Данные diagnostics по-прежнему сохраняются при импорте и питают чип, удалён лишь их вывод в GUI. Вместе с ней удалены осиротевшие CSS-блоки diagnosticsCard/diagnosticsGrid и неиспользуемые ключи settings.diagnostics.* + статус-ключи доступа (заменены на dashboard.secret*). --- .../src/pages/ServerSettings.module.css | 67 ----------------- src/renderer/src/pages/ServerSettings.tsx | 75 +++---------------- 2 files changed, 9 insertions(+), 133 deletions(-) diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index b1a00fa..ce6638a 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -105,73 +105,6 @@ gap: 20px; } -.accessStatusCard { - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: 16px; - padding: 14px 18px; -} - -.accessStatusRow { - display: flex; - align-items: center; - gap: 12px; -} - -.accessStatusIcon { - flex-shrink: 0; - color: var(--success); -} - -.accessStatusText { - display: flex; - flex-direction: column; - gap: 3px; - min-width: 0; - flex: 1; -} - -.accessStatusText strong { - font-size: 14px; - font-weight: 600; - color: var(--text-0); -} - -.accessStatusMeta { - font-size: 12px; - line-height: 1.4; - color: var(--text-2); -} - -.accessStatusWarning { - font-size: 12px; - line-height: 1.4; - color: var(--warning); -} - -.diagnosticsCard { - display: flex; - flex-direction: column; - gap: 10px; - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: 16px; - padding: 18px 20px; -} - -.diagnosticsGrid { - display: grid; - grid-template-columns: auto 1fr auto 1fr; - gap: 6px 14px; - font-size: 13px; - color: var(--text-2); -} - -.diagnosticsGrid span:nth-child(odd) { - color: var(--text-1); - font-weight: 500; -} - .connectCard, .createCard, .listCard { diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index d5015ae..9404ad0 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -4,7 +4,6 @@ import { Settings2, Play, Trash2, - Power, Lock, CloudDownload, CloudOff, @@ -23,6 +22,8 @@ import styles from './ServerSettings.module.css' interface ServerSettingsProps { server: Server + /** Открыт по «Изменить доступ» с карточки сервера: показать форму, не автоподключаясь. */ + editingAccess?: boolean onBack: () => void } @@ -53,7 +54,11 @@ export const SNI_CATEGORIES = [ export const PORT_PRESETS = [443, 8443, 2053, 2083, 2087, 2096, 9443, 8080] as const -export function ServerSettings({ server, onBack }: ServerSettingsProps): React.JSX.Element { +export function ServerSettings({ + server, + editingAccess = false, + onBack +}: ServerSettingsProps): React.JSX.Element { const { t } = useTranslation() const [access, setAccess] = useState({ username: server.username || 'root', @@ -112,18 +117,6 @@ export function ServerSettings({ server, onBack }: ServerSettingsProps): React.J const connected = profiles !== null const accessReady = isSshAccessReady(access) - const accessSecretKey = - access.authMethod === 'password' - ? access.passwordPersisted === false - ? 'settings.passwordNotPersisted' - : access.passwordPersisted === true - ? 'settings.accessSecretPassword' - : 'settings.accessSecretNone' - : access.privateKeyCredentialId - ? access.privateKeyPersisted === false - ? 'settings.accessSecretSessionKey' - : 'settings.accessSecretKey' - : 'settings.accessSecretNone' const futureNames = useMemo(() => { const base = name.trim() || 'phone-1' @@ -171,6 +164,7 @@ export function ServerSettings({ server, onBack }: ServerSettingsProps): React.J useEffect(() => { if (autoConnectStarted.current) return autoConnectStarted.current = true + if (editingAccess) return if (shouldAutoConnectServer(server)) void load() }, [server.id]) @@ -419,17 +413,6 @@ export function ServerSettings({ server, onBack }: ServerSettingsProps): React.J const transportLabel = (profile: ServerProfile): string => profile.multi_route ? `${profile.transport} · ${profile.routes} ${t('settings.routes')}` : profile.transport - const reset = (): void => { - setProfiles(null) - setError(null) - setAccess((current) => ({ - ...current, - password: '', - passphrase: '', - sudoPassword: '' - })) - } - return (
@@ -475,47 +458,7 @@ export function ServerSettings({ server, onBack }: ServerSettingsProps): React.J
- {server.diagnostics && ( -
-

{t('settings.diagnostics.title')}

-
- {t('settings.diagnostics.manager')} - {t(`settings.diagnostics.${server.diagnostics.manager}`)} - {t('settings.diagnostics.xray')} - {t(`settings.diagnostics.${server.diagnostics.xray}`)} - {t('settings.diagnostics.profiles')} - {t(`settings.diagnostics.${server.diagnostics.profiles}`)} - {t('settings.diagnostics.subscription')} - {t(`settings.diagnostics.${server.diagnostics.subscription}`)} -
-
- )} - {connected ? ( -
-
- -
- - {t('settings.accessConnected')}: {access.username}@{server.host}:{server.port} - - - {t(accessSecretKey)} - -
- -
-
- ) : ( + {!connected && (

{t('settings.hint')} From 6fef5dadd6071927a73ab41c33e2c7f62f2f7e2b Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:18:23 +0300 Subject: [PATCH 28/35] =?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=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D1=80=D0=B0=D1=81=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D1=83?= =?UTF-8?q?=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=B0=20=D0=B8=20=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GUI-доки (EN/RU/ZH), спецификация и план описывают новую раскладку: сводка доступа и «Изменить доступ» — на карточке сервера, форма доступа в настройках только при отсутствии секрета или ошибке, таблицы «Состояние установки» в профилях больше нет. Убрано устаревшее «+ Add». --- docs/desktop-gui.md | 4 ++-- docs/ru/desktop-gui.md | 4 ++-- docs/superpowers/plans/2026-09-23-server-onboarding.md | 6 +++--- .../specs/2026-09-23-server-onboarding-design.md | 4 +++- docs/zh-CN/desktop-gui.md | 4 ++-- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/desktop-gui.md b/docs/desktop-gui.md index 9087050..bb82c6b 100644 --- a/docs/desktop-gui.md +++ b/docs/desktop-gui.md @@ -19,7 +19,7 @@ The renderer reaches privileged operations only through the narrow preload `cont ### Dashboard -Dashboard displays the saved server cards, a reachability status dot, an installation status chip (`Configured`, `Partially configured`, or `Imported`), location/OS and route metadata, and actions for keys, settings, and removing a local server card. On an empty Dashboard the operator chooses between two scenarios: **Deploy a new server** (install Xrayebator on a clean VPS) and **Connect an existing server** (find an installed Xrayebator over SSH and open its panel without touching the installation). With servers already present, `+ Add` opens the same choice. Its reachability check is a bounded TCP check performed by the Electron main process; it is not the server-side `probe-test` command. The language selector switches between `RU`, `EN`, and `中文`. +Dashboard displays the saved server cards, a reachability status dot, an installation status chip (`Configured`, `Partially configured`, or `Imported`), location/OS and route metadata, and actions for keys, settings, and removing a local server card. The right side of each card summarizes the saved access — `user@host:port` and where the secret lives (password or key in the system keychain, or this session only) — together with a **Change access** action that opens the access form for that card. On an empty Dashboard the operator chooses between two scenarios: **Deploy a new server** (install Xrayebator on a clean VPS) and **Connect an existing server** (find an installed Xrayebator over SSH and open its panel without touching the installation). With servers already present, `Add` opens the same choice. Its reachability check is a bounded TCP check performed by the Electron main process; it is not the server-side `probe-test` command. The language selector switches between `RU`, `EN`, and `中文`. ### Add server @@ -45,7 +45,7 @@ Server keys refreshes the subscription from the saved `subscription_url` and dis ### Server settings -Server settings first authenticates over SSH. When the card already has a keychain-backed SSH password or a persisted private key, the page attempts to connect automatically; after success the access form collapses to a compact "access confirmed" status with an explicit "Change access" action. Once connected, the page can: +Server settings first authenticates over SSH. When the card already has a keychain-backed SSH password or a persisted private key, the page attempts to connect automatically and then shows only the profile panel — the access form appears only when there is no saved secret or after a failed connection. The access summary and the "Change access" action live on the server card in the dashboard, not inside the profile page. Once connected, the page can: - list existing profiles; - create one or more profiles and delete profiles; diff --git a/docs/ru/desktop-gui.md b/docs/ru/desktop-gui.md index b722b39..8a38682 100644 --- a/docs/ru/desktop-gui.md +++ b/docs/ru/desktop-gui.md @@ -19,7 +19,7 @@ Renderer получает привилегированные операции т ### Dashboard -Dashboard показывает сохранённые карточки серверов, точку доступности, чип статуса установки («Настроен», «Настроен частично» или «Импортирован»), регион/ОС и количество маршрутов, а также действия для ключей, настроек и удаления локальной карточки сервера. На пустом Dashboard оператор выбирает один из двух сценариев: **Развернуть новый сервер** (установить Xrayebator на чистый VPS) и **Подключить существующий сервер** (найти установленный Xrayebator по SSH и открыть его панель, не затрагивая установку). Если серверы уже есть, `+ Add` открывает тот же выбор. Проверка доступности — ограниченная TCP-проверка в Electron main process; это не серверная команда `probe-test`. Переключатель языка выбирает `RU`, `EN` или `中文`. +Dashboard показывает сохранённые карточки серверов, точку доступности, чип статуса установки («Настроен», «Настроен частично» или «Импортирован»), регион/ОС и количество маршрутов, а также действия для ключей, настроек и удаления локальной карточки сервера. В правой части карточки выводится сводка доступа — `user@host:port` и где лежит секрет (пароль или ключ в системном хранилище, либо только на текущий сеанс) — вместе с действием **Изменить доступ**, которое открывает форму доступа для этой карточки. На пустом Dashboard оператор выбирает один из двух сценариев: **Развернуть новый сервер** (установить Xrayebator на чистый VPS) и **Подключить существующий сервер** (найти установленный Xrayebator по SSH и открыть его панель, не затрагивая установку). Если серверы уже есть, `Add` открывает тот же выбор. Проверка доступности — ограниченная TCP-проверка в Electron main process; это не серверная команда `probe-test`. Переключатель языка выбирает `RU`, `EN` или `中文`. ### Add server @@ -46,7 +46,7 @@ Server keys обновляет подписку по сохранённому `s ### Server settings -Server settings сначала аутентифицируется по SSH. Если у карточки уже есть SSH-пароль или persistent-ключ в системном keychain, страница подключается автоматически; после успеха форма доступа сворачивается в компактный статус «доступ подтверждён» с явным действием «Изменить доступ». После подключения страница позволяет: +Server settings сначала аутентифицируется по SSH. Если у карточки уже есть SSH-пароль или persistent-ключ в системном keychain, страница подключается автоматически и показывает только профильную панель; форма доступа появляется лишь когда сохранённого секрета нет или подключение не удалось. Сводка доступа и действие «Изменить доступ» живут на карточке сервера в дашборде, а не внутри страницы профилей. После подключения страница позволяет: - получить список существующих профилей; - создать один или несколько профилей и удалить профили; diff --git a/docs/superpowers/plans/2026-09-23-server-onboarding.md b/docs/superpowers/plans/2026-09-23-server-onboarding.md index 2c22c16..08b94f1 100644 --- a/docs/superpowers/plans/2026-09-23-server-onboarding.md +++ b/docs/superpowers/plans/2026-09-23-server-onboarding.md @@ -20,7 +20,7 @@ - Отдельный sudo-пароль и passphrase не сохранять; если sudo-пароль не задан, использовать SSH-пароль по существующему правилу. - Существующий host-key TOFU pinning не ослаблять: fingerprint закреплять только после успешной аутентификации, mismatch блокирует команды. - При недоступном keychain не использовать plaintext-фолбек; в этом режиме SSH-пароль придётся вводить заново после перезапуска. -- При открытии Server Settings автоматически подключаться по сохранённым credentials; после успеха скрывать форму доступа и показывать действие для её повторного открытия. +- При открытии Server Settings автоматически подключаться по сохранённым credentials и показывать сразу профильную панель; форма доступа — только при отсутствии секрета или ошибке подключения. - Удалённые команды строить через существующий `shellCommand`/`shellQuote`; JSON stdout server-side CLI не загрязнять статусами. - Все jq-изменения серверного `xrayebator` выполнять через существующие safe-write/rollback правила; read-only inspect не должен менять файлы. - Bash-комментарии и сообщения остаются на русском; identifiers и TypeScript API — на английском; UI переводится во все три locale-файла. @@ -753,7 +753,7 @@ Expected: FAIL until import helper/form exists. - passphrase не сохраняется; - SSH-пароль сохраняется в системный keychain после первого успешного входа и переиспользуется; отдельный sudo-пароль не сохраняется; - если keychain запись потеряна, предложить ввести доступ заново; -- при наличии сохранённого credential страница подключается автоматически, а после успеха форма доступа сворачивается в статус с кнопкой «Изменить доступ». +- при наличии сохранённого credential страница подключается автоматически и показывает профильную панель без формы доступа; сменить доступ можно с карточки сервера в дашборде. В `ServerKeys` при `subscriptionUrl === ''` показывать `keys.none`/diagnostic message вместо попытки fetch пустого URL. @@ -801,7 +801,7 @@ git commit -m "feat: добавить мастер импорта существ - partial → warning “Partially configured”; - unknown → neutral “Imported”. -В settings после подключения показать diagnostic list; не делать автоматических repair calls. Existing buttons update/uninstall remain explicit actions. +В settings после подключения показать только профильную панель; таблицу «Состояние установки» и статус-бокс доступа не показывать (сводка доступа — на карточке сервера). Не делать автоматических repair calls. Existing buttons update/uninstall remain explicit actions. - [ ] **Step 3: Update docs** diff --git a/docs/superpowers/specs/2026-09-23-server-onboarding-design.md b/docs/superpowers/specs/2026-09-23-server-onboarding-design.md index 1d1e5e4..a699246 100644 --- a/docs/superpowers/specs/2026-09-23-server-onboarding-design.md +++ b/docs/superpowers/specs/2026-09-23-server-onboarding-design.md @@ -60,7 +60,9 @@ xrayebator quickstart --without-email **Уточнение решения от 2026-09-23 после проверки Server Settings:** SSH-пароль после первой успешной аутентификации сохраняется в системном keychain ОС и далее переиспользуется; пароль не хранится в `electron-store` или renderer. Отдельный sudo-пароль и passphrase приватного ключа не сохраняются и запрашиваются при необходимости. При недоступном keychain plaintext-фолбека нет: после выхода из приложения SSH-пароль придётся ввести снова. -Server Settings автоматически пробует подключиться при открытии, если у карточки есть сохранённый keychain credential. После успешного подключения форма доступа скрывается, а профильная панель занимает её место; пользователь может явно вернуться к вводу доступа для смены credentials. +Server Settings автоматически пробует подключиться при открытии, если у карточки есть сохранённый keychain credential, и после успеха показывает только профильную панель. Форма доступа нужна лишь когда сохранённого секрета нет или подключение не удалось; открыть её для смены credentials можно с карточки сервера в дашборде («Изменить доступ»). + +**Уточнение решения от 2026-09-23 по карточке сервера:** сводка доступа (`user@host:port` и где лежит секрет: пароль/ключ в системном хранилище либо только на сеанс) и действие «Изменить доступ» вынесены в правую часть карточки сервера в дашборде. Из Server Settings убраны и статус-бокс доступа, и таблица «Состояние установки»: текущее состояние установки пользователю в GUI не нужно — оно и так показывается в консоли развёртывания/импорта, а на карточке остаётся компактный чип setup status. Данные `diagnostics` по-прежнему сохраняются при импорте и питают этот чип. ### Локальное хранение SSH-ключа diff --git a/docs/zh-CN/desktop-gui.md b/docs/zh-CN/desktop-gui.md index 2799748..67bd2e4 100644 --- a/docs/zh-CN/desktop-gui.md +++ b/docs/zh-CN/desktop-gui.md @@ -19,7 +19,7 @@ renderer 只能通过精简的 preload `contextBridge` API 访问特权操作。 ### Dashboard -Dashboard 显示已保存的服务器卡片、可达性状态点、安装状态标签(“已配置”“配置不完整”或“已导入”)、位置/OS 与线路元数据,以及密钥、设置和删除本地服务器卡片的操作。空 Dashboard 提供两个并列场景:**部署新服务器**(在干净 VPS 上安装 Xrayebator)和 **连接现有服务器**(通过 SSH 查找已安装的 Xrayebator 并打开面板,不改动现有安装)。已有服务器时,`+ Add` 会打开同样的场景选择。可达性检查由 Electron main process 执行,是有时间限制的 TCP 检查;它不是服务端的 `probe-test` 命令。语言选择器可以切换 `RU`、`EN` 和 `中文`。 +Dashboard 显示已保存的服务器卡片、可达性状态点、安装状态标签(“已配置”“配置不完整”或“已导入”)、位置/OS 与线路元数据,以及密钥、设置和删除本地服务器卡片的操作。每张卡片右侧显示访问摘要——`user@host:port` 以及凭据存放位置(密码或密钥保存在系统钥匙串中,或仅在本次会话中)——并带有 **更改访问方式** 操作,用于打开该卡片的访问表单。空 Dashboard 提供两个并列场景:**部署新服务器**(在干净 VPS 上安装 Xrayebator)和 **连接现有服务器**(通过 SSH 查找已安装的 Xrayebator 并打开面板,不改动现有安装)。已有服务器时,`Add` 会打开同样的场景选择。可达性检查由 Electron main process 执行,是有时间限制的 TCP 检查;它不是服务端的 `probe-test` 命令。语言选择器可以切换 `RU`、`EN` 和 `中文`。 ### Add server @@ -46,7 +46,7 @@ Server keys 会从保存的 `subscription_url` 刷新订阅,并显示返回的 ### Server settings -Server settings 先通过 SSH 认证。如果卡片中已有系统钥匙串保存的 SSH 密码或持久化私钥,页面会自动尝试连接;成功后访问表单收起为简洁的“访问已确认”状态,并提供显式的“更改访问方式”操作。连接后可以: +Server settings 先通过 SSH 认证。如果卡片中已有系统钥匙串保存的 SSH 密码或持久化私钥,页面会自动连接并只显示配置档面板;仅当没有已保存的凭据或连接失败时才显示访问表单。访问摘要与“更改访问方式”操作位于 Dashboard 的服务器卡片上,而不在配置档页面内。连接后可以: - 列出已有配置档; - 创建一个或多个配置档并删除配置档; From 5d188879523d820530f0ed818b8798aa7650f0d8 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:17:02 +0300 Subject: [PATCH 29/35] =?UTF-8?q?ui:=20=D0=BE=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5?= =?UTF-8?q?=20=D0=BA=D0=B0=D0=BA=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C,?= =?UTF-8?q?=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D1=83=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BA=D0=B0=D0=BD=D0=BE=D0=BD=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сводка доступа выглядела как обрывистый текст поверх карточки, а «Изменить доступ» — как синяя текстовая ссылка, выбиваясь из ряда «Ключи/Настройки». Теперь это отдельная панель (фон bg-1, рамка, radius-md, заголовок «Доступ» капителью) и обычная secondary-кнопка с иконкой карандаша, как соседние кнопки карточки. Подписи про секрет сокращены до одной строки (пароль/ключ сохранён в системе, только на этот сеанс, секрет не сохранён) — раньше они переносились на две строки и распирали панель. Правка в трёх локалях. Проверено предпросмотром собранного CSS: панель центрирует endpoint, подпись и кнопку по правому краю, обе карточки не разъезжаются. --- src/renderer/src/i18n/en.json | 9 +++-- src/renderer/src/i18n/ru.json | 9 +++-- src/renderer/src/i18n/zh.json | 9 +++-- src/renderer/src/pages/Dashboard.module.css | 24 ++++++++---- src/renderer/src/pages/Dashboard.tsx | 42 +++++++++++---------- 5 files changed, 54 insertions(+), 39 deletions(-) diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 8b0d21c..7879312 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -23,10 +23,11 @@ "settings": "Settings", "delete": "Remove from app", "changeAccess": "Change access", - "secretPasswordSaved": "password saved in the system — you won't be asked again when opening", - "secretKeySaved": "key saved in the system — sign-in is automatic", - "secretSessionOnly": "available only in this session: re-enter after a restart", - "secretNotSaved": "no secret saved: access is requested every time", + "accessTitle": "Access", + "secretPasswordSaved": "password saved in the system", + "secretKeySaved": "key saved in the system", + "secretSessionOnly": "this session only", + "secretNotSaved": "no secret saved", "back": "Back", "confirmDeleteTitle": "Remove server from the app?", "confirmDeleteBody": "Server «{{name}}» will be removed from the app along with its keys. The installation on the server itself remains untouched.", diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index 2c01e91..78450a4 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -24,10 +24,11 @@ "settings": "Настройки", "delete": "Удалить из приложения", "changeAccess": "Изменить доступ", - "secretPasswordSaved": "пароль сохранён в системе — при открытии запрашиваться не будет", - "secretKeySaved": "ключ сохранён в системе — вход выполняется автоматически", - "secretSessionOnly": "доступ только в этом сеансе: после перезапуска введите заново", - "secretNotSaved": "секрет не сохранён: доступ вводится при каждом открытии", + "accessTitle": "Доступ", + "secretPasswordSaved": "пароль сохранён в системе", + "secretKeySaved": "ключ сохранён в системе", + "secretSessionOnly": "только на этот сеанс", + "secretNotSaved": "секрет не сохранён", "back": "Назад", "confirmDeleteTitle": "Удалить сервер из приложения?", "confirmDeleteBody": "Сервер «{{name}}» будет удалён из приложения вместе с ключами. Установка на самом сервере останется нетронутой.", diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index 57ac473..cf9cac6 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -22,10 +22,11 @@ "settings": "设置", "delete": "从应用移除", "changeAccess": "更改访问方式", - "secretPasswordSaved": "密码已保存到系统——下次打开时无需再输入", - "secretKeySaved": "密钥已保存到系统——将自动登录", - "secretSessionOnly": "仅在本次会话中可用:重启后需重新输入", - "secretNotSaved": "未保存凭据:每次打开时需重新输入", + "accessTitle": "访问", + "secretPasswordSaved": "密码已保存到系统", + "secretKeySaved": "密钥已保存到系统", + "secretSessionOnly": "仅本次会话有效", + "secretNotSaved": "未保存凭据", "back": "返回", "confirmDeleteTitle": "从应用中移除服务器?", "confirmDeleteBody": "服务器「{{name}}」将从应用中删除,连同其密钥。服务器上的安装不会受影响。", diff --git a/src/renderer/src/pages/Dashboard.module.css b/src/renderer/src/pages/Dashboard.module.css index c4163aa..669cd0e 100644 --- a/src/renderer/src/pages/Dashboard.module.css +++ b/src/renderer/src/pages/Dashboard.module.css @@ -204,15 +204,28 @@ display: flex; flex-direction: column; align-items: flex-end; - gap: 5px; + gap: 6px; flex-shrink: 0; text-align: right; + min-width: 250px; + padding: 12px 14px; + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); +} + +.accessTitle { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-2); } .accessEndpoint { font-family: var(--font-mono); font-size: 13px; - color: var(--text-1); + color: var(--text-0); word-break: break-all; } @@ -223,7 +236,7 @@ font-size: 12px; line-height: 1.4; color: var(--text-2); - max-width: 340px; + white-space: nowrap; } .accessSecretWarn { @@ -236,10 +249,7 @@ } .accessEditBtn { - padding: 2px 6px; - color: var(--accent); - font-size: 12px; - height: auto; + margin-top: 2px; } .onboardingGrid { diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index 967918c..e071d66 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -15,7 +15,8 @@ import { Check, Rocket, Link2, - Lock + Lock, + Pencil } from 'lucide-react' import { useTranslation } from 'react-i18next' import { setLanguage, supportedLngs, type SupportedLng } from '../i18n' @@ -186,6 +187,7 @@ export function Dashboard({

+
{t('dashboard.accessTitle')}
{summary.endpoint}
- {t(SECRET_I18N[summary.secret])} + {t(SECRET_I18N[summary.secret])}
- - - + + +
) From 5bfbfca03cf9a82f537a1f87934bfb4443081c25 Mon Sep 17 00:00:00 2001 From: Ap3x0 <82464229+Ap3x0s@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:10:29 +0300 Subject: [PATCH 30/35] =?UTF-8?q?ui:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=B8=D1=82=D1=8C=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D1=83=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=D1=86=D1=83=20?= =?UTF-8?q?=D0=B8=20=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D1=88=D0=BA=D1=83=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раскладка карточки приведена к виду, который пользователь показал как эталон: заголовок с локацией под ним, ряд из трёх плиток с иконками (ОС, активные маршруты, SSH-доступ) и ряд кнопок внизу. Действие «Изменить доступ» убрано с вида — оно живёт в меню из трёх точек в плитке доступа, как на образце. - плитки ОС/маршруты/доступ: иконка + значение + подпись, все данные уже приходят из бэкенда (os и profile_count из inspect, routesCount и username из карточки), ничего не зашито под Ubuntu/один маршрут/root; - новые ключи dashboard.statOs/statRoutes в трёх локалях (иначе i18next рисовал сырые ключи — это было видно на скриншоте пользователя); - статус установки: плашка-чип заменена на обычный текст зелёным (--success, тот же цвет, что у точки онлайн), «Настроен частично» — жёлтым; - удалены осиротевшие стили прежней панели доступа и неиспользуемый импорт Chip. --- src/renderer/src/i18n/en.json | 2 + src/renderer/src/i18n/ru.json | 2 + src/renderer/src/i18n/zh.json | 2 + src/renderer/src/pages/Dashboard.module.css | 178 ++++++++++++-------- src/renderer/src/pages/Dashboard.tsx | 114 ++++++++----- 5 files changed, 190 insertions(+), 108 deletions(-) diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 7879312..e1f6a32 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -29,6 +29,8 @@ "secretSessionOnly": "this session only", "secretNotSaved": "no secret saved", "back": "Back", + "statOs": "Operating system", + "statRoutes": "Active routes", "confirmDeleteTitle": "Remove server from the app?", "confirmDeleteBody": "Server «{{name}}» will be removed from the app along with its keys. The installation on the server itself remains untouched.", "cancel": "Cancel" diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index 78450a4..ea1a3ec 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -30,6 +30,8 @@ "secretSessionOnly": "только на этот сеанс", "secretNotSaved": "секрет не сохранён", "back": "Назад", + "statOs": "Операционная система", + "statRoutes": "Активных маршрутов", "confirmDeleteTitle": "Удалить сервер из приложения?", "confirmDeleteBody": "Сервер «{{name}}» будет удалён из приложения вместе с ключами. Установка на самом сервере останется нетронутой.", "cancel": "Отмена" diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index cf9cac6..e4c80d8 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -28,6 +28,8 @@ "secretSessionOnly": "仅本次会话有效", "secretNotSaved": "未保存凭据", "back": "返回", + "statOs": "操作系统", + "statRoutes": "活跃线路", "confirmDeleteTitle": "从应用中移除服务器?", "confirmDeleteBody": "服务器「{{name}}」将从应用中删除,连同其密钥。服务器上的安装不会受影响。", "cancel": "取消" diff --git a/src/renderer/src/pages/Dashboard.module.css b/src/renderer/src/pages/Dashboard.module.css index 669cd0e..9513f77 100644 --- a/src/renderer/src/pages/Dashboard.module.css +++ b/src/renderer/src/pages/Dashboard.module.css @@ -121,17 +121,128 @@ .card { width: 100%; + display: flex; + flex-direction: column; + gap: 14px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 16px; padding: 18px; } +.cardHead { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + .cardHeader { display: flex; align-items: center; gap: 14px; - margin-bottom: 14px; + min-width: 0; +} + +.cardLocation { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-2); +} + +.cardLocationIcon { + flex-shrink: 0; + color: var(--accent); +} + +.cardStats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.statCard { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + padding: 12px 14px; + background: color-mix(in srgb, var(--text-1) 6%, var(--bg-2)); + border: 1px solid var(--border); + border-radius: var(--radius-md); +} + +.statIcon { + flex-shrink: 0; + color: var(--accent); +} + +.statText { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.statValue { + font-size: 14px; + font-weight: 600; + color: var(--text-0); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.statLabel { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--text-2); +} + +.statMenuBtn { + margin-left: auto; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + color: var(--text-2); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.statMenuBtn:hover { + background: color-mix(in srgb, var(--text-1) 12%, transparent); + color: var(--text-0); +} + +/* Статус установки — без плашки, цветом текста как у точки доступности. */ +.setupBadge { + flex-shrink: 0; + font-size: 13px; + font-weight: 600; + white-space: nowrap; +} + +.setupBadgeReady { + color: var(--success); +} + +.setupBadgePartial { + color: var(--warning); +} + +.setupBadgeDefault { + color: var(--text-2); } .cardInfo { @@ -187,71 +298,6 @@ gap: 8px; } -.cardTop { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - margin-bottom: 14px; -} - -.cardHeader { - margin-bottom: 0; - min-width: 0; -} - -.cardAccess { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 6px; - flex-shrink: 0; - text-align: right; - min-width: 250px; - padding: 12px 14px; - background: var(--bg-1); - border: 1px solid var(--border); - border-radius: var(--radius-md); -} - -.accessTitle { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-2); -} - -.accessEndpoint { - font-family: var(--font-mono); - font-size: 13px; - color: var(--text-0); - word-break: break-all; -} - -.accessSecret { - display: inline-flex; - align-items: center; - gap: 5px; - font-size: 12px; - line-height: 1.4; - color: var(--text-2); - white-space: nowrap; -} - -.accessSecretWarn { - color: var(--warning); -} - -.accessSecretIcon { - flex-shrink: 0; - opacity: 0.8; -} - -.accessEditBtn { - margin-top: 2px; -} - .onboardingGrid { display: grid; grid-template-columns: 1fr; diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index e071d66..b4b915c 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react' import { Button, - Chip, AlertDialog, Dropdown, DropdownItem @@ -16,7 +15,11 @@ import { Rocket, Link2, Lock, - Pencil + MapPin, + MonitorCog, + Route, + User, + EllipsisVertical } from 'lucide-react' import { useTranslation } from 'react-i18next' import { setLanguage, supportedLngs, type SupportedLng } from '../i18n' @@ -151,7 +154,7 @@ export function Dashboard({ const summary = accessSummary(server) return (
-
+
{server.name}
-
- - {t(`dashboard.setup.${server.setupStatus ?? 'unknown'}`)} - - - {server.country || '—'} - - {server.city && {server.city}} - - {server.os ?? '—'} - - - {t('dashboard.routes', { count: server.routesCount ?? 0 })} - +
+ + {[server.country, server.city].filter(Boolean).join(' · ') || '—'}
-
-
{t('dashboard.accessTitle')}
-
{summary.endpoint}
-
- - {t(SECRET_I18N[summary.secret])} + + {t(`dashboard.setup.${server.setupStatus ?? 'unknown'}`)} + +
+ +
+
+ +
+ {server.os ?? '—'} + {t('dashboard.statOs')}
- +
+
+ +
+ {server.routesCount ?? 0} + {t('dashboard.statRoutes')} +
+
+
+ +
+ {summary.endpoint} + + + {t(SECRET_I18N[summary.secret])} + +
+ + + + + + + onEditAccess(server)} + > + + + {t('dashboard.changeAccess')} + + + + +
+