diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ca486b..b9a1788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ User-facing Xrayebator changes. The server manager and Electron application are published from the canonical `howdeploy/Xrayebator` repository. +## [Unreleased] + +### Added + +- Optional email during deployment: `quickstart --without-email` registers Certbot with `--register-unsafely-without-email` instead of substituting a fake address; the GUI offers both modes and explains that renewal notices and ACME account recovery are unavailable without an email. +- Separate "deploy a new server" and "connect an existing server" flows. Import is strictly read-only (`xrayebator inspect --json`), recognizes Xrayebator installations only, and imports a partially configured server with honest component statuses. +- SSH login password is stored in the operating-system keychain after the first successful authentication and reused across restarts; the server card keeps only its non-secret credential id. +- Server card shows a summary of the saved access (`user@host:port`, where the secret lives) with a three-dot menu to change it, plus the reported OS, active route count and SSH user as icon tiles. +- The import wizard shows a live console of the work performed, alongside the step index. + +### Changed + +- Server Settings auto-connects with saved credentials and shows the profile panel directly; the access form appears only when there is no saved secret or a connection failed. The installation-status table was removed as a duplicate of the deployment and import consoles. +- The subscription token is masked in the deployment log and import console, because it is a bearer credential. + +### Fixed + +- `quickstart` no longer fails with `apt-get install nginx failed` when `unattended-upgrades` holds the apt/dpkg lock: every install waits for an active `unattended-upgrade` worker within a 12-minute budget and passes `-o DPkg::Lock::Timeout=180`. +- Bash validation scripts no longer die with `tr: write error: Broken pipe` on Ubuntu (`pipefail` plus an early-exiting reader). + ## [0.5.0] - 2026-09-22 The first combined release of the updated server manager and **Xrayebator Desktop GUI**. diff --git a/CLAUDE.md b/CLAUDE.md index 007effd..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/`** — 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/`** — 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. @@ -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 60fad7a..4d282ff 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 (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 | -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 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 @@ -314,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 17162ea..6186c4b 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. +Выбранные приватный ключ и успешно использованный 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 и логи/статус сервисов остаются терминальными операциями. Полная @@ -309,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 ace96fe..488cb65 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 吊销订阅。 +选中的私钥与成功登录时使用过的 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 边界、安全模型与打包说明见 @@ -296,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/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..2c6add2 100644 --- a/docs/desktop-gui.md +++ b/docs/desktop-gui.md @@ -19,29 +19,35 @@ 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. Each card shows the title with its location beneath, a status line (`Configured` in green, `Partially configured` in yellow, or `Imported`), and a row of three tiles — operating system, active routes, and SSH access (`user@host:port` plus where the secret lives: password or key in the system keychain, or this session only). The tile values come from the server card and the read-only inspection, so the OS, route count and SSH user are whatever the server actually reports. A three-dot menu in the access tile opens the access form for that card. Actions for keys, settings, and removing the card sit at the bottom. 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 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. + +The wizard shows a step index and a live console of the work actually performed: the SSH connect, the `xrayebator inspect --json` call, the reported component statuses, the subscription probe and the result. The subscription URL is a bearer credential, so its token is masked (`…`) before it reaches the console; passwords and key bytes never appear there at all. + ### Server keys Server keys refreshes the subscription from the saved `subscription_url` and displays the returned VLESS routes. Each VLESS link can be copied or rendered as a QR code; the subscription URL can also be copied, and the page offers a copy-all action. This page does not create a separate server-side subscription or rotate a subscription token. ### Server 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 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; @@ -56,11 +62,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. +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: @@ -88,6 +92,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 +130,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..4a742af 100644 --- a/docs/ru/desktop-gui.md +++ b/docs/ru/desktop-gui.md @@ -19,29 +19,36 @@ Renderer получает привилегированные операции т ### Dashboard -Dashboard показывает сохранённые карточки серверов, точку доступности, регион/ОС и количество маршрутов, а также действия для ключей, настроек и удаления локальной карточки сервера. Проверка доступности — ограниченная TCP-проверка в Electron main process; это не серверная команда `probe-test`. Переключатель языка выбирает `RU`, `EN` или `中文`. +Dashboard показывает сохранённые карточки серверов. В карточке: заголовок с локацией под ним, строка статуса («Настроен» зелёным, «Настроен частично» жёлтым, «Импортирован» серым) и ряд из трёх плиток — операционная система, активные маршруты и SSH-доступ (`user@host:port` и где лежит секрет: пароль или ключ в системном хранилище, либо только на текущий сеанс). Значения плиток берутся из карточки и read-only диагностики, поэтому ОС, число маршрутов и SSH-пользователь — те, что реально сообщает сервер. Меню из трёх точек в плитке доступа открывает форму доступа для этой карточки. Действия для ключей, настроек и удаления карточки — внизу. На пустом 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-доступа, что и страница добавления нового сервера: пароль или приватный ключ (ключ сохраняется в системном keychain и переиспользуется). GUI выполняет по SSH `xrayebator inspect --json` — это read-only диагностика, которая распознаёт только Xrayebator. Импорт не запускает установщик, `quickstart`, `happ-setup`, миграции, обновление, перезапуск служб, изменение firewall или конфигурации. Частичная установка всё равно добавляется с отдельными статусами менеджера, Xray, профилей и подписки; локальная или недоступная подписка не выдаётся за рабочую. Повторный импорт того же `host + port` обновляет существующую карточку, не меняя её id и host-key pin. После импорта открывается Server settings. + +Мастер показывает индекс шагов и живую консоль реально выполненной работы: SSH-подключение, вызов `xrayebator inspect --json`, полученные статусы компонентов, проверку подписки и итог. URL подписки — bearer credential, поэтому его токен маскируется (`…`) до попадания в консоль; пароли и байты ключей туда не попадают вовсе. ### Server keys Server keys обновляет подписку по сохранённому `subscription_url` и показывает полученные VLESS-маршруты. Каждый VLESS-ключ можно скопировать или показать как QR-код; URL подписки также копируется, есть действие «скопировать всё». Эта страница не создаёт отдельную подписку на сервере и не меняет токен подписки. ### Server settings -Server settings сначала аутентифицируется по SSH, после чего позволяет: +Server settings сначала аутентифицируется по SSH. Если у карточки уже есть SSH-пароль или persistent-ключ в системном keychain, страница подключается автоматически и показывает только профильную панель; форма доступа появляется лишь когда сохранённого секрета нет или подключение не удалось. Сводка доступа и действие «Изменить доступ» живут на карточке сервера в дашборде, а не внутри страницы профилей. После подключения страница позволяет: - получить список существующих профилей; - создать один или несколько профилей и удалить профили; @@ -56,11 +63,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-пароль сохраняется в системный 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 включает следующие меры защиты: @@ -84,10 +89,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 +131,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..d798e0b 100644 --- a/docs/ru/security.md +++ b/docs/ru/security.md @@ -40,7 +40,9 @@ lifecycle-путь вызывает `safe_restart_xray`. ## Безопасность подписки URL подписки — bearer-credential. Он не публичен, но любой, кто получил полный URL, может скачать -список маршрутов и защищённые токеном ресурсы подписки. +список маршрутов и защищённые токеном ресурсы подписки. Поэтому desktop-GUI маскирует токен (`…`) +до того, как URL попадёт в любую из консолей — в лог развёртывания и в консоль мастера импорта; +пароли и байты приватного ключа туда не пишутся вовсе. Что уже сделано на стороне сервера: @@ -104,21 +106,14 @@ TCPKeepAlive yes ## Пароли в десктоп-GUI -Активный Electron-GUI поддерживает SSH по паролю и приватному ключу, с прямым root или sudo. Пароли, -sudo-пароли, passphrase и содержимое приватного ключа живут только в активной форме/операции и не -сохраняются. Приватный ключ читается только после выбора через нативный файловый dialog Electron. +Активный Electron-GUI поддерживает SSH по паролю и приватному ключу, с прямым root или sudo. Выбранный через нативный dialog приватный ключ main process сохраняет через `keytar` в системное хранилище ОС (Windows Credential Manager, macOS Keychain или Linux Secret Service). Поэтому ключ можно повторно использовать для SSH-операций и после перезапуска приложения. Renderer получает только несекретный credential id и отображаемое имя файла; байты приватного ключа не переходят через preload boundary. -GUI сохраняет метаданные сервера, необходимые для возврата к серверу: хост, SSH-порт, -имя пользователя, способ аутентификации, режим привилегий и выбранный путь к ключу. Он также -сохраняет настройки, `subscription_url`, полученные `vless://`-ссылки и SHA-256 pin SSH host key. -URL подписки и VLESS-ссылки — bearer-credentials, поэтому защищайте локальные данные Electron-приложения -и отзывайте подписку при утечке. +Если системный keychain недоступен, plaintext-фолбека на диске нет: ключ остаётся только в памяти main process до выхода из приложения, а GUI предупреждает, что после перезапуска ключ придётся выбрать снова. SSH-пароль сохраняется в системный keychain только после первого успешного входа и далее переиспользуется; отдельный sudo-пароль и passphrase зашифрованного ключа не сохраняются и вводятся заново при необходимости. -SSH host key работает по TOFU после первой успешной аутентификации. Fingerprint затем закрепляется; при -последующем несовпадении подключение останавливается до выполнения команд. После осознанной переустановки -VPS явно сбросьте pin в Server Settings и подтвердите новый ключ при следующем успешном подключении. +`electron-store` сохраняет карточку сервера и настройки подключения, credential id и имя ключа, диагностику установки, `subscription_url`, полученные `vless://`-ссылки и SHA-256 pin SSH host key. URL подписки и VLESS-ссылки — bearer-credentials: защищайте локальные данные Electron-приложения и отзывайте подписку при утечке. При удалении последней карточки, ссылающейся на credential, соответствующая запись keychain удаляется; общая запись остаётся, пока на неё ссылается другая карточка. -`keytar` указан в `package.json`, но активный Electron-GUI не использует его для хранения SSH-паролей -или passphrase в системном keychain. Секреты остаются session-only. +Импорт по 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/ru/testing.md b/docs/ru/testing.md index ec585b3..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/` лежат 24 статических и локальных регрессионных теста: +В `validation/` лежат 26 статических и локальных регрессионных тестов: | Тест | Что проверяет | |---|---| @@ -40,6 +40,8 @@ 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 | | `test-audit-functional.sh` | Функциональные regression-проверки аудита HowDeploy (P0/P1) | @@ -82,19 +84,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` всех скриптов + все 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/security.md b/docs/security.md index 6175c7d..86f3d6e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -41,7 +41,9 @@ endpoint/service rather than assuming that a successful script exit proves every ## Subscription security The subscription URL is a bearer credential. It is not public, but anyone holding the full URL can -download the route list and the token-protected subscription resources. +download the route list and the token-protected subscription resources. The desktop GUI therefore +masks the token (`…`) before the URL can appear in either console — the deployment log and the import +wizard console; passwords and private-key bytes are never written there. Already handled server-side: @@ -108,23 +110,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. + +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 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 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 +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/superpowers/plans/2026-09-23-server-onboarding.md b/docs/superpowers/plans/2026-09-23-server-onboarding.md new file mode 100644 index 0000000..08b94f1 --- /dev/null +++ b/docs/superpowers/plans/2026-09-23-server-onboarding.md @@ -0,0 +1,919 @@ +# Расширенное добавление серверов — план реализации + +> **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 или логи. +- SSH-пароль после успешной аутентификации сохранять в системном keychain и повторно использовать; не записывать пароль в `electron-store` или renderer. +- Отдельный sudo-пароль и passphrase не сохранять; если sudo-пароль не задан, использовать SSH-пароль по существующему правилу. +- Существующий host-key TOFU pinning не ослаблять: fingerprint закреплять только после успешной аутентификации, mismatch блокирует команды. +- При недоступном keychain не использовать plaintext-фолбек; в этом режиме SSH-пароль придётся вводить заново после перезапуска. +- При открытии Server Settings автоматически подключаться по сохранённым credentials и показывать сразу профильную панель; форма доступа — только при отсутствии секрета или ошибке подключения. +- Удалённые команды строить через существующий `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` и `passwordCredentialId` в access state. Для старых server cards разрешить path fallback только после native dialog approval. При `load`, `create`, `update`, `uninstall`, profile actions использовать saved credential id и не требовать повторного выбора файла/пароля. + +Обновить note: + +- ключ хранится в системном keychain; +- passphrase не сохраняется; +- SSH-пароль сохраняется в системный keychain после первого успешного входа и переиспользуется; отдельный sudo-пароль не сохраняется; +- если keychain запись потеряна, предложить ввести доступ заново; +- при наличии сохранённого credential страница подключается автоматически и показывает профильную панель без формы доступа; сменить доступ можно с карточки сервера в дашборде. + +В `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 после подключения показать только профильную панель; таблицу «Состояние установки» и статус-бокс доступа не показывать (сводка доступа — на карточке сервера). Не делать автоматических 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 for the private key and the successful SSH login password, no plaintext fallback, sudo-пароль/passphrase session-only, auto-connect plus access-form collapse; +- 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. 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..2e863b5 --- /dev/null +++ b/docs/superpowers/specs/2026-09-23-server-onboarding-design.md @@ -0,0 +1,328 @@ +# Дизайн расширенного добавления серверов в 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-аутентификация + +Для импорта существующего сервера GUI предлагает те же способы доступа, что и развёртывание нового: пароль или приватный SSH-ключ. (Первоначальная редакция спецификации ограничивала импорт только ключом; по итогам ручного теста пользователь потребовал тот же выбор пароль/ключ, что и на странице первого добавления. Read-only характер импорта от этого не меняется.) + +**Уточнение решения от 2026-09-23 после проверки Server Settings:** SSH-пароль после первой успешной аутентификации сохраняется в системном keychain ОС и далее переиспользуется; пароль не хранится в `electron-store` или renderer. Отдельный sudo-пароль и passphrase приватного ключа не сохраняются и запрашиваются при необходимости. При недоступном keychain plaintext-фолбека нет: после выхода из приложения SSH-пароль придётся ввести снова. + +Server Settings автоматически пробует подключиться при открытии, если у карточки есть сохранённый keychain credential, и после успеха показывает только профильную панель. Форма доступа нужна лишь когда сохранённого секрета нет или подключение не удалось; открыть её для смены credentials можно с карточки сервера в дашборде («Изменить доступ»). + +**Уточнение решения от 2026-09-23 по карточке сервера:** карточка переложена по образцу, который пользователь дал как эталон: заголовок с локацией, строка статуса цветом (зелёный «Настроен» — тот же --success, что у точки онлайн; жёлтый для частичной установки; серый для импортированной) и ряд из трёх плиток с иконками — ОС, активные маршруты, SSH-доступ. Действие смены credentials живёт в меню из трёх точек в плитке доступа, а не отдельной кнопкой. Значения плиток (ОС, число маршрутов, SSH-пользователь) определяются из карточки и read-only диагностики, а не зашиты под Ubuntu/один маршрут/root. Из Server Settings убраны и статус-бокс доступа, и таблица «Состояние установки»: текущее состояние установки пользователю в GUI не нужно — оно и так показывается в консоли развёртывания/импорта. Данные `diagnostics` по-прежнему сохраняются при импорте и питают статус на карточке. + +### Локальное хранение 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. diff --git a/docs/testing.md b/docs/testing.md index b10a95b..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 24 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,8 @@ 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 | | `test-sni-change-cli.sh` | SNI-change JSON output, transport fields, profile sync and rollback | @@ -104,20 +106,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 +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 24 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/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..5c52b48 100644 --- a/docs/zh-CN/desktop-gui.md +++ b/docs/zh-CN/desktop-gui.md @@ -19,29 +19,36 @@ renderer 只能通过精简的 preload `contextBridge` API 访问特权操作。 ### Dashboard -Dashboard 显示已保存的服务器卡片、可达性状态点、位置/OS 与线路元数据,以及密钥、设置和删除本地服务器卡片的操作。可达性检查由 Electron main process 执行,是有时间限制的 TCP 检查;它不是服务端的 `probe-test` 命令。语言选择器可以切换 `RU`、`EN` 和 `中文`。 +Dashboard 显示已保存的服务器卡片。每张卡片包含:标题及其下方的位置、状态行(“已配置”为绿色、“配置不完整”为黄色、“已导入”为灰色),以及三个信息块——操作系统、活跃线路和 SSH 访问(`user@host:port` 以及凭据存放位置:密码或密钥保存在系统钥匙串中,或仅在本次会话中)。信息块的取值来自服务器卡片和只读诊断,因此操作系统、线路数量和 SSH 用户都是服务器实际报告的值。访问信息块中的三点菜单可打开该卡片的访问表单。密钥、设置和删除卡片位于底部。空 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 访问方式:密码或私钥(私钥通过 `keytar` 保存在系统钥匙串中并可复用)。GUI 通过 SSH 执行只读命令 `xrayebator inspect --json`,仅识别 Xrayebator 安装;不会自动运行安装程序、`quickstart`、`happ-setup`、迁移、更新、服务重启、防火墙修改或配置变更。部分配置的安装仍会导入,并分别显示 manager、Xray、配置档和订阅状态;仅本地或不可达的订阅不会被标记为可用。再次导入相同的 `host + port` 会更新原卡片,不会产生重复项,并保留 server id 和 host-key pin。成功导入后会直接打开 Server settings。 + +向导显示步骤索引和实际执行工作的实时控制台:SSH 连接、`xrayebator inspect --json` 调用、返回的组件状态、订阅探测和最终结果。订阅 URL 是 bearer credential,因此其令牌在进入控制台前会被遮蔽(`…`);密码和密钥字节完全不会出现在其中。 ### Server keys Server keys 会从保存的 `subscription_url` 刷新订阅,并显示返回的 VLESS 线路。每条 VLESS 链接都可以复制或生成二维码;订阅 URL 也可以复制,页面还提供复制全部内容的操作。此页面不会在服务器上创建独立订阅,也不会轮换订阅令牌。 ### Server settings -Server settings 先通过 SSH 认证,然后可以: +Server settings 先通过 SSH 认证。如果卡片中已有系统钥匙串保存的 SSH 密码或持久化私钥,页面会自动连接并只显示配置档面板;仅当没有已保存的凭据或连接失败时才显示访问表单。访问摘要与“更改访问方式”操作位于 Dashboard 的服务器卡片上,而不在配置档页面内。连接后可以: - 列出已有配置档; - 创建一个或多个配置档并删除配置档; @@ -56,11 +63,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 登录密码在首次成功认证后保存到系统钥匙串,之后的操作和应用重启均可复用;服务器卡片只保存非敏感 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 边界包含以下保护措施: @@ -84,10 +89,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 +131,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..294eecc 100644 --- a/docs/zh-CN/security.md +++ b/docs/zh-CN/security.md @@ -36,7 +36,8 @@ Xray 以系统用户 `xray` 运行。Drop-in ## 订阅安全 订阅 URL 是 bearer credential。它不是公开信息,但持有完整 URL 的任何人都可以下载线路列表和 -受令牌保护的订阅资源。 +受令牌保护的订阅资源。因此桌面 GUI 会在 URL 进入任何控制台(部署日志和导入向导控制台)之前 +遮蔽令牌(`…`);密码和私钥字节完全不会写入其中。 服务端已经处理: @@ -98,19 +99,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/docs/zh-CN/testing.md b/docs/zh-CN/testing.md index 6e0783f..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/` 中有 24 个静态与本地回归测试: +`validation/` 中有 26 个静态与本地回归测试: | 测试 | 检查内容 | |---|---| @@ -39,6 +39,8 @@ 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 | | `test-audit-functional.sh` | HowDeploy 审计(P0/P1)的功能回归检查 | @@ -79,19 +81,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`,并运行全部 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 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/src/main/core/deployer.ts b/src/main/core/deployer.ts index 8d9a8cd..c69aa63 100644 --- a/src/main/core/deployer.ts +++ b/src/main/core/deployer.ts @@ -4,13 +4,15 @@ 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 { maskSubscriptionUrl } from './server-inspector' +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 +74,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 +95,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 +159,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) { @@ -172,7 +186,9 @@ export class Deployer { if (!keys.length && subUrl) { throw new Error('Subscription вернул пустой список ключей') } - this.onLog(`Подписка: ${subUrl}; маршрутов получено: ${keys.length}`) + this.onLog( + `Подписка: ${subUrl ? maskSubscriptionUrl(subUrl) : '—'}; маршрутов получено: ${keys.length}` + ) return { subscriptionUrl: subUrl ?? '', diff --git a/src/main/core/server-inspector.ts b/src/main/core/server-inspector.ts new file mode 100644 index 0000000..b79542b --- /dev/null +++ b/src/main/core/server-inspector.ts @@ -0,0 +1,184 @@ +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 подписки — 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() ?? '' + 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-команд. + * onLog получает строки консоли (пароли/токены маскируются до отправки). + */ +export class ServerInspector { + constructor( + private readonly creds: SshCredentials, + 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 + }) + 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)}` + ) + } + this.onLog(`inspect: код ${res.code}, менеджер ${snapshot.manager}, Xray ${snapshot.xray}, профилей ${snapshot.profile_count}`) + if (!snapshot.ok) { + throw new Error(snapshot.error ?? 'Xrayebator на сервере не распознан') + } + + const publicUrl = subscriptionProbeTarget(snapshot) + let probe: SubscriptionProbe = null + if (publicUrl) { + this.onLog(`subscription: $ curl ${maskSubscriptionUrl(publicUrl)}`) + try { + const keys = await this.probeSubscription(publicUrl) + 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 не обнаружен — пропускаю проверку') + } + 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/core/servers.ts b/src/main/core/servers.ts index cc95c4d..89fc41c 100644 --- a/src/main/core/servers.ts +++ b/src/main/core/servers.ts @@ -17,14 +17,28 @@ export interface ServerConnectionMetadata { authMethod: SshAuthMethod privilegeMode: SshPrivilegeMode privateKeyPath?: string | null + privateKeyCredentialId?: string | null + privateKeyName?: string | null + privateKeyPersisted?: boolean | null + passwordCredentialId?: string | null + passwordPersisted?: boolean | 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 + countPasswordCredentialReferences: (credentialId: string, exceptId?: string) => number + clearPasswordCredentialReference: (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 +55,13 @@ 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, + privateKeyPersisted: server.privateKeyPersisted ?? null, + passwordCredentialId: server.passwordCredentialId ?? null, + passwordPersisted: server.passwordPersisted ?? 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 +85,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 +103,50 @@ 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, + privateKeyPersisted: connection.privateKeyPersisted ?? existing?.privateKeyPersisted ?? null, + privateKeyCredentialId: + connection.privateKeyCredentialId ?? existing?.privateKeyCredentialId ?? null, + passwordCredentialId: + connection.passwordCredentialId ?? existing?.passwordCredentialId ?? null, + passwordPersisted: connection.passwordPersisted ?? existing?.passwordPersisted ?? 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 +167,19 @@ 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, + privateKeyPersisted: input.privateKeyPersisted ?? servers[idx].privateKeyPersisted ?? null, + passwordCredentialId: + input.passwordCredentialId !== undefined + ? input.passwordCredentialId + : servers[idx].passwordCredentialId ?? null, + passwordPersisted: + input.passwordPersisted !== undefined + ? input.passwordPersisted + : servers[idx].passwordPersisted ?? null } const next = [...servers] next[idx] = updated @@ -103,6 +187,42 @@ 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')) + }, + + countPasswordCredentialReferences(credentialId: string, exceptId?: string): number { + return store + .get('servers') + .filter((server) => server.id !== exceptId && server.passwordCredentialId === credentialId) + .length + }, + + clearPasswordCredentialReference(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], passwordCredentialId: null, passwordPersisted: 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/src/main/core/ssh-access.ts b/src/main/core/ssh-access.ts index a377b37..994c35c 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, SshPasswordStore } from './ssh-keychain' import type { SshCredentials } from './ssh-client' const MAX_PRIVATE_KEY_BYTES = 1024 * 1024 @@ -17,6 +18,80 @@ 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 async function resolveStoredSshPassword( + input: SshAccessInput, + server: Pick | null, + passwordStore: SshPasswordStore +): Promise { + const access = normalizeSshAccess(input) + if (access.authMethod !== 'password' || access.password) return access + + const credentialId = access.passwordCredentialId ?? server?.passwordCredentialId ?? undefined + if (!credentialId) return access + + const password = await passwordStore.load(credentialId) + if (!password) { + throw new Error('SSH-пароль не найден в системном хранилище; введите его заново') + } + access.password = password + access.passwordCredentialId = credentialId + access.passwordPersisted = true + return access } export function normalizeSshAccess( @@ -27,7 +102,12 @@ export function normalizeSshAccess( username: access.username.trim(), authMethod: access.authMethod, password: access.password, + passwordCredentialId: access.passwordCredentialId, + passwordPersisted: access.passwordPersisted, 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 +157,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 +181,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/core/ssh-client.ts b/src/main/core/ssh-client.ts index bf573c4..e98d121 100644 --- a/src/main/core/ssh-client.ts +++ b/src/main/core/ssh-client.ts @@ -14,7 +14,7 @@ export interface SshCredentials { sudoPassword?: string expectedHostKeyFingerprint?: string onHostKeyTrusted?: (fingerprint: string) => void - onAuthenticated?: () => void + onAuthenticated?: () => void | Promise } export interface ExecResult { @@ -98,7 +98,7 @@ export class SshClient { client.end() reject(hostKeyError ?? error) } - client.on('ready', () => { + client.on('ready', async () => { if (settled) return try { if (!presentedFingerprint) { @@ -107,7 +107,7 @@ export class SshClient { if (!this.creds.expectedHostKeyFingerprint) { this.creds.onHostKeyTrusted?.(presentedFingerprint) } - this.creds.onAuthenticated?.() + await this.creds.onAuthenticated?.() settled = true this.client = client resolve() diff --git a/src/main/core/ssh-keychain.ts b/src/main/core/ssh-keychain.ts new file mode 100644 index 0000000..5243c80 --- /dev/null +++ b/src/main/core/ssh-keychain.ts @@ -0,0 +1,103 @@ +import keytar from 'keytar' + +export const SSH_KEYCHAIN_SERVICE = 'com.xrayebator.gui.ssh-key' +export const SSH_PASSWORD_SERVICE = 'com.xrayebator.gui.ssh-password' +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 +} + +export interface SshPasswordStore { + save(credentialId: string, password: string): Promise + load(credentialId: string): Promise + remove(credentialId: string): Promise +} + +const MAX_SSH_PASSWORD_LENGTH = 16 * 1024 + +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 createSshPasswordStore(api: KeychainApi = keytar): SshPasswordStore { + const validatePassword = (password: string): void => { + if (!password || password.length > MAX_SSH_PASSWORD_LENGTH) { + throw new Error('SSH-пароль пустой или слишком большой') + } + if (/[\r\n\0]/.test(password)) { + throw new Error('SSH-пароль не может содержать перевод строки') + } + } + + return { + async save(credentialId, password) { + validateCredentialId(credentialId) + if (!password) return + validatePassword(password) + await api.setPassword(SSH_PASSWORD_SERVICE, credentialId, password) + }, + + async load(credentialId) { + validateCredentialId(credentialId) + const password = await api.getPassword(SSH_PASSWORD_SERVICE, credentialId) + if (password === null) return null + validatePassword(password) + return password + }, + + async remove(credentialId) { + validateCredentialId(credentialId) + await api.deletePassword(SSH_PASSWORD_SERVICE, credentialId) + } + } +} + +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/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 6321ec6..3e3115a 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -1,7 +1,11 @@ -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 { + ImportServerPayload, + ImportStep, ProfileCreateInput, ProfileFingerprintInput, ProfilePortInput, @@ -10,13 +14,23 @@ 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 } from './core/ssh-access' +import { + createSshCredentials, + resolvePrivateKey, + resolveStoredSshPassword +} from './core/ssh-access' +import { + createSshKeychain, + createSshPasswordStore, + MAX_PRIVATE_KEY_BYTES +} from './core/ssh-keychain' import type { SshCredentials } from './core/ssh-client' interface IpcContext { @@ -29,55 +43,160 @@ export function registerIpcHandlers({ store }: IpcContext): void { if (server.privateKeyPath) approvedPrivateKeyPaths.add(resolve(server.privateKeyPath)) } + const keychain = createSshKeychain() + const passwordStore = createSshPasswordStore() + 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 { + // 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 } + } finally { + temporaryKey.fill(0) + } }) - const credentialsFor = ( - server: Pick | null, + const persistSshPassword = async ( + access: SshAccessInput, + existingCredentialId?: string | null + ): Promise => { + if (access.authMethod !== 'password' || !access.password || access.passwordPersisted === true) { + return + } + const credentialId = existingCredentialId ?? access.passwordCredentialId ?? randomUUID().replace(/-/g, '') + try { + await passwordStore.save(credentialId, access.password) + access.passwordCredentialId = credentialId + access.passwordPersisted = true + } catch { + // OS keychain failure is non-fatal to the current authenticated session; no file fallback. + access.passwordCredentialId = undefined + access.passwordPersisted = false + } + } + + const credentialsFor = async ( + server: Pick< + Server, + | 'id' + | 'host' + | 'port' + | 'privateKeyPath' + | 'privateKeyCredentialId' + | 'privateKeyName' + | 'passwordCredentialId' + | 'passwordPersisted' + | '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 resolvedAccess = await resolveStoredSshPassword(resolved.access, server, passwordStore) const expectedHostKey = store.getHostKey(target.host, target.port) ?? server?.hostKeyFingerprint ?? undefined - const credentials = createSshCredentials(target, access, { + const credentials = createSshCredentials(target, resolvedAccess, { approvedPrivateKeyPaths, expectedHostKeyFingerprint: expectedHostKey, fallbackPrivateKeyPath: server?.privateKeyPath, + privateKey, onHostKeyTrusted: (fingerprint) => { store.trustHostKey(target.host, target.port, fingerprint) }, onAuthenticated: server - ? () => { + ? async () => { if (expectedHostKey) store.trustHostKey(target.host, target.port, expectedHostKey) + await persistSshPassword(resolvedAccess, server.passwordCredentialId) store.updateConnection(server.id, { - username: access.username, - authMethod: access.authMethod, - privilegeMode: access.privilegeMode, - privateKeyPath: access.privateKeyPath ?? null + username: resolvedAccess.username, + authMethod: resolvedAccess.authMethod, + privilegeMode: resolvedAccess.privilegeMode, + privateKeyPath: resolvedAccess.privateKeyPersisted ? null : resolvedAccess.privateKeyPath ?? null, + privateKeyCredentialId: resolvedAccess.privateKeyCredentialId ?? null, + privateKeyName: resolvedAccess.privateKeyName ?? null, + privateKeyPersisted: resolvedAccess.privateKeyPersisted ?? null, + passwordCredentialId: resolvedAccess.passwordCredentialId ?? null, + passwordPersisted: resolvedAccess.passwordPersisted ?? null }) } : undefined }) - return { credentials, access } + return { credentials, access: resolvedAccess } } 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 passwordCredentialId = server.passwordCredentialId + if ( + passwordCredentialId && + store.countPasswordCredentialReferences(passwordCredentialId) === 0 + ) { + try { + await passwordStore.remove(passwordCredentialId) + } catch { + // Ключница недоступна или запись уже удалена; карточка всё равно удалена. + } + } const sameEndpointRemains = store .list() .some( @@ -119,11 +238,13 @@ 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({ + emailMode: payload.emailMode === 'without' ? 'without' : 'provided', email: payload.email, credentials }) + await persistSshPassword(access) const server = store.add({ name: payload.host, @@ -139,7 +260,12 @@ 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, + passwordCredentialId: access.passwordCredentialId ?? null, + passwordPersisted: access.passwordPersisted ?? null, hostKeyFingerprint: store.getHostKey(payload.host, payload.port) ?? null }) @@ -168,15 +294,85 @@ export function registerIpcHandlers({ store }: IpcContext): void { return { serverId, subscriptionUrl: server.subscriptionUrl, keys } }) - const profileManagerFor = (serverId: string, access: SshAccessInput): ProfileManager => { + ipcMain.handle('servers:import', async (event, payload: ImportServerPayload) => { + if (!payload || typeof payload !== 'object') throw new Error('Некорректный запрос импорта') + if (!payload.access) { + throw new Error('Не указаны параметры SSH-доступа') + } + const emitStep = (step: ImportStep): void => { + if (!event.sender.isDestroyed()) { + 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) + }, + emitLog + ) + const result = await inspector.inspect() + await persistSshPassword(access) + emitStep('save') + emitLog('card: сервер сохранён в списке, открываю панель настроек') + + 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, + passwordCredentialId: access.passwordCredentialId ?? null, + passwordPersisted: access.passwordPersisted ?? 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 + ): 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 +386,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 +394,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 +407,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 +420,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 +438,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..5eb7029 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3,7 +3,10 @@ import type { DeployEvent, DeployStartPayload, ElectronAPI, - PrivateKeySelection, + ImportProgressEvent, + PrivateKeyReference, + ImportResult, + ImportServerPayload, ProfileCreateInput, ProfileCreateResult, ProfileDeleteResult, @@ -23,12 +26,20 @@ 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), + 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..d45790a 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,8 +9,9 @@ import type { Server } from '@shared/types' type View = | { name: 'dashboard' } | { name: 'add' } + | { name: 'import' } | { name: 'keys'; server: Server } - | { name: 'settings'; server: Server } + | { name: 'settings'; server: Server; editingAccess?: boolean } export default function App(): React.JSX.Element { const [view, setView] = useState({ name: 'dashboard' }) @@ -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: 'dashboard' })} /> ) @@ -53,8 +75,10 @@ export default function App(): React.JSX.Element { setView({ name: 'add' })} + onImport={() => setView({ name: 'import' })} onOpen={(server) => setView({ name: 'keys', server })} onSettings={(server) => setView({ name: 'settings', server })} + onEditAccess={(server) => setView({ name: 'settings', server, editingAccess: true })} onRemove={async (id) => { await window.api.servers.remove(id) setServers((prev) => prev.filter((s) => s.id !== id)) diff --git a/src/renderer/src/components/SshAccessForm.tsx b/src/renderer/src/components/SshAccessForm.tsx index 6f381d9..553b0a2 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,12 +11,30 @@ interface SshAccessFormProps { disabled?: boolean hostKeyFingerprint?: string | null onForgetHostKey?: () => void + /** Ограничивает выбор способа входа (например, только ключ для импорта). */ + allowedAuthMethods?: SshAuthMethod[] } export function isSshAccessReady(access: SshAccessInput): boolean { if (!access.username.trim()) return false - if (access.authMethod === 'password') return Boolean(access.password) - return Boolean(access.privateKeyPath) + if (access.authMethod === 'password') { + return Boolean(access.password || access.passwordCredentialId) + } + return Boolean(access.privateKeyCredentialId || access.privateKeyPath) +} + +/** + * Ввод нового пароля всегда сбрасывает флаг "уже сохранено": keychain-запись должна + * перезаписаться после успешного входа. Очистка поля оставляет сохранённый credential. + */ +export function setTypedSshPassword( + access: SshAccessInput, + password: string +): SshAccessInput { + if (password === '') { + return { ...access, password: '' } + } + return { ...access, password, passwordPersisted: false } } export function SshAccessForm({ @@ -24,11 +42,12 @@ export function SshAccessForm({ onChange, disabled = false, hostKeyFingerprint, - onForgetHostKey + onForgetHostKey, + allowedAuthMethods = ['password', 'privateKey'] }: 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 +57,13 @@ export function SshAccessForm({ setKeyError(null) try { const selection = await window.api.ssh.selectPrivateKey() - if (selection) update({ privateKeyPath: selection.path }) + 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 +82,43 @@ export function SshAccessForm({ /> -
- {t('sshAccess.authMethod')} -
- - + {allowedAuthMethods.length > 1 && ( +
+ {t('sshAccess.authMethod')} +
+ + +
-
+ )} {value.authMethod === 'password' ? ( @@ -102,7 +129,7 @@ export function SshAccessForm({ disabled={disabled} placeholder="••••••••" autoComplete="current-password" - onChange={(event) => update({ password: event.target.value })} + onChange={(event) => onChange(setTypedSshPassword(value, event.target.value))} /> ) : ( @@ -116,6 +143,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 70b2348..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,17 +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, - 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 => ( @@ -109,7 +113,7 @@ export function AddServer({ onDone, onBack }: AddServerProps): React.JSX.Element setForm((f) => ({ ...f, [key]: e.target.value }))} @@ -136,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')} + + + + )}
- {servers.map((server) => ( -
-
- -
-
- - {server.name} + {servers.map((server) => { + const summary = accessSummary(server) + return ( +
+
+
+ +
+ + {server.name} +
+ +
+ {[server.country, server.city].filter(Boolean).join(' · ') || '—'} +
-
- - {server.country || '—'} - - {server.city && {server.city}} - - {server.os ?? '—'} - - - {t('dashboard.routes', { count: server.routesCount ?? 0 })} - + + {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')} + + + + +
+ +
+ + + +
-
- - - -
-
- ))} + ) + })} {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..d4aa9f7 --- /dev/null +++ b/src/renderer/src/pages/ImportServer.module.css @@ -0,0 +1,243 @@ +.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)); +} + +.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 new file mode 100644 index 0000000..abfdd46 --- /dev/null +++ b/src/renderer/src/pages/ImportServer.tsx @@ -0,0 +1,176 @@ +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 { ImportProgressEvent, ImportStep, Server, SshAccessInput } from '@shared/types' +import { SshAccessForm, isSshAccessReady } 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: 'password', + password: '', + privilegeMode: 'root' + }) + const [running, setRunning] = useState(false) + const [currentStep, setCurrentStep] = useState(null) + const [log, setLog] = useState([]) + const [error, setError] = useState(null) + const logPanelRef = useRef(null) + + 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: 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 + .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 }))} + /> + + + + + + {access.authMethod === 'privateKey' && + !access.privateKeyCredentialId && + !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. + ) + })} +
+ +
{t('deploy.log')}
+
+ {log.length === 0 && !error && ( +
{t('deploy.waiting')}
+ )} + {log.map((line, i) => ( +
+ > {line} +
+ ))} + {error &&
> {error}
} +
+
+
+
+ ) +} diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index c8cd672..9404ad0 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -1,10 +1,9 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Button, TextField, Label, Input, Chip, Spinner, AlertDialog } from '@heroui/react' import { Settings2, Play, Trash2, - Power, Lock, CloudDownload, CloudOff, @@ -18,10 +17,13 @@ import { import { useTranslation } from 'react-i18next' import type { Server, ServerProfile, SniEntry, SshAccessInput } from '@shared/types' import { isSshAccessReady, SshAccessForm } from '../components/SshAccessForm' +import { shouldAutoConnectServer } from './server-access' import styles from './ServerSettings.module.css' interface ServerSettingsProps { server: Server + /** Открыт по «Изменить доступ» с карточки сервера: показать форму, не автоподключаясь. */ + editingAccess?: boolean onBack: () => void } @@ -52,18 +54,28 @@ 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', authMethod: server.authMethod ?? 'password', password: '', + passwordCredentialId: server.passwordCredentialId ?? undefined, + passwordPersisted: server.passwordPersisted ?? undefined, privateKeyPath: server.privateKeyPath ?? undefined, + privateKeyCredentialId: server.privateKeyCredentialId ?? undefined, + privateKeyName: server.privateKeyName ?? undefined, + privateKeyPersisted: server.privateKeyPersisted ?? undefined, passphrase: '', privilegeMode: server.privilegeMode ?? 'root', sudoPassword: '' }) const [busy, setBusy] = useState(false) + const autoConnectStarted = useRef(false) const [profiles, setProfiles] = useState(null) const [error, setError] = useState(null) const [toast, setToast] = useState(null) @@ -131,10 +143,16 @@ export function ServerSettings({ server, onBack }: ServerSettingsProps): React.J try { const result = await window.api.profiles.list(server.id, access) setProfiles(result.profiles ?? []) - void window.api.servers - .get(server.id) - .then((refreshed) => setHostKeyFingerprint(refreshed?.hostKeyFingerprint ?? null)) - .catch(() => {}) + const refreshed = await window.api.servers.get(server.id) + setHostKeyFingerprint(refreshed?.hostKeyFingerprint ?? null) + if (refreshed) { + setAccess((current) => ({ + ...current, + passwordCredentialId: refreshed.passwordCredentialId ?? current.passwordCredentialId, + passwordPersisted: refreshed.passwordPersisted ?? current.passwordPersisted, + password: refreshed.passwordPersisted ? '' : current.password + })) + } } catch (err) { setProfiles(null) setError(err instanceof Error ? err.message : String(err)) @@ -143,6 +161,13 @@ 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]) + const create = async (): Promise => { if (!accessReady) { setError(t('settings.errorPassword')) @@ -388,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 (
@@ -444,19 +458,19 @@ export function ServerSettings({ server, onBack }: ServerSettingsProps): React.J
-
-

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

- setConfirmHostKeyReset(true)} - /> -
- {!connected ? ( + {!connected && ( +
+

+ {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/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/renderer/src/pages/server-access.ts b/src/renderer/src/pages/server-access.ts new file mode 100644 index 0000000..de2daf0 --- /dev/null +++ b/src/renderer/src/pages/server-access.ts @@ -0,0 +1,50 @@ +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. */ +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 +} + +/** Где сейчас лежит секрет доступа — ключ 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/src/shared/types.ts b/src/shared/types.ts index ffe07fd..dffbb3f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2,21 +2,81 @@ 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 + persisted?: boolean +} + +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_service?: string | 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 + passwordCredentialId?: string + passwordPersisted?: boolean privateKeyPath?: string + privateKeyCredentialId?: string + privateKeyName?: string + /** true — ключ в системном keychain; false — только выбранный файл в текущей сессии. */ + privateKeyPersisted?: boolean passphrase?: string privilegeMode: SshPrivilegeMode sudoPassword?: string } -export interface PrivateKeySelection { - path: string - name: string -} - export interface Server { id: string name: string @@ -34,6 +94,13 @@ export interface Server { authMethod?: SshAuthMethod privilegeMode?: SshPrivilegeMode privateKeyPath?: string | null + privateKeyName?: string | null + privateKeyCredentialId?: string | null + privateKeyPersisted?: boolean | null + passwordCredentialId?: string | null + passwordPersisted?: boolean | null + setupStatus?: ServerSetupStatus + diagnostics?: ServerDiagnostics | null hostKeyFingerprint?: string | null } @@ -165,10 +232,17 @@ export type DeployStep = export type DeployStatus = 'pending' | 'running' | 'done' | 'error' +/** Фазы read-only импорта: main process шлёт шаг только при реальном входе в фазу. */ +export type ImportStep = 'ssh' | 'inspect' | 'subscription' | 'save' + +/** Событие импорта: переход фазы либо строка консоли (секреты уже замаскированы в main). */ +export type ImportProgressEvent = { step: ImportStep } | { log: string } + export interface DeployStartPayload { host: string port: number - email: string + emailMode: EmailMode + email?: string access: SshAccessInput } @@ -186,10 +260,12 @@ export type DeployEvent = export interface ElectronAPI { ssh: { - selectPrivateKey: () => Promise + selectPrivateKey: () => Promise } 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/type-contracts/onboarding-contracts.ts b/tests/type-contracts/onboarding-contracts.ts new file mode 100644 index 0000000..378fcea --- /dev/null +++ b/tests/type-contracts/onboarding-contracts.ts @@ -0,0 +1,59 @@ +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 +> +type ServerStoresPasswordCredential = Assert<'passwordCredentialId' 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, + ServerStoresPasswordCredential +] +void (undefined as unknown as OnboardingContractAssertions) diff --git a/tests/unit/deployer.test.ts b/tests/unit/deployer.test.ts new file mode 100644 index 0000000..36abdfa --- /dev/null +++ b/tests/unit/deployer.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { buildQuickstartArgs } from '../../src/main/core/deployer' +import { maskSubscriptionUrl } from '../../src/main/core/server-inspector' + +describe('deploy console redaction', () => { + it('masks the subscription token before it can reach the deploy console', () => { + const url = 'https://203.0.113.10:8443/sub/0123456789abcdef0123456789abcdef' + const masked = maskSubscriptionUrl(url) + // Токен — bearer credential: в консоль попадает только его начало и конец. + expect(masked).not.toContain('0123456789abcdef0123456789abcdef') + expect(masked).toBe('https://203.0.113.10:8443/sub/0123…cdef') + }) +}) + +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) + }) +}) diff --git a/tests/unit/server-inspector.test.ts b/tests/unit/server-inspector.test.ts new file mode 100644 index 0000000..6f559ab --- /dev/null +++ b/tests/unit/server-inspector.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' +import type { InspectionSnapshot, VlessLink } from '../../src/shared/types' +import { + maskSubscriptionUrl, + 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('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 }) + ).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() + }) +}) diff --git a/tests/unit/server-store.test.ts b/tests/unit/server-store.test.ts new file mode 100644 index 0000000..2e4ab4f --- /dev/null +++ b/tests/unit/server-store.test.ts @@ -0,0 +1,195 @@ +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', + 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 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('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) + 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() + }) +}) diff --git a/tests/unit/ssh-access.test.ts b/tests/unit/ssh-access.test.ts index df858d4..848e663 100644 --- a/tests/unit/ssh-access.test.ts +++ b/tests/unit/ssh-access.test.ts @@ -1,9 +1,14 @@ 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, + resolveStoredSshPassword +} 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 +18,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,9 +114,152 @@ 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(/=+$/, '')}` ) }) + + 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 new file mode 100644 index 0000000..47b6cee --- /dev/null +++ b/tests/unit/ssh-keychain.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest' +import type { KeychainApi } from '../../src/main/core/ssh-keychain' +import { createSshKeychain, createSshPasswordStore } 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' + ) + }) +}) + +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 new file mode 100644 index 0000000..f7e4012 --- /dev/null +++ b/tests/unit/ui-contracts.test.ts @@ -0,0 +1,143 @@ +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, accessSummary } from '../../src/renderer/src/pages/server-access' +import type { SshAccessInput } from '../../src/shared/types' + +const readyAccess: SshAccessInput = { + username: 'root', + authMethod: 'password', + password: 'secret', + privilegeMode: 'root' +} + +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) + ).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('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('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' }, + readyAccess + ) + expect(payload).toMatchObject({ host: 'vps.example', port: 2222, emailMode: 'without' }) + expect(payload.email).toBeUndefined() + }) +}) 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"] +} diff --git a/validation/test-apt-lock-race.sh b/validation/test-apt-lock-race.sh new file mode 100644 index 0000000..923ed89 --- /dev/null +++ b/validation/test-apt-lock-race.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Regression test: гонка apt-lock в quickstart (2026-09-23, Ubuntu 22.04). +# Корень: unattended-upgrades на свежеподнятом VPS вызывает dpkg отдельно на +# каждый пакет (~90 пакетов, ~10 минут); flock между пакетами свободен секунды, +# quickstart-овый apt стартовал в зазор и падал с «apt-get install nginx failed». +# Контракты: +# 1) существует APT_LOCK_OPTS с DPkg::Lock::Timeout и он подключён ко всем +# `apt-get install` в xrayebator; +# 2) _apt_wait_lock учитывает активный процесс /usr/bin/unattended-upgrade +# (по полному пути в cmdline, НЕ по comm — comm обрезается ядром до 15 +# символов и не отличает воркер от резидентного shutdown-helper'а); +# 3) бюджет первого ожидания в quickstart >= 12 минут (покрывает наблюдённый +# прогон ~10 минут); +# 4) внешний timeout вокруг apt-install >= DPkg::Lock::Timeout (иначе kill +# обрывает ожидание lock внутри apt и воспроизводит баг). +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +fail() { + echo "✗ FAIL: $*" >&2 + exit 1 +} + +source_text=$(tr -d '\r' < xrayebator) + +# 1) APT_LOCK_OPTS определён и подключён к каждой установке пакета. +grep -Fq 'APT_LOCK_OPTS=(-o "DPkg::Lock::Timeout=' <<< "$source_text" \ + || fail "APT_LOCK_OPTS с DPkg::Lock::Timeout не определён" + +# Каждая строка фактического вызова «apt-get ... install» обязана нести +# "${APT_LOCK_OPTS[@]}" (echo-сообщения с текстом «apt-get install» не считаем). +while IFS= read -r line; do + lineno=${line%%:*} + grep -Fq -- '"${APT_LOCK_OPTS[@]}"' <<< "$line" \ + || fail "apt-get install без APT_LOCK_OPTS на строке $lineno: $line" +done < <(grep -n 'apt-get .*install' <<< "$source_text" | grep -v 'echo ' | grep -v ':\s*#') + +# 2) Детект рабочего процесса unattended-upgrade по полному пути. +grep -Fq "pgrep -f '/usr/bin/unattended-upgrade" <<< "$source_text" \ + || fail "_apt_wait_lock не учитывает активный процесс unattended-upgrade" +if grep -Fq 'pgrep -x unattended-upgrade' <<< "$source_text"; then + fail "найден pgrep -x unattended-upgrade: comm обрезается до 15 символов и не работает" +fi + +# 3) quickstart: бюджет ожидания до первой установки nginx >= 720 секунд. +quickstart_block=$(sed -n '/^quickstart_command() {$/,/^happ_setup_command() {$/p' <<< "$source_text") +[[ -n "$quickstart_block" ]] || fail "quickstart_command block not found" +first_wait=$(grep -oE '_apt_wait_lock [0-9]+' <<< "$quickstart_block" | head -1 | grep -oE '[0-9]+') +[[ -n "$first_wait" && "$first_wait" -ge 720 ]] \ + || fail "quickstart: бюджет первого _apt_wait_lock = '${first_wait:-нет}' < 720 (прогон unattended ~10 мин)" + +# 4) Внешний timeout вокруг обязательных nginx-install >= 300 +# (DPkg::Lock::Timeout=180 + время закачки/настройки; snapd-резервы не считаем). +while IFS=: read -r lineno line; do + tmo=$(grep -oE 'timeout [0-9]+' <<< "$line" | grep -oE '[0-9]+' || true) + [[ -n "$tmo" && "$tmo" -ge 300 ]] \ + || fail "строка $lineno: внешний timeout='${tmo:-нет}' < 300 обрезает Lock::Timeout=180: $line" +done < <(grep -n 'timeout [0-9]* apt-get .*install -y nginx' <<< "$quickstart_block") + +echo "✓ apt-install защищены от гонки unattended-upgrades (опция+процесс+бюджет 12 мин)" diff --git a/validation/test-quickstart-email-and-inspect.sh b/validation/test-quickstart-email-and-inspect.sh new file mode 100644 index 0000000..350f521 --- /dev/null +++ b/validation/test-quickstart-email-and-inspect.sh @@ -0,0 +1,65 @@ +#!/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 +} + +# Полностью читаем источник в переменную: 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" \ + || 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 без фиктивного адреса" + +# ── inspect: read-only диагностика для GUI-импорта ── +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\)' <<< "$source_text" \ + || 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 b9ef055..16abb5b 100755 --- a/xrayebator +++ b/xrayebator @@ -224,10 +224,24 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then fi fi -# Ждёт освобождения apt/dpkg lock (apt-daily, unattended-upgrades и т.п.). -# На свежих Ubuntu после первого входа фоновые сервисы держат lock до ~10 минут, -# из-за чего `apt-get install` молча висит и падает по timeout (GUI: «quickstart код 1»). -# Возвращает 0, когда lock свободен, или 1 по истечении timeout (сек). +# Встроенное в apt ожидание dpkg lock. На Ubuntu 22.04 apt уже ставит дефолт +# DPkg::Lock::Timeout=120 (видно в apt-config dump) — поднятием до 180 мы лишь +# страхуем системы без дефолта. Это НЕ основной механизм ожидания: entire +# очередь unattended-upgrades занимает lock ~10 минут (замер на живом VPS +# 2026-09-23), а apt с опцией ждёт только её окно. Основной механизм — +# _apt_wait_lock по процессу-воркеру. +APT_LOCK_OPTS=(-o "DPkg::Lock::Timeout=180") + +# Ждёт освобождения apt/dpkg lock (apt-daily, unattended-upgrades и т.п.) +# до запуска собственной apt-команды. Возвращает 0, когда можно ставить пакеты, +# или 1 по истечении timeout (сек). +# Критично: на свежеподнятых Ubuntu unattended-upgrades прогоняет полный +# security-апгрейд (~90 пакетов, ~10 минут — dpkg вызывается отдельно на +# каждый). Между пакетами flock свободен секунды, голый flock проскакивает в +# такой зазор, apt стартует и умирает по своему lock-timeout или внешнему kill +# (зафиксировано на живом VPS 2026-09-23: quickstart падал с «apt-get install +# nginx failed» при формально «свободном» lock). +# Поэтому занятым считаем и активный рабочий процесс unattended-upgrade. _apt_wait_lock() { local max_sec="${1:-300}" local deadline=$(( $(date +%s) + max_sec )) @@ -240,14 +254,24 @@ _apt_wait_lock() { while (( $(date +%s) < deadline )); do local held=0 local lock - for lock in "${locks[@]}"; do - # flock не создаёт файл — touch обязателен для несуществующих lock-путей. - touch "$lock" 2>/dev/null || true - if ! flock -n "$lock" true 2>/dev/null; then - held=1 - break - fi - done + # Активный рабочий процесс unattended-upgrade = очередь пакетов ещё идёт. + # По comm не различить (ядро обрезает до 15 символов «unattended-upgr» и у + # воркера, и у резидентного shutdown-helper'а с самого бутa) — проверяем + # cmdline: путь скрипта ровно «/usr/bin/unattended-upgrade» (helper'овский + # «/usr/share/unattended-upgrades/unattended-upgrade-shutdown» не совпадает). + if command -v pgrep >/dev/null 2>&1 && pgrep -f '/usr/bin/unattended-upgrade( |$)' >/dev/null 2>&1; then + held=1 + fi + if [[ "$held" -eq 0 ]]; then + for lock in "${locks[@]}"; do + # flock не создаёт файл — touch обязателен для несуществующих lock-путей. + touch "$lock" 2>/dev/null || true + if ! flock -n "$lock" true 2>/dev/null; then + held=1 + break + fi + done + fi if [[ "$held" -eq 0 ]]; then return 0 fi @@ -1251,7 +1275,7 @@ _ensure_certbot_ip_tls() { done echo -e "${YELLOW} → Системный certbot не поддерживает IP certificates; ставлю актуальный certbot через snap...${NC}" - if ! apt-get install -y snapd >/dev/null 2>&1; then + if ! apt-get "${APT_LOCK_OPTS[@]}" install -y snapd >/dev/null 2>&1; then echo -e "${RED}✗ Не удалось установить snapd для Certbot 5.4+${NC}" return 1 fi @@ -4661,7 +4685,7 @@ install_selfsteal_stub_menu() { fi echo -e "${CYAN} → Установка nginx + certbot...${NC}" - if ! apt-get install -y nginx certbot >/dev/null 2>&1; then + if ! apt-get "${APT_LOCK_OPTS[@]}" install -y nginx certbot >/dev/null 2>&1; then echo -e "${RED}✗ apt-get install nginx certbot не удалось${NC}" return 1 fi @@ -5384,10 +5408,10 @@ install_subscription_server() { if ! command -v socat &>/dev/null; then echo -e "${CYAN} → Установка socat...${NC}" _apt_wait_lock 300 || true - if ! timeout 180 apt-get install -y socat >/dev/null 2>&1; then + if ! timeout 240 apt-get "${APT_LOCK_OPTS[@]}" install -y socat >/dev/null 2>&1; then _apt_wait_lock 300 || true timeout 180 apt-get update >/dev/null 2>&1 || true - if ! timeout 180 apt-get install -y socat >/dev/null 2>&1; then + if ! timeout 240 apt-get "${APT_LOCK_OPTS[@]}" install -y socat >/dev/null 2>&1; then echo -e "${RED}✗ Не удалось установить socat${NC}" echo -e "${YELLOW}Установите вручную: apt-get install -y socat${NC}" return 1 @@ -6112,7 +6136,7 @@ install_subscription_ip_tls() { echo -e "${CYAN} → HTTPS порт подписки: $pub_port${NC}" echo -e "${CYAN} → Установка nginx...${NC}" - if ! apt-get install -y nginx >/dev/null 2>&1; then + if ! apt-get "${APT_LOCK_OPTS[@]}" install -y nginx >/dev/null 2>&1; then echo -e "${RED}✗ apt-get install nginx не удалось${NC}" return 1 fi @@ -6345,7 +6369,7 @@ install_subscription_public_tls() { echo -e "${CYAN} → Публичный порт: $pub_port${NC}" echo -e "${CYAN} → Установка nginx + certbot...${NC}" - if ! apt-get install -y nginx certbot >/dev/null 2>&1; then + if ! apt-get "${APT_LOCK_OPTS[@]}" install -y nginx certbot >/dev/null 2>&1; then echo -e "${RED}✗ apt-get install nginx certbot не удалось${NC}" return 1 fi @@ -10099,16 +10123,118 @@ 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="" + 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 @@ -10201,19 +10327,26 @@ quickstart_command() { # Установить nginx если нет if ! command -v nginx &>/dev/null; then echo " -> Установка nginx..." >&2 - # FIX: фоновые apt-daily/unattended-upgrades держат lock на свежих Ubuntu — - # без ожидания apt-get висит и quickstart падает с кодом 1 (GUI). - if ! _apt_wait_lock 300; then - echo '{"ok":false,"error":"apt lock не освободился за 5 минут (apt-daily/unattended-upgrades)"}' + # FIX (11.08): фоновые apt-daily/unattended-upgrades держат lock на свежих + # Ubuntu — без ожидания apt-get висит и quickstart падает с кодом 1 (GUI). + # FIX (23.09): прогон unattended-upgrades на свежеподнятом VPS занимает + # ~10 минут (замер: 21:10:39→21:20:23, ~90 пакетов); flock между пакетами + # свободен секунды и проскакивает гонку, поэтому бюджет ожидания — 12 минут + # и учёт самого процесса-воркера внутри _apt_wait_lock. + if ! _apt_wait_lock 720; then + echo '{"ok":false,"error":"apt занят обновлениями дольше 12 минут (unattended-upgrades). Повторите quickstart чуть позже"}' return 1 fi - if ! timeout 180 apt-get install -y nginx >/dev/null 2>&1; then + # ВНИМАНИЕ: timeout обязан превышать DPkg::Lock::Timeout=180 внутри apt, + # иначе внешний kill обрывает ожидание lock раньше времени и воспроизводит + # исходный баг. + if ! timeout 300 apt-get "${APT_LOCK_OPTS[@]}" install -y nginx >/dev/null 2>&1; then # При пропущенном install.sh (повторный деплой) apt-индексы могут быть # устаревшими — обновляем и пробуем ещё раз, прежде чем сдаться. echo " -> apt-get install nginx не удался, обновляю индексы и повторяю..." >&2 - _apt_wait_lock 300 || true + _apt_wait_lock 720 || true timeout 180 apt-get update >/dev/null 2>&1 || true - if ! timeout 180 apt-get install -y nginx >/dev/null 2>&1; then + if ! timeout 300 apt-get "${APT_LOCK_OPTS[@]}" install -y nginx >/dev/null 2>&1; then echo '{"ok":false,"error":"apt-get install nginx failed"}' return 1 fi @@ -10230,7 +10363,7 @@ quickstart_command() { if [[ -z "$certbot_bin" ]]; then echo " -> Установка certbot через snap..." >&2 _apt_wait_lock 300 || true - timeout 180 apt-get install -y snapd >/dev/null 2>&1 || true + timeout 240 apt-get "${APT_LOCK_OPTS[@]}" install -y snapd >/dev/null 2>&1 || true systemctl enable --now snapd.socket >/dev/null 2>&1 || true export PATH="/snap/bin:$PATH" if command -v snap &>/dev/null; then @@ -10370,13 +10503,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 @@ -11759,6 +11900,10 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then shift quickstart_command "$@" ;; + inspect) + shift + inspect_command "$@" + ;; happ-setup) shift happ_setup_command "$@" @@ -11809,6 +11954,8 @@ 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 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-список профилей"