Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c0509e090 | ||
|
|
2df1c00516 | ||
|
|
0842c4d4a5 | ||
|
|
88f8819570 | ||
|
|
9288fc95cb | ||
|
|
beced42f2f |
+109
-10
@@ -49,19 +49,50 @@ jobs:
|
|||||||
# electron-builder.yml просто копирует resources/zapret, а он у чистого чекаута пуст.
|
# electron-builder.yml просто копирует resources/zapret, а он у чистого чекаута пуст.
|
||||||
- run: node scripts/fetch-zapret.mjs
|
- run: node scripts/fetch-zapret.mjs
|
||||||
|
|
||||||
# Подпись не настроена: артефакты неподписанные.
|
# electron-builder заливает артефакты несколькими параллельными publisher'ами внутри
|
||||||
# Чтобы подписывать — добавьте секреты CSC_LINK, CSC_KEY_PASSWORD и передайте их в env.
|
# этой же джобы, а с приходом Linux-сборки (отдельная джоба ниже) к этому добавилась
|
||||||
#
|
# ещё и гонка МЕЖДУ джобами: обе проверяют, есть ли релиз с этим тегом, обе видят «нет»
|
||||||
# На пуш тега vX.Y.Z (см. on.push.tags выше) electron-builder публикует .exe в GitHub
|
# и обе лезут его создавать — вторая падает с 422 already_exists (на v0.5.0 так
|
||||||
# Release с этим тегом (создаёт его, если нет), используя repository из package.json.
|
# потерялся сам .exe, когда конкурентами были два publisher'а внутри одной джобы).
|
||||||
# На push в main/PR публикации нет — только упаковка и загрузка в Artifacts ниже.
|
# `|| true` — проигравшая гонку джоба не должна из-за этого падать целиком: релиз
|
||||||
- name: Build
|
# к моменту upload всё равно уже существует, просто создала его не она.
|
||||||
run: npx --no-install electron-vite build && npx --no-install electron-builder --publish ${{ startsWith(github.ref, 'refs/tags/v') && 'always' || 'never' }}
|
- name: Заготовка релиза
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 ||
|
||||||
|
gh release create "$GITHUB_REF_NAME" --title "${GITHUB_REF_NAME#v}" --notes '' || true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# electron-builder создаёт релиз с пустым описанием, поэтому список изменений
|
# Подпись не настроена: артефакты неподписанные.
|
||||||
# дописываем сами: коммиты между предыдущим тегом vX.Y.Z и текущим.
|
# Чтобы подписывать — добавьте секреты CSC_LINK, CSC_KEY_PASSWORD и передайте их в env.
|
||||||
|
#
|
||||||
|
# На пуш тега vX.Y.Z (см. on.push.tags выше) electron-builder заливает .exe в релиз,
|
||||||
|
# заготовленный шагом выше, используя repository из package.json.
|
||||||
|
# На push в main/PR публикации нет — только упаковка и загрузка в Artifacts ниже.
|
||||||
|
- name: Build
|
||||||
|
run: npx --no-install electron-vite build && npx --no-install electron-builder --win --publish ${{ startsWith(github.ref, 'refs/tags/v') && 'always' || 'never' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# differentialPackage: false и publishAutoUpdate: false в electron-builder.yml убирают
|
||||||
|
# .blockmap и latest.yml из ассетов (electron-updater в приложении нет — они были бы
|
||||||
|
# мёртвым грузом). В релизе должен остаться только .exe и контрольная сумма к нему.
|
||||||
|
- name: Контрольная сумма
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
exe="$(ls release/*.exe)"
|
||||||
|
md5sum "$exe" | awk '{ print $1 }' > "$exe.md5"
|
||||||
|
gh release upload "$GITHUB_REF_NAME" "$exe.md5" --clobber
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# Релиз создан с пустым описанием, поэтому список изменений дописываем сами:
|
||||||
|
# коммиты между предыдущим тегом vX.Y.Z и текущим. Только в этой джобе — если то же
|
||||||
|
# самое сделает ещё и Linux-сборка, они просто перезапишут результат друг друга.
|
||||||
- name: Описание релиза
|
- name: Описание релиза
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -96,3 +127,71 @@ jobs:
|
|||||||
path: release/*.exe
|
path: release/*.exe
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
name: Сборка (Linux)
|
||||||
|
needs: check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# На тег vX.Y.Z нужен contents: write — electron-builder публикует релиз через GH_TOKEN.
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
# Без этого шага сборка уезжает без nfqws: extraResources в electron-builder.yml
|
||||||
|
# просто копирует resources/zapret, а он у чистого чекаута пуст.
|
||||||
|
- run: node scripts/fetch-zapret.mjs
|
||||||
|
|
||||||
|
# Та же гонка «кто первый создаст релиз», что и в джобе Windows выше — эта джоба
|
||||||
|
# с ней конкурирует наравне (обе стартуют параллельно после check), отсюда и тот же
|
||||||
|
# `|| true`. Комментарий с полным объяснением — в джобе Windows.
|
||||||
|
- name: Заготовка релиза
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 ||
|
||||||
|
gh release create "$GITHUB_REF_NAME" --title "${GITHUB_REF_NAME#v}" --notes '' || true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# Подпись/нотаризация не при чём здесь — это Linux, .AppImage и .deb неподписанные
|
||||||
|
# в любом случае. На push в main/PR публикации нет — только упаковка и Artifacts ниже.
|
||||||
|
- name: Build
|
||||||
|
run: npx --no-install electron-vite build && npx --no-install electron-builder --linux --publish ${{ startsWith(github.ref, 'refs/tags/v') && 'always' || 'never' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# Тот же мотив, что у Windows-джобы: в релизе должны остаться только сами артефакты
|
||||||
|
# и контрольная сумма к каждому — никаких .blockmap/latest-linux.yml.
|
||||||
|
- name: Контрольная сумма
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
files=(release/*.AppImage release/*.deb)
|
||||||
|
if [ "${#files[@]}" -eq 0 ]; then
|
||||||
|
echo "не нашлось ни .AppImage, ни .deb в release/" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for file in "${files[@]}"; do
|
||||||
|
md5sum "$file" | awk '{ print $1 }' > "$file.md5"
|
||||||
|
gh release upload "$GITHUB_REF_NAME" "$file.md5" --clobber
|
||||||
|
done
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: rknboost-linux
|
||||||
|
path: |
|
||||||
|
release/*.AppImage
|
||||||
|
release/*.deb
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|||||||
@@ -21,11 +21,27 @@ npm run typecheck # tsc для node- и web-частей отдельно
|
|||||||
npm run build:unpack # быстрая проверка: приложение без установщика (release/*-unpacked)
|
npm run build:unpack # быстрая проверка: приложение без установщика (release/*-unpacked)
|
||||||
npm run build:mac # .dmg + .zip (x64 + arm64)
|
npm run build:mac # .dmg + .zip (x64 + arm64)
|
||||||
npm run build:win # NSIS .exe (x64; arm64 не собираем — нет подписанного WinDivert)
|
npm run build:win # NSIS .exe (x64; arm64 не собираем — нет подписанного WinDivert)
|
||||||
npm run build:linux # AppImage + .deb
|
npm run build:linux # AppImage + .deb (x64)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Обход блокировок по платформам
|
||||||
|
|
||||||
|
| Платформа | Движок | Права |
|
||||||
|
| --------- | ----------------------------------------------------- | ----------------------------------------------------- |
|
||||||
|
| macOS | `tpws` — локальный socks5-прокси | не нужны |
|
||||||
|
| Windows | `winws` + драйвер WinDivert, служба `rknboost-zapret` | UAC при первой настройке |
|
||||||
|
| Linux | `nfqws` + правила nftables (NFQUEUE) | пароль через `pkexec` при каждом включении/выключении |
|
||||||
|
|
||||||
|
На Linux дополнительно нужны пакеты `nftables` и `polkit` (в .deb — заявлены как зависимости
|
||||||
|
и ставятся сами; для AppImage — своими силами, `sudo apt install nftables policykit-1`).
|
||||||
|
Ни службы, ни автозапуска с системой на Linux нет: демон и правила nftables переживают выход
|
||||||
|
из приложения (правила закрыты флагом `bypass` — без работающего nfqws трафик просто проходит
|
||||||
|
как есть, а не блокируется), но после перезагрузки заново их никто не поднимает.
|
||||||
|
|
||||||
## Ссылки
|
## Ссылки
|
||||||
|
|
||||||
- **[bol-van/zapret](https://github.com/bol-van/zapret)** — сам движок zapret, спасибо за столь хороший инструмент
|
- **[bol-van/zapret](https://github.com/bol-van/zapret)** — сам движок zapret, спасибо за столь хороший инструмент
|
||||||
- **[Flowseal/zapret-discord-youtube](https://github.com/Flowseal/zapret-discord-youtube)** —
|
- **[Flowseal/zapret-discord-youtube](https://github.com/Flowseal/zapret-discord-youtube)** —
|
||||||
списки сайтов (`resources/zapret/lists/`) и fake-пакеты (`resources/zapret/fakes/`).
|
списки сайтов (`resources/zapret/lists/`) и fake-пакеты (`resources/zapret/fakes/`).
|
||||||
|
- **[Sergeydigl3/zapret-discord-youtube-linux](https://github.com/Sergeydigl3/zapret-discord-youtube-linux)** —
|
||||||
|
референс для Linux-порта: те же стратегии Flowseal через `nfqws`, nftables вместо WinDivert.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 147 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 633 KiB |
+70
-5
@@ -7,9 +7,14 @@ directories:
|
|||||||
# releaseType: release — публиковать релиз сразу, без черновика (draft — поведение по
|
# releaseType: release — публиковать релиз сразу, без черновика (draft — поведение по
|
||||||
# умолчанию у electron-builder). С черновиком параллельная загрузка нескольких файлов иногда
|
# умолчанию у electron-builder). С черновиком параллельная загрузка нескольких файлов иногда
|
||||||
# создаёт два черновика с одним и тем же тегом вместо одного релиза.
|
# создаёт два черновика с одним и тем же тегом вместо одного релиза.
|
||||||
|
#
|
||||||
|
# publishAutoUpdate: false — не заливать latest.yml. Это метаданные для electron-updater,
|
||||||
|
# которого в приложении нет (обновляется пользователь вручную, скачивая новый инсталлятор
|
||||||
|
# со страницы релиза) — файл был бы мёртвым грузом в ассетах.
|
||||||
publish:
|
publish:
|
||||||
provider: github
|
provider: github
|
||||||
releaseType: release
|
releaseType: release
|
||||||
|
publishAutoUpdate: false
|
||||||
|
|
||||||
files:
|
files:
|
||||||
- '!**/.vscode/*'
|
- '!**/.vscode/*'
|
||||||
@@ -19,16 +24,30 @@ files:
|
|||||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml,yarn.lock}'
|
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml,yarn.lock}'
|
||||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||||
- '!.github/*'
|
- '!.github/*'
|
||||||
# zapret идёт отдельно через extraResources ниже — не дублировать его в app.asar.
|
# zapret и linux-helper идут отдельно через extraResources ниже — не дублировать их в app.asar.
|
||||||
- '!resources/zapret/**'
|
- '!resources/zapret/**'
|
||||||
|
- '!resources/linux-helper/**'
|
||||||
asarUnpack:
|
asarUnpack:
|
||||||
- 'resources/**'
|
- 'resources/**'
|
||||||
|
|
||||||
# Бинарники zapret (npm run zapret:fetch) кладём рядом с приложением, а не в asar —
|
# Бинарники zapret (npm run zapret:fetch) кладём рядом с приложением, а не в asar —
|
||||||
# это исполняемые файлы, которые main-процесс запускает через spawn.
|
# это исполняемые файлы, которые main-процесс запускает через spawn.
|
||||||
|
#
|
||||||
|
# Общие для всех платформ куски (списки, fake-пакеты, лицензии zapret/Flowseal) — здесь;
|
||||||
|
# платформенные бинарники — в extraResources соответствующего раздела (win/mac/linux) ниже.
|
||||||
|
# extraResources платформы ДОБАВЛЯЕТСЯ к этому списку, а не заменяет его (проверено по
|
||||||
|
# исходникам electron-builder, app-builder-lib/fileMatcher.js) — так Windows-сборка не везёт
|
||||||
|
# nfqws и Mach-O tpws, а Linux-сборка не везёт WinDivert и winws.exe.
|
||||||
extraResources:
|
extraResources:
|
||||||
|
- from: resources/zapret/lists
|
||||||
|
to: zapret/lists
|
||||||
|
- from: resources/zapret/fakes
|
||||||
|
to: zapret/fakes
|
||||||
- from: resources/zapret
|
- from: resources/zapret
|
||||||
to: zapret
|
to: zapret
|
||||||
|
filter:
|
||||||
|
- 'LICENSE.zapret.txt'
|
||||||
|
- 'LICENSE.flowseal.txt'
|
||||||
|
|
||||||
win:
|
win:
|
||||||
executableName: RKNboost
|
executableName: RKNboost
|
||||||
@@ -45,6 +64,13 @@ win:
|
|||||||
# в поставке zapret) — arm64-инсталлятор был бы заведомо нерабочим.
|
# в поставке zapret) — arm64-инсталлятор был бы заведомо нерабочим.
|
||||||
- target: nsis
|
- target: nsis
|
||||||
arch: [x64]
|
arch: [x64]
|
||||||
|
extraResources:
|
||||||
|
- from: resources/zapret/win32
|
||||||
|
to: zapret/win32
|
||||||
|
- from: resources/zapret
|
||||||
|
to: zapret
|
||||||
|
filter:
|
||||||
|
- 'LICENSE.WinDivert.txt'
|
||||||
nsis:
|
nsis:
|
||||||
# Быстрая установка per-user, без UAC и без мастера: ставится в %LocalAppData%, куда
|
# Быстрая установка per-user, без UAC и без мастера: ставится в %LocalAppData%, куда
|
||||||
# обычный пользователь и так пишет без повышения прав. Раньше здесь стоял perMachine —
|
# обычный пользователь и так пишет без повышения прав. Раньше здесь стоял perMachine —
|
||||||
@@ -54,6 +80,10 @@ nsis:
|
|||||||
# в src/main/zapret/service.win32.ts) — сама установка приложения per-user безопасна.
|
# в src/main/zapret/service.win32.ts) — сама установка приложения per-user безопасна.
|
||||||
oneClick: true
|
oneClick: true
|
||||||
perMachine: false
|
perMachine: false
|
||||||
|
# Без electron-updater (см. publishAutoUpdate выше) differential-обновления не нужны —
|
||||||
|
# differentialPackage: false отключает генерацию .blockmap, который иначе всегда
|
||||||
|
# заливался бы в ассеты релиза рядом с инсталлятором.
|
||||||
|
differentialPackage: false
|
||||||
# Удалять %APPDATA%\rknboost (списки сайтов, метка первого запуска) вместе с приложением.
|
# Удалять %APPDATA%\rknboost (списки сайтов, метка первого запуска) вместе с приложением.
|
||||||
# По умолчанию electron-builder этого НЕ делает, и данные оставались бы в системе после
|
# По умолчанию electron-builder этого НЕ делает, и данные оставались бы в системе после
|
||||||
# удаления. Обновление приложения под это не попадает — в шаблоне деинсталлятора стоит
|
# удаления. Обновление приложения под это не попадает — в шаблоне деинсталлятора стоит
|
||||||
@@ -82,17 +112,52 @@ mac:
|
|||||||
# Подпись и нотаризация выключены: без Apple Developer ID сборка иначе падает.
|
# Подпись и нотаризация выключены: без Apple Developer ID сборка иначе падает.
|
||||||
# Как включить — см. раздел «Подпись» в README.md.
|
# Как включить — см. раздел «Подпись» в README.md.
|
||||||
notarize: false
|
notarize: false
|
||||||
|
extraResources:
|
||||||
|
- from: resources/zapret/darwin
|
||||||
|
to: zapret/darwin
|
||||||
dmg:
|
dmg:
|
||||||
artifactName: ${name}-${version}-${arch}.${ext}
|
artifactName: ${name}-${version}-${arch}.${ext}
|
||||||
|
|
||||||
linux:
|
linux:
|
||||||
|
executableName: rknboost
|
||||||
|
# desktopName в package.json + syncDesktopName — иначе Electron не свяжет открытые окна
|
||||||
|
# с .desktop-записью (app_id/WM_CLASS), и часть окружений не покажет иконку в панели задач.
|
||||||
|
syncDesktopName: true
|
||||||
target:
|
target:
|
||||||
- AppImage
|
- target: AppImage
|
||||||
- deb
|
arch: [x64]
|
||||||
|
- target: deb
|
||||||
|
arch: [x64]
|
||||||
maintainer: RKNboost
|
maintainer: RKNboost
|
||||||
category: Utility
|
category: Network
|
||||||
synopsis: Кроссплатформенное приложение на Electron
|
synopsis: Обход блокировок через zapret
|
||||||
|
extraResources:
|
||||||
|
- from: resources/zapret/linux
|
||||||
|
to: zapret/linux
|
||||||
|
# rknboost-helper.sh — привилегированный помощник (nfqws.linux.ts зовёт его через
|
||||||
|
# pkexec). Не из resources/zapret: тот качает fetch-zapret.mjs, а этот файл — свой,
|
||||||
|
# лежит в репозитории (см. src/main/zapret/paths.ts:bundledHelperPath()).
|
||||||
|
- from: resources/linux-helper
|
||||||
|
to: linux-helper
|
||||||
appImage:
|
appImage:
|
||||||
artifactName: ${name}-${version}-${arch}.${ext}
|
artifactName: ${name}-${version}-${arch}.${ext}
|
||||||
|
deb:
|
||||||
|
artifactName: ${name}-${version}-${arch}.${ext}
|
||||||
|
# electron-builder ЗАМЕНЯЕТ этим списком свои дефолтные зависимости Electron, а не
|
||||||
|
# дополняет их (проверено по исходникам, app-builder-lib/targets/FpmTarget.js) — потерять
|
||||||
|
# хотя бы одну из первых девяти значило бы получить .deb, который ставится и не запускается.
|
||||||
|
# nftables и polkit — уже наши, для nfqws/pkexec.
|
||||||
|
depends:
|
||||||
|
- libgtk-3-0
|
||||||
|
- libnotify4
|
||||||
|
- libnss3
|
||||||
|
- libxss1
|
||||||
|
- libxtst6
|
||||||
|
- xdg-utils
|
||||||
|
- libatspi2.0-0
|
||||||
|
- libuuid1
|
||||||
|
- libsecret-1-0
|
||||||
|
- nftables
|
||||||
|
- policykit-1
|
||||||
|
|
||||||
npmRebuild: false
|
npmRebuild: false
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rknboost",
|
"name": "rknboost",
|
||||||
"version": "0.5.0",
|
"version": "0.5.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rknboost",
|
"name": "rknboost",
|
||||||
"version": "0.5.0",
|
"version": "0.5.4",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+2
-1
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "rknboost",
|
"name": "rknboost",
|
||||||
"version": "0.5.0",
|
"version": "0.5.4",
|
||||||
"description": "Кроссплатформенное десктопное приложение на Electron + React + TypeScript",
|
"description": "Кроссплатформенное десктопное приложение на Electron + React + TypeScript",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
|
"desktopName": "rknboost.desktop",
|
||||||
"author": "RKNboost",
|
"author": "RKNboost",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 633 KiB |
Executable
+175
@@ -0,0 +1,175 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Привилегированный помощник обхода блокировок на Linux — единственное, что RKNboost
|
||||||
|
# запускает через `pkexec`. Не редактировать под bash-специфику: /bin/sh на Debian/Ubuntu —
|
||||||
|
# это dash, а не bash.
|
||||||
|
#
|
||||||
|
# Зовётся из src/main/zapret/nfqws.linux.ts ровно двумя способами:
|
||||||
|
# pkexec <этот файл> start <nfqws> <strategy.cfg> <tcp-порты> <udp-порты>
|
||||||
|
# pkexec <этот файл> stop
|
||||||
|
#
|
||||||
|
# Всё, что можно решить без root (выбор стратегии, генерация strategy.cfg, проверка
|
||||||
|
# `nfqws --dry-run`), уже сделано на стороне приложения — сюда попадают только готовые пути
|
||||||
|
# и уже провалидированные строки портов (см. nfqwsConfig() в strategies.ts: там же формат
|
||||||
|
# портов проверен регуляркой, здесь ему просто доверяем). Раздельные пути/аргументы вместо
|
||||||
|
# одной строки — чтобы этот скрипт не собирал команду nft из чего-то похожего на shell-код.
|
||||||
|
#
|
||||||
|
# Коды выхода — намеренно 3..6, чтобы не путаться с 126/127 самого pkexec (отказ в правах /
|
||||||
|
# команда не найдена) при разборе результата на стороне приложения.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
RUN_DIR=/run/rknboost
|
||||||
|
PIDFILE="$RUN_DIR/nfqws.pid"
|
||||||
|
CONFIG="$RUN_DIR/strategy.cfg"
|
||||||
|
ERRLOG="$RUN_DIR/nfqws.err"
|
||||||
|
TABLE_FAMILY=inet
|
||||||
|
TABLE_NAME=rknboost
|
||||||
|
# Держать в синхроне с NFQUEUE_NUM в nfqws.linux.ts (используется там же для --dry-run).
|
||||||
|
QUEUE_NUM=220
|
||||||
|
FW_MARK=0x40000000
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "rknboost-helper: $*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# pkexec запускает с урезанным PATH (санирует окружение) — sbin, где обычно лежит nft,
|
||||||
|
# в нём может не быть. Ищем явно по стандартным местам, а не полагаемся на PATH.
|
||||||
|
find_nft() {
|
||||||
|
for candidate in /usr/sbin/nft /sbin/nft /usr/local/sbin/nft /usr/bin/nft; do
|
||||||
|
if [ -x "$candidate" ]; then
|
||||||
|
echo "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if command -v nft >/dev/null 2>&1; then
|
||||||
|
command -v nft
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Сносит и таблицу, и демона. Идёт первым шагом `start` (самолечение: следующий успешный
|
||||||
|
# запуск убирает любой мусор от аварийно прерванной прошлой сессии) и отдельной командой
|
||||||
|
# `stop`. Всегда завершается успешно — «уже остановлено» не ошибка, тот же принцип, что и
|
||||||
|
# у stopService() на Windows.
|
||||||
|
cmd_stop() {
|
||||||
|
nft="${1:-}"
|
||||||
|
|
||||||
|
if [ -f "$PIDFILE" ]; then
|
||||||
|
pid="$(cat "$PIDFILE" 2>/dev/null || true)"
|
||||||
|
# /proc/<pid>/comm — это ровно "nfqws\n" (обрезано до 15 символов ядром, но nfqws
|
||||||
|
# короче), сверяем, чтобы не убить чужой процесс, переиспользовавший тот же pid.
|
||||||
|
if [ -n "$pid" ] && [ -r "/proc/$pid/comm" ] && [ "$(cat "/proc/$pid/comm" 2>/dev/null)" = "nfqws" ]; then
|
||||||
|
kill -TERM "$pid" 2>/dev/null || true
|
||||||
|
i=0
|
||||||
|
while [ -d "/proc/$pid" ] && [ "$i" -lt 30 ]; do
|
||||||
|
i=$((i + 1))
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
if [ -d "/proc/$pid" ]; then
|
||||||
|
kill -KILL "$pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
rm -f "$PIDFILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$CONFIG" "$ERRLOG"
|
||||||
|
|
||||||
|
if [ -z "$nft" ]; then
|
||||||
|
nft="$(find_nft || true)"
|
||||||
|
fi
|
||||||
|
if [ -n "$nft" ] && "$nft" list table "$TABLE_FAMILY" "$TABLE_NAME" >/dev/null 2>&1; then
|
||||||
|
"$nft" delete table "$TABLE_FAMILY" "$TABLE_NAME" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_start() {
|
||||||
|
nfqws_bin="$1"
|
||||||
|
config_src="$2"
|
||||||
|
tcp_ports="$3"
|
||||||
|
udp_ports="$4"
|
||||||
|
|
||||||
|
nft="$(find_nft || true)"
|
||||||
|
if [ -z "$nft" ]; then
|
||||||
|
log "не найдена утилита nft (пакет nftables)"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# На всякий случай предупреждаем, если параллельно уже висит другой zapret со своей
|
||||||
|
# таблицей/очередью — двойной приём одного трафика в две очереди работать не будет,
|
||||||
|
# но это не наша таблица, сносить её самовольно нельзя.
|
||||||
|
if "$nft" list table inet zapret >/dev/null 2>&1 || "$nft" list table inet zapretunix >/dev/null 2>&1; then
|
||||||
|
log "внимание: обнаружена таблица nftables другого zapret-инструмента — возможен конфликт очередей"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cmd_stop "$nft"
|
||||||
|
|
||||||
|
mkdir -p "$RUN_DIR"
|
||||||
|
chmod 0755 "$RUN_DIR"
|
||||||
|
|
||||||
|
# strategy.cfg в /run — источник маркера стратегии для LinuxEngine.sync(): читается без
|
||||||
|
# root (0644), первая строка — тот же --comment=rknboost:<id>:<hash>, что и на Windows.
|
||||||
|
cp "$config_src" "$CONFIG"
|
||||||
|
chmod 0644 "$CONFIG"
|
||||||
|
|
||||||
|
if ! "$nft" add table "$TABLE_FAMILY" "$TABLE_NAME"; then
|
||||||
|
log "не удалось создать таблицу nftables"
|
||||||
|
cmd_stop "$nft"
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
"$nft" add chain "$TABLE_FAMILY" "$TABLE_NAME" post '{ type filter hook postrouting priority mangle; }'
|
||||||
|
"$nft" add chain "$TABLE_FAMILY" "$TABLE_NAME" pre '{ type filter hook prerouting priority filter; }'
|
||||||
|
|
||||||
|
# bypass обязателен: без него при завершении/падении nfqws весь трафик на эти порты
|
||||||
|
# молча дропался бы. С bypass ядро принимает пакет, если очередь никто не слушает —
|
||||||
|
# то есть отключение обхода (или его крах) не рвёт пользователю сеть.
|
||||||
|
ok=1
|
||||||
|
"$nft" add rule "$TABLE_FAMILY" "$TABLE_NAME" post \
|
||||||
|
meta mark and $FW_MARK == 0 tcp dport "{$tcp_ports}" \
|
||||||
|
ct original packets 1-6 queue num $QUEUE_NUM bypass || ok=0
|
||||||
|
"$nft" add rule "$TABLE_FAMILY" "$TABLE_NAME" post \
|
||||||
|
meta mark and $FW_MARK == 0 udp dport "{$udp_ports}" \
|
||||||
|
ct original packets 1-6 queue num $QUEUE_NUM bypass || ok=0
|
||||||
|
"$nft" add rule "$TABLE_FAMILY" "$TABLE_NAME" pre \
|
||||||
|
tcp sport "{$tcp_ports}" \
|
||||||
|
ct reply packets 1-3 queue num $QUEUE_NUM bypass || ok=0
|
||||||
|
|
||||||
|
if [ "$ok" -ne 1 ]; then
|
||||||
|
log "не удалось создать правила nftables"
|
||||||
|
cmd_stop "$nft"
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
|
||||||
|
# nfq_init() у nfqws происходит до daemonize() — если очередь занята, модуля нет в ядре
|
||||||
|
# или не хватает прав, процесс завершится с ненулевым кодом ДО демонизации, и мы это
|
||||||
|
# увидим здесь же, без опроса готовности.
|
||||||
|
if ! "$nfqws_bin" --daemon \
|
||||||
|
--pidfile="$PIDFILE" \
|
||||||
|
--qnum=$QUEUE_NUM \
|
||||||
|
--dpi-desync-fwmark=$FW_MARK \
|
||||||
|
"@$CONFIG" 2>"$ERRLOG"; then
|
||||||
|
log "nfqws не запустился: $(cat "$ERRLOG" 2>/dev/null || true)"
|
||||||
|
cmd_stop "$nft"
|
||||||
|
exit 5
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
action="${1:-}"
|
||||||
|
case "$action" in
|
||||||
|
start)
|
||||||
|
shift
|
||||||
|
if [ "$#" -ne 4 ]; then
|
||||||
|
log "start ожидает 4 аргумента: <nfqws> <config> <tcp-порты> <udp-порты>"
|
||||||
|
exit 6
|
||||||
|
fi
|
||||||
|
cmd_start "$1" "$2" "$3" "$4"
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
cmd_stop ""
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log "неизвестная команда: $action (ожидается start|stop)"
|
||||||
|
exit 6
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -41,6 +41,7 @@ const PREFIX = `zapret-${ZAPRET_VERSION}`
|
|||||||
/** Что забираем из архива bol-van/zapret: путь внутри архива → путь внутри resources/zapret. */
|
/** Что забираем из архива bol-van/zapret: путь внутри архива → путь внутри resources/zapret. */
|
||||||
const FILES = {
|
const FILES = {
|
||||||
'binaries/mac64/tpws': 'darwin/tpws',
|
'binaries/mac64/tpws': 'darwin/tpws',
|
||||||
|
'binaries/linux-x86_64/nfqws': 'linux/x64/nfqws',
|
||||||
'binaries/windows-x86_64/winws.exe': 'win32/x64/winws.exe',
|
'binaries/windows-x86_64/winws.exe': 'win32/x64/winws.exe',
|
||||||
'binaries/windows-x86_64/cygwin1.dll': 'win32/x64/cygwin1.dll',
|
'binaries/windows-x86_64/cygwin1.dll': 'win32/x64/cygwin1.dll',
|
||||||
'binaries/windows-x86_64/WinDivert.dll': 'win32/x64/WinDivert.dll',
|
'binaries/windows-x86_64/WinDivert.dll': 'win32/x64/WinDivert.dll',
|
||||||
@@ -49,7 +50,7 @@ const FILES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Исполняемый бит нужен только там, где мы сами запускаем бинарник. */
|
/** Исполняемый бит нужен только там, где мы сами запускаем бинарник. */
|
||||||
const EXECUTABLE = new Set(['darwin/tpws'])
|
const EXECUTABLE = new Set(['darwin/tpws', 'linux/x64/nfqws'])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Списки доменов Flowseal → наши короткие имена (используются и в `lists.ts`, и
|
* Списки доменов Flowseal → наши короткие имена (используются и в `lists.ts`, и
|
||||||
@@ -206,6 +207,7 @@ async function main() {
|
|||||||
if (
|
if (
|
||||||
process.argv.includes('--force') ||
|
process.argv.includes('--force') ||
|
||||||
!existsSync(join(DEST, 'darwin', 'tpws')) ||
|
!existsSync(join(DEST, 'darwin', 'tpws')) ||
|
||||||
|
!existsSync(join(DEST, 'linux', 'x64', 'nfqws')) ||
|
||||||
!existsSync(join(DEST, 'win32', 'x64', 'winws.exe')) ||
|
!existsSync(join(DEST, 'win32', 'x64', 'winws.exe')) ||
|
||||||
!existsSync(join(DEST, 'lists', 'general.txt'))
|
!existsSync(join(DEST, 'lists', 'general.txt'))
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ function parseBatFile(filename, content) {
|
|||||||
// Мы передаём argv напрямую в spawn (без шелла) — кавычки внутри значения
|
// Мы передаём argv напрямую в spawn (без шелла) — кавычки внутри значения
|
||||||
// остались бы буквальными символами в имени файла, поэтому убираем везде.
|
// остались бы буквальными символами в имени файла, поэтому убираем везде.
|
||||||
.map((t) => t.replaceAll('"', ''))
|
.map((t) => t.replaceAll('"', ''))
|
||||||
|
// `^!` — экранирование `!` в cmd.exe (в .bat включён enabledelayedexpansion, где `!`
|
||||||
|
// иначе разворачивался бы как переменная). cmd.exe снимает его сам перед запуском
|
||||||
|
// winws.exe; здесь командную строку разбираем руками, и `^` иначе остался бы буквально
|
||||||
|
// в значении. Реальное значение zapret ожидает — `!` («'!' = standard fake» в readme).
|
||||||
|
.map((t) => t.replaceAll('=^!', '=!'))
|
||||||
.map(resolvePlaceholders)
|
.map(resolvePlaceholders)
|
||||||
|
|
||||||
if (tokens.length < 5)
|
if (tokens.length < 5)
|
||||||
|
|||||||
+5
-1
@@ -1,4 +1,4 @@
|
|||||||
import { BrowserWindow, ipcMain, nativeTheme, type IpcMainInvokeEvent } from 'electron'
|
import { app, BrowserWindow, ipcMain, nativeTheme, type IpcMainInvokeEvent } from 'electron'
|
||||||
import type { IpcChannel, IpcHandlers, ThemeState } from '../shared/ipc-contract'
|
import type { IpcChannel, IpcHandlers, ThemeState } from '../shared/ipc-contract'
|
||||||
import { uninstallApp } from './uninstall.win32'
|
import { uninstallApp } from './uninstall.win32'
|
||||||
import {
|
import {
|
||||||
@@ -46,6 +46,10 @@ function themeState(): ThemeState {
|
|||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
handle('theme:get', () => themeState())
|
handle('theme:get', () => themeState())
|
||||||
|
|
||||||
|
// Ровно то же значение, что electron-builder кладёт в имя установщика и в
|
||||||
|
// «Установленные приложения» — оба берут его из "version" в package.json.
|
||||||
|
handle('app:version', () => app.getVersion())
|
||||||
|
|
||||||
// Кнопка «Удалить приложение» есть в интерфейсе только на Windows (SettingsView.tsx),
|
// Кнопка «Удалить приложение» есть в интерфейсе только на Windows (SettingsView.tsx),
|
||||||
// но канал регистрируем всегда — так и должно быть по контракту, просто на других
|
// но канал регистрируем всегда — так и должно быть по контракту, просто на других
|
||||||
// платформах он никогда не вызывается.
|
// платформах он никогда не вызывается.
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { access, constants } from 'node:fs/promises'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `pkexec` из состава polkit — им поднимаем root для nftables/nfqws. Проверяем по конкретным
|
||||||
|
* путям, а не через `PATH`: тот же мотив, что у `find_nft()` в `rknboost-helper.sh` — санитайз
|
||||||
|
* окружения инструментами вроде pkexec/env-с-урезанным-PATH встречается достаточно часто,
|
||||||
|
* чтобы не полагаться на переменную окружения при поиске самого пути к элевации.
|
||||||
|
*/
|
||||||
|
const PKEXEC_CANDIDATES = ['/usr/bin/pkexec', '/bin/pkexec']
|
||||||
|
|
||||||
|
/** `nft` — тот же список путей, что перебирает `find_nft()` в помощнике (держим в синхроне). */
|
||||||
|
const NFT_CANDIDATES = ['/usr/sbin/nft', '/sbin/nft', '/usr/local/sbin/nft', '/usr/bin/nft']
|
||||||
|
|
||||||
|
async function findExecutable(candidates: readonly string[]): Promise<string | null> {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
await access(candidate, constants.X_OK)
|
||||||
|
return candidate
|
||||||
|
} catch {
|
||||||
|
// пробуем следующий путь
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка окружения до первого запроса пароля — тот же мотив, что у `assertExecutable()`
|
||||||
|
* в `paths.ts`: без неё пользователь увидел бы «spawn pkexec ENOENT» или пустой отказ nft
|
||||||
|
* внутри чужого stderr и не понял бы, что установить.
|
||||||
|
*/
|
||||||
|
export async function assertLinuxDependencies(): Promise<void> {
|
||||||
|
if (!(await findExecutable(PKEXEC_CANDIDATES))) {
|
||||||
|
throw new Error(
|
||||||
|
'Не найден pkexec — установите пакет polkit ' +
|
||||||
|
'(Debian/Ubuntu: «sudo apt install policykit-1»).'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!(await findExecutable(NFT_CANDIDATES))) {
|
||||||
|
throw new Error(
|
||||||
|
'Не найдена утилита nft — установите пакет nftables (Debian/Ubuntu: «sudo apt install nftables»).'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import type { ZapretStatus } from '../../shared/ipc-contract'
|
||||||
|
import { assertLinuxDependencies } from './deps.linux'
|
||||||
|
import { ZapretEngine } from './engine'
|
||||||
|
import { dryRun, isProcessAlive, readNfqwsState, startNfqws, stopNfqws } from './nfqws.linux'
|
||||||
|
import { defaultStrategyId, nfqwsConfig } from './strategies'
|
||||||
|
|
||||||
|
/** Как часто опрашивать /proc — демон нам не дочерний процесс, экзит-событие поймать некому. */
|
||||||
|
const WATCH_INTERVAL_MS = 3000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linux: nfqws (движок zapret на уровне пакетов, как и Windows-версия) + правила nftables
|
||||||
|
* NFQUEUE. Ни служба, ни автозапуск не заведены (см. README.md) — каждое включение просит
|
||||||
|
* пароль через `pkexec` (см. `nfqws.linux.ts`), а сам демон и его правила переживают выход
|
||||||
|
* из приложения: `bypass` на очереди означает, что их отсутствие не рвёт пользователю сеть
|
||||||
|
* (см. `rknboost-helper.sh`), поэтому `dispose()` намеренно пуст — как и на Windows.
|
||||||
|
*/
|
||||||
|
export class LinuxEngine extends ZapretEngine {
|
||||||
|
private watchTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('nfqws-nftables', defaultStrategyId('linux'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вызывается из `recoverZapret()` до открытия окна — как и `Win32Engine.sync()`, подхватывает
|
||||||
|
* состояние, оставшееся от предыдущего запуска приложения (демон root-процессом переживает
|
||||||
|
* закрытие приложения, автозапуска с системой при этом нет — состояние может быть только
|
||||||
|
* «работает с прошлой сессии» или «выключен»).
|
||||||
|
*/
|
||||||
|
async sync(): Promise<ZapretStatus> {
|
||||||
|
const { pid, strategyId } = await readNfqwsState()
|
||||||
|
|
||||||
|
if (pid === null) {
|
||||||
|
this.stopWatch()
|
||||||
|
return this.patch({ state: 'stopped', startedAt: null, error: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
this.startWatch(pid)
|
||||||
|
return this.patch({
|
||||||
|
state: 'running',
|
||||||
|
strategyId: strategyId ?? this.status.strategyId,
|
||||||
|
// Момент реального запуска нам не известен — демон мог подняться в прошлой сессии
|
||||||
|
// приложения. Тот же компромисс, что и в Win32Engine.sync().
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
error: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(strategyId: string): Promise<ZapretStatus> {
|
||||||
|
this.patch({ state: 'starting', strategyId, error: null })
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Всё, что можно проверить без пароля, проверяем в этом порядке: сначала формат
|
||||||
|
// самой стратегии, потом окружение, потом сами аргументы через --dry-run — тот же
|
||||||
|
// принцип, что и у Win32Engine.start(), только тут это не опционально, а единственный
|
||||||
|
// шанс не побеспокоить пользователя запросом root ради заведомо неверных данных.
|
||||||
|
const config = nfqwsConfig(strategyId)
|
||||||
|
await assertLinuxDependencies()
|
||||||
|
await dryRun(config.body)
|
||||||
|
|
||||||
|
await startNfqws({
|
||||||
|
configBody: config.body,
|
||||||
|
tcpPorts: config.tcpPorts,
|
||||||
|
udpPorts: config.udpPorts
|
||||||
|
})
|
||||||
|
|
||||||
|
const { pid } = await readNfqwsState()
|
||||||
|
if (pid !== null) this.startWatch(pid)
|
||||||
|
|
||||||
|
return this.patch({ state: 'running', startedAt: new Date().toISOString(), error: null })
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
this.appendLog('error', message)
|
||||||
|
return this.patch({ state: 'error', error: message, startedAt: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<ZapretStatus> {
|
||||||
|
this.patch({ state: 'stopping' })
|
||||||
|
this.stopWatch()
|
||||||
|
try {
|
||||||
|
await stopNfqws()
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
this.appendLog('error', message)
|
||||||
|
return this.patch({ state: 'error', error: message })
|
||||||
|
}
|
||||||
|
return this.patch({ state: 'stopped', startedAt: null, error: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
// Демон и правила nftables живут своей жизнью в системе — выход приложения их не
|
||||||
|
// останавливает (см. комментарий класса). Опрос /proc всё равно больше не нужен.
|
||||||
|
this.stopWatch()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Демон нам не дочерний процесс (владеет им root, а не Electron) — событие завершения
|
||||||
|
* поймать некому, поэтому опрашиваем `/proc/<pid>` по таймеру. `bypass` в правилах
|
||||||
|
* nftables делает мёртвый демон безопасным для сети, но не для интерфейса: без этого
|
||||||
|
* опроса карточка продолжала бы показывать «Активен» после падения nfqws.
|
||||||
|
*/
|
||||||
|
private startWatch(pid: number): void {
|
||||||
|
this.stopWatch()
|
||||||
|
this.watchTimer = setInterval(() => {
|
||||||
|
void isProcessAlive(pid).then((alive) => {
|
||||||
|
if (alive || this.status.state !== 'running') return
|
||||||
|
this.stopWatch()
|
||||||
|
this.appendLog('error', `nfqws (pid ${pid}) больше не запущен`)
|
||||||
|
this.patch({
|
||||||
|
state: 'error',
|
||||||
|
error: 'Процесс nfqws неожиданно завершился.',
|
||||||
|
startedAt: null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, WATCH_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopWatch(): void {
|
||||||
|
if (this.watchTimer) clearInterval(this.watchTimer)
|
||||||
|
this.watchTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,15 +116,15 @@ export abstract class ZapretEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Заглушка для платформ, под которые мы бинарники не собираем (Linux). */
|
/** Заглушка для платформ, под которые мы бинарники не собираем (FreeBSD и прочее — не macOS/Windows/Linux). */
|
||||||
export class UnsupportedEngine extends ZapretEngine {
|
export class UnsupportedEngine extends ZapretEngine {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('unsupported', '')
|
super('unsupported', '')
|
||||||
this.status.error = 'Обход блокировок доступен только на macOS и Windows.'
|
this.status.error = 'Обход блокировок доступен только на macOS, Windows и Linux.'
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<ZapretStatus> {
|
async start(): Promise<ZapretStatus> {
|
||||||
throw new Error('Обход блокировок доступен только на macOS и Windows.')
|
throw new Error('Обход блокировок доступен только на macOS, Windows и Linux.')
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<ZapretStatus> {
|
async stop(): Promise<ZapretStatus> {
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { execFile } from 'node:child_process'
|
import { execFile } from 'node:child_process'
|
||||||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
import { createHash } from 'node:crypto'
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
import type { ZapretStatus } from '../../shared/ipc-contract'
|
import type { ZapretStatus } from '../../shared/ipc-contract'
|
||||||
import { ZapretEngine } from './engine'
|
import { ZapretEngine } from './engine'
|
||||||
import { assertExecutable, protectedWinwsPath, serviceConfigPath, winwsPath } from './paths'
|
import {
|
||||||
|
SERVICE_BINARY_NAMES,
|
||||||
|
assertExecutable,
|
||||||
|
serviceDataDir,
|
||||||
|
serviceConfigPath,
|
||||||
|
winwsPath
|
||||||
|
} from './paths'
|
||||||
import {
|
import {
|
||||||
applyServiceConfig,
|
applyServiceConfig,
|
||||||
queryService,
|
queryService,
|
||||||
@@ -14,7 +21,12 @@ import {
|
|||||||
stopService,
|
stopService,
|
||||||
type ServiceInfo
|
type ServiceInfo
|
||||||
} from './service.win32'
|
} from './service.win32'
|
||||||
import { defaultStrategyId, winwsConfigFile, type WinwsConfig } from './strategies'
|
import {
|
||||||
|
defaultStrategyId,
|
||||||
|
type EngineConfig,
|
||||||
|
parseStrategyMarker,
|
||||||
|
winwsConfigFile
|
||||||
|
} from './strategies'
|
||||||
|
|
||||||
const run = promisify(execFile)
|
const run = promisify(execFile)
|
||||||
|
|
||||||
@@ -28,18 +40,34 @@ async function readInstalledMarkerLine(): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sha256(path: string): Promise<string> {
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(await readFile(path))
|
||||||
|
.digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Служба всегда запускает защищённую копию winws.exe (protectedWinwsPath), а не оригинал
|
* Служба запускает защищённую копию бинарников, а не оригинал из папки приложения — значит
|
||||||
* из папки установки приложения (winwsPath) — значит переустановку/обновление приложения
|
* обновление по самому пути службы не отследить. Сравниваем содержимое: время изменения тут
|
||||||
* по самому пути службы не отследить, как раньше. Вместо этого сравниваем размер и время
|
* не годится совсем, `scripts/fetch-zapret.mjs` кладёт файлы через `copyFile`, а тот ставит
|
||||||
* изменения оригинала с уже скопированным файлом: разошлись — обновление zapret вышло
|
* текущее время — после каждой сборки CI mtime новее, хотя версия zapret запинована и байты
|
||||||
* позже последней копии, нужно копировать заново (а значит и UAC).
|
* те же. По mtime мы на ровном месте требовали UAC при каждом обновлении приложения и лезли
|
||||||
|
* копировать поверх WinDivert64.sys, ещё загруженного в ядро.
|
||||||
*/
|
*/
|
||||||
async function binariesNeedCopy(): Promise<boolean> {
|
async function binariesNeedCopy(): Promise<boolean> {
|
||||||
|
const sourceDir = dirname(winwsPath())
|
||||||
|
const targetDir = serviceDataDir()
|
||||||
try {
|
try {
|
||||||
const [source, copy] = await Promise.all([stat(winwsPath()), stat(protectedWinwsPath())])
|
for (const name of SERVICE_BINARY_NAMES) {
|
||||||
return source.size !== copy.size || source.mtimeMs > copy.mtimeMs
|
const [source, copy] = await Promise.all([
|
||||||
|
sha256(join(sourceDir, name)),
|
||||||
|
sha256(join(targetDir, name))
|
||||||
|
])
|
||||||
|
if (source !== copy) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
} catch {
|
} catch {
|
||||||
|
// Какого-то файла ещё нет (первая установка) или он нечитаем — надёжнее скопировать.
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +77,7 @@ async function binariesNeedCopy(): Promise<boolean> {
|
|||||||
* winws.exe отстала от оригинала в папке приложения, или если аргументы стратегии
|
* winws.exe отстала от оригинала в папке приложения, или если аргументы стратегии
|
||||||
* изменились (маркер в `strategy.cfg` не совпадает с тем, что сгенерировали сейчас).
|
* изменились (маркер в `strategy.cfg` не совпадает с тем, что сгенерировали сейчас).
|
||||||
*/
|
*/
|
||||||
async function needsReconfigure(config: WinwsConfig, info: ServiceInfo | null): Promise<boolean> {
|
async function needsReconfigure(config: EngineConfig, info: ServiceInfo | null): Promise<boolean> {
|
||||||
if (!info) return true
|
if (!info) return true
|
||||||
if (await binariesNeedCopy()) return true
|
if (await binariesNeedCopy()) return true
|
||||||
return (await readInstalledMarkerLine()) !== config.marker
|
return (await readInstalledMarkerLine()) !== config.marker
|
||||||
@@ -84,8 +112,7 @@ export class Win32Engine extends ZapretEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const installedLine = await readInstalledMarkerLine()
|
const installedLine = await readInstalledMarkerLine()
|
||||||
const match = installedLine ? /^--comment=rknboost:([^:]+):/.exec(installedLine) : null
|
const strategyId = parseStrategyMarker(installedLine) ?? this.status.strategyId
|
||||||
const strategyId = match?.[1] ?? this.status.strategyId
|
|
||||||
|
|
||||||
return this.patch({
|
return this.patch({
|
||||||
serviceInstalled: true,
|
serviceInstalled: true,
|
||||||
@@ -102,7 +129,7 @@ export class Win32Engine extends ZapretEngine {
|
|||||||
async start(strategyId: string): Promise<ZapretStatus> {
|
async start(strategyId: string): Promise<ZapretStatus> {
|
||||||
this.patch({ state: 'starting', strategyId, error: null })
|
this.patch({ state: 'starting', strategyId, error: null })
|
||||||
|
|
||||||
let config: WinwsConfig
|
let config: EngineConfig
|
||||||
try {
|
try {
|
||||||
config = winwsConfigFile(strategyId)
|
config = winwsConfigFile(strategyId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { loadSettings } from '../settings'
|
|||||||
import { DIAGNOSTIC_TARGETS, runDiagnostics } from './diagnostics'
|
import { DIAGNOSTIC_TARGETS, runDiagnostics } from './diagnostics'
|
||||||
import { DarwinEngine } from './engine.darwin'
|
import { DarwinEngine } from './engine.darwin'
|
||||||
import { ZapretEngine, UnsupportedEngine } from './engine'
|
import { ZapretEngine, UnsupportedEngine } from './engine'
|
||||||
|
import { LinuxEngine } from './engine.linux'
|
||||||
import { Win32Engine } from './engine.win32'
|
import { Win32Engine } from './engine.win32'
|
||||||
import { readAllLists, resetList, seedLists, writeList } from './lists'
|
import { readAllLists, resetList, seedLists, writeList } from './lists'
|
||||||
import { autoHostlistPath } from './paths'
|
import { autoHostlistPath } from './paths'
|
||||||
@@ -23,6 +24,8 @@ function createEngine(): ZapretEngine {
|
|||||||
return new DarwinEngine()
|
return new DarwinEngine()
|
||||||
case 'win32':
|
case 'win32':
|
||||||
return new Win32Engine()
|
return new Win32Engine()
|
||||||
|
case 'linux':
|
||||||
|
return new LinuxEngine()
|
||||||
default:
|
default:
|
||||||
return new UnsupportedEngine()
|
return new UnsupportedEngine()
|
||||||
}
|
}
|
||||||
@@ -33,8 +36,9 @@ const engine = createEngine()
|
|||||||
/**
|
/**
|
||||||
* Готовим списки сайтов, возвращаем выбор пользователя из прошлого запуска и синхронизируем
|
* Готовим списки сайтов, возвращаем выбор пользователя из прошлого запуска и синхронизируем
|
||||||
* состояние с системой — на macOS чиним прокси от прошлого аварийного завершения, на Windows
|
* состояние с системой — на macOS чиним прокси от прошлого аварийного завершения, на Windows
|
||||||
* подхватываем службу, которая могла работать ещё до открытия окна (автозапуск с системой).
|
* подхватываем службу, которая могла работать ещё до открытия окна (автозапуск с системой),
|
||||||
* Все шаги нужно сделать до открытия окна.
|
* на Linux — демон nfqws, переживший закрытие приложения (автозапуска у него нет, но сам
|
||||||
|
* процесс и правила nftables продолжают работать). Все шаги нужно сделать до открытия окна.
|
||||||
*/
|
*/
|
||||||
export async function recoverZapret(): Promise<void> {
|
export async function recoverZapret(): Promise<void> {
|
||||||
await seedLists()
|
await seedLists()
|
||||||
@@ -52,6 +56,7 @@ export async function recoverZapret(): Promise<void> {
|
|||||||
|
|
||||||
if (engine instanceof DarwinEngine) await engine.recoverFromCrash()
|
if (engine instanceof DarwinEngine) await engine.recoverFromCrash()
|
||||||
if (engine instanceof Win32Engine) await engine.sync()
|
if (engine instanceof Win32Engine) await engine.sync()
|
||||||
|
if (engine instanceof LinuxEngine) await engine.sync()
|
||||||
}
|
}
|
||||||
|
|
||||||
function knownStrategyId(strategyId: string | null): string | null {
|
function knownStrategyId(strategyId: string | null): string | null {
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { execFile, spawn } from 'node:child_process'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import {
|
||||||
|
access,
|
||||||
|
chmod,
|
||||||
|
constants,
|
||||||
|
copyFile,
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readdir,
|
||||||
|
readFile,
|
||||||
|
rm,
|
||||||
|
writeFile
|
||||||
|
} from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { promisify } from 'node:util'
|
||||||
|
import {
|
||||||
|
bundledFakesDir,
|
||||||
|
bundledHelperPath,
|
||||||
|
linuxInstalledConfigPath,
|
||||||
|
linuxPidfilePath,
|
||||||
|
linuxStagingDir,
|
||||||
|
nfqwsPath,
|
||||||
|
stagedFakesDir,
|
||||||
|
stagedHelperPath,
|
||||||
|
stagedNfqwsPath
|
||||||
|
} from './paths'
|
||||||
|
import { parseStrategyMarker } from './strategies'
|
||||||
|
|
||||||
|
const run = promisify(execFile)
|
||||||
|
|
||||||
|
async function sha256(path: string): Promise<string> {
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(await readFile(path))
|
||||||
|
.digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filesIdentical(a: string, b: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const [hashA, hashB] = await Promise.all([sha256(a), sha256(b)])
|
||||||
|
return hashA === hashB
|
||||||
|
} catch {
|
||||||
|
// Копии ещё нет (первый запуск) или она нечитаема — надёжнее перекопировать.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Копирует nfqws, помощника и fake-пакеты из `resources/zapret`/`resources/linux-helper`
|
||||||
|
* в `linuxStagingDir()` — см. её комментарий в paths.ts про то, почему это не оптимизация,
|
||||||
|
* а необходимость под AppImage. Сравниваем содержимое, а не mtime — тот же мотив, что у
|
||||||
|
* `binariesNeedCopy()` в engine.win32.ts: `copyFile` всегда ставит текущее время, хотя
|
||||||
|
* зафиксированная версия zapret даёт одни и те же байты между сборками.
|
||||||
|
*/
|
||||||
|
export async function stageRuntime(): Promise<void> {
|
||||||
|
await mkdir(linuxStagingDir(), { recursive: true })
|
||||||
|
await mkdir(stagedFakesDir(), { recursive: true })
|
||||||
|
|
||||||
|
if (!(await filesIdentical(nfqwsPath(), stagedNfqwsPath()))) {
|
||||||
|
await copyFile(nfqwsPath(), stagedNfqwsPath())
|
||||||
|
}
|
||||||
|
await chmod(stagedNfqwsPath(), 0o755)
|
||||||
|
|
||||||
|
if (!(await filesIdentical(bundledHelperPath(), stagedHelperPath()))) {
|
||||||
|
await copyFile(bundledHelperPath(), stagedHelperPath())
|
||||||
|
}
|
||||||
|
await chmod(stagedHelperPath(), 0o755)
|
||||||
|
|
||||||
|
const fakesSrcDir = bundledFakesDir()
|
||||||
|
for (const name of await readdir(fakesSrcDir)) {
|
||||||
|
const src = join(fakesSrcDir, name)
|
||||||
|
const dst = join(stagedFakesDir(), name)
|
||||||
|
if (!(await filesIdentical(src, dst))) {
|
||||||
|
await copyFile(src, dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Номер очереди NFQUEUE — держать в синхроне с `QUEUE_NUM` в `rknboost-helper.sh`
|
||||||
|
* (там же и объяснение выбора числа). В TS он нужен только для `--dry-run` ниже: сам
|
||||||
|
* помощник получает его не отсюда, а как захардкоженное значение в своём коде.
|
||||||
|
*/
|
||||||
|
const NFQUEUE_NUM = 220
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `nfqws --dry-run @<файл>` — проверяет аргументы фильтра, root не нужен (в отличие от
|
||||||
|
* рабочего запуска демона). Запускаем бинарник из сборки напрямую: dry-run идёт от uid
|
||||||
|
* самого приложения, а не root, так что FUSE-примонтированный AppImage тут не помеха —
|
||||||
|
* ограничение касается только `pkexec` (см. linuxStagingDir()).
|
||||||
|
*
|
||||||
|
* В отличие от `Win32Engine['dryRun']` (там `winws.exe --dry-run @file` без доп. флагов
|
||||||
|
* достаточно) nfqws требует `--qnum` даже для проверки — «Need queue number (--qnum)»,
|
||||||
|
* даже когда реальная очередь не открывается. Значение то же, что использует помощник
|
||||||
|
* при настоящем запуске: раз номер не совпадёт — `nft`-правила и демон всё равно будут
|
||||||
|
* рассинхронизированы, но сам факт валидности остальных аргументов dry-run проверяет верно.
|
||||||
|
*/
|
||||||
|
export async function dryRun(configBody: string): Promise<void> {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'rknboost-dryrun-'))
|
||||||
|
const configPath = join(dir, 'dry-run.cfg')
|
||||||
|
try {
|
||||||
|
await writeFile(configPath, configBody, 'utf8')
|
||||||
|
await run(nfqwsPath(), ['--dry-run', `--qnum=${NFQUEUE_NUM}`, `@${configPath}`])
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
throw new Error(`Стратегия содержит недопустимые параметры nfqws: ${message}`, {
|
||||||
|
cause: error
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Отрендеренный конфиг лежит здесь до pkexec — сам помощник, уже под root, копирует его в /run. */
|
||||||
|
async function writeStagedConfig(body: string): Promise<string> {
|
||||||
|
const path = join(linuxStagingDir(), 'pending-strategy.cfg')
|
||||||
|
await writeFile(path, body, 'utf8')
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Хвост stderr помощника — тот же лимит и мотив, что у `stderrTail` в engine.darwin.ts. */
|
||||||
|
const STDERR_TAIL_LIMIT = 4000
|
||||||
|
|
||||||
|
function runHelper(args: string[]): Promise<{ code: number; stderr: string }> {
|
||||||
|
return new Promise((resolveHelper) => {
|
||||||
|
const child = spawn('pkexec', [stagedHelperPath(), ...args], {
|
||||||
|
stdio: ['ignore', 'ignore', 'pipe']
|
||||||
|
})
|
||||||
|
|
||||||
|
let stderr = ''
|
||||||
|
child.stderr.on('data', (chunk: Buffer) => {
|
||||||
|
stderr = (stderr + chunk.toString('utf8')).slice(-STDERR_TAIL_LIMIT)
|
||||||
|
})
|
||||||
|
|
||||||
|
// pkexec не найден — spawn сообщает об этом событием 'error', а не кодом выхода
|
||||||
|
// (см. launchUninstaller() в uninstall.win32.ts, та же причина).
|
||||||
|
child.on('error', (error) => resolveHelper({ code: 127, stderr: error.message }))
|
||||||
|
child.on('close', (code) => resolveHelper({ code: code ?? 1, stderr }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartNfqwsOptions {
|
||||||
|
configBody: string
|
||||||
|
tcpPorts: string
|
||||||
|
udpPorts: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Единственные два места, где приложение просит root — включение и выключение обхода. */
|
||||||
|
export async function startNfqws(options: StartNfqwsOptions): Promise<void> {
|
||||||
|
await stageRuntime()
|
||||||
|
const configPath = await writeStagedConfig(options.configBody)
|
||||||
|
|
||||||
|
const { code, stderr } = await runHelper([
|
||||||
|
'start',
|
||||||
|
stagedNfqwsPath(),
|
||||||
|
configPath,
|
||||||
|
options.tcpPorts,
|
||||||
|
options.udpPorts
|
||||||
|
])
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Не удалось запустить обход (pkexec, код ${code})` +
|
||||||
|
(stderr ? `: ${stderr}` : ' — запрос прав администратора отклонён или прерван.')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopNfqws(): Promise<void> {
|
||||||
|
// Помощник может понадобиться, даже если start() в эту сессию ещё не звали — например,
|
||||||
|
// sync() увидел уже работающий с прошлого сеанса демон, и первое действие пользователя
|
||||||
|
// в новом запуске приложения — «Выключить».
|
||||||
|
await stageRuntime()
|
||||||
|
|
||||||
|
const { code, stderr } = await runHelper(['stop'])
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Не удалось остановить обход (pkexec, код ${code})` +
|
||||||
|
(stderr ? `: ${stderr}` : ' — запрос прав администратора отклонён или прерван.')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NfqwsState {
|
||||||
|
pid: number | null
|
||||||
|
strategyId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/proc/<pid>` — существование каталога о процессе видно без прав, сигнал слать не нужно. */
|
||||||
|
export async function isProcessAlive(pid: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(`/proc/${pid}`, constants.F_OK)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Не требует root: `nfqws.pid` и `strategy.cfg` в `/run/rknboost` помощник кладёт с правами
|
||||||
|
* 0644 (см. rknboost-helper.sh) именно затем, чтобы это чтение — и восстановление состояния
|
||||||
|
* в `LinuxEngine.sync()` — обходилось без пароля.
|
||||||
|
*/
|
||||||
|
export async function readNfqwsState(): Promise<NfqwsState> {
|
||||||
|
let pid: number | null = null
|
||||||
|
try {
|
||||||
|
const raw = (await readFile(linuxPidfilePath(), 'utf8')).trim()
|
||||||
|
const parsed = Number.parseInt(raw, 10)
|
||||||
|
if (Number.isInteger(parsed) && parsed > 0 && (await isProcessAlive(parsed))) {
|
||||||
|
pid = parsed
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// pidfile нет или процесс из него уже не живёт — считаем демон не запущенным.
|
||||||
|
}
|
||||||
|
|
||||||
|
let strategyId: string | null = null
|
||||||
|
try {
|
||||||
|
const content = await readFile(linuxInstalledConfigPath(), 'utf8')
|
||||||
|
strategyId = parseStrategyMarker(content.split('\n')[0]?.trim() ?? null)
|
||||||
|
} catch {
|
||||||
|
// strategy.cfg нет — не страшно, id останется null.
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pid, strategyId }
|
||||||
|
}
|
||||||
@@ -23,6 +23,71 @@ export function winwsPath(): string {
|
|||||||
return join(zapretRoot(), 'win32', 'x64', 'winws.exe')
|
return join(zapretRoot(), 'win32', 'x64', 'winws.exe')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Linux: nfqws в составе сборки — для `--dry-run` (без root, читает свой же uid). */
|
||||||
|
export function nfqwsPath(): string {
|
||||||
|
return join(zapretRoot(), 'linux', 'x64', 'nfqws')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linux: привилегированный помощник в составе сборки (`resources/linux-helper/`, отдельный
|
||||||
|
* `extraResources` в electron-builder.yml — не качается `fetch-zapret.mjs`, лежит в репозитории).
|
||||||
|
*/
|
||||||
|
export function bundledHelperPath(): string {
|
||||||
|
return app.isPackaged
|
||||||
|
? join(process.resourcesPath, 'linux-helper', 'rknboost-helper.sh')
|
||||||
|
: resolve(app.getAppPath(), 'resources', 'linux-helper', 'rknboost-helper.sh')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linux: каталог, куда `nfqws`, помощник и fake-пакеты копируются перед КАЖДЫМ запуском
|
||||||
|
* (см. `nfqws.linux.ts`). Не оптимизация — необходимость: в AppImage `resources/zapret`
|
||||||
|
* примонтирован через FUSE без `allow_other`, и root (а `pkexec` поднимает именно root)
|
||||||
|
* туда просто не достучится — ни исполнить бинарник, ни прочитать fake-пакет во время
|
||||||
|
* работы. `userData` — обычная файловая система на диске; root видит любой файл на ней
|
||||||
|
* независимо от прав (DAC_OVERRIDE), так что копия здесь читается без проблем что для
|
||||||
|
* AppImage, что для .deb (там `resources/zapret` и так root-owned, копия просто лишняя,
|
||||||
|
* но не вредная — один код-путь на обе цели дешевле двух).
|
||||||
|
*/
|
||||||
|
export function linuxStagingDir(): string {
|
||||||
|
return join(app.getPath('userData'), 'linux-runtime')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stagedNfqwsPath(): string {
|
||||||
|
return join(linuxStagingDir(), 'nfqws')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stagedHelperPath(): string {
|
||||||
|
return join(linuxStagingDir(), 'rknboost-helper.sh')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Стейджинговая копия `fakesDir()` — то, что реально читает nfqws (см. её комментарий). */
|
||||||
|
export function stagedFakesDir(): string {
|
||||||
|
return join(linuxStagingDir(), 'fakes')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Встроенные fake-пакеты — источник для копирования в `stagedFakesDir()`. */
|
||||||
|
export function bundledFakesDir(): string {
|
||||||
|
return join(zapretRoot(), 'fakes')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рабочий каталог демона — pidfile и `strategy.cfg` (последний — источник маркера стратегии
|
||||||
|
* для `LinuxEngine.sync()`, читается без root: помощник кладёт его с правами 0644). Создаёт
|
||||||
|
* и чистит сам `rknboost-helper.sh`. `/run` — стандартный FHS-путь для данных, не переживающих
|
||||||
|
* перезагрузку: nfqws её тоже не переживает, автозапуска на Linux нет (см. README.md).
|
||||||
|
*/
|
||||||
|
export function linuxRuntimeDir(): string {
|
||||||
|
return '/run/rknboost'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linuxPidfilePath(): string {
|
||||||
|
return join(linuxRuntimeDir(), 'nfqws.pid')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linuxInstalledConfigPath(): string {
|
||||||
|
return join(linuxRuntimeDir(), 'strategy.cfg')
|
||||||
|
}
|
||||||
|
|
||||||
/** Встроенные списки (из Flowseal/zapret-discord-youtube, см. scripts/fetch-zapret.mjs) — только для чтения. */
|
/** Встроенные списки (из Flowseal/zapret-discord-youtube, см. scripts/fetch-zapret.mjs) — только для чтения. */
|
||||||
export function bundledListsDir(): string {
|
export function bundledListsDir(): string {
|
||||||
return join(zapretRoot(), 'lists')
|
return join(zapretRoot(), 'lists')
|
||||||
@@ -37,9 +102,14 @@ export function userListsDir(): string {
|
|||||||
return join(app.getPath('userData'), 'zapret-lists')
|
return join(app.getPath('userData'), 'zapret-lists')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fake-пакеты (TLS ClientHello, QUIC initial, ...) для стратегий winws. */
|
/**
|
||||||
|
* Fake-пакеты (TLS ClientHello, QUIC initial, ...) — путь, который резолвится в
|
||||||
|
* `{FAKES}` (`strategies.ts`) и должен суметь прочитать сам движок при работе, не только
|
||||||
|
* при запуске. На Linux это стейджинговая копия (`stagedFakesDir()`) — см. её комментарий
|
||||||
|
* про FUSE; на остальных платформах движок и так работает под тем же uid, что видит сборку.
|
||||||
|
*/
|
||||||
export function fakesDir(): string {
|
export function fakesDir(): string {
|
||||||
return join(zapretRoot(), 'fakes')
|
return process.platform === 'linux' ? stagedFakesDir() : join(zapretRoot(), 'fakes')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +126,19 @@ export function serviceConfigPath(): string {
|
|||||||
return join(serviceDataDir(), 'strategy.cfg')
|
return join(serviceDataDir(), 'strategy.cfg')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Файлы, которые служба запускает из своего защищённого каталога: сам winws и всё, от чего
|
||||||
|
* он зависит при старте. Список нужен и `engine.win32.ts` (решить, надо ли копировать),
|
||||||
|
* и повышенному скрипту в `service.win32.ts` (собственно копирование) — держим его здесь,
|
||||||
|
* чтобы он не разъехался на две копии.
|
||||||
|
*/
|
||||||
|
export const SERVICE_BINARY_NAMES = [
|
||||||
|
'winws.exe',
|
||||||
|
'cygwin1.dll',
|
||||||
|
'WinDivert.dll',
|
||||||
|
'WinDivert64.sys'
|
||||||
|
] as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Защищённая копия `winws.exe` (+ cygwin1.dll, WinDivert.dll, WinDivert64.sys) — на неё,
|
* Защищённая копия `winws.exe` (+ cygwin1.dll, WinDivert.dll, WinDivert64.sys) — на неё,
|
||||||
* а не на оригинал из папки установки приложения, указывает служба. Установка теперь
|
* а не на оригинал из папки установки приложения, указывает служба. Установка теперь
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
isElevated,
|
isElevated,
|
||||||
runElevatedServiceTask
|
runElevatedServiceTask
|
||||||
} from './elevated-task.win32'
|
} from './elevated-task.win32'
|
||||||
import { serviceConfigPath, serviceDataDir, winwsPath } from './paths'
|
import { SERVICE_BINARY_NAMES, serviceConfigPath, serviceDataDir, winwsPath } from './paths'
|
||||||
|
|
||||||
const run = promisify(execFile)
|
const run = promisify(execFile)
|
||||||
|
|
||||||
@@ -31,6 +31,8 @@ const ERROR_SERVICE_ALREADY_RUNNING = 1056
|
|||||||
const ERROR_SERVICE_NOT_ACTIVE = 1062
|
const ERROR_SERVICE_NOT_ACTIVE = 1062
|
||||||
|
|
||||||
export type ServiceState = 'Running' | 'Stopped' | 'StartPending' | 'StopPending' | 'Other'
|
export type ServiceState = 'Running' | 'Stopped' | 'StartPending' | 'StopPending' | 'Other'
|
||||||
|
/** То, чего можно дождаться от службы. `Absent` — службы в системе нет (снята). */
|
||||||
|
type WaitState = 'Running' | 'Stopped' | 'Absent'
|
||||||
export type ServiceStartMode = 'Auto' | 'Manual' | 'Disabled' | 'Other'
|
export type ServiceStartMode = 'Auto' | 'Manual' | 'Disabled' | 'Other'
|
||||||
|
|
||||||
export interface ServiceInfo {
|
export interface ServiceInfo {
|
||||||
@@ -40,10 +42,6 @@ export interface ServiceInfo {
|
|||||||
binPath: string
|
binPath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
||||||
}
|
|
||||||
|
|
||||||
function exitCodeOf(error: unknown): number {
|
function exitCodeOf(error: unknown): number {
|
||||||
const code = (error as { code?: number | string } | undefined)?.code
|
const code = (error as { code?: number | string } | undefined)?.code
|
||||||
return typeof code === 'number' ? code : 1
|
return typeof code === 'number' ? code : 1
|
||||||
@@ -81,12 +79,29 @@ function normalizeStartMode(raw: string | undefined): ServiceStartMode {
|
|||||||
* Опрос через `Get-CimInstance Win32_Service`, а не парсинг вывода `sc query` — у CIM
|
* Опрос через `Get-CimInstance Win32_Service`, а не парсинг вывода `sc query` — у CIM
|
||||||
* `State`/`StartMode` это английские константы независимо от языка Windows, а `sc` печатает
|
* `State`/`StartMode` это английские константы независимо от языка Windows, а `sc` печатает
|
||||||
* локализованный текст, который парсить надёжно нельзя.
|
* локализованный текст, который парсить надёжно нельзя.
|
||||||
|
*
|
||||||
|
* Ждём тоже здесь, внутри одного скрипта: запуск powershell.exe стоит около секунды (он
|
||||||
|
* поднимает .NET), а опросов до перехода службы в нужное состояние бывает с десяток. Раньше
|
||||||
|
* каждый опрос был отдельным процессом, и включение обхода складывалось в основном из этих
|
||||||
|
* запусков; теперь на всё ожидание приходится один. Сам опрос внутри CIM — десятки мс.
|
||||||
*/
|
*/
|
||||||
export async function queryService(): Promise<ServiceInfo | null> {
|
function queryScript(until: readonly WaitState[], timeoutMs: number): string {
|
||||||
const script =
|
return `
|
||||||
`Get-CimInstance Win32_Service -Filter "Name='${SERVICE_NAME}'" | ` +
|
$targets = @(${until.map((state) => `'${state}'`).join(',')})
|
||||||
'Select-Object State,StartMode,PathName | ConvertTo-Json -Compress'
|
$deadline = [DateTime]::UtcNow.AddMilliseconds(${timeoutMs})
|
||||||
|
for (;;) {
|
||||||
|
$svc = Get-CimInstance Win32_Service -Filter "Name='${SERVICE_NAME}'" |
|
||||||
|
Select-Object State,StartMode,PathName
|
||||||
|
$state = if ($svc) { $svc.State } else { 'Absent' }
|
||||||
|
if ($targets.Count -eq 0 -or $targets -contains $state) { break }
|
||||||
|
if ([DateTime]::UtcNow -ge $deadline) { break }
|
||||||
|
Start-Sleep -Milliseconds ${POLL_INTERVAL_MS}
|
||||||
|
}
|
||||||
|
if ($svc) { $svc | ConvertTo-Json -Compress } else { 'null' }
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runQuery(script: string): Promise<ServiceInfo | null> {
|
||||||
let stdout: string
|
let stdout: string
|
||||||
try {
|
try {
|
||||||
;({ stdout } = await run('powershell.exe', ['-NoProfile', '-Command', script]))
|
;({ stdout } = await run('powershell.exe', ['-NoProfile', '-Command', script]))
|
||||||
@@ -108,6 +123,19 @@ export async function queryService(): Promise<ServiceInfo | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Текущее состояние службы; `null` — службы в системе нет. */
|
||||||
|
export function queryService(): Promise<ServiceInfo | null> {
|
||||||
|
return runQuery(queryScript([], 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ждёт одного из состояний и возвращает последнее увиденное — в том числе по таймауту,
|
||||||
|
* поэтому результат у вызывающего кода обязателен к проверке.
|
||||||
|
*/
|
||||||
|
function waitForService(...states: WaitState[]): Promise<ServiceInfo | null> {
|
||||||
|
return runQuery(queryScript(states, POLL_TIMEOUT_MS))
|
||||||
|
}
|
||||||
|
|
||||||
function explainScError(error: unknown, verb: string): string {
|
function explainScError(error: unknown, verb: string): string {
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
if (/access is denied/i.test(message)) {
|
if (/access is denied/i.test(message)) {
|
||||||
@@ -119,18 +147,6 @@ function explainScError(error: unknown, verb: string): string {
|
|||||||
return `Не удалось ${verb} службу: ${message}`
|
return `Не удалось ${verb} службу: ${message}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollUntil(
|
|
||||||
predicate: (info: ServiceInfo | null) => boolean
|
|
||||||
): Promise<ServiceInfo | null> {
|
|
||||||
const deadline = Date.now() + POLL_TIMEOUT_MS
|
|
||||||
for (;;) {
|
|
||||||
const info = await queryService()
|
|
||||||
if (predicate(info)) return info
|
|
||||||
if (Date.now() >= deadline) return info
|
|
||||||
await sleep(POLL_INTERVAL_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `sc start` только просит SCM перевести службу в running и возвращается сразу — если не
|
* `sc start` только просит SCM перевести службу в running и возвращается сразу — если не
|
||||||
* дождаться реального состояния, свежепадающая служба (например, WinDivert не загрузился)
|
* дождаться реального состояния, свежепадающая служба (например, WinDivert не загрузился)
|
||||||
@@ -147,7 +163,7 @@ export async function startService(): Promise<ServiceInfo | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = await pollUntil((i) => i?.state === 'Running' || i?.state === 'Stopped')
|
const info = await waitForService('Running', 'Stopped')
|
||||||
if (info?.state !== 'Running') {
|
if (info?.state !== 'Running') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Служба запущена системой, но не перешла в рабочее состояние (текущее: ` +
|
`Служба запущена системой, но не перешла в рабочее состояние (текущее: ` +
|
||||||
@@ -169,12 +185,11 @@ export async function stopService(): Promise<ServiceInfo | null> {
|
|||||||
throw new Error(explainScError(error, 'остановить'), { cause: error })
|
throw new Error(explainScError(error, 'остановить'), { cause: error })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// `pollUntil` возвращает последнее состояние и по истечении таймаута, независимо от предиката,
|
// Результат обязательно проверяем: «остановка», не уложившаяся в POLL_TIMEOUT_MS, иначе
|
||||||
// поэтому результат обязательно проверяем. Иначе «остановка», не уложившаяся в POLL_TIMEOUT_MS,
|
// молча сошла бы за успешную — и вызывающий код (перенастройка стратегии в engine.win32.ts)
|
||||||
// молча сходила бы за успешную — и вызывающий код (перенастройка стратегии в engine.win32.ts)
|
|
||||||
// пошёл бы копировать winws.exe поверх ещё работающего, ровно в ту ошибку, от которой
|
// пошёл бы копировать winws.exe поверх ещё работающего, ровно в ту ошибку, от которой
|
||||||
// остановка и защищает.
|
// остановка и защищает.
|
||||||
const info = await pollUntil((i) => i === null || i.state === 'Stopped')
|
const info = await waitForService('Stopped', 'Absent')
|
||||||
if (info && info.state !== 'Stopped') {
|
if (info && info.state !== 'Stopped') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Служба не остановилась за ${POLL_TIMEOUT_MS / 1000} с (состояние: ${info.state}).`
|
`Служба не остановилась за ${POLL_TIMEOUT_MS / 1000} с (состояние: ${info.state}).`
|
||||||
@@ -270,40 +285,46 @@ function Invoke-ScRaw($rawArgs) {
|
|||||||
return [pscustomobject]@{ ExitCode = $proc.ExitCode; Output = ("$stdout $stderr").Trim() }
|
return [pscustomobject]@{ ExitCode = $proc.ExitCode; Output = ("$stdout $stderr").Trim() }
|
||||||
}
|
}
|
||||||
|
|
||||||
# WinDivert64.sys — файл драйвера, загруженного в ядро: пока драйвер не выгружен, файл занят,
|
# WinDivert64.sys — образ драйвера, загруженного в ядро: пока драйвер не выгружен, файл
|
||||||
# и Copy-Item поверх него падает с «Процесс не может получить доступ к файлу ... используется
|
# занят, и Copy-Item поверх него падает с «файл используется другим процессом». SCM
|
||||||
# другим процессом». SCM рапортует службе состояние Stopped, как только winws.exe отдал сигнал
|
# рапортует Stopped, как только winws.exe отдал сигнал, а выгрузка идёт уже после — при
|
||||||
# об остановке, — выгрузка драйвера происходит уже после этого, поэтому при быстром
|
# быстром переподключении попадаем ровно в этот зазор.
|
||||||
# переподключении (выключил и сразу включил, сменил стратегию) перенастройка попадала ровно
|
|
||||||
# в этот зазор.
|
|
||||||
#
|
#
|
||||||
# Отсюда две меры. Первая и главная: не копировать то, что не менялось, — при смене стратегии
|
# Отсюда три ступени. Сравнение по ХЕШУ, а не по времени изменения: fetch-zapret.mjs кладёт
|
||||||
# бинарники те же самые, и трогать занятый .sys незачем (Copy-Item сохраняет LastWriteTime
|
# бинарники через copyFile, тот ставит текущее время, и после каждой сборки CI mtime новее
|
||||||
# источника, так что длина + время записи однозначно отличают уже скопированный файл).
|
# при тех же байтах — по времени мы лезли копировать занятый .sys после каждого обновления
|
||||||
# Вторая — на случай, когда копировать действительно надо (обновление приложения принесло
|
# приложения. Дальше короткие повторы — вдруг драйвер вот-вот выгрузится. Последняя ступень:
|
||||||
# новый zapret): ждём выгрузки драйвера повторами, а не падаем на первой же попытке.
|
# уводим занятый файл в сторону переименованием (NTFS разрешает переименовать загруженный
|
||||||
|
# образ, хотя удалить его не даёт) и кладём новый на освободившееся имя.
|
||||||
function Copy-IfChanged($source, $destination) {
|
function Copy-IfChanged($source, $destination) {
|
||||||
$src = Get-Item -LiteralPath $source
|
|
||||||
if (Test-Path -LiteralPath $destination) {
|
if (Test-Path -LiteralPath $destination) {
|
||||||
$dst = Get-Item -LiteralPath $destination
|
$sourceHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $source).Hash
|
||||||
if ($dst.Length -eq $src.Length -and $dst.LastWriteTimeUtc -eq $src.LastWriteTimeUtc) { return }
|
$copyHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash
|
||||||
|
if ($sourceHash -eq $copyHash) { return }
|
||||||
}
|
}
|
||||||
|
|
||||||
$attempts = 20
|
for ($attempt = 1; $attempt -le 6; $attempt++) {
|
||||||
for ($attempt = 1; $attempt -le $attempts; $attempt++) {
|
|
||||||
try {
|
try {
|
||||||
Copy-Item -LiteralPath $source -Destination $destination -Force
|
Copy-Item -LiteralPath $source -Destination $destination -Force
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
if ($attempt -eq $attempts) {
|
|
||||||
throw "Не удалось обновить $destination за 10 с: $($_.Exception.Message) Если обход только что был выключен, драйвер WinDivert ещё выгружается — повторите через несколько секунд."
|
|
||||||
}
|
|
||||||
Start-Sleep -Milliseconds 500
|
Start-Sleep -Milliseconds 500
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stale = "$destination.old"
|
||||||
|
Remove-Item -LiteralPath $stale -Force -ErrorAction SilentlyContinue
|
||||||
|
Move-Item -LiteralPath $destination -Destination $stale -Force
|
||||||
|
Copy-Item -LiteralPath $source -Destination $destination -Force
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
throw "Не удалось обновить $destination : $($_.Exception.Message)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$payload = Get-Content -Raw -Path $PayloadPath | ConvertFrom-Json
|
$payload = Get-Content -Raw -Path $PayloadPath | ConvertFrom-Json
|
||||||
|
|
||||||
@@ -332,10 +353,15 @@ try {
|
|||||||
# cygwin1.dll/WinDivert.dll/WinDivert64.sys, от них он зависит при запуске.
|
# cygwin1.dll/WinDivert.dll/WinDivert64.sys, от них он зависит при запуске.
|
||||||
$sourceDir = Split-Path -Parent $payload.winwsPath
|
$sourceDir = Split-Path -Parent $payload.winwsPath
|
||||||
$protectedWinws = Join-Path $dir 'winws.exe'
|
$protectedWinws = Join-Path $dir 'winws.exe'
|
||||||
foreach ($name in @('winws.exe', 'cygwin1.dll', 'WinDivert.dll', 'WinDivert64.sys')) {
|
foreach ($name in $payload.binaryNames) {
|
||||||
Copy-IfChanged (Join-Path $sourceDir $name) (Join-Path $dir $name)
|
Copy-IfChanged (Join-Path $sourceDir $name) (Join-Path $dir $name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Хвосты от прошлой перенастройки: тогда файл был занят загруженным драйвером, сейчас
|
||||||
|
# он, скорее всего, уже свободен. Не вышло — не беда, попробуем в следующий раз.
|
||||||
|
Get-ChildItem -LiteralPath $dir -Filter '*.old' -ErrorAction SilentlyContinue |
|
||||||
|
Remove-Item -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
# sc.exe читает binPath как ОДИН аргумент — значит всю строку нужно взять в одну пару
|
# sc.exe читает binPath как ОДИН аргумент — значит всю строку нужно взять в одну пару
|
||||||
# кавычек, а внутренние кавычки экранировать как \\" (без этого CommandLineToArgvW режет
|
# кавычек, а внутренние кавычки экранировать как \\" (без этого CommandLineToArgvW режет
|
||||||
# значение на два токена по пробелу перед "@", и sc create падает с кодом 1639).
|
# значение на два токена по пробелу перед "@", и sc create падает с кодом 1639).
|
||||||
@@ -500,6 +526,7 @@ export async function applyServiceConfig(options: ApplyServiceConfigOptions): Pr
|
|||||||
configPath: serviceConfigPath(),
|
configPath: serviceConfigPath(),
|
||||||
configBody: options.configBody,
|
configBody: options.configBody,
|
||||||
winwsPath: winwsPath(),
|
winwsPath: winwsPath(),
|
||||||
|
binaryNames: SERVICE_BINARY_NAMES,
|
||||||
autoStart: options.autoStart
|
autoStart: options.autoStart
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-29
@@ -13,11 +13,14 @@ import { WIN32_STRATEGIES } from './strategies.win32.generated'
|
|||||||
* переупорядочивать сегменты (`--split-pos`, `--disorder`, `--oob`, `--tlsrec`), но не
|
* переупорядочивать сегменты (`--split-pos`, `--disorder`, `--oob`, `--tlsrec`), но не
|
||||||
* умеет ни fake-пакетов, ни UDP/QUIC вообще. Профили ниже — свои, под это ограничение.
|
* умеет ни fake-пакетов, ни UDP/QUIC вообще. Профили ниже — свои, под это ограничение.
|
||||||
*
|
*
|
||||||
* `winws` (Windows) работает на уровне пакетов через WinDivert. Его пресеты — 22 варианта
|
* `winws` (Windows) и `nfqws` (Linux) — один и тот же движок zapret на уровне пакетов
|
||||||
* из Flowseal/zapret-discord-youtube, перенесённые в `strategies.win32.generated.ts`
|
* (winws — сборка nfq/nfqws.c под WinDivert вместо netfilter), поэтому делят один набор
|
||||||
* генератором `scripts/import-flowseal-strategies.mjs` (не батниками — кодом).
|
* пресетов — 22 варианта из Flowseal/zapret-discord-youtube, перенесённые в
|
||||||
|
* `strategies.win32.generated.ts` генератором `scripts/import-flowseal-strategies.mjs`
|
||||||
|
* (не батниками — кодом). На Linux `--wf-tcp`/`--wf-udp` из тех же аргументов уходят не в
|
||||||
|
* nfqws, а в правила nftables — см. `nfqwsConfig()` ниже и `resources/linux-helper/`.
|
||||||
*
|
*
|
||||||
* Оба движка используют одни и те же списки сайтов (`src/main/zapret/lists.ts`):
|
* Все три движка используют одни и те же списки сайтов (`src/main/zapret/lists.ts`):
|
||||||
* `{LISTS}/<id>.txt` — путь резолвится в `resolvePlaceholders()` ниже.
|
* `{LISTS}/<id>.txt` — путь резолвится в `resolvePlaceholders()` ниже.
|
||||||
*/
|
*/
|
||||||
interface DarwinStrategyDefinition extends ZapretStrategy {
|
interface DarwinStrategyDefinition extends ZapretStrategy {
|
||||||
@@ -143,9 +146,18 @@ const DARWIN_DEFINITIONS: DarwinStrategyDefinition[] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Стратегия по умолчанию — своя на каждой платформе, наборы id не пересекаются. */
|
/**
|
||||||
|
* Стратегия по умолчанию. win32 и linux делят один набор id (оба читают
|
||||||
|
* `WIN32_STRATEGIES`), поэтому и дефолт у них общий — 'general'; у darwin свой набор.
|
||||||
|
*/
|
||||||
export function defaultStrategyId(platform: NodeJS.Platform): string {
|
export function defaultStrategyId(platform: NodeJS.Platform): string {
|
||||||
return platform === 'win32' ? 'general' : 'split'
|
switch (platform) {
|
||||||
|
case 'win32':
|
||||||
|
case 'linux':
|
||||||
|
return 'general'
|
||||||
|
default:
|
||||||
|
return 'split'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePlaceholders(arg: string): string {
|
function resolvePlaceholders(arg: string): string {
|
||||||
@@ -155,6 +167,17 @@ function resolvePlaceholders(arg: string): string {
|
|||||||
.replaceAll(AUTO_PLACEHOLDER, autoHostlistPath())
|
.replaceAll(AUTO_PLACEHOLDER, autoHostlistPath())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** win32 и linux показывают один и тот же список Flowseal-пресетов — читают его из общего массива. */
|
||||||
|
function flowsealStrategies(platform: 'win32' | 'linux'): ZapretStrategy[] {
|
||||||
|
return WIN32_STRATEGIES.map(({ id, name }) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
// У Flowseal нет описаний стратегий — только имена вариантов, подобранных под провайдера.
|
||||||
|
description: 'Стратегия из подборки Flowseal/zapret-discord-youtube.',
|
||||||
|
platforms: [platform]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/** Пресеты, применимые к текущей платформе. */
|
/** Пресеты, применимые к текущей платформе. */
|
||||||
export function listStrategies(platform: NodeJS.Platform): ZapretStrategy[] {
|
export function listStrategies(platform: NodeJS.Platform): ZapretStrategy[] {
|
||||||
if (platform === 'darwin') {
|
if (platform === 'darwin') {
|
||||||
@@ -165,15 +188,8 @@ export function listStrategies(platform: NodeJS.Platform): ZapretStrategy[] {
|
|||||||
platforms
|
platforms
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
if (platform === 'win32') {
|
if (platform === 'win32') return flowsealStrategies('win32')
|
||||||
return WIN32_STRATEGIES.map(({ id, name }) => ({
|
if (platform === 'linux') return flowsealStrategies('linux')
|
||||||
id,
|
|
||||||
name,
|
|
||||||
// У Flowseal нет описаний стратегий — только имена вариантов, подобранных под провайдера.
|
|
||||||
description: 'Стратегия из подборки Flowseal/zapret-discord-youtube.',
|
|
||||||
platforms: ['win32']
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,12 +217,13 @@ export function winwsArgs(strategyId: string): string[] {
|
|||||||
return strategy.args.map(resolvePlaceholders)
|
return strategy.args.map(resolvePlaceholders)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Читает служба: `winws.exe @strategy.cfg`, ограничение размера у самого winws — 16 КБ. */
|
/** Читают winws и nfqws через `@strategy.cfg` — ограничение размера общее у обоих, 16 КБ. */
|
||||||
const MAX_CONFIG_FILE_BYTES = 16000
|
const MAX_CONFIG_FILE_BYTES = 16000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* winws разбирает файл конфигурации через POSIX `wordexp()` (см. `nfq/nfqws.c` в поставке
|
* winws и nfqws — один и тот же парсер аргументов (`nfq/nfqws.c` в поставке zapret,
|
||||||
* zapret) — без кавычек он режет аргумент по пробелам и съедает обратные слэши в путях вида
|
* winws — его сборка под WinDivert), файл конфигурации разбирают через POSIX `wordexp()` —
|
||||||
|
* без кавычек он режет аргумент по пробелам и съедает обратные слэши в путях вида
|
||||||
* `C:\Users\...`. Каждый аргумент поэтому кладём в одинарные кавычки, а `'` внутри значения
|
* `C:\Users\...`. Каждый аргумент поэтому кладём в одинарные кавычки, а `'` внутри значения
|
||||||
* экранируем как `'\''` — тем же приёмом, что и `networksetup()` в `proxy.darwin.ts` для shell.
|
* экранируем как `'\''` — тем же приёмом, что и `networksetup()` в `proxy.darwin.ts` для shell.
|
||||||
*/
|
*/
|
||||||
@@ -215,33 +232,104 @@ function quoteConfigArg(arg: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Первая строка — маркер: по нему `Win32Engine.sync()` узнаёт, какая стратегия уже установлена
|
* Первая строка — маркер: по нему `Win32Engine.sync()`/`LinuxEngine.sync()` узнают, какая
|
||||||
* в службу, не перечитывая все аргументы. Сам маркер — штатная опция `--comment` winws,
|
* стратегия уже установлена, не перечитывая все аргументы. Сам маркер — штатная опция
|
||||||
* в работу процесса не вмешивается.
|
* `--comment` (есть и у winws, и у nfqws), в работу процесса не вмешивается.
|
||||||
*/
|
*/
|
||||||
function configMarker(strategyId: string, body: string): string {
|
function configMarker(strategyId: string, body: string): string {
|
||||||
const hash = createHash('sha256').update(body).digest('hex').slice(0, 12)
|
const hash = createHash('sha256').update(body).digest('hex').slice(0, 12)
|
||||||
return `--comment=rknboost:${strategyId}:${hash}`
|
return `--comment=rknboost:${strategyId}:${hash}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WinwsConfig {
|
const MARKER_PATTERN = /^--comment=rknboost:([^:]+):/
|
||||||
/** Содержимое `strategy.cfg` — UTF-8 без BOM, как требует winws (docs/windows.md zapret). */
|
|
||||||
|
/**
|
||||||
|
* Обратная операция к `configMarker()` — достаёт id стратегии из первой строки уже
|
||||||
|
* установленного конфига. Общий разбор для `Win32Engine.sync()` и `LinuxEngine.sync()`,
|
||||||
|
* чтобы регэксп маркера не разъехался на два места.
|
||||||
|
*/
|
||||||
|
export function parseStrategyMarker(line: string | null): string | null {
|
||||||
|
if (!line) return null
|
||||||
|
return MARKER_PATTERN.exec(line)?.[1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EngineConfig {
|
||||||
|
/** Содержимое `strategy.cfg` — UTF-8 без BOM, как требует winws/nfqws (docs/windows.md zapret). */
|
||||||
body: string
|
body: string
|
||||||
/** Маркер первой строки — сравнить с уже установленным, чтобы понять, нужна ли перенастройка. */
|
/** Маркер первой строки — сравнить с уже установленным, чтобы понять, нужна ли перенастройка. */
|
||||||
marker: string
|
marker: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Рендерит стратегию в файл аргументов для `winws.exe @<file>` — см. `WinwsConfig`. */
|
/** Общий рендер: кавычки, маркер первой строкой, проверка лимита в 16 КБ. */
|
||||||
export function winwsConfigFile(strategyId: string): WinwsConfig {
|
function renderConfigFile(strategyId: string, args: string[]): EngineConfig {
|
||||||
const args = winwsArgs(strategyId).map(quoteConfigArg).join('\n')
|
const quoted = args.map(quoteConfigArg).join('\n')
|
||||||
const marker = configMarker(strategyId, args)
|
const marker = configMarker(strategyId, quoted)
|
||||||
const body = `${marker}\n${args}\n`
|
const body = `${marker}\n${quoted}\n`
|
||||||
|
|
||||||
if (Buffer.byteLength(body, 'utf8') > MAX_CONFIG_FILE_BYTES) {
|
if (Buffer.byteLength(body, 'utf8') > MAX_CONFIG_FILE_BYTES) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Стратегия «${strategyId}» не помещается в лимит winws на файл конфигурации (16 КБ).`
|
`Стратегия «${strategyId}» не помещается в лимит winws/nfqws на файл конфигурации (16 КБ).`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { body, marker }
|
return { body, marker }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Рендерит стратегию в файл аргументов для `winws.exe @<file>` — см. `EngineConfig`. */
|
||||||
|
export function winwsConfigFile(strategyId: string): EngineConfig {
|
||||||
|
return renderConfigFile(strategyId, winwsArgs(strategyId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const WF_TCP_PREFIX = '--wf-tcp='
|
||||||
|
const WF_UDP_PREFIX = '--wf-udp='
|
||||||
|
|
||||||
|
export interface NfqwsConfig extends EngineConfig {
|
||||||
|
/** Порты для правила nftables `post` (исходящий трафик) — см. `rknboost-helper.sh`. */
|
||||||
|
tcpPorts: string
|
||||||
|
/** Порты для правил nftables `post`/`pre` (исходящий и ответный UDP). */
|
||||||
|
udpPorts: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* На Windows `--wf-tcp`/`--wf-udp` — это фильтр самого WinDivert, зашитый в те же аргументы
|
||||||
|
* winws. На Linux фильтрацию делает nftables снаружи nfqws (см. `rknboost-helper.sh`), а
|
||||||
|
* сам nfqws эти два аргумента не понимает — вырезаем их и отдаём отдельно вызывающей стороне.
|
||||||
|
*/
|
||||||
|
export function nfqwsConfig(strategyId: string): NfqwsConfig {
|
||||||
|
const args = winwsArgs(strategyId)
|
||||||
|
let tcpPorts: string | null = null
|
||||||
|
let udpPorts: string | null = null
|
||||||
|
const nfqwsArgs: string[] = []
|
||||||
|
|
||||||
|
for (const arg of args) {
|
||||||
|
if (arg.startsWith(WF_TCP_PREFIX)) {
|
||||||
|
tcpPorts = arg.slice(WF_TCP_PREFIX.length)
|
||||||
|
} else if (arg.startsWith(WF_UDP_PREFIX)) {
|
||||||
|
udpPorts = arg.slice(WF_UDP_PREFIX.length)
|
||||||
|
} else {
|
||||||
|
nfqwsArgs.push(arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// На всех 22 стратегиях сейчас ровно по одному --wf-tcp/--wf-udp (проверено вручную при
|
||||||
|
// добавлении Linux), но если очередной апдейт Flowseal формат изменит — лучше явная ошибка
|
||||||
|
// здесь, чем nftables-правило без портов.
|
||||||
|
if (tcpPorts === null || udpPorts === null) {
|
||||||
|
throw new Error(`Стратегия «${strategyId}»: не найден --wf-tcp/--wf-udp в аргументах.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Значение уходит прямо в множество nft-правила (rknboost-helper.sh) без дополнительного
|
||||||
|
// экранирования — только цифры, запятые и дефисы диапазонов. Если Flowseal когда-нибудь
|
||||||
|
// передаст что-то ещё (например, `~` — отрицание порта), лучше явная ошибка тут, чем
|
||||||
|
// нераспознанный текст в командной строке nft, которую выполняет root.
|
||||||
|
for (const [flag, ports] of [
|
||||||
|
['--wf-tcp', tcpPorts],
|
||||||
|
['--wf-udp', udpPorts]
|
||||||
|
] as const) {
|
||||||
|
if (!/^\d+(-\d+)?(,\d+(-\d+)?)*$/.test(ports)) {
|
||||||
|
throw new Error(`Стратегия «${strategyId}»: неожиданный формат ${flag}=${ports}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...renderConfigFile(strategyId, nfqwsArgs), tcpPorts, udpPorts }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1581,7 +1581,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [
|
|||||||
'--dpi-desync-repeats=11',
|
'--dpi-desync-repeats=11',
|
||||||
'--dpi-desync-fooling=badseq',
|
'--dpi-desync-fooling=badseq',
|
||||||
'--dpi-desync-fake-tls=0x00000000',
|
'--dpi-desync-fake-tls=0x00000000',
|
||||||
'--dpi-desync-fake-tls=^!',
|
'--dpi-desync-fake-tls=!',
|
||||||
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
||||||
'--new',
|
'--new',
|
||||||
'--filter-tcp=443',
|
'--filter-tcp=443',
|
||||||
@@ -1592,7 +1592,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [
|
|||||||
'--dpi-desync-repeats=11',
|
'--dpi-desync-repeats=11',
|
||||||
'--dpi-desync-fooling=badseq',
|
'--dpi-desync-fooling=badseq',
|
||||||
'--dpi-desync-fake-tls=0x00000000',
|
'--dpi-desync-fake-tls=0x00000000',
|
||||||
'--dpi-desync-fake-tls=^!',
|
'--dpi-desync-fake-tls=!',
|
||||||
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
||||||
'--new',
|
'--new',
|
||||||
'--filter-tcp=80,443',
|
'--filter-tcp=80,443',
|
||||||
@@ -1604,7 +1604,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [
|
|||||||
'--dpi-desync-repeats=11',
|
'--dpi-desync-repeats=11',
|
||||||
'--dpi-desync-fooling=badseq',
|
'--dpi-desync-fooling=badseq',
|
||||||
'--dpi-desync-fake-tls=0x00000000',
|
'--dpi-desync-fake-tls=0x00000000',
|
||||||
'--dpi-desync-fake-tls=^!',
|
'--dpi-desync-fake-tls=!',
|
||||||
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
||||||
'--dpi-desync-fake-http={FAKES}/tls_clienthello_max_ru.bin',
|
'--dpi-desync-fake-http={FAKES}/tls_clienthello_max_ru.bin',
|
||||||
'--new',
|
'--new',
|
||||||
@@ -1625,7 +1625,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [
|
|||||||
'--dpi-desync-repeats=11',
|
'--dpi-desync-repeats=11',
|
||||||
'--dpi-desync-fooling=badseq',
|
'--dpi-desync-fooling=badseq',
|
||||||
'--dpi-desync-fake-tls=0x00000000',
|
'--dpi-desync-fake-tls=0x00000000',
|
||||||
'--dpi-desync-fake-tls=^!',
|
'--dpi-desync-fake-tls=!',
|
||||||
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
||||||
'--dpi-desync-fake-http={FAKES}/tls_clienthello_max_ru.bin',
|
'--dpi-desync-fake-http={FAKES}/tls_clienthello_max_ru.bin',
|
||||||
'--new',
|
'--new',
|
||||||
@@ -1639,7 +1639,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [
|
|||||||
'--dpi-desync-repeats=11',
|
'--dpi-desync-repeats=11',
|
||||||
'--dpi-desync-fooling=badseq',
|
'--dpi-desync-fooling=badseq',
|
||||||
'--dpi-desync-fake-tls=0x00000000',
|
'--dpi-desync-fake-tls=0x00000000',
|
||||||
'--dpi-desync-fake-tls=^!',
|
'--dpi-desync-fake-tls=!',
|
||||||
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
||||||
'--dpi-desync-fake-http={FAKES}/tls_clienthello_max_ru.bin',
|
'--dpi-desync-fake-http={FAKES}/tls_clienthello_max_ru.bin',
|
||||||
'--dpi-desync-fake-unknown=0x00000000',
|
'--dpi-desync-fake-unknown=0x00000000',
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ const api = {
|
|||||||
* службы только на Windows (см. SettingsView.tsx). */
|
* службы только на Windows (см. SettingsView.tsx). */
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
|
|
||||||
|
/** Версия запущенной сборки — из package.json через `app.getVersion()` в main. */
|
||||||
|
getAppVersion: (): Promise<string> => invoke('app:version'),
|
||||||
|
|
||||||
getTheme: (): Promise<ThemeState> => invoke('theme:get'),
|
getTheme: (): Promise<ThemeState> => invoke('theme:get'),
|
||||||
onThemeChanged: (listener: (state: ThemeState) => void): (() => void) =>
|
onThemeChanged: (listener: (state: ThemeState) => void): (() => void) =>
|
||||||
subscribe('theme:changed', listener),
|
subscribe('theme:changed', listener),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ZapretListId, ZapretStatus } from '@shared/ipc-contract'
|
import type { ZapretListId, ZapretStatus } from '@shared/ipc-contract'
|
||||||
import { GITHUB_URL } from '@shared/links'
|
import { GITHUB_URL } from '@shared/links'
|
||||||
|
import { useAppVersion } from '../hooks/useAppVersion'
|
||||||
import { useZapretLists } from '../hooks/useZapretLists'
|
import { useZapretLists } from '../hooks/useZapretLists'
|
||||||
import { LIST_TITLES } from '../lib/zapretListMeta'
|
import { LIST_TITLES } from '../lib/zapretListMeta'
|
||||||
import ListEditor from './ListEditor'
|
import ListEditor from './ListEditor'
|
||||||
@@ -11,10 +12,8 @@ import ListEditor from './ListEditor'
|
|||||||
* включения, где ими и пользуются. Отдельный экран за шестерёнкой в шапке.
|
* включения, где ими и пользуются. Отдельный экран за шестерёнкой в шапке.
|
||||||
*/
|
*/
|
||||||
const isWindows = window.api.platform === 'win32'
|
const isWindows = window.api.platform === 'win32'
|
||||||
|
const isDarwin = window.api.platform === 'darwin'
|
||||||
const APP_CHANNEL = 'beta'
|
const APP_CHANNEL = 'beta'
|
||||||
// Держать в синхроне с "version" в package.json — оттуда её берёт electron-builder
|
|
||||||
// для имени установщика и для «Установленные приложения» в Windows.
|
|
||||||
const APP_VERSION = '0.4.0'
|
|
||||||
|
|
||||||
export default function SettingsView({
|
export default function SettingsView({
|
||||||
onBack,
|
onBack,
|
||||||
@@ -27,6 +26,7 @@ export default function SettingsView({
|
|||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { lists, autoHostlist, save, reset, clearAuto } = useZapretLists()
|
const { lists, autoHostlist, save, reset, clearAuto } = useZapretLists()
|
||||||
const [openListId, setOpenListId] = useState<ZapretListId | null>(null)
|
const [openListId, setOpenListId] = useState<ZapretListId | null>(null)
|
||||||
|
const appVersion = useAppVersion()
|
||||||
|
|
||||||
const openList = lists.find((list) => list.id === openListId)
|
const openList = lists.find((list) => list.id === openListId)
|
||||||
if (openList) {
|
if (openList) {
|
||||||
@@ -88,9 +88,9 @@ export default function SettingsView({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* На Windows ни одна из 22 стратегий Flowseal не использует --hostlist-auto — счётчик
|
{/* --hostlist-auto есть только в darwin-профилях (см. strategies.ts) — ни одна из 22
|
||||||
всегда пуст, показывать его смысла нет. */}
|
стратегий Flowseal (Windows и Linux) его не использует, счётчик там всегда пуст. */}
|
||||||
{!isWindows && (
|
{isDarwin && (
|
||||||
<div className="settings-row settings-row--static">
|
<div className="settings-row settings-row--static">
|
||||||
<span>Найдено автоматически</span>
|
<span>Найдено автоматически</span>
|
||||||
<span className="settings-row__count">
|
<span className="settings-row__count">
|
||||||
@@ -119,9 +119,13 @@ export default function SettingsView({
|
|||||||
<p className="settings__support-hint">Поддержите звёздочкой</p>
|
<p className="settings__support-hint">Поддержите звёздочкой</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p className="settings__version">
|
{/* Пока версия не пришла из main, строки нет вовсе: иначе на первом кадре
|
||||||
{APP_CHANNEL}-{APP_VERSION}-{window.api.platform}
|
мелькнуло бы «beta--win32» с пустотой на месте номера. */}
|
||||||
</p>
|
{appVersion && (
|
||||||
|
<p className="settings__version">
|
||||||
|
{APP_CHANNEL}-{appVersion}-{window.api.platform}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{isWindows && (
|
{isWindows && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -47,12 +47,14 @@ export default function ZapretCard(): React.JSX.Element {
|
|||||||
{toggleLabel}
|
{toggleLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{status?.state === 'error' && status.error && (
|
{/* Не только при state === 'error': UnsupportedEngine (платформа без сборки движка)
|
||||||
<p className="list-editor__error">{status.error}</p>
|
пишет причину в error, оставляя state 'stopped' — иначе текст был бы недостижим,
|
||||||
)}
|
а пользователь на неподдерживаемой платформе видел бы пустой список стратегий
|
||||||
|
без единого объяснения. */}
|
||||||
|
{status?.error && <p className="list-editor__error">{status.error}</p>}
|
||||||
|
|
||||||
<div className="settings__field">
|
<div className="settings__field">
|
||||||
<span className="settings__label">Стратегия</span>
|
<span className="settings__label">Способ</span>
|
||||||
{/* На время переключения список заблокирован: смена стратегии на работающем
|
{/* На время переключения список заблокирован: смена стратегии на работающем
|
||||||
обходе останавливает и перенастраивает службу, и второй выбор посреди этого
|
обходе останавливает и перенастраивает службу, и второй выбор посреди этого
|
||||||
наложился бы на первый. */}
|
наложился бы на первый. */}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Версия запущенной сборки — та же, что в имени установщика и в «Установленных
|
||||||
|
* приложениях»: main отдаёт её через `app.getVersion()`, то есть прямо из package.json.
|
||||||
|
*
|
||||||
|
* Раньше renderer держал версию своей константой с пометкой «держать в синхроне с
|
||||||
|
* package.json». Синхронизировать вручную забыли: константа осталась на 0.4.0, и релизы
|
||||||
|
* 0.5.0 и 0.5.1 показывали пользователю чужую версию. Спрашивать у main дороже на один
|
||||||
|
* IPC-вызов, зато разъехаться уже нечему.
|
||||||
|
*
|
||||||
|
* `null` — версия ещё не пришла; показывать её в этот момент нечего.
|
||||||
|
*/
|
||||||
|
export function useAppVersion(): string | null {
|
||||||
|
const [version, setVersion] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Окно могло закрыться, пока шёл ответ — тогда setState уже некому применять.
|
||||||
|
let cancelled = false
|
||||||
|
void window.api.getAppVersion().then((value) => {
|
||||||
|
if (!cancelled) setVersion(value)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return version
|
||||||
|
}
|
||||||
@@ -24,11 +24,13 @@ export type ZapretState = 'stopped' | 'starting' | 'running' | 'stopping' | 'err
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Чем именно обходим на этой платформе:
|
* Чем именно обходим на этой платформе:
|
||||||
* • `tpws-socks` — macOS: локальный socks5-прокси, права не нужны;
|
* • `tpws-socks` — macOS: локальный socks5-прокси, права не нужны;
|
||||||
* • `winws-service` — Windows: служба с драйвером WinDivert;
|
* • `winws-service` — Windows: служба с драйвером WinDivert;
|
||||||
* • `unsupported` — платформа без поддержки (Linux у нас не собран).
|
* • `nfqws-nftables` — Linux: демон nfqws + правила nftables (NFQUEUE), root через pkexec
|
||||||
|
* на каждое включение — ни службы, ни автозапуска здесь нет;
|
||||||
|
* • `unsupported` — платформа без поддержки (не macOS/Windows/Linux).
|
||||||
*/
|
*/
|
||||||
export type ZapretBackend = 'tpws-socks' | 'winws-service' | 'unsupported'
|
export type ZapretBackend = 'tpws-socks' | 'winws-service' | 'nfqws-nftables' | 'unsupported'
|
||||||
|
|
||||||
export interface ZapretStatus {
|
export interface ZapretStatus {
|
||||||
state: ZapretState
|
state: ZapretState
|
||||||
@@ -39,13 +41,13 @@ export interface ZapretStatus {
|
|||||||
startedAt: string | null
|
startedAt: string | null
|
||||||
/** Текст последней ошибки — показываем пользователю как есть. */
|
/** Текст последней ошибки — показываем пользователю как есть. */
|
||||||
error: string | null
|
error: string | null
|
||||||
/** macOS: адрес socks5-прокси, пока обход работает. */
|
/** macOS: адрес socks5-прокси, пока обход работает. На Linux/Windows всегда null. */
|
||||||
socksAddress: string | null
|
socksAddress: string | null
|
||||||
/** macOS: прописан ли прокси в системных настройках сети. */
|
/** macOS: прописан ли прокси в системных настройках сети. На Linux/Windows не используется. */
|
||||||
systemProxyApplied: boolean
|
systemProxyApplied: boolean
|
||||||
/** Windows: установлена ли служба (её ставит и настраивает само приложение при первом включении). */
|
/** Windows: установлена ли служба (её ставит и настраивает само приложение при первом включении). */
|
||||||
serviceInstalled: boolean
|
serviceInstalled: boolean
|
||||||
/** Windows: стоит ли служба на автозапуск с системой (`sc config start=`). */
|
/** Windows: стоит ли служба на автозапуск с системой (`sc config start=`). На Linux автозапуска нет. */
|
||||||
autoStart: boolean
|
autoStart: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +103,14 @@ export interface ZapretDiagnosticResult extends ZapretDiagnosticTarget {
|
|||||||
export interface IpcHandlers {
|
export interface IpcHandlers {
|
||||||
'theme:get': () => ThemeState
|
'theme:get': () => ThemeState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Версия приложения — `app.getVersion()`, то есть `"version"` из package.json той сборки,
|
||||||
|
* которая сейчас запущена. Раньше renderer держал её отдельной константой «в синхроне
|
||||||
|
* с package.json» вручную: константа осталась на 0.4.0 и релизы 0.5.0 и 0.5.1 показали
|
||||||
|
* пользователю чужую версию. Спрашивать у main — единственный способ не разъехаться.
|
||||||
|
*/
|
||||||
|
'app:version': () => string
|
||||||
|
|
||||||
/** Windows: подтверждает диалогом ОС, запускает деинсталлятор и закрывает приложение. */
|
/** Windows: подтверждает диалогом ОС, запускает деинсталлятор и закрывает приложение. */
|
||||||
'app:uninstall': () => void
|
'app:uninstall': () => void
|
||||||
|
|
||||||
@@ -112,7 +122,8 @@ export interface IpcHandlers {
|
|||||||
'zapret:set-system-proxy': (enabled: boolean) => ZapretStatus
|
'zapret:set-system-proxy': (enabled: boolean) => ZapretStatus
|
||||||
/**
|
/**
|
||||||
* Выбор пресета. Если обход выключен — просто запоминается. Если включён — на macOS
|
* Выбор пресета. Если обход выключен — просто запоминается. Если включён — на macOS
|
||||||
* перезапускает tpws, на Windows перенастраивает службу (потребуется UAC).
|
* перезапускает tpws, на Windows перенастраивает службу (потребуется UAC), на Linux
|
||||||
|
* перезапускает nfqws и правила nftables (потребуется пароль через pkexec).
|
||||||
*/
|
*/
|
||||||
'zapret:set-strategy': (strategyId: string) => ZapretStatus
|
'zapret:set-strategy': (strategyId: string) => ZapretStatus
|
||||||
/** Windows: автозапуск службы с системой (`sc config start=`); требует UAC. */
|
/** Windows: автозапуск службы с системой (`sc config start=`); требует UAC. */
|
||||||
|
|||||||
Reference in New Issue
Block a user