Compare commits
13
Commits
1.0.0
..
9c987df6e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c987df6e9 | ||
|
|
90ec4ca086 | ||
|
|
67792f245f | ||
|
|
90b2eb507c | ||
|
|
dbba3806cc | ||
|
|
9fd0a8c0b9 | ||
|
|
1bb795a532 | ||
|
|
db0c1dede9 | ||
|
|
4d859dc0a6 | ||
|
|
a2581187da | ||
|
|
2e80f7b8eb | ||
|
|
f6b722b22a | ||
|
|
42b85cc8fa |
@@ -0,0 +1,61 @@
|
||||
# ProxyWarden Agent Kit
|
||||
|
||||
Этот каталог содержит инструкции для кодовых агентов, которые работают с ProxyWarden.
|
||||
|
||||
Главный файл — `AGENTS.md` в корне репозитория. Он задает инварианты и общие правила. Файлы в `.agent/skills` описывают конкретные режимы работы: backend, UI, security, Windows services, subscriptions/routing, testing/release и понятные отчеты.
|
||||
|
||||
## Как использовать
|
||||
|
||||
1. Прочитать корневой `AGENTS.md`.
|
||||
2. Выбрать skill под задачу.
|
||||
3. Перед изменением проверить релевантные чек-листы из `.agent/checklists`.
|
||||
4. После изменения выполнить минимальные проверки.
|
||||
5. Для любого нетривиального ответа использовать `communication-reporting`: коротко, по файлам, с проверками и рисками.
|
||||
6. В финальном отчете явно указать, что было и не было проверено.
|
||||
|
||||
## Как агент должен писать ответы
|
||||
|
||||
По умолчанию агент пишет не техническую простыню, а короткий отчет:
|
||||
|
||||
```text
|
||||
Коротко
|
||||
Что изменилось по файлам
|
||||
Важные места
|
||||
Проверено
|
||||
Не проверено
|
||||
Риски
|
||||
```
|
||||
|
||||
Для 2+ файлов желательно использовать таблицу `Файл / Что изменилось / Зачем`. В ответе должны быть конкретные пути файлов и человеческая причина изменения. Не надо пересказывать каждую строку diff, если пользователь не попросил.
|
||||
|
||||
Подробные правила лежат в `.agent/skills/communication-reporting/SKILL.md`, чек-лист — в `.agent/checklists/communication.md`. Да, это нужно отдельно прописывать, иначе агент опять напишет роман о своем внутреннем мире и двух переименованных переменных.
|
||||
|
||||
## Skill index
|
||||
|
||||
- `repository-orientation` — вход в проект, карта файлов, где искать source of truth.
|
||||
- `rust-tauri-backend` — Tauri commands, Rust models, validation, storage, adapters.
|
||||
- `react-typescript-ui` — React UI, typed invoke facade, readiness, components.
|
||||
- `security-hardening` — CSP, секреты, elevated boundary, storage corruption, SSRF.
|
||||
- `windows-services-powershell` — scripts, UAC, services, ProxiFyre/sing-box operations.
|
||||
- `subscriptions-routing` — external SOCKS5, sing-box subscriptions, config generation, ping.
|
||||
- `testing-ci-release` — build/test matrix, CI recommendations, release hygiene.
|
||||
- `communication-reporting` — короткие планы, понятные сводки по файлам, отчеты без текстовой каши.
|
||||
|
||||
## Communication defaults
|
||||
|
||||
Перед длинным ответом или отчетом использовать:
|
||||
|
||||
- `.agent/skills/communication-reporting/SKILL.md`
|
||||
- `.agent/checklists/communication.md`
|
||||
- `.agent/checklists/explanation-quality.md`
|
||||
- `.agent/templates/change-report.md` или `.agent/templates/user-facing-summary.md`
|
||||
- `.agent/templates/file-impact-map.md`, если надо заранее показать, какие файлы будут затронуты
|
||||
|
||||
Главная идея: сначала короткая сводка, потом таблица файлов, потом проверки и риски. Не наоборот, потому что пользователь не обязан добывать смысл киркой.
|
||||
|
||||
## Что не является целью
|
||||
|
||||
- Перевод проекта в SaaS/gateway/server.
|
||||
- Добавление облачного backend.
|
||||
- Замена ProxiFyre без отдельной архитектурной задачи.
|
||||
- Коммерциализация, telemetry-first подход или рекламная шелуха, этот вид пластика уже и так в океане.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Change Safety Checklist
|
||||
|
||||
Use this before and after non-trivial changes.
|
||||
|
||||
## Scope
|
||||
|
||||
- [ ] I identified whether this touches backend, UI, security, Windows service, subscription/routing, testing/release.
|
||||
- [ ] I read the matching skill file.
|
||||
- [ ] I avoided unrelated rewrites.
|
||||
- [ ] I did not introduce a second source of truth.
|
||||
|
||||
## Tauri boundary
|
||||
|
||||
- [ ] New/changed Rust command has matching TypeScript wrapper.
|
||||
- [ ] DTOs are synchronized between Rust and TypeScript.
|
||||
- [ ] Error shape is structured and actionable.
|
||||
- [ ] Blocking work is not run on async runtime thread.
|
||||
|
||||
## UX
|
||||
|
||||
- [ ] User-visible actions are explicit.
|
||||
- [ ] Disabled states have reasons.
|
||||
- [ ] Pending changes are visible before apply.
|
||||
- [ ] Secrets are redacted.
|
||||
|
||||
## Validation
|
||||
|
||||
- [ ] Relevant frontend build/test was run or explicitly not run with reason.
|
||||
- [ ] Relevant Rust fmt/clippy/test was run or explicitly not run with reason.
|
||||
- [ ] Windows-specific behavior was not claimed unless tested on Windows.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Communication Checklist
|
||||
|
||||
Используй перед финальным ответом по любой нетривиальной задаче.
|
||||
|
||||
## Структура
|
||||
|
||||
- [ ] Ответ начинается с `Коротко` или с такой же короткой сводки на 2-4 пункта.
|
||||
- [ ] Измененные файлы или зоны проекта перечислены в начале ответа, а не спрятаны в конце.
|
||||
- [ ] Для каждого важного файла понятно: что изменилось и зачем.
|
||||
- [ ] Важные изменения поведения, безопасности или состояния отделены от мелких деталей.
|
||||
- [ ] Проверки разделены на `Проверено` и `Не проверено`.
|
||||
- [ ] Риски написаны явно.
|
||||
|
||||
## Понятность
|
||||
|
||||
- [ ] Нет плотных абзацев длиннее 4-5 строк.
|
||||
- [ ] Нет терминов и аббревиатур без пользы или краткого объяснения.
|
||||
- [ ] Нет полных логов, если они не нужны для вывода.
|
||||
- [ ] Нет пересказа каждой строки diff, если пользователь не просил.
|
||||
- [ ] Нет мутных фраз вроде `улучшена архитектура` без объяснения, что стало проще, безопаснее или понятнее.
|
||||
|
||||
## Честность
|
||||
|
||||
- [ ] Windows/service/elevation поведение не названо проверенным, если оно не тестировалось на Windows.
|
||||
- [ ] У пропущенных проверок есть простая причина.
|
||||
- [ ] Ответ не говорит `готово`, если важные проверки пропущены.
|
||||
|
||||
## Быстрая самопроверка
|
||||
|
||||
Перед отправкой ответ должен отвечать на вопросы:
|
||||
|
||||
1. Что изменилось или найдено?
|
||||
2. В каких файлах?
|
||||
3. Зачем это нужно?
|
||||
4. Что реально проверено?
|
||||
5. Что не проверено?
|
||||
6. Где остался риск?
|
||||
@@ -0,0 +1,41 @@
|
||||
# Checklist: Explanation Quality
|
||||
|
||||
Используй перед финальным ответом или PR summary.
|
||||
|
||||
## Обязательное
|
||||
|
||||
- [ ] В начале есть короткий итог на 2–4 пункта.
|
||||
- [ ] Есть список файлов или таблица `файл / что / зачем`.
|
||||
- [ ] Термины объяснены простыми словами, если они важны.
|
||||
- [ ] Нет длинных полотен без заголовков.
|
||||
- [ ] Нет пересказа каждой строки diff.
|
||||
- [ ] Указано, что проверено.
|
||||
- [ ] Указано, что не проверено.
|
||||
- [ ] Риски написаны прямо, без «должно работать».
|
||||
|
||||
## Хороший формат
|
||||
|
||||
```md
|
||||
## Коротко
|
||||
- ...
|
||||
|
||||
## Файлы
|
||||
| Файл | Что | Зачем |
|
||||
|---|---|---|
|
||||
|
||||
## Проверки
|
||||
- Выполнено: ...
|
||||
- Не выполнено: ...
|
||||
|
||||
## Риски
|
||||
- ...
|
||||
```
|
||||
|
||||
## Плохие признаки
|
||||
|
||||
- Один огромный абзац.
|
||||
- Много терминов без пользы.
|
||||
- «Исправлена логика» без указания файла и эффекта.
|
||||
- «Проверено» без команды или способа проверки.
|
||||
- «Не проверял Windows, но всё готово».
|
||||
- Список из 25 пунктов одинаковой важности.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Release Checklist
|
||||
|
||||
## Build
|
||||
|
||||
- [ ] `npm ci`
|
||||
- [ ] `npm run build`
|
||||
- [ ] `cargo fmt --all -- --check`
|
||||
- [ ] `cargo clippy --all-targets --all-features -- -D warnings`
|
||||
- [ ] `cargo test --all-targets`
|
||||
- [ ] `npm run tauri -- build`
|
||||
|
||||
## Windows smoke
|
||||
|
||||
- [ ] Fresh Windows VM smoke test.
|
||||
- [ ] ProxiFyre install plan and real install.
|
||||
- [ ] sing-box install plan and real install.
|
||||
- [ ] External SOCKS5 route works.
|
||||
- [ ] Local sing-box subscription route works.
|
||||
- [ ] Start/stop/restart service controls work.
|
||||
- [ ] Uninstall does not delete unmanaged paths.
|
||||
|
||||
## Security/release hygiene
|
||||
|
||||
- [ ] CSP enabled.
|
||||
- [ ] Capabilities minimal.
|
||||
- [ ] No raw secrets in repo/logs.
|
||||
- [ ] Subscription redaction checked.
|
||||
- [ ] Generated config writes are safe.
|
||||
- [ ] Artifact version is correct.
|
||||
- [ ] Large assets reviewed/compressed if practical.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Security Checklist
|
||||
|
||||
## Secrets
|
||||
|
||||
- [ ] Subscription URLs are redacted through parser-based logic.
|
||||
- [ ] Proxy credentials are not logged.
|
||||
- [ ] Outbound configs with secrets are not printed in diagnostics.
|
||||
- [ ] Error messages do not include tokens/passwords/userinfo.
|
||||
|
||||
## Tauri/webview
|
||||
|
||||
- [ ] CSP is enabled.
|
||||
- [ ] No broad shell permissions added.
|
||||
- [ ] No direct command execution from UI input.
|
||||
- [ ] No `dangerouslySetInnerHTML` or equivalent unsafe HTML rendering without sanitization.
|
||||
|
||||
## Network fetch
|
||||
|
||||
- [ ] Subscription fetch has timeout.
|
||||
- [ ] URL scheme is restricted.
|
||||
- [ ] Local/private/link-local/metadata address behavior is explicit.
|
||||
- [ ] Redirect behavior does not bypass blocked address checks.
|
||||
|
||||
## Filesystem
|
||||
|
||||
- [ ] Critical writes are atomic where practical.
|
||||
- [ ] Corrupt config handling does not silently discard user state.
|
||||
- [ ] Recursive delete has strict path/marker checks.
|
||||
- [ ] Temp elevated scripts use unpredictable names and safe directory/ACL when practical.
|
||||
|
||||
## Windows services
|
||||
|
||||
- [ ] Managed service is verified by name and PathName/metadata.
|
||||
- [ ] Fuzzy candidates are not automatically controlled.
|
||||
- [ ] UAC cancellation has clear error.
|
||||
- [ ] Plan-only remains side-effect-free.
|
||||
@@ -0,0 +1,24 @@
|
||||
# UI Checklist
|
||||
|
||||
## Structure
|
||||
|
||||
- [ ] No direct `invoke(...)` outside `src/api/tauriCommands.ts`.
|
||||
- [ ] New reusable UI uses `src/ui/*` components or extends them.
|
||||
- [ ] New business/display logic is not buried in JSX if it can be tested separately.
|
||||
- [ ] `App.tsx` was not made worse without justification.
|
||||
|
||||
## Accessibility and behavior
|
||||
|
||||
- [ ] Buttons have accessible names.
|
||||
- [ ] Toggle state uses `aria-pressed` or equivalent.
|
||||
- [ ] Tabs/popovers preserve keyboard and screen-reader behavior.
|
||||
- [ ] Reduced motion preference is respected where animation is added.
|
||||
- [ ] Errors are visible and readable.
|
||||
|
||||
## ProxyWarden-specific
|
||||
|
||||
- [ ] External SOCKS5 route does not require sing-box.
|
||||
- [ ] Local sing-box route requires selected server and readiness.
|
||||
- [ ] Route chain matches actual backend state.
|
||||
- [ ] Apply readiness gives a clear reason.
|
||||
- [ ] Summary remains read-only.
|
||||
@@ -0,0 +1,248 @@
|
||||
# Skill: Communication Reporting
|
||||
|
||||
## Когда использовать
|
||||
|
||||
Используй этот skill в каждом ответе пользователю после анализа, правки кода, ревью, аудита, планирования рефакторинга или подготовки PR. Особенно если задача затрагивает несколько файлов, backend/frontend boundary, security, Windows services или UI.
|
||||
|
||||
## Цель
|
||||
|
||||
Писать так, чтобы человек с опытом разработки быстро понял суть без чтения технической простыни. Не упрощать до детского сада, но объяснять по-человечески: что поменялось, где, зачем, как проверить, где риск.
|
||||
|
||||
Пользователь не обязан продираться через внутренний монолог агента и каталог аббревиатур. У него есть жизнь, возможно даже вне репозитория, страшно представить.
|
||||
|
||||
## Базовый формат ответа
|
||||
|
||||
Для нетривиальных изменений используй такую структуру:
|
||||
|
||||
```text
|
||||
Коротко
|
||||
- 2-4 пункта: главный результат, важный риск, что проверить.
|
||||
|
||||
Что изменилось по файлам
|
||||
| Файл | Что изменилось | Зачем |
|
||||
|---|---|---|
|
||||
| src/... | Кратко | Человеческая причина |
|
||||
|
||||
Важные места
|
||||
- 3-6 конкретных мест: файл + функция/секция + смысл.
|
||||
|
||||
Как проверить
|
||||
- Команды или ручные шаги.
|
||||
|
||||
Что не проверено
|
||||
- Честно и коротко.
|
||||
|
||||
Риски
|
||||
- Только реальные риски, не философия.
|
||||
```
|
||||
|
||||
Если изменение маленькое, можно сократить до:
|
||||
|
||||
```text
|
||||
Коротко: ...
|
||||
|
||||
Файлы:
|
||||
- `path`: что и зачем.
|
||||
|
||||
Проверка: ...
|
||||
```
|
||||
|
||||
## Правила ясности
|
||||
|
||||
- Сначала вывод, потом детали.
|
||||
- Не писать длиннее, чем нужно для решения задачи.
|
||||
- Не перечислять каждую строку diff. Указывать только смысловые изменения.
|
||||
- Всегда называть конкретные файлы.
|
||||
- Для сложных мест указывать функцию, модуль или секцию, если это помогает найти код.
|
||||
- Если используешь термин, рядом дать короткое человеческое объяснение.
|
||||
- Не использовать аббревиатуры без расшифровки при первом упоминании, кроме очевидных: UI, JSON, URL, API.
|
||||
- Не писать «улучшена архитектура» без объяснения, что именно стало проще или безопаснее.
|
||||
- Не писать «всё готово», если часть проверок не запускалась.
|
||||
- Не скрывать ошибки окружения. Если `cargo` или Windows недоступны, так и сказать.
|
||||
|
||||
## Как объяснять технические изменения
|
||||
|
||||
Плохо:
|
||||
|
||||
```text
|
||||
Refactored orchestration layer and extracted imperative use-case side effects into composable boundaries.
|
||||
```
|
||||
|
||||
Хорошо:
|
||||
|
||||
```text
|
||||
Вынес запуск service-команд из большого `commands.rs` в отдельный модуль. Теперь Tauri command только принимает запрос и возвращает ошибку, а вся Windows-логика лежит отдельно. Так проще тестировать и меньше шанс сломать соседние команды.
|
||||
```
|
||||
|
||||
Плохо:
|
||||
|
||||
```text
|
||||
Added CSP hardening.
|
||||
```
|
||||
|
||||
Хорошо:
|
||||
|
||||
```text
|
||||
Включил CSP в `src-tauri/tauri.conf.json`. Это ограничивает, какие скрипты/ресурсы может загрузить webview, и снижает ущерб, если в UI когда-нибудь появится XSS.
|
||||
```
|
||||
|
||||
## Уровни детализации
|
||||
|
||||
По умолчанию — средний уровень:
|
||||
|
||||
- достаточно конкретно, чтобы разработчик понял diff;
|
||||
- без пересказа каждой строки;
|
||||
- без внутренних рассуждений агента;
|
||||
- без длинной теории.
|
||||
|
||||
Если пользователь просит глубже, добавь раздел:
|
||||
|
||||
```text
|
||||
Детальнее
|
||||
- ...
|
||||
```
|
||||
|
||||
Если пользователь просит совсем кратко, оставь только:
|
||||
|
||||
```text
|
||||
Коротко
|
||||
Файлы
|
||||
Проверка
|
||||
```
|
||||
|
||||
## Таблица файлов
|
||||
|
||||
Для 2+ файлов почти всегда используй таблицу:
|
||||
|
||||
| Файл | Тип изменения | Смысл |
|
||||
|---|---|---|
|
||||
| `src/api/tauriCommands.ts` | API boundary | Добавлен typed wrapper для новой Tauri command |
|
||||
| `src-tauri/src/commands.rs` | Backend | Добавлена команда, которая вызывает уже существующую service-логику |
|
||||
|
||||
Правила:
|
||||
|
||||
- Не вставлять огромные таблицы на 30 строк. Группировать мелкие файлы.
|
||||
- В колонке `Смысл` писать человеческую причину, не только «обновлено».
|
||||
- Если файл опасный, отметить это: `security-sensitive`, `Windows/elevation`, `storage`, `routing`.
|
||||
|
||||
## Как писать про риски
|
||||
|
||||
Риск должен быть конкретным:
|
||||
|
||||
Плохо:
|
||||
|
||||
```text
|
||||
Есть некоторые риски.
|
||||
```
|
||||
|
||||
Хорошо:
|
||||
|
||||
```text
|
||||
Риск: я не запускал real Windows service flow, поэтому install/start/stop надо проверить на Windows 10/11 с UAC.
|
||||
```
|
||||
|
||||
Плохо:
|
||||
|
||||
```text
|
||||
Может быть несовместимость.
|
||||
```
|
||||
|
||||
Хорошо:
|
||||
|
||||
```text
|
||||
Риск: если у пользователя уже стоит чужая служба с похожим именем ProxiFyre, fuzzy detection может показать ее кандидатом. Управлять ей нельзя без проверки `PathName`.
|
||||
```
|
||||
|
||||
## Как писать про проверки
|
||||
|
||||
Разделяй выполненное и невыполненное:
|
||||
|
||||
```text
|
||||
Проверено
|
||||
- `npm run build` — прошел.
|
||||
- Markdown-файлы открываются, битых путей не нашел.
|
||||
|
||||
Не проверено
|
||||
- `cargo test` — не запускал, в среде нет Rust toolchain.
|
||||
- Windows service flow — не проверял, нужна Windows-машина с UAC.
|
||||
```
|
||||
|
||||
Не объединять это в мутное «тесты частично пройдены». Машины и люди заслуживают хотя бы грамм конкретики.
|
||||
|
||||
## Запрещенный стиль
|
||||
|
||||
Не писать:
|
||||
|
||||
- огромные абзацы без заголовков;
|
||||
- «магия», «оптимизировано», «улучшено» без конкретики;
|
||||
- внутренний дневник действий агента;
|
||||
- цепочки мыслей;
|
||||
- список всех строк diff;
|
||||
- рекламный тон;
|
||||
- уверенные заявления о непроверенных Windows/elevation сценариях;
|
||||
- «как вы и просили, я с радостью...» — репозиторий от этого лучше не станет.
|
||||
|
||||
## Мини-шаблоны
|
||||
|
||||
### Для bugfix
|
||||
|
||||
```text
|
||||
Коротко
|
||||
- Исправил ...
|
||||
- Основной риск был ...
|
||||
- Проверка: ...
|
||||
|
||||
Что изменилось по файлам
|
||||
| Файл | Что изменилось | Зачем |
|
||||
|---|---|---|
|
||||
|
||||
Важные места
|
||||
- `file`: ...
|
||||
|
||||
Проверено
|
||||
- ...
|
||||
|
||||
Не проверено
|
||||
- ...
|
||||
```
|
||||
|
||||
### Для ревью без правок
|
||||
|
||||
```text
|
||||
Коротко
|
||||
- Самая важная проблема: ...
|
||||
- Второй приоритет: ...
|
||||
- Быстрый выигрыш: ...
|
||||
|
||||
Что я смотрел
|
||||
- ...
|
||||
|
||||
Проблемы по важности
|
||||
1. Critical/High: ...
|
||||
2. Medium: ...
|
||||
3. Low: ...
|
||||
|
||||
Что бы я сделал первым
|
||||
- ...
|
||||
```
|
||||
|
||||
### Для плана изменений
|
||||
|
||||
```text
|
||||
Коротко
|
||||
- Цель: ...
|
||||
- Затронет: ...
|
||||
- Не трогаем: ...
|
||||
|
||||
План по файлам
|
||||
| Файл/зона | Что сделать | Почему |
|
||||
|---|---|---|
|
||||
|
||||
Порядок работ
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
Проверка
|
||||
- ...
|
||||
```
|
||||
@@ -0,0 +1,109 @@
|
||||
# Skill: React / TypeScript UI
|
||||
|
||||
## Когда использовать
|
||||
|
||||
Используй этот skill при изменениях в `src`, UI, state management, typed Tauri wrappers, readiness logic, route visualization, forms, logs, buttons, popovers, service panels.
|
||||
|
||||
## Главная цель
|
||||
|
||||
UI должен ясно объяснять, что будет сделано с сетью пользователя, не прятать опасные действия и не превращаться в панель управления космической станцией ради одной прокси-кнопки.
|
||||
|
||||
## Инварианты
|
||||
|
||||
- `src/api/tauriCommands.ts` — единственное место для `invoke(...)`.
|
||||
- `src/domain/types.ts` должен отражать backend DTO.
|
||||
- Apply-readiness logic живет в `src/app/readiness.ts`.
|
||||
- Presentational UI должен переиспользовать `src/ui/*`.
|
||||
- Summary panel read-only.
|
||||
- Install/start/stop/uninstall/apply actions must be explicit.
|
||||
- Secrets must be redacted.
|
||||
|
||||
## App.tsx rule
|
||||
|
||||
`src/app/App.tsx` уже слишком большой. Новую логику не добавлять туда, если можно вынести:
|
||||
|
||||
```text
|
||||
src/app/hooks/useStartupSnapshot.ts
|
||||
src/app/hooks/useApplyFlow.ts
|
||||
src/app/hooks/useServiceControl.ts
|
||||
src/app/hooks/useSubscription.ts
|
||||
src/app/lib/parseProxy.ts
|
||||
src/app/lib/snapshots.ts
|
||||
src/app/components/RouteChain.tsx
|
||||
src/app/components/ChangesDock.tsx
|
||||
src/app/components/ProxyPanel.tsx
|
||||
src/app/components/SingBoxWorkspace.tsx
|
||||
```
|
||||
|
||||
Если изменение маленькое и локальное, допустимо править `App.tsx`, но не расширять его архитектурную роль.
|
||||
|
||||
## UI behavior rules
|
||||
|
||||
- Disabled button must explain why.
|
||||
- Loading/busy state must prevent double submit.
|
||||
- Errors must be human-readable and actionable.
|
||||
- Long paths, service names and tags must not break layout.
|
||||
- Do not use native `title` as main tooltip UX. Use existing popover/tooltip pattern.
|
||||
- Use `aria-*` for tabs, toggle buttons, popovers, service controls.
|
||||
- Honor reduced motion where relevant.
|
||||
|
||||
## Animated disclosures
|
||||
|
||||
- Keep disclosure content mounted through opening and closing so both directions can animate. Do not conditionally render content directly into its final open state.
|
||||
- Keep the trigger at one screen position and separate layout placement from hover/active transforms.
|
||||
- Gate hidden content with `aria-hidden` plus `inert` or `tabIndex`; opacity and `pointer-events` alone do not remove controls from keyboard navigation.
|
||||
- Keep `aria-expanded` and `aria-controls` on the trigger synchronized with the rendered state.
|
||||
- Use one motion origin and timeline for background, copy, and actions. Implement and verify the reverse transition at the same time as the entrance.
|
||||
|
||||
## Startup responsiveness
|
||||
|
||||
- Render the shell and saved/default configuration immediately. Do not gate first paint on network access, subscription refresh, or every component probe.
|
||||
- Show slow component detection in reserved `checking` geometry and apply partial results as they arrive without replaying page entrance motion.
|
||||
- Do not serialize independent probes to create a staged UI. When the backend exposes only an aggregate snapshot, animate reserved placeholders and replace their values in place when that snapshot arrives.
|
||||
- Keep navigation and already-known configuration usable while background detection continues.
|
||||
|
||||
## Proxy/routing UI
|
||||
|
||||
When editing route UI:
|
||||
|
||||
- External SOCKS5 mode must not require sing-box.
|
||||
- Local sing-box mode must require selected server and sing-box readiness.
|
||||
- App list must clearly distinguish process, folder, exe/path if those are different target kinds.
|
||||
- Route chain should reflect actual backend target/profile state.
|
||||
- Pending changes should be visible before apply.
|
||||
|
||||
## Tests/checks
|
||||
|
||||
Minimum:
|
||||
|
||||
```powershell
|
||||
npm run build
|
||||
```
|
||||
|
||||
Recommended for extracted pure logic:
|
||||
|
||||
- Unit tests for proxy parsing.
|
||||
- Unit tests for readiness states.
|
||||
- Unit tests for snapshot diff/change dock model.
|
||||
- UI smoke checks for desktop and narrow layout.
|
||||
- For hover, disclosure, or motion changes, exercise first open, close, repeated toggle, hover during transition, keyboard focus, loading copy, and `prefers-reduced-motion`. Build and unit tests do not validate these behaviors.
|
||||
|
||||
## Do not
|
||||
|
||||
- Do not call backend commands from random components.
|
||||
- Do not store secrets in React state longer than needed if display value can be redacted.
|
||||
- Do not duplicate Rust validation as the only validation. Frontend validation is UX, backend validation is authority.
|
||||
- Do not add a visual state that implies a service is running unless backend confirmed it.
|
||||
|
||||
## Как отчитываться
|
||||
|
||||
Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`.
|
||||
|
||||
Минимум для нетривиальной задачи:
|
||||
|
||||
- короткая сводка;
|
||||
- таблица файлов `Файл / Что изменилось / Зачем`;
|
||||
- важные места без пересказа каждой строки;
|
||||
- что проверено;
|
||||
- что не проверено;
|
||||
- конкретные риски.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Skill: Repository Orientation
|
||||
|
||||
## Когда использовать
|
||||
|
||||
Используй этот skill в начале любой нетривиальной задачи по ProxyWarden: аудит, рефакторинг, bugfix, изменение UI, изменение Windows service flow, изменение subscription/routing.
|
||||
|
||||
## Цель
|
||||
|
||||
Быстро понять, где находится нужная логика, какие инварианты нельзя нарушать и какие проверки нужны перед финальным ответом.
|
||||
|
||||
## Карта проекта
|
||||
|
||||
```text
|
||||
src/api/tauriCommands.ts typed frontend API boundary
|
||||
src/domain/types.ts TypeScript DTO/domain mirror
|
||||
src/app/App.tsx current UI orchestration, large file
|
||||
src/app/readiness.ts apply readiness/gating
|
||||
src/app/viewModel.ts view helpers
|
||||
src/ui/* reusable UI components
|
||||
src/styles/app.css global/component CSS
|
||||
|
||||
src-tauri/src/models.rs Rust domain models
|
||||
src-tauri/src/validation.rs input normalization/validation
|
||||
src-tauri/src/storage.rs JSON config/state storage
|
||||
src-tauri/src/activity.rs activity log
|
||||
src-tauri/src/subscription.rs subscription fetch/parse
|
||||
src-tauri/src/component_detection.rs component status detection
|
||||
src-tauri/src/adapters/* ProxiFyre/sing-box/proxy router adapters
|
||||
src-tauri/src/commands.rs Tauri command layer, currently too large
|
||||
src-tauri/tests/* Rust tests
|
||||
scripts/*.ps1 Windows install/control scripts
|
||||
```
|
||||
|
||||
## Source of truth
|
||||
|
||||
- Persistent app config/state: `C:\ProgramData\ProxyWarden\config` and `state`.
|
||||
- Generated artifacts: `C:\ProgramData\ProxyWarden\generated`.
|
||||
- Frontend state is not source of truth. It should represent backend state and pending UI edits.
|
||||
- Component detection/runtime status should come from backend, not guessed in UI.
|
||||
|
||||
## First-pass procedure
|
||||
|
||||
1. Identify whether the task is backend, UI, security, Windows service, subscription/routing, or testing/release.
|
||||
2. Read the matching skill file.
|
||||
3. Inspect the relevant source files listed above.
|
||||
4. Determine whether the change crosses the Tauri boundary. If yes, update both Rust DTO/command and TypeScript wrapper/types.
|
||||
5. Determine whether the change touches secrets, service control, generated configs, process execution, filesystem deletion, or network fetch. If yes, apply security checklist.
|
||||
6. Prefer small, isolated changes over broad rewrites.
|
||||
|
||||
## Do not
|
||||
|
||||
- Do not treat `App.tsx` or `commands.rs` as the correct permanent architecture just because they currently contain lots of logic.
|
||||
- Do not introduce a second storage system.
|
||||
- Do not move service/install logic into frontend.
|
||||
- Do not claim Windows service/elevation behavior is verified unless it was actually tested on Windows.
|
||||
|
||||
## Output expectations
|
||||
|
||||
For code changes, final report should include:
|
||||
|
||||
- Changed files.
|
||||
- User-visible behavior changes.
|
||||
- Internal behavior changes.
|
||||
- Tests/checks run.
|
||||
- Known unverified areas.
|
||||
|
||||
## Как отчитываться
|
||||
|
||||
Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`.
|
||||
|
||||
Минимум для нетривиальной задачи:
|
||||
|
||||
- короткая сводка;
|
||||
- таблица файлов `Файл / Что изменилось / Зачем`;
|
||||
- важные места без пересказа каждой строки;
|
||||
- что проверено;
|
||||
- что не проверено;
|
||||
- конкретные риски.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Skill: Rust / Tauri Backend
|
||||
|
||||
## Когда использовать
|
||||
|
||||
Используй этот skill при изменениях в `src-tauri`, Tauri commands, Rust models, validation, storage, adapters, component detection, subscription fetching, config generation или service orchestration.
|
||||
|
||||
## Главная цель
|
||||
|
||||
Держать backend надежным, типизированным и безопасным. Backend управляет системой пользователя, поэтому «ну вроде работает» здесь примерно как инструкция по посадке самолета, написанная на салфетке.
|
||||
|
||||
## Инварианты
|
||||
|
||||
- Tauri command layer должен быть thin boundary, а не склад всей бизнес-логики.
|
||||
- Долгие/blocking операции не должны выполняться на async runtime thread.
|
||||
- Все ошибки, которые видит UI, должны быть structured and actionable.
|
||||
- Validation должна происходить на backend даже если UI уже проверяет input.
|
||||
- Generated config writes должны быть atomic where practical.
|
||||
- Secrets never logged or fully displayed.
|
||||
|
||||
## Preferred command pattern
|
||||
|
||||
Для тяжелых операций:
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub async fn some_command(input: SomeInput) -> Result<SomeOutput, CommandError> {
|
||||
tauri::async_runtime::spawn_blocking(move || some_command_impl(input))
|
||||
.await
|
||||
.map_err(background_task_error)?
|
||||
}
|
||||
```
|
||||
|
||||
Implementation function должна быть тестируемой без Tauri runtime, если возможно.
|
||||
|
||||
## Startup responsiveness
|
||||
|
||||
- Keep first paint independent from network access, subscription refresh, and slow component detection.
|
||||
- Run independent startup probes concurrently and outside the async runtime thread. Do not serialize ProxiFyre, sing-box, service, and admin checks without a dependency between them.
|
||||
- Give external or process-heavy probes a bounded timeout and return partial status when one probe is slow or unavailable.
|
||||
- Load saved configuration and other cheap state first. Let the UI render it while detection results update separately or through a partial startup snapshot.
|
||||
- Do not fail the entire startup snapshot because one optional component cannot be detected. Preserve structured per-component errors or unknown/checking state.
|
||||
|
||||
## DTO boundary
|
||||
|
||||
При добавлении или изменении command:
|
||||
|
||||
1. Rust input/output DTO.
|
||||
2. TypeScript DTO in `src/domain/types.ts`.
|
||||
3. Wrapper in `src/api/tauriCommands.ts`.
|
||||
4. UI usage.
|
||||
5. Tests for pure logic.
|
||||
|
||||
Не использовать `serde_json::Value` как permanent API, если структура известна.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Не использовать `unwrap()`/`expect()` в production path.
|
||||
- Возвращать `CommandError { code, message, details }`.
|
||||
- Для validation использовать список проблем, а не первую попавшуюся ошибку.
|
||||
- Internal error text не должен раскрывать secrets.
|
||||
- Если операция partial, вернуть partial state/result where possible.
|
||||
|
||||
## Storage
|
||||
|
||||
При изменении `storage.rs`:
|
||||
|
||||
- Keep tmp + bak write pattern.
|
||||
- Не превращать invalid JSON в default молча. Prefer corruption backup/restore path.
|
||||
- Для critical writes использовать same-directory temp file and rename.
|
||||
- Не хранить derived artifacts как source of truth.
|
||||
- Не хранить raw subscription/proxy secrets в activity log.
|
||||
|
||||
## Refactoring guidance
|
||||
|
||||
`src-tauri/src/commands.rs` слишком большой. Новую логику по возможности выносить:
|
||||
|
||||
```text
|
||||
commands/dto.rs
|
||||
commands/status.rs
|
||||
commands/profiles.rs
|
||||
commands/targets.rs
|
||||
commands/components.rs
|
||||
commands/subscription.rs
|
||||
commands/proxifyre.rs
|
||||
commands/singbox.rs
|
||||
services/elevated.rs
|
||||
services/powershell.rs
|
||||
services/proxifyre_service.rs
|
||||
services/singbox_service.rs
|
||||
```
|
||||
|
||||
При рефакторинге сохранять external command names, чтобы UI не ломался без причины.
|
||||
|
||||
## Validation checklist
|
||||
|
||||
- `cargo fmt --all -- --check`
|
||||
- `cargo clippy --all-targets --all-features -- -D warnings`
|
||||
- `cargo test --all-targets`
|
||||
- Relevant Windows/manual check if touching service/install/elevation.
|
||||
- When changing startup aggregation, verify that one delayed or failed probe does not postpone unrelated saved state or component results.
|
||||
|
||||
Если `cargo` недоступен в среде, честно написать, что backend проверен только статически. Не изображать компилятор, у него и так тяжелая жизнь.
|
||||
|
||||
## Как отчитываться
|
||||
|
||||
Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`.
|
||||
|
||||
Минимум для нетривиальной задачи:
|
||||
|
||||
- короткая сводка;
|
||||
- таблица файлов `Файл / Что изменилось / Зачем`;
|
||||
- важные места без пересказа каждой строки;
|
||||
- что проверено;
|
||||
- что не проверено;
|
||||
- конкретные риски.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Skill: Security Hardening
|
||||
|
||||
## Когда использовать
|
||||
|
||||
Используй этот skill при изменениях, связанных с Tauri security, CSP, secrets, subscription fetching, process execution, PowerShell, temporary files, install/uninstall, file writes, generated configs, service control, logs, diagnostics.
|
||||
|
||||
## Threat model
|
||||
|
||||
ProxyWarden — desktop app that can influence network routing and run elevated Windows operations. Главные риски:
|
||||
|
||||
- leaking proxy/subscription credentials;
|
||||
- unsafe local/network fetches;
|
||||
- unsafe generated elevated PowerShell scripts;
|
||||
- unscoped process execution;
|
||||
- corrupting generated/service configs;
|
||||
- deleting wrong directories;
|
||||
- stale component status causing wrong actions;
|
||||
- XSS/webview compromise amplified by privileged backend commands.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
- `tauri.conf.json` must not use `"csp": null` as final state.
|
||||
- Do not add broad Tauri shell permissions.
|
||||
- Do not execute user-controlled strings as commands.
|
||||
- Do not log full subscription URLs, proxy passwords, userinfo, access tokens, or outbound configs with credentials.
|
||||
- Do not recursively delete directories based only on fuzzy name matching.
|
||||
- Do not treat fuzzy-detected services as managed without verification.
|
||||
|
||||
## CSP guidance
|
||||
|
||||
Prefer a restrictive CSP such as:
|
||||
|
||||
```json
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' asset: data:; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
||||
}
|
||||
```
|
||||
|
||||
Tighten further when possible. If inline styles are removed, remove `'unsafe-inline'`.
|
||||
|
||||
## Secret redaction
|
||||
|
||||
For URLs use a parser, not string splitting. Redacted display should include only:
|
||||
|
||||
- scheme;
|
||||
- host;
|
||||
- port if useful;
|
||||
- generic path marker if necessary.
|
||||
|
||||
Never show:
|
||||
|
||||
- username;
|
||||
- password;
|
||||
- query string;
|
||||
- fragment;
|
||||
- subscription token path;
|
||||
- full proxy credentials.
|
||||
|
||||
Bad:
|
||||
|
||||
```text
|
||||
https://user:password@example.com/...
|
||||
```
|
||||
|
||||
Good:
|
||||
|
||||
```text
|
||||
https://example.com/...
|
||||
```
|
||||
|
||||
## Subscription fetch hardening
|
||||
|
||||
- Add connect/read timeout.
|
||||
- Accept only `http` and `https` unless explicitly designed otherwise.
|
||||
- Consider blocking loopback/private/link-local/multicast/metadata addresses by default.
|
||||
- Add explicit allow-local option only if needed.
|
||||
- Do not follow redirects into blocked address ranges without re-check.
|
||||
- Avoid storing remote body in logs.
|
||||
|
||||
## Temp/elevated script hardening
|
||||
|
||||
Runtime-generated elevated scripts must:
|
||||
|
||||
- use unpredictable names, preferably UUID/random;
|
||||
- be written to a safe controlled directory where possible;
|
||||
- set restrictive ACL when practical;
|
||||
- be generated from static templates with escaped parameters;
|
||||
- avoid including secrets in command line args;
|
||||
- be cleaned up best-effort;
|
||||
- fail closed if path validation fails.
|
||||
|
||||
Timestamp-only temp names are not enough.
|
||||
|
||||
## Atomic writes
|
||||
|
||||
For config files used by services:
|
||||
|
||||
1. Write to temp file in same directory.
|
||||
2. Validate temp file if validator exists.
|
||||
3. Backup current file.
|
||||
4. Rename temp to final.
|
||||
5. On failure, preserve backup and return actionable error.
|
||||
|
||||
## Safe delete checklist
|
||||
|
||||
Before recursive delete:
|
||||
|
||||
- Is path absolute?
|
||||
- Is it under expected managed root?
|
||||
- Does it contain ProxyWarden marker metadata?
|
||||
- Does service PathName point inside this directory?
|
||||
- Is it not drive root, user profile root, Desktop, ProgramData root, Windows directory, temp root?
|
||||
- Is user action explicit?
|
||||
|
||||
If answer is unclear, do not delete.
|
||||
|
||||
## Final report expectations
|
||||
|
||||
When touching security-sensitive code, report:
|
||||
|
||||
- what threat was addressed;
|
||||
- what was hardened;
|
||||
- what remains unverified;
|
||||
- whether any secrets could appear in logs/UI;
|
||||
- whether Windows elevated path was tested.
|
||||
|
||||
## Как отчитываться
|
||||
|
||||
Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`.
|
||||
|
||||
Минимум для нетривиальной задачи:
|
||||
|
||||
- короткая сводка;
|
||||
- таблица файлов `Файл / Что изменилось / Зачем`;
|
||||
- важные места без пересказа каждой строки;
|
||||
- что проверено;
|
||||
- что не проверено;
|
||||
- конкретные риски.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Skill: Subscriptions and Routing
|
||||
|
||||
## Когда использовать
|
||||
|
||||
Используй этот skill при изменениях в external SOCKS5 flow, Local sing-box flow, subscription fetching/parsing, server selection, ping/check route, ProxiFyre config generation или sing-box config generation.
|
||||
|
||||
## Mental model
|
||||
|
||||
Supported route shapes:
|
||||
|
||||
```text
|
||||
selected Windows apps -> ProxiFyre -> external SOCKS5 proxy
|
||||
```
|
||||
|
||||
```text
|
||||
selected Windows apps -> ProxiFyre -> 127.0.0.1:1080 Local sing-box -> selected subscription server
|
||||
```
|
||||
|
||||
ProxiFyre is the per-app router. Sing-box is optional local outbound runtime.
|
||||
|
||||
## Invariants
|
||||
|
||||
- External SOCKS5 must work without Local sing-box.
|
||||
- Local sing-box route requires installed/configured/running sing-box and selected server.
|
||||
- UI route chain must match generated backend config.
|
||||
- Subscription URL is secret.
|
||||
- Server identity should not rely only on non-unique human tag forever.
|
||||
- Generated config should be validated before apply/start where possible.
|
||||
|
||||
## Subscription rules
|
||||
|
||||
When changing subscription parsing:
|
||||
|
||||
- Preserve JSON outbound support.
|
||||
- Do not claim support for link formats that parser does not implement.
|
||||
- If adding VMess/Trojan/Shadowsocks link parsing, add tests for each.
|
||||
- Keep unsupported outbound types visible as unsupported, not silently dropped if this affects user expectation.
|
||||
- Redact subscription URL and outbound secrets in logs/UI.
|
||||
|
||||
## Ping/check rules
|
||||
|
||||
- Network checks should have timeout.
|
||||
- Checks should be cancel-safe where possible.
|
||||
- Do not make route check mutate config/service state.
|
||||
- Do not store external IP probe result as secret, but avoid over-logging.
|
||||
- Make it clear if check verifies local sing-box only, external proxy only, or full route.
|
||||
|
||||
## Config generation
|
||||
|
||||
For ProxiFyre:
|
||||
|
||||
- Respect selected app targets.
|
||||
- Deduplicate carefully, preferably case-insensitive where Windows semantics apply.
|
||||
- Validate target kind semantics: process name vs exe path vs folder.
|
||||
- Write config atomically.
|
||||
|
||||
For sing-box:
|
||||
|
||||
- Validate selected server/outbound exists.
|
||||
- Avoid duplicate tag ambiguity by introducing stable id if needed.
|
||||
- Run `sing-box check` when binary is available.
|
||||
- Avoid writing secrets to temp files outside safe app directories.
|
||||
|
||||
## Tests to add for changes
|
||||
|
||||
- External route without sing-box.
|
||||
- Local route with selected server.
|
||||
- Missing ProxiFyre blocks apply.
|
||||
- Missing sing-box blocks local route apply.
|
||||
- Duplicate server tags.
|
||||
- Redacted subscription display.
|
||||
- Unsupported subscription formats.
|
||||
- Timeout/fetch failure.
|
||||
- Config generation produces expected route chain.
|
||||
|
||||
## Как отчитываться
|
||||
|
||||
Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`.
|
||||
|
||||
Минимум для нетривиальной задачи:
|
||||
|
||||
- короткая сводка;
|
||||
- таблица файлов `Файл / Что изменилось / Зачем`;
|
||||
- важные места без пересказа каждой строки;
|
||||
- что проверено;
|
||||
- что не проверено;
|
||||
- конкретные риски.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Skill: Testing, CI and Release
|
||||
|
||||
## Когда использовать
|
||||
|
||||
Используй этот skill при добавлении CI, release scripts, build fixes, test changes, dependency updates, packaging changes или перед финальным отчетом по крупной задаче.
|
||||
|
||||
## Minimal local checks
|
||||
|
||||
Frontend:
|
||||
|
||||
```powershell
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
Rust:
|
||||
|
||||
```powershell
|
||||
cd src-tauri
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
cargo test --all-targets
|
||||
```
|
||||
|
||||
Tauri:
|
||||
|
||||
```powershell
|
||||
npm run tauri -- info
|
||||
npm run tauri -- build
|
||||
```
|
||||
|
||||
PowerShell plan-only:
|
||||
|
||||
```powershell
|
||||
& .\scripts\install-control-app.ps1 -PlanOnly
|
||||
& .\scripts\install-proxyfier.ps1 -PlanOnly
|
||||
& .\scripts\install-singbox.ps1 -PlanOnly
|
||||
```
|
||||
|
||||
## Interaction smoke for UI motion
|
||||
|
||||
Build, lint, and unit tests do not validate motion or pointer behavior. For any hover, disclosure, stagger, or hit-target change, verify:
|
||||
|
||||
- first open and first close;
|
||||
- repeated and rapid toggle;
|
||||
- hover and click before, during, and after transition;
|
||||
- keyboard focus and hidden-control tab order;
|
||||
- loading and longest localized labels;
|
||||
- `prefers-reduced-motion`;
|
||||
- desktop and narrow window geometry.
|
||||
|
||||
Use a controlled mock or preview state when backend status is difficult to reproduce. If no visual interaction smoke is possible, report that evidence as missing and do not claim the motion task is complete.
|
||||
|
||||
## CI recommendation
|
||||
|
||||
Add GitHub Actions with at least:
|
||||
|
||||
- frontend build on Windows and Ubuntu if practical;
|
||||
- Rust fmt/clippy/test;
|
||||
- PowerShell syntax/plan-only smoke on Windows;
|
||||
- Tauri build on Windows for release branches/tags;
|
||||
- artifact upload only for trusted release workflow.
|
||||
|
||||
## Dependency updates
|
||||
|
||||
When changing dependencies:
|
||||
|
||||
- Update lockfiles.
|
||||
- Check Tauri v2 compatibility.
|
||||
- Avoid adding large UI/runtime dependencies for tiny tasks.
|
||||
- Avoid adding shell/process libraries that bypass existing backend boundaries.
|
||||
- Note why dependency is needed.
|
||||
|
||||
## Release hygiene
|
||||
|
||||
Before release:
|
||||
|
||||
- Verify app version in `package.json` and Tauri config if applicable.
|
||||
- Verify icons/assets size.
|
||||
- Verify CSP and capabilities.
|
||||
- Verify no raw secrets/test URLs in repo.
|
||||
- Verify installer scripts with `-PlanOnly`.
|
||||
- Verify clean install on Windows VM.
|
||||
- Verify external SOCKS5 flow.
|
||||
- Verify local sing-box subscription flow.
|
||||
- Verify uninstall/safe cleanup behavior.
|
||||
|
||||
## Final report format
|
||||
|
||||
```text
|
||||
Changed:
|
||||
- ...
|
||||
|
||||
Verified:
|
||||
- npm run build
|
||||
- cargo test
|
||||
|
||||
Not verified:
|
||||
- Windows elevated install/uninstall, because ...
|
||||
|
||||
Risks:
|
||||
- ...
|
||||
```
|
||||
|
||||
Do not write “all tests pass” unless all listed relevant tests actually ran. Humanity has enough fictional dashboards.
|
||||
|
||||
## Как отчитываться
|
||||
|
||||
Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`.
|
||||
|
||||
Минимум для нетривиальной задачи:
|
||||
|
||||
- короткая сводка;
|
||||
- таблица файлов `Файл / Что изменилось / Зачем`;
|
||||
- важные места без пересказа каждой строки;
|
||||
- что проверено;
|
||||
- что не проверено;
|
||||
- конкретные риски.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Skill: Windows Services / PowerShell / Elevation
|
||||
|
||||
## Когда использовать
|
||||
|
||||
Используй этот skill при изменениях в `scripts/*.ps1`, ProxiFyre install/start/stop/uninstall, sing-box service control, UAC/admin checks, helper/elevation boundary, component detection.
|
||||
|
||||
## Цель
|
||||
|
||||
Сохранять service/install operations явными, безопасными и проверяемыми. Пользователь должен понимать, что приложение собирается менять в системе. Компьютер пользователя — не песочница для творческих экспериментов агента, как ни печально.
|
||||
|
||||
## Инварианты
|
||||
|
||||
- Install/start/stop/uninstall are explicit user actions.
|
||||
- `apply` must not silently install/uninstall/start/stop components unless that behavior is clearly designed and surfaced.
|
||||
- `-PlanOnly` scripts must be side-effect-free.
|
||||
- PowerShell output intended for UI/backend must be structured JSON.
|
||||
- Service detection must distinguish managed service from fuzzy candidate.
|
||||
- Never relax safe-path checks to make uninstall easier.
|
||||
|
||||
## Script rules
|
||||
|
||||
PowerShell scripts should:
|
||||
|
||||
- use `Set-StrictMode -Version Latest` where practical;
|
||||
- set `$ErrorActionPreference = 'Stop'`;
|
||||
- return structured JSON for plan/status paths;
|
||||
- avoid localized text parsing for control flow;
|
||||
- avoid writing secrets to host output;
|
||||
- have clear exit codes;
|
||||
- support `-PlanOnly` for dry-run/status checks;
|
||||
- avoid downloading/executing arbitrary remote scripts.
|
||||
|
||||
## Elevation rules
|
||||
|
||||
When launching elevated PowerShell:
|
||||
|
||||
- keep command fixed and parameters escaped;
|
||||
- avoid user-controlled script text;
|
||||
- avoid predictable temp script names;
|
||||
- do not pass secrets via command line;
|
||||
- verify script path before launch;
|
||||
- clean up temp artifacts best-effort;
|
||||
- return clear error if user cancels UAC.
|
||||
|
||||
## Service detection
|
||||
|
||||
Preferred approach:
|
||||
|
||||
1. Search known managed service names first.
|
||||
2. Read service `PathName` through WMI/CIM.
|
||||
3. Verify binary path and managed install metadata.
|
||||
4. Only then mark as managed/controllable.
|
||||
5. Fuzzy matches should be shown as candidates, not automatically controlled.
|
||||
|
||||
## Testing
|
||||
|
||||
Pure logic can be tested cross-platform with mocks.
|
||||
|
||||
Real verification requires Windows:
|
||||
|
||||
```powershell
|
||||
& .\scripts\install-control-app.ps1 -PlanOnly
|
||||
& .\scripts\install-proxyfier.ps1 -PlanOnly
|
||||
& .\scripts\install-singbox.ps1 -PlanOnly
|
||||
npm run tauri -- dev
|
||||
```
|
||||
|
||||
For real service tests:
|
||||
|
||||
- Windows 10/11.
|
||||
- Admin/UAC path.
|
||||
- Fresh machine or VM snapshot.
|
||||
- Existing ProxiFyre/sing-box absent.
|
||||
- Existing fuzzy ProxiFyre-like service present, if testing safety.
|
||||
|
||||
## Do not
|
||||
|
||||
- Do not claim actual service operations were tested unless they were run on Windows.
|
||||
- Do not parse human-localized `sc.exe` output if structured WMI/CIM data is available.
|
||||
- Do not delete paths from fuzzy discovery alone.
|
||||
- Do not make scripts silently modify firewall/proxy/system settings outside their stated purpose.
|
||||
|
||||
## Как отчитываться
|
||||
|
||||
Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`.
|
||||
|
||||
Минимум для нетривиальной задачи:
|
||||
|
||||
- короткая сводка;
|
||||
- таблица файлов `Файл / Что изменилось / Зачем`;
|
||||
- важные места без пересказа каждой строки;
|
||||
- что проверено;
|
||||
- что не проверено;
|
||||
- конкретные риски.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Change Report
|
||||
|
||||
## Коротко
|
||||
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Что изменилось | Зачем |
|
||||
|---|---|---|
|
||||
| `path/file` | | |
|
||||
|
||||
## Важные детали
|
||||
|
||||
-
|
||||
|
||||
## Проверки
|
||||
|
||||
- ✅/⚠️ `command` — результат простыми словами.
|
||||
|
||||
## Не проверено
|
||||
|
||||
-
|
||||
|
||||
## Риски / что потом
|
||||
|
||||
-
|
||||
@@ -0,0 +1,30 @@
|
||||
# Concise Change Summary Template
|
||||
|
||||
## Коротко
|
||||
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
## Что изменилось по файлам
|
||||
|
||||
| Файл | Что изменилось | Зачем |
|
||||
|---|---|---|
|
||||
| `path/to/file` | | |
|
||||
|
||||
## Важные места
|
||||
|
||||
- `path/to/file`, функция/секция:
|
||||
- `path/to/file`, функция/секция:
|
||||
|
||||
## Проверено
|
||||
|
||||
-
|
||||
|
||||
## Не проверено
|
||||
|
||||
-
|
||||
|
||||
## Риски
|
||||
|
||||
-
|
||||
@@ -0,0 +1,20 @@
|
||||
# File Impact Map Template
|
||||
|
||||
Используй для плана или ревью, когда нужно заранее показать, какие файлы будут затронуты.
|
||||
|
||||
| Зона | Файлы | Что будет сделано | Почему это нужно | Риск |
|
||||
|---|---|---|---|---|
|
||||
| Frontend API | `src/api/tauriCommands.ts` | | | Low/Medium/High |
|
||||
| Frontend UI | `src/app/...` | | | Low/Medium/High |
|
||||
| Backend command | `src-tauri/src/...` | | | Low/Medium/High |
|
||||
| Storage/config | `src-tauri/src/storage.rs` | | | Low/Medium/High |
|
||||
| Windows/elevation | `scripts/*.ps1` | | | Low/Medium/High |
|
||||
| Docs/agent | `.agent/...` | | | Low/Medium/High |
|
||||
|
||||
## Что не трогаем
|
||||
|
||||
-
|
||||
|
||||
## Как проверить после изменений
|
||||
|
||||
-
|
||||
@@ -0,0 +1,27 @@
|
||||
# Investigation Report
|
||||
|
||||
## Коротко
|
||||
|
||||
- Главный вывод:
|
||||
- Где проблема:
|
||||
- Что делать первым:
|
||||
|
||||
## Что смотрел
|
||||
|
||||
| Файл / зона | Зачем смотрел | Вывод |
|
||||
|---|---|---|
|
||||
| `path/file` | | |
|
||||
|
||||
## Находки
|
||||
|
||||
| Приоритет | Где | Что не так | Как исправить |
|
||||
|---|---|---|---|
|
||||
| Критично / Важно / Можно потом / Косметика | `path/file` | | |
|
||||
|
||||
## Проверки
|
||||
|
||||
-
|
||||
|
||||
## Ограничения анализа
|
||||
|
||||
-
|
||||
@@ -0,0 +1,32 @@
|
||||
## Коротко
|
||||
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
## Файлы / зоны
|
||||
|
||||
| Файл / зона | Что изменилось | Зачем |
|
||||
|---|---|---|
|
||||
| `path/file` | | |
|
||||
|
||||
## Пользовательское поведение
|
||||
|
||||
- Что пользователь увидит:
|
||||
- Что не должно измениться:
|
||||
|
||||
## Технические детали
|
||||
|
||||
-
|
||||
|
||||
## Проверки
|
||||
|
||||
- [ ] `npm run build`
|
||||
- [ ] `cargo fmt --all -- --check`
|
||||
- [ ] `cargo clippy --all-targets --all-features -- -D warnings`
|
||||
- [ ] `cargo test --all-targets`
|
||||
- [ ] Windows manual smoke, если затронуты service/elevation/install/routing
|
||||
|
||||
## Не проверено / риски
|
||||
|
||||
-
|
||||
@@ -0,0 +1,27 @@
|
||||
# User-facing Summary
|
||||
|
||||
## Коротко
|
||||
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
## Что изменилось простыми словами
|
||||
|
||||
-
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Что изменилось | Зачем |
|
||||
|---|---|---|
|
||||
| `path/file` | | |
|
||||
|
||||
## Что важно знать
|
||||
|
||||
-
|
||||
|
||||
## Проверки и риски
|
||||
|
||||
- ✅ Проверено:
|
||||
- ⚠️ Не проверено:
|
||||
- Риск:
|
||||
@@ -0,0 +1,23 @@
|
||||
# Work Plan
|
||||
|
||||
## Коротко
|
||||
|
||||
Сделаю так:
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Какие файлы, вероятно, затрону
|
||||
|
||||
| Файл / зона | Что планируется | Зачем |
|
||||
|---|---|---|
|
||||
| `path/file` | | |
|
||||
|
||||
## Что проверю
|
||||
|
||||
-
|
||||
|
||||
## Что может остаться непроверенным
|
||||
|
||||
-
|
||||
@@ -0,0 +1,4 @@
|
||||
# `cargo run` / `tauri dev` should control the components installed by ProxyWarden,
|
||||
# not copies that happen to exist beside target\debug\proxywarden.exe.
|
||||
[env]
|
||||
PROXYWARDEN_DEV_INSTALL_ROOT = { value = 'C:\Program Files\ProxyWarden', force = false }
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: design-proxywarden-ui
|
||||
description: Design, implement, review, or refine ProxyWarden UI using the shared calm monospace VPN-client language: centered state control, green-tinted neutrals, route-aware accents, stable geometry, and smooth state-driven motion. Use for React components, CSS, service controls, routing views, tooltips, status transitions, and responsive polish in this repository.
|
||||
---
|
||||
|
||||
# Design ProxyWarden UI
|
||||
|
||||
Keep ProxyWarden a compact Windows utility while matching the visual language of the sibling VPN client.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read `AGENTS.md`, `.agent/skills/react-typescript-ui/SKILL.md`, and the complete component and CSS being changed.
|
||||
2. Read [visual-language.md](references/visual-language.md) for composition, typography, color, and surfaces.
|
||||
3. Read [motion-and-interaction.md](references/motion-and-interaction.md) for state and interaction animation.
|
||||
4. Before editing a disclosure or motion-heavy control, write a compact storyboard for `collapsed`, `opening`, `open`, and `closing`: fixed elements, origin, direction, duration, easing, focus, and reduced-motion behavior.
|
||||
5. Reuse `src/ui/*`, existing state, CSS tokens, and typed Tauri boundaries. Prefer CSS and narrow markup changes over dependencies or new abstractions.
|
||||
6. Keep geometry stable across loading, success, error, copy, refresh, and route changes.
|
||||
7. Add `prefers-reduced-motion` behavior with every new animation.
|
||||
8. After a second user correction to the same interaction, stop stacking overrides. Re-read its markup and styles, restate the latest behavior, remove superseded assumptions, and rebuild the motion model cleanly.
|
||||
9. Run `npm test`, `npm run build`, and an interaction smoke for visible motion or hit-target changes. Check desktop and narrow layouts; build and lint never substitute for visual verification.
|
||||
|
||||
## Non-negotiable decisions
|
||||
|
||||
- Preserve explicit install, start, stop, uninstall, and apply actions. Styling must not blur operational meaning.
|
||||
- Keep the summary read-only except for its existing service power action; do not add configuration mutations there.
|
||||
- Render the primary power action as a generous invisible hit target around the icon, not a large filled accent circle.
|
||||
- Use the blue-green accent for ready/active routing and orange only for direct/local-route distinction. Keep warnings and errors semantic.
|
||||
- Prefer open composition, quiet surface shifts, and localized light over dashboard cards, thick borders, and decorative chrome.
|
||||
- Animate opacity, blur, glow, color, filter, and transform; never animate layout properties or use `transition: all`.
|
||||
- Keep interactive triggers at one screen position throughout disclosure motion. Use layout for resting placement, never a transform that hover or active feedback can overwrite.
|
||||
- Make transient prompts independent overlays; they must not add shell height or move the main workspace.
|
||||
- Keep labels, paths, status copy, spinners, and feedback in reserved geometry so neighboring content does not move.
|
||||
- Keep tooltips independent from transformed, rotating, glowing, or filtered controls.
|
||||
- Keep secrets and credential-bearing URLs redacted in every visual state.
|
||||
- Keep narrow layouts single-column and keyboard focus visible.
|
||||
- Do not call a motion task complete without checking open, close, repeated toggle, hover during transition, keyboard focus, and reduced motion. If the state cannot be reproduced, report the missing visual evidence explicitly.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Motion and interaction
|
||||
|
||||
## Character
|
||||
|
||||
Use fluid, slightly viscous motion that makes work and state legible without moving layout. Avoid bounce, elastic easing, abrupt unmounts, and decorative page choreography.
|
||||
|
||||
Use `cubic-bezier(0.16, 1, 0.3, 1)` for arrivals and interaction feedback.
|
||||
|
||||
- hover and press: 180-300ms;
|
||||
- popover/tooltip: 90-180ms;
|
||||
- panel reveal: 420-600ms;
|
||||
- state color and glow: 600-900ms;
|
||||
- progress or numeric tween: about 900ms.
|
||||
|
||||
## State controls
|
||||
|
||||
- Transition inactive gray to the route accent slowly when a service becomes active, and back to gray when stopped.
|
||||
- Animate icon color, localized light, and SVG shadow together while keeping the hit target fixed.
|
||||
- Use a short `scale(0.97)` press followed by a slower release.
|
||||
- Show checking and running work with restrained motion that finishes cleanly; do not stop spinners or cycles at arbitrary coordinates.
|
||||
|
||||
## Changing content
|
||||
|
||||
- Crossfade alternate labels inside one fixed slot. Do not replace text in normal flow when its length can move the interface.
|
||||
- Animate only what changed. Unchanged labels, icons, surrounding rows, and route nodes stay fixed.
|
||||
- Update data immediately when it arrives; finishing a decorative cycle must not delay the result.
|
||||
- Repeated background polling updates quietly and does not replay entrance choreography.
|
||||
- Keep mode selectors outside the keyed content they replace. Let the new content enter with a short directional fade and blur while focus remains on the selected mode.
|
||||
- For user-triggered sorting, fade and lightly blur the reordered list as one surface; row stagger stays bounded and saved data order does not change.
|
||||
|
||||
## Anchored disclosures
|
||||
|
||||
- Keep the trigger fixed while its surface opens and closes. Position its resting hit area with grid, flex, or logical inset properties; never rely on a placement `transform` that hover or active feedback can replace.
|
||||
- Give the surface, background, copy, and actions one origin and one timeline. They should emerge from the trigger together; do not make the background pop before the trigger or appear after the content.
|
||||
- Keep animated disclosure content mounted through entry and exit. Gate pointer and keyboard access separately; conditional rendering directly into the final state is not an entrance animation.
|
||||
- Design opening and closing together. Preserve visible reverse motion long enough before fading opacity, and keep both directions interruptible under repeated clicks.
|
||||
- Let explicit product feedback override the default easing. When a component calls for a slow start followed by acceleration, define a local curve instead of forcing the global ease-out.
|
||||
- Compose hover and active feedback without changing the resting position. If transform composition is unavoidable, use separate wrappers, individual transform properties, or shared custom properties and verify every state.
|
||||
- Keep decorative sweeps subordinate to state motion, low-opacity, bounded to the surface, and finished cleanly. The disclosure must remain legible without the effect.
|
||||
|
||||
## Lists and disclosures
|
||||
|
||||
- Reveal dynamic rows with opacity, light blur, and a small transform.
|
||||
- On hover, let a row lift one or two pixels and reveal a restrained local surface/light; keep resting rows visually flat.
|
||||
- Animate status dots through color, light, and a small scale change instead of animating a surrounding badge or border.
|
||||
- Keep departing rows and disclosures mounted until their exit animation completes; remove immediately under reduced motion.
|
||||
- Bound list stagger to 60-100ms and never make interaction latency grow with list length.
|
||||
- Tooltips appear quickly above the trigger as independent translucent surfaces and never inherit trigger transforms or filters.
|
||||
|
||||
## Reduced motion
|
||||
|
||||
Under `prefers-reduced-motion: reduce`, remove transforms, filters, transitions, and keyframes while preserving final state, focus, contrast, status wording, and all functionality.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Visual language
|
||||
|
||||
## Character
|
||||
|
||||
Design for a Windows user opening a small control surface to check routing, recover a service, or apply one deliberate configuration change. The UI should feel soft, precise, dependable, and slightly terminal-like, not like a network administration dashboard.
|
||||
|
||||
## Composition
|
||||
|
||||
- Make current system state and the next safe action dominant.
|
||||
- Keep the summary power control visually centered and pair it with a compact vertical route chain.
|
||||
- Use open space, typography, subtle surface shifts, localized light, and state color before frames or dividers.
|
||||
- Keep service rows compact: status, human-readable detail, one primary action, then secondary actions.
|
||||
- Preserve the existing tabs and operational grouping; visual consistency does not justify moving ownership or hiding actions.
|
||||
|
||||
## Typography and geometry
|
||||
|
||||
- Use JetBrains Mono with uppercase tracked micro-labels only for metadata.
|
||||
- Use weight and color before large size jumps. Use tabular numerals for changing values.
|
||||
- Reserve equal space for mutually exclusive labels and feedback.
|
||||
- Use 8px controls, 10px surfaces, and pills only for status tokens.
|
||||
- Keep icon-only hit areas at least 40px and align icons in flex/grid rather than guessed offsets.
|
||||
|
||||
## Color and light
|
||||
|
||||
- Base dark surfaces on green-tinted OKLCH neutrals around hue 145.
|
||||
- Use blue-green `oklch(0.68 0.11 185)` as the primary active/focus accent.
|
||||
- Use orange `oklch(0.71 0.12 72)` for direct/local-route distinction, never as general decoration.
|
||||
- Keep warning/error colors semantic. Do not recolor destructive actions with the route accent.
|
||||
- Prefer localized `drop-shadow`, text glow, or a soft radial light layer over filled accent containers.
|
||||
- Keep inactive power neutral even on hover; color communicates state, not clickability alone.
|
||||
|
||||
## Surfaces and controls
|
||||
|
||||
- Use quiet translucent cloud surfaces for tooltips and transient overlays.
|
||||
- Inputs are inset and slightly darker than surrounding surfaces.
|
||||
- Avoid nested cards. Group related controls with spacing and one subtle surface shift.
|
||||
- Keep persistent work surfaces borderless by default. Use a border only when it communicates input focus, destructive confirmation, or another essential state.
|
||||
- Render statuses and counters as a glowing dot or quiet value plus text, not as bordered badge capsules.
|
||||
- Let service rows, route nodes, app rows, and server rows float on the shared canvas; reveal their surface only on hover, focus, selection, or active work.
|
||||
- Prefer a short luminous underline or localized glow for selection and keyboard focus over a rectangular focus frame.
|
||||
- Use shared `src/ui` primitives and preserve their default, hover, active, focus, disabled, loading, empty, and error states.
|
||||
|
||||
## Emphasis and border budget
|
||||
|
||||
- Give each compact surface one dominant accent at most. A transient warning action must not outshine the primary system state or its trigger.
|
||||
- Do not stack borders on the container, trigger, and action. Start with tonal background, spacing, and text hierarchy; keep persistent outlines for keyboard focus, destructive confirmation, or an otherwise ambiguous hit target.
|
||||
- Treat warm warning color as a restrained semantic tint, not decorative fill or a large glow. Adapt a shared `primary` button locally when its default emphasis conflicts with the surrounding prompt.
|
||||
- Validate the complete component, not isolated controls: resting, hover, focus, active, disabled, loading, open, and closed states must share one radius and emphasis language.
|
||||
|
||||
## Route checks
|
||||
|
||||
- Keep the route description, endpoint, and check action in a stable three-part row. Reserve the action width so mode changes and endpoint length never move the button.
|
||||
- Present the endpoint as the named route target, not as a detached badge or a second result.
|
||||
- Reveal a borderless result surface only while a check is running or after it completes. Show every returned probe in a structured table with separate status, external IP, and latency columns; do not compress unlike values into mixed badges or hardcode a fixed probe count.
|
||||
- Keep the summary short. Put verbose URLs, request methods, status codes, and errors in a calm structured detail cloud opened by hovering or focusing the result surface.
|
||||
- Animate result arrival and status light, while preserving the same geometry and honoring reduced motion.
|
||||
|
||||
## Route chain semantics
|
||||
|
||||
- Show only stages with distinct user-facing responsibilities. Never render both `Выход` and `SOCKS5 endpoint` when they describe the same destination.
|
||||
- Use `Приложения → ProxiFyre → SOCKS5` for the external-proxy route. End a direct route with `Интернет: напрямую` instead of an implementation-stage label.
|
||||
- Explain each stage in plain Russian for a non-technical user. Omit filesystem paths, ports, service names, and generated-config details unless the user explicitly asks for diagnostics.
|
||||
- Reserve the final chain height before revealing nodes. Progressive arrival may change opacity, blur, or transform, but must not reflow neighboring content.
|
||||
- Treat progressive arrival as a presentation sequence over reserved slots. Do not serialize independent backend probes just to match the animation; if the API returns one aggregate snapshot, show calm `checking` placeholders and replace them in place.
|
||||
- Reveal the initial chain in a short, legible sequence and do not replay it for background polling or quiet status refreshes.
|
||||
|
||||
## Admin elevation prompt
|
||||
|
||||
- Render the prompt as a fixed bottom-right overlay that never changes shell height or shifts the workspace. Offset it above persistent bottom docks instead of covering them.
|
||||
- Keep the collapsed trigger as a stationary 44px warm shield. Show a concise hint once per application session after admin status is known, then dismiss it automatically.
|
||||
- On click, expand the surface leftward from the shield while the shield stays in the same screen position. Keep the full row height tied to the trigger.
|
||||
- Reveal background, copy, and action from the same origin and timeline. Use a roughly 520-560ms slow-start opening and a visible 380-420ms reverse close; never delay the background until the end.
|
||||
- Keep any light pass subtle, local, and optional. It must not replace the actual surface/content motion.
|
||||
- Use the concise title `Нужны права администратора`, the reason `Для управления ProxiFyre и правилами Windows.`, and the action `Перезапустить`. Do not show paths or elevation internals.
|
||||
- Keep the surface and action borderless by default. Use a muted warm tint; the action must remain quieter than the shield and main system state.
|
||||
- Verify the Russian copy, `Открываю UAC`, hover, focus, repeated toggle, narrow width, and Windows text scaling without clipping or layout movement.
|
||||
|
||||
## Route modes and managed lists
|
||||
|
||||
- Present external and local proxy routes as two peer choices above the content they replace. Keep the chooser mounted while the mode body crossfades in from the selected direction.
|
||||
- Reserve the same configuration-stage height for both routes and place it before route diagnostics, so mode-specific labels and controls remain aligned even when check results expand.
|
||||
- Use the blue-green accent for the external route and the warm route accent for Local sing-box. A small status light and quiet surface shift are enough; do not add a long selection rule.
|
||||
- A green service light means running, not merely installed. Installed-without-service, stopped, and missing states remain warning-colored.
|
||||
- Hovering service and application rows reveals a neutral side marker and slight positional response. Do not place a green radial wash behind the entire row.
|
||||
- Application grouping is display-only. Preserve saved order as the default, provide explicit Processes, EXE files, and Folders sections with counts, and keep alphabetical sorting as a separate option. Remount only the visible list surface so changes can fade into place.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
description: ProxyWarden repository rules for Cursor agents
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# ProxyWarden Cursor Rules
|
||||
|
||||
Read `AGENTS.md` before editing. Use `.agent/skills/*/SKILL.md` for task-specific guidance.
|
||||
|
||||
## Core rules
|
||||
|
||||
- Keep ProxyWarden a standalone Windows desktop utility: Tauri 2 + React/TypeScript + Rust.
|
||||
- Preserve separation between Control App, ProxiFyre and Local sing-box.
|
||||
- External SOCKS5 route must work without sing-box.
|
||||
- UI must call backend through `src/api/tauriCommands.ts`, not direct random `invoke(...)` calls.
|
||||
- Backend must validate all inputs even if UI validates them.
|
||||
- Do not leak subscription URLs, proxy credentials or outbound secrets.
|
||||
- Keep install/start/stop/uninstall explicit.
|
||||
- Do not relax safe deletion or elevated script rules.
|
||||
- Prefer shrinking `src-tauri/src/commands.rs` and `src/app/App.tsx` over adding more logic there.
|
||||
- Run relevant checks and state unverified Windows/elevation behavior honestly.
|
||||
|
||||
## Communication rules
|
||||
|
||||
Before non-trivial answers, follow `.agent/skills/communication-reporting/SKILL.md` and `.agent/checklists/communication.md`.
|
||||
|
||||
- Write final answers in short, structured Russian unless the user asks otherwise.
|
||||
- Start with `Коротко` for nontrivial work.
|
||||
- For 2+ files, use a table with `Файл / Что изменилось / Зачем`.
|
||||
- Do not dump every diff line. Mention important functions/sections only.
|
||||
- Clearly split `Проверено` and `Не проверено`.
|
||||
- Do not claim Windows/UAC/service checks were done unless they actually ran.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Copilot Instructions for ProxyWarden
|
||||
|
||||
Read `AGENTS.md` first. Follow the repo invariants there.
|
||||
|
||||
## Key reminders
|
||||
|
||||
- This is a standalone Windows Tauri 2 + React/TypeScript + Rust app.
|
||||
- Do not turn it into a SaaS, gateway, server or cloud control plane.
|
||||
- Do not call `invoke(...)` outside `src/api/tauriCommands.ts`.
|
||||
- Do not make Local sing-box required for external SOCKS5 routing.
|
||||
- Do not hide install/start/stop/uninstall behind apply.
|
||||
- Do not log or display full subscription URLs, proxy credentials or outbound secrets.
|
||||
- Keep Tauri capabilities minimal and CSP enabled.
|
||||
- Treat `commands.rs` and `App.tsx` as large legacy orchestration files that should shrink over time.
|
||||
- For security-sensitive changes, read `.agent/skills/security-hardening/SKILL.md`.
|
||||
- For Windows service/elevation changes, read `.agent/skills/windows-services-powershell/SKILL.md`.
|
||||
|
||||
## Reporting style
|
||||
|
||||
Before non-trivial answers, follow `.agent/skills/communication-reporting/SKILL.md` and `.agent/checklists/communication.md`.
|
||||
|
||||
- Start with `Коротко`: 2-4 main points.
|
||||
- For code changes, include a `Файл / Что изменилось / Зачем` table.
|
||||
- Explain only important behavior, safety, UX and risk points. Do not retell every line.
|
||||
- Split checks into `Проверено` and `Не проверено`.
|
||||
- State unverified Windows/UAC/service behavior honestly.
|
||||
@@ -0,0 +1,73 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
windows-baseline:
|
||||
name: Windows baseline
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
run: rustup show
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check frontend formatting
|
||||
run: npm run format:check
|
||||
|
||||
- name: Run frontend lints
|
||||
run: npm run lint
|
||||
|
||||
- name: Check frontend types
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run frontend tests
|
||||
run: npm test -- --run
|
||||
|
||||
- name: Build frontend
|
||||
run: npm run build
|
||||
|
||||
- name: Check Rust formatting
|
||||
working-directory: src-tauri
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Run Rust lints
|
||||
working-directory: src-tauri
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
- name: Run Rust tests
|
||||
working-directory: src-tauri
|
||||
run: cargo test --all-targets
|
||||
|
||||
- name: Check Tauri environment
|
||||
run: npm run tauri -- info
|
||||
|
||||
- name: Plan control app installer
|
||||
shell: pwsh
|
||||
run: .\scripts\install-control-app.ps1 -PlanOnly
|
||||
|
||||
- name: Plan ProxiFyre installer
|
||||
shell: pwsh
|
||||
run: .\scripts\install-proxyfier.ps1 -PlanOnly
|
||||
|
||||
- name: Plan sing-box installer
|
||||
shell: pwsh
|
||||
run: .\scripts\install-singbox.ps1 -PlanOnly
|
||||
|
||||
- name: Plan Windows smoke evidence capture
|
||||
shell: pwsh
|
||||
run: .\scripts\audit-windows-smoke.ps1 -Mode PlanOnly
|
||||
@@ -1,72 +1,198 @@
|
||||
# Инструкции для агентов
|
||||
# AGENTS.md
|
||||
|
||||
## Контекст проекта
|
||||
## Назначение
|
||||
|
||||
ProxyWarden - standalone Windows desktop client в корне репозитория. Это Tauri 2 + React/TypeScript UI + Rust backend для маршрутизации выбранных Windows-приложений через внешний SOCKS5-прокси или опциональный Local sing-box.
|
||||
ProxyWarden — standalone Windows desktop-приложение для удобного per-app proxy routing. Стек: Tauri 2, Rust backend, React/TypeScript frontend, Vite, PowerShell installer/control scripts. Приложение управляет выбранными Windows-приложениями через ProxiFyre и, опционально, через локальный sing-box runtime.
|
||||
|
||||
Не возвращать старую идею `APP_MODE=windows` и не подключать Windows-клиент к отдельному Node gateway/server. Текущий рабочий путь - `src`, `src-tauri`, `scripts` в корне репозитория.
|
||||
Этот файл — главный контракт для кодового агента. Любой агент, который меняет репозиторий, обязан соблюдать эти правила. Да, даже если ему очень хочется «быстренько поправить одну кнопочку» и случайно переписать половину сетевого стека. Особенно тогда.
|
||||
|
||||
## Основные инварианты
|
||||
## Продуктовая рамка
|
||||
|
||||
- Три компонента должны оставаться разделенными: Control App, ProxiFyre, Local sing-box.
|
||||
- ProxiFyre - обязательный слой для per-app routing; Local sing-box - необязательный runtime.
|
||||
- Внешний SOCKS5 flow должен работать без установленного Local sing-box.
|
||||
- Profile apply не должен скрыто устанавливать, удалять, запускать или чинить компоненты. Install/start/stop/uninstall - только явные действия пользователя.
|
||||
- Source of truth - JSON под `C:\ProgramData\ProxyWarden\config` и `state`.
|
||||
- `C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json` и `sing-box-config.json` - derived artifacts, их можно пересоздать.
|
||||
- Subscription URL и другие секреты нельзя показывать полностью в UI, diagnostics или логах.
|
||||
- Summary panel должен оставаться read-only: без apply/install/start/stop/delete/input/subscription mutations.
|
||||
Проект не должен превращаться в коммерческий SaaS, Node gateway, VPN-провайдер, proxy server или облачный control plane. Это локальная Windows-утилита для себя и друзей.
|
||||
|
||||
Цель: надежно и понятно конфигурировать маршрутизацию выбранных приложений через внешний SOCKS5 proxy или через локальный sing-box, не ломая системную сеть и не пряча опасные действия за безобидными кнопками.
|
||||
|
||||
## Архитектурные инварианты
|
||||
|
||||
- Control App, ProxiFyre и Local sing-box — разные компоненты. Не смешивать их ответственность.
|
||||
- ProxiFyre — обязательный слой для per-app routing.
|
||||
- Local sing-box — optional runtime. Внешний SOCKS5 flow обязан работать без sing-box.
|
||||
- React UI не пишет generated config напрямую. UI вызывает typed Tauri commands.
|
||||
- `src/api/tauriCommands.ts` — единственная TypeScript-обертка над `invoke(...)`.
|
||||
- Rust backend отвечает за storage, validation, config generation, component detection, service/install orchestration и structured errors.
|
||||
- `C:\ProgramData\ProxyWarden\config` и `C:\ProgramData\ProxyWarden\state` — source of truth.
|
||||
- `C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json` и `sing-box-config.json` — derived artifacts. Их можно пересоздавать.
|
||||
- Install/start/stop/uninstall — только явные действия пользователя. `apply` не должен скрыто устанавливать, удалять или «чинить» компоненты.
|
||||
- Subscription URL, credentials, proxy passwords и userinfo нельзя выводить полностью в UI, logs, diagnostics, crash text или activity.
|
||||
- Summary panel должен оставаться read-only: без install/start/stop/apply/delete/input/subscription mutations.
|
||||
- Любые elevated операции должны быть максимально явными и проверяемыми.
|
||||
|
||||
## Основная структура
|
||||
|
||||
```text
|
||||
src/
|
||||
api/tauriCommands.ts # typed invoke facade
|
||||
app/App.tsx # текущая UI orchestration зона, слишком крупная
|
||||
app/readiness.ts # apply gating logic
|
||||
app/viewModel.ts # display/view helpers
|
||||
domain/types.ts # TypeScript DTO mirror
|
||||
ui/* # reusable presentational components
|
||||
styles/app.css # основной CSS
|
||||
|
||||
src-tauri/
|
||||
tauri.conf.json # Tauri config, security, window config
|
||||
capabilities/default.json # Tauri permissions/capabilities
|
||||
src/models.rs # Rust domain models/defaults
|
||||
src/validation.rs # normalization/validation
|
||||
src/storage.rs # JSON storage, tmp/bak writes
|
||||
src/activity.rs # activity log
|
||||
src/subscription.rs # subscription fetch/parse
|
||||
src/component_detection.rs # ProxiFyre/sing-box detection
|
||||
src/singbox_service.rs # sing-box Windows service logic
|
||||
src/process.rs # process/system helpers
|
||||
src/helper.rs # helper/elevation boundary
|
||||
src/adapters/* # ProxiFyre/sing-box/proxy-router adapters
|
||||
src/commands.rs # Tauri command handlers; currently too large
|
||||
tests/* # Rust integration/domain tests
|
||||
|
||||
scripts/
|
||||
install-control-app.ps1
|
||||
install-proxyfier.ps1
|
||||
install-singbox.ps1
|
||||
prepare-release.ps1
|
||||
```
|
||||
|
||||
## Агентские skill-модули
|
||||
|
||||
Подробные инструкции лежат в `.agent/skills`:
|
||||
|
||||
- `.agent/skills/repository-orientation/SKILL.md` — как быстро понять репозиторий.
|
||||
- `.agent/skills/rust-tauri-backend/SKILL.md` — Rust/Tauri backend changes.
|
||||
- `.agent/skills/react-typescript-ui/SKILL.md` — frontend/UI changes.
|
||||
- `.agent/skills/security-hardening/SKILL.md` — CSP, секреты, temp files, storage, SSRF, elevated boundary.
|
||||
- `.agent/skills/windows-services-powershell/SKILL.md` — Windows service/install/PowerShell изменения.
|
||||
- `.agent/skills/subscriptions-routing/SKILL.md` — subscription, sing-box, ProxiFyre routing.
|
||||
- `.agent/skills/testing-ci-release/SKILL.md` — проверки, CI, release hygiene.
|
||||
- `.agent/skills/communication-reporting/SKILL.md` — короткие понятные планы, ревью и отчеты с таблицами файлов.
|
||||
|
||||
Перед сложным изменением прочитать релевантный skill. Перед любым нетривиальным ответом владельцу проекта — прочитать `communication-reporting`. Да, инструкция про то, как не писать кашу, теперь тоже инструкция. Так мы и живем.
|
||||
|
||||
## Стиль общения агента
|
||||
|
||||
Пользователь — разработчик, но ему не нужен роман о каждом `match`, `useState` и переименованном импорте. Писать надо как для человека, которому нужно быстро принять решение: что изменилось, где изменилось, зачем и что проверить.
|
||||
|
||||
Перед любым нетривиальным ответом прочитать `.agent/skills/communication-reporting/SKILL.md` и перед финальным сообщением пройти `.agent/checklists/communication.md`.
|
||||
|
||||
### Обязательные правила
|
||||
|
||||
- Сначала результат, потом детали.
|
||||
- Короткие абзацы, списки и таблицы вместо полотна текста.
|
||||
- Для нетривиальных изменений использовать таблицу `Файл / Что изменилось / Зачем`.
|
||||
- Не объяснять каждую строку. Объяснять важные места, решения, риски и поведение.
|
||||
- Технические термины использовать только когда они помогают. Сложный термин объяснять одной простой фразой.
|
||||
- Проверки делить на выполненные, не выполненные и требующие Windows/manual check.
|
||||
- Для ревью группировать находки по приоритетам: `Критично`, `Важно`, `Можно потом`, `Косметика`.
|
||||
- Не писать корпоративный туман вроде «улучшена архитектура» без указания, что именно стало проще, безопаснее или понятнее.
|
||||
- Не заявлять “всё проверено”, если Rust tests, Windows service flow, Tauri build или UAC сценарии не запускались.
|
||||
|
||||
### Минимальный формат финального ответа
|
||||
|
||||
```md
|
||||
## Коротко
|
||||
|
||||
- 1-3 главных результата.
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Что изменилось | Зачем |
|
||||
|---|---|---|
|
||||
| `path/file` | простое описание | практическая причина |
|
||||
|
||||
## Проверки
|
||||
|
||||
| Проверка | Статус | Комментарий |
|
||||
|---|---|---|
|
||||
| `command` | выполнено / не выполнено | почему |
|
||||
|
||||
## Риски
|
||||
|
||||
- Что осталось проверить или почему риска нет.
|
||||
```
|
||||
|
||||
Если задача маленькая, формат можно сжать до нескольких строк. Если задача security/service/storage/routing-sensitive, детали обязательны, потому что «ну вроде работает» — это не инженерный метод, а жанр народного фольклора.
|
||||
|
||||
## Структура
|
||||
|
||||
- `src/app/App.tsx` - основная React-оркестрация, вкладки `Сводка`, `ProxiFyre`, `VPN / Прокси`, вызовы Tauri-команд и transient UI state.
|
||||
- `src/app/readiness.ts` - gating применимости маршрута. Не обходить его локальными проверками в JSX.
|
||||
- `src/app/viewModel.ts` - маленькие display/view-model helpers.
|
||||
- `src/ui/*` - общие presentational-компоненты. Для новых кнопок, вкладок, service rows, pills, полей и лог-дока сначала расширять эти компоненты.
|
||||
- `src/api/tauriCommands.ts` - единственная TypeScript-обертка над `invoke(...)`; держать DTO в синхронизации с Rust.
|
||||
- `src/domain/types.ts` - TypeScript-зеркало доменных DTO.
|
||||
- `src-tauri/src/models.rs` - Rust-модели и default values.
|
||||
- `src-tauri/src/validation.rs` - нормализация входов.
|
||||
- `src-tauri/src/storage.rs` и `activity.rs` - JSON storage, backup/tmp writes, activity cap/sort.
|
||||
- `src-tauri/src/adapters/proxy_router.rs` - adapter boundary для proxy-router.
|
||||
- `src-tauri/src/adapters/proxifyre.rs` - первый adapter, генерирует ProxiFyre `app-config.json`.
|
||||
- `src-tauri/src/adapters/singbox.rs` - генерация локального `sing-box` конфига из subscription cache и выбранного сервера.
|
||||
- `src-tauri/src/component_detection.rs` - detection ProxiFyre/Proxifier/Local sing-box.
|
||||
- `src-tauri/src/commands.rs` - Tauri command handlers, installer/service orchestration, structured errors.
|
||||
- `src-tauri/src/main.rs` - реальная Tauri entrypoint-регистрация команд.
|
||||
- `src-tauri/src/lib.rs` сейчас scaffold/stale; не считать его источником регистрации команд без отдельной cleanup-задачи.
|
||||
- `scripts/*.ps1` - явные installer entrypoints. `-PlanOnly` должен возвращать structured JSON без side effects.
|
||||
|
||||
## Правила изменений
|
||||
|
||||
- Не создавать второй источник правды для профилей, targets, components, subscription или activity.
|
||||
- Не писать generated config напрямую из React.
|
||||
- Не парсить raw PowerShell/stdout в UI. Backend/helper boundary должен возвращать structured JSON/error DTO.
|
||||
- Не привязывать UI напрямую к деталям ProxiFyre, если изменение относится к общему proxy-router поведению.
|
||||
- Не делать Local sing-box обязательным для external target.
|
||||
- Для service/install операций сохранять UAC/admin boundary и человекочитаемые ошибки.
|
||||
- При удалении install folders сохранять safe-path checks; не ослаблять рекурсивное удаление.
|
||||
- В UI держать стиль компактной Windows-утилиты, а не landing/dashboard. Использовать existing `Button`, `Tabs`, `ServiceControlRow`, `StatusPill`, `Field`, `ActionMenu`, `LogDock`.
|
||||
- Всплывающие подсказки при наведении делать быстрыми, кастомными и читаемыми: темная compact-плашка с мягкой рамкой/тенью, появление ~120ms, без нативного browser `title` как основного UI. Для иконок расширять общий `IconButton`/tooltip-паттерн, а не дублировать JSX/CSS локально.
|
||||
- Apply actions должны быть disabled с объяснением, когда нет приложений, ProxiFyre отсутствует, proxy input неверный или local route не готов.
|
||||
- Не оставлять dev-серверы (`npm run dev`, `npm run tauri -- dev`, preview-серверы) запущенными после проверки. Если сервер был поднят агентом, остановить его перед финальным ответом.
|
||||
### Backend
|
||||
|
||||
## Проверка
|
||||
- Не добавлять новую Tauri command без typed wrapper в `src/api/tauriCommands.ts` и соответствующего TypeScript DTO в `src/domain/types.ts`, если command используется UI.
|
||||
- Не возвращать raw strings для сложных ошибок. Использовать structured error DTO: `code`, `message`, `details`.
|
||||
- Тяжелые или блокирующие операции должны быть `async` command + `tauri::async_runtime::spawn_blocking`.
|
||||
- Не вызывать network/process/service/file-heavy logic прямо из async runtime thread.
|
||||
- Не использовать `unwrap()`/`expect()` в production path, кроме очевидно невозможных bootstrap cases с комментарием.
|
||||
- Не писать generated configs неатомарно. Использовать temp + backup + rename where practical.
|
||||
- Не расширять `commands.rs` без необходимости. Для новой логики предпочитать отдельные модули и thin command wrapper.
|
||||
|
||||
Минимум для frontend/UI:
|
||||
### Frontend
|
||||
|
||||
- Не увеличивать `App.tsx`, если можно вынести hook/helper/component.
|
||||
- Не вызывать `invoke(...)` напрямую вне `src/api/tauriCommands.ts`.
|
||||
- Не дублировать apply-readiness проверки в JSX. Расширять `src/app/readiness.ts`.
|
||||
- Для UI использовать существующие компоненты из `src/ui`.
|
||||
- Apply/start/install/delete buttons должны иметь disabled state и понятную причину.
|
||||
- Не показывать secrets. Для subscription/proxy URL использовать redacted display values.
|
||||
- UI должен оставаться compact Windows utility, а не SaaS dashboard с иллюзией корпоративной важности.
|
||||
|
||||
### Security
|
||||
|
||||
- Не отключать CSP. Если CSP мешает, исправлять source policy, а не ставить `csp: null`.
|
||||
- Не добавлять Tauri shell permissions без жесткого scope и отдельного обоснования.
|
||||
- Не запускать произвольные команды из UI input.
|
||||
- Runtime-generated elevated scripts должны использовать непредсказуемые имена, safe directory/ACL и cleanup best-effort.
|
||||
- Удаление директорий допускается только после safe-path/marker/service-path checks.
|
||||
- Subscription fetch должен иметь timeout и защиту от очевидно опасных/local metadata адресов либо explicit allow-mode.
|
||||
|
||||
### Windows/service boundary
|
||||
|
||||
- `-PlanOnly` у PowerShell scripts должен оставаться side-effect-free и возвращать structured JSON.
|
||||
- Install/start/stop/uninstall должны быть явными user actions.
|
||||
- Fuzzy-detected service не считать managed service без проверки `PathName`/metadata.
|
||||
- В Linux/macOS CI не пытаться «проверить» Windows service operations как реальные. Тестировать pure logic/mocks.
|
||||
|
||||
## Известный технический долг
|
||||
|
||||
- `src-tauri/src/commands.rs` слишком большой. Главная цель рефакторинга: разрезать на модули по use-case.
|
||||
- `src/app/App.tsx` слишком большой. Главная цель frontend-рефакторинга: hooks/components/view-model helpers.
|
||||
- `tauri.conf.json` сейчас требует security review, особенно CSP и window resize settings.
|
||||
- JSON storage молча возвращает default при invalid JSON. Нужен corruption recovery через `.bak` и user-visible warning.
|
||||
- ProxiFyre config apply должен стать atomic.
|
||||
- Subscription URL redaction должен исключать userinfo/password.
|
||||
- Link subscription parser сейчас ориентирован на VLESS; не обещать больше, чем реально поддерживается.
|
||||
- Ping/select по server tag может ломаться при duplicate tags. Нужен stable server id.
|
||||
|
||||
## Минимальная проверка перед ответом
|
||||
|
||||
Для docs-only изменений достаточно проверить структуру файлов и отсутствие очевидных Markdown/JSON ошибок.
|
||||
|
||||
Для frontend изменений:
|
||||
|
||||
```powershell
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
Rust/backend:
|
||||
Для Rust/backend изменений:
|
||||
|
||||
```powershell
|
||||
cd D:\repos\ProxyWarden\src-tauri
|
||||
cargo test
|
||||
cd src-tauri
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
cargo test --all-targets
|
||||
```
|
||||
|
||||
Tauri/toolchain:
|
||||
Для Tauri/toolchain:
|
||||
|
||||
```powershell
|
||||
npm run tauri -- info
|
||||
@@ -74,7 +200,7 @@ npm run tauri -- dev
|
||||
npm run tauri -- build
|
||||
```
|
||||
|
||||
Installer boundaries:
|
||||
Для installer boundaries:
|
||||
|
||||
```powershell
|
||||
& .\scripts\install-control-app.ps1 -PlanOnly
|
||||
@@ -82,10 +208,25 @@ Installer boundaries:
|
||||
& .\scripts\install-singbox.ps1 -PlanOnly
|
||||
```
|
||||
|
||||
Для UI-изменений проверять browser-preview на desktop и narrow viewport. Browser-preview не доказывает native Tauri commands или elevated service lane.
|
||||
Не оставлять dev/preview/Tauri dev servers запущенными после проверки.
|
||||
|
||||
## Известные риски
|
||||
## Формат отчета агента
|
||||
|
||||
- Реальные elevated install/start/stop/uninstall операции для ProxiFyre и Local sing-box считаются `implemented but unproven`, пока они не проверены на Windows с UAC/admin confirmation.
|
||||
- Исторические planning/evidence файлы лежат в ignored `docs`-папках и не должны попадать в коммиты.
|
||||
- Старые документы могут ссылаться на `apps/windows-client`; текущая структура репозитория - standalone client в корне.
|
||||
Использовать один из шаблонов:
|
||||
|
||||
- `.agent/templates/work-plan.md` — короткий план перед работой.
|
||||
- `.agent/templates/change-report.md` — отчет после изменения кода.
|
||||
- `.agent/templates/investigation-report.md` — аудит, расследование, разбор проблемы.
|
||||
- `.agent/templates/user-facing-summary.md` — краткая сводка для владельца проекта.
|
||||
- `.agent/templates/pr-description.md` — описание PR.
|
||||
|
||||
Каждый нетривиальный ответ должен отвечать на вопросы:
|
||||
|
||||
1. Что поменялось или найдено?
|
||||
2. В каких файлах?
|
||||
3. Зачем это нужно?
|
||||
4. Что проверено?
|
||||
5. Что не проверено?
|
||||
6. Где остался риск?
|
||||
|
||||
Не писать «всё готово», если Windows/elevated/service flow не проверялся на Windows. Эта фраза и так слишком много навредила миру.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Участие в разработке ProxyWarden
|
||||
|
||||
ProxyWarden остается локальной Windows-утилитой. Изменения не должны превращать проект в VPN-провайдер, proxy server, SaaS или облачный control plane. Перед работой прочитайте `AGENTS.md` и релевантный skill из `.agent/skills`.
|
||||
|
||||
## Локальная проверка
|
||||
|
||||
```powershell
|
||||
npm ci
|
||||
npm run format:check
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm test -- --run
|
||||
npm run build
|
||||
|
||||
Push-Location src-tauri
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
cargo test --all-targets
|
||||
Pop-Location
|
||||
|
||||
npm run tauri -- info
|
||||
& .\scripts\install-control-app.ps1 -PlanOnly
|
||||
& .\scripts\install-proxyfier.ps1 -PlanOnly
|
||||
& .\scripts\install-singbox.ps1 -PlanOnly
|
||||
& .\scripts\audit-windows-smoke.ps1 -Mode PlanOnly
|
||||
```
|
||||
|
||||
Windows service, UAC, installer и реальный routing нельзя считать проверенными только по unit-тестам. Для таких изменений укажите выполненный ручной сценарий или явно оставьте этот пробел в отчете.
|
||||
|
||||
## Изменения
|
||||
|
||||
- Держите `src/api/tauriCommands.ts` единственным TypeScript facade над Tauri `invoke`.
|
||||
- Не показывайте subscription URL, credentials, proxy password или `X-HWID` в логах и UI.
|
||||
- Не добавляйте скрытые install/start/stop/uninstall действия в apply.
|
||||
- Добавляйте минимальный тест для новой ветвящейся логики.
|
||||
- Не коммитьте runtime-файлы из `C:\ProgramData\ProxyWarden` и generated output.
|
||||
|
||||
В pull request кратко опишите поведение, затронутые файлы, выполненные проверки и оставшиеся Windows/manual риски.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ProxyWarden contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -143,6 +143,8 @@ C:\ProgramData\ProxyWarden\generated\sing-box-config.json
|
||||
|
||||
Subscription URL считается секретом. UI и diagnostics должны показывать только редактированную/сокращенную версию ссылки.
|
||||
|
||||
При загрузке подписки ProxyWarden отправляет провайдеру стандартные идентификационные заголовки приложения и `X-HWID` - случайный постоянный UUID этой установки. Это не серийный номер оборудования, но провайдер может использовать его для связывания запросов одной установки. Проверка маршрута делает HTTPS-запросы через выбранный proxy к Cloudflare и ipify, чтобы подтвердить выход и определить внешний IP.
|
||||
|
||||
## Типовые сценарии
|
||||
|
||||
### Внешний SOCKS5
|
||||
@@ -224,6 +226,10 @@ Browser-preview годится для проверки интерфейса, н
|
||||
Frontend/UI:
|
||||
|
||||
```powershell
|
||||
npm run format:check
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm test -- --run
|
||||
npm run build
|
||||
```
|
||||
|
||||
@@ -253,6 +259,8 @@ Installer boundaries:
|
||||
## Ограничения текущей версии
|
||||
|
||||
- Основной поддержанный маршрут - SOCKS5.
|
||||
- Link-подписки разбирают VLESS, VMess, Trojan и Shadowsocks; sing-box JSON также принимает поддержанные proxy outbounds. Неизвестные форматы отклоняются явно.
|
||||
- Для VLESS outbound без собственного `packet_encoding` генератор добавляет `xudp`; значение, заданное провайдером подписки, не перезаписывается.
|
||||
- ProxiFyre является текущим backend-слоем для per-app routing.
|
||||
- Local sing-box остается опциональным и не требуется для внешнего SOCKS5.
|
||||
- Elevated install/start/stop/uninstall операции считаются реализованными, но требуют дополнительной проверки на реальной Windows-машине с UAC/admin confirmation.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# ProxyWarden Architecture Notes for Agents
|
||||
|
||||
## Current design
|
||||
|
||||
ProxyWarden is a local Windows control app. It does not proxy traffic by itself. It orchestrates:
|
||||
|
||||
1. ProxiFyre for per-app routing.
|
||||
2. Optional local sing-box for subscription-based outbound routing.
|
||||
3. External SOCKS5 target for direct proxy routing.
|
||||
|
||||
## Route modes
|
||||
|
||||
External SOCKS5:
|
||||
|
||||
```text
|
||||
selected Windows apps -> ProxiFyre -> external SOCKS5
|
||||
```
|
||||
|
||||
Local sing-box:
|
||||
|
||||
```text
|
||||
selected Windows apps -> ProxiFyre -> 127.0.0.1:1080 -> sing-box selected outbound
|
||||
```
|
||||
|
||||
## Main risks
|
||||
|
||||
- Large orchestration files: `commands.rs` and `App.tsx`.
|
||||
- Security-sensitive elevated operations.
|
||||
- Secrets in subscription/proxy config.
|
||||
- Non-atomic writes to generated configs.
|
||||
- Stale component status.
|
||||
- Duplicate sing-box server tags.
|
||||
- UI/business logic entanglement.
|
||||
|
||||
## Desired direction
|
||||
|
||||
Backend:
|
||||
|
||||
- Thin Tauri command handlers.
|
||||
- Use-case modules.
|
||||
- Testable pure functions.
|
||||
- Structured errors.
|
||||
- Atomic writes and hardened temp/elevation handling.
|
||||
|
||||
Frontend:
|
||||
|
||||
- Smaller `App.tsx`.
|
||||
- Extracted hooks/components.
|
||||
- Typed API boundary.
|
||||
- Testable readiness/snapshot/proxy parsing logic.
|
||||
- Clear UX for pending changes and service state.
|
||||
|
||||
Security:
|
||||
|
||||
- CSP enabled.
|
||||
- Minimal Tauri capabilities.
|
||||
- No broad shell permission.
|
||||
- Redacted secrets.
|
||||
- Safe deletion.
|
||||
- Explicit Windows service control.
|
||||
@@ -0,0 +1,21 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist/**', 'src-tauri/**'] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
document: 'readonly',
|
||||
HTMLElement: 'readonly',
|
||||
HTMLDivElement: 'readonly',
|
||||
requestAnimationFrame: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
window: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+1608
-3
File diff suppressed because it is too large
Load Diff
+13
-3
@@ -1,13 +1,18 @@
|
||||
{
|
||||
"name": "proxywarden",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Standalone Windows desktop proxy management app for ProxyWarden.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build": "npm run typecheck && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -19,11 +24,16 @@
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"eslint": "^10.7.0",
|
||||
"prettier": "^3.9.5",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^7.0.0"
|
||||
"typescript-eslint": "^8.63.0",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2356
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
components = ["rustfmt", "clippy"]
|
||||
@@ -0,0 +1,182 @@
|
||||
param(
|
||||
[ValidateSet("PlanOnly", "Capture")]
|
||||
[string]$Mode = "PlanOnly",
|
||||
[string]$DataRoot = "C:\ProgramData\ProxyWarden",
|
||||
[string]$ProxiFyreRoot = "C:\Tools\ProxiFyre",
|
||||
[string]$SingBoxRoot = "C:\Program Files\ProxyWarden\sing-box",
|
||||
[string]$ForeignServiceName = "",
|
||||
[string]$OutputPath = ""
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function New-Result {
|
||||
param(
|
||||
[bool]$Success,
|
||||
[string]$Action,
|
||||
[bool]$Changed,
|
||||
[string]$Message,
|
||||
[hashtable]$Details
|
||||
)
|
||||
|
||||
[ordered]@{
|
||||
success = $Success
|
||||
action = $Action
|
||||
changed = $Changed
|
||||
message = $Message
|
||||
details = $Details
|
||||
} | ConvertTo-Json -Depth 8
|
||||
}
|
||||
|
||||
function Get-ServiceEvidence {
|
||||
param([string[]]$Names)
|
||||
|
||||
$result = @()
|
||||
foreach ($name in $Names | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique) {
|
||||
$escaped = $name.Replace("'", "''")
|
||||
$service = Get-CimInstance Win32_Service -Filter "Name='$escaped'" -ErrorAction SilentlyContinue
|
||||
if ($null -eq $service) {
|
||||
$result += [ordered]@{ name = $name; found = $false }
|
||||
continue
|
||||
}
|
||||
|
||||
$result += [ordered]@{
|
||||
name = $service.Name
|
||||
found = $true
|
||||
state = $service.State
|
||||
startMode = $service.StartMode
|
||||
pathName = $service.PathName
|
||||
processId = [int]$service.ProcessId
|
||||
}
|
||||
}
|
||||
return $result
|
||||
}
|
||||
|
||||
function Test-PathUnderRoot {
|
||||
param([string]$Path, [string]$Root)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Path) -or [string]::IsNullOrWhiteSpace($Root)) { return $false }
|
||||
$fullPath = [IO.Path]::GetFullPath($Path).TrimEnd('\')
|
||||
$fullRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\')
|
||||
return $fullPath.Equals($fullRoot, [StringComparison]::OrdinalIgnoreCase) -or
|
||||
$fullPath.StartsWith("$fullRoot\", [StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
|
||||
function Get-ServiceExecutablePath {
|
||||
param([string]$PathName)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($PathName)) { return "" }
|
||||
$trimmed = $PathName.Trim()
|
||||
if ($trimmed.StartsWith('"')) {
|
||||
$closingQuote = $trimmed.IndexOf('"', 1)
|
||||
if ($closingQuote -gt 1) { return $trimmed.Substring(1, $closingQuote - 1) }
|
||||
}
|
||||
return ($trimmed -split '\s+', 2)[0]
|
||||
}
|
||||
|
||||
function Get-FileEvidence {
|
||||
param([string]$Root)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Root -PathType Container)) { return @() }
|
||||
return @(
|
||||
Get-ChildItem -LiteralPath $Root -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Select-Object @{N="path";E={$_.FullName}}, @{N="length";E={$_.Length}}, @{N="lastWriteTimeUtc";E={$_.LastWriteTimeUtc.ToString("o")}}
|
||||
)
|
||||
}
|
||||
|
||||
function Get-SecretFindingCategories {
|
||||
param([string]$Root)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Root -PathType Container)) { return @() }
|
||||
$patterns = [ordered]@{
|
||||
urlUserInfo = '://[^/\s"'']+@'
|
||||
credentialQuery = '(?i)[?&](token|key|auth|password|passwd|secret)=[^&\s"'']+'
|
||||
socksCredentials = '(?i)socks5://[^/\s:@]+:[^/\s@]+@'
|
||||
hwidHeader = '(?i)x-hwid[^\r\n]*[0-9a-f]{8}-[0-9a-f-]{27,}'
|
||||
}
|
||||
|
||||
$findings = @()
|
||||
$files = Get-ChildItem -LiteralPath $Root -Recurse -File -Include *.json,*.log,*.txt -ErrorAction SilentlyContinue
|
||||
foreach ($file in $files) {
|
||||
$content = Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue
|
||||
if ($null -eq $content) { continue }
|
||||
foreach ($entry in $patterns.GetEnumerator()) {
|
||||
if ($content -match $entry.Value) {
|
||||
$findings += [ordered]@{ path = $file.FullName; category = $entry.Key }
|
||||
}
|
||||
}
|
||||
}
|
||||
return $findings
|
||||
}
|
||||
|
||||
try {
|
||||
$quotedServiceFixture = '"C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe" -service'
|
||||
$quotedExecutable = Get-ServiceExecutablePath -PathName $quotedServiceFixture
|
||||
if (-not (Test-PathUnderRoot -Path $quotedExecutable -Root "C:\Program Files\ProxyWarden\sing-box")) {
|
||||
throw "Quoted service PathName ownership self-test failed."
|
||||
}
|
||||
|
||||
$plan = [ordered]@{
|
||||
mode = $Mode
|
||||
serviceNames = @("ProxiFyreService", "ProxyWardenSingBox")
|
||||
foreignServiceName = $ForeignServiceName
|
||||
roots = [ordered]@{
|
||||
data = [IO.Path]::GetFullPath($DataRoot)
|
||||
proxifyre = [IO.Path]::GetFullPath($ProxiFyreRoot)
|
||||
singbox = [IO.Path]::GetFullPath($SingBoxRoot)
|
||||
}
|
||||
checks = @("service-state-and-path", "managed-root-membership", "file-metadata", "secret-category-scan")
|
||||
}
|
||||
|
||||
if ($Mode -eq "PlanOnly") {
|
||||
New-Result -Success $true -Action "audit-windows-smoke.plan" -Changed $false -Message "Windows smoke evidence plan is ready." -Details $plan
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||
$OutputPath = Join-Path $PWD ("audit-windows-smoke-{0}.json" -f (Get-Date -Format "yyyyMMdd-HHmmss"))
|
||||
}
|
||||
$outputFullPath = [IO.Path]::GetFullPath($OutputPath)
|
||||
$outputDirectory = Split-Path -Parent $outputFullPath
|
||||
if ([string]::IsNullOrWhiteSpace($outputDirectory)) { throw "OutputPath must include a writable directory." }
|
||||
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
|
||||
|
||||
$serviceNames = @("ProxiFyreService", "ProxyWardenSingBox", $ForeignServiceName)
|
||||
$services = @(Get-ServiceEvidence -Names $serviceNames)
|
||||
$ownership = @(
|
||||
$services | Where-Object found | ForEach-Object {
|
||||
$expectedRoot = switch ($_.name) {
|
||||
"ProxiFyreService" { $ProxiFyreRoot }
|
||||
"ProxyWardenSingBox" { $SingBoxRoot }
|
||||
default { "" }
|
||||
}
|
||||
[ordered]@{
|
||||
name = $_.name
|
||||
expectedManagedRoot = if ($expectedRoot) { [IO.Path]::GetFullPath($expectedRoot) } else { $null }
|
||||
pathUnderExpectedRoot = if ($expectedRoot) { Test-PathUnderRoot -Path (Get-ServiceExecutablePath -PathName $_.pathName) -Root $expectedRoot } else { $false }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
$report = [ordered]@{
|
||||
capturedAt = (Get-Date).ToUniversalTime().ToString("o")
|
||||
computerName = $env:COMPUTERNAME
|
||||
os = (Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, OSArchitecture)
|
||||
services = $services
|
||||
ownership = $ownership
|
||||
files = @(Get-FileEvidence -Root $DataRoot)
|
||||
secretFindingCategories = @(Get-SecretFindingCategories -Root $DataRoot)
|
||||
}
|
||||
$report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $outputFullPath -Encoding UTF8
|
||||
|
||||
New-Result -Success $true -Action "audit-windows-smoke.capture" -Changed $true -Message "Read-only Windows smoke evidence captured." -Details @{
|
||||
outputPath = $outputFullPath
|
||||
serviceCount = @($services | Where-Object found).Count
|
||||
fileCount = @($report.files).Count
|
||||
secretFindingCount = @($report.secretFindingCategories).Count
|
||||
}
|
||||
} catch {
|
||||
New-Result -Success $false -Action "audit-windows-smoke.$($Mode.ToLowerInvariant())" -Changed $false -Message $_.Exception.Message -Details @{}
|
||||
exit 1
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
param(
|
||||
[string]$InstallRoot = "C:\Tools\ProxiFyre",
|
||||
[string]$InstallRoot = "C:\Program Files\ProxyWarden\components\ProxiFyre",
|
||||
[string]$PackagePath = "",
|
||||
[string]$ServiceName = "ProxiFyreService",
|
||||
[switch]$PlanOnly,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
param(
|
||||
[string]$InstallRoot = "C:\Program Files\ProxyWarden\sing-box",
|
||||
[string]$InstallRoot = "C:\Program Files\ProxyWarden\components\sing-box",
|
||||
[string]$ServiceName = "ProxyWardenSingBox",
|
||||
[string]$ConfigSource = "C:\ProgramData\ProxyWarden\generated\sing-box-config.json",
|
||||
[switch]$PlanOnly,
|
||||
@@ -77,7 +77,7 @@ function Test-SafeInstallRoot {
|
||||
$leaf = Split-Path -Leaf $full
|
||||
$parent = Split-Path -Parent $full
|
||||
if ($leaf -ne "sing-box") { return $false }
|
||||
return $parent -match "\\ProxyWarden$|\\proxywarden$"
|
||||
return $parent -match "\\ProxyWarden\\components$|\\proxywarden\\components$|\\ProxyWarden$|\\proxywarden$"
|
||||
}
|
||||
|
||||
function Backup-File {
|
||||
|
||||
@@ -386,6 +386,25 @@ function Invoke-NativeCommand {
|
||||
}
|
||||
}
|
||||
|
||||
function Clear-ReleaseBundleOutput {
|
||||
if (-not (Test-Path -LiteralPath $BundleRoot)) {
|
||||
return
|
||||
}
|
||||
|
||||
$targetRoot = Get-FullPath -Path (Join-Path $RepoRoot "src-tauri\target")
|
||||
$bundleFull = Get-FullPath -Path $BundleRoot
|
||||
if (
|
||||
$bundleFull.Equals($targetRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
-not (Test-IsSubPath -Parent $targetRoot -Child $bundleFull)
|
||||
) {
|
||||
throw "Refusing to remove bundle directory outside src-tauri target: $bundleFull"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Cleaning stale Tauri bundle output: $bundleFull"
|
||||
Remove-Item -LiteralPath $bundleFull -Recurse -Force
|
||||
}
|
||||
|
||||
function Invoke-ReleaseBuild {
|
||||
if ($SkipBuild) {
|
||||
Write-Host ""
|
||||
@@ -402,22 +421,50 @@ function Invoke-ReleaseBuild {
|
||||
Write-Host "Skipping Rust tests because -SkipTests was provided."
|
||||
}
|
||||
|
||||
Clear-ReleaseBundleOutput
|
||||
Invoke-NativeCommand -Name "Tauri release build" -FilePath "npm" -Arguments @("run", "tauri", "--", "build")
|
||||
}
|
||||
|
||||
function Get-ArtifactVersionPattern {
|
||||
param([string]$TargetVersion)
|
||||
|
||||
"(^|[^0-9A-Za-z])$([regex]::Escape($TargetVersion))([^0-9A-Za-z]|$)"
|
||||
}
|
||||
|
||||
function Copy-ReleaseArtifacts {
|
||||
param([string]$ReleaseDir)
|
||||
param(
|
||||
[string]$ReleaseDir,
|
||||
[string]$TargetVersion
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BundleRoot)) {
|
||||
throw "Tauri bundle output was not found: $BundleRoot"
|
||||
}
|
||||
|
||||
$artifactDir = Join-Path $ReleaseDir "artifacts"
|
||||
$files = Get-ChildItem -LiteralPath $BundleRoot -Recurse -File |
|
||||
Where-Object { $_.Extension -in @(".exe", ".msi", ".zip", ".sig") }
|
||||
$allFiles = @(Get-ChildItem -LiteralPath $BundleRoot -Recurse -File |
|
||||
Where-Object { $_.Extension -in @(".exe", ".msi", ".zip", ".sig") } |
|
||||
Sort-Object FullName)
|
||||
|
||||
if ($allFiles.Count -eq 0) {
|
||||
throw "No release artifacts were found under $BundleRoot."
|
||||
}
|
||||
|
||||
$versionPattern = Get-ArtifactVersionPattern -TargetVersion $TargetVersion
|
||||
$files = @($allFiles | Where-Object { $_.Name -match $versionPattern })
|
||||
$ignoredFiles = @($allFiles | Where-Object { $_.Name -notmatch $versionPattern })
|
||||
|
||||
if ($files.Count -eq 0) {
|
||||
throw "No release artifacts were found under $BundleRoot."
|
||||
$found = ($allFiles | ForEach-Object { Get-RelativePath -BasePath $BundleRoot -Path $_.FullName }) -join ", "
|
||||
throw "No release artifacts for version $TargetVersion were found under $BundleRoot. Found artifacts: $found"
|
||||
}
|
||||
|
||||
if ($ignoredFiles.Count -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Host "Ignoring bundle artifacts that do not match version ${TargetVersion}:"
|
||||
foreach ($ignored in $ignoredFiles) {
|
||||
Write-Host (" - " + (Get-RelativePath -BasePath $BundleRoot -Path $ignored.FullName))
|
||||
}
|
||||
}
|
||||
|
||||
$copied = @()
|
||||
@@ -587,7 +634,7 @@ try {
|
||||
Invoke-ReleaseBuild
|
||||
|
||||
$releaseDir = New-ReleaseDirectory -TargetVersion $targetVersion
|
||||
$artifacts = @(Copy-ReleaseArtifacts -ReleaseDir $releaseDir)
|
||||
$artifacts = @(Copy-ReleaseArtifacts -ReleaseDir $releaseDir -TargetVersion $targetVersion)
|
||||
Write-Checksums -ReleaseDir $releaseDir -Files $artifacts | Out-Null
|
||||
Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
param(
|
||||
[string]$OutputDir = (Join-Path $PSScriptRoot '..\src-tauri\bundled\proxifyre'),
|
||||
[ValidateSet('x64', 'x86', 'ARM64')]
|
||||
[string[]]$Architectures = @('x64'),
|
||||
[switch]$SkipVcRuntime
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
function Invoke-JsonApi([string]$Uri) {
|
||||
Invoke-RestMethod -Uri $Uri -Headers @{
|
||||
'User-Agent' = 'proxywarden-bundle-updater'
|
||||
'Accept' = 'application/vnd.github+json'
|
||||
} -TimeoutSec 60 -MaximumRedirection 10
|
||||
}
|
||||
|
||||
function Invoke-FileDownload([string]$Uri, [string]$Path) {
|
||||
$partialPath = "$Path.part"
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $partialPath -Headers @{
|
||||
'User-Agent' = 'proxywarden-bundle-updater'
|
||||
'Accept' = 'application/octet-stream,*/*'
|
||||
} -TimeoutSec 240 -MaximumRedirection 10
|
||||
} catch {
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
throw
|
||||
}
|
||||
|
||||
$item = Get-Item -LiteralPath $partialPath
|
||||
if ($item.Length -le 0) {
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
throw "Downloaded file is empty: $Uri"
|
||||
}
|
||||
|
||||
Move-Item -LiteralPath $partialPath -Destination $Path -Force
|
||||
}
|
||||
|
||||
function Select-ReleaseAsset($Release, [string]$Pattern, [string]$Label) {
|
||||
$asset = $Release.assets | Where-Object { $_.name -match $Pattern } | Select-Object -First 1
|
||||
if ($null -eq $asset) {
|
||||
throw "No asset found for $Label using pattern $Pattern"
|
||||
}
|
||||
|
||||
$asset
|
||||
}
|
||||
|
||||
function Save-Asset([string]$Id, [string]$Name, [string]$Url, [string]$ExpectedDigest = '') {
|
||||
$path = Join-Path $OutputDir $Name
|
||||
if (Test-Path -LiteralPath $path) {
|
||||
$existing = Get-Item -LiteralPath $path
|
||||
if ($existing.Length -gt 0) {
|
||||
$existingHash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$expectedHash = ''
|
||||
if (-not [string]::IsNullOrWhiteSpace($ExpectedDigest) -and $ExpectedDigest -match '^sha256:(.+)$') {
|
||||
$expectedHash = $Matches[1].ToLowerInvariant()
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($expectedHash) -or $existingHash -eq $expectedHash) {
|
||||
Write-Host "Using existing $Name"
|
||||
return [PSCustomObject]@{
|
||||
id = $Id
|
||||
name = $Name
|
||||
sha256 = $existingHash
|
||||
size = $existing.Length
|
||||
sourceUrl = $Url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Downloading $Name"
|
||||
Invoke-FileDownload $Url $path
|
||||
|
||||
$hash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if (-not [string]::IsNullOrWhiteSpace($ExpectedDigest) -and $ExpectedDigest -match '^sha256:(.+)$') {
|
||||
$expected = $Matches[1].ToLowerInvariant()
|
||||
if ($hash -ne $expected) {
|
||||
throw "SHA256 mismatch for $Name. Expected $expected, got $hash."
|
||||
}
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
id = $Id
|
||||
name = $Name
|
||||
sha256 = $hash
|
||||
size = (Get-Item -LiteralPath $path).Length
|
||||
sourceUrl = $Url
|
||||
}
|
||||
}
|
||||
|
||||
$resolvedOutputDir = [System.IO.Path]::GetFullPath($OutputDir)
|
||||
New-Item -ItemType Directory -Force -Path $resolvedOutputDir | Out-Null
|
||||
$OutputDir = $resolvedOutputDir
|
||||
|
||||
$selectedArchitectures = $Architectures |
|
||||
ForEach-Object {
|
||||
if ($_ -eq 'ARM64') { 'ARM64' } elseif ($_ -eq 'x86') { 'x86' } else { 'x64' }
|
||||
} |
|
||||
Select-Object -Unique
|
||||
|
||||
$proxifyreRelease = Invoke-JsonApi 'https://api.github.com/repos/wiresock/proxifyre/releases/latest'
|
||||
$ndisapiRelease = Invoke-JsonApi 'https://api.github.com/repos/wiresock/ndisapi/releases/latest'
|
||||
|
||||
$files = New-Object System.Collections.Generic.List[object]
|
||||
|
||||
foreach ($arch in $selectedArchitectures) {
|
||||
$proxifyreAsset = Select-ReleaseAsset $proxifyreRelease "ProxiFyre-.*-$arch-signed\.zip$" "ProxiFyre $arch"
|
||||
$files.Add((Save-Asset "proxifyre-$($arch.ToLowerInvariant())" $proxifyreAsset.name $proxifyreAsset.browser_download_url $proxifyreAsset.digest))
|
||||
|
||||
$ndisAsset = Select-ReleaseAsset $ndisapiRelease "Windows\.Packet\.Filter\..*\.$arch\.msi$" "Windows Packet Filter $arch"
|
||||
$files.Add((Save-Asset "packet-filter-$($arch.ToLowerInvariant())" $ndisAsset.name $ndisAsset.browser_download_url $ndisAsset.digest))
|
||||
}
|
||||
|
||||
if (-not $SkipVcRuntime) {
|
||||
if ($selectedArchitectures | Where-Object { $_ -ne 'x86' }) {
|
||||
$files.Add((Save-Asset 'vc-runtime-x64' 'vc_redist.x64.exe' 'https://aka.ms/vc14/vc_redist.x64.exe'))
|
||||
}
|
||||
if ($selectedArchitectures -contains 'x86') {
|
||||
$files.Add((Save-Asset 'vc-runtime-x86' 'vc_redist.x86.exe' 'https://aka.ms/vc14/vc_redist.x86.exe'))
|
||||
}
|
||||
}
|
||||
|
||||
$manifest = [PSCustomObject]@{
|
||||
generatedAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||
architectures = @($selectedArchitectures)
|
||||
proxifyreRelease = $proxifyreRelease.tag_name
|
||||
windowsPacketFilterRelease = $ndisapiRelease.tag_name
|
||||
files = $files
|
||||
}
|
||||
|
||||
$manifestPath = Join-Path $OutputDir 'manifest.json'
|
||||
$manifest | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $manifestPath -Encoding UTF8
|
||||
|
||||
$keepNames = @($files | ForEach-Object { $_.name }) + 'manifest.json'
|
||||
Get-ChildItem -LiteralPath $OutputDir -File |
|
||||
Where-Object { $keepNames -notcontains $_.Name } |
|
||||
ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force }
|
||||
|
||||
Write-Host "Bundle updated: $OutputDir"
|
||||
Generated
+5
-1
@@ -2314,16 +2314,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxywarden"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"percent-encoding",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"uuid",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "proxywarden"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
description = "Standalone Windows desktop proxy management app for ProxyWarden."
|
||||
authors = ["ProxyWarden"]
|
||||
edition = "2021"
|
||||
@@ -19,4 +19,10 @@ serde_json = "1"
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
base64 = "0.22"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "socks"] }
|
||||
percent-encoding = "2"
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
thiserror = "2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.55"
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
param(
|
||||
[string]$InstallRoot = "",
|
||||
[switch]$ForceRemoveWindowsPacketFilter
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function New-Result {
|
||||
param(
|
||||
[bool]$Success,
|
||||
[string]$Message,
|
||||
[hashtable]$Details = @{}
|
||||
)
|
||||
|
||||
[ordered]@{
|
||||
success = $Success
|
||||
message = $Message
|
||||
details = $Details
|
||||
} | ConvertTo-Json -Depth 8 -Compress
|
||||
}
|
||||
|
||||
function Get-FullPath([string]$Path) {
|
||||
return [System.IO.Path]::GetFullPath($Path).TrimEnd("\")
|
||||
}
|
||||
|
||||
function Test-PathInside([string]$Path, [string]$Root) {
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
|
||||
try {
|
||||
$fullPath = Get-FullPath $Path
|
||||
$fullRoot = Get-FullPath $Root
|
||||
return $fullPath.StartsWith($fullRoot + "\", [StringComparison]::OrdinalIgnoreCase)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-SafeInstallRoot([string]$Root) {
|
||||
if ([string]::IsNullOrWhiteSpace($Root)) {
|
||||
throw "InstallRoot is empty."
|
||||
}
|
||||
|
||||
$full = Get-FullPath $Root
|
||||
if ($full -match "^[A-Za-z]:\\?$") {
|
||||
throw "Refusing to use drive root as InstallRoot: $full"
|
||||
}
|
||||
if ($full -match "\\Windows($|\\)" -or $full -match "\\ProgramData$" -or $full -match "\\Users$") {
|
||||
throw "Refusing unsafe InstallRoot: $full"
|
||||
}
|
||||
|
||||
$knownAppFiles = @(
|
||||
(Join-Path $full "proxywarden.exe"),
|
||||
(Join-Path $full "uninstall.exe"),
|
||||
(Join-Path $full "bundled\cleanup\uninstall-managed-components.ps1")
|
||||
)
|
||||
foreach ($candidate in $knownAppFiles) {
|
||||
if (Test-Path -LiteralPath $candidate) { return $full }
|
||||
}
|
||||
|
||||
throw "InstallRoot does not look like a ProxyWarden install directory: $full"
|
||||
}
|
||||
|
||||
function Resolve-SafeComponentDir([string]$Root, [string]$Leaf) {
|
||||
$componentRoot = Join-Path $Root "components"
|
||||
$path = Join-Path $componentRoot $Leaf
|
||||
$full = Get-FullPath $path
|
||||
$expectedParent = Get-FullPath $componentRoot
|
||||
$actualLeaf = Split-Path -Leaf $full
|
||||
|
||||
if ($actualLeaf -ne $Leaf) {
|
||||
throw "Unexpected component directory leaf: $full"
|
||||
}
|
||||
if (-not $full.StartsWith($expectedParent + "\", [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Component directory is outside ProxyWarden components root: $full"
|
||||
}
|
||||
|
||||
return $full
|
||||
}
|
||||
|
||||
function Read-ComponentMarker([string]$Dir) {
|
||||
$markerPath = Join-Path $Dir "proxywarden-component.json"
|
||||
if (-not (Test-Path -LiteralPath $markerPath)) { return $null }
|
||||
try {
|
||||
return Get-Content -LiteralPath $markerPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-MarkerBool($Marker, [string]$Name) {
|
||||
if ($null -eq $Marker) { return $false }
|
||||
$property = $Marker.PSObject.Properties[$Name]
|
||||
if ($null -eq $property) { return $false }
|
||||
return [bool]$property.Value
|
||||
}
|
||||
|
||||
function Get-ServiceRecord([string]$Name) {
|
||||
$escaped = $Name.Replace("'", "''")
|
||||
return Get-CimInstance Win32_Service -Filter "Name='$escaped'" -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Get-ServiceImagePath($Record) {
|
||||
if ($null -eq $Record -or [string]::IsNullOrWhiteSpace([string]$Record.PathName)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$pathName = ([string]$Record.PathName).Trim()
|
||||
if ($pathName -match '^"([^"]+)"') { return $Matches[1] }
|
||||
if ($pathName -match '^(.+?\.exe)\b') { return $Matches[1].Trim() }
|
||||
return $pathName
|
||||
}
|
||||
|
||||
function Stop-ServiceRecord($Record) {
|
||||
if ($null -eq $Record) { return }
|
||||
|
||||
$service = Get-Service -Name $Record.Name -ErrorAction SilentlyContinue
|
||||
if ($null -ne $service -and $service.Status -ne "Stopped") {
|
||||
Stop-Service -Name $service.Name -Force -ErrorAction SilentlyContinue
|
||||
$service = Get-Service -Name $Record.Name -ErrorAction SilentlyContinue
|
||||
if ($null -ne $service) {
|
||||
try { $service.WaitForStatus("Stopped", [TimeSpan]::FromSeconds(12)) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
$recordAfterStop = Get-ServiceRecord $Record.Name
|
||||
if ($null -ne $recordAfterStop -and [int]$recordAfterStop.ProcessId -gt 0) {
|
||||
taskkill.exe /PID ([int]$recordAfterStop.ProcessId) /F | Out-Null
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-ManagedService {
|
||||
param(
|
||||
[string[]]$Names,
|
||||
[string]$InstallRoot,
|
||||
[string]$UninstallExe = ""
|
||||
)
|
||||
|
||||
$removed = @()
|
||||
foreach ($name in $Names) {
|
||||
$record = Get-ServiceRecord $name
|
||||
if ($null -eq $record) { continue }
|
||||
|
||||
$imagePath = Get-ServiceImagePath $record
|
||||
if (-not [string]::IsNullOrWhiteSpace($imagePath) -and -not (Test-PathInside $imagePath $InstallRoot)) {
|
||||
continue
|
||||
}
|
||||
|
||||
Stop-ServiceRecord $record
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($UninstallExe) -and (Test-Path -LiteralPath $UninstallExe)) {
|
||||
Push-Location (Split-Path -Parent $UninstallExe)
|
||||
try { & $UninstallExe uninstall | Out-Null } finally { Pop-Location }
|
||||
}
|
||||
|
||||
$record = Get-ServiceRecord $name
|
||||
if ($null -ne $record) {
|
||||
sc.exe delete $name | Out-Null
|
||||
}
|
||||
$removed += $name
|
||||
}
|
||||
|
||||
return $removed
|
||||
}
|
||||
|
||||
function Remove-SafeDirectory([string]$Path, [string]$Root) {
|
||||
if (-not (Test-Path -LiteralPath $Path)) { return $false }
|
||||
if (-not (Test-PathInside $Path $Root)) {
|
||||
throw "Refusing to remove directory outside InstallRoot: $Path"
|
||||
}
|
||||
Remove-Item -LiteralPath $Path -Recurse -Force
|
||||
return $true
|
||||
}
|
||||
|
||||
function Remove-ManagedFirewallRules {
|
||||
$removed = @()
|
||||
foreach ($name in @("ProxyWarden.ProxiFyre.Inbound", "ProxyWarden.ProxiFyre.Outbound")) {
|
||||
$rule = Get-NetFirewallRule -Name $name -ErrorAction SilentlyContinue
|
||||
if ($null -eq $rule) { continue }
|
||||
$rule | Remove-NetFirewallRule -ErrorAction Stop
|
||||
$removed += $name
|
||||
}
|
||||
return $removed
|
||||
}
|
||||
|
||||
function Get-InstalledProgram([string]$Pattern) {
|
||||
$paths = @(
|
||||
"HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*",
|
||||
"HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||
)
|
||||
return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.DisplayName -match $Pattern } |
|
||||
Select-Object -First 1 DisplayName, DisplayVersion, PSChildName, UninstallString, QuietUninstallString
|
||||
}
|
||||
|
||||
function Resolve-MsiProductCode($Program, [string]$Label) {
|
||||
if ($null -eq $Program) { return $null }
|
||||
if ($Program.PSChildName -match "^\{[0-9A-Fa-f-]{36}\}$") {
|
||||
return $Program.PSChildName
|
||||
}
|
||||
foreach ($candidate in @($Program.QuietUninstallString, $Program.UninstallString)) {
|
||||
if ($candidate -match "\{[0-9A-Fa-f-]{36}\}") {
|
||||
return $Matches[0]
|
||||
}
|
||||
}
|
||||
throw "Could not resolve MSI product code for $Label."
|
||||
}
|
||||
|
||||
function Uninstall-MsiProgram($Program, [string]$Label) {
|
||||
$productCode = Resolve-MsiProductCode $Program $Label
|
||||
if ([string]::IsNullOrWhiteSpace($productCode)) { return $false }
|
||||
|
||||
$logPath = Join-Path ([System.IO.Path]::GetTempPath()) "proxywarden-$Label-uninstall.log"
|
||||
$process = Start-Process -FilePath "msiexec.exe" -ArgumentList @("/x", $productCode, "/qn", "/norestart", "/L*v", $logPath) -Wait -PassThru -WindowStyle Hidden
|
||||
if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010 -and $process.ExitCode -ne 1605) {
|
||||
throw "$Label uninstall exited with code $($process.ExitCode). MSI log: $logPath"
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
try {
|
||||
$details = @{}
|
||||
$root = Assert-SafeInstallRoot $InstallRoot
|
||||
$details.installRoot = $root
|
||||
|
||||
$proxifyreDir = Resolve-SafeComponentDir $root "ProxiFyre"
|
||||
$singboxDir = Resolve-SafeComponentDir $root "sing-box"
|
||||
$proxifyreMarker = Read-ComponentMarker $proxifyreDir
|
||||
$removePacketFilter = [bool]$ForceRemoveWindowsPacketFilter -or (Get-MarkerBool $proxifyreMarker "packetFilterInstalledByProxyWarden")
|
||||
|
||||
$details.removedProxiFyreServices = Remove-ManagedService -Names @("ProxiFyreService", "ProxiFyre") -InstallRoot $root -UninstallExe (Join-Path $proxifyreDir "ProxiFyre.exe")
|
||||
$details.removedSingBoxServices = Remove-ManagedService -Names @("ProxyWardenSingBox") -InstallRoot $root -UninstallExe (Join-Path $singboxDir "ProxyWardenSingBox.exe")
|
||||
$details.removedProxiFyreFirewallRules = Remove-ManagedFirewallRules
|
||||
$details.removedProxiFyreDir = Remove-SafeDirectory $proxifyreDir $root
|
||||
$details.removedSingBoxDir = Remove-SafeDirectory $singboxDir $root
|
||||
|
||||
if ($removePacketFilter) {
|
||||
$packetFilter = Get-InstalledProgram "Windows Packet Filter|WinpkFilter|NDISAPI"
|
||||
$details.removedWindowsPacketFilter = Uninstall-MsiProgram $packetFilter "windows-packet-filter"
|
||||
} else {
|
||||
$details.removedWindowsPacketFilter = $false
|
||||
}
|
||||
|
||||
New-Result -Success $true -Message "ProxyWarden managed components cleanup completed." -Details $details
|
||||
exit 0
|
||||
} catch {
|
||||
New-Result -Success $false -Message $_.Exception.Message -Details @{}
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
!macro NSIS_HOOK_PREUNINSTALL
|
||||
DetailPrint "ProxyWarden: cleaning managed components"
|
||||
nsExec::ExecToLog 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\bundled\cleanup\uninstall-managed-components.ps1" -InstallRoot "$INSTDIR"'
|
||||
Pop $0
|
||||
DetailPrint "ProxyWarden cleanup exit code: $0"
|
||||
!macroend
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"generatedAt": "2026-07-09T16:13:27.3087159Z",
|
||||
"architectures": [
|
||||
"x64"
|
||||
],
|
||||
"proxifyreRelease": "v2.2.1",
|
||||
"windowsPacketFilterRelease": "v3.6.2",
|
||||
"files": [
|
||||
{
|
||||
"id": "proxifyre-x64",
|
||||
"name": "ProxiFyre-v2.2.1-x64-signed.zip",
|
||||
"sha256": "c38ca1caa68cd730712f5c0911e4240711bf9e7684988ae64ed04ec693cce899",
|
||||
"size": 1372483,
|
||||
"sourceUrl": "https://github.com/wiresock/proxifyre/releases/download/v2.2.1/ProxiFyre-v2.2.1-x64-signed.zip"
|
||||
},
|
||||
{
|
||||
"id": "packet-filter-x64",
|
||||
"name": "Windows.Packet.Filter.3.6.2.1.x64.msi",
|
||||
"sha256": "9c388c0b7f189f7fa98720bae2caecf7d64f30910838b80b438ecf8956b8502c",
|
||||
"size": 819200,
|
||||
"sourceUrl": "https://github.com/wiresock/ndisapi/releases/download/v3.6.2/Windows.Packet.Filter.3.6.2.1.x64.msi"
|
||||
},
|
||||
{
|
||||
"id": "vc-runtime-x64",
|
||||
"name": "vc_redist.x64.exe",
|
||||
"sha256": "843068991daaa1f73ad9f6239bce4d0f6a07a51f18c37ea2a867e9beca71295c",
|
||||
"size": 18731856,
|
||||
"sourceUrl": "https://aka.ms/vc14/vc_redist.x64.exe"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -1,4 +1,3 @@
|
||||
#[cfg(not(test))]
|
||||
use crate::adapters::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||
ProxyRouterRequest,
|
||||
@@ -7,11 +6,6 @@ use crate::models::{
|
||||
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItemType, Protocol,
|
||||
ProxyProtocol, Target,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||
ProxyRouterRequest,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const PROXIFYRE_ADAPTER_ID: &str = "proxifyre";
|
||||
@@ -211,7 +205,10 @@ fn app_names_for_profile(profile: &Profile) -> Vec<String> {
|
||||
ProfileItemType::Process | ProfileItemType::Folder | ProfileItemType::Exe => value,
|
||||
};
|
||||
|
||||
if !names.iter().any(|existing| existing == app_name) {
|
||||
if !names
|
||||
.iter()
|
||||
.any(|existing: &String| existing.eq_ignore_ascii_case(app_name))
|
||||
{
|
||||
names.push(app_name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
use crate::models::{LocalSingBoxConfig, SubscriptionCache};
|
||||
use crate::models::{LocalSingBoxConfig, SubscriptionCache, SubscriptionServer};
|
||||
use crate::process::command_no_window;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
env, fs,
|
||||
path::Path,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use std::{env, fs, fs::OpenOptions, io::Write, path::Path};
|
||||
|
||||
pub const SINGBOX_ADAPTER_ID: &str = "singbox";
|
||||
pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json";
|
||||
@@ -43,23 +39,36 @@ impl SingBoxAdapter {
|
||||
checker: &C,
|
||||
) -> Result<SingBoxGeneratedConfig, SingBoxConfigError>
|
||||
where
|
||||
C: SingBoxConfigChecker,
|
||||
C: SingBoxConfigChecker + ?Sized,
|
||||
{
|
||||
let selected_server_tag = request
|
||||
let selected_server = request
|
||||
.config
|
||||
.selected_server_tag
|
||||
.selected_server_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.and_then(|id| {
|
||||
request
|
||||
.subscription_cache
|
||||
.servers
|
||||
.iter()
|
||||
.find(|server| server.id == id)
|
||||
})
|
||||
.or_else(|| {
|
||||
let tag = request.config.selected_server_tag.as_deref()?;
|
||||
request
|
||||
.subscription_cache
|
||||
.servers
|
||||
.iter()
|
||||
.find(|server| server.tag == tag)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingSelectedServer,
|
||||
"Сервер Local sing-box не выбран",
|
||||
"Сервер Local sing-box не выбран или отсутствует в текущей подписке",
|
||||
)
|
||||
})?;
|
||||
let vpn_outbound = selected_outbound(
|
||||
&request.subscription_cache.config,
|
||||
selected_server_tag,
|
||||
selected_server,
|
||||
&self.vpn_outbound_tag,
|
||||
)?;
|
||||
let generated_config = json!({
|
||||
@@ -105,7 +114,7 @@ impl SingBoxAdapter {
|
||||
adapter_id: SINGBOX_ADAPTER_ID.to_string(),
|
||||
output_file_name: SINGBOX_OUTPUT_FILE.to_string(),
|
||||
contents,
|
||||
selected_server_tag: selected_server_tag.to_string(),
|
||||
selected_server_tag: selected_server.tag.clone(),
|
||||
listen: request.config.listen_host.clone(),
|
||||
listen_port: request.config.listen_port,
|
||||
check,
|
||||
@@ -200,20 +209,37 @@ impl SingBoxConfigChecker for SingBoxCommandChecker {
|
||||
config_json: &str,
|
||||
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
|
||||
let config_path = env::temp_dir().join(format!(
|
||||
"proxywarden-sing-box-{}-{}.json",
|
||||
std::process::id(),
|
||||
now_millis()
|
||||
"proxywarden-sing-box-{}.json",
|
||||
uuid::Uuid::new_v4().hyphenated()
|
||||
));
|
||||
|
||||
fs::write(&config_path, config_json).map_err(|error| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::CheckFailed,
|
||||
format!(
|
||||
"Не удалось записать временный конфиг sing-box '{}': {error}",
|
||||
config_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
{
|
||||
let mut config_file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&config_path)
|
||||
.map_err(|error| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::CheckFailed,
|
||||
format!(
|
||||
"Не удалось создать временный конфиг sing-box '{}': {error}",
|
||||
config_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let write_result = config_file.write_all(config_json.as_bytes());
|
||||
drop(config_file);
|
||||
if let Err(error) = write_result {
|
||||
let _ = fs::remove_file(&config_path);
|
||||
return Err(SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::CheckFailed,
|
||||
format!(
|
||||
"Не удалось записать временный конфиг sing-box '{}': {error}",
|
||||
config_path.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let output = command_no_window(binary_path)
|
||||
.arg("check")
|
||||
@@ -257,7 +283,7 @@ impl SingBoxConfigChecker for SingBoxCommandChecker {
|
||||
|
||||
fn selected_outbound(
|
||||
subscription_config: &Value,
|
||||
selected_server_tag: &str,
|
||||
selected_server: &SubscriptionServer,
|
||||
vpn_outbound_tag: &str,
|
||||
) -> Result<Value, SingBoxConfigError> {
|
||||
let outbounds = subscription_config
|
||||
@@ -272,15 +298,27 @@ fn selected_outbound(
|
||||
let outbound = outbounds
|
||||
.iter()
|
||||
.find(|outbound| {
|
||||
outbound
|
||||
let tag_matches = outbound
|
||||
.get("tag")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|tag| tag.trim() == selected_server_tag)
|
||||
.is_some_and(|tag| tag.trim() == selected_server.tag);
|
||||
let server_matches = outbound
|
||||
.get("server")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|server| server.eq_ignore_ascii_case(&selected_server.server));
|
||||
let port_matches = outbound
|
||||
.get("server_port")
|
||||
.and_then(Value::as_u64)
|
||||
.is_some_and(|port| port == u64::from(selected_server.server_port));
|
||||
tag_matches && server_matches && port_matches
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingSelectedOutbound,
|
||||
format!("Outbound не найден: {selected_server_tag}"),
|
||||
format!(
|
||||
"Outbound не найден: {} ({}:{})",
|
||||
selected_server.tag, selected_server.server, selected_server.server_port
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let outbound_type = outbound
|
||||
@@ -292,7 +330,8 @@ fn selected_outbound(
|
||||
return Err(SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
|
||||
format!(
|
||||
"Outbound '{selected_server_tag}' имеет неподдерживаемый тип '{outbound_type}'"
|
||||
"Outbound '{}' имеет неподдерживаемый тип '{outbound_type}'",
|
||||
selected_server.tag
|
||||
),
|
||||
));
|
||||
}
|
||||
@@ -301,7 +340,10 @@ fn selected_outbound(
|
||||
let object = outbound.as_object_mut().ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
|
||||
format!("Outbound '{selected_server_tag}' должен быть JSON-объектом"),
|
||||
format!(
|
||||
"Outbound '{}' должен быть JSON-объектом",
|
||||
selected_server.tag
|
||||
),
|
||||
)
|
||||
})?;
|
||||
object.insert(
|
||||
@@ -318,13 +360,6 @@ fn selected_outbound(
|
||||
Ok(outbound)
|
||||
}
|
||||
|
||||
fn now_millis() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn command_message(stdout: &str, stderr: &str) -> String {
|
||||
let stdout = stdout.trim();
|
||||
let stderr = stderr.trim();
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Administrator-state detection and explicit UAC restart boundary.
|
||||
|
||||
use crate::command_dto::{AdminStatusResponse, CommandError};
|
||||
use crate::powershell::{
|
||||
escape_single as escape_powershell_single, is_elevated as is_running_elevated,
|
||||
output_message as powershell_output_message, run_command as run_powershell_command,
|
||||
};
|
||||
use std::env;
|
||||
|
||||
pub fn admin_status() -> AdminStatusResponse {
|
||||
let is_windows = cfg!(windows);
|
||||
let is_elevated = is_running_elevated();
|
||||
let message = if !is_windows {
|
||||
"Проверка прав администратора нужна только в Windows.".to_string()
|
||||
} else if is_elevated {
|
||||
"ProxyWarden уже запущен от имени администратора.".to_string()
|
||||
} else {
|
||||
"Для установки компонентов и управления службами можно перезапустить ProxyWarden от имени администратора один раз.".to_string()
|
||||
};
|
||||
|
||||
AdminStatusResponse {
|
||||
is_windows,
|
||||
is_elevated,
|
||||
can_restart_elevated: is_windows && !is_elevated,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn launch_app_as_admin() -> Result<(), CommandError> {
|
||||
if !cfg!(windows) {
|
||||
return Err(CommandError::new(
|
||||
"admin_restart_unsupported",
|
||||
"Перезапуск от имени администратора доступен только в Windows.",
|
||||
));
|
||||
}
|
||||
|
||||
if is_running_elevated() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let exe_path = env::current_exe().map_err(|error| {
|
||||
CommandError::new(
|
||||
"admin_restart_failed",
|
||||
format!("Не удалось определить путь текущего приложения: {error}"),
|
||||
)
|
||||
})?;
|
||||
let working_dir = env::current_dir().ok();
|
||||
let working_dir_arg = working_dir
|
||||
.as_ref()
|
||||
.map(|path| {
|
||||
format!(
|
||||
" -WorkingDirectory '{}'",
|
||||
escape_powershell_single(&path.display().to_string())
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let script = format!(
|
||||
r#"
|
||||
$ErrorActionPreference = 'Stop'
|
||||
try {{
|
||||
Start-Process -FilePath '{}' -Verb RunAs{}
|
||||
exit 0
|
||||
}} catch {{
|
||||
Write-Error ($_ | Out-String)
|
||||
exit 1
|
||||
}}
|
||||
"#,
|
||||
escape_powershell_single(&exe_path.display().to_string()),
|
||||
working_dir_arg
|
||||
);
|
||||
let output = run_powershell_command(&script).map_err(|error| {
|
||||
CommandError::new(
|
||||
"admin_restart_failed",
|
||||
format!("Не удалось запросить права администратора: {error}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(CommandError::new(
|
||||
"admin_restart_failed",
|
||||
powershell_output_message(
|
||||
&output,
|
||||
"Перезапуск от имени администратора отменен или не был запущен.",
|
||||
),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
//! Transactional configuration apply use case.
|
||||
//!
|
||||
//! The module validates and generates all artifacts before source writes,
|
||||
//! performs no service lifecycle actions, and attempts rollback when a later
|
||||
//! write or runtime apply fails.
|
||||
|
||||
use crate::adapters::proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
|
||||
use crate::adapters::singbox::{
|
||||
SingBoxAdapter, SingBoxConfigChecker, SingBoxGenerationRequest, SINGBOX_OUTPUT_FILE,
|
||||
};
|
||||
use crate::clock::Clock;
|
||||
use crate::component_detection::{
|
||||
proxyfier_component_from_detection, singbox_component_from_detection, DetectedProxyfier,
|
||||
DetectedSingBox,
|
||||
};
|
||||
use crate::models::{
|
||||
ActivityEntry, ActivityLevel, ComponentId, LocalSingBoxConfig, Profile, ProfileInput,
|
||||
ProxyProtocol, Target, TargetInput, TargetKind,
|
||||
};
|
||||
use crate::proxy_apply::{HelperApplyRequest, ProxyApplyHelper};
|
||||
use crate::safe_fs;
|
||||
use crate::storage::JsonStorage;
|
||||
use crate::validation::{normalize_profile, normalize_target, ValidationError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{fs, path::Path};
|
||||
use thiserror::Error;
|
||||
|
||||
const LOCAL_SINGBOX_TARGET_ID: &str = "local-singbox";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ApplyRouteMode {
|
||||
External,
|
||||
LocalSingbox,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApplyConfigurationInput {
|
||||
pub route_mode: ApplyRouteMode,
|
||||
pub profile: ProfileInput,
|
||||
pub external_target: Option<TargetInput>,
|
||||
#[serde(default = "default_true")]
|
||||
pub disable_other_profiles: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApplyPhase {
|
||||
pub id: String,
|
||||
pub status: ApplyPhaseStatus,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ApplyPhaseStatus {
|
||||
Succeeded,
|
||||
Failed,
|
||||
RolledBack,
|
||||
Skipped,
|
||||
Warning,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApplyConfigurationResult {
|
||||
pub success: bool,
|
||||
pub changed: bool,
|
||||
pub partial_state: bool,
|
||||
pub message: String,
|
||||
pub error_code: Option<String>,
|
||||
pub generated_config_path: String,
|
||||
pub singbox_generated_config_path: Option<String>,
|
||||
pub restart_required: Vec<ComponentId>,
|
||||
pub phases: Vec<ApplyPhase>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApplyFlowError {
|
||||
#[error("Проверьте поля конфигурации")]
|
||||
Validation { details: Vec<ValidationError> },
|
||||
#[error("{message}")]
|
||||
Failure { code: String, message: String },
|
||||
}
|
||||
|
||||
impl ApplyFlowError {
|
||||
pub fn code(&self) -> &str {
|
||||
match self {
|
||||
Self::Validation { .. } => "validation_failed",
|
||||
Self::Failure { code, .. } => code,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn details(self) -> Vec<ValidationError> {
|
||||
match self {
|
||||
Self::Validation { details } => details,
|
||||
Self::Failure { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self::Failure {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn validation(details: Vec<ValidationError>) -> Self {
|
||||
Self::Validation { details }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ApplyServices<'a> {
|
||||
pub proxy_adapter: &'a dyn ProxyRouterAdapter,
|
||||
pub singbox_adapter: &'a SingBoxAdapter,
|
||||
pub checker: &'a dyn SingBoxConfigChecker,
|
||||
pub helper: &'a dyn ProxyApplyHelper,
|
||||
pub clock: &'a dyn Clock,
|
||||
pub detected_proxyfier: Option<DetectedProxyfier>,
|
||||
pub detected_singbox: Option<DetectedSingBox>,
|
||||
}
|
||||
|
||||
/// Applies one complete routing draft without starting, stopping, installing,
|
||||
/// uninstalling, or restarting Windows services.
|
||||
pub fn apply_configuration(
|
||||
storage: &JsonStorage,
|
||||
input: ApplyConfigurationInput,
|
||||
services: ApplyServices<'_>,
|
||||
) -> Result<ApplyConfigurationResult, ApplyFlowError> {
|
||||
let mut phases = Vec::new();
|
||||
let old_profiles = storage
|
||||
.read_profiles()
|
||||
.map_err(|error| storage_error("profiles_read_failed", error))?;
|
||||
let old_targets = storage
|
||||
.read_targets()
|
||||
.map_err(|error| storage_error("targets_read_failed", error))?;
|
||||
|
||||
let PreparedApply {
|
||||
profiles,
|
||||
targets,
|
||||
proxy_config,
|
||||
singbox_config,
|
||||
} = prepare_apply(storage, input, &services)?;
|
||||
phases.push(phase(
|
||||
"preflight",
|
||||
ApplyPhaseStatus::Succeeded,
|
||||
"Входные данные и оба generated config проверены до записи.",
|
||||
));
|
||||
|
||||
let source_changed = profiles != old_profiles || targets != old_targets;
|
||||
let proxy_path = storage
|
||||
.paths()
|
||||
.generated_dir
|
||||
.join(&proxy_config.output_file_name);
|
||||
let singbox_path = singbox_config
|
||||
.as_ref()
|
||||
.map(|_| storage.paths().generated_dir.join(SINGBOX_OUTPUT_FILE));
|
||||
let old_proxy_contents = fs::read(&proxy_path).ok();
|
||||
let old_singbox_contents = singbox_path.as_ref().and_then(|path| fs::read(path).ok());
|
||||
let rollback_state = RollbackState {
|
||||
storage,
|
||||
old_profiles: &old_profiles,
|
||||
old_targets: &old_targets,
|
||||
proxy_path: &proxy_path,
|
||||
old_proxy_contents: old_proxy_contents.as_deref(),
|
||||
singbox_path: singbox_path.as_deref(),
|
||||
old_singbox_contents: old_singbox_contents.as_deref(),
|
||||
};
|
||||
|
||||
if let Err(error) = storage.write_targets(&targets) {
|
||||
let rollback = rollback_source(storage, &old_profiles, &old_targets);
|
||||
phases.push(phase(
|
||||
"source-state",
|
||||
ApplyPhaseStatus::Failed,
|
||||
"Не удалось сохранить targets.",
|
||||
));
|
||||
phases.push(rollback_phase(&rollback));
|
||||
return Ok(failed_result(
|
||||
"targets_write_failed",
|
||||
format!("Не удалось сохранить цели: {error}"),
|
||||
rollback.is_err(),
|
||||
&proxy_path,
|
||||
singbox_path.as_deref(),
|
||||
phases,
|
||||
));
|
||||
}
|
||||
if let Err(error) = storage.write_profiles(&profiles) {
|
||||
let rollback = rollback_source(storage, &old_profiles, &old_targets);
|
||||
phases.push(phase(
|
||||
"source-state",
|
||||
ApplyPhaseStatus::Failed,
|
||||
"Не удалось сохранить profiles.",
|
||||
));
|
||||
phases.push(rollback_phase(&rollback));
|
||||
return Ok(failed_result(
|
||||
"profiles_write_failed",
|
||||
format!("Не удалось сохранить профили: {error}"),
|
||||
rollback.is_err(),
|
||||
&proxy_path,
|
||||
singbox_path.as_deref(),
|
||||
phases,
|
||||
));
|
||||
}
|
||||
phases.push(phase(
|
||||
"source-state",
|
||||
ApplyPhaseStatus::Succeeded,
|
||||
"Profiles и targets сохранены.",
|
||||
));
|
||||
|
||||
if let (Some(generated), Some(path)) = (singbox_config.as_ref(), singbox_path.as_ref()) {
|
||||
if let Err(error) = safe_fs::write_with_backup(path, generated.contents.as_bytes()) {
|
||||
return Ok(rollback_after_failure(
|
||||
&rollback_state,
|
||||
"singbox_config_write_failed",
|
||||
format!("Не удалось записать generated sing-box config: {error}"),
|
||||
"singbox-config",
|
||||
phases,
|
||||
));
|
||||
}
|
||||
phases.push(phase(
|
||||
"singbox-config",
|
||||
ApplyPhaseStatus::Succeeded,
|
||||
"Generated sing-box config записан; служба не перезапускалась.",
|
||||
));
|
||||
} else {
|
||||
phases.push(phase(
|
||||
"singbox-config",
|
||||
ApplyPhaseStatus::Skipped,
|
||||
"External SOCKS5 не использует Local sing-box.",
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(error) = safe_fs::write_with_backup(&proxy_path, proxy_config.contents.as_bytes()) {
|
||||
return Ok(rollback_after_failure(
|
||||
&rollback_state,
|
||||
"proxifyre_config_write_failed",
|
||||
format!("Не удалось записать generated ProxiFyre config: {error}"),
|
||||
"proxifyre-config",
|
||||
phases,
|
||||
));
|
||||
}
|
||||
phases.push(phase(
|
||||
"proxifyre-config",
|
||||
ApplyPhaseStatus::Succeeded,
|
||||
"Generated ProxiFyre config записан.",
|
||||
));
|
||||
|
||||
let helper_result = match services.helper.apply_proxy_config(HelperApplyRequest {
|
||||
adapter_id: &proxy_config.adapter_id,
|
||||
config_path: &proxy_path,
|
||||
config_contents: &proxy_config.contents,
|
||||
}) {
|
||||
Ok(result) if result.success => result,
|
||||
Ok(result) => {
|
||||
return Ok(rollback_after_failure(
|
||||
&rollback_state,
|
||||
"proxifyre_apply_failed",
|
||||
result.message,
|
||||
"runtime-apply",
|
||||
phases,
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
return Ok(rollback_after_failure(
|
||||
&rollback_state,
|
||||
&error.code,
|
||||
error.message,
|
||||
"runtime-apply",
|
||||
phases,
|
||||
));
|
||||
}
|
||||
};
|
||||
phases.push(phase(
|
||||
"runtime-apply",
|
||||
ApplyPhaseStatus::Succeeded,
|
||||
"ProxiFyre config применён без управления службой.",
|
||||
));
|
||||
phases.push(phase(
|
||||
"service-control",
|
||||
ApplyPhaseStatus::Skipped,
|
||||
"Apply не запускает, не останавливает и не перезапускает службы.",
|
||||
));
|
||||
|
||||
let mut restart_required = Vec::new();
|
||||
if services.detected_proxyfier.is_some() {
|
||||
restart_required.push(ComponentId::Proxyfier);
|
||||
}
|
||||
if singbox_config.is_some() && services.detected_singbox.is_some() {
|
||||
restart_required.push(ComponentId::Singbox);
|
||||
}
|
||||
let message = if restart_required.is_empty() {
|
||||
helper_result.message.clone()
|
||||
} else {
|
||||
"Конфигурация применена. Для загрузки новых файлов явно перезапустите отмеченные службы."
|
||||
.to_string()
|
||||
};
|
||||
let activity = ActivityEntry {
|
||||
id: "configuration-applied".to_string(),
|
||||
at: services.clock.now(),
|
||||
level: ActivityLevel::Success,
|
||||
title: "Маршрут применён".to_string(),
|
||||
message: format!(
|
||||
"Профилей: {}, приложений: {}. Управление службами не выполнялось.",
|
||||
proxy_config.enabled_profiles, proxy_config.routed_apps
|
||||
),
|
||||
};
|
||||
if let Err(error) = storage.append_activity(activity) {
|
||||
phases.push(phase(
|
||||
"activity",
|
||||
ApplyPhaseStatus::Warning,
|
||||
format!("Маршрут применён, но запись activity не удалась: {error}"),
|
||||
));
|
||||
} else {
|
||||
phases.push(phase(
|
||||
"activity",
|
||||
ApplyPhaseStatus::Succeeded,
|
||||
"Activity обновлена.",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ApplyConfigurationResult {
|
||||
success: true,
|
||||
changed: source_changed || helper_result.changed,
|
||||
partial_state: false,
|
||||
message,
|
||||
error_code: None,
|
||||
generated_config_path: proxy_path.display().to_string(),
|
||||
singbox_generated_config_path: singbox_path.map(|path| path.display().to_string()),
|
||||
restart_required,
|
||||
phases,
|
||||
})
|
||||
}
|
||||
|
||||
struct PreparedApply {
|
||||
profiles: Vec<Profile>,
|
||||
targets: Vec<Target>,
|
||||
proxy_config: crate::adapters::proxy_router::ProxyRouterGeneratedConfig,
|
||||
singbox_config: Option<crate::adapters::singbox::SingBoxGeneratedConfig>,
|
||||
}
|
||||
|
||||
fn prepare_apply(
|
||||
storage: &JsonStorage,
|
||||
input: ApplyConfigurationInput,
|
||||
services: &ApplyServices<'_>,
|
||||
) -> Result<PreparedApply, ApplyFlowError> {
|
||||
if services.detected_proxyfier.is_none() {
|
||||
return Err(ApplyFlowError::failure(
|
||||
"proxifyre_not_found",
|
||||
"ProxiFyre не найден. Установите компонент отдельным явным действием перед apply.",
|
||||
));
|
||||
}
|
||||
let mut profile_input = input.profile;
|
||||
let mut targets = storage
|
||||
.read_targets()
|
||||
.map_err(|error| storage_error("targets_read_failed", error))?;
|
||||
let singbox_config = match input.route_mode {
|
||||
ApplyRouteMode::External => {
|
||||
let target_input = input.external_target.ok_or_else(|| {
|
||||
ApplyFlowError::failure(
|
||||
"external_target_missing",
|
||||
"Для external маршрута требуется SOCKS5 target.",
|
||||
)
|
||||
})?;
|
||||
let target = normalize_target(target_input).map_err(ApplyFlowError::validation)?;
|
||||
profile_input.target_id = target.id.clone();
|
||||
upsert_target(&mut targets, target);
|
||||
None
|
||||
}
|
||||
ApplyRouteMode::LocalSingbox => {
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.map_err(|error| storage_error("singbox_config_read_failed", error))?;
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.map_err(|error| storage_error("singbox_cache_read_failed", error))?
|
||||
.ok_or_else(|| {
|
||||
ApplyFlowError::failure(
|
||||
"singbox_subscription_cache_missing",
|
||||
"Сначала загрузите подписку Local sing-box.",
|
||||
)
|
||||
})?;
|
||||
profile_input.target_id = LOCAL_SINGBOX_TARGET_ID.to_string();
|
||||
upsert_target(&mut targets, local_singbox_target(&config));
|
||||
Some(
|
||||
services
|
||||
.singbox_adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(
|
||||
&config,
|
||||
&cache,
|
||||
services
|
||||
.detected_singbox
|
||||
.as_ref()
|
||||
.map(|detected| detected.executable_path.as_path()),
|
||||
),
|
||||
services.checker,
|
||||
)
|
||||
.map_err(|error| {
|
||||
ApplyFlowError::failure("singbox_preflight_failed", error.message)
|
||||
})?,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let profile = normalize_profile(profile_input).map_err(ApplyFlowError::validation)?;
|
||||
let mut profiles = storage
|
||||
.read_profiles()
|
||||
.map_err(|error| storage_error("profiles_read_failed", error))?;
|
||||
if input.disable_other_profiles {
|
||||
for existing in &mut profiles {
|
||||
if existing.id != profile.id {
|
||||
existing.enabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
upsert_profile(&mut profiles, profile);
|
||||
|
||||
let components = vec![
|
||||
proxyfier_component_from_detection(services.detected_proxyfier.as_ref()),
|
||||
singbox_component_from_detection(services.detected_singbox.as_ref()),
|
||||
];
|
||||
let proxy_config = services
|
||||
.proxy_adapter
|
||||
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
|
||||
.map_err(|error| ApplyFlowError::failure("proxifyre_preflight_failed", error.message))?;
|
||||
|
||||
Ok(PreparedApply {
|
||||
profiles,
|
||||
targets,
|
||||
proxy_config,
|
||||
singbox_config,
|
||||
})
|
||||
}
|
||||
|
||||
fn local_singbox_target(config: &LocalSingBoxConfig) -> Target {
|
||||
Target {
|
||||
id: LOCAL_SINGBOX_TARGET_ID.to_string(),
|
||||
name: "Локальный sing-box".to_string(),
|
||||
kind: TargetKind::Local,
|
||||
protocol: ProxyProtocol::Socks5,
|
||||
host: config.listen_host.clone(),
|
||||
port: config.listen_port,
|
||||
requires_component: Some(ComponentId::Singbox),
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_profile(profiles: &mut Vec<Profile>, profile: Profile) {
|
||||
match profiles
|
||||
.iter()
|
||||
.position(|existing| existing.id == profile.id)
|
||||
{
|
||||
Some(index) => profiles[index] = profile,
|
||||
None => profiles.push(profile),
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_target(targets: &mut Vec<Target>, target: Target) {
|
||||
match targets.iter().position(|existing| existing.id == target.id) {
|
||||
Some(index) => targets[index] = target,
|
||||
None => targets.push(target),
|
||||
}
|
||||
}
|
||||
|
||||
struct RollbackState<'a> {
|
||||
storage: &'a JsonStorage,
|
||||
old_profiles: &'a [Profile],
|
||||
old_targets: &'a [Target],
|
||||
proxy_path: &'a Path,
|
||||
old_proxy_contents: Option<&'a [u8]>,
|
||||
singbox_path: Option<&'a Path>,
|
||||
old_singbox_contents: Option<&'a [u8]>,
|
||||
}
|
||||
|
||||
fn rollback_after_failure(
|
||||
state: &RollbackState<'_>,
|
||||
code: &str,
|
||||
message: String,
|
||||
failed_phase: &str,
|
||||
mut phases: Vec<ApplyPhase>,
|
||||
) -> ApplyConfigurationResult {
|
||||
phases.push(phase(failed_phase, ApplyPhaseStatus::Failed, &message));
|
||||
let source_rollback = rollback_source(state.storage, state.old_profiles, state.old_targets);
|
||||
let proxy_rollback = restore_generated(state.proxy_path, state.old_proxy_contents);
|
||||
let singbox_rollback = state
|
||||
.singbox_path
|
||||
.map(|path| restore_generated(path, state.old_singbox_contents))
|
||||
.unwrap_or(Ok(()));
|
||||
let rollback_ok = source_rollback.is_ok() && proxy_rollback.is_ok() && singbox_rollback.is_ok();
|
||||
phases.push(if rollback_ok {
|
||||
phase(
|
||||
"rollback",
|
||||
ApplyPhaseStatus::RolledBack,
|
||||
"Source state и generated artifacts восстановлены.",
|
||||
)
|
||||
} else {
|
||||
phase(
|
||||
"rollback",
|
||||
ApplyPhaseStatus::Failed,
|
||||
"Rollback завершился не полностью; проверьте файлы config/generated.",
|
||||
)
|
||||
});
|
||||
failed_result(
|
||||
code,
|
||||
message,
|
||||
!rollback_ok,
|
||||
state.proxy_path,
|
||||
state.singbox_path,
|
||||
phases,
|
||||
)
|
||||
}
|
||||
|
||||
fn rollback_source(
|
||||
storage: &JsonStorage,
|
||||
profiles: &[Profile],
|
||||
targets: &[Target],
|
||||
) -> Result<(), String> {
|
||||
let targets_result = storage
|
||||
.write_targets(targets)
|
||||
.map_err(|error| error.to_string());
|
||||
let profiles_result = storage
|
||||
.write_profiles(profiles)
|
||||
.map_err(|error| error.to_string());
|
||||
targets_result.and(profiles_result)
|
||||
}
|
||||
|
||||
fn restore_generated(path: &Path, previous: Option<&[u8]>) -> Result<(), String> {
|
||||
match previous {
|
||||
Some(contents) => {
|
||||
safe_fs::write_with_backup(path, contents).map_err(|error| error.to_string())
|
||||
}
|
||||
None => match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn rollback_phase(result: &Result<(), String>) -> ApplyPhase {
|
||||
match result {
|
||||
Ok(()) => phase(
|
||||
"rollback",
|
||||
ApplyPhaseStatus::RolledBack,
|
||||
"Source state восстановлен.",
|
||||
),
|
||||
Err(error) => phase(
|
||||
"rollback",
|
||||
ApplyPhaseStatus::Failed,
|
||||
format!("Не удалось полностью восстановить source state: {error}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_result(
|
||||
code: &str,
|
||||
message: String,
|
||||
partial_state: bool,
|
||||
proxy_path: &Path,
|
||||
singbox_path: Option<&Path>,
|
||||
phases: Vec<ApplyPhase>,
|
||||
) -> ApplyConfigurationResult {
|
||||
ApplyConfigurationResult {
|
||||
success: false,
|
||||
changed: false,
|
||||
partial_state,
|
||||
message,
|
||||
error_code: Some(code.to_string()),
|
||||
generated_config_path: proxy_path.display().to_string(),
|
||||
singbox_generated_config_path: singbox_path.map(|path| path.display().to_string()),
|
||||
restart_required: Vec::new(),
|
||||
phases,
|
||||
}
|
||||
}
|
||||
|
||||
fn phase(
|
||||
id: impl Into<String>,
|
||||
status: ApplyPhaseStatus,
|
||||
message: impl Into<String>,
|
||||
) -> ApplyPhase {
|
||||
ApplyPhase {
|
||||
id: id.into(),
|
||||
status,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_error(code: &str, error: std::io::Error) -> ApplyFlowError {
|
||||
ApplyFlowError::failure(code, format!("Ошибка storage: {error}"))
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Small injectable time boundary for deterministic activity records.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub trait Clock {
|
||||
fn now(&self) -> String;
|
||||
}
|
||||
|
||||
pub struct SystemClock;
|
||||
|
||||
impl Clock for SystemClock {
|
||||
fn now(&self) -> String {
|
||||
let seconds = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
format!("unix:{seconds}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
//! Serialized Tauri command boundary types.
|
||||
//!
|
||||
//! System/domain truth stays in `models`; these DTOs only define the stable
|
||||
//! camelCase contract exposed to the React webview.
|
||||
|
||||
use crate::adapters::singbox::SingBoxCheckResult;
|
||||
use crate::models::{
|
||||
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
|
||||
Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
|
||||
SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind,
|
||||
};
|
||||
use crate::singbox_service::SingBoxSetupStatus;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminStatusResponse {
|
||||
pub is_windows: bool,
|
||||
pub is_elevated: bool,
|
||||
pub can_restart_elevated: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ValidationIssue {
|
||||
pub field: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommandError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub details: Vec<ValidationIssue>,
|
||||
}
|
||||
|
||||
impl CommandError {
|
||||
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
details: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_details(
|
||||
code: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
details: Vec<ValidationIssue>,
|
||||
) -> Self {
|
||||
Self {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
details,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StatusResponse {
|
||||
pub route_line: String,
|
||||
pub active_profile_count: usize,
|
||||
pub routed_app_count: usize,
|
||||
pub active_target: Option<TargetDto>,
|
||||
pub components: Vec<ComponentStatusDto>,
|
||||
pub recent_activity: Vec<ActivityEntryDto>,
|
||||
pub generated_config_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SavedStateResponse {
|
||||
pub profiles: Vec<ProfileDto>,
|
||||
pub targets: Vec<TargetDto>,
|
||||
pub generated_config_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StartupSnapshotResponse {
|
||||
pub admin_status: AdminStatusResponse,
|
||||
pub saved_state: SavedStateResponse,
|
||||
pub components: Vec<ComponentStatusDto>,
|
||||
pub proxifyre_setup_status: ProxiFyreSetupStatusDto,
|
||||
pub singbox_status: LocalSingBoxStatusResponse,
|
||||
pub singbox_setup_status: SingBoxSetupStatusDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxiFyreSetupStatusDto {
|
||||
pub ready: bool,
|
||||
pub missing_count: usize,
|
||||
pub items: Vec<ProxiFyreSetupItemDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxiFyreSetupItemDto {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub installed: bool,
|
||||
pub version: Option<String>,
|
||||
pub details: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxiFyreSetupProgressDto {
|
||||
pub operation: String,
|
||||
pub status: String,
|
||||
pub active_step: Option<String>,
|
||||
pub percent: u8,
|
||||
pub message: String,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
pub type SingBoxSetupStatusDto = SingBoxSetupStatus;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalSingBoxStatusResponse {
|
||||
pub config: LocalSingBoxConfigDto,
|
||||
pub cache: Option<SubscriptionCacheDto>,
|
||||
pub component: ComponentStatusDto,
|
||||
pub generated_config_path: String,
|
||||
pub lan_listen_host: Option<String>,
|
||||
#[cfg(debug_assertions)]
|
||||
pub subscription_identity: SubscriptionRequestIdentityDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalSingBoxConfigDto {
|
||||
pub subscription_display_url: Option<String>,
|
||||
pub has_subscription: bool,
|
||||
pub selected_server_tag: Option<String>,
|
||||
pub selected_server_id: Option<String>,
|
||||
pub listen_host: String,
|
||||
pub listen_port: u16,
|
||||
pub service_name: String,
|
||||
pub install_root: String,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscriptionCacheDto {
|
||||
pub servers: Vec<SubscriptionServerDto>,
|
||||
pub user_info: serde_json::Map<String, serde_json::Value>,
|
||||
pub fetched_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscriptionServerDto {
|
||||
pub id: String,
|
||||
pub tag: String,
|
||||
#[serde(rename = "type")]
|
||||
pub server_type: String,
|
||||
pub server: String,
|
||||
pub server_port: u16,
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscriptionRequestIdentityDto {
|
||||
pub headers: Vec<SubscriptionRequestHeaderDto>,
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscriptionRequestHeaderDto {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveSingBoxSubscriptionInputDto {
|
||||
pub subscription_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SelectSingBoxServerInputDto {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
pub tag: String,
|
||||
#[serde(default)]
|
||||
pub server: Option<String>,
|
||||
#[serde(default)]
|
||||
pub server_port: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PingSingBoxServerInputDto {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PingProxyTargetInputDto {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PingServerResponse {
|
||||
pub id: String,
|
||||
pub tag: String,
|
||||
pub server: String,
|
||||
pub server_port: u16,
|
||||
pub ok: bool,
|
||||
pub latency: Option<u128>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyProbeResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub ok: bool,
|
||||
pub status: Option<u16>,
|
||||
pub latency: Option<u128>,
|
||||
pub ip: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyTargetCheckResponse {
|
||||
pub tag: String,
|
||||
pub server: String,
|
||||
pub server_port: u16,
|
||||
pub ok: bool,
|
||||
pub latency: Option<u128>,
|
||||
pub error: Option<String>,
|
||||
pub probes: Vec<ProxyProbeResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GenerateSingBoxConfigResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub adapter_id: String,
|
||||
pub generated_config_path: String,
|
||||
pub selected_server_tag: String,
|
||||
pub listen_host: String,
|
||||
pub listen_port: u16,
|
||||
pub check: Option<SingBoxCheckResult>,
|
||||
pub activity: ActivityEntryDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileInputDto {
|
||||
pub id: Option<String>,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub target_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub protocols: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub items: Option<Vec<ProfileItemInputDto>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProfileItemInputDto {
|
||||
#[serde(rename = "type")]
|
||||
pub item_type: String,
|
||||
pub value: String,
|
||||
#[serde(default)]
|
||||
pub recursive: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetInputDto {
|
||||
pub id: Option<String>,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub protocol: Option<String>,
|
||||
pub host: String,
|
||||
pub port: u32,
|
||||
#[serde(default)]
|
||||
pub requires_component: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileDto {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
pub target_id: String,
|
||||
pub protocols: Vec<Protocol>,
|
||||
pub items: Vec<ProfileItemDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProfileItemDto {
|
||||
#[serde(rename = "type")]
|
||||
pub item_type: ProfileItemType,
|
||||
pub value: String,
|
||||
pub recursive: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetDto {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: TargetKind,
|
||||
pub protocol: ProxyProtocol,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub requires_component: Option<ComponentId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComponentStatusDto {
|
||||
pub id: ComponentId,
|
||||
pub name: String,
|
||||
pub state: ComponentState,
|
||||
pub installed: bool,
|
||||
pub running: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub service_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub service_status: Option<String>,
|
||||
pub problems: Vec<String>,
|
||||
pub actions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActivityEntryDto {
|
||||
pub id: String,
|
||||
pub at: String,
|
||||
pub level: ActivityLevel,
|
||||
pub title: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResolveProfilePreviewResponse {
|
||||
pub profile_id: String,
|
||||
pub apps: Vec<ResolvedAppDto>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResolvedAppDto {
|
||||
pub source_type: ProfileItemType,
|
||||
pub source_value: String,
|
||||
pub app_name: String,
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<ProfileInputDto> for ProfileInput {
|
||||
fn from(input: ProfileInputDto) -> Self {
|
||||
Self {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
enabled: input.enabled.unwrap_or(true),
|
||||
target_id: input
|
||||
.target_id
|
||||
.unwrap_or_else(|| "local-singbox".to_string()),
|
||||
protocols: input
|
||||
.protocols
|
||||
.unwrap_or_else(|| vec!["TCP".to_string(), "UDP".to_string()]),
|
||||
items: input
|
||||
.items
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(ProfileItemInput::from)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProfileItemInputDto> for ProfileItemInput {
|
||||
fn from(input: ProfileItemInputDto) -> Self {
|
||||
Self {
|
||||
item_type: input.item_type,
|
||||
value: input.value,
|
||||
recursive: input.recursive,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TargetInputDto> for TargetInput {
|
||||
fn from(input: TargetInputDto) -> Self {
|
||||
Self {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
kind: input.kind.unwrap_or_else(|| "external".to_string()),
|
||||
protocol: input.protocol.unwrap_or_else(|| "socks5".to_string()),
|
||||
host: input.host,
|
||||
port: input.port,
|
||||
requires_component: input.requires_component,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Profile> for ProfileDto {
|
||||
fn from(profile: &Profile) -> Self {
|
||||
Self {
|
||||
id: profile.id.clone(),
|
||||
name: profile.name.clone(),
|
||||
enabled: profile.enabled,
|
||||
target_id: profile.target_id.clone(),
|
||||
protocols: profile.protocols.clone(),
|
||||
items: profile.items.iter().map(ProfileItemDto::from).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ProfileItem> for ProfileItemDto {
|
||||
fn from(item: &ProfileItem) -> Self {
|
||||
Self {
|
||||
item_type: item.item_type.clone(),
|
||||
value: item.value.clone(),
|
||||
recursive: item.recursive,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Target> for TargetDto {
|
||||
fn from(target: &Target) -> Self {
|
||||
Self {
|
||||
id: target.id.clone(),
|
||||
name: target.name.clone(),
|
||||
kind: target.kind.clone(),
|
||||
protocol: target.protocol.clone(),
|
||||
host: target.host.clone(),
|
||||
port: target.port,
|
||||
requires_component: target.requires_component.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ComponentStatus> for ComponentStatusDto {
|
||||
fn from(component: &ComponentStatus) -> Self {
|
||||
Self {
|
||||
id: component.id.clone(),
|
||||
name: component.name.clone(),
|
||||
state: component.state.clone(),
|
||||
installed: component.installed,
|
||||
running: component.running,
|
||||
version: component.version.clone(),
|
||||
path: component.path.clone(),
|
||||
service_name: component.service_name.clone(),
|
||||
service_status: component.service_status.clone(),
|
||||
problems: component.problems.clone(),
|
||||
actions: component.actions.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ActivityEntry> for ActivityEntryDto {
|
||||
fn from(entry: &ActivityEntry) -> Self {
|
||||
Self {
|
||||
id: entry.id.clone(),
|
||||
at: entry.at.clone(),
|
||||
level: entry.level.clone(),
|
||||
title: entry.title.clone(),
|
||||
message: entry.message.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&LocalSingBoxConfig> for LocalSingBoxConfigDto {
|
||||
fn from(config: &LocalSingBoxConfig) -> Self {
|
||||
Self {
|
||||
subscription_display_url: config.subscription_display_url(),
|
||||
has_subscription: config
|
||||
.subscription_url
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.trim().is_empty()),
|
||||
selected_server_tag: config.selected_server_tag.clone(),
|
||||
selected_server_id: config.selected_server_id.clone(),
|
||||
listen_host: config.listen_host.clone(),
|
||||
listen_port: config.listen_port,
|
||||
service_name: config.service_name.clone(),
|
||||
install_root: config.install_root.clone(),
|
||||
updated_at: config.updated_at.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SubscriptionCache> for SubscriptionCacheDto {
|
||||
fn from(cache: &SubscriptionCache) -> Self {
|
||||
Self {
|
||||
servers: cache
|
||||
.servers
|
||||
.iter()
|
||||
.map(SubscriptionServerDto::from)
|
||||
.collect(),
|
||||
user_info: cache.user_info.clone(),
|
||||
fetched_at: cache.fetched_at.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SubscriptionServer> for SubscriptionServerDto {
|
||||
fn from(server: &SubscriptionServer) -> Self {
|
||||
Self {
|
||||
id: server.id.clone(),
|
||||
tag: server.tag.clone(),
|
||||
server_type: server.server_type.clone(),
|
||||
server: server.server.clone(),
|
||||
server_port: server.server_port,
|
||||
}
|
||||
}
|
||||
}
|
||||
+153
-3972
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,12 @@ use std::{
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
pub const PROXYWARDEN_COMPONENTS_DIR_NAME: &str = "components";
|
||||
pub const PROXIFYRE_COMPONENT_DIR_NAME: &str = "ProxiFyre";
|
||||
pub const SINGBOX_COMPONENT_DIR_NAME: &str = "sing-box";
|
||||
#[cfg(debug_assertions)]
|
||||
const PROXYWARDEN_DEV_INSTALL_ROOT_ENV: &str = "PROXYWARDEN_DEV_INSTALL_ROOT";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProxyfierEngine {
|
||||
ProxiFyre,
|
||||
@@ -23,6 +29,15 @@ pub struct DetectedProxyfier {
|
||||
pub config_path: Option<PathBuf>,
|
||||
pub running: bool,
|
||||
pub service_name: Option<String>,
|
||||
pub service_status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DetectedService {
|
||||
pub name: String,
|
||||
pub status: String,
|
||||
pub path_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -50,7 +65,21 @@ pub trait ProxyfierDetectionHost {
|
||||
|
||||
fn process_running(&self, process_name: &str) -> bool;
|
||||
|
||||
fn service_running(&self, service_name: &str) -> bool;
|
||||
fn service_status(&self, service_name: &str) -> Option<String>;
|
||||
|
||||
fn service_info(&self, service_name: &str) -> Option<DetectedService> {
|
||||
self.service_status(service_name)
|
||||
.map(|status| DetectedService {
|
||||
name: service_name.to_string(),
|
||||
status,
|
||||
path_name: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn service_running(&self, service_name: &str) -> bool {
|
||||
self.service_status(service_name)
|
||||
.is_some_and(|status| service_status_is_running(&status))
|
||||
}
|
||||
|
||||
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry>;
|
||||
}
|
||||
@@ -77,13 +106,22 @@ impl ProxyfierDetectionHost for SystemProxyfierDetectionHost {
|
||||
powershell_bool(&script)
|
||||
}
|
||||
|
||||
fn service_running(&self, service_name: &str) -> bool {
|
||||
fn service_status(&self, service_name: &str) -> Option<String> {
|
||||
let script = format!(
|
||||
"$s = Get-Service -Name '{}' -ErrorAction SilentlyContinue; if ($s -and $s.Status -eq 'Running') {{ 'true' }} else {{ 'false' }}",
|
||||
"$s = Get-Service -Name '{}' -ErrorAction SilentlyContinue; if ($s) {{ $s.Status.ToString() }}",
|
||||
escape_powershell_single(service_name)
|
||||
);
|
||||
|
||||
powershell_bool(&script)
|
||||
powershell_text(&script).map(|status| status.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn service_info(&self, service_name: &str) -> Option<DetectedService> {
|
||||
let script = format!(
|
||||
"$s = Get-CimInstance Win32_Service -Filter \"Name='{}'\" -ErrorAction SilentlyContinue; if ($s) {{ [ordered]@{{ name = $s.Name; status = $s.State; pathName = $s.PathName }} | ConvertTo-Json -Compress }}",
|
||||
escape_powershell_single(service_name)
|
||||
);
|
||||
let json = powershell_text(&script)?;
|
||||
serde_json::from_str(&json).ok()
|
||||
}
|
||||
|
||||
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
|
||||
@@ -95,16 +133,77 @@ pub fn detect_proxyfier_install() -> Option<DetectedProxyfier> {
|
||||
detect_proxyfier_install_with_host(&SystemProxyfierDetectionHost)
|
||||
}
|
||||
|
||||
pub fn app_install_dir_from_current_exe() -> Option<PathBuf> {
|
||||
#[cfg(debug_assertions)]
|
||||
if let Some(path) = env::var_os(PROXYWARDEN_DEV_INSTALL_ROOT_ENV)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
{
|
||||
return Some(path);
|
||||
}
|
||||
|
||||
env::current_exe()
|
||||
.ok()
|
||||
.and_then(|path| path.parent().map(Path::to_path_buf))
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[cfg(debug_assertions)]
|
||||
fn debug_component_roots_follow_configured_install() {
|
||||
let install_root = PathBuf::from(
|
||||
env::var(PROXYWARDEN_DEV_INSTALL_ROOT_ENV)
|
||||
.expect("Cargo dev config should define the installed ProxyWarden root"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
default_proxifyre_install_dir(),
|
||||
proxifyre_install_dir_from_app_dir(&install_root)
|
||||
);
|
||||
assert_eq!(
|
||||
default_singbox_install_dir(),
|
||||
singbox_install_dir_from_app_dir(&install_root)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn component_root_from_app_dir(app_dir: &Path) -> PathBuf {
|
||||
app_dir.join(PROXYWARDEN_COMPONENTS_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn proxifyre_install_dir_from_app_dir(app_dir: &Path) -> PathBuf {
|
||||
component_root_from_app_dir(app_dir).join(PROXIFYRE_COMPONENT_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn singbox_install_dir_from_app_dir(app_dir: &Path) -> PathBuf {
|
||||
component_root_from_app_dir(app_dir).join(SINGBOX_COMPONENT_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn default_proxifyre_install_dir() -> PathBuf {
|
||||
app_install_dir_from_current_exe()
|
||||
.map(|app_dir| proxifyre_install_dir_from_app_dir(&app_dir))
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(r"C:\Program Files\ProxyWarden")
|
||||
.join(PROXYWARDEN_COMPONENTS_DIR_NAME)
|
||||
.join(PROXIFYRE_COMPONENT_DIR_NAME)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn default_singbox_install_dir() -> PathBuf {
|
||||
app_install_dir_from_current_exe()
|
||||
.map(|app_dir| singbox_install_dir_from_app_dir(&app_dir))
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT))
|
||||
}
|
||||
|
||||
pub fn detect_proxyfier_install_with_host(
|
||||
host: &impl ProxyfierDetectionHost,
|
||||
) -> Option<DetectedProxyfier> {
|
||||
let proxifyre_running = host.process_running("ProxiFyre.exe")
|
||||
|| host.service_running("ProxiFyreService")
|
||||
|| host.service_running("ProxiFyre");
|
||||
|
||||
proxyfier_candidates(host)
|
||||
.into_iter()
|
||||
.filter_map(|candidate| candidate.into_detected(host, proxifyre_running))
|
||||
.filter_map(|candidate| candidate.into_detected(host))
|
||||
.next()
|
||||
}
|
||||
|
||||
@@ -161,6 +260,15 @@ fn detected_proxyfier_component(proxyfier: &DetectedProxyfier) -> ComponentStatu
|
||||
}
|
||||
}
|
||||
};
|
||||
let service_name = proxyfier
|
||||
.service_name
|
||||
.clone()
|
||||
.or_else(|| service_name(&proxyfier.engine).map(str::to_string));
|
||||
let service_status = proxyfier.service_status.clone();
|
||||
let mut problems = Vec::new();
|
||||
if service_status.is_none() {
|
||||
problems.push("Служба ProxiFyre не установлена".to_string());
|
||||
}
|
||||
|
||||
ComponentStatus {
|
||||
id: ComponentId::Proxyfier,
|
||||
@@ -169,10 +277,16 @@ fn detected_proxyfier_component(proxyfier: &DetectedProxyfier) -> ComponentStatu
|
||||
installed: true,
|
||||
running: proxyfier.running,
|
||||
version: Some(match proxyfier.engine {
|
||||
ProxyfierEngine::ProxiFyre => "ProxiFyre найден".to_string(),
|
||||
ProxyfierEngine::ProxiFyre => match service_status.as_deref() {
|
||||
Some(status) if service_status_is_running(status) => "служба запущена".to_string(),
|
||||
Some(_) => "служба остановлена".to_string(),
|
||||
None => "служба не установлена".to_string(),
|
||||
},
|
||||
}),
|
||||
path: Some(proxyfier.install_dir.display().to_string()),
|
||||
problems: Vec::new(),
|
||||
service_name,
|
||||
service_status,
|
||||
problems,
|
||||
actions,
|
||||
}
|
||||
}
|
||||
@@ -186,6 +300,8 @@ fn missing_proxyfier_component() -> ComponentStatus {
|
||||
running: false,
|
||||
version: None,
|
||||
path: None,
|
||||
service_name: service_name(&ProxyfierEngine::ProxiFyre).map(str::to_string),
|
||||
service_status: None,
|
||||
problems: vec!["ProxiFyre нужен для маршрутизации выбранных приложений".to_string()],
|
||||
actions: vec!["Установить ProxiFyre".to_string()],
|
||||
}
|
||||
@@ -224,6 +340,15 @@ fn detected_singbox_component(singbox: &DetectedSingBox) -> ComponentStatus {
|
||||
running: singbox.running,
|
||||
version: Some("sing-box найден".to_string()),
|
||||
path: Some(singbox.executable_path.display().to_string()),
|
||||
service_name: Some(singbox.service_name.clone()),
|
||||
service_status: Some(
|
||||
if singbox.running {
|
||||
"running"
|
||||
} else {
|
||||
"stopped"
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
problems,
|
||||
actions,
|
||||
}
|
||||
@@ -238,6 +363,8 @@ fn missing_singbox_component() -> ComponentStatus {
|
||||
running: false,
|
||||
version: None,
|
||||
path: None,
|
||||
service_name: Some(DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string()),
|
||||
service_status: None,
|
||||
problems: Vec::new(),
|
||||
actions: vec!["Установить Local sing-box".to_string()],
|
||||
}
|
||||
@@ -251,25 +378,24 @@ struct ProxyfierCandidate {
|
||||
}
|
||||
|
||||
impl ProxyfierCandidate {
|
||||
fn into_detected(
|
||||
self,
|
||||
host: &impl ProxyfierDetectionHost,
|
||||
proxifyre_running: bool,
|
||||
) -> Option<DetectedProxyfier> {
|
||||
fn into_detected(self, host: &impl ProxyfierDetectionHost) -> Option<DetectedProxyfier> {
|
||||
let executable_path = self.install_dir.join(executable_name(&self.engine));
|
||||
let config_path = config_path(&self.engine, &self.install_dir);
|
||||
let exists = host.path_exists(&self.install_dir)
|
||||
|| host.path_exists(&executable_path)
|
||||
|| config_path
|
||||
.as_ref()
|
||||
.is_some_and(|path| host.path_exists(path));
|
||||
|
||||
if !exists {
|
||||
if !host.path_exists(&executable_path) {
|
||||
return None;
|
||||
}
|
||||
let detected_service = detect_proxifyre_service(host, &executable_path);
|
||||
let proxifyre_running = detected_service
|
||||
.as_ref()
|
||||
.is_some_and(|service| service_status_is_running(&service.status));
|
||||
|
||||
Some(DetectedProxyfier {
|
||||
service_name: service_name(&self.engine).map(str::to_string),
|
||||
service_name: detected_service
|
||||
.as_ref()
|
||||
.map(|service| service.name.clone()),
|
||||
service_status: detected_service
|
||||
.as_ref()
|
||||
.map(|service| service.status.clone()),
|
||||
engine: self.engine,
|
||||
name: self.name,
|
||||
install_dir: self.install_dir,
|
||||
@@ -290,6 +416,14 @@ fn proxyfier_candidates(host: &impl ProxyfierDetectionHost) -> Vec<ProxyfierCand
|
||||
"ProxiFyre",
|
||||
"PROXYWARDEN_PROXIFYRE_ROOT",
|
||||
);
|
||||
push_candidate(
|
||||
&mut candidates,
|
||||
ProxyfierCandidate {
|
||||
engine: ProxyfierEngine::ProxiFyre,
|
||||
name: "ProxiFyre".to_string(),
|
||||
install_dir: default_proxifyre_install_dir(),
|
||||
},
|
||||
);
|
||||
for entry in host.registry_install_entries() {
|
||||
if let Some(engine) = engine_from_name(&entry.display_name) {
|
||||
let install_dir = entry
|
||||
@@ -350,11 +484,17 @@ fn push_candidate(candidates: &mut Vec<ProxyfierCandidate>, candidate: Proxyfier
|
||||
}
|
||||
|
||||
fn common_install_dirs(host: &impl ProxyfierDetectionHost, folder_name: &str) -> Vec<PathBuf> {
|
||||
let mut dirs = vec![PathBuf::from(format!(r"C:\Tools\{folder_name}"))];
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
|
||||
if let Some(root) = host.env_var(env_name) {
|
||||
dirs.push(PathBuf::from(root).join(folder_name));
|
||||
let proxywarden_root = PathBuf::from(root).join("ProxyWarden");
|
||||
dirs.push(
|
||||
proxywarden_root
|
||||
.join(PROXYWARDEN_COMPONENTS_DIR_NAME)
|
||||
.join(folder_name),
|
||||
);
|
||||
dirs.push(proxywarden_root.join(folder_name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,21 +507,26 @@ fn singbox_candidates(host: &impl ProxyfierDetectionHost) -> Vec<PathBuf> {
|
||||
if let Some(path) = host.env_var("PROXYWARDEN_SINGBOX_ROOT") {
|
||||
push_path_candidate(&mut candidates, PathBuf::from(path));
|
||||
}
|
||||
push_path_candidate(&mut candidates, default_singbox_install_dir());
|
||||
push_path_candidate(
|
||||
&mut candidates,
|
||||
PathBuf::from(DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT),
|
||||
);
|
||||
push_path_candidate(
|
||||
&mut candidates,
|
||||
PathBuf::from(r"C:\Tools\ProxyWarden\sing-box"),
|
||||
);
|
||||
for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
|
||||
if let Some(root) = host.env_var(env_name) {
|
||||
push_path_candidate(
|
||||
&mut candidates,
|
||||
PathBuf::from(&root).join("ProxyWarden").join("sing-box"),
|
||||
PathBuf::from(&root)
|
||||
.join("ProxyWarden")
|
||||
.join(PROXYWARDEN_COMPONENTS_DIR_NAME)
|
||||
.join(SINGBOX_COMPONENT_DIR_NAME),
|
||||
);
|
||||
push_path_candidate(
|
||||
&mut candidates,
|
||||
PathBuf::from(&root)
|
||||
.join("ProxyWarden")
|
||||
.join(SINGBOX_COMPONENT_DIR_NAME),
|
||||
);
|
||||
push_path_candidate(&mut candidates, PathBuf::from(root).join("sing-box"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +586,44 @@ fn service_name(engine: &ProxyfierEngine) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_proxifyre_service(
|
||||
host: &impl ProxyfierDetectionHost,
|
||||
executable_path: &Path,
|
||||
) -> Option<DetectedService> {
|
||||
for name in ["ProxiFyreService", "ProxiFyre"] {
|
||||
if let Some(mut service) = host.service_info(name) {
|
||||
let matches_executable = service.path_name.as_deref().is_some_and(|path_name| {
|
||||
service_path_matches_executable(path_name, executable_path)
|
||||
});
|
||||
if matches_executable {
|
||||
service.status = normalize_service_status(&service.status);
|
||||
return Some(service);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn service_path_matches_executable(path_name: &str, executable_path: &Path) -> bool {
|
||||
let path_name = path_name.trim();
|
||||
let candidate = if let Some(rest) = path_name.strip_prefix('"') {
|
||||
rest.split_once('"').map(|(path, _)| path)
|
||||
} else {
|
||||
path_name.split_whitespace().next()
|
||||
};
|
||||
|
||||
candidate.is_some_and(|candidate| same_path(Path::new(candidate), executable_path))
|
||||
}
|
||||
|
||||
fn normalize_service_status(status: &str) -> String {
|
||||
status.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn service_status_is_running(status: &str) -> bool {
|
||||
status.trim().eq_ignore_ascii_case("running")
|
||||
}
|
||||
|
||||
fn engine_from_name(name: &str) -> Option<ProxyfierEngine> {
|
||||
let normalized = name.to_ascii_lowercase();
|
||||
if normalized.contains("proxifyre") {
|
||||
@@ -459,6 +642,17 @@ fn same_path(left: &Path, right: &Path) -> bool {
|
||||
.eq_ignore_ascii_case(&right.to_string_lossy())
|
||||
}
|
||||
|
||||
fn powershell_text(script: &str) -> Option<String> {
|
||||
command_no_window("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", script])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.and_then(|output| String::from_utf8(output.stdout).ok())
|
||||
.map(|stdout| stdout.trim().to_string())
|
||||
.filter(|stdout| !stdout.is_empty())
|
||||
}
|
||||
|
||||
fn powershell_bool(script: &str) -> bool {
|
||||
command_no_window("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", script])
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Live component status resolution and read-only route/profile presentation.
|
||||
|
||||
use crate::command_dto::{CommandError, ResolvedAppDto};
|
||||
use crate::component_detection::{
|
||||
detect_proxyfier_install, detect_singbox_install, proxyfier_component_from_detection,
|
||||
singbox_component_from_detection, DetectedProxyfier, DetectedSingBox,
|
||||
};
|
||||
use crate::models::{
|
||||
ComponentId, ComponentState, ComponentStatus, ProfileItem, ProfileItemType, Target,
|
||||
};
|
||||
use crate::storage::JsonStorage;
|
||||
|
||||
pub(crate) fn components_or_defaults(
|
||||
storage: &JsonStorage,
|
||||
) -> Result<Vec<ComponentStatus>, CommandError> {
|
||||
components_or_defaults_with_detection(
|
||||
storage,
|
||||
detect_proxyfier_install(),
|
||||
detect_singbox_install(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn components_or_defaults_with_detection(
|
||||
storage: &JsonStorage,
|
||||
detected_proxyfier: Option<DetectedProxyfier>,
|
||||
detected_singbox: Option<DetectedSingBox>,
|
||||
) -> Result<Vec<ComponentStatus>, CommandError> {
|
||||
let components = storage.read_components().map_err(storage_error)?;
|
||||
Ok(resolve_component_statuses(
|
||||
components,
|
||||
detected_proxyfier,
|
||||
detected_singbox,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn resolve_component_statuses(
|
||||
stored_components: Vec<ComponentStatus>,
|
||||
detected_proxyfier: Option<DetectedProxyfier>,
|
||||
detected_singbox: Option<DetectedSingBox>,
|
||||
) -> Vec<ComponentStatus> {
|
||||
let mut components = default_components();
|
||||
|
||||
for component in stored_components {
|
||||
upsert_component(&mut components, component);
|
||||
}
|
||||
|
||||
upsert_component(
|
||||
&mut components,
|
||||
proxyfier_component_from_detection(detected_proxyfier.as_ref()),
|
||||
);
|
||||
upsert_component(
|
||||
&mut components,
|
||||
singbox_component_from_detection(detected_singbox.as_ref()),
|
||||
);
|
||||
|
||||
components
|
||||
}
|
||||
|
||||
fn default_components() -> Vec<ComponentStatus> {
|
||||
vec![
|
||||
ComponentStatus {
|
||||
id: ComponentId::ControlApp,
|
||||
name: "Приложение управления".to_string(),
|
||||
state: ComponentState::Running,
|
||||
installed: true,
|
||||
running: true,
|
||||
version: None,
|
||||
path: None,
|
||||
service_name: None,
|
||||
service_status: None,
|
||||
problems: Vec::new(),
|
||||
actions: vec![
|
||||
"Открыть журнал".to_string(),
|
||||
"Скопировать диагностику".to_string(),
|
||||
],
|
||||
},
|
||||
ComponentStatus {
|
||||
id: ComponentId::Proxyfier,
|
||||
name: "ProxiFyre".to_string(),
|
||||
state: ComponentState::Missing,
|
||||
installed: false,
|
||||
running: false,
|
||||
version: None,
|
||||
path: None,
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
service_status: None,
|
||||
problems: vec!["ProxiFyre нужен для маршрутизации выбранных приложений".to_string()],
|
||||
actions: vec!["Установить ProxiFyre".to_string()],
|
||||
},
|
||||
ComponentStatus {
|
||||
id: ComponentId::Singbox,
|
||||
name: "Локальный sing-box".to_string(),
|
||||
state: ComponentState::Missing,
|
||||
installed: false,
|
||||
running: false,
|
||||
version: None,
|
||||
path: None,
|
||||
service_name: Some(crate::models::DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string()),
|
||||
service_status: None,
|
||||
problems: Vec::new(),
|
||||
actions: vec!["Установить локальный sing-box".to_string()],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn upsert_component(components: &mut Vec<ComponentStatus>, component: ComponentStatus) {
|
||||
match components
|
||||
.iter()
|
||||
.position(|existing| existing.id == component.id)
|
||||
{
|
||||
Some(index) => components[index] = component,
|
||||
None => components.push(component),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn route_line(active_target: Option<&Target>) -> String {
|
||||
match active_target {
|
||||
Some(target) if target.id == "local-singbox" => {
|
||||
format!(
|
||||
"Выбранные приложения -> ProxiFyre -> локальный sing-box {}:{} -> VPN",
|
||||
target.host, target.port
|
||||
)
|
||||
}
|
||||
Some(target) => format!(
|
||||
"Выбранные приложения -> ProxiFyre -> внешний прокси {}:{}",
|
||||
target.host, target.port
|
||||
),
|
||||
None => "Выбранные приложения -> ProxiFyre -> внешний прокси".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolved_app(item: &ProfileItem, warnings: &mut Vec<String>) -> ResolvedAppDto {
|
||||
let mut notes = Vec::new();
|
||||
match item.item_type {
|
||||
ProfileItemType::Process => notes.push("Имя процесса используется напрямую".to_string()),
|
||||
ProfileItemType::Folder => {
|
||||
let note = "Сканирование папок отложено; ProxiFyre получает путь к папке";
|
||||
notes.push(note.to_string());
|
||||
warnings.push(note.to_string());
|
||||
}
|
||||
ProfileItemType::Exe => {
|
||||
notes.push("Путь к EXE сохраняется для сопоставления в ProxiFyre".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
ResolvedAppDto {
|
||||
source_type: item.item_type.clone(),
|
||||
source_value: item.value.clone(),
|
||||
app_name: item.value.clone(),
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_error(error: std::io::Error) -> CommandError {
|
||||
CommandError::new("storage_error", error.to_string())
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
//! Persisted profiles/targets, startup snapshot, ProxiFyre bootstrap import, and preview use cases.
|
||||
|
||||
use crate::adapters::proxifyre::{ProxiFyreConfig, ProxiFyreProxy};
|
||||
use crate::admin::admin_status;
|
||||
use crate::command_dto::*;
|
||||
use crate::component_detection::{
|
||||
default_proxifyre_install_dir, default_singbox_install_dir, detect_proxyfier_install,
|
||||
detect_singbox_install,
|
||||
};
|
||||
use crate::component_status::{
|
||||
components_or_defaults, resolve_component_statuses, resolved_app, route_line,
|
||||
};
|
||||
use crate::models::{
|
||||
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind,
|
||||
};
|
||||
use crate::proxifyre_runtime::build_proxifyre_setup_status_with_detection;
|
||||
use crate::singbox_service::build_singbox_setup_status_with_install_root;
|
||||
use crate::singbox_subscription::read_singbox_status_with_detection;
|
||||
use crate::storage::JsonStorage;
|
||||
use crate::validation::{normalize_profile, normalize_target, ValidationError};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const MAIN_PROFILE_ID: &str = "main-profile";
|
||||
const MAIN_TARGET_ID: &str = "main-proxy";
|
||||
|
||||
pub fn build_status(storage: &JsonStorage) -> Result<StatusResponse, CommandError> {
|
||||
let profiles = storage.read_profiles().map_err(storage_error)?;
|
||||
let targets = storage.read_targets().map_err(storage_error)?;
|
||||
let components = components_or_defaults(storage)?;
|
||||
let activity = storage.read_activity().map_err(storage_error)?;
|
||||
let active_profile_count = profiles.iter().filter(|profile| profile.enabled).count();
|
||||
let routed_app_count = profiles
|
||||
.iter()
|
||||
.filter(|profile| profile.enabled)
|
||||
.map(|profile| profile.items.len())
|
||||
.sum();
|
||||
let active_target = profiles
|
||||
.iter()
|
||||
.find(|profile| profile.enabled)
|
||||
.and_then(|profile| targets.iter().find(|target| target.id == profile.target_id));
|
||||
let route_line = route_line(active_target);
|
||||
|
||||
Ok(StatusResponse {
|
||||
route_line,
|
||||
active_profile_count,
|
||||
routed_app_count,
|
||||
active_target: active_target.map(TargetDto::from),
|
||||
components: components.iter().map(ComponentStatusDto::from).collect(),
|
||||
recent_activity: activity
|
||||
.iter()
|
||||
.take(10)
|
||||
.map(ActivityEntryDto::from)
|
||||
.collect(),
|
||||
generated_config_path: storage
|
||||
.paths()
|
||||
.generated_dir
|
||||
.join("proxifyre-app-config.json")
|
||||
.display()
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_profiles(storage: &JsonStorage) -> Result<Vec<ProfileDto>, CommandError> {
|
||||
storage
|
||||
.read_profiles()
|
||||
.map_err(storage_error)
|
||||
.map(|profiles| profiles.iter().map(ProfileDto::from).collect())
|
||||
}
|
||||
|
||||
pub fn save_profile_to_storage(
|
||||
storage: &JsonStorage,
|
||||
input: ProfileInputDto,
|
||||
) -> Result<ProfileDto, CommandError> {
|
||||
let profile = normalize_profile(input.into()).map_err(validation_error)?;
|
||||
let mut profiles = storage.read_profiles().map_err(storage_error)?;
|
||||
|
||||
match profiles
|
||||
.iter()
|
||||
.position(|existing| existing.id == profile.id)
|
||||
{
|
||||
Some(index) => profiles[index] = profile.clone(),
|
||||
None => profiles.push(profile.clone()),
|
||||
}
|
||||
|
||||
storage.write_profiles(&profiles).map_err(storage_error)?;
|
||||
Ok(ProfileDto::from(&profile))
|
||||
}
|
||||
|
||||
pub fn read_targets(storage: &JsonStorage) -> Result<Vec<TargetDto>, CommandError> {
|
||||
storage
|
||||
.read_targets()
|
||||
.map_err(storage_error)
|
||||
.map(|targets| targets.iter().map(TargetDto::from).collect())
|
||||
}
|
||||
|
||||
pub fn save_target_to_storage(
|
||||
storage: &JsonStorage,
|
||||
input: TargetInputDto,
|
||||
) -> Result<TargetDto, CommandError> {
|
||||
let target = normalize_target(input.into()).map_err(validation_error)?;
|
||||
let mut targets = storage.read_targets().map_err(storage_error)?;
|
||||
|
||||
match targets.iter().position(|existing| existing.id == target.id) {
|
||||
Some(index) => targets[index] = target.clone(),
|
||||
None => targets.push(target.clone()),
|
||||
}
|
||||
|
||||
storage.write_targets(&targets).map_err(storage_error)?;
|
||||
Ok(TargetDto::from(&target))
|
||||
}
|
||||
|
||||
pub fn read_components(storage: &JsonStorage) -> Result<Vec<ComponentStatusDto>, CommandError> {
|
||||
components_or_defaults(storage).map(|components| {
|
||||
components
|
||||
.iter()
|
||||
.map(ComponentStatusDto::from)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_startup_snapshot(
|
||||
storage: &JsonStorage,
|
||||
) -> Result<StartupSnapshotResponse, CommandError> {
|
||||
// Both detectors query Windows independently. Run them together so the
|
||||
// startup snapshot is bounded by the slower check instead of their sum.
|
||||
let proxyfier_detection = std::thread::spawn(detect_proxyfier_install);
|
||||
let detected_singbox = detect_singbox_install();
|
||||
let detected_proxyfier = proxyfier_detection.join().ok().flatten();
|
||||
let saved_state = read_saved_state_with_proxifyre_config(
|
||||
storage,
|
||||
detected_proxyfier
|
||||
.as_ref()
|
||||
.and_then(|detected| detected.config_path.as_deref()),
|
||||
)?;
|
||||
let stored_components = storage.read_components().map_err(storage_error)?;
|
||||
let components = resolve_component_statuses(
|
||||
stored_components,
|
||||
detected_proxyfier.clone(),
|
||||
detected_singbox.clone(),
|
||||
)
|
||||
.iter()
|
||||
.map(ComponentStatusDto::from)
|
||||
.collect();
|
||||
let proxifyre_setup_status = build_proxifyre_setup_status_with_detection(
|
||||
detected_proxyfier.as_ref(),
|
||||
&default_proxifyre_install_dir(),
|
||||
);
|
||||
let singbox_status = read_singbox_status_with_detection(storage, detected_singbox.as_ref())?;
|
||||
let singbox_setup_status = build_singbox_setup_status_with_install_root(
|
||||
detected_singbox.as_ref(),
|
||||
&default_singbox_install_dir(),
|
||||
);
|
||||
|
||||
Ok(StartupSnapshotResponse {
|
||||
admin_status: admin_status(),
|
||||
saved_state,
|
||||
components,
|
||||
proxifyre_setup_status,
|
||||
singbox_status,
|
||||
singbox_setup_status,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_activity(storage: &JsonStorage) -> Result<Vec<ActivityEntryDto>, CommandError> {
|
||||
storage
|
||||
.read_activity()
|
||||
.map_err(storage_error)
|
||||
.map(|entries| entries.iter().map(ActivityEntryDto::from).collect())
|
||||
}
|
||||
|
||||
pub fn read_saved_state(storage: &JsonStorage) -> Result<SavedStateResponse, CommandError> {
|
||||
let detected_config_path = detect_proxyfier_install().and_then(|detected| detected.config_path);
|
||||
read_saved_state_with_proxifyre_config(storage, detected_config_path.as_deref())
|
||||
}
|
||||
|
||||
pub fn read_saved_state_with_proxifyre_config(
|
||||
storage: &JsonStorage,
|
||||
proxifyre_config_path: Option<&Path>,
|
||||
) -> Result<SavedStateResponse, CommandError> {
|
||||
let mut profiles = storage.read_profiles().map_err(storage_error)?;
|
||||
let mut targets = storage.read_targets().map_err(storage_error)?;
|
||||
|
||||
if should_bootstrap_profiles(&profiles) {
|
||||
if let Some(imported) =
|
||||
proxifyre_config_path.and_then(import_saved_state_from_proxifyre_config)
|
||||
{
|
||||
profiles = imported.profiles;
|
||||
upsert_targets(&mut targets, imported.targets);
|
||||
storage.write_targets(&targets).map_err(storage_error)?;
|
||||
storage.write_profiles(&profiles).map_err(storage_error)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SavedStateResponse {
|
||||
profiles: profiles.iter().map(ProfileDto::from).collect(),
|
||||
targets: targets.iter().map(TargetDto::from).collect(),
|
||||
generated_config_path: storage
|
||||
.paths()
|
||||
.generated_dir
|
||||
.join("proxifyre-app-config.json")
|
||||
.display()
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
struct ImportedSavedState {
|
||||
profiles: Vec<Profile>,
|
||||
targets: Vec<Target>,
|
||||
}
|
||||
|
||||
fn should_bootstrap_profiles(profiles: &[Profile]) -> bool {
|
||||
!profiles
|
||||
.iter()
|
||||
.any(|profile| profile.enabled && !profile.items.is_empty())
|
||||
}
|
||||
|
||||
fn import_saved_state_from_proxifyre_config(path: &Path) -> Option<ImportedSavedState> {
|
||||
let contents = fs::read_to_string(path).ok()?;
|
||||
let config: ProxiFyreConfig = serde_json::from_str(&contents).ok()?;
|
||||
|
||||
let proxy_entries = config
|
||||
.proxies
|
||||
.iter()
|
||||
.filter_map(import_proxy_entry)
|
||||
.collect::<Vec<_>>();
|
||||
if proxy_entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let single_entry = proxy_entries.len() == 1;
|
||||
let mut profiles = Vec::with_capacity(proxy_entries.len());
|
||||
let mut targets = Vec::with_capacity(proxy_entries.len());
|
||||
|
||||
for (index, entry) in proxy_entries.into_iter().enumerate() {
|
||||
let ordinal = index + 1;
|
||||
let target_id = if single_entry {
|
||||
MAIN_TARGET_ID.to_string()
|
||||
} else {
|
||||
format!("proxifyre-import-target-{ordinal}")
|
||||
};
|
||||
let profile_id = if single_entry {
|
||||
MAIN_PROFILE_ID.to_string()
|
||||
} else {
|
||||
format!("proxifyre-import-profile-{ordinal}")
|
||||
};
|
||||
let profile_name = if single_entry {
|
||||
"Приложения через прокси".to_string()
|
||||
} else {
|
||||
format!("Импорт ProxiFyre {ordinal}")
|
||||
};
|
||||
|
||||
targets.push(Target {
|
||||
id: target_id.clone(),
|
||||
name: if single_entry {
|
||||
"Основной прокси".to_string()
|
||||
} else {
|
||||
format!("Прокси ProxiFyre {ordinal}")
|
||||
},
|
||||
kind: TargetKind::External,
|
||||
protocol: ProxyProtocol::Socks5,
|
||||
host: entry.host,
|
||||
port: entry.port,
|
||||
requires_component: None,
|
||||
});
|
||||
profiles.push(Profile {
|
||||
id: profile_id,
|
||||
name: profile_name,
|
||||
enabled: true,
|
||||
target_id,
|
||||
protocols: entry.protocols,
|
||||
items: entry.items,
|
||||
});
|
||||
}
|
||||
|
||||
Some(ImportedSavedState { profiles, targets })
|
||||
}
|
||||
|
||||
struct ImportedProxyEntry {
|
||||
items: Vec<ProfileItem>,
|
||||
protocols: Vec<Protocol>,
|
||||
host: String,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
fn import_proxy_entry(proxy: &ProxiFyreProxy) -> Option<ImportedProxyEntry> {
|
||||
let items = proxy
|
||||
.app_names
|
||||
.iter()
|
||||
.filter_map(|name| imported_profile_item(name))
|
||||
.collect::<Vec<_>>();
|
||||
if items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (host, port) = parse_socks5_endpoint(&proxy.socks5_proxy_endpoint)?;
|
||||
|
||||
Some(ImportedProxyEntry {
|
||||
items,
|
||||
protocols: imported_protocols(&proxy.supported_protocols),
|
||||
host,
|
||||
port,
|
||||
})
|
||||
}
|
||||
|
||||
fn imported_profile_item(raw_value: &str) -> Option<ProfileItem> {
|
||||
let value = raw_value.trim().trim_matches('"');
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let looks_like_path = value.contains('\\') || value.contains('/');
|
||||
let item_type = if looks_like_path && value.to_ascii_lowercase().ends_with(".exe") {
|
||||
ProfileItemType::Exe
|
||||
} else if looks_like_path {
|
||||
ProfileItemType::Folder
|
||||
} else {
|
||||
ProfileItemType::Process
|
||||
};
|
||||
let value = match item_type {
|
||||
ProfileItemType::Process => {
|
||||
let base = value.rsplit(['\\', '/']).next().unwrap_or(value);
|
||||
if base.to_ascii_lowercase().ends_with(".exe") {
|
||||
base[..base.len() - 4].to_string()
|
||||
} else {
|
||||
base.to_string()
|
||||
}
|
||||
}
|
||||
ProfileItemType::Folder | ProfileItemType::Exe => value.to_string(),
|
||||
};
|
||||
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ProfileItem {
|
||||
recursive: matches!(item_type, ProfileItemType::Folder),
|
||||
item_type,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
fn imported_protocols(values: &[String]) -> Vec<Protocol> {
|
||||
let mut protocols = Vec::new();
|
||||
for value in values {
|
||||
let protocol = match value.trim().to_ascii_uppercase().as_str() {
|
||||
"TCP" => Protocol::Tcp,
|
||||
"UDP" => Protocol::Udp,
|
||||
_ => continue,
|
||||
};
|
||||
if !protocols.contains(&protocol) {
|
||||
protocols.push(protocol);
|
||||
}
|
||||
}
|
||||
|
||||
if protocols.is_empty() {
|
||||
vec![Protocol::Tcp, Protocol::Udp]
|
||||
} else {
|
||||
protocols
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_socks5_endpoint(endpoint: &str) -> Option<(String, u16)> {
|
||||
let endpoint = endpoint.trim();
|
||||
let endpoint = if endpoint
|
||||
.get(.."socks5://".len())
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("socks5://"))
|
||||
{
|
||||
&endpoint["socks5://".len()..]
|
||||
} else {
|
||||
endpoint
|
||||
};
|
||||
if endpoint.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(rest) = endpoint.strip_prefix('[') {
|
||||
let (host, rest) = rest.split_once(']')?;
|
||||
let port = rest.strip_prefix(':')?.parse::<u16>().ok()?;
|
||||
let host = host.trim();
|
||||
return (!host.is_empty()).then(|| (host.to_string(), port));
|
||||
}
|
||||
|
||||
let (host, port) = endpoint.rsplit_once(':')?;
|
||||
let host = host.trim();
|
||||
let port = port.trim().parse::<u16>().ok()?;
|
||||
(!host.is_empty()).then(|| (host.to_string(), port))
|
||||
}
|
||||
|
||||
fn upsert_targets(targets: &mut Vec<Target>, imported_targets: Vec<Target>) {
|
||||
for target in imported_targets {
|
||||
match targets.iter().position(|existing| existing.id == target.id) {
|
||||
Some(index) => targets[index] = target,
|
||||
None => targets.push(target),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_preview(
|
||||
input: ProfileInputDto,
|
||||
) -> Result<ResolveProfilePreviewResponse, CommandError> {
|
||||
let profile = normalize_profile(input.into()).map_err(validation_error)?;
|
||||
let mut warnings = Vec::new();
|
||||
let apps = profile
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| resolved_app(item, &mut warnings))
|
||||
.collect();
|
||||
|
||||
Ok(ResolveProfilePreviewResponse {
|
||||
profile_id: profile.id,
|
||||
apps,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
fn storage_error(error: std::io::Error) -> CommandError {
|
||||
CommandError::new("storage_error", error.to_string())
|
||||
}
|
||||
|
||||
fn validation_error(errors: Vec<ValidationError>) -> CommandError {
|
||||
CommandError::with_details(
|
||||
"validation_error",
|
||||
"Проверка введенных данных не прошла",
|
||||
errors
|
||||
.into_iter()
|
||||
.map(|error| ValidationIssue {
|
||||
field: error.field,
|
||||
message: error.message,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn temp_script_path(prefix: &str) -> PathBuf {
|
||||
env::temp_dir().join(unique_file_name(prefix, "ps1"))
|
||||
}
|
||||
|
||||
pub fn artifact_path(artifact_dir: &Path, prefix: &str, extension: &str) -> PathBuf {
|
||||
artifact_dir.join(unique_file_name(prefix, extension))
|
||||
}
|
||||
|
||||
fn unique_file_name(prefix: &str, extension: &str) -> String {
|
||||
let extension = extension.trim_start_matches('.');
|
||||
format!(
|
||||
"{prefix}-{}.{}",
|
||||
uuid::Uuid::new_v4().hyphenated(),
|
||||
extension
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,69 @@
|
||||
pub mod activity;
|
||||
pub mod admin;
|
||||
pub mod apply_flow;
|
||||
pub mod clock;
|
||||
pub mod command_dto;
|
||||
pub mod commands;
|
||||
pub mod component_detection;
|
||||
pub mod component_status;
|
||||
pub mod configuration_use_case;
|
||||
pub mod elevated_scripts;
|
||||
pub mod helper;
|
||||
pub mod models;
|
||||
mod powershell;
|
||||
pub mod process;
|
||||
pub mod proxifyre_ownership;
|
||||
pub mod proxifyre_runtime;
|
||||
pub mod proxifyre_scripts;
|
||||
pub mod proxy_apply;
|
||||
pub mod proxy_probe;
|
||||
pub mod safe_fs;
|
||||
pub mod singbox_config;
|
||||
pub mod singbox_runtime;
|
||||
pub mod singbox_service;
|
||||
pub mod singbox_subscription;
|
||||
pub mod storage;
|
||||
pub mod subscription;
|
||||
pub mod validation;
|
||||
|
||||
pub mod adapters {
|
||||
pub mod proxifyre;
|
||||
pub mod proxy_router;
|
||||
pub mod singbox;
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(commands::CommandState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::restart_as_admin,
|
||||
commands::get_startup_snapshot,
|
||||
commands::get_saved_state,
|
||||
commands::get_components,
|
||||
commands::get_proxifyre_setup_status,
|
||||
commands::get_proxifyre_setup_progress,
|
||||
commands::get_singbox_status,
|
||||
commands::get_singbox_setup_status,
|
||||
commands::save_singbox_subscription,
|
||||
commands::fetch_singbox_subscription,
|
||||
commands::forget_singbox_subscription,
|
||||
commands::select_singbox_server,
|
||||
commands::ping_singbox_server,
|
||||
commands::ping_all_singbox_servers,
|
||||
commands::ping_proxy_target,
|
||||
commands::generate_singbox_config,
|
||||
commands::apply_configuration,
|
||||
commands::start_proxifyre_service,
|
||||
commands::stop_proxifyre_service,
|
||||
commands::install_proxifyre,
|
||||
commands::configure_proxifyre_firewall_rules,
|
||||
commands::uninstall_proxifyre,
|
||||
commands::start_singbox_service,
|
||||
commands::stop_singbox_service,
|
||||
commands::install_singbox,
|
||||
commands::uninstall_singbox
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("не удалось запустить клиент ProxyWarden");
|
||||
}
|
||||
|
||||
+1
-71
@@ -1,75 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod activity;
|
||||
mod commands;
|
||||
mod component_detection;
|
||||
mod models;
|
||||
mod process;
|
||||
mod singbox_service;
|
||||
mod storage;
|
||||
mod subscription;
|
||||
mod validation;
|
||||
|
||||
mod adapters {
|
||||
pub mod proxifyre;
|
||||
pub mod proxy_router;
|
||||
pub mod singbox;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod proxifyre {
|
||||
pub use crate::adapters::proxifyre::*;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod proxy_router {
|
||||
pub use crate::adapters::proxy_router::*;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod singbox {
|
||||
pub use crate::adapters::singbox::*;
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(commands::CommandState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_status,
|
||||
commands::get_admin_status,
|
||||
commands::restart_as_admin,
|
||||
commands::get_startup_snapshot,
|
||||
commands::get_profiles,
|
||||
commands::get_saved_state,
|
||||
commands::save_profile,
|
||||
commands::get_targets,
|
||||
commands::save_target,
|
||||
commands::get_components,
|
||||
commands::get_proxifyre_setup_status,
|
||||
commands::get_singbox_status,
|
||||
commands::get_singbox_setup_status,
|
||||
commands::resolve_profile_preview,
|
||||
commands::save_singbox_subscription,
|
||||
commands::fetch_singbox_subscription,
|
||||
commands::forget_singbox_subscription,
|
||||
commands::select_singbox_server,
|
||||
commands::ping_singbox_server,
|
||||
commands::ping_all_singbox_servers,
|
||||
commands::ping_proxy_target,
|
||||
commands::generate_singbox_config,
|
||||
commands::apply_profiles,
|
||||
commands::get_logs,
|
||||
commands::open_config_location,
|
||||
commands::start_proxifyre_service,
|
||||
commands::stop_proxifyre_service,
|
||||
commands::install_proxifyre,
|
||||
commands::uninstall_proxifyre,
|
||||
commands::start_singbox_service,
|
||||
commands::stop_singbox_service,
|
||||
commands::install_singbox,
|
||||
commands::uninstall_singbox
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("не удалось запустить клиент ProxyWarden");
|
||||
proxywarden_lib::run();
|
||||
}
|
||||
|
||||
+108
-19
@@ -1,9 +1,13 @@
|
||||
use percent_encoding::percent_decode_str;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use url::Url;
|
||||
|
||||
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1";
|
||||
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080;
|
||||
pub const DEFAULT_LOCAL_SINGBOX_SERVICE_NAME: &str = "ProxyWardenSingBox";
|
||||
pub const DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT: &str = r"C:\Program Files\ProxyWarden\sing-box";
|
||||
pub const DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT: &str =
|
||||
r"C:\Program Files\ProxyWarden\components\sing-box";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
@@ -53,6 +57,7 @@ pub enum ComponentState {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileItemInput {
|
||||
#[serde(rename = "type")]
|
||||
pub item_type: String,
|
||||
@@ -62,6 +67,7 @@ pub struct ProfileItemInput {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileInput {
|
||||
pub id: Option<String>,
|
||||
pub name: String,
|
||||
@@ -94,6 +100,7 @@ pub struct Profile {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetInput {
|
||||
pub id: Option<String>,
|
||||
pub name: String,
|
||||
@@ -128,6 +135,10 @@ pub struct ComponentStatus {
|
||||
pub version: Option<String>,
|
||||
pub path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub service_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub service_status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub problems: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub actions: Vec<String>,
|
||||
@@ -138,7 +149,11 @@ pub struct LocalSingBoxConfig {
|
||||
#[serde(default)]
|
||||
pub subscription_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub device_hwid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_server_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_server_id: Option<String>,
|
||||
#[serde(default = "default_local_singbox_listen_host")]
|
||||
pub listen_host: String,
|
||||
#[serde(default = "default_local_singbox_listen_port")]
|
||||
@@ -157,13 +172,21 @@ impl LocalSingBoxConfig {
|
||||
.as_deref()
|
||||
.map(redact_subscription_url)
|
||||
}
|
||||
|
||||
pub fn normalize_percent_encoded_tags(&mut self) {
|
||||
if let Some(selected_server_tag) = self.selected_server_tag.as_mut() {
|
||||
*selected_server_tag = decode_percent_encoded_utf8(selected_server_tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LocalSingBoxConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
subscription_url: None,
|
||||
device_hwid: None,
|
||||
selected_server_tag: None,
|
||||
selected_server_id: None,
|
||||
listen_host: default_local_singbox_listen_host(),
|
||||
listen_port: default_local_singbox_listen_port(),
|
||||
service_name: default_local_singbox_service_name(),
|
||||
@@ -183,8 +206,41 @@ pub struct SubscriptionCache {
|
||||
pub fetched_at: String,
|
||||
}
|
||||
|
||||
impl SubscriptionCache {
|
||||
pub fn normalize_percent_encoded_tags(&mut self) {
|
||||
for server in &mut self.servers {
|
||||
server.tag = decode_percent_encoded_utf8(&server.tag);
|
||||
server.ensure_id();
|
||||
}
|
||||
|
||||
let Some(outbounds) = self
|
||||
.config
|
||||
.get_mut("outbounds")
|
||||
.and_then(Value::as_array_mut)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
for outbound in outbounds {
|
||||
let Some(decoded_tag) = outbound
|
||||
.get("tag")
|
||||
.and_then(Value::as_str)
|
||||
.map(decode_percent_encoded_utf8)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(object) = outbound.as_object_mut() {
|
||||
object.insert("tag".to_string(), Value::String(decoded_tag));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SubscriptionServer {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
pub tag: String,
|
||||
#[serde(rename = "type")]
|
||||
pub server_type: String,
|
||||
@@ -192,6 +248,34 @@ pub struct SubscriptionServer {
|
||||
pub server_port: u16,
|
||||
}
|
||||
|
||||
impl SubscriptionServer {
|
||||
pub fn ensure_id(&mut self) {
|
||||
if self.id.trim().is_empty() {
|
||||
self.id = subscription_server_id(
|
||||
&self.server_type,
|
||||
&self.tag,
|
||||
&self.server,
|
||||
self.server_port,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscription_server_id(
|
||||
server_type: &str,
|
||||
tag: &str,
|
||||
server: &str,
|
||||
server_port: u16,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}|{}|{}|{}",
|
||||
server_type.trim().to_ascii_lowercase(),
|
||||
tag.trim(),
|
||||
server.trim().to_ascii_lowercase(),
|
||||
server_port
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ActivityEntry {
|
||||
pub id: String,
|
||||
@@ -252,22 +336,27 @@ pub fn redact_subscription_url(raw_url: &str) -> String {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
match trimmed.split_once("://") {
|
||||
Some((scheme, rest)) => {
|
||||
let host = rest
|
||||
.split(['/', '?', '#'])
|
||||
.next()
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("subscription");
|
||||
format!("{scheme}://{host}/...")
|
||||
}
|
||||
None => {
|
||||
let visible = trimmed.chars().take(18).collect::<String>();
|
||||
if trimmed.chars().count() <= 18 {
|
||||
"***".to_string()
|
||||
} else {
|
||||
format!("{visible}...")
|
||||
}
|
||||
}
|
||||
}
|
||||
let Ok(parsed) = Url::parse(trimmed) else {
|
||||
return "***".to_string();
|
||||
};
|
||||
|
||||
let host = parsed.host_str().unwrap_or("subscription");
|
||||
let host = if host.contains(':') && !host.starts_with('[') {
|
||||
format!("[{host}]")
|
||||
} else {
|
||||
host.to_string()
|
||||
};
|
||||
let port = parsed
|
||||
.port()
|
||||
.map(|port| format!(":{port}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
format!("{}://{}{}/...", parsed.scheme(), host, port)
|
||||
}
|
||||
|
||||
pub fn decode_percent_encoded_utf8(value: &str) -> String {
|
||||
percent_decode_str(value)
|
||||
.decode_utf8()
|
||||
.map(|decoded| decoded.into_owned())
|
||||
.unwrap_or_else(|_| value.to_string())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Shared PowerShell execution boundary for fixed ProxyWarden scripts.
|
||||
//!
|
||||
//! Callers remain responsible for generating static script templates and for
|
||||
//! validating every path or service identifier before invoking this module.
|
||||
|
||||
use crate::process::command_no_window;
|
||||
use std::{fs, path::Path, process::Output};
|
||||
|
||||
pub(crate) fn write_script(path: &Path, script: &str) -> std::io::Result<()> {
|
||||
let mut bytes = Vec::with_capacity(script.len() + 3);
|
||||
bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]);
|
||||
bytes.extend_from_slice(script.as_bytes());
|
||||
fs::write(path, bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn run_command(script: &str) -> std::io::Result<Output> {
|
||||
command_no_window("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
script,
|
||||
])
|
||||
.output()
|
||||
}
|
||||
|
||||
pub(crate) fn run_file(script_path: &Path) -> std::io::Result<Output> {
|
||||
command_no_window("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
])
|
||||
.arg(script_path)
|
||||
.output()
|
||||
}
|
||||
|
||||
pub(crate) fn is_elevated() -> bool {
|
||||
if !cfg!(windows) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let script = r#"([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"#;
|
||||
let Ok(output) = run_command(script) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
output.status.success()
|
||||
&& String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("true")
|
||||
}
|
||||
|
||||
pub(crate) fn output_message(output: &Output, fallback: &str) -> String {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if !stderr.is_empty() {
|
||||
return stderr;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
return stdout;
|
||||
}
|
||||
|
||||
fallback.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn package_failure_details(result_path: &Path, output: &Output) -> String {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
if let Ok(contents) = fs::read_to_string(result_path) {
|
||||
let details = compact_error_text(&contents);
|
||||
if !details.is_empty() && !details.eq_ignore_ascii_case("ok") {
|
||||
parts.push(details);
|
||||
}
|
||||
}
|
||||
|
||||
let stdout = compact_error_text(&String::from_utf8_lossy(&output.stdout));
|
||||
if !stdout.is_empty() {
|
||||
parts.push(format!("stdout: {stdout}"));
|
||||
}
|
||||
|
||||
let stderr = compact_error_text(&String::from_utf8_lossy(&output.stderr));
|
||||
if !stderr.is_empty() {
|
||||
parts.push(format!("stderr: {stderr}"));
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
parts.push(
|
||||
"Лог elevated-скрипта не создан. Обычно это значит, что окно UAC было отменено или Windows не дала запустить elevated PowerShell."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
fn compact_error_text(value: &str) -> String {
|
||||
let text = value
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
const MAX_CHARS: usize = 1400;
|
||||
if text.chars().count() <= MAX_CHARS {
|
||||
return text;
|
||||
}
|
||||
|
||||
format!("{}...", text.chars().take(MAX_CHARS).collect::<String>())
|
||||
}
|
||||
|
||||
pub(crate) fn escape_single(value: &str) -> String {
|
||||
value.replace('\'', "''")
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Ownership proof for destructive ProxiFyre uninstall operations.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
pub const PROXIFYRE_MARKER_FILE: &str = "proxywarden-component.json";
|
||||
pub const PROXIFYRE_MANAGED_SERVICE_NAME: &str = "ProxiFyreService";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ManagedProxiFyreOwnership {
|
||||
pub service_name: String,
|
||||
pub remove_packet_filter: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProxiFyreInstallMarker {
|
||||
manager: String,
|
||||
component: String,
|
||||
service_name: String,
|
||||
install_root: String,
|
||||
#[serde(default)]
|
||||
packet_filter_installed_by_proxy_warden: bool,
|
||||
}
|
||||
|
||||
pub fn verify_managed_proxifyre_install(
|
||||
install_dir: &Path,
|
||||
executable_path: &Path,
|
||||
expected_install_dir: &Path,
|
||||
) -> Result<ManagedProxiFyreOwnership, String> {
|
||||
let install_dir = canonical_path(install_dir, "папку ProxiFyre")?;
|
||||
let expected_install_dir = canonical_path(expected_install_dir, "ожидаемую папку ProxiFyre")?;
|
||||
if install_dir != expected_install_dir {
|
||||
return Err(format!(
|
||||
"папка {} не является управляемой папкой {}",
|
||||
install_dir.display(),
|
||||
expected_install_dir.display()
|
||||
));
|
||||
}
|
||||
|
||||
let has_expected_shape = install_dir
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("ProxiFyre"))
|
||||
&& install_dir
|
||||
.parent()
|
||||
.and_then(Path::file_name)
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("components"));
|
||||
if !has_expected_shape {
|
||||
return Err("управляемая папка должна оканчиваться на components\\ProxiFyre".to_string());
|
||||
}
|
||||
|
||||
let executable_path = canonical_path(executable_path, "ProxiFyre.exe")?;
|
||||
if executable_path.parent() != Some(install_dir.as_path())
|
||||
|| !executable_path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("ProxiFyre.exe"))
|
||||
{
|
||||
return Err("обнаруженный ProxiFyre.exe находится вне управляемой папки".to_string());
|
||||
}
|
||||
|
||||
let marker_path = install_dir.join(PROXIFYRE_MARKER_FILE);
|
||||
let marker_text = fs::read_to_string(&marker_path).map_err(|error| {
|
||||
format!(
|
||||
"не удалось прочитать marker установки {}: {error}",
|
||||
marker_path.display()
|
||||
)
|
||||
})?;
|
||||
let marker_text = marker_text.strip_prefix('\u{feff}').unwrap_or(&marker_text);
|
||||
let marker: ProxiFyreInstallMarker = serde_json::from_str(marker_text).map_err(|error| {
|
||||
format!(
|
||||
"marker установки {} содержит некорректный JSON: {error}",
|
||||
marker_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if !marker.manager.eq_ignore_ascii_case("ProxyWarden")
|
||||
|| !marker.component.eq_ignore_ascii_case("proxifyre")
|
||||
{
|
||||
return Err("marker установки не подтверждает владение ProxyWarden/ProxiFyre".to_string());
|
||||
}
|
||||
if !marker
|
||||
.service_name
|
||||
.eq_ignore_ascii_case(PROXIFYRE_MANAGED_SERVICE_NAME)
|
||||
{
|
||||
return Err("marker установки содержит неподдерживаемое имя службы".to_string());
|
||||
}
|
||||
|
||||
let marker_root = canonical_path(Path::new(&marker.install_root), "installRoot из marker")?;
|
||||
if marker_root != install_dir {
|
||||
return Err("installRoot из marker не совпадает с управляемой папкой".to_string());
|
||||
}
|
||||
|
||||
Ok(ManagedProxiFyreOwnership {
|
||||
service_name: PROXIFYRE_MANAGED_SERVICE_NAME.to_string(),
|
||||
remove_packet_filter: marker.packet_filter_installed_by_proxy_warden,
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_path(path: &Path, label: &str) -> Result<std::path::PathBuf, String> {
|
||||
fs::canonicalize(path)
|
||||
.map_err(|error| format!("не удалось проверить {label} '{}': {error}", path.display()))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,687 @@
|
||||
//! Static-template PowerShell generation for explicit ProxiFyre package actions.
|
||||
|
||||
use crate::component_detection::{default_proxifyre_install_dir, DetectedProxyfier};
|
||||
use crate::powershell::escape_single as escape_powershell_single;
|
||||
use crate::proxifyre_ownership::ManagedProxiFyreOwnership;
|
||||
use std::path::Path;
|
||||
|
||||
const PROXIFYRE_RELEASE_API_URL: &str =
|
||||
"https://api.github.com/repos/wiresock/proxifyre/releases/latest";
|
||||
const NDISAPI_RELEASE_API_URL: &str =
|
||||
"https://api.github.com/repos/wiresock/ndisapi/releases/latest";
|
||||
const PROXIFYRE_PINNED_RELEASE_TAG: &str = "v2.2.1";
|
||||
const NDISAPI_PINNED_RELEASE_TAG: &str = "v3.6.2";
|
||||
const NDISAPI_PINNED_INSTALLER_VERSION: &str = "3.6.2.1";
|
||||
const VC_REDIST_X64_URL: &str = "https://aka.ms/vc14/vc_redist.x64.exe";
|
||||
const VC_REDIST_X86_URL: &str = "https://aka.ms/vc14/vc_redist.x86.exe";
|
||||
pub const PROXIFYRE_FIREWALL_INBOUND_RULE: &str = "ProxyWarden.ProxiFyre.Inbound";
|
||||
pub const PROXIFYRE_FIREWALL_OUTBOUND_RULE: &str = "ProxyWarden.ProxiFyre.Outbound";
|
||||
|
||||
pub fn install_proxifyre_script(generated_config_path: &Path) -> String {
|
||||
install_proxifyre_script_with_bundle(generated_config_path, None)
|
||||
}
|
||||
|
||||
pub fn install_proxifyre_script_with_bundle(
|
||||
generated_config_path: &Path,
|
||||
bundled_asset_dir: Option<&Path>,
|
||||
) -> String {
|
||||
install_proxifyre_script_for_target(
|
||||
generated_config_path,
|
||||
bundled_asset_dir,
|
||||
&default_proxifyre_install_dir(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn install_proxifyre_script_for_target(
|
||||
generated_config_path: &Path,
|
||||
bundled_asset_dir: Option<&Path>,
|
||||
target_dir: &Path,
|
||||
) -> String {
|
||||
let mut script = String::new();
|
||||
script.push_str(&format!(
|
||||
"$targetDir = '{}'\n",
|
||||
escape_powershell_single(&target_dir.display().to_string())
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$generatedConfigPath = '{}'\n",
|
||||
escape_powershell_single(&generated_config_path.display().to_string())
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$bundledAssetDir = '{}'\n",
|
||||
escape_powershell_single(
|
||||
&bundled_asset_dir
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_default()
|
||||
)
|
||||
));
|
||||
script.push_str("$script:bundledAssetDir = [string]$bundledAssetDir\n");
|
||||
script.push_str(&format!(
|
||||
"$proxifyreReleaseApi = '{}'\n",
|
||||
escape_powershell_single(PROXIFYRE_RELEASE_API_URL)
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$ndisapiReleaseApi = '{}'\n",
|
||||
escape_powershell_single(NDISAPI_RELEASE_API_URL)
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$proxifyrePinnedReleaseTag = '{}'\n",
|
||||
escape_powershell_single(PROXIFYRE_PINNED_RELEASE_TAG)
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$ndisapiPinnedReleaseTag = '{}'\n",
|
||||
escape_powershell_single(NDISAPI_PINNED_RELEASE_TAG)
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$ndisapiPinnedInstallerVersion = '{}'\n",
|
||||
escape_powershell_single(NDISAPI_PINNED_INSTALLER_VERSION)
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$vcRedistX64Url = '{}'\n",
|
||||
escape_powershell_single(VC_REDIST_X64_URL)
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$vcRedistX86Url = '{}'\n",
|
||||
escape_powershell_single(VC_REDIST_X86_URL)
|
||||
));
|
||||
script.push_str(
|
||||
r#"
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
function Get-NativeArchitecture {
|
||||
$processor = Get-CimInstance Win32_Processor | Select-Object -First 1
|
||||
if ($null -ne $processor -and $processor.Architecture -eq 12) { return 'ARM64' }
|
||||
if ([Environment]::Is64BitOperatingSystem) { return 'x64' }
|
||||
return 'x86'
|
||||
}
|
||||
|
||||
function Get-SafeUriForLog([string]$uri) {
|
||||
try {
|
||||
$parsed = [Uri]$uri
|
||||
$port = if ($parsed.IsDefaultPort) { '' } else { ":$($parsed.Port)" }
|
||||
return "$($parsed.Scheme)://$($parsed.Host)$port$($parsed.AbsolutePath)"
|
||||
} catch {
|
||||
return '<invalid-url>'
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-ReleaseApi([string]$uri, [string]$label) {
|
||||
$safeUri = Get-SafeUriForLog $uri
|
||||
$headers = @{ 'User-Agent' = 'proxywarden'; 'Accept' = 'application/vnd.github+json' }
|
||||
$lastError = $null
|
||||
|
||||
foreach ($attempt in 1..3) {
|
||||
try {
|
||||
return Invoke-RestMethod -Uri $uri -Headers $headers -TimeoutSec 60 -MaximumRedirection 10
|
||||
} catch {
|
||||
$lastError = $_.Exception.Message
|
||||
if ($attempt -lt 3) {
|
||||
Start-Sleep -Seconds ([Math]::Min(10, $attempt * 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw "Не удалось получить metadata для $label ($safeUri): $lastError"
|
||||
}
|
||||
|
||||
function New-ReleaseAsset([string]$name, [string]$url) {
|
||||
[PSCustomObject]@{
|
||||
name = $name
|
||||
browser_download_url = $url
|
||||
digest = $null
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ReleaseAsset([string]$apiUri, [string]$pattern, [string]$label, $fallbackAsset, [int]$fallbackPercent) {
|
||||
try {
|
||||
$release = Invoke-ReleaseApi $apiUri $label
|
||||
return Select-Asset $release.assets $pattern $label
|
||||
} catch {
|
||||
$fallbackUri = Get-SafeUriForLog $fallbackAsset.browser_download_url
|
||||
Write-ProxyWardenProgress $script:progressOperation $script:progressActiveStep 'running' $fallbackPercent "GitHub API недоступен для $label. Пробую прямую ссылку: $fallbackUri"
|
||||
return $fallbackAsset
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PinnedProxiFyreAsset([string]$arch) {
|
||||
$archLabel = if ($arch -eq 'ARM64') { 'ARM64' } elseif ($arch -eq 'x86') { 'x86' } else { 'x64' }
|
||||
$name = "ProxiFyre-$proxifyrePinnedReleaseTag-$archLabel-signed.zip"
|
||||
$url = "https://github.com/wiresock/proxifyre/releases/download/$proxifyrePinnedReleaseTag/$name"
|
||||
return New-ReleaseAsset $name $url
|
||||
}
|
||||
|
||||
function Get-PinnedWindowsPacketFilterAsset([string]$arch) {
|
||||
$archLabel = if ($arch -eq 'ARM64') { 'ARM64' } elseif ($arch -eq 'x86') { 'x86' } else { 'x64' }
|
||||
$name = "Windows.Packet.Filter.$ndisapiPinnedInstallerVersion.$archLabel.msi"
|
||||
$url = "https://github.com/wiresock/ndisapi/releases/download/$ndisapiPinnedReleaseTag/$name"
|
||||
return New-ReleaseAsset $name $url
|
||||
}
|
||||
|
||||
function Complete-Download([string]$partialPath, [string]$path, [string]$label) {
|
||||
if (-not (Test-Path -LiteralPath $partialPath)) {
|
||||
throw "${label}: файл не был создан."
|
||||
}
|
||||
|
||||
$item = Get-Item -LiteralPath $partialPath
|
||||
if ($item.Length -le 0) {
|
||||
throw "${label}: скачанный файл пустой."
|
||||
}
|
||||
|
||||
Move-Item -LiteralPath $partialPath -Destination $path -Force
|
||||
}
|
||||
|
||||
function Invoke-WebClientDownload([string]$uri, [string]$partialPath) {
|
||||
$client = New-Object System.Net.WebClient
|
||||
try {
|
||||
$client.Headers.Add('User-Agent', 'proxywarden')
|
||||
$client.Headers.Add('Accept', 'application/octet-stream,*/*')
|
||||
$client.DownloadFile($uri, $partialPath)
|
||||
} finally {
|
||||
$client.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-CurlDownload([string]$uri, [string]$partialPath) {
|
||||
$curl = Get-Command 'curl.exe' -ErrorAction SilentlyContinue
|
||||
if ($null -eq $curl) {
|
||||
throw 'curl.exe не найден.'
|
||||
}
|
||||
|
||||
$curlOutput = & $curl.Source --silent --show-error --fail --location --retry 2 --retry-delay 2 --connect-timeout 30 --max-time 180 --user-agent 'proxywarden' --output $partialPath --url $uri 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$curlMessage = ($curlOutput | Out-String).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($curlMessage)) {
|
||||
throw "curl.exe завершился с кодом $LASTEXITCODE."
|
||||
}
|
||||
|
||||
throw "curl.exe завершился с кодом ${LASTEXITCODE}: $curlMessage"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Download([string]$uri, [string]$path, [string]$label) {
|
||||
$safeUri = Get-SafeUriForLog $uri
|
||||
$partialPath = "$path.part"
|
||||
$headers = @{ 'User-Agent' = 'proxywarden'; 'Accept' = 'application/octet-stream,*/*' }
|
||||
$webRequestError = $null
|
||||
$webClientError = $null
|
||||
$curlError = $null
|
||||
|
||||
foreach ($attempt in 1..3) {
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $uri -OutFile $partialPath -Headers $headers -TimeoutSec 180 -MaximumRedirection 10
|
||||
Complete-Download $partialPath $path $label
|
||||
return
|
||||
} catch {
|
||||
$webRequestError = $_.Exception.Message
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
if ($attempt -lt 3) {
|
||||
Start-Sleep -Seconds ([Math]::Min(10, $attempt * 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
Invoke-WebClientDownload $uri $partialPath
|
||||
Complete-Download $partialPath $path $label
|
||||
return
|
||||
} catch {
|
||||
$webClientError = $_.Exception.Message
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
try {
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
Invoke-CurlDownload $uri $partialPath
|
||||
Complete-Download $partialPath $path $label
|
||||
return
|
||||
} catch {
|
||||
$curlError = $_.Exception.Message
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$errors = @()
|
||||
if (-not [string]::IsNullOrWhiteSpace($webRequestError)) { $errors += "Invoke-WebRequest: $webRequestError" }
|
||||
if (-not [string]::IsNullOrWhiteSpace($webClientError)) { $errors += "WebClient: $webClientError" }
|
||||
if (-not [string]::IsNullOrWhiteSpace($curlError)) { $errors += "curl.exe: $curlError" }
|
||||
$details = if ($errors.Count -gt 0) { $errors -join ' | ' } else { 'неизвестная ошибка' }
|
||||
|
||||
throw "Не удалось скачать $label ($safeUri): $details"
|
||||
}
|
||||
|
||||
function Select-Asset($assets, [string]$pattern, [string]$label) {
|
||||
$asset = $assets | Where-Object { $_.name -match $pattern } | Select-Object -First 1
|
||||
if ($null -eq $asset) { throw "Не найден подходящий asset для $label ($pattern)." }
|
||||
return $asset
|
||||
}
|
||||
|
||||
function Verify-AssetHash([string]$path, $asset) {
|
||||
if ($asset.digest -match '^sha256:(.+)$') {
|
||||
$expected = $Matches[1].ToLowerInvariant()
|
||||
$actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actual -ne $expected) {
|
||||
throw "SHA256 не совпал для $($asset.name). Ожидалось $expected, получилось $actual."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-ExitCode($process, [string]$label) {
|
||||
if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010) {
|
||||
throw "$label завершился с кодом $($process.ExitCode)."
|
||||
}
|
||||
}
|
||||
|
||||
function Get-InstalledProgram([string]$pattern) {
|
||||
$paths = @(
|
||||
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||
'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
|
||||
)
|
||||
return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.DisplayName -match $pattern } |
|
||||
Select-Object -First 1
|
||||
}
|
||||
|
||||
function Test-VcRuntime([string]$arch) {
|
||||
$pattern = if ($arch -eq 'ARM64') {
|
||||
'Microsoft Visual C\+\+.*Redistributable.*\((ARM64|x64)\)'
|
||||
} else {
|
||||
"Microsoft Visual C\+\+.*Redistributable.*\($arch\)"
|
||||
}
|
||||
|
||||
return $null -ne (Get-InstalledProgram $pattern)
|
||||
}
|
||||
|
||||
function Test-WindowsPacketFilter {
|
||||
return $null -ne (Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI')
|
||||
}
|
||||
|
||||
function Get-LogTail([string]$path) {
|
||||
if (-not (Test-Path -LiteralPath $path)) { return '' }
|
||||
return (Get-Content -LiteralPath $path -Tail 40 -ErrorAction SilentlyContinue) -join ' '
|
||||
}
|
||||
|
||||
function Get-BundledAssetDir {
|
||||
$dir = [string]$script:bundledAssetDir
|
||||
if ([string]::IsNullOrWhiteSpace($dir)) { return $null }
|
||||
if (-not (Test-Path -LiteralPath $dir -PathType Container)) { return $null }
|
||||
return $dir
|
||||
}
|
||||
|
||||
function Get-BundledAssetManifest {
|
||||
$assetDir = Get-BundledAssetDir
|
||||
if ($null -eq $assetDir) { return $null }
|
||||
$manifestPath = [IO.Path]::Combine($assetDir, 'manifest.json')
|
||||
if (-not (Test-Path -LiteralPath $manifestPath)) { return $null }
|
||||
|
||||
try {
|
||||
return Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
} catch {
|
||||
throw "Не удалось прочитать manifest встроенных пакетов ProxiFyre: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
$script:bundledAssetManifest = Get-BundledAssetManifest
|
||||
|
||||
function Get-BundledAssetHash([string]$name) {
|
||||
if ($null -eq $script:bundledAssetManifest -or $null -eq $script:bundledAssetManifest.files) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$entry = $script:bundledAssetManifest.files |
|
||||
Where-Object { $_.name -eq $name } |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $entry) { return $null }
|
||||
return [string]$entry.sha256
|
||||
}
|
||||
|
||||
function Verify-BundledAssetHash([string]$path, [string]$label) {
|
||||
$name = [IO.Path]::GetFileName($path)
|
||||
$expected = Get-BundledAssetHash $name
|
||||
if ([string]::IsNullOrWhiteSpace($expected)) {
|
||||
throw "Во встроенном manifest нет SHA256 для $label ($name)."
|
||||
}
|
||||
|
||||
$actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actual -ne $expected.ToLowerInvariant()) {
|
||||
throw "SHA256 не совпал для встроенного $label ($name). Ожидалось $expected, получилось $actual."
|
||||
}
|
||||
}
|
||||
|
||||
function Get-BundledAsset([string]$pattern, [string]$label) {
|
||||
$assetDir = Get-BundledAssetDir
|
||||
if ($null -eq $assetDir) { return $null }
|
||||
|
||||
$asset = Get-ChildItem -LiteralPath $assetDir -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -match $pattern } |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $asset) { return $null }
|
||||
|
||||
Verify-BundledAssetHash $asset.FullName $label
|
||||
return $asset.FullName
|
||||
}
|
||||
|
||||
function Copy-BundledAsset([string]$sourcePath, [string]$targetPath, [string]$label) {
|
||||
Copy-Item -LiteralPath $sourcePath -Destination $targetPath -Force
|
||||
$item = Get-Item -LiteralPath $targetPath
|
||||
if ($item.Length -le 0) {
|
||||
throw "${label}: встроенный файл пустой."
|
||||
}
|
||||
}
|
||||
|
||||
$arch = Get-NativeArchitecture
|
||||
$workDir = Join-Path ([IO.Path]::GetTempPath()) 'proxywarden-proxifyre-install'
|
||||
$extractDir = Join-Path $workDir 'proxifyre'
|
||||
Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $workDir, $extractDir, $targetDir | Out-Null
|
||||
|
||||
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 8 'Проверяю сетевой драйвер Windows Packet Filter.'
|
||||
$packetFilterAlreadyInstalled = Test-WindowsPacketFilter
|
||||
if (-not $packetFilterAlreadyInstalled) {
|
||||
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 14 'Готовлю Windows Packet Filter.'
|
||||
$ndisPattern = if ($arch -eq 'ARM64') { 'ARM64\.msi$' } elseif ($arch -eq 'x86') { 'x86\.msi$' } else { 'x64\.msi$' }
|
||||
$bundledNdisPath = Get-BundledAsset $ndisPattern 'Windows Packet Filter'
|
||||
if ($null -ne $bundledNdisPath) {
|
||||
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 16 'Использую встроенный Windows Packet Filter.'
|
||||
$ndisPath = Join-Path $workDir ([IO.Path]::GetFileName($bundledNdisPath))
|
||||
Copy-BundledAsset $bundledNdisPath $ndisPath 'Windows Packet Filter'
|
||||
} else {
|
||||
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 16 'Скачиваю Windows Packet Filter.'
|
||||
$ndisAsset = Resolve-ReleaseAsset $ndisapiReleaseApi $ndisPattern 'Windows Packet Filter' (Get-PinnedWindowsPacketFilterAsset $arch) 16
|
||||
$ndisPath = Join-Path $workDir $ndisAsset.name
|
||||
Invoke-Download $ndisAsset.browser_download_url $ndisPath 'Windows Packet Filter'
|
||||
Verify-AssetHash $ndisPath $ndisAsset
|
||||
}
|
||||
$ndisLogPath = Join-Path $workDir 'windows-packet-filter-install.log'
|
||||
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 26 'Устанавливаю Windows Packet Filter.'
|
||||
$ndisProcess = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', $ndisPath, '/qn', '/norestart', '/L*v', $ndisLogPath) -Wait -PassThru -WindowStyle Hidden
|
||||
if ($ndisProcess.ExitCode -ne 0 -and $ndisProcess.ExitCode -ne 3010 -and -not (Test-WindowsPacketFilter)) {
|
||||
$ndisLogTail = Get-LogTail $ndisLogPath
|
||||
throw "Windows Packet Filter завершился с кодом $($ndisProcess.ExitCode). MSI log: $ndisLogPath $ndisLogTail"
|
||||
}
|
||||
}
|
||||
Write-ProxyWardenProgress 'install' 'packet-filter' 'succeeded' 36 'Сетевой драйвер готов.'
|
||||
|
||||
Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 40 'Проверяю Microsoft Visual C++ Runtime.'
|
||||
if (-not (Test-VcRuntime $arch)) {
|
||||
$vcBundledPattern = if ($arch -eq 'x86') { '^vc_redist\.x86\.exe$' } else { '^vc_redist\.x64\.exe$' }
|
||||
$vcRedistUrl = if ($arch -eq 'x86') { $vcRedistX86Url } else { $vcRedistX64Url }
|
||||
$bundledVcPath = Get-BundledAsset $vcBundledPattern 'Microsoft Visual C++ Runtime'
|
||||
$vcRedistPath = Join-Path $workDir 'vc_redist.exe'
|
||||
if ($null -ne $bundledVcPath) {
|
||||
Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 46 'Использую встроенный Microsoft Visual C++ Runtime.'
|
||||
Copy-BundledAsset $bundledVcPath $vcRedistPath 'Microsoft Visual C++ Runtime'
|
||||
} else {
|
||||
Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 46 'Скачиваю Microsoft Visual C++ Runtime.'
|
||||
Invoke-Download $vcRedistUrl $vcRedistPath 'Microsoft Visual C++ Runtime'
|
||||
}
|
||||
Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 54 'Устанавливаю Microsoft Visual C++ Runtime.'
|
||||
$vcProcess = Start-Process -FilePath $vcRedistPath -ArgumentList @('/install', '/quiet', '/norestart') -Wait -PassThru -WindowStyle Hidden
|
||||
if ($vcProcess.ExitCode -ne 0 -and $vcProcess.ExitCode -ne 3010 -and $vcProcess.ExitCode -ne 1638 -and -not (Test-VcRuntime $arch)) {
|
||||
throw "Visual C++ Runtime завершился с кодом $($vcProcess.ExitCode)."
|
||||
}
|
||||
}
|
||||
Write-ProxyWardenProgress 'install' 'vc-runtime' 'succeeded' 62 'Среда запуска готова.'
|
||||
|
||||
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 66 'Готовлю ProxiFyre.'
|
||||
$proxifyrePattern = if ($arch -eq 'ARM64') { 'ARM64-signed\.zip$' } elseif ($arch -eq 'x86') { 'x86-signed\.zip$' } else { 'x64-signed\.zip$' }
|
||||
$bundledProxiFyrePath = Get-BundledAsset $proxifyrePattern 'ProxiFyre'
|
||||
if ($null -ne $bundledProxiFyrePath) {
|
||||
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 68 'Использую встроенный ProxiFyre.'
|
||||
$proxifyreZipPath = Join-Path $workDir ([IO.Path]::GetFileName($bundledProxiFyrePath))
|
||||
Copy-BundledAsset $bundledProxiFyrePath $proxifyreZipPath 'ProxiFyre'
|
||||
} else {
|
||||
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 68 'Скачиваю ProxiFyre.'
|
||||
$proxifyreAsset = Resolve-ReleaseAsset $proxifyreReleaseApi $proxifyrePattern 'ProxiFyre' (Get-PinnedProxiFyreAsset $arch) 68
|
||||
$proxifyreZipPath = Join-Path $workDir $proxifyreAsset.name
|
||||
Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath 'ProxiFyre'
|
||||
Verify-AssetHash $proxifyreZipPath $proxifyreAsset
|
||||
}
|
||||
|
||||
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 76 'Распаковываю ProxiFyre.'
|
||||
Expand-Archive -LiteralPath $proxifyreZipPath -DestinationPath $extractDir -Force
|
||||
$proxifyreExe = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter 'ProxiFyre.exe' | Select-Object -First 1
|
||||
if ($null -eq $proxifyreExe) { throw 'В архиве ProxiFyre не найден ProxiFyre.exe.' }
|
||||
|
||||
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 82 'Копирую ProxiFyre в папку установки.'
|
||||
Copy-Item -Path (Join-Path $proxifyreExe.Directory.FullName '*') -Destination $targetDir -Recurse -Force
|
||||
|
||||
$configTarget = Join-Path $targetDir 'app-config.json'
|
||||
if (Test-Path -LiteralPath $generatedConfigPath) {
|
||||
Copy-Item -LiteralPath $generatedConfigPath -Destination $configTarget -Force
|
||||
} elseif (-not (Test-Path -LiteralPath $configTarget)) {
|
||||
$emptyConfig = '{"logLevel":"Info","bypassLan":true,"proxies":[]}'
|
||||
Set-Content -LiteralPath $configTarget -Value $emptyConfig -Encoding UTF8
|
||||
}
|
||||
|
||||
$markerPath = Join-Path $targetDir 'proxywarden-component.json'
|
||||
$markerJson = [ordered]@{
|
||||
manager = 'ProxyWarden'
|
||||
component = 'proxifyre'
|
||||
serviceName = 'ProxiFyreService'
|
||||
installedAt = (Get-Date).ToString('o')
|
||||
installRoot = $targetDir
|
||||
packetFilterInstalledByProxyWarden = (-not $packetFilterAlreadyInstalled)
|
||||
} | ConvertTo-Json -Depth 4
|
||||
[IO.File]::WriteAllText($markerPath, $markerJson, [Text.UTF8Encoding]::new($false))
|
||||
|
||||
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 90 'Устанавливаю и запускаю службу ProxiFyre.'
|
||||
Push-Location $targetDir
|
||||
try {
|
||||
& .\ProxiFyre.exe stop | Out-Null
|
||||
& .\ProxiFyre.exe uninstall | Out-Null
|
||||
& .\ProxiFyre.exe install
|
||||
if ($LASTEXITCODE -ne 0) { throw "ProxiFyre.exe install завершился с кодом $LASTEXITCODE." }
|
||||
& .\ProxiFyre.exe start
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Start-Service -Name 'ProxiFyreService' -ErrorAction Stop
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
Write-ProxyWardenProgress 'install' 'proxifyre' 'succeeded' 100 'ProxiFyre и сетевой драйвер готовы.'
|
||||
"#,
|
||||
);
|
||||
|
||||
script
|
||||
}
|
||||
|
||||
pub fn configure_proxifyre_firewall_script(executable_path: &Path) -> String {
|
||||
let executable_path = escape_powershell_single(&executable_path.display().to_string());
|
||||
format!(
|
||||
r#"
|
||||
$exePath = '{executable_path}'
|
||||
if (-not (Test-Path -LiteralPath $exePath -PathType Leaf)) {{
|
||||
throw "ProxiFyre.exe не найден по подтвержденному пути: $exePath"
|
||||
}}
|
||||
|
||||
$ruleSpecs = @(
|
||||
@{{ Name = '{PROXIFYRE_FIREWALL_INBOUND_RULE}'; DisplayName = 'ProxyWarden: ProxiFyre (входящие)'; Direction = 'Inbound' }},
|
||||
@{{ Name = '{PROXIFYRE_FIREWALL_OUTBOUND_RULE}'; DisplayName = 'ProxyWarden: ProxiFyre (исходящие)'; Direction = 'Outbound' }}
|
||||
)
|
||||
|
||||
foreach ($rule in $ruleSpecs) {{
|
||||
Get-NetFirewallRule -Name $rule.Name -ErrorAction SilentlyContinue |
|
||||
Remove-NetFirewallRule -ErrorAction Stop
|
||||
New-NetFirewallRule `
|
||||
-Name $rule.Name `
|
||||
-DisplayName $rule.DisplayName `
|
||||
-Group 'ProxyWarden' `
|
||||
-Program $exePath `
|
||||
-Direction $rule.Direction `
|
||||
-Action Allow `
|
||||
-Profile Any `
|
||||
-Enabled True `
|
||||
-ErrorAction Stop | Out-Null
|
||||
}}
|
||||
"#,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn uninstall_proxifyre_script(
|
||||
detected: Option<&DetectedProxyfier>,
|
||||
ownership: &ManagedProxiFyreOwnership,
|
||||
) -> String {
|
||||
let mut script = String::new();
|
||||
let install_dir = detected
|
||||
.map(|detected| detected.install_dir.display().to_string())
|
||||
.unwrap_or_default();
|
||||
let executable_path = detected
|
||||
.map(|detected| detected.executable_path.display().to_string())
|
||||
.unwrap_or_default();
|
||||
script.push_str(&format!(
|
||||
"$installDir = '{}'\n",
|
||||
escape_powershell_single(&install_dir)
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$exePath = '{}'\n",
|
||||
escape_powershell_single(&executable_path)
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$serviceName = '{}'\n",
|
||||
escape_powershell_single(&ownership.service_name)
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$removePacketFilter = ${}\n",
|
||||
if ownership.remove_packet_filter {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
}
|
||||
));
|
||||
script.push_str(
|
||||
r#"
|
||||
function Get-InstalledProgram([string]$pattern) {
|
||||
$paths = @(
|
||||
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
|
||||
)
|
||||
return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.DisplayName -match $pattern } |
|
||||
Select-Object -First 1 DisplayName, DisplayVersion, PSChildName, UninstallString, QuietUninstallString
|
||||
}
|
||||
|
||||
function Test-WindowsPacketFilter {
|
||||
return $null -ne (Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI')
|
||||
}
|
||||
|
||||
function Get-LogTail([string]$path) {
|
||||
if (-not (Test-Path -LiteralPath $path)) { return '' }
|
||||
return (Get-Content -LiteralPath $path -Tail 40 -ErrorAction SilentlyContinue) -join ' '
|
||||
}
|
||||
|
||||
function Resolve-MsiProductCode($program, [string]$label) {
|
||||
if ($null -eq $program) { return $null }
|
||||
if ($program.PSChildName -match '^\{[0-9A-Fa-f-]{36}\}$') {
|
||||
return $program.PSChildName
|
||||
}
|
||||
foreach ($candidate in @($program.QuietUninstallString, $program.UninstallString)) {
|
||||
if ($candidate -match '\{[0-9A-Fa-f-]{36}\}') {
|
||||
return $Matches[0]
|
||||
}
|
||||
}
|
||||
throw "Не удалось найти MSI product code для $label. Отказываюсь запускать произвольный UninstallString."
|
||||
}
|
||||
|
||||
function Uninstall-MsiProgram($program, [string]$label, [string]$logPath) {
|
||||
$productCode = Resolve-MsiProductCode $program $label
|
||||
if ([string]::IsNullOrWhiteSpace($productCode)) { return }
|
||||
$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/x', $productCode, '/qn', '/norestart', '/L*v', $logPath) -Wait -PassThru -WindowStyle Hidden
|
||||
if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010 -and $process.ExitCode -ne 1605) {
|
||||
$logTail = Get-LogTail $logPath
|
||||
throw "$label uninstall завершился с кодом $($process.ExitCode). MSI log: $logPath $logTail"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ServiceBinaryPath([string]$pathName) {
|
||||
if ([string]::IsNullOrWhiteSpace($pathName)) { return $null }
|
||||
$pathName = $pathName.Trim()
|
||||
if ($pathName.StartsWith('"')) {
|
||||
$closingQuote = $pathName.IndexOf('"', 1)
|
||||
if ($closingQuote -lt 2) { return $null }
|
||||
return $pathName.Substring(1, $closingQuote - 1)
|
||||
}
|
||||
return ($pathName -split '\s+', 2)[0]
|
||||
}
|
||||
|
||||
function Find-ManagedProxiFyreService {
|
||||
$escapedName = $serviceName.Replace("'", "''")
|
||||
$record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue
|
||||
if ($null -eq $record) { return $null }
|
||||
$binaryPath = Get-ServiceBinaryPath $record.PathName
|
||||
if (-not [string]::Equals($binaryPath, $exePath, [StringComparison]::OrdinalIgnoreCase)) { return $null }
|
||||
return Get-Service -Name $serviceName -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Get-ServiceProcessId([string]$name) {
|
||||
$escapedName = $name.Replace("'", "''")
|
||||
$record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue
|
||||
if ($null -eq $record) { return 0 }
|
||||
return [int]$record.ProcessId
|
||||
}
|
||||
|
||||
Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'running' 10 'Останавливаю службу ProxiFyre.'
|
||||
$service = Find-ManagedProxiFyreService
|
||||
if ($null -ne $service -and $service.Status -ne 'Stopped') {
|
||||
try {
|
||||
if ($service.CanStop) { Stop-Service -Name $service.Name -Force -ErrorAction SilentlyContinue }
|
||||
$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue
|
||||
if ($null -ne $service) { $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
$service = Find-ManagedProxiFyreService
|
||||
if ($null -ne $service -and $service.Status -ne 'Stopped') {
|
||||
$processId = Get-ServiceProcessId $service.Name
|
||||
if ($processId -gt 0) {
|
||||
taskkill.exe /PID $processId /F | Out-Null
|
||||
Start-Sleep -Milliseconds 700
|
||||
}
|
||||
}
|
||||
|
||||
Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'running' 34 'Удаляю службу и файлы ProxiFyre.'
|
||||
if (-not [string]::IsNullOrWhiteSpace($exePath) -and (Test-Path -LiteralPath $exePath)) {
|
||||
Push-Location (Split-Path -Parent $exePath)
|
||||
try {
|
||||
& $exePath uninstall | Out-Null
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
$service = Find-ManagedProxiFyreService
|
||||
if ($null -ne $service) {
|
||||
sc.exe delete $service.Name | Out-Null
|
||||
}
|
||||
|
||||
foreach ($firewallRuleName in @('ProxyWarden.ProxiFyre.Inbound', 'ProxyWarden.ProxiFyre.Outbound')) {
|
||||
Get-NetFirewallRule -Name $firewallRuleName -ErrorAction SilentlyContinue |
|
||||
Remove-NetFirewallRule -ErrorAction Stop
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($installDir) -and (Test-Path -LiteralPath $installDir)) {
|
||||
Remove-Item -LiteralPath $installDir -Recurse -Force
|
||||
}
|
||||
|
||||
Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'succeeded' 58 'ProxiFyre удален.'
|
||||
|
||||
if ($removePacketFilter) {
|
||||
Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'running' 68 'Проверяю Windows Packet Filter.'
|
||||
$packetFilter = Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI'
|
||||
if ($null -ne $packetFilter) {
|
||||
Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'running' 78 'Удаляю Windows Packet Filter.'
|
||||
$driverLogPath = Join-Path ([IO.Path]::GetTempPath()) 'proxywarden-windows-packet-filter-uninstall.log'
|
||||
Uninstall-MsiProgram $packetFilter 'Windows Packet Filter' $driverLogPath
|
||||
}
|
||||
if (Test-WindowsPacketFilter) {
|
||||
throw 'Windows Packet Filter все еще найден после удаления. Возможно, Windows требует перезагрузку.'
|
||||
}
|
||||
Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'succeeded' 100 'ProxiFyre и принадлежащий ProxyWarden Windows Packet Filter удалены.'
|
||||
} else {
|
||||
Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'skipped' 100 'Windows Packet Filter оставлен: marker не подтверждает владение ProxyWarden.'
|
||||
}
|
||||
"#,
|
||||
);
|
||||
|
||||
script
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! ProxiFyre config apply helper boundary and testable legacy apply fixture.
|
||||
//!
|
||||
//! The current webview path uses `apply_flow`; the lower-level fixture remains
|
||||
//! for adapter/storage integration tests and shares the same detected writer.
|
||||
|
||||
use crate::adapters::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||
ProxyRouterRequest,
|
||||
};
|
||||
use crate::clock::Clock;
|
||||
use crate::command_dto::{ActivityEntryDto, CommandError};
|
||||
use crate::component_detection::{
|
||||
detect_proxyfier_install, detect_proxyfier_install_with_host, detect_singbox_install,
|
||||
DetectedProxyfier, DetectedSingBox, ProxyfierDetectionHost, SystemProxyfierDetectionHost,
|
||||
};
|
||||
use crate::component_status::components_or_defaults_with_detection;
|
||||
use crate::models::{ActivityEntry, ActivityLevel};
|
||||
use crate::safe_fs;
|
||||
use crate::storage::JsonStorage;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApplyProfilesResponse {
|
||||
pub success: bool,
|
||||
pub changed: bool,
|
||||
pub message: String,
|
||||
pub adapter_id: String,
|
||||
pub generated_config_path: String,
|
||||
pub enabled_profiles: usize,
|
||||
pub routed_apps: usize,
|
||||
pub helper: HelperApplyResult,
|
||||
pub activity: ActivityEntryDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HelperApplyResult {
|
||||
pub success: bool,
|
||||
pub changed: bool,
|
||||
pub action: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub struct HelperApplyRequest<'a> {
|
||||
pub adapter_id: &'a str,
|
||||
pub config_path: &'a Path,
|
||||
pub config_contents: &'a str,
|
||||
}
|
||||
|
||||
pub trait ProxyApplyHelper {
|
||||
fn apply_proxy_config(
|
||||
&self,
|
||||
request: HelperApplyRequest<'_>,
|
||||
) -> Result<HelperApplyResult, CommandError>;
|
||||
}
|
||||
|
||||
pub struct DetectedProxyApplyHelper<H = SystemProxyfierDetectionHost> {
|
||||
host: H,
|
||||
}
|
||||
|
||||
impl DetectedProxyApplyHelper<SystemProxyfierDetectionHost> {
|
||||
pub fn system() -> Self {
|
||||
SystemProxyfierDetectionHost.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl<H> From<H> for DetectedProxyApplyHelper<H> {
|
||||
fn from(host: H) -> Self {
|
||||
Self { host }
|
||||
}
|
||||
}
|
||||
|
||||
impl<H> ProxyApplyHelper for DetectedProxyApplyHelper<H>
|
||||
where
|
||||
H: ProxyfierDetectionHost,
|
||||
{
|
||||
fn apply_proxy_config(
|
||||
&self,
|
||||
request: HelperApplyRequest<'_>,
|
||||
) -> Result<HelperApplyResult, CommandError> {
|
||||
let Some(detected) = detect_proxyfier_install_with_host(&self.host) else {
|
||||
return staged_apply_result(request);
|
||||
};
|
||||
|
||||
apply_to_detected_proxyfier(request, &detected)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_profiles_with_services(
|
||||
storage: &JsonStorage,
|
||||
adapter: &impl ProxyRouterAdapter,
|
||||
helper: &impl ProxyApplyHelper,
|
||||
clock: &impl Clock,
|
||||
) -> Result<ApplyProfilesResponse, CommandError> {
|
||||
apply_profiles_with_services_and_detection(
|
||||
storage,
|
||||
adapter,
|
||||
helper,
|
||||
clock,
|
||||
detect_proxyfier_install(),
|
||||
detect_singbox_install(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn apply_profiles_with_services_and_detection(
|
||||
storage: &JsonStorage,
|
||||
adapter: &impl ProxyRouterAdapter,
|
||||
helper: &impl ProxyApplyHelper,
|
||||
clock: &impl Clock,
|
||||
detected_proxyfier: Option<DetectedProxyfier>,
|
||||
detected_singbox: Option<DetectedSingBox>,
|
||||
) -> Result<ApplyProfilesResponse, CommandError> {
|
||||
let profiles = storage.read_profiles().map_err(storage_error)?;
|
||||
let targets = storage.read_targets().map_err(storage_error)?;
|
||||
let components =
|
||||
components_or_defaults_with_detection(storage, detected_proxyfier, detected_singbox)?;
|
||||
let generated =
|
||||
match adapter.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) {
|
||||
Ok(generated) => generated,
|
||||
Err(error) => {
|
||||
let command_error = adapter_error(error);
|
||||
let activity = activity_for_apply_error(clock, &command_error);
|
||||
storage.append_activity(activity).map_err(storage_error)?;
|
||||
return Err(command_error);
|
||||
}
|
||||
};
|
||||
|
||||
let generated_path = storage
|
||||
.paths()
|
||||
.generated_dir
|
||||
.join(generated.output_file_name.as_str());
|
||||
write_generated_config(&generated_path, &generated.contents)?;
|
||||
|
||||
let helper_result = helper.apply_proxy_config(HelperApplyRequest {
|
||||
adapter_id: generated.adapter_id.as_str(),
|
||||
config_path: &generated_path,
|
||||
config_contents: generated.contents.as_str(),
|
||||
})?;
|
||||
|
||||
let activity = activity_for_apply(clock, &generated, &generated_path, &helper_result);
|
||||
storage
|
||||
.append_activity(activity.clone())
|
||||
.map_err(storage_error)?;
|
||||
|
||||
Ok(ApplyProfilesResponse {
|
||||
success: helper_result.success,
|
||||
changed: helper_result.changed,
|
||||
message: helper_result.message.clone(),
|
||||
adapter_id: generated.adapter_id,
|
||||
generated_config_path: generated_path.display().to_string(),
|
||||
enabled_profiles: generated.enabled_profiles,
|
||||
routed_apps: generated.routed_apps,
|
||||
helper: helper_result,
|
||||
activity: ActivityEntryDto::from(&activity),
|
||||
})
|
||||
}
|
||||
|
||||
fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> {
|
||||
safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error)
|
||||
}
|
||||
|
||||
fn apply_to_detected_proxyfier(
|
||||
request: HelperApplyRequest<'_>,
|
||||
detected: &DetectedProxyfier,
|
||||
) -> Result<HelperApplyResult, CommandError> {
|
||||
let Some(config_path) = &detected.config_path else {
|
||||
return staged_apply_result(request);
|
||||
};
|
||||
|
||||
safe_fs::write_with_backup(config_path, request.config_contents.as_bytes()).map_err(
|
||||
|error| {
|
||||
CommandError::new(
|
||||
"proxyfier_apply_failed",
|
||||
format!(
|
||||
"Не удалось безопасно записать конфиг ProxiFyre '{}': {error}",
|
||||
config_path.display()
|
||||
),
|
||||
)
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(HelperApplyResult {
|
||||
success: true,
|
||||
changed: true,
|
||||
action: "proxifyre.apply-detected-config".to_string(),
|
||||
message: format!(
|
||||
"Сгенерированный конфиг записан в найденную установку ProxiFyre: {}",
|
||||
config_path.display()
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn staged_apply_result(request: HelperApplyRequest<'_>) -> Result<HelperApplyResult, CommandError> {
|
||||
Ok(HelperApplyResult {
|
||||
success: true,
|
||||
changed: true,
|
||||
action: format!("{}.stage-generated-config", request.adapter_id),
|
||||
message: format!(
|
||||
"Сгенерированный конфиг подготовлен в {}; совместимая установка ProxiFyre не найдена",
|
||||
request.config_path.display()
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn activity_for_apply(
|
||||
clock: &impl Clock,
|
||||
generated: &ProxyRouterGeneratedConfig,
|
||||
generated_path: &Path,
|
||||
helper_result: &HelperApplyResult,
|
||||
) -> ActivityEntry {
|
||||
let level = if helper_result.success {
|
||||
ActivityLevel::Success
|
||||
} else {
|
||||
ActivityLevel::Error
|
||||
};
|
||||
|
||||
ActivityEntry {
|
||||
id: format!("apply-{}", generated.adapter_id),
|
||||
at: clock.now(),
|
||||
level,
|
||||
title: "Конфиг ProxiFyre создан".to_string(),
|
||||
message: format!(
|
||||
"Профилей: {}, приложений: {}, конфиг: {}",
|
||||
generated.enabled_profiles,
|
||||
generated.routed_apps,
|
||||
generated_path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn activity_for_apply_error(clock: &impl Clock, error: &CommandError) -> ActivityEntry {
|
||||
ActivityEntry {
|
||||
id: format!("apply-error-{}", error.code),
|
||||
at: clock.now(),
|
||||
level: ActivityLevel::Error,
|
||||
title: "Применение ProxiFyre заблокировано".to_string(),
|
||||
message: error.message.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_error(error: std::io::Error) -> CommandError {
|
||||
CommandError::new("storage_error", error.to_string())
|
||||
}
|
||||
|
||||
fn adapter_error(error: ProxyRouterError) -> CommandError {
|
||||
let code = match error.kind {
|
||||
ProxyRouterErrorKind::EmptyProfileItems => "empty_profile_items",
|
||||
ProxyRouterErrorKind::MissingTarget => "missing_target",
|
||||
ProxyRouterErrorKind::MissingRequiredComponent => "missing_required_component",
|
||||
ProxyRouterErrorKind::RequiredComponentNotRunning => "required_component_not_running",
|
||||
ProxyRouterErrorKind::UnsupportedTargetProtocol => "unsupported_target_protocol",
|
||||
ProxyRouterErrorKind::Serialization => "serialization_error",
|
||||
};
|
||||
|
||||
CommandError::new(code, error.message)
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
//! TCP and outbound HTTP checks used to verify a configured SOCKS5 route.
|
||||
//!
|
||||
//! All functions are blocking. Tauri handlers must call them through
|
||||
//! `spawn_blocking`; probe URLs are static and never come from webview input.
|
||||
|
||||
use crate::command_dto::{
|
||||
CommandError, PingProxyTargetInputDto, PingServerResponse, ProxyProbeResponse,
|
||||
ProxyTargetCheckResponse,
|
||||
};
|
||||
use std::net::{IpAddr, TcpStream, ToSocketAddrs};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const PROXY_CHECK_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
const PROXY_CHECK_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const PROXY_CHECK_USER_AGENT: &str = "proxywarden route-check";
|
||||
|
||||
const DEFAULT_PROXY_PROBES: &[ProxyProbeEndpoint] = &[
|
||||
ProxyProbeEndpoint {
|
||||
id: "cloudflare-trace",
|
||||
name: "Cloudflare Trace",
|
||||
url: "https://www.cloudflare.com/cdn-cgi/trace",
|
||||
ip_source: ProbeIpSource::CloudflareTrace,
|
||||
},
|
||||
ProxyProbeEndpoint {
|
||||
id: "cloudflare-speed",
|
||||
name: "Cloudflare Speed",
|
||||
url: "https://speed.cloudflare.com/meta",
|
||||
ip_source: ProbeIpSource::JsonField("clientIp"),
|
||||
},
|
||||
ProxyProbeEndpoint {
|
||||
id: "ipify",
|
||||
name: "ipify",
|
||||
url: "https://api.ipify.org?format=json",
|
||||
ip_source: ProbeIpSource::JsonField("ip"),
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ProxyProbeEndpoint {
|
||||
id: &'static str,
|
||||
name: &'static str,
|
||||
url: &'static str,
|
||||
ip_source: ProbeIpSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum ProbeIpSource {
|
||||
CloudflareTrace,
|
||||
JsonField(&'static str),
|
||||
}
|
||||
|
||||
pub fn ping_proxy_target_endpoint(
|
||||
input: PingProxyTargetInputDto,
|
||||
) -> Result<ProxyTargetCheckResponse, CommandError> {
|
||||
ping_proxy_target_endpoint_with_probes(input, DEFAULT_PROXY_PROBES)
|
||||
}
|
||||
|
||||
pub fn ping_proxy_target_endpoint_with_probes(
|
||||
input: PingProxyTargetInputDto,
|
||||
probes: &[ProxyProbeEndpoint],
|
||||
) -> Result<ProxyTargetCheckResponse, CommandError> {
|
||||
let host = input.host.trim();
|
||||
if host.is_empty() {
|
||||
return Err(CommandError::new(
|
||||
"proxy_target_host_missing",
|
||||
"Хост внешнего прокси не указан.",
|
||||
));
|
||||
}
|
||||
|
||||
let tcp = ping_endpoint("route-proxy", "route-proxy", host, input.port);
|
||||
if !tcp.ok {
|
||||
return Ok(ProxyTargetCheckResponse {
|
||||
tag: "route-proxy".to_string(),
|
||||
server: host.to_string(),
|
||||
server_port: input.port,
|
||||
ok: false,
|
||||
latency: tcp.latency,
|
||||
error: tcp.error,
|
||||
probes: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let probe_results = run_proxy_probes(host, input.port, probes);
|
||||
let has_probe_success = probe_results.iter().any(|probe| probe.ok);
|
||||
let ok = probe_results.is_empty() || has_probe_success;
|
||||
let error = (!ok).then(|| {
|
||||
"SOCKS5 порт доступен, но тестовые HTTP endpoints не ответили через прокси.".to_string()
|
||||
});
|
||||
|
||||
Ok(ProxyTargetCheckResponse {
|
||||
tag: "route-proxy".to_string(),
|
||||
server: host.to_string(),
|
||||
server_port: input.port,
|
||||
ok,
|
||||
latency: tcp.latency,
|
||||
error,
|
||||
probes: probe_results,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ping_endpoint(id: &str, tag: &str, server: &str, server_port: u16) -> PingServerResponse {
|
||||
let started = Instant::now();
|
||||
let addresses = match (server, server_port).to_socket_addrs() {
|
||||
Ok(addresses) => addresses.collect::<Vec<_>>(),
|
||||
Err(error) => {
|
||||
return PingServerResponse {
|
||||
id: id.to_string(),
|
||||
tag: tag.to_string(),
|
||||
server: server.to_string(),
|
||||
server_port,
|
||||
ok: false,
|
||||
latency: None,
|
||||
error: Some(format!("DNS/адрес недоступен: {error}")),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if addresses.is_empty() {
|
||||
return PingServerResponse {
|
||||
id: id.to_string(),
|
||||
tag: tag.to_string(),
|
||||
server: server.to_string(),
|
||||
server_port,
|
||||
ok: false,
|
||||
latency: None,
|
||||
error: Some("DNS не вернул адреса".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
let timeout = Duration::from_secs(2);
|
||||
let mut last_error = None;
|
||||
for address in addresses {
|
||||
match TcpStream::connect_timeout(&address, timeout) {
|
||||
Ok(_) => {
|
||||
return PingServerResponse {
|
||||
id: id.to_string(),
|
||||
tag: tag.to_string(),
|
||||
server: server.to_string(),
|
||||
server_port,
|
||||
ok: true,
|
||||
latency: Some(started.elapsed().as_millis()),
|
||||
error: None,
|
||||
};
|
||||
}
|
||||
Err(error) => last_error = Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
PingServerResponse {
|
||||
id: id.to_string(),
|
||||
tag: tag.to_string(),
|
||||
server: server.to_string(),
|
||||
server_port,
|
||||
ok: false,
|
||||
latency: None,
|
||||
error: last_error,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_proxy_probes(
|
||||
proxy_host: &str,
|
||||
proxy_port: u16,
|
||||
probes: &[ProxyProbeEndpoint],
|
||||
) -> Vec<ProxyProbeResponse> {
|
||||
if probes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let proxy_url = socks5h_proxy_url(proxy_host, proxy_port);
|
||||
let client = match reqwest::Proxy::all(&proxy_url).and_then(|proxy| {
|
||||
reqwest::blocking::Client::builder()
|
||||
.timeout(PROXY_CHECK_TIMEOUT)
|
||||
.connect_timeout(PROXY_CHECK_CONNECT_TIMEOUT)
|
||||
.proxy(proxy)
|
||||
.build()
|
||||
}) {
|
||||
Ok(client) => client,
|
||||
Err(error) => {
|
||||
return probes
|
||||
.iter()
|
||||
.map(|probe| {
|
||||
failed_probe(
|
||||
*probe,
|
||||
format!("Не удалось подготовить SOCKS5 проверку: {error}"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
};
|
||||
|
||||
let handles = probes
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|probe| {
|
||||
let client = client.clone();
|
||||
std::thread::spawn(move || run_proxy_probe(&client, probe))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
handles
|
||||
.into_iter()
|
||||
.zip(probes.iter().copied())
|
||||
.map(|(handle, probe)| {
|
||||
handle
|
||||
.join()
|
||||
.unwrap_or_else(|_| failed_probe(probe, "Проверка была прервана.".to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_proxy_probe(
|
||||
client: &reqwest::blocking::Client,
|
||||
probe: ProxyProbeEndpoint,
|
||||
) -> ProxyProbeResponse {
|
||||
let started = Instant::now();
|
||||
let response = match client
|
||||
.get(probe.url)
|
||||
.header(reqwest::header::USER_AGENT, PROXY_CHECK_USER_AGENT)
|
||||
.send()
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(error) => return failed_probe(probe, format!("HTTP через SOCKS5 не прошел: {error}")),
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
let status_code = status.as_u16();
|
||||
let body = match response.text() {
|
||||
Ok(body) => body,
|
||||
Err(error) => {
|
||||
return failed_probe_with_status(
|
||||
probe,
|
||||
status_code,
|
||||
format!("Ответ не прочитан: {error}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
let latency = started.elapsed().as_millis();
|
||||
|
||||
if !status.is_success() {
|
||||
return ProxyProbeResponse {
|
||||
id: probe.id.to_string(),
|
||||
name: probe.name.to_string(),
|
||||
url: probe.url.to_string(),
|
||||
ok: false,
|
||||
status: Some(status_code),
|
||||
latency: Some(latency),
|
||||
ip: None,
|
||||
error: Some(format!("HTTP {status_code}")),
|
||||
};
|
||||
}
|
||||
|
||||
ProxyProbeResponse {
|
||||
id: probe.id.to_string(),
|
||||
name: probe.name.to_string(),
|
||||
url: probe.url.to_string(),
|
||||
ok: true,
|
||||
status: Some(status_code),
|
||||
latency: Some(latency),
|
||||
ip: extract_probe_ip(probe, &body),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_probe(probe: ProxyProbeEndpoint, error: String) -> ProxyProbeResponse {
|
||||
failed_probe_with_status(probe, 0, error)
|
||||
}
|
||||
|
||||
fn failed_probe_with_status(
|
||||
probe: ProxyProbeEndpoint,
|
||||
status: u16,
|
||||
error: String,
|
||||
) -> ProxyProbeResponse {
|
||||
ProxyProbeResponse {
|
||||
id: probe.id.to_string(),
|
||||
name: probe.name.to_string(),
|
||||
url: probe.url.to_string(),
|
||||
ok: false,
|
||||
status: (status > 0).then_some(status),
|
||||
latency: None,
|
||||
ip: None,
|
||||
error: Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn socks5h_proxy_url(host: &str, port: u16) -> String {
|
||||
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
|
||||
if host.contains(':') {
|
||||
format!("socks5h://[{host}]:{port}")
|
||||
} else {
|
||||
format!("socks5h://{host}:{port}")
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_probe_ip(probe: ProxyProbeEndpoint, body: &str) -> Option<String> {
|
||||
match probe.ip_source {
|
||||
ProbeIpSource::CloudflareTrace => body
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("ip=").and_then(normalize_ip)),
|
||||
ProbeIpSource::JsonField(field) => serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get(field)
|
||||
.and_then(|field| field.as_str())
|
||||
.and_then(normalize_ip)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_ip(value: &str) -> Option<String> {
|
||||
let candidate = value.trim().trim_matches('"');
|
||||
candidate
|
||||
.parse::<IpAddr>()
|
||||
.is_ok()
|
||||
.then(|| candidate.to_string())
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn backup_path(path: &Path) -> PathBuf {
|
||||
sibling_with_suffix(path, "bak")
|
||||
}
|
||||
|
||||
pub fn corrupt_path(path: &Path) -> PathBuf {
|
||||
sibling_with_suffix(
|
||||
path,
|
||||
&format!("corrupt.{}", uuid::Uuid::new_v4().hyphenated()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn temp_path(path: &Path) -> PathBuf {
|
||||
sibling_with_suffix(path, &format!("tmp.{}", uuid::Uuid::new_v4().hyphenated()))
|
||||
}
|
||||
|
||||
pub fn write_with_backup(path: &Path, contents: &[u8]) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let temp_path = temp_path(path);
|
||||
fs::write(&temp_path, contents)?;
|
||||
|
||||
let backup_path = backup_path(path);
|
||||
if path.exists() {
|
||||
fs::copy(path, &backup_path)?;
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
|
||||
match fs::rename(&temp_path, path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => {
|
||||
let _ = fs::remove_file(&temp_path);
|
||||
if !path.exists() && backup_path.exists() {
|
||||
let _ = fs::copy(&backup_path, path);
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("proxywarden-file");
|
||||
|
||||
path.with_file_name(format!("{file_name}.{suffix}"))
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Local sing-box config generation and derived local-target persistence.
|
||||
|
||||
use crate::adapters::singbox::{
|
||||
SingBoxAdapter, SingBoxConfigChecker, SingBoxConfigError, SingBoxConfigErrorKind,
|
||||
SingBoxGeneratedConfig, SingBoxGenerationRequest,
|
||||
};
|
||||
use crate::clock::Clock;
|
||||
use crate::command_dto::{ActivityEntryDto, CommandError, GenerateSingBoxConfigResponse};
|
||||
use crate::models::{
|
||||
ActivityEntry, ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, Target,
|
||||
TargetKind,
|
||||
};
|
||||
use crate::safe_fs;
|
||||
use crate::singbox_subscription::read_required_singbox_cache;
|
||||
use crate::storage::JsonStorage;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn generate_singbox_config_with_services<C>(
|
||||
storage: &JsonStorage,
|
||||
adapter: &SingBoxAdapter,
|
||||
checker: &C,
|
||||
clock: &impl Clock,
|
||||
binary_path: Option<&Path>,
|
||||
) -> Result<GenerateSingBoxConfigResponse, CommandError>
|
||||
where
|
||||
C: SingBoxConfigChecker,
|
||||
{
|
||||
let config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||
let cache = read_required_singbox_cache(storage)?;
|
||||
let generated = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&config, &cache, binary_path),
|
||||
checker,
|
||||
)
|
||||
.map_err(singbox_adapter_error)?;
|
||||
let generated_path = storage
|
||||
.paths()
|
||||
.generated_dir
|
||||
.join(generated.output_file_name.as_str());
|
||||
|
||||
write_generated_config(&generated_path, &generated.contents)?;
|
||||
ensure_local_singbox_target(storage, &config)?;
|
||||
|
||||
let activity = activity_for_singbox_generate(clock, &generated, &generated_path);
|
||||
storage
|
||||
.append_activity(activity.clone())
|
||||
.map_err(storage_error)?;
|
||||
|
||||
Ok(GenerateSingBoxConfigResponse {
|
||||
success: true,
|
||||
message: "Конфиг Local sing-box создан".to_string(),
|
||||
adapter_id: generated.adapter_id,
|
||||
generated_config_path: generated_path.display().to_string(),
|
||||
selected_server_tag: generated.selected_server_tag,
|
||||
listen_host: generated.listen,
|
||||
listen_port: generated.listen_port,
|
||||
check: generated.check,
|
||||
activity: ActivityEntryDto::from(&activity),
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_local_singbox_target(
|
||||
storage: &JsonStorage,
|
||||
config: &LocalSingBoxConfig,
|
||||
) -> Result<(), CommandError> {
|
||||
let mut targets = storage.read_targets().map_err(storage_error)?;
|
||||
let target = Target {
|
||||
id: "local-singbox".to_string(),
|
||||
name: "Локальный sing-box".to_string(),
|
||||
kind: TargetKind::Local,
|
||||
protocol: ProxyProtocol::Socks5,
|
||||
host: config.listen_host.clone(),
|
||||
port: config.listen_port,
|
||||
requires_component: Some(ComponentId::Singbox),
|
||||
};
|
||||
|
||||
match targets.iter().position(|existing| existing.id == target.id) {
|
||||
Some(index) => targets[index] = target,
|
||||
None => targets.push(target),
|
||||
}
|
||||
|
||||
storage.write_targets(&targets).map_err(storage_error)
|
||||
}
|
||||
|
||||
fn activity_for_singbox_generate(
|
||||
clock: &impl Clock,
|
||||
generated: &SingBoxGeneratedConfig,
|
||||
generated_path: &Path,
|
||||
) -> ActivityEntry {
|
||||
ActivityEntry {
|
||||
id: "singbox-config-generated".to_string(),
|
||||
at: clock.now(),
|
||||
level: ActivityLevel::Success,
|
||||
title: "Конфиг Local sing-box создан".to_string(),
|
||||
message: format!(
|
||||
"Сервер: {}, listen: {}:{}, конфиг: {}",
|
||||
generated.selected_server_tag,
|
||||
generated.listen,
|
||||
generated.listen_port,
|
||||
generated_path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn singbox_adapter_error(error: SingBoxConfigError) -> CommandError {
|
||||
let code = match error.kind {
|
||||
SingBoxConfigErrorKind::MissingSelectedServer => "singbox_server_not_selected",
|
||||
SingBoxConfigErrorKind::MissingSelectedOutbound => "singbox_selected_server_missing",
|
||||
SingBoxConfigErrorKind::UnsupportedSelectedOutbound => {
|
||||
"singbox_selected_server_unsupported"
|
||||
}
|
||||
SingBoxConfigErrorKind::Serialization => "serialization_error",
|
||||
SingBoxConfigErrorKind::CheckFailed => "singbox_check_failed",
|
||||
};
|
||||
|
||||
CommandError::new(code, error.message)
|
||||
}
|
||||
|
||||
fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> {
|
||||
safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error)
|
||||
}
|
||||
|
||||
fn storage_error(error: std::io::Error) -> CommandError {
|
||||
CommandError::new("storage_error", error.to_string())
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
//! Explicit Local sing-box service and package lifecycle orchestration.
|
||||
//!
|
||||
//! These operations may request UAC elevation. Apply configuration never calls
|
||||
//! this module; install/start/stop/uninstall remain separate user actions.
|
||||
|
||||
use crate::command_dto::{CommandError, ComponentStatusDto};
|
||||
use crate::component_detection::{detect_singbox_install, singbox_component_from_detection};
|
||||
use crate::elevated_scripts;
|
||||
use crate::powershell::{
|
||||
escape_single as escape_powershell_single, is_elevated as is_running_elevated,
|
||||
package_failure_details, run_command as run_powershell_command,
|
||||
run_file as run_powershell_file, write_script as write_powershell_script,
|
||||
};
|
||||
use crate::process::command_no_window;
|
||||
use crate::singbox_service::{
|
||||
ensure_safe_singbox_install_dir,
|
||||
parse_service_command_output as parse_singbox_service_command_output, service_control_script,
|
||||
ServiceCommandOutput as SingBoxServiceCommandOutput, SingBoxServiceAction,
|
||||
};
|
||||
use crate::storage::{default_config_root, JsonStorage};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) fn control_singbox_service(
|
||||
action: SingBoxServiceAction,
|
||||
config_source: Option<&Path>,
|
||||
) -> Result<ComponentStatusDto, CommandError> {
|
||||
let Some(detected) = detect_singbox_install() else {
|
||||
return Err(CommandError::new(
|
||||
"singbox_not_found",
|
||||
"Local sing-box не найден на компьютере.",
|
||||
));
|
||||
};
|
||||
|
||||
let config_target = config_source.map(|_| detected.install_dir.join("config.json"));
|
||||
let script = service_control_script(
|
||||
action,
|
||||
&detected.service_name,
|
||||
config_source,
|
||||
config_target.as_deref(),
|
||||
);
|
||||
let output = command_no_window("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
script.as_str(),
|
||||
])
|
||||
.output()
|
||||
.map_err(|error| {
|
||||
CommandError::new(
|
||||
singbox_service_error_code(action),
|
||||
format!(
|
||||
"Не удалось {} службу Local sing-box: {error}",
|
||||
action.label()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let result = parse_singbox_service_command_output(&output.stdout).ok_or_else(|| {
|
||||
CommandError::new(
|
||||
singbox_service_error_code(action),
|
||||
singbox_service_script_failed_message(action, output.status.code()),
|
||||
)
|
||||
})?;
|
||||
|
||||
if result.success {
|
||||
let refreshed = detect_singbox_install();
|
||||
let component = singbox_component_from_detection(refreshed.as_ref());
|
||||
return Ok(ComponentStatusDto::from(&component));
|
||||
}
|
||||
|
||||
if matches!(
|
||||
result.code.as_str(),
|
||||
"start_failed" | "stop_failed" | "config_sync_failed"
|
||||
) {
|
||||
run_elevated_singbox_service_command(
|
||||
action,
|
||||
&detected.service_name,
|
||||
config_source,
|
||||
config_target.as_deref(),
|
||||
&result,
|
||||
)?;
|
||||
let refreshed = detect_singbox_install();
|
||||
let component = singbox_component_from_detection(refreshed.as_ref());
|
||||
return Ok(ComponentStatusDto::from(&component));
|
||||
}
|
||||
|
||||
Err(CommandError::new(
|
||||
singbox_service_error_code(action),
|
||||
singbox_service_command_failed_message(action, &result),
|
||||
))
|
||||
}
|
||||
|
||||
fn run_elevated_singbox_service_command(
|
||||
action: SingBoxServiceAction,
|
||||
service_name: &str,
|
||||
config_source: Option<&Path>,
|
||||
config_target: Option<&Path>,
|
||||
direct_result: &SingBoxServiceCommandOutput,
|
||||
) -> Result<(), CommandError> {
|
||||
let script_path =
|
||||
write_elevated_singbox_service_script(action, service_name, config_source, config_target)?;
|
||||
let launch_script = format!(
|
||||
"$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode",
|
||||
escape_powershell_single(&script_path.display().to_string())
|
||||
);
|
||||
let output = if is_running_elevated() {
|
||||
run_powershell_file(&script_path)
|
||||
} else {
|
||||
run_powershell_command(&launch_script)
|
||||
};
|
||||
|
||||
let _ = fs::remove_file(&script_path);
|
||||
|
||||
match output {
|
||||
Ok(output) if output.status.success() => Ok(()),
|
||||
Ok(output) => Err(CommandError::new(
|
||||
singbox_service_error_code(action),
|
||||
elevated_singbox_service_failed_message(action, direct_result, output.status.code()),
|
||||
)),
|
||||
Err(error) => Err(CommandError::new(
|
||||
singbox_service_error_code(action),
|
||||
format!(
|
||||
"Не удалось запросить права администратора, чтобы {} службу Local sing-box: {error}",
|
||||
action.label()
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_elevated_singbox_service_script(
|
||||
action: SingBoxServiceAction,
|
||||
service_name: &str,
|
||||
config_source: Option<&Path>,
|
||||
config_target: Option<&Path>,
|
||||
) -> Result<PathBuf, CommandError> {
|
||||
let script_path = elevated_scripts::temp_script_path("proxywarden-singbox-service");
|
||||
let script =
|
||||
elevated_singbox_service_script(action, service_name, config_source, config_target);
|
||||
|
||||
write_powershell_script(&script_path, &script).map_err(|error| {
|
||||
CommandError::new(
|
||||
singbox_service_error_code(action),
|
||||
format!(
|
||||
"Не удалось подготовить временный скрипт для управления Local sing-box '{}': {error}",
|
||||
script_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(script_path)
|
||||
}
|
||||
|
||||
fn elevated_singbox_service_script(
|
||||
action: SingBoxServiceAction,
|
||||
service_name: &str,
|
||||
config_source: Option<&Path>,
|
||||
config_target: Option<&Path>,
|
||||
) -> String {
|
||||
let action_name = action.action_name();
|
||||
let escaped_service_name = escape_powershell_single(service_name);
|
||||
let escaped_config_source = config_source
|
||||
.map(|path| escape_powershell_single(&path.display().to_string()))
|
||||
.unwrap_or_default();
|
||||
let escaped_config_target = config_target
|
||||
.map(|path| escape_powershell_single(&path.display().to_string()))
|
||||
.unwrap_or_default();
|
||||
|
||||
format!(
|
||||
r#"
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
$serviceName = '{escaped_service_name}'
|
||||
$action = '{action_name}'
|
||||
$configSource = '{escaped_config_source}'
|
||||
$configTarget = '{escaped_config_target}'
|
||||
|
||||
if ($action -eq 'start') {{
|
||||
if (-not [string]::IsNullOrWhiteSpace($configSource)) {{
|
||||
if (-not (Test-Path -LiteralPath $configSource)) {{ exit 5 }}
|
||||
if (-not [string]::IsNullOrWhiteSpace($configTarget)) {{
|
||||
try {{
|
||||
Copy-Item -LiteralPath $configSource -Destination $configTarget -Force -ErrorAction Stop
|
||||
}} catch {{
|
||||
exit 6
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
|
||||
if ($null -eq $service) {{ exit 2 }}
|
||||
if ($service.Status -eq 'Running') {{ exit 0 }}
|
||||
|
||||
Start-Service -Name $serviceName -ErrorAction SilentlyContinue
|
||||
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
|
||||
if ($null -ne $service) {{
|
||||
try {{ $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15)) }} catch {{}}
|
||||
if ($service.Status -eq 'Running') {{ exit 0 }}
|
||||
}}
|
||||
|
||||
exit 3
|
||||
}}
|
||||
|
||||
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
|
||||
if ($null -eq $service) {{ exit 2 }}
|
||||
if ($service.Status -eq 'Stopped') {{ exit 0 }}
|
||||
|
||||
Stop-Service -Name $serviceName -Force -ErrorAction SilentlyContinue
|
||||
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
|
||||
if ($null -ne $service) {{
|
||||
try {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(15)) }} catch {{}}
|
||||
if ($service.Status -eq 'Stopped') {{ exit 0 }}
|
||||
}}
|
||||
|
||||
exit 4
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn install_singbox_component(
|
||||
storage: &JsonStorage,
|
||||
install_dir: &Path,
|
||||
) -> Result<ComponentStatusDto, CommandError> {
|
||||
let generated_config_path = storage.paths().generated_dir.join("sing-box-config.json");
|
||||
run_elevated_singbox_package_script(
|
||||
SingBoxPackageAction::Install,
|
||||
include_str!("../../scripts/install-singbox.ps1"),
|
||||
vec![
|
||||
"-InstallRoot".to_string(),
|
||||
install_dir.display().to_string(),
|
||||
"-ConfigSource".to_string(),
|
||||
generated_config_path.display().to_string(),
|
||||
],
|
||||
&storage.paths().state_dir,
|
||||
)?;
|
||||
|
||||
let refreshed = detect_singbox_install();
|
||||
let Some(detected) = refreshed.as_ref() else {
|
||||
return Err(CommandError::new(
|
||||
SingBoxPackageAction::Install.error_code(),
|
||||
"Установка Local sing-box завершилась, но приложение не найдено после проверки.",
|
||||
));
|
||||
};
|
||||
|
||||
Ok(ComponentStatusDto::from(&singbox_component_from_detection(
|
||||
Some(detected),
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) fn uninstall_singbox_component() -> Result<ComponentStatusDto, CommandError> {
|
||||
let Some(detected) = detect_singbox_install() else {
|
||||
let component = singbox_component_from_detection(None);
|
||||
return Ok(ComponentStatusDto::from(&component));
|
||||
};
|
||||
|
||||
ensure_safe_singbox_install_dir(&detected.install_dir).map_err(|message| {
|
||||
CommandError::new(SingBoxPackageAction::Uninstall.error_code(), message)
|
||||
})?;
|
||||
let artifact_dir = default_config_root().join("state");
|
||||
run_elevated_singbox_package_script(
|
||||
SingBoxPackageAction::Uninstall,
|
||||
include_str!("../../scripts/install-singbox.ps1"),
|
||||
vec![
|
||||
"-InstallRoot".to_string(),
|
||||
detected.install_dir.display().to_string(),
|
||||
"-ServiceName".to_string(),
|
||||
detected.service_name,
|
||||
"-Uninstall".to_string(),
|
||||
],
|
||||
&artifact_dir,
|
||||
)?;
|
||||
|
||||
let refreshed = detect_singbox_install();
|
||||
if refreshed.is_some() {
|
||||
return Err(CommandError::new(
|
||||
SingBoxPackageAction::Uninstall.error_code(),
|
||||
"Удаление Local sing-box завершилось, но приложение все еще найдено на компьютере.",
|
||||
));
|
||||
}
|
||||
|
||||
let component = singbox_component_from_detection(None);
|
||||
Ok(ComponentStatusDto::from(&component))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SingBoxPackageAction {
|
||||
Install,
|
||||
Uninstall,
|
||||
}
|
||||
|
||||
impl SingBoxPackageAction {
|
||||
fn error_code(self) -> &'static str {
|
||||
match self {
|
||||
SingBoxPackageAction::Install => "singbox_install_failed",
|
||||
SingBoxPackageAction::Uninstall => "singbox_uninstall_failed",
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
SingBoxPackageAction::Install => "установить",
|
||||
SingBoxPackageAction::Uninstall => "удалить",
|
||||
}
|
||||
}
|
||||
|
||||
fn file_label(self) -> &'static str {
|
||||
match self {
|
||||
SingBoxPackageAction::Install => "install",
|
||||
SingBoxPackageAction::Uninstall => "uninstall",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_elevated_singbox_package_script(
|
||||
action: SingBoxPackageAction,
|
||||
installer_body: &str,
|
||||
installer_args: Vec<String>,
|
||||
artifact_dir: &Path,
|
||||
) -> Result<(), CommandError> {
|
||||
fs::create_dir_all(artifact_dir).map_err(|error| {
|
||||
CommandError::new(
|
||||
action.error_code(),
|
||||
format!(
|
||||
"Не удалось создать папку для временных файлов Local sing-box '{}': {error}",
|
||||
artifact_dir.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
let prefix = format!("proxywarden-singbox-{}", action.file_label());
|
||||
let installer_path = elevated_scripts::artifact_path(artifact_dir, &prefix, "ps1");
|
||||
let runner_path =
|
||||
elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.runner"), "ps1");
|
||||
let result_path =
|
||||
elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.result"), "log");
|
||||
|
||||
write_powershell_script(&installer_path, installer_body).map_err(|error| {
|
||||
CommandError::new(
|
||||
action.error_code(),
|
||||
format!(
|
||||
"Не удалось подготовить установщик Local sing-box '{}': {error}",
|
||||
installer_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
write_powershell_script(
|
||||
&runner_path,
|
||||
&singbox_installer_runner_script(&installer_path, &result_path, &installer_args),
|
||||
)
|
||||
.map_err(|error| {
|
||||
CommandError::new(
|
||||
action.error_code(),
|
||||
format!(
|
||||
"Не удалось подготовить runner Local sing-box '{}': {error}",
|
||||
runner_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
let launch_script = format!(
|
||||
r#"
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$resultPath = '{}'
|
||||
try {{
|
||||
$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}')
|
||||
if ($null -eq $p) {{
|
||||
Set-Content -LiteralPath $resultPath -Value 'Elevated PowerShell не был запущен.' -Encoding UTF8
|
||||
exit 1
|
||||
}}
|
||||
exit $p.ExitCode
|
||||
}} catch {{
|
||||
Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8
|
||||
exit 1
|
||||
}}
|
||||
"#,
|
||||
escape_powershell_single(&result_path.display().to_string()),
|
||||
escape_powershell_single(&runner_path.display().to_string())
|
||||
);
|
||||
let output = if is_running_elevated() {
|
||||
run_powershell_file(&runner_path)
|
||||
} else {
|
||||
run_powershell_command(&launch_script)
|
||||
};
|
||||
|
||||
let _ = fs::remove_file(&installer_path);
|
||||
let _ = fs::remove_file(&runner_path);
|
||||
|
||||
match output {
|
||||
Ok(output) if output.status.success() => {
|
||||
let _ = fs::remove_file(&result_path);
|
||||
Ok(())
|
||||
}
|
||||
Ok(output) => {
|
||||
let details = package_failure_details(&result_path, &output);
|
||||
let _ = fs::remove_file(&result_path);
|
||||
Err(CommandError::new(
|
||||
action.error_code(),
|
||||
format!(
|
||||
"Не удалось {} Local sing-box. Код elevated-команды: {}. {details}",
|
||||
action.label(),
|
||||
output.status.code().unwrap_or(-1),
|
||||
),
|
||||
))
|
||||
}
|
||||
Err(error) => Err(CommandError::new(
|
||||
action.error_code(),
|
||||
format!(
|
||||
"Не удалось запросить права администратора, чтобы {} Local sing-box: {error}",
|
||||
action.label()
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn singbox_installer_runner_script(
|
||||
installer_path: &Path,
|
||||
result_path: &Path,
|
||||
installer_args: &[String],
|
||||
) -> String {
|
||||
let args = installer_args
|
||||
.iter()
|
||||
.map(|arg| format!("'{}'", escape_powershell_single(arg)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
format!(
|
||||
r#"
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$installerPath = '{}'
|
||||
$resultPath = '{}'
|
||||
$stdoutPath = "$resultPath.stdout.log"
|
||||
$stderrPath = "$resultPath.stderr.log"
|
||||
$installerArgs = @({args})
|
||||
try {{
|
||||
$output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
Set-Content -LiteralPath $stdoutPath -Value ($output | Out-String) -Encoding UTF8
|
||||
if ($exitCode -ne 0) {{
|
||||
$stdout = if (Test-Path -LiteralPath $stdoutPath) {{ Get-Content -LiteralPath $stdoutPath -Raw }} else {{ '' }}
|
||||
$stderr = if (Test-Path -LiteralPath $stderrPath) {{ Get-Content -LiteralPath $stderrPath -Raw }} else {{ '' }}
|
||||
throw "install-singbox.ps1 завершился с кодом $exitCode. stdout: $stdout stderr: $stderr"
|
||||
}}
|
||||
Set-Content -LiteralPath $resultPath -Value 'ok' -Encoding UTF8
|
||||
exit 0
|
||||
}} catch {{
|
||||
Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8
|
||||
exit 1
|
||||
}} finally {{
|
||||
Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue
|
||||
}}
|
||||
"#,
|
||||
escape_powershell_single(&installer_path.display().to_string()),
|
||||
escape_powershell_single(&result_path.display().to_string())
|
||||
)
|
||||
}
|
||||
|
||||
fn singbox_service_error_code(action: SingBoxServiceAction) -> &'static str {
|
||||
match action {
|
||||
SingBoxServiceAction::Start => "singbox_service_start_failed",
|
||||
SingBoxServiceAction::Stop => "singbox_service_stop_failed",
|
||||
}
|
||||
}
|
||||
|
||||
fn singbox_service_script_failed_message(
|
||||
action: SingBoxServiceAction,
|
||||
exit_code: Option<i32>,
|
||||
) -> String {
|
||||
let exit_code = exit_code
|
||||
.map(|code| format!(" Код выхода PowerShell: {code}."))
|
||||
.unwrap_or_default();
|
||||
|
||||
format!(
|
||||
"Не удалось {} службу Local sing-box: команда управления службой не вернула корректный результат.{exit_code}",
|
||||
action.label()
|
||||
)
|
||||
}
|
||||
|
||||
fn singbox_service_command_failed_message(
|
||||
action: SingBoxServiceAction,
|
||||
result: &SingBoxServiceCommandOutput,
|
||||
) -> String {
|
||||
let service_name = result
|
||||
.service_name
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("ProxyWardenSingBox");
|
||||
let status = result
|
||||
.status
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("неизвестен");
|
||||
let pid = result
|
||||
.process_id
|
||||
.filter(|value| *value > 0)
|
||||
.map(|value| format!(", PID: {value}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
match result.code.as_str() {
|
||||
"service_not_found" => "Служба Local sing-box не найдена.".to_string(),
|
||||
"config_source_missing" => {
|
||||
"Сгенерированный конфиг Local sing-box не найден перед запуском службы.".to_string()
|
||||
}
|
||||
"config_sync_failed" => {
|
||||
"Не удалось обновить config.json службы Local sing-box перед запуском. Попробуй запустить приложение от имени администратора.".to_string()
|
||||
}
|
||||
"start_failed" => format!(
|
||||
"Не удалось запустить службу {service_name}. Текущий статус: {status}{pid}. Попробуй запустить приложение от имени администратора."
|
||||
),
|
||||
"stop_failed" => format!(
|
||||
"Не удалось остановить службу {service_name}. Текущий статус: {status}{pid}. Запусти приложение от имени администратора или останови службу вручную в services.msc."
|
||||
),
|
||||
_ => format!(
|
||||
"Не удалось {} службу {service_name}. Текущий статус: {status}{pid}.",
|
||||
action.label()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn elevated_singbox_service_failed_message(
|
||||
action: SingBoxServiceAction,
|
||||
direct_result: &SingBoxServiceCommandOutput,
|
||||
exit_code: Option<i32>,
|
||||
) -> String {
|
||||
let exit_code = exit_code
|
||||
.map(|code| format!(" Код выхода elevated PowerShell: {code}."))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"{} Попытка с правами администратора тоже не сработала.{exit_code}",
|
||||
singbox_service_command_failed_message(action, direct_result)
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::component_detection::DetectedSingBox;
|
||||
use crate::models::{DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT, DEFAULT_LOCAL_SINGBOX_SERVICE_NAME};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const WINSW_WRAPPER_FILE: &str = "ProxyWardenSingBox.exe";
|
||||
|
||||
@@ -56,9 +56,19 @@ pub struct ServiceCommandOutput {
|
||||
}
|
||||
|
||||
pub fn build_singbox_setup_status(detected: Option<&DetectedSingBox>) -> SingBoxSetupStatus {
|
||||
build_singbox_setup_status_with_install_root(
|
||||
detected,
|
||||
&PathBuf::from(DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_singbox_setup_status_with_install_root(
|
||||
detected: Option<&DetectedSingBox>,
|
||||
default_install_root: &Path,
|
||||
) -> SingBoxSetupStatus {
|
||||
let install_root = detected
|
||||
.map(|singbox| singbox.install_dir.display().to_string())
|
||||
.unwrap_or_else(|| DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT.to_string());
|
||||
.unwrap_or_else(|| default_install_root.display().to_string());
|
||||
let binary_item = match detected {
|
||||
Some(singbox) if singbox.binary_exists => SingBoxSetupItem {
|
||||
id: "sing-box-binary".to_string(),
|
||||
@@ -152,9 +162,10 @@ pub fn ensure_safe_singbox_install_dir(path: &Path) -> Result<(), String> {
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
if file_name == "sing-box"
|
||||
&& (normalized.contains("\\proxywarden\\") || normalized.contains("\\proxywarden\\"))
|
||||
{
|
||||
let is_proxywarden_component = normalized.contains("\\proxywarden\\components\\");
|
||||
let is_legacy_proxywarden_child = normalized.ends_with("\\proxywarden\\sing-box");
|
||||
|
||||
if file_name == "sing-box" && (is_proxywarden_component || is_legacy_proxywarden_child) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Local sing-box subscription persistence, selection, status, and ping use cases.
|
||||
|
||||
use crate::clock::Clock;
|
||||
use crate::command_dto::*;
|
||||
use crate::component_detection::{
|
||||
detect_singbox_install, singbox_component_from_detection, DetectedSingBox,
|
||||
};
|
||||
use crate::models::{
|
||||
ActivityEntry, ActivityLevel, LocalSingBoxConfig, SubscriptionCache, SubscriptionServer,
|
||||
};
|
||||
use crate::proxy_probe::ping_endpoint;
|
||||
use crate::storage::JsonStorage;
|
||||
use crate::subscription;
|
||||
use std::net::{IpAddr, UdpSocket};
|
||||
|
||||
pub trait SubscriptionFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
url: &str,
|
||||
identity: &subscription::SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError>;
|
||||
}
|
||||
|
||||
pub struct SystemSubscriptionFetcher;
|
||||
|
||||
impl SubscriptionFetcher for SystemSubscriptionFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
url: &str,
|
||||
identity: &subscription::SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||
subscription::fetch_subscription_with_identity(url, identity)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
fn subscription_request_identity_for_display() -> SubscriptionRequestIdentityDto {
|
||||
let identity = subscription::SubscriptionFetchIdentity::default();
|
||||
let headers = identity
|
||||
.request_headers_without_device_hwid()
|
||||
.into_iter()
|
||||
.map(|(name, value)| SubscriptionRequestHeaderDto {
|
||||
name: name.to_string(),
|
||||
value,
|
||||
})
|
||||
.collect();
|
||||
|
||||
SubscriptionRequestIdentityDto { headers }
|
||||
}
|
||||
|
||||
pub fn read_singbox_status(
|
||||
storage: &JsonStorage,
|
||||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||
let detected = detect_singbox_install();
|
||||
read_singbox_status_with_detection(storage, detected.as_ref())
|
||||
}
|
||||
|
||||
pub(crate) fn read_singbox_status_with_detection(
|
||||
storage: &JsonStorage,
|
||||
detected: Option<&DetectedSingBox>,
|
||||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||
let config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.map_err(storage_error)?;
|
||||
let component = singbox_component_from_detection(detected);
|
||||
|
||||
Ok(LocalSingBoxStatusResponse {
|
||||
config: LocalSingBoxConfigDto::from(&config),
|
||||
cache: cache.as_ref().map(SubscriptionCacheDto::from),
|
||||
component: ComponentStatusDto::from(&component),
|
||||
generated_config_path: storage
|
||||
.paths()
|
||||
.generated_dir
|
||||
.join("sing-box-config.json")
|
||||
.display()
|
||||
.to_string(),
|
||||
lan_listen_host: local_lan_ipv4(),
|
||||
#[cfg(debug_assertions)]
|
||||
subscription_identity: subscription_request_identity_for_display(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_singbox_subscription_to_storage(
|
||||
storage: &JsonStorage,
|
||||
input: SaveSingBoxSubscriptionInputDto,
|
||||
clock: &impl Clock,
|
||||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||
let subscription_url = input.subscription_url.trim().to_string();
|
||||
validate_subscription_url(&subscription_url)?;
|
||||
|
||||
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||
config.subscription_url = Some(subscription_url);
|
||||
ensure_device_hwid(&mut config);
|
||||
config.updated_at = Some(clock.now());
|
||||
storage
|
||||
.write_local_singbox_config(&config)
|
||||
.map_err(storage_error)?;
|
||||
|
||||
read_singbox_status(storage)
|
||||
}
|
||||
|
||||
pub fn fetch_singbox_subscription_with_fetcher(
|
||||
storage: &JsonStorage,
|
||||
fetcher: &impl SubscriptionFetcher,
|
||||
clock: &impl Clock,
|
||||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||
let subscription_url = config
|
||||
.subscription_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| {
|
||||
CommandError::new(
|
||||
"singbox_subscription_missing",
|
||||
"Ссылка на подписку Local sing-box не сохранена.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let device_hwid_created = ensure_device_hwid(&mut config);
|
||||
if device_hwid_created {
|
||||
config.updated_at = Some(clock.now());
|
||||
storage
|
||||
.write_local_singbox_config(&config)
|
||||
.map_err(storage_error)?;
|
||||
}
|
||||
|
||||
let identity =
|
||||
subscription::SubscriptionFetchIdentity::with_device_hwid(config.device_hwid.as_deref());
|
||||
let cache = fetcher
|
||||
.fetch_subscription(&subscription_url, &identity)
|
||||
.map_err(|error| CommandError::new("singbox_subscription_fetch_failed", error.message))?;
|
||||
let selected_server = config
|
||||
.selected_server_id
|
||||
.as_deref()
|
||||
.and_then(|id| cache.servers.iter().find(|server| server.id == id))
|
||||
.or_else(|| {
|
||||
let tag = config.selected_server_tag.as_deref()?;
|
||||
cache.servers.iter().find(|server| server.tag == tag)
|
||||
})
|
||||
.or_else(|| cache.servers.first());
|
||||
|
||||
config.selected_server_id = selected_server.map(|server| server.id.clone());
|
||||
config.selected_server_tag = selected_server.map(|server| server.tag.clone());
|
||||
config.updated_at = Some(clock.now());
|
||||
storage
|
||||
.write_singbox_subscription_cache(&cache)
|
||||
.map_err(storage_error)?;
|
||||
storage
|
||||
.write_local_singbox_config(&config)
|
||||
.map_err(storage_error)?;
|
||||
storage
|
||||
.append_activity(ActivityEntry {
|
||||
id: "singbox-subscription-fetched".to_string(),
|
||||
at: clock.now(),
|
||||
level: ActivityLevel::Success,
|
||||
title: "Подписка Local sing-box обновлена".to_string(),
|
||||
message: format!("Серверов найдено: {}", cache.servers.len()),
|
||||
})
|
||||
.map_err(storage_error)?;
|
||||
|
||||
read_singbox_status(storage)
|
||||
}
|
||||
|
||||
pub fn forget_singbox_subscription_in_storage(
|
||||
storage: &JsonStorage,
|
||||
clock: &impl Clock,
|
||||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||
config.subscription_url = None;
|
||||
config.selected_server_tag = None;
|
||||
config.selected_server_id = None;
|
||||
config.updated_at = Some(clock.now());
|
||||
storage
|
||||
.write_local_singbox_config(&config)
|
||||
.map_err(storage_error)?;
|
||||
storage
|
||||
.remove_singbox_subscription_cache()
|
||||
.map_err(storage_error)?;
|
||||
|
||||
read_singbox_status(storage)
|
||||
}
|
||||
|
||||
pub fn select_singbox_server_in_storage(
|
||||
storage: &JsonStorage,
|
||||
input: SelectSingBoxServerInputDto,
|
||||
clock: &impl Clock,
|
||||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||
let requested_tag = input.tag.trim().to_string();
|
||||
let requested_id = input
|
||||
.id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty());
|
||||
if requested_tag.is_empty() {
|
||||
return Err(CommandError::new(
|
||||
"singbox_server_tag_missing",
|
||||
"Сервер Local sing-box не выбран.",
|
||||
));
|
||||
}
|
||||
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.map_err(storage_error)?
|
||||
.ok_or_else(|| {
|
||||
CommandError::new(
|
||||
"singbox_subscription_cache_missing",
|
||||
"Сначала нужно загрузить подписку Local sing-box.",
|
||||
)
|
||||
})?;
|
||||
let Some(server) = find_subscription_server(
|
||||
&cache,
|
||||
requested_id,
|
||||
&requested_tag,
|
||||
input.server.as_deref(),
|
||||
input.server_port,
|
||||
) else {
|
||||
return Err(CommandError::new(
|
||||
"singbox_server_not_found",
|
||||
format!("Сервер Local sing-box '{requested_tag}' не найден в текущей подписке."),
|
||||
));
|
||||
};
|
||||
let selected_tag = server.tag.clone();
|
||||
let selected_id = server.id.clone();
|
||||
|
||||
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||
config.selected_server_tag = Some(selected_tag);
|
||||
config.selected_server_id = Some(selected_id);
|
||||
config.updated_at = Some(clock.now());
|
||||
storage
|
||||
.write_local_singbox_config(&config)
|
||||
.map_err(storage_error)?;
|
||||
|
||||
read_singbox_status(storage)
|
||||
}
|
||||
|
||||
pub fn ping_singbox_server_in_storage(
|
||||
storage: &JsonStorage,
|
||||
input: PingSingBoxServerInputDto,
|
||||
) -> Result<PingServerResponse, CommandError> {
|
||||
let tag = input.tag.trim();
|
||||
let id = input
|
||||
.id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty());
|
||||
let cache = read_required_singbox_cache(storage)?;
|
||||
let server = find_subscription_server(&cache, id, tag, None, None).ok_or_else(|| {
|
||||
CommandError::new(
|
||||
"singbox_server_not_found",
|
||||
format!("Сервер Local sing-box '{tag}' не найден в текущей подписке."),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(ping_subscription_server(server))
|
||||
}
|
||||
|
||||
pub fn ping_all_singbox_servers_in_storage(
|
||||
storage: &JsonStorage,
|
||||
) -> Result<Vec<PingServerResponse>, CommandError> {
|
||||
let cache = read_required_singbox_cache(storage)?;
|
||||
Ok(cache.servers.iter().map(ping_subscription_server).collect())
|
||||
}
|
||||
|
||||
pub(crate) fn read_required_singbox_cache(
|
||||
storage: &JsonStorage,
|
||||
) -> Result<SubscriptionCache, CommandError> {
|
||||
storage
|
||||
.read_singbox_subscription_cache()
|
||||
.map_err(storage_error)?
|
||||
.ok_or_else(|| {
|
||||
CommandError::new(
|
||||
"singbox_subscription_cache_missing",
|
||||
"Сначала нужно загрузить подписку Local sing-box.",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_subscription_url(subscription_url: &str) -> Result<(), CommandError> {
|
||||
if subscription_url.is_empty() {
|
||||
return Err(CommandError::new(
|
||||
"singbox_subscription_url_missing",
|
||||
"Ссылка на подписку Local sing-box не указана.",
|
||||
));
|
||||
}
|
||||
|
||||
let parsed = url::Url::parse(subscription_url).map_err(|_| {
|
||||
CommandError::new(
|
||||
"singbox_subscription_url_invalid",
|
||||
"Ссылка на подписку Local sing-box должна быть корректным URL.",
|
||||
)
|
||||
})?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return Err(CommandError::new(
|
||||
"singbox_subscription_url_invalid",
|
||||
"Ссылка на подписку Local sing-box должна начинаться с http:// или https://.",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_device_hwid(config: &mut LocalSingBoxConfig) -> bool {
|
||||
if config
|
||||
.device_hwid
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
config.device_hwid = Some(uuid::Uuid::new_v4().hyphenated().to_string().to_uppercase());
|
||||
true
|
||||
}
|
||||
|
||||
fn ping_subscription_server(server: &SubscriptionServer) -> PingServerResponse {
|
||||
ping_endpoint(&server.id, &server.tag, &server.server, server.server_port)
|
||||
}
|
||||
|
||||
fn local_lan_ipv4() -> Option<String> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
|
||||
socket.connect("8.8.8.8:80").ok()?;
|
||||
let IpAddr::V4(address) = socket.local_addr().ok()?.ip() else {
|
||||
return None;
|
||||
};
|
||||
if address.is_loopback() || address.is_link_local() || address.is_unspecified() {
|
||||
return None;
|
||||
}
|
||||
Some(address.to_string())
|
||||
}
|
||||
|
||||
fn find_subscription_server<'a>(
|
||||
cache: &'a SubscriptionCache,
|
||||
requested_id: Option<&str>,
|
||||
requested_tag: &str,
|
||||
requested_server: Option<&str>,
|
||||
requested_port: Option<u16>,
|
||||
) -> Option<&'a SubscriptionServer> {
|
||||
requested_id
|
||||
.and_then(|id| cache.servers.iter().find(|server| server.id == id))
|
||||
.or_else(|| {
|
||||
cache
|
||||
.servers
|
||||
.iter()
|
||||
.find(|server| server.tag == requested_tag)
|
||||
})
|
||||
.or_else(|| {
|
||||
let requested = comparable_server_tag(requested_tag);
|
||||
cache
|
||||
.servers
|
||||
.iter()
|
||||
.find(|server| comparable_server_tag(&server.tag) == requested)
|
||||
})
|
||||
.or_else(|| {
|
||||
let server_name = requested_server?.trim();
|
||||
let server_port = requested_port?;
|
||||
cache.servers.iter().find(|server| {
|
||||
server.server.eq_ignore_ascii_case(server_name) && server.server_port == server_port
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn comparable_server_tag(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|ch| !matches!(ch, '\u{fe0e}' | '\u{fe0f}' | '\u{200d}'))
|
||||
.collect::<String>()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn storage_error(error: std::io::Error) -> CommandError {
|
||||
CommandError::new("storage_error", error.to_string())
|
||||
}
|
||||
+77
-35
@@ -2,6 +2,7 @@ use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT};
|
||||
use crate::models::{
|
||||
ActivityEntry, ComponentStatus, LocalSingBoxConfig, Profile, SubscriptionCache, Target,
|
||||
};
|
||||
use crate::safe_fs;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use std::fs;
|
||||
use std::io::{self, ErrorKind};
|
||||
@@ -96,7 +97,10 @@ impl JsonStorage {
|
||||
}
|
||||
|
||||
pub fn read_local_singbox_config(&self) -> io::Result<LocalSingBoxConfig> {
|
||||
self.read_json_or_default(&self.paths.local_singbox_file)
|
||||
let mut config: LocalSingBoxConfig =
|
||||
self.read_json_or_default(&self.paths.local_singbox_file)?;
|
||||
config.normalize_percent_encoded_tags();
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn write_local_singbox_config(&self, config: &LocalSingBoxConfig) -> io::Result<()> {
|
||||
@@ -104,7 +108,12 @@ impl JsonStorage {
|
||||
}
|
||||
|
||||
pub fn read_singbox_subscription_cache(&self) -> io::Result<Option<SubscriptionCache>> {
|
||||
self.read_optional_json(&self.paths.singbox_subscription_cache_file)
|
||||
let mut cache = self
|
||||
.read_optional_json::<SubscriptionCache>(&self.paths.singbox_subscription_cache_file)?;
|
||||
if let Some(cache) = cache.as_mut() {
|
||||
cache.normalize_percent_encoded_tags();
|
||||
}
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
pub fn write_singbox_subscription_cache(&self, cache: &SubscriptionCache) -> io::Result<()> {
|
||||
@@ -136,10 +145,9 @@ impl JsonStorage {
|
||||
T: DeserializeOwned + Default,
|
||||
{
|
||||
match fs::read_to_string(path) {
|
||||
Ok(contents) => match serde_json::from_str(&contents) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(_) => Ok(T::default()),
|
||||
},
|
||||
Ok(contents) => {
|
||||
parse_json(path, &contents).or_else(|error| recover_corrupt_json(path, error))
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
@@ -159,7 +167,9 @@ impl JsonStorage {
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
match fs::read_to_string(path) {
|
||||
Ok(contents) => Ok(serde_json::from_str(&contents).ok()),
|
||||
Ok(contents) => parse_json(path, &contents)
|
||||
.map(Some)
|
||||
.or_else(|error| recover_corrupt_json(path, error).map(Some)),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
@@ -173,40 +183,72 @@ impl Default for JsonStorage {
|
||||
}
|
||||
|
||||
pub fn backup_path(path: &Path) -> PathBuf {
|
||||
sibling_with_suffix(path, "bak")
|
||||
}
|
||||
|
||||
fn temp_path(path: &Path) -> PathBuf {
|
||||
sibling_with_suffix(path, "tmp")
|
||||
}
|
||||
|
||||
fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("storage.json");
|
||||
|
||||
path.with_file_name(format!("{file_name}.{suffix}"))
|
||||
safe_fs::backup_path(path)
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
safe_fs::write_with_backup(path, contents)
|
||||
}
|
||||
|
||||
fn parse_json<T>(path: &Path, contents: &str) -> io::Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
serde_json::from_str(contents).map_err(|error| {
|
||||
io::Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
format!("Invalid JSON in '{}': {error}", path.display()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn recover_corrupt_json<T>(path: &Path, parse_error: io::Error) -> io::Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
let corrupt_path = safe_fs::corrupt_path(path);
|
||||
move_corrupt_file(path, &corrupt_path)?;
|
||||
|
||||
let backup_path = backup_path(path);
|
||||
if backup_path.exists() {
|
||||
let backup_contents = fs::read_to_string(&backup_path)?;
|
||||
match parse_json(&backup_path, &backup_contents) {
|
||||
Ok(value) => {
|
||||
fs::copy(&backup_path, path)?;
|
||||
Ok(value)
|
||||
}
|
||||
Err(backup_error) => Err(io::Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Invalid JSON in '{}'; corrupt file moved to '{}'; backup '{}' could not be restored: {backup_error}; original error: {parse_error}",
|
||||
path.display(),
|
||||
corrupt_path.display(),
|
||||
backup_path.display()
|
||||
),
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
Err(io::Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Invalid JSON in '{}'; corrupt file moved to '{}'; no valid backup available: {parse_error}",
|
||||
path.display(),
|
||||
corrupt_path.display()
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let temp_path = temp_path(path);
|
||||
fs::write(&temp_path, contents)?;
|
||||
|
||||
if path.exists() {
|
||||
fs::copy(path, backup_path(path))?;
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
|
||||
match fs::rename(&temp_path, path) {
|
||||
fn move_corrupt_file(path: &Path, corrupt_path: &Path) -> io::Result<()> {
|
||||
match fs::rename(path, corrupt_path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => {
|
||||
let _ = fs::remove_file(&temp_path);
|
||||
Err(error)
|
||||
Err(rename_error) => {
|
||||
fs::copy(path, corrupt_path)?;
|
||||
fs::remove_file(path)?;
|
||||
if !corrupt_path.exists() {
|
||||
return Err(rename_error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+529
-36
@@ -1,10 +1,16 @@
|
||||
use crate::models::{SubscriptionCache, SubscriptionServer};
|
||||
use crate::models::{decode_percent_encoded_utf8, SubscriptionCache, SubscriptionServer};
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::net::{IpAddr, Ipv6Addr, SocketAddr, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use url::Url;
|
||||
|
||||
const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"];
|
||||
const DEFAULT_APP_NAME: &str = "ProxyWarden";
|
||||
const SUBSCRIPTION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const SUBSCRIPTION_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const SUBSCRIPTION_MAX_REDIRECTS: usize = 5;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubscriptionError {
|
||||
@@ -33,6 +39,72 @@ pub struct ParsedSubscription {
|
||||
pub servers: Vec<SubscriptionServer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct SubscriptionFetchPolicy {
|
||||
pub allow_unsafe_local_urls: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubscriptionFetchIdentity {
|
||||
pub device_hwid: Option<String>,
|
||||
pub app_name: String,
|
||||
pub user_agent: String,
|
||||
pub device_os: String,
|
||||
pub device_os_version: Option<String>,
|
||||
pub device_model: String,
|
||||
}
|
||||
|
||||
impl SubscriptionFetchIdentity {
|
||||
pub fn with_device_hwid(device_hwid: Option<&str>) -> Self {
|
||||
Self {
|
||||
device_hwid: device_hwid
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_headers_without_device_hwid(&self) -> Vec<(&'static str, String)> {
|
||||
let mut headers = vec![
|
||||
("User-Agent", self.user_agent.clone()),
|
||||
("X-App-Name", self.app_name.clone()),
|
||||
("X-Device-OS", self.device_os.clone()),
|
||||
("X-Device-Model", self.device_model.clone()),
|
||||
];
|
||||
|
||||
if let Some(device_os_version) = self
|
||||
.device_os_version
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let header_value = sanitize_header_value(device_os_version);
|
||||
if !header_value.is_empty() {
|
||||
headers.push(("X-Device-OS-Version", header_value.clone()));
|
||||
headers.push(("X-Ver-OS", header_value));
|
||||
}
|
||||
}
|
||||
|
||||
headers
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SubscriptionFetchIdentity {
|
||||
fn default() -> Self {
|
||||
let device_os = std::env::consts::OS.to_string();
|
||||
|
||||
Self {
|
||||
device_hwid: None,
|
||||
app_name: DEFAULT_APP_NAME.to_string(),
|
||||
user_agent: format!("{DEFAULT_APP_NAME}/{device_os}"),
|
||||
device_os,
|
||||
device_os_version: detect_device_os_version(),
|
||||
device_model: DEFAULT_APP_NAME.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_subscription_body(body: &str) -> Result<ParsedSubscription, SubscriptionError> {
|
||||
let config = match serde_json::from_str::<Value>(body) {
|
||||
Ok(value) => value,
|
||||
@@ -66,47 +138,207 @@ pub fn parse_user_info(header_value: Option<&str>) -> Map<String, Value> {
|
||||
}
|
||||
|
||||
pub fn fetch_subscription(url: &str) -> Result<SubscriptionCache, SubscriptionError> {
|
||||
fetch_subscription_with_identity(url, &SubscriptionFetchIdentity::default())
|
||||
}
|
||||
|
||||
pub fn fetch_subscription_with_identity(
|
||||
url: &str,
|
||||
identity: &SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, SubscriptionError> {
|
||||
fetch_subscription_with_identity_and_policy(url, identity, SubscriptionFetchPolicy::default())
|
||||
}
|
||||
|
||||
pub fn fetch_subscription_with_identity_and_policy(
|
||||
url: &str,
|
||||
identity: &SubscriptionFetchIdentity,
|
||||
policy: SubscriptionFetchPolicy,
|
||||
) -> Result<SubscriptionCache, SubscriptionError> {
|
||||
let parsed_url =
|
||||
Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?;
|
||||
let mut current_url = parsed_url;
|
||||
|
||||
for redirect_count in 0..=SUBSCRIPTION_MAX_REDIRECTS {
|
||||
validate_subscription_fetch_url(¤t_url, policy)?;
|
||||
let client = subscription_client_for_url(¤t_url, policy)?;
|
||||
let mut request = client.get(current_url.clone());
|
||||
|
||||
for (name, value) in identity.request_headers_without_device_hwid() {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
if let Some(device_hwid) = identity
|
||||
.device_hwid
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
request = request.header("x-hwid", device_hwid);
|
||||
}
|
||||
|
||||
let response = request.send().map_err(|error| {
|
||||
SubscriptionError::new(format!("Subscription request failed: {error}"))
|
||||
})?;
|
||||
let status = response.status();
|
||||
if status.is_redirection() {
|
||||
if redirect_count == SUBSCRIPTION_MAX_REDIRECTS {
|
||||
return Err(SubscriptionError::new(
|
||||
"Subscription request exceeded redirect limit",
|
||||
));
|
||||
}
|
||||
let location = response
|
||||
.headers()
|
||||
.get(reqwest::header::LOCATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
SubscriptionError::new("Subscription redirect has no valid Location header")
|
||||
})?;
|
||||
current_url = current_url
|
||||
.join(location)
|
||||
.map_err(|_| SubscriptionError::new("Subscription redirect URL is invalid"))?;
|
||||
continue;
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(SubscriptionError::new(format!(
|
||||
"Subscription request failed: HTTP {}",
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
|
||||
let user_info = parse_user_info(
|
||||
response
|
||||
.headers()
|
||||
.get("subscription-userinfo")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
);
|
||||
let body = response.text().map_err(|error| {
|
||||
SubscriptionError::new(format!("Subscription body read failed: {error}"))
|
||||
})?;
|
||||
let parsed = parse_subscription_body(&body)?;
|
||||
|
||||
return Ok(SubscriptionCache {
|
||||
config: parsed.config,
|
||||
servers: parsed.servers,
|
||||
user_info,
|
||||
fetched_at: now_timestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
Err(SubscriptionError::new(
|
||||
"Subscription request could not complete",
|
||||
))
|
||||
}
|
||||
|
||||
fn subscription_client_for_url(
|
||||
parsed_url: &Url,
|
||||
policy: SubscriptionFetchPolicy,
|
||||
) -> Result<reqwest::blocking::Client, SubscriptionError> {
|
||||
let mut builder = reqwest::blocking::Client::builder()
|
||||
.connect_timeout(SUBSCRIPTION_CONNECT_TIMEOUT)
|
||||
.timeout(SUBSCRIPTION_REQUEST_TIMEOUT)
|
||||
.redirect(reqwest::redirect::Policy::none());
|
||||
|
||||
if !policy.allow_unsafe_local_urls {
|
||||
let host = parsed_url
|
||||
.host_str()
|
||||
.ok_or_else(|| SubscriptionError::new("Subscription URL has no host"))?;
|
||||
if host.parse::<IpAddr>().is_err() {
|
||||
let port = parsed_url
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| SubscriptionError::new("Subscription URL has no resolvable port"))?;
|
||||
let addresses = (host, port)
|
||||
.to_socket_addrs()
|
||||
.map_err(|error| {
|
||||
SubscriptionError::new(format!(
|
||||
"Subscription host DNS resolution failed: {error}"
|
||||
))
|
||||
})?
|
||||
.collect::<Vec<_>>();
|
||||
validate_resolved_subscription_addresses(&addresses)?;
|
||||
builder = builder.resolve_to_addrs(host, &addresses);
|
||||
}
|
||||
}
|
||||
|
||||
builder.build().map_err(|error| {
|
||||
SubscriptionError::new(format!("Subscription client setup failed: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_resolved_subscription_addresses(
|
||||
addresses: &[SocketAddr],
|
||||
) -> Result<(), SubscriptionError> {
|
||||
if addresses.is_empty() {
|
||||
return Err(SubscriptionError::new(
|
||||
"Subscription host DNS resolution returned no addresses",
|
||||
));
|
||||
}
|
||||
if addresses.iter().any(|address| is_unsafe_ip(address.ip())) {
|
||||
return Err(SubscriptionError::new(
|
||||
"Subscription host resolves to a local, private, link-local, multicast, or metadata address",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_subscription_fetch_url(
|
||||
parsed_url: &Url,
|
||||
policy: SubscriptionFetchPolicy,
|
||||
) -> Result<(), SubscriptionError> {
|
||||
if !matches!(parsed_url.scheme(), "http" | "https") {
|
||||
return Err(SubscriptionError::new(
|
||||
"Subscription URL must use http or https",
|
||||
));
|
||||
}
|
||||
|
||||
let response = reqwest::blocking::Client::new()
|
||||
.get(parsed_url)
|
||||
.header("user-agent", "singbox")
|
||||
.header("x-device-os", std::env::consts::OS)
|
||||
.header("x-device-model", "proxywarden")
|
||||
.send()
|
||||
.map_err(|error| SubscriptionError::new(format!("Subscription request failed: {error}")))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(SubscriptionError::new(format!(
|
||||
"Subscription request failed: HTTP {}",
|
||||
status.as_u16()
|
||||
)));
|
||||
if !policy.allow_unsafe_local_urls && is_unsafe_subscription_host(parsed_url) {
|
||||
return Err(SubscriptionError::new(
|
||||
"Subscription URL host is local, private, link-local, multicast, or metadata-only",
|
||||
));
|
||||
}
|
||||
|
||||
let user_info = parse_user_info(
|
||||
response
|
||||
.headers()
|
||||
.get("subscription-userinfo")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
);
|
||||
let body = response.text().map_err(|error| {
|
||||
SubscriptionError::new(format!("Subscription body read failed: {error}"))
|
||||
})?;
|
||||
let parsed = parse_subscription_body(&body)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Ok(SubscriptionCache {
|
||||
config: parsed.config,
|
||||
servers: parsed.servers,
|
||||
user_info,
|
||||
fetched_at: now_timestamp(),
|
||||
})
|
||||
fn is_unsafe_subscription_host(parsed_url: &Url) -> bool {
|
||||
let Some(host) = parsed_url.host_str() else {
|
||||
return true;
|
||||
};
|
||||
let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
|
||||
|
||||
if matches!(host.as_str(), "localhost" | "metadata.google.internal")
|
||||
|| host.ends_with(".localhost")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
host.parse::<IpAddr>().is_ok_and(is_unsafe_ip)
|
||||
}
|
||||
|
||||
fn is_unsafe_ip(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(ip) => {
|
||||
ip.is_loopback()
|
||||
|| ip.is_private()
|
||||
|| ip.is_link_local()
|
||||
|| ip.is_multicast()
|
||||
|| ip.is_broadcast()
|
||||
|| ip.is_unspecified()
|
||||
|| ip.octets() == [169, 254, 169, 254]
|
||||
}
|
||||
IpAddr::V6(ip) => {
|
||||
ip.is_loopback()
|
||||
|| ip.is_unspecified()
|
||||
|| ip.is_multicast()
|
||||
|| is_unique_local_ipv6(ip)
|
||||
|| is_unicast_link_local_ipv6(ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_unique_local_ipv6(ip: Ipv6Addr) -> bool {
|
||||
(ip.segments()[0] & 0xfe00) == 0xfc00
|
||||
}
|
||||
|
||||
fn is_unicast_link_local_ipv6(ip: Ipv6Addr) -> bool {
|
||||
(ip.segments()[0] & 0xffc0) == 0xfe80
|
||||
}
|
||||
|
||||
fn parse_link_subscription(body: &str) -> Result<Value, SubscriptionError> {
|
||||
@@ -114,30 +346,181 @@ fn parse_link_subscription(body: &str) -> Result<Value, SubscriptionError> {
|
||||
let links = decoded
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| line.starts_with("vless://"))
|
||||
.filter(|line| {
|
||||
["vless://", "trojan://", "ss://", "vmess://"]
|
||||
.iter()
|
||||
.any(|scheme| line.starts_with(scheme))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if links.is_empty() {
|
||||
return Err(SubscriptionError::new(
|
||||
"Subscription does not contain JSON config or VLESS links",
|
||||
"Subscription does not contain JSON config or supported VLESS, VMess, Trojan, or Shadowsocks links",
|
||||
));
|
||||
}
|
||||
|
||||
let outbounds = links
|
||||
.into_iter()
|
||||
.map(parse_vless_url)
|
||||
.map(|link| {
|
||||
if link.starts_with("vless://") {
|
||||
parse_vless_url(link)
|
||||
} else if link.starts_with("trojan://") {
|
||||
parse_trojan_url(link)
|
||||
} else if link.starts_with("ss://") {
|
||||
parse_shadowsocks_url(link)
|
||||
} else {
|
||||
parse_vmess_url(link)
|
||||
}
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(json!({ "outbounds": outbounds }))
|
||||
}
|
||||
|
||||
fn parse_trojan_url(raw_url: &str) -> Result<Value, SubscriptionError> {
|
||||
let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid Trojan URL"))?;
|
||||
let password = parsed.username().trim().to_string();
|
||||
let server = parsed.host_str().map(str::to_string).unwrap_or_default();
|
||||
let server_port = parsed.port_or_known_default().unwrap_or(443);
|
||||
if password.is_empty() || server.is_empty() {
|
||||
return Err(SubscriptionError::new(
|
||||
"Trojan URL misses password, host or port",
|
||||
));
|
||||
}
|
||||
let tag = parsed
|
||||
.fragment()
|
||||
.map(decode_percent_encoded_utf8)
|
||||
.unwrap_or_else(|| "trojan-out".to_string());
|
||||
let server_name = query_value(&parsed, "sni").unwrap_or_else(|| server.clone());
|
||||
|
||||
Ok(json!({
|
||||
"type": "trojan",
|
||||
"tag": tag,
|
||||
"server": server,
|
||||
"server_port": server_port,
|
||||
"password": password,
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_name": server_name
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_shadowsocks_url(raw_url: &str) -> Result<Value, SubscriptionError> {
|
||||
let parsed =
|
||||
Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid Shadowsocks URL"))?;
|
||||
let server = parsed.host_str().map(str::to_string).unwrap_or_default();
|
||||
let server_port = parsed.port().unwrap_or(8388);
|
||||
let credentials = match parsed.password() {
|
||||
Some(password) => format!("{}:{password}", parsed.username()),
|
||||
None => decode_base64_text(parsed.username()).ok_or_else(|| {
|
||||
SubscriptionError::new("Shadowsocks credentials are not valid base64")
|
||||
})?,
|
||||
};
|
||||
let (method, password) = credentials
|
||||
.split_once(':')
|
||||
.ok_or_else(|| SubscriptionError::new("Shadowsocks URL misses method or password"))?;
|
||||
if method.trim().is_empty() || password.is_empty() || server.is_empty() {
|
||||
return Err(SubscriptionError::new(
|
||||
"Shadowsocks URL misses method, password, host or port",
|
||||
));
|
||||
}
|
||||
let tag = parsed
|
||||
.fragment()
|
||||
.map(decode_percent_encoded_utf8)
|
||||
.unwrap_or_else(|| "shadowsocks-out".to_string());
|
||||
|
||||
Ok(json!({
|
||||
"type": "shadowsocks",
|
||||
"tag": tag,
|
||||
"server": server,
|
||||
"server_port": server_port,
|
||||
"method": method,
|
||||
"password": password
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_vmess_url(raw_url: &str) -> Result<Value, SubscriptionError> {
|
||||
let payload = raw_url
|
||||
.strip_prefix("vmess://")
|
||||
.and_then(|value| value.split('#').next())
|
||||
.ok_or_else(|| SubscriptionError::new("Invalid VMess URL"))?;
|
||||
let decoded = decode_base64_text(payload)
|
||||
.ok_or_else(|| SubscriptionError::new("VMess payload is not valid base64"))?;
|
||||
let source: Value = serde_json::from_str(&decoded)
|
||||
.map_err(|_| SubscriptionError::new("VMess payload is not valid JSON"))?;
|
||||
let server = source
|
||||
.get("add")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let server_port = source
|
||||
.get("port")
|
||||
.and_then(|value| value.as_u64().or_else(|| value.as_str()?.parse().ok()))
|
||||
.and_then(|value| u16::try_from(value).ok())
|
||||
.unwrap_or(443);
|
||||
let uuid = source.get("id").and_then(Value::as_str).unwrap_or_default();
|
||||
if server.is_empty() || uuid.is_empty() {
|
||||
return Err(SubscriptionError::new(
|
||||
"VMess payload misses host, port or uuid",
|
||||
));
|
||||
}
|
||||
let tag = source
|
||||
.get("ps")
|
||||
.and_then(Value::as_str)
|
||||
.map(decode_percent_encoded_utf8)
|
||||
.unwrap_or_else(|| "vmess-out".to_string());
|
||||
let security = source
|
||||
.get("scy")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("auto");
|
||||
let mut outbound = json!({
|
||||
"type": "vmess",
|
||||
"tag": tag,
|
||||
"server": server,
|
||||
"server_port": server_port,
|
||||
"uuid": uuid,
|
||||
"security": security
|
||||
});
|
||||
if source.get("tls").and_then(Value::as_str) == Some("tls") {
|
||||
let server_name = source
|
||||
.get("sni")
|
||||
.or_else(|| source.get("host"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(server);
|
||||
outbound["tls"] = json!({ "enabled": true, "server_name": server_name });
|
||||
}
|
||||
if source.get("net").and_then(Value::as_str) == Some("ws") {
|
||||
let path = source
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("/");
|
||||
let host = source
|
||||
.get("host")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty());
|
||||
outbound["transport"] = json!({
|
||||
"type": "ws",
|
||||
"path": path,
|
||||
"headers": host.map(|host| json!({ "Host": host })).unwrap_or_else(|| json!({}))
|
||||
});
|
||||
}
|
||||
|
||||
Ok(outbound)
|
||||
}
|
||||
|
||||
fn parse_vless_url(raw_url: &str) -> Result<Value, SubscriptionError> {
|
||||
if !raw_url.starts_with("vless://") {
|
||||
return Err(SubscriptionError::new("VLESS URL must start with vless://"));
|
||||
}
|
||||
|
||||
let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid VLESS URL"))?;
|
||||
let tag = parsed.fragment().unwrap_or("vless-out").to_string();
|
||||
let tag = parsed
|
||||
.fragment()
|
||||
.map(decode_percent_encoded_utf8)
|
||||
.unwrap_or_else(|| "vless-out".to_string());
|
||||
let uuid = parsed.username().trim().to_string();
|
||||
let server = parsed.host_str().map(str::to_string).unwrap_or_default();
|
||||
let server_port = parsed.port_or_known_default().unwrap_or(443);
|
||||
@@ -224,6 +607,7 @@ fn server_from_outbound(outbound: &Value) -> Option<SubscriptionServer> {
|
||||
.unwrap_or_else(|| format!("{server_type}-{server}"));
|
||||
|
||||
Some(SubscriptionServer {
|
||||
id: outbound_server_id(outbound),
|
||||
tag,
|
||||
server_type,
|
||||
server,
|
||||
@@ -231,6 +615,14 @@ fn server_from_outbound(outbound: &Value) -> Option<SubscriptionServer> {
|
||||
})
|
||||
}
|
||||
|
||||
fn outbound_server_id(outbound: &Value) -> String {
|
||||
let bytes = serde_json::to_vec(outbound).unwrap_or_default();
|
||||
let hash = bytes.iter().fold(0xcbf29ce484222325_u64, |hash, byte| {
|
||||
(hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
|
||||
});
|
||||
format!("pw-{hash:016x}")
|
||||
}
|
||||
|
||||
fn maybe_decode_base64(content: &str) -> String {
|
||||
let compact = content.split_whitespace().collect::<String>();
|
||||
if compact.is_empty()
|
||||
@@ -244,7 +636,11 @@ fn maybe_decode_base64(content: &str) -> String {
|
||||
for engine in [general_purpose::STANDARD, general_purpose::URL_SAFE] {
|
||||
if let Ok(decoded) = engine.decode(compact.as_bytes()) {
|
||||
if let Ok(decoded) = String::from_utf8(decoded) {
|
||||
if decoded.contains("vless://") || decoded.contains('{') {
|
||||
if ["vless://", "vmess://", "trojan://", "ss://"]
|
||||
.iter()
|
||||
.any(|scheme| decoded.contains(scheme))
|
||||
|| decoded.contains('{')
|
||||
{
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
@@ -254,12 +650,109 @@ fn maybe_decode_base64(content: &str) -> String {
|
||||
content.to_string()
|
||||
}
|
||||
|
||||
fn decode_base64_text(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
for engine in [
|
||||
general_purpose::STANDARD,
|
||||
general_purpose::STANDARD_NO_PAD,
|
||||
general_purpose::URL_SAFE,
|
||||
general_purpose::URL_SAFE_NO_PAD,
|
||||
] {
|
||||
if let Ok(decoded) = engine.decode(value.as_bytes()) {
|
||||
if let Ok(decoded) = String::from_utf8(decoded) {
|
||||
return Some(decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn query_value(url: &Url, key: &str) -> Option<String> {
|
||||
url.query_pairs()
|
||||
.find(|(name, _)| name == key)
|
||||
.map(|(_, value)| value.into_owned())
|
||||
}
|
||||
|
||||
fn sanitize_header_value(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_graphic() || *ch == ' ')
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
fn detect_device_os_version() -> Option<String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
windows_device_os_version()
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_device_os_version() -> Option<String> {
|
||||
use winreg::{enums::HKEY_LOCAL_MACHINE, RegKey};
|
||||
|
||||
let current_version = RegKey::predef(HKEY_LOCAL_MACHINE)
|
||||
.open_subkey("SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion")
|
||||
.ok()?;
|
||||
|
||||
let product_name = current_version
|
||||
.get_value::<String, _>("ProductName")
|
||||
.ok()
|
||||
.map(|value| normalize_windows_product_name(&value, ¤t_version))
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
let display_version = current_version
|
||||
.get_value::<String, _>("DisplayVersion")
|
||||
.ok()
|
||||
.or_else(|| current_version.get_value::<String, _>("ReleaseId").ok())
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
let build = current_version
|
||||
.get_value::<String, _>("CurrentBuildNumber")
|
||||
.ok()
|
||||
.or_else(|| current_version.get_value::<String, _>("CurrentBuild").ok())
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
let ubr = current_version.get_value::<u32, _>("UBR").ok();
|
||||
let build = match (build, ubr) {
|
||||
(Some(build), Some(ubr)) => Some(format!("{build}.{ubr}")),
|
||||
(build, _) => build,
|
||||
};
|
||||
|
||||
let mut parts = Vec::new();
|
||||
if let Some(product_name) = product_name {
|
||||
parts.push(product_name);
|
||||
}
|
||||
if let Some(display_version) = display_version {
|
||||
parts.push(display_version);
|
||||
}
|
||||
if let Some(build) = build {
|
||||
parts.push(format!("build {build}"));
|
||||
}
|
||||
|
||||
let version = parts.join(" | ");
|
||||
(!version.is_empty()).then_some(version)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn normalize_windows_product_name(value: &str, current_version: &winreg::RegKey) -> String {
|
||||
let trimmed = value.trim();
|
||||
let build_number = current_version
|
||||
.get_value::<String, _>("CurrentBuildNumber")
|
||||
.ok()
|
||||
.or_else(|| current_version.get_value::<String, _>("CurrentBuild").ok())
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if build_number >= 22000 && trimmed.starts_with("Windows 10") {
|
||||
return trimmed.replacen("Windows 10", "Windows 11", 1);
|
||||
}
|
||||
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
fn now_timestamp() -> String {
|
||||
let seconds = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
||||
@@ -25,11 +25,18 @@ fn clean(value: &str) -> String {
|
||||
fn slug(value: &str, fallback: &str) -> String {
|
||||
let mut output = String::new();
|
||||
let mut previous_dash = false;
|
||||
let mut has_non_ascii = false;
|
||||
|
||||
for ch in value.trim().to_lowercase().chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
output.push(ch);
|
||||
previous_dash = false;
|
||||
} else if ch.is_alphanumeric() {
|
||||
has_non_ascii = true;
|
||||
if !previous_dash {
|
||||
output.push('-');
|
||||
previous_dash = true;
|
||||
}
|
||||
} else if !previous_dash {
|
||||
output.push('-');
|
||||
previous_dash = true;
|
||||
@@ -37,13 +44,56 @@ fn slug(value: &str, fallback: &str) -> String {
|
||||
}
|
||||
|
||||
let output = output.trim_matches('-').to_string();
|
||||
if output.is_empty() {
|
||||
fallback.to_string()
|
||||
let base = if output.is_empty() { fallback } else { &output };
|
||||
if has_non_ascii {
|
||||
format!("{base}-{:016x}", stable_hash(value.trim().as_bytes()))
|
||||
} else {
|
||||
output
|
||||
base.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn stable_hash(bytes: &[u8]) -> u64 {
|
||||
bytes.iter().fold(0xcbf29ce484222325, |hash, byte| {
|
||||
(hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_proxy_host(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& !value.contains("://")
|
||||
&& !value.chars().any(|ch| {
|
||||
ch.is_whitespace() || ch.is_control() || matches!(ch, '/' | '\\' | '@' | '?' | '#')
|
||||
})
|
||||
&& url::Host::parse(value).is_ok()
|
||||
}
|
||||
|
||||
fn valid_windows_item_path(value: &str, item_type: &ProfileItemType) -> bool {
|
||||
if value
|
||||
.chars()
|
||||
.any(|ch| ch.is_control() || matches!(ch, '"' | '<' | '>' | '|' | '?' | '*'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let bytes = value.as_bytes();
|
||||
let absolute_drive = bytes.len() >= 3
|
||||
&& bytes[0].is_ascii_alphabetic()
|
||||
&& bytes[1] == b':'
|
||||
&& matches!(bytes[2], b'\\' | b'/');
|
||||
let unc = value.starts_with(r"\\");
|
||||
let environment_root = value.starts_with('%')
|
||||
&& value[1..].find('%').is_some_and(|index| {
|
||||
value
|
||||
.as_bytes()
|
||||
.get(index + 2)
|
||||
.is_some_and(|ch| matches!(ch, b'\\' | b'/'))
|
||||
});
|
||||
let path_shape_valid = absolute_drive || unc || environment_root;
|
||||
|
||||
path_shape_valid
|
||||
&& (!matches!(item_type, ProfileItemType::Exe)
|
||||
|| value.to_ascii_lowercase().ends_with(".exe"))
|
||||
}
|
||||
|
||||
fn process_name(value: &str) -> String {
|
||||
let base = value.trim().rsplit(['\\', '/']).next().unwrap_or("").trim();
|
||||
base.strip_suffix(".exe")
|
||||
@@ -149,6 +199,15 @@ pub fn normalize_profile(input: ProfileInput) -> ValidationResult<Profile> {
|
||||
errors.push(error("items.value", "Укажите значение элемента профиля"));
|
||||
continue;
|
||||
}
|
||||
if matches!(item_type, ProfileItemType::Folder | ProfileItemType::Exe)
|
||||
&& !valid_windows_item_path(&value, &item_type)
|
||||
{
|
||||
errors.push(error(
|
||||
"items.value",
|
||||
"Укажите абсолютный Windows-путь; для exe путь должен оканчиваться на .exe",
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let recursive =
|
||||
matches!(item_type, ProfileItemType::Folder) && raw_item.recursive.unwrap_or(true);
|
||||
@@ -183,6 +242,11 @@ pub fn normalize_target(input: TargetInput) -> ValidationResult<Target> {
|
||||
}
|
||||
if host.is_empty() {
|
||||
errors.push(error("host", "Укажите хост цели"));
|
||||
} else if !valid_proxy_host(&host) {
|
||||
errors.push(error(
|
||||
"host",
|
||||
"Укажите только IP-адрес или имя хоста без схемы, пути и учетных данных",
|
||||
));
|
||||
}
|
||||
if input.port == 0 || input.port > u16::MAX as u32 {
|
||||
errors.push(error("port", "Порт цели должен быть от 1 до 65535"));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyWarden",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"identifier": "ru.dokops.proxywarden.windows",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -13,21 +13,31 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "ProxyWarden",
|
||||
"width": 820,
|
||||
"width": 920,
|
||||
"height": 760,
|
||||
"minWidth": 820,
|
||||
"maxWidth": 820,
|
||||
"minWidth": 760,
|
||||
"maxWidth": 1200,
|
||||
"minHeight": 560,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"targets": "nsis",
|
||||
"resources": [
|
||||
"bundled/proxifyre",
|
||||
"bundled/cleanup"
|
||||
],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installMode": "perMachine",
|
||||
"installerHooks": "bundled/installer-hooks/proxywarden-hooks.nsh"
|
||||
}
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
use proxywarden_lib::adapters::proxifyre::ProxiFyreAdapter;
|
||||
use proxywarden_lib::adapters::singbox::{
|
||||
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
|
||||
};
|
||||
use proxywarden_lib::apply_flow::{
|
||||
apply_configuration, ApplyConfigurationInput, ApplyPhaseStatus, ApplyRouteMode, ApplyServices,
|
||||
};
|
||||
use proxywarden_lib::commands::{
|
||||
Clock, CommandError, HelperApplyRequest, HelperApplyResult, ProxyApplyHelper,
|
||||
};
|
||||
use proxywarden_lib::component_detection::{DetectedProxyfier, ProxyfierEngine};
|
||||
use proxywarden_lib::models::{
|
||||
LocalSingBoxConfig, Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType,
|
||||
Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target, TargetInput,
|
||||
TargetKind,
|
||||
};
|
||||
use proxywarden_lib::storage::JsonStorage;
|
||||
use std::{cell::Cell, fs, path::Path};
|
||||
|
||||
#[test]
|
||||
fn external_apply_commits_one_source_state_without_service_control() {
|
||||
let fixture = ApplyFixture::new("external-success");
|
||||
fixture.seed_old_state();
|
||||
let helper = RecordingHelper::success();
|
||||
|
||||
let result =
|
||||
run_apply(&fixture.storage, external_input(), &helper).expect("preflight should succeed");
|
||||
|
||||
assert!(result.success);
|
||||
assert!(!result.partial_state);
|
||||
assert_eq!(helper.calls.get(), 1);
|
||||
assert!(result.phases.iter().any(|phase| {
|
||||
phase.id == "service-control" && phase.status == ApplyPhaseStatus::Skipped
|
||||
}));
|
||||
let profiles = fixture.storage.read_profiles().expect("read profiles");
|
||||
let targets = fixture.storage.read_targets().expect("read targets");
|
||||
assert!(profiles
|
||||
.iter()
|
||||
.any(|profile| profile.id == "main-profile" && profile.enabled));
|
||||
assert!(profiles
|
||||
.iter()
|
||||
.any(|profile| profile.id == "legacy" && !profile.enabled));
|
||||
assert!(targets.iter().any(|target| {
|
||||
target.id == "main-proxy" && target.host == "proxy.example.test" && target.port == 1080
|
||||
}));
|
||||
assert!(Path::new(&result.generated_config_path).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preflight_failure_does_not_write_source_or_call_helper() {
|
||||
let fixture = ApplyFixture::new("preflight-failure");
|
||||
fixture.seed_old_state();
|
||||
let before_profiles = fixture.storage.read_profiles().expect("profiles before");
|
||||
let before_targets = fixture.storage.read_targets().expect("targets before");
|
||||
let helper = RecordingHelper::success();
|
||||
let mut input = external_input();
|
||||
input.external_target.as_mut().expect("target").host =
|
||||
"socks5://unsafe.example.test".to_string();
|
||||
|
||||
let error = run_apply(&fixture.storage, input, &helper)
|
||||
.expect_err("invalid target should fail before writes");
|
||||
|
||||
assert_eq!(error.code(), "validation_failed");
|
||||
assert_eq!(helper.calls.get(), 0);
|
||||
assert_eq!(
|
||||
fixture.storage.read_profiles().expect("profiles after"),
|
||||
before_profiles
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.storage.read_targets().expect("targets after"),
|
||||
before_targets
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_blocks_apply_when_proxifyre_is_not_detected() {
|
||||
let fixture = ApplyFixture::new("missing-proxifyre");
|
||||
fixture.seed_old_state();
|
||||
let helper = RecordingHelper::success();
|
||||
let proxy_adapter = ProxiFyreAdapter::default();
|
||||
let singbox_adapter = SingBoxAdapter::default();
|
||||
|
||||
let error = apply_configuration(
|
||||
&fixture.storage,
|
||||
external_input(),
|
||||
ApplyServices {
|
||||
proxy_adapter: &proxy_adapter,
|
||||
singbox_adapter: &singbox_adapter,
|
||||
checker: &NoopChecker,
|
||||
helper: &helper,
|
||||
clock: &FixedClock,
|
||||
detected_proxyfier: None,
|
||||
detected_singbox: None,
|
||||
},
|
||||
)
|
||||
.expect_err("backend must not trust frontend readiness");
|
||||
|
||||
assert_eq!(error.code(), "proxifyre_not_found");
|
||||
assert_eq!(helper.calls.get(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_command_contract_uses_camel_case_nested_dtos() {
|
||||
let input: ApplyConfigurationInput = serde_json::from_value(serde_json::json!({
|
||||
"routeMode": "external",
|
||||
"profile": {
|
||||
"id": "main-profile",
|
||||
"name": "Main",
|
||||
"enabled": true,
|
||||
"targetId": "main-proxy",
|
||||
"protocols": ["TCP"],
|
||||
"items": [{ "type": "process", "value": "Discord.exe" }]
|
||||
},
|
||||
"externalTarget": {
|
||||
"id": "main-proxy",
|
||||
"name": "Proxy",
|
||||
"kind": "external",
|
||||
"protocol": "socks5",
|
||||
"host": "proxy.example.test",
|
||||
"port": 1080
|
||||
},
|
||||
"disableOtherProfiles": true
|
||||
}))
|
||||
.expect("typed Tauri input should deserialize");
|
||||
|
||||
assert_eq!(input.profile.target_id, "main-proxy");
|
||||
assert_eq!(input.profile.items[0].item_type, "process");
|
||||
assert_eq!(
|
||||
input.external_target.expect("target").host,
|
||||
"proxy.example.test"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_failure_rolls_back_source_and_generated_artifact() {
|
||||
let fixture = ApplyFixture::new("helper-rollback");
|
||||
fixture.seed_old_state();
|
||||
let before_profiles = fixture.storage.read_profiles().expect("profiles before");
|
||||
let before_targets = fixture.storage.read_targets().expect("targets before");
|
||||
let generated_path = fixture
|
||||
.storage
|
||||
.paths()
|
||||
.generated_dir
|
||||
.join("proxifyre-app-config.json");
|
||||
fs::create_dir_all(generated_path.parent().expect("generated parent"))
|
||||
.expect("create generated dir");
|
||||
fs::write(&generated_path, b"old-generated").expect("seed generated config");
|
||||
|
||||
let helper = RecordingHelper::failure();
|
||||
let result = run_apply(&fixture.storage, external_input(), &helper)
|
||||
.expect("runtime failure should return phase result");
|
||||
|
||||
assert!(!result.success);
|
||||
assert!(!result.partial_state);
|
||||
assert_eq!(result.error_code.as_deref(), Some("fixture_apply_failed"));
|
||||
assert!(result
|
||||
.phases
|
||||
.iter()
|
||||
.any(|phase| phase.status == ApplyPhaseStatus::RolledBack));
|
||||
assert_eq!(
|
||||
fixture.storage.read_profiles().expect("profiles after"),
|
||||
before_profiles
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.storage.read_targets().expect("targets after"),
|
||||
before_targets
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&generated_path).expect("generated after"),
|
||||
b"old-generated"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_apply_with_missing_running_service_stops_at_preflight() {
|
||||
let fixture = ApplyFixture::new("local-service-preflight");
|
||||
fixture.seed_old_state();
|
||||
fixture
|
||||
.storage
|
||||
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/list".to_string()),
|
||||
selected_server_id: Some("fixture-server".to_string()),
|
||||
selected_server_tag: Some("fixture".to_string()),
|
||||
..LocalSingBoxConfig::default()
|
||||
})
|
||||
.expect("write local config");
|
||||
fixture
|
||||
.storage
|
||||
.write_singbox_subscription_cache(&SubscriptionCache {
|
||||
config: serde_json::json!({
|
||||
"outbounds": [{
|
||||
"type": "vless",
|
||||
"tag": "fixture",
|
||||
"server": "edge.example.test",
|
||||
"server_port": 443,
|
||||
"uuid": "11111111-1111-1111-1111-111111111111"
|
||||
}]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
id: "fixture-server".to_string(),
|
||||
tag: "fixture".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "edge.example.test".to_string(),
|
||||
server_port: 443,
|
||||
}],
|
||||
user_info: serde_json::Map::new(),
|
||||
fetched_at: "fixture".to_string(),
|
||||
})
|
||||
.expect("write cache");
|
||||
let helper = RecordingHelper::success();
|
||||
let before_profiles = fixture.storage.read_profiles().expect("profiles before");
|
||||
|
||||
let error = run_apply(
|
||||
&fixture.storage,
|
||||
ApplyConfigurationInput {
|
||||
route_mode: ApplyRouteMode::LocalSingbox,
|
||||
profile: profile_input(),
|
||||
external_target: None,
|
||||
disable_other_profiles: true,
|
||||
},
|
||||
&helper,
|
||||
)
|
||||
.expect_err("stopped/missing Local sing-box must block preflight");
|
||||
|
||||
assert_eq!(error.code(), "proxifyre_preflight_failed");
|
||||
assert_eq!(helper.calls.get(), 0);
|
||||
assert_eq!(
|
||||
fixture.storage.read_profiles().expect("profiles after"),
|
||||
before_profiles
|
||||
);
|
||||
}
|
||||
|
||||
fn external_input() -> ApplyConfigurationInput {
|
||||
ApplyConfigurationInput {
|
||||
route_mode: ApplyRouteMode::External,
|
||||
profile: profile_input(),
|
||||
external_target: Some(TargetInput {
|
||||
id: Some("main-proxy".to_string()),
|
||||
name: "Основной прокси".to_string(),
|
||||
kind: "external".to_string(),
|
||||
protocol: "socks5".to_string(),
|
||||
host: "proxy.example.test".to_string(),
|
||||
port: 1080,
|
||||
requires_component: None,
|
||||
}),
|
||||
disable_other_profiles: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_input() -> ProfileInput {
|
||||
ProfileInput {
|
||||
id: Some("main-profile".to_string()),
|
||||
name: "Приложения через прокси".to_string(),
|
||||
enabled: true,
|
||||
target_id: String::new(),
|
||||
protocols: vec!["TCP".to_string(), "UDP".to_string()],
|
||||
items: vec![ProfileItemInput {
|
||||
item_type: "process".to_string(),
|
||||
value: "Discord.exe".to_string(),
|
||||
recursive: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn run_apply(
|
||||
storage: &JsonStorage,
|
||||
input: ApplyConfigurationInput,
|
||||
helper: &dyn ProxyApplyHelper,
|
||||
) -> Result<
|
||||
proxywarden_lib::apply_flow::ApplyConfigurationResult,
|
||||
proxywarden_lib::apply_flow::ApplyFlowError,
|
||||
> {
|
||||
let proxy_adapter = ProxiFyreAdapter::default();
|
||||
let singbox_adapter = SingBoxAdapter::default();
|
||||
apply_configuration(
|
||||
storage,
|
||||
input,
|
||||
ApplyServices {
|
||||
proxy_adapter: &proxy_adapter,
|
||||
singbox_adapter: &singbox_adapter,
|
||||
checker: &NoopChecker,
|
||||
helper,
|
||||
clock: &FixedClock,
|
||||
detected_proxyfier: Some(test_proxyfier()),
|
||||
detected_singbox: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn test_proxyfier() -> DetectedProxyfier {
|
||||
DetectedProxyfier {
|
||||
engine: ProxyfierEngine::ProxiFyre,
|
||||
name: "ProxiFyre".to_string(),
|
||||
install_dir: r"C:\Program Files\ProxyWarden\components\ProxiFyre".into(),
|
||||
executable_path: r"C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe".into(),
|
||||
config_path: Some(
|
||||
r"C:\Program Files\ProxyWarden\components\ProxiFyre\app-config.json".into(),
|
||||
),
|
||||
running: true,
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
service_status: Some("running".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
struct RecordingHelper {
|
||||
calls: Cell<usize>,
|
||||
succeed: bool,
|
||||
}
|
||||
|
||||
impl RecordingHelper {
|
||||
fn success() -> Self {
|
||||
Self {
|
||||
calls: Cell::new(0),
|
||||
succeed: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn failure() -> Self {
|
||||
Self {
|
||||
calls: Cell::new(0),
|
||||
succeed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyApplyHelper for RecordingHelper {
|
||||
fn apply_proxy_config(
|
||||
&self,
|
||||
_request: HelperApplyRequest<'_>,
|
||||
) -> Result<HelperApplyResult, CommandError> {
|
||||
self.calls.set(self.calls.get() + 1);
|
||||
if self.succeed {
|
||||
Ok(HelperApplyResult {
|
||||
success: true,
|
||||
changed: true,
|
||||
action: "apply".to_string(),
|
||||
message: "fixture applied".to_string(),
|
||||
})
|
||||
} else {
|
||||
Err(CommandError {
|
||||
code: "fixture_apply_failed".to_string(),
|
||||
message: "fixture helper failed".to_string(),
|
||||
details: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopChecker;
|
||||
|
||||
impl SingBoxConfigChecker for NoopChecker {
|
||||
fn check_config(
|
||||
&self,
|
||||
_binary_path: &Path,
|
||||
_config_json: &str,
|
||||
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
|
||||
Ok(SingBoxCheckResult {
|
||||
checked: true,
|
||||
success: true,
|
||||
message: "fixture valid".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedClock;
|
||||
|
||||
impl Clock for FixedClock {
|
||||
fn now(&self) -> String {
|
||||
"2026-07-11T00:00:00Z".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
struct ApplyFixture {
|
||||
root: std::path::PathBuf,
|
||||
storage: JsonStorage,
|
||||
}
|
||||
|
||||
impl ApplyFixture {
|
||||
fn new(label: &str) -> Self {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"proxywarden-apply-flow-{label}-{}",
|
||||
uuid::Uuid::new_v4().hyphenated()
|
||||
));
|
||||
Self {
|
||||
storage: JsonStorage::new(root.clone()),
|
||||
root,
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_old_state(&self) {
|
||||
self.storage
|
||||
.write_profiles(&[Profile {
|
||||
id: "legacy".to_string(),
|
||||
name: "Legacy".to_string(),
|
||||
enabled: true,
|
||||
target_id: "legacy-target".to_string(),
|
||||
protocols: vec![Protocol::Tcp],
|
||||
items: vec![ProfileItem {
|
||||
item_type: ProfileItemType::Process,
|
||||
value: "legacy".to_string(),
|
||||
recursive: false,
|
||||
}],
|
||||
}])
|
||||
.expect("seed profiles");
|
||||
self.storage
|
||||
.write_targets(&[Target {
|
||||
id: "legacy-target".to_string(),
|
||||
name: "Legacy".to_string(),
|
||||
kind: TargetKind::External,
|
||||
protocol: ProxyProtocol::Socks5,
|
||||
host: "legacy.example.test".to_string(),
|
||||
port: 1080,
|
||||
requires_component: None,
|
||||
}])
|
||||
.expect("seed targets");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ApplyFixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,20 @@
|
||||
#[path = "../src/activity.rs"]
|
||||
mod activity;
|
||||
#[path = "../src/commands.rs"]
|
||||
mod commands;
|
||||
#[path = "../src/component_detection.rs"]
|
||||
mod component_detection;
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/process.rs"]
|
||||
mod process;
|
||||
#[path = "../src/adapters/proxifyre.rs"]
|
||||
mod proxifyre;
|
||||
#[path = "../src/adapters/proxy_router.rs"]
|
||||
mod proxy_router;
|
||||
#[path = "../src/adapters/singbox.rs"]
|
||||
mod singbox;
|
||||
#[path = "../src/singbox_service.rs"]
|
||||
mod singbox_service;
|
||||
#[path = "../src/storage.rs"]
|
||||
mod storage;
|
||||
#[path = "../src/subscription.rs"]
|
||||
mod subscription;
|
||||
#[path = "../src/validation.rs"]
|
||||
mod validation;
|
||||
|
||||
use commands::{
|
||||
apply_profiles_with_services, build_status, read_saved_state_with_proxifyre_config,
|
||||
resolve_component_statuses, resolve_preview, save_profile_to_storage, save_target_to_storage,
|
||||
Clock, CommandError, DetectedProxyApplyHelper, HelperApplyRequest, HelperApplyResult,
|
||||
ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper, TargetInputDto,
|
||||
use proxywarden_lib::adapters::proxifyre::ProxiFyreAdapter;
|
||||
use proxywarden_lib::commands::{
|
||||
self, apply_profiles_with_services, apply_profiles_with_services_and_detection, build_status,
|
||||
read_saved_state_with_proxifyre_config, resolve_component_statuses, resolve_preview,
|
||||
save_profile_to_storage, save_target_to_storage, Clock, CommandError, DetectedProxyApplyHelper,
|
||||
HelperApplyRequest, HelperApplyResult, ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper,
|
||||
TargetInputDto,
|
||||
};
|
||||
use component_detection::{
|
||||
use proxywarden_lib::component_detection::{
|
||||
DetectedProxyfier, ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry,
|
||||
};
|
||||
use models::{
|
||||
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
|
||||
ProxyProtocol, Target, TargetKind,
|
||||
use proxywarden_lib::models::{
|
||||
self, ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType,
|
||||
Protocol, ProxyProtocol, Target, TargetKind,
|
||||
};
|
||||
use proxifyre::ProxiFyreAdapter;
|
||||
use proxywarden_lib::proxifyre_ownership::ManagedProxiFyreOwnership;
|
||||
use proxywarden_lib::storage::JsonStorage;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::net::TcpListener;
|
||||
@@ -44,7 +22,6 @@ use std::path::{Path, PathBuf};
|
||||
#[cfg(windows)]
|
||||
use std::process::Command as ProcessCommand;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use storage::JsonStorage;
|
||||
|
||||
#[test]
|
||||
fn save_commands_normalize_and_persist_profile_and_target() {
|
||||
@@ -275,6 +252,259 @@ fn proxifyre_install_script_parses_as_powershell() {
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxifyre_install_script_uses_resilient_download_helpers() {
|
||||
let root = test_root("proxifyre-install-script-downloads");
|
||||
let script = commands::install_proxifyre_script(&root.join("proxifyre-app-config.json"));
|
||||
|
||||
assert!(script.contains("function Get-SafeUriForLog([string]$uri)"));
|
||||
assert!(script.contains("function Invoke-ReleaseApi([string]$uri, [string]$label)"));
|
||||
assert!(script.contains("function Resolve-ReleaseAsset("));
|
||||
assert!(script.contains("function Get-PinnedWindowsPacketFilterAsset([string]$arch)"));
|
||||
assert!(script.contains("function Get-PinnedProxiFyreAsset([string]$arch)"));
|
||||
assert!(
|
||||
script.contains("function Invoke-Download([string]$uri, [string]$path, [string]$label)")
|
||||
);
|
||||
assert!(script.contains("foreach ($attempt in 1..3)"));
|
||||
assert!(script.contains("Invoke-WebClientDownload $uri $partialPath"));
|
||||
assert!(script.contains("Invoke-CurlDownload $uri $partialPath"));
|
||||
assert!(script.contains("--user-agent 'proxywarden' --output $partialPath --url $uri"));
|
||||
assert!(script.contains("Move-Item -LiteralPath $partialPath -Destination $path -Force"));
|
||||
assert!(script.contains("function Get-BundledAsset([string]$pattern, [string]$label)"));
|
||||
assert!(script.contains("function Verify-BundledAssetHash([string]$path, [string]$label)"));
|
||||
assert!(script
|
||||
.contains("Invoke-Download $vcRedistUrl $vcRedistPath 'Microsoft Visual C++ Runtime'"));
|
||||
assert!(script
|
||||
.contains("Resolve-ReleaseAsset $ndisapiReleaseApi $ndisPattern 'Windows Packet Filter'"));
|
||||
assert!(script.contains(
|
||||
"Invoke-Download $ndisAsset.browser_download_url $ndisPath 'Windows Packet Filter'"
|
||||
));
|
||||
assert!(
|
||||
script.contains("Resolve-ReleaseAsset $proxifyreReleaseApi $proxifyrePattern 'ProxiFyre'")
|
||||
);
|
||||
assert!(script.contains(
|
||||
"Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath 'ProxiFyre'"
|
||||
));
|
||||
assert!(script.contains("github.com/wiresock/ndisapi/releases/download"));
|
||||
assert!(script.contains("github.com/wiresock/proxifyre/releases/download"));
|
||||
assert!(script.contains(
|
||||
"[IO.File]::WriteAllText($markerPath, $markerJson, [Text.UTF8Encoding]::new($false))"
|
||||
));
|
||||
assert!(!script
|
||||
.contains("ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8"));
|
||||
let packet_filter_step = script
|
||||
.find("Write-ProxyWardenProgress 'install' 'packet-filter'")
|
||||
.expect("packet filter install step should be present");
|
||||
let vc_runtime_step = script
|
||||
.find("Write-ProxyWardenProgress 'install' 'vc-runtime'")
|
||||
.expect("runtime install step should be present");
|
||||
let proxifyre_step = script
|
||||
.find("Write-ProxyWardenProgress 'install' 'proxifyre'")
|
||||
.expect("proxifyre install step should be present");
|
||||
assert!(packet_filter_step < vc_runtime_step);
|
||||
assert!(vc_runtime_step < proxifyre_step);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxifyre_install_script_prefers_bundled_assets_before_downloads() {
|
||||
let root = test_root("proxifyre-install-script-bundled-assets");
|
||||
let bundle_dir = root.join("bundle");
|
||||
let script = commands::install_proxifyre_script_with_bundle(
|
||||
&root.join("proxifyre-app-config.json"),
|
||||
Some(&bundle_dir),
|
||||
);
|
||||
|
||||
assert!(script.contains(&format!(
|
||||
"$bundledAssetDir = '{}'",
|
||||
bundle_dir.display().to_string().replace('\'', "''")
|
||||
)));
|
||||
assert!(script.contains("$script:bundledAssetDir = [string]$bundledAssetDir"));
|
||||
assert!(script.contains("function Get-BundledAssetDir"));
|
||||
assert!(script.contains("$manifestPath = [IO.Path]::Combine($assetDir, 'manifest.json')"));
|
||||
assert!(script.contains("$script:bundledAssetManifest = Get-BundledAssetManifest"));
|
||||
assert!(script.contains("Copy-BundledAsset $bundledNdisPath $ndisPath"));
|
||||
assert!(script.contains("Copy-BundledAsset $bundledVcPath $vcRedistPath"));
|
||||
assert!(script.contains("Copy-BundledAsset $bundledProxiFyrePath $proxifyreZipPath"));
|
||||
|
||||
let bundled_ndis = script
|
||||
.find("Get-BundledAsset $ndisPattern 'Windows Packet Filter'")
|
||||
.expect("ndis bundle check should be present");
|
||||
let online_ndis = script
|
||||
.find("Resolve-ReleaseAsset $ndisapiReleaseApi $ndisPattern 'Windows Packet Filter'")
|
||||
.expect("ndis online fallback should be present");
|
||||
assert!(bundled_ndis < online_ndis);
|
||||
|
||||
let bundled_proxifyre = script
|
||||
.find("Get-BundledAsset $proxifyrePattern 'ProxiFyre'")
|
||||
.expect("proxifyre bundle check should be present");
|
||||
let online_proxifyre = script
|
||||
.find("Resolve-ReleaseAsset $proxifyreReleaseApi $proxifyrePattern 'ProxiFyre'")
|
||||
.expect("proxifyre online fallback should be present");
|
||||
assert!(bundled_proxifyre < online_proxifyre);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxifyre_firewall_script_scopes_rules_to_managed_executable() {
|
||||
let executable =
|
||||
PathBuf::from(r"C:\Program Files\Proxy'Warden\components\ProxiFyre\ProxiFyre.exe");
|
||||
let script = commands::configure_proxifyre_firewall_script(&executable);
|
||||
|
||||
assert!(script.contains(
|
||||
"$exePath = 'C:\\Program Files\\Proxy''Warden\\components\\ProxiFyre\\ProxiFyre.exe'"
|
||||
));
|
||||
assert!(script.contains("ProxyWarden.ProxiFyre.Inbound"));
|
||||
assert!(script.contains("ProxyWarden.ProxiFyre.Outbound"));
|
||||
assert!(script.contains("-Program $exePath"));
|
||||
assert!(script.contains("Get-NetFirewallRule -Name $rule.Name"));
|
||||
assert!(!script.contains("Get-NetFirewallRule -DisplayName"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn proxifyre_firewall_script_parses_as_powershell() {
|
||||
let root = test_root("proxifyre-firewall-script");
|
||||
fs::create_dir_all(&root).expect("test root should be created");
|
||||
let script = commands::configure_proxifyre_firewall_script(
|
||||
&root.join("ProxiFyre").join("ProxiFyre.exe"),
|
||||
);
|
||||
let script_path = root.join("firewall.ps1");
|
||||
fs::write(&script_path, script).expect("script should be written");
|
||||
|
||||
let escaped_path = script_path.display().to_string().replace('\'', "''");
|
||||
let parser = format!(
|
||||
"$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}"
|
||||
);
|
||||
let output = ProcessCommand::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", &parser])
|
||||
.output()
|
||||
.expect("powershell parser should run");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"firewall script should parse\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn proxifyre_uninstall_script_parses_as_powershell() {
|
||||
let root = test_root("proxifyre-uninstall-script");
|
||||
fs::create_dir_all(&root).expect("test root should be created");
|
||||
let detected = DetectedProxyfier {
|
||||
engine: ProxyfierEngine::ProxiFyre,
|
||||
name: "ProxiFyre".to_string(),
|
||||
install_dir: root.join("ProxiFyre"),
|
||||
executable_path: root.join("ProxiFyre").join("ProxiFyre.exe"),
|
||||
config_path: Some(root.join("ProxiFyre").join("app-config.json")),
|
||||
running: false,
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
service_status: Some("stopped".to_string()),
|
||||
};
|
||||
|
||||
let script = commands::wrap_elevated_package_script(
|
||||
&commands::uninstall_proxifyre_script(Some(&detected), &managed_ownership(true)),
|
||||
&root.join("uninstall.log"),
|
||||
);
|
||||
let script_path = root.join("uninstall.ps1");
|
||||
let mut script_bytes = vec![0xEF, 0xBB, 0xBF];
|
||||
script_bytes.extend_from_slice(script.as_bytes());
|
||||
fs::write(&script_path, script_bytes).expect("script should be written");
|
||||
|
||||
let escaped_path = script_path.display().to_string().replace('\'', "''");
|
||||
let parser = format!(
|
||||
"$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}"
|
||||
);
|
||||
let output = ProcessCommand::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", &parser])
|
||||
.output()
|
||||
.expect("powershell parser should run");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"uninstall script should parse\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxifyre_uninstall_script_removes_packet_filter_after_proxifyre() {
|
||||
let detected = DetectedProxyfier {
|
||||
engine: ProxyfierEngine::ProxiFyre,
|
||||
name: "ProxiFyre".to_string(),
|
||||
install_dir: PathBuf::from(r"C:\Tools\ProxiFyre"),
|
||||
executable_path: PathBuf::from(r"C:\Tools\ProxiFyre\ProxiFyre.exe"),
|
||||
config_path: Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")),
|
||||
running: true,
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
service_status: Some("running".to_string()),
|
||||
};
|
||||
let script = commands::uninstall_proxifyre_script(Some(&detected), &managed_ownership(true));
|
||||
|
||||
assert!(script.contains("function Find-ManagedProxiFyreService"));
|
||||
assert!(script.contains("Get-CimInstance Win32_Service"));
|
||||
assert!(script.contains("[StringComparison]::OrdinalIgnoreCase"));
|
||||
assert!(!script.contains("function Find-ProxiFyreService"));
|
||||
assert!(!script.contains("Where-Object { $_.Name -match 'ProxiFyre|Proxifyre'"));
|
||||
assert!(script.contains("function Resolve-MsiProductCode($program, [string]$label)"));
|
||||
assert!(script.contains("Отказываюсь запускать произвольный UninstallString"));
|
||||
assert!(script.contains("Start-Process -FilePath 'msiexec.exe'"));
|
||||
assert!(script.contains("ArgumentList @('/x', $productCode, '/qn', '/norestart'"));
|
||||
assert!(script.contains("Uninstall-MsiProgram $packetFilter 'Windows Packet Filter'"));
|
||||
assert!(script.contains("ProxyWarden.ProxiFyre.Inbound"));
|
||||
assert!(script.contains("ProxyWarden.ProxiFyre.Outbound"));
|
||||
let proxifyre_step = script
|
||||
.find("Write-ProxyWardenProgress 'uninstall' 'proxifyre'")
|
||||
.expect("proxifyre uninstall step should be present");
|
||||
let packet_filter_step = script
|
||||
.find("Write-ProxyWardenProgress 'uninstall' 'packet-filter'")
|
||||
.expect("packet filter uninstall step should be present");
|
||||
assert!(proxifyre_step < packet_filter_step);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxywarden_uninstall_hook_removes_only_managed_firewall_rules() {
|
||||
let script = include_str!("../bundled/cleanup/uninstall-managed-components.ps1");
|
||||
|
||||
assert!(script.contains("ProxyWarden.ProxiFyre.Inbound"));
|
||||
assert!(script.contains("ProxyWarden.ProxiFyre.Outbound"));
|
||||
assert!(script.contains("Get-NetFirewallRule -Name $name"));
|
||||
assert!(!script.contains("Get-NetFirewallRule -DisplayName"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxifyre_uninstall_script_leaves_shared_packet_filter_installed() {
|
||||
let detected = DetectedProxyfier {
|
||||
engine: ProxyfierEngine::ProxiFyre,
|
||||
name: "ProxiFyre".to_string(),
|
||||
install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre"),
|
||||
executable_path: PathBuf::from(
|
||||
r"C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe",
|
||||
),
|
||||
config_path: None,
|
||||
running: false,
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
service_status: Some("stopped".to_string()),
|
||||
};
|
||||
|
||||
let script = commands::uninstall_proxifyre_script(Some(&detected), &managed_ownership(false));
|
||||
|
||||
assert!(script.contains("$removePacketFilter = $false"));
|
||||
assert!(script.contains("if ($removePacketFilter)"));
|
||||
assert!(script.contains("Windows Packet Filter оставлен"));
|
||||
assert!(!script.contains("Get-Process -Name 'ProxiFyre'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singbox_runner_preserves_installer_args_with_spaces() {
|
||||
let script = commands::singbox_installer_runner_script(
|
||||
@@ -282,15 +512,16 @@ fn singbox_runner_preserves_installer_args_with_spaces() {
|
||||
Path::new(r"C:\ProgramData\ProxyWarden\state\install.log"),
|
||||
&[
|
||||
"-InstallRoot".to_string(),
|
||||
r"C:\Program Files\ProxyWarden\sing-box".to_string(),
|
||||
r"C:\Program Files\ProxyWarden\components\sing-box".to_string(),
|
||||
"-ServiceName".to_string(),
|
||||
"ProxyWardenSingBox".to_string(),
|
||||
"-Uninstall".to_string(),
|
||||
],
|
||||
);
|
||||
|
||||
assert!(script
|
||||
.contains("$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\sing-box'"));
|
||||
assert!(script.contains(
|
||||
"$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\components\\sing-box'"
|
||||
));
|
||||
assert!(script.contains(
|
||||
"& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs"
|
||||
));
|
||||
@@ -354,11 +585,13 @@ fn apply_blocks_local_singbox_target_when_component_is_missing() {
|
||||
.expect("write targets");
|
||||
write_json(&storage.paths().components_file, &[singbox_missing()]);
|
||||
|
||||
let error = apply_profiles_with_services(
|
||||
let error = apply_profiles_with_services_and_detection(
|
||||
&storage,
|
||||
&ProxiFyreAdapter::default(),
|
||||
&MockApplyHelper,
|
||||
&FixedClock,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect_err("missing sing-box should block local target apply");
|
||||
let activity = storage.read_activity().expect("read blocked activity");
|
||||
@@ -383,6 +616,7 @@ fn component_status_merges_detected_existing_proxifyre() {
|
||||
config_path: Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")),
|
||||
running: true,
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
service_status: Some("running".to_string()),
|
||||
}),
|
||||
None,
|
||||
);
|
||||
@@ -398,6 +632,20 @@ fn component_status_merges_detected_existing_proxifyre() {
|
||||
assert!(proxyfier.problems.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_status_does_not_keep_stale_installed_state_when_detection_is_missing() {
|
||||
let components = resolve_component_statuses(vec![proxyfier_running()], None, None);
|
||||
let proxyfier = components
|
||||
.iter()
|
||||
.find(|component| component.id == ComponentId::Proxyfier)
|
||||
.expect("proxyfier component");
|
||||
|
||||
assert_eq!(proxyfier.state, ComponentState::Missing);
|
||||
assert!(!proxyfier.installed);
|
||||
assert!(!proxyfier.running);
|
||||
assert_eq!(proxyfier.path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detected_proxy_apply_helper_writes_proxifyre_app_config() {
|
||||
let root = test_root("detected-proxifyre");
|
||||
@@ -422,11 +670,14 @@ fn detected_proxy_apply_helper_writes_proxifyre_app_config() {
|
||||
|
||||
let applied =
|
||||
fs::read_to_string(install_dir.join("app-config.json")).expect("read applied app-config");
|
||||
let backup =
|
||||
fs::read_to_string(install_dir.join("app-config.json.bak")).expect("read backup config");
|
||||
|
||||
assert!(result.success);
|
||||
assert!(result.changed);
|
||||
assert_eq!(result.action, "proxifyre.apply-detected-config");
|
||||
assert_eq!(applied, r#"{"proxies":[]}"#);
|
||||
assert_eq!(backup, "{}");
|
||||
assert!(install_dir.join("app-config.json.bak").exists());
|
||||
|
||||
cleanup(&root);
|
||||
@@ -530,8 +781,8 @@ impl ProxyfierDetectionHost for DetectionHost {
|
||||
false
|
||||
}
|
||||
|
||||
fn service_running(&self, _service_name: &str) -> bool {
|
||||
false
|
||||
fn service_status(&self, _service_name: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
|
||||
@@ -615,6 +866,8 @@ fn proxyfier_running() -> ComponentStatus {
|
||||
running: true,
|
||||
version: Some("2.2.1".to_string()),
|
||||
path: Some(r"C:\Tools\ProxiFyre".to_string()),
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
service_status: Some("running".to_string()),
|
||||
problems: Vec::new(),
|
||||
actions: vec!["Restart".to_string()],
|
||||
}
|
||||
@@ -629,7 +882,16 @@ fn singbox_missing() -> ComponentStatus {
|
||||
running: false,
|
||||
version: None,
|
||||
path: None,
|
||||
service_name: Some("ProxyWardenSingBox".to_string()),
|
||||
service_status: None,
|
||||
problems: vec!["Локальный sing-box не установлен".to_string()],
|
||||
actions: vec!["Установить локальный sing-box".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_ownership(remove_packet_filter: bool) -> ManagedProxiFyreOwnership {
|
||||
ManagedProxiFyreOwnership {
|
||||
service_name: "ProxiFyreService".to_string(),
|
||||
remove_packet_filter,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
#[path = "../src/component_detection.rs"]
|
||||
mod component_detection;
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/process.rs"]
|
||||
mod process;
|
||||
|
||||
use component_detection::{
|
||||
use proxywarden_lib::component_detection::{
|
||||
detect_proxyfier_install_with_host, detect_singbox_install_with_host,
|
||||
proxyfier_component_from_detection, singbox_component_from_detection, ProxyfierDetectionHost,
|
||||
ProxyfierEngine, RegistryInstallEntry,
|
||||
};
|
||||
use models::ComponentState;
|
||||
use proxywarden_lib::models::ComponentState;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::{Path, PathBuf},
|
||||
@@ -21,7 +14,11 @@ fn detects_existing_proxifyre_from_registry_install_location() {
|
||||
let host = MockHost::new()
|
||||
.with_registry("ProxiFyre", r"C:\Tools\ProxiFyre")
|
||||
.with_path(r"C:\Tools\ProxiFyre")
|
||||
.with_service("ProxiFyreService");
|
||||
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
|
||||
.with_service_path(
|
||||
"ProxiFyreService",
|
||||
r#""C:\Tools\ProxiFyre\ProxiFyre.exe" --service"#,
|
||||
);
|
||||
|
||||
let detected = detect_proxyfier_install_with_host(&host)
|
||||
.expect("existing ProxiFyre install should be detected");
|
||||
@@ -33,15 +30,30 @@ fn detects_existing_proxifyre_from_registry_install_location() {
|
||||
Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json"))
|
||||
);
|
||||
assert!(detected.running);
|
||||
assert_eq!(detected.service_name, Some("ProxiFyreService".to_string()));
|
||||
assert_eq!(detected.service_status, Some("running".to_string()));
|
||||
|
||||
let component = proxyfier_component_from_detection(Some(&detected));
|
||||
assert_eq!(component.state, ComponentState::Running);
|
||||
assert!(component.installed);
|
||||
assert!(component.running);
|
||||
assert_eq!(component.path, Some(r"C:\Tools\ProxiFyre".to_string()));
|
||||
assert_eq!(component.service_name, Some("ProxiFyreService".to_string()));
|
||||
assert_eq!(component.service_status, Some("running".to_string()));
|
||||
assert!(component.problems.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_empty_common_proxifyre_folder_without_executable() {
|
||||
let host = MockHost::new().with_path(r"C:\Tools\ProxiFyre");
|
||||
|
||||
assert!(detect_proxyfier_install_with_host(&host).is_none());
|
||||
|
||||
let component = proxyfier_component_from_detection(None);
|
||||
assert_eq!(component.state, ComponentState::Missing);
|
||||
assert!(!component.installed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_plain_proxifier_install() {
|
||||
let host = MockHost::new()
|
||||
@@ -68,6 +80,28 @@ fn env_override_can_point_to_portable_proxifyre_install() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_stopped_proxifyre_service_when_executable_exists() {
|
||||
let host = MockHost::new()
|
||||
.with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"C:\Tools\ProxiFyre")
|
||||
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
|
||||
.with_stopped_service_path(
|
||||
"ProxiFyreService",
|
||||
r#""C:\Tools\ProxiFyre\ProxiFyre.exe" --service"#,
|
||||
);
|
||||
|
||||
let detected =
|
||||
detect_proxyfier_install_with_host(&host).expect("proxifyre executable should be detected");
|
||||
let component = proxyfier_component_from_detection(Some(&detected));
|
||||
|
||||
assert_eq!(component.state, ComponentState::Installed);
|
||||
assert!(component.installed);
|
||||
assert!(!component.running);
|
||||
assert_eq!(component.service_name, Some("ProxiFyreService".to_string()));
|
||||
assert_eq!(component.service_status, Some("stopped".to_string()));
|
||||
assert!(component.problems.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_proxyfier_returns_install_action_status() {
|
||||
let component = proxyfier_component_from_detection(None);
|
||||
@@ -77,10 +111,41 @@ fn missing_proxyfier_returns_install_action_status() {
|
||||
assert_eq!(component.actions, vec!["Установить ProxiFyre"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_known_service_name_when_path_points_to_foreign_binary() {
|
||||
let host = MockHost::new()
|
||||
.with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"C:\Tools\ProxiFyre")
|
||||
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
|
||||
.with_service_path(
|
||||
"ProxiFyreService",
|
||||
r#""C:\Foreign\ProxiFyre.exe" --service"#,
|
||||
);
|
||||
|
||||
let detected =
|
||||
detect_proxyfier_install_with_host(&host).expect("executable should still be detected");
|
||||
|
||||
assert!(!detected.running);
|
||||
assert_eq!(detected.service_status, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_known_service_name_without_path_metadata() {
|
||||
let host = MockHost::new()
|
||||
.with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"C:\Tools\ProxiFyre")
|
||||
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
|
||||
.with_service("ProxiFyreService");
|
||||
|
||||
let detected =
|
||||
detect_proxyfier_install_with_host(&host).expect("executable should still be detected");
|
||||
|
||||
assert!(!detected.running);
|
||||
assert_eq!(detected.service_status, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_running_local_singbox_from_default_install_root_and_service() {
|
||||
let host = MockHost::new()
|
||||
.with_path(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe")
|
||||
.with_path(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe")
|
||||
.with_service("ProxyWardenSingBox");
|
||||
|
||||
let detected =
|
||||
@@ -88,7 +153,7 @@ fn detects_running_local_singbox_from_default_install_root_and_service() {
|
||||
|
||||
assert_eq!(
|
||||
detected.executable_path,
|
||||
PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe")
|
||||
PathBuf::from(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe")
|
||||
);
|
||||
assert_eq!(detected.service_name, "ProxyWardenSingBox");
|
||||
assert!(detected.running);
|
||||
@@ -99,7 +164,7 @@ fn detects_running_local_singbox_from_default_install_root_and_service() {
|
||||
assert!(component.running);
|
||||
assert_eq!(
|
||||
component.path,
|
||||
Some(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe".to_string())
|
||||
Some(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe".to_string())
|
||||
);
|
||||
assert!(component.problems.is_empty());
|
||||
}
|
||||
@@ -120,6 +185,11 @@ fn detects_stopped_local_singbox_from_env_override() {
|
||||
assert_eq!(component.state, ComponentState::Stopped);
|
||||
assert!(component.installed);
|
||||
assert!(!component.running);
|
||||
assert_eq!(
|
||||
component.service_name,
|
||||
Some("ProxyWardenSingBox".to_string())
|
||||
);
|
||||
assert_eq!(component.service_status, Some("stopped".to_string()));
|
||||
assert!(component
|
||||
.problems
|
||||
.iter()
|
||||
@@ -142,7 +212,8 @@ struct MockHost {
|
||||
env: HashMap<String, String>,
|
||||
paths: HashSet<String>,
|
||||
processes: HashSet<String>,
|
||||
services: HashSet<String>,
|
||||
services: HashMap<String, String>,
|
||||
service_paths: HashMap<String, String>,
|
||||
registry: Vec<RegistryInstallEntry>,
|
||||
}
|
||||
|
||||
@@ -167,7 +238,24 @@ impl MockHost {
|
||||
}
|
||||
|
||||
fn with_service(mut self, service: &str) -> Self {
|
||||
self.services.insert(service.to_ascii_lowercase());
|
||||
self.services
|
||||
.insert(service.to_ascii_lowercase(), "running".to_string());
|
||||
self
|
||||
}
|
||||
|
||||
fn with_service_path(mut self, service: &str, path_name: &str) -> Self {
|
||||
self.services
|
||||
.insert(service.to_ascii_lowercase(), "running".to_string());
|
||||
self.service_paths
|
||||
.insert(service.to_ascii_lowercase(), path_name.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
fn with_stopped_service_path(mut self, service: &str, path_name: &str) -> Self {
|
||||
self.services
|
||||
.insert(service.to_ascii_lowercase(), "stopped".to_string());
|
||||
self.service_paths
|
||||
.insert(service.to_ascii_lowercase(), path_name.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
@@ -195,8 +283,24 @@ impl ProxyfierDetectionHost for MockHost {
|
||||
self.processes.contains(&process_name.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn service_running(&self, service_name: &str) -> bool {
|
||||
self.services.contains(&service_name.to_ascii_lowercase())
|
||||
fn service_status(&self, service_name: &str) -> Option<String> {
|
||||
self.services
|
||||
.get(&service_name.to_ascii_lowercase())
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn service_info(
|
||||
&self,
|
||||
service_name: &str,
|
||||
) -> Option<proxywarden_lib::component_detection::DetectedService> {
|
||||
let key = service_name.to_ascii_lowercase();
|
||||
self.services.get(&key).map(|status| {
|
||||
proxywarden_lib::component_detection::DetectedService {
|
||||
name: service_name.to_string(),
|
||||
status: status.clone(),
|
||||
path_name: self.service_paths.get(&key).cloned(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/validation.rs"]
|
||||
mod validation;
|
||||
|
||||
use models::{
|
||||
use proxywarden_lib::models::{
|
||||
ComponentId, ProfileInput, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
|
||||
TargetInput, TargetKind,
|
||||
};
|
||||
use validation::{normalize_profile, normalize_target};
|
||||
use proxywarden_lib::validation::{normalize_profile, normalize_target};
|
||||
|
||||
#[test]
|
||||
fn normalizes_profile_source_items() {
|
||||
@@ -126,3 +121,91 @@ fn rejects_malformed_target_fields() {
|
||||
assert!(error.iter().any(|item| item.field == "protocol"));
|
||||
assert!(error.iter().any(|item| item.field == "requires_component"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_names_receive_distinct_stable_ids() {
|
||||
let profile = normalize_profile(ProfileInput {
|
||||
id: None,
|
||||
name: "Игры".to_string(),
|
||||
enabled: true,
|
||||
target_id: "main-proxy".to_string(),
|
||||
protocols: vec!["TCP".to_string()],
|
||||
items: vec![ProfileItemInput {
|
||||
item_type: "process".to_string(),
|
||||
value: "game.exe".to_string(),
|
||||
recursive: None,
|
||||
}],
|
||||
})
|
||||
.expect("unicode profile should normalize");
|
||||
let other = normalize_profile(ProfileInput {
|
||||
id: None,
|
||||
name: "Работа".to_string(),
|
||||
enabled: true,
|
||||
target_id: "main-proxy".to_string(),
|
||||
protocols: vec!["TCP".to_string()],
|
||||
items: vec![ProfileItemInput {
|
||||
item_type: "process".to_string(),
|
||||
value: "work.exe".to_string(),
|
||||
recursive: None,
|
||||
}],
|
||||
})
|
||||
.expect("second unicode profile should normalize");
|
||||
|
||||
assert!(profile.id.starts_with("profile-"));
|
||||
assert!(other.id.starts_with("profile-"));
|
||||
assert_ne!(profile.id, other.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_host_with_scheme_credentials_or_path() {
|
||||
for host in [
|
||||
"socks5://proxy.example.test",
|
||||
"user@proxy.example.test",
|
||||
"proxy.example.test/path",
|
||||
] {
|
||||
let error = normalize_target(TargetInput {
|
||||
id: None,
|
||||
name: "Invalid host".to_string(),
|
||||
kind: "external".to_string(),
|
||||
protocol: "socks5".to_string(),
|
||||
host: host.to_string(),
|
||||
port: 1080,
|
||||
requires_component: None,
|
||||
})
|
||||
.expect_err("host must not contain URL syntax");
|
||||
|
||||
assert!(error.iter().any(|item| item.field == "host"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_relative_or_non_executable_profile_paths() {
|
||||
let error = normalize_profile(ProfileInput {
|
||||
id: None,
|
||||
name: "Invalid paths".to_string(),
|
||||
enabled: true,
|
||||
target_id: "main-proxy".to_string(),
|
||||
protocols: vec!["TCP".to_string()],
|
||||
items: vec![
|
||||
ProfileItemInput {
|
||||
item_type: "folder".to_string(),
|
||||
value: r"relative\folder".to_string(),
|
||||
recursive: None,
|
||||
},
|
||||
ProfileItemInput {
|
||||
item_type: "exe".to_string(),
|
||||
value: r"C:\Games\game.txt".to_string(),
|
||||
recursive: None,
|
||||
},
|
||||
],
|
||||
})
|
||||
.expect_err("unsafe path shapes should fail validation");
|
||||
|
||||
assert_eq!(
|
||||
error
|
||||
.iter()
|
||||
.filter(|item| item.field == "items.value")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
#[path = "../src/helper.rs"]
|
||||
mod helper;
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
|
||||
use helper::{
|
||||
use proxywarden_lib::helper::{
|
||||
helper_action_requires_elevation, install_request, parse_helper_response,
|
||||
proxifyre_apply_request, service_request, HelperAction, HelperCommandOutput,
|
||||
HelperCommandRunner, HelperCommandSpec, HelperError, HelperResponse, StructuredHelper,
|
||||
};
|
||||
use models::ComponentId;
|
||||
use proxywarden_lib::models::ComponentId;
|
||||
use serde_json::json;
|
||||
use std::cell::RefCell;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/adapters/proxifyre.rs"]
|
||||
mod proxifyre;
|
||||
#[path = "../src/adapters/proxy_router.rs"]
|
||||
mod proxy_router;
|
||||
|
||||
use models::{
|
||||
use proxywarden_lib::adapters::proxifyre::{
|
||||
ProxiFyreAdapter, ProxiFyreConfig, PROXIFYRE_OUTPUT_FILE,
|
||||
};
|
||||
use proxywarden_lib::adapters::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterErrorKind, ProxyRouterRequest,
|
||||
};
|
||||
use proxywarden_lib::models::{
|
||||
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
|
||||
ProxyProtocol, Target, TargetKind,
|
||||
};
|
||||
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, PROXIFYRE_OUTPUT_FILE};
|
||||
use proxy_router::{ProxyRouterAdapter, ProxyRouterErrorKind, ProxyRouterRequest};
|
||||
|
||||
#[test]
|
||||
fn generates_proxifyre_config_for_discord_external_socks5_target() {
|
||||
@@ -86,6 +83,35 @@ fn includes_folder_paths_when_generating_proxifyre_config() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_windows_app_names_case_insensitively() {
|
||||
let adapter = ProxiFyreAdapter::default();
|
||||
let mut profile = discord_profile("home-gateway");
|
||||
profile.items.extend([
|
||||
ProfileItem {
|
||||
item_type: ProfileItemType::Process,
|
||||
value: "discord".to_string(),
|
||||
recursive: false,
|
||||
},
|
||||
ProfileItem {
|
||||
item_type: ProfileItemType::Exe,
|
||||
value: "DISCORD".to_string(),
|
||||
recursive: false,
|
||||
},
|
||||
]);
|
||||
let profiles = vec![profile];
|
||||
let targets = vec![external_socks5_target()];
|
||||
|
||||
let generated = adapter
|
||||
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &[]))
|
||||
.expect("Windows app names should generate");
|
||||
let config: ProxiFyreConfig =
|
||||
serde_json::from_str(&generated.contents).expect("generated config json");
|
||||
|
||||
assert_eq!(config.proxies[0].app_names, vec!["Discord"]);
|
||||
assert_eq!(generated.routed_apps, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_local_singbox_target_when_required_component_is_missing() {
|
||||
let adapter = ProxiFyreAdapter::default();
|
||||
@@ -187,6 +213,8 @@ fn missing_singbox_component() -> ComponentStatus {
|
||||
running: false,
|
||||
version: None,
|
||||
path: None,
|
||||
service_name: Some("ProxyWardenSingBox".to_string()),
|
||||
service_status: None,
|
||||
problems: vec!["Local sing-box is not installed".to_string()],
|
||||
actions: vec!["Install Local sing-box".to_string()],
|
||||
}
|
||||
@@ -200,7 +228,9 @@ fn running_singbox_component() -> ComponentStatus {
|
||||
installed: true,
|
||||
running: true,
|
||||
version: Some("1.11.0".to_string()),
|
||||
path: Some(r"C:\Tools\ProxyWarden\sing-box\sing-box.exe".to_string()),
|
||||
path: Some(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe".to_string()),
|
||||
service_name: Some("ProxyWardenSingBox".to_string()),
|
||||
service_status: Some("running".to_string()),
|
||||
problems: Vec::new(),
|
||||
actions: vec!["Restart".to_string(), "Stop".to_string()],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
use proxywarden_lib::proxifyre_ownership::verify_managed_proxifyre_install;
|
||||
use serde_json::json;
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
#[test]
|
||||
fn accepts_matching_managed_install_and_returns_packet_filter_ownership() {
|
||||
let fixture = ManagedInstallFixture::new("owned");
|
||||
fixture.write_marker(true, &fixture.install_dir);
|
||||
|
||||
let ownership = verify_managed_proxifyre_install(
|
||||
&fixture.install_dir,
|
||||
&fixture.executable_path,
|
||||
&fixture.install_dir,
|
||||
)
|
||||
.expect("matching marker should prove ownership");
|
||||
|
||||
assert_eq!(ownership.service_name, "ProxiFyreService");
|
||||
assert!(ownership.remove_packet_filter);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_windows_powershell_utf8_bom_marker() {
|
||||
let fixture = ManagedInstallFixture::new("utf8-bom");
|
||||
let marker = json!({
|
||||
"manager": "ProxyWarden",
|
||||
"component": "proxifyre",
|
||||
"serviceName": "ProxiFyreService",
|
||||
"installRoot": fixture.install_dir,
|
||||
"packetFilterInstalledByProxyWarden": false
|
||||
});
|
||||
let mut marker_bytes = vec![0xEF, 0xBB, 0xBF];
|
||||
marker_bytes.extend(serde_json::to_vec_pretty(&marker).expect("marker should serialize"));
|
||||
fs::write(
|
||||
fixture.install_dir.join("proxywarden-component.json"),
|
||||
marker_bytes,
|
||||
)
|
||||
.expect("BOM marker should be written");
|
||||
|
||||
let ownership = verify_managed_proxifyre_install(
|
||||
&fixture.install_dir,
|
||||
&fixture.executable_path,
|
||||
&fixture.install_dir,
|
||||
)
|
||||
.expect("Windows PowerShell BOM marker should prove ownership");
|
||||
|
||||
assert_eq!(ownership.service_name, "ProxiFyreService");
|
||||
assert!(!ownership.remove_packet_filter);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_install_outside_expected_managed_directory() {
|
||||
let fixture = ManagedInstallFixture::new("unexpected-root");
|
||||
fixture.write_marker(true, &fixture.install_dir);
|
||||
let other_root = fixture
|
||||
.root
|
||||
.join("other")
|
||||
.join("components")
|
||||
.join("ProxiFyre");
|
||||
fs::create_dir_all(&other_root).expect("other root should be created");
|
||||
|
||||
let error = verify_managed_proxifyre_install(
|
||||
&fixture.install_dir,
|
||||
&fixture.executable_path,
|
||||
&other_root,
|
||||
)
|
||||
.expect_err("a detected portable install must not be recursively removed");
|
||||
|
||||
assert!(error.contains("не является управляемой папкой"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_marker_with_mismatched_install_root() {
|
||||
let fixture = ManagedInstallFixture::new("mismatched-marker");
|
||||
fixture.write_marker(false, &fixture.root);
|
||||
|
||||
let error = verify_managed_proxifyre_install(
|
||||
&fixture.install_dir,
|
||||
&fixture.executable_path,
|
||||
&fixture.install_dir,
|
||||
)
|
||||
.expect_err("marker installRoot must match the managed directory");
|
||||
|
||||
assert!(error.contains("installRoot из marker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_marker_with_foreign_service_name() {
|
||||
let fixture = ManagedInstallFixture::new("foreign-service");
|
||||
fixture.write_custom_marker(json!({
|
||||
"manager": "ProxyWarden",
|
||||
"component": "proxifyre",
|
||||
"serviceName": "ForeignProxyService",
|
||||
"installRoot": fixture.install_dir,
|
||||
"packetFilterInstalledByProxyWarden": true
|
||||
}));
|
||||
|
||||
let error = verify_managed_proxifyre_install(
|
||||
&fixture.install_dir,
|
||||
&fixture.executable_path,
|
||||
&fixture.install_dir,
|
||||
)
|
||||
.expect_err("foreign service name must not be trusted");
|
||||
|
||||
assert!(error.contains("неподдерживаемое имя службы"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_packet_filter_flag_defaults_to_not_owned() {
|
||||
let fixture = ManagedInstallFixture::new("shared-driver");
|
||||
fixture.write_custom_marker(json!({
|
||||
"manager": "ProxyWarden",
|
||||
"component": "proxifyre",
|
||||
"serviceName": "ProxiFyreService",
|
||||
"installRoot": fixture.install_dir
|
||||
}));
|
||||
|
||||
let ownership = verify_managed_proxifyre_install(
|
||||
&fixture.install_dir,
|
||||
&fixture.executable_path,
|
||||
&fixture.install_dir,
|
||||
)
|
||||
.expect("valid marker without ownership flag should remain safe");
|
||||
|
||||
assert!(!ownership.remove_packet_filter);
|
||||
}
|
||||
|
||||
struct ManagedInstallFixture {
|
||||
root: PathBuf,
|
||||
install_dir: PathBuf,
|
||||
executable_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ManagedInstallFixture {
|
||||
fn new(label: &str) -> Self {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"proxywarden-ownership-{label}-{}",
|
||||
uuid::Uuid::new_v4().hyphenated()
|
||||
));
|
||||
let install_dir = root.join("components").join("ProxiFyre");
|
||||
let executable_path = install_dir.join("ProxiFyre.exe");
|
||||
fs::create_dir_all(&install_dir).expect("managed install directory should be created");
|
||||
fs::write(&executable_path, b"fixture").expect("fixture executable should be written");
|
||||
|
||||
Self {
|
||||
root,
|
||||
install_dir,
|
||||
executable_path,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_marker(&self, packet_filter_owned: bool, install_root: &std::path::Path) {
|
||||
self.write_custom_marker(json!({
|
||||
"manager": "ProxyWarden",
|
||||
"component": "proxifyre",
|
||||
"serviceName": "ProxiFyreService",
|
||||
"installRoot": install_root,
|
||||
"packetFilterInstalledByProxyWarden": packet_filter_owned
|
||||
}));
|
||||
}
|
||||
|
||||
fn write_custom_marker(&self, marker: serde_json::Value) {
|
||||
fs::write(
|
||||
self.install_dir.join("proxywarden-component.json"),
|
||||
serde_json::to_vec_pretty(&marker).expect("marker should serialize"),
|
||||
)
|
||||
.expect("marker should be written");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ManagedInstallFixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,15 @@
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/process.rs"]
|
||||
mod process;
|
||||
#[path = "../src/adapters/proxifyre.rs"]
|
||||
mod proxifyre;
|
||||
#[path = "../src/adapters/proxy_router.rs"]
|
||||
mod proxy_router;
|
||||
#[path = "../src/adapters/singbox.rs"]
|
||||
mod singbox;
|
||||
|
||||
use models::{
|
||||
ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, Profile, ProfileItem,
|
||||
ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target,
|
||||
TargetKind,
|
||||
};
|
||||
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig};
|
||||
use proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
|
||||
use singbox::{
|
||||
use proxywarden_lib::adapters::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig};
|
||||
use proxywarden_lib::adapters::proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
|
||||
use proxywarden_lib::adapters::singbox::{
|
||||
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
|
||||
SingBoxConfigErrorKind, SingBoxGenerationRequest, DEFAULT_VPN_OUTBOUND_TAG,
|
||||
SINGBOX_OUTPUT_FILE,
|
||||
};
|
||||
use proxywarden_lib::models::{
|
||||
ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, Profile, ProfileItem,
|
||||
ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target,
|
||||
TargetKind,
|
||||
};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
path::{Path, PathBuf},
|
||||
@@ -32,7 +21,7 @@ fn generates_selected_outbound_config_and_runs_check_when_binary_path_is_supplie
|
||||
let config = local_singbox_config("nl-1");
|
||||
let cache = subscription_cache();
|
||||
let checker = RecordingChecker::ok("configuration OK");
|
||||
let binary_path = Path::new(r"C:\Tools\ProxyWarden\sing-box\sing-box.exe");
|
||||
let binary_path = Path::new(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe");
|
||||
|
||||
let generated = adapter
|
||||
.generate_config(
|
||||
@@ -102,6 +91,7 @@ fn blocks_config_when_server_is_not_selected() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let mut config = local_singbox_config("nl-1");
|
||||
config.selected_server_tag = None;
|
||||
config.selected_server_id = None;
|
||||
let cache = subscription_cache();
|
||||
let checker = RecordingChecker::ok("should not run");
|
||||
|
||||
@@ -119,8 +109,9 @@ fn blocks_config_when_server_is_not_selected() {
|
||||
#[test]
|
||||
fn blocks_config_when_selected_outbound_is_missing() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let config = local_singbox_config("missing-server");
|
||||
let cache = subscription_cache();
|
||||
let config = local_singbox_config("nl-1");
|
||||
let mut cache = subscription_cache();
|
||||
cache.config = serde_json::json!({ "outbounds": [] });
|
||||
let checker = RecordingChecker::ok("should not run");
|
||||
|
||||
let error = adapter
|
||||
@@ -131,10 +122,67 @@ fn blocks_config_when_selected_outbound_is_missing() {
|
||||
.expect_err("missing outbound should block config");
|
||||
|
||||
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedOutbound);
|
||||
assert!(error.message.contains("missing-server"));
|
||||
assert!(error.message.contains("nl-1"));
|
||||
assert!(checker.calls.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_tags_generate_the_outbound_selected_by_stable_id() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let mut config = local_singbox_config("shared-name");
|
||||
config.selected_server_id = Some("vless|shared-name|second.example.test|8443".to_string());
|
||||
let cache = SubscriptionCache {
|
||||
config: serde_json::json!({
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "shared-name",
|
||||
"server": "first.example.test",
|
||||
"server_port": 443,
|
||||
"uuid": "11111111-1111-1111-1111-111111111111"
|
||||
},
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "shared-name",
|
||||
"server": "second.example.test",
|
||||
"server_port": 8443,
|
||||
"uuid": "22222222-2222-2222-2222-222222222222"
|
||||
}
|
||||
]
|
||||
}),
|
||||
servers: vec![
|
||||
SubscriptionServer {
|
||||
id: "vless|shared-name|first.example.test|443".to_string(),
|
||||
tag: "shared-name".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "first.example.test".to_string(),
|
||||
server_port: 443,
|
||||
},
|
||||
SubscriptionServer {
|
||||
id: "vless|shared-name|second.example.test|8443".to_string(),
|
||||
tag: "shared-name".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "second.example.test".to_string(),
|
||||
server_port: 8443,
|
||||
},
|
||||
],
|
||||
user_info: serde_json::Map::new(),
|
||||
fetched_at: "2026-07-11T00:00:00Z".to_string(),
|
||||
};
|
||||
|
||||
let generated = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||
&RecordingChecker::ok("not used"),
|
||||
)
|
||||
.expect("stable id should resolve the second duplicate tag");
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&generated.contents).expect("generated config should parse");
|
||||
|
||||
assert_eq!(value["outbounds"][0]["server"], "second.example.test");
|
||||
assert_eq!(value["outbounds"][0]["server_port"], 8443);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagates_failed_singbox_check_as_structured_error() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
@@ -217,11 +265,13 @@ impl SingBoxConfigChecker for RecordingChecker {
|
||||
fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig {
|
||||
LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/list".to_string()),
|
||||
device_hwid: None,
|
||||
selected_server_tag: Some(selected_server_tag.to_string()),
|
||||
selected_server_id: Some(format!("vless|{selected_server_tag}|nl.example.test|443")),
|
||||
listen_host: "127.0.0.1".to_string(),
|
||||
listen_port: 1080,
|
||||
service_name: "ProxyWardenSingBox".to_string(),
|
||||
install_root: r"C:\Program Files\ProxyWarden\sing-box".to_string(),
|
||||
install_root: r"C:\Program Files\ProxyWarden\components\sing-box".to_string(),
|
||||
updated_at: Some("2026-07-07T10:00:00Z".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -244,6 +294,7 @@ fn subscription_cache() -> SubscriptionCache {
|
||||
]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
id: "vless|nl-1|nl.example.test|443".to_string(),
|
||||
tag: "nl-1".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "nl.example.test".to_string(),
|
||||
@@ -290,6 +341,8 @@ fn missing_singbox_component() -> ComponentStatus {
|
||||
running: false,
|
||||
version: None,
|
||||
path: None,
|
||||
service_name: Some("ProxyWardenSingBox".to_string()),
|
||||
service_status: None,
|
||||
problems: vec!["Local sing-box is not installed".to_string()],
|
||||
actions: vec!["Install Local sing-box".to_string()],
|
||||
}
|
||||
|
||||
@@ -1,44 +1,22 @@
|
||||
#[path = "../src/activity.rs"]
|
||||
mod activity;
|
||||
#[path = "../src/commands.rs"]
|
||||
mod commands;
|
||||
#[path = "../src/component_detection.rs"]
|
||||
mod component_detection;
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/process.rs"]
|
||||
mod process;
|
||||
#[path = "../src/adapters/proxifyre.rs"]
|
||||
mod proxifyre;
|
||||
#[path = "../src/adapters/proxy_router.rs"]
|
||||
mod proxy_router;
|
||||
#[path = "../src/adapters/singbox.rs"]
|
||||
mod singbox;
|
||||
#[path = "../src/singbox_service.rs"]
|
||||
mod singbox_service;
|
||||
#[path = "../src/storage.rs"]
|
||||
mod storage;
|
||||
#[path = "../src/subscription.rs"]
|
||||
mod subscription;
|
||||
#[path = "../src/validation.rs"]
|
||||
mod validation;
|
||||
|
||||
use commands::{
|
||||
fetch_singbox_subscription_with_fetcher, forget_singbox_subscription_in_storage,
|
||||
generate_singbox_config_with_services, save_singbox_subscription_to_storage,
|
||||
select_singbox_server_in_storage, Clock, SaveSingBoxSubscriptionInputDto,
|
||||
SelectSingBoxServerInputDto, SubscriptionFetcher,
|
||||
use proxywarden_lib::adapters::singbox::{
|
||||
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
|
||||
};
|
||||
use models::{
|
||||
use proxywarden_lib::commands::{
|
||||
fetch_singbox_subscription_with_fetcher, forget_singbox_subscription_in_storage,
|
||||
generate_singbox_config_with_services, read_singbox_status,
|
||||
save_singbox_subscription_to_storage, select_singbox_server_in_storage, Clock,
|
||||
SaveSingBoxSubscriptionInputDto, SelectSingBoxServerInputDto, SubscriptionFetcher,
|
||||
};
|
||||
use proxywarden_lib::models::{
|
||||
ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, SubscriptionCache,
|
||||
SubscriptionServer, TargetKind,
|
||||
};
|
||||
use proxywarden_lib::storage::JsonStorage;
|
||||
use proxywarden_lib::subscription;
|
||||
use serde_json::{json, Map};
|
||||
use singbox::{SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use storage::JsonStorage;
|
||||
|
||||
#[test]
|
||||
fn saves_subscription_url_without_exposing_secret_query() {
|
||||
@@ -61,6 +39,7 @@ fn saves_subscription_url_without_exposing_secret_query() {
|
||||
config.subscription_url,
|
||||
Some("https://sub.example.test/path?token=secret".to_string())
|
||||
);
|
||||
assert_valid_generated_hwid(config.device_hwid.as_deref());
|
||||
assert!(status.config.has_subscription);
|
||||
assert_eq!(
|
||||
status.config.subscription_display_url,
|
||||
@@ -70,6 +49,39 @@ fn saves_subscription_url_without_exposing_secret_query() {
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[test]
|
||||
fn singbox_status_exposes_dev_subscription_headers_without_hwid() {
|
||||
let root = test_root("dev-subscription-identity");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
|
||||
let status = read_singbox_status(&storage).expect("read sing-box status");
|
||||
let headers = status
|
||||
.subscription_identity
|
||||
.headers
|
||||
.iter()
|
||||
.map(|header| (header.name.clone(), header.value.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let expected_headers = subscription::SubscriptionFetchIdentity::default()
|
||||
.request_headers_without_device_hwid()
|
||||
.into_iter()
|
||||
.map(|(name, value)| (name.to_string(), value))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(headers, expected_headers);
|
||||
assert!(headers
|
||||
.iter()
|
||||
.all(|(name, _)| !name.eq_ignore_ascii_case("x-hwid")));
|
||||
let serialized = serde_json::to_value(&status).expect("serialize status");
|
||||
assert!(serialized.get("subscriptionIdentity").is_some());
|
||||
assert!(!serialized
|
||||
.to_string()
|
||||
.to_ascii_lowercase()
|
||||
.contains("x-hwid"));
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_http_subscription_url() {
|
||||
let root = test_root("invalid-subscription");
|
||||
@@ -127,6 +139,64 @@ fn fetches_subscription_cache_and_selects_first_server() {
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetches_subscription_with_saved_device_hwid() {
|
||||
let root = test_root("fetch-subscription-hwid");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
save_singbox_subscription_to_storage(
|
||||
&storage,
|
||||
SaveSingBoxSubscriptionInputDto {
|
||||
subscription_url: "https://sub.example.test/path?token=secret".to_string(),
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
.expect("save subscription URL and generated HWID");
|
||||
let expected_hwid = storage
|
||||
.read_local_singbox_config()
|
||||
.expect("read local sing-box config")
|
||||
.device_hwid
|
||||
.expect("generated HWID");
|
||||
|
||||
fetch_singbox_subscription_with_fetcher(
|
||||
&storage,
|
||||
&HwidAssertingFetcher {
|
||||
cache: sample_cache(),
|
||||
expected_hwid,
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
.expect("fetch subscription through mock");
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_generates_device_hwid_for_existing_subscription_without_one() {
|
||||
let root = test_root("fetch-generates-hwid");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
storage
|
||||
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/path?token=secret".to_string()),
|
||||
device_hwid: None,
|
||||
..LocalSingBoxConfig::default()
|
||||
})
|
||||
.expect("write local sing-box config");
|
||||
|
||||
fetch_singbox_subscription_with_fetcher(
|
||||
&storage,
|
||||
&GeneratedHwidAssertingFetcher(sample_cache()),
|
||||
&FixedClock,
|
||||
)
|
||||
.expect("fetch subscription through mock");
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.expect("read local sing-box config");
|
||||
|
||||
assert_valid_generated_hwid(config.device_hwid.as_deref());
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_server_from_cached_subscription() {
|
||||
let root = test_root("select-server");
|
||||
@@ -138,6 +208,7 @@ fn selects_server_from_cached_subscription() {
|
||||
let status = select_singbox_server_in_storage(
|
||||
&storage,
|
||||
SelectSingBoxServerInputDto {
|
||||
id: Some("trojan|de-1|de.example.test|443".to_string()),
|
||||
tag: "de-1".to_string(),
|
||||
server: None,
|
||||
server_port: None,
|
||||
@@ -150,7 +221,47 @@ fn selects_server_from_cached_subscription() {
|
||||
.expect("read local sing-box config");
|
||||
|
||||
assert_eq!(status.config.selected_server_tag, Some("de-1".to_string()));
|
||||
assert_eq!(
|
||||
status.config.selected_server_id,
|
||||
Some("trojan|de-1|de.example.test|443".to_string())
|
||||
);
|
||||
assert_eq!(config.selected_server_tag, Some("de-1".to_string()));
|
||||
assert_eq!(
|
||||
config.selected_server_id,
|
||||
Some("trojan|de-1|de.example.test|443".to_string())
|
||||
);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_duplicate_tag_by_stable_server_id() {
|
||||
let root = test_root("select-duplicate-tag");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let mut cache = sample_cache();
|
||||
cache.servers[1].tag = "nl-1".to_string();
|
||||
cache.servers[1].id = "trojan|nl-1|de.example.test|443".to_string();
|
||||
storage
|
||||
.write_singbox_subscription_cache(&cache)
|
||||
.expect("write cache");
|
||||
|
||||
let status = select_singbox_server_in_storage(
|
||||
&storage,
|
||||
SelectSingBoxServerInputDto {
|
||||
id: Some("trojan|nl-1|de.example.test|443".to_string()),
|
||||
tag: "nl-1".to_string(),
|
||||
server: Some("de.example.test".to_string()),
|
||||
server_port: Some(443),
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
.expect("stable id should select the second duplicate tag");
|
||||
|
||||
assert_eq!(
|
||||
status.config.selected_server_id,
|
||||
Some("trojan|nl-1|de.example.test|443".to_string())
|
||||
);
|
||||
assert_eq!(status.config.selected_server_tag, Some("nl-1".to_string()));
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
@@ -166,6 +277,7 @@ fn selects_server_by_endpoint_when_display_tag_is_sanitized() {
|
||||
let status = select_singbox_server_in_storage(
|
||||
&storage,
|
||||
SelectSingBoxServerInputDto {
|
||||
id: None,
|
||||
tag: "Умный".to_string(),
|
||||
server: Some("media.example.test".to_string()),
|
||||
server_port: Some(443),
|
||||
@@ -193,6 +305,7 @@ fn generate_writes_config_and_local_singbox_target() {
|
||||
storage
|
||||
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/path".to_string()),
|
||||
device_hwid: Some("C34C14C9-94BC-4918-B053-A249BC117A91".to_string()),
|
||||
selected_server_tag: Some("nl-1".to_string()),
|
||||
..LocalSingBoxConfig::default()
|
||||
})
|
||||
@@ -256,9 +369,11 @@ fn generate_requires_cached_subscription() {
|
||||
fn forget_subscription_clears_url_selection_and_cache() {
|
||||
let root = test_root("forget-subscription");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let existing_hwid = "C34C14C9-94BC-4918-B053-A249BC117A91".to_string();
|
||||
storage
|
||||
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/path".to_string()),
|
||||
device_hwid: Some(existing_hwid.clone()),
|
||||
selected_server_tag: Some("nl-1".to_string()),
|
||||
..LocalSingBoxConfig::default()
|
||||
})
|
||||
@@ -278,6 +393,7 @@ fn forget_subscription_clears_url_selection_and_cache() {
|
||||
|
||||
assert!(!status.config.has_subscription);
|
||||
assert_eq!(config.subscription_url, None);
|
||||
assert_eq!(config.device_hwid, Some(existing_hwid));
|
||||
assert_eq!(config.selected_server_tag, None);
|
||||
assert_eq!(cache, None);
|
||||
|
||||
@@ -290,12 +406,59 @@ impl SubscriptionFetcher for MockFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
url: &str,
|
||||
_identity: &subscription::SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||
assert_eq!(url, "https://sub.example.test/path?token=secret");
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct HwidAssertingFetcher {
|
||||
cache: SubscriptionCache,
|
||||
expected_hwid: String,
|
||||
}
|
||||
|
||||
impl SubscriptionFetcher for HwidAssertingFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
url: &str,
|
||||
identity: &subscription::SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||
assert_eq!(url, "https://sub.example.test/path?token=secret");
|
||||
assert_eq!(
|
||||
identity.device_hwid.as_deref(),
|
||||
Some(self.expected_hwid.as_str())
|
||||
);
|
||||
assert_eq!(identity.app_name, "ProxyWarden");
|
||||
assert!(identity.user_agent.starts_with("ProxyWarden/"));
|
||||
assert_eq!(identity.device_os, std::env::consts::OS);
|
||||
assert_eq!(identity.device_model, "ProxyWarden");
|
||||
Ok(self.cache.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct GeneratedHwidAssertingFetcher(SubscriptionCache);
|
||||
|
||||
impl SubscriptionFetcher for GeneratedHwidAssertingFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
url: &str,
|
||||
identity: &subscription::SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||
assert_eq!(url, "https://sub.example.test/path?token=secret");
|
||||
assert_valid_generated_hwid(identity.device_hwid.as_deref());
|
||||
assert_eq!(identity.app_name, "ProxyWarden");
|
||||
assert!(identity.user_agent.starts_with("ProxyWarden/"));
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_valid_generated_hwid(value: Option<&str>) {
|
||||
let value = value.expect("generated HWID");
|
||||
uuid::Uuid::parse_str(value).expect("HWID should be a UUID");
|
||||
assert_eq!(value, value.to_ascii_uppercase());
|
||||
}
|
||||
|
||||
struct MockChecker;
|
||||
|
||||
impl SingBoxConfigChecker for MockChecker {
|
||||
@@ -344,12 +507,14 @@ fn sample_cache() -> SubscriptionCache {
|
||||
}),
|
||||
servers: vec![
|
||||
SubscriptionServer {
|
||||
id: "vless|nl-1|nl.example.test|443".to_string(),
|
||||
tag: "nl-1".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "nl.example.test".to_string(),
|
||||
server_port: 443,
|
||||
},
|
||||
SubscriptionServer {
|
||||
id: "trojan|de-1|de.example.test|443".to_string(),
|
||||
tag: "de-1".to_string(),
|
||||
server_type: "trojan".to_string(),
|
||||
server: "de.example.test".to_string(),
|
||||
@@ -375,6 +540,7 @@ fn sample_cache_with_flag_tag() -> SubscriptionCache {
|
||||
]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
id: "vless|Умный 🇳🇱->🇷🇺|media.example.test|443".to_string(),
|
||||
tag: "Умный 🇳🇱->🇷🇺".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "media.example.test".to_string(),
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
#[path = "../src/component_detection.rs"]
|
||||
mod component_detection;
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/process.rs"]
|
||||
mod process;
|
||||
#[path = "../src/singbox_service.rs"]
|
||||
mod singbox_service;
|
||||
|
||||
use component_detection::DetectedSingBox;
|
||||
use singbox_service::{
|
||||
use proxywarden_lib::component_detection::DetectedSingBox;
|
||||
use proxywarden_lib::singbox_service::{
|
||||
build_singbox_setup_status, ensure_safe_singbox_install_dir, parse_service_command_output,
|
||||
service_control_script, SingBoxServiceAction,
|
||||
};
|
||||
@@ -56,10 +47,10 @@ noise
|
||||
|
||||
#[test]
|
||||
fn safe_install_dir_allows_only_proxywarden_singbox_folder() {
|
||||
assert!(
|
||||
ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\ProxyWarden\sing-box"))
|
||||
.is_ok()
|
||||
);
|
||||
assert!(ensure_safe_singbox_install_dir(Path::new(
|
||||
r"C:\Program Files\ProxyWarden\components\sing-box"
|
||||
))
|
||||
.is_ok());
|
||||
assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Windows")).is_err());
|
||||
assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\sing-box")).is_err());
|
||||
}
|
||||
@@ -81,7 +72,7 @@ fn service_control_script_targets_named_service_and_action() {
|
||||
#[test]
|
||||
fn service_control_script_syncs_generated_config_before_start() {
|
||||
let source = Path::new(r"C:\ProgramData\ProxyWarden\generated\sing-box-config.json");
|
||||
let target = Path::new(r"C:\Program Files\ProxyWarden\sing-box\config.json");
|
||||
let target = Path::new(r"C:\Program Files\ProxyWarden\components\sing-box\config.json");
|
||||
let script = service_control_script(
|
||||
SingBoxServiceAction::Start,
|
||||
"ProxyWardenSingBox",
|
||||
@@ -92,9 +83,9 @@ fn service_control_script_syncs_generated_config_before_start() {
|
||||
assert!(script.contains(
|
||||
"$configSource = 'C:\\ProgramData\\ProxyWarden\\generated\\sing-box-config.json'"
|
||||
));
|
||||
assert!(
|
||||
script.contains("$configTarget = 'C:\\Program Files\\ProxyWarden\\sing-box\\config.json'")
|
||||
);
|
||||
assert!(script.contains(
|
||||
"$configTarget = 'C:\\Program Files\\ProxyWarden\\components\\sing-box\\config.json'"
|
||||
));
|
||||
assert!(script.contains("Copy-Item -LiteralPath $configSource"));
|
||||
assert!(script.contains("'config_sync_failed'"));
|
||||
}
|
||||
@@ -125,10 +116,12 @@ fn install_singbox_script_parses_as_powershell() {
|
||||
|
||||
fn detected_singbox(binary_exists: bool, wrapper_exists: bool, running: bool) -> DetectedSingBox {
|
||||
DetectedSingBox {
|
||||
install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box"),
|
||||
executable_path: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe"),
|
||||
install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\components\sing-box"),
|
||||
executable_path: PathBuf::from(
|
||||
r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe",
|
||||
),
|
||||
wrapper_path: PathBuf::from(
|
||||
r"C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe",
|
||||
r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe",
|
||||
),
|
||||
binary_exists,
|
||||
wrapper_exists,
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
#[path = "../src/activity.rs"]
|
||||
mod activity;
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/storage.rs"]
|
||||
mod storage;
|
||||
|
||||
use models::{
|
||||
use proxywarden_lib::models::{
|
||||
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
|
||||
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache,
|
||||
SubscriptionServer, Target, TargetKind,
|
||||
};
|
||||
use proxywarden_lib::storage::{backup_path, default_config_root, JsonStorage, StoragePaths};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use storage::{backup_path, default_config_root, JsonStorage, StoragePaths};
|
||||
|
||||
#[test]
|
||||
fn storage_defaults_to_programdata_root() {
|
||||
@@ -59,11 +52,13 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let config = LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/path?token=secret".to_string()),
|
||||
device_hwid: Some("hwid-abcdef1234".to_string()),
|
||||
selected_server_tag: Some("nl-1".to_string()),
|
||||
selected_server_id: Some("vless|nl-1|nl.example.test|443".to_string()),
|
||||
listen_host: "127.0.0.1".to_string(),
|
||||
listen_port: 1080,
|
||||
service_name: "ProxyWardenSingBox".to_string(),
|
||||
install_root: r"C:\Program Files\ProxyWarden\sing-box".to_string(),
|
||||
install_root: r"C:\Program Files\ProxyWarden\components\sing-box".to_string(),
|
||||
updated_at: Some("2026-07-07T10:00:00Z".to_string()),
|
||||
};
|
||||
let cache = sample_subscription_cache();
|
||||
@@ -91,7 +86,6 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
|
||||
config.subscription_display_url(),
|
||||
Some("https://sub.example.test/...".to_string())
|
||||
);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
@@ -104,6 +98,7 @@ fn missing_local_singbox_config_defaults_to_optional_empty_state() {
|
||||
.expect("read default local sing-box config");
|
||||
|
||||
assert_eq!(config.subscription_url, None);
|
||||
assert_eq!(config.device_hwid, None);
|
||||
assert_eq!(config.selected_server_tag, None);
|
||||
assert_eq!(config.listen_host, "127.0.0.1");
|
||||
assert_eq!(config.listen_port, 1080);
|
||||
@@ -113,7 +108,60 @@ fn missing_local_singbox_config_defaults_to_optional_empty_state() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_subscription_cache_falls_back_to_none() {
|
||||
fn reads_percent_encoded_singbox_tags_as_utf8() {
|
||||
let root = test_root("local-singbox-percent-tags");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let encoded_tag =
|
||||
"%D0%A3%D0%BC%D0%BD%D1%8B%D0%B9%20%F0%9F%87%B3%F0%9F%87%B1-%3E%F0%9F%87%B7%F0%9F%87%BA";
|
||||
let decoded_tag = "Умный 🇳🇱->🇷🇺";
|
||||
|
||||
storage
|
||||
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||
selected_server_tag: Some(encoded_tag.to_string()),
|
||||
..LocalSingBoxConfig::default()
|
||||
})
|
||||
.expect("write local sing-box config");
|
||||
storage
|
||||
.write_singbox_subscription_cache(&SubscriptionCache {
|
||||
config: serde_json::json!({
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": encoded_tag,
|
||||
"server": "nl.example.test",
|
||||
"server_port": 443
|
||||
}
|
||||
]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
id: String::new(),
|
||||
tag: encoded_tag.to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "nl.example.test".to_string(),
|
||||
server_port: 443,
|
||||
}],
|
||||
user_info: serde_json::Map::new(),
|
||||
fetched_at: "2026-07-07T10:00:00Z".to_string(),
|
||||
})
|
||||
.expect("write subscription cache");
|
||||
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.expect("read local sing-box config");
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.expect("read subscription cache")
|
||||
.expect("subscription cache");
|
||||
|
||||
assert_eq!(config.selected_server_tag, Some(decoded_tag.to_string()));
|
||||
assert_eq!(cache.servers[0].tag, decoded_tag);
|
||||
assert_eq!(cache.config["outbounds"][0]["tag"], decoded_tag);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_subscription_cache_without_backup_returns_error_and_moves_corrupt_file() {
|
||||
let root = test_root("invalid-subscription-cache");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
fs::create_dir_all(&storage.paths().state_dir).expect("create state dir");
|
||||
@@ -123,27 +171,62 @@ fn invalid_subscription_cache_falls_back_to_none() {
|
||||
)
|
||||
.expect("write invalid cache");
|
||||
|
||||
assert_eq!(
|
||||
storage
|
||||
.read_singbox_subscription_cache()
|
||||
.expect("invalid cache fallback"),
|
||||
None
|
||||
);
|
||||
let error = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.expect_err("invalid cache should not silently fallback");
|
||||
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(!storage.paths().singbox_subscription_cache_file.exists());
|
||||
assert!(has_corrupt_sibling(
|
||||
&storage.paths().singbox_subscription_cache_file
|
||||
));
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_falls_back_to_empty_collection() {
|
||||
let root = test_root("invalid-json");
|
||||
fn invalid_json_without_backup_returns_error_and_moves_corrupt_file() {
|
||||
let root = test_root("invalid-json-no-backup");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
fs::create_dir_all(&storage.paths().config_dir).expect("create config dir");
|
||||
fs::write(&storage.paths().profiles_file, "{not valid json").expect("write invalid json");
|
||||
|
||||
let error = storage
|
||||
.read_profiles()
|
||||
.expect_err("invalid profiles should not silently fallback");
|
||||
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(!storage.paths().profiles_file.exists());
|
||||
assert!(has_corrupt_sibling(&storage.paths().profiles_file));
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_recovers_from_valid_backup() {
|
||||
let root = test_root("invalid-json-valid-backup");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let backup_profiles = vec![sample_profile("backup")];
|
||||
let current_profiles = vec![sample_profile("current")];
|
||||
|
||||
storage
|
||||
.write_profiles(&backup_profiles)
|
||||
.expect("write first profiles");
|
||||
storage
|
||||
.write_profiles(¤t_profiles)
|
||||
.expect("write second profiles");
|
||||
fs::write(&storage.paths().profiles_file, "{not valid json").expect("corrupt live json");
|
||||
|
||||
let recovered = storage
|
||||
.read_profiles()
|
||||
.expect("invalid profiles should recover from valid backup");
|
||||
|
||||
assert_eq!(recovered, backup_profiles);
|
||||
assert_eq!(
|
||||
storage.read_profiles().expect("invalid profiles fallback"),
|
||||
Vec::<Profile>::new()
|
||||
storage.read_profiles().expect("restored live profiles"),
|
||||
backup_profiles
|
||||
);
|
||||
assert!(has_corrupt_sibling(&storage.paths().profiles_file));
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
@@ -233,6 +316,26 @@ fn write_json<T: serde::Serialize + ?Sized>(path: &Path, value: &T) {
|
||||
fs::write(path, contents).expect("write json");
|
||||
}
|
||||
|
||||
fn has_corrupt_sibling(path: &Path) -> bool {
|
||||
let Some(parent) = path.parent() else {
|
||||
return false;
|
||||
};
|
||||
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
let prefix = format!("{file_name}.corrupt.");
|
||||
|
||||
fs::read_dir(parent)
|
||||
.expect("read sibling dir")
|
||||
.filter_map(Result::ok)
|
||||
.any(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| name.starts_with(&prefix))
|
||||
})
|
||||
}
|
||||
|
||||
fn sample_profile(id: &str) -> Profile {
|
||||
Profile {
|
||||
id: id.to_string(),
|
||||
@@ -269,6 +372,8 @@ fn sample_component() -> ComponentStatus {
|
||||
running: false,
|
||||
version: None,
|
||||
path: None,
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
service_status: None,
|
||||
problems: vec!["ProxiFyre не установлен".to_string()],
|
||||
actions: vec!["Установить ProxiFyre".to_string()],
|
||||
}
|
||||
@@ -287,6 +392,7 @@ fn sample_subscription_cache() -> SubscriptionCache {
|
||||
]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
id: "vless|nl-1|nl.example.test|443".to_string(),
|
||||
tag: "nl-1".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "nl.example.test".to_string(),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/subscription.rs"]
|
||||
mod subscription;
|
||||
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use models::redact_subscription_url;
|
||||
use subscription::{parse_subscription_body, parse_user_info};
|
||||
use proxywarden_lib::models::redact_subscription_url;
|
||||
use proxywarden_lib::subscription::{
|
||||
self, parse_subscription_body, parse_user_info, validate_resolved_subscription_addresses,
|
||||
SubscriptionFetchIdentity, SubscriptionFetchPolicy,
|
||||
};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn parses_singbox_json_config_servers() {
|
||||
@@ -26,6 +27,29 @@ fn parses_singbox_json_config_servers() {
|
||||
assert_eq!(parsed.servers[1].server_port, 8443);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_ids_are_opaque_and_distinguish_credentials_on_same_endpoint() {
|
||||
let parsed = parse_subscription_body(
|
||||
r#"{
|
||||
"outbounds": [
|
||||
{ "type": "vless", "tag": "same", "server": "edge.example.test", "server_port": 443, "uuid": "11111111-1111-1111-1111-111111111111" },
|
||||
{ "type": "vless", "tag": "same", "server": "edge.example.test", "server_port": 443, "uuid": "22222222-2222-2222-2222-222222222222" }
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("duplicate endpoint subscription should parse");
|
||||
|
||||
assert_ne!(parsed.servers[0].id, parsed.servers[1].id);
|
||||
assert!(parsed
|
||||
.servers
|
||||
.iter()
|
||||
.all(|server| server.id.starts_with("pw-")));
|
||||
assert!(parsed
|
||||
.servers
|
||||
.iter()
|
||||
.all(|server| !server.id.contains("11111111")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_base64_vless_link_list() {
|
||||
let link = sample_vless_link("nl-1");
|
||||
@@ -41,6 +65,58 @@ fn parses_base64_vless_link_list() {
|
||||
assert_eq!(outbound["packet_encoding"], "xudp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_trojan_shadowsocks_and_vmess_link_formats() {
|
||||
let vmess_payload = serde_json::json!({
|
||||
"v": "2",
|
||||
"ps": "VMess NL",
|
||||
"add": "vmess.example.test",
|
||||
"port": "443",
|
||||
"id": "33333333-3333-3333-3333-333333333333",
|
||||
"scy": "auto",
|
||||
"net": "ws",
|
||||
"host": "cdn.example.test",
|
||||
"path": "/ws",
|
||||
"tls": "tls",
|
||||
"sni": "vmess.example.test"
|
||||
});
|
||||
let vmess_link = format!(
|
||||
"vmess://{}",
|
||||
general_purpose::STANDARD_NO_PAD.encode(vmess_payload.to_string())
|
||||
);
|
||||
let body = format!(
|
||||
"trojan://secret@trojan.example.test:443?sni=edge.example.test#Trojan%20DE\nss://aes-256-gcm:password@ss.example.test:8388#SS%20US\n{vmess_link}"
|
||||
);
|
||||
|
||||
let parsed = parse_subscription_body(&body).expect("supported link formats should parse");
|
||||
|
||||
assert_eq!(parsed.servers.len(), 3);
|
||||
assert_eq!(parsed.servers[0].server_type, "trojan");
|
||||
assert_eq!(parsed.servers[0].tag, "Trojan DE");
|
||||
assert_eq!(
|
||||
parsed.config["outbounds"][0]["tls"]["server_name"],
|
||||
"edge.example.test"
|
||||
);
|
||||
assert_eq!(parsed.servers[1].server_type, "shadowsocks");
|
||||
assert_eq!(parsed.config["outbounds"][1]["method"], "aes-256-gcm");
|
||||
assert_eq!(parsed.servers[2].server_type, "vmess");
|
||||
assert_eq!(parsed.config["outbounds"][2]["transport"]["type"], "ws");
|
||||
assert_eq!(parsed.config["outbounds"][2]["tls"]["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_percent_encoded_vless_fragment_tag() {
|
||||
let link = sample_vless_link(
|
||||
"%D0%A3%D0%BC%D0%BD%D1%8B%D0%B9%20%F0%9F%87%B3%F0%9F%87%B1-%3E%F0%9F%87%B7%F0%9F%87%BA",
|
||||
);
|
||||
|
||||
let parsed = parse_subscription_body(&link).expect("vless link should parse");
|
||||
let outbound = &parsed.config["outbounds"][0];
|
||||
|
||||
assert_eq!(parsed.servers[0].tag, "Умный 🇳🇱->🇷🇺");
|
||||
assert_eq!(outbound["tag"], "Умный 🇳🇱->🇷🇺");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_body_without_supported_outbounds() {
|
||||
let error = parse_subscription_body(r#"{"outbounds":[{"type":"direct","tag":"direct"}]}"#)
|
||||
@@ -78,6 +154,102 @@ fn rejects_invalid_or_non_http_subscription_url_before_network() {
|
||||
assert!(unsupported.message.contains("http or https"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsafe_local_subscription_urls_before_network() {
|
||||
for url in [
|
||||
"http://127.0.0.1:9/subscription",
|
||||
"http://localhost/subscription",
|
||||
"http://169.254.169.254/latest/meta-data",
|
||||
"http://192.168.0.1/subscription",
|
||||
"http://[::1]/subscription",
|
||||
] {
|
||||
let error = subscription::fetch_subscription(url)
|
||||
.expect_err("unsafe local URL should fail before request");
|
||||
assert!(
|
||||
error.message.contains("local, private"),
|
||||
"unexpected error for {url}: {}",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_dns_results_containing_private_or_metadata_addresses() {
|
||||
for ip in [
|
||||
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
|
||||
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
|
||||
IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
|
||||
] {
|
||||
let error = validate_resolved_subscription_addresses(&[SocketAddr::new(ip, 443)])
|
||||
.expect_err("unsafe resolved address should be blocked");
|
||||
assert!(error.message.contains("resolves to"));
|
||||
}
|
||||
|
||||
validate_resolved_subscription_addresses(&[SocketAddr::new(
|
||||
IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)),
|
||||
443,
|
||||
)])
|
||||
.expect("public resolved address should be accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_subscription_sends_device_hwid_header_when_identity_is_set() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test listener");
|
||||
let url = format!("http://{}/subscription", listener.local_addr().unwrap());
|
||||
let request_thread = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("accept test request");
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.expect("set read timeout");
|
||||
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 512];
|
||||
loop {
|
||||
let bytes_read = stream.read(&mut buffer).expect("read request");
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&buffer[..bytes_read]);
|
||||
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let body = r#"{"outbounds":[{"type":"vless","tag":"nl-1","server":"nl.example.test","server_port":443}]}"#;
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.expect("write response");
|
||||
|
||||
String::from_utf8_lossy(&request).to_ascii_lowercase()
|
||||
});
|
||||
|
||||
let mut identity = SubscriptionFetchIdentity::with_device_hwid(Some("hwid-abc123"));
|
||||
identity.device_os_version = Some("Windows 11 Pro | 25H2 | build 26200.8655".to_string());
|
||||
let cache = subscription::fetch_subscription_with_identity_and_policy(
|
||||
&url,
|
||||
&identity,
|
||||
SubscriptionFetchPolicy {
|
||||
allow_unsafe_local_urls: true,
|
||||
},
|
||||
)
|
||||
.expect("fetch subscription through local test server");
|
||||
let request = request_thread.join().expect("request thread");
|
||||
|
||||
assert_eq!(cache.servers[0].tag, "nl-1");
|
||||
assert!(request.contains("x-hwid: hwid-abc123"));
|
||||
assert!(request.contains("user-agent: proxywarden/"));
|
||||
assert!(request.contains("x-app-name: proxywarden"));
|
||||
assert!(request.contains("x-device-os:"));
|
||||
assert!(request.contains("x-device-os-version: windows 11 pro | 25h2 | build 26200.8655"));
|
||||
assert!(request.contains("x-ver-os: windows 11 pro | 25h2 | build 26200.8655"));
|
||||
assert!(request.contains("x-device-model: proxywarden"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_subscription_url_for_display() {
|
||||
assert_eq!(
|
||||
@@ -86,7 +258,13 @@ fn redacts_subscription_url_for_display() {
|
||||
);
|
||||
assert_eq!(
|
||||
redact_subscription_url("vless://uuid@example.test"),
|
||||
"vless://uuid@example.test/..."
|
||||
"vless://example.test/..."
|
||||
);
|
||||
assert_eq!(
|
||||
redact_subscription_url(
|
||||
"https://user:password@sub.example.test:8443/path?token=secret#frag"
|
||||
),
|
||||
"https://sub.example.test:8443/..."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+100
-89
@@ -1,4 +1,4 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
ActivityEntry,
|
||||
ComponentStatus,
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
SubscriptionServer,
|
||||
Target,
|
||||
TargetInput,
|
||||
} from '../domain/types';
|
||||
} from "../domain/types";
|
||||
|
||||
export interface CommandError {
|
||||
code: string;
|
||||
@@ -20,16 +20,6 @@ export interface CommandError {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
routeLine: string;
|
||||
activeProfileCount: number;
|
||||
routedAppCount: number;
|
||||
activeTarget?: Target;
|
||||
components: ComponentStatus[];
|
||||
recentActivity: ActivityEntry[];
|
||||
generatedConfigPath: string;
|
||||
}
|
||||
|
||||
export interface AdminStatusResponse {
|
||||
isWindows: boolean;
|
||||
isElevated: boolean;
|
||||
@@ -66,6 +56,15 @@ export interface ProxiFyreSetupStatus {
|
||||
items: ProxiFyreSetupItem[];
|
||||
}
|
||||
|
||||
export interface ProxiFyreSetupProgress {
|
||||
operation: "idle" | "install" | "uninstall" | string;
|
||||
status: "idle" | "running" | "succeeded" | "failed" | string;
|
||||
activeStep?: string;
|
||||
percent: number;
|
||||
message: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export type SingBoxSetupItem = ProxiFyreSetupItem;
|
||||
|
||||
export interface SingBoxSetupStatus {
|
||||
@@ -80,9 +79,20 @@ export interface LocalSingBoxStatusResponse {
|
||||
component: ComponentStatus;
|
||||
generatedConfigPath: string;
|
||||
lanListenHost?: string;
|
||||
subscriptionIdentity?: SubscriptionRequestIdentity;
|
||||
}
|
||||
|
||||
export interface SubscriptionRequestIdentity {
|
||||
headers: SubscriptionRequestHeader[];
|
||||
}
|
||||
|
||||
export interface SubscriptionRequestHeader {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PingServerResponse {
|
||||
id: string;
|
||||
tag: string;
|
||||
server: string;
|
||||
serverPort: number;
|
||||
@@ -91,6 +101,34 @@ export interface PingServerResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type ApplyPhaseStatus =
|
||||
"succeeded" | "failed" | "rolledback" | "skipped" | "warning";
|
||||
|
||||
export interface ApplyPhase {
|
||||
id: string;
|
||||
status: ApplyPhaseStatus;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ApplyConfigurationInput {
|
||||
routeMode: "external" | "local-singbox";
|
||||
profile: ProfileInput;
|
||||
externalTarget?: TargetInput;
|
||||
disableOtherProfiles?: boolean;
|
||||
}
|
||||
|
||||
export interface ApplyConfigurationResult {
|
||||
success: boolean;
|
||||
changed: boolean;
|
||||
partialState: boolean;
|
||||
message: string;
|
||||
errorCode?: string;
|
||||
generatedConfigPath: string;
|
||||
singboxGeneratedConfigPath?: string;
|
||||
restartRequired: Array<"control-app" | "proxyfier" | "singbox">;
|
||||
phases: ApplyPhase[];
|
||||
}
|
||||
|
||||
export interface ProxyProbeResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -128,94 +166,60 @@ export interface GenerateSingBoxConfigResponse {
|
||||
activity: ActivityEntry;
|
||||
}
|
||||
|
||||
export interface HelperApplyResult {
|
||||
success: boolean;
|
||||
changed: boolean;
|
||||
action: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ApplyProfilesResponse {
|
||||
success: boolean;
|
||||
changed: boolean;
|
||||
message: string;
|
||||
adapterId: string;
|
||||
generatedConfigPath: string;
|
||||
enabledProfiles: number;
|
||||
routedApps: number;
|
||||
helper: HelperApplyResult;
|
||||
activity: ActivityEntry;
|
||||
}
|
||||
|
||||
export function getStatus(): Promise<StatusResponse> {
|
||||
return invoke<StatusResponse>('get_status');
|
||||
}
|
||||
|
||||
export function getAdminStatus(): Promise<AdminStatusResponse> {
|
||||
return invoke<AdminStatusResponse>('get_admin_status');
|
||||
}
|
||||
|
||||
export function restartAsAdmin(): Promise<void> {
|
||||
return invoke<void>('restart_as_admin');
|
||||
return invoke<void>("restart_as_admin");
|
||||
}
|
||||
|
||||
export function getStartupSnapshot(): Promise<StartupSnapshotResponse> {
|
||||
return invoke<StartupSnapshotResponse>('get_startup_snapshot');
|
||||
return invoke<StartupSnapshotResponse>("get_startup_snapshot");
|
||||
}
|
||||
|
||||
export function getSavedState(): Promise<SavedStateResponse> {
|
||||
return invoke<SavedStateResponse>('get_saved_state');
|
||||
}
|
||||
|
||||
export function getProfiles(): Promise<Profile[]> {
|
||||
return invoke<Profile[]>('get_profiles');
|
||||
}
|
||||
|
||||
export function saveProfile(input: ProfileInput): Promise<Profile> {
|
||||
return invoke<Profile>('save_profile', { input });
|
||||
}
|
||||
|
||||
export function getTargets(): Promise<Target[]> {
|
||||
return invoke<Target[]>('get_targets');
|
||||
}
|
||||
|
||||
export function saveTarget(input: TargetInput): Promise<Target> {
|
||||
return invoke<Target>('save_target', { input });
|
||||
return invoke<SavedStateResponse>("get_saved_state");
|
||||
}
|
||||
|
||||
export function getComponents(): Promise<ComponentStatus[]> {
|
||||
return invoke<ComponentStatus[]>('get_components');
|
||||
return invoke<ComponentStatus[]>("get_components");
|
||||
}
|
||||
|
||||
export function getProxiFyreSetupStatus(): Promise<ProxiFyreSetupStatus> {
|
||||
return invoke<ProxiFyreSetupStatus>('get_proxifyre_setup_status');
|
||||
return invoke<ProxiFyreSetupStatus>("get_proxifyre_setup_status");
|
||||
}
|
||||
|
||||
export function getProxiFyreSetupProgress(): Promise<ProxiFyreSetupProgress> {
|
||||
return invoke<ProxiFyreSetupProgress>("get_proxifyre_setup_progress");
|
||||
}
|
||||
|
||||
export function getSingBoxStatus(): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>('get_singbox_status');
|
||||
return invoke<LocalSingBoxStatusResponse>("get_singbox_status");
|
||||
}
|
||||
|
||||
export function getSingBoxSetupStatus(): Promise<SingBoxSetupStatus> {
|
||||
return invoke<SingBoxSetupStatus>('get_singbox_setup_status');
|
||||
return invoke<SingBoxSetupStatus>("get_singbox_setup_status");
|
||||
}
|
||||
|
||||
export function saveSingBoxSubscription(subscriptionUrl: string): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>('save_singbox_subscription', {
|
||||
export function saveSingBoxSubscription(
|
||||
subscriptionUrl: string,
|
||||
): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>("save_singbox_subscription", {
|
||||
input: { subscriptionUrl },
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchSingBoxSubscription(): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>('fetch_singbox_subscription');
|
||||
return invoke<LocalSingBoxStatusResponse>("fetch_singbox_subscription");
|
||||
}
|
||||
|
||||
export function forgetSingBoxSubscription(): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>('forget_singbox_subscription');
|
||||
return invoke<LocalSingBoxStatusResponse>("forget_singbox_subscription");
|
||||
}
|
||||
|
||||
export function selectSingBoxServer(server: SubscriptionServer): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>('select_singbox_server', {
|
||||
export function selectSingBoxServer(
|
||||
server: SubscriptionServer,
|
||||
): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>("select_singbox_server", {
|
||||
input: {
|
||||
id: server.id,
|
||||
tag: server.tag,
|
||||
server: server.server,
|
||||
serverPort: server.serverPort,
|
||||
@@ -223,62 +227,69 @@ export function selectSingBoxServer(server: SubscriptionServer): Promise<LocalSi
|
||||
});
|
||||
}
|
||||
|
||||
export function pingSingBoxServer(tag: string): Promise<PingServerResponse> {
|
||||
return invoke<PingServerResponse>('ping_singbox_server', {
|
||||
input: { tag },
|
||||
export function pingSingBoxServer(
|
||||
server: SubscriptionServer,
|
||||
): Promise<PingServerResponse> {
|
||||
return invoke<PingServerResponse>("ping_singbox_server", {
|
||||
input: { id: server.id, tag: server.tag },
|
||||
});
|
||||
}
|
||||
|
||||
export function pingAllSingBoxServers(): Promise<PingServerResponse[]> {
|
||||
return invoke<PingServerResponse[]>('ping_all_singbox_servers');
|
||||
return invoke<PingServerResponse[]>("ping_all_singbox_servers");
|
||||
}
|
||||
|
||||
export function pingProxyTarget(host: string, port: number): Promise<ProxyTargetCheckResponse> {
|
||||
return invoke<ProxyTargetCheckResponse>('ping_proxy_target', {
|
||||
export function pingProxyTarget(
|
||||
host: string,
|
||||
port: number,
|
||||
): Promise<ProxyTargetCheckResponse> {
|
||||
return invoke<ProxyTargetCheckResponse>("ping_proxy_target", {
|
||||
input: { host, port },
|
||||
});
|
||||
}
|
||||
|
||||
export function generateSingBoxConfig(): Promise<GenerateSingBoxConfigResponse> {
|
||||
return invoke<GenerateSingBoxConfigResponse>('generate_singbox_config');
|
||||
return invoke<GenerateSingBoxConfigResponse>("generate_singbox_config");
|
||||
}
|
||||
|
||||
export function applyProfiles(): Promise<ApplyProfilesResponse> {
|
||||
return invoke<ApplyProfilesResponse>('apply_profiles');
|
||||
}
|
||||
|
||||
export function openConfigLocation(): Promise<string> {
|
||||
return invoke<string>('open_config_location');
|
||||
export function applyConfiguration(
|
||||
input: ApplyConfigurationInput,
|
||||
): Promise<ApplyConfigurationResult> {
|
||||
return invoke<ApplyConfigurationResult>("apply_configuration", { input });
|
||||
}
|
||||
|
||||
export function startProxiFyreService(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('start_proxifyre_service');
|
||||
return invoke<ComponentStatus>("start_proxifyre_service");
|
||||
}
|
||||
|
||||
export function stopProxiFyreService(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('stop_proxifyre_service');
|
||||
return invoke<ComponentStatus>("stop_proxifyre_service");
|
||||
}
|
||||
|
||||
export function installProxiFyre(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('install_proxifyre');
|
||||
return invoke<ComponentStatus>("install_proxifyre");
|
||||
}
|
||||
|
||||
export function configureProxiFyreFirewallRules(): Promise<void> {
|
||||
return invoke<void>("configure_proxifyre_firewall_rules");
|
||||
}
|
||||
|
||||
export function uninstallProxiFyre(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('uninstall_proxifyre');
|
||||
return invoke<ComponentStatus>("uninstall_proxifyre");
|
||||
}
|
||||
|
||||
export function startSingBoxService(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('start_singbox_service');
|
||||
return invoke<ComponentStatus>("start_singbox_service");
|
||||
}
|
||||
|
||||
export function stopSingBoxService(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('stop_singbox_service');
|
||||
return invoke<ComponentStatus>("stop_singbox_service");
|
||||
}
|
||||
|
||||
export function installSingBox(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('install_singbox');
|
||||
return invoke<ComponentStatus>("install_singbox");
|
||||
}
|
||||
|
||||
export function uninstallSingBox(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('uninstall_singbox');
|
||||
return invoke<ComponentStatus>("uninstall_singbox");
|
||||
}
|
||||
|
||||
+807
-1655
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
ApplyConfigurationResult,
|
||||
PingServerResponse,
|
||||
} from "../api/tauriCommands";
|
||||
import type { ComponentStatus } from "../domain/types";
|
||||
import {
|
||||
connectionCheckView,
|
||||
noticeFromConfigurationApply,
|
||||
pingSummary,
|
||||
routeChainSegments,
|
||||
summaryRouteChainSegments,
|
||||
} from "./viewModel";
|
||||
|
||||
const runningProxiFyre: ComponentStatus = {
|
||||
id: "proxyfier",
|
||||
name: "ProxiFyre",
|
||||
state: "running",
|
||||
installed: true,
|
||||
running: true,
|
||||
path: "C:\\Tools\\ProxiFyre\\ProxiFyre.exe",
|
||||
problems: [],
|
||||
actions: [],
|
||||
};
|
||||
|
||||
describe("App view helpers", () => {
|
||||
it("describes the external SOCKS5 route without Local sing-box", () => {
|
||||
const segments = routeChainSegments({
|
||||
routeMode: "external",
|
||||
proxyInput: "proxy.example.test:1080",
|
||||
proxyfier: runningProxiFyre,
|
||||
singbox: undefined,
|
||||
singBoxStatus: null,
|
||||
selectedServer: null,
|
||||
appCount: 2,
|
||||
isDetectingComponents: false,
|
||||
});
|
||||
|
||||
expect(segments.map((segment) => segment.id)).toEqual([
|
||||
"apps",
|
||||
"proxifyre",
|
||||
"endpoint",
|
||||
]);
|
||||
expect(segments[2]).toMatchObject({
|
||||
value: "proxy.example.test:1080",
|
||||
tone: "ok",
|
||||
});
|
||||
expect(segments[2].details).toContain(
|
||||
"Трафик пойдет через внешний SOCKS5.",
|
||||
);
|
||||
expect(segments[1].details).toEqual([
|
||||
"Служба запущена и готова к маршрутизации.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps three summary route slots while components are being detected", () => {
|
||||
const input = {
|
||||
routeMode: "external" as const,
|
||||
proxyInput: "",
|
||||
proxyfier: undefined,
|
||||
singbox: undefined,
|
||||
singBoxStatus: null,
|
||||
selectedServer: null,
|
||||
appCount: 0,
|
||||
isDetectingComponents: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
summaryRouteChainSegments(input, "idle").map(({ id }) => id),
|
||||
).toEqual(["apps", "proxifyre", "endpoint"]);
|
||||
});
|
||||
|
||||
it("keeps three summary route slots when traffic bypasses ProxiFyre", () => {
|
||||
const stoppedProxiFyre: ComponentStatus = {
|
||||
...runningProxiFyre,
|
||||
state: "stopped",
|
||||
running: false,
|
||||
};
|
||||
const segments = summaryRouteChainSegments(
|
||||
{
|
||||
routeMode: "external",
|
||||
proxyInput: "proxy.example.test:1080",
|
||||
proxyfier: stoppedProxiFyre,
|
||||
singbox: undefined,
|
||||
singBoxStatus: null,
|
||||
selectedServer: null,
|
||||
appCount: 2,
|
||||
isDetectingComponents: false,
|
||||
},
|
||||
"direct",
|
||||
);
|
||||
|
||||
expect(segments.map(({ id }) => id)).toEqual([
|
||||
"apps",
|
||||
"proxifyre",
|
||||
"endpoint",
|
||||
]);
|
||||
expect(segments[2]).toMatchObject({ label: "Интернет", value: "напрямую" });
|
||||
});
|
||||
|
||||
it("summarizes the fastest successful ping", () => {
|
||||
const results = [
|
||||
{ tag: "slow", ok: true, latency: 90 },
|
||||
{ tag: "failed", ok: false },
|
||||
{ tag: "fast", ok: true, latency: 20 },
|
||||
] as PingServerResponse[];
|
||||
|
||||
expect(pingSummary(results)).toBe("Ответили 2/3; быстрее fast: 20 ms.");
|
||||
});
|
||||
|
||||
it("builds an expandable route-check result from every backend probe", () => {
|
||||
const view = connectionCheckView({
|
||||
routeMode: "external",
|
||||
proxyInput: "192.168.50.111:8080",
|
||||
proxyCheck: {
|
||||
tag: "external",
|
||||
server: "192.168.50.111",
|
||||
serverPort: 8080,
|
||||
ok: true,
|
||||
latency: 14,
|
||||
probes: [
|
||||
{
|
||||
id: "cloudflare-trace",
|
||||
name: "Cloudflare Trace",
|
||||
url: "https://cloudflare.com/cdn-cgi/trace",
|
||||
ok: true,
|
||||
status: 200,
|
||||
latency: 21,
|
||||
ip: "203.0.113.10",
|
||||
},
|
||||
{
|
||||
id: "ipify",
|
||||
name: "ipify",
|
||||
url: "https://api.ipify.org",
|
||||
ok: false,
|
||||
error: "timeout",
|
||||
},
|
||||
],
|
||||
},
|
||||
singbox: undefined,
|
||||
singBoxStatus: null,
|
||||
selectedServer: null,
|
||||
isDetectingComponents: false,
|
||||
isProxyChecking: false,
|
||||
});
|
||||
|
||||
expect(view).toMatchObject({
|
||||
checked: true,
|
||||
endpoint: "192.168.50.111:8080",
|
||||
tone: "warning",
|
||||
});
|
||||
expect(view.probes.map((probe) => probe.id)).toEqual([
|
||||
"tcp",
|
||||
"cloudflare-trace",
|
||||
"ipify",
|
||||
]);
|
||||
expect(view.details).toContainEqual({
|
||||
label: "Cloudflare Trace",
|
||||
value: "IP 203.0.113.10 · HTTP 200 · 21 ms",
|
||||
});
|
||||
expect(view.probes[1]).toMatchObject({
|
||||
ip: "203.0.113.10",
|
||||
latency: "21 ms",
|
||||
status: "Доступна",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the result surface while the route check is running", () => {
|
||||
const view = connectionCheckView({
|
||||
routeMode: "external",
|
||||
proxyInput: "192.168.50.111:8080",
|
||||
proxyCheck: null,
|
||||
singbox: undefined,
|
||||
singBoxStatus: null,
|
||||
selectedServer: null,
|
||||
isDetectingComponents: false,
|
||||
isProxyChecking: true,
|
||||
});
|
||||
|
||||
expect(view).toMatchObject({ checked: false, loading: true });
|
||||
});
|
||||
|
||||
it("distinguishes rolled-back and partial apply failures", () => {
|
||||
const base: ApplyConfigurationResult = {
|
||||
success: false,
|
||||
changed: false,
|
||||
partialState: false,
|
||||
message: "Helper failed; previous files restored.",
|
||||
generatedConfigPath:
|
||||
"C:\\ProgramData\\ProxyWarden\\generated\\proxifyre-app-config.json",
|
||||
restartRequired: [],
|
||||
phases: [],
|
||||
};
|
||||
|
||||
expect(noticeFromConfigurationApply(base).title).toBe("Изменения отменены");
|
||||
expect(
|
||||
noticeFromConfigurationApply({ ...base, partialState: true }).title,
|
||||
).toBe("Проверь состояние файлов");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DraftItem } from "../viewModel";
|
||||
import { groupDraftItems, sortDraftItems } from "./AppList";
|
||||
|
||||
const items: DraftItem[] = [
|
||||
{ id: "folder", type: "folder", value: "C:\\Games" },
|
||||
{ id: "process-z", type: "process", value: "Zoom" },
|
||||
{ id: "exe", type: "exe", value: "C:\\Apps\\Browser.exe" },
|
||||
{ id: "process-a", type: "process", value: "Discord" },
|
||||
];
|
||||
|
||||
describe("sortDraftItems", () => {
|
||||
it("keeps the saved order by default", () => {
|
||||
expect(sortDraftItems(items, "added")).toBe(items);
|
||||
});
|
||||
|
||||
it("groups by item type and sorts values within each group", () => {
|
||||
const groups = groupDraftItems(items, "grouped");
|
||||
|
||||
expect(groups.map((group) => group.label)).toEqual([
|
||||
"Процессы",
|
||||
"EXE-файлы",
|
||||
"Папки",
|
||||
]);
|
||||
expect(
|
||||
groups.flatMap((group) => group.items.map((item) => item.id)),
|
||||
).toEqual(["process-a", "process-z", "exe", "folder"]);
|
||||
});
|
||||
|
||||
it("sorts all display values without mutating the source", () => {
|
||||
expect(sortDraftItems(items, "name").map((item) => item.id)).toEqual([
|
||||
"exe",
|
||||
"folder",
|
||||
"process-a",
|
||||
"process-z",
|
||||
]);
|
||||
expect(items[0]?.id).toBe("folder");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Cpu, FileCode2, FolderOpen } from "lucide-react";
|
||||
import { Button } from "../../ui";
|
||||
import { itemTypeLabel, type DraftItemType } from "../lib/profileItems";
|
||||
import type { DraftItem } from "../viewModel";
|
||||
|
||||
export type ItemSortMode = "added" | "grouped" | "name";
|
||||
|
||||
const SORT_OPTIONS: Array<{ value: ItemSortMode; label: string }> = [
|
||||
{ value: "added", label: "Добавлены" },
|
||||
{ value: "grouped", label: "Группы" },
|
||||
{ value: "name", label: "А–Я" },
|
||||
];
|
||||
|
||||
const TYPE_ORDER: Record<DraftItemType, number> = {
|
||||
process: 0,
|
||||
exe: 1,
|
||||
folder: 2,
|
||||
};
|
||||
|
||||
interface AppListProps {
|
||||
items: DraftItem[];
|
||||
loading: boolean;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
export function AppList({ items, loading, onRemove }: AppListProps) {
|
||||
const [sortMode, setSortMode] = useState<ItemSortMode>("added");
|
||||
const itemGroups = useMemo(
|
||||
() => groupDraftItems(items, sortMode),
|
||||
[items, sortMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="app-list-shell">
|
||||
{!loading && items.length > 1 ? (
|
||||
<div className="app-sort-row">
|
||||
<span>Порядок</span>
|
||||
<div
|
||||
className="app-sort"
|
||||
role="group"
|
||||
aria-label="Сортировка приложений"
|
||||
>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
size="sm"
|
||||
className="app-sort-option"
|
||||
aria-pressed={sortMode === option.value}
|
||||
key={option.value}
|
||||
onClick={() => setSortMode(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="app-list" key={sortMode} aria-live="polite">
|
||||
{loading ? (
|
||||
<div className="list-skeleton" aria-label="Загрузка приложений">
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
) : itemGroups.length ? (
|
||||
itemGroups.map((group) => (
|
||||
<section className="app-item-group" key={group.id}>
|
||||
{group.label ? (
|
||||
<div className="app-item-group-heading">
|
||||
<strong>{group.label}</strong>
|
||||
<span>{group.items.length}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{group.items.map((item) => (
|
||||
<div className="app-row" key={item.id}>
|
||||
<div className="app-row-main">
|
||||
<span className="item-icon" aria-hidden="true">
|
||||
{itemIcon(item.type)}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{item.value}</strong>
|
||||
<span>{itemTypeLabel(item.type)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={() => onRemove(item.id)}
|
||||
aria-label={`Удалить ${item.value}`}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
Список пуст. Добавь первое приложение сверху.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function sortDraftItems(items: DraftItem[], mode: ItemSortMode) {
|
||||
if (mode === "added") return items;
|
||||
|
||||
return [...items].sort((left, right) => {
|
||||
if (mode === "grouped") {
|
||||
const byType = TYPE_ORDER[left.type] - TYPE_ORDER[right.type];
|
||||
if (byType !== 0) return byType;
|
||||
}
|
||||
|
||||
return left.value.localeCompare(right.value, "ru", {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function groupDraftItems(items: DraftItem[], mode: ItemSortMode) {
|
||||
const sortedItems = sortDraftItems(items, mode);
|
||||
if (mode !== "grouped")
|
||||
return [{ id: mode, label: null, items: sortedItems }];
|
||||
|
||||
return (["process", "exe", "folder"] as const).flatMap((type) => {
|
||||
const typeItems = sortedItems.filter((item) => item.type === type);
|
||||
return typeItems.length
|
||||
? [
|
||||
{
|
||||
id: type,
|
||||
label: groupLabel(type),
|
||||
items: typeItems,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
function groupLabel(type: DraftItemType) {
|
||||
if (type === "process") return "Процессы";
|
||||
if (type === "folder") return "Папки";
|
||||
return "EXE-файлы";
|
||||
}
|
||||
|
||||
function itemIcon(type: DraftItemType) {
|
||||
if (type === "process") return <Cpu size={18} strokeWidth={1.9} />;
|
||||
if (type === "folder") return <FolderOpen size={18} strokeWidth={1.9} />;
|
||||
return <FileCode2 size={18} strokeWidth={1.9} />;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ConnectionCheckView } from "../viewModel";
|
||||
import { ConnectionCheckPanel } from "./ConnectionCheckPanel";
|
||||
|
||||
describe("ConnectionCheckPanel", () => {
|
||||
it("renders the result summary, every probe, and hover details", () => {
|
||||
const check: ConnectionCheckView = {
|
||||
tone: "warning",
|
||||
title: "Частичный ответ",
|
||||
text: "Доступны 2 из 3 контрольных точек.",
|
||||
endpoint: "192.168.50.111:8080",
|
||||
endpointLabel: "Внешний SOCKS5",
|
||||
details: [
|
||||
{ label: "SOCKS5", value: "192.168.50.111:8080 · 14 ms" },
|
||||
{
|
||||
label: "Cloudflare",
|
||||
value: "IP 203.0.113.10 · HTTP 200 · 21 ms",
|
||||
},
|
||||
{ label: "ipify", value: "timeout" },
|
||||
],
|
||||
probes: [
|
||||
{
|
||||
id: "tcp",
|
||||
label: "SOCKS5",
|
||||
tone: "ok",
|
||||
status: "Доступен",
|
||||
ip: null,
|
||||
latency: "14 ms",
|
||||
detail: "TCP-соединение с SOCKS5 endpoint",
|
||||
},
|
||||
{
|
||||
id: "cloudflare",
|
||||
label: "Cloudflare",
|
||||
tone: "ok",
|
||||
status: "Доступна",
|
||||
ip: "203.0.113.10",
|
||||
latency: "21 ms",
|
||||
detail: "https://cloudflare.com · HTTP 200",
|
||||
},
|
||||
{
|
||||
id: "ipify",
|
||||
label: "ipify",
|
||||
tone: "error",
|
||||
status: "Ошибка",
|
||||
ip: null,
|
||||
latency: null,
|
||||
detail: "https://api.ipify.org · timeout",
|
||||
},
|
||||
],
|
||||
checked: true,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<ConnectionCheckPanel check={check} onCheck={() => undefined} />,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Результат проверки");
|
||||
expect(markup).toContain("Cloudflare");
|
||||
expect(markup).toContain("ipify");
|
||||
expect(markup).toContain("Внешний IP");
|
||||
expect(markup).toContain("Задержка");
|
||||
expect(markup).toContain("Технические детали");
|
||||
expect(markup).toContain('aria-live="polite"');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user