13 Commits
Author SHA1 Message Date
dokril c6b66a4ed7 Release v2.0.0
CI / Windows baseline (push) Canceled after 0s
2026-09-11 18:29:33 +03:00
dokril c5532f2087 Release v2.0.0
CI / Windows baseline (push) Canceled after 0s
2026-09-11 18:03:03 +03:00
dokril f812e32270 Release v2.0.0
CI / Windows baseline (push) Canceled after 0s
2026-09-11 17:38:40 +03:00
dokril fd79606052 Release v2.0.0
CI / Windows baseline (push) Canceled after 0s
2026-09-11 17:25:12 +03:00
dokril efda8eb98f Release v2.0.0
CI / Windows baseline (push) Canceled after 0s
2026-09-10 20:59:52 +03:00
dokril 9c987df6e9 Improve startup responsiveness and admin prompt motion
CI / Windows baseline (push) Has been cancelled
2026-07-22 17:50:12 +03:00
dokril 90ec4ca086 Add ProxiFyre firewall rule management and bump version to 1.1.0 2026-07-22 16:17:00 +03:00
dokril 67792f245f Refine ProxyWarden routing workflow 2026-07-22 01:26:51 +03:00
dokril 90b2eb507c Refactor application structure and simplify implementation 2026-07-22 00:08:09 +03:00
dokril dbba3806cc Refine ProxyWarden routing and config flows 2026-07-10 21:15:17 +03:00
dokril 9fd0a8c0b9 Harden ProxiFyre installer downloads
CI / Windows baseline (push) Has been cancelled
2026-07-09 14:20:35 +03:00
dokril 1bb795a532 Refactor ProxyWarden routing and settings flow 2026-07-09 11:51:16 +03:00
dokril db0c1dede9 Expand AGENTS.md with current repo and reporting rules 2026-07-09 11:19:10 +03:00
194 changed files with 82309 additions and 8584 deletions
+61
View File
@@ -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 подход или рекламная шелуха, этот вид пластика уже и так в океане.
+30
View File
@@ -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.
+37
View File
@@ -0,0 +1,37 @@
# Communication Checklist
Используй перед финальным ответом по любой нетривиальной задаче.
## Структура
- [ ] Ответ начинается с `Коротко` или с такой же короткой сводки на 2-4 пункта.
- [ ] Измененные файлы или зоны проекта перечислены в начале ответа, а не спрятаны в конце.
- [ ] Для каждого важного файла понятно: что изменилось и зачем.
- [ ] Важные изменения поведения, безопасности или состояния отделены от мелких деталей.
- [ ] Проверки разделены на `Проверено` и `Не проверено`.
- [ ] Риски написаны явно.
## Понятность
- [ ] Нет плотных абзацев длиннее 4-5 строк.
- [ ] Нет терминов и аббревиатур без пользы или краткого объяснения.
- [ ] Нет полных логов, если они не нужны для вывода.
- [ ] Нет пересказа каждой строки diff, если пользователь не просил.
- [ ] Нет мутных фраз вроде `улучшена архитектура` без объяснения, что стало проще, безопаснее или понятнее.
## Честность
- [ ] Windows/service/elevation поведение не названо проверенным, если оно не тестировалось на Windows.
- [ ] У пропущенных проверок есть простая причина.
- [ ] Ответ не говорит `готово`, если важные проверки пропущены.
## Быстрая самопроверка
Перед отправкой ответ должен отвечать на вопросы:
1. Что изменилось или найдено?
2. В каких файлах?
3. Зачем это нужно?
4. Что реально проверено?
5. Что не проверено?
6. Где остался риск?
+41
View File
@@ -0,0 +1,41 @@
# Checklist: Explanation Quality
Используй перед финальным ответом или PR summary.
## Обязательное
- [ ] В начале есть короткий итог на 2–4 пункта.
- [ ] Есть список файлов или таблица `файл / что / зачем`.
- [ ] Термины объяснены простыми словами, если они важны.
- [ ] Нет длинных полотен без заголовков.
- [ ] Нет пересказа каждой строки diff.
- [ ] Указано, что проверено.
- [ ] Указано, что не проверено.
- [ ] Риски написаны прямо, без «должно работать».
## Хороший формат
```md
## Коротко
- ...
## Файлы
| Файл | Что | Зачем |
|---|---|---|
## Проверки
- Выполнено: ...
- Не выполнено: ...
## Риски
- ...
```
## Плохие признаки
- Один огромный абзац.
- Много терминов без пользы.
- «Исправлена логика» без указания файла и эффекта.
- «Проверено» без команды или способа проверки.
- «Не проверял Windows, но всё готово».
- Список из 25 пунктов одинаковой важности.
+30
View File
@@ -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.
+36
View File
@@ -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.
+24
View File
@@ -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. ...
Проверка
- ...
```
+109
View File
@@ -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,94 @@
# 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/migration.rs versioned storage adoption/migration
src-tauri/src/activity.rs activity log
src-tauri/src/subscription.rs subscription fetch/parse
src-tauri/src/component_catalog.rs pinned offline package catalog
src-tauri/src/component_inventory.rs exact native SCM/process/registry inventory
src-tauri/src/component_detection.rs component status mapping
src-tauri/src/component_packages.rs bundled/cache package plans
src-tauri/src/component_cutover.rs durable legacy cutover/rollback/cleanup
src-tauri/src/privileged_jobs.rs sealed one-shot elevation records
src-tauri/src/privileged_runtime.rs fixed native privileged actions
src-tauri/src/proxifyre_runtime.rs native ProxiFyre lifecycle
src-tauri/src/singbox_runtime.rs native sing-box lifecycle
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 build/release/QA tooling only
```
## Source of truth
- Persistent app config/state: `C:\ProgramData\ProxyWarden\config` and `state`.
- `config\components.json` is legacy migration input only; live component truth comes from native inventory and verified receipts.
- Current managed roots: `C:\Program Files\ProxyWarden\components\ProxiFyre` and `...\sing-box`.
- Offline baseline: packaged component catalog; verified downloaded cache: `C:\ProgramData\ProxyWarden\packages`.
- 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. If the change touches install/service/elevation, trace `privileged_jobs.rs`/`privileged_runtime.rs` and the native component runtime. Do not introduce PowerShell runtime fallback.
7. If the change touches packaging or release tooling, run `scripts/check-runtime-powershell-boundary.ps1 -CheckOnly`.
8. 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 treat legacy `components.json`, fuzzy paths, or service name alone as ownership proof.
- Do not add `.ps1`, `powershell.exe`, `pwsh`, or generated script text to production Rust/Tauri/NSIS paths.
- 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`.
Минимум для нетривиальной задачи:
- короткая сводка;
- таблица файлов `Файл / Что изменилось / Зачем`;
- важные места без пересказа каждой строки;
- что проверено;
- что не проверено;
- конкретные риски.
+115
View File
@@ -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`.
Минимум для нетривиальной задачи:
- короткая сводка;
- таблица файлов `Файл / Что изменилось / Зачем`;
- важные места без пересказа каждой строки;
- что проверено;
- что не проверено;
- конкретные риски.
+138
View File
@@ -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`.
Минимум для нетривиальной задачи:
- короткая сводка;
- таблица файлов `Файл / Что изменилось / Зачем`;
- важные места без пересказа каждой строки;
- что проверено;
- что не проверено;
- конкретные риски.
+93
View File
@@ -0,0 +1,93 @@
# Skill: Testing, CI and Release
## Когда использовать
Используй этот skill при изменениях CI, release scripts, tests, dependencies, Tauri/NSIS packaging, offline component catalog или перед финальным отчётом по крупной задаче.
## Minimal local checks
Frontend:
```powershell
npm ci
npm run format:check
npm run lint
npm run typecheck
npm test -- --run
npm run build
```
Rust:
```powershell
Push-Location src-tauri
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-targets
Pop-Location
```
Tauri/build/release boundaries:
```powershell
npm run tauri -- info
& .\scripts\check-runtime-powershell-boundary.ps1 -CheckOnly
& .\scripts\update-component-bundle.ps1 -PlanOnly
& .\scripts\update-component-bundle.ps1 -CheckOnly
& .\scripts\audit-windows-smoke.ps1 -Mode PlanOnly
& .\scripts\prepare-release.ps1 -PlanOnly -SkipBuild
```
`PlanOnly`/`CheckOnly` должны возвращать structured JSON с `changed: false` и не менять repo, ProgramData, services или network state.
## Interaction smoke for UI motion
Build, lint и unit tests не проверяют motion/geometry. Для hover, disclosure, stagger или hit-target изменений проверь first/repeated/rapid toggle, keyboard focus, loading/long labels, `prefers-reduced-motion`, desktop и narrow window. Если visual smoke не выполнен, так и напиши.
## CI contract
Windows baseline должен включать:
- frontend format/lint/typecheck/tests/build;
- Rust fmt/clippy/all-target tests;
- Tauri environment check;
- runtime PowerShell boundary check;
- offline bundle PlanOnly + CheckOnly;
- Windows audit PlanOnly;
- release preparation PlanOnly with build skipped.
CI не изображает реальную SCM/UAC/driver проверку. Artifact upload и tag/publish допустимы только в отдельном trusted release workflow после принятой VM evidence.
## Dependency updates
- Обновить lockfiles.
- Проверить Tauri v2 и Windows x64 compatibility.
- Не добавлять dependency, если stdlib/native API или уже установленный crate решает задачу.
- Не добавлять shell/process library, возвращающую production PowerShell path.
- Объяснить, зачем dependency нужна и какой owner её вызывает.
## Release hygiene
Перед release candidate:
- версии совпадают в `package.json`, `package-lock.json`, `src-tauri/tauri.conf.json` и `src-tauri/Cargo.toml`;
- packaged component catalog, asset hashes, licenses и `THIRD_PARTY_NOTICES.md` согласованы;
- installer содержит consolidated offline component bundle и WebView2 Offline Installer;
- runtime PowerShell checker проходит, bundled cleanup script отсутствует;
- NSIS hook разделяет verify-only upgrade и full managed uninstall;
- fresh offline VM, legacy upgrade/rollback, foreign service refusal, UAC cancel, uninstall/reboot и реальные routing flows записаны в evidence;
- tag/publish выполняются только для того же проверенного commit.
Установка Control App не должна скрыто install/start/update routing-компоненты. Payloads могут быть в installer, но component mutation остаётся отдельным user action.
## Финальный отчёт
Разделить:
- `Проверено`: точные команды и результаты;
- `Не проверено`: Windows VM/UAC/SCM/driver/installer gaps;
- `Риски`: только конкретные release blockers.
Не писать «все тесты проходят», если весь релевантный набор действительно не запускался.
Перед ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`.
@@ -0,0 +1,74 @@
# Skill: Windows Services / PowerShell / Elevation
## Когда использовать
Используй этот skill при изменениях ProxiFyre/sing-box install/start/stop/update/uninstall, UAC/admin boundary, native inventory, NSIS upgrade/uninstall или build/release/QA PowerShell scripts.
## Цель
Сохранять системные операции явными, native и проверяемыми. Production runtime не зависит от PowerShell; Rust владеет Windows SCM, registry, process, filesystem, package verification и UAC flow.
## Runtime-инварианты
- Install/start/stop/update/uninstall/migrate — только явные действия пользователя.
- `apply` не устанавливает, не обновляет, не переносит и не удаляет компоненты.
- Current managed roots — только `C:\Program Files\ProxyWarden\components\ProxiFyre` и `...\sing-box`.
- Service control требует exact `PathName`, marker/receipt, canonical path и non-reparse checks. Имя службы или fuzzy candidate недостаточны.
- Elevated UI action передаёт только UUID sealed job record; fixed early mode сам повторно проверяет ACL, TTL, action, paths, hashes и ownership.
- NSIS использует только exact `--nsis-verify-upgrade` и `--nsis-uninstall-managed`; никаких user/path/script arguments.
- Active/recovery/pending cutover journal блокирует upgrade/uninstall и не удаляется общим cleanup.
- Runtime-generated scripts и запуск `powershell.exe`/`pwsh` запрещены.
## PowerShell allowlist
PowerShell остаётся только для build/release/QA:
- `scripts/check-runtime-powershell-boundary.ps1`;
- `scripts/update-component-bundle.ps1`;
- `scripts/prepare-release.ps1`;
- `scripts/audit-windows-smoke.ps1`.
`PlanOnly`/`CheckOnly` должны быть side-effect-free и возвращать structured JSON с `changed: false`. Любой новый `.ps1`, `.psm1`, `.psd1`, production caller или bundled cleanup resource должен ломать boundary checker.
## Native service flow
1. Получить inventory через Windows API и canonicalize все пути.
2. Классифицировать `Missing / Managed / Foreign / Incomplete` до первой mutation.
3. Проверить marker/receipt, service `PathName`, file identity, ACL и reparse boundary.
4. Захватить общий lifecycle lock.
5. Выполнить только allowlisted fixed action.
6. Query-back подтвердить service/path/start policy/state.
7. При ошибке оставить durable recovery state; не угадывать cleanup.
Для uninstall сначала preflight всех компонентов. `Missing` — no-op; `Foreign`/`Incomplete` — zero mutation. Running service сначала останавливается и проверяется, затем удаляется. MSI code `3010` означает success with reboot required, а не обычную ошибку.
## Удаление файлов
- Не использовать generic recursive delete по app root.
- Удалять только exact receipt/journal-owned entries после safe-path, ACL, reparse и file-shape checks.
- `.proxywarden-cutover` и `.proxywarden-quarantine` удаляет только owner terminal retirement после проверки journal state.
- Unexpected files, active jobs, process/service references или partial tombstone блокируют cleanup.
## Проверка
Cross-platform/pure logic:
```powershell
Push-Location src-tauri
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-targets
Pop-Location
& .\scripts\check-runtime-powershell-boundary.ps1 -CheckOnly
& .\scripts\update-component-bundle.ps1 -CheckOnly
& .\scripts\audit-windows-smoke.ps1 -Mode PlanOnly
```
Реальная проверка требует Windows 10/11 x64 VM: UAC cancel/success, SCM create/start/stop/delete, driver/VC installer exit codes, fresh offline install, foreign same-name service refusal, legacy rollback/recovery и NSIS upgrade/uninstall/reboot.
Не называть service/elevation behavior проверенным без этой VM evidence.
## Как отчитываться
Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`. Отдельно перечислить automated evidence, Windows/manual evidence и незакрытые UAC/SCM/driver риски.
+29
View File
@@ -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`, функция/секция:
## Проверено
-
## Не проверено
-
## Риски
-
+20
View 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 |
## Что не трогаем
-
## Как проверить после изменений
-
+27
View File
@@ -0,0 +1,27 @@
# Investigation Report
## Коротко
- Главный вывод:
- Где проблема:
- Что делать первым:
## Что смотрел
| Файл / зона | Зачем смотрел | Вывод |
|---|---|---|
| `path/file` | | |
## Находки
| Приоритет | Где | Что не так | Как исправить |
|---|---|---|---|
| Критично / Важно / Можно потом / Косметика | `path/file` | | |
## Проверки
-
## Ограничения анализа
-
+32
View 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
## Не проверено / риски
-
+27
View File
@@ -0,0 +1,27 @@
# User-facing Summary
## Коротко
-
-
-
## Что изменилось простыми словами
-
## Файлы
| Файл | Что изменилось | Зачем |
|---|---|---|
| `path/file` | | |
## Что важно знать
-
## Проверки и риски
- ✅ Проверено:
- ⚠️ Не проверено:
- Риск:
+23
View File
@@ -0,0 +1,23 @@
# Work Plan
## Коротко
Сделаю так:
1.
2.
3.
## Какие файлы, вероятно, затрону
| Файл / зона | Что планируется | Зачем |
|---|---|---|
| `path/file` | | |
## Что проверю
-
## Что может остаться непроверенным
-
+4
View 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.
+32
View File
@@ -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.
+26
View File
@@ -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.
+84
View File
@@ -0,0 +1,84 @@
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: Test component catalog contract
working-directory: src-tauri
run: cargo test --test component_catalog_tests
- name: Run Rust tests
working-directory: src-tauri
run: cargo test --all-targets
- name: Check Tauri environment
run: npm run tauri -- info
- name: Check runtime PowerShell boundary
shell: pwsh
run: .\scripts\check-runtime-powershell-boundary.ps1 -CheckOnly
- name: Plan component bundle update
shell: pwsh
run: .\scripts\update-component-bundle.ps1 -PlanOnly
- name: Check component bundle
shell: pwsh
run: .\scripts\update-component-bundle.ps1 -CheckOnly
- name: Plan Windows smoke evidence capture
shell: pwsh
run: .\scripts\audit-windows-smoke.ps1 -Mode PlanOnly
- name: Plan release preparation
shell: pwsh
run: .\scripts\prepare-release.ps1 -PlanOnly -SkipBuild
- name: Test release workflow with local Git remotes
run: node --test scripts/prepare-release.check.mjs
+2
View File
@@ -1,4 +1,6 @@
node_modules/ node_modules/
.pnpm-store/
*.tsbuildinfo
dist/ dist/
releases/ releases/
src-tauri/target/ src-tauri/target/
+207 -57
View File
@@ -1,72 +1,205 @@
# Инструкции для агентов # 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. Production install/service/UAC runtime реализован в Rust; PowerShell остаётся только build/release/QA tooling. Приложение управляет выбранными Windows-приложениями через ProxiFyre и, опционально, через локальный sing-box runtime.
Не возвращать старую идею `APP_MODE=windows` и не подключать Windows-клиент к отдельному Node gateway/server. Текущий рабочий путь - `src`, `src-tauri`, `scripts` в корне репозитория. Этот файл — главный контракт для кодового агента. Любой агент, который меняет репозиторий, обязан соблюдать эти правила. Да, даже если ему очень хочется «быстренько поправить одну кнопочку» и случайно переписать половину сетевого стека. Особенно тогда.
## Основные инварианты ## Продуктовая рамка
- Три компонента должны оставаться разделенными: Control App, ProxiFyre, Local sing-box. Проект не должен превращаться в коммерческий SaaS, Node gateway, VPN-провайдер, proxy server или облачный control plane. Это локальная Windows-утилита для себя и друзей.
- ProxiFyre - обязательный слой для per-app routing; Local sing-box - необязательный runtime.
- Внешний SOCKS5 flow должен работать без установленного Local sing-box. Цель: надежно и понятно конфигурировать маршрутизацию выбранных приложений через внешний SOCKS5 proxy или через локальный 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 или логах. - Control App, ProxiFyre и Local sing-box — разные компоненты. Не смешивать их ответственность.
- Summary panel должен оставаться read-only: без apply/install/start/stop/delete/input/subscription mutations. - 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:\Program Files\ProxyWarden\components\ProxiFyre` и `C:\Program Files\ProxyWarden\components\sing-box` — единственные current managed component roots.
- `config\components.json` — только legacy migration input. Реальный component status принадлежит native Windows inventory и проверенным receipts.
- Packaged component catalog — immutable offline baseline; проверенный download cache лежит отдельно в `C:\ProgramData\ProxyWarden\packages`.
- `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_catalog.rs # pinned offline component catalog
src/component_inventory.rs # exact native SCM/process/registry inventory
src/component_packages.rs # verified bundled/cache package plans
src/component_cutover.rs # durable legacy cutover/rollback/cleanup
src/migration.rs # versioned storage migration/adoption
src/privileged_jobs.rs # sealed one-shot elevated job records
src/privileged_runtime.rs # fixed native elevated action dispatcher
src/proxifyre_runtime.rs # native ProxiFyre lifecycle
src/singbox_runtime.rs # native sing-box lifecycle
src/singbox_service.rs # WinSW service spec/status logic
src/safe_fs.rs # safe path/ACL/reparse helpers
src/adapters/* # ProxiFyre/sing-box/proxy-router adapters
src/commands.rs # Tauri command handlers; currently too large
tests/* # Rust integration/domain tests
scripts/
check-runtime-powershell-boundary.ps1
update-component-bundle.ps1
audit-windows-smoke.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. ### Backend
- Не писать 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-серверы) запущенными после проверки. Если сервер был поднят агентом, остановить его перед финальным ответом.
## Проверка - Не добавлять новую 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 запрещены. Elevation использует current exe, fixed early modes и sealed typed job records без arbitrary command/path arguments.
- Удаление директорий допускается только после safe-path/marker/service-path checks.
- Subscription fetch должен иметь timeout и защиту от очевидно опасных/local metadata адресов либо explicit allow-mode.
### Windows/service boundary
- PowerShell разрешён только в build/release/QA allowlist: `check-runtime-powershell-boundary.ps1`, `update-component-bundle.ps1`, `audit-windows-smoke.ps1`, `prepare-release.ps1`.
- `PlanOnly`/`CheckOnly` у этих scripts должны быть side-effect-free, возвращать structured JSON и иметь `changed: false`.
- Production Rust, Tauri resources и NSIS hooks не должны запускать `powershell.exe`, `pwsh`, `.ps1` или generated script text.
- После изменения этой границы запускать `scripts/check-runtime-powershell-boundary.ps1 -CheckOnly`.
- 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.
## Минимальная проверка перед ответом
Для docs-only изменений достаточно проверить структуру файлов и отсутствие очевидных Markdown/JSON ошибок.
Для frontend изменений:
```powershell ```powershell
npm ci
npm run build npm run build
``` ```
Rust/backend: Для Rust/backend изменений:
```powershell ```powershell
cd D:\repos\ProxyWarden\src-tauri cd src-tauri
cargo test cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-targets
``` ```
Tauri/toolchain: Для Tauri/toolchain:
```powershell ```powershell
npm run tauri -- info npm run tauri -- info
@@ -74,18 +207,35 @@ npm run tauri -- dev
npm run tauri -- build npm run tauri -- build
``` ```
Installer boundaries: Для offline bundle/release boundaries:
```powershell ```powershell
& .\scripts\install-control-app.ps1 -PlanOnly & .\scripts\check-runtime-powershell-boundary.ps1 -CheckOnly
& .\scripts\install-proxyfier.ps1 -PlanOnly & .\scripts\update-component-bundle.ps1 -PlanOnly
& .\scripts\install-singbox.ps1 -PlanOnly & .\scripts\update-component-bundle.ps1 -CheckOnly
& .\scripts\audit-windows-smoke.ps1 -Mode PlanOnly
& .\scripts\prepare-release.ps1 -PlanOnly -SkipBuild
``` ```
Для 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. Эта фраза и так слишком много навредила миру.
+41
View File
@@ -0,0 +1,41 @@
# Участие в разработке 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\check-runtime-powershell-boundary.ps1 -CheckOnly
& .\scripts\update-component-bundle.ps1 -PlanOnly
& .\scripts\update-component-bundle.ps1 -CheckOnly
& .\scripts\audit-windows-smoke.ps1 -Mode PlanOnly
& .\scripts\prepare-release.ps1 -PlanOnly -SkipBuild
```
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.
- Не добавляйте PowerShell, `.ps1` resources или generated scripts в production runtime. PowerShell разрешён только в точном build/release/QA allowlist, который проверяет `check-runtime-powershell-boundary.ps1`.
- Храните managed components только в `C:\Program Files\ProxyWarden\components`; `config\components.json` допустим лишь как legacy migration input, а не source of truth.
- Добавляйте минимальный тест для новой ветвящейся логики.
- Не коммитьте runtime-файлы из `C:\ProgramData\ProxyWarden` и generated output.
В pull request кратко опишите поведение, затронутые файлы, выполненные проверки и оставшиеся Windows/manual риски.
+21
View File
@@ -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.
+136 -179
View File
@@ -1,35 +1,36 @@
# ProxyWarden # ProxyWarden
ProxyWarden - это standalone Windows desktop-приложение для маршрутизации выбранных программ через SOCKS5-прокси. По сути это удобная оболочка управления над внешними компонентами: обязательным маршрутизатором приложений ProxiFyre и, опционально, локальным runtime `sing-box`. ProxyWarden standalone Windows desktop-приложение для маршрутизации выбранных программ через SOCKS5-прокси. Оно управляет обязательным ProxiFyre и, при необходимости, локальным `sing-box`, но само не является VPN-драйвером, proxy server или облачным control plane.
ProxyWarden сам не является VPN-драйвером, прокси-сервером или отдельным gateway/server. Он хранит настройки, показывает состояние компонентов, генерирует конфиги и запускает только явные действия пользователя: установить, запустить, остановить, удалить или применить конфиг. Все системные действия остаются явными: `apply` только проверяет и применяет конфигурацию; установка, обновление, запуск, остановка, перенос и удаление компонентов выполняются отдельными командами пользователя.
## Главное ## Главное
- Работает как Windows-клиент: Tauri 2 + React/TypeScript UI + Rust backend. - Tauri 2 + React/TypeScript UI + Rust backend.
- Маршрутизирует не всю систему, а выбранные приложения: процесс, папку или конкретный `.exe`. - Маршрутизируются выбранные процессы, папки или `.exe`, а не вся система.
- Не меняет глобальный proxy в Windows. - Глобальный Windows proxy не меняется.
- Для per-app routing нужен ProxiFyre. - Внешний SOCKS5 работает без Local sing-box.
- Local sing-box нужен только для сценария с подпиской и локальным SOCKS5 endpoint. - Production runtime не запускает PowerShell: service/install/UAC orchestration принадлежит native Rust.
- Внешний SOCKS5-прокси работает без Local sing-box. - x64 installer содержит проверенные offline payloads компонентов и WebView2 Offline Installer; сеть для baseline-установки не нужна.
- Применение профиля не устанавливает и не чинит компоненты скрыто.
## Из чего состоит ## Компоненты
| Компонент | Что это | Нужен когда | Откуда берется | | Компонент | Роль | Когда нужен |
| --- | --- | --- | --- | | --- | --- | --- |
| ProxyWarden Control App | Окно управления, настройки, status/readiness, генерация конфигов | Всегда | Этот репозиторий | | ProxyWarden Control App | UI, storage, validation, config generation и orchestration | Всегда |
| [ProxiFyre](https://github.com/wiresock/proxifyre) | Windows-приложение/служба для перехвата трафика выбранных процессов и отправки его в SOCKS5 | Всегда для маршрутизации приложений | GitHub releases `wiresock/proxifyre` | | [ProxiFyre](https://github.com/wiresock/proxifyre) | Перехватывает трафик выбранных приложений и направляет его в SOCKS5 | Для любого per-app routing |
| [Windows Packet Filter / NDISAPI](https://github.com/wiresock/ndisapi) | Сетевой драйвер, который нужен ProxiFyre | Устанавливается вместе с ProxiFyre, если отсутствует | GitHub releases `wiresock/ndisapi` | | [Windows Packet Filter / NDISAPI](https://github.com/wiresock/ndisapi) | Сетевой драйвер ProxiFyre | Устанавливается вместе с ProxiFyre, если отсутствует |
| [Microsoft Visual C++ Redistributable](https://learn.microsoft.com/cpp/windows/latest-supported-vc-redist) | Runtime-зависимость для `ProxiFyre.exe` | Устанавливается вместе с ProxiFyre, если отсутствует | Официальный `vc_redist` Microsoft | | [Microsoft Visual C++ Redistributable](https://learn.microsoft.com/cpp/windows/latest-supported-vc-redist) | Runtime-зависимость ProxiFyre | Устанавливается при необходимости |
| [sing-box](https://github.com/SagerNet/sing-box) | Локальный proxy/VPN runtime, который слушает `127.0.0.1:1080` | Только для маршрута через subscription/выбранный сервер | GitHub releases `SagerNet/sing-box` | | [sing-box](https://github.com/SagerNet/sing-box) | Создаёт локальный SOCKS5 endpoint для выбранного subscription-сервера | Только для Local sing-box flow |
| [WinSW](https://github.com/winsw/winsw) | Wrapper, который запускает Local sing-box как Windows-службу | Только для Local sing-box | GitHub releases `winsw/winsw` | | [WinSW](https://github.com/winsw/winsw) | Запускает sing-box как Windows-службу | Только для Local sing-box flow |
В UI и коде компонент ProxiFyre иногда проходит через внутренний id `proxyfier`. Это не отдельный продукт Proxifier; текущий backend adapter работает именно с ProxiFyre. Версии, SHA-256 и лицензии offline payloads зафиксированы в packaged component catalog. Установка Control App не запускает routing-компоненты: нужный компонент устанавливается отдельным действием в UI.
## Как идут маршруты В UI и части внутренних DTO ProxiFyre может иметь исторический id `proxyfier`. Это не продукт Proxifier.
Внешний SOCKS5-прокси: ## Маршруты
Внешний SOCKS5:
```text ```text
выбранные приложения -> ProxiFyre -> внешний SOCKS5 proxy выбранные приложения -> ProxiFyre -> внешний SOCKS5 proxy
@@ -38,222 +39,178 @@ ProxyWarden сам не является VPN-драйвером, прокси-с
Local sing-box: Local sing-box:
```text ```text
выбранные приложения -> ProxiFyre -> Local sing-box 127.0.0.1:1080 -> выбранный сервер из подписки выбранные приложения -> ProxiFyre -> Local sing-box 127.0.0.1:1080 -> выбранный subscription-сервер
``` ```
Во втором сценарии ProxiFyre все равно обязателен: именно он делает маршрутизацию конкретных Windows-приложений. Local sing-box только дает локальный SOCKS5 endpoint и ходит дальше к выбранному серверу. Во втором маршруте ProxiFyre по-прежнему отвечает за выбор приложений. Local sing-box только предоставляет локальный SOCKS5 endpoint и соединяется с выбранным сервером.
## Что устанавливается ## Установка и системные пути
### Control App Tauri NSIS installer устанавливает Control App per-machine. Managed runtime-компоненты лежат только под текущим app root:
Обычная сборка Tauri создает desktop-приложение ProxyWarden. Отдельный скрипт `scripts/install-control-app.ps1` сейчас подготавливает стандартные директории:
```text ```text
C:\Program Files\ProxyWarden\ControlApp C:\Program Files\ProxyWarden
C:\ProgramData\ProxyWarden\config C:\Program Files\ProxyWarden\components\ProxiFyre
C:\ProgramData\ProxyWarden\state C:\Program Files\ProxyWarden\components\sing-box
C:\ProgramData\ProxyWarden\generated
``` ```
### ProxiFyre Службы:
Явная установка ProxiFyre из приложения выполняется через elevated PowerShell и ставит/обновляет:
```text ```text
C:\Tools\ProxiFyre ProxiFyreService
C:\Tools\ProxiFyre\ProxiFyre.exe ProxyWardenSingBox
C:\Tools\ProxiFyre\app-config.json
Windows service: ProxiFyreService
``` ```
Если на машине не найдены зависимости, установщик также скачивает и ставит Microsoft Visual C++ Redistributable и Windows Packet Filter / NDISAPI. ProxyWarden управляет службой только после точной проверки `PathName`, marker/receipt и canonical component root. Похожее имя службы или найденная папка сами по себе не дают права на start/stop/delete.
### Local sing-box ## Релиз одной командой
Явная установка Local sing-box ставит: В PowerShell из корня проекта:
```powershell
.\release.cmd
```
То же действие доступно как `npm run release`. Сценарий показывает изменения Git и предлагает patch/minor/major, произвольную версию или текущую ещё не выпущенную версию. Можно сразу ввести номер вроде `1.2.1`.
После выбора он синхронизирует версии в package.json, package-lock.json, tauri.conf.json, Cargo.toml и Cargo.lock, проверяет frontend/Rust/offline bundle, собирает NSIS и готовит папку `releases/proxywarden-vX.Y.Z`. Затем создаёт commit со всеми текущими отслеживаемыми и неигнорируемыми новыми файлами, annotated tag `vX.Y.Z` и одним atomic push отправляет текущую ветку и этот тег в `origin`. При отсутствии изменений новый commit не нужен. Артефакты не попадают в Git.
В папке релиза: `artifacts/nsis/ProxyWarden_X.Y.Z_x64-setup.exe`, `SHA256SUMS.txt`, `release-manifest.json` с точным commit/hash и `release-notes.md`. EXE загружается на сайт вручную; GitHub/Gitea release page автоматически не создаётся.
Нужны Git с настроенной identity и доступом к origin, Node, установленные frontend-зависимости (`npm ci` один раз), Rust/MSVC/Windows SDK. Сам сценарий сборки использует Node напрямую и не требует npm в PATH. Запуск от администратора не нужен.
```powershell
.\release.cmd -PlanOnly # только JSON-план: без записи, сборки и сети
.\release.cmd -Version 1.2.1 # версия без вопроса
.\release.cmd -Version 1.2.1 -Resume # повторить только неудачный push
.\release.cmd -Version 2.0.0 -Replace # пересобрать ещё не выпущенную версию с заменой тега
```
Не меняйте исходники во время сборки. По умолчанию существующие теги не перезаписываются; при расхождении с удалённой веткой сценарий останавливается до изменения версий. При ошибке сборки изменения версии остаются локально для исправления, commit/tag/push не выполняются. При неудачном push готовая папка и локальный commit/tag сохраняются; `-Resume` проверяет исходники и SHA-256 перед повторной отправкой.
Если версия ещё не выложена пользователям, `-Version X.Y.Z -Replace` заново выполняет проверки и сборку с текущими изменениями. После сборки предыдущая папка сохраняется рядом как `proxywarden-vX.Y.Z-replaced-...`, а выбранный тег обновляется локально и в origin. История ветки сохраняется. Отправка использует `--force-with-lease` только для этого тега: если он изменился на сервере с начала операции, замена отклоняется. При сбое отправки используется обычный `-Version X.Y.Z -Resume`, который сохраняет первоначальное условие замены. `-Replace` требует явного номера версии и не совмещается с `-Resume`.
Для локальной подготовки без commit/tag/push остаётся `scripts/prepare-release.ps1 -Version X.Y.Z`. Автоматические проверки не заменяют Windows VM/UAC/driver/routing acceptance: в manifest это отмечается отдельно.
## Данные и source of truth
Настройки и состояние лежат под `C:\ProgramData\ProxyWarden`:
```text ```text
C:\Program Files\ProxyWarden\sing-box\sing-box.exe config\profiles.json
C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe config\targets.json
C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.xml config\local-singbox.json
C:\Program Files\ProxyWarden\sing-box\config.json config\storage-meta.json
Windows service: ProxyWardenSingBox state\activity.json
state\component-layout.json
state\component-updates.json
state\migrations\...
packages\...
``` ```
`ProxyWardenSingBox.exe` - это WinSW wrapper. Он нужен только чтобы запускать `sing-box.exe` как Windows-службу. `config\components.json` не является текущим источником статуса компонентов. Это только legacy input: migration может проверить, сохранить snapshot/archive и затем перестать использовать его. Фактический install/service/version status читается из native inventory Windows и проверенных receipts.
Generated artifacts можно пересоздать:
```text
generated\proxifyre-app-config.json
generated\sing-box-config.json
```
Не редактируйте generated-файлы как основной источник правды. Subscription URL, userinfo, credentials, proxy password и внутренние migration/job records нельзя выводить целиком в UI, logs или diagnostics.
## Миграция старой установки
- Startup выполняет только безопасную storage adoption/migration: backup, validation, atomic commit и повторный no-op.
- Старые component roots и службы сначала обнаруживаются read-only.
- Перенос компонента — отдельное UAC-действие с exact identity checks, rollback journal и quarantine.
- Foreign или incomplete installation не управляется автоматически.
- Пока cutover journal активен, требует recovery или quarantine ещё не подтверждён к удалению, upgrade/uninstall блокируется до безопасного завершения.
## Права администратора ## Права администратора
Без прав администратора можно открыть приложение, редактировать настройки, добавлять приложения, вводить внешний proxy, загружать/выбирать подписку и смотреть состояние. Без UAC можно редактировать настройки, выбирать приложения и proxy, загружать subscription, смотреть статус и генерировать конфигурацию.
Права администратора или UAC confirmation нужны для операций, которые меняют систему: UAC требуется для явных действий, которые меняют Windows:
- установка или удаление ProxiFyre; - install/update/uninstall ProxiFyre или Local sing-box;
- установка Windows Packet Filter / NDISAPI; - установка Windows Packet Filter и VC++ Runtime при необходимости;
- установка Microsoft Visual C++ Redistributable, если его нет; - start/stop/create/delete Windows-служб;
- установка или удаление Local sing-box; - подтверждённый legacy component cutover и его cleanup.
- создание, запуск и остановка Windows-служб;
- удаление install folder для managed-компонентов.
Применение профиля не запускает установку. Оно генерирует derived config и пытается записать его в найденную установку ProxiFyre. Если прав на запись в папку установки не хватает, операция должна завершиться ошибкой, а не устанавливать что-то скрыто. Elevated mode принимает только заранее записанный typed job ID либо один из фиксированных NSIS modes. UI не передаёт произвольную команду, script text или install path.
## Поддержанная среда
Подтверждено вручную сейчас:
```text
Windows 11
PowerShell 7 как пользовательская shell для запуска команд разработки
```
Важно: Rust backend и elevated-операции сейчас запускают именно `powershell.exe` с `-NoProfile` и `-ExecutionPolicy Bypass`. На Windows это обычно Windows PowerShell 5.1. Скрипты используют стандартные команды вроде `Get-CimInstance`, `Invoke-WebRequest`, `Expand-Archive`, `Get-FileHash`, `Start-Service`, `Stop-Service`, `ConvertTo-Json`, поэтому должны быть близки к Windows PowerShell 5.1, но полный ручной тест пока был только на Windows 11 с PowerShell 7 в окружении разработки.
Ожидаемая, но не полностью подтвержденная область:
- Windows 10/11 desktop;
- x64 как основной сценарий;
- x86 и ARM64 частично учтены в installer-логике через выбор release assets, но не считаются проверенными;
- обычный desktop/laptop без специальных требований к GPU;
- доступ в интернет к GitHub releases и Microsoft download endpoints для установки компонентов.
Linux/macOS не являются целевой платформой для этого клиента.
## Где лежат настройки
Source of truth лежит в JSON под `C:\ProgramData\ProxyWarden`:
```text
C:\ProgramData\ProxyWarden\config\profiles.json
C:\ProgramData\ProxyWarden\config\targets.json
C:\ProgramData\ProxyWarden\config\components.json
C:\ProgramData\ProxyWarden\config\local-singbox.json
C:\ProgramData\ProxyWarden\state\activity.json
C:\ProgramData\ProxyWarden\state\singbox-subscription-cache.json
```
Сгенерированные файлы лежат отдельно и могут быть пересозданы:
```text
C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json
C:\ProgramData\ProxyWarden\generated\sing-box-config.json
```
Не редактируйте generated-файлы как основной источник правды. При следующей генерации они могут быть перезаписаны.
Subscription URL считается секретом. UI и diagnostics должны показывать только редактированную/сокращенную версию ссылки.
## Типовые сценарии ## Типовые сценарии
### Внешний SOCKS5 ### Внешний SOCKS5
1. Запустите ProxyWarden. 1. Установите ProxiFyre явной кнопкой, если он отсутствует.
2. Установите или проверьте ProxiFyre. 2. На вкладке `VPN / Прокси` выберите внешний proxy и укажите `host:port` или `socks5://host:port`.
3. На вкладке `VPN / Прокси` выберите внешний proxy. 3. Добавьте приложения в ProxiFyre route.
4. Введите `host:port` или `socks5://host:port`. 4. Нажмите `Применить`.
5. На вкладке `ProxiFyre` добавьте приложения.
6. Нажмите `Применить в ProxiFyre`.
Local sing-box для этого сценария не нужен. Local sing-box для этого сценария не нужен.
### Local sing-box с подпиской ### Local sing-box с подпиской
1. Запустите ProxyWarden. 1. Явно установите ProxiFyre и Local sing-box.
2. Установите ProxiFyre. 2. Добавьте subscription URL, загрузите список и выберите сервер.
3. Установите Local sing-box. 3. Добавьте приложения и примените маршрут.
4. Вставьте subscription URL.
5. Загрузите список серверов и выберите сервер.
6. Добавьте приложения.
7. Сгенерируйте/примените маршрут.
## Установка и запуск из исходников ## Разработка
Нужны: Целевая платформа — Windows 10/11 x64. Для сборки нужны Node.js/npm, Rust через rustup, Visual Studio Build Tools с MSVC и Windows SDK. PowerShell 7 используется только для build/release/QA tooling; установленному приложению PowerShell не нужен.
- Windows 11 для подтвержденного пути разработки;
- Node.js и npm;
- Rust через rustup;
- Visual Studio Build Tools с MSVC и Windows SDK;
- Microsoft Edge WebView2 Runtime;
- PowerShell 7 удобно использовать как shell разработки, но elevated runtime-команды приложения запускаются через `powershell.exe`.
Установка зависимостей и запуск:
```powershell ```powershell
cd D:\repos\ProxyWarden Set-Location D:\repos\ProxyWarden
npm install npm ci
npm run tauri -- dev npm run tauri -- dev
``` ```
Собрать frontend: Browser preview не доказывает работу Tauri commands, UAC или Windows-служб:
```powershell
npm run build
```
Собрать установочный пакет Tauri:
```powershell
npm run tauri -- build
```
Запустить только browser-preview без нативных Tauri-команд:
```powershell ```powershell
npm run dev -- --host 127.0.0.1 npm run dev -- --host 127.0.0.1
``` ```
Browser-preview годится для проверки интерфейса, но не доказывает работу Windows-служб, elevated-операций и Tauri command handlers. ## Проверка
## Installer-скрипты Frontend и Rust:
В репозитории есть явные entrypoint-скрипты:
```powershell
& .\scripts\install-control-app.ps1 -PlanOnly
& .\scripts\install-proxyfier.ps1 -PlanOnly
& .\scripts\install-singbox.ps1 -PlanOnly
```
`-PlanOnly` возвращает structured JSON и не должен иметь side effects.
Реальная установка через эти скрипты требует прав администратора. `scripts/install-proxyfier.ps1` как standalone boundary сейчас ожидает локальный `-PackagePath`; путь установки из UI/backend использует отдельный elevated-скрипт, который скачивает ProxiFyre, Windows Packet Filter и runtime-зависимости сам.
## Проверка для разработчика
Frontend/UI:
```powershell ```powershell
npm run format:check
npm run lint
npm run typecheck
npm test -- --run
npm run build 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
``` ```
Rust/backend: Build/release/QA boundaries:
```powershell
cd D:\repos\ProxyWarden\src-tauri
cargo test
```
Tauri/toolchain:
```powershell ```powershell
& .\scripts\check-runtime-powershell-boundary.ps1 -CheckOnly
& .\scripts\update-component-bundle.ps1 -PlanOnly
& .\scripts\update-component-bundle.ps1 -CheckOnly
& .\scripts\audit-windows-smoke.ps1 -Mode PlanOnly
& .\scripts\prepare-release.ps1 -PlanOnly -SkipBuild
npm run tauri -- info npm run tauri -- info
npm run tauri -- dev
npm run tauri -- build npm run tauri -- build
``` ```
Installer boundaries: `PlanOnly` и `CheckOnly` возвращают structured JSON с `changed: false`. Обновление packaged component catalog — отдельная release-команда и не является runtime action.
```powershell Unit tests и build не подтверждают реальный UAC/SCM/driver/routing flow. Для release candidate нужны Windows VM smoke-сценарии: fresh offline install, legacy upgrade/rollback, foreign same-name service refusal и uninstall/reboot behavior.
& .\scripts\install-control-app.ps1 -PlanOnly
& .\scripts\install-proxyfier.ps1 -PlanOnly
& .\scripts\install-singbox.ps1 -PlanOnly
```
## Ограничения текущей версии ## Ограничения
- Основной поддержанный маршрут - SOCKS5. - Основной routing protocol — SOCKS5.
- ProxiFyre является текущим backend-слоем для per-app routing. - Link subscriptions поддерживают только форматы, которые явно принимает текущий parser; неизвестные поля/форматы отклоняются, а не теряются молча.
- Local sing-box остается опциональным и не требуется для внешнего SOCKS5. - Local sing-box остаётся optional.
- Elevated install/start/stop/uninstall операции считаются реализованными, но требуют дополнительной проверки на реальной Windows-машине с UAC/admin confirmation. - x86 и ARM64 не входят в текущий release contract.
- Windows 10, Windows PowerShell 5.1, ARM64 и x86 нужно отдельно подтвердить перед тем, как называть их официально поддержанными. - Реальные Windows service, UAC, driver и offline installer сценарии нельзя считать подтверждёнными без VM evidence.
+41
View File
@@ -0,0 +1,41 @@
# Third-party notices for the ProxyWarden offline bundle
This file records the third-party runtime payload planned for the ProxyWarden
`1.2.0` Windows x64 installer. It is an engineering inventory, not legal advice
or a completed distribution approval. Exact bundled hashes and sizes are owned
by `src-tauri/bundled/components/catalog.json`.
## Managed runtime assets
| Component | Pinned asset and official source | License copy | Update trust and distribution note |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ProxiFyre | `2.4.0`, [`ProxiFyre-v2.4.0-x64-signed.zip`](https://github.com/wiresock/proxifyre/releases/download/v2.4.0/ProxiFyre-v2.4.0-x64-signed.zip); [commit-pinned source archive](https://github.com/wiresock/proxifyre/archive/dd1512840e1e3bc596b06b80eda4e2dcd6a9c9ed.tar.gz) | `AGPL-3.0-only`; `src-tauri/bundled/components/proxifyre/LICENSE` | Origin is accepted only with the official GitHub release digest and the Authenticode publisher `The Anti-Cloud Corporation` on the inner executable. Before release, the project/release owner must record the corresponding-source or written-source-offer decision and approve redistribution. |
| Windows Packet Filter | release `3.6.2`, product `3.6.2.1`, [`Windows.Packet.Filter.3.6.2.1.x64.msi`](https://github.com/wiresock/ndisapi/releases/download/v3.6.2/Windows.Packet.Filter.3.6.2.1.x64.msi); [commit-pinned source archive](https://github.com/wiresock/ndisapi/archive/417b8734e844083a10236387fba705d94a2d6bc9.tar.gz) | `MIT`; `src-tauri/bundled/components/windows-packet-filter/LICENSE` | Origin is accepted only with the official GitHub release digest and MSI Authenticode publisher `The Anti-Cloud Corporation`. The MSI is a shared system dependency; its presence alone does not prove ProxyWarden ownership and does not authorize uninstall. |
| Microsoft Visual C++ x64 Redistributable | file/product version `14.51.36247.0`, [`VC_redist.x64.exe`](https://aka.ms/vs/18/release/14.51.36247/VC_redist.x64.exe) | `LicenseRef-Microsoft-Visual-Cpp-v14-Redistributable-2026`; [`src-tauri/bundled/components/vc-runtime/LICENSE.docx`](https://visualstudio.microsoft.com/wp-content/uploads/2025/10/Visual-C-V14-License-Redistributable_and_Runtime_ENU.docx) | Build-time refresh only. The pinned file must retain a valid Microsoft Corporation Authenticode signature; no in-app remote update is offered. This is proprietary Microsoft software, so the project/release owner must approve its redistribution under the bundled official terms before release. |
| sing-box | `1.13.19`, [`sing-box-1.13.19-windows-amd64.zip`](https://github.com/SagerNet/sing-box/releases/download/v1.13.19/sing-box-1.13.19-windows-amd64.zip); [commit-pinned source archive](https://github.com/SagerNet/sing-box/archive/b5ebaa1fc0f2b94256180b95468e73ef53caa27d.tar.gz) | `LicenseRef-Sing-Box-Project` (GPL-3.0-or-later plus the upstream name restriction); `src-tauri/bundled/components/sing-box/LICENSE` | Origin is accepted only with the official GitHub release digest. Redistribution must preserve the GPL terms and the upstream name restriction. Before release, the project/release owner must record the corresponding-source/source-offer decision and approve the notice text. |
| WinSW | `2.12.0`, [`WinSW.NET461.exe`](https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW.NET461.exe); [commit-pinned source archive](https://github.com/winsw/winsw/archive/eef5bade59fca0254e387ac73ed7625ba6aa7147.tar.gz) | `MIT`; `src-tauri/bundled/components/winsw/LICENSE.txt` | The selected binary is IL-only AnyCPU and is used on the x64 target with supported .NET Framework 4.8/4.8.1. Upstream supplies neither an independent digest nor an Authenticode signature for this asset, so it is `bundled-only/no-independent-proof`: remote update is disabled and a newer bundle is required to replace it. |
## WebView2 prerequisite
Microsoft Edge WebView2 Runtime is not part of the managed component catalog and
does not receive an in-app update action. Tauri packages the Microsoft WebView2
Evergreen Standalone Offline Installer into the NSIS installer through
`bundle.windows.webviewInstallMode.type = "offlineInstaller"`. Microsoft/Windows
owns later runtime servicing. The release evidence must prove that the offline
payload is present and that a clean Windows 10/11 x64 machine can install and
start ProxyWarden without network access. See the official
[WebView2 distribution page](https://developer.microsoft.com/en-us/microsoft-edge/webview2/).
## Release compliance gate
No license or distribution sign-off is claimed by this file. Before tagging or
publishing `1.2.0`, the project/release owner must record in
`docs/goals/production-ready-offline-migration/EVIDENCE.md`:
- the exact installer composition and catalog hashes;
- the reviewed license copies and source links;
- the corresponding-source/source-offer decisions for ProxiFyre and sing-box;
- the Microsoft Visual C++ and WebView2 redistribution decision;
- reviewer name/date and explicit approval.
Until that record exists, license/distribution remains a release blocker.
+60
View File
@@ -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.
+21
View File
@@ -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',
},
},
},
);
+1608 -3
View File
File diff suppressed because it is too large Load Diff
+15 -3
View File
@@ -1,13 +1,20 @@
{ {
"name": "proxywarden", "name": "proxywarden",
"version": "1.0.1", "version": "2.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Standalone Windows desktop proxy management app for ProxyWarden.", "description": "Standalone Windows desktop proxy management app for ProxyWarden.",
"scripts": { "scripts": {
"release": ".\\release.cmd",
"test:release": "node --test scripts/prepare-release.check.mjs",
"dev": "vite", "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", "preview": "vite preview",
"test": "vitest",
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
@@ -19,11 +26,16 @@
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "^2.0.0", "@tauri-apps/cli": "^2.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"eslint": "^10.7.0",
"prettier": "^3.9.5",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vite": "^7.0.0" "typescript-eslint": "^8.63.0",
"vite": "^7.0.0",
"vitest": "^3.2.4"
} }
} }
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: set this to true or false
+8
View File
@@ -0,0 +1,8 @@
@echo off
where pwsh >nul 2>nul
if errorlevel 1 (
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\prepare-release.ps1" -Publish %*
) else (
pwsh -NoProfile -File "%~dp0scripts\prepare-release.ps1" -Publish %*
)
exit /b %errorlevel%
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]
+211
View File
@@ -0,0 +1,211 @@
param(
[ValidateSet("PlanOnly", "Capture")]
[string]$Mode = "PlanOnly",
[string]$DataRoot = "C:\ProgramData\ProxyWarden",
[string]$AppRoot = "C:\Program Files\ProxyWarden",
[string]$ProxiFyreRoot = "C:\Program Files\ProxyWarden\components\ProxiFyre",
[string]$SingBoxRoot = "C:\Program Files\ProxyWarden\components\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
}
function Get-InternalStateEvidence {
param([string]$Root)
$categories = [ordered]@{
cutoverJournal = ".proxywarden-cutover"
cutoverQuarantine = ".proxywarden-quarantine"
packageStaging = ".proxywarden-package-staging"
privilegedJobs = ".proxywarden-privileged-jobs"
serviceLogs = ".proxywarden-service-logs"
singBoxCleanupTombstone = ".proxywarden-sing-box-cleanup"
}
$result = @()
foreach ($entry in $categories.GetEnumerator()) {
$path = Join-Path $Root $entry.Value
$item = Get-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
$result += [ordered]@{
category = $entry.Key
present = $null -ne $item
itemType = if ($null -eq $item) { $null } elseif ($item.PSIsContainer) { "directory" } else { "file" }
}
}
return $result
}
try {
$quotedServiceFixture = '"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe" -service'
$quotedExecutable = Get-ServiceExecutablePath -PathName $quotedServiceFixture
if (-not (Test-PathUnderRoot -Path $quotedExecutable -Root "C:\Program Files\ProxyWarden\components\sing-box")) {
throw "Quoted service PathName ownership self-test failed."
}
$plan = [ordered]@{
mode = $Mode
serviceNames = @("ProxiFyreService", "ProxyWardenSingBox")
foreignServiceName = $ForeignServiceName
roots = [ordered]@{
app = [IO.Path]::GetFullPath($AppRoot)
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", "internal-state-presence-only")
}
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)
internalState = @(Get-InternalStateEvidence -Root $AppRoot)
}
$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
internalStateCategoryCount = @($report.internalState).Count
}
} catch {
New-Result -Success $false -Action "audit-windows-smoke.$($Mode.ToLowerInvariant())" -Changed $false -Message $_.Exception.Message -Details @{}
exit 1
}
@@ -0,0 +1,253 @@
[CmdletBinding()]
param(
[switch]$CheckOnly
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$RepoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
$AllowedPowerShellFiles = @(
"scripts/audit-windows-smoke.ps1",
"scripts/check-runtime-powershell-boundary.ps1",
"scripts/prepare-release.ps1",
"scripts/update-component-bundle.ps1"
)
$ExpectedNsisFlags = @(
"--nsis-uninstall-managed",
"--nsis-verify-upgrade"
)
$IgnoredPathPattern = '^(?:\.git|node_modules|dist|releases|src-tauri/target)(?:/|$)'
function Get-RelativeRepoPath {
param([string]$Path)
$rootUri = [Uri]($RepoRoot.TrimEnd("\", "/") + [IO.Path]::DirectorySeparatorChar)
$pathUri = [Uri][IO.Path]::GetFullPath($Path)
[Uri]::UnescapeDataString($rootUri.MakeRelativeUri($pathUri).ToString()).Replace("\", "/")
}
function New-Violation {
param(
[string]$Rule,
[string]$Path,
[string]$Message,
[int]$Line = 0
)
[ordered]@{
rule = $Rule
path = $Path
line = $Line
message = $Message
}
}
function Get-ProductionLines {
param([string]$Path)
$lines = @(Get-Content -LiteralPath $Path)
for ($index = 0; $index -lt $lines.Count; $index++) {
if ($lines[$index] -match '^\s*#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]') {
if ($index -eq 0) { return @() }
return @($lines[0..($index - 1)])
}
}
return $lines
}
function Write-Result {
param(
[bool]$Success,
[string]$Message,
[object[]]$Violations,
[int]$PowerShellFileCount,
[int]$ProductionFileCount,
[string[]]$ObservedNsisFlags
)
[ordered]@{
success = $Success
action = "runtime-powershell-boundary.check"
changed = $false
message = $Message
details = [ordered]@{
allowlistedPowerShellFiles = $AllowedPowerShellFiles
scannedPowerShellFileCount = $PowerShellFileCount
scannedProductionFileCount = $ProductionFileCount
expectedNsisFlags = $ExpectedNsisFlags
observedNsisFlags = $ObservedNsisFlags
violations = $Violations
}
} | ConvertTo-Json -Depth 8
}
$violations = New-Object System.Collections.Generic.List[object]
$powerShellFileCount = 0
$productionFileCount = 0
$observedNsisFlags = @()
try {
if (-not $CheckOnly) {
[void]$violations.Add((New-Violation `
-Rule "check-only-required" `
-Path "scripts/check-runtime-powershell-boundary.ps1" `
-Message "Invoke this read-only boundary as -CheckOnly."))
}
$powerShellFiles = @(
Get-ChildItem -LiteralPath $RepoRoot -Recurse -File |
Where-Object { $_.Extension -in @(".ps1", ".psm1", ".psd1") } |
ForEach-Object {
[ordered]@{
fullPath = $_.FullName
relativePath = Get-RelativeRepoPath -Path $_.FullName
}
} |
Where-Object { $_.relativePath -notmatch $IgnoredPathPattern } |
Sort-Object relativePath
)
$powerShellFileCount = $powerShellFiles.Count
foreach ($file in $powerShellFiles) {
if ($file.relativePath -notin $AllowedPowerShellFiles) {
[void]$violations.Add((New-Violation `
-Rule "unexpected-powershell-file" `
-Path $file.relativePath `
-Message "PowerShell is allowed only for the exact build/release/QA allowlist."))
}
}
foreach ($allowedPath in $AllowedPowerShellFiles) {
if ($allowedPath -notin $powerShellFiles.relativePath) {
[void]$violations.Add((New-Violation `
-Rule "missing-allowlisted-tool" `
-Path $allowedPath `
-Message "Required build/release/QA tool is missing."))
}
}
$forbiddenRuntimeFiles = @(
"src-tauri/src/elevated_scripts.rs",
"src-tauri/src/helper.rs",
"src-tauri/src/powershell.rs",
"src-tauri/src/proxifyre_scripts.rs",
"src-tauri/bundled/cleanup/uninstall-managed-components.ps1"
)
foreach ($relativePath in $forbiddenRuntimeFiles) {
if (Test-Path -LiteralPath (Join-Path $RepoRoot $relativePath.Replace("/", "\"))) {
[void]$violations.Add((New-Violation `
-Rule "legacy-runtime-file" `
-Path $relativePath `
-Message "Legacy runtime PowerShell owner must be deleted after the native cutover."))
}
}
$tauriConfigPath = Join-Path $RepoRoot "src-tauri\tauri.conf.json"
if ((Get-Content -LiteralPath $tauriConfigPath -Raw) -match '(?i)bundled[\\/]cleanup') {
[void]$violations.Add((New-Violation `
-Rule "bundled-cleanup-resource" `
-Path "src-tauri/tauri.conf.json" `
-Message "The installer must not package the displaced PowerShell cleanup resource."))
}
$productionFiles = @(
Get-ChildItem -LiteralPath (Join-Path $RepoRoot "src-tauri\src") -Recurse -File -Filter "*.rs"
Get-ChildItem -LiteralPath (Join-Path $RepoRoot "src-tauri\bundled\installer-hooks") -Recurse -File | Where-Object { $_.Extension -in @(".nsh", ".nsi") }
)
$productionFileCount = $productionFiles.Count
$rules = @(
[ordered]@{ name = "powershell-process"; pattern = '(?i)(?:command_no_window|Command::new).*\b(?:powershell|pwsh)(?:\.exe)?\b' },
[ordered]@{ name = "powershell-command-line"; pattern = '(?i)\b(?:powershell|pwsh)(?:\.exe)?\b\s+-[A-Za-z]' },
[ordered]@{ name = "powershell-policy-bypass"; pattern = '(?i)-ExecutionPolicy\b' },
[ordered]@{ name = "powershell-script-path"; pattern = '(?i)\.ps1\b' },
[ordered]@{ name = "powershell-runtime-helper"; pattern = '(?i)\b(?:run|write)_powershell_(?:command|file|script)\b' },
[ordered]@{ name = "legacy-module-declaration"; pattern = '(?i)\b(?:pub\s+)?mod\s+(?:elevated_scripts|helper|powershell|proxifyre_scripts)\s*;' },
[ordered]@{ name = "legacy-module-reexport"; pattern = '(?i)\bpub\s+use\s+crate::(?:elevated_scripts|helper|powershell|proxifyre_scripts)\b' }
)
$productionTextParts = New-Object System.Collections.Generic.List[string]
$rustTextParts = New-Object System.Collections.Generic.List[string]
foreach ($file in $productionFiles) {
$relativePath = Get-RelativeRepoPath -Path $file.FullName
$lines = @(Get-ProductionLines -Path $file.FullName)
for ($index = 0; $index -lt $lines.Count; $index++) {
$line = [string]$lines[$index]
[void]$productionTextParts.Add($line)
if ($file.Extension -ieq ".rs") {
[void]$rustTextParts.Add($line)
}
foreach ($rule in $rules) {
if ($line -match $rule.pattern) {
[void]$violations.Add((New-Violation `
-Rule $rule.name `
-Path $relativePath `
-Line ($index + 1) `
-Message "Production code still contains a PowerShell runtime boundary."))
}
}
}
}
$productionText = $productionTextParts -join "`n"
$rustText = $rustTextParts -join "`n"
$observedNsisFlags = @(
[regex]::Matches($productionText, '--nsis-[a-z0-9-]+', [Text.RegularExpressions.RegexOptions]::IgnoreCase) |
ForEach-Object { $_.Value.ToLowerInvariant() } |
Sort-Object -Unique
)
foreach ($flag in $ExpectedNsisFlags) {
if (-not $rustText.Contains($flag)) {
[void]$violations.Add((New-Violation `
-Rule "missing-nsis-runtime-mode" `
-Path "src-tauri/src" `
-Message "Rust early-mode parser is missing fixed NSIS mode: $flag"))
}
}
foreach ($flag in $observedNsisFlags) {
if ($flag -notin $ExpectedNsisFlags) {
[void]$violations.Add((New-Violation `
-Rule "unexpected-nsis-mode" `
-Path "src-tauri" `
-Message "Unexpected reserved NSIS early mode: $flag"))
}
}
$hookPath = Join-Path $RepoRoot "src-tauri\bundled\installer-hooks\proxywarden-hooks.nsh"
$hookText = Get-Content -LiteralPath $hookPath -Raw
foreach ($flag in $ExpectedNsisFlags) {
if (-not $hookText.Contains($flag)) {
[void]$violations.Add((New-Violation `
-Rule "missing-nsis-hook-mode" `
-Path "src-tauri/bundled/installer-hooks/proxywarden-hooks.nsh" `
-Message "Installer hook does not call fixed early mode: $flag"))
}
}
$success = $violations.Count -eq 0
$message = if ($success) {
"Runtime PowerShell boundary is clean."
} else {
"Runtime PowerShell boundary has $($violations.Count) violation(s)."
}
Write-Result `
-Success $success `
-Message $message `
-Violations $violations.ToArray() `
-PowerShellFileCount $powerShellFileCount `
-ProductionFileCount $productionFileCount `
-ObservedNsisFlags $observedNsisFlags
if (-not $success) { exit 1 }
} catch {
$failure = New-Violation `
-Rule "checker-error" `
-Path "scripts/check-runtime-powershell-boundary.ps1" `
-Message $_.Exception.Message
Write-Result `
-Success $false `
-Message "Runtime PowerShell boundary check could not complete." `
-Violations @($failure) `
-PowerShellFileCount $powerShellFileCount `
-ProductionFileCount $productionFileCount `
-ObservedNsisFlags $observedNsisFlags
exit 1
}
-79
View File
@@ -1,79 +0,0 @@
param(
[string]$InstallRoot = "C:\Program Files\ProxyWarden\ControlApp",
[string]$DataRoot = "C:\ProgramData\ProxyWarden",
[switch]$PlanOnly,
[switch]$Force
)
$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 6
}
function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Ensure-Directory {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
return $true
}
return $false
}
try {
$details = @{
installRoot = $InstallRoot
dataRoot = $DataRoot
planOnly = [bool]$PlanOnly
}
if ($PlanOnly) {
New-Result -Success $true -Action "install-control-app" -Changed $false -Message "Control App install plan is ready." -Details $details
exit 0
}
if (-not (Test-IsAdministrator)) {
New-Result -Success $false -Action "install-control-app" -Changed $false -Message "Administrator rights are required." -Details $details
exit 1
}
$changed = $false
$changed = (Ensure-Directory -Path $InstallRoot) -or $changed
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "config")) -or $changed
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "state")) -or $changed
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "generated")) -or $changed
$markerPath = Join-Path $InstallRoot "install-control-app.marker.json"
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
@{ component = "control-app"; installedAt = (Get-Date).ToString("o") } |
ConvertTo-Json -Depth 4 |
Set-Content -LiteralPath $markerPath -Encoding UTF8
$changed = $true
}
$details.markerPath = $markerPath
New-Result -Success $true -Action "install-control-app" -Changed $changed -Message "Control App directories are installed." -Details $details
} catch {
New-Result -Success $false -Action "install-control-app" -Changed $false -Message $_.Exception.Message
exit 1
}
-96
View File
@@ -1,96 +0,0 @@
param(
[string]$InstallRoot = "C:\Tools\ProxiFyre",
[string]$PackagePath = "",
[string]$ServiceName = "ProxiFyreService",
[switch]$PlanOnly,
[switch]$Force
)
$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 6
}
function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Backup-File {
param([string]$Path)
if (Test-Path -LiteralPath $Path) {
$backup = "$Path.bak"
Copy-Item -LiteralPath $Path -Destination $backup -Force
return $backup
}
return $null
}
try {
$details = @{
installRoot = $InstallRoot
packagePath = $PackagePath
serviceName = $ServiceName
planOnly = [bool]$PlanOnly
}
if ($PlanOnly) {
New-Result -Success $true -Action "install-proxyfier" -Changed $false -Message "Proxyfier install plan is ready." -Details $details
exit 0
}
if (-not (Test-IsAdministrator)) {
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "Administrator rights are required." -Details $details
exit 1
}
if ([string]::IsNullOrWhiteSpace($PackagePath) -or -not (Test-Path -LiteralPath $PackagePath)) {
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "PackagePath is required and must point to a local ProxiFyre package." -Details $details
exit 2
}
$changed = $false
if (-not (Test-Path -LiteralPath $InstallRoot)) {
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
$changed = $true
}
$configPath = Join-Path $InstallRoot "app-config.json"
$backupPath = Backup-File -Path $configPath
if ($backupPath) {
$details.backupPath = $backupPath
}
$markerPath = Join-Path $InstallRoot "install-proxyfier.marker.json"
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
@{
component = "proxyfier"
packagePath = $PackagePath
serviceName = $ServiceName
installedAt = (Get-Date).ToString("o")
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8
$changed = $true
}
$details.markerPath = $markerPath
New-Result -Success $true -Action "install-proxyfier" -Changed $changed -Message "Proxyfier install boundary completed." -Details $details
} catch {
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message $_.Exception.Message
exit 1
}
-270
View File
@@ -1,270 +0,0 @@
param(
[string]$InstallRoot = "C:\Program Files\ProxyWarden\sing-box",
[string]$ServiceName = "ProxyWardenSingBox",
[string]$ConfigSource = "C:\ProgramData\ProxyWarden\generated\sing-box-config.json",
[switch]$PlanOnly,
[switch]$Force,
[switch]$Uninstall
)
$ErrorActionPreference = "Stop"
$SingBoxReleaseApi = "https://api.github.com/repos/SagerNet/sing-box/releases/latest"
$WinSwReleaseApi = "https://api.github.com/repos/winsw/winsw/releases/latest"
$WrapperFile = "$ServiceName.exe"
$ConfigFile = "config.json"
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 Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
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 "amd64" }
return "386"
}
function Get-WinSwArchitecture {
param([string]$Arch)
if ($Arch -eq "arm64") { return "arm64" }
if ($Arch -eq "386") { return "x86" }
return "x64"
}
function Invoke-Download {
param([string]$Uri, [string]$Path)
Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $Path -Headers @{ "User-Agent" = "proxywarden" }
}
function Select-Asset {
param(
[object[]]$Assets,
[string]$Pattern,
[string]$Label
)
$asset = $Assets | Where-Object { $_.name -match $Pattern } | Select-Object -First 1
if ($null -eq $asset) {
throw "Не найден release asset для $Label по шаблону $Pattern."
}
return $asset
}
function Test-SafeInstallRoot {
param([string]$Path)
$full = [System.IO.Path]::GetFullPath($Path).TrimEnd("\")
$leaf = Split-Path -Leaf $full
$parent = Split-Path -Parent $full
if ($leaf -ne "sing-box") { return $false }
return $parent -match "\\ProxyWarden$|\\proxywarden$"
}
function Backup-File {
param([string]$Path)
if (Test-Path -LiteralPath $Path) {
$backup = "$Path.bak"
Copy-Item -LiteralPath $Path -Destination $backup -Force
return $backup
}
return $null
}
function Write-Utf8NoBomFile {
param(
[string]$Path,
[string]$Value
)
$encoding = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($Path, $Value, $encoding)
}
function Write-WinSwConfig {
param(
[string]$Root,
[string]$Name
)
$xmlPath = Join-Path $Root "$Name.xml"
$logDir = Join-Path $Root "logs"
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
$xml = @"
<service>
<id>$Name</id>
<name>ProxyWarden Local sing-box</name>
<description>Local sing-box runtime managed by ProxyWarden.</description>
<executable>%BASE%\sing-box.exe</executable>
<arguments>run -c "%BASE%\config.json"</arguments>
<logpath>%BASE%\logs</logpath>
<log mode="roll-by-size">
<sizeThreshold>10485760</sizeThreshold>
<keepFiles>4</keepFiles>
</log>
<onfailure action="restart" delay="5 sec"/>
</service>
"@
Write-Utf8NoBomFile -Path $xmlPath -Value $xml
return $xmlPath
}
function Stop-And-Uninstall-Service {
param(
[string]$Root,
[string]$Name
)
$wrapper = Join-Path $Root "$Name.exe"
$service = Get-Service -Name $Name -ErrorAction SilentlyContinue
if ($null -ne $service -and $service.Status -ne "Stopped") {
Stop-Service -Name $Name -Force -ErrorAction SilentlyContinue
$service = Get-Service -Name $Name -ErrorAction SilentlyContinue
if ($null -ne $service) {
try { $service.WaitForStatus("Stopped", [TimeSpan]::FromSeconds(15)) } catch {}
}
}
if (Test-Path -LiteralPath $wrapper) {
Push-Location $Root
try { & $wrapper uninstall | Out-Null } finally { Pop-Location }
}
$service = Get-Service -Name $Name -ErrorAction SilentlyContinue
if ($null -ne $service) {
sc.exe delete $Name | Out-Null
}
}
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$installRootFull = [System.IO.Path]::GetFullPath($InstallRoot)
$details = @{
installRoot = $installRootFull
serviceName = $ServiceName
configSource = $ConfigSource
singboxReleaseApi = $SingBoxReleaseApi
winswReleaseApi = $WinSwReleaseApi
planOnly = [bool]$PlanOnly
uninstall = [bool]$Uninstall
}
if ($PlanOnly) {
$details.items = @(
@{ id = "sing-box-binary"; name = "sing-box.exe"; source = $SingBoxReleaseApi; target = (Join-Path $installRootFull "sing-box.exe") },
@{ id = "winsw-wrapper"; name = $WrapperFile; source = $WinSwReleaseApi; target = (Join-Path $installRootFull $WrapperFile) },
@{ id = "windows-service"; name = $ServiceName; target = "Windows Service" },
@{ id = "config"; name = $ConfigFile; source = $ConfigSource; target = (Join-Path $installRootFull $ConfigFile) }
)
New-Result -Success $true -Action "install-singbox.plan" -Changed $false -Message "Local sing-box install plan is ready." -Details $details
exit 0
}
if (-not (Test-IsAdministrator)) {
New-Result -Success $false -Action "install-singbox" -Changed $false -Message "Administrator rights are required." -Details $details
exit 1
}
if ($Uninstall) {
if (-not (Test-SafeInstallRoot -Path $installRootFull)) {
New-Result -Success $false -Action "uninstall-singbox" -Changed $false -Message "Unsafe InstallRoot for recursive uninstall." -Details $details
exit 2
}
Stop-And-Uninstall-Service -Root $installRootFull -Name $ServiceName
if (Test-Path -LiteralPath $installRootFull) {
Remove-Item -LiteralPath $installRootFull -Recurse -Force
}
New-Result -Success $true -Action "uninstall-singbox" -Changed $true -Message "Local sing-box service and install folder were removed." -Details $details
exit 0
}
$changed = $false
New-Item -ItemType Directory -Path $installRootFull -Force | Out-Null
$workDir = Join-Path ([System.IO.Path]::GetTempPath()) ("proxywarden-singbox-" + [guid]::NewGuid().ToString("N"))
$extractDir = Join-Path $workDir "extract"
New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
try {
$arch = Get-NativeArchitecture
$winswArch = Get-WinSwArchitecture -Arch $arch
$details.architecture = $arch
$details.winswArchitecture = $winswArch
$singboxRelease = Invoke-RestMethod -Uri $SingBoxReleaseApi -Headers @{ "User-Agent" = "proxywarden" }
$singboxAsset = Select-Asset $singboxRelease.assets "windows-$arch\.zip$" "sing-box"
$singboxZip = Join-Path $workDir $singboxAsset.name
Invoke-Download $singboxAsset.browser_download_url $singboxZip
Expand-Archive -LiteralPath $singboxZip -DestinationPath $extractDir -Force
$singboxExe = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter "sing-box.exe" | Select-Object -First 1
if ($null -eq $singboxExe) { throw "В архиве sing-box не найден sing-box.exe." }
Copy-Item -LiteralPath $singboxExe.FullName -Destination (Join-Path $installRootFull "sing-box.exe") -Force
$changed = $true
$winswRelease = Invoke-RestMethod -Uri $WinSwReleaseApi -Headers @{ "User-Agent" = "proxywarden" }
$winswAsset = Select-Asset $winswRelease.assets "WinSW-$winswArch\.exe$" "WinSW"
Invoke-Download $winswAsset.browser_download_url (Join-Path $installRootFull $WrapperFile)
$changed = $true
$configTarget = Join-Path $installRootFull $ConfigFile
$backupPath = Backup-File -Path $configTarget
if ($backupPath) { $details.backupPath = $backupPath }
if (Test-Path -LiteralPath $ConfigSource) {
Copy-Item -LiteralPath $ConfigSource -Destination $configTarget -Force
} elseif (-not (Test-Path -LiteralPath $configTarget)) {
Write-Utf8NoBomFile -Path $configTarget -Value '{"log":{"level":"info","timestamp":true},"inbounds":[],"outbounds":[{"type":"direct","tag":"direct"}],"route":{"final":"direct"}}'
}
$xmlPath = Write-WinSwConfig -Root $installRootFull -Name $ServiceName
$details.configPath = $configTarget
$details.wrapperConfigPath = $xmlPath
if ($Force) {
Stop-And-Uninstall-Service -Root $installRootFull -Name $ServiceName
}
Push-Location $installRootFull
try {
$service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($null -eq $service) {
& ".\$WrapperFile" install
if ($LASTEXITCODE -ne 0) { throw "WinSW install завершился с кодом $LASTEXITCODE." }
$changed = $true
}
& ".\$WrapperFile" start
if ($LASTEXITCODE -ne 0) {
Start-Service -Name $ServiceName -ErrorAction Stop
}
} finally {
Pop-Location
}
} finally {
if (Test-Path -LiteralPath $workDir) {
Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
New-Result -Success $true -Action "install-singbox" -Changed $changed -Message "Local sing-box service is installed and started." -Details $details
} catch {
New-Result -Success $false -Action "install-singbox" -Changed $false -Message $_.Exception.Message
exit 1
}
+437
View File
@@ -0,0 +1,437 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import {
mkdtempSync,
mkdirSync,
readFileSync,
readdirSync,
writeFileSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
const source = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), "prepare-release.ps1"),
"utf8",
).replace(/^\ufeff/, "");
const entry = source.lastIndexOf("try {\n Push-Location $RepoRoot");
const crlfEntry = source.lastIndexOf("try {\r\n Push-Location $RepoRoot");
const entryOffset = Math.max(entry, crlfEntry);
assert.ok(entryOffset > 0);
function run(cwd, command, args, ok = true) {
const result = spawnSync(command, args, {
cwd,
encoding: "utf8",
timeout: 60000,
windowsHide: true,
});
if (ok)
assert.equal(
result.status,
0,
`${command}: ${result.stdout}\n${result.stderr}`,
);
return result;
}
function fixture(t, build = "") {
const root = mkdtempSync(join(tmpdir(), "proxywarden-release-test-"));
t.after(() => {
assert.ok(resolve(root).startsWith(resolve(tmpdir()) + sep));
assert.ok(root.includes("proxywarden-release-test-"));
rmSync(root, { recursive: true, force: true });
});
const repo = join(root, "repo");
mkdirSync(repo);
const write = (path, text) => {
mkdirSync(dirname(join(repo, path)), { recursive: true });
writeFileSync(join(repo, path), text);
};
const git = (...args) => run(repo, "git", args).stdout.trim();
write("package.json", '{"name":"proxywarden","version":"1.2.0"}\n');
write(
"package-lock.json",
'{"name":"proxywarden","version":"1.2.0","packages":{"":{"name":"proxywarden","version":"1.2.0"}}}\n',
);
write("src-tauri/tauri.conf.json", '{"version":"1.2.0"}\n');
write(
"src-tauri/Cargo.toml",
'[package]\nname = "proxywarden"\nversion = "1.2.0"\n',
);
write(
"src-tauri/Cargo.lock",
'[[package]]\nname = "proxywarden"\nversion = "1.2.0"\n',
);
write(".gitignore", "node_modules/\nsrc-tauri/target/\nreleases/\n");
for (const cli of [
"typescript/bin/tsc",
"vite/bin/vite.js",
"@tauri-apps/cli/tauri.js",
"prettier/bin/prettier.cjs",
"eslint/bin/eslint.js",
"vitest/vitest.mjs",
])
write(`node_modules/${cli}`, "fixture");
// Replace only expensive checks/build in this isolated copy. Git/version/artifact/push code is real.
const stub = `
function Invoke-ReleaseChecks {}
function Invoke-ReleaseBuild {
${build}
$output = Join-Path $BundleRoot 'nsis'
New-Item -ItemType Directory -Path $output -Force | Out-Null
[IO.File]::WriteAllText((Join-Path $output "ProxyWarden_$($targetVersion)_x64-setup.exe"), 'test artifact')
}
`;
write(
"scripts/prepare-release.ps1",
"\ufeff" + source.slice(0, entryOffset) + stub + source.slice(entryOffset),
);
git("init", "-b", "master");
git("config", "user.name", "Release Test");
git("config", "user.email", "release-test@example.invalid");
git("config", "core.autocrlf", "false");
git("add", ".");
git("commit", "-m", "initial");
const remote = join(root, "origin.git");
run(root, "git", ["init", "--bare", remote]);
git("remote", "add", "origin", remote);
git("push", "-u", "origin", "master");
const release = (...args) =>
run(
repo,
"pwsh",
[
"-NoProfile",
"-File",
"scripts/prepare-release.ps1",
"-Publish",
...args,
],
false,
);
const manifest = () =>
JSON.parse(
readFileSync(
join(repo, "releases/proxywarden-v1.2.1/release-manifest.json"),
"utf8",
).replace(/^\ufeff/, ""),
);
return { root, repo, remote, git, write, release, manifest };
}
test("PlanOnly is offline and leaves versions/index/refs unchanged", (t) => {
const f = fixture(t);
f.git("remote", "set-url", "origin", join(f.root, "absent.git"));
const before = f.git("status", "--porcelain");
const head = f.git("rev-parse", "HEAD");
const result = f.release("-PlanOnly");
assert.equal(result.status, 0, result.stderr);
const plan = JSON.parse(result.stdout);
assert.equal(plan.changed, false);
assert.equal(plan.details.targetVersion, "1.2.1");
assert.equal(f.git("status", "--porcelain"), before);
assert.equal(f.git("rev-parse", "HEAD"), head);
const replacement = f.release("-Version", "1.2.0", "-Replace", "-PlanOnly");
assert.equal(replacement.status, 0, replacement.stderr);
const replacementPlan = JSON.parse(replacement.stdout);
assert.equal(replacementPlan.changed, false);
assert.equal(replacementPlan.details.replace, true);
assert.equal(
replacementPlan.details.git.replaceOnlyVersionTagWithLease,
true,
);
assert.equal(f.git("status", "--porcelain"), before);
assert.equal(f.git("rev-parse", "HEAD"), head);
});
test("release commits exact dirty source, versions both locks, tags and atomically pushes", (t) => {
const f = fixture(t);
f.write("feature.txt", "new feature");
const result = f.release("-Version", "1.2.1");
assert.equal(result.status, 0, result.stdout + result.stderr);
const head = f.git("rev-parse", "HEAD");
assert.equal(f.git("rev-parse", "v1.2.1^{commit}"), head);
assert.equal(
f.git("ls-remote", "origin", "refs/heads/master").split(/\s/)[0],
head,
);
assert.equal(f.git("status", "--porcelain"), "");
assert.equal(f.manifest().gitCommit, head);
assert.equal(f.manifest().gitRelease.status, "pushed");
assert.equal(f.manifest().artifacts.length, 1);
assert.match(
readFileSync(join(f.repo, "src-tauri/Cargo.lock"), "utf8"),
/version = "1.2.1"/,
);
const repeat = f.release("-Version", "1.2.1");
assert.notEqual(repeat.status, 0);
assert.equal(f.git("rev-parse", "HEAD"), head);
});
test("failed build creates no commit/tag/push and preserves existing staging", (t) => {
const f = fixture(t, "throw 'Synthetic build failure'");
f.write("staged.txt", "staged");
f.git("add", "staged.txt");
const index = f.git("write-tree"),
head = f.git("rev-parse", "HEAD");
assert.notEqual(f.release("-Version", "1.2.1").status, 0);
assert.equal(f.git("write-tree"), index);
assert.equal(f.git("rev-parse", "HEAD"), head);
assert.equal(f.git("tag", "--list"), "");
});
for (const remoteOnly of [false, true]) {
test(`replacement rebuilds the same version and preserves the old folder (remote-only tag: ${remoteOnly})`, (t) => {
const f = fixture(t);
assert.equal(f.release("-Version", "1.2.1").status, 0);
const oldTag = f.git("rev-parse", "refs/tags/v1.2.1");
const oldCommit = f.git("rev-parse", "HEAD");
const oldManifest = f.manifest();
if (remoteOnly) f.git("tag", "-d", "v1.2.1");
f.write("feature.txt", "updated before publishing");
const result = f.release("-Version", "1.2.1", "-Replace");
assert.equal(result.status, 0, result.stdout + result.stderr);
const newTag = f.git("rev-parse", "refs/tags/v1.2.1");
assert.notEqual(newTag, oldTag);
assert.equal(
f.git("rev-parse", "v1.2.1^{commit}"),
f.git("rev-parse", "HEAD"),
);
assert.equal(
f.git("ls-remote", "origin", "refs/tags/v1.2.1").split(/\s/)[0],
newTag,
);
assert.equal(f.git("rev-parse", "HEAD~1"), oldCommit);
assert.equal(f.manifest().gitRelease.previousRemoteTag, oldTag);
assert.equal(f.manifest().gitRelease.status, "pushed");
const backups = readdirSync(join(f.repo, "releases")).filter((name) =>
name.startsWith("proxywarden-v1.2.1-replaced-"),
);
assert.equal(backups.length, 1);
assert.deepEqual(
JSON.parse(
readFileSync(
join(f.repo, "releases", backups[0], "release-manifest.json"),
"utf8",
),
),
oldManifest,
);
assert.equal(
readFileSync(
join(
f.repo,
"releases",
backups[0],
"artifacts/nsis/ProxyWarden_1.2.1_x64-setup.exe",
),
"utf8",
),
"test artifact",
);
assert.equal(f.git("status", "--porcelain"), "");
});
}
test("failed replacement build preserves the previous release and refs", (t) => {
const f = fixture(
t,
"if ($Replace) { throw 'Synthetic replacement build failure' }",
);
assert.equal(f.release("-Version", "1.2.1").status, 0);
const oldManifest = f.manifest();
const oldRefs = f.git("show-ref");
f.write("feature.txt", "work in progress");
const result = f.release("-Version", "1.2.1", "-Replace");
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Synthetic replacement build failure/);
assert.equal(f.git("show-ref"), oldRefs);
assert.deepEqual(f.manifest(), oldManifest);
assert.deepEqual(readdirSync(join(f.repo, "releases")), [
"proxywarden-v1.2.1",
]);
});
test("a failed replacement push resumes using the original tag lease", (t) => {
const f = fixture(t);
assert.equal(f.release("-Version", "1.2.1").status, 0);
const oldRefs = f.git("ls-remote", "origin");
f.write("feature.txt", "replacement");
const hook = join(f.remote, "hooks/pre-receive");
writeFileSync(hook, "#!/bin/sh\nexit 1\n");
const result = f.release("-Version", "1.2.1", "-Replace");
assert.notEqual(result.status, 0);
assert.equal(f.manifest().gitRelease.status, "pending-push");
assert.equal(f.git("ls-remote", "origin"), oldRefs);
const replacementTag = f.git("rev-parse", "refs/tags/v1.2.1");
rmSync(hook);
const resumed = f.release("-Version", "1.2.1", "-Resume");
assert.equal(resumed.status, 0, resumed.stdout + resumed.stderr);
assert.equal(f.manifest().gitRelease.status, "pushed");
assert.equal(
f.git("ls-remote", "origin", "refs/tags/v1.2.1").split(/\s/)[0],
replacementTag,
);
const repeat = f.release("-Version", "1.2.1", "-Resume");
assert.equal(repeat.status, 0, repeat.stdout + repeat.stderr);
});
test("replacement never forces the branch when it advances during the build", (t) => {
const f = fixture(
t,
`if ($Replace) {
$otherCommit = 'Concurrent remote commit' | & git commit-tree 'HEAD^{tree}' -p HEAD
Invoke-Git @('push', 'origin', "${"$"}{otherCommit}:refs/heads/master") | Out-Null
}`,
);
assert.equal(f.release("-Version", "1.2.1").status, 0);
const oldTag = f.git("ls-remote", "origin", "refs/tags/v1.2.1");
f.write("feature.txt", "replacement");
const result = f.release("-Version", "1.2.1", "-Replace");
assert.notEqual(result.status, 0);
assert.match(result.stderr, /non-fast-forward|fetch first/);
const remoteBranch = f
.git("ls-remote", "origin", "refs/heads/master")
.split(/\s/)[0];
assert.notEqual(remoteBranch, f.git("rev-parse", "HEAD"));
assert.equal(
f.git("show", "-s", "--format=%s", remoteBranch),
"Concurrent remote commit",
);
assert.equal(f.git("ls-remote", "origin", "refs/tags/v1.2.1"), oldTag);
});
test("a concurrent remote tag change is preserved, including on Resume", (t) => {
const f = fixture(
t,
`if ($Replace) {
$otherCommit = Invoke-Git @('rev-parse', 'HEAD~1')
Invoke-Git @('--git-dir', (Join-Path $RepoRoot '../origin.git'), 'update-ref', 'refs/tags/v1.2.1', $otherCommit) | Out-Null
}`,
);
assert.equal(f.release("-Version", "1.2.1").status, 0);
const oldBranch = f.git("ls-remote", "origin", "refs/heads/master");
const concurrentTag = f.git("rev-parse", "HEAD~1");
f.write("feature.txt", "replacement");
const result = f.release("-Version", "1.2.1", "-Replace");
assert.notEqual(result.status, 0);
assert.match(result.stderr, /stale info/);
const resumed = f.release("-Version", "1.2.1", "-Resume");
assert.notEqual(resumed.status, 0);
assert.match(resumed.stderr, /stale info/);
assert.equal(
f.git("ls-remote", "origin", "refs/tags/v1.2.1").split(/\s/)[0],
concurrentTag,
);
assert.equal(f.git("ls-remote", "origin", "refs/heads/master"), oldBranch);
});
test("a concurrent local tag change is not overwritten by replacement", (t) => {
const f = fixture(
t,
"if ($Replace) { Invoke-Git @('tag', '-f', 'v1.2.1', 'HEAD~1') | Out-Null }",
);
assert.equal(f.release("-Version", "1.2.1").status, 0);
const oldRemote = f.git("ls-remote", "origin");
const concurrentTag = f.git("rev-parse", "HEAD~1");
f.write("feature.txt", "replacement");
const result = f.release("-Version", "1.2.1", "-Replace");
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Local version tag changed/);
assert.equal(f.git("rev-parse", "refs/tags/v1.2.1"), concurrentTag);
assert.equal(f.git("ls-remote", "origin"), oldRemote);
});
test("source edit during build refuses to tag an artifact from another tree", (t) => {
const f = fixture(
t,
"[IO.File]::WriteAllText((Join-Path $RepoRoot 'concurrent.txt'), 'changed during build')",
);
const head = f.git("rev-parse", "HEAD");
const result = f.release("-Version", "1.2.1");
assert.notEqual(result.status, 0);
assert.match(result.stderr, /changed during the build/);
assert.match(result.stderr, /concurrent\.txt/);
assert.equal(f.git("rev-parse", "HEAD"), head);
assert.equal(f.git("tag", "--list"), "");
});
test("failed atomic push keeps artifacts and resumes without rebuilding", (t) => {
const f = fixture(t);
const hook = join(f.remote, "hooks/pre-receive");
writeFileSync(hook, "#!/bin/sh\nexit 1\n");
const before = f.git("ls-remote", "origin", "refs/heads/master");
const failed = f.release("-Version", "1.2.1");
assert.notEqual(failed.status, 0);
assert.equal(f.manifest().gitRelease.status, "pending-push");
assert.equal(f.git("ls-remote", "origin", "refs/heads/master"), before);
assert.equal(f.git("ls-remote", "origin", "refs/tags/v1.2.1"), "");
const commit = f.git("rev-parse", "HEAD");
rmSync(hook);
const resumed = f.release("-Version", "1.2.1", "-Resume");
assert.equal(resumed.status, 0, resumed.stdout + resumed.stderr);
assert.equal(f.git("rev-parse", "HEAD"), commit);
assert.equal(f.manifest().gitRelease.status, "pushed");
f.write(
"releases/proxywarden-v1.2.1/artifacts/nsis/ProxyWarden_1.2.1_x64-setup.exe",
"tampered",
);
assert.notEqual(f.release("-Version", "1.2.1", "-Resume").status, 0);
});
test("remote-only version tag and diverged branch are refused before version edits", (t) => {
const f = fixture(t);
f.git("tag", "v1.2.1");
f.git("push", "origin", "refs/tags/v1.2.1");
f.git("tag", "-d", "v1.2.1");
const version = readFileSync(join(f.repo, "package.json"), "utf8");
assert.notEqual(f.release("-Version", "1.2.1").status, 0);
assert.equal(readFileSync(join(f.repo, "package.json"), "utf8"), version);
const clone = join(f.root, "other");
run(f.root, "git", ["clone", "--branch", "master", f.remote, clone]);
run(clone, "git", ["config", "user.name", "Other"]);
run(clone, "git", ["config", "user.email", "other@example.invalid"]);
writeFileSync(join(clone, "remote-change.txt"), "remote");
run(clone, "git", ["add", "."]);
run(clone, "git", ["commit", "-m", "remote change"]);
run(clone, "git", ["push"]);
const result = f.release("-Version", "1.2.2");
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Integrate them before releasing/);
assert.equal(readFileSync(join(f.repo, "package.json"), "utf8"), version);
});
test("invalid Windows versions and mismatched Cargo.lock fail without mutations", (t) => {
const f = fixture(t);
for (const version of ["01.2.3", "1.2.65536", "1.2.3-rc.1"])
assert.notEqual(f.release("-Version", version, "-PlanOnly").status, 0);
f.write(
"src-tauri/Cargo.lock",
'[[package]]\nname = "proxywarden"\nversion = "0.0.0"\n',
);
const result = f.release("-PlanOnly");
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Version mismatch/);
});
test("unreachable origin reports the Git cause and diagnostic command without a PowerShell stack", (t) => {
const f = fixture(t);
f.git("remote", "set-url", "origin", join(f.root, "absent.git"));
const head = f.git("rev-parse", "HEAD");
const version = readFileSync(join(f.repo, "package.json"), "utf8");
const result = f.release("-Version", "1.2.1");
assert.notEqual(result.status, 0);
assert.match(result.stderr, /git ls-remote origin/);
assert.match(result.stderr, /does not appear to be a git repository/);
assert.doesNotMatch(
result.stderr,
/prepare-release\.ps1:\d|ScriptStackTrace|Line \|/,
);
assert.equal(f.git("rev-parse", "HEAD"), head);
assert.equal(readFileSync(join(f.repo, "package.json"), "utf8"), version);
});
+338 -24
View File
@@ -1,4 +1,4 @@
param( param(
[string]$Version = "", [string]$Version = "",
[ValidateSet("", "patch", "minor", "major")] [ValidateSet("", "patch", "minor", "major")]
[string]$Bump = "", [string]$Bump = "",
@@ -6,9 +6,13 @@ param(
[switch]$SkipTests, [switch]$SkipTests,
[switch]$SkipBuild, [switch]$SkipBuild,
[switch]$PlanOnly, [switch]$PlanOnly,
[switch]$Publish,
[switch]$Resume,
[switch]$Replace,
[switch]$Force [switch]$Force
) )
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) $RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
@@ -16,7 +20,11 @@ $PackageJsonPath = Join-Path $RepoRoot "package.json"
$PackageLockPath = Join-Path $RepoRoot "package-lock.json" $PackageLockPath = Join-Path $RepoRoot "package-lock.json"
$TauriConfigPath = Join-Path $RepoRoot "src-tauri\tauri.conf.json" $TauriConfigPath = Join-Path $RepoRoot "src-tauri\tauri.conf.json"
$CargoTomlPath = Join-Path $RepoRoot "src-tauri\Cargo.toml" $CargoTomlPath = Join-Path $RepoRoot "src-tauri\Cargo.toml"
$CargoLockPath = Join-Path $RepoRoot "src-tauri\Cargo.lock"
$BundleRoot = Join-Path $RepoRoot "src-tauri\target\release\bundle" $BundleRoot = Join-Path $RepoRoot "src-tauri\target\release\bundle"
$RuntimeBoundaryCheckPath = Join-Path $RepoRoot "scripts\check-runtime-powershell-boundary.ps1"
$ComponentBundleScriptPath = Join-Path $RepoRoot "scripts\update-component-bundle.ps1"
$WindowsAuditScriptPath = Join-Path $RepoRoot "scripts\audit-windows-smoke.ps1"
function Write-Utf8NoBomFile { function Write-Utf8NoBomFile {
param( param(
@@ -143,9 +151,12 @@ function Set-PackageLockVersions {
function Assert-Semver { function Assert-Semver {
param([string]$Value) param([string]$Value)
if ($Value -notmatch "^\d+\.\d+\.\d+$") { if ($Value -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$') {
throw "Version '$Value' is not supported. Use numeric SemVer like 0.1.0." throw "Version '$Value' is not supported. Use numeric SemVer like 0.1.0."
} }
foreach ($part in $Value.Split('.')) {
if ([long]$part -gt 65535) { throw "Version components must be between 0 and 65535 for Windows." }
}
} }
function ConvertTo-VersionParts { function ConvertTo-VersionParts {
@@ -206,6 +217,9 @@ function Get-CargoPackageVersion {
function Get-VersionState { function Get-VersionState {
$packageLock = Get-PackageLockVersions $packageLock = Get-PackageLockVersions
$cargoLock = Get-Content -Raw -LiteralPath $CargoLockPath
$cargoMatch = [regex]::Match($cargoLock, '(?m)^name = "proxywarden"\r?\nversion = "([^"]+)"')
if (-not $cargoMatch.Success) { throw 'Cannot find ProxyWarden in Cargo.lock.' }
[ordered]@{ [ordered]@{
packageJson = [string](Get-FirstJsonVersion -Path $PackageJsonPath -Label "package.json") packageJson = [string](Get-FirstJsonVersion -Path $PackageJsonPath -Label "package.json")
@@ -213,6 +227,7 @@ function Get-VersionState {
packageLockRoot = [string]$packageLock.packageLockRoot packageLockRoot = [string]$packageLock.packageLockRoot
tauriConfig = [string](Get-FirstJsonVersion -Path $TauriConfigPath -Label "tauri.conf.json") tauriConfig = [string](Get-FirstJsonVersion -Path $TauriConfigPath -Label "tauri.conf.json")
cargoToml = [string](Get-CargoPackageVersion) cargoToml = [string](Get-CargoPackageVersion)
cargoLock = $cargoMatch.Groups[1].Value
} }
} }
@@ -223,7 +238,8 @@ function Get-CurrentVersion {
$state.packageLock, $state.packageLock,
$state.packageLockRoot, $state.packageLockRoot,
$state.tauriConfig, $state.tauriConfig,
$state.cargoToml $state.cargoToml,
$state.cargoLock
) | Select-Object -Unique) ) | Select-Object -Unique)
if ($versions.Count -ne 1) { if ($versions.Count -ne 1) {
@@ -247,23 +263,24 @@ function Resolve-TargetVersion {
return Get-NextVersion -Current $Current -Kind $Bump return Get-NextVersion -Current $Current -Kind $Bump
} }
if ($PlanOnly -or -not [Environment]::UserInteractive) { if ($PlanOnly) {
return Get-NextVersion -Current $Current -Kind "patch" return Get-NextVersion -Current $Current -Kind "patch"
} }
if (-not [Environment]::UserInteractive) { throw "Specify -Version or -Bump in non-interactive mode." }
$patch = Get-NextVersion -Current $Current -Kind "patch" $patch = Get-NextVersion -Current $Current -Kind "patch"
$minor = Get-NextVersion -Current $Current -Kind "minor" $minor = Get-NextVersion -Current $Current -Kind "minor"
$major = Get-NextVersion -Current $Current -Kind "major" $major = Get-NextVersion -Current $Current -Kind "major"
Write-Host "" Write-Host ""
Write-Host "Current version: $Current" Write-Host "Текущая версия: $Current"
Write-Host "Choose release version:" Write-Host "Выбери номер или введи версию, например $patch :"
Write-Host " 1) patch $patch" Write-Host " 1) patch $patch"
Write-Host " 2) minor $minor" Write-Host " 2) minor $minor"
Write-Host " 3) major $major" Write-Host " 3) major $major"
Write-Host " 4) custom" Write-Host " 4) другая версия"
Write-Host " 5) keep current $Current" Write-Host " 5) текущая $Current (если ещё не выпущена)"
$choice = Read-Host "Selection [1]" $choice = Read-Host "Версия [1]"
if ([string]::IsNullOrWhiteSpace($choice)) { $choice = "1" } if ([string]::IsNullOrWhiteSpace($choice)) { $choice = "1" }
@@ -277,7 +294,7 @@ function Resolve-TargetVersion {
return $custom return $custom
} }
"5" { return $Current } "5" { return $Current }
default { throw "Unknown selection '$choice'." } default { Assert-Semver -Value $choice.Trim(); return $choice.Trim() }
} }
} }
@@ -313,6 +330,9 @@ function Set-ManifestVersions {
Set-PackageLockVersions -TargetVersion $TargetVersion Set-PackageLockVersions -TargetVersion $TargetVersion
Set-FirstJsonVersion -Path $TauriConfigPath -TargetVersion $TargetVersion -Label "tauri.conf.json" Set-FirstJsonVersion -Path $TauriConfigPath -TargetVersion $TargetVersion -Label "tauri.conf.json"
Set-CargoPackageVersion -TargetVersion $TargetVersion Set-CargoPackageVersion -TargetVersion $TargetVersion
$lock = Get-Content -Raw -LiteralPath $CargoLockPath
$lock = Replace-RegexGroup -Content $lock -Pattern '(?m)^name = "proxywarden"\r?\nversion = "(?<value>[^"]+)"' -GroupName "value" -Value $TargetVersion -Label "ProxyWarden version in Cargo.lock"
Write-Utf8NoBomFile -Path $CargoLockPath -Value $lock
} }
function Get-FullPath { function Get-FullPath {
@@ -354,11 +374,19 @@ function New-ReleaseDirectory {
$releaseDir = Join-Path $root "proxywarden-v$TargetVersion" $releaseDir = Join-Path $root "proxywarden-v$TargetVersion"
if (Test-Path -LiteralPath $releaseDir) { if (Test-Path -LiteralPath $releaseDir) {
if (-not $Replace -and ($Publish -or -not $Force)) { throw "Release directory already exists: $releaseDir. Use -Version $TargetVersion -Replace to rebuild an unreleased version, or -Resume to retry its push." }
if (-not (Test-IsSubPath -Parent $root -Child $releaseDir)) { if (-not (Test-IsSubPath -Parent $root -Child $releaseDir)) {
throw "Refusing to remove release directory outside OutputRoot: $releaseDir" throw "Refusing to replace release directory outside OutputRoot: $releaseDir"
}
if ($Replace) {
$backupDir = "$releaseDir-replaced-$(Get-Date -Format 'yyyyMMdd-HHmmss')-$([guid]::NewGuid().ToString('N').Substring(0, 8))"
if (-not (Test-IsSubPath -Parent $root -Child $backupDir)) { throw 'Release backup must stay inside OutputRoot.' }
Move-Item -LiteralPath $releaseDir -Destination $backupDir
Write-Host "Предыдущая сборка сохранена: $backupDir"
} else {
Write-Host "Replacing existing release directory: $releaseDir"
Remove-Item -LiteralPath $releaseDir -Recurse -Force
} }
Write-Host "Replacing existing release directory: $releaseDir"
Remove-Item -LiteralPath $releaseDir -Recurse -Force
} }
New-Item -ItemType Directory -Path (Join-Path $releaseDir "artifacts") -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $releaseDir "artifacts") -Force | Out-Null
@@ -412,17 +440,51 @@ function Invoke-ReleaseBuild {
return return
} }
Invoke-NativeCommand -Name "Frontend build" -FilePath "npm" -Arguments @("run", "build") Invoke-NativeCommand -Name "Frontend types" -FilePath "node" -Arguments @("node_modules/typescript/bin/tsc", "--noEmit")
if (-not $SkipTests) { if (-not $SkipTests) {
Invoke-NativeCommand -Name "Rust tests" -FilePath "cargo" -Arguments @("test") -WorkingDirectory (Join-Path $RepoRoot "src-tauri") Invoke-NativeCommand -Name "Frontend formatting" -FilePath "node" -Arguments @("node_modules/prettier/bin/prettier.cjs", "--check", "src/**/*.{ts,tsx,css}")
Invoke-NativeCommand -Name "Frontend lint" -FilePath "node" -Arguments @("node_modules/eslint/bin/eslint.js", "src")
Invoke-NativeCommand -Name "Frontend tests" -FilePath "node" -Arguments @("node_modules/vitest/vitest.mjs", "run")
Invoke-NativeCommand -Name "Rust formatting" -FilePath "cargo" -Arguments @("fmt", "--all", "--", "--check") -WorkingDirectory (Join-Path $RepoRoot "src-tauri")
Invoke-NativeCommand -Name "Rust lint" -FilePath "cargo" -Arguments @("clippy", "--locked", "--all-targets", "--all-features", "--", "-D", "warnings") -WorkingDirectory (Join-Path $RepoRoot "src-tauri")
Invoke-NativeCommand -Name "Rust tests" -FilePath "cargo" -Arguments @("test", "--locked", "--all-targets") -WorkingDirectory (Join-Path $RepoRoot "src-tauri")
} else { } else {
Write-Host "" Write-Host ""
Write-Host "Skipping Rust tests because -SkipTests was provided." Write-Host "Skipping Rust tests because -SkipTests was provided."
} }
Invoke-NativeCommand -Name "Frontend build" -FilePath "node" -Arguments @("node_modules/vite/bin/vite.js", "build")
Clear-ReleaseBundleOutput Clear-ReleaseBundleOutput
Invoke-NativeCommand -Name "Tauri release build" -FilePath "npm" -Arguments @("run", "tauri", "--", "build") # Use a temporary config file: JSON command-line quoting differs between Windows PowerShell and pwsh.
$config = Join-Path ([IO.Path]::GetTempPath()) ("proxywarden-build-" + [guid]::NewGuid().ToString('N') + '.json')
try {
Write-Utf8NoBomFile -Path $config -Value '{"build":{"beforeBuildCommand":""}}'
Invoke-NativeCommand -Name "Tauri release build" -FilePath "node" -Arguments @("node_modules/@tauri-apps/cli/tauri.js", "build", "--config", $config, "--bundles", "nsis")
} finally { if (Test-Path -LiteralPath $config) { Remove-Item -LiteralPath $config } }
}
function Invoke-ScriptCheck {
param(
[string]$Name,
[string]$ScriptPath,
[hashtable]$Parameters
)
Write-Host ""
Write-Host "==> $Name"
$output = & $ScriptPath @Parameters
$succeeded = $?
$output | Write-Output
if (-not $succeeded) {
throw "$Name failed."
}
}
function Invoke-ReleaseChecks {
Invoke-ScriptCheck -Name "Runtime PowerShell boundary" -ScriptPath $RuntimeBoundaryCheckPath -Parameters @{ CheckOnly = $true }
Invoke-ScriptCheck -Name "Offline component bundle" -ScriptPath $ComponentBundleScriptPath -Parameters @{ CheckOnly = $true }
Invoke-ScriptCheck -Name "Windows smoke evidence plan" -ScriptPath $WindowsAuditScriptPath -Parameters @{ Mode = "PlanOnly" }
} }
function Get-ArtifactVersionPattern { function Get-ArtifactVersionPattern {
@@ -503,7 +565,7 @@ function Get-GitValue {
param([string[]]$Arguments) param([string[]]$Arguments)
try { try {
$value = & git @Arguments 2>$null $value = & git --no-optional-locks @Arguments 2>$null
if ($LASTEXITCODE -eq 0) { if ($LASTEXITCODE -eq 0) {
return ($value -join [Environment]::NewLine).Trim() return ($value -join [Environment]::NewLine).Trim()
} }
@@ -511,11 +573,197 @@ function Get-GitValue {
return "" return ""
} }
function Get-GitFailureMessage {
param([string]$Operation, [int]$ExitCode, [string]$Diagnostic)
$reason = if ($Diagnostic -match 'Too many authentication failures') {
'SSH-сервер отклонил слишком много попыток входа. Укажи правильный ключ и IdentitiesOnly yes для этого Git-сервера.'
} elseif ($Diagnostic -match 'Permission denied \(publickey|Authentication failed|could not read Username|terminal prompts disabled') {
'Сервер Git отклонил вход. Проверь SSH-ключ или HTTPS-аутентификацию и доступ к репозиторию.'
} elseif ($Diagnostic -match 'Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED') {
'Не подтверждён SSH-ключ сервера. Проверь его отпечаток перед повторным подключением.'
} elseif ($Diagnostic -match 'Could not resolve|Connection timed out|Connection refused|Network is unreachable|connect to host.*Permission denied|Failed to connect') {
'Не удалось подключиться к Git-серверу. Проверь сеть/VPN, адрес и порт origin.'
} elseif ($Diagnostic -match 'not found|does not appear to be a git repository') {
'Репозиторий недоступен по адресу origin. Проверь URL и права доступа.'
} else {
"Git не выполнил операцию $Operation (код $ExitCode)."
}
$details = "$Diagnostic".Trim() -replace '(https?://)[^/\s@]+@', '$1[redacted]@' -replace '(https?://[^\s?#]+)[?#][^\s]*', '$1'
if ($details.Length -gt 2500) { $details = $details.Substring(0, 2500) + '...' }
$next = if ($Operation -in @('ls-remote', 'fetch')) {
'Проверка origin завершилась до изменения версии, сборки, commit, tag и push. Для диагностики запусти: git ls-remote origin'
} else { 'Подробности ответа Git приведены ниже.' }
return "$reason`n$next`n`nОтвет Git:`n$details"
}
function Invoke-Git {
param([string[]]$Arguments)
$stderrPath = Join-Path ([IO.Path]::GetTempPath()) ("proxywarden-git-" + [guid]::NewGuid().ToString('N') + '.log')
$previousPreference = $ErrorActionPreference
try {
# Windows PowerShell wraps redirected stderr as NativeCommandError; preserve it,
# then classify by the actual exit code instead of losing the original cause.
$ErrorActionPreference = 'Continue'
$output = & git @Arguments 2>$stderrPath
$exitCode = $LASTEXITCODE
$ErrorActionPreference = $previousPreference
[string]$diagnostic = ''
if (Test-Path -LiteralPath $stderrPath) { $diagnostic = [string](Get-Content -Raw -LiteralPath $stderrPath) }
if ($exitCode -ne 0) { throw (Get-GitFailureMessage -Operation $Arguments[0] -ExitCode $exitCode -Diagnostic $diagnostic) }
if (-not [string]::IsNullOrWhiteSpace($diagnostic)) { Write-Host $diagnostic.Trim() }
return ([string]($output -join "`n")).Trim()
} finally {
$ErrorActionPreference = $previousPreference
if (Test-Path -LiteralPath $stderrPath) { Remove-Item -LiteralPath $stderrPath }
}
}
function Get-SourceTree {
# Snapshot tracked + non-ignored new files without touching the user's staging area.
$previousIndex = $env:GIT_INDEX_FILE
$index = Join-Path ([IO.Path]::GetTempPath()) ("proxywarden-index-" + [guid]::NewGuid().ToString('N'))
try {
$env:GIT_INDEX_FILE = $index
Invoke-Git @('read-tree', 'HEAD') | Out-Null
Invoke-Git @('add', '-A', '--', '.') | Out-Null
return Invoke-Git @('write-tree')
} finally {
$env:GIT_INDEX_FILE = $previousIndex
foreach ($path in @($index, "$index.lock")) {
if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path }
}
}
}
function Get-ReleasePath {
param([string]$TargetVersion)
$root = if ([IO.Path]::IsPathRooted($OutputRoot)) { $OutputRoot } else { Join-Path $RepoRoot $OutputRoot }
return [IO.Path]::GetFullPath((Join-Path $root "proxywarden-v$TargetVersion"))
}
function Test-GitTag {
param([string]$Tag)
& git show-ref --verify --quiet "refs/tags/$Tag"
if ($LASTEXITCODE -eq 0) { return $true }
if ($LASTEXITCODE -ne 1) { throw "Cannot inspect local tag $Tag." }
return $false
}
function Get-ReleaseGitContext {
param([string]$TargetVersion)
$branch = Invoke-Git @('symbolic-ref', '--quiet', '--short', 'HEAD')
$headCommit = Invoke-Git @('rev-parse', 'HEAD')
foreach ($marker in @('MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply')) {
$path = Invoke-Git @('rev-parse', '--git-path', $marker)
if (Test-Path -LiteralPath $path) { throw "Finish the active Git operation before releasing ($marker)." }
}
if (Invoke-Git @('diff', '--name-only', '--diff-filter=U')) { throw 'Resolve Git conflicts before releasing.' }
Invoke-Git @('var', 'GIT_AUTHOR_IDENT') | Out-Null
Invoke-Git @('var', 'GIT_COMMITTER_IDENT') | Out-Null
$remote = Invoke-Git @('remote', 'get-url', '--push', 'origin')
$tag = "v$TargetVersion"
$localTag = if (Test-GitTag $tag) { Invoke-Git @('rev-parse', "refs/tags/$tag") } else { '' }
if (-not $Resume -and -not $Replace -and $localTag) { throw "Tag $tag already exists. Use -Version $TargetVersion -Replace to rebuild an unreleased version, -Resume to retry its push, or choose another version." }
$remoteTag = Invoke-Git @('ls-remote', '--refs', '--tags', 'origin', "refs/tags/$tag")
if (-not $Resume -and -not $Replace -and $remoteTag) { throw "Remote tag $tag already exists. Use -Version $TargetVersion -Replace to rebuild an unreleased version, or choose another version." }
$remoteTagId = if ($remoteTag) { ($remoteTag -split '\s+')[0] } else { '' }
$remoteBranch = Invoke-Git @('ls-remote', '--heads', 'origin', "refs/heads/$branch")
if ($remoteBranch) {
Invoke-Git @('fetch', '--no-tags', 'origin', "refs/heads/$branch") | Out-Null
& git merge-base --is-ancestor FETCH_HEAD HEAD
if ($LASTEXITCODE -ne 0) { throw "The origin/$branch branch has changes not in HEAD. Integrate them before releasing; automatic merge is not performed." }
}
return @{ branch = $branch; head = $headCommit; remote = $remote; tag = $tag; replace = [bool]$Replace; previousLocalTag = $localTag; previousRemoteTag = $remoteTagId }
}
function Complete-ReleaseGit {
param([hashtable]$Context, [string]$SourceTree, [string]$TargetVersion)
$currentTree = Get-SourceTree
if ((Invoke-Git @('rev-parse', 'HEAD')) -ne $Context.head -or
(Invoke-Git @('symbolic-ref', '--quiet', '--short', 'HEAD')) -ne $Context.branch -or
$currentTree -ne $SourceTree) {
$changed = Invoke-Git @('-c', 'core.quotepath=false', 'diff', '--name-only', $SourceTree, $currentTree)
throw "Source files or HEAD changed during the build. No release commit/tag was created.`nИсходники изменились во время сборки. Повтори сборку после завершения правок.`nИзменённые файлы:`n$changed"
}
if ((Invoke-Git @('rev-parse', 'HEAD^{tree}')) -ne $SourceTree) {
Invoke-Git @('add', '-A', '--', '.') | Out-Null
if ((Invoke-Git @('write-tree')) -ne $SourceTree) { throw 'Staged source changed. Rebuild before releasing.' }
Invoke-Git @('commit', '-m', "Release v$TargetVersion") | Write-Host
}
if ((Invoke-Git @('rev-parse', 'HEAD^{tree}')) -ne $SourceTree -or
(Get-SourceTree) -ne $SourceTree) { throw 'A Git hook changed source files. Rebuild before tagging.' }
return Invoke-Git @('rev-parse', 'HEAD')
}
function Push-Release {
param([hashtable]$Context, [string]$Commit)
if ((Invoke-Git @('rev-parse', 'HEAD')) -ne $Commit -or
(Invoke-Git @('symbolic-ref', '--quiet', '--short', 'HEAD')) -ne $Context.branch -or
(Invoke-Git @('remote', 'get-url', '--push', 'origin')) -ne $Context.remote) {
throw 'HEAD, branch or origin changed before push.'
}
$localTag = if (Test-GitTag $Context.tag) { Invoke-Git @('rev-parse', "refs/tags/$($Context.tag)") } else { '' }
if ($Context.replace -and $localTag -ne $Context.previousLocalTag -and
(-not $Resume -or -not $localTag -or (Invoke-Git @('rev-parse', "$($Context.tag)^{commit}")) -ne $Commit)) {
throw 'Local version tag changed during the release. Replacement refused.'
}
if ($localTag) {
if ((Invoke-Git @('rev-parse', "$($Context.tag)^{commit}")) -ne $Commit) {
if (-not $Context.replace) { throw 'Existing tag points to another commit.' }
Invoke-Git @('tag', '-a', '-f', $Context.tag, $Commit, '-m', "ProxyWarden $($Context.tag)") | Out-Null
}
} else {
Invoke-Git @('tag', '-a', $Context.tag, $Commit, '-m', "ProxyWarden $($Context.tag)") | Out-Null
}
$tagObject = Invoke-Git @('rev-parse', "refs/tags/$($Context.tag)")
$pushArgs = @('push', '--atomic')
if ($Context.replace) {
# Lease only this tag, never the branch. Keep the original expectation across Resume.
$pushArgs += "--force-with-lease=refs/tags/$($Context.tag):$($Context.previousRemoteTag)"
}
$pushArgs += @('origin', "${Commit}:refs/heads/$($Context.branch)", "${tagObject}:refs/tags/$($Context.tag)")
Invoke-Git $pushArgs | Write-Host
}
function Resume-Release {
param([string]$TargetVersion, [hashtable]$Context)
$releaseDir = Get-ReleasePath $TargetVersion
$manifestPath = Join-Path $releaseDir 'release-manifest.json'
$manifest = Read-JsonFile $manifestPath
if (-not $manifest.PSObject.Properties['gitRelease'] -or -not $manifest.gitRelease) {
throw 'This folder has no completed release commit. Resume only retries a failed push; choose a new version and rebuild.'
}
if ($manifest.version -ne $TargetVersion -or $manifest.gitRelease.branch -ne $Context.branch -or
$manifest.gitRelease.remote -ne $Context.remote -or $manifest.gitRelease.tag -ne $Context.tag -or
$manifest.gitRelease.status -notin @('pending-push', 'pushed') -or
$manifest.gitCommit -ne $Context.head -or
(Invoke-Git @('rev-parse', 'HEAD^{tree}')) -ne $manifest.gitRelease.sourceTree -or
(Get-SourceTree) -ne $manifest.gitRelease.sourceTree) {
throw 'This release no longer matches HEAD/source/origin. Resume refused; use a new version.'
}
if (@($manifest.artifacts).Count -eq 0) { throw 'No artifacts to resume.' }
foreach ($artifact in $manifest.artifacts) {
$path = [IO.Path]::GetFullPath((Join-Path $releaseDir $artifact.path))
if (-not (Test-IsSubPath $releaseDir $path) -or
(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ne $artifact.sha256) { throw 'Release artifact checksum mismatch.' }
}
if ($manifest.gitRelease.PSObject.Properties['replace'] -and $manifest.gitRelease.replace) {
$Context.replace = $true
$Context.previousLocalTag = $manifest.gitRelease.previousLocalTag
$Context.previousRemoteTag = $manifest.gitRelease.previousRemoteTag
}
Push-Release -Context $Context -Commit $manifest.gitCommit
$manifest.gitRelease.status = 'pushed'
Write-JsonFile -Path $manifestPath -Value $manifest
Write-Host "Релиз отправлен. Файлы для сайта: $releaseDir"
}
function Write-ReleaseMetadata { function Write-ReleaseMetadata {
param( param(
[string]$ReleaseDir, [string]$ReleaseDir,
[string]$TargetVersion, [string]$TargetVersion,
[object[]]$Artifacts [object[]]$Artifacts,
[object]$GitRelease = $null
) )
$artifactDir = Join-Path $ReleaseDir "artifacts" $artifactDir = Join-Path $ReleaseDir "artifacts"
@@ -534,6 +782,8 @@ function Write-ReleaseMetadata {
source = "local" source = "local"
gitCommit = Get-GitValue -Arguments @("rev-parse", "HEAD") gitCommit = Get-GitValue -Arguments @("rev-parse", "HEAD")
gitStatus = Get-GitValue -Arguments @("status", "--short") gitStatus = Get-GitValue -Arguments @("status", "--short")
gitRelease = $GitRelease
windowsAcceptance = "not-verified-by-this-command"
artifacts = @($artifactItems) artifacts = @($artifactItems)
} }
@@ -560,7 +810,7 @@ See `SHA256SUMS.txt`.
## Release boundary ## Release boundary
This release contains the ProxyWarden Control App only. ProxiFyre and Local sing-box remain explicit user-managed components. The ProxyWarden installer contains pinned offline payloads for ProxiFyre, Windows Packet Filter, VC++ Runtime, sing-box, WinSW, and WebView2. Installing, updating, starting, stopping, or removing routing components remains an explicit user action.
"@ "@
@@ -590,16 +840,34 @@ function New-PlanResult {
releaseDirectory = (Join-Path $outputRootFull "proxywarden-v$Target") releaseDirectory = (Join-Path $outputRootFull "proxywarden-v$Target")
skipTests = [bool]$SkipTests skipTests = [bool]$SkipTests
skipBuild = [bool]$SkipBuild skipBuild = [bool]$SkipBuild
publish = [bool]$Publish
resume = [bool]$Resume
replace = [bool]$Replace
git = [ordered]@{
branch = Get-GitValue @('symbolic-ref', '--quiet', '--short', 'HEAD')
remote = 'origin'
tag = "v$Target"
includedChanges = Get-GitValue @('status', '--short')
commitAfterSuccessfulBuild = [bool]$Publish
atomicPush = [bool]$Publish
replaceOnlyVersionTagWithLease = [bool]$Replace
preservePreviousReleaseDirectory = [bool]$Replace
}
manifests = @( manifests = @(
$PackageJsonPath, $PackageJsonPath,
$PackageLockPath, $PackageLockPath,
$TauriConfigPath, $TauriConfigPath,
$CargoTomlPath $CargoTomlPath,
$CargoLockPath
) )
commands = @( commands = @(
"npm run build", ".\scripts\check-runtime-powershell-boundary.ps1 -CheckOnly",
"cd src-tauri; cargo test", ".\scripts\update-component-bundle.ps1 -CheckOnly",
"npm run tauri -- build" ".\scripts\audit-windows-smoke.ps1 -Mode PlanOnly",
"node: TypeScript, Prettier, ESLint, Vitest, Vite",
"cargo fmt / clippy --locked / test --locked --all-targets",
"node: Tauri build --bundles nsis",
"if -Publish: commit source, annotated version tag, atomic branch+tag push to origin"
) )
} }
} | ConvertTo-Json -Depth 8 } | ConvertTo-Json -Depth 8
@@ -608,6 +876,16 @@ function New-PlanResult {
try { try {
Push-Location $RepoRoot Push-Location $RepoRoot
if ($Resume -and (-not $Publish -or -not $Version -or $Bump)) { throw '-Resume requires -Publish -Version X.Y.Z.' }
if ($Replace -and (-not $Publish -or -not $Version -or $Bump -or $Resume)) { throw '-Replace requires -Publish -Version X.Y.Z and cannot be combined with -Resume or -Bump.' }
if ($Version -and $Bump) { throw 'Use either -Version or -Bump.' }
if ($Publish -and -not $PlanOnly -and ($SkipTests -or $SkipBuild -or $Force)) { throw 'A published release requires checks and a fresh build; SkipTests, SkipBuild and Force are not allowed.' }
if ($Publish -and -not $PlanOnly -and -not $Resume) {
Write-Host 'В релиз войдут все изменения Git ниже (кроме игнорируемых файлов).'
Write-Host 'После успешной сборки: commit, тег версии и push текущей ветки в origin.'
Write-Host 'Файлы установщика останутся локально для загрузки на сайт.'
Write-Host (Invoke-Git @('status', '--short'))
}
$currentVersion = Get-CurrentVersion $currentVersion = Get-CurrentVersion
$targetVersion = Resolve-TargetVersion -Current $currentVersion $targetVersion = Resolve-TargetVersion -Current $currentVersion
Assert-Semver -Value $targetVersion Assert-Semver -Value $targetVersion
@@ -624,6 +902,28 @@ try {
Write-Host "" Write-Host ""
Write-Host "Preparing ProxyWarden release $targetVersion..." Write-Host "Preparing ProxyWarden release $targetVersion..."
Write-Host "Repository: $RepoRoot" Write-Host "Repository: $RepoRoot"
if ($Replace) { Write-Host "Пересборка невыпущенного релиза v$targetVersion с заменой тега. Предыдущая папка будет сохранена рядом." }
$gitContext = $null
if ($Publish) {
$gitContext = Get-ReleaseGitContext $targetVersion
if ($Resume) { Resume-Release -TargetVersion $targetVersion -Context $gitContext; return }
}
$releasePath = Get-ReleasePath $targetVersion
if ((Test-Path -LiteralPath $releasePath) -and -not $Replace -and ($Publish -or -not $Force)) {
throw "Release directory already exists: $releasePath. Use -Version $targetVersion -Replace to rebuild an unreleased version, or -Resume to retry its push."
}
if ($Publish -and (Test-IsSubPath $RepoRoot $releasePath)) {
& git check-ignore --quiet -- (Join-Path $releasePath 'release-manifest.json')
if ($LASTEXITCODE -ne 0) { throw 'OutputRoot must be ignored by Git, or outside the repository.' }
}
if (-not $SkipBuild) {
Get-Command node, cargo -ErrorAction Stop | Out-Null
foreach ($cli in @('typescript/bin/tsc', 'vite/bin/vite.js', '@tauri-apps/cli/tauri.js', 'prettier/bin/prettier.cjs', 'eslint/bin/eslint.js', 'vitest/vitest.mjs')) {
if (-not (Test-Path -LiteralPath (Join-Path $RepoRoot "node_modules/$cli"))) { throw 'Frontend dependencies are missing. Run npm ci once, then retry release.' }
}
}
Invoke-ReleaseChecks
Set-ManifestVersions -TargetVersion $targetVersion Set-ManifestVersions -TargetVersion $targetVersion
$afterUpdateVersion = Get-CurrentVersion $afterUpdateVersion = Get-CurrentVersion
@@ -631,6 +931,7 @@ try {
throw "Version update failed. Current version is $afterUpdateVersion." throw "Version update failed. Current version is $afterUpdateVersion."
} }
$sourceTree = if ($Publish) { Get-SourceTree } else { $null }
Invoke-ReleaseBuild Invoke-ReleaseBuild
$releaseDir = New-ReleaseDirectory -TargetVersion $targetVersion $releaseDir = New-ReleaseDirectory -TargetVersion $targetVersion
@@ -638,11 +939,24 @@ try {
Write-Checksums -ReleaseDir $releaseDir -Files $artifacts | Out-Null Write-Checksums -ReleaseDir $releaseDir -Files $artifacts | Out-Null
Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts
if ($Publish) {
$commit = Complete-ReleaseGit -Context $gitContext -SourceTree $sourceTree -TargetVersion $targetVersion
$gitRelease = [ordered]@{ branch = $gitContext.branch; remote = $gitContext.remote; tag = $gitContext.tag; sourceTree = $sourceTree; status = 'pending-push'; replace = $gitContext.replace; previousLocalTag = $gitContext.previousLocalTag; previousRemoteTag = $gitContext.previousRemoteTag }
Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts -GitRelease $gitRelease
try { Push-Release -Context $gitContext -Commit $commit }
catch { throw "Push failed; local release is preserved. Retry: .\release.cmd -Version $targetVersion -Resume. $($_.Exception.Message)" }
$gitRelease.status = 'pushed'
Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts -GitRelease $gitRelease
}
Write-Host "" Write-Host ""
Write-Host "Release folder is ready:" Write-Host "Release folder is ready:"
Write-Host $releaseDir Write-Host $releaseDir
Write-Host "" Write-Host ""
Write-Host "Upload the files from the release folder to GitHub release v$targetVersion." Write-Host 'Загрузи EXE из artifacts\nsis на сайт. SHA256SUMS.txt содержит контрольную сумму.'
} catch {
[Console]::Error.WriteLine("`nРелиз не завершён.`n" + $_.Exception.Message)
exit 1
} finally { } finally {
Pop-Location Pop-Location
} }
File diff suppressed because it is too large Load Diff
+46 -1
View File
@@ -47,6 +47,15 @@ version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "atk" name = "atk"
version = "0.18.2" version = "0.18.2"
@@ -543,6 +552,17 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@@ -783,6 +803,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [ dependencies = [
"crc32fast", "crc32fast",
"miniz_oxide", "miniz_oxide",
"zlib-rs",
] ]
[[package]] [[package]]
@@ -2314,19 +2335,24 @@ dependencies = [
[[package]] [[package]]
name = "proxywarden" name = "proxywarden"
version = "1.0.1" version = "2.0.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"percent-encoding", "percent-encoding",
"quick-xml",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"thiserror 2.0.18",
"url", "url",
"uuid", "uuid",
"windows-sys 0.61.2",
"winreg", "winreg",
"zip",
] ]
[[package]] [[package]]
@@ -4846,6 +4872,25 @@ dependencies = [
"syn 2.0.118", "syn 2.0.118",
] ]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"flate2",
"indexmap 2.14.0",
"memchr",
]
[[package]]
name = "zlib-rs"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
+22 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "proxywarden" name = "proxywarden"
version = "1.0.1" version = "2.0.0"
description = "Standalone Windows desktop proxy management app for ProxyWarden." description = "Standalone Windows desktop proxy management app for ProxyWarden."
authors = ["ProxyWarden"] authors = ["ProxyWarden"]
edition = "2021" edition = "2021"
@@ -22,6 +22,27 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking",
percent-encoding = "2" percent-encoding = "2"
url = "2" url = "2"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
thiserror = "2"
sha2 = "0.10"
quick-xml = "0.39"
zip = { version = "4", default-features = false, features = ["deflate-flate2-zlib-rs"] }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winreg = "0.55" winreg = "0.55"
windows-sys = { version = "0.61.2", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Security_Cryptography",
"Win32_Security_Cryptography_Catalog",
"Win32_Security_Cryptography_Sip",
"Win32_Security_WinTrust",
"Win32_Storage_FileSystem",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Registry",
"Win32_System_Services",
"Win32_System_SystemInformation",
"Win32_System_Threading",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
+130
View File
@@ -0,0 +1,130 @@
{
"schemaVersion": 1,
"targetArch": "x64",
"components": [
{
"id": "proxifyre",
"version": "2.4.0",
"fileVersion": "2.4.0",
"productVersion": "2.4.0",
"assetPath": "proxifyre/ProxiFyre-v2.4.0-x64-signed.zip",
"assetArch": "x64",
"effectiveTarget": "x64",
"sha256": "eab65fd7d8eeb716abedb5614618c641de3f9eb8326b99cee1da787141e30cac",
"size": 1519694,
"sourceUrl": "https://github.com/wiresock/proxifyre/releases/download/v2.4.0/ProxiFyre-v2.4.0-x64-signed.zip",
"license": {
"id": "AGPL-3.0-only",
"path": "proxifyre/LICENSE"
},
"installRole": "proxifyre-runtime",
"updateTrustPolicy": {
"type": "githubReleaseDigest",
"repository": "wiresock/proxifyre",
"tagPattern": "v*",
"assetPattern": "ProxiFyre-v*-x64-signed.zip",
"requireStable": true,
"authenticodePublishers": [
"The Anti-Cloud Corporation"
]
}
},
{
"id": "windows-packet-filter",
"version": "3.6.2",
"fileVersion": "3.6.2.1",
"productVersion": "3.6.2.1",
"assetPath": "windows-packet-filter/Windows.Packet.Filter.3.6.2.1.x64.msi",
"assetArch": "x64",
"effectiveTarget": "x64",
"sha256": "9c388c0b7f189f7fa98720bae2caecf7d64f30910838b80b438ecf8956b8502c",
"size": 819200,
"sourceUrl": "https://github.com/wiresock/ndisapi/releases/download/v3.6.2/Windows.Packet.Filter.3.6.2.1.x64.msi",
"license": {
"id": "MIT",
"path": "windows-packet-filter/LICENSE"
},
"installRole": "packet-filter-driver",
"updateTrustPolicy": {
"type": "githubReleaseDigest",
"repository": "wiresock/ndisapi",
"tagPattern": "v*",
"assetPattern": "Windows.Packet.Filter.*.x64.msi",
"requireStable": true,
"authenticodePublishers": [
"The Anti-Cloud Corporation"
]
}
},
{
"id": "vc-runtime",
"version": "14.51.36247.0",
"fileVersion": "14.51.36247.0",
"productVersion": "14.51.36247.0",
"assetPath": "vc-runtime/VC_redist.x64.exe",
"assetArch": "x64",
"effectiveTarget": "x64",
"sha256": "843068991daaa1f73ad9f6239bce4d0f6a07a51f18c37ea2a867e9beca71295c",
"size": 18731856,
"sourceUrl": "https://aka.ms/vs/18/release/14.51.36247/VC_redist.x64.exe",
"license": {
"id": "LicenseRef-Microsoft-Visual-Cpp-v14-Redistributable-2026",
"path": "vc-runtime/LICENSE.docx"
},
"installRole": "vc-runtime-prerequisite",
"updateTrustPolicy": {
"type": "buildTimeOnlyAuthenticode",
"allowedSourceHosts": [
"aka.ms"
],
"assetPattern": "VC_redist.x64.exe",
"publishers": [
"Microsoft Corporation"
]
}
},
{
"id": "sing-box",
"version": "1.13.19",
"assetPath": "sing-box/sing-box-1.13.19-windows-amd64.zip",
"assetArch": "x64",
"effectiveTarget": "x64",
"sha256": "e011a4def2f5e2b143ed54adb2b1a20a6be407806ab4442f3667f1dd817a2c8d",
"size": 21046252,
"sourceUrl": "https://github.com/SagerNet/sing-box/releases/download/v1.13.19/sing-box-1.13.19-windows-amd64.zip",
"license": {
"id": "LicenseRef-Sing-Box-Project",
"path": "sing-box/LICENSE"
},
"installRole": "sing-box-runtime",
"updateTrustPolicy": {
"type": "githubReleaseDigest",
"repository": "SagerNet/sing-box",
"tagPattern": "v*",
"assetPattern": "sing-box-*-windows-amd64.zip",
"requireStable": true
}
},
{
"id": "winsw",
"version": "2.12.0",
"fileVersion": "2.12.0.0",
"productVersion": "2.12.0+eef5bade59fca0254e387ac73ed7625ba6aa7147",
"assetPath": "winsw/WinSW.NET461.exe",
"assetArch": "anycpu",
"effectiveTarget": "x64",
"sha256": "b5066b7bbdfba1293e5d15cda3caaea88fbeab35bd5b38c41c913d492aadfc4f",
"size": 655872,
"sourceUrl": "https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW.NET461.exe",
"license": {
"id": "MIT",
"path": "winsw/LICENSE.txt"
},
"installRole": "sing-box-service-wrapper",
"updateTrustPolicy": {
"type": "bundledOnlyNoIndependentProof",
"reason": "The official v2.12.0 asset is unsigned and has no independent release digest; runtime network update is disabled."
}
}
]
}
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
@@ -0,0 +1,17 @@
Copyright (C) 2022 by nekohasekai <contact-sagernet@sekai.icu>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
In addition, no derivative work may use the name or imply association
with this application without prior consent.
Binary file not shown.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Vadim Smirnov
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.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2008-2020 Kohsuke Kawaguchi, Sun Microsystems, Inc., CloudBees, Inc., Oleg Nenashev and other 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.
Binary file not shown.
@@ -0,0 +1,809 @@
; Upstream: tauri-cli-v2.11.4 / tauri-bundler 2.9.4
; Original SHA256: 20f4ecc730defb71f1342eaeaec4021df13be3d843abba0effe88ea5835fa079
; ProxyWarden: upgrade in place; never run a previous uninstaller.
Unicode true
ManifestDPIAware true
; Add in `dpiAwareness` `PerMonitorV2` to manifest for Windows 10 1607+ (note this should not affect lower versions since they should be able to ignore this and pick up `dpiAware` `true` set by `ManifestDPIAware true`)
; Currently undocumented on NSIS's website but is in the Docs folder of source tree, see
; https://github.com/kichik/nsis/blob/5fc0b87b819a9eec006df4967d08e522ddd651c9/Docs/src/attributes.but#L286-L300
; https://github.com/tauri-apps/tauri/pull/10106
ManifestDPIAwareness PerMonitorV2
!if "{{compression}}" == "none"
SetCompress off
!else
; Set the compression algorithm. We default to LZMA.
SetCompressor /SOLID "{{compression}}"
!endif
; Keep above !include to stay ahead of any plugin command
; see https://github.com/tauri-apps/tauri/pull/15422#discussion_r3289239624
{{#if signed_plugins_path}}
!addplugindir "{{signed_plugins_path}}"
{{/if}}
!include MUI2.nsh
!include FileFunc.nsh
!include x64.nsh
!include WordFunc.nsh
!include "utils.nsh"
!include "FileAssociation.nsh"
!include "Win\COM.nsh"
!include "Win\Propkey.nsh"
!include "StrFunc.nsh"
${StrCase}
${StrLoc}
{{#if installer_hooks}}
!include "{{installer_hooks}}"
{{/if}}
!define WEBVIEW2APPGUID "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
!define MANUFACTURER "{{manufacturer}}"
!define PRODUCTNAME "{{product_name}}"
!define VERSION "{{version}}"
!define VERSIONWITHBUILD "{{version_with_build}}"
!define HOMEPAGE "{{homepage}}"
!define INSTALLMODE "{{install_mode}}"
!define LICENSE "{{license}}"
!define INSTALLERICON "{{installer_icon}}"
!define SIDEBARIMAGE "{{sidebar_image}}"
!define HEADERIMAGE "{{header_image}}"
!define UNINSTALLERICON "{{uninstaller_icon}}"
!define UNINSTALLERHEADERIMAGE "{{uninstaller_header_image}}"
!define MAINBINARYNAME "{{main_binary_name}}"
!define MAINBINARYSRCPATH "{{main_binary_path}}"
!define BUNDLEID "{{bundle_id}}"
!define COPYRIGHT "{{copyright}}"
!define OUTFILE "{{out_file}}"
!define ARCH "{{arch}}"
!define ADDITIONALPLUGINSPATH "{{additional_plugins_path}}"
!define ALLOWDOWNGRADES "{{allow_downgrades}}"
!define DISPLAYLANGUAGESELECTOR "{{display_language_selector}}"
!define INSTALLWEBVIEW2MODE "{{install_webview2_mode}}"
!define WEBVIEW2INSTALLERARGS "{{webview2_installer_args}}"
!define WEBVIEW2BOOTSTRAPPERPATH "{{webview2_bootstrapper_path}}"
!define WEBVIEW2INSTALLERPATH "{{webview2_installer_path}}"
!define MINIMUMWEBVIEW2VERSION "{{minimum_webview2_version}}"
!define UNINSTKEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCTNAME}"
!define MANUKEY "Software\${MANUFACTURER}"
!define MANUPRODUCTKEY "${MANUKEY}\${PRODUCTNAME}"
!define UNINSTALLERSIGNCOMMAND "{{uninstaller_sign_cmd}}"
!define ESTIMATEDSIZE "{{estimated_size}}"
!define STARTMENUFOLDER "{{start_menu_folder}}"
Var PassiveMode
Var UpdateMode
Var NoShortcutMode
Var WixMode
Var OldMainBinaryName
Name "${PRODUCTNAME}"
BrandingText "${COPYRIGHT}"
OutFile "${OUTFILE}"
; We don't actually use this value as default install path,
; it's just for nsis to append the product name folder in the directory selector
; https://nsis.sourceforge.io/Reference/InstallDir
!define PLACEHOLDER_INSTALL_DIR "placeholder\${PRODUCTNAME}"
InstallDir "${PLACEHOLDER_INSTALL_DIR}"
VIProductVersion "${VERSIONWITHBUILD}"
VIAddVersionKey "ProductName" "${PRODUCTNAME}"
VIAddVersionKey "FileDescription" "${PRODUCTNAME}"
VIAddVersionKey "LegalCopyright" "${COPYRIGHT}"
VIAddVersionKey "FileVersion" "${VERSION}"
VIAddVersionKey "ProductVersion" "${VERSION}"
# additional plugins
!addplugindir "${ADDITIONALPLUGINSPATH}"
; Uninstaller signing command
!if "${UNINSTALLERSIGNCOMMAND}" != ""
!uninstfinalize '${UNINSTALLERSIGNCOMMAND}'
!endif
; Handle install mode, `perUser`, `perMachine` or `both`
!if "${INSTALLMODE}" == "perMachine"
RequestExecutionLevel admin
!endif
!if "${INSTALLMODE}" == "currentUser"
RequestExecutionLevel user
!endif
!if "${INSTALLMODE}" == "both"
!define MULTIUSER_MUI
!define MULTIUSER_INSTALLMODE_INSTDIR "${PRODUCTNAME}"
!define MULTIUSER_INSTALLMODE_COMMANDLINE
!if "${ARCH}" == "x64"
!define MULTIUSER_USE_PROGRAMFILES64
!else if "${ARCH}" == "arm64"
!define MULTIUSER_USE_PROGRAMFILES64
!endif
!define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_KEY "${UNINSTKEY}"
!define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_VALUENAME "CurrentUser"
!define MULTIUSER_INSTALLMODEPAGE_SHOWUSERNAME
!define MULTIUSER_INSTALLMODE_FUNCTION RestorePreviousInstallLocation
!define MULTIUSER_EXECUTIONLEVEL Highest
!include MultiUser.nsh
!endif
; Installer icon
!if "${INSTALLERICON}" != ""
!define MUI_ICON "${INSTALLERICON}"
!endif
; Installer sidebar image
!if "${SIDEBARIMAGE}" != ""
!define MUI_WELCOMEFINISHPAGE_BITMAP "${SIDEBARIMAGE}"
!endif
; Enable header images for installer and uninstaller pages when either image is configured.
!if "${HEADERIMAGE}" != ""
!define MUI_HEADERIMAGE
!else if "${UNINSTALLERHEADERIMAGE}" != ""
!define MUI_HEADERIMAGE
!endif
; Installer header image
!if "${HEADERIMAGE}" != ""
!define MUI_HEADERIMAGE_BITMAP "${HEADERIMAGE}"
!endif
; Uninstaller header image
!if "${UNINSTALLERHEADERIMAGE}" != ""
!define MUI_HEADERIMAGE_UNBITMAP "${UNINSTALLERHEADERIMAGE}"
!endif
; Uninstaller icon
!if "${UNINSTALLERICON}" != ""
!define MUI_UNICON "${UNINSTALLERICON}"
!endif
; Define registry key to store installer language
!define MUI_LANGDLL_REGISTRY_ROOT "HKCU"
!define MUI_LANGDLL_REGISTRY_KEY "${MANUPRODUCTKEY}"
!define MUI_LANGDLL_REGISTRY_VALUENAME "Installer Language"
; Installer pages, must be ordered as they appear
; 1. Welcome Page
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive
!insertmacro MUI_PAGE_WELCOME
; 2. License Page (if defined)
!if "${LICENSE}" != ""
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive
!insertmacro MUI_PAGE_LICENSE "${LICENSE}"
!endif
; 3. Install mode (if it is set to `both`)
!if "${INSTALLMODE}" == "both"
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive
!insertmacro MULTIUSER_PAGE_INSTALLMODE
!endif
; 4. Custom page to ask user if he wants to reinstall/uninstall
; only if a previous installation was detected
; Reinstall page removed: previous uninstallers may delete managed data.
; 5. Choose install directory page
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive
!insertmacro MUI_PAGE_DIRECTORY
; 6. Start menu shortcut page
Var AppStartMenuFolder
!if "${STARTMENUFOLDER}" != ""
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive
!define MUI_STARTMENUPAGE_DEFAULTFOLDER "${STARTMENUFOLDER}"
!else
!define MUI_PAGE_CUSTOMFUNCTION_PRE Skip
!endif
!insertmacro MUI_PAGE_STARTMENU Application $AppStartMenuFolder
; 7. Installation page
!insertmacro MUI_PAGE_INSTFILES
; 8. Finish page
;
; Don't auto jump to finish page after installation page,
; because the installation page has useful info that can be used debug any issues with the installer.
!define MUI_FINISHPAGE_NOAUTOCLOSE
; Use show readme button in the finish page as a button create a desktop shortcut
!define MUI_FINISHPAGE_SHOWREADME
!define MUI_FINISHPAGE_SHOWREADME_TEXT "$(createDesktop)"
!define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateOrUpdateDesktopShortcut
; Show run app after installation.
!define MUI_FINISHPAGE_RUN
!define MUI_FINISHPAGE_RUN_FUNCTION RunMainBinary
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive
!insertmacro MUI_PAGE_FINISH
Function RunMainBinary
nsis_tauri_utils::RunAsUser "$INSTDIR\${MAINBINARYNAME}.exe" ""
FunctionEnd
; Uninstaller Pages
; 1. Confirm uninstall page
Var DeleteAppDataCheckbox
Var DeleteAppDataCheckboxState
!define /ifndef WS_EX_LAYOUTRTL 0x00400000
!define MUI_PAGE_CUSTOMFUNCTION_SHOW un.ConfirmShow
Function un.ConfirmShow ; Add add a `Delete app data` check box
; $1 inner dialog HWND
; $2 window DPI
; $3 style
; $4 x
; $5 y
; $6 width
; $7 height
FindWindow $1 "#32770" "" $HWNDPARENT ; Find inner dialog
System::Call "user32::GetDpiForWindow(p r1) i .r2"
${If} $(^RTL) = 1
StrCpy $3 "${__NSD_CheckBox_EXSTYLE} | ${WS_EX_LAYOUTRTL}"
IntOp $4 50 * $2
${Else}
StrCpy $3 "${__NSD_CheckBox_EXSTYLE}"
IntOp $4 0 * $2
${EndIf}
IntOp $5 100 * $2
IntOp $6 400 * $2
IntOp $7 25 * $2
IntOp $4 $4 / 96
IntOp $5 $5 / 96
IntOp $6 $6 / 96
IntOp $7 $7 / 96
System::Call 'user32::CreateWindowEx(i r3, w "${__NSD_CheckBox_CLASS}", w "$(deleteAppData)", i ${__NSD_CheckBox_STYLE}, i r4, i r5, i r6, i r7, p r1, i0, i0, i0) i .s'
Pop $DeleteAppDataCheckbox
SendMessage $HWNDPARENT ${WM_GETFONT} 0 0 $1
SendMessage $DeleteAppDataCheckbox ${WM_SETFONT} $1 1
FunctionEnd
!define MUI_PAGE_CUSTOMFUNCTION_LEAVE un.ConfirmLeave
Function un.ConfirmLeave
SendMessage $DeleteAppDataCheckbox ${BM_GETCHECK} 0 0 $DeleteAppDataCheckboxState
FunctionEnd
!define MUI_PAGE_CUSTOMFUNCTION_PRE un.SkipIfPassive
!insertmacro MUI_UNPAGE_CONFIRM
; 2. Uninstalling Page
!insertmacro MUI_UNPAGE_INSTFILES
;Languages
{{#each languages}}
!insertmacro MUI_LANGUAGE "{{this}}"
{{/each}}
!insertmacro MUI_RESERVEFILE_LANGDLL
{{#each language_files}}
!include "{{this}}"
{{/each}}
; Read-only checks run from .onInit for interactive, passive, silent and /UPDATE.
!macro PWRejectMsi ROOT VIEW
SetRegView ${VIEW}
StrCpy $0 0
${Do}
EnumRegKey $1 ${ROOT} "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" $0
${If} $1 == ""
${Break}
${EndIf}
IntOp $0 $0 + 1
ReadRegStr $2 ${ROOT} "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "DisplayName"
${If} $2 == "${PRODUCTNAME}"
ReadRegDWORD $3 ${ROOT} "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "WindowsInstaller"
${If} $3 == 1
IfSilent +2
MessageBox MB_ICONSTOP "Обнаружена MSI-установка ProxyWarden. Автоматическое удаление старой версии заблокировано для сохранности компонентов и настроек. Требуется отдельный проверенный перенос MSI → NSIS."
SetErrorLevel 1603
Quit
${EndIf}
${EndIf}
${Loop}
!macroend
Function .onInit
${GetOptions} $CMDLINE "/P" $PassiveMode
${IfNot} ${Errors}
StrCpy $PassiveMode 1
${EndIf}
${GetOptions} $CMDLINE "/NS" $NoShortcutMode
${IfNot} ${Errors}
StrCpy $NoShortcutMode 1
${EndIf}
${GetOptions} $CMDLINE "/UPDATE" $UpdateMode
${IfNot} ${Errors}
StrCpy $UpdateMode 1
${EndIf}
!if "${DISPLAYLANGUAGESELECTOR}" == "true"
!insertmacro MUI_LANGDLL_DISPLAY
!endif
!insertmacro PWRejectMsi HKLM 32
!insertmacro PWRejectMsi HKCU 32
${If} ${RunningX64}
!insertmacro PWRejectMsi HKLM 64
!insertmacro PWRejectMsi HKCU 64
${EndIf}
!insertmacro SetContext
StrCpy $WixMode 0
ReadRegStr $R0 SHCTX "${UNINSTKEY}" "DisplayVersion"
${If} $R0 != ""
nsis_tauri_utils::SemverCompare "${VERSION}" $R0
Pop $R0
${If} $R0 = -1
IfSilent +2
MessageBox MB_ICONSTOP "Установлена более новая версия ProxyWarden. Понижение версии заблокировано."
SetErrorLevel 1603
Quit
${EndIf}
${EndIf}
!insertmacro SetContext
${If} $INSTDIR == "${PLACEHOLDER_INSTALL_DIR}"
; Set default install location
!if "${INSTALLMODE}" == "perMachine"
${If} ${RunningX64}
!if "${ARCH}" == "x64"
StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}"
!else if "${ARCH}" == "arm64"
StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}"
!else
StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}"
!endif
${Else}
StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}"
${EndIf}
!else if "${INSTALLMODE}" == "currentUser"
StrCpy $INSTDIR "$LOCALAPPDATA\${PRODUCTNAME}"
!endif
Call RestorePreviousInstallLocation
${EndIf}
!if "${INSTALLMODE}" == "both"
!insertmacro MULTIUSER_INIT
!endif
FunctionEnd
Section WebView2
; Check if Webview2 is already installed and skip this section
${If} ${RunningX64}
ReadRegStr $4 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv"
${Else}
ReadRegStr $4 HKLM "SOFTWARE\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv"
${EndIf}
${If} $4 == ""
ReadRegStr $4 HKCU "SOFTWARE\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv"
${EndIf}
${If} $4 == ""
; Webview2 installation
;
; Skip if updating
${If} $UpdateMode <> 1
!if "${INSTALLWEBVIEW2MODE}" == "downloadBootstrapper"
Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe"
DetailPrint "$(webview2Downloading)"
NSISdl::download "https://go.microsoft.com/fwlink/p/?LinkId=2124703" "$TEMP\MicrosoftEdgeWebview2Setup.exe"
Pop $0
${If} $0 == "success"
DetailPrint "$(webview2DownloadSuccess)"
${Else}
DetailPrint "$(webview2DownloadError)"
Abort "$(webview2AbortError)"
${EndIf}
StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe"
Goto install_webview2
!endif
!if "${INSTALLWEBVIEW2MODE}" == "embedBootstrapper"
Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe"
File "/oname=$TEMP\MicrosoftEdgeWebview2Setup.exe" "${WEBVIEW2BOOTSTRAPPERPATH}"
DetailPrint "$(installingWebview2)"
StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe"
Goto install_webview2
!endif
!if "${INSTALLWEBVIEW2MODE}" == "offlineInstaller"
Delete "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe"
File "/oname=$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" "${WEBVIEW2INSTALLERPATH}"
DetailPrint "$(installingWebview2)"
StrCpy $6 "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe"
Goto install_webview2
!endif
Goto webview2_done
install_webview2:
DetailPrint "$(installingWebview2)"
; $6 holds the path to the webview2 installer
ExecWait "$6 ${WEBVIEW2INSTALLERARGS} /install" $1
${If} $1 = 0
DetailPrint "$(webview2InstallSuccess)"
${Else}
DetailPrint "$(webview2InstallError)"
Abort "$(webview2AbortError)"
${EndIf}
webview2_done:
${EndIf}
${Else}
!if "${MINIMUMWEBVIEW2VERSION}" != ""
${VersionCompare} "${MINIMUMWEBVIEW2VERSION}" "$4" $R0
${If} $R0 = 1
update_webview:
DetailPrint "$(installingWebview2)"
${If} ${RunningX64}
ReadRegStr $R1 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate" "path"
${Else}
ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\EdgeUpdate" "path"
${EndIf}
${If} $R1 == ""
ReadRegStr $R1 HKCU "SOFTWARE\Microsoft\EdgeUpdate" "path"
${EndIf}
${If} $R1 != ""
; Chromium updater docs: https://source.chromium.org/chromium/chromium/src/+/main:docs/updater/user_manual.md
; Modified from "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft EdgeWebView\ModifyPath"
ExecWait `"$R1" /install appguid=${WEBVIEW2APPGUID}&needsadmin=true` $1
${If} $1 = 0
DetailPrint "$(webview2InstallSuccess)"
${Else}
MessageBox MB_ICONEXCLAMATION|MB_ABORTRETRYIGNORE "$(webview2InstallError)" IDIGNORE ignore IDRETRY update_webview
Quit
ignore:
${EndIf}
${EndIf}
${EndIf}
!endif
${EndIf}
SectionEnd
Section Install
SetOutPath $INSTDIR
!ifmacrodef NSIS_HOOK_PREINSTALL
!insertmacro NSIS_HOOK_PREINSTALL
!endif
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
; Copy main executable
File "${MAINBINARYSRCPATH}"
; Copy resources
{{#each resources_dirs}}
CreateDirectory "$INSTDIR\\{{this}}"
{{/each}}
{{#each resources}}
File /a "/oname={{this.[1]}}" "{{no-escape @key}}"
{{/each}}
; Copy external binaries
{{#each binaries}}
File /a "/oname={{this}}" "{{no-escape @key}}"
{{/each}}
; Create file associations
{{#each file_associations as |association| ~}}
{{#each association.ext as |ext| ~}}
!insertmacro APP_ASSOCIATE "{{ext}}" "{{or association.name ext}}" "{{association-description association.description ext}}" "$INSTDIR\${MAINBINARYNAME}.exe,0" "Open with ${PRODUCTNAME}" "$INSTDIR\${MAINBINARYNAME}.exe $\"%1$\""
{{/each}}
{{/each}}
; Register deep links
{{#each deep_link_protocols as |protocol| ~}}
WriteRegStr SHCTX "Software\Classes\\{{protocol}}" "URL Protocol" ""
WriteRegStr SHCTX "Software\Classes\\{{protocol}}" "" "URL:${BUNDLEID} protocol"
WriteRegStr SHCTX "Software\Classes\\{{protocol}}\DefaultIcon" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\",0"
WriteRegStr SHCTX "Software\Classes\\{{protocol}}\shell\open\command" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\" $\"%1$\""
{{/each}}
; Create uninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
; Save $INSTDIR in registry for future installations
WriteRegStr SHCTX "${MANUPRODUCTKEY}" "" $INSTDIR
!if "${INSTALLMODE}" == "both"
; Save install mode to be selected by default for the next installation such as updating
; or when uninstalling
WriteRegStr SHCTX "${UNINSTKEY}" $MultiUser.InstallMode 1
!endif
; Remove old main binary if it doesn't match new main binary name
ReadRegStr $OldMainBinaryName SHCTX "${UNINSTKEY}" "MainBinaryName"
${If} $OldMainBinaryName != ""
${AndIf} $OldMainBinaryName != "${MAINBINARYNAME}.exe"
Delete "$INSTDIR\$OldMainBinaryName"
${EndIf}
; Save current MAINBINARYNAME for future updates
WriteRegStr SHCTX "${UNINSTKEY}" "MainBinaryName" "${MAINBINARYNAME}.exe"
; Registry information for add/remove programs
WriteRegStr SHCTX "${UNINSTKEY}" "DisplayName" "${PRODUCTNAME}"
WriteRegStr SHCTX "${UNINSTKEY}" "DisplayIcon" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\""
WriteRegStr SHCTX "${UNINSTKEY}" "DisplayVersion" "${VERSION}"
WriteRegStr SHCTX "${UNINSTKEY}" "Publisher" "${MANUFACTURER}"
WriteRegStr SHCTX "${UNINSTKEY}" "InstallLocation" "$\"$INSTDIR$\""
WriteRegStr SHCTX "${UNINSTKEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegDWORD SHCTX "${UNINSTKEY}" "NoModify" "1"
WriteRegDWORD SHCTX "${UNINSTKEY}" "NoRepair" "1"
${GetSize} "$INSTDIR" "/M=uninstall.exe /S=0K /G=0" $0 $1 $2
IntOp $0 $0 + ${ESTIMATEDSIZE}
IntFmt $0 "0x%08X" $0
WriteRegDWORD SHCTX "${UNINSTKEY}" "EstimatedSize" "$0"
!if "${HOMEPAGE}" != ""
WriteRegStr SHCTX "${UNINSTKEY}" "URLInfoAbout" "${HOMEPAGE}"
WriteRegStr SHCTX "${UNINSTKEY}" "URLUpdateInfo" "${HOMEPAGE}"
WriteRegStr SHCTX "${UNINSTKEY}" "HelpLink" "${HOMEPAGE}"
!endif
; Create start menu shortcut
!insertmacro MUI_STARTMENU_WRITE_BEGIN Application
Call CreateOrUpdateStartMenuShortcut
!insertmacro MUI_STARTMENU_WRITE_END
; Create desktop shortcut for silent and passive installers
; because finish page will be skipped
${If} $PassiveMode = 1
${OrIf} ${Silent}
Call CreateOrUpdateDesktopShortcut
${EndIf}
!ifmacrodef NSIS_HOOK_POSTINSTALL
!insertmacro NSIS_HOOK_POSTINSTALL
!endif
; Auto close this page for passive mode
${If} $PassiveMode = 1
SetAutoClose true
${EndIf}
SectionEnd
Function .onInstSuccess
; Check for `/R` flag only in silent and passive installers because
; GUI installer has a toggle for the user to (re)start the app
${If} $PassiveMode = 1
${OrIf} ${Silent}
${GetOptions} $CMDLINE "/R" $R0
${IfNot} ${Errors}
${GetOptions} $CMDLINE "/ARGS" $R0
nsis_tauri_utils::RunAsUser "$INSTDIR\${MAINBINARYNAME}.exe" "$R0"
${EndIf}
${EndIf}
FunctionEnd
Function un.onInit
!insertmacro SetContext
!if "${INSTALLMODE}" == "both"
!insertmacro MULTIUSER_UNINIT
!endif
!insertmacro MUI_UNGETLANGUAGE
${GetOptions} $CMDLINE "/P" $PassiveMode
${IfNot} ${Errors}
StrCpy $PassiveMode 1
${EndIf}
${GetOptions} $CMDLINE "/UPDATE" $UpdateMode
${IfNot} ${Errors}
StrCpy $UpdateMode 1
${EndIf}
FunctionEnd
Section Uninstall
!ifmacrodef NSIS_HOOK_PREUNINSTALL
!insertmacro NSIS_HOOK_PREUNINSTALL
!endif
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
; Delete the app directory and its content from disk
; Copy main executable
Delete "$INSTDIR\${MAINBINARYNAME}.exe"
; Delete resources
{{#each resources}}
Delete "$INSTDIR\\{{this.[1]}}"
{{/each}}
; Delete external binaries
{{#each binaries}}
Delete "$INSTDIR\\{{this}}"
{{/each}}
; Delete app associations
{{#each file_associations as |association| ~}}
{{#each association.ext as |ext| ~}}
!insertmacro APP_UNASSOCIATE "{{ext}}" "{{or association.name ext}}"
{{/each}}
{{/each}}
; Delete deep links
{{#each deep_link_protocols as |protocol| ~}}
ReadRegStr $R7 SHCTX "Software\Classes\\{{protocol}}\shell\open\command" ""
${If} $R7 == "$\"$INSTDIR\${MAINBINARYNAME}.exe$\" $\"%1$\""
DeleteRegKey SHCTX "Software\Classes\\{{protocol}}"
${EndIf}
{{/each}}
; Delete uninstaller
Delete "$INSTDIR\uninstall.exe"
{{#each resources_ancestors}}
RMDir /REBOOTOK "$INSTDIR\\{{this}}"
{{/each}}
RMDir "$INSTDIR"
; Remove shortcuts if not updating
${If} $UpdateMode <> 1
!insertmacro DeleteAppUserModelId
; Remove start menu shortcut
!insertmacro MUI_STARTMENU_GETFOLDER Application $AppStartMenuFolder
!insertmacro IsShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
Pop $0
${If} $0 = 1
!insertmacro UnpinShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk"
Delete "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk"
RMDir "$SMPROGRAMS\$AppStartMenuFolder"
${EndIf}
!insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
Pop $0
${If} $0 = 1
!insertmacro UnpinShortcut "$SMPROGRAMS\${PRODUCTNAME}.lnk"
Delete "$SMPROGRAMS\${PRODUCTNAME}.lnk"
${EndIf}
; Remove desktop shortcuts
!insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
Pop $0
${If} $0 = 1
!insertmacro UnpinShortcut "$DESKTOP\${PRODUCTNAME}.lnk"
Delete "$DESKTOP\${PRODUCTNAME}.lnk"
${EndIf}
${EndIf}
; Remove registry information for add/remove programs
!if "${INSTALLMODE}" == "both"
DeleteRegKey SHCTX "${UNINSTKEY}"
!else if "${INSTALLMODE}" == "perMachine"
DeleteRegKey HKLM "${UNINSTKEY}"
!else
DeleteRegKey HKCU "${UNINSTKEY}"
!endif
; Removes the Autostart entry for ${PRODUCTNAME} from the HKCU Run key if it exists.
; This ensures the program does not launch automatically after uninstallation if it exists.
; If it doesn't exist, it does nothing.
; We do this when not updating (to preserve the registry value on updates)
${If} $UpdateMode <> 1
DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCTNAME}"
${EndIf}
; Delete app data if the checkbox is selected
; and if not updating
${If} $DeleteAppDataCheckboxState = 1
${AndIf} $UpdateMode <> 1
; Clear the install location $INSTDIR from registry
DeleteRegKey SHCTX "${MANUPRODUCTKEY}"
DeleteRegKey /ifempty SHCTX "${MANUKEY}"
; Clear the install language from registry
DeleteRegValue HKCU "${MANUPRODUCTKEY}" "Installer Language"
DeleteRegKey /ifempty HKCU "${MANUPRODUCTKEY}"
DeleteRegKey /ifempty HKCU "${MANUKEY}"
SetShellVarContext current
RmDir /r "$APPDATA\${BUNDLEID}"
RmDir /r "$LOCALAPPDATA\${BUNDLEID}"
${EndIf}
!ifmacrodef NSIS_HOOK_POSTUNINSTALL
!insertmacro NSIS_HOOK_POSTUNINSTALL
!endif
; Auto close if passive mode or updating
${If} $PassiveMode = 1
${OrIf} $UpdateMode = 1
SetAutoClose true
${EndIf}
SectionEnd
Function RestorePreviousInstallLocation
ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" ""
StrCmp $4 "" +2 0
StrCpy $INSTDIR $4
FunctionEnd
Function Skip
Abort
FunctionEnd
Function SkipIfPassive
${IfThen} $PassiveMode = 1 ${|} Abort ${|}
FunctionEnd
Function un.SkipIfPassive
${IfThen} $PassiveMode = 1 ${|} Abort ${|}
FunctionEnd
Function CreateOrUpdateStartMenuShortcut
; We used to use product name as MAINBINARYNAME
; migrate old shortcuts to target the new MAINBINARYNAME
StrCpy $R0 0
!insertmacro IsShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName"
Pop $0
${If} $0 = 1
!insertmacro SetShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
StrCpy $R0 1
${EndIf}
!insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName"
Pop $0
${If} $0 = 1
!insertmacro SetShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
StrCpy $R0 1
${EndIf}
${If} $R0 = 1
Return
${EndIf}
; Skip creating shortcut if in update mode or no shortcut mode
; but always create if migrating from wix
${If} $WixMode = 0
${If} $UpdateMode = 1
${OrIf} $NoShortcutMode = 1
Return
${EndIf}
${EndIf}
!if "${STARTMENUFOLDER}" != ""
CreateDirectory "$SMPROGRAMS\$AppStartMenuFolder"
CreateShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
!insertmacro SetLnkAppUserModelId "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk"
!else
CreateShortcut "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
!insertmacro SetLnkAppUserModelId "$SMPROGRAMS\${PRODUCTNAME}.lnk"
!endif
FunctionEnd
Function CreateOrUpdateDesktopShortcut
; We used to use product name as MAINBINARYNAME
; migrate old shortcuts to target the new MAINBINARYNAME
!insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName"
Pop $0
${If} $0 = 1
!insertmacro SetShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
Return
${EndIf}
; Skip creating shortcut if in update mode or no shortcut mode
; but always create if migrating from wix
${If} $WixMode = 0
${If} $UpdateMode = 1
${OrIf} $NoShortcutMode = 1
Return
${EndIf}
${EndIf}
CreateShortcut "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
!insertmacro SetLnkAppUserModelId "$DESKTOP\${PRODUCTNAME}.lnk"
FunctionEnd
@@ -0,0 +1,35 @@
!macro NSIS_HOOK_PREUNINSTALL
${If} $UpdateMode = 1
DetailPrint "ProxyWarden: verifying managed component state before update"
ClearErrors
ExecWait '"$INSTDIR\${MAINBINARYNAME}.exe" --nsis-verify-upgrade' $0
${Else}
; The generated Tauri guard normally runs after PREUNINSTALL. Repeat it
; here so no service/filesystem mutation starts while the app is alive.
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
DetailPrint "ProxyWarden: uninstalling verified managed components"
ClearErrors
ExecWait '"$INSTDIR\${MAINBINARYNAME}.exe" --nsis-uninstall-managed' $0
${EndIf}
IfErrors 0 +3
DetailPrint "ProxyWarden native lifecycle helper could not be launched"
Abort "ProxyWarden could not start the native lifecycle verifier."
${If} $0 = 3010
SetRebootFlag true
; The helper keeps its exact durable reboot fact until this parent has
; observed 3010. Delete only that fixed published marker, then fail closed
; if acknowledgement cannot be persisted before uninstall continues.
ClearErrors
Delete "$INSTDIR\.proxywarden-nsis-reboot-required.json"
IfErrors 0 +3
DetailPrint "ProxyWarden reboot acknowledgement could not be persisted"
Abort "ProxyWarden could not safely acknowledge the required reboot."
StrCpy $0 0
${EndIf}
${If} $0 != 0
DetailPrint "ProxyWarden native lifecycle check failed with exit code $0"
Abort "ProxyWarden could not safely verify or remove managed components."
${EndIf}
!macroend
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Default capability for the main ProxyWarden Windows shell. Task 8 keeps helper/install launch explicit: no shell or sidecar permission is granted here until a packaged helper is declared.", "description": "Default capability for the main ProxyWarden Windows shell. Privileged lifecycle work stays behind fixed native Rust modes; no shell or sidecar permission is granted.",
"windows": ["main"], "windows": ["main"],
"permissions": ["core:default", "dialog:allow-open"] "permissions": ["core:default", "dialog:allow-open"]
} }
+9 -2
View File
@@ -48,7 +48,11 @@ impl ProxiFyreAdapter {
proxies.push(ProxiFyreProxy { proxies.push(ProxiFyreProxy {
app_names, app_names,
socks5_proxy_endpoint: format!("{}:{}", target.host, target.port), socks5_proxy_endpoint: if target.host.contains(':') {
format!("[{}]:{}", target.host, target.port)
} else {
format!("{}:{}", target.host, target.port)
},
supported_protocols: protocols_for_profile(profile), supported_protocols: protocols_for_profile(profile),
}); });
} }
@@ -205,7 +209,10 @@ fn app_names_for_profile(profile: &Profile) -> Vec<String> {
ProfileItemType::Process | ProfileItemType::Folder | ProfileItemType::Exe => value, 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()); names.push(app_name.to_string());
} }
} }
+99 -96
View File
@@ -1,12 +1,8 @@
use crate::models::{LocalSingBoxConfig, SubscriptionCache}; use crate::models::{LocalSingBoxConfig, SubscriptionCache, SubscriptionServer};
use crate::process::command_no_window; use crate::process::run_fixed_process;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::{ use std::{env, fs, path::Path, time::Duration};
env, fs,
path::Path,
time::{SystemTime, UNIX_EPOCH},
};
pub const SINGBOX_ADAPTER_ID: &str = "singbox"; pub const SINGBOX_ADAPTER_ID: &str = "singbox";
pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json"; pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json";
@@ -43,23 +39,29 @@ impl SingBoxAdapter {
checker: &C, checker: &C,
) -> Result<SingBoxGeneratedConfig, SingBoxConfigError> ) -> Result<SingBoxGeneratedConfig, SingBoxConfigError>
where where
C: SingBoxConfigChecker, C: SingBoxConfigChecker + ?Sized,
{ {
let selected_server_tag = request let selected_server = if let Some(id) = request.config.selected_server_id.as_deref() {
.config request
.selected_server_tag .subscription_cache
.as_deref() .servers
.map(str::trim) .iter()
.filter(|value| !value.is_empty()) .find(|server| server.id == id)
.ok_or_else(|| { } else {
SingBoxConfigError::new( let mut matches = request.subscription_cache.servers.iter().filter(|server| {
SingBoxConfigErrorKind::MissingSelectedServer, Some(server.tag.as_str()) == request.config.selected_server_tag.as_deref()
"Сервер Local sing-box не выбран", });
) matches.next().filter(|_| matches.next().is_none())
})?; }
.ok_or_else(|| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::MissingSelectedServer,
"Сервер Local sing-box не выбран или отсутствует в текущей подписке",
)
})?;
let vpn_outbound = selected_outbound( let vpn_outbound = selected_outbound(
&request.subscription_cache.config, &request.subscription_cache.config,
selected_server_tag, selected_server,
&self.vpn_outbound_tag, &self.vpn_outbound_tag,
)?; )?;
let generated_config = json!({ let generated_config = json!({
@@ -105,7 +107,7 @@ impl SingBoxAdapter {
adapter_id: SINGBOX_ADAPTER_ID.to_string(), adapter_id: SINGBOX_ADAPTER_ID.to_string(),
output_file_name: SINGBOX_OUTPUT_FILE.to_string(), output_file_name: SINGBOX_OUTPUT_FILE.to_string(),
contents, contents,
selected_server_tag: selected_server_tag.to_string(), selected_server_tag: selected_server.tag.clone(),
listen: request.config.listen_host.clone(), listen: request.config.listen_host.clone(),
listen_port: request.config.listen_port, listen_port: request.config.listen_port,
check, check,
@@ -200,64 +202,61 @@ impl SingBoxConfigChecker for SingBoxCommandChecker {
config_json: &str, config_json: &str,
) -> Result<SingBoxCheckResult, SingBoxConfigError> { ) -> Result<SingBoxCheckResult, SingBoxConfigError> {
let config_path = env::temp_dir().join(format!( let config_path = env::temp_dir().join(format!(
"proxywarden-sing-box-{}-{}.json", "proxywarden-sing-box-{}.json",
std::process::id(), uuid::Uuid::new_v4().hyphenated()
now_millis()
)); ));
fs::write(&config_path, config_json).map_err(|error| { struct TemporaryConfig(std::path::PathBuf);
SingBoxConfigError::new( impl Drop for TemporaryConfig {
SingBoxConfigErrorKind::CheckFailed, fn drop(&mut self) {
format!( let _ = fs::remove_file(&self.0);
"Не удалось записать временный конфиг sing-box '{}': {error}", }
config_path.display() }
), let _temporary = TemporaryConfig(config_path.clone());
) crate::safe_fs::write_restricted_atomic(&config_path, config_json.as_bytes()).map_err(
})?; |_| {
let output = command_no_window(binary_path)
.arg("check")
.arg("-c")
.arg(&config_path)
.output()
.map_err(|error| {
let _ = fs::remove_file(&config_path);
SingBoxConfigError::new( SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed, SingBoxConfigErrorKind::CheckFailed,
format!( "Не удалось безопасно создать временный конфиг sing-box",
"Не удалось выполнить '{} check': {error}",
binary_path.display()
),
) )
})?; },
let _ = fs::remove_file(&config_path); )?;
// Checker output can contain credentials from the outbound. The bounded
let stdout = String::from_utf8_lossy(&output.stdout); // native process runner discards both streams instead of exposing them.
let stderr = String::from_utf8_lossy(&output.stderr); let status = run_fixed_process(
let message = command_message(&stdout, &stderr); binary_path,
&[
if !output.status.success() { "check".into(),
return Err(SingBoxConfigError::new( "-c".into(),
config_path.as_os_str().to_owned(),
],
Duration::from_secs(30),
)
.map_err(|error| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed, SingBoxConfigErrorKind::CheckFailed,
format!("Проверка sing-box не прошла: {message}"), if error.kind() == std::io::ErrorKind::TimedOut {
)); "Проверка sing-box превысила 30 секунд"
} else {
"Не удалось выполнить проверку sing-box"
},
)
})?;
if !status.success() {
return Err(SingBoxConfigError::new(SingBoxConfigErrorKind::CheckFailed,
"sing-box отклонил конфигурацию выбранного сервера. Обновите подписку или выберите другой сервер."));
} }
Ok(SingBoxCheckResult { Ok(SingBoxCheckResult {
checked: true, checked: true,
success: true, success: true,
message: if message.is_empty() { message: "Проверка sing-box прошла успешно".to_string(),
"Проверка sing-box прошла успешно".to_string()
} else {
message
},
}) })
} }
} }
fn selected_outbound( fn selected_outbound(
subscription_config: &Value, subscription_config: &Value,
selected_server_tag: &str, selected_server: &SubscriptionServer,
vpn_outbound_tag: &str, vpn_outbound_tag: &str,
) -> Result<Value, SingBoxConfigError> { ) -> Result<Value, SingBoxConfigError> {
let outbounds = subscription_config let outbounds = subscription_config
@@ -269,20 +268,39 @@ fn selected_outbound(
"В cache подписки нет outbounds", "В cache подписки нет outbounds",
) )
})?; })?;
let outbound = outbounds let outbound = if selected_server.id.starts_with("pw-") {
.iter() outbounds.iter().find(|outbound| {
.find(|outbound| { crate::subscription::outbound_server_id(outbound) == selected_server.id
})
} else {
// Legacy endpoint IDs are readable only when they identify exactly one outbound.
let mut matches = outbounds.iter().filter(|outbound| {
outbound outbound
.get("tag") .get("tag")
.and_then(Value::as_str) .and_then(Value::as_str)
.is_some_and(|tag| tag.trim() == selected_server_tag) .is_some_and(|tag| {
}) crate::models::decode_percent_encoded_utf8(tag).trim() == selected_server.tag
.ok_or_else(|| { })
SingBoxConfigError::new( && outbound.get("type").and_then(Value::as_str)
SingBoxConfigErrorKind::MissingSelectedOutbound, == Some(selected_server.server_type.as_str())
format!("Outbound не найден: {selected_server_tag}"), && outbound
) .get("server")
})?; .and_then(Value::as_str)
.is_some_and(|host| host.eq_ignore_ascii_case(&selected_server.server))
&& outbound.get("server_port").and_then(Value::as_u64)
== Some(u64::from(selected_server.server_port))
});
matches.next().filter(|_| matches.next().is_none())
}
.ok_or_else(|| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::MissingSelectedOutbound,
format!(
"Outbound не найден: {} ({}:{})",
selected_server.tag, selected_server.server, selected_server.server_port
),
)
})?;
let outbound_type = outbound let outbound_type = outbound
.get("type") .get("type")
.and_then(Value::as_str) .and_then(Value::as_str)
@@ -292,7 +310,8 @@ fn selected_outbound(
return Err(SingBoxConfigError::new( return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::UnsupportedSelectedOutbound, SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
format!( format!(
"Outbound '{selected_server_tag}' имеет неподдерживаемый тип '{outbound_type}'" "Outbound '{}' имеет неподдерживаемый тип '{outbound_type}'",
selected_server.tag
), ),
)); ));
} }
@@ -301,7 +320,10 @@ fn selected_outbound(
let object = outbound.as_object_mut().ok_or_else(|| { let object = outbound.as_object_mut().ok_or_else(|| {
SingBoxConfigError::new( SingBoxConfigError::new(
SingBoxConfigErrorKind::UnsupportedSelectedOutbound, SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
format!("Outbound '{selected_server_tag}' должен быть JSON-объектом"), format!(
"Outbound '{}' должен быть JSON-объектом",
selected_server.tag
),
) )
})?; })?;
object.insert( object.insert(
@@ -317,22 +339,3 @@ fn selected_outbound(
Ok(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();
match (stdout.is_empty(), stderr.is_empty()) {
(true, true) => String::new(),
(false, true) => stdout.to_string(),
(true, false) => stderr.to_string(),
(false, false) => format!("{stdout}\n{stderr}"),
}
}
+23
View File
@@ -0,0 +1,23 @@
//! Administrator-state detection and explicit UAC restart boundary.
use crate::command_dto::AdminStatusResponse;
use crate::process::is_process_elevated;
pub fn admin_status() -> AdminStatusResponse {
let is_windows = cfg!(windows);
let is_elevated = is_process_elevated();
let message = if !is_windows {
"Проверка прав администратора нужна только в Windows.".to_string()
} else if is_elevated {
"ProxyWarden уже запущен от имени администратора.".to_string()
} else {
"Права администратора будут запрошены отдельно для выбранного действия.".to_string()
};
AdminStatusResponse {
is_windows,
is_elevated,
can_restart_elevated: false,
message,
}
}
+523
View File
@@ -0,0 +1,523 @@
//! 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::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 {
#[serde(default)]
pub expected_revision: Option<String>,
pub route_mode: ApplyRouteMode,
pub profile: ProfileInput,
pub external_target: Option<TargetInput>,
#[serde(default)]
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 saved_state: Option<crate::command_dto::SavedStateResponse>,
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 read_guard = crate::configuration_transaction::read_guard(storage)
.map_err(|error| storage_error("configuration_locked", error))?;
if let Some(expected) = &input.expected_revision {
if crate::configuration_transaction::revision_locked(storage)
.map_err(|e| storage_error("configuration_read_failed", e))?
!= *expected
{
return Err(ApplyFlowError::failure(
"configuration_changed",
"Настройки изменились. Обновите сохранённое состояние перед применением.",
));
}
}
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)?;
let revision = crate::configuration_transaction::revision_locked(storage)
.map_err(|error| storage_error("configuration_read_failed", error))?;
drop(read_guard);
if let (Some(generated), Some(detected)) = (&singbox_config, &services.detected_singbox) {
services
.checker
.check_config(&detected.executable_path, &generated.contents)
.map_err(|error| ApplyFlowError::failure("singbox_preflight_failed", error.message))?;
}
let transaction =
crate::configuration_transaction::ConfigurationTransaction::begin(storage, Some(&revision))
.map_err(|error| storage_error("configuration_changed", error))?;
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 staged = (|| {
storage
.write_targets(&targets)
.map_err(|e| storage_error("targets_write_failed", e))?;
storage
.write_profiles(&profiles)
.map_err(|e| storage_error("profiles_write_failed", e))?;
phases.push(phase(
"source-state",
ApplyPhaseStatus::Succeeded,
"Profiles и targets сохранены.",
));
if let (Some(generated), Some(path)) = (&singbox_config, &singbox_path) {
safe_fs::write_restricted_with_backup(path, generated.contents.as_bytes())
.map_err(|e| storage_error("singbox_config_write_failed", e))?;
}
safe_fs::write_restricted_with_backup(&proxy_path, proxy_config.contents.as_bytes())
.map_err(|e| storage_error("proxifyre_config_write_failed", e))?;
let result = services
.helper
.apply_proxy_config(HelperApplyRequest {
adapter_id: &proxy_config.adapter_id,
config_path: &proxy_path,
config_contents: &proxy_config.contents,
})
.map_err(|e| ApplyFlowError::failure(e.code, e.message))?;
if !result.success {
return Err(ApplyFlowError::failure(
"proxifyre_apply_failed",
result.message,
));
}
crate::route_state::record_prepared_locked(
storage,
crate::privileged_jobs::ManagedComponent::Proxifyre,
)
.map_err(|e| storage_error("prepared_state_write_failed", e))?;
if singbox_config.is_some() {
crate::route_state::record_prepared_locked(
storage,
crate::privileged_jobs::ManagedComponent::SingBox,
)
.map_err(|e| storage_error("prepared_state_write_failed", e))?;
}
Ok(result)
})();
let (helper_result, committed_revision, artifacts) = match staged {
Ok(result) => {
let artifacts = crate::route_state::read_status_locked(storage)
.map_err(|e| storage_error("prepared_state_read_failed", e))?;
let revision = transaction
.commit_with_revision()
.map_err(|error| storage_error("configuration_commit_failed", error))?;
(result, revision, artifacts)
}
Err(error) => {
let rollback = transaction.abort();
phases.push(phase(
"rollback",
if rollback.is_ok() {
ApplyPhaseStatus::RolledBack
} else {
ApplyPhaseStatus::Failed
},
if rollback.is_ok() {
"Предыдущие настройки и конфиги восстановлены."
} else {
"Восстановление не завершено; новые операции заблокированы до recovery."
},
));
return Ok(failed_result(
if rollback.is_ok() {
error.code()
} else {
"configuration_recovery_required"
},
error.to_string(),
rollback.is_err(),
&proxy_path,
singbox_path.as_deref(),
phases,
));
}
};
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 {
saved_state: Some(crate::command_dto::SavedStateResponse {
artifacts,
revision: committed_revision,
profiles: profiles
.iter()
.map(crate::command_dto::ProfileDto::from)
.collect(),
targets: targets
.iter()
.map(crate::command_dto::TargetDto::from)
.collect(),
generated_config_path: proxy_path.display().to_string(),
}),
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 profiles = storage
.read_profiles()
.map_err(|error| storage_error("profiles_read_failed", error))?;
let mut targets = storage
.read_targets()
.map_err(|error| storage_error("targets_read_failed", error))?;
let clearing_profile = !profile_input.enabled && profile_input.items.is_empty();
let singbox_config = if clearing_profile {
None
} else {
match input.route_mode {
ApplyRouteMode::External => {
let target_input = input.external_target.ok_or_else(|| {
ApplyFlowError::failure(
"external_target_missing",
"Для external маршрута требуется SOCKS5 target.",
)
})?;
let mut target =
normalize_target(target_input).map_err(ApplyFlowError::validation)?;
let shared = profiles.iter().any(|existing| {
Some(existing.id.as_str()) != profile_input.id.as_deref()
&& existing.target_id == target.id
});
if shared
&& targets
.iter()
.any(|existing| existing.id == target.id && existing != &target)
{
target.id = format!("target-{}", uuid::Uuid::new_v4());
}
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, None),
services.checker,
)
.map_err(|error| {
ApplyFlowError::failure("singbox_preflight_failed", error.message)
})?,
)
}
}
};
let profile = normalize_profile(profile_input).map_err(ApplyFlowError::validation)?;
if input.disable_other_profiles {
for existing in &mut profiles {
if existing.id != profile.id {
existing.enabled = false;
}
}
}
upsert_profile(&mut profiles, profile);
if !profiles.iter().any(|profile| profile.enabled)
&& proxyfier_component_from_detection(services.detected_proxyfier.as_ref()).running
{
return Err(ApplyFlowError::failure(
"stop_before_clearing_route",
"Сначала явно остановите ProxiFyre, затем примените удаление последних правил.",
));
}
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),
}
}
fn failed_result(
code: &str,
message: String,
partial_state: bool,
proxy_path: &Path,
singbox_path: Option<&Path>,
phases: Vec<ApplyPhase>,
) -> ApplyConfigurationResult {
ApplyConfigurationResult {
saved_state: None,
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}"))
}
+19
View File
@@ -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}")
}
}
+831
View File
@@ -0,0 +1,831 @@
//! 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::component_catalog::ComponentId as CatalogComponentId;
use crate::component_packages::{
ComponentInstallSource, ComponentUpdateState, ComponentUpdateStatus, PackageSource,
UpdateCheckTrust, UpdateFreshness,
};
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 artifacts: Vec<crate::route_state::ArtifactStatus>,
pub revision: String,
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 migration_status: StorageMigrationStatusDto,
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 StorageMigrationStatusDto {
pub storage_schema_version: u32,
pub component_layout_version: Option<u32>,
pub outcome: String,
pub changed: bool,
pub blocking: bool,
pub notice_code: Option<String>,
pub message: String,
}
#[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,
}
pub type SingBoxSetupStatusDto = SingBoxSetupStatus;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalSingBoxStatusResponse {
pub saved_state: SavedStateResponse,
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 ComponentLifecycleResponseDto {
pub component: ComponentStatusDto,
pub changed: bool,
pub reboot_required: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ManagedPackageComponentDto {
Proxifyre,
SingBox,
}
impl ManagedPackageComponentDto {
pub(crate) const fn catalog_id(self) -> CatalogComponentId {
match self {
Self::Proxifyre => CatalogComponentId::Proxifyre,
Self::SingBox => CatalogComponentId::SingBox,
}
}
pub(crate) const fn model_id(self) -> ComponentId {
match self {
Self::Proxifyre => ComponentId::Proxyfier,
Self::SingBox => ComponentId::Singbox,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentUpdateFreshnessDto {
NeverChecked,
Fresh,
Stale,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentUpdateStateDto {
Current,
UpdateAvailable,
CheckStale,
UnknownOffline,
Unsupported,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentInstallSourceDto {
Bundled,
Cache,
External,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentPackageSourceDto {
Bundled,
Cache,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentUpdateTrustDto {
Trusted,
MissingIndependentDigest,
MalformedIndependentDigest,
Unsupported,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentPackageRequestDto {
pub component_id: ManagedPackageComponentDto,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentPackageStatusDto {
pub component_id: ManagedPackageComponentDto,
pub installed_version: Option<String>,
pub bundled_version: String,
pub available_offline_version: String,
pub latest_known_version: Option<String>,
pub last_checked_at: Option<u64>,
pub freshness: ComponentUpdateFreshnessDto,
pub update_state: ComponentUpdateStateDto,
pub install_source: ComponentInstallSourceDto,
pub offline_package_source: ComponentPackageSourceDto,
pub can_install_offline: bool,
pub offline_unavailable_reason: Option<String>,
pub can_download: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentUpdateCheckResponseDto {
pub trust: ComponentUpdateTrustDto,
pub update_available: bool,
pub status: ComponentPackageStatusDto,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentUpdateDownloadResponseDto {
pub downloaded_version: String,
pub source: ComponentPackageSourceDto,
pub status: ComponentPackageStatusDto,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentUpdateResponseDto {
pub component: ComponentStatusDto,
pub package: ComponentPackageStatusDto,
pub changed: bool,
pub reboot_required: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentCutoverStateDto {
NotNeeded,
Ready,
ManualMigrationRequired,
InProgress,
AwaitingNextStart,
AwaitingRouteSmoke,
CleanupReady,
CleanupPending,
Complete,
RolledBack,
RecoveryRequired,
Blocked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentCutoverModeDto {
ServiceSwitch,
ManualOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentCutoverServiceStateDto {
Running,
Stopped,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentCutoverStatusDto {
pub component_id: ManagedPackageComponentDto,
pub state: ComponentCutoverStateDto,
pub mode: ComponentCutoverModeDto,
pub legacy_version: Option<String>,
pub current_version: Option<String>,
pub bundled_version: Option<String>,
pub original_service_state: Option<ComponentCutoverServiceStateDto>,
pub legacy_path_label: Option<String>,
pub current_path_label: Option<String>,
pub steps: Vec<String>,
pub next_start_verified: bool,
pub route_smoke_confirmed: bool,
pub can_cutover: bool,
pub can_confirm_route_smoke: bool,
pub can_cleanup: bool,
pub disabled_code: Option<String>,
pub disabled_message: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentCutoverRequestDto {
pub component_id: ManagedPackageComponentDto,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfirmComponentRouteSmokeInputDto {
pub component_id: ManagedPackageComponentDto,
pub confirmed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentCutoverResponseDto {
pub status: ComponentCutoverStatusDto,
pub changed: bool,
pub reboot_required: bool,
}
impl TryFrom<&ComponentUpdateStatus> for ComponentPackageStatusDto {
type Error = ();
fn try_from(status: &ComponentUpdateStatus) -> Result<Self, Self::Error> {
let component_id = match status.component_id {
CatalogComponentId::Proxifyre => ManagedPackageComponentDto::Proxifyre,
CatalogComponentId::SingBox => ManagedPackageComponentDto::SingBox,
CatalogComponentId::WindowsPacketFilter
| CatalogComponentId::VcRuntime
| CatalogComponentId::Winsw => return Err(()),
};
Ok(Self {
component_id,
installed_version: status.installed_version.clone(),
bundled_version: status.bundled_version.clone(),
available_offline_version: status.available_offline_version.clone(),
latest_known_version: status.latest_known_version.clone(),
last_checked_at: status.last_checked_at_unix,
freshness: status.freshness.into(),
update_state: status.update_state.into(),
install_source: status.install_source.into(),
offline_package_source: status.offline_package_source.into(),
can_install_offline: status.can_install_offline,
offline_unavailable_reason: status.offline_unavailable_reason.clone(),
can_download: status.can_download,
})
}
}
impl From<UpdateFreshness> for ComponentUpdateFreshnessDto {
fn from(value: UpdateFreshness) -> Self {
match value {
UpdateFreshness::NeverChecked => Self::NeverChecked,
UpdateFreshness::Fresh => Self::Fresh,
UpdateFreshness::Stale => Self::Stale,
}
}
}
impl From<ComponentUpdateState> for ComponentUpdateStateDto {
fn from(value: ComponentUpdateState) -> Self {
match value {
ComponentUpdateState::Current => Self::Current,
ComponentUpdateState::UpdateAvailable => Self::UpdateAvailable,
ComponentUpdateState::CheckStale => Self::CheckStale,
ComponentUpdateState::UnknownOffline => Self::UnknownOffline,
ComponentUpdateState::Unsupported => Self::Unsupported,
}
}
}
impl From<ComponentInstallSource> for ComponentInstallSourceDto {
fn from(value: ComponentInstallSource) -> Self {
match value {
ComponentInstallSource::Bundled => Self::Bundled,
ComponentInstallSource::Cache => Self::Cache,
ComponentInstallSource::External => Self::External,
ComponentInstallSource::None => Self::None,
}
}
}
impl From<PackageSource> for ComponentPackageSourceDto {
fn from(value: PackageSource) -> Self {
match value {
PackageSource::Bundled => Self::Bundled,
PackageSource::Cache => Self::Cache,
}
}
}
impl From<UpdateCheckTrust> for ComponentUpdateTrustDto {
fn from(value: UpdateCheckTrust) -> Self {
match value {
UpdateCheckTrust::Trusted => Self::Trusted,
UpdateCheckTrust::MissingIndependentDigest => Self::MissingIndependentDigest,
UpdateCheckTrust::MalformedIndependentDigest => Self::MalformedIndependentDigest,
UpdateCheckTrust::Unsupported => Self::Unsupported,
}
}
}
#[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,
}
}
}
+1659 -4003
View File
File diff suppressed because it is too large Load Diff
+869
View File
@@ -0,0 +1,869 @@
use crate::safe_fs::ensure_no_reparse_ancestors;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::fs::{self, File};
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use thiserror::Error;
use url::Url;
pub const COMPONENT_CATALOG_SCHEMA_VERSION: u32 = 1;
pub const COMPONENT_CATALOG_FILENAME: &str = "catalog.json";
#[derive(Debug, Error)]
pub enum ComponentCatalogError {
#[error("component catalog JSON is invalid: {0}")]
Json(#[from] serde_json::Error),
#[error("component catalog is invalid: {0}")]
Invalid(String),
#[error("component bundle cannot be read: {0}")]
Io(#[from] io::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TargetArch {
X64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AssetArch {
X64,
Anycpu,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ComponentId {
Proxifyre,
WindowsPacketFilter,
VcRuntime,
SingBox,
Winsw,
}
impl ComponentId {
pub const ALL: [Self; 5] = [
Self::Proxifyre,
Self::WindowsPacketFilter,
Self::VcRuntime,
Self::SingBox,
Self::Winsw,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::Proxifyre => "proxifyre",
Self::WindowsPacketFilter => "windows-packet-filter",
Self::VcRuntime => "vc-runtime",
Self::SingBox => "sing-box",
Self::Winsw => "winsw",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum InstallRole {
ProxifyreRuntime,
PacketFilterDriver,
VcRuntimePrerequisite,
SingBoxRuntime,
SingBoxServiceWrapper,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ComponentCatalog {
pub schema_version: u32,
pub target_arch: TargetArch,
pub components: Vec<ComponentPackage>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ComponentPackage {
pub id: ComponentId,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub product_version: Option<String>,
pub asset_path: String,
pub asset_arch: AssetArch,
pub effective_target: TargetArch,
pub sha256: String,
pub size: u64,
pub source_url: String,
pub license: ComponentLicense,
pub install_role: InstallRole,
pub update_trust_policy: UpdateTrustPolicy,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ComponentLicense {
pub id: String,
pub path: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase",
deny_unknown_fields
)]
pub enum UpdateTrustPolicy {
GithubReleaseDigest {
repository: String,
tag_pattern: String,
asset_pattern: String,
require_stable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
authenticode_publishers: Option<Vec<String>>,
},
BuildTimeOnlyAuthenticode {
allowed_source_hosts: Vec<String>,
asset_pattern: String,
publishers: Vec<String>,
},
BundledOnlyNoIndependentProof {
reason: String,
},
}
pub fn parse_catalog(bytes: &[u8]) -> Result<ComponentCatalog, ComponentCatalogError> {
let catalog: ComponentCatalog = serde_json::from_slice(bytes)?;
validate_catalog(&catalog)?;
Ok(catalog)
}
pub fn validate_bundle(root: &Path) -> Result<ComponentCatalog, ComponentCatalogError> {
ensure_no_reparse_ancestors(root)?;
let catalog_path = root.join(COMPONENT_CATALOG_FILENAME);
require_regular_file(&catalog_path, "catalog")?;
let catalog = parse_catalog(&fs::read(&catalog_path)?)?;
let mut expected_files = HashSet::from([COMPONENT_CATALOG_FILENAME.to_string()]);
for component in &catalog.components {
if !expected_files.insert(component.asset_path.clone()) {
return Err(invalid("two components reference the same asset path"));
}
expected_files.insert(component.license.path.clone());
let asset_path = root.join(relative_path(&component.asset_path));
require_regular_file(&asset_path, "component asset")?;
let metadata = fs::metadata(&asset_path)?;
if metadata.len() != component.size {
return Err(invalid(format!(
"asset size does not match catalog for {}",
component.id.as_str()
)));
}
if sha256_file(&asset_path)? != component.sha256 {
return Err(invalid(format!(
"asset SHA-256 does not match catalog for {}",
component.id.as_str()
)));
}
let license_path = root.join(relative_path(&component.license.path));
require_regular_file(&license_path, "license")?;
if fs::metadata(license_path)?.len() == 0 {
return Err(invalid(format!(
"license file is empty for {}",
component.id.as_str()
)));
}
}
let actual_files = collect_bundle_files(root)?;
if actual_files != expected_files {
let missing = expected_files.difference(&actual_files).count();
let extra = actual_files.difference(&expected_files).count();
return Err(invalid(format!(
"bundle file set does not match catalog (missing: {missing}, extra: {extra})"
)));
}
Ok(catalog)
}
pub fn parse_bundled_catalog_if_present(
root: &Path,
) -> Result<Option<ComponentCatalog>, ComponentCatalogError> {
ensure_no_reparse_ancestors(root)?;
match fs::symlink_metadata(root.join(COMPONENT_CATALOG_FILENAME)) {
Ok(_) => validate_bundle(root).map(Some),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
fn validate_catalog(catalog: &ComponentCatalog) -> Result<(), ComponentCatalogError> {
if catalog.schema_version != COMPONENT_CATALOG_SCHEMA_VERSION {
return Err(invalid("unsupported schemaVersion"));
}
if catalog.target_arch != TargetArch::X64 {
return Err(invalid("targetArch must be x64"));
}
if catalog.components.len() != ComponentId::ALL.len() {
return Err(invalid("catalog must contain exactly five components"));
}
let mut component_ids = HashSet::new();
let mut install_roles = HashSet::new();
let mut asset_paths = HashSet::new();
let mut license_paths = HashSet::new();
for component in &catalog.components {
if !component_ids.insert(component.id) {
return Err(invalid("component IDs must be unique"));
}
if !install_roles.insert(component.install_role) {
return Err(invalid("install roles must be unique"));
}
if !asset_paths.insert(component.asset_path.as_str()) {
return Err(invalid("asset paths must be unique"));
}
if !license_paths.insert(component.license.path.as_str()) {
return Err(invalid("license paths must be unique"));
}
validate_component(component)?;
}
if ComponentId::ALL
.iter()
.any(|component_id| !component_ids.contains(component_id))
{
return Err(invalid("catalog is missing a required component"));
}
Ok(())
}
fn validate_component(component: &ComponentPackage) -> Result<(), ComponentCatalogError> {
let (expected_role, expected_arch) = expected_role_and_arch(component.id);
if component.install_role != expected_role {
return Err(invalid(format!(
"installRole does not match component {}",
component.id.as_str()
)));
}
if component.asset_arch != expected_arch || component.effective_target != TargetArch::X64 {
return Err(invalid(format!(
"asset architecture does not match component {}",
component.id.as_str()
)));
}
if !is_stable_numeric_version(&component.version)
|| component
.file_version
.as_deref()
.is_some_and(|version| !is_stable_numeric_version(version))
|| component
.product_version
.as_deref()
.is_some_and(|version| !is_stable_product_version(version))
{
return Err(invalid(format!(
"version metadata is invalid for {}",
component.id.as_str()
)));
}
validate_relative_path(&component.asset_path, "assetPath")?;
if component.asset_path.split('/').next() != Some(component.id.as_str()) {
return Err(invalid(format!(
"assetPath must be inside the {} directory",
component.id.as_str()
)));
}
validate_relative_path(&component.license.path, "license.path")?;
if component.license.path.split('/').next() != Some(component.id.as_str()) {
return Err(invalid(format!(
"license.path must be inside the {} directory",
component.id.as_str()
)));
}
if component.asset_path == component.license.path {
return Err(invalid("assetPath and license.path must be different"));
}
if !is_valid_sha256(&component.sha256) {
return Err(invalid(format!(
"SHA-256 is invalid for {}",
component.id.as_str()
)));
}
if component.size == 0 {
return Err(invalid(format!(
"asset size must be positive for {}",
component.id.as_str()
)));
}
if !is_valid_license_id(&component.license.id) {
return Err(invalid(format!(
"license ID is invalid for {}",
component.id.as_str()
)));
}
validate_component_contract(component)?;
let source = validate_source_url(&component.source_url)?;
let asset_name = component
.asset_path
.rsplit('/')
.next()
.ok_or_else(|| invalid("assetPath has no filename"))?;
if source
.path_segments()
.and_then(|mut segments| segments.next_back())
!= Some(asset_name)
{
return Err(invalid(format!(
"sourceUrl filename does not match assetPath for {}",
component.id.as_str()
)));
}
validate_official_source(component, &source, asset_name)?;
validate_trust_policy(&component.update_trust_policy, &source, asset_name)?;
Ok(())
}
fn validate_component_contract(component: &ComponentPackage) -> Result<(), ComponentCatalogError> {
let expected_license = match component.id {
ComponentId::Proxifyre => "AGPL-3.0-only",
ComponentId::WindowsPacketFilter => "MIT",
ComponentId::VcRuntime => "LicenseRef-Microsoft-Visual-Cpp-v14-Redistributable-2026",
ComponentId::SingBox => "LicenseRef-Sing-Box-Project",
ComponentId::Winsw => "MIT",
};
if component.license.id != expected_license {
return Err(invalid(format!(
"license ID does not match component {}",
component.id.as_str()
)));
}
let policy_matches_component = match (component.id, &component.update_trust_policy) {
(
ComponentId::Proxifyre,
UpdateTrustPolicy::GithubReleaseDigest {
repository,
tag_pattern,
asset_pattern,
require_stable,
authenticode_publishers,
},
) => {
repository == "wiresock/proxifyre"
&& tag_pattern == "v*"
&& asset_pattern == "ProxiFyre-v*-x64-signed.zip"
&& *require_stable
&& authenticode_publishers
.as_deref()
.is_some_and(|publishers| {
publishers.len() == 1 && publishers[0] == "The Anti-Cloud Corporation"
})
}
(
ComponentId::WindowsPacketFilter,
UpdateTrustPolicy::GithubReleaseDigest {
repository,
tag_pattern,
asset_pattern,
require_stable,
authenticode_publishers,
},
) => {
repository == "wiresock/ndisapi"
&& tag_pattern == "v*"
&& asset_pattern == "Windows.Packet.Filter.*.x64.msi"
&& *require_stable
&& authenticode_publishers
.as_deref()
.is_some_and(|publishers| {
publishers.len() == 1 && publishers[0] == "The Anti-Cloud Corporation"
})
}
(
ComponentId::SingBox,
UpdateTrustPolicy::GithubReleaseDigest {
repository,
tag_pattern,
asset_pattern,
require_stable,
authenticode_publishers,
},
) => {
repository == "SagerNet/sing-box"
&& tag_pattern == "v*"
&& asset_pattern == "sing-box-*-windows-amd64.zip"
&& *require_stable
&& authenticode_publishers.is_none()
}
(
ComponentId::VcRuntime,
UpdateTrustPolicy::BuildTimeOnlyAuthenticode {
allowed_source_hosts,
asset_pattern,
publishers,
},
) => {
allowed_source_hosts.len() == 1
&& allowed_source_hosts[0] == "aka.ms"
&& asset_pattern == "VC_redist.x64.exe"
&& publishers.len() == 1
&& publishers[0] == "Microsoft Corporation"
}
(ComponentId::Winsw, UpdateTrustPolicy::BundledOnlyNoIndependentProof { .. }) => true,
_ => false,
};
if !policy_matches_component {
return Err(invalid(format!(
"trust policy does not match component {}",
component.id.as_str()
)));
}
Ok(())
}
fn validate_official_source(
component: &ComponentPackage,
source: &Url,
asset_name: &str,
) -> Result<(), ComponentCatalogError> {
let expected_repository = match component.id {
ComponentId::Proxifyre => Some("wiresock/proxifyre"),
ComponentId::WindowsPacketFilter => Some("wiresock/ndisapi"),
ComponentId::SingBox => Some("SagerNet/sing-box"),
ComponentId::Winsw => Some("winsw/winsw"),
ComponentId::VcRuntime => None,
};
if let Some(expected_repository) = expected_repository {
if source.host_str() != Some("github.com") {
return Err(invalid(
"component source is not its official GitHub repository",
));
}
let segments = github_release_segments(source)?;
if !segments[0..2]
.join("/")
.eq_ignore_ascii_case(expected_repository)
|| segments[5] != asset_name
|| segments[4].strip_prefix('v').unwrap_or(segments[4]) != component.version
{
return Err(invalid(
"component source is not its pinned official release",
));
}
} else if component.version != "14.51.36247.0"
|| source.as_str() != "https://aka.ms/vs/18/release/14.51.36247/VC_redist.x64.exe"
{
return Err(invalid(
"VC runtime must use the pinned Microsoft 14.51.36247.0 source",
));
}
Ok(())
}
const fn expected_role_and_arch(component_id: ComponentId) -> (InstallRole, AssetArch) {
match component_id {
ComponentId::Proxifyre => (InstallRole::ProxifyreRuntime, AssetArch::X64),
ComponentId::WindowsPacketFilter => (InstallRole::PacketFilterDriver, AssetArch::X64),
ComponentId::VcRuntime => (InstallRole::VcRuntimePrerequisite, AssetArch::X64),
ComponentId::SingBox => (InstallRole::SingBoxRuntime, AssetArch::X64),
ComponentId::Winsw => (InstallRole::SingBoxServiceWrapper, AssetArch::Anycpu),
}
}
fn validate_trust_policy(
policy: &UpdateTrustPolicy,
source: &Url,
asset_name: &str,
) -> Result<(), ComponentCatalogError> {
match policy {
UpdateTrustPolicy::GithubReleaseDigest {
repository,
tag_pattern,
asset_pattern,
require_stable,
authenticode_publishers,
} => {
if !*require_stable {
return Err(invalid(
"GitHub release policy must require a stable release",
));
}
validate_repository(repository)?;
validate_pattern(tag_pattern, "tagPattern")?;
validate_pattern(asset_pattern, "assetPattern")?;
validate_optional_publishers(authenticode_publishers)?;
if source.host_str() != Some("github.com") {
return Err(invalid("GitHub release source must use github.com"));
}
let segments = github_release_segments(source)?;
if !segments[0..2].join("/").eq_ignore_ascii_case(repository)
|| segments[5] != asset_name
|| !pattern_matches(tag_pattern, segments[4])
|| !pattern_matches(asset_pattern, asset_name)
{
return Err(invalid(
"GitHub source URL does not match repository/tag/asset policy",
));
}
}
UpdateTrustPolicy::BuildTimeOnlyAuthenticode {
allowed_source_hosts,
asset_pattern,
publishers,
} => {
validate_hosts(allowed_source_hosts)?;
validate_pattern(asset_pattern, "assetPattern")?;
validate_publishers(publishers)?;
let source_host = source
.host_str()
.ok_or_else(|| invalid("sourceUrl has no host"))?;
if !allowed_source_hosts
.iter()
.any(|host| host.eq_ignore_ascii_case(source_host))
|| !pattern_matches(asset_pattern, asset_name)
{
return Err(invalid(
"build-time Authenticode policy does not match source asset",
));
}
}
UpdateTrustPolicy::BundledOnlyNoIndependentProof { reason } => {
if reason.trim().is_empty()
|| reason.trim() != reason
|| reason.chars().count() > 240
|| reason.chars().any(char::is_control)
{
return Err(invalid("bundled-only policy must contain a safe reason"));
}
}
}
Ok(())
}
fn github_release_segments(source: &Url) -> Result<Vec<&str>, ComponentCatalogError> {
let segments: Vec<_> = source
.path_segments()
.ok_or_else(|| invalid("GitHub source URL has no path"))?
.collect();
if segments.len() != 6 || segments[2] != "releases" || segments[3] != "download" {
return Err(invalid("GitHub source URL is not a release asset URL"));
}
Ok(segments)
}
fn validate_source_url(raw: &str) -> Result<Url, ComponentCatalogError> {
let parsed = Url::parse(raw).map_err(|_| invalid("sourceUrl is not a valid URL"))?;
if parsed.scheme() != "https"
|| parsed.host_str().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.port().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return Err(invalid("sourceUrl must be a plain HTTPS official URL"));
}
Ok(parsed)
}
fn validate_repository(repository: &str) -> Result<(), ComponentCatalogError> {
let mut segments = repository.split('/');
let owner = segments.next().unwrap_or_default();
let name = segments.next().unwrap_or_default();
if segments.next().is_some()
|| !is_safe_repository_segment(owner)
|| !is_safe_repository_segment(name)
|| name.ends_with(".git")
{
return Err(invalid("GitHub repository identity is invalid"));
}
Ok(())
}
fn is_safe_repository_segment(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 100
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
&& value != "."
&& value != ".."
}
fn validate_pattern(pattern: &str, field: &str) -> Result<(), ComponentCatalogError> {
if pattern.is_empty()
|| pattern.len() > 160
|| pattern.matches('*').count() > 1
|| pattern.contains(['/', '\\'])
|| pattern.bytes().any(|byte| {
!(byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'*' | b'+'))
})
{
return Err(invalid(format!("{field} is invalid")));
}
Ok(())
}
fn pattern_matches(pattern: &str, value: &str) -> bool {
match pattern.split_once('*') {
Some((prefix, suffix)) => {
value.len() >= prefix.len() + suffix.len()
&& value.starts_with(prefix)
&& value.ends_with(suffix)
}
None => pattern == value,
}
}
/// Validates a discovered GitHub release asset against the immutable policy
/// embedded in the bundled component catalog.
pub fn validate_github_update_asset(
component: &ComponentPackage,
version: &str,
asset_name: &str,
source_url: &str,
) -> Result<(), ComponentCatalogError> {
let UpdateTrustPolicy::GithubReleaseDigest {
repository,
tag_pattern,
asset_pattern,
require_stable,
..
} = &component.update_trust_policy
else {
return Err(invalid("component does not allow GitHub runtime updates"));
};
if !*require_stable || !is_stable_numeric_version(version) {
return Err(invalid("update version is not stable"));
}
validate_relative_path(asset_name, "update asset name")?;
if asset_name.contains('/') || !pattern_matches(asset_pattern, asset_name) {
return Err(invalid("update asset name does not match policy"));
}
let source = validate_source_url(source_url)?;
if source.host_str() != Some("github.com") {
return Err(invalid("update asset is not hosted by GitHub"));
}
let segments = github_release_segments(&source)?;
let tag = segments[4];
if !segments[0..2].join("/").eq_ignore_ascii_case(repository)
|| segments[5] != asset_name
|| !pattern_matches(tag_pattern, tag)
|| tag.strip_prefix('v').unwrap_or(tag) != version
{
return Err(invalid(
"update asset does not match the pinned repository policy",
));
}
Ok(())
}
fn validate_hosts(hosts: &[String]) -> Result<(), ComponentCatalogError> {
let mut unique = HashSet::new();
if hosts.is_empty()
|| hosts.iter().any(|host| {
host.is_empty()
|| host.len() > 253
|| host != &host.to_ascii_lowercase()
|| host.starts_with('.')
|| host.ends_with('.')
|| !host
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.'))
|| !unique.insert(host.as_str())
})
{
return Err(invalid("allowedSourceHosts is invalid"));
}
Ok(())
}
fn validate_optional_publishers(
publishers: &Option<Vec<String>>,
) -> Result<(), ComponentCatalogError> {
if let Some(publishers) = publishers {
validate_publishers(publishers)?;
}
Ok(())
}
fn validate_publishers(publishers: &[String]) -> Result<(), ComponentCatalogError> {
let mut unique = HashSet::new();
if publishers.is_empty()
|| publishers.iter().any(|publisher| {
publisher.trim().is_empty()
|| publisher.trim() != publisher
|| publisher.chars().count() > 128
|| publisher.chars().any(char::is_control)
|| !unique.insert(publisher.as_str())
})
{
return Err(invalid("Authenticode publishers are invalid"));
}
Ok(())
}
fn validate_relative_path(value: &str, field: &str) -> Result<(), ComponentCatalogError> {
if value.is_empty()
|| value.len() > 512
|| value.contains('\\')
|| value.starts_with('/')
|| value.ends_with('/')
|| value.split('/').any(|segment| {
segment.is_empty()
|| segment == "."
|| segment == ".."
|| segment.len() > 128
|| segment.ends_with('.')
|| is_windows_reserved_name(segment)
|| !segment
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
})
{
return Err(invalid(format!("{field} is not a safe relative path")));
}
Ok(())
}
fn is_windows_reserved_name(segment: &str) -> bool {
let stem = segment.split('.').next().unwrap_or_default();
let upper = stem.to_ascii_uppercase();
matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| upper
.strip_prefix("COM")
.or_else(|| upper.strip_prefix("LPT"))
.is_some_and(|suffix| suffix.len() == 1 && matches!(suffix.as_bytes()[0], b'1'..=b'9'))
}
fn is_valid_sha256(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
}
fn is_valid_license_id(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 96
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'+' | b'_'))
}
fn is_stable_numeric_version(value: &str) -> bool {
let segments: Vec<_> = value.split('.').collect();
(2..=4).contains(&segments.len())
&& segments.iter().all(|segment| {
!segment.is_empty()
&& segment.len() <= 10
&& segment.bytes().all(|byte| byte.is_ascii_digit())
})
}
fn is_stable_product_version(value: &str) -> bool {
let Some((numeric, metadata)) = value.split_once('+') else {
return is_stable_numeric_version(value);
};
is_stable_numeric_version(numeric)
&& !metadata.is_empty()
&& metadata.len() <= 128
&& !metadata.contains('+')
&& metadata.split('.').all(|segment| {
!segment.is_empty()
&& segment
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
})
}
fn relative_path(value: &str) -> PathBuf {
value.split('/').collect()
}
fn require_regular_file(path: &Path, label: &str) -> Result<(), ComponentCatalogError> {
ensure_no_reparse_ancestors(path)?;
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(invalid(format!("{label} must be a regular file")));
}
Ok(())
}
fn collect_bundle_files(root: &Path) -> Result<HashSet<String>, ComponentCatalogError> {
ensure_no_reparse_ancestors(root)?;
let mut files = HashSet::new();
let mut directories = vec![root.to_path_buf()];
while let Some(directory) = directories.pop() {
ensure_no_reparse_ancestors(&directory)?;
for entry in fs::read_dir(directory)? {
let entry = entry?;
ensure_no_reparse_ancestors(&entry.path())?;
let file_type = entry.file_type()?;
if file_type.is_symlink() {
return Err(invalid("bundle must not contain symbolic links"));
}
if file_type.is_dir() {
directories.push(entry.path());
} else if file_type.is_file() {
let relative = entry
.path()
.strip_prefix(root)
.map_err(|_| invalid("bundle entry escaped the root directory"))?
.to_string_lossy()
.replace('\\', "/");
validate_relative_path(&relative, "bundle entry")?;
files.insert(relative);
} else {
return Err(invalid("bundle contains a non-regular filesystem entry"));
}
}
}
Ok(files)
}
pub fn sha256_file(path: &Path) -> Result<String, ComponentCatalogError> {
ensure_no_reparse_ancestors(path)?;
let mut file = File::open(path)?;
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let count = file.read(&mut buffer)?;
if count == 0 {
break;
}
digest.update(&buffer[..count]);
}
Ok(hex_lower(&digest.finalize()))
}
fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(HEX[(byte >> 4) as usize] as char);
output.push(HEX[(byte & 0x0f) as usize] as char);
}
output
}
fn invalid(message: impl Into<String>) -> ComponentCatalogError {
ComponentCatalogError::Invalid(message.into())
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+924
View File
@@ -0,0 +1,924 @@
//! Pure component ownership classification and lifecycle preflight.
//!
//! Detection gathers evidence; this module decides whether ProxyWarden may
//! inspect or mutate a candidate. No component binary is executed here.
use crate::models::ComponentId;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
pub const OWNERSHIP_MISMATCH: &str = "ownership_mismatch";
pub const COMPONENT_INCOMPLETE: &str = "component_incomplete";
pub const FOREIGN_COMPONENT: &str = "foreign_component";
pub const AMBIGUOUS_LEGACY: &str = "ambiguous_legacy";
pub const COMPONENT_MISSING: &str = "component_missing";
pub const LEGACY_IDENTITY_CHANGED: &str = "legacy_identity_changed";
pub const MANUAL_MIGRATION_REQUIRED: &str = "manual_migration_required";
pub const LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT: &str = r"C:\Tools\ProxiFyre";
pub const LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION: &str = "2.2.1";
const LEGACY_PROXIFYRE_PRIMARY_SERVICE: &str = "ProxiFyreService";
const LEGACY_PROXIFYRE_FIXED_VERSION: &str = "2.2.1.0";
const SERVICE_WIN32_OWN_PROCESS: u32 = 0x0000_0010;
const SERVICE_AUTO_START: u32 = 0x0000_0002;
const SERVICE_ERROR_NORMAL: u32 = 0x0000_0001;
const SERVICE_SID_TYPE_NONE: u32 = 0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentClassification {
ManagedCurrent,
ManagedLegacy,
Foreign,
Incomplete,
Missing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CandidateRole {
Current,
Legacy,
ForeignByDefault,
Foreign,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MarkerEvidence {
Valid,
Missing,
Invalid,
NotRequired,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BinaryIdentityEvidence {
KnownPackage,
Unknown,
Mismatch,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServiceEvidence {
pub name: String,
pub status: String,
pub path_name: Option<String>,
pub executable_path: Option<PathBuf>,
pub path_matches_candidate: bool,
pub binary_version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComponentCandidateProbe {
pub component_id: ComponentId,
pub role: CandidateRole,
pub root: PathBuf,
pub root_exists: bool,
pub has_reparse_point: bool,
pub executable_path: Option<PathBuf>,
pub missing_files: Vec<PathBuf>,
pub marker: MarkerEvidence,
pub marker_required: bool,
pub binary_identity: BinaryIdentityEvidence,
pub binary_version: Option<String>,
pub service: Option<ServiceEvidence>,
pub service_required: bool,
pub legacy_identity_complete: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InventoryIssue {
pub code: String,
pub message: String,
}
impl InventoryIssue {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComponentCandidate {
pub component_id: ComponentId,
pub classification: ComponentClassification,
pub role: CandidateRole,
pub root: PathBuf,
pub executable_path: Option<PathBuf>,
pub binary_version: Option<String>,
pub service: Option<ServiceEvidence>,
pub marker: MarkerEvidence,
pub issues: Vec<InventoryIssue>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComponentInventory {
pub component_id: ComponentId,
pub candidates: Vec<ComponentCandidate>,
pub selected: Option<usize>,
pub issues: Vec<InventoryIssue>,
}
/// Immutable identity retained only by the disabled legacy compatibility
/// helpers until Task 8 removes their implementation. Normal lifecycle routing
/// no longer grants ManagedLegacy Start/Stop/Apply authority.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LegacyComponentIdentity {
component_id: ComponentId,
fingerprint: String,
}
/// Read-only evidence used by the durable cutover coordinator. This is
/// intentionally separate from `ComponentClassification`: legacy discovery
/// and compatibility helpers must not grant migration authority.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LegacyCutoverEvidence {
pub proxifyre_manifest_matches: bool,
pub proxifyre_scm_profile: Option<LegacyProxifyreScmProfile>,
/// SHA-256 over the complete SCM restore snapshot (base config, every
/// CONFIG2 value, security descriptor, and original stable state). The
/// cutover coordinator computes it from the leased snapshot so fields
/// outside the frozen safety profile remain bound to the sealed evidence.
pub proxifyre_scm_snapshot_fingerprint: String,
pub additional_matching_service: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LegacyProxifyreScmProfile {
pub service_type: u32,
pub start_type: u32,
pub error_control: u32,
pub account_name: String,
pub display_name: String,
pub description: String,
pub dependencies: Vec<String>,
pub load_order_group: Option<String>,
pub has_failure_actions: bool,
pub failure_actions_on_non_crash: bool,
pub delayed_auto_start: bool,
pub sid_type: u32,
pub required_privileges: Vec<String>,
pub has_triggers: bool,
pub untrusted_mutation_rights: bool,
}
impl LegacyProxifyreScmProfile {
pub fn matches_frozen_2_2_1_profile(&self) -> bool {
self.service_type == SERVICE_WIN32_OWN_PROCESS
&& self.start_type == SERVICE_AUTO_START
&& self.error_control == SERVICE_ERROR_NORMAL
&& self.account_name.eq_ignore_ascii_case("LocalSystem")
&& self.display_name == "ProxiFyre Service"
&& self.description == "ProxiFyre - SOCKS5 ProxiFyre Service"
&& self.dependencies.is_empty()
&& self.load_order_group.as_deref().is_none_or(str::is_empty)
&& !self.has_failure_actions
&& !self.failure_actions_on_non_crash
&& !self.delayed_auto_start
&& self.sid_type == SERVICE_SID_TYPE_NONE
&& self.required_privileges.is_empty()
&& !self.has_triggers
&& !self.untrusted_mutation_rights
}
}
/// Opaque strict-gate result. External callers can only obtain one through the
/// matcher below; private fields prevent constructing an "approved" enum, and
/// mutation entrypoints do not accept caller-supplied proofs.
///
/// ```compile_fail
/// use proxywarden_lib::component_inventory::LegacyCutoverProof;
///
/// let _forged = LegacyCutoverProof {
/// identity_fingerprint: "forged".to_string(),
/// };
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LegacyCutoverProof {
identity_fingerprint: String,
}
impl LegacyCutoverProof {
pub fn fingerprint(&self) -> &str {
&self.identity_fingerprint
}
}
impl ComponentInventory {
pub fn missing(component_id: ComponentId) -> Self {
Self {
component_id,
candidates: Vec::new(),
selected: None,
issues: Vec::new(),
}
}
pub fn selected_candidate(&self) -> Option<&ComponentCandidate> {
self.selected.and_then(|index| self.candidates.get(index))
}
pub fn classification(&self) -> ComponentClassification {
self.selected_candidate()
.map(|candidate| candidate.classification)
.unwrap_or(ComponentClassification::Missing)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InventoryAction {
Install,
Apply,
CheckBinary,
Start,
Stop,
ConfigureFirewall,
Update,
Uninstall,
Cutover,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthorizedActionError<E> {
Denied(InventoryIssue),
Runner(E),
}
pub fn classify_component_candidates(
component_id: ComponentId,
probes: Vec<ComponentCandidateProbe>,
) -> ComponentInventory {
let mut candidates: Vec<_> = probes.into_iter().map(classify_candidate).collect();
candidates.retain(|candidate| candidate.classification != ComponentClassification::Missing);
let current = candidates
.iter()
.position(|candidate| candidate.role == CandidateRole::Current);
let managed_legacy: Vec<_> = candidates
.iter()
.enumerate()
.filter(|(_, candidate)| candidate.classification == ComponentClassification::ManagedLegacy)
.map(|(index, _)| index)
.collect();
let mut issues = Vec::new();
let selected = if let Some(current) = current {
Some(current)
} else if managed_legacy.len() == 1 {
managed_legacy.first().copied()
} else if managed_legacy.len() > 1 {
issues.push(InventoryIssue::new(
AMBIGUOUS_LEGACY,
"Найдено несколько подтвержденных старых установок; автоматический выбор заблокирован.",
));
None
} else {
candidates
.iter()
.position(|candidate| {
candidate.classification == ComponentClassification::Foreign
&& candidate
.issues
.iter()
.any(|issue| issue.code == OWNERSHIP_MISMATCH)
})
.or_else(|| {
candidates.iter().position(|candidate| {
candidate.classification == ComponentClassification::Foreign
})
})
.or_else(|| {
candidates.iter().position(|candidate| {
candidate.classification == ComponentClassification::Incomplete
})
})
};
ComponentInventory {
component_id,
candidates,
selected,
issues,
}
}
pub fn authorize_component_action(
inventory: &ComponentInventory,
action: InventoryAction,
) -> Result<Option<&ComponentCandidate>, InventoryIssue> {
if let Some(issue) = inventory.issues.first() {
return Err(issue.clone());
}
let Some(candidate) = inventory.selected_candidate() else {
return if action == InventoryAction::Install {
Ok(None)
} else {
Err(InventoryIssue::new(
COMPONENT_MISSING,
"Управляемый компонент не найден.",
))
};
};
match candidate.classification {
ComponentClassification::ManagedCurrent => match action {
InventoryAction::Apply
| InventoryAction::CheckBinary
| InventoryAction::Start
| InventoryAction::Stop
| InventoryAction::ConfigureFirewall
| InventoryAction::Update
| InventoryAction::Uninstall => Ok(Some(candidate)),
InventoryAction::Install | InventoryAction::Cutover => Err(InventoryIssue::new(
"component_already_current",
"Компонент уже находится в текущей управляемой папке.",
)),
},
ComponentClassification::ManagedLegacy => match action {
InventoryAction::CheckBinary => Ok(Some(candidate)),
InventoryAction::Apply
| InventoryAction::Install
| InventoryAction::Start
| InventoryAction::Stop
| InventoryAction::ConfigureFirewall
| InventoryAction::Update
| InventoryAction::Uninstall
| InventoryAction::Cutover => Err(InventoryIssue::new(
"legacy_cutover_required",
"Старая установка требует отдельного доказанного cutover-потока.",
)),
},
ComponentClassification::Foreign => {
Err(candidate.issues.first().cloned().unwrap_or_else(|| {
InventoryIssue::new(
FOREIGN_COMPONENT,
"Найдена чужая установка; управление ею заблокировано.",
)
}))
}
ComponentClassification::Incomplete => {
Err(candidate.issues.first().cloned().unwrap_or_else(|| {
InventoryIssue::new(
COMPONENT_INCOMPLETE,
"Установка компонента неполна; опасные действия заблокированы.",
)
}))
}
ComponentClassification::Missing => {
if action == InventoryAction::Install {
Ok(None)
} else {
Err(InventoryIssue::new(
COMPONENT_MISSING,
"Управляемый компонент не найден.",
))
}
}
}
}
/// Produces read-only proof for the one supported automatic legacy cutover.
///
/// A `ManagedLegacy` candidate alone is discovery evidence, not mutation
/// authority. The caller must independently match the leased ten-file package
/// manifest and query the complete live SCM profile before calling this gate.
pub fn prove_legacy_cutover(
inventory: &ComponentInventory,
evidence: &LegacyCutoverEvidence,
) -> Result<LegacyCutoverProof, InventoryIssue> {
let manual = || {
Err(InventoryIssue::new(
MANUAL_MIGRATION_REQUIRED,
"Найдена старая установка, но ее identity недостаточна для автоматического переноса.",
))
};
if inventory.component_id != ComponentId::Proxyfier
|| !inventory.issues.is_empty()
|| inventory.candidates.len() != 1
{
return manual();
}
let Some(candidate) = inventory.selected_candidate() else {
return manual();
};
if candidate.component_id != ComponentId::Proxyfier
|| candidate.classification != ComponentClassification::ManagedLegacy
|| candidate.role != CandidateRole::Legacy
|| !candidate.issues.is_empty()
|| normalized_identity_path(&candidate.root)
!= normalized_identity_text(LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT)
|| !candidate
.binary_version
.as_deref()
.is_some_and(legacy_proxifyre_version_matches)
|| !evidence.proxifyre_manifest_matches
|| !is_sha256(&evidence.proxifyre_scm_snapshot_fingerprint)
|| evidence.additional_matching_service
|| !evidence
.proxifyre_scm_profile
.as_ref()
.is_some_and(LegacyProxifyreScmProfile::matches_frozen_2_2_1_profile)
{
return manual();
}
let expected_executable = candidate.root.join("ProxiFyre.exe");
if candidate.executable_path.as_deref().is_none_or(|path| {
normalized_identity_path(path) != normalized_identity_path(&expected_executable)
}) {
return manual();
}
let Some(service) = candidate.service.as_ref() else {
return manual();
};
if !service
.name
.eq_ignore_ascii_case(LEGACY_PROXIFYRE_PRIMARY_SERVICE)
|| !matches!(
service.status.trim().to_ascii_lowercase().as_str(),
"running" | "stopped"
)
|| service
.binary_version
.as_deref()
.is_none_or(|version| !legacy_proxifyre_version_matches(version))
|| service.executable_path.as_deref().is_none_or(|path| {
normalized_identity_path(path) != normalized_identity_path(&expected_executable)
})
|| service.path_name.as_deref().is_none_or(|path_name| {
!legacy_proxifyre_topshelf_path_matches(path_name, &expected_executable)
})
{
return manual();
}
let identity_fingerprint = json!({
"domain": "proxywarden-legacy-cutover-proof-v1",
"candidate": legacy_candidate_fingerprint(candidate, service),
"evidence": evidence,
});
Ok(LegacyCutoverProof {
identity_fingerprint: format!(
"{:x}",
Sha256::digest(identity_fingerprint.to_string().as_bytes())
),
})
}
/// Cross-platform pure matcher for the historical Topshelf service command.
/// It parses Windows quoting rules even when contract tests run on Linux.
pub fn legacy_proxifyre_topshelf_path_matches(path_name: &str, expected_executable: &Path) -> bool {
if normalized_identity_path(expected_executable)
!= normalized_identity_text(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
{
return false;
}
let Some(arguments) = split_windows_command_line(path_name) else {
return false;
};
if arguments.len() != 5
|| normalized_identity_text(&arguments[0]) != normalized_identity_path(expected_executable)
{
return false;
}
let mut display_name = false;
let mut service_name = false;
for pair in arguments[1..].chunks_exact(2) {
match (pair[0].to_ascii_lowercase().as_str(), pair[1].as_str()) {
("-displayname", "ProxiFyre Service") if !display_name => display_name = true,
("-servicename", LEGACY_PROXIFYRE_PRIMARY_SERVICE) if !service_name => {
service_name = true;
}
_ => return false,
}
}
display_name && service_name
}
pub fn run_authorized_component_action<T, E>(
inventory: &ComponentInventory,
action: InventoryAction,
runner: impl FnOnce(Option<&ComponentCandidate>) -> Result<T, E>,
) -> Result<T, AuthorizedActionError<E>> {
let candidate =
authorize_component_action(inventory, action).map_err(AuthorizedActionError::Denied)?;
runner(candidate).map_err(AuthorizedActionError::Runner)
}
pub fn capture_legacy_component_identity(
inventory: &ComponentInventory,
) -> Result<LegacyComponentIdentity, InventoryIssue> {
if !inventory.issues.is_empty() {
return Err(legacy_identity_changed());
}
let candidate = inventory
.selected_candidate()
.filter(|candidate| candidate.classification == ComponentClassification::ManagedLegacy)
.ok_or_else(legacy_identity_changed)?;
let service = candidate
.service
.as_ref()
.filter(|service| {
!service.name.trim().is_empty()
&& service
.path_name
.as_deref()
.is_some_and(|path_name| !path_name.trim().is_empty())
&& service.executable_path.is_some()
&& service.path_matches_candidate
})
.ok_or_else(legacy_identity_changed)?;
if candidate.component_id != inventory.component_id
|| candidate.executable_path.is_none()
|| !candidate.issues.is_empty()
{
return Err(legacy_identity_changed());
}
Ok(LegacyComponentIdentity {
component_id: inventory.component_id.clone(),
fingerprint: legacy_candidate_fingerprint(candidate, service),
})
}
pub fn revalidate_legacy_component<'a>(
expected: &LegacyComponentIdentity,
inventory: &'a ComponentInventory,
action: InventoryAction,
) -> Result<&'a ComponentCandidate, InventoryIssue> {
if !matches!(action, InventoryAction::Start | InventoryAction::Stop)
|| inventory.component_id != expected.component_id
{
return Err(legacy_identity_changed());
}
let candidate = authorize_component_action(inventory, action)
.ok()
.flatten()
.filter(|candidate| candidate.classification == ComponentClassification::ManagedLegacy)
.ok_or_else(legacy_identity_changed)?;
let actual = capture_legacy_component_identity(inventory)?;
if actual != *expected {
return Err(legacy_identity_changed());
}
Ok(candidate)
}
pub fn run_revalidated_legacy_action<T, E>(
expected: &LegacyComponentIdentity,
inventory: &ComponentInventory,
action: InventoryAction,
runner: impl FnOnce(&ComponentCandidate) -> Result<T, E>,
) -> Result<T, AuthorizedActionError<E>> {
let candidate = revalidate_legacy_component(expected, inventory, action)
.map_err(AuthorizedActionError::Denied)?;
runner(candidate).map_err(AuthorizedActionError::Runner)
}
fn legacy_candidate_fingerprint(
candidate: &ComponentCandidate,
service: &ServiceEvidence,
) -> String {
let value = json!({
"component": component_identity_label(&candidate.component_id),
"classification": "managed-legacy",
"role": candidate_role_label(candidate.role),
"root": normalized_identity_path(&candidate.root),
"executable": candidate.executable_path.as_deref().map(normalized_identity_path),
"binaryIdentity": "known-package",
"binaryVersion": candidate.binary_version,
"marker": marker_identity_label(candidate.marker),
"service": {
"name": service.name.to_ascii_lowercase(),
"pathName": service.path_name.as_deref().map(normalized_identity_text),
"executable": service.executable_path.as_deref().map(normalized_identity_path),
"pathMatchesCandidate": service.path_matches_candidate,
"binaryVersion": service.binary_version,
},
});
format!("{:x}", Sha256::digest(value.to_string().as_bytes()))
}
/// Canonical redacted identity used by normal startup, the privileged plan,
/// and elevated next-start verification. Keeping this in the inventory owner
/// prevents subtly different hashes from authorizing cleanup.
pub fn component_inventory_fingerprint_for_cutover(inventory: &ComponentInventory) -> String {
let mut candidates = inventory
.candidates
.iter()
.map(inventory_candidate_fingerprint_value)
.collect::<Vec<_>>();
candidates.sort_by_key(Value::to_string);
let mut issues = inventory
.issues
.iter()
.map(|issue| issue.code.clone())
.collect::<Vec<_>>();
issues.sort();
let value = json!({
"component": component_identity_label(&inventory.component_id),
"selected": inventory.selected_candidate().map(inventory_candidate_fingerprint_value),
"candidates": candidates,
"issues": issues,
});
format!("{:x}", Sha256::digest(value.to_string().as_bytes()))
}
fn inventory_candidate_fingerprint_value(candidate: &ComponentCandidate) -> Value {
let mut issues = candidate
.issues
.iter()
.map(|issue| issue.code.clone())
.collect::<Vec<_>>();
issues.sort();
json!({
"component": component_identity_label(&candidate.component_id),
"classification": component_classification_label(candidate.classification),
"role": candidate_role_label(candidate.role),
"root": normalized_inventory_path(&candidate.root),
"executable": candidate.executable_path.as_deref().map(normalized_inventory_path),
"binaryVersion": candidate.binary_version,
"marker": marker_identity_label(candidate.marker),
"service": candidate.service.as_ref().map(|service| json!({
"name": service.name.to_ascii_lowercase(),
"status": service.status.to_ascii_lowercase(),
"pathName": service.path_name.as_deref().map(normalized_inventory_text),
"executable": service.executable_path.as_deref().map(normalized_inventory_path),
"pathMatches": service.path_matches_candidate,
"binaryVersion": service.binary_version,
})),
"issues": issues,
})
}
fn component_classification_label(classification: ComponentClassification) -> &'static str {
match classification {
ComponentClassification::ManagedCurrent => "managed-current",
ComponentClassification::ManagedLegacy => "managed-legacy",
ComponentClassification::Foreign => "foreign",
ComponentClassification::Incomplete => "incomplete",
ComponentClassification::Missing => "missing",
}
}
fn normalized_inventory_path(path: &Path) -> String {
normalized_inventory_text(&path.to_string_lossy())
}
fn normalized_inventory_text(value: &str) -> String {
value.trim().replace('/', "\\").to_ascii_lowercase()
}
fn legacy_identity_changed() -> InventoryIssue {
InventoryIssue::new(
LEGACY_IDENTITY_CHANGED,
"Старая управляемая установка изменилась после проверки; действие отменено.",
)
}
fn legacy_proxifyre_version_matches(version: &str) -> bool {
matches!(
version.trim(),
LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION | LEGACY_PROXIFYRE_FIXED_VERSION
)
}
fn is_sha256(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn normalized_identity_path(path: &Path) -> String {
normalized_identity_text(&path.to_string_lossy())
}
fn normalized_identity_text(value: &str) -> String {
value
.trim()
.replace('/', "\\")
.trim_end_matches('\\')
.to_ascii_lowercase()
}
fn split_windows_command_line(value: &str) -> Option<Vec<String>> {
if value.contains('\0') {
return None;
}
let characters: Vec<char> = value.chars().collect();
let mut index = 0;
let mut arguments = Vec::new();
while index < characters.len() {
while index < characters.len() && characters[index].is_whitespace() {
index += 1;
}
if index == characters.len() {
break;
}
let mut argument = String::new();
let mut quoted = false;
while index < characters.len() {
if characters[index] == '\\' {
let start = index;
while index < characters.len() && characters[index] == '\\' {
index += 1;
}
let count = index - start;
if index < characters.len() && characters[index] == '"' {
argument.extend(std::iter::repeat_n('\\', count / 2));
if count % 2 == 0 {
quoted = !quoted;
} else {
argument.push('"');
}
index += 1;
} else {
argument.extend(std::iter::repeat_n('\\', count));
}
continue;
}
match characters[index] {
'"' => quoted = !quoted,
character if character.is_whitespace() && !quoted => break,
character => argument.push(character),
}
index += 1;
}
if quoted || argument.is_empty() {
return None;
}
arguments.push(argument);
while index < characters.len() && characters[index].is_whitespace() {
index += 1;
}
}
(!arguments.is_empty()).then_some(arguments)
}
fn component_identity_label(component: &ComponentId) -> &'static str {
match component {
ComponentId::ControlApp => "control-app",
ComponentId::Proxyfier => "proxifyre",
ComponentId::Singbox => "sing-box",
}
}
fn candidate_role_label(role: CandidateRole) -> &'static str {
match role {
CandidateRole::Current => "current",
CandidateRole::Legacy => "legacy",
CandidateRole::ForeignByDefault => "foreign-by-default",
CandidateRole::Foreign => "foreign",
}
}
fn marker_identity_label(marker: MarkerEvidence) -> &'static str {
match marker {
MarkerEvidence::Valid => "valid",
MarkerEvidence::Missing => "missing",
MarkerEvidence::Invalid => "invalid",
MarkerEvidence::NotRequired => "not-required",
}
}
fn classify_candidate(probe: ComponentCandidateProbe) -> ComponentCandidate {
let mut issues = Vec::new();
let classification =
if !probe.root_exists && probe.executable_path.is_none() && probe.service.is_none() {
ComponentClassification::Missing
} else if probe.has_reparse_point {
issues.push(InventoryIssue::new(
OWNERSHIP_MISMATCH,
format!(
"Путь компонента содержит reparse point и не может считаться управляемым: {}",
probe.root.display()
),
));
ComponentClassification::Foreign
} else if probe.binary_identity == BinaryIdentityEvidence::Mismatch {
issues.push(InventoryIssue::new(
OWNERSHIP_MISMATCH,
"Binary не совпадает с известным пакетом ProxyWarden.",
));
ComponentClassification::Foreign
} else if probe
.service
.as_ref()
.is_some_and(|service| !service.path_matches_candidate)
{
issues.push(InventoryIssue::new(
OWNERSHIP_MISMATCH,
"Имя службы совпало, но ее PathName указывает на другой binary.",
));
ComponentClassification::Foreign
} else if probe.role == CandidateRole::Foreign {
let (code, message) = if probe.service.is_some() {
(
OWNERSHIP_MISMATCH,
"Служба с известным именем указывает в путь вне allowlist ProxyWarden.",
)
} else {
(
FOREIGN_COMPONENT,
"Путь не входит в allowlist управляемых установок ProxyWarden.",
)
};
issues.push(InventoryIssue::new(code, message));
ComponentClassification::Foreign
} else if !probe.root_exists || !probe.missing_files.is_empty() {
issues.push(InventoryIssue::new(
COMPONENT_INCOMPLETE,
missing_files_message(&probe.root, &probe.missing_files),
));
incomplete_classification(probe.role)
} else if probe.marker_required && probe.marker != MarkerEvidence::Valid {
let code = if probe.marker == MarkerEvidence::Invalid {
OWNERSHIP_MISMATCH
} else {
COMPONENT_INCOMPLETE
};
issues.push(InventoryIssue::new(
code,
"Marker установки не подтверждает владение ProxyWarden.",
));
if probe.marker == MarkerEvidence::Invalid {
ComponentClassification::Foreign
} else {
incomplete_classification(probe.role)
}
} else if probe.service_required && probe.service.is_none() {
issues.push(InventoryIssue::new(
COMPONENT_INCOMPLETE,
"Ожидаемая Windows-служба отсутствует.",
));
incomplete_classification(probe.role)
} else {
match probe.role {
CandidateRole::Current
if probe.marker == MarkerEvidence::Valid || probe.legacy_identity_complete =>
{
ComponentClassification::ManagedCurrent
}
CandidateRole::Legacy | CandidateRole::ForeignByDefault
if probe.legacy_identity_complete
&& probe.binary_identity == BinaryIdentityEvidence::KnownPackage =>
{
ComponentClassification::ManagedLegacy
}
CandidateRole::ForeignByDefault => {
issues.push(InventoryIssue::new(
FOREIGN_COMPONENT,
"Путь считается чужим без полной legacy identity ProxyWarden.",
));
ComponentClassification::Foreign
}
CandidateRole::Current | CandidateRole::Legacy => {
issues.push(InventoryIssue::new(
COMPONENT_INCOMPLETE,
"Недостаточно evidence для подтверждения владения компонентом.",
));
ComponentClassification::Incomplete
}
CandidateRole::Foreign => ComponentClassification::Foreign,
}
};
ComponentCandidate {
component_id: probe.component_id,
classification,
role: probe.role,
root: probe.root,
executable_path: probe.executable_path,
binary_version: probe.binary_version,
service: probe.service,
marker: probe.marker,
issues,
}
}
fn incomplete_classification(role: CandidateRole) -> ComponentClassification {
if role == CandidateRole::ForeignByDefault {
ComponentClassification::Foreign
} else {
ComponentClassification::Incomplete
}
}
fn missing_files_message(root: &Path, missing_files: &[PathBuf]) -> String {
if missing_files.is_empty() {
return format!("Папка компонента отсутствует: {}", root.display());
}
let names = missing_files
.iter()
.filter_map(|path| path.file_name().and_then(|name| name.to_str()))
.collect::<Vec<_>>()
.join(", ");
format!("Установка неполна; отсутствуют: {names}")
}
File diff suppressed because it is too large Load Diff
+153
View File
@@ -0,0 +1,153 @@
//! Live component status resolution and read-only route/profile presentation.
use crate::command_dto::ResolvedAppDto;
use crate::component_detection::{
inventory_proxyfier, inventory_singbox, proxyfier_component_from_detection,
proxyfier_component_from_inventory, singbox_component_from_detection,
singbox_component_from_inventory, DetectedProxyfier, DetectedSingBox,
};
use crate::component_inventory::ComponentInventory;
use crate::models::{
ComponentId, ComponentState, ComponentStatus, ProfileItem, ProfileItemType, Target,
};
pub(crate) fn live_components() -> Vec<ComponentStatus> {
resolve_component_statuses_with_inventories(&inventory_proxyfier(), &inventory_singbox())
}
pub(crate) fn components_with_detection(
detected_proxyfier: Option<DetectedProxyfier>,
detected_singbox: Option<DetectedSingBox>,
) -> Vec<ComponentStatus> {
resolve_component_statuses(detected_proxyfier, detected_singbox)
}
pub fn resolve_component_statuses(
detected_proxyfier: Option<DetectedProxyfier>,
detected_singbox: Option<DetectedSingBox>,
) -> Vec<ComponentStatus> {
let mut components = default_components();
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
}
pub fn resolve_component_statuses_with_inventories(
proxyfier_inventory: &ComponentInventory,
singbox_inventory: &ComponentInventory,
) -> Vec<ComponentStatus> {
let mut components = default_components();
upsert_component(
&mut components,
proxyfier_component_from_inventory(proxyfier_inventory),
);
upsert_component(
&mut components,
singbox_component_from_inventory(singbox_inventory),
);
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,
}
}
+301
View File
@@ -0,0 +1,301 @@
//! One process-independent configuration lock and a fixed, recoverable commit.
//! Only ProgramData source/generated files are included; this is never privileged authority.
use crate::{safe_fs, storage::JsonStorage};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
fs::{self, File, OpenOptions},
io,
path::{Path, PathBuf},
};
pub struct RootGuard {
_file: File,
}
pub fn acquire_root(storage: &JsonStorage) -> io::Result<RootGuard> {
let dir = &storage.paths().migrations_dir;
safe_fs::ensure_no_reparse_ancestors(dir)?;
fs::create_dir_all(dir)?;
safe_fs::protect_path_for_owner_admin_system(dir)?;
let path = dir.join("storage-migration.lock");
safe_fs::ensure_no_reparse_ancestors(&path)?;
let mut options = OpenOptions::new();
options.read(true).write(true).create(true).truncate(false);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.share_mode(0);
}
let file = options.open(&path)?;
safe_fs::protect_path_for_owner_admin_system(&path)?;
#[cfg(not(windows))]
file.try_lock().map_err(io::Error::other)?;
Ok(RootGuard { _file: file })
}
pub fn read_guard(storage: &JsonStorage) -> io::Result<RootGuard> {
let guard = acquire_root(storage)?;
if migration_active(storage).try_exists()? {
return Err(io::Error::other(
"storage recovery required before reading configuration",
));
}
recover_locked(storage)?;
Ok(guard)
}
fn migration_active(storage: &JsonStorage) -> PathBuf {
storage
.paths()
.migrations_dir
.join("active-storage-migration.json")
}
fn journal_path(storage: &JsonStorage) -> PathBuf {
storage
.paths()
.migrations_dir
.join("configuration-commit.json")
}
fn snapshot_path(storage: &JsonStorage, index: usize) -> PathBuf {
storage
.paths()
.migrations_dir
.join(format!("configuration-before-{index}.json"))
}
fn revision_path(storage: &JsonStorage) -> PathBuf {
storage
.paths()
.state_dir
.join("configuration-revision.json")
}
fn tracked_paths(storage: &JsonStorage) -> Vec<PathBuf> {
let paths = storage.paths();
[
paths.profiles_file.clone(),
paths.targets_file.clone(),
paths.local_singbox_file.clone(),
paths.singbox_subscription_cache_file.clone(),
paths.generated_dir.join("proxifyre-app-config.json"),
paths.generated_dir.join("sing-box-config.json"),
revision_path(storage),
crate::route_state::prepared_path(storage),
]
.into_iter()
.flat_map(|path| [path.clone(), safe_fs::backup_path(&path)])
.collect()
}
fn optional_bytes(path: &Path) -> io::Result<Option<Vec<u8>>> {
safe_fs::ensure_no_reparse_ancestors(path)?;
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
fn digest(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
/// Read only while holding this module's root guard. Content protects against uncoordinated old writers too.
pub fn revision_locked(storage: &JsonStorage) -> io::Result<String> {
let mut hash = Sha256::new();
for path in tracked_paths(storage).into_iter().step_by(2) {
match optional_bytes(&path)? {
Some(bytes) => {
hash.update([1]);
hash.update((bytes.len() as u64).to_le_bytes());
hash.update(bytes);
}
None => hash.update([0]),
}
}
Ok(format!("{:x}", hash.finalize()))
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Intent {
version: u8,
committed: bool,
before: Vec<Option<String>>,
}
/// Caller must hold the common root lock; migration calls this before inspecting source.
pub fn recover_locked(storage: &JsonStorage) -> io::Result<()> {
let Some(bytes) = optional_bytes(&journal_path(storage))? else {
return Ok(());
};
if migration_active(storage).try_exists()? {
return Err(io::Error::other(
"conflicting storage intents require recovery",
));
}
let intent: Intent = serde_json::from_slice(&bytes)
.map_err(|_| io::Error::other("invalid configuration intent"))?;
let paths = tracked_paths(storage);
if intent.version != 1 || intent.before.len() != paths.len() {
return Err(io::Error::other("unsupported configuration intent"));
}
if !intent.committed {
// Verify every snapshot before the first restoration, including absent destinations.
let mut snapshots = Vec::new();
for (index, expected) in intent.before.iter().enumerate() {
safe_fs::ensure_no_reparse_ancestors(&paths[index])?;
snapshots.push(match expected {
Some(hash) => {
let bytes = optional_bytes(&snapshot_path(storage, index))?
.ok_or_else(|| io::Error::other("missing configuration snapshot"))?;
if digest(&bytes) != *hash {
return Err(io::Error::other("damaged configuration snapshot"));
}
Some(bytes)
}
None => None,
});
}
for (path, bytes) in paths.iter().zip(snapshots) {
// Old elevated versions left some readable files owned by Administrators.
// An unchanged file is already restored; rewriting it can fail and strand
// an otherwise complete rollback, blocking every subsequent guarded read.
if optional_bytes(path)? == bytes {
continue;
}
match bytes {
Some(bytes) => safe_fs::write_restricted_atomic(path, &bytes)?,
None => remove_optional(path)?,
}
}
}
cleanup(storage, paths.len())
}
fn remove_optional(path: &Path) -> io::Result<()> {
safe_fs::ensure_no_reparse_ancestors(path)?;
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
fn cleanup(storage: &JsonStorage, count: usize) -> io::Result<()> {
// Mark rollback complete before deleting any snapshot, so interrupted cleanup is retryable.
let marker = Intent {
version: 1,
committed: true,
before: vec![None; count],
};
safe_fs::write_restricted_atomic(&journal_path(storage), &serde_json::to_vec(&marker)?)?;
for index in 0..count {
remove_optional(&safe_fs::backup_path(&snapshot_path(storage, index)))?;
remove_optional(&snapshot_path(storage, index))?;
}
remove_optional(&safe_fs::backup_path(&journal_path(storage)))?;
remove_optional(&journal_path(storage))
}
pub struct ConfigurationTransaction<'a> {
storage: &'a JsonStorage,
guard: Option<RootGuard>,
intent: Intent,
committed: bool,
}
impl<'a> ConfigurationTransaction<'a> {
pub fn begin(storage: &'a JsonStorage, expected: Option<&str>) -> io::Result<Self> {
let guard = read_guard(storage)?;
if let Some(expected) = expected {
if revision_locked(storage)? != expected {
return Err(io::Error::other(
"configuration changed; retry using current settings",
));
}
}
let mut before = Vec::new();
for (index, path) in tracked_paths(storage).iter().enumerate() {
before.push(match optional_bytes(path)? {
Some(bytes) => {
safe_fs::write_restricted_atomic(&snapshot_path(storage, index), &bytes)?;
Some(digest(&bytes))
}
None => None,
});
}
let intent = Intent {
version: 1,
committed: false,
before,
};
safe_fs::write_restricted_atomic(&journal_path(storage), &serde_json::to_vec(&intent)?)?;
Ok(Self {
storage,
guard: Some(guard),
intent,
committed: false,
})
}
pub fn commit(self) -> io::Result<()> {
self.commit_with_revision().map(|_| ())
}
pub fn commit_with_revision(mut self) -> io::Result<String> {
// A fresh nonce records intent even if a later edit returns source to identical bytes.
let prepared = safe_fs::write_restricted_atomic(
&revision_path(self.storage),
&serde_json::to_vec(&uuid::Uuid::new_v4().to_string())?,
)
.and_then(|()| revision_locked(self.storage));
let revision = match prepared {
Ok(revision) => revision,
Err(error) => {
self.committed = true;
return match recover_locked(self.storage) {
Ok(()) => Err(error),
Err(_) => Err(io::Error::other(
"configuration_recovery_required: восстановление сохранения не завершено",
)),
};
}
};
self.intent.committed = true;
let marker = serde_json::to_vec(&self.intent)?;
if let Err(error) = safe_fs::write_restricted_atomic(&journal_path(self.storage), &marker) {
// The atomic writer may fail its final ACL step after promotion.
// Read back the exact marker under the same lock before deciding the outcome.
match optional_bytes(&journal_path(self.storage)) {
Ok(Some(bytes)) if bytes == marker => {}
Ok(Some(_)) => {
self.committed = true;
return match recover_locked(self.storage) {
Ok(()) => Err(error),
Err(_) => Err(io::Error::other("configuration_recovery_required: восстановление сохранения не завершено")),
};
}
_ => {
self.committed = true;
return Err(io::Error::other("configuration_outcome_unknown: итог сохранения не подтверждён; обновите состояние перед повтором"));
}
}
}
self.committed = true;
let _ = cleanup(self.storage, self.intent.before.len());
self.guard.take();
Ok(revision)
}
pub fn abort(mut self) -> io::Result<()> {
let result = recover_locked(self.storage);
// Do not silently retry and hide a failed explicit recovery in Drop.
self.committed = true;
result
}
}
impl Drop for ConfigurationTransaction<'_> {
fn drop(&mut self) {
if !self.committed {
let _ = recover_locked(self.storage);
}
// Failed recovery leaves the durable intent for the next guarded read, never fresh defaults.
}
}
+435
View File
@@ -0,0 +1,435 @@
use crate::configuration_transaction::{read_guard, ConfigurationTransaction};
// Persisted profiles/targets, startup preparation, and preview use cases.
use crate::adapters::proxifyre::{ProxiFyreAdapter, PROXIFYRE_OUTPUT_FILE};
use crate::adapters::proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
use crate::admin::admin_status;
use crate::command_dto::*;
use crate::component_detection::{
default_proxifyre_install_dir, default_singbox_install_dir, detected_proxyfier_from_inventory,
detected_singbox_from_inventory, inventory_proxyfier, inventory_singbox,
};
use crate::component_inventory::ComponentClassification;
use crate::component_status::{
live_components, resolve_component_statuses_with_inventories, resolved_app, route_line,
};
use crate::migration::{
prepare_storage, reconcile_component_layout, record_component_cutover_startup_evidence,
recover_incomplete_migration, with_component_layout,
};
use crate::proxifyre_runtime::build_proxifyre_setup_status_with_detection;
use crate::safe_fs;
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;
pub fn build_status(storage: &JsonStorage) -> Result<StatusResponse, CommandError> {
let _guard = read_guard(storage).map_err(storage_error)?;
let profiles = storage.read_profiles().map_err(storage_error)?;
let targets = storage.read_targets().map_err(storage_error)?;
let components = live_components();
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> {
let _guard = read_guard(storage).map_err(storage_error)?;
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 transaction = ConfigurationTransaction::begin(storage, None).map_err(storage_error)?;
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)?;
transaction.commit().map_err(storage_error)?;
Ok(ProfileDto::from(&profile))
}
pub fn read_targets(storage: &JsonStorage) -> Result<Vec<TargetDto>, CommandError> {
let _guard = read_guard(storage).map_err(storage_error)?;
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 transaction = ConfigurationTransaction::begin(storage, None).map_err(storage_error)?;
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)?;
transaction.commit().map_err(storage_error)?;
Ok(TargetDto::from(&target))
}
pub fn read_live_components() -> Vec<ComponentStatusDto> {
live_components()
.iter()
.map(ComponentStatusDto::from)
.collect()
}
/// Rebuilds only an existing, untrusted derived config from authoritative
/// profiles/targets. A missing config still requires an explicit Apply action.
pub fn ensure_proxifyre_generated_config_ready(storage: &JsonStorage) -> Result<(), CommandError> {
let guard = read_guard(storage).map_err(storage_error)?;
let path = storage.paths().generated_dir.join(PROXIFYRE_OUTPUT_FILE);
if !path.try_exists().map_err(storage_error)? {
return Err(CommandError::new(
"generated_config_missing",
"Сначала нажмите «Применить», чтобы создать конфигурацию ProxiFyre.",
));
}
let profiles = storage.read_profiles().map_err(storage_error)?;
if !profiles
.iter()
.any(|profile| profile.enabled && !profile.items.is_empty())
{
return Err(CommandError::new(
"route_has_no_apps",
"Нет включённых правил. Добавьте приложения и примените конфигурацию перед запуском ProxiFyre.",
));
}
if safe_fs::open_restricted_file_read_lease(&path).is_ok() {
return Ok(());
}
let targets = storage.read_targets().map_err(storage_error)?;
let components = live_components();
let generated = ProxiFyreAdapter::default()
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.map_err(|_| {
CommandError::new(
"generated_config_rebuild_failed",
"Старую конфигурацию ProxiFyre нельзя использовать. Нажмите «Применить», чтобы пересоздать её.",
)
})?;
let revision =
crate::configuration_transaction::revision_locked(storage).map_err(storage_error)?;
drop(guard);
let transaction =
ConfigurationTransaction::begin(storage, Some(&revision)).map_err(storage_error)?;
remove_untrusted_generated_file(&path, true)?;
remove_untrusted_generated_file(&safe_fs::backup_path(&path), false)?;
safe_fs::write_restricted_atomic(&path, generated.contents.as_bytes())
.map_err(|_| generated_config_rebuild_error())?;
transaction.commit().map_err(storage_error)
}
fn remove_untrusted_generated_file(
path: &std::path::Path,
required: bool,
) -> Result<(), CommandError> {
safe_fs::ensure_no_reparse_ancestors(path).map_err(|_| generated_config_rebuild_error())?;
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_file() => {
fs::remove_file(path).map_err(|_| generated_config_rebuild_error())
}
Ok(_) => Err(generated_config_rebuild_error()),
Err(error) if !required && error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(_) => Err(generated_config_rebuild_error()),
}
}
fn generated_config_rebuild_error() -> CommandError {
CommandError::new(
"generated_config_rebuild_failed",
"Не удалось безопасно пересоздать старую конфигурацию ProxiFyre. Нажмите «Применить» и повторите запуск.",
)
}
pub fn read_startup_snapshot(
storage: &JsonStorage,
startup_session_id: &str,
) -> Result<StartupSnapshotResponse, CommandError> {
// Resolve an interrupted storage transaction before any normal read or
// component-dependent startup work.
recover_incomplete_migration(storage)?;
// Both detectors query Windows independently. Run them together so the
// startup snapshot is bounded by the slower check instead of their sum.
let proxyfier_inventory_task = std::thread::spawn(inventory_proxyfier);
let singbox_inventory = inventory_singbox();
let proxyfier_inventory = proxyfier_inventory_task.join().map_err(|_| {
CommandError::new(
"component_inventory_failed",
"Не удалось проверить установку ProxiFyre.",
)
})?;
let detected_proxyfier = detected_proxyfier_from_inventory(&proxyfier_inventory);
let detected_singbox = detected_singbox_from_inventory(&singbox_inventory);
let mut legacy_candidates = vec![storage
.paths()
.generated_dir
.join("proxifyre-app-config.json")];
legacy_candidates.extend(
proxyfier_inventory
.candidates
.iter()
.filter(|candidate| {
matches!(
candidate.classification,
ComponentClassification::ManagedCurrent
| ComponentClassification::ManagedLegacy
)
})
.map(|candidate| candidate.root.join("app-config.json")),
);
let migration_status = prepare_storage(storage, &legacy_candidates)?;
if migration_status.blocking {
return Err(CommandError::new(
migration_status
.notice_code
.clone()
.unwrap_or_else(|| "storage_migration_blocked".to_string()),
migration_status.message,
));
}
let component_layout_version =
reconcile_component_layout(storage, &proxyfier_inventory, &singbox_inventory)?;
// This is an untrusted UX carrier. A failed write must not block normal
// startup; cleanup remains unavailable until an exact later observation.
let _ = record_component_cutover_startup_evidence(
storage,
startup_session_id,
&proxyfier_inventory,
);
let migration_status = with_component_layout(migration_status, component_layout_version);
let components =
resolve_component_statuses_with_inventories(&proxyfier_inventory, &singbox_inventory)
.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 saved_state = singbox_status.saved_state.clone();
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(),
migration_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 _guard = read_guard(storage).map_err(storage_error)?;
read_saved_state_locked(storage)
}
pub(crate) fn read_saved_state_locked(
storage: &JsonStorage,
) -> Result<SavedStateResponse, CommandError> {
let profiles = storage.read_profiles().map_err(storage_error)?;
let targets = storage.read_targets().map_err(storage_error)?;
Ok(SavedStateResponse {
artifacts: crate::route_state::read_status_locked(storage).map_err(storage_error)?,
revision: crate::configuration_transaction::revision_locked(storage)
.map_err(storage_error)?,
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(),
})
}
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(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind,
};
use std::path::Path;
#[test]
fn rebuilds_existing_untrusted_generated_config_from_source_of_truth() {
let root = test_root("rebuild-generated");
let storage = JsonStorage::new(&root);
storage
.write_profiles(&[test_profile()])
.expect("write profiles");
storage
.write_targets(&[test_target()])
.expect("write targets");
let generated = storage.paths().generated_dir.join(PROXIFYRE_OUTPUT_FILE);
fs::create_dir_all(generated.parent().expect("generated parent"))
.expect("create generated parent");
fs::write(&generated, b"untrusted legacy bytes").expect("write weak legacy config");
fs::write(safe_fs::backup_path(&generated), b"untrusted backup")
.expect("write weak legacy backup");
ensure_proxifyre_generated_config_ready(&storage).expect("rebuild generated config");
let contents = fs::read_to_string(&generated).expect("read rebuilt config");
assert!(contents.contains("Discord.exe"));
assert!(contents.contains("127.0.0.1:1080"));
assert!(!contents.contains("untrusted legacy bytes"));
assert!(!safe_fs::backup_path(&generated).exists());
#[cfg(windows)]
safe_fs::verify_path_protected_for_owner_admin_system(&generated)
.expect("rebuilt config keeps the restricted ACL");
cleanup(&root);
}
#[test]
fn missing_generated_config_still_requires_explicit_apply() {
let root = test_root("missing-generated");
let storage = JsonStorage::new(&root);
let error = ensure_proxifyre_generated_config_ready(&storage)
.expect_err("missing config must not be created implicitly");
assert_eq!(error.code, "generated_config_missing");
cleanup(&root);
}
fn test_profile() -> Profile {
Profile {
id: "test".to_string(),
name: "Test".to_string(),
enabled: true,
target_id: "external".to_string(),
protocols: vec![Protocol::Tcp, Protocol::Udp],
items: vec![ProfileItem {
item_type: ProfileItemType::Process,
value: "Discord.exe".to_string(),
recursive: false,
}],
}
}
fn test_target() -> Target {
Target {
id: "external".to_string(),
name: "External".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Socks5,
host: "127.0.0.1".to_string(),
port: 1080,
requires_component: None,
}
}
fn test_root(label: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("proxywarden-{label}-{}", uuid::Uuid::new_v4()))
}
fn cleanup(root: &Path) {
let _ = fs::remove_dir_all(root);
}
}
-184
View File
@@ -1,184 +0,0 @@
use crate::models::ComponentId;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum HelperAction {
#[serde(rename = "install-control-app")]
InstallControlApp,
#[serde(rename = "install-proxyfier")]
InstallProxyfier,
#[serde(rename = "install-singbox")]
InstallSingbox,
#[serde(rename = "proxyfier.apply")]
ProxyfierApply,
#[serde(rename = "service.status")]
ServiceStatus,
#[serde(rename = "service.start")]
ServiceStart,
#[serde(rename = "service.stop")]
ServiceStop,
#[serde(rename = "service.restart")]
ServiceRestart,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperRequest {
pub action: HelperAction,
#[serde(skip_serializing_if = "Option::is_none")]
pub component: Option<ComponentId>,
#[serde(default)]
pub payload: Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperResponse {
pub success: bool,
pub action: HelperAction,
pub changed: bool,
pub message: String,
#[serde(default)]
pub details: Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelperCommandSpec {
pub program: PathBuf,
pub args: Vec<String>,
pub stdin: String,
pub requires_elevation: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelperCommandOutput {
pub status_code: i32,
pub stdout: String,
pub stderr: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperError {
pub code: String,
pub message: String,
}
impl HelperError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
pub trait HelperCommandRunner {
fn run(&self, spec: &HelperCommandSpec) -> Result<HelperCommandOutput, HelperError>;
}
#[derive(Debug, Clone)]
pub struct StructuredHelper<R> {
helper_program: PathBuf,
runner: R,
}
impl<R> StructuredHelper<R>
where
R: HelperCommandRunner,
{
pub fn new(helper_program: impl Into<PathBuf>, runner: R) -> Self {
Self {
helper_program: helper_program.into(),
runner,
}
}
pub fn runner(&self) -> &R {
&self.runner
}
pub fn execute(&self, request: &HelperRequest) -> Result<HelperResponse, HelperError> {
let stdin = serde_json::to_string(request)
.map_err(|error| HelperError::new("helper_request_encode", error.to_string()))?;
let spec = HelperCommandSpec {
program: self.helper_program.clone(),
args: vec!["--json".to_string()],
stdin,
requires_elevation: helper_action_requires_elevation(&request.action),
};
let output = self.runner.run(&spec)?;
if output.status_code != 0 {
return Err(HelperError::new(
"helper_exit",
format!(
"Помощник завершился с кодом {}: {}",
output.status_code, output.stderr
),
));
}
parse_helper_response(&output.stdout)
}
}
pub fn parse_helper_response(stdout: &str) -> Result<HelperResponse, HelperError> {
serde_json::from_str(stdout).map_err(|error| {
HelperError::new(
"helper_response_decode",
format!("Помощник вернул не JSON или некорректный JSON: {error}"),
)
})
}
pub fn install_request(component: ComponentId) -> HelperRequest {
let action = match component {
ComponentId::ControlApp => HelperAction::InstallControlApp,
ComponentId::Proxyfier => HelperAction::InstallProxyfier,
ComponentId::Singbox => HelperAction::InstallSingbox,
};
HelperRequest {
action,
component: Some(component),
payload: json!({}),
}
}
pub fn service_request(component: ComponentId, action: HelperAction) -> HelperRequest {
HelperRequest {
action,
component: Some(component),
payload: json!({}),
}
}
pub fn proxifyre_apply_request(
config_path: impl AsRef<Path>,
service_name: impl Into<String>,
) -> HelperRequest {
HelperRequest {
action: HelperAction::ProxyfierApply,
component: Some(ComponentId::Proxyfier),
payload: json!({
"configPath": config_path.as_ref().display().to_string(),
"serviceName": service_name.into(),
}),
}
}
pub fn helper_action_requires_elevation(action: &HelperAction) -> bool {
matches!(
action,
HelperAction::InstallControlApp
| HelperAction::InstallProxyfier
| HelperAction::InstallSingbox
| HelperAction::ProxyfierApply
| HelperAction::ServiceStart
| HelperAction::ServiceStop
| HelperAction::ServiceRestart
)
}
+141 -12
View File
@@ -1,14 +1,144 @@
pub mod activity; pub mod activity;
pub mod admin;
pub mod apply_flow;
pub mod clock;
pub mod command_dto;
pub mod commands; pub mod commands;
pub mod component_catalog;
pub mod component_cutover;
pub mod component_detection; pub mod component_detection;
pub mod helper; pub mod component_inventory;
pub mod component_packages;
pub mod component_status;
pub mod configuration_transaction;
pub mod configuration_use_case;
pub mod migration;
pub mod models; pub mod models;
pub mod nsis_runtime;
pub mod privileged_jobs;
pub mod privileged_runtime;
pub mod process; pub mod process;
pub mod proxifyre_ownership;
pub mod proxifyre_runtime;
pub mod proxy_apply;
pub mod proxy_probe;
pub mod route_state;
pub mod safe_fs;
pub mod singbox_config;
pub mod singbox_runtime;
pub mod singbox_service; pub mod singbox_service;
pub mod singbox_subscription;
pub mod storage; pub mod storage;
pub mod subscription; pub mod subscription;
pub mod validation; pub mod validation;
pub enum EarlyProcessMode {
NotHandled,
Exit(i32),
}
/// Handles the fixed elevated-helper mode before Tauri or a webview is initialized.
/// Ordinary startup returns before constructing any component/network runtime.
pub fn run_early_process_mode<I>(arguments: I) -> EarlyProcessMode
where
I: IntoIterator<Item = std::ffi::OsString>,
{
let arguments = arguments.into_iter().collect::<Vec<_>>();
match nsis_runtime::parse_nsis_early_arguments(arguments.clone()) {
Ok(Some(mode)) => {
return EarlyProcessMode::Exit(nsis_runtime::nsis_process_exit_code(
nsis_runtime::run_system_nsis_mode(mode),
));
}
Ok(None) => {}
Err(_) => return EarlyProcessMode::Exit(nsis_runtime::NSIS_EXIT_USAGE),
}
let job_id = match privileged_jobs::parse_early_helper_arguments(arguments) {
Ok(Some(job_id)) => job_id,
Ok(None) => return EarlyProcessMode::NotHandled,
Err(_) => return EarlyProcessMode::Exit(64),
};
let runtime = match privileged_runtime::SystemPrivilegedRuntime::production() {
Ok(runtime) => runtime,
Err(_) => return EarlyProcessMode::Exit(2),
};
run_recognized_early_job(&job_id, &runtime, &runtime)
}
pub fn run_early_process_mode_with_runtime<I>(
arguments: I,
resolver: &dyn privileged_jobs::PrivilegedPlanResolver,
runner: &dyn privileged_jobs::PrivilegedActionRunner,
) -> EarlyProcessMode
where
I: IntoIterator<Item = std::ffi::OsString>,
{
let job_id = match privileged_jobs::parse_early_helper_arguments(arguments) {
Ok(Some(job_id)) => job_id,
Ok(None) => return EarlyProcessMode::NotHandled,
Err(_) => return EarlyProcessMode::Exit(64),
};
run_recognized_early_job(&job_id, resolver, runner)
}
fn run_recognized_early_job(
job_id: &privileged_jobs::PrivilegedJobId,
resolver: &dyn privileged_jobs::PrivilegedPlanResolver,
runner: &dyn privileged_jobs::PrivilegedActionRunner,
) -> EarlyProcessMode {
let store = match privileged_jobs::PrivilegedJobStore::production() {
Ok(store) => store,
Err(_) => return EarlyProcessMode::Exit(2),
};
let result = privileged_jobs::execute_privileged_job(
&store,
job_id,
&privileged_jobs::SystemEpochClock,
&privileged_jobs::NativeElevationProbe,
resolver,
runner,
);
match result {
Ok(result) if result.status == privileged_jobs::PrivilegedJobStatus::Succeeded => {
EarlyProcessMode::Exit(0)
}
Ok(_) => EarlyProcessMode::Exit(1),
Err(_) => EarlyProcessMode::Exit(2),
}
}
#[cfg(test)]
mod early_process_mode_tests {
use super::*;
#[test]
fn ordinary_startup_returns_before_constructing_privileged_runtime() {
assert!(matches!(
run_early_process_mode(Vec::<std::ffi::OsString>::new()),
EarlyProcessMode::NotHandled
));
}
#[test]
fn malformed_helper_arguments_fail_before_runtime_construction() {
assert!(matches!(
run_early_process_mode([std::ffi::OsString::from("--elevated-helper")]),
EarlyProcessMode::Exit(64)
));
}
#[test]
fn malformed_nsis_arguments_fail_before_runtime_construction() {
assert!(matches!(
run_early_process_mode([
std::ffi::OsString::from(nsis_runtime::NSIS_VERIFY_UPGRADE_ARGUMENT),
std::ffi::OsString::from("unexpected"),
]),
EarlyProcessMode::Exit(nsis_runtime::NSIS_EXIT_USAGE)
));
}
}
pub mod adapters { pub mod adapters {
pub mod proxifyre; pub mod proxifyre;
pub mod proxy_router; pub mod proxy_router;
@@ -20,21 +150,21 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.manage(commands::CommandState::default()) .manage(commands::CommandState::default())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::get_status,
commands::get_admin_status,
commands::restart_as_admin, commands::restart_as_admin,
commands::get_startup_snapshot, commands::get_startup_snapshot,
commands::get_profiles,
commands::get_saved_state, commands::get_saved_state,
commands::save_profile,
commands::get_targets,
commands::save_target,
commands::get_components, commands::get_components,
commands::get_proxifyre_setup_status, commands::get_proxifyre_setup_status,
commands::get_singbox_status, commands::get_singbox_status,
commands::get_singbox_setup_status, commands::get_singbox_setup_status,
commands::resolve_profile_preview, commands::get_component_package_statuses,
commands::save_singbox_subscription, commands::get_component_cutover_statuses,
commands::check_component_update,
commands::download_component_update,
commands::update_component,
commands::cutover_component,
commands::confirm_component_route_smoke,
commands::cleanup_component_quarantine,
commands::fetch_singbox_subscription, commands::fetch_singbox_subscription,
commands::forget_singbox_subscription, commands::forget_singbox_subscription,
commands::select_singbox_server, commands::select_singbox_server,
@@ -42,12 +172,11 @@ pub fn run() {
commands::ping_all_singbox_servers, commands::ping_all_singbox_servers,
commands::ping_proxy_target, commands::ping_proxy_target,
commands::generate_singbox_config, commands::generate_singbox_config,
commands::apply_profiles, commands::apply_configuration,
commands::get_logs,
commands::open_config_location,
commands::start_proxifyre_service, commands::start_proxifyre_service,
commands::stop_proxifyre_service, commands::stop_proxifyre_service,
commands::install_proxifyre, commands::install_proxifyre,
commands::configure_proxifyre_firewall_rules,
commands::uninstall_proxifyre, commands::uninstall_proxifyre,
commands::start_singbox_service, commands::start_singbox_service,
commands::stop_singbox_service, commands::stop_singbox_service,
+4
View File
@@ -1,5 +1,9 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() { fn main() {
match proxywarden_lib::run_early_process_mode(std::env::args_os().skip(1)) {
proxywarden_lib::EarlyProcessMode::NotHandled => {}
proxywarden_lib::EarlyProcessMode::Exit(code) => std::process::exit(code),
}
proxywarden_lib::run(); proxywarden_lib::run();
} }
File diff suppressed because it is too large Load Diff
+86 -42
View File
@@ -1,11 +1,12 @@
use percent_encoding::percent_decode_str; use percent_encoding::percent_decode_str;
use serde::{Deserialize, Serialize}; 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_HOST: &str = "127.0.0.1";
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080; pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080;
pub const DEFAULT_LOCAL_SINGBOX_SERVICE_NAME: &str = "ProxyWardenSingBox"; 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
@@ -55,6 +56,7 @@ pub enum ComponentState {
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileItemInput { pub struct ProfileItemInput {
#[serde(rename = "type")] #[serde(rename = "type")]
pub item_type: String, pub item_type: String,
@@ -64,6 +66,7 @@ pub struct ProfileItemInput {
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileInput { pub struct ProfileInput {
pub id: Option<String>, pub id: Option<String>,
pub name: String, pub name: String,
@@ -96,6 +99,7 @@ pub struct Profile {
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetInput { pub struct TargetInput {
pub id: Option<String>, pub id: Option<String>,
pub name: String, pub name: String,
@@ -130,6 +134,10 @@ pub struct ComponentStatus {
pub version: Option<String>, pub version: Option<String>,
pub path: Option<String>, pub path: Option<String>,
#[serde(default)] #[serde(default)]
pub service_name: Option<String>,
#[serde(default)]
pub service_status: Option<String>,
#[serde(default)]
pub problems: Vec<String>, pub problems: Vec<String>,
#[serde(default)] #[serde(default)]
pub actions: Vec<String>, pub actions: Vec<String>,
@@ -143,18 +151,44 @@ pub struct LocalSingBoxConfig {
pub device_hwid: Option<String>, pub device_hwid: Option<String>,
#[serde(default)] #[serde(default)]
pub selected_server_tag: Option<String>, pub selected_server_tag: Option<String>,
#[serde(default)]
pub selected_server_id: Option<String>,
#[serde(default = "default_local_singbox_listen_host")] #[serde(default = "default_local_singbox_listen_host")]
pub listen_host: String, pub listen_host: String,
#[serde(default = "default_local_singbox_listen_port")] #[serde(default = "default_local_singbox_listen_port")]
pub listen_port: u16, pub listen_port: u16,
#[serde(default = "default_local_singbox_service_name")] #[serde(default = "default_local_singbox_service_name")]
pub service_name: String, pub service_name: String,
#[serde(default = "default_local_singbox_install_root")] #[serde(default = "default_local_singbox_install_root", skip_serializing)]
pub install_root: String, pub install_root: String,
#[serde(default)] #[serde(default)]
pub updated_at: Option<String>, pub updated_at: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StorageMigrationOutcome {
InitializedEmpty,
AdoptedWithoutLegacyImport,
ImportedLegacyConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StorageMeta {
pub storage_schema_version: u32,
pub outcome: StorageMigrationOutcome,
pub migration_id: String,
pub completed_at_epoch_seconds: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ComponentLayoutMeta {
pub component_layout_version: u32,
pub verified_at_epoch_seconds: u64,
}
impl LocalSingBoxConfig { impl LocalSingBoxConfig {
pub fn subscription_display_url(&self) -> Option<String> { pub fn subscription_display_url(&self) -> Option<String> {
self.subscription_url self.subscription_url
@@ -175,6 +209,7 @@ impl Default for LocalSingBoxConfig {
subscription_url: None, subscription_url: None,
device_hwid: None, device_hwid: None,
selected_server_tag: None, selected_server_tag: None,
selected_server_id: None,
listen_host: default_local_singbox_listen_host(), listen_host: default_local_singbox_listen_host(),
listen_port: default_local_singbox_listen_port(), listen_port: default_local_singbox_listen_port(),
service_name: default_local_singbox_service_name(), service_name: default_local_singbox_service_name(),
@@ -198,34 +233,17 @@ impl SubscriptionCache {
pub fn normalize_percent_encoded_tags(&mut self) { pub fn normalize_percent_encoded_tags(&mut self) {
for server in &mut self.servers { for server in &mut self.servers {
server.tag = decode_percent_encoded_utf8(&server.tag); server.tag = decode_percent_encoded_utf8(&server.tag);
server.ensure_id();
} }
let Some(outbounds) = self // Outbound bytes define stable identity. Decode display labels only.
.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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubscriptionServer { pub struct SubscriptionServer {
#[serde(default)]
pub id: String,
pub tag: String, pub tag: String,
#[serde(rename = "type")] #[serde(rename = "type")]
pub server_type: String, pub server_type: String,
@@ -233,6 +251,34 @@ pub struct SubscriptionServer {
pub server_port: u16, 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActivityEntry { pub struct ActivityEntry {
pub id: String, pub id: String,
@@ -293,24 +339,22 @@ pub fn redact_subscription_url(raw_url: &str) -> String {
return String::new(); return String::new();
} }
match trimmed.split_once("://") { let Ok(parsed) = Url::parse(trimmed) else {
Some((scheme, rest)) => { return "***".to_string();
let host = rest };
.split(['/', '?', '#'])
.next() let host = parsed.host_str().unwrap_or("subscription");
.filter(|value| !value.is_empty()) let host = if host.contains(':') && !host.starts_with('[') {
.unwrap_or("subscription"); format!("[{host}]")
format!("{scheme}://{host}/...") } else {
} host.to_string()
None => { };
let visible = trimmed.chars().take(18).collect::<String>(); let port = parsed
if trimmed.chars().count() <= 18 { .port()
"***".to_string() .map(|port| format!(":{port}"))
} else { .unwrap_or_default();
format!("{visible}...")
} format!("{}://{}{}/...", parsed.scheme(), host, port)
}
}
} }
pub fn decode_percent_encoded_utf8(value: &str) -> String { pub fn decode_percent_encoded_utf8(value: &str) -> String {
File diff suppressed because it is too large Load Diff
+993
View File
@@ -0,0 +1,993 @@
use super::*;
use crate::privileged_jobs::{
verify_nsis_privileged_lifecycle_idle_for_tests, write_nsis_interrupted_retirement_for_tests,
write_nsis_partial_reboot_staging_for_tests, write_nsis_partial_retirement_staging_for_tests,
write_nsis_terminal_pair_for_tests, NsisPrivilegedLifecycleGuard, NsisPrivilegedLifecycleState,
PrivilegedJobsError,
};
use std::cell::RefCell;
use std::collections::VecDeque;
#[derive(Default)]
struct FakeHost {
elevated: bool,
calls: RefCell<Vec<&'static str>>,
verify_executable: VecDeque<Result<(), NsisRuntimeError>>,
lifecycle_state: VecDeque<Result<NsisLifecycleState, NsisRuntimeError>>,
cutover: VecDeque<Result<NsisCutoverState, NsisRuntimeError>>,
proxifyre: VecDeque<Result<NsisComponentState, NsisRuntimeError>>,
singbox: VecDeque<Result<NsisComponentState, NsisRuntimeError>>,
transients: VecDeque<Result<NsisTransientState, NsisRuntimeError>>,
acquire: VecDeque<Result<(), NsisRuntimeError>>,
retire: VecDeque<Result<(), NsisRuntimeError>>,
stop_proxifyre: VecDeque<Result<(), NsisRuntimeError>>,
stop_singbox: VecDeque<Result<(), NsisRuntimeError>>,
retry_singbox_cleanup: VecDeque<Result<(), NsisRuntimeError>>,
uninstall_proxifyre: VecDeque<Result<bool, NsisRuntimeError>>,
uninstall_singbox: VecDeque<Result<bool, NsisRuntimeError>>,
reboot_under_lock: VecDeque<Result<bool, NsisRuntimeError>>,
mark_reboot: VecDeque<Result<bool, NsisRuntimeError>>,
clear_reboot: VecDeque<Result<(), NsisRuntimeError>>,
cleanup: VecDeque<Result<(), NsisRuntimeError>>,
}
impl FakeHost {
fn ready(proxifyre: NsisComponentState, singbox: NsisComponentState) -> Self {
Self {
elevated: true,
verify_executable: VecDeque::from([Ok(()), Ok(())]),
lifecycle_state: VecDeque::from([Ok(NsisLifecycleState {
retirement_pending: false,
reboot_required: false,
})]),
cutover: VecDeque::from([Ok(NsisCutoverState::Absent), Ok(NsisCutoverState::Absent)]),
proxifyre: VecDeque::from([Ok(proxifyre), Ok(proxifyre)]),
singbox: VecDeque::from([Ok(singbox), Ok(singbox)]),
transients: VecDeque::from([
Ok(NsisTransientState {
singbox_cleanup_pending: false,
package_staging_pending: false,
}),
Ok(NsisTransientState {
singbox_cleanup_pending: false,
package_staging_pending: false,
}),
]),
acquire: VecDeque::from([Ok(())]),
retire: VecDeque::from([Ok(())]),
stop_proxifyre: VecDeque::from([Ok(())]),
stop_singbox: VecDeque::from([Ok(())]),
retry_singbox_cleanup: VecDeque::from([Ok(())]),
uninstall_proxifyre: VecDeque::from([Ok(false)]),
uninstall_singbox: VecDeque::from([Ok(false)]),
reboot_under_lock: VecDeque::from([Ok(false)]),
mark_reboot: VecDeque::from([Ok(true), Ok(true)]),
clear_reboot: VecDeque::from([Ok(()), Ok(())]),
cleanup: VecDeque::from([Ok(())]),
..Self::default()
}
}
fn call(&self, name: &'static str) {
self.calls.borrow_mut().push(name);
}
fn calls(&self) -> Vec<&'static str> {
self.calls.borrow().clone()
}
}
fn next<T>(queue: &mut VecDeque<Result<T, NsisRuntimeError>>) -> Result<T, NsisRuntimeError> {
queue.pop_front().expect("fake call was not planned")
}
impl NsisRuntimeHost for FakeHost {
fn is_elevated(&self) -> bool {
self.call("elevated");
self.elevated
}
fn verify_current_executable(&mut self) -> Result<(), NsisRuntimeError> {
self.call("verify-exe");
next(&mut self.verify_executable)
}
fn verify_lifecycle_state(&mut self) -> Result<NsisLifecycleState, NsisRuntimeError> {
self.call("lifecycle-idle");
next(&mut self.lifecycle_state)
}
fn acquire_lifecycle_lock(&mut self) -> Result<(), NsisRuntimeError> {
self.call("acquire");
next(&mut self.acquire)
}
fn inspect_cutover(&mut self) -> Result<NsisCutoverState, NsisRuntimeError> {
self.call("cutover");
next(&mut self.cutover)
}
fn preflight_proxifyre(&mut self) -> Result<NsisComponentState, NsisRuntimeError> {
self.call("proxifyre");
next(&mut self.proxifyre)
}
fn preflight_singbox(&mut self) -> Result<NsisComponentState, NsisRuntimeError> {
self.call("singbox");
next(&mut self.singbox)
}
fn verify_transient_layout(&mut self) -> Result<NsisTransientState, NsisRuntimeError> {
self.call("transients");
next(&mut self.transients)
}
fn retire_cutover(
&mut self,
_expected: &CutoverTerminalRetirementExpectation,
) -> Result<(), NsisRuntimeError> {
self.call("retire-cutover");
next(&mut self.retire)
}
fn stop_proxifyre(&mut self) -> Result<(), NsisRuntimeError> {
self.call("stop-proxifyre");
next(&mut self.stop_proxifyre)
}
fn stop_singbox(&mut self) -> Result<(), NsisRuntimeError> {
self.call("stop-singbox");
next(&mut self.stop_singbox)
}
fn retry_singbox_cleanup(&mut self) -> Result<(), NsisRuntimeError> {
self.call("retry-singbox-cleanup");
next(&mut self.retry_singbox_cleanup)
}
fn uninstall_proxifyre(&mut self) -> Result<bool, NsisRuntimeError> {
self.call("uninstall-proxifyre");
next(&mut self.uninstall_proxifyre)
}
fn uninstall_singbox(&mut self) -> Result<bool, NsisRuntimeError> {
self.call("uninstall-singbox");
next(&mut self.uninstall_singbox)
}
fn reboot_required_under_lock(&mut self) -> Result<bool, NsisRuntimeError> {
self.call("reboot-under-lock");
next(&mut self.reboot_under_lock)
}
fn mark_reboot_required(&mut self) -> Result<bool, NsisRuntimeError> {
self.call("mark-reboot");
next(&mut self.mark_reboot)
}
fn clear_reboot_required(&mut self) -> Result<(), NsisRuntimeError> {
self.call("clear-reboot");
next(&mut self.clear_reboot)
}
fn cleanup_transients(&mut self) -> Result<(), NsisRuntimeError> {
self.call("cleanup");
next(&mut self.cleanup)
}
}
#[test]
fn parser_accepts_only_exact_single_nsis_flags() {
assert_eq!(
parse_nsis_early_arguments([OsString::from(NSIS_VERIFY_UPGRADE_ARGUMENT)])
.expect("verify flag"),
Some(NsisEarlyMode::VerifyUpgrade)
);
assert_eq!(
parse_nsis_early_arguments([OsString::from(NSIS_UNINSTALL_MANAGED_ARGUMENT)])
.expect("uninstall flag"),
Some(NsisEarlyMode::UninstallManaged)
);
assert_eq!(
parse_nsis_early_arguments(Vec::<OsString>::new()).expect("ordinary launch"),
None
);
assert_eq!(
parse_nsis_early_arguments([OsString::from("--elevated-helper")])
.expect("other early mode"),
None
);
for invalid in [
vec![OsString::from(format!("{}{}", "--nsis-", "unknown"))],
vec![
OsString::from(NSIS_VERIFY_UPGRADE_ARGUMENT),
OsString::from("extra"),
],
vec![
OsString::from(NSIS_VERIFY_UPGRADE_ARGUMENT),
OsString::from(NSIS_UNINSTALL_MANAGED_ARGUMENT),
],
vec![
OsString::from("ordinary"),
OsString::from(NSIS_UNINSTALL_MANAGED_ARGUMENT),
],
] {
assert_eq!(
parse_nsis_early_arguments(invalid),
Err(NsisRuntimeError::InvalidArguments)
);
}
}
#[test]
fn package_staging_recovery_accepts_only_fixed_component_uuid_and_entry_shapes() {
let uuid = "6f21e8c7-b63f-4c4c-9aa7-df96a7d0049d";
assert_eq!(
parse_package_staging_directory_name(&format!(".package-proxifyre-{uuid}")),
Ok(PackageStagingComponent::Proxifyre)
);
assert_eq!(
parse_package_staging_directory_name(&format!(".package-windows-packet-filter-{uuid}")),
Ok(PackageStagingComponent::WindowsPacketFilter)
);
assert_eq!(
parse_package_staging_directory_name(&format!(".package-sing-box-{uuid}")),
Ok(PackageStagingComponent::SingBox)
);
for invalid in [
".package-proxifyre-not-a-uuid",
".package-vc-runtime-6f21e8c7-b63f-4c4c-9aa7-df96a7d0049d",
".package-proxifyre-6F21E8C7-B63F-4C4C-9AA7-DF96A7D0049D",
] {
assert_eq!(
parse_package_staging_directory_name(invalid),
Err(NsisRuntimeError::TransientUnsafe)
);
}
assert!(package_staging_entry_role(
PackageStagingComponent::Proxifyre,
"ProxiFyre-v2.5.1-x64-signed.zip"
)
.is_some());
assert!(package_staging_entry_role(
PackageStagingComponent::WindowsPacketFilter,
"Windows.Packet.Filter.3.7.0.1.x64.msi"
)
.is_some());
assert!(package_staging_entry_role(
PackageStagingComponent::SingBox,
"sing-box-1.14.0-windows-amd64.zip"
)
.is_some());
assert!(package_staging_entry_role(PackageStagingComponent::SingBox, "foreign.zip").is_none());
}
#[test]
fn elevation_failure_returns_before_runtime_or_filesystem_checks() {
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
host.elevated = false;
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Err(NsisRuntimeError::NotElevated)
);
assert_eq!(host.calls(), ["elevated"]);
}
#[test]
fn upgrade_is_strictly_read_only() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedRunning,
NsisComponentState::ManagedStopped,
);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::VerifyUpgrade),
Ok(NsisRunOutcome::Success)
);
assert_eq!(
host.calls(),
[
"elevated",
"verify-exe",
"lifecycle-idle",
"cutover",
"proxifyre",
"singbox",
"transients",
]
);
}
#[test]
fn upgrade_blocks_terminal_cutover_without_retiring_it() {
let expected = CutoverTerminalRetirementExpectation::EmptyInfrastructure;
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
host.cutover = VecDeque::from([Ok(NsisCutoverState::Retirable(expected))]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::VerifyUpgrade),
Err(NsisRuntimeError::CutoverBlocked)
);
assert!(!host.calls().contains(&"retire-cutover"));
assert!(!host.calls().contains(&"acquire"));
}
#[test]
fn upgrade_blocks_pending_tombstone_without_retrying_it() {
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
host.transients = VecDeque::from([Ok(NsisTransientState {
singbox_cleanup_pending: true,
package_staging_pending: false,
})]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::VerifyUpgrade),
Err(NsisRuntimeError::TransientUnsafe)
);
assert!(!host.calls().contains(&"retry-singbox-cleanup"));
assert!(!host.calls().contains(&"acquire"));
}
#[test]
fn upgrade_blocks_interrupted_job_store_retirement_without_mutating_it() {
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
host.lifecycle_state = VecDeque::from([Ok(NsisLifecycleState {
retirement_pending: true,
reboot_required: false,
})]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::VerifyUpgrade),
Err(NsisRuntimeError::TransientUnsafe)
);
assert!(!host.calls().contains(&"acquire"));
assert!(!host.calls().contains(&"cleanup"));
}
#[test]
fn full_uninstall_resumes_interrupted_job_store_retirement() {
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
host.lifecycle_state = VecDeque::from([Ok(NsisLifecycleState {
retirement_pending: true,
reboot_required: false,
})]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::Success)
);
assert!(host.calls().contains(&"acquire"));
assert!(host.calls().contains(&"cleanup"));
}
#[test]
fn update_blocks_stale_package_staging_but_uninstall_retires_it() {
let pending = NsisTransientState {
singbox_cleanup_pending: false,
package_staging_pending: true,
};
let mut update = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
update.transients = VecDeque::from([Ok(pending)]);
assert_eq!(
run_nsis_mode(&mut update, NsisEarlyMode::VerifyUpgrade),
Err(NsisRuntimeError::TransientUnsafe)
);
assert!(!update.calls().contains(&"cleanup"));
let mut uninstall = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
uninstall.transients = VecDeque::from([Ok(pending), Ok(pending)]);
assert_eq!(
run_nsis_mode(&mut uninstall, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::Success)
);
assert!(uninstall.calls().contains(&"cleanup"));
}
#[test]
fn unsafe_first_component_still_preflights_second_and_causes_zero_mutation() {
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
host.proxifyre = VecDeque::from([Err(NsisRuntimeError::ComponentUnsafe)]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Err(NsisRuntimeError::ComponentUnsafe)
);
assert!(host.calls().contains(&"singbox"));
assert!(host.calls().contains(&"transients"));
assert!(!host.calls().contains(&"acquire"));
assert!(!host.calls().contains(&"stop-proxifyre"));
assert!(!host.calls().contains(&"uninstall-singbox"));
}
#[test]
fn busy_lifecycle_still_runs_full_read_only_preflight_and_never_mutates() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedRunning,
NsisComponentState::ManagedStopped,
);
host.lifecycle_state = VecDeque::from([Err(NsisRuntimeError::LifecycleBusy)]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Err(NsisRuntimeError::LifecycleBusy)
);
assert!(host.calls().contains(&"proxifyre"));
assert!(host.calls().contains(&"singbox"));
assert!(host.calls().contains(&"transients"));
assert!(!host.calls().contains(&"acquire"));
assert!(!host.calls().contains(&"stop-proxifyre"));
}
#[test]
fn uninstall_stops_both_running_services_before_uninstalling_either() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedRunning,
NsisComponentState::ManagedRunning,
);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::Success)
);
let calls = host.calls();
let stop_prox = calls
.iter()
.position(|call| *call == "stop-proxifyre")
.unwrap();
let stop_sing = calls
.iter()
.position(|call| *call == "stop-singbox")
.unwrap();
let uninstall_prox = calls
.iter()
.position(|call| *call == "uninstall-proxifyre")
.unwrap();
let uninstall_sing = calls
.iter()
.position(|call| *call == "uninstall-singbox")
.unwrap();
let cleanup = calls.iter().position(|call| *call == "cleanup").unwrap();
assert!(stop_prox < uninstall_prox);
assert!(stop_sing < uninstall_prox);
assert!(uninstall_prox < uninstall_sing);
assert!(uninstall_sing < cleanup);
}
#[test]
fn missing_components_are_noops_but_owned_transients_are_retired() {
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::Success)
);
let calls = host.calls();
assert!(!calls.contains(&"stop-proxifyre"));
assert!(!calls.contains(&"stop-singbox"));
assert!(!calls.contains(&"uninstall-proxifyre"));
assert!(!calls.contains(&"uninstall-singbox"));
assert!(calls.contains(&"cleanup"));
}
#[test]
fn state_drift_after_lock_causes_zero_component_mutation() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedRunning,
NsisComponentState::Missing,
);
host.proxifyre = VecDeque::from([
Ok(NsisComponentState::ManagedRunning),
Ok(NsisComponentState::ManagedStopped),
]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Err(NsisRuntimeError::StateChanged)
);
assert!(host.calls().contains(&"acquire"));
assert!(!host.calls().contains(&"stop-proxifyre"));
assert!(!host.calls().contains(&"uninstall-proxifyre"));
}
#[test]
fn terminal_cutover_is_exactly_retired_before_component_mutation() {
let expected = CutoverTerminalRetirementExpectation::EmptyInfrastructure;
let mut host = FakeHost::ready(
NsisComponentState::ManagedRunning,
NsisComponentState::Missing,
);
host.cutover = VecDeque::from([
Ok(NsisCutoverState::Retirable(expected.clone())),
Ok(NsisCutoverState::Retirable(expected)),
Ok(NsisCutoverState::Absent),
]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::Success)
);
let calls = host.calls();
let retire = calls
.iter()
.position(|call| *call == "retire-cutover")
.unwrap();
let stop = calls
.iter()
.position(|call| *call == "stop-proxifyre")
.unwrap();
assert!(retire < stop);
}
#[test]
fn stop_failure_prevents_all_uninstall_and_terminal_cleanup() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedRunning,
NsisComponentState::ManagedRunning,
);
host.stop_proxifyre = VecDeque::from([Err(NsisRuntimeError::OperationFailed)]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Err(NsisRuntimeError::OperationFailed)
);
let calls = host.calls();
assert!(!calls.contains(&"stop-singbox"));
assert!(!calls.contains(&"uninstall-proxifyre"));
assert!(!calls.contains(&"cleanup"));
}
#[test]
fn uninstall_reboot_requirement_maps_to_msi_3010() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedStopped,
NsisComponentState::ManagedStopped,
);
host.uninstall_proxifyre = VecDeque::from([Ok(true)]);
assert_eq!(
nsis_process_exit_code(run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged)),
NSIS_EXIT_REBOOT_REQUIRED
);
assert_eq!(
nsis_process_exit_code(Err(NsisRuntimeError::InvalidArguments)),
NSIS_EXIT_USAGE
);
let calls = host.calls();
assert!(
calls.iter().position(|call| *call == "mark-reboot")
< calls.iter().position(|call| *call == "uninstall-proxifyre")
);
assert!(!calls.contains(&"clear-reboot"));
}
#[test]
fn reboot_intent_is_write_ahead_and_cleared_only_after_proven_no_reboot() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedStopped,
NsisComponentState::Missing,
);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::Success)
);
let calls = host.calls();
let mark = calls
.iter()
.position(|call| *call == "mark-reboot")
.unwrap();
let uninstall = calls
.iter()
.position(|call| *call == "uninstall-proxifyre")
.unwrap();
let clear = calls
.iter()
.position(|call| *call == "clear-reboot")
.unwrap();
assert!(mark < uninstall);
assert!(uninstall < clear);
}
#[test]
fn failed_uninstall_keeps_write_ahead_reboot_intent_for_retry() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedStopped,
NsisComponentState::Missing,
);
host.uninstall_proxifyre = VecDeque::from([Err(NsisRuntimeError::OperationFailed)]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Err(NsisRuntimeError::OperationFailed)
);
let calls = host.calls();
assert!(
calls.iter().position(|call| *call == "mark-reboot")
< calls.iter().position(|call| *call == "uninstall-proxifyre")
);
assert!(!calls.contains(&"clear-reboot"));
assert!(!calls.contains(&"cleanup"));
}
#[test]
fn intent_published_while_waiting_for_lock_is_never_adopted_or_cleared() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedStopped,
NsisComponentState::Missing,
);
// The read-only pre-lock probe saw no marker, but authoritative observation
// under the acquired lock sees the earlier owner's durable fact.
host.reboot_under_lock = VecDeque::from([Ok(true)]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::RebootRequired)
);
let calls = host.calls();
assert!(calls.contains(&"uninstall-proxifyre"));
assert!(!calls.contains(&"mark-reboot"));
assert!(!calls.contains(&"clear-reboot"));
}
#[test]
fn marker_published_after_probe_is_delivered_even_when_components_are_missing() {
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
host.reboot_under_lock = VecDeque::from([Ok(true)]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::RebootRequired)
);
let calls = host.calls();
assert!(calls.contains(&"reboot-under-lock"));
assert!(!calls.contains(&"uninstall-proxifyre"));
assert!(!calls.contains(&"uninstall-singbox"));
assert!(!calls.contains(&"clear-reboot"));
}
#[test]
fn reboot_requirement_survives_a_later_failure_and_retry() {
let mut first = FakeHost::ready(
NsisComponentState::ManagedStopped,
NsisComponentState::ManagedStopped,
);
first.uninstall_proxifyre = VecDeque::from([Ok(true)]);
first.uninstall_singbox = VecDeque::from([Err(NsisRuntimeError::OperationFailed)]);
assert_eq!(
run_nsis_mode(&mut first, NsisEarlyMode::UninstallManaged),
Err(NsisRuntimeError::OperationFailed)
);
let calls = first.calls();
assert!(
calls.iter().position(|call| *call == "mark-reboot")
< calls.iter().position(|call| *call == "uninstall-singbox")
);
assert!(!calls.contains(&"cleanup"));
let mut retry = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
retry.lifecycle_state = VecDeque::from([Ok(NsisLifecycleState {
retirement_pending: false,
reboot_required: true,
})]);
retry.reboot_under_lock = VecDeque::from([Ok(true)]);
assert_eq!(
run_nsis_mode(&mut retry, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::RebootRequired)
);
assert!(!retry.calls().contains(&"mark-reboot"));
assert!(retry.calls().contains(&"cleanup"));
}
#[test]
fn transient_shape_failure_is_observed_before_lock_and_component_mutation() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedRunning,
NsisComponentState::ManagedStopped,
);
host.transients = VecDeque::from([Err(NsisRuntimeError::TransientUnsafe)]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Err(NsisRuntimeError::TransientUnsafe)
);
assert!(!host.calls().contains(&"acquire"));
assert!(!host.calls().contains(&"stop-proxifyre"));
}
#[test]
fn pending_singbox_tombstone_is_retried_before_services_are_stopped() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedRunning,
NsisComponentState::Missing,
);
host.transients = VecDeque::from([
Ok(NsisTransientState {
singbox_cleanup_pending: true,
package_staging_pending: false,
}),
Ok(NsisTransientState {
singbox_cleanup_pending: true,
package_staging_pending: false,
}),
Ok(NsisTransientState {
singbox_cleanup_pending: false,
package_staging_pending: false,
}),
]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::Success)
);
let calls = host.calls();
let retry = calls
.iter()
.position(|call| *call == "retry-singbox-cleanup")
.unwrap();
let stop = calls
.iter()
.position(|call| *call == "stop-proxifyre")
.unwrap();
assert!(retry < stop);
}
#[test]
fn hook_separates_update_from_full_uninstall_without_powershell() {
let hook = include_str!("../bundled/installer-hooks/proxywarden-hooks.nsh");
assert!(hook.contains("$UpdateMode"));
assert!(hook.contains(NSIS_VERIFY_UPGRADE_ARGUMENT));
assert!(hook.contains(NSIS_UNINSTALL_MANAGED_ARGUMENT));
assert!(hook.contains("CheckIfAppIsRunning"));
assert!(hook.contains("3010"));
assert!(hook.contains("SetRebootFlag true"));
assert_eq!(hook.matches("ClearErrors").count(), 3);
let launch_error_gate = hook.find("IfErrors").expect("launch-error gate");
let last_exec = hook.rfind("ExecWait").expect("native helper launch");
assert!(last_exec < launch_error_gate);
for branch in hook.split("ExecWait").take(2) {
assert!(branch.rfind("ClearErrors").is_some());
}
assert!(!hook.to_ascii_lowercase().contains("powershell"));
let guard = hook.find("CheckIfAppIsRunning").expect("app guard");
let destructive = hook
.find(NSIS_UNINSTALL_MANAGED_ARGUMENT)
.expect("destructive mode");
assert!(guard < destructive);
let reboot_observed = hook.find("SetRebootFlag true").expect("reboot flag");
let marker_ack = hook
.find("Delete \"$INSTDIR\\.proxywarden-nsis-reboot-required.json\"")
.expect("exact reboot marker acknowledgement");
let marker_error = hook[marker_ack..]
.find("IfErrors")
.map(|offset| marker_ack + offset)
.expect("marker delete error gate");
assert!(reboot_observed < marker_ack);
assert!(marker_ack < marker_error);
assert!(hook[marker_error..].contains("Abort"));
}
#[cfg(all(windows, debug_assertions))]
mod windows_store {
use super::*;
use std::fs;
struct TestRoot(PathBuf);
impl TestRoot {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"proxywarden-nsis-store-{}",
uuid::Uuid::new_v4().hyphenated()
));
fs::create_dir(&path).expect("create temp app root");
Self(path)
}
}
impl Drop for TestRoot {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn read_only_idle_probe_does_not_create_store() {
let root = TestRoot::new();
assert_eq!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0).expect("idle missing store"),
NsisPrivilegedLifecycleState {
retirement_pending: false,
reboot_required: false,
}
);
assert!(!root.0.join(".proxywarden-privileged-jobs").exists());
}
#[test]
fn exact_terminal_pairs_and_lock_are_retired_nonrecursively() {
let root = TestRoot::new();
write_nsis_terminal_pair_for_tests(&root.0, true).expect("terminal pair");
assert_eq!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0).expect("terminal idle store"),
NsisPrivilegedLifecycleState {
retirement_pending: false,
reboot_required: false,
}
);
let held = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0)
.expect("exclusive lifecycle guard");
assert!(matches!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0),
Err(PrivilegedJobsError::LifecycleBusy)
));
drop(held);
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
.expect("persisted idle lock is read-only verifiable");
let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0)
.expect("reacquire lifecycle guard");
match guard.retire_terminal_store() {
Ok(()) => {}
Err(PrivilegedJobsError::Io(error))
if error.kind() == std::io::ErrorKind::PermissionDenied =>
{
// Stable identity leases capture SACL bytes. A normal
// developer token cannot enable SeSecurityPrivilege; the
// elevated NSIS path and elevated Windows gate exercise the
// actual same-handle deletion.
return;
}
Err(error) => panic!("exact retirement: {error}"),
}
assert!(!root.0.join(".proxywarden-privileged-jobs").exists());
}
#[test]
fn interrupted_terminal_retirement_is_detected_and_resumed() {
let root = TestRoot::new();
write_nsis_terminal_pair_for_tests(&root.0, true).expect("independent terminal pair");
write_nsis_interrupted_retirement_for_tests(&root.0)
.expect("interrupted retirement fixture");
assert_eq!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
.expect("durable retirement marker"),
NsisPrivilegedLifecycleState {
retirement_pending: true,
reboot_required: false,
}
);
let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0)
.expect("resume lifecycle guard");
match guard.retire_terminal_store() {
Ok(()) => {
assert!(!root.0.join(".proxywarden-privileged-jobs").exists());
}
Err(PrivilegedJobsError::Io(error))
if error.kind() == std::io::ErrorKind::PermissionDenied => {}
Err(error) => panic!("resumed retirement: {error}"),
}
}
#[test]
fn reboot_marker_survives_store_retirement_until_nsis_observes_3010() {
let root = TestRoot::new();
let guard =
NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0).expect("lifecycle guard");
assert!(guard.mark_reboot_required().expect("durable reboot marker"));
drop(guard);
assert!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
.expect("reboot state")
.reboot_required
);
fs::remove_dir_all(root.0.join(".proxywarden-privileged-jobs"))
.expect("simulate completed store cleanup before process exit");
assert_eq!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
.expect("reboot survives store loss"),
NsisPrivilegedLifecycleState {
retirement_pending: false,
reboot_required: true,
}
);
let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0)
.expect("recreate exact lifecycle store");
match guard.retire_terminal_store() {
Ok(()) => assert_eq!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
.expect("reboot marker retained for outward 3010"),
NsisPrivilegedLifecycleState {
retirement_pending: false,
reboot_required: true,
}
),
Err(PrivilegedJobsError::Io(error))
if error.kind() == std::io::ErrorKind::PermissionDenied => {}
Err(error) => panic!("reboot marker retirement: {error}"),
}
}
#[test]
fn proven_no_reboot_clears_only_the_exact_write_ahead_marker() {
let root = TestRoot::new();
let guard =
NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0).expect("lifecycle guard");
assert!(guard.mark_reboot_required().expect("write-ahead intent"));
guard
.clear_reboot_required()
.expect("exact no-reboot acknowledgement");
drop(guard);
assert_eq!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
.expect("marker cleared after proven no-reboot"),
NsisPrivilegedLifecycleState {
retirement_pending: false,
reboot_required: false,
}
);
}
#[test]
fn partial_retirement_staging_is_durable_and_resumed_pair_at_a_time() {
let root = TestRoot::new();
write_nsis_partial_retirement_staging_for_tests(&root.0)
.expect("partial retirement staging");
assert_eq!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
.expect("staging is a durable retirement intent"),
NsisPrivilegedLifecycleState {
retirement_pending: true,
reboot_required: false,
}
);
let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0)
.expect("resume staged retirement");
match guard.retire_terminal_store() {
Ok(()) => assert!(!root.0.join(".proxywarden-privileged-jobs").exists()),
Err(PrivilegedJobsError::Io(error))
if error.kind() == std::io::ErrorKind::PermissionDenied => {}
Err(error) => panic!("staged retirement recovery: {error}"),
}
}
#[test]
fn partial_reboot_staging_is_published_and_never_acknowledged_by_helper() {
let root = TestRoot::new();
write_nsis_partial_reboot_staging_for_tests(&root.0).expect("partial reboot staging");
assert!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
.expect("partial reboot intent")
.reboot_required
);
let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0)
.expect("recover reboot marker under lifecycle lock");
assert!(!root
.0
.join(".proxywarden-nsis-reboot-required.pending")
.exists());
assert!(root
.0
.join(".proxywarden-nsis-reboot-required.json")
.is_file());
match guard.retire_terminal_store() {
Ok(()) => assert!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
.expect("reboot fact remains after store cleanup")
.reboot_required
),
Err(PrivilegedJobsError::Io(error))
if error.kind() == std::io::ErrorKind::PermissionDenied => {}
Err(error) => panic!("reboot staging recovery: {error}"),
}
}
#[test]
fn running_or_unknown_records_block_without_deletion() {
let running = TestRoot::new();
write_nsis_terminal_pair_for_tests(&running.0, false).expect("running pair");
assert!(matches!(
verify_nsis_privileged_lifecycle_idle_for_tests(&running.0),
Err(PrivilegedJobsError::InvalidRecord)
));
assert!(running.0.join(".proxywarden-privileged-jobs").exists());
let unknown = TestRoot::new();
write_nsis_terminal_pair_for_tests(&unknown.0, true).expect("terminal pair");
let path = unknown
.0
.join(".proxywarden-privileged-jobs")
.join("foreign.bin");
fs::write(&path, b"foreign").expect("foreign entry");
safe_fs::protect_path_for_owner_admin_system(&path).expect("seal fixture");
assert!(matches!(
verify_nsis_privileged_lifecycle_idle_for_tests(&unknown.0),
Err(PrivilegedJobsError::InvalidRecord)
));
assert!(path.exists());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3941 -1
View File
File diff suppressed because it is too large Load Diff
+142
View File
@@ -0,0 +1,142 @@
//! 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 validate_proxifyre_marker_text(
marker_text: &str,
expected_install_dir: &Path,
) -> Result<ManagedProxiFyreOwnership, String> {
let marker = parse_marker(marker_text)?;
validate_marker_identity(&marker)?;
if !same_path(Path::new(&marker.install_root), expected_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,
})
}
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 = parse_marker(&marker_text).map_err(|error| {
format!(
"marker установки {} содержит некорректные данные: {error}",
marker_path.display()
)
})?;
validate_marker_identity(&marker)?;
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 parse_marker(marker_text: &str) -> Result<ProxiFyreInstallMarker, String> {
let marker_text = marker_text.strip_prefix('\u{feff}').unwrap_or(marker_text);
serde_json::from_str(marker_text)
.map_err(|error| format!("marker содержит некорректный JSON: {error}"))
}
fn validate_marker_identity(marker: &ProxiFyreInstallMarker) -> Result<(), String> {
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());
}
Ok(())
}
fn same_path(left: &Path, right: &Path) -> bool {
left.to_string_lossy()
.replace('/', "\\")
.trim_end_matches('\\')
.eq_ignore_ascii_case(
right
.to_string_lossy()
.replace('/', "\\")
.trim_end_matches('\\'),
)
}
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,921 @@
use super::*;
use crate::component_cutover::{
CutoverOperation, EffectDisposition, LegacyServiceState, MutationDirection, MutationEffect,
MutationRecord, StateFingerprint,
};
use crate::process::{
FullServiceSnapshot, ServiceBaseConfigSnapshot, ServiceSecuritySnapshot, ServiceStableState,
SERVICE_CONFIG2_KINDS,
};
#[derive(Debug, Clone, PartialEq, Eq)]
enum Call {
CaptureLegacy,
QueryLegacy,
QueryCurrent,
QueryComplete,
QueryLegacyPolicy(ServiceConfig2Kind),
QueryCurrentPolicy(ServiceConfig2Kind),
QueryCurrentSecurity,
StopLegacy,
DeleteLegacy,
CreateCurrent,
SetCurrentPolicy(ServiceConfig2Kind),
SetCurrentSecurity,
StartCurrent,
StopCurrent,
DeleteCurrent,
CreateLegacy,
RestoreLegacyPolicy(ServiceConfig2Kind),
RestoreLegacySecurity,
StartLegacy,
}
struct FakeScm {
calls: Vec<Call>,
fail_on: Option<Call>,
before: ServiceRestoreSnapshot,
complete: CompleteServiceObservation,
current_base: ServiceBaseConfigSnapshot,
}
impl FakeScm {
fn new() -> Self {
let current_base = expected_current_proxifyre_service_base(
&std::env::temp_dir().join("ProxyWarden-current-ProxiFyre.exe"),
)
.expect("current base fixture");
Self {
calls: Vec::new(),
fail_on: None,
before: before_state(),
complete: CompleteServiceObservation::Missing,
current_base,
}
}
fn record(&mut self, call: Call) -> Result<(), ProxifyreNativeHostError> {
self.calls.push(call.clone());
if self.fail_on.as_ref() == Some(&call) {
Err(ProxifyreNativeHostError)
} else {
Ok(())
}
}
fn missing_policy() -> ServicePolicySnapshot {
ServicePolicySnapshot {
service: crate::process::ServiceSnapshot {
exists: false,
state: None,
path_name: None,
process_id: None,
},
path_matches: false,
demand_start: false,
failure_recovery_disabled: false,
dacl_matches: false,
}
}
}
impl ProxifyreCutoverScm for FakeScm {
fn capture_legacy_service(
&mut self,
) -> Result<ServiceRestoreSnapshot, ProxifyreNativeHostError> {
self.record(Call::CaptureLegacy)?;
Ok(self.before.clone())
}
fn query_legacy_service(&mut self) -> Result<ServicePolicySnapshot, ProxifyreNativeHostError> {
self.record(Call::QueryLegacy)?;
Ok(Self::missing_policy())
}
fn query_current_service(&mut self) -> Result<ServicePolicySnapshot, ProxifyreNativeHostError> {
self.record(Call::QueryCurrent)?;
Ok(Self::missing_policy())
}
fn query_complete_service(
&mut self,
) -> Result<CompleteServiceObservation, ProxifyreNativeHostError> {
self.record(Call::QueryComplete)?;
Ok(self.complete.clone())
}
fn expected_current_service_base(
&self,
) -> Result<ServiceBaseConfigSnapshot, ProxifyreNativeHostError> {
Ok(self.current_base.clone())
}
fn query_legacy_service_policy(
&mut self,
kind: ServiceConfig2Kind,
) -> Result<Option<ServiceConfig2Snapshot>, ProxifyreNativeHostError> {
self.record(Call::QueryLegacyPolicy(kind))?;
Ok(self.before.config2(kind).cloned())
}
fn query_current_service_policy(
&mut self,
kind: ServiceConfig2Kind,
) -> Result<Option<ServiceConfig2Snapshot>, ProxifyreNativeHostError> {
self.record(Call::QueryCurrentPolicy(kind))?;
Ok(None)
}
fn current_service_security_matches(&mut self) -> Result<bool, ProxifyreNativeHostError> {
self.record(Call::QueryCurrentSecurity)?;
Ok(false)
}
fn stop_legacy_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::StopLegacy)
}
fn delete_legacy_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::DeleteLegacy)
}
fn create_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::CreateCurrent)
}
fn set_current_service_policy(
&mut self,
kind: ServiceConfig2Kind,
) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::SetCurrentPolicy(kind))
}
fn set_current_service_security(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::SetCurrentSecurity)
}
fn start_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::StartCurrent)
}
fn stop_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::StopCurrent)
}
fn delete_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::DeleteCurrent)
}
fn create_legacy_service(
&mut self,
_before: &ServiceRestoreSnapshot,
) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::CreateLegacy)
}
fn restore_legacy_service_policy(
&mut self,
snapshot: &ServiceConfig2Snapshot,
) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::RestoreLegacyPolicy(snapshot.kind()))
}
fn restore_legacy_service_security(
&mut self,
_before: &ServiceRestoreSnapshot,
) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::RestoreLegacySecurity)
}
fn start_legacy_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::StartLegacy)
}
}
fn before_state() -> ServiceRestoreSnapshot {
FullServiceSnapshot {
service_name: PROXIFYRE_MANAGED_SERVICE_NAME.to_owned(),
base: ServiceBaseConfigSnapshot {
service_type: 0x10,
start_type: 2,
error_control: 1,
binary_path_name: concat!(
r#""C:\Tools\ProxiFyre\ProxiFyre.exe" "#,
r#"-displayname "ProxiFyre Service" -servicename "ProxiFyreService""#
)
.to_owned(),
load_order_group: None,
tag_id: 0,
dependencies: Vec::new(),
service_start_name: "LocalSystem".to_owned(),
display_name: "ProxiFyre Service".to_owned(),
},
config2: SERVICE_CONFIG2_KINDS
.iter()
.copied()
.map(expected_current_proxifyre_service_policy)
.collect(),
security: ServiceSecuritySnapshot {
self_relative_descriptor: vec![1, 2, 3],
untrusted_mutation_rights: false,
},
original_state: ServiceStableState::Running,
}
}
#[test]
fn create_current_is_exactly_one_call_and_never_starts() {
let mut host = FakeScm::new();
let before = host.before.clone();
assert!(mutate_proxifyre_cutover_scm(
&mut host,
&CutoverOperation::CreateCurrentService,
&before,
)
.expect("SCM mutation dispatch"));
assert_eq!(host.calls, vec![Call::CreateCurrent]);
}
#[test]
fn collision_or_failure_stops_after_the_single_selected_mutation() {
let mut host = FakeScm::new();
host.fail_on = Some(Call::DeleteLegacy);
let before = host.before.clone();
mutate_proxifyre_cutover_scm(&mut host, &CutoverOperation::DeleteLegacyService, &before)
.expect_err("collision/failure must surface");
assert_eq!(host.calls, vec![Call::DeleteLegacy]);
}
#[test]
fn restore_policy_selects_only_the_requested_captured_record() {
let mut host = FakeScm::new();
let before = host.before.clone();
assert!(mutate_proxifyre_cutover_scm(
&mut host,
&CutoverOperation::RestoreLegacyServicePolicy(ServiceConfig2Kind::Triggers),
&before,
)
.expect("restore dispatch"));
assert_eq!(
host.calls,
vec![Call::RestoreLegacyPolicy(ServiceConfig2Kind::Triggers)]
);
}
#[test]
fn read_only_missing_policy_is_typed_absence_and_never_mutates() {
let mut host = FakeScm::new();
assert_eq!(
host.query_current_service_policy(ServiceConfig2Kind::Description)
.expect("read-only query"),
None
);
assert_eq!(
host.calls,
vec![Call::QueryCurrentPolicy(ServiceConfig2Kind::Description)]
);
}
#[test]
fn non_scm_operation_is_not_claimed_or_mutated() {
let mut host = FakeScm::new();
let before = host.before.clone();
assert!(!mutate_proxifyre_cutover_scm(
&mut host,
&CutoverOperation::HardenLegacyRootSecurity,
&before,
)
.expect("non-SCM dispatch"));
assert!(host.calls.is_empty());
}
#[test]
fn expected_current_policy_covers_every_config2_kind() {
for kind in SERVICE_CONFIG2_KINDS {
assert_eq!(expected_current_proxifyre_service_policy(kind).kind(), kind);
}
assert!(matches!(
expected_current_proxifyre_service_policy(ServiceConfig2Kind::Triggers),
ServiceConfig2Snapshot::Triggers(ref triggers) if triggers.is_empty()
));
}
#[test]
fn scm_observer_matches_typed_expected_fingerprint_for_every_scm_operation() {
let before = before_state();
let operations = vec![
CutoverOperation::StopLegacyService,
CutoverOperation::DeleteLegacyService,
CutoverOperation::CreateCurrentService,
CutoverOperation::SetCurrentServicePolicy(ServiceConfig2Kind::Description),
CutoverOperation::SetCurrentServiceSecurity,
CutoverOperation::StartCurrentService,
CutoverOperation::StopCurrentService,
CutoverOperation::DeleteCurrentService,
CutoverOperation::CreateLegacyService,
CutoverOperation::RestoreLegacyServicePolicy(ServiceConfig2Kind::Triggers),
CutoverOperation::RestoreLegacyServiceSecurity,
CutoverOperation::StartLegacyService,
];
for operation in operations {
let mut host = FakeScm::new();
host.complete = satisfying_scm_observation(&operation, &before, &host.current_base);
assert_eq!(
observe_proxifyre_cutover_scm_state(&mut host, &operation, &before)
.expect("typed complete SCM observation"),
expected_proxifyre_cutover_scm_effect(&operation).expect("typed expected SCM effect"),
"operation {operation:?}"
);
assert_eq!(host.calls, vec![Call::QueryComplete]);
}
}
#[test]
fn scm_unexpected_fingerprint_preserves_complete_drift_instead_of_boolean_bucket() {
let before = before_state();
let operation = CutoverOperation::CreateCurrentService;
let mut first = FakeScm::new();
let mut first_snapshot = before.clone();
first_snapshot.base.display_name = "foreign-one".to_owned();
first.complete = complete_service(first_snapshot, false);
let first_fingerprint = observe_proxifyre_cutover_scm_state(&mut first, &operation, &before)
.expect("first exact unexpected state");
let mut repeated = FakeScm::new();
let mut repeated_snapshot = before.clone();
repeated_snapshot.base.display_name = "foreign-one".to_owned();
repeated.complete = complete_service(repeated_snapshot, false);
let repeated_fingerprint =
observe_proxifyre_cutover_scm_state(&mut repeated, &operation, &before)
.expect("repeated exact unexpected state");
let mut second = FakeScm::new();
let mut second_snapshot = before.clone();
second_snapshot.base.display_name = "foreign-two".to_owned();
second.complete = complete_service(second_snapshot, false);
let second_fingerprint = observe_proxifyre_cutover_scm_state(&mut second, &operation, &before)
.expect("second exact unexpected state");
assert_eq!(first_fingerprint, repeated_fingerprint);
assert_ne!(first_fingerprint, second_fingerprint);
assert_ne!(
first_fingerprint,
expected_proxifyre_cutover_scm_effect(&operation).expect("expected effect")
);
}
#[test]
fn scm_expected_effect_rejects_untrusted_mutation_rights() {
let before = before_state();
let operation = CutoverOperation::CreateCurrentService;
let mut host = FakeScm::new();
let mut live = satisfying_scm_observation(&operation, &before, &host.current_base);
let CompleteServiceObservation::Present { snapshot, .. } = &mut live else {
panic!("current service fixture must be present");
};
snapshot.security.untrusted_mutation_rights = true;
host.complete = live;
assert_ne!(
observe_proxifyre_cutover_scm_state(&mut host, &operation, &before)
.expect("exact unsafe SCM observation"),
expected_proxifyre_cutover_scm_effect(&operation).expect("expected effect")
);
}
#[test]
fn create_current_effect_requires_the_complete_fresh_service_default_profile() {
let before = before_state();
let operation = CutoverOperation::CreateCurrentService;
let mut exact = FakeScm::new();
exact.complete = satisfying_scm_observation(&operation, &before, &exact.current_base);
assert_eq!(
observe_proxifyre_cutover_scm_state(&mut exact, &operation, &before)
.expect("complete fresh-service defaults"),
expected_proxifyre_cutover_scm_effect(&operation).expect("expected create effect")
);
let mut drifted = FakeScm::new();
let mut live = satisfying_scm_observation(&operation, &before, &drifted.current_base);
let CompleteServiceObservation::Present { snapshot, .. } = &mut live else {
panic!("current service fixture must be present");
};
let description = snapshot
.config2
.iter_mut()
.find(|value| value.kind() == ServiceConfig2Kind::Description)
.expect("complete default profile");
*description = ServiceConfig2Snapshot::Description(Some("drift".to_owned()));
drifted.complete = live;
assert_ne!(
observe_proxifyre_cutover_scm_state(&mut drifted, &operation, &before)
.expect("drifted fresh-service defaults"),
expected_proxifyre_cutover_scm_effect(&operation).expect("expected create effect")
);
}
fn complete_service(
snapshot: ServiceRestoreSnapshot,
current_dacl_matches: bool,
) -> CompleteServiceObservation {
CompleteServiceObservation::Present {
snapshot: Box::new(snapshot),
current_dacl_matches,
}
}
fn satisfying_scm_observation(
operation: &CutoverOperation,
before: &ServiceRestoreSnapshot,
current_base: &ServiceBaseConfigSnapshot,
) -> CompleteServiceObservation {
if matches!(
operation,
CutoverOperation::DeleteLegacyService | CutoverOperation::DeleteCurrentService
) {
return CompleteServiceObservation::Missing;
}
let current = matches!(
operation,
CutoverOperation::CreateCurrentService
| CutoverOperation::SetCurrentServicePolicy(_)
| CutoverOperation::SetCurrentServiceSecurity
| CutoverOperation::StartCurrentService
| CutoverOperation::StopCurrentService
);
let mut snapshot = before.clone();
let mut current_dacl_matches = false;
if current {
snapshot.base = current_base.clone();
snapshot.config2 = SERVICE_CONFIG2_KINDS
.iter()
.copied()
.map(expected_current_proxifyre_service_policy)
.collect();
current_dacl_matches = matches!(
operation,
CutoverOperation::SetCurrentServiceSecurity
| CutoverOperation::StartCurrentService
| CutoverOperation::StopCurrentService
);
}
snapshot.original_state = if matches!(
operation,
CutoverOperation::StartCurrentService | CutoverOperation::StartLegacyService
) {
ServiceStableState::Running
} else {
ServiceStableState::Stopped
};
complete_service(snapshot, current_dacl_matches)
}
#[cfg(windows)]
#[test]
fn missing_primary_service_config2_probe_is_live_and_read_only() {
let service = crate::process::query_known_service(KnownWindowsService::Proxifyre)
.expect("read-only SCM probe");
if service.exists {
eprintln!("skipping missing-service assertion because ProxiFyreService exists");
return;
}
let executable = std::env::current_exe().expect("current test executable");
assert_eq!(
query_service_config2_exact(
PROXIFYRE_MANAGED_SERVICE_NAME,
&executable,
ServiceConfig2Kind::Description,
)
.expect("missing service query"),
None
);
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CandidateCall {
CreateRoot,
WritePackage(PathBuf),
WriteConfig,
WriteMarker,
WriteReceipt,
}
struct FakeCandidateWriter {
calls: Vec<CandidateCall>,
fail_on: Option<CandidateCall>,
fail_after_effect: Option<CandidateCall>,
observation: ProxifyreCutoverCandidateObservation,
}
impl Default for FakeCandidateWriter {
fn default() -> Self {
Self {
calls: Vec::new(),
fail_on: None,
fail_after_effect: None,
observation: ProxifyreCutoverCandidateObservation::Absent,
}
}
}
impl FakeCandidateWriter {
fn record(&mut self, call: CandidateCall) -> Result<(), ProxifyreNativeHostError> {
self.calls.push(call.clone());
if self.fail_on.as_ref() == Some(&call) {
Err(ProxifyreNativeHostError)
} else if self.fail_after_effect.as_ref() == Some(&call) {
self.observation = expected_candidate_observation();
Err(ProxifyreNativeHostError)
} else {
Ok(())
}
}
}
impl ProxifyreCutoverCandidateWriter for FakeCandidateWriter {
fn observe_candidate(
&mut self,
operation: &CutoverOperation,
) -> Result<ProxifyreCutoverCandidateObservation, ProxifyreNativeHostError> {
if !matches!(
operation,
CutoverOperation::CreateCurrentCandidateRoot
| CutoverOperation::WriteCurrentCandidatePackageEntry(_)
| CutoverOperation::WriteCurrentCandidateConfig
| CutoverOperation::WriteCurrentCandidateMarker
| CutoverOperation::WriteCurrentCandidateReceipt
) {
return Err(ProxifyreNativeHostError);
}
Ok(self.observation.clone())
}
fn create_candidate_root(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(CandidateCall::CreateRoot)
}
fn write_candidate_package_entry(
&mut self,
relative_path: &Path,
) -> Result<(), ProxifyreNativeHostError> {
self.record(CandidateCall::WritePackage(relative_path.to_path_buf()))
}
fn write_candidate_config(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(CandidateCall::WriteConfig)
}
fn write_candidate_marker(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(CandidateCall::WriteMarker)
}
fn write_candidate_receipt(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(CandidateCall::WriteReceipt)
}
}
fn expected_candidate_observation() -> ProxifyreCutoverCandidateObservation {
let snapshot: SealedPathSnapshot = serde_json::from_value(serde_json::json!({
"identity": {
"volumeSerialNumber": 7,
"fileId": 11,
"kind": "regular_file",
"size": 3
},
"security": {
"selfRelative": [1, 2, 3],
"sacl": "present"
}
}))
.expect("sealed candidate fixture");
ProxifyreCutoverCandidateObservation::Expected(snapshot)
}
#[test]
fn candidate_dispatch_selects_exactly_one_create_new_mutation() {
let mut writer = FakeCandidateWriter::default();
let relative_path = PathBuf::from("ProxiFyre.exe");
assert!(mutate_proxifyre_cutover_candidate(
&mut writer,
&CutoverOperation::WriteCurrentCandidatePackageEntry(relative_path.clone()),
)
.expect("candidate mutation dispatch"));
assert_eq!(
writer.calls,
vec![CandidateCall::WritePackage(relative_path)]
);
}
#[test]
fn candidate_collision_or_write_failure_is_not_hidden() {
let mut writer = FakeCandidateWriter {
fail_on: Some(CandidateCall::WriteReceipt),
..FakeCandidateWriter::default()
};
mutate_proxifyre_cutover_candidate(
&mut writer,
&CutoverOperation::WriteCurrentCandidateReceipt,
)
.expect_err("collision/failure must surface");
assert_eq!(writer.calls, vec![CandidateCall::WriteReceipt]);
}
#[test]
fn candidate_failed_create_or_write_distinguishes_no_effect_from_reacquired_exact_effect() {
for (operation, call) in [
(
CutoverOperation::CreateCurrentCandidateRoot,
CandidateCall::CreateRoot,
),
(
CutoverOperation::WriteCurrentCandidateReceipt,
CandidateCall::WriteReceipt,
),
] {
let mut before_effect = FakeCandidateWriter {
fail_on: Some(call.clone()),
..FakeCandidateWriter::default()
};
mutate_proxifyre_cutover_candidate(&mut before_effect, &operation)
.expect_err("failure before external effect");
assert_eq!(
before_effect
.observe_candidate(&operation)
.expect("observe absent target"),
ProxifyreCutoverCandidateObservation::Absent
);
let mut after_effect = FakeCandidateWriter {
fail_after_effect: Some(call),
..FakeCandidateWriter::default()
};
mutate_proxifyre_cutover_candidate(&mut after_effect, &operation)
.expect_err("failure after external effect");
let observed = after_effect
.observe_candidate(&operation)
.expect("reacquire exact target");
assert!(matches!(
observed,
ProxifyreCutoverCandidateObservation::Expected(SealedPathSnapshot {
identity: safe_fs::StableObjectIdentity {
volume_serial_number: 7,
file_id: 11,
..
},
..
})
));
}
}
#[test]
fn candidate_observer_keeps_unknown_distinct_from_absent_and_expected() {
let operation = CutoverOperation::CreateCurrentCandidateRoot;
let mut writer = FakeCandidateWriter {
observation: ProxifyreCutoverCandidateObservation::Unknown,
..FakeCandidateWriter::default()
};
assert_eq!(
writer
.observe_candidate(&operation)
.expect("typed unknown observation"),
ProxifyreCutoverCandidateObservation::Unknown
);
assert_ne!(
ProxifyreCutoverCandidateObservation::Unknown,
ProxifyreCutoverCandidateObservation::Absent
);
assert_ne!(
ProxifyreCutoverCandidateObservation::Unknown,
expected_candidate_observation()
);
}
#[test]
fn candidate_dispatch_does_not_claim_scm_or_legacy_filesystem_operations() {
let mut writer = FakeCandidateWriter::default();
assert!(!mutate_proxifyre_cutover_candidate(
&mut writer,
&CutoverOperation::CreateCurrentService,
)
.expect("non-candidate operation"));
assert!(writer.calls.is_empty());
}
#[test]
fn partial_candidate_handoff_accepts_only_unique_durable_forward_identity() {
let operation = CutoverOperation::CreateCurrentCandidateRoot;
let identity = safe_fs::StableObjectIdentity {
volume_serial_number: 7,
file_id: 11,
kind: safe_fs::StableObjectKind::Directory,
size: 0,
};
let fingerprint = StateFingerprint::digest("candidate-handoff-test", b"state");
let durable = MutationRecord {
sequence: 0,
direction: MutationDirection::Forward,
operation: operation.clone(),
before_state: fingerprint.clone(),
expected_effect: fingerprint.clone(),
intent_written_at_epoch_seconds: 1,
authority_evidence: None,
effect: Some(MutationEffect {
disposition: EffectDisposition::ExpectedEffect,
observed: fingerprint,
object_identity: Some(identity.clone()),
observed_at_epoch_seconds: 2,
}),
};
assert_eq!(
unique_forward_expected_effect_identity(std::slice::from_ref(&durable), &operation)
.expect("unique durable identity"),
Some(&identity)
);
let mut pending = durable.clone();
pending.effect = None;
assert_eq!(
unique_forward_expected_effect_identity(&[pending], &operation)
.expect("pending intent is not durable effect"),
None
);
let mut missing_identity = durable.clone();
missing_identity.effect.as_mut().unwrap().object_identity = None;
assert!(unique_forward_expected_effect_identity(&[missing_identity], &operation).is_err());
assert!(
unique_forward_expected_effect_identity(&[durable.clone(), durable], &operation).is_err()
);
}
#[test]
fn prepared_candidate_freezes_complete_sorted_final_metadata() {
let (plan, runtime, config, config_sha256) = candidate_inputs();
let prepared = prepare_proxifyre_cutover_candidate(
&plan,
runtime,
&config,
&config_sha256,
false,
1_700_000_000,
)
.expect("prepare complete cutover candidate");
assert_eq!(
prepared.snapshot().files.len(),
CURRENT_PROXIFYRE_PACKAGE_FILES.len() + 3
);
assert!(valid_sha256(&prepared.snapshot().manifest_fingerprint));
assert!(prepared.snapshot().files.windows(2).all(|pair| {
candidate_relative_label(&pair[0].relative_path)
< candidate_relative_label(&pair[1].relative_path)
}));
let config_spec = prepared
.file_spec(Path::new("app-config.json"))
.expect("config spec");
assert_eq!(config_spec.role, CurrentCandidateFileRole::Config);
assert_eq!(config_spec.sha256, config_sha256);
let marker: SystemProxifyreMarker = serde_json::from_slice(
prepared
.file_bytes(Path::new(PROXIFYRE_MARKER_FILE))
.expect("marker bytes"),
)
.expect("marker JSON");
assert!(marker.packet_filter_installed_by_proxy_warden);
let receipt: InstallReceipt = serde_json::from_slice(
prepared
.file_bytes(Path::new(INSTALL_RECEIPT_FILENAME))
.expect("receipt bytes"),
)
.expect("receipt JSON");
assert_eq!(receipt.installed_at, 1_700_000_000);
assert!(receipt
.windows_packet_filter
.as_ref()
.is_some_and(|ownership| ownership.installed_by_proxy_warden));
let (_, repeated_runtime, _, _) = candidate_inputs_with_plan(&plan);
let repeated = prepare_proxifyre_cutover_candidate(
&plan,
repeated_runtime,
&config,
&config_sha256,
false,
1_700_000_000,
)
.expect("repeat identical candidate");
assert_eq!(
prepared.snapshot().manifest_fingerprint,
repeated.snapshot().manifest_fingerprint
);
}
#[test]
fn captured_timestamp_and_preexisting_packet_filter_change_final_manifest() {
let (plan, runtime, config, config_sha256) = candidate_inputs();
let first = prepare_proxifyre_cutover_candidate(
&plan,
runtime,
&config,
&config_sha256,
false,
1_700_000_000,
)
.expect("first candidate");
let (_, runtime, _, _) = candidate_inputs_with_plan(&plan);
let second = prepare_proxifyre_cutover_candidate(
&plan,
runtime,
&config,
&config_sha256,
true,
1_700_000_001,
)
.expect("second candidate");
assert_ne!(
first.snapshot().manifest_fingerprint,
second.snapshot().manifest_fingerprint
);
let receipt: InstallReceipt = serde_json::from_slice(
second
.file_bytes(Path::new(INSTALL_RECEIPT_FILENAME))
.expect("receipt bytes"),
)
.expect("receipt JSON");
assert!(receipt.windows_packet_filter.is_none());
}
fn candidate_inputs() -> (
ProxifyreCutoverPlan,
PreparedProxifyreRuntime,
Vec<u8>,
String,
) {
let app_root = std::env::temp_dir().join("proxywarden-cutover-contract");
let config = br#"{"proxies":[],"applications":[]}"#.to_vec();
let config_sha256 = format!("{:x}", Sha256::digest(&config));
let package_sha256 = "a".repeat(64);
let plan = ProxifyreCutoverPlan::new(
&app_root,
PathBuf::from(r"C:\Tools\ProxiFyre"),
LegacyServiceState::Stopped,
"2.2.1".to_owned(),
package_sha256,
config_sha256.clone(),
"b".repeat(64),
uuid::Uuid::new_v4().hyphenated().to_string(),
);
let (_, runtime, _, _) = candidate_inputs_with_plan(&plan);
(plan, runtime, config, config_sha256)
}
fn candidate_inputs_with_plan(
plan: &ProxifyreCutoverPlan,
) -> (
ProxifyreCutoverPlan,
PreparedProxifyreRuntime,
Vec<u8>,
String,
) {
let files: Vec<_> = CURRENT_PROXIFYRE_PACKAGE_FILES
.iter()
.enumerate()
.map(|(index, name)| {
let bytes = vec![u8::try_from(index + 1).expect("small fixture index")];
ProxifyreStagedFile {
relative_path: (*name).to_owned(),
sha256: format!("{:x}", Sha256::digest(&bytes)),
size: bytes.len() as u64,
bytes,
}
})
.collect();
let runtime = PreparedProxifyreRuntime {
proof: PrivilegedPackageProof {
component_id: ComponentId::Proxifyre,
version: plan.bundled_version.clone(),
asset_name: "proxifyre.zip".to_owned(),
sha256: plan.package_fingerprint.clone(),
size: 123,
source: PackageSource::Bundled,
independent_proof: None,
},
installed_files: installed_file_inventory(&files),
files,
};
let config = br#"{"proxies":[],"applications":[]}"#.to_vec();
let config_sha256 = format!("{:x}", Sha256::digest(&config));
(plan.clone(), runtime, config, config_sha256)
}
File diff suppressed because it is too large Load Diff
+294
View File
@@ -0,0 +1,294 @@
//! 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_singbox_install, inventory_proxyfier_with_host,
inventory_proxyfier_with_host_and_current_root, DetectedProxyfier, DetectedSingBox,
ProxyfierDetectionHost, SystemProxyfierDetectionHost,
};
use crate::component_inventory::{
run_authorized_component_action, AuthorizedActionError, ComponentClassification,
InventoryAction,
};
use crate::component_status::components_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,
current_root: Option<std::path::PathBuf>,
}
impl DetectedProxyApplyHelper<SystemProxyfierDetectionHost> {
pub fn system() -> Self {
SystemProxyfierDetectionHost.into()
}
}
impl<H> From<H> for DetectedProxyApplyHelper<H> {
fn from(host: H) -> Self {
Self {
host,
current_root: None,
}
}
}
impl<H> DetectedProxyApplyHelper<H> {
pub fn with_current_root(host: H, current_root: std::path::PathBuf) -> Self {
Self {
host,
current_root: Some(current_root),
}
}
}
impl<H> ProxyApplyHelper for DetectedProxyApplyHelper<H>
where
H: ProxyfierDetectionHost,
{
fn apply_proxy_config(
&self,
request: HelperApplyRequest<'_>,
) -> Result<HelperApplyResult, CommandError> {
let inventory = self.current_root.as_deref().map_or_else(
|| inventory_proxyfier_with_host(&self.host),
|current_root| inventory_proxyfier_with_host_and_current_root(&self.host, current_root),
);
if inventory.classification() == ComponentClassification::Missing {
return staged_apply_result(request);
}
if inventory.classification() == ComponentClassification::ManagedLegacy {
return Err(CommandError::new(
"legacy_cutover_required",
"Старая установка ProxiFyre не изменена. Сначала выполните явный перенос компонента.",
));
}
run_authorized_component_action(&inventory, InventoryAction::Apply, |_| {
if inventory.classification() == ComponentClassification::ManagedCurrent {
return staged_managed_current_result(request);
}
Err(CommandError::new(
"ownership_mismatch",
"Найденный ProxiFyre не прошел ownership-проверку.",
))
})
.map_err(authorized_action_error)
}
}
fn authorized_action_error(error: AuthorizedActionError<CommandError>) -> CommandError {
match error {
AuthorizedActionError::Denied(issue) => CommandError::new(issue.code, issue.message),
AuthorizedActionError::Runner(error) => error,
}
}
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 transaction =
crate::configuration_transaction::ConfigurationTransaction::begin(storage, None)
.map_err(storage_error)?;
let profiles = storage.read_profiles().map_err(storage_error)?;
let targets = storage.read_targets().map_err(storage_error)?;
let components = components_with_detection(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(),
})?;
crate::route_state::record_prepared_locked(
storage,
crate::privileged_jobs::ManagedComponent::Proxifyre,
)
.map_err(storage_error)?;
if helper_result.success {
transaction.commit().map_err(storage_error)?;
} else {
drop(transaction);
}
let activity = activity_for_apply(clock, &generated, &generated_path, &helper_result);
let _ = storage.append_activity(activity.clone());
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_restricted_with_backup(path, contents.as_bytes()).map_err(storage_error)
}
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 staged_managed_current_result(
request: HelperApplyRequest<'_>,
) -> Result<HelperApplyResult, CommandError> {
Ok(HelperApplyResult {
success: true,
changed: true,
action: format!("{}.stage-managed-config", request.adapter_id),
message: format!(
"Сгенерированный конфиг подготовлен в {}; служба получит его при следующем явном запуске",
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)
}
+316
View File
@@ -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())
}
+274
View File
@@ -0,0 +1,274 @@
//! Source/prepared/activation are separate facts. This module never controls services.
use crate::{
configuration_transaction,
privileged_jobs::{ManagedComponent, PrivilegedJobStore},
process::{self, KnownWindowsService, ServiceState},
safe_fs,
storage::JsonStorage,
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{fs, io, path::PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PreparedArtifact {
source_fingerprint: String,
config_sha256: String,
}
#[derive(Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PreparedState {
proxifyre: Option<PreparedArtifact>,
singbox: Option<PreparedArtifact>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ActivationState {
Unknown,
Stopped,
RestartRequired,
Confirmed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactStatus {
pub component: String,
pub source_matches_prepared: bool,
pub generated_exists: bool,
pub activation: ActivationState,
}
pub fn prepared_path(storage: &JsonStorage) -> PathBuf {
storage
.paths()
.state_dir
.join("prepared-configuration.json")
}
fn generated_path(storage: &JsonStorage, component: ManagedComponent) -> PathBuf {
storage.paths().generated_dir.join(match component {
ManagedComponent::Proxifyre => "proxifyre-app-config.json",
ManagedComponent::SingBox => "sing-box-config.json",
})
}
fn hash(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
fn source_fingerprint(storage: &JsonStorage, component: ManagedComponent) -> io::Result<String> {
let bytes = match component {
ManagedComponent::Proxifyre => {
serde_json::to_vec(&(storage.read_profiles()?, storage.read_targets()?))?
}
ManagedComponent::SingBox => serde_json::to_vec(&(
storage.read_local_singbox_config()?,
storage.read_singbox_subscription_cache()?,
))?,
};
Ok(hash(&bytes))
}
fn read_prepared(storage: &JsonStorage) -> PreparedState {
// Missing, old, or invalid derived metadata is unknown, never reconstructed from source.
let path = prepared_path(storage);
if safe_fs::ensure_no_reparse_ancestors(&path).is_err() {
return PreparedState::default();
}
fs::read(path)
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
/// Must run inside ConfigurationTransaction after all source and generated writes.
pub fn record_prepared_locked(
storage: &JsonStorage,
component: ManagedComponent,
) -> io::Result<()> {
let path = generated_path(storage, component);
safe_fs::ensure_no_reparse_ancestors(&path)?;
let artifact = PreparedArtifact {
source_fingerprint: source_fingerprint(storage, component)?,
config_sha256: hash(&fs::read(path)?),
};
let mut state = read_prepared(storage);
match component {
ManagedComponent::Proxifyre => state.proxifyre = Some(artifact),
ManagedComponent::SingBox => state.singbox = Some(artifact),
};
safe_fs::write_restricted_atomic(&prepared_path(storage), &serde_json::to_vec(&state)?)
}
pub fn read_status_locked(storage: &JsonStorage) -> io::Result<Vec<ArtifactStatus>> {
let prepared = read_prepared(storage);
let store = PrivilegedJobStore::production().ok();
[ManagedComponent::Proxifyre, ManagedComponent::SingBox]
.into_iter()
.map(|component| {
let path = generated_path(storage, component);
safe_fs::ensure_no_reparse_ancestors(&path)?;
let generated = fs::read(path).ok().map(|bytes| hash(&bytes));
let artifact = match component {
ManagedComponent::Proxifyre => &prepared.proxifyre,
ManagedComponent::SingBox => &prepared.singbox,
};
let source = source_fingerprint(storage, component)?;
let source_matches_prepared = artifact.as_ref().is_some_and(|record| {
record.source_fingerprint == source
&& generated.as_ref() == Some(&record.config_sha256)
});
let service = match component {
ManagedComponent::Proxifyre => KnownWindowsService::Proxifyre,
ManagedComponent::SingBox => KnownWindowsService::SingBox,
};
let ack = store
.as_ref()
.and_then(|store| store.read_activation(component).ok().flatten());
let current = process::running_service_instance(service).ok();
let managed = match component {
ManagedComponent::Proxifyre => crate::component_detection::inventory_proxyfier(),
ManagedComponent::SingBox => crate::component_detection::inventory_singbox(),
}
.classification()
== crate::component_inventory::ComponentClassification::ManagedCurrent;
let stopped = process::query_known_service(service)
.ok()
.is_some_and(|state| !state.exists || state.state == Some(ServiceState::Stopped));
let activation = classify_activation(
source_matches_prepared,
generated.as_deref(),
ack.as_ref(),
current,
managed,
stopped,
);
Ok(ArtifactStatus {
component: match component {
ManagedComponent::Proxifyre => "proxyfier",
ManagedComponent::SingBox => "singbox",
}
.into(),
source_matches_prepared,
generated_exists: generated.is_some(),
activation,
})
})
.collect()
}
pub fn read_status(storage: &JsonStorage) -> io::Result<Vec<ArtifactStatus>> {
let _guard = configuration_transaction::read_guard(storage)?;
read_status_locked(storage)
}
fn classify_activation(
prepared: bool,
generated: Option<&str>,
ack: Option<&crate::privileged_jobs::ActivationAcknowledgement>,
current: Option<process::ServiceInstance>,
managed: bool,
stopped: bool,
) -> ActivationState {
if stopped {
return ActivationState::Stopped;
}
match (ack, current) {
(Some(ack), Some(current)) if managed && ack.instance == current => {
if prepared && generated == Some(ack.config_sha256.as_str()) {
ActivationState::Confirmed
} else {
ActivationState::RestartRequired
}
}
_ => ActivationState::Unknown,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn late_activation_never_confirms_new_preparation_or_a_reused_pid() {
let instance = process::ServiceInstance {
process_id: 42,
created_at_filetime: 100,
};
let ack = crate::privileged_jobs::ActivationAcknowledgement {
component: ManagedComponent::Proxifyre,
config_sha256: "a".repeat(64),
instance,
};
assert_eq!(
classify_activation(
true,
Some(&ack.config_sha256),
Some(&ack),
Some(instance),
true,
false
),
ActivationState::Confirmed
);
assert_eq!(
classify_activation(
true,
Some(&"b".repeat(64)),
Some(&ack),
Some(instance),
true,
false
),
ActivationState::RestartRequired
);
assert_eq!(
classify_activation(
false,
Some(&ack.config_sha256),
Some(&ack),
Some(instance),
true,
false
),
ActivationState::RestartRequired
);
assert_eq!(
classify_activation(
true,
Some(&ack.config_sha256),
Some(&ack),
Some(process::ServiceInstance {
created_at_filetime: 101,
..instance
}),
true,
false
),
ActivationState::Unknown
);
assert_eq!(
classify_activation(
true,
Some(&ack.config_sha256),
Some(&ack),
Some(instance),
false,
false
),
ActivationState::Unknown
);
assert_eq!(
classify_activation(
true,
Some(&ack.config_sha256),
None,
Some(instance),
true,
false
),
ActivationState::Unknown
);
assert_eq!(
classify_activation(true, Some(&ack.config_sha256), Some(&ack), None, true, true),
ActivationState::Stopped
);
}
}
File diff suppressed because it is too large Load Diff
+142
View File
@@ -0,0 +1,142 @@
//! 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::configuration_transaction::{read_guard, revision_locked, ConfigurationTransaction};
use crate::models::{
ActivityEntry, ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, Target,
TargetKind,
};
use crate::safe_fs;
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 guard = read_guard(storage).map_err(storage_error)?;
let config = storage.read_local_singbox_config().map_err(storage_error)?;
let cache = storage
.read_singbox_subscription_cache()
.map_err(storage_error)?
.ok_or_else(|| {
CommandError::new(
"singbox_subscription_cache_missing",
"Сначала загрузите подписку.",
)
})?;
let revision = revision_locked(storage).map_err(storage_error)?;
drop(guard);
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());
let transaction =
ConfigurationTransaction::begin(storage, Some(&revision)).map_err(storage_error)?;
write_generated_config(&generated_path, &generated.contents)?;
ensure_local_singbox_target(storage, &config)?;
crate::route_state::record_prepared_locked(
storage,
crate::privileged_jobs::ManagedComponent::SingBox,
)
.map_err(storage_error)?;
transaction.commit().map_err(storage_error)?;
let activity = activity_for_singbox_generate(clock, &generated, &generated_path);
let _ = storage.append_activity(activity.clone());
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_restricted_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())
}

Some files were not shown because too many files have changed in this diff Show More