diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73ce0d5..313445f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,17 +49,19 @@ jobs: # electron-builder.yml просто копирует resources/zapret, а он у чистого чекаута пуст. - run: node scripts/fetch-zapret.mjs - # electron-builder заливает артефакты несколькими параллельными publisher'ами: оба - # проверяют, есть ли релиз с этим тегом, оба видят «нет» и оба лезут его создавать — - # второй падает с 422 already_exists и уносит с собой свой файл (на v0.5.0 так потерялся - # сам .exe). Создаём релиз заранее, тогда обоим остаётся только залить в готовый. - # Описание пустое — его заполняет шаг «Описание релиза» после сборки. + # electron-builder заливает артефакты несколькими параллельными publisher'ами внутри + # этой же джобы, а с приходом Linux-сборки (отдельная джоба ниже) к этому добавилась + # ещё и гонка МЕЖДУ джобами: обе проверяют, есть ли релиз с этим тегом, обе видят «нет» + # и обе лезут его создавать — вторая падает с 422 already_exists (на v0.5.0 так + # потерялся сам .exe, когда конкурентами были два publisher'а внутри одной джобы). + # `|| true` — проигравшая гонку джоба не должна из-за этого падать целиком: релиз + # к моменту upload всё равно уже существует, просто создала его не она. - 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 '' + gh release create "$GITHUB_REF_NAME" --title "${GITHUB_REF_NAME#v}" --notes '' || true env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -70,7 +72,7 @@ jobs: # заготовленный шагом выше, используя repository из package.json. # На push в main/PR публикации нет — только упаковка и загрузка в Artifacts ниже. - name: Build - run: npx --no-install electron-vite build && npx --no-install electron-builder --publish ${{ startsWith(github.ref, 'refs/tags/v') && 'always' || 'never' }} + 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 }} @@ -89,7 +91,8 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Релиз создан с пустым описанием, поэтому список изменений дописываем сами: - # коммиты между предыдущим тегом vX.Y.Z и текущим. + # коммиты между предыдущим тегом vX.Y.Z и текущим. Только в этой джобе — если то же + # самое сделает ещё и Linux-сборка, они просто перезапишут результат друг друга. - name: Описание релиза if: startsWith(github.ref, 'refs/tags/v') shell: bash @@ -124,3 +127,71 @@ jobs: path: release/*.exe if-no-files-found: error 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 diff --git a/README.md b/README.md index cde73f4..d6e73b7 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,27 @@ npm run typecheck # tsc для node- и web-частей отдельно npm run build:unpack # быстрая проверка: приложение без установщика (release/*-unpacked) npm run build:mac # .dmg + .zip (x64 + arm64) 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, спасибо за столь хороший инструмент - **[Flowseal/zapret-discord-youtube](https://github.com/Flowseal/zapret-discord-youtube)** — списки сайтов (`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. diff --git a/electron-builder.yml b/electron-builder.yml index 9e91562..b1d8448 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -24,16 +24,30 @@ files: - '!{.env,.env.*,.npmrc,pnpm-lock.yaml,yarn.lock}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' - '!.github/*' - # zapret идёт отдельно через extraResources ниже — не дублировать его в app.asar. + # zapret и linux-helper идут отдельно через extraResources ниже — не дублировать их в app.asar. - '!resources/zapret/**' + - '!resources/linux-helper/**' asarUnpack: - 'resources/**' # Бинарники zapret (npm run zapret:fetch) кладём рядом с приложением, а не в asar — # это исполняемые файлы, которые 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: + - from: resources/zapret/lists + to: zapret/lists + - from: resources/zapret/fakes + to: zapret/fakes - from: resources/zapret to: zapret + filter: + - 'LICENSE.zapret.txt' + - 'LICENSE.flowseal.txt' win: executableName: RKNboost @@ -50,6 +64,13 @@ win: # в поставке zapret) — arm64-инсталлятор был бы заведомо нерабочим. - target: nsis arch: [x64] + extraResources: + - from: resources/zapret/win32 + to: zapret/win32 + - from: resources/zapret + to: zapret + filter: + - 'LICENSE.WinDivert.txt' nsis: # Быстрая установка per-user, без UAC и без мастера: ставится в %LocalAppData%, куда # обычный пользователь и так пишет без повышения прав. Раньше здесь стоял perMachine — @@ -91,17 +112,52 @@ mac: # Подпись и нотаризация выключены: без Apple Developer ID сборка иначе падает. # Как включить — см. раздел «Подпись» в README.md. notarize: false + extraResources: + - from: resources/zapret/darwin + to: zapret/darwin dmg: artifactName: ${name}-${version}-${arch}.${ext} linux: + executableName: rknboost + # desktopName в package.json + syncDesktopName — иначе Electron не свяжет открытые окна + # с .desktop-записью (app_id/WM_CLASS), и часть окружений не покажет иконку в панели задач. + syncDesktopName: true target: - - AppImage - - deb + - target: AppImage + arch: [x64] + - target: deb + arch: [x64] maintainer: RKNboost - category: Utility - synopsis: Кроссплатформенное приложение на Electron + category: Network + 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: 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 diff --git a/package-lock.json b/package-lock.json index 18fb987..f68a62e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rknboost", - "version": "0.5.2", + "version": "0.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rknboost", - "version": "0.5.2", + "version": "0.5.3", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 32645ef..84ef5b8 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "name": "rknboost", - "version": "0.5.2", + "version": "0.5.3", "description": "Кроссплатформенное десктопное приложение на Electron + React + TypeScript", "main": "./out/main/index.js", + "desktopName": "rknboost.desktop", "author": "RKNboost", "license": "MIT", "repository": { diff --git a/resources/linux-helper/rknboost-helper.sh b/resources/linux-helper/rknboost-helper.sh new file mode 100755 index 0000000..712b427 --- /dev/null +++ b/resources/linux-helper/rknboost-helper.sh @@ -0,0 +1,174 @@ +#!/bin/sh +# Привилегированный помощник обхода блокировок на Linux — единственное, что RKNboost +# запускает через `pkexec`. Не редактировать под bash-специфику: /bin/sh на Debian/Ubuntu — +# это dash, а не bash. +# +# Зовётся из src/main/zapret/nfqws.linux.ts ровно двумя способами: +# pkexec <этот файл> start +# 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 +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//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::, что и на 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 аргумента: " + exit 6 + fi + cmd_start "$1" "$2" "$3" "$4" + ;; + stop) + cmd_stop "" + ;; + *) + log "неизвестная команда: $action (ожидается start|stop)" + exit 6 + ;; +esac diff --git a/scripts/fetch-zapret.mjs b/scripts/fetch-zapret.mjs index b2e92e5..66cb5fb 100644 --- a/scripts/fetch-zapret.mjs +++ b/scripts/fetch-zapret.mjs @@ -41,6 +41,7 @@ const PREFIX = `zapret-${ZAPRET_VERSION}` /** Что забираем из архива bol-van/zapret: путь внутри архива → путь внутри resources/zapret. */ const FILES = { '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/cygwin1.dll': 'win32/x64/cygwin1.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`, и @@ -206,6 +207,7 @@ async function main() { if ( process.argv.includes('--force') || !existsSync(join(DEST, 'darwin', 'tpws')) || + !existsSync(join(DEST, 'linux', 'x64', 'nfqws')) || !existsSync(join(DEST, 'win32', 'x64', 'winws.exe')) || !existsSync(join(DEST, 'lists', 'general.txt')) ) { diff --git a/scripts/import-flowseal-strategies.mjs b/scripts/import-flowseal-strategies.mjs index 65c252e..4e7ff14 100644 --- a/scripts/import-flowseal-strategies.mjs +++ b/scripts/import-flowseal-strategies.mjs @@ -97,6 +97,11 @@ function parseBatFile(filename, content) { // Мы передаём argv напрямую в spawn (без шелла) — кавычки внутри значения // остались бы буквальными символами в имени файла, поэтому убираем везде. .map((t) => t.replaceAll('"', '')) + // `^!` — экранирование `!` в cmd.exe (в .bat включён enabledelayedexpansion, где `!` + // иначе разворачивался бы как переменная). cmd.exe снимает его сам перед запуском + // winws.exe; здесь командную строку разбираем руками, и `^` иначе остался бы буквально + // в значении. Реальное значение zapret ожидает — `!` («'!' = standard fake» в readme). + .map((t) => t.replaceAll('=^!', '=!')) .map(resolvePlaceholders) if (tokens.length < 5) diff --git a/src/main/zapret/deps.linux.ts b/src/main/zapret/deps.linux.ts new file mode 100644 index 0000000..d30f493 --- /dev/null +++ b/src/main/zapret/deps.linux.ts @@ -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 { + 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 { + 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»).' + ) + } +} diff --git a/src/main/zapret/engine.linux.ts b/src/main/zapret/engine.linux.ts new file mode 100644 index 0000000..21c5b40 --- /dev/null +++ b/src/main/zapret/engine.linux.ts @@ -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 | null = null + + constructor() { + super('nfqws-nftables', defaultStrategyId('linux')) + } + + /** + * Вызывается из `recoverZapret()` до открытия окна — как и `Win32Engine.sync()`, подхватывает + * состояние, оставшееся от предыдущего запуска приложения (демон root-процессом переживает + * закрытие приложения, автозапуска с системой при этом нет — состояние может быть только + * «работает с прошлой сессии» или «выключен»). + */ + async sync(): Promise { + 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 { + 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 { + 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 { + // Демон и правила nftables живут своей жизнью в системе — выход приложения их не + // останавливает (см. комментарий класса). Опрос /proc всё равно больше не нужен. + this.stopWatch() + } + + /** + * Демон нам не дочерний процесс (владеет им root, а не Electron) — событие завершения + * поймать некому, поэтому опрашиваем `/proc/` по таймеру. `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 + } +} diff --git a/src/main/zapret/engine.ts b/src/main/zapret/engine.ts index a963771..b81681e 100644 --- a/src/main/zapret/engine.ts +++ b/src/main/zapret/engine.ts @@ -116,15 +116,15 @@ export abstract class ZapretEngine { } } -/** Заглушка для платформ, под которые мы бинарники не собираем (Linux). */ +/** Заглушка для платформ, под которые мы бинарники не собираем (FreeBSD и прочее — не macOS/Windows/Linux). */ export class UnsupportedEngine extends ZapretEngine { constructor() { super('unsupported', '') - this.status.error = 'Обход блокировок доступен только на macOS и Windows.' + this.status.error = 'Обход блокировок доступен только на macOS, Windows и Linux.' } async start(): Promise { - throw new Error('Обход блокировок доступен только на macOS и Windows.') + throw new Error('Обход блокировок доступен только на macOS, Windows и Linux.') } async stop(): Promise { diff --git a/src/main/zapret/engine.win32.ts b/src/main/zapret/engine.win32.ts index 5bc06cb..8e92af6 100644 --- a/src/main/zapret/engine.win32.ts +++ b/src/main/zapret/engine.win32.ts @@ -21,7 +21,12 @@ import { stopService, type ServiceInfo } from './service.win32' -import { defaultStrategyId, winwsConfigFile, type WinwsConfig } from './strategies' +import { + defaultStrategyId, + type EngineConfig, + parseStrategyMarker, + winwsConfigFile +} from './strategies' const run = promisify(execFile) @@ -72,7 +77,7 @@ async function binariesNeedCopy(): Promise { * winws.exe отстала от оригинала в папке приложения, или если аргументы стратегии * изменились (маркер в `strategy.cfg` не совпадает с тем, что сгенерировали сейчас). */ -async function needsReconfigure(config: WinwsConfig, info: ServiceInfo | null): Promise { +async function needsReconfigure(config: EngineConfig, info: ServiceInfo | null): Promise { if (!info) return true if (await binariesNeedCopy()) return true return (await readInstalledMarkerLine()) !== config.marker @@ -107,8 +112,7 @@ export class Win32Engine extends ZapretEngine { } const installedLine = await readInstalledMarkerLine() - const match = installedLine ? /^--comment=rknboost:([^:]+):/.exec(installedLine) : null - const strategyId = match?.[1] ?? this.status.strategyId + const strategyId = parseStrategyMarker(installedLine) ?? this.status.strategyId return this.patch({ serviceInstalled: true, @@ -125,7 +129,7 @@ export class Win32Engine extends ZapretEngine { async start(strategyId: string): Promise { this.patch({ state: 'starting', strategyId, error: null }) - let config: WinwsConfig + let config: EngineConfig try { config = winwsConfigFile(strategyId) } catch (error) { diff --git a/src/main/zapret/index.ts b/src/main/zapret/index.ts index f8e265d..e91e7c6 100644 --- a/src/main/zapret/index.ts +++ b/src/main/zapret/index.ts @@ -12,6 +12,7 @@ import { loadSettings } from '../settings' import { DIAGNOSTIC_TARGETS, runDiagnostics } from './diagnostics' import { DarwinEngine } from './engine.darwin' import { ZapretEngine, UnsupportedEngine } from './engine' +import { LinuxEngine } from './engine.linux' import { Win32Engine } from './engine.win32' import { readAllLists, resetList, seedLists, writeList } from './lists' import { autoHostlistPath } from './paths' @@ -23,6 +24,8 @@ function createEngine(): ZapretEngine { return new DarwinEngine() case 'win32': return new Win32Engine() + case 'linux': + return new LinuxEngine() default: return new UnsupportedEngine() } @@ -33,8 +36,9 @@ const engine = createEngine() /** * Готовим списки сайтов, возвращаем выбор пользователя из прошлого запуска и синхронизируем * состояние с системой — на macOS чиним прокси от прошлого аварийного завершения, на Windows - * подхватываем службу, которая могла работать ещё до открытия окна (автозапуск с системой). - * Все шаги нужно сделать до открытия окна. + * подхватываем службу, которая могла работать ещё до открытия окна (автозапуск с системой), + * на Linux — демон nfqws, переживший закрытие приложения (автозапуска у него нет, но сам + * процесс и правила nftables продолжают работать). Все шаги нужно сделать до открытия окна. */ export async function recoverZapret(): Promise { await seedLists() @@ -52,6 +56,7 @@ export async function recoverZapret(): Promise { if (engine instanceof DarwinEngine) await engine.recoverFromCrash() if (engine instanceof Win32Engine) await engine.sync() + if (engine instanceof LinuxEngine) await engine.sync() } function knownStrategyId(strategyId: string | null): string | null { diff --git a/src/main/zapret/nfqws.linux.ts b/src/main/zapret/nfqws.linux.ts new file mode 100644 index 0000000..c6990db --- /dev/null +++ b/src/main/zapret/nfqws.linux.ts @@ -0,0 +1,214 @@ +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 { + return createHash('sha256') + .update(await readFile(path)) + .digest('hex') +} + +async function filesIdentical(a: string, b: string): Promise { + 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 { + 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) + } + } +} + +/** + * `nfqws --dry-run @<файл>` — проверяет только аргументы фильтра, root не нужен (в отличие + * от рабочего запуска демона). Запускаем бинарник из сборки напрямую: dry-run идёт от uid + * самого приложения, а не root, так что FUSE-примонтированный AppImage тут не помеха — + * ограничение касается только `pkexec` (см. linuxStagingDir()). Дословный аналог + * `Win32Engine['dryRun']`. + */ +export async function dryRun(configBody: string): Promise { + 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', `@${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 { + 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 { + 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 { + // Помощник может понадобиться, даже если 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/` — существование каталога о процессе видно без прав, сигнал слать не нужно. */ +export async function isProcessAlive(pid: number): Promise { + 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 { + 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 } +} diff --git a/src/main/zapret/paths.ts b/src/main/zapret/paths.ts index 2462b2b..80cc514 100644 --- a/src/main/zapret/paths.ts +++ b/src/main/zapret/paths.ts @@ -23,6 +23,71 @@ export function winwsPath(): string { 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) — только для чтения. */ export function bundledListsDir(): string { return join(zapretRoot(), 'lists') @@ -37,9 +102,14 @@ export function userListsDir(): string { 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 { - return join(zapretRoot(), 'fakes') + return process.platform === 'linux' ? stagedFakesDir() : join(zapretRoot(), 'fakes') } /** diff --git a/src/main/zapret/strategies.ts b/src/main/zapret/strategies.ts index b7b7f5e..555bdb2 100644 --- a/src/main/zapret/strategies.ts +++ b/src/main/zapret/strategies.ts @@ -13,11 +13,14 @@ import { WIN32_STRATEGIES } from './strategies.win32.generated' * переупорядочивать сегменты (`--split-pos`, `--disorder`, `--oob`, `--tlsrec`), но не * умеет ни fake-пакетов, ни UDP/QUIC вообще. Профили ниже — свои, под это ограничение. * - * `winws` (Windows) работает на уровне пакетов через WinDivert. Его пресеты — 22 варианта - * из Flowseal/zapret-discord-youtube, перенесённые в `strategies.win32.generated.ts` - * генератором `scripts/import-flowseal-strategies.mjs` (не батниками — кодом). + * `winws` (Windows) и `nfqws` (Linux) — один и тот же движок zapret на уровне пакетов + * (winws — сборка nfq/nfqws.c под WinDivert вместо netfilter), поэтому делят один набор + * пресетов — 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}/.txt` — путь резолвится в `resolvePlaceholders()` ниже. */ 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 { - return platform === 'win32' ? 'general' : 'split' + switch (platform) { + case 'win32': + case 'linux': + return 'general' + default: + return 'split' + } } function resolvePlaceholders(arg: string): string { @@ -155,6 +167,17 @@ function resolvePlaceholders(arg: string): string { .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[] { if (platform === 'darwin') { @@ -165,15 +188,8 @@ export function listStrategies(platform: NodeJS.Platform): ZapretStrategy[] { platforms })) } - if (platform === 'win32') { - return WIN32_STRATEGIES.map(({ id, name }) => ({ - id, - name, - // У Flowseal нет описаний стратегий — только имена вариантов, подобранных под провайдера. - description: 'Стратегия из подборки Flowseal/zapret-discord-youtube.', - platforms: ['win32'] - })) - } + if (platform === 'win32') return flowsealStrategies('win32') + if (platform === 'linux') return flowsealStrategies('linux') return [] } @@ -201,12 +217,13 @@ export function winwsArgs(strategyId: string): string[] { return strategy.args.map(resolvePlaceholders) } -/** Читает служба: `winws.exe @strategy.cfg`, ограничение размера у самого winws — 16 КБ. */ +/** Читают winws и nfqws через `@strategy.cfg` — ограничение размера общее у обоих, 16 КБ. */ const MAX_CONFIG_FILE_BYTES = 16000 /** - * winws разбирает файл конфигурации через POSIX `wordexp()` (см. `nfq/nfqws.c` в поставке - * zapret) — без кавычек он режет аргумент по пробелам и съедает обратные слэши в путях вида + * winws и nfqws — один и тот же парсер аргументов (`nfq/nfqws.c` в поставке zapret, + * winws — его сборка под WinDivert), файл конфигурации разбирают через POSIX `wordexp()` — + * без кавычек он режет аргумент по пробелам и съедает обратные слэши в путях вида * `C:\Users\...`. Каждый аргумент поэтому кладём в одинарные кавычки, а `'` внутри значения * экранируем как `'\''` — тем же приёмом, что и `networksetup()` в `proxy.darwin.ts` для shell. */ @@ -215,33 +232,104 @@ function quoteConfigArg(arg: string): string { } /** - * Первая строка — маркер: по нему `Win32Engine.sync()` узнаёт, какая стратегия уже установлена - * в службу, не перечитывая все аргументы. Сам маркер — штатная опция `--comment` winws, - * в работу процесса не вмешивается. + * Первая строка — маркер: по нему `Win32Engine.sync()`/`LinuxEngine.sync()` узнают, какая + * стратегия уже установлена, не перечитывая все аргументы. Сам маркер — штатная опция + * `--comment` (есть и у winws, и у nfqws), в работу процесса не вмешивается. */ function configMarker(strategyId: string, body: string): string { const hash = createHash('sha256').update(body).digest('hex').slice(0, 12) return `--comment=rknboost:${strategyId}:${hash}` } -export interface WinwsConfig { - /** Содержимое `strategy.cfg` — UTF-8 без BOM, как требует winws (docs/windows.md zapret). */ +const MARKER_PATTERN = /^--comment=rknboost:([^:]+):/ + +/** + * Обратная операция к `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 /** Маркер первой строки — сравнить с уже установленным, чтобы понять, нужна ли перенастройка. */ marker: string } -/** Рендерит стратегию в файл аргументов для `winws.exe @` — см. `WinwsConfig`. */ -export function winwsConfigFile(strategyId: string): WinwsConfig { - const args = winwsArgs(strategyId).map(quoteConfigArg).join('\n') - const marker = configMarker(strategyId, args) - const body = `${marker}\n${args}\n` +/** Общий рендер: кавычки, маркер первой строкой, проверка лимита в 16 КБ. */ +function renderConfigFile(strategyId: string, args: string[]): EngineConfig { + const quoted = args.map(quoteConfigArg).join('\n') + const marker = configMarker(strategyId, quoted) + const body = `${marker}\n${quoted}\n` if (Buffer.byteLength(body, 'utf8') > MAX_CONFIG_FILE_BYTES) { throw new Error( - `Стратегия «${strategyId}» не помещается в лимит winws на файл конфигурации (16 КБ).` + `Стратегия «${strategyId}» не помещается в лимит winws/nfqws на файл конфигурации (16 КБ).` ) } return { body, marker } } + +/** Рендерит стратегию в файл аргументов для `winws.exe @` — см. `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 } +} diff --git a/src/main/zapret/strategies.win32.generated.ts b/src/main/zapret/strategies.win32.generated.ts index 64170b4..cac27f3 100644 --- a/src/main/zapret/strategies.win32.generated.ts +++ b/src/main/zapret/strategies.win32.generated.ts @@ -1581,7 +1581,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [ '--dpi-desync-repeats=11', '--dpi-desync-fooling=badseq', '--dpi-desync-fake-tls=0x00000000', - '--dpi-desync-fake-tls=^!', + '--dpi-desync-fake-tls=!', '--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com', '--new', '--filter-tcp=443', @@ -1592,7 +1592,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [ '--dpi-desync-repeats=11', '--dpi-desync-fooling=badseq', '--dpi-desync-fake-tls=0x00000000', - '--dpi-desync-fake-tls=^!', + '--dpi-desync-fake-tls=!', '--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com', '--new', '--filter-tcp=80,443', @@ -1604,7 +1604,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [ '--dpi-desync-repeats=11', '--dpi-desync-fooling=badseq', '--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-http={FAKES}/tls_clienthello_max_ru.bin', '--new', @@ -1625,7 +1625,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [ '--dpi-desync-repeats=11', '--dpi-desync-fooling=badseq', '--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-http={FAKES}/tls_clienthello_max_ru.bin', '--new', @@ -1639,7 +1639,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [ '--dpi-desync-repeats=11', '--dpi-desync-fooling=badseq', '--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-http={FAKES}/tls_clienthello_max_ru.bin', '--dpi-desync-fake-unknown=0x00000000', diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index dad4e63..7988995 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -12,6 +12,7 @@ import ListEditor from './ListEditor' * включения, где ими и пользуются. Отдельный экран за шестерёнкой в шапке. */ const isWindows = window.api.platform === 'win32' +const isDarwin = window.api.platform === 'darwin' const APP_CHANNEL = 'beta' export default function SettingsView({ @@ -87,9 +88,9 @@ export default function SettingsView({ ))} - {/* На Windows ни одна из 22 стратегий Flowseal не использует --hostlist-auto — счётчик - всегда пуст, показывать его смысла нет. */} - {!isWindows && ( + {/* --hostlist-auto есть только в darwin-профилях (см. strategies.ts) — ни одна из 22 + стратегий Flowseal (Windows и Linux) его не использует, счётчик там всегда пуст. */} + {isDarwin && (
Найдено автоматически diff --git a/src/renderer/src/components/ZapretCard.tsx b/src/renderer/src/components/ZapretCard.tsx index 2f25ed8..a72ebff 100644 --- a/src/renderer/src/components/ZapretCard.tsx +++ b/src/renderer/src/components/ZapretCard.tsx @@ -47,9 +47,11 @@ export default function ZapretCard(): React.JSX.Element { {toggleLabel} - {status?.state === 'error' && status.error && ( -

{status.error}

- )} + {/* Не только при state === 'error': UnsupportedEngine (платформа без сборки движка) + пишет причину в error, оставляя state 'stopped' — иначе текст был бы недостижим, + а пользователь на неподдерживаемой платформе видел бы пустой список стратегий + без единого объяснения. */} + {status?.error &&

{status.error}

}
Способ diff --git a/src/shared/ipc-contract.ts b/src/shared/ipc-contract.ts index 8d484af..a986e1f 100644 --- a/src/shared/ipc-contract.ts +++ b/src/shared/ipc-contract.ts @@ -24,11 +24,13 @@ export type ZapretState = 'stopped' | 'starting' | 'running' | 'stopping' | 'err /** * Чем именно обходим на этой платформе: - * • `tpws-socks` — macOS: локальный socks5-прокси, права не нужны; - * • `winws-service` — Windows: служба с драйвером WinDivert; - * • `unsupported` — платформа без поддержки (Linux у нас не собран). + * • `tpws-socks` — macOS: локальный socks5-прокси, права не нужны; + * • `winws-service` — Windows: служба с драйвером WinDivert; + * • `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 { state: ZapretState @@ -39,13 +41,13 @@ export interface ZapretStatus { startedAt: string | null /** Текст последней ошибки — показываем пользователю как есть. */ error: string | null - /** macOS: адрес socks5-прокси, пока обход работает. */ + /** macOS: адрес socks5-прокси, пока обход работает. На Linux/Windows всегда null. */ socksAddress: string | null - /** macOS: прописан ли прокси в системных настройках сети. */ + /** macOS: прописан ли прокси в системных настройках сети. На Linux/Windows не используется. */ systemProxyApplied: boolean /** Windows: установлена ли служба (её ставит и настраивает само приложение при первом включении). */ serviceInstalled: boolean - /** Windows: стоит ли служба на автозапуск с системой (`sc config start=`). */ + /** Windows: стоит ли служба на автозапуск с системой (`sc config start=`). На Linux автозапуска нет. */ autoStart: boolean } @@ -120,7 +122,8 @@ export interface IpcHandlers { 'zapret:set-system-proxy': (enabled: boolean) => ZapretStatus /** * Выбор пресета. Если обход выключен — просто запоминается. Если включён — на macOS - * перезапускает tpws, на Windows перенастраивает службу (потребуется UAC). + * перезапускает tpws, на Windows перенастраивает службу (потребуется UAC), на Linux + * перезапускает nfqws и правила nftables (потребуется пароль через pkexec). */ 'zapret:set-strategy': (strategyId: string) => ZapretStatus /** Windows: автозапуск службы с системой (`sc config start=`); требует UAC. */