Compare commits
18
Commits
7316e932f0
...
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6b66a4ed7 | ||
|
|
c5532f2087 | ||
|
|
f812e32270 | ||
|
|
fd79606052 | ||
|
|
efda8eb98f | ||
|
|
9c987df6e9 | ||
|
|
90ec4ca086 | ||
|
|
67792f245f | ||
|
|
90b2eb507c | ||
|
|
dbba3806cc | ||
|
|
9fd0a8c0b9 | ||
|
|
1bb795a532 | ||
|
|
db0c1dede9 | ||
|
|
4d859dc0a6 | ||
|
|
a2581187da | ||
|
|
2e80f7b8eb | ||
|
|
f6b722b22a | ||
|
|
42b85cc8fa |
@@ -0,0 +1,61 @@
|
||||
# ProxyWarden Agent Kit
|
||||
|
||||
Этот каталог содержит инструкции для кодовых агентов, которые работают с ProxyWarden.
|
||||
|
||||
Главный файл — `AGENTS.md` в корне репозитория. Он задает инварианты и общие правила. Файлы в `.agent/skills` описывают конкретные режимы работы: backend, UI, security, Windows services, subscriptions/routing, testing/release и понятные отчеты.
|
||||
|
||||
## Как использовать
|
||||
|
||||
1. Прочитать корневой `AGENTS.md`.
|
||||
2. Выбрать skill под задачу.
|
||||
3. Перед изменением проверить релевантные чек-листы из `.agent/checklists`.
|
||||
4. После изменения выполнить минимальные проверки.
|
||||
5. Для любого нетривиального ответа использовать `communication-reporting`: коротко, по файлам, с проверками и рисками.
|
||||
6. В финальном отчете явно указать, что было и не было проверено.
|
||||
|
||||
## Как агент должен писать ответы
|
||||
|
||||
По умолчанию агент пишет не техническую простыню, а короткий отчет:
|
||||
|
||||
```text
|
||||
Коротко
|
||||
Что изменилось по файлам
|
||||
Важные места
|
||||
Проверено
|
||||
Не проверено
|
||||
Риски
|
||||
```
|
||||
|
||||
Для 2+ файлов желательно использовать таблицу `Файл / Что изменилось / Зачем`. В ответе должны быть конкретные пути файлов и человеческая причина изменения. Не надо пересказывать каждую строку diff, если пользователь не попросил.
|
||||
|
||||
Подробные правила лежат в `.agent/skills/communication-reporting/SKILL.md`, чек-лист — в `.agent/checklists/communication.md`. Да, это нужно отдельно прописывать, иначе агент опять напишет роман о своем внутреннем мире и двух переименованных переменных.
|
||||
|
||||
## Skill index
|
||||
|
||||
- `repository-orientation` — вход в проект, карта файлов, где искать source of truth.
|
||||
- `rust-tauri-backend` — Tauri commands, Rust models, validation, storage, adapters.
|
||||
- `react-typescript-ui` — React UI, typed invoke facade, readiness, components.
|
||||
- `security-hardening` — CSP, секреты, elevated boundary, storage corruption, SSRF.
|
||||
- `windows-services-powershell` — scripts, UAC, services, ProxiFyre/sing-box operations.
|
||||
- `subscriptions-routing` — external SOCKS5, sing-box subscriptions, config generation, ping.
|
||||
- `testing-ci-release` — build/test matrix, CI recommendations, release hygiene.
|
||||
- `communication-reporting` — короткие планы, понятные сводки по файлам, отчеты без текстовой каши.
|
||||
|
||||
## Communication defaults
|
||||
|
||||
Перед длинным ответом или отчетом использовать:
|
||||
|
||||
- `.agent/skills/communication-reporting/SKILL.md`
|
||||
- `.agent/checklists/communication.md`
|
||||
- `.agent/checklists/explanation-quality.md`
|
||||
- `.agent/templates/change-report.md` или `.agent/templates/user-facing-summary.md`
|
||||
- `.agent/templates/file-impact-map.md`, если надо заранее показать, какие файлы будут затронуты
|
||||
|
||||
Главная идея: сначала короткая сводка, потом таблица файлов, потом проверки и риски. Не наоборот, потому что пользователь не обязан добывать смысл киркой.
|
||||
|
||||
## Что не является целью
|
||||
|
||||
- Перевод проекта в SaaS/gateway/server.
|
||||
- Добавление облачного backend.
|
||||
- Замена ProxiFyre без отдельной архитектурной задачи.
|
||||
- Коммерциализация, telemetry-first подход или рекламная шелуха, этот вид пластика уже и так в океане.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Change Safety Checklist
|
||||
|
||||
Use this before and after non-trivial changes.
|
||||
|
||||
## Scope
|
||||
|
||||
- [ ] I identified whether this touches backend, UI, security, Windows service, subscription/routing, testing/release.
|
||||
- [ ] I read the matching skill file.
|
||||
- [ ] I avoided unrelated rewrites.
|
||||
- [ ] I did not introduce a second source of truth.
|
||||
|
||||
## Tauri boundary
|
||||
|
||||
- [ ] New/changed Rust command has matching TypeScript wrapper.
|
||||
- [ ] DTOs are synchronized between Rust and TypeScript.
|
||||
- [ ] Error shape is structured and actionable.
|
||||
- [ ] Blocking work is not run on async runtime thread.
|
||||
|
||||
## UX
|
||||
|
||||
- [ ] User-visible actions are explicit.
|
||||
- [ ] Disabled states have reasons.
|
||||
- [ ] Pending changes are visible before apply.
|
||||
- [ ] Secrets are redacted.
|
||||
|
||||
## Validation
|
||||
|
||||
- [ ] Relevant frontend build/test was run or explicitly not run with reason.
|
||||
- [ ] Relevant Rust fmt/clippy/test was run or explicitly not run with reason.
|
||||
- [ ] Windows-specific behavior was not claimed unless tested on Windows.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Communication Checklist
|
||||
|
||||
Используй перед финальным ответом по любой нетривиальной задаче.
|
||||
|
||||
## Структура
|
||||
|
||||
- [ ] Ответ начинается с `Коротко` или с такой же короткой сводки на 2-4 пункта.
|
||||
- [ ] Измененные файлы или зоны проекта перечислены в начале ответа, а не спрятаны в конце.
|
||||
- [ ] Для каждого важного файла понятно: что изменилось и зачем.
|
||||
- [ ] Важные изменения поведения, безопасности или состояния отделены от мелких деталей.
|
||||
- [ ] Проверки разделены на `Проверено` и `Не проверено`.
|
||||
- [ ] Риски написаны явно.
|
||||
|
||||
## Понятность
|
||||
|
||||
- [ ] Нет плотных абзацев длиннее 4-5 строк.
|
||||
- [ ] Нет терминов и аббревиатур без пользы или краткого объяснения.
|
||||
- [ ] Нет полных логов, если они не нужны для вывода.
|
||||
- [ ] Нет пересказа каждой строки diff, если пользователь не просил.
|
||||
- [ ] Нет мутных фраз вроде `улучшена архитектура` без объяснения, что стало проще, безопаснее или понятнее.
|
||||
|
||||
## Честность
|
||||
|
||||
- [ ] Windows/service/elevation поведение не названо проверенным, если оно не тестировалось на Windows.
|
||||
- [ ] У пропущенных проверок есть простая причина.
|
||||
- [ ] Ответ не говорит `готово`, если важные проверки пропущены.
|
||||
|
||||
## Быстрая самопроверка
|
||||
|
||||
Перед отправкой ответ должен отвечать на вопросы:
|
||||
|
||||
1. Что изменилось или найдено?
|
||||
2. В каких файлах?
|
||||
3. Зачем это нужно?
|
||||
4. Что реально проверено?
|
||||
5. Что не проверено?
|
||||
6. Где остался риск?
|
||||
@@ -0,0 +1,41 @@
|
||||
# Checklist: Explanation Quality
|
||||
|
||||
Используй перед финальным ответом или PR summary.
|
||||
|
||||
## Обязательное
|
||||
|
||||
- [ ] В начале есть короткий итог на 2–4 пункта.
|
||||
- [ ] Есть список файлов или таблица `файл / что / зачем`.
|
||||
- [ ] Термины объяснены простыми словами, если они важны.
|
||||
- [ ] Нет длинных полотен без заголовков.
|
||||
- [ ] Нет пересказа каждой строки diff.
|
||||
- [ ] Указано, что проверено.
|
||||
- [ ] Указано, что не проверено.
|
||||
- [ ] Риски написаны прямо, без «должно работать».
|
||||
|
||||
## Хороший формат
|
||||
|
||||
```md
|
||||
## Коротко
|
||||
- ...
|
||||
|
||||
## Файлы
|
||||
| Файл | Что | Зачем |
|
||||
|---|---|---|
|
||||
|
||||
## Проверки
|
||||
- Выполнено: ...
|
||||
- Не выполнено: ...
|
||||
|
||||
## Риски
|
||||
- ...
|
||||
```
|
||||
|
||||
## Плохие признаки
|
||||
|
||||
- Один огромный абзац.
|
||||
- Много терминов без пользы.
|
||||
- «Исправлена логика» без указания файла и эффекта.
|
||||
- «Проверено» без команды или способа проверки.
|
||||
- «Не проверял Windows, но всё готово».
|
||||
- Список из 25 пунктов одинаковой важности.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Release Checklist
|
||||
|
||||
## Build
|
||||
|
||||
- [ ] `npm ci`
|
||||
- [ ] `npm run build`
|
||||
- [ ] `cargo fmt --all -- --check`
|
||||
- [ ] `cargo clippy --all-targets --all-features -- -D warnings`
|
||||
- [ ] `cargo test --all-targets`
|
||||
- [ ] `npm run tauri -- build`
|
||||
|
||||
## Windows smoke
|
||||
|
||||
- [ ] Fresh Windows VM smoke test.
|
||||
- [ ] ProxiFyre install plan and real install.
|
||||
- [ ] sing-box install plan and real install.
|
||||
- [ ] External SOCKS5 route works.
|
||||
- [ ] Local sing-box subscription route works.
|
||||
- [ ] Start/stop/restart service controls work.
|
||||
- [ ] Uninstall does not delete unmanaged paths.
|
||||
|
||||
## Security/release hygiene
|
||||
|
||||
- [ ] CSP enabled.
|
||||
- [ ] Capabilities minimal.
|
||||
- [ ] No raw secrets in repo/logs.
|
||||
- [ ] Subscription redaction checked.
|
||||
- [ ] Generated config writes are safe.
|
||||
- [ ] Artifact version is correct.
|
||||
- [ ] Large assets reviewed/compressed if practical.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Security Checklist
|
||||
|
||||
## Secrets
|
||||
|
||||
- [ ] Subscription URLs are redacted through parser-based logic.
|
||||
- [ ] Proxy credentials are not logged.
|
||||
- [ ] Outbound configs with secrets are not printed in diagnostics.
|
||||
- [ ] Error messages do not include tokens/passwords/userinfo.
|
||||
|
||||
## Tauri/webview
|
||||
|
||||
- [ ] CSP is enabled.
|
||||
- [ ] No broad shell permissions added.
|
||||
- [ ] No direct command execution from UI input.
|
||||
- [ ] No `dangerouslySetInnerHTML` or equivalent unsafe HTML rendering without sanitization.
|
||||
|
||||
## Network fetch
|
||||
|
||||
- [ ] Subscription fetch has timeout.
|
||||
- [ ] URL scheme is restricted.
|
||||
- [ ] Local/private/link-local/metadata address behavior is explicit.
|
||||
- [ ] Redirect behavior does not bypass blocked address checks.
|
||||
|
||||
## Filesystem
|
||||
|
||||
- [ ] Critical writes are atomic where practical.
|
||||
- [ ] Corrupt config handling does not silently discard user state.
|
||||
- [ ] Recursive delete has strict path/marker checks.
|
||||
- [ ] Temp elevated scripts use unpredictable names and safe directory/ACL when practical.
|
||||
|
||||
## Windows services
|
||||
|
||||
- [ ] Managed service is verified by name and PathName/metadata.
|
||||
- [ ] Fuzzy candidates are not automatically controlled.
|
||||
- [ ] UAC cancellation has clear error.
|
||||
- [ ] Plan-only remains side-effect-free.
|
||||
@@ -0,0 +1,24 @@
|
||||
# UI Checklist
|
||||
|
||||
## Structure
|
||||
|
||||
- [ ] No direct `invoke(...)` outside `src/api/tauriCommands.ts`.
|
||||
- [ ] New reusable UI uses `src/ui/*` components or extends them.
|
||||
- [ ] New business/display logic is not buried in JSX if it can be tested separately.
|
||||
- [ ] `App.tsx` was not made worse without justification.
|
||||
|
||||
## Accessibility and behavior
|
||||
|
||||
- [ ] Buttons have accessible names.
|
||||
- [ ] Toggle state uses `aria-pressed` or equivalent.
|
||||
- [ ] Tabs/popovers preserve keyboard and screen-reader behavior.
|
||||
- [ ] Reduced motion preference is respected where animation is added.
|
||||
- [ ] Errors are visible and readable.
|
||||
|
||||
## ProxyWarden-specific
|
||||
|
||||
- [ ] External SOCKS5 route does not require sing-box.
|
||||
- [ ] Local sing-box route requires selected server and readiness.
|
||||
- [ ] Route chain matches actual backend state.
|
||||
- [ ] Apply readiness gives a clear reason.
|
||||
- [ ] Summary remains read-only.
|
||||
@@ -0,0 +1,248 @@
|
||||
# Skill: Communication Reporting
|
||||
|
||||
## Когда использовать
|
||||
|
||||
Используй этот skill в каждом ответе пользователю после анализа, правки кода, ревью, аудита, планирования рефакторинга или подготовки PR. Особенно если задача затрагивает несколько файлов, backend/frontend boundary, security, Windows services или UI.
|
||||
|
||||
## Цель
|
||||
|
||||
Писать так, чтобы человек с опытом разработки быстро понял суть без чтения технической простыни. Не упрощать до детского сада, но объяснять по-человечески: что поменялось, где, зачем, как проверить, где риск.
|
||||
|
||||
Пользователь не обязан продираться через внутренний монолог агента и каталог аббревиатур. У него есть жизнь, возможно даже вне репозитория, страшно представить.
|
||||
|
||||
## Базовый формат ответа
|
||||
|
||||
Для нетривиальных изменений используй такую структуру:
|
||||
|
||||
```text
|
||||
Коротко
|
||||
- 2-4 пункта: главный результат, важный риск, что проверить.
|
||||
|
||||
Что изменилось по файлам
|
||||
| Файл | Что изменилось | Зачем |
|
||||
|---|---|---|
|
||||
| src/... | Кратко | Человеческая причина |
|
||||
|
||||
Важные места
|
||||
- 3-6 конкретных мест: файл + функция/секция + смысл.
|
||||
|
||||
Как проверить
|
||||
- Команды или ручные шаги.
|
||||
|
||||
Что не проверено
|
||||
- Честно и коротко.
|
||||
|
||||
Риски
|
||||
- Только реальные риски, не философия.
|
||||
```
|
||||
|
||||
Если изменение маленькое, можно сократить до:
|
||||
|
||||
```text
|
||||
Коротко: ...
|
||||
|
||||
Файлы:
|
||||
- `path`: что и зачем.
|
||||
|
||||
Проверка: ...
|
||||
```
|
||||
|
||||
## Правила ясности
|
||||
|
||||
- Сначала вывод, потом детали.
|
||||
- Не писать длиннее, чем нужно для решения задачи.
|
||||
- Не перечислять каждую строку diff. Указывать только смысловые изменения.
|
||||
- Всегда называть конкретные файлы.
|
||||
- Для сложных мест указывать функцию, модуль или секцию, если это помогает найти код.
|
||||
- Если используешь термин, рядом дать короткое человеческое объяснение.
|
||||
- Не использовать аббревиатуры без расшифровки при первом упоминании, кроме очевидных: UI, JSON, URL, API.
|
||||
- Не писать «улучшена архитектура» без объяснения, что именно стало проще или безопаснее.
|
||||
- Не писать «всё готово», если часть проверок не запускалась.
|
||||
- Не скрывать ошибки окружения. Если `cargo` или Windows недоступны, так и сказать.
|
||||
|
||||
## Как объяснять технические изменения
|
||||
|
||||
Плохо:
|
||||
|
||||
```text
|
||||
Refactored orchestration layer and extracted imperative use-case side effects into composable boundaries.
|
||||
```
|
||||
|
||||
Хорошо:
|
||||
|
||||
```text
|
||||
Вынес запуск service-команд из большого `commands.rs` в отдельный модуль. Теперь Tauri command только принимает запрос и возвращает ошибку, а вся Windows-логика лежит отдельно. Так проще тестировать и меньше шанс сломать соседние команды.
|
||||
```
|
||||
|
||||
Плохо:
|
||||
|
||||
```text
|
||||
Added CSP hardening.
|
||||
```
|
||||
|
||||
Хорошо:
|
||||
|
||||
```text
|
||||
Включил CSP в `src-tauri/tauri.conf.json`. Это ограничивает, какие скрипты/ресурсы может загрузить webview, и снижает ущерб, если в UI когда-нибудь появится XSS.
|
||||
```
|
||||
|
||||
## Уровни детализации
|
||||
|
||||
По умолчанию — средний уровень:
|
||||
|
||||
- достаточно конкретно, чтобы разработчик понял diff;
|
||||
- без пересказа каждой строки;
|
||||
- без внутренних рассуждений агента;
|
||||
- без длинной теории.
|
||||
|
||||
Если пользователь просит глубже, добавь раздел:
|
||||
|
||||
```text
|
||||
Детальнее
|
||||
- ...
|
||||
```
|
||||
|
||||
Если пользователь просит совсем кратко, оставь только:
|
||||
|
||||
```text
|
||||
Коротко
|
||||
Файлы
|
||||
Проверка
|
||||
```
|
||||
|
||||
## Таблица файлов
|
||||
|
||||
Для 2+ файлов почти всегда используй таблицу:
|
||||
|
||||
| Файл | Тип изменения | Смысл |
|
||||
|---|---|---|
|
||||
| `src/api/tauriCommands.ts` | API boundary | Добавлен typed wrapper для новой Tauri command |
|
||||
| `src-tauri/src/commands.rs` | Backend | Добавлена команда, которая вызывает уже существующую service-логику |
|
||||
|
||||
Правила:
|
||||
|
||||
- Не вставлять огромные таблицы на 30 строк. Группировать мелкие файлы.
|
||||
- В колонке `Смысл` писать человеческую причину, не только «обновлено».
|
||||
- Если файл опасный, отметить это: `security-sensitive`, `Windows/elevation`, `storage`, `routing`.
|
||||
|
||||
## Как писать про риски
|
||||
|
||||
Риск должен быть конкретным:
|
||||
|
||||
Плохо:
|
||||
|
||||
```text
|
||||
Есть некоторые риски.
|
||||
```
|
||||
|
||||
Хорошо:
|
||||
|
||||
```text
|
||||
Риск: я не запускал real Windows service flow, поэтому install/start/stop надо проверить на Windows 10/11 с UAC.
|
||||
```
|
||||
|
||||
Плохо:
|
||||
|
||||
```text
|
||||
Может быть несовместимость.
|
||||
```
|
||||
|
||||
Хорошо:
|
||||
|
||||
```text
|
||||
Риск: если у пользователя уже стоит чужая служба с похожим именем ProxiFyre, fuzzy detection может показать ее кандидатом. Управлять ей нельзя без проверки `PathName`.
|
||||
```
|
||||
|
||||
## Как писать про проверки
|
||||
|
||||
Разделяй выполненное и невыполненное:
|
||||
|
||||
```text
|
||||
Проверено
|
||||
- `npm run build` — прошел.
|
||||
- Markdown-файлы открываются, битых путей не нашел.
|
||||
|
||||
Не проверено
|
||||
- `cargo test` — не запускал, в среде нет Rust toolchain.
|
||||
- Windows service flow — не проверял, нужна Windows-машина с UAC.
|
||||
```
|
||||
|
||||
Не объединять это в мутное «тесты частично пройдены». Машины и люди заслуживают хотя бы грамм конкретики.
|
||||
|
||||
## Запрещенный стиль
|
||||
|
||||
Не писать:
|
||||
|
||||
- огромные абзацы без заголовков;
|
||||
- «магия», «оптимизировано», «улучшено» без конкретики;
|
||||
- внутренний дневник действий агента;
|
||||
- цепочки мыслей;
|
||||
- список всех строк diff;
|
||||
- рекламный тон;
|
||||
- уверенные заявления о непроверенных Windows/elevation сценариях;
|
||||
- «как вы и просили, я с радостью...» — репозиторий от этого лучше не станет.
|
||||
|
||||
## Мини-шаблоны
|
||||
|
||||
### Для bugfix
|
||||
|
||||
```text
|
||||
Коротко
|
||||
- Исправил ...
|
||||
- Основной риск был ...
|
||||
- Проверка: ...
|
||||
|
||||
Что изменилось по файлам
|
||||
| Файл | Что изменилось | Зачем |
|
||||
|---|---|---|
|
||||
|
||||
Важные места
|
||||
- `file`: ...
|
||||
|
||||
Проверено
|
||||
- ...
|
||||
|
||||
Не проверено
|
||||
- ...
|
||||
```
|
||||
|
||||
### Для ревью без правок
|
||||
|
||||
```text
|
||||
Коротко
|
||||
- Самая важная проблема: ...
|
||||
- Второй приоритет: ...
|
||||
- Быстрый выигрыш: ...
|
||||
|
||||
Что я смотрел
|
||||
- ...
|
||||
|
||||
Проблемы по важности
|
||||
1. Critical/High: ...
|
||||
2. Medium: ...
|
||||
3. Low: ...
|
||||
|
||||
Что бы я сделал первым
|
||||
- ...
|
||||
```
|
||||
|
||||
### Для плана изменений
|
||||
|
||||
```text
|
||||
Коротко
|
||||
- Цель: ...
|
||||
- Затронет: ...
|
||||
- Не трогаем: ...
|
||||
|
||||
План по файлам
|
||||
| Файл/зона | Что сделать | Почему |
|
||||
|---|---|---|
|
||||
|
||||
Порядок работ
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
Проверка
|
||||
- ...
|
||||
```
|
||||
@@ -0,0 +1,109 @@
|
||||
# Skill: React / TypeScript UI
|
||||
|
||||
## Когда использовать
|
||||
|
||||
Используй этот skill при изменениях в `src`, UI, state management, typed Tauri wrappers, readiness logic, route visualization, forms, logs, buttons, popovers, service panels.
|
||||
|
||||
## Главная цель
|
||||
|
||||
UI должен ясно объяснять, что будет сделано с сетью пользователя, не прятать опасные действия и не превращаться в панель управления космической станцией ради одной прокси-кнопки.
|
||||
|
||||
## Инварианты
|
||||
|
||||
- `src/api/tauriCommands.ts` — единственное место для `invoke(...)`.
|
||||
- `src/domain/types.ts` должен отражать backend DTO.
|
||||
- Apply-readiness logic живет в `src/app/readiness.ts`.
|
||||
- Presentational UI должен переиспользовать `src/ui/*`.
|
||||
- Summary panel read-only.
|
||||
- Install/start/stop/uninstall/apply actions must be explicit.
|
||||
- Secrets must be redacted.
|
||||
|
||||
## App.tsx rule
|
||||
|
||||
`src/app/App.tsx` уже слишком большой. Новую логику не добавлять туда, если можно вынести:
|
||||
|
||||
```text
|
||||
src/app/hooks/useStartupSnapshot.ts
|
||||
src/app/hooks/useApplyFlow.ts
|
||||
src/app/hooks/useServiceControl.ts
|
||||
src/app/hooks/useSubscription.ts
|
||||
src/app/lib/parseProxy.ts
|
||||
src/app/lib/snapshots.ts
|
||||
src/app/components/RouteChain.tsx
|
||||
src/app/components/ChangesDock.tsx
|
||||
src/app/components/ProxyPanel.tsx
|
||||
src/app/components/SingBoxWorkspace.tsx
|
||||
```
|
||||
|
||||
Если изменение маленькое и локальное, допустимо править `App.tsx`, но не расширять его архитектурную роль.
|
||||
|
||||
## UI behavior rules
|
||||
|
||||
- Disabled button must explain why.
|
||||
- Loading/busy state must prevent double submit.
|
||||
- Errors must be human-readable and actionable.
|
||||
- Long paths, service names and tags must not break layout.
|
||||
- Do not use native `title` as main tooltip UX. Use existing popover/tooltip pattern.
|
||||
- Use `aria-*` for tabs, toggle buttons, popovers, service controls.
|
||||
- Honor reduced motion where relevant.
|
||||
|
||||
## Animated disclosures
|
||||
|
||||
- Keep disclosure content mounted through opening and closing so both directions can animate. Do not conditionally render content directly into its final open state.
|
||||
- Keep the trigger at one screen position and separate layout placement from hover/active transforms.
|
||||
- Gate hidden content with `aria-hidden` plus `inert` or `tabIndex`; opacity and `pointer-events` alone do not remove controls from keyboard navigation.
|
||||
- Keep `aria-expanded` and `aria-controls` on the trigger synchronized with the rendered state.
|
||||
- Use one motion origin and timeline for background, copy, and actions. Implement and verify the reverse transition at the same time as the entrance.
|
||||
|
||||
## Startup responsiveness
|
||||
|
||||
- Render the shell and saved/default configuration immediately. Do not gate first paint on network access, subscription refresh, or every component probe.
|
||||
- Show slow component detection in reserved `checking` geometry and apply partial results as they arrive without replaying page entrance motion.
|
||||
- Do not serialize independent probes to create a staged UI. When the backend exposes only an aggregate snapshot, animate reserved placeholders and replace their values in place when that snapshot arrives.
|
||||
- Keep navigation and already-known configuration usable while background detection continues.
|
||||
|
||||
## Proxy/routing UI
|
||||
|
||||
When editing route UI:
|
||||
|
||||
- External SOCKS5 mode must not require sing-box.
|
||||
- Local sing-box mode must require selected server and sing-box readiness.
|
||||
- App list must clearly distinguish process, folder, exe/path if those are different target kinds.
|
||||
- Route chain should reflect actual backend target/profile state.
|
||||
- Pending changes should be visible before apply.
|
||||
|
||||
## Tests/checks
|
||||
|
||||
Minimum:
|
||||
|
||||
```powershell
|
||||
npm run build
|
||||
```
|
||||
|
||||
Recommended for extracted pure logic:
|
||||
|
||||
- Unit tests for proxy parsing.
|
||||
- Unit tests for readiness states.
|
||||
- Unit tests for snapshot diff/change dock model.
|
||||
- UI smoke checks for desktop and narrow layout.
|
||||
- For hover, disclosure, or motion changes, exercise first open, close, repeated toggle, hover during transition, keyboard focus, loading copy, and `prefers-reduced-motion`. Build and unit tests do not validate these behaviors.
|
||||
|
||||
## Do not
|
||||
|
||||
- Do not call backend commands from random components.
|
||||
- Do not store secrets in React state longer than needed if display value can be redacted.
|
||||
- Do not duplicate Rust validation as the only validation. Frontend validation is UX, backend validation is authority.
|
||||
- Do not add a visual state that implies a service is running unless backend confirmed it.
|
||||
|
||||
## Как отчитываться
|
||||
|
||||
Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`.
|
||||
|
||||
Минимум для нетривиальной задачи:
|
||||
|
||||
- короткая сводка;
|
||||
- таблица файлов `Файл / Что изменилось / Зачем`;
|
||||
- важные места без пересказа каждой строки;
|
||||
- что проверено;
|
||||
- что не проверено;
|
||||
- конкретные риски.
|
||||
@@ -0,0 +1,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`.
|
||||
|
||||
Минимум для нетривиальной задачи:
|
||||
|
||||
- короткая сводка;
|
||||
- таблица файлов `Файл / Что изменилось / Зачем`;
|
||||
- важные места без пересказа каждой строки;
|
||||
- что проверено;
|
||||
- что не проверено;
|
||||
- конкретные риски.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Skill: Rust / Tauri Backend
|
||||
|
||||
## Когда использовать
|
||||
|
||||
Используй этот skill при изменениях в `src-tauri`, Tauri commands, Rust models, validation, storage, adapters, component detection, subscription fetching, config generation или service orchestration.
|
||||
|
||||
## Главная цель
|
||||
|
||||
Держать backend надежным, типизированным и безопасным. Backend управляет системой пользователя, поэтому «ну вроде работает» здесь примерно как инструкция по посадке самолета, написанная на салфетке.
|
||||
|
||||
## Инварианты
|
||||
|
||||
- Tauri command layer должен быть thin boundary, а не склад всей бизнес-логики.
|
||||
- Долгие/blocking операции не должны выполняться на async runtime thread.
|
||||
- Все ошибки, которые видит UI, должны быть structured and actionable.
|
||||
- Validation должна происходить на backend даже если UI уже проверяет input.
|
||||
- Generated config writes должны быть atomic where practical.
|
||||
- Secrets never logged or fully displayed.
|
||||
|
||||
## Preferred command pattern
|
||||
|
||||
Для тяжелых операций:
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub async fn some_command(input: SomeInput) -> Result<SomeOutput, CommandError> {
|
||||
tauri::async_runtime::spawn_blocking(move || some_command_impl(input))
|
||||
.await
|
||||
.map_err(background_task_error)?
|
||||
}
|
||||
```
|
||||
|
||||
Implementation function должна быть тестируемой без Tauri runtime, если возможно.
|
||||
|
||||
## Startup responsiveness
|
||||
|
||||
- Keep first paint independent from network access, subscription refresh, and slow component detection.
|
||||
- Run independent startup probes concurrently and outside the async runtime thread. Do not serialize ProxiFyre, sing-box, service, and admin checks without a dependency between them.
|
||||
- Give external or process-heavy probes a bounded timeout and return partial status when one probe is slow or unavailable.
|
||||
- Load saved configuration and other cheap state first. Let the UI render it while detection results update separately or through a partial startup snapshot.
|
||||
- Do not fail the entire startup snapshot because one optional component cannot be detected. Preserve structured per-component errors or unknown/checking state.
|
||||
|
||||
## DTO boundary
|
||||
|
||||
При добавлении или изменении command:
|
||||
|
||||
1. Rust input/output DTO.
|
||||
2. TypeScript DTO in `src/domain/types.ts`.
|
||||
3. Wrapper in `src/api/tauriCommands.ts`.
|
||||
4. UI usage.
|
||||
5. Tests for pure logic.
|
||||
|
||||
Не использовать `serde_json::Value` как permanent API, если структура известна.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Не использовать `unwrap()`/`expect()` в production path.
|
||||
- Возвращать `CommandError { code, message, details }`.
|
||||
- Для validation использовать список проблем, а не первую попавшуюся ошибку.
|
||||
- Internal error text не должен раскрывать secrets.
|
||||
- Если операция partial, вернуть partial state/result where possible.
|
||||
|
||||
## Storage
|
||||
|
||||
При изменении `storage.rs`:
|
||||
|
||||
- Keep tmp + bak write pattern.
|
||||
- Не превращать invalid JSON в default молча. Prefer corruption backup/restore path.
|
||||
- Для critical writes использовать same-directory temp file and rename.
|
||||
- Не хранить derived artifacts как source of truth.
|
||||
- Не хранить raw subscription/proxy secrets в activity log.
|
||||
|
||||
## Refactoring guidance
|
||||
|
||||
`src-tauri/src/commands.rs` слишком большой. Новую логику по возможности выносить:
|
||||
|
||||
```text
|
||||
commands/dto.rs
|
||||
commands/status.rs
|
||||
commands/profiles.rs
|
||||
commands/targets.rs
|
||||
commands/components.rs
|
||||
commands/subscription.rs
|
||||
commands/proxifyre.rs
|
||||
commands/singbox.rs
|
||||
services/elevated.rs
|
||||
services/powershell.rs
|
||||
services/proxifyre_service.rs
|
||||
services/singbox_service.rs
|
||||
```
|
||||
|
||||
При рефакторинге сохранять external command names, чтобы UI не ломался без причины.
|
||||
|
||||
## Validation checklist
|
||||
|
||||
- `cargo fmt --all -- --check`
|
||||
- `cargo clippy --all-targets --all-features -- -D warnings`
|
||||
- `cargo test --all-targets`
|
||||
- Relevant Windows/manual check if touching service/install/elevation.
|
||||
- When changing startup aggregation, verify that one delayed or failed probe does not postpone unrelated saved state or component results.
|
||||
|
||||
Если `cargo` недоступен в среде, честно написать, что backend проверен только статически. Не изображать компилятор, у него и так тяжелая жизнь.
|
||||
|
||||
## Как отчитываться
|
||||
|
||||
Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`.
|
||||
|
||||
Минимум для нетривиальной задачи:
|
||||
|
||||
- короткая сводка;
|
||||
- таблица файлов `Файл / Что изменилось / Зачем`;
|
||||
- важные места без пересказа каждой строки;
|
||||
- что проверено;
|
||||
- что не проверено;
|
||||
- конкретные риски.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Skill: Security Hardening
|
||||
|
||||
## Когда использовать
|
||||
|
||||
Используй этот skill при изменениях, связанных с Tauri security, CSP, secrets, subscription fetching, process execution, PowerShell, temporary files, install/uninstall, file writes, generated configs, service control, logs, diagnostics.
|
||||
|
||||
## Threat model
|
||||
|
||||
ProxyWarden — desktop app that can influence network routing and run elevated Windows operations. Главные риски:
|
||||
|
||||
- leaking proxy/subscription credentials;
|
||||
- unsafe local/network fetches;
|
||||
- unsafe generated elevated PowerShell scripts;
|
||||
- unscoped process execution;
|
||||
- corrupting generated/service configs;
|
||||
- deleting wrong directories;
|
||||
- stale component status causing wrong actions;
|
||||
- XSS/webview compromise amplified by privileged backend commands.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
- `tauri.conf.json` must not use `"csp": null` as final state.
|
||||
- Do not add broad Tauri shell permissions.
|
||||
- Do not execute user-controlled strings as commands.
|
||||
- Do not log full subscription URLs, proxy passwords, userinfo, access tokens, or outbound configs with credentials.
|
||||
- Do not recursively delete directories based only on fuzzy name matching.
|
||||
- Do not treat fuzzy-detected services as managed without verification.
|
||||
|
||||
## CSP guidance
|
||||
|
||||
Prefer a restrictive CSP such as:
|
||||
|
||||
```json
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' asset: data:; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
||||
}
|
||||
```
|
||||
|
||||
Tighten further when possible. If inline styles are removed, remove `'unsafe-inline'`.
|
||||
|
||||
## Secret redaction
|
||||
|
||||
For URLs use a parser, not string splitting. Redacted display should include only:
|
||||
|
||||
- scheme;
|
||||
- host;
|
||||
- port if useful;
|
||||
- generic path marker if necessary.
|
||||
|
||||
Never show:
|
||||
|
||||
- username;
|
||||
- password;
|
||||
- query string;
|
||||
- fragment;
|
||||
- subscription token path;
|
||||
- full proxy credentials.
|
||||
|
||||
Bad:
|
||||
|
||||
```text
|
||||
https://user:password@example.com/...
|
||||
```
|
||||
|
||||
Good:
|
||||
|
||||
```text
|
||||
https://example.com/...
|
||||
```
|
||||
|
||||
## Subscription fetch hardening
|
||||
|
||||
- Add connect/read timeout.
|
||||
- Accept only `http` and `https` unless explicitly designed otherwise.
|
||||
- Consider blocking loopback/private/link-local/multicast/metadata addresses by default.
|
||||
- Add explicit allow-local option only if needed.
|
||||
- Do not follow redirects into blocked address ranges without re-check.
|
||||
- Avoid storing remote body in logs.
|
||||
|
||||
## Temp/elevated script hardening
|
||||
|
||||
Runtime-generated elevated scripts must:
|
||||
|
||||
- use unpredictable names, preferably UUID/random;
|
||||
- be written to a safe controlled directory where possible;
|
||||
- set restrictive ACL when practical;
|
||||
- be generated from static templates with escaped parameters;
|
||||
- avoid including secrets in command line args;
|
||||
- be cleaned up best-effort;
|
||||
- fail closed if path validation fails.
|
||||
|
||||
Timestamp-only temp names are not enough.
|
||||
|
||||
## Atomic writes
|
||||
|
||||
For config files used by services:
|
||||
|
||||
1. Write to temp file in same directory.
|
||||
2. Validate temp file if validator exists.
|
||||
3. Backup current file.
|
||||
4. Rename temp to final.
|
||||
5. On failure, preserve backup and return actionable error.
|
||||
|
||||
## Safe delete checklist
|
||||
|
||||
Before recursive delete:
|
||||
|
||||
- Is path absolute?
|
||||
- Is it under expected managed root?
|
||||
- Does it contain ProxyWarden marker metadata?
|
||||
- Does service PathName point inside this directory?
|
||||
- Is it not drive root, user profile root, Desktop, ProgramData root, Windows directory, temp root?
|
||||
- Is user action explicit?
|
||||
|
||||
If answer is unclear, do not delete.
|
||||
|
||||
## Final report expectations
|
||||
|
||||
When touching security-sensitive code, report:
|
||||
|
||||
- what threat was addressed;
|
||||
- what was hardened;
|
||||
- what remains unverified;
|
||||
- whether any secrets could appear in logs/UI;
|
||||
- whether Windows elevated path was tested.
|
||||
|
||||
## Как отчитываться
|
||||
|
||||
Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`.
|
||||
|
||||
Минимум для нетривиальной задачи:
|
||||
|
||||
- короткая сводка;
|
||||
- таблица файлов `Файл / Что изменилось / Зачем`;
|
||||
- важные места без пересказа каждой строки;
|
||||
- что проверено;
|
||||
- что не проверено;
|
||||
- конкретные риски.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Skill: Subscriptions and Routing
|
||||
|
||||
## Когда использовать
|
||||
|
||||
Используй этот skill при изменениях в external SOCKS5 flow, Local sing-box flow, subscription fetching/parsing, server selection, ping/check route, ProxiFyre config generation или sing-box config generation.
|
||||
|
||||
## Mental model
|
||||
|
||||
Supported route shapes:
|
||||
|
||||
```text
|
||||
selected Windows apps -> ProxiFyre -> external SOCKS5 proxy
|
||||
```
|
||||
|
||||
```text
|
||||
selected Windows apps -> ProxiFyre -> 127.0.0.1:1080 Local sing-box -> selected subscription server
|
||||
```
|
||||
|
||||
ProxiFyre is the per-app router. Sing-box is optional local outbound runtime.
|
||||
|
||||
## Invariants
|
||||
|
||||
- External SOCKS5 must work without Local sing-box.
|
||||
- Local sing-box route requires installed/configured/running sing-box and selected server.
|
||||
- UI route chain must match generated backend config.
|
||||
- Subscription URL is secret.
|
||||
- Server identity should not rely only on non-unique human tag forever.
|
||||
- Generated config should be validated before apply/start where possible.
|
||||
|
||||
## Subscription rules
|
||||
|
||||
When changing subscription parsing:
|
||||
|
||||
- Preserve JSON outbound support.
|
||||
- Do not claim support for link formats that parser does not implement.
|
||||
- If adding VMess/Trojan/Shadowsocks link parsing, add tests for each.
|
||||
- Keep unsupported outbound types visible as unsupported, not silently dropped if this affects user expectation.
|
||||
- Redact subscription URL and outbound secrets in logs/UI.
|
||||
|
||||
## Ping/check rules
|
||||
|
||||
- Network checks should have timeout.
|
||||
- Checks should be cancel-safe where possible.
|
||||
- Do not make route check mutate config/service state.
|
||||
- Do not store external IP probe result as secret, but avoid over-logging.
|
||||
- Make it clear if check verifies local sing-box only, external proxy only, or full route.
|
||||
|
||||
## Config generation
|
||||
|
||||
For ProxiFyre:
|
||||
|
||||
- Respect selected app targets.
|
||||
- Deduplicate carefully, preferably case-insensitive where Windows semantics apply.
|
||||
- Validate target kind semantics: process name vs exe path vs folder.
|
||||
- Write config atomically.
|
||||
|
||||
For sing-box:
|
||||
|
||||
- Validate selected server/outbound exists.
|
||||
- Avoid duplicate tag ambiguity by introducing stable id if needed.
|
||||
- Run `sing-box check` when binary is available.
|
||||
- Avoid writing secrets to temp files outside safe app directories.
|
||||
|
||||
## Tests to add for changes
|
||||
|
||||
- External route without sing-box.
|
||||
- Local route with selected server.
|
||||
- Missing ProxiFyre blocks apply.
|
||||
- Missing sing-box blocks local route apply.
|
||||
- Duplicate server tags.
|
||||
- Redacted subscription display.
|
||||
- Unsupported subscription formats.
|
||||
- Timeout/fetch failure.
|
||||
- Config generation produces expected route chain.
|
||||
|
||||
## Как отчитываться
|
||||
|
||||
Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`.
|
||||
|
||||
Минимум для нетривиальной задачи:
|
||||
|
||||
- короткая сводка;
|
||||
- таблица файлов `Файл / Что изменилось / Зачем`;
|
||||
- важные места без пересказа каждой строки;
|
||||
- что проверено;
|
||||
- что не проверено;
|
||||
- конкретные риски.
|
||||
@@ -0,0 +1,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 риски.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Change Report
|
||||
|
||||
## Коротко
|
||||
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Что изменилось | Зачем |
|
||||
|---|---|---|
|
||||
| `path/file` | | |
|
||||
|
||||
## Важные детали
|
||||
|
||||
-
|
||||
|
||||
## Проверки
|
||||
|
||||
- ✅/⚠️ `command` — результат простыми словами.
|
||||
|
||||
## Не проверено
|
||||
|
||||
-
|
||||
|
||||
## Риски / что потом
|
||||
|
||||
-
|
||||
@@ -0,0 +1,30 @@
|
||||
# Concise Change Summary Template
|
||||
|
||||
## Коротко
|
||||
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
## Что изменилось по файлам
|
||||
|
||||
| Файл | Что изменилось | Зачем |
|
||||
|---|---|---|
|
||||
| `path/to/file` | | |
|
||||
|
||||
## Важные места
|
||||
|
||||
- `path/to/file`, функция/секция:
|
||||
- `path/to/file`, функция/секция:
|
||||
|
||||
## Проверено
|
||||
|
||||
-
|
||||
|
||||
## Не проверено
|
||||
|
||||
-
|
||||
|
||||
## Риски
|
||||
|
||||
-
|
||||
@@ -0,0 +1,20 @@
|
||||
# File Impact Map Template
|
||||
|
||||
Используй для плана или ревью, когда нужно заранее показать, какие файлы будут затронуты.
|
||||
|
||||
| Зона | Файлы | Что будет сделано | Почему это нужно | Риск |
|
||||
|---|---|---|---|---|
|
||||
| Frontend API | `src/api/tauriCommands.ts` | | | Low/Medium/High |
|
||||
| Frontend UI | `src/app/...` | | | Low/Medium/High |
|
||||
| Backend command | `src-tauri/src/...` | | | Low/Medium/High |
|
||||
| Storage/config | `src-tauri/src/storage.rs` | | | Low/Medium/High |
|
||||
| Windows/elevation | `scripts/*.ps1` | | | Low/Medium/High |
|
||||
| Docs/agent | `.agent/...` | | | Low/Medium/High |
|
||||
|
||||
## Что не трогаем
|
||||
|
||||
-
|
||||
|
||||
## Как проверить после изменений
|
||||
|
||||
-
|
||||
@@ -0,0 +1,27 @@
|
||||
# Investigation Report
|
||||
|
||||
## Коротко
|
||||
|
||||
- Главный вывод:
|
||||
- Где проблема:
|
||||
- Что делать первым:
|
||||
|
||||
## Что смотрел
|
||||
|
||||
| Файл / зона | Зачем смотрел | Вывод |
|
||||
|---|---|---|
|
||||
| `path/file` | | |
|
||||
|
||||
## Находки
|
||||
|
||||
| Приоритет | Где | Что не так | Как исправить |
|
||||
|---|---|---|---|
|
||||
| Критично / Важно / Можно потом / Косметика | `path/file` | | |
|
||||
|
||||
## Проверки
|
||||
|
||||
-
|
||||
|
||||
## Ограничения анализа
|
||||
|
||||
-
|
||||
@@ -0,0 +1,32 @@
|
||||
## Коротко
|
||||
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
## Файлы / зоны
|
||||
|
||||
| Файл / зона | Что изменилось | Зачем |
|
||||
|---|---|---|
|
||||
| `path/file` | | |
|
||||
|
||||
## Пользовательское поведение
|
||||
|
||||
- Что пользователь увидит:
|
||||
- Что не должно измениться:
|
||||
|
||||
## Технические детали
|
||||
|
||||
-
|
||||
|
||||
## Проверки
|
||||
|
||||
- [ ] `npm run build`
|
||||
- [ ] `cargo fmt --all -- --check`
|
||||
- [ ] `cargo clippy --all-targets --all-features -- -D warnings`
|
||||
- [ ] `cargo test --all-targets`
|
||||
- [ ] Windows manual smoke, если затронуты service/elevation/install/routing
|
||||
|
||||
## Не проверено / риски
|
||||
|
||||
-
|
||||
@@ -0,0 +1,27 @@
|
||||
# User-facing Summary
|
||||
|
||||
## Коротко
|
||||
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
## Что изменилось простыми словами
|
||||
|
||||
-
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Что изменилось | Зачем |
|
||||
|---|---|---|
|
||||
| `path/file` | | |
|
||||
|
||||
## Что важно знать
|
||||
|
||||
-
|
||||
|
||||
## Проверки и риски
|
||||
|
||||
- ✅ Проверено:
|
||||
- ⚠️ Не проверено:
|
||||
- Риск:
|
||||
@@ -0,0 +1,23 @@
|
||||
# Work Plan
|
||||
|
||||
## Коротко
|
||||
|
||||
Сделаю так:
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Какие файлы, вероятно, затрону
|
||||
|
||||
| Файл / зона | Что планируется | Зачем |
|
||||
|---|---|---|
|
||||
| `path/file` | | |
|
||||
|
||||
## Что проверю
|
||||
|
||||
-
|
||||
|
||||
## Что может остаться непроверенным
|
||||
|
||||
-
|
||||
@@ -0,0 +1,4 @@
|
||||
# `cargo run` / `tauri dev` should control the components installed by ProxyWarden,
|
||||
# not copies that happen to exist beside target\debug\proxywarden.exe.
|
||||
[env]
|
||||
PROXYWARDEN_DEV_INSTALL_ROOT = { value = 'C:\Program Files\ProxyWarden', force = false }
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: design-proxywarden-ui
|
||||
description: Design, implement, review, or refine ProxyWarden UI using the shared calm monospace VPN-client language: centered state control, green-tinted neutrals, route-aware accents, stable geometry, and smooth state-driven motion. Use for React components, CSS, service controls, routing views, tooltips, status transitions, and responsive polish in this repository.
|
||||
---
|
||||
|
||||
# Design ProxyWarden UI
|
||||
|
||||
Keep ProxyWarden a compact Windows utility while matching the visual language of the sibling VPN client.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read `AGENTS.md`, `.agent/skills/react-typescript-ui/SKILL.md`, and the complete component and CSS being changed.
|
||||
2. Read [visual-language.md](references/visual-language.md) for composition, typography, color, and surfaces.
|
||||
3. Read [motion-and-interaction.md](references/motion-and-interaction.md) for state and interaction animation.
|
||||
4. Before editing a disclosure or motion-heavy control, write a compact storyboard for `collapsed`, `opening`, `open`, and `closing`: fixed elements, origin, direction, duration, easing, focus, and reduced-motion behavior.
|
||||
5. Reuse `src/ui/*`, existing state, CSS tokens, and typed Tauri boundaries. Prefer CSS and narrow markup changes over dependencies or new abstractions.
|
||||
6. Keep geometry stable across loading, success, error, copy, refresh, and route changes.
|
||||
7. Add `prefers-reduced-motion` behavior with every new animation.
|
||||
8. After a second user correction to the same interaction, stop stacking overrides. Re-read its markup and styles, restate the latest behavior, remove superseded assumptions, and rebuild the motion model cleanly.
|
||||
9. Run `npm test`, `npm run build`, and an interaction smoke for visible motion or hit-target changes. Check desktop and narrow layouts; build and lint never substitute for visual verification.
|
||||
|
||||
## Non-negotiable decisions
|
||||
|
||||
- Preserve explicit install, start, stop, uninstall, and apply actions. Styling must not blur operational meaning.
|
||||
- Keep the summary read-only except for its existing service power action; do not add configuration mutations there.
|
||||
- Render the primary power action as a generous invisible hit target around the icon, not a large filled accent circle.
|
||||
- Use the blue-green accent for ready/active routing and orange only for direct/local-route distinction. Keep warnings and errors semantic.
|
||||
- Prefer open composition, quiet surface shifts, and localized light over dashboard cards, thick borders, and decorative chrome.
|
||||
- Animate opacity, blur, glow, color, filter, and transform; never animate layout properties or use `transition: all`.
|
||||
- Keep interactive triggers at one screen position throughout disclosure motion. Use layout for resting placement, never a transform that hover or active feedback can overwrite.
|
||||
- Make transient prompts independent overlays; they must not add shell height or move the main workspace.
|
||||
- Keep labels, paths, status copy, spinners, and feedback in reserved geometry so neighboring content does not move.
|
||||
- Keep tooltips independent from transformed, rotating, glowing, or filtered controls.
|
||||
- Keep secrets and credential-bearing URLs redacted in every visual state.
|
||||
- Keep narrow layouts single-column and keyboard focus visible.
|
||||
- Do not call a motion task complete without checking open, close, repeated toggle, hover during transition, keyboard focus, and reduced motion. If the state cannot be reproduced, report the missing visual evidence explicitly.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Motion and interaction
|
||||
|
||||
## Character
|
||||
|
||||
Use fluid, slightly viscous motion that makes work and state legible without moving layout. Avoid bounce, elastic easing, abrupt unmounts, and decorative page choreography.
|
||||
|
||||
Use `cubic-bezier(0.16, 1, 0.3, 1)` for arrivals and interaction feedback.
|
||||
|
||||
- hover and press: 180-300ms;
|
||||
- popover/tooltip: 90-180ms;
|
||||
- panel reveal: 420-600ms;
|
||||
- state color and glow: 600-900ms;
|
||||
- progress or numeric tween: about 900ms.
|
||||
|
||||
## State controls
|
||||
|
||||
- Transition inactive gray to the route accent slowly when a service becomes active, and back to gray when stopped.
|
||||
- Animate icon color, localized light, and SVG shadow together while keeping the hit target fixed.
|
||||
- Use a short `scale(0.97)` press followed by a slower release.
|
||||
- Show checking and running work with restrained motion that finishes cleanly; do not stop spinners or cycles at arbitrary coordinates.
|
||||
|
||||
## Changing content
|
||||
|
||||
- Crossfade alternate labels inside one fixed slot. Do not replace text in normal flow when its length can move the interface.
|
||||
- Animate only what changed. Unchanged labels, icons, surrounding rows, and route nodes stay fixed.
|
||||
- Update data immediately when it arrives; finishing a decorative cycle must not delay the result.
|
||||
- Repeated background polling updates quietly and does not replay entrance choreography.
|
||||
- Keep mode selectors outside the keyed content they replace. Let the new content enter with a short directional fade and blur while focus remains on the selected mode.
|
||||
- For user-triggered sorting, fade and lightly blur the reordered list as one surface; row stagger stays bounded and saved data order does not change.
|
||||
|
||||
## Anchored disclosures
|
||||
|
||||
- Keep the trigger fixed while its surface opens and closes. Position its resting hit area with grid, flex, or logical inset properties; never rely on a placement `transform` that hover or active feedback can replace.
|
||||
- Give the surface, background, copy, and actions one origin and one timeline. They should emerge from the trigger together; do not make the background pop before the trigger or appear after the content.
|
||||
- Keep animated disclosure content mounted through entry and exit. Gate pointer and keyboard access separately; conditional rendering directly into the final state is not an entrance animation.
|
||||
- Design opening and closing together. Preserve visible reverse motion long enough before fading opacity, and keep both directions interruptible under repeated clicks.
|
||||
- Let explicit product feedback override the default easing. When a component calls for a slow start followed by acceleration, define a local curve instead of forcing the global ease-out.
|
||||
- Compose hover and active feedback without changing the resting position. If transform composition is unavoidable, use separate wrappers, individual transform properties, or shared custom properties and verify every state.
|
||||
- Keep decorative sweeps subordinate to state motion, low-opacity, bounded to the surface, and finished cleanly. The disclosure must remain legible without the effect.
|
||||
|
||||
## Lists and disclosures
|
||||
|
||||
- Reveal dynamic rows with opacity, light blur, and a small transform.
|
||||
- On hover, let a row lift one or two pixels and reveal a restrained local surface/light; keep resting rows visually flat.
|
||||
- Animate status dots through color, light, and a small scale change instead of animating a surrounding badge or border.
|
||||
- Keep departing rows and disclosures mounted until their exit animation completes; remove immediately under reduced motion.
|
||||
- Bound list stagger to 60-100ms and never make interaction latency grow with list length.
|
||||
- Tooltips appear quickly above the trigger as independent translucent surfaces and never inherit trigger transforms or filters.
|
||||
|
||||
## Reduced motion
|
||||
|
||||
Under `prefers-reduced-motion: reduce`, remove transforms, filters, transitions, and keyframes while preserving final state, focus, contrast, status wording, and all functionality.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Visual language
|
||||
|
||||
## Character
|
||||
|
||||
Design for a Windows user opening a small control surface to check routing, recover a service, or apply one deliberate configuration change. The UI should feel soft, precise, dependable, and slightly terminal-like, not like a network administration dashboard.
|
||||
|
||||
## Composition
|
||||
|
||||
- Make current system state and the next safe action dominant.
|
||||
- Keep the summary power control visually centered and pair it with a compact vertical route chain.
|
||||
- Use open space, typography, subtle surface shifts, localized light, and state color before frames or dividers.
|
||||
- Keep service rows compact: status, human-readable detail, one primary action, then secondary actions.
|
||||
- Preserve the existing tabs and operational grouping; visual consistency does not justify moving ownership or hiding actions.
|
||||
|
||||
## Typography and geometry
|
||||
|
||||
- Use JetBrains Mono with uppercase tracked micro-labels only for metadata.
|
||||
- Use weight and color before large size jumps. Use tabular numerals for changing values.
|
||||
- Reserve equal space for mutually exclusive labels and feedback.
|
||||
- Use 8px controls, 10px surfaces, and pills only for status tokens.
|
||||
- Keep icon-only hit areas at least 40px and align icons in flex/grid rather than guessed offsets.
|
||||
|
||||
## Color and light
|
||||
|
||||
- Base dark surfaces on green-tinted OKLCH neutrals around hue 145.
|
||||
- Use blue-green `oklch(0.68 0.11 185)` as the primary active/focus accent.
|
||||
- Use orange `oklch(0.71 0.12 72)` for direct/local-route distinction, never as general decoration.
|
||||
- Keep warning/error colors semantic. Do not recolor destructive actions with the route accent.
|
||||
- Prefer localized `drop-shadow`, text glow, or a soft radial light layer over filled accent containers.
|
||||
- Keep inactive power neutral even on hover; color communicates state, not clickability alone.
|
||||
|
||||
## Surfaces and controls
|
||||
|
||||
- Use quiet translucent cloud surfaces for tooltips and transient overlays.
|
||||
- Inputs are inset and slightly darker than surrounding surfaces.
|
||||
- Avoid nested cards. Group related controls with spacing and one subtle surface shift.
|
||||
- Keep persistent work surfaces borderless by default. Use a border only when it communicates input focus, destructive confirmation, or another essential state.
|
||||
- Render statuses and counters as a glowing dot or quiet value plus text, not as bordered badge capsules.
|
||||
- Let service rows, route nodes, app rows, and server rows float on the shared canvas; reveal their surface only on hover, focus, selection, or active work.
|
||||
- Prefer a short luminous underline or localized glow for selection and keyboard focus over a rectangular focus frame.
|
||||
- Use shared `src/ui` primitives and preserve their default, hover, active, focus, disabled, loading, empty, and error states.
|
||||
|
||||
## Emphasis and border budget
|
||||
|
||||
- Give each compact surface one dominant accent at most. A transient warning action must not outshine the primary system state or its trigger.
|
||||
- Do not stack borders on the container, trigger, and action. Start with tonal background, spacing, and text hierarchy; keep persistent outlines for keyboard focus, destructive confirmation, or an otherwise ambiguous hit target.
|
||||
- Treat warm warning color as a restrained semantic tint, not decorative fill or a large glow. Adapt a shared `primary` button locally when its default emphasis conflicts with the surrounding prompt.
|
||||
- Validate the complete component, not isolated controls: resting, hover, focus, active, disabled, loading, open, and closed states must share one radius and emphasis language.
|
||||
|
||||
## Route checks
|
||||
|
||||
- Keep the route description, endpoint, and check action in a stable three-part row. Reserve the action width so mode changes and endpoint length never move the button.
|
||||
- Present the endpoint as the named route target, not as a detached badge or a second result.
|
||||
- Reveal a borderless result surface only while a check is running or after it completes. Show every returned probe in a structured table with separate status, external IP, and latency columns; do not compress unlike values into mixed badges or hardcode a fixed probe count.
|
||||
- Keep the summary short. Put verbose URLs, request methods, status codes, and errors in a calm structured detail cloud opened by hovering or focusing the result surface.
|
||||
- Animate result arrival and status light, while preserving the same geometry and honoring reduced motion.
|
||||
|
||||
## Route chain semantics
|
||||
|
||||
- Show only stages with distinct user-facing responsibilities. Never render both `Выход` and `SOCKS5 endpoint` when they describe the same destination.
|
||||
- Use `Приложения → ProxiFyre → SOCKS5` for the external-proxy route. End a direct route with `Интернет: напрямую` instead of an implementation-stage label.
|
||||
- Explain each stage in plain Russian for a non-technical user. Omit filesystem paths, ports, service names, and generated-config details unless the user explicitly asks for diagnostics.
|
||||
- Reserve the final chain height before revealing nodes. Progressive arrival may change opacity, blur, or transform, but must not reflow neighboring content.
|
||||
- Treat progressive arrival as a presentation sequence over reserved slots. Do not serialize independent backend probes just to match the animation; if the API returns one aggregate snapshot, show calm `checking` placeholders and replace them in place.
|
||||
- Reveal the initial chain in a short, legible sequence and do not replay it for background polling or quiet status refreshes.
|
||||
|
||||
## Admin elevation prompt
|
||||
|
||||
- Render the prompt as a fixed bottom-right overlay that never changes shell height or shifts the workspace. Offset it above persistent bottom docks instead of covering them.
|
||||
- Keep the collapsed trigger as a stationary 44px warm shield. Show a concise hint once per application session after admin status is known, then dismiss it automatically.
|
||||
- On click, expand the surface leftward from the shield while the shield stays in the same screen position. Keep the full row height tied to the trigger.
|
||||
- Reveal background, copy, and action from the same origin and timeline. Use a roughly 520-560ms slow-start opening and a visible 380-420ms reverse close; never delay the background until the end.
|
||||
- Keep any light pass subtle, local, and optional. It must not replace the actual surface/content motion.
|
||||
- Use the concise title `Нужны права администратора`, the reason `Для управления ProxiFyre и правилами Windows.`, and the action `Перезапустить`. Do not show paths or elevation internals.
|
||||
- Keep the surface and action borderless by default. Use a muted warm tint; the action must remain quieter than the shield and main system state.
|
||||
- Verify the Russian copy, `Открываю UAC`, hover, focus, repeated toggle, narrow width, and Windows text scaling without clipping or layout movement.
|
||||
|
||||
## Route modes and managed lists
|
||||
|
||||
- Present external and local proxy routes as two peer choices above the content they replace. Keep the chooser mounted while the mode body crossfades in from the selected direction.
|
||||
- Reserve the same configuration-stage height for both routes and place it before route diagnostics, so mode-specific labels and controls remain aligned even when check results expand.
|
||||
- Use the blue-green accent for the external route and the warm route accent for Local sing-box. A small status light and quiet surface shift are enough; do not add a long selection rule.
|
||||
- A green service light means running, not merely installed. Installed-without-service, stopped, and missing states remain warning-colored.
|
||||
- Hovering service and application rows reveals a neutral side marker and slight positional response. Do not place a green radial wash behind the entire row.
|
||||
- Application grouping is display-only. Preserve saved order as the default, provide explicit Processes, EXE files, and Folders sections with counts, and keep alphabetical sorting as a separate option. Remount only the visible list surface so changes can fade into place.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
description: ProxyWarden repository rules for Cursor agents
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# ProxyWarden Cursor Rules
|
||||
|
||||
Read `AGENTS.md` before editing. Use `.agent/skills/*/SKILL.md` for task-specific guidance.
|
||||
|
||||
## Core rules
|
||||
|
||||
- Keep ProxyWarden a standalone Windows desktop utility: Tauri 2 + React/TypeScript + Rust.
|
||||
- Preserve separation between Control App, ProxiFyre and Local sing-box.
|
||||
- External SOCKS5 route must work without sing-box.
|
||||
- UI must call backend through `src/api/tauriCommands.ts`, not direct random `invoke(...)` calls.
|
||||
- Backend must validate all inputs even if UI validates them.
|
||||
- Do not leak subscription URLs, proxy credentials or outbound secrets.
|
||||
- Keep install/start/stop/uninstall explicit.
|
||||
- Do not relax safe deletion or elevated script rules.
|
||||
- Prefer shrinking `src-tauri/src/commands.rs` and `src/app/App.tsx` over adding more logic there.
|
||||
- Run relevant checks and state unverified Windows/elevation behavior honestly.
|
||||
|
||||
## Communication rules
|
||||
|
||||
Before non-trivial answers, follow `.agent/skills/communication-reporting/SKILL.md` and `.agent/checklists/communication.md`.
|
||||
|
||||
- Write final answers in short, structured Russian unless the user asks otherwise.
|
||||
- Start with `Коротко` for nontrivial work.
|
||||
- For 2+ files, use a table with `Файл / Что изменилось / Зачем`.
|
||||
- Do not dump every diff line. Mention important functions/sections only.
|
||||
- Clearly split `Проверено` and `Не проверено`.
|
||||
- Do not claim Windows/UAC/service checks were done unless they actually ran.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Copilot Instructions for ProxyWarden
|
||||
|
||||
Read `AGENTS.md` first. Follow the repo invariants there.
|
||||
|
||||
## Key reminders
|
||||
|
||||
- This is a standalone Windows Tauri 2 + React/TypeScript + Rust app.
|
||||
- Do not turn it into a SaaS, gateway, server or cloud control plane.
|
||||
- Do not call `invoke(...)` outside `src/api/tauriCommands.ts`.
|
||||
- Do not make Local sing-box required for external SOCKS5 routing.
|
||||
- Do not hide install/start/stop/uninstall behind apply.
|
||||
- Do not log or display full subscription URLs, proxy credentials or outbound secrets.
|
||||
- Keep Tauri capabilities minimal and CSP enabled.
|
||||
- Treat `commands.rs` and `App.tsx` as large legacy orchestration files that should shrink over time.
|
||||
- For security-sensitive changes, read `.agent/skills/security-hardening/SKILL.md`.
|
||||
- For Windows service/elevation changes, read `.agent/skills/windows-services-powershell/SKILL.md`.
|
||||
|
||||
## Reporting style
|
||||
|
||||
Before non-trivial answers, follow `.agent/skills/communication-reporting/SKILL.md` and `.agent/checklists/communication.md`.
|
||||
|
||||
- Start with `Коротко`: 2-4 main points.
|
||||
- For code changes, include a `Файл / Что изменилось / Зачем` table.
|
||||
- Explain only important behavior, safety, UX and risk points. Do not retell every line.
|
||||
- Split checks into `Проверено` and `Не проверено`.
|
||||
- State unverified Windows/UAC/service behavior honestly.
|
||||
@@ -0,0 +1,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
|
||||
@@ -1,4 +1,6 @@
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
*.tsbuildinfo
|
||||
dist/
|
||||
releases/
|
||||
src-tauri/target/
|
||||
|
||||
@@ -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.
|
||||
- ProxiFyre - обязательный слой для per-app routing; Local sing-box - необязательный runtime.
|
||||
- Внешний SOCKS5 flow должен работать без установленного Local sing-box.
|
||||
- Profile apply не должен скрыто устанавливать, удалять, запускать или чинить компоненты. Install/start/stop/uninstall - только явные действия пользователя.
|
||||
- Source of truth - JSON под `C:\ProgramData\ProxyWarden\config` и `state`.
|
||||
- `C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json` и `sing-box-config.json` - derived artifacts, их можно пересоздать.
|
||||
- Subscription URL и другие секреты нельзя показывать полностью в UI, diagnostics или логах.
|
||||
- Summary panel должен оставаться read-only: без apply/install/start/stop/delete/input/subscription mutations.
|
||||
Проект не должен превращаться в коммерческий SaaS, Node gateway, VPN-провайдер, proxy server или облачный control plane. Это локальная Windows-утилита для себя и друзей.
|
||||
|
||||
Цель: надежно и понятно конфигурировать маршрутизацию выбранных приложений через внешний SOCKS5 proxy или через локальный sing-box, не ломая системную сеть и не пряча опасные действия за безобидными кнопками.
|
||||
|
||||
## Архитектурные инварианты
|
||||
|
||||
- Control App, ProxiFyre и Local sing-box — разные компоненты. Не смешивать их ответственность.
|
||||
- ProxiFyre — обязательный слой для per-app routing.
|
||||
- Local sing-box — optional runtime. Внешний SOCKS5 flow обязан работать без sing-box.
|
||||
- React UI не пишет generated config напрямую. UI вызывает typed Tauri commands.
|
||||
- `src/api/tauriCommands.ts` — единственная TypeScript-обертка над `invoke(...)`.
|
||||
- Rust backend отвечает за storage, validation, config generation, component detection, service/install orchestration и structured errors.
|
||||
- `C:\ProgramData\ProxyWarden\config` и `C:\ProgramData\ProxyWarden\state` — source of truth.
|
||||
- `C:\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.
|
||||
- Не писать generated config напрямую из React.
|
||||
- Не парсить raw PowerShell/stdout в UI. Backend/helper boundary должен возвращать structured JSON/error DTO.
|
||||
- Не привязывать UI напрямую к деталям ProxiFyre, если изменение относится к общему proxy-router поведению.
|
||||
- Не делать Local sing-box обязательным для external target.
|
||||
- Для service/install операций сохранять UAC/admin boundary и человекочитаемые ошибки.
|
||||
- При удалении install folders сохранять safe-path checks; не ослаблять рекурсивное удаление.
|
||||
- В UI держать стиль компактной Windows-утилиты, а не landing/dashboard. Использовать existing `Button`, `Tabs`, `ServiceControlRow`, `StatusPill`, `Field`, `ActionMenu`, `LogDock`.
|
||||
- Всплывающие подсказки при наведении делать быстрыми, кастомными и читаемыми: темная compact-плашка с мягкой рамкой/тенью, появление ~120ms, без нативного browser `title` как основного UI. Для иконок расширять общий `IconButton`/tooltip-паттерн, а не дублировать JSX/CSS локально.
|
||||
- Apply actions должны быть disabled с объяснением, когда нет приложений, ProxiFyre отсутствует, proxy input неверный или local route не готов.
|
||||
- Не оставлять dev-серверы (`npm run dev`, `npm run tauri -- dev`, preview-серверы) запущенными после проверки. Если сервер был поднят агентом, остановить его перед финальным ответом.
|
||||
### Backend
|
||||
|
||||
## Проверка
|
||||
- Не добавлять новую Tauri command без typed wrapper в `src/api/tauriCommands.ts` и соответствующего TypeScript DTO в `src/domain/types.ts`, если command используется UI.
|
||||
- Не возвращать raw strings для сложных ошибок. Использовать structured error DTO: `code`, `message`, `details`.
|
||||
- Тяжелые или блокирующие операции должны быть `async` command + `tauri::async_runtime::spawn_blocking`.
|
||||
- Не вызывать network/process/service/file-heavy logic прямо из async runtime thread.
|
||||
- Не использовать `unwrap()`/`expect()` в production path, кроме очевидно невозможных bootstrap cases с комментарием.
|
||||
- Не писать generated configs неатомарно. Использовать temp + backup + rename where practical.
|
||||
- Не расширять `commands.rs` без необходимости. Для новой логики предпочитать отдельные модули и thin command wrapper.
|
||||
|
||||
Минимум для frontend/UI:
|
||||
### Frontend
|
||||
|
||||
- Не увеличивать `App.tsx`, если можно вынести hook/helper/component.
|
||||
- Не вызывать `invoke(...)` напрямую вне `src/api/tauriCommands.ts`.
|
||||
- Не дублировать apply-readiness проверки в JSX. Расширять `src/app/readiness.ts`.
|
||||
- Для UI использовать существующие компоненты из `src/ui`.
|
||||
- Apply/start/install/delete buttons должны иметь disabled state и понятную причину.
|
||||
- Не показывать secrets. Для subscription/proxy URL использовать redacted display values.
|
||||
- UI должен оставаться compact Windows utility, а не SaaS dashboard с иллюзией корпоративной важности.
|
||||
|
||||
### Security
|
||||
|
||||
- Не отключать CSP. Если CSP мешает, исправлять source policy, а не ставить `csp: null`.
|
||||
- Не добавлять Tauri shell permissions без жесткого scope и отдельного обоснования.
|
||||
- Не запускать произвольные команды из UI input.
|
||||
- Runtime-generated elevated scripts запрещены. 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
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
Rust/backend:
|
||||
Для Rust/backend изменений:
|
||||
|
||||
```powershell
|
||||
cd D:\repos\ProxyWarden\src-tauri
|
||||
cargo test
|
||||
cd src-tauri
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
cargo test --all-targets
|
||||
```
|
||||
|
||||
Tauri/toolchain:
|
||||
Для Tauri/toolchain:
|
||||
|
||||
```powershell
|
||||
npm run tauri -- info
|
||||
@@ -74,18 +207,35 @@ npm run tauri -- dev
|
||||
npm run tauri -- build
|
||||
```
|
||||
|
||||
Installer boundaries:
|
||||
Для offline bundle/release boundaries:
|
||||
|
||||
```powershell
|
||||
& .\scripts\install-control-app.ps1 -PlanOnly
|
||||
& .\scripts\install-proxyfier.ps1 -PlanOnly
|
||||
& .\scripts\install-singbox.ps1 -PlanOnly
|
||||
& .\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
|
||||
```
|
||||
|
||||
Для UI-изменений проверять browser-preview на desktop и narrow viewport. Browser-preview не доказывает native Tauri commands или elevated service lane.
|
||||
Не оставлять dev/preview/Tauri dev servers запущенными после проверки.
|
||||
|
||||
## Известные риски
|
||||
## Формат отчета агента
|
||||
|
||||
- Реальные elevated install/start/stop/uninstall операции для ProxiFyre и Local sing-box считаются `implemented but unproven`, пока они не проверены на Windows с UAC/admin confirmation.
|
||||
- Исторические planning/evidence файлы лежат в ignored `docs`-папках и не должны попадать в коммиты.
|
||||
- Старые документы могут ссылаться на `apps/windows-client`; текущая структура репозитория - standalone client в корне.
|
||||
Использовать один из шаблонов:
|
||||
|
||||
- `.agent/templates/work-plan.md` — короткий план перед работой.
|
||||
- `.agent/templates/change-report.md` — отчет после изменения кода.
|
||||
- `.agent/templates/investigation-report.md` — аудит, расследование, разбор проблемы.
|
||||
- `.agent/templates/user-facing-summary.md` — краткая сводка для владельца проекта.
|
||||
- `.agent/templates/pr-description.md` — описание PR.
|
||||
|
||||
Каждый нетривиальный ответ должен отвечать на вопросы:
|
||||
|
||||
1. Что поменялось или найдено?
|
||||
2. В каких файлах?
|
||||
3. Зачем это нужно?
|
||||
4. Что проверено?
|
||||
5. Что не проверено?
|
||||
6. Где остался риск?
|
||||
|
||||
Не писать «всё готово», если Windows/elevated/service flow не проверялся на Windows. Эта фраза и так слишком много навредила миру.
|
||||
|
||||
@@ -0,0 +1,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 риски.
|
||||
@@ -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.
|
||||
@@ -1,35 +1,36 @@
|
||||
# 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.
|
||||
- Маршрутизирует не всю систему, а выбранные приложения: процесс, папку или конкретный `.exe`.
|
||||
- Не меняет глобальный proxy в Windows.
|
||||
- Для per-app routing нужен ProxiFyre.
|
||||
- Local sing-box нужен только для сценария с подпиской и локальным SOCKS5 endpoint.
|
||||
- Внешний SOCKS5-прокси работает без Local sing-box.
|
||||
- Применение профиля не устанавливает и не чинит компоненты скрыто.
|
||||
- Tauri 2 + React/TypeScript UI + Rust backend.
|
||||
- Маршрутизируются выбранные процессы, папки или `.exe`, а не вся система.
|
||||
- Глобальный Windows proxy не меняется.
|
||||
- Внешний SOCKS5 работает без Local sing-box.
|
||||
- Production runtime не запускает PowerShell: service/install/UAC orchestration принадлежит native Rust.
|
||||
- x64 installer содержит проверенные offline payloads компонентов и WebView2 Offline Installer; сеть для baseline-установки не нужна.
|
||||
|
||||
## Из чего состоит
|
||||
## Компоненты
|
||||
|
||||
| Компонент | Что это | Нужен когда | Откуда берется |
|
||||
| --- | --- | --- | --- |
|
||||
| ProxyWarden Control App | Окно управления, настройки, status/readiness, генерация конфигов | Всегда | Этот репозиторий |
|
||||
| [ProxiFyre](https://github.com/wiresock/proxifyre) | Windows-приложение/служба для перехвата трафика выбранных процессов и отправки его в SOCKS5 | Всегда для маршрутизации приложений | GitHub releases `wiresock/proxifyre` |
|
||||
| [Windows Packet Filter / NDISAPI](https://github.com/wiresock/ndisapi) | Сетевой драйвер, который нужен ProxiFyre | Устанавливается вместе с ProxiFyre, если отсутствует | GitHub releases `wiresock/ndisapi` |
|
||||
| [Microsoft Visual C++ Redistributable](https://learn.microsoft.com/cpp/windows/latest-supported-vc-redist) | Runtime-зависимость для `ProxiFyre.exe` | Устанавливается вместе с ProxiFyre, если отсутствует | Официальный `vc_redist` Microsoft |
|
||||
| [sing-box](https://github.com/SagerNet/sing-box) | Локальный proxy/VPN runtime, который слушает `127.0.0.1:1080` | Только для маршрута через subscription/выбранный сервер | GitHub releases `SagerNet/sing-box` |
|
||||
| [WinSW](https://github.com/winsw/winsw) | Wrapper, который запускает Local sing-box как Windows-службу | Только для Local sing-box | GitHub releases `winsw/winsw` |
|
||||
| Компонент | Роль | Когда нужен |
|
||||
| --- | --- | --- |
|
||||
| ProxyWarden Control App | UI, storage, validation, config generation и orchestration | Всегда |
|
||||
| [ProxiFyre](https://github.com/wiresock/proxifyre) | Перехватывает трафик выбранных приложений и направляет его в SOCKS5 | Для любого per-app routing |
|
||||
| [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 | Устанавливается при необходимости |
|
||||
| [sing-box](https://github.com/SagerNet/sing-box) | Создаёт локальный SOCKS5 endpoint для выбранного subscription-сервера | Только для Local sing-box flow |
|
||||
| [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
|
||||
выбранные приложения -> ProxiFyre -> внешний SOCKS5 proxy
|
||||
@@ -38,222 +39,178 @@ ProxyWarden сам не является VPN-драйвером, прокси-с
|
||||
Local sing-box:
|
||||
|
||||
```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 создает desktop-приложение ProxyWarden. Отдельный скрипт `scripts/install-control-app.ps1` сейчас подготавливает стандартные директории:
|
||||
Tauri NSIS installer устанавливает Control App per-machine. Managed runtime-компоненты лежат только под текущим app root:
|
||||
|
||||
```text
|
||||
C:\Program Files\ProxyWarden\ControlApp
|
||||
C:\ProgramData\ProxyWarden\config
|
||||
C:\ProgramData\ProxyWarden\state
|
||||
C:\ProgramData\ProxyWarden\generated
|
||||
C:\Program Files\ProxyWarden
|
||||
C:\Program Files\ProxyWarden\components\ProxiFyre
|
||||
C:\Program Files\ProxyWarden\components\sing-box
|
||||
```
|
||||
|
||||
### ProxiFyre
|
||||
|
||||
Явная установка ProxiFyre из приложения выполняется через elevated PowerShell и ставит/обновляет:
|
||||
Службы:
|
||||
|
||||
```text
|
||||
C:\Tools\ProxiFyre
|
||||
C:\Tools\ProxiFyre\ProxiFyre.exe
|
||||
C:\Tools\ProxiFyre\app-config.json
|
||||
Windows service: ProxiFyreService
|
||||
ProxiFyreService
|
||||
ProxyWardenSingBox
|
||||
```
|
||||
|
||||
Если на машине не найдены зависимости, установщик также скачивает и ставит 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
|
||||
C:\Program Files\ProxyWarden\sing-box\sing-box.exe
|
||||
C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe
|
||||
C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.xml
|
||||
C:\Program Files\ProxyWarden\sing-box\config.json
|
||||
Windows service: ProxyWardenSingBox
|
||||
config\profiles.json
|
||||
config\targets.json
|
||||
config\local-singbox.json
|
||||
config\storage-meta.json
|
||||
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;
|
||||
- установка Windows Packet Filter / NDISAPI;
|
||||
- установка Microsoft Visual C++ Redistributable, если его нет;
|
||||
- установка или удаление Local sing-box;
|
||||
- создание, запуск и остановка Windows-служб;
|
||||
- удаление install folder для managed-компонентов.
|
||||
- install/update/uninstall ProxiFyre или Local sing-box;
|
||||
- установка Windows Packet Filter и VC++ Runtime при необходимости;
|
||||
- start/stop/create/delete Windows-служб;
|
||||
- подтверждённый legacy component cutover и его cleanup.
|
||||
|
||||
Применение профиля не запускает установку. Оно генерирует derived config и пытается записать его в найденную установку ProxiFyre. Если прав на запись в папку установки не хватает, операция должна завершиться ошибкой, а не устанавливать что-то скрыто.
|
||||
|
||||
## Поддержанная среда
|
||||
|
||||
Подтверждено вручную сейчас:
|
||||
|
||||
```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 должны показывать только редактированную/сокращенную версию ссылки.
|
||||
Elevated mode принимает только заранее записанный typed job ID либо один из фиксированных NSIS modes. UI не передаёт произвольную команду, script text или install path.
|
||||
|
||||
## Типовые сценарии
|
||||
|
||||
### Внешний SOCKS5
|
||||
|
||||
1. Запустите ProxyWarden.
|
||||
2. Установите или проверьте ProxiFyre.
|
||||
3. На вкладке `VPN / Прокси` выберите внешний proxy.
|
||||
4. Введите `host:port` или `socks5://host:port`.
|
||||
5. На вкладке `ProxiFyre` добавьте приложения.
|
||||
6. Нажмите `Применить в ProxiFyre`.
|
||||
1. Установите ProxiFyre явной кнопкой, если он отсутствует.
|
||||
2. На вкладке `VPN / Прокси` выберите внешний proxy и укажите `host:port` или `socks5://host:port`.
|
||||
3. Добавьте приложения в ProxiFyre route.
|
||||
4. Нажмите `Применить`.
|
||||
|
||||
Local sing-box для этого сценария не нужен.
|
||||
|
||||
### Local sing-box с подпиской
|
||||
|
||||
1. Запустите ProxyWarden.
|
||||
2. Установите ProxiFyre.
|
||||
3. Установите Local sing-box.
|
||||
4. Вставьте subscription URL.
|
||||
5. Загрузите список серверов и выберите сервер.
|
||||
6. Добавьте приложения.
|
||||
7. Сгенерируйте/примените маршрут.
|
||||
1. Явно установите ProxiFyre и Local sing-box.
|
||||
2. Добавьте subscription URL, загрузите список и выберите сервер.
|
||||
3. Добавьте приложения и примените маршрут.
|
||||
|
||||
## Установка и запуск из исходников
|
||||
## Разработка
|
||||
|
||||
Нужны:
|
||||
|
||||
- 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`.
|
||||
|
||||
Установка зависимостей и запуск:
|
||||
Целевая платформа — Windows 10/11 x64. Для сборки нужны Node.js/npm, Rust через rustup, Visual Studio Build Tools с MSVC и Windows SDK. PowerShell 7 используется только для build/release/QA tooling; установленному приложению PowerShell не нужен.
|
||||
|
||||
```powershell
|
||||
cd D:\repos\ProxyWarden
|
||||
npm install
|
||||
Set-Location D:\repos\ProxyWarden
|
||||
npm ci
|
||||
npm run tauri -- dev
|
||||
```
|
||||
|
||||
Собрать frontend:
|
||||
|
||||
```powershell
|
||||
npm run build
|
||||
```
|
||||
|
||||
Собрать установочный пакет Tauri:
|
||||
|
||||
```powershell
|
||||
npm run tauri -- build
|
||||
```
|
||||
|
||||
Запустить только browser-preview без нативных Tauri-команд:
|
||||
Browser preview не доказывает работу Tauri commands, UAC или Windows-служб:
|
||||
|
||||
```powershell
|
||||
npm run dev -- --host 127.0.0.1
|
||||
```
|
||||
|
||||
Browser-preview годится для проверки интерфейса, но не доказывает работу Windows-служб, elevated-операций и Tauri command handlers.
|
||||
## Проверка
|
||||
|
||||
## Installer-скрипты
|
||||
|
||||
В репозитории есть явные 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:
|
||||
Frontend и Rust:
|
||||
|
||||
```powershell
|
||||
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
|
||||
```
|
||||
|
||||
Rust/backend:
|
||||
|
||||
```powershell
|
||||
cd D:\repos\ProxyWarden\src-tauri
|
||||
cargo test
|
||||
```
|
||||
|
||||
Tauri/toolchain:
|
||||
Build/release/QA boundaries:
|
||||
|
||||
```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 -- dev
|
||||
npm run tauri -- build
|
||||
```
|
||||
|
||||
Installer boundaries:
|
||||
`PlanOnly` и `CheckOnly` возвращают structured JSON с `changed: false`. Обновление packaged component catalog — отдельная release-команда и не является runtime action.
|
||||
|
||||
```powershell
|
||||
& .\scripts\install-control-app.ps1 -PlanOnly
|
||||
& .\scripts\install-proxyfier.ps1 -PlanOnly
|
||||
& .\scripts\install-singbox.ps1 -PlanOnly
|
||||
```
|
||||
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.
|
||||
|
||||
## Ограничения текущей версии
|
||||
## Ограничения
|
||||
|
||||
- Основной поддержанный маршрут - SOCKS5.
|
||||
- ProxiFyre является текущим backend-слоем для per-app routing.
|
||||
- Local sing-box остается опциональным и не требуется для внешнего SOCKS5.
|
||||
- Elevated install/start/stop/uninstall операции считаются реализованными, но требуют дополнительной проверки на реальной Windows-машине с UAC/admin confirmation.
|
||||
- Windows 10, Windows PowerShell 5.1, ARM64 и x86 нужно отдельно подтвердить перед тем, как называть их официально поддержанными.
|
||||
- Основной routing protocol — SOCKS5.
|
||||
- Link subscriptions поддерживают только форматы, которые явно принимает текущий parser; неизвестные поля/форматы отклоняются, а не теряются молча.
|
||||
- Local sing-box остаётся optional.
|
||||
- x86 и ARM64 не входят в текущий release contract.
|
||||
- Реальные Windows service, UAC, driver и offline installer сценарии нельзя считать подтверждёнными без VM evidence.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,60 @@
|
||||
# ProxyWarden Architecture Notes for Agents
|
||||
|
||||
## Current design
|
||||
|
||||
ProxyWarden is a local Windows control app. It does not proxy traffic by itself. It orchestrates:
|
||||
|
||||
1. ProxiFyre for per-app routing.
|
||||
2. Optional local sing-box for subscription-based outbound routing.
|
||||
3. External SOCKS5 target for direct proxy routing.
|
||||
|
||||
## Route modes
|
||||
|
||||
External SOCKS5:
|
||||
|
||||
```text
|
||||
selected Windows apps -> ProxiFyre -> external SOCKS5
|
||||
```
|
||||
|
||||
Local sing-box:
|
||||
|
||||
```text
|
||||
selected Windows apps -> ProxiFyre -> 127.0.0.1:1080 -> sing-box selected outbound
|
||||
```
|
||||
|
||||
## Main risks
|
||||
|
||||
- Large orchestration files: `commands.rs` and `App.tsx`.
|
||||
- Security-sensitive elevated operations.
|
||||
- Secrets in subscription/proxy config.
|
||||
- Non-atomic writes to generated configs.
|
||||
- Stale component status.
|
||||
- Duplicate sing-box server tags.
|
||||
- UI/business logic entanglement.
|
||||
|
||||
## Desired direction
|
||||
|
||||
Backend:
|
||||
|
||||
- Thin Tauri command handlers.
|
||||
- Use-case modules.
|
||||
- Testable pure functions.
|
||||
- Structured errors.
|
||||
- Atomic writes and hardened temp/elevation handling.
|
||||
|
||||
Frontend:
|
||||
|
||||
- Smaller `App.tsx`.
|
||||
- Extracted hooks/components.
|
||||
- Typed API boundary.
|
||||
- Testable readiness/snapshot/proxy parsing logic.
|
||||
- Clear UX for pending changes and service state.
|
||||
|
||||
Security:
|
||||
|
||||
- CSP enabled.
|
||||
- Minimal Tauri capabilities.
|
||||
- No broad shell permission.
|
||||
- Redacted secrets.
|
||||
- Safe deletion.
|
||||
- Explicit Windows service control.
|
||||
@@ -0,0 +1,21 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist/**', 'src-tauri/**'] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
document: 'readonly',
|
||||
HTMLElement: 'readonly',
|
||||
HTMLDivElement: 'readonly',
|
||||
requestAnimationFrame: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
window: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+1608
-3
File diff suppressed because it is too large
Load Diff
+15
-3
@@ -1,13 +1,20 @@
|
||||
{
|
||||
"name": "proxywarden",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Standalone Windows desktop proxy management app for ProxyWarden.",
|
||||
"scripts": {
|
||||
"release": ".\\release.cmd",
|
||||
"test:release": "node --test scripts/prepare-release.check.mjs",
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build": "npm run typecheck && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -19,11 +26,16 @@
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"eslint": "^10.7.0",
|
||||
"prettier": "^3.9.5",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^7.0.0"
|
||||
"typescript-eslint": "^8.63.0",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
@@ -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%
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
components = ["rustfmt", "clippy"]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
+388
-27
@@ -1,4 +1,4 @@
|
||||
param(
|
||||
param(
|
||||
[string]$Version = "",
|
||||
[ValidateSet("", "patch", "minor", "major")]
|
||||
[string]$Bump = "",
|
||||
@@ -6,9 +6,13 @@ param(
|
||||
[switch]$SkipTests,
|
||||
[switch]$SkipBuild,
|
||||
[switch]$PlanOnly,
|
||||
[switch]$Publish,
|
||||
[switch]$Resume,
|
||||
[switch]$Replace,
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$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"
|
||||
$TauriConfigPath = Join-Path $RepoRoot "src-tauri\tauri.conf.json"
|
||||
$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"
|
||||
$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 {
|
||||
param(
|
||||
@@ -143,9 +151,12 @@ function Set-PackageLockVersions {
|
||||
|
||||
function Assert-Semver {
|
||||
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."
|
||||
}
|
||||
foreach ($part in $Value.Split('.')) {
|
||||
if ([long]$part -gt 65535) { throw "Version components must be between 0 and 65535 for Windows." }
|
||||
}
|
||||
}
|
||||
|
||||
function ConvertTo-VersionParts {
|
||||
@@ -206,6 +217,9 @@ function Get-CargoPackageVersion {
|
||||
|
||||
function Get-VersionState {
|
||||
$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]@{
|
||||
packageJson = [string](Get-FirstJsonVersion -Path $PackageJsonPath -Label "package.json")
|
||||
@@ -213,6 +227,7 @@ function Get-VersionState {
|
||||
packageLockRoot = [string]$packageLock.packageLockRoot
|
||||
tauriConfig = [string](Get-FirstJsonVersion -Path $TauriConfigPath -Label "tauri.conf.json")
|
||||
cargoToml = [string](Get-CargoPackageVersion)
|
||||
cargoLock = $cargoMatch.Groups[1].Value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +238,8 @@ function Get-CurrentVersion {
|
||||
$state.packageLock,
|
||||
$state.packageLockRoot,
|
||||
$state.tauriConfig,
|
||||
$state.cargoToml
|
||||
$state.cargoToml,
|
||||
$state.cargoLock
|
||||
) | Select-Object -Unique)
|
||||
|
||||
if ($versions.Count -ne 1) {
|
||||
@@ -247,23 +263,24 @@ function Resolve-TargetVersion {
|
||||
return Get-NextVersion -Current $Current -Kind $Bump
|
||||
}
|
||||
|
||||
if ($PlanOnly -or -not [Environment]::UserInteractive) {
|
||||
if ($PlanOnly) {
|
||||
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"
|
||||
$minor = Get-NextVersion -Current $Current -Kind "minor"
|
||||
$major = Get-NextVersion -Current $Current -Kind "major"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Current version: $Current"
|
||||
Write-Host "Choose release version:"
|
||||
Write-Host "Текущая версия: $Current"
|
||||
Write-Host "Выбери номер или введи версию, например $patch :"
|
||||
Write-Host " 1) patch $patch"
|
||||
Write-Host " 2) minor $minor"
|
||||
Write-Host " 3) major $major"
|
||||
Write-Host " 4) custom"
|
||||
Write-Host " 5) keep current $Current"
|
||||
$choice = Read-Host "Selection [1]"
|
||||
Write-Host " 4) другая версия"
|
||||
Write-Host " 5) текущая $Current (если ещё не выпущена)"
|
||||
$choice = Read-Host "Версия [1]"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($choice)) { $choice = "1" }
|
||||
|
||||
@@ -277,7 +294,7 @@ function Resolve-TargetVersion {
|
||||
return $custom
|
||||
}
|
||||
"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-FirstJsonVersion -Path $TauriConfigPath -TargetVersion $TargetVersion -Label "tauri.conf.json"
|
||||
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 {
|
||||
@@ -354,12 +374,20 @@ function New-ReleaseDirectory {
|
||||
$releaseDir = Join-Path $root "proxywarden-v$TargetVersion"
|
||||
|
||||
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)) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path (Join-Path $releaseDir "artifacts") -Force | Out-Null
|
||||
$releaseDir
|
||||
@@ -386,6 +414,25 @@ function Invoke-NativeCommand {
|
||||
}
|
||||
}
|
||||
|
||||
function Clear-ReleaseBundleOutput {
|
||||
if (-not (Test-Path -LiteralPath $BundleRoot)) {
|
||||
return
|
||||
}
|
||||
|
||||
$targetRoot = Get-FullPath -Path (Join-Path $RepoRoot "src-tauri\target")
|
||||
$bundleFull = Get-FullPath -Path $BundleRoot
|
||||
if (
|
||||
$bundleFull.Equals($targetRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
-not (Test-IsSubPath -Parent $targetRoot -Child $bundleFull)
|
||||
) {
|
||||
throw "Refusing to remove bundle directory outside src-tauri target: $bundleFull"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Cleaning stale Tauri bundle output: $bundleFull"
|
||||
Remove-Item -LiteralPath $bundleFull -Recurse -Force
|
||||
}
|
||||
|
||||
function Invoke-ReleaseBuild {
|
||||
if ($SkipBuild) {
|
||||
Write-Host ""
|
||||
@@ -393,31 +440,93 @@ function Invoke-ReleaseBuild {
|
||||
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) {
|
||||
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 {
|
||||
Write-Host ""
|
||||
Write-Host "Skipping Rust tests because -SkipTests was provided."
|
||||
}
|
||||
|
||||
Invoke-NativeCommand -Name "Tauri release build" -FilePath "npm" -Arguments @("run", "tauri", "--", "build")
|
||||
Invoke-NativeCommand -Name "Frontend build" -FilePath "node" -Arguments @("node_modules/vite/bin/vite.js", "build")
|
||||
Clear-ReleaseBundleOutput
|
||||
# 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 {
|
||||
param([string]$TargetVersion)
|
||||
|
||||
"(^|[^0-9A-Za-z])$([regex]::Escape($TargetVersion))([^0-9A-Za-z]|$)"
|
||||
}
|
||||
|
||||
function Copy-ReleaseArtifacts {
|
||||
param([string]$ReleaseDir)
|
||||
param(
|
||||
[string]$ReleaseDir,
|
||||
[string]$TargetVersion
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BundleRoot)) {
|
||||
throw "Tauri bundle output was not found: $BundleRoot"
|
||||
}
|
||||
|
||||
$artifactDir = Join-Path $ReleaseDir "artifacts"
|
||||
$files = Get-ChildItem -LiteralPath $BundleRoot -Recurse -File |
|
||||
Where-Object { $_.Extension -in @(".exe", ".msi", ".zip", ".sig") }
|
||||
$allFiles = @(Get-ChildItem -LiteralPath $BundleRoot -Recurse -File |
|
||||
Where-Object { $_.Extension -in @(".exe", ".msi", ".zip", ".sig") } |
|
||||
Sort-Object FullName)
|
||||
|
||||
if ($allFiles.Count -eq 0) {
|
||||
throw "No release artifacts were found under $BundleRoot."
|
||||
}
|
||||
|
||||
$versionPattern = Get-ArtifactVersionPattern -TargetVersion $TargetVersion
|
||||
$files = @($allFiles | Where-Object { $_.Name -match $versionPattern })
|
||||
$ignoredFiles = @($allFiles | Where-Object { $_.Name -notmatch $versionPattern })
|
||||
|
||||
if ($files.Count -eq 0) {
|
||||
throw "No release artifacts were found under $BundleRoot."
|
||||
$found = ($allFiles | ForEach-Object { Get-RelativePath -BasePath $BundleRoot -Path $_.FullName }) -join ", "
|
||||
throw "No release artifacts for version $TargetVersion were found under $BundleRoot. Found artifacts: $found"
|
||||
}
|
||||
|
||||
if ($ignoredFiles.Count -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Host "Ignoring bundle artifacts that do not match version ${TargetVersion}:"
|
||||
foreach ($ignored in $ignoredFiles) {
|
||||
Write-Host (" - " + (Get-RelativePath -BasePath $BundleRoot -Path $ignored.FullName))
|
||||
}
|
||||
}
|
||||
|
||||
$copied = @()
|
||||
@@ -456,7 +565,7 @@ function Get-GitValue {
|
||||
param([string[]]$Arguments)
|
||||
|
||||
try {
|
||||
$value = & git @Arguments 2>$null
|
||||
$value = & git --no-optional-locks @Arguments 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
return ($value -join [Environment]::NewLine).Trim()
|
||||
}
|
||||
@@ -464,11 +573,197 @@ function Get-GitValue {
|
||||
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 {
|
||||
param(
|
||||
[string]$ReleaseDir,
|
||||
[string]$TargetVersion,
|
||||
[object[]]$Artifacts
|
||||
[object[]]$Artifacts,
|
||||
[object]$GitRelease = $null
|
||||
)
|
||||
|
||||
$artifactDir = Join-Path $ReleaseDir "artifacts"
|
||||
@@ -487,6 +782,8 @@ function Write-ReleaseMetadata {
|
||||
source = "local"
|
||||
gitCommit = Get-GitValue -Arguments @("rev-parse", "HEAD")
|
||||
gitStatus = Get-GitValue -Arguments @("status", "--short")
|
||||
gitRelease = $GitRelease
|
||||
windowsAcceptance = "not-verified-by-this-command"
|
||||
artifacts = @($artifactItems)
|
||||
}
|
||||
|
||||
@@ -513,7 +810,7 @@ See `SHA256SUMS.txt`.
|
||||
|
||||
## 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.
|
||||
|
||||
"@
|
||||
|
||||
@@ -543,16 +840,34 @@ function New-PlanResult {
|
||||
releaseDirectory = (Join-Path $outputRootFull "proxywarden-v$Target")
|
||||
skipTests = [bool]$SkipTests
|
||||
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 = @(
|
||||
$PackageJsonPath,
|
||||
$PackageLockPath,
|
||||
$TauriConfigPath,
|
||||
$CargoTomlPath
|
||||
$CargoTomlPath,
|
||||
$CargoLockPath
|
||||
)
|
||||
commands = @(
|
||||
"npm run build",
|
||||
"cd src-tauri; cargo test",
|
||||
"npm run tauri -- build"
|
||||
".\scripts\check-runtime-powershell-boundary.ps1 -CheckOnly",
|
||||
".\scripts\update-component-bundle.ps1 -CheckOnly",
|
||||
".\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
|
||||
@@ -561,6 +876,16 @@ function New-PlanResult {
|
||||
try {
|
||||
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
|
||||
$targetVersion = Resolve-TargetVersion -Current $currentVersion
|
||||
Assert-Semver -Value $targetVersion
|
||||
@@ -577,6 +902,28 @@ try {
|
||||
Write-Host ""
|
||||
Write-Host "Preparing ProxyWarden release $targetVersion..."
|
||||
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
|
||||
$afterUpdateVersion = Get-CurrentVersion
|
||||
@@ -584,18 +931,32 @@ try {
|
||||
throw "Version update failed. Current version is $afterUpdateVersion."
|
||||
}
|
||||
|
||||
$sourceTree = if ($Publish) { Get-SourceTree } else { $null }
|
||||
Invoke-ReleaseBuild
|
||||
|
||||
$releaseDir = New-ReleaseDirectory -TargetVersion $targetVersion
|
||||
$artifacts = @(Copy-ReleaseArtifacts -ReleaseDir $releaseDir)
|
||||
$artifacts = @(Copy-ReleaseArtifacts -ReleaseDir $releaseDir -TargetVersion $targetVersion)
|
||||
Write-Checksums -ReleaseDir $releaseDir -Files $artifacts | Out-Null
|
||||
Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts
|
||||
|
||||
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 "Release folder is ready:"
|
||||
Write-Host $releaseDir
|
||||
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 {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+49
-1
@@ -47,6 +47,15 @@ version = "1.0.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -543,6 +552,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
@@ -783,6 +803,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2314,16 +2335,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxywarden"
|
||||
version = "1.0.0"
|
||||
version = "2.0.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"percent-encoding",
|
||||
"quick-xml",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winreg",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4843,6 +4872,25 @@ dependencies = [
|
||||
"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]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
+27
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "proxywarden"
|
||||
version = "1.0.0"
|
||||
version = "2.0.0"
|
||||
description = "Standalone Windows desktop proxy management app for ProxyWarden."
|
||||
authors = ["ProxyWarden"]
|
||||
edition = "2021"
|
||||
@@ -19,4 +19,30 @@ serde_json = "1"
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
base64 = "0.22"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "socks"] }
|
||||
percent-encoding = "2"
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
thiserror = "2"
|
||||
sha2 = "0.10"
|
||||
quick-xml = "0.39"
|
||||
zip = { version = "4", default-features = false, features = ["deflate-flate2-zlib-rs"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
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",
|
||||
] }
|
||||
|
||||
@@ -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/>.
|
||||
Binary file not shown.
@@ -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.
Binary file not shown.
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.
|
||||
BIN
Binary file not shown.
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"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"],
|
||||
"permissions": ["core:default", "dialog:allow-open"]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#[cfg(not(test))]
|
||||
use crate::adapters::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||
ProxyRouterRequest,
|
||||
@@ -7,11 +6,6 @@ use crate::models::{
|
||||
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItemType, Protocol,
|
||||
ProxyProtocol, Target,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||
ProxyRouterRequest,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const PROXIFYRE_ADAPTER_ID: &str = "proxifyre";
|
||||
@@ -54,7 +48,11 @@ impl ProxiFyreAdapter {
|
||||
|
||||
proxies.push(ProxiFyreProxy {
|
||||
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),
|
||||
});
|
||||
}
|
||||
@@ -211,7 +209,10 @@ fn app_names_for_profile(profile: &Profile) -> Vec<String> {
|
||||
ProfileItemType::Process | ProfileItemType::Folder | ProfileItemType::Exe => value,
|
||||
};
|
||||
|
||||
if !names.iter().any(|existing| existing == app_name) {
|
||||
if !names
|
||||
.iter()
|
||||
.any(|existing: &String| existing.eq_ignore_ascii_case(app_name))
|
||||
{
|
||||
names.push(app_name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
use crate::models::{LocalSingBoxConfig, SubscriptionCache};
|
||||
use crate::process::command_no_window;
|
||||
use crate::models::{LocalSingBoxConfig, SubscriptionCache, SubscriptionServer};
|
||||
use crate::process::run_fixed_process;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
env, fs,
|
||||
path::Path,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use std::{env, fs, path::Path, time::Duration};
|
||||
|
||||
pub const SINGBOX_ADAPTER_ID: &str = "singbox";
|
||||
pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json";
|
||||
@@ -43,23 +39,29 @@ impl SingBoxAdapter {
|
||||
checker: &C,
|
||||
) -> Result<SingBoxGeneratedConfig, SingBoxConfigError>
|
||||
where
|
||||
C: SingBoxConfigChecker,
|
||||
C: SingBoxConfigChecker + ?Sized,
|
||||
{
|
||||
let selected_server_tag = request
|
||||
.config
|
||||
.selected_server_tag
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
let selected_server = if let Some(id) = request.config.selected_server_id.as_deref() {
|
||||
request
|
||||
.subscription_cache
|
||||
.servers
|
||||
.iter()
|
||||
.find(|server| server.id == id)
|
||||
} else {
|
||||
let mut matches = request.subscription_cache.servers.iter().filter(|server| {
|
||||
Some(server.tag.as_str()) == request.config.selected_server_tag.as_deref()
|
||||
});
|
||||
matches.next().filter(|_| matches.next().is_none())
|
||||
}
|
||||
.ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingSelectedServer,
|
||||
"Сервер Local sing-box не выбран",
|
||||
"Сервер Local sing-box не выбран или отсутствует в текущей подписке",
|
||||
)
|
||||
})?;
|
||||
let vpn_outbound = selected_outbound(
|
||||
&request.subscription_cache.config,
|
||||
selected_server_tag,
|
||||
selected_server,
|
||||
&self.vpn_outbound_tag,
|
||||
)?;
|
||||
let generated_config = json!({
|
||||
@@ -105,7 +107,7 @@ impl SingBoxAdapter {
|
||||
adapter_id: SINGBOX_ADAPTER_ID.to_string(),
|
||||
output_file_name: SINGBOX_OUTPUT_FILE.to_string(),
|
||||
contents,
|
||||
selected_server_tag: selected_server_tag.to_string(),
|
||||
selected_server_tag: selected_server.tag.clone(),
|
||||
listen: request.config.listen_host.clone(),
|
||||
listen_port: request.config.listen_port,
|
||||
check,
|
||||
@@ -200,64 +202,61 @@ impl SingBoxConfigChecker for SingBoxCommandChecker {
|
||||
config_json: &str,
|
||||
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
|
||||
let config_path = env::temp_dir().join(format!(
|
||||
"proxywarden-sing-box-{}-{}.json",
|
||||
std::process::id(),
|
||||
now_millis()
|
||||
"proxywarden-sing-box-{}.json",
|
||||
uuid::Uuid::new_v4().hyphenated()
|
||||
));
|
||||
|
||||
fs::write(&config_path, config_json).map_err(|error| {
|
||||
struct TemporaryConfig(std::path::PathBuf);
|
||||
impl Drop for TemporaryConfig {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
let _temporary = TemporaryConfig(config_path.clone());
|
||||
crate::safe_fs::write_restricted_atomic(&config_path, config_json.as_bytes()).map_err(
|
||||
|_| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::CheckFailed,
|
||||
format!(
|
||||
"Не удалось записать временный конфиг sing-box '{}': {error}",
|
||||
config_path.display()
|
||||
),
|
||||
"Не удалось безопасно создать временный конфиг sing-box",
|
||||
)
|
||||
})?;
|
||||
|
||||
let output = command_no_window(binary_path)
|
||||
.arg("check")
|
||||
.arg("-c")
|
||||
.arg(&config_path)
|
||||
.output()
|
||||
},
|
||||
)?;
|
||||
// Checker output can contain credentials from the outbound. The bounded
|
||||
// native process runner discards both streams instead of exposing them.
|
||||
let status = run_fixed_process(
|
||||
binary_path,
|
||||
&[
|
||||
"check".into(),
|
||||
"-c".into(),
|
||||
config_path.as_os_str().to_owned(),
|
||||
],
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.map_err(|error| {
|
||||
let _ = fs::remove_file(&config_path);
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::CheckFailed,
|
||||
format!(
|
||||
"Не удалось выполнить '{} check': {error}",
|
||||
binary_path.display()
|
||||
),
|
||||
if error.kind() == std::io::ErrorKind::TimedOut {
|
||||
"Проверка sing-box превысила 30 секунд"
|
||||
} else {
|
||||
"Не удалось выполнить проверку sing-box"
|
||||
},
|
||||
)
|
||||
})?;
|
||||
let _ = fs::remove_file(&config_path);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let message = command_message(&stdout, &stderr);
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::CheckFailed,
|
||||
format!("Проверка sing-box не прошла: {message}"),
|
||||
));
|
||||
if !status.success() {
|
||||
return Err(SingBoxConfigError::new(SingBoxConfigErrorKind::CheckFailed,
|
||||
"sing-box отклонил конфигурацию выбранного сервера. Обновите подписку или выберите другой сервер."));
|
||||
}
|
||||
|
||||
Ok(SingBoxCheckResult {
|
||||
checked: true,
|
||||
success: true,
|
||||
message: if message.is_empty() {
|
||||
"Проверка sing-box прошла успешно".to_string()
|
||||
} else {
|
||||
message
|
||||
},
|
||||
message: "Проверка sing-box прошла успешно".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_outbound(
|
||||
subscription_config: &Value,
|
||||
selected_server_tag: &str,
|
||||
selected_server: &SubscriptionServer,
|
||||
vpn_outbound_tag: &str,
|
||||
) -> Result<Value, SingBoxConfigError> {
|
||||
let outbounds = subscription_config
|
||||
@@ -269,18 +268,37 @@ fn selected_outbound(
|
||||
"В cache подписки нет outbounds",
|
||||
)
|
||||
})?;
|
||||
let outbound = outbounds
|
||||
.iter()
|
||||
.find(|outbound| {
|
||||
let outbound = if selected_server.id.starts_with("pw-") {
|
||||
outbounds.iter().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
|
||||
.get("tag")
|
||||
.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
|
||||
})
|
||||
&& outbound.get("type").and_then(Value::as_str)
|
||||
== Some(selected_server.server_type.as_str())
|
||||
&& 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}"),
|
||||
format!(
|
||||
"Outbound не найден: {} ({}:{})",
|
||||
selected_server.tag, selected_server.server, selected_server.server_port
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let outbound_type = outbound
|
||||
@@ -292,7 +310,8 @@ fn selected_outbound(
|
||||
return Err(SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
|
||||
format!(
|
||||
"Outbound '{selected_server_tag}' имеет неподдерживаемый тип '{outbound_type}'"
|
||||
"Outbound '{}' имеет неподдерживаемый тип '{outbound_type}'",
|
||||
selected_server.tag
|
||||
),
|
||||
));
|
||||
}
|
||||
@@ -301,7 +320,10 @@ fn selected_outbound(
|
||||
let object = outbound.as_object_mut().ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
|
||||
format!("Outbound '{selected_server_tag}' должен быть JSON-объектом"),
|
||||
format!(
|
||||
"Outbound '{}' должен быть JSON-объектом",
|
||||
selected_server.tag
|
||||
),
|
||||
)
|
||||
})?;
|
||||
object.insert(
|
||||
@@ -317,22 +339,3 @@ fn selected_outbound(
|
||||
|
||||
Ok(outbound)
|
||||
}
|
||||
|
||||
fn now_millis() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn command_message(stdout: &str, stderr: &str) -> String {
|
||||
let stdout = stdout.trim();
|
||||
let stderr = stderr.trim();
|
||||
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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}"))
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Small injectable time boundary for deterministic activity records.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub trait Clock {
|
||||
fn now(&self) -> String;
|
||||
}
|
||||
|
||||
pub struct SystemClock;
|
||||
|
||||
impl Clock for SystemClock {
|
||||
fn now(&self) -> String {
|
||||
let seconds = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
format!("unix:{seconds}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
+1642
-3910
File diff suppressed because it is too large
Load Diff
@@ -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
+1378
-193
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,188 @@
|
||||
pub mod activity;
|
||||
pub mod admin;
|
||||
pub mod apply_flow;
|
||||
pub mod clock;
|
||||
pub mod command_dto;
|
||||
pub mod commands;
|
||||
pub mod component_catalog;
|
||||
pub mod component_cutover;
|
||||
pub mod component_detection;
|
||||
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 nsis_runtime;
|
||||
pub mod privileged_jobs;
|
||||
pub mod privileged_runtime;
|
||||
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_subscription;
|
||||
pub mod storage;
|
||||
pub mod subscription;
|
||||
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 proxifyre;
|
||||
pub mod proxy_router;
|
||||
pub mod singbox;
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(commands::CommandState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::restart_as_admin,
|
||||
commands::get_startup_snapshot,
|
||||
commands::get_saved_state,
|
||||
commands::get_components,
|
||||
commands::get_proxifyre_setup_status,
|
||||
commands::get_singbox_status,
|
||||
commands::get_singbox_setup_status,
|
||||
commands::get_component_package_statuses,
|
||||
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::forget_singbox_subscription,
|
||||
commands::select_singbox_server,
|
||||
commands::ping_singbox_server,
|
||||
commands::ping_all_singbox_servers,
|
||||
commands::ping_proxy_target,
|
||||
commands::generate_singbox_config,
|
||||
commands::apply_configuration,
|
||||
commands::start_proxifyre_service,
|
||||
commands::stop_proxifyre_service,
|
||||
commands::install_proxifyre,
|
||||
commands::configure_proxifyre_firewall_rules,
|
||||
commands::uninstall_proxifyre,
|
||||
commands::start_singbox_service,
|
||||
commands::stop_singbox_service,
|
||||
commands::install_singbox,
|
||||
commands::uninstall_singbox
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("не удалось запустить клиент ProxyWarden");
|
||||
}
|
||||
|
||||
+5
-71
@@ -1,75 +1,9 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod activity;
|
||||
mod commands;
|
||||
mod component_detection;
|
||||
mod models;
|
||||
mod process;
|
||||
mod singbox_service;
|
||||
mod storage;
|
||||
mod subscription;
|
||||
mod validation;
|
||||
|
||||
mod adapters {
|
||||
pub mod proxifyre;
|
||||
pub mod proxy_router;
|
||||
pub mod singbox;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod proxifyre {
|
||||
pub use crate::adapters::proxifyre::*;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod proxy_router {
|
||||
pub use crate::adapters::proxy_router::*;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod singbox {
|
||||
pub use crate::adapters::singbox::*;
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(commands::CommandState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_status,
|
||||
commands::get_admin_status,
|
||||
commands::restart_as_admin,
|
||||
commands::get_startup_snapshot,
|
||||
commands::get_profiles,
|
||||
commands::get_saved_state,
|
||||
commands::save_profile,
|
||||
commands::get_targets,
|
||||
commands::save_target,
|
||||
commands::get_components,
|
||||
commands::get_proxifyre_setup_status,
|
||||
commands::get_singbox_status,
|
||||
commands::get_singbox_setup_status,
|
||||
commands::resolve_profile_preview,
|
||||
commands::save_singbox_subscription,
|
||||
commands::fetch_singbox_subscription,
|
||||
commands::forget_singbox_subscription,
|
||||
commands::select_singbox_server,
|
||||
commands::ping_singbox_server,
|
||||
commands::ping_all_singbox_servers,
|
||||
commands::ping_proxy_target,
|
||||
commands::generate_singbox_config,
|
||||
commands::apply_profiles,
|
||||
commands::get_logs,
|
||||
commands::open_config_location,
|
||||
commands::start_proxifyre_service,
|
||||
commands::stop_proxifyre_service,
|
||||
commands::install_proxifyre,
|
||||
commands::uninstall_proxifyre,
|
||||
commands::start_singbox_service,
|
||||
commands::stop_singbox_service,
|
||||
commands::install_singbox,
|
||||
commands::uninstall_singbox
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("не удалось запустить клиент ProxyWarden");
|
||||
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();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+110
-18
@@ -1,9 +1,12 @@
|
||||
use percent_encoding::percent_decode_str;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1";
|
||||
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080;
|
||||
pub const DEFAULT_LOCAL_SINGBOX_SERVICE_NAME: &str = "ProxyWardenSingBox";
|
||||
pub const DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT: &str = r"C:\Program Files\ProxyWarden\sing-box";
|
||||
pub const DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT: &str =
|
||||
r"C:\Program Files\ProxyWarden\components\sing-box";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
@@ -53,6 +56,7 @@ pub enum ComponentState {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileItemInput {
|
||||
#[serde(rename = "type")]
|
||||
pub item_type: String,
|
||||
@@ -62,6 +66,7 @@ pub struct ProfileItemInput {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileInput {
|
||||
pub id: Option<String>,
|
||||
pub name: String,
|
||||
@@ -94,6 +99,7 @@ pub struct Profile {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetInput {
|
||||
pub id: Option<String>,
|
||||
pub name: String,
|
||||
@@ -128,6 +134,10 @@ pub struct ComponentStatus {
|
||||
pub version: Option<String>,
|
||||
pub path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub service_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub service_status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub problems: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub actions: Vec<String>,
|
||||
@@ -138,32 +148,68 @@ pub struct LocalSingBoxConfig {
|
||||
#[serde(default)]
|
||||
pub subscription_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub device_hwid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_server_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_server_id: Option<String>,
|
||||
#[serde(default = "default_local_singbox_listen_host")]
|
||||
pub listen_host: String,
|
||||
#[serde(default = "default_local_singbox_listen_port")]
|
||||
pub listen_port: u16,
|
||||
#[serde(default = "default_local_singbox_service_name")]
|
||||
pub service_name: String,
|
||||
#[serde(default = "default_local_singbox_install_root")]
|
||||
#[serde(default = "default_local_singbox_install_root", skip_serializing)]
|
||||
pub install_root: String,
|
||||
#[serde(default)]
|
||||
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 {
|
||||
pub fn subscription_display_url(&self) -> Option<String> {
|
||||
self.subscription_url
|
||||
.as_deref()
|
||||
.map(redact_subscription_url)
|
||||
}
|
||||
|
||||
pub fn normalize_percent_encoded_tags(&mut self) {
|
||||
if let Some(selected_server_tag) = self.selected_server_tag.as_mut() {
|
||||
*selected_server_tag = decode_percent_encoded_utf8(selected_server_tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LocalSingBoxConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
subscription_url: None,
|
||||
device_hwid: None,
|
||||
selected_server_tag: None,
|
||||
selected_server_id: None,
|
||||
listen_host: default_local_singbox_listen_host(),
|
||||
listen_port: default_local_singbox_listen_port(),
|
||||
service_name: default_local_singbox_service_name(),
|
||||
@@ -183,8 +229,21 @@ pub struct SubscriptionCache {
|
||||
pub fetched_at: String,
|
||||
}
|
||||
|
||||
impl SubscriptionCache {
|
||||
pub fn normalize_percent_encoded_tags(&mut self) {
|
||||
for server in &mut self.servers {
|
||||
server.tag = decode_percent_encoded_utf8(&server.tag);
|
||||
server.ensure_id();
|
||||
}
|
||||
|
||||
// Outbound bytes define stable identity. Decode display labels only.
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SubscriptionServer {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
pub tag: String,
|
||||
#[serde(rename = "type")]
|
||||
pub server_type: String,
|
||||
@@ -192,6 +251,34 @@ pub struct SubscriptionServer {
|
||||
pub server_port: u16,
|
||||
}
|
||||
|
||||
impl SubscriptionServer {
|
||||
pub fn ensure_id(&mut self) {
|
||||
if self.id.trim().is_empty() {
|
||||
self.id = subscription_server_id(
|
||||
&self.server_type,
|
||||
&self.tag,
|
||||
&self.server,
|
||||
self.server_port,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscription_server_id(
|
||||
server_type: &str,
|
||||
tag: &str,
|
||||
server: &str,
|
||||
server_port: u16,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}|{}|{}|{}",
|
||||
server_type.trim().to_ascii_lowercase(),
|
||||
tag.trim(),
|
||||
server.trim().to_ascii_lowercase(),
|
||||
server_port
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ActivityEntry {
|
||||
pub id: String,
|
||||
@@ -252,22 +339,27 @@ pub fn redact_subscription_url(raw_url: &str) -> String {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
match trimmed.split_once("://") {
|
||||
Some((scheme, rest)) => {
|
||||
let host = rest
|
||||
.split(['/', '?', '#'])
|
||||
.next()
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("subscription");
|
||||
format!("{scheme}://{host}/...")
|
||||
}
|
||||
None => {
|
||||
let visible = trimmed.chars().take(18).collect::<String>();
|
||||
if trimmed.chars().count() <= 18 {
|
||||
"***".to_string()
|
||||
let Ok(parsed) = Url::parse(trimmed) else {
|
||||
return "***".to_string();
|
||||
};
|
||||
|
||||
let host = parsed.host_str().unwrap_or("subscription");
|
||||
let host = if host.contains(':') && !host.starts_with('[') {
|
||||
format!("[{host}]")
|
||||
} else {
|
||||
format!("{visible}...")
|
||||
}
|
||||
}
|
||||
host.to_string()
|
||||
};
|
||||
let port = parsed
|
||||
.port()
|
||||
.map(|port| format!(":{port}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
format!("{}://{}{}/...", parsed.scheme(), host, port)
|
||||
}
|
||||
|
||||
pub fn decode_percent_encoded_utf8(value: &str) -> String {
|
||||
percent_decode_str(value)
|
||||
.decode_utf8()
|
||||
.map(|decoded| decoded.into_owned())
|
||||
.unwrap_or_else(|_| value.to_string())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
//! TCP and outbound HTTP checks used to verify a configured SOCKS5 route.
|
||||
//!
|
||||
//! All functions are blocking. Tauri handlers must call them through
|
||||
//! `spawn_blocking`; probe URLs are static and never come from webview input.
|
||||
|
||||
use crate::command_dto::{
|
||||
CommandError, PingProxyTargetInputDto, PingServerResponse, ProxyProbeResponse,
|
||||
ProxyTargetCheckResponse,
|
||||
};
|
||||
use std::net::{IpAddr, TcpStream, ToSocketAddrs};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const PROXY_CHECK_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
const PROXY_CHECK_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const PROXY_CHECK_USER_AGENT: &str = "proxywarden route-check";
|
||||
|
||||
const DEFAULT_PROXY_PROBES: &[ProxyProbeEndpoint] = &[
|
||||
ProxyProbeEndpoint {
|
||||
id: "cloudflare-trace",
|
||||
name: "Cloudflare Trace",
|
||||
url: "https://www.cloudflare.com/cdn-cgi/trace",
|
||||
ip_source: ProbeIpSource::CloudflareTrace,
|
||||
},
|
||||
ProxyProbeEndpoint {
|
||||
id: "cloudflare-speed",
|
||||
name: "Cloudflare Speed",
|
||||
url: "https://speed.cloudflare.com/meta",
|
||||
ip_source: ProbeIpSource::JsonField("clientIp"),
|
||||
},
|
||||
ProxyProbeEndpoint {
|
||||
id: "ipify",
|
||||
name: "ipify",
|
||||
url: "https://api.ipify.org?format=json",
|
||||
ip_source: ProbeIpSource::JsonField("ip"),
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ProxyProbeEndpoint {
|
||||
id: &'static str,
|
||||
name: &'static str,
|
||||
url: &'static str,
|
||||
ip_source: ProbeIpSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum ProbeIpSource {
|
||||
CloudflareTrace,
|
||||
JsonField(&'static str),
|
||||
}
|
||||
|
||||
pub fn ping_proxy_target_endpoint(
|
||||
input: PingProxyTargetInputDto,
|
||||
) -> Result<ProxyTargetCheckResponse, CommandError> {
|
||||
ping_proxy_target_endpoint_with_probes(input, DEFAULT_PROXY_PROBES)
|
||||
}
|
||||
|
||||
pub fn ping_proxy_target_endpoint_with_probes(
|
||||
input: PingProxyTargetInputDto,
|
||||
probes: &[ProxyProbeEndpoint],
|
||||
) -> Result<ProxyTargetCheckResponse, CommandError> {
|
||||
let host = input.host.trim();
|
||||
if host.is_empty() {
|
||||
return Err(CommandError::new(
|
||||
"proxy_target_host_missing",
|
||||
"Хост внешнего прокси не указан.",
|
||||
));
|
||||
}
|
||||
|
||||
let tcp = ping_endpoint("route-proxy", "route-proxy", host, input.port);
|
||||
if !tcp.ok {
|
||||
return Ok(ProxyTargetCheckResponse {
|
||||
tag: "route-proxy".to_string(),
|
||||
server: host.to_string(),
|
||||
server_port: input.port,
|
||||
ok: false,
|
||||
latency: tcp.latency,
|
||||
error: tcp.error,
|
||||
probes: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let probe_results = run_proxy_probes(host, input.port, probes);
|
||||
let has_probe_success = probe_results.iter().any(|probe| probe.ok);
|
||||
let ok = probe_results.is_empty() || has_probe_success;
|
||||
let error = (!ok).then(|| {
|
||||
"SOCKS5 порт доступен, но тестовые HTTP endpoints не ответили через прокси.".to_string()
|
||||
});
|
||||
|
||||
Ok(ProxyTargetCheckResponse {
|
||||
tag: "route-proxy".to_string(),
|
||||
server: host.to_string(),
|
||||
server_port: input.port,
|
||||
ok,
|
||||
latency: tcp.latency,
|
||||
error,
|
||||
probes: probe_results,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ping_endpoint(id: &str, tag: &str, server: &str, server_port: u16) -> PingServerResponse {
|
||||
let started = Instant::now();
|
||||
let addresses = match (server, server_port).to_socket_addrs() {
|
||||
Ok(addresses) => addresses.collect::<Vec<_>>(),
|
||||
Err(error) => {
|
||||
return PingServerResponse {
|
||||
id: id.to_string(),
|
||||
tag: tag.to_string(),
|
||||
server: server.to_string(),
|
||||
server_port,
|
||||
ok: false,
|
||||
latency: None,
|
||||
error: Some(format!("DNS/адрес недоступен: {error}")),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if addresses.is_empty() {
|
||||
return PingServerResponse {
|
||||
id: id.to_string(),
|
||||
tag: tag.to_string(),
|
||||
server: server.to_string(),
|
||||
server_port,
|
||||
ok: false,
|
||||
latency: None,
|
||||
error: Some("DNS не вернул адреса".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
let timeout = Duration::from_secs(2);
|
||||
let mut last_error = None;
|
||||
for address in addresses {
|
||||
match TcpStream::connect_timeout(&address, timeout) {
|
||||
Ok(_) => {
|
||||
return PingServerResponse {
|
||||
id: id.to_string(),
|
||||
tag: tag.to_string(),
|
||||
server: server.to_string(),
|
||||
server_port,
|
||||
ok: true,
|
||||
latency: Some(started.elapsed().as_millis()),
|
||||
error: None,
|
||||
};
|
||||
}
|
||||
Err(error) => last_error = Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
PingServerResponse {
|
||||
id: id.to_string(),
|
||||
tag: tag.to_string(),
|
||||
server: server.to_string(),
|
||||
server_port,
|
||||
ok: false,
|
||||
latency: None,
|
||||
error: last_error,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_proxy_probes(
|
||||
proxy_host: &str,
|
||||
proxy_port: u16,
|
||||
probes: &[ProxyProbeEndpoint],
|
||||
) -> Vec<ProxyProbeResponse> {
|
||||
if probes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let proxy_url = socks5h_proxy_url(proxy_host, proxy_port);
|
||||
let client = match reqwest::Proxy::all(&proxy_url).and_then(|proxy| {
|
||||
reqwest::blocking::Client::builder()
|
||||
.timeout(PROXY_CHECK_TIMEOUT)
|
||||
.connect_timeout(PROXY_CHECK_CONNECT_TIMEOUT)
|
||||
.proxy(proxy)
|
||||
.build()
|
||||
}) {
|
||||
Ok(client) => client,
|
||||
Err(error) => {
|
||||
return probes
|
||||
.iter()
|
||||
.map(|probe| {
|
||||
failed_probe(
|
||||
*probe,
|
||||
format!("Не удалось подготовить SOCKS5 проверку: {error}"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
};
|
||||
|
||||
let handles = probes
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|probe| {
|
||||
let client = client.clone();
|
||||
std::thread::spawn(move || run_proxy_probe(&client, probe))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
handles
|
||||
.into_iter()
|
||||
.zip(probes.iter().copied())
|
||||
.map(|(handle, probe)| {
|
||||
handle
|
||||
.join()
|
||||
.unwrap_or_else(|_| failed_probe(probe, "Проверка была прервана.".to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_proxy_probe(
|
||||
client: &reqwest::blocking::Client,
|
||||
probe: ProxyProbeEndpoint,
|
||||
) -> ProxyProbeResponse {
|
||||
let started = Instant::now();
|
||||
let response = match client
|
||||
.get(probe.url)
|
||||
.header(reqwest::header::USER_AGENT, PROXY_CHECK_USER_AGENT)
|
||||
.send()
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(error) => return failed_probe(probe, format!("HTTP через SOCKS5 не прошел: {error}")),
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
let status_code = status.as_u16();
|
||||
let body = match response.text() {
|
||||
Ok(body) => body,
|
||||
Err(error) => {
|
||||
return failed_probe_with_status(
|
||||
probe,
|
||||
status_code,
|
||||
format!("Ответ не прочитан: {error}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
let latency = started.elapsed().as_millis();
|
||||
|
||||
if !status.is_success() {
|
||||
return ProxyProbeResponse {
|
||||
id: probe.id.to_string(),
|
||||
name: probe.name.to_string(),
|
||||
url: probe.url.to_string(),
|
||||
ok: false,
|
||||
status: Some(status_code),
|
||||
latency: Some(latency),
|
||||
ip: None,
|
||||
error: Some(format!("HTTP {status_code}")),
|
||||
};
|
||||
}
|
||||
|
||||
ProxyProbeResponse {
|
||||
id: probe.id.to_string(),
|
||||
name: probe.name.to_string(),
|
||||
url: probe.url.to_string(),
|
||||
ok: true,
|
||||
status: Some(status_code),
|
||||
latency: Some(latency),
|
||||
ip: extract_probe_ip(probe, &body),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_probe(probe: ProxyProbeEndpoint, error: String) -> ProxyProbeResponse {
|
||||
failed_probe_with_status(probe, 0, error)
|
||||
}
|
||||
|
||||
fn failed_probe_with_status(
|
||||
probe: ProxyProbeEndpoint,
|
||||
status: u16,
|
||||
error: String,
|
||||
) -> ProxyProbeResponse {
|
||||
ProxyProbeResponse {
|
||||
id: probe.id.to_string(),
|
||||
name: probe.name.to_string(),
|
||||
url: probe.url.to_string(),
|
||||
ok: false,
|
||||
status: (status > 0).then_some(status),
|
||||
latency: None,
|
||||
ip: None,
|
||||
error: Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn socks5h_proxy_url(host: &str, port: u16) -> String {
|
||||
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
|
||||
if host.contains(':') {
|
||||
format!("socks5h://[{host}]:{port}")
|
||||
} else {
|
||||
format!("socks5h://{host}:{port}")
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_probe_ip(probe: ProxyProbeEndpoint, body: &str) -> Option<String> {
|
||||
match probe.ip_source {
|
||||
ProbeIpSource::CloudflareTrace => body
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("ip=").and_then(normalize_ip)),
|
||||
ProbeIpSource::JsonField(field) => serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get(field)
|
||||
.and_then(|field| field.as_str())
|
||||
.and_then(normalize_ip)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_ip(value: &str) -> Option<String> {
|
||||
let candidate = value.trim().trim_matches('"');
|
||||
candidate
|
||||
.parse::<IpAddr>()
|
||||
.is_ok()
|
||||
.then(|| candidate.to_string())
|
||||
}
|
||||
@@ -0,0 +1,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
@@ -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
Reference in New Issue
Block a user