diff --git a/README.md b/README.md index d6e73b7..d821c90 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,26 @@ npm run build:linux # AppImage + .deb (x64) ## Обход блокировок по платформам -| Платформа | Движок | Права | -| --------- | ----------------------------------------------------- | ----------------------------------------------------- | -| macOS | `tpws` — локальный socks5-прокси | не нужны | -| Windows | `winws` + драйвер WinDivert, служба `rknboost-zapret` | UAC при первой настройке | -| Linux | `nfqws` + правила nftables (NFQUEUE) | пароль через `pkexec` при каждом включении/выключении | +| Платформа | Движок | Права | Статус | +| --------- | ------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------ | +| Windows | `winws` + драйвер WinDivert, служба `rknboost-zapret` | UAC при первой настройке | стабильно, гарантированно работает | +| macOS | `utunws` (форк nfqws под utun+BPF) + правила pf, LaunchDaemon | пароль администратора при каждом включении/выключении | частично — движок портирован недавно | +| Linux | `nfqws` + правила nftables (NFQUEUE) | пароль через `pkexec` при каждом включении/выключении | частично | + +На всех трёх платформах — один и тот же движок zapret уровня пакетов (winws/nfqws/utunws — +одна и та же сборка `nfq/nfqws.c` под разный бэкенд), поэтому используется один набор из +22 стратегий Flowseal/zapret-discord-youtube. + +На macOS для работы нужна версия 14 (Sonoma) или новее — движок собран под соответствующий +API ядра. Переотправка пакетов идёт через BPF на MAC-адрес шлюза, поэтому активный +VPN/туннель (маршрут по умолчанию уходит в свой utun) ломает обход — выключите другие VPN +перед включением. Как и на других платформах, полезно сменить DNS на сторонний +(1.1.1.1/8.8.8.8 или DNS-over-HTTPS) — блокировку по DNS сам zapret не лечит. +Принудительная остановка (если приложение недоступно): + +```bash +sudo "/Library/Application Support/RKNboost/zapret/stop.sh" +``` На Linux дополнительно нужны пакеты `nftables` и `polkit` (в .deb — заявлены как зависимости и ставятся сами; для AppImage — своими силами, `sudo apt install nftables policykit-1`). @@ -42,6 +57,10 @@ npm run build:linux # AppImage + .deb (x64) - **[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/`). + списки сайтов (`resources/zapret/lists/`), fake-пакеты (`resources/zapret/fakes/`) и все + 22 стратегии (`src/main/zapret/strategies.win32.generated.ts`). +- **[Flowseal/zapret-mac-discord-youtube](https://github.com/Flowseal/zapret-mac-discord-youtube)** — + macOS-порт nfqws (`utunws`, `resources/zapret/darwin/`) и референс для привилегированных + скриптов LaunchDaemon (`resources/darwin-helper/`). - **[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 b1d8448..0d12885 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -24,9 +24,11 @@ files: - '!{.env,.env.*,.npmrc,pnpm-lock.yaml,yarn.lock}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' - '!.github/*' - # zapret и linux-helper идут отдельно через extraResources ниже — не дублировать их в app.asar. + # zapret и linux-helper/darwin-helper идут отдельно через extraResources ниже — не + # дублировать их в app.asar. - '!resources/zapret/**' - '!resources/linux-helper/**' + - '!resources/darwin-helper/**' asarUnpack: - 'resources/**' @@ -97,6 +99,10 @@ nsis: mac: category: public.app-category.productivity + # utunws (форк nfqws под utun+BPF, см. src/main/zapret/engine.darwin.ts) собран под + # -target …-macos14.0 в самом релизе Flowseal/zapret-mac-discord-youtube — на более + # старой системе он не запустится, минимум объявляем и на уровне инсталлятора. + minimumSystemVersion: '14.0' target: - target: dmg arch: [x64, arm64] @@ -105,16 +111,22 @@ mac: entitlements: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.plist hardenedRuntime: true - # tpws — сторонний Mach-O внутри .app; hardened runtime требует, чтобы electron-builder + # utunws — сторонний Mach-O внутри .app; hardened runtime требует, чтобы electron-builder # подписал и его тем же Developer ID, иначе Gatekeeper убьёт процесс при первом spawn. binaries: - - Contents/Resources/zapret/darwin/tpws + - Contents/Resources/zapret/darwin/utunws # Подпись и нотаризация выключены: без Apple Developer ID сборка иначе падает. # Как включить — см. раздел «Подпись» в README.md. notarize: false extraResources: - from: resources/zapret/darwin to: zapret/darwin + # install.sh/stop.sh/daemon.sh/watchdog.sh/autostart.sh — привилегированные скрипты, + # запускаются через osascript "with administrator privileges" (elevate.darwin.ts). + # Не из resources/zapret: тот качает fetch-zapret.mjs, а этот каталог — свой, лежит + # в репозитории (см. src/main/zapret/paths.ts:bundledDarwinHelperDir()). + - from: resources/darwin-helper + to: darwin-helper dmg: artifactName: ${name}-${version}-${arch}.${ext} diff --git a/package-lock.json b/package-lock.json index cf8166c..d2c3a0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rknboost", - "version": "0.5.4", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rknboost", - "version": "0.5.4", + "version": "0.6.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 6bc9c8e..faafb65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rknboost", - "version": "0.5.4", + "version": "0.6.0", "description": "Кроссплатформенное десктопное приложение на Electron + React + TypeScript", "main": "./out/main/index.js", "desktopName": "rknboost.desktop", diff --git a/resources/darwin-helper/autostart.sh b/resources/darwin-helper/autostart.sh new file mode 100755 index 0000000..f35922f --- /dev/null +++ b/resources/darwin-helper/autostart.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Меняет RunAtLoad уже установленного LaunchDaemon без перезапуска демона и без разрыва +# pf-правил — root нужен только чтобы переписать защищённый plist в /Library/LaunchDaemons. +# Отдельный скрипт вместо повторного install.sh: переустановка перечитала бы и +# перекопировала utunws/fakes/strategy.cfg и пересобрала pf-правила заново — ради смены +# одного булева поля это лишний риск моргнуть сетью, которого нет у setServiceAutoStart() +# на Windows (`sc config start=`, тоже не трогает работающую службу). +set -eu + +PLIST=/Library/LaunchDaemons/com.rknboost.zapret.plist + +if [ "$#" -ne 1 ]; then + echo 'autostart.sh: ожидается 1 аргумент (0 или 1)' >&2 + exit 1 +fi +case "$1" in + 0) RUNATLOAD=false ;; + 1) RUNATLOAD=true ;; + *) echo 'autostart.sh: ожидается 0 или 1' >&2; exit 1 ;; +esac + +[ -f "$PLIST" ] || { echo 'autostart.sh: демон не установлен' >&2; exit 1; } +/usr/bin/plutil -replace RunAtLoad -bool "$RUNATLOAD" "$PLIST" diff --git a/resources/darwin-helper/com.rknboost.zapret.plist.in b/resources/darwin-helper/com.rknboost.zapret.plist.in new file mode 100644 index 0000000..8084891 --- /dev/null +++ b/resources/darwin-helper/com.rknboost.zapret.plist.in @@ -0,0 +1,25 @@ + + + + + Label + @LABEL@ + ProgramArguments + + @DEST@/daemon.sh + + RunAtLoad + <@RUNATLOAD@/> + KeepAlive + + SuccessfulExit + + + ThrottleInterval + 2 + StandardOutPath + @DEST@/zapret.log + StandardErrorPath + @DEST@/zapret.log + + diff --git a/resources/darwin-helper/daemon.sh b/resources/darwin-helper/daemon.sh new file mode 100755 index 0000000..b107185 --- /dev/null +++ b/resources/darwin-helper/daemon.sh @@ -0,0 +1,119 @@ +#!/bin/sh +# Супервизор обхода — то, что реально запускает launchd (com.rknboost.zapret.plist.in, +# ProgramArguments). Живёт под root, пока LaunchDaemon загружен; KeepAlive/SuccessfulExit +# в plist перезапускает этот скрипт заново при любом его завершении — поэтому определение +# интерфейса/MAC шлюза выполняется здесь при каждом старте (в том числе после смены сети), +# а не один раз в install.sh. Портировано из run.sh форка Flowseal/zapret-mac-discord-youtube +# (MIT) — та же логика с utunws через utun+BPF, свои идентификаторы (см. install.sh). +set -eu + +DEST='/Library/Application Support/RKNboost/zapret' +ANCHOR=com.apple/rknboost +UTUN_UNIT=61 +UTUN_IFACE=utun60 +UTUN_LOCAL=10.78.0.1 +UTUN_REMOTE=10.78.0.2 +PIDFILE=/var/run/rknboost-zapret.pid +TOKEN_FILE=/var/run/rknboost-zapret.pf-token +ROUTE6_FILE=/var/run/rknboost-zapret.route6 +ENGINE_PID= +WATCHDOG_PID= + +if [ -L "$DEST" ] || [ ! -f "$DEST/strategy.cfg" ] || [ ! -f "$DEST/ports.conf" ]; then + exit 1 +fi +TCP_PORTS=$(/usr/bin/sed -n '1p' "$DEST/ports.conf") +UDP_PORTS=$(/usr/bin/sed -n '2p' "$DEST/ports.conf") +# ':' — диапазоны в синтаксисе pf (19294:19344), не дефис — см. install.sh. +case ",$TCP_PORTS," in *[!0-9,:]*) exit 1 ;; esac +case ",$UDP_PORTS," in *[!0-9,:]*) exit 1 ;; esac + +clear_intercept() { + /sbin/pfctl -a "$ANCHOR" -F all >/dev/null 2>&1 || true +} + +release_pf_token() { + if [ -s "$TOKEN_FILE" ]; then + TOKEN=$(/bin/cat "$TOKEN_FILE" 2>/dev/null || true) + if [ -n "$TOKEN" ]; then /sbin/pfctl -X "$TOKEN" >/dev/null 2>&1 || true; fi + /bin/rm -f "$TOKEN_FILE" + fi +} + +cleanup() { + trap - EXIT INT TERM HUP + clear_intercept + if [ -n "$WATCHDOG_PID" ]; then kill "$WATCHDOG_PID" >/dev/null 2>&1 || true; fi + if [ -n "$ENGINE_PID" ]; then kill "$ENGINE_PID" >/dev/null 2>&1 || true; fi + if [ -n "$WATCHDOG_PID" ]; then wait "$WATCHDOG_PID" >/dev/null 2>&1 || true; fi + if [ -n "$ENGINE_PID" ]; then wait "$ENGINE_PID" >/dev/null 2>&1 || true; fi + release_pf_token + /bin/rm -f "$PIDFILE" +} + +trap cleanup EXIT INT TERM HUP +clear_intercept +/usr/bin/pkill -9 -f "$DEST/bin/utunws" >/dev/null 2>&1 || true + +PHYSICAL_IFACE=$(/sbin/route -n get default | /usr/bin/awk '/interface:/{print $2; exit}') +GATEWAY=$(/sbin/route -n get default | /usr/bin/awk '/gateway:/{print $2; exit}') +# Нет маршрута по умолчанию или он смотрит не в физический интерфейс (например, весь +# трафик уже уходит в VPN/другой utun) — переотправка через BPF работать не будет, честная +# ошибка вместо демона, который поднялся, но ничего не десинхронизирует. +if [ -z "$PHYSICAL_IFACE" ] || [ -z "$GATEWAY" ]; then exit 1; fi +/sbin/ping -c 1 -t 1 "$GATEWAY" >/dev/null 2>&1 || true +GATEWAY_MAC=$(/usr/sbin/arp -n "$GATEWAY" | /usr/bin/awk '/ at /{print $4; exit}') +case "$GATEWAY_MAC" in + *:*:*:*:*:*) ;; + *) exit 1 ;; +esac + +GATEWAY6_MAC= +if /sbin/route -n get -inet6 default >"$ROUTE6_FILE" 2>/dev/null; then + GATEWAY6=$(/usr/bin/awk '/gateway:/{print $2; exit}' "$ROUTE6_FILE") + if [ -n "$GATEWAY6" ]; then + GATEWAY6_MAC=$(/usr/sbin/ndp -n "$GATEWAY6" 2>/dev/null | /usr/bin/awk '/ at |%/{for(i=1;i<=NF;i++) if($i ~ /^([0-9a-f]{1,2}:){5}[0-9a-f]{1,2}$/){print $i; exit}}') + fi +fi +/bin/rm -f "$ROUTE6_FILE" + +export ZAPRET_IFACE="$PHYSICAL_IFACE" +export ZAPRET_GATEWAY_MAC="$GATEWAY_MAC" +export ZAPRET_GATEWAY6_MAC="$GATEWAY6_MAC" +export ZAPRET_UTUN_UNIT=$UTUN_UNIT +"$DEST/bin/utunws" --pidfile="$PIDFILE" "@$DEST/strategy.cfg" >>"$DEST/engine.log" 2>&1 & +ENGINE_PID=$! + +I=0 +while ! /sbin/ifconfig "$UTUN_IFACE" >/dev/null 2>&1; do + I=$((I + 1)) + if ! kill -0 "$ENGINE_PID" 2>/dev/null || [ "$I" -gt 100 ]; then exit 1; fi + sleep 0.1 +done +/sbin/ifconfig "$UTUN_IFACE" "$UTUN_LOCAL" "$UTUN_REMOTE" netmask 255.255.255.255 up +# utunws сам пишет pidfile до начала работы (fopen до цикла чтения utun) — chmod здесь +# только на случай нестандартного umask, чтобы sync()/наблюдатель читали его без root. +/bin/chmod 0644 "$PIDFILE" 2>/dev/null || true +"$DEST/watchdog.sh" $$ "$ENGINE_PID" & +WATCHDOG_PID=$! + +if /sbin/pfctl -s info 2>/dev/null | /usr/bin/grep -q '^Status: Disabled'; then + TOKEN=$(/sbin/pfctl -E 2>&1 | /usr/bin/awk '/Token :/ { print $3 }') + if [ -n "$TOKEN" ]; then /bin/echo "$TOKEN" >"$TOKEN_FILE"; fi +fi + +printf '%s\n' \ + "pass out quick route-to ($UTUN_IFACE $UTUN_REMOTE) inet proto tcp from any to any port {$TCP_PORTS} user { >root } no state" \ + "pass out quick route-to ($UTUN_IFACE $UTUN_REMOTE) inet proto udp from any to any port {$UDP_PORTS} user { >root } no state" \ + | /sbin/pfctl -a "$ANCHOR" -f - + +# Смена сети (Wi-Fi ↔ Ethernet, новый шлюз при DHCP-обновлении) делает пойманный при старте +# gateway MAC неверным — переотправка на старый MAC уходит в никуда. Выход отсюда с ошибкой +# перезапускает весь скрипт заново через KeepAlive плиста, включая переопределение шлюза. +while kill -0 "$ENGINE_PID" 2>/dev/null; do + sleep 2 + CURRENT_IFACE=$(/sbin/route -n get default 2>/dev/null | /usr/bin/awk '/interface:/{print $2; exit}') + CURRENT_GATEWAY=$(/sbin/route -n get default 2>/dev/null | /usr/bin/awk '/gateway:/{print $2; exit}') + if [ "$CURRENT_IFACE" != "$PHYSICAL_IFACE" ] || [ "$CURRENT_GATEWAY" != "$GATEWAY" ]; then exit 1; fi +done +exit 1 diff --git a/resources/darwin-helper/install.sh b/resources/darwin-helper/install.sh new file mode 100755 index 0000000..090a748 --- /dev/null +++ b/resources/darwin-helper/install.sh @@ -0,0 +1,133 @@ +#!/bin/sh +# Привилегированная установка обхода блокировок на macOS — единственное, что RKNboost +# запускает через osascript "with administrator privileges" (см. elevate.darwin.ts) для +# включения обхода. Не редактировать под bash-специфику: этот файл исполняется как +# /bin/sh — на macOS это тот же путь, что и на Linux, но лучше не полагаться на bashisms. +# +# Зовётся из src/main/zapret/utunws.darwin.ts ровно так: +# osascript … 'do shell script "'\''<этот файл>'\'' '\'''\'' …" with administrator privileges' +# +# Аргументы (все уже подготовлены и провалидированы на стороне приложения — сюда попадают +# только готовые пути и провалидированные строки портов, тот же принцип, что у Linux- +# помощника resources/linux-helper/rknboost-helper.sh): +# 1: utunws_src — путь к бинарнику utunws в resources/zapret/darwin (ещё не установлен) +# 2: fakes_src — каталог с fake-пакетами (resources/zapret/fakes) +# 3: config_src — уже отрендеренный strategy.cfg (маркер --comment=rknboost:: +# первой строкой, пути {LISTS}/{FAKES} уже резолвлены — см. strategies.ts) +# 4: tcp_ports — список портов TCP для pf, формат "80,443,8443" (уже проверен регуляркой +# в packetEngineConfig()) +# 5: udp_ports — то же для UDP +# 6: run_at_load — 0 или 1: должен ли демон переживать перезагрузку системы без +# запущенного приложения (настройка «Автозапуск» в интерфейсе) +set -eu + +HELPER_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) +DEST='/Library/Application Support/RKNboost/zapret' +LABEL=com.rknboost.zapret +PLIST=/Library/LaunchDaemons/$LABEL.plist +ANCHOR=com.apple/rknboost +PIDFILE=/var/run/rknboost-zapret.pid +TOKEN_FILE=/var/run/rknboost-zapret.pf-token +KEEPINIT_FILE=/var/db/rknboost-zapret.keepinit + +if [ "$#" -ne 6 ]; then + echo 'install.sh: ожидается 6 аргументов' >&2 + exit 1 +fi +UTUNWS_SRC=$1 +FAKES_SRC=$2 +CONFIG_SRC=$3 +TCP_PORTS=$4 +UDP_PORTS=$5 +RUN_AT_LOAD=$6 + +[ -x "$UTUNWS_SRC" ] || { echo 'install.sh: не найден бинарник utunws' >&2; exit 1; } +[ -d "$FAKES_SRC" ] || { echo 'install.sh: не найден каталог fake-пакетов' >&2; exit 1; } +[ -f "$CONFIG_SRC" ] || { echo 'install.sh: не найден strategy.cfg' >&2; exit 1; } +# Формат уже проверен регуляркой в packetEngineConfig()/utunwsConfig() — здесь просто не +# доверяем слепо тому, что дошло до root, тот же приём, что в rknboost-helper.sh на Linux. +# ':' — не опечатка: utunwsConfig() переводит диапазоны в синтаксис pf (19294-19344 → +# 19294:19344, см. toPfPortRanges() в strategies.ts), дефисов в этих строках уже нет. +case ",$TCP_PORTS," in *[!0-9,:]*) echo 'install.sh: странный формат tcp-портов' >&2; exit 1 ;; esac +case ",$UDP_PORTS," in *[!0-9,:]*) echo 'install.sh: странный формат udp-портов' >&2; exit 1 ;; esac +case "$RUN_AT_LOAD" in + 0) RUNATLOAD=false ;; + 1) RUNATLOAD=true ;; + *) echo 'install.sh: run_at_load должен быть 0 или 1' >&2; exit 1 ;; +esac + +# Сносим прошлую установку — самолечение на случай аварийно прерванной прошлой сессии, +# тот же приём, что cmd_stop() в rknboost-helper.sh на Linux делает первым шагом start. +/bin/launchctl bootout system/"$LABEL" >/dev/null 2>&1 || true +/sbin/pfctl -a "$ANCHOR" -F all >/dev/null 2>&1 || true +# Матчим процесс по полному пути НАШЕЙ установки, а не по имени: "utunws" называется так +# же и у независимо установленного Flowseal/zapret-mac-discord-youtube — pkill -x убил бы +# чужой процесс, если пользователь пользуется обоими приложениями одновременно. +/usr/bin/pkill -9 -f "$DEST/bin/utunws" >/dev/null 2>&1 || true +if [ -s "$TOKEN_FILE" ]; then + TOKEN=$(/bin/cat "$TOKEN_FILE" 2>/dev/null || true) + if [ -n "$TOKEN" ]; then /sbin/pfctl -X "$TOKEN" >/dev/null 2>&1 || true; fi + /bin/rm -f "$TOKEN_FILE" +fi +/bin/rm -f "$PIDFILE" + +# Копия, а не запуск прямо из resources/zapret приложения: root не должен исполнять файл +# из каталога, куда обычный пользователь может писать (то же рассуждение, что у +# protectedWinwsPath() в paths.ts на Windows). daemon.sh/watchdog.sh лежат рядом с этим +# скриптом в bundledDarwinHelperDir() — тоже часть сборки, тоже копируем в защищённое место. +/bin/rm -rf "$DEST/bin" "$DEST/fakes" +/bin/mkdir -p "$DEST/bin" "$DEST/fakes" +/usr/bin/ditto "$UTUNWS_SRC" "$DEST/bin/utunws" +/usr/bin/ditto "$FAKES_SRC" "$DEST/fakes" +/usr/bin/ditto "$HELPER_DIR/daemon.sh" "$DEST/daemon.sh" +/usr/bin/ditto "$HELPER_DIR/watchdog.sh" "$DEST/watchdog.sh" +# Скачанный релиз-архив помечен com.apple.quarantine — карантин на исполняемый файл внутри +# защищённого каталога, который запускает launchd, только мешал бы. +/usr/bin/xattr -dr com.apple.quarantine "$DEST" >/dev/null 2>&1 || true + +/usr/bin/install -m 0644 "$CONFIG_SRC" "$DEST/strategy.cfg" +printf '%s\n%s\n' "$TCP_PORTS" "$UDP_PORTS" > "$DEST/ports.conf" + +/usr/sbin/chown -R root:wheel "$DEST" +/bin/chmod 755 "$DEST" "$DEST/bin" "$DEST/bin/utunws" "$DEST/daemon.sh" "$DEST/watchdog.sh" "$DEST/fakes" +/bin/chmod 644 "$DEST/strategy.cfg" "$DEST/ports.conf" + +/usr/bin/sed \ + -e "s|@LABEL@|$LABEL|g" \ + -e "s|@DEST@|$DEST|g" \ + -e "s|@RUNATLOAD@|$RUNATLOAD|g" \ + "$HELPER_DIR/com.rknboost.zapret.plist.in" > "$PLIST" +/usr/sbin/chown root:wheel "$PLIST" +/bin/chmod 644 "$PLIST" + +# net.inet.tcp.keepinit=7000 удлиняет время до TCP RST на этапе установления соединения — +# desync нужно время сработать. Сохраняем исходное значение один раз, откатываем в stop.sh. +if [ ! -s "$KEEPINIT_FILE" ]; then + /usr/sbin/sysctl -n net.inet.tcp.keepinit > "$KEEPINIT_FILE" + /bin/chmod 600 "$KEEPINIT_FILE" +fi +/usr/sbin/sysctl -w net.inet.tcp.keepinit=7000 >/dev/null + +/bin/launchctl enable system/"$LABEL" +I=0 +while ! /bin/launchctl bootstrap system "$PLIST"; do + I=$((I + 1)) + if [ "$I" -gt 10 ]; then + /bin/sh "$HELPER_DIR/stop.sh" + exit 1 + fi + sleep 0.5 +done +/bin/launchctl kickstart -k system/"$LABEL" + +I=0 +while ! /sbin/ifconfig utun60 >/dev/null 2>&1; do + I=$((I + 1)) + if [ "$I" -gt 100 ]; then + /bin/sh "$HELPER_DIR/stop.sh" + echo 'install.sh: utunws не поднялся' >&2 + /usr/bin/tail -n 40 "$DEST/engine.log" >&2 2>/dev/null || true + exit 1 + fi + sleep 0.1 +done diff --git a/resources/darwin-helper/stop.sh b/resources/darwin-helper/stop.sh new file mode 100755 index 0000000..69eecb9 --- /dev/null +++ b/resources/darwin-helper/stop.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# Привилегированная остановка обхода блокировок на macOS. Без аргументов — вызывается и как +# osascript-точка входа из utunws.darwin.ts (выключение обхода), и напрямую (уже под root, +# без повторной элевации) из install.sh, когда новая установка не поднялась. +# +# Полный снос, а не пауза: LaunchDaemon, strategy.cfg, pf-правила — всё убирается. Обратное +# решение (оставить демона установленным и просто «выключенным», как служба на Windows) не +# подходит здесь — на macOS нет отдельного «выключенного» состояния для LaunchDaemon кроме +# unload/bootout, так что «выключить» и «удалить установку» здесь одно и то же действие. +# Настройка автозапуска (RunAtLoad) значения не теряет — она хранится в приложении и +# применяется заново при следующем включении (см. engine.darwin.ts). +set -eu + +LABEL=com.rknboost.zapret +ANCHOR=com.apple/rknboost +DEST='/Library/Application Support/RKNboost/zapret' +PLIST=/Library/LaunchDaemons/$LABEL.plist +PIDFILE=/var/run/rknboost-zapret.pid +TOKEN_FILE=/var/run/rknboost-zapret.pf-token +KEEPINIT_FILE=/var/db/rknboost-zapret.keepinit + +/bin/launchctl disable system/"$LABEL" >/dev/null 2>&1 || true +/bin/launchctl bootout system/"$LABEL" >/dev/null 2>&1 || true +/sbin/pfctl -a "$ANCHOR" -F all >/dev/null 2>&1 || true +# См. install.sh про то, почему по полному пути, а не по имени процесса. +/usr/bin/pkill -9 -f "$DEST/bin/utunws" >/dev/null 2>&1 || true +/bin/rm -f "$PIDFILE" "$PLIST" + +if [ -s "$TOKEN_FILE" ]; then + TOKEN=$(/bin/cat "$TOKEN_FILE" 2>/dev/null || true) + if [ -n "$TOKEN" ]; then /sbin/pfctl -X "$TOKEN" >/dev/null 2>&1 || true; fi + /bin/rm -f "$TOKEN_FILE" +fi +if [ -s "$KEEPINIT_FILE" ]; then + KEEPINIT=$(/bin/cat "$KEEPINIT_FILE" 2>/dev/null || true) + case "$KEEPINIT" in + *[!0-9]*|'') ;; + *) /usr/sbin/sysctl -w "net.inet.tcp.keepinit=$KEEPINIT" >/dev/null 2>&1 || true ;; + esac + /bin/rm -f "$KEEPINIT_FILE" +fi diff --git a/resources/darwin-helper/watchdog.sh b/resources/darwin-helper/watchdog.sh new file mode 100755 index 0000000..37b9ad5 --- /dev/null +++ b/resources/darwin-helper/watchdog.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Подчищает pf, если daemon.sh (супервизор) умер, не успев выполнить свой cleanup() — +# например, получил SIGKILL. Без этого правило route-to пережило бы демона, и трафик на +# заблокированные порты уходил бы в мёртвый utun вместо прохождения как обычно (pf-правило +# не имеет bypass-семантики, в отличие от очереди NFQUEUE на Linux). Портировано из +# watchdog.sh форка Flowseal/zapret-mac-discord-youtube (MIT) без изменений логики. +SUPERVISOR_PID=$1 +ENGINE_PID=$2 +ANCHOR=com.apple/rknboost +TOKEN_FILE=/var/run/rknboost-zapret.pf-token + +while kill -0 "$SUPERVISOR_PID" 2>/dev/null && kill -0 "$ENGINE_PID" 2>/dev/null; do + sleep 0.2 +done + +/sbin/pfctl -a "$ANCHOR" -F all >/dev/null 2>&1 || true +kill "$ENGINE_PID" >/dev/null 2>&1 || true +if [ -s "$TOKEN_FILE" ]; then + TOKEN=$(/bin/cat "$TOKEN_FILE" 2>/dev/null || true) + if [ -n "$TOKEN" ]; then /sbin/pfctl -X "$TOKEN" >/dev/null 2>&1 || true; fi + /bin/rm -f "$TOKEN_FILE" +fi diff --git a/scripts/fetch-zapret.mjs b/scripts/fetch-zapret.mjs index 66cb5fb..3505a63 100644 --- a/scripts/fetch-zapret.mjs +++ b/scripts/fetch-zapret.mjs @@ -3,13 +3,16 @@ * Кладёт бинарники и данные zapret в resources/zapret/ — оттуда electron-builder * положит их в сборку через extraResources. * - * Два источника: - * • bol-van/zapret — движки (tpws, winws.exe + WinDivert), версия пинована и + * Три источника: + * • bol-van/zapret — движки (winws.exe + WinDivert, nfqws), версия пинована и * сверяется по sha256sum.txt из релиза; * • Flowseal/zapret-discord-youtube — подобранные сообществом списки доменов и - * стратегии winws (переносятся в код генератором `import-flowseal-strategies.mjs`, + * стратегии winws (переносятся в код генератором `parser-strategies.mjs`, * здесь просто данные: списки + fake-пакеты). У Flowseal нет sha256sum.txt — * версия фиксируется по sha коммита, на который указывает тег. + * • Flowseal/zapret-mac-discord-youtube — macOS-порт nfqws (`utunws`, работает через + * utun+BPF вместо netfilter/WinDivert). Тоже без sha256sum.txt — сумму архива + * релиза фиксируем сами (см. `FLOWSEAL_MAC_ZIP_SHA256`). * * Каталог resources/zapret в git не хранится: запустите `npm run zapret:fetch` * (или просто `npm run build` — он вызывает скрипт сам). @@ -30,6 +33,16 @@ const WINDIVERT_VERSION = 'v2.2.2' const FLOWSEAL_VERSION = '1.10.2' const FLOWSEAL_COMMIT = 'dfd8e613b099676cf2aa7b474ee5923801514dec' +/** + * Релиз Flowseal/zapret-mac-discord-youtube, откуда берём `utunws` — форк nfqws под + * macOS (utun+BPF вместо netfilter). У репозитория нет sha256sum.txt, поэтому вместо + * суммы отдельного бинарника фиксируем сумму всего архива релиза — она получена один раз + * командой `shasum -a 256` по скачанному `ZapretMac-macOS-universal.zip` и проверяется + * при каждой закачке, чтобы подмена ассета в чужом форке или на CDN не прошла незамеченной. + */ +const FLOWSEAL_MAC_VERSION = '1.1.2' +const FLOWSEAL_MAC_ZIP_SHA256 = '9895b0ec5d13ec05676f755cde71107e72d2661538440c3cc4f06e0e6d47844f' + const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') const DEST = join(ROOT, 'resources', 'zapret') @@ -40,7 +53,6 @@ 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', @@ -50,7 +62,7 @@ const FILES = { } /** Исполняемый бит нужен только там, где мы сами запускаем бинарник. */ -const EXECUTABLE = new Set(['darwin/tpws', 'linux/x64/nfqws']) +const EXECUTABLE = new Set(['darwin/utunws', 'linux/x64/nfqws']) /** * Списки доменов Flowseal → наши короткие имена (используются и в `lists.ts`, и @@ -191,12 +203,51 @@ async function fetchFlowsealData(work) { console.log(' LICENSE.flowseal.txt') } +/** Путь внутри ZapretMac-macOS-universal.zip до самого бинарника. */ +const UTUNWS_ZIP_ENTRY = 'ZapretMac.app/Contents/Resources/Payload/bin/utunws' + +async function fetchUtunwsEngine(work) { + console.log(`Flowseal-mac ${FLOWSEAL_MAC_VERSION}: скачиваю ZapretMac-macOS-universal.zip`) + + const zipPath = join(work, 'zapretmac.zip') + const zipBuffer = await download( + `https://github.com/Flowseal/zapret-mac-discord-youtube/releases/download/${FLOWSEAL_MAC_VERSION}/ZapretMac-macOS-universal.zip` + ) + + const actual = sha256(zipBuffer) + if (actual !== FLOWSEAL_MAC_ZIP_SHA256) { + throw new Error( + `не совпала контрольная сумма ZapretMac-macOS-universal.zip\n` + + ` ожидалось: ${FLOWSEAL_MAC_ZIP_SHA256}\n получено: ${actual}` + ) + } + await writeFile(zipPath, zipBuffer) + + // unzip есть из коробки на macOS и в Git for Windows/WSL; в архиве Flowseal нет sha256sum.txt + // на отдельные файлы — сумму всего zip уже сверили выше, здесь просто достаём бинарник. + const utunwsDestination = join(DEST, 'darwin', 'utunws') + await mkdir(dirname(utunwsDestination), { recursive: true }) + const extracted = execFileSync('unzip', ['-p', zipPath, UTUNWS_ZIP_ENTRY]) + await writeFile(utunwsDestination, extracted) + await chmod(utunwsDestination, 0o755) + console.log(` darwin/utunws (${extracted.length} байт, universal x86_64+arm64)`) + + // LICENSE в архив релиза не входит (только ZapretMac.app) — берём из репозитория по тому + // же тегу, что и версия релиза, тем же приёмом, что LICENSE.WinDivert.txt выше. + const license = await download( + `https://raw.githubusercontent.com/Flowseal/zapret-mac-discord-youtube/${FLOWSEAL_MAC_VERSION}/LICENSE` + ) + await writeFile(join(DEST, 'LICENSE.flowseal-mac.txt'), license) + console.log(' LICENSE.flowseal-mac.txt') +} + async function main() { const work = await mkdtemp(join(tmpdir(), 'zapret-')) try { await fetchZapretEngines(work) await fetchFlowsealData(work) + await fetchUtunwsEngine(work) console.log(`Готово: ${DEST}`) } finally { await rm(work, { recursive: true, force: true }) @@ -206,7 +257,7 @@ async function main() { // Повторная сборка не должна каждый раз ходить в сеть. if ( process.argv.includes('--force') || - !existsSync(join(DEST, 'darwin', 'tpws')) || + !existsSync(join(DEST, 'darwin', 'utunws')) || !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/parser-strategies.mjs similarity index 100% rename from scripts/import-flowseal-strategies.mjs rename to scripts/parser-strategies.mjs diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 7ac5c68..89cb4fb 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -2,15 +2,12 @@ import { app, BrowserWindow, ipcMain, nativeTheme, type IpcMainInvokeEvent } fro import type { IpcChannel, IpcHandlers, ThemeState } from '../shared/ipc-contract' import { uninstallApp } from './uninstall.win32' import { - clearZapretAutoHostlist, resetZapretList, saveZapretList, setZapretAutoStart, setZapretStrategy, - setZapretSystemProxy, startZapret, stopZapret, - zapretAutoHostlist, zapretDiagnostics, zapretLists, zapretStatus, @@ -61,15 +58,12 @@ export function registerIpcHandlers(): void { handle('zapret:strategies', () => zapretStrategies()) handle('zapret:start', (_event, strategyId) => startZapret(strategyId)) handle('zapret:stop', () => stopZapret()) - handle('zapret:set-system-proxy', (_event, enabled) => setZapretSystemProxy(enabled)) handle('zapret:set-strategy', (_event, strategyId) => setZapretStrategy(strategyId)) handle('zapret:set-autostart', (_event, enabled) => setZapretAutoStart(enabled)) handle('zapret:lists', () => zapretLists()) handle('zapret:list-save', (_event, id, entries) => saveZapretList(id, entries)) handle('zapret:list-reset', (_event, id) => resetZapretList(id)) - handle('zapret:auto-hostlist', () => zapretAutoHostlist()) - handle('zapret:auto-hostlist-clear', () => clearZapretAutoHostlist()) // Результаты уходят не ответом на invoke, а событиями по мере готовности — и именно // в то окно, которое просило проверку, а не всем подряд. Окно могло закрыться, пока // сайт отвечал (или молчал все 10 с таймаута), — тогда показывать результат уже некому. diff --git a/src/main/zapret/diagnostics.ts b/src/main/zapret/diagnostics.ts index 560c885..8fb6753 100644 --- a/src/main/zapret/diagnostics.ts +++ b/src/main/zapret/diagnostics.ts @@ -1,16 +1,9 @@ -import { connect as netConnect, type Socket } from 'node:net' -import { connect as tlsConnect } from 'node:tls' import { net } from 'electron' -import type { - ZapretDiagnosticResult, - ZapretDiagnosticTarget, - ZapretStatus -} from '../../shared/ipc-contract' +import type { ZapretDiagnosticResult, ZapretDiagnosticTarget } from '../../shared/ipc-contract' // 6 секунд не хватало: десинк с `--dpi-desync-repeats` заметно удлиняет установку соединения, // и медленный, но живой сайт успевал попасть в «Недоступно». const TIMEOUT_MS = 10000 -const TARGET_PORT = 443 const RETRY_DELAY_MS = 300 /** Сайты, которые нужно проверять в первую очередь — по просьбе пользователя. */ @@ -41,43 +34,11 @@ function describeNetError(message: string): string { } /** - * SOCKS5 CONNECT без авторизации — ровно то, что понимает tpws в режиме `--socks`. - * Три сообщения: приветствие (метод «без авторизации»), запрос на домен, ответ сервера. - */ -function socksConnect(socket: Socket, host: string, port: number): Promise { - return new Promise((resolve, reject) => { - const hostBuf = Buffer.from(host, 'utf8') - - socket.once('data', (greeting: Buffer) => { - if (greeting[0] !== 0x05 || greeting[1] !== 0x00) { - reject(new Error('SOCKS5: сервер отклонил метод авторизации')) - return - } - - socket.once('data', (reply: Buffer) => { - if (reply[1] !== 0x00) { - reject(new Error(`SOCKS5: код ошибки 0x${reply[1].toString(16)}`)) - return - } - resolve() - }) - - socket.write( - Buffer.concat([ - Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]), - hostBuf, - Buffer.from([port >> 8, port & 0xff]) - ]) - ) - }) - - socket.write(Buffer.from([0x05, 0x01, 0x00])) - }) -} - -/** - * Прямая проверка — путь для Windows (десинк общесистемный, через WinDivert) и для - * выключенного обхода. + * Единственный путь проверки на всех трёх платформах: десинк везде общесистемный — + * winws через WinDivert, nfqws через nftables/NFQUEUE, utunws через pf/utun (см. + * `engine.*.ts`) — прямой запрос видит его так же, как и обычный браузерный трафик. + * Раньше на macOS был отдельный путь через локальный SOCKS5 (`tpws --socks`), но новый + * движок `utunws` — не прокси, трафик десинхронизируется до того, как уйдёт в сеть. * * Намеренно через `net` Electron, то есть через сетевой стек Chromium, а НЕ через * `node:tls`. Тест должен отвечать на вопрос «откроется ли этот сайт в браузере», а @@ -118,35 +79,6 @@ function checkDirect(host: string): Promise { }) } -/** Тот же успех, но через локальный SOCKS5 tpws — путь для macOS, пока обход включён. */ -function checkViaSocks(host: string, socksHost: string, socksPort: number): Promise { - return new Promise((resolve, reject) => { - const start = Date.now() - const socket = netConnect({ host: socksHost, port: socksPort }) - let settled = false - - const finish = (error: Error | null): void => { - if (settled) return - settled = true - socket.destroy() - if (error) reject(error) - else resolve(Date.now() - start) - } - - socket.setTimeout(TIMEOUT_MS, () => finish(new Error(`Таймаут ${TIMEOUT_MS} мс`))) - socket.once('error', finish) - socket.once('connect', () => { - socksConnect(socket, host, TARGET_PORT) - .then(() => { - const tlsSocket = tlsConnect({ socket, servername: host }) - tlsSocket.once('secureConnect', () => finish(null)) - tlsSocket.once('error', finish) - }) - .catch(finish) - }) - }) -} - function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } @@ -171,11 +103,6 @@ async function withRetry(attempt: () => Promise): Promise { * Успех означает «до сайта дошли и он ответил» — этого достаточно, чтобы отличить * «DPI оборвал соединение» от «соединение установилось», разбирать ответ незачем. * - * `status.socksAddress` заполнен только на macOS и только пока обход запущен — во всех - * остальных случаях (Windows, обход выключен, платформа не поддерживается) бьём напрямую: - * на Windows десинк уже общесистемный через WinDivert, а без обхода тест просто показывает - * то, что есть сейчас. - * * Каждый сайт отдаёт свой результат в `onResult` сам по себе, не дожидаясь остальных: * проверки идут параллельно и заканчиваются вразнобой (живой сайт отвечает за десятки * миллисекунд, заблокированный — только по таймауту в 10 с), и общий ответ одним куском @@ -183,20 +110,12 @@ async function withRetry(attempt: () => Promise): Promise { * по нему вызывающий код понимает, что проверка закончена. */ export async function runDiagnostics( - status: ZapretStatus, onResult: (result: ZapretDiagnosticResult) => void ): Promise { - const socksMatch = status.socksAddress ? /^(.+):(\d+)$/.exec(status.socksAddress) : null - await Promise.all( DIAGNOSTIC_TARGETS.map(async (target) => { - const attempt = (): Promise => - socksMatch - ? checkViaSocks(target.host, socksMatch[1], Number(socksMatch[2])) - : checkDirect(target.host) - try { - const ms = await withRetry(attempt) + const ms = await withRetry(() => checkDirect(target.host)) onResult({ ...target, ok: true, ms, error: null }) } catch (error) { onResult({ diff --git a/src/main/zapret/elevate.darwin.ts b/src/main/zapret/elevate.darwin.ts new file mode 100644 index 0000000..6e749d1 --- /dev/null +++ b/src/main/zapret/elevate.darwin.ts @@ -0,0 +1,35 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' + +const run = promisify(execFile) + +/** + * Запускает привилегированный скрипт из `resources/darwin-helper/` через системный диалог + * авторизации — единственное место в приложении, где macOS просит пароль администратора. + * Логика перенесена из старого `proxy.darwin.ts` (`networksetup()`), где так же собиралась + * команда `osascript -e 'do shell script "..." with administrator privileges'`: одинарные + * кавычки вокруг каждого аргумента и `'` внутри значения экранируется как `'\''` — тот же + * приём, что `quoteConfigArg()` в `strategies.ts` использует для strategy.cfg. + */ +export async function runPrivileged(scriptPath: string, args: string[]): Promise { + const command = [scriptPath, ...args] + .map((part) => `'${part.replaceAll("'", `'\\''`)}'`) + .join(' ') + + try { + await run('/usr/bin/osascript', [ + '-e', + `do shell script "${command.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}" with administrator privileges` + ]) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + // -128 — «User canceled» из Authorization Services: пользователь закрыл диалог пароля + // или нажал «Отмена», это не ошибка выполнения скрипта, а отказ от неё. + if (message.includes('-128')) { + throw new Error('Операция отменена — не введён пароль администратора.', { cause: error }) + } + throw new Error(`Не удалось выполнить привилегированную операцию: ${message}`, { + cause: error + }) + } +} diff --git a/src/main/zapret/engine.darwin.ts b/src/main/zapret/engine.darwin.ts index 57053e9..7382ff9 100644 --- a/src/main/zapret/engine.darwin.ts +++ b/src/main/zapret/engine.darwin.ts @@ -1,243 +1,155 @@ -import { spawn, type ChildProcessByStdio } from 'node:child_process' -import type { Readable } from 'node:stream' -import { createConnection, createServer } from 'node:net' import type { ZapretStatus } from '../../shared/ipc-contract' import { ZapretEngine } from './engine' -import { assertExecutable, tpwsPath } from './paths' -import { applySystemProxy, hasStaleSnapshot, restoreSystemProxy } from './proxy.darwin' -import { defaultStrategyId, tpwsArgs } from './strategies' +import { assertExecutable, utunwsPath } from './paths' +import { defaultStrategyId, utunwsConfig } from './strategies' +import { + assertMacosVersion, + dryRun, + readUtunwsState, + setUtunwsAutoStart, + startUtunws, + stopUtunws +} from './utunws.darwin' -const HOST = '127.0.0.1' -const PREFERRED_PORT = 1080 -const READY_TIMEOUT_MS = 3000 -const STOP_TIMEOUT_MS = 3000 - -/** Свободен ли порт на localhost. */ -function isPortFree(port: number): Promise { - return new Promise((resolve) => { - const server = createServer() - server.once('error', () => resolve(false)) - server.listen(port, HOST, () => server.close(() => resolve(true))) - }) -} - -async function pickPort(): Promise { - for (let port = PREFERRED_PORT; port < PREFERRED_PORT + 20; port += 1) { - if (await isPortFree(port)) return port - } - throw new Error('Не нашлось свободного порта для socks-прокси.') -} - -/** У tpws нет сигнала готовности — просто ждём, пока порт начнёт принимать соединения. */ -function canConnect(port: number): Promise { - return new Promise((resolve) => { - const socket = createConnection({ host: HOST, port }) - const finish = (ok: boolean): void => { - socket.destroy() - resolve(ok) - } - socket.once('connect', () => finish(true)) - socket.once('error', () => finish(false)) - }) -} - -async function waitUntilReady(port: number, isAlive: () => boolean): Promise { - const deadline = Date.now() + READY_TIMEOUT_MS - - while (Date.now() < deadline) { - if (!isAlive()) return // процесс уже умер, ошибку соберёт вызывающий код - if (await canConnect(port)) return - await new Promise((resolve) => setTimeout(resolve, 100)) - } - - throw new Error(`tpws не начал слушать ${HOST}:${port} за ${READY_TIMEOUT_MS} мс.`) -} +/** Как часто опрашивать pidfile — демон нам не дочерний процесс, экзит-событие поймать некому. */ +const WATCH_INTERVAL_MS = 3000 /** - * macOS: tpws поднимается обычным пользовательским процессом в режиме socks5 - * (документация zapret: «Режим --socks не требует повышенных привилегий»), - * а трафик заворачивается системной настройкой прокси. - * - * Важное ограничение платформы: tpws не умеет UDP, поэтому QUIC не обходится. - * Практика это скрадывает — при системном socks-прокси браузеры сами уходят с QUIC на TCP. + * macOS: `utunws` (форк `nfqws` из Flowseal/zapret-mac-discord-youtube — тот же движок + * уровня пакетов, что winws и nfqws, но через utun+BPF вместо WinDivert/netfilter) под + * LaunchDaemon. Пароль администратора спрашивается ровно при двух действиях — включении и + * выключении обхода (`resources/darwin-helper/install.sh`/`stop.sh` через `osascript`), + * тот же принцип, что `pkexec` на Linux. Выключение полностью сносит установку (плист, + * strategy.cfg, pf-правила) — «выключено» и «не установлено» здесь одно состояние, поэтому + * `dispose()` не требуется останавливать демон: он живёт своей жизнью в системе, как и на + * Windows/Linux (если пользователь не тронул переключатель, обход продолжает работать + * после закрытия приложения). */ export class DarwinEngine extends ZapretEngine { - private child: ChildProcessByStdio | null = null - private port = PREFERRED_PORT - /** Просили ли включить системный прокси. Переживает перезапуск tpws. */ - private wantSystemProxy = true + private watchTimer: ReturnType | null = null constructor() { - super('tpws-socks', defaultStrategyId('darwin')) + super('utunws-pf', defaultStrategyId('darwin')) + // Решение по умолчанию для первой установки — то же, что у Win32Engine: обход должен + // работать и после перезагрузки, не дожидаясь запуска приложения. + this.status.autoStart = true } /** - * Прошлый запуск мог не снять прокси (например, kill -9). Тогда система смотрит - * в мёртвый socks и интернета нет вовсе — чиним до того, как показать окно. + * Вызывается из `recoverZapret()` до открытия окна — подхватывает LaunchDaemon, + * переживший закрытие приложения (RunAtLoad+KeepAlive держит его живым и через + * перезагрузку системы), тот же принцип, что `Win32Engine.sync()`/`LinuxEngine.sync()`. */ - async recoverFromCrash(): Promise { - if (!(await hasStaleSnapshot())) return + async sync(): Promise { + const { pid, strategyId, autoStart } = await readUtunwsState() - this.appendLog( - 'info', - 'Найдены незакрытые настройки прокси от прошлого запуска — восстанавливаю.' - ) - try { - await restoreSystemProxy() - } catch (error) { - this.appendLog('error', error instanceof Error ? error.message : String(error)) + if (pid === null) { + this.stopWatch() + return this.patch({ state: 'stopped', startedAt: null, error: null }) } + + this.startWatch() + return this.patch({ + state: 'running', + strategyId: strategyId ?? this.status.strategyId, + // Реальное состояние демона важнее сохранённого приложением выбора — тот же принцип, + // что у Win32Engine.sync() с info.startMode. + autoStart: autoStart ?? this.status.autoStart, + // Момент реального запуска нам не известен — демон мог подняться ещё при загрузке + // системы, до старта приложения. Тот же компромисс, что и в остальных движках. + startedAt: new Date().toISOString(), + error: null + }) } async start(strategyId: string): Promise { - if (this.child) await this.stop() - - const binary = tpwsPath() - await assertExecutable(binary) - this.patch({ state: 'starting', strategyId, error: null }) try { - this.port = await pickPort() - const child = spawn(binary, tpwsArgs(strategyId, this.port), { - stdio: ['ignore', 'pipe', 'pipe'] - }) - this.child = child + // Всё, что можно проверить без пароля, проверяем в этом порядке: сначала формат + // самой стратегии, потом версию системы, потом сами аргументы через --dry-run — тот + // же принцип, что и у LinuxEngine.start(), единственный шанс не побеспокоить + // пользователя диалогом авторизации ради заведомо неверных данных. + const config = utunwsConfig(strategyId) + assertMacosVersion() + await assertExecutable(utunwsPath()) + await dryRun(config.body) - // Вывод tpws идёт в stderr целиком, включая обычные сообщения о старте. - child.stdout.setEncoding('utf8') - child.stderr.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => this.appendLog('info', chunk)) - - let stderrTail = '' - child.stderr.on('data', (chunk: string) => { - stderrTail = `${stderrTail}${chunk}`.slice(-2000) - this.appendLog('info', chunk) + await startUtunws({ + configBody: config.body, + tcpPorts: config.tcpPorts, + udpPorts: config.udpPorts, + autoStart: this.status.autoStart }) - child.on('exit', (code, signal) => { - this.child = null - // Плановую остановку обрабатывает stop(), здесь ловим только падение. - if (this.status.state === 'stopping' || this.status.state === 'stopped') return - void this.handleCrash(code, signal, stderrTail) - }) + const { pid } = await readUtunwsState() + if (pid !== null) this.startWatch() - await waitUntilReady(this.port, () => this.child !== null) - - if (!this.child) { - throw new Error(stderrTail.trim() || 'tpws завершился сразу после запуска.') - } - - if (this.wantSystemProxy) { - await applySystemProxy(HOST, this.port) - } - - return this.patch({ - state: 'running', - startedAt: new Date().toISOString(), - error: null, - socksAddress: `${HOST}:${this.port}`, - systemProxyApplied: this.wantSystemProxy - }) + return this.patch({ state: 'running', startedAt: new Date().toISOString(), error: null }) } catch (error) { - await this.cleanup() const message = error instanceof Error ? error.message : String(error) this.appendLog('error', message) - return this.patch({ - state: 'error', - error: message, - startedAt: null, - socksAddress: null, - systemProxyApplied: false - }) + return this.patch({ state: 'error', error: message, startedAt: null }) } } async stop(): Promise { - if (!this.child && !this.status.systemProxyApplied) { - return this.patch({ state: 'stopped', startedAt: null, socksAddress: null }) - } - this.patch({ state: 'stopping' }) - await this.cleanup() - - return this.patch({ - state: 'stopped', - startedAt: null, - error: null, - socksAddress: null, - systemProxyApplied: false - }) - } - - async setSystemProxy(enabled: boolean): Promise { - this.wantSystemProxy = enabled - - // Пока обход выключен, менять нечего — запомнили выбор и всё. - if (this.status.state !== 'running') return this.patch({ systemProxyApplied: false }) - - if (enabled) { - await applySystemProxy(HOST, this.port) - } else { - await restoreSystemProxy() + this.stopWatch() + try { + await stopUtunws() + } 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({ systemProxyApplied: enabled }) - } - - async dispose(): Promise { - await this.cleanup() + return this.patch({ state: 'stopped', startedAt: null, error: null }) } /** - * Порядок важен: сначала снимаем прокси, потом гасим tpws. Наоборот нельзя — - * между шагами система осталась бы с прокси, который уже никто не слушает. + * Только RunAtLoad уже установленного LaunchDaemon — если демона ещё нет, лишь запоминаем + * выбор до первого включения (никакого диалога авторизации на пустом месте), тот же + * принцип, что у `Win32Engine.setAutoStart()`. */ - private async cleanup(): Promise { - try { - await restoreSystemProxy() - } catch (error) { - this.appendLog('error', error instanceof Error ? error.message : String(error)) + async setAutoStart(enabled: boolean): Promise { + if (this.status.state !== 'running') { + return this.patch({ autoStart: enabled }) } - - const child = this.child - if (!child) return - this.child = null - - child.kill('SIGTERM') - await new Promise((resolve) => { - const timer = setTimeout(() => { - child.kill('SIGKILL') - resolve() - }, STOP_TIMEOUT_MS) - - child.once('exit', () => { - clearTimeout(timer) - resolve() - }) - }) + await setUtunwsAutoStart(enabled) + return this.patch({ autoStart: enabled }) } - private async handleCrash( - code: number | null, - signal: NodeJS.Signals | null, - stderrTail: string - ): Promise { - const reason = signal ? `сигнал ${signal}` : `код ${code}` - const details = stderrTail.trim().split('\n').slice(-3).join(' ') + async dispose(): Promise { + // LaunchDaemon живёт своей жизнью в системе — выход приложения его не останавливает + // (см. комментарий класса). Опрос pidfile всё равно больше не нужен. + this.stopWatch() + } - // Прокси обязательно снять: иначе система указывает на мёртвый socks и сети не будет. - try { - await restoreSystemProxy() - } catch (error) { - this.appendLog('error', error instanceof Error ? error.message : String(error)) - } + /** + * Демон нам не дочерний процесс (владеет им launchd, а не Electron) — событие завершения + * поймать некому, поэтому опрашиваем pidfile по таймеру, как и `LinuxEngine`. В отличие + * от Linux pid здесь может смениться без нашего участия: `daemon.sh` перезапускает + * utunws заново при смене сети (новый шлюз), поэтому pidfile перечитываем на каждом тике, + * а не держим захваченный при старте pid. + */ + private startWatch(): void { + this.stopWatch() + this.watchTimer = setInterval(() => { + void readUtunwsState().then(({ pid: currentPid }) => { + if (currentPid !== null || this.status.state !== 'running') return + this.stopWatch() + this.appendLog('error', 'utunws больше не запущен') + this.patch({ + state: 'error', + error: 'Процесс utunws неожиданно завершился.', + startedAt: null + }) + }) + }, WATCH_INTERVAL_MS) + } - this.patch({ - state: 'error', - error: `tpws неожиданно завершился (${reason}). ${details}`.trim(), - startedAt: null, - socksAddress: null, - systemProxyApplied: false - }) + 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 b81681e..939a87d 100644 --- a/src/main/zapret/engine.ts +++ b/src/main/zapret/engine.ts @@ -22,8 +22,6 @@ export abstract class ZapretEngine { strategyId: defaultStrategyId, startedAt: null, error: null, - socksAddress: null, - systemProxyApplied: false, serviceInstalled: false, autoStart: false } @@ -57,12 +55,6 @@ export abstract class ZapretEngine { /** Вызывается при выходе из приложения: вернуть систему в исходное состояние. */ abstract dispose(): Promise - /** macOS-специфика; на других платформах переопределять не нужно. */ - async setSystemProxy(enabled: boolean): Promise { - void enabled - throw new Error('Системный прокси на этой платформе не настраивается.') - } - /** * Выключен — просто запоминаем выбор (никаких системных действий и никакого UAC). * Работает — перезапускаем с новой стратегией через `start()`; на Windows тот сам @@ -76,7 +68,7 @@ export abstract class ZapretEngine { return this.start(strategyId) } - /** Windows-специфика (автозапуск службы); на других платформах переопределять не нужно. */ + /** macOS/Windows-специфика (автозапуск демона/службы); на Linux переопределять не нужно. */ async setAutoStart(enabled: boolean): Promise { void enabled throw new Error('Автозапуск на этой платформе не настраивается.') diff --git a/src/main/zapret/index.ts b/src/main/zapret/index.ts index e91e7c6..1f06ca1 100644 --- a/src/main/zapret/index.ts +++ b/src/main/zapret/index.ts @@ -1,4 +1,3 @@ -import { readFile, rm } from 'node:fs/promises' import type { ZapretDiagnosticResult, ZapretDiagnosticTarget, @@ -15,7 +14,7 @@ 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' +import { restoreSystemProxy } from './proxy.darwin' import { listStrategies } from './strategies' function createEngine(): ZapretEngine { @@ -35,16 +34,18 @@ const engine = createEngine() /** * Готовим списки сайтов, возвращаем выбор пользователя из прошлого запуска и синхронизируем - * состояние с системой — на macOS чиним прокси от прошлого аварийного завершения, на Windows - * подхватываем службу, которая могла работать ещё до открытия окна (автозапуск с системой), - * на Linux — демон nfqws, переживший закрытие приложения (автозапуска у него нет, но сам - * процесс и правила nftables продолжают работать). Все шаги нужно сделать до открытия окна. + * состояние с системой — на macOS подхватываем LaunchDaemon, который мог работать ещё до + * открытия окна (автозапуск с системой) и один раз чиним системный прокси, оставшийся от + * версий на tpws (≤0.5.4, см. `proxy.darwin.ts`), на Windows подхватываем службу с тем же + * автозапуском, на Linux — демон nfqws, переживший закрытие приложения (автозапуска у него + * нет, но сам процесс и правила nftables продолжают работать). Все шаги нужно сделать до + * открытия окна. */ export async function recoverZapret(): Promise { await seedLists() - // Сохранённый выбор применяем ДО sync(): на Windows у уже установленной службы реальное - // состояние (стратегия в strategy.cfg, StartMode) важнее записанного нами и перекроет его. + // Сохранённый выбор применяем ДО sync(): у уже установленной службы/демона реальное + // состояние (стратегия в strategy.cfg, автозапуск) важнее записанного нами и перекроет его. const settings = await loadSettings() engine.restorePreferences({ // Стратегия могла исчезнуть в обновлении (подборка Flowseal меняется) или остаться от @@ -54,7 +55,12 @@ export async function recoverZapret(): Promise { autoStart: settings.autoStart }) - if (engine instanceof DarwinEngine) await engine.recoverFromCrash() + if (engine instanceof DarwinEngine) { + await restoreSystemProxy().catch(() => { + // Раз откатить не удалось — оставляем как есть, не мешаем окну открыться. + }) + await engine.sync() + } if (engine instanceof Win32Engine) await engine.sync() if (engine instanceof LinuxEngine) await engine.sync() } @@ -85,10 +91,6 @@ export function stopZapret(): Promise { return engine.stop() } -export function setZapretSystemProxy(enabled: boolean): Promise { - return engine.setSystemProxy(enabled) -} - export function setZapretStrategy(strategyId: string): Promise { return engine.setStrategy(strategyId) } @@ -114,23 +116,6 @@ export function resetZapretList(id: ZapretListId): Promise { return resetList(id) } -/** Домены, которые zapret нашёл сам через `--hostlist-auto` (только macOS-профили). */ -export async function zapretAutoHostlist(): Promise { - try { - const content = await readFile(autoHostlistPath(), 'utf8') - return content - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - } catch { - return [] - } -} - -export async function clearZapretAutoHostlist(): Promise { - await rm(autoHostlistPath(), { force: true }) -} - /** * Запускает проверку и сразу возвращает список сайтов — результаты приходят в `onResult` * по одному, по мере готовности (см. `runDiagnostics`). Промис проверки намеренно не ждём: @@ -142,7 +127,7 @@ export function zapretDiagnostics( ): ZapretDiagnosticTarget[] { // Каждая проверка ловит свою ошибку сама, наружу выбраться нечему — ловим здесь только // чтобы неожиданное падение не осталось необработанным промисом. - runDiagnostics(engine.getStatus(), onResult).catch((error: unknown) => { + runDiagnostics(onResult).catch((error: unknown) => { console.error('[zapret] проверка соединения завершилась ошибкой:', error) }) return DIAGNOSTIC_TARGETS diff --git a/src/main/zapret/paths.ts b/src/main/zapret/paths.ts index 80cc514..3e406a2 100644 --- a/src/main/zapret/paths.ts +++ b/src/main/zapret/paths.ts @@ -13,9 +13,9 @@ function zapretRoot(): string { : resolve(app.getAppPath(), 'resources', 'zapret') } -/** macOS: universal-бинарник tpws (arm64 + x86_64). */ -export function tpwsPath(): string { - return join(zapretRoot(), 'darwin', 'tpws') +/** macOS: universal-бинарник utunws (arm64 + x86_64), форк nfqws под utun+BPF. */ +export function utunwsPath(): string { + return join(zapretRoot(), 'darwin', 'utunws') } /** Windows: winws.exe рядом с cygwin1.dll и WinDivert. */ @@ -88,6 +88,67 @@ export function linuxInstalledConfigPath(): string { return join(linuxRuntimeDir(), 'strategy.cfg') } +/** + * macOS: привилегированные скрипты в составе сборки (`resources/darwin-helper/`, отдельный + * `extraResources` в electron-builder.yml — не качается `fetch-zapret.mjs`, лежит в + * репозитории, тот же принцип, что и `bundledHelperPath()` для Linux). + */ +export function bundledDarwinHelperDir(): string { + return app.isPackaged + ? join(process.resourcesPath, 'darwin-helper') + : resolve(app.getAppPath(), 'resources', 'darwin-helper') +} + +export function darwinInstallScriptPath(): string { + return join(bundledDarwinHelperDir(), 'install.sh') +} + +export function darwinStopScriptPath(): string { + return join(bundledDarwinHelperDir(), 'stop.sh') +} + +export function darwinAutostartScriptPath(): string { + return join(bundledDarwinHelperDir(), 'autostart.sh') +} + +/** + * macOS: каталог, куда `install.sh` копирует utunws, daemon.sh/watchdog.sh, fake-пакеты и + * strategy.cfg — свой пользовательский `resources/zapret` для этого не годится: root + * (`daemon.sh` под launchd) не должен исполнять файлы из каталога, доступного на запись + * обычному пользователю (то же рассуждение, что у `protectedWinwsPath()` на Windows). + */ +export function darwinInstallDir(): string { + return '/Library/Application Support/RKNboost/zapret' +} + +/** Стейджинговая копия `fakesDir()` — то же назначение, что `stagedFakesDir()` на Linux. */ +export function darwinInstalledFakesDir(): string { + return join(darwinInstallDir(), 'fakes') +} + +/** Источник маркера стратегии для `DarwinEngine.sync()` — читается без root (0644). */ +export function darwinInstalledConfigPath(): string { + return join(darwinInstallDir(), 'strategy.cfg') +} + +/** Порты TCP/UDP (по строке на каждый) — их же читает `daemon.sh` при заряде pf-правил. */ +export function darwinPortsPath(): string { + return join(darwinInstallDir(), 'ports.conf') +} + +/** + * `utunws --pidfile=...` пишет сюда свой pid до начала работы (см. `daemon.sh`) — читается + * без root, тот же принцип, что у `linuxPidfilePath()`. `/var/run` — не `/run/rknboost` + * (Linux-путь): на macOS это симлинк на `/private/var/run`, а не отдельная точка монтирования. + */ +export function darwinPidfilePath(): string { + return '/var/run/rknboost-zapret.pid' +} + +export function darwinPlistPath(): string { + return '/Library/LaunchDaemons/com.rknboost.zapret.plist' +} + /** Встроенные списки (из Flowseal/zapret-discord-youtube, см. scripts/fetch-zapret.mjs) — только для чтения. */ export function bundledListsDir(): string { return join(zapretRoot(), 'lists') @@ -105,11 +166,14 @@ export function userListsDir(): string { /** * Fake-пакеты (TLS ClientHello, QUIC initial, ...) — путь, который резолвится в * `{FAKES}` (`strategies.ts`) и должен суметь прочитать сам движок при работе, не только - * при запуске. На Linux это стейджинговая копия (`stagedFakesDir()`) — см. её комментарий - * про FUSE; на остальных платформах движок и так работает под тем же uid, что видит сборку. + * при запуске. На Linux это стейджинговая копия (`stagedFakesDir()`, см. её комментарий про + * FUSE), на macOS — установленная копия под root (`darwinInstalledFakesDir()`, см. её + * комментарий); на Windows движок работает под тем же uid, что видит сборку приложения. */ export function fakesDir(): string { - return process.platform === 'linux' ? stagedFakesDir() : join(zapretRoot(), 'fakes') + if (process.platform === 'linux') return stagedFakesDir() + if (process.platform === 'darwin') return darwinInstalledFakesDir() + return join(zapretRoot(), 'fakes') } /** @@ -152,15 +216,6 @@ export function protectedWinwsPath(): string { return join(serviceDataDir(), 'winws.exe') } -/** - * Список, который zapret пополняет сам (`--hostlist-auto`): при похожей на блокировку - * ошибке домен дописывается сюда и на лету попадает под десинк, без участия пользователя. - * Не входит в `ZapretListId` — это нередактируемый журнал находок, а не список на правку. - */ -export function autoHostlistPath(): string { - return join(userListsDir(), 'auto.txt') -} - /** * Понятная ошибка вместо ENOENT из глубины spawn: без этой проверки пользователь * увидит «spawn ... ENOENT» и не поймёт, что надо запустить `npm run zapret:fetch`. diff --git a/src/main/zapret/proxy.darwin.ts b/src/main/zapret/proxy.darwin.ts index 5a95d1a..6303969 100644 --- a/src/main/zapret/proxy.darwin.ts +++ b/src/main/zapret/proxy.darwin.ts @@ -1,17 +1,24 @@ import { app } from 'electron' import { execFile } from 'node:child_process' -import { readFile, writeFile, rm } from 'node:fs/promises' +import { readFile, rm } from 'node:fs/promises' import { join } from 'node:path' import { promisify } from 'node:util' const run = promisify(execFile) /** - * Домены и адреса, которые не должны идти через прокси. Без этого списка отвалится - * всё локальное: tpws по документации запрещает подключения к тому же хосту, - * на котором работает сам, включая localhost. + * Разовая миграция с версий на `tpws` (≤0.5.4, socks5-прокси уровня TCP-соединений). + * Новый движок (`utunws`, см. `engine.darwin.ts`) обходит блокировки на уровне пакетов + * через pf/utun и системный прокси не использует вовсе — `applySystemProxy()` (когда-то + * прописывала socks в сетевые настройки) больше не нужна и удалена. + * + * Но у пользователей, обновляющихся со старой версии, снимок настроек мог остаться на + * диске: если tpws-приложение завершилось аварийно (`kill -9`, крах системы), оно не + * успевало снять socks-прокси из системных настроек сети — тогда система тычется в + * несуществующий сервер, и после обновления просто нет интернета, пока кто-то не откатит + * прокси руками. `restoreSystemProxy()`/`hasStaleSnapshot()` остаются ровно ради этого + * разового отката в `recoverZapret()` (`zapret/index.ts`), до открытия окна. */ -const BYPASS = ['*.local', '169.254/16', 'localhost', '127.0.0.1', '::1'] interface ServiceProxyState { service: string @@ -35,7 +42,9 @@ function snapshotPath(): string { * `networksetup` требует членства в группе admin. На обычном Mac пользователь в ней * состоит и пароль не спрашивается. Если же включена политика «требовать пароль * администратора для системных настроек», команда падает — тогда повторяем её через - * системный диалог авторизации. + * системный диалог авторизации (та же логика, что и `runPrivileged()` в `elevate.darwin.ts`, + * этот файл написан раньше и его исторически не трогаем — обе копии удаляются вместе с + * этой миграцией, когда версии ≤0.5.4 отойдут в прошлое). */ async function networksetup(args: string[]): Promise { try { @@ -58,48 +67,14 @@ async function networksetup(args: string[]): Promise { } catch (elevationError) { const text = elevationError instanceof Error ? elevationError.message : String(elevationError) if (text.includes('-128')) - throw new Error('Изменение системного прокси отменено.', { cause: elevationError }) - throw new Error(`Не удалось изменить настройки прокси: ${text}`, { cause: elevationError }) + throw new Error('Восстановление системного прокси отменено.', { cause: elevationError }) + throw new Error(`Не удалось восстановить настройки прокси: ${text}`, { + cause: elevationError + }) } } } -/** Сетевые сервисы, кроме выключенных: у тех `networksetup` ставит «*» перед именем. */ -async function activeServices(): Promise { - const stdout = await networksetup(['-listallnetworkservices']) - return stdout - .split('\n') - .slice(1) // первая строка — пояснение про звёздочку - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.startsWith('*')) -} - -async function readServiceState(service: string): Promise { - const stdout = await networksetup(['-getsocksfirewallproxy', service]) - const value = (key: string): string => - new RegExp(`^${key}:\\s*(.*)$`, 'm').exec(stdout)?.[1]?.trim() ?? '' - - const bypassOutput = await networksetup(['-getproxybypassdomains', service]) - // Пустой список macOS печатает фразой «There aren't any bypass domains…», - // а в доменах пробелов не бывает — по этому и отличаем. - const bypass = bypassOutput - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.includes(' ')) - - return { - service, - enabled: value('Enabled').toLowerCase() === 'yes', - server: value('Server'), - port: value('Port'), - bypass - } -} - -async function saveSnapshot(snapshot: ProxySnapshot): Promise { - await writeFile(snapshotPath(), JSON.stringify(snapshot, null, 2), 'utf8') -} - async function loadSnapshot(): Promise { try { return JSON.parse(await readFile(snapshotPath(), 'utf8')) as ProxySnapshot @@ -126,26 +101,6 @@ async function restoreService(state: ServiceProxyState): Promise { await networksetup(['-setproxybypassdomains', state.service, ...bypass]) } -/** Прописать socks-прокси во все активные сетевые сервисы, запомнив прежнее состояние. */ -export async function applySystemProxy(host: string, port: number): Promise { - const services = await activeServices() - if (services.length === 0) throw new Error('Не найдено ни одного активного сетевого сервиса.') - - // Снимок делаем до первой правки и сразу пишем на диск: если приложение - // упадёт посреди применения, следующий запуск сможет всё вернуть. - const snapshot: ProxySnapshot = { - takenAt: new Date().toISOString(), - services: await Promise.all(services.map(readServiceState)) - } - await saveSnapshot(snapshot) - - for (const service of services) { - await networksetup(['-setsocksfirewallproxy', service, host, String(port)]) - await networksetup(['-setproxybypassdomains', service, ...BYPASS]) - await networksetup(['-setsocksfirewallproxystate', service, 'on']) - } -} - /** Вернуть настройки прокси в состояние из снимка. Без снимка ничего не трогаем. */ export async function restoreSystemProxy(): Promise { const snapshot = await loadSnapshot() @@ -159,7 +114,7 @@ export async function restoreSystemProxy(): Promise { } /** - * Снимок остался с прошлого запуска — значит приложение завершилось аварийно, + * Снимок остался с прошлого запуска старой версии — значит tpws завершился аварийно, * не сняв прокси, и система сейчас смотрит в несуществующий socks. Чиним на старте. */ export async function hasStaleSnapshot(): Promise { diff --git a/src/main/zapret/strategies.ts b/src/main/zapret/strategies.ts index 555bdb2..b8d8683 100644 --- a/src/main/zapret/strategies.ts +++ b/src/main/zapret/strategies.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import type { ZapretStrategy } from '../../shared/ipc-contract' -import { autoHostlistPath, fakesDir, userListsDir } from './paths' +import { fakesDir, userListsDir } from './paths' import { WIN32_STRATEGIES } from './strategies.win32.generated' /** @@ -9,166 +9,38 @@ import { WIN32_STRATEGIES } from './strategies.win32.generated' * Универсального набора параметров не существует: что сработает, зависит от DPI * конкретного провайдера. * - * `tpws` (macOS) — прокси уровня TCP-соединений, а не пакетов: умеет только резать/ - * переупорядочивать сегменты (`--split-pos`, `--disorder`, `--oob`, `--tlsrec`), но не - * умеет ни fake-пакетов, ни UDP/QUIC вообще. Профили ниже — свои, под это ограничение. + * `winws` (Windows), `nfqws` (Linux) и `utunws` (macOS) — один и тот же движок zapret на + * уровне пакетов с общим парсером аргументов (winws — сборка `nfq/nfqws.c` под WinDivert + * вместо netfilter, utunws — та же сборка под utun+BPF, см. Flowseal/zapret-mac-discord- + * youtube), поэтому все три платформы делят один набор пресетов — 22 варианта из + * Flowseal/zapret-discord-youtube, перенесённые в `strategies.win32.generated.ts` + * генератором `scripts/parser-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/`. + * `--wf-tcp`/`--wf-udp` из тех же аргументов сам движок понимает только на Windows + * (фильтр WinDivert); на Linux и macOS фильтрацию делает внешний слой — nftables и pf + * соответственно, — а сами порты вырезаются из аргументов и отдаются вызывающей стороне + * отдельно, см. `packetEngineConfig()` ниже, `resources/linux-helper/` и + * `resources/darwin-helper/`. * * Все три движка используют одни и те же списки сайтов (`src/main/zapret/lists.ts`): * `{LISTS}/.txt` — путь резолвится в `resolvePlaceholders()` ниже. */ -interface DarwinStrategyDefinition extends ZapretStrategy { - /** Аргументы tpws после общих `--socks --port=...`; плейсхолдеры ещё не резолвлены. */ - tpws: string[] -} const LISTS_PLACEHOLDER = '{LISTS}' const FAKES_PLACEHOLDER = '{FAKES}' -const AUTO_PLACEHOLDER = '{AUTO}' -/** `--hostlist-auto` заставляет zapret самому копить сюда домены, похожие на заблокированные. */ -const AUTO_HOSTLIST_ARGS = [`--hostlist-auto=${AUTO_PLACEHOLDER}`] - -const DARWIN_DEFINITIONS: DarwinStrategyDefinition[] = [ - { - id: 'split', - name: 'Разрез + переупорядочивание', - description: 'Базовый вариант из поставки zapret: разрез TLS ClientHello по середине SNI.', - platforms: ['darwin'], - tpws: [ - '--filter-tcp=80', - `--hostlist=${LISTS_PLACEHOLDER}/general.txt`, - `--hostlist=${LISTS_PLACEHOLDER}/google.txt`, - `--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`, - '--methodeol', - '--new', - '--filter-tcp=443', - `--hostlist=${LISTS_PLACEHOLDER}/general.txt`, - `--hostlist=${LISTS_PLACEHOLDER}/google.txt`, - `--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`, - ...AUTO_HOSTLIST_ARGS, - '--split-pos=1,midsld', - '--disorder' - ] - }, - { - id: 'multisplit', - name: 'Многократный разрез', - description: 'Больше точек разреза. Помогает, когда DPI склеивает сегменты обратно.', - platforms: ['darwin'], - tpws: [ - '--filter-tcp=80', - `--hostlist=${LISTS_PLACEHOLDER}/general.txt`, - `--hostlist=${LISTS_PLACEHOLDER}/google.txt`, - `--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`, - '--methodeol', - '--new', - '--filter-tcp=443', - `--hostlist=${LISTS_PLACEHOLDER}/general.txt`, - `--hostlist=${LISTS_PLACEHOLDER}/google.txt`, - `--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`, - ...AUTO_HOSTLIST_ARGS, - // --oob здесь не добавляем: tpws отказывается совмещать его с --disorder вне Linux. - '--split-pos=1,midsld,host+1', - '--disorder' - ] - }, - { - id: 'tlsrec', - name: 'Разрез TLS-записи', - description: 'Другой способ разреза (--tlsrec) — стоит попробовать, если «Разрез» не помогает.', - platforms: ['darwin'], - tpws: [ - '--filter-tcp=80', - `--hostlist=${LISTS_PLACEHOLDER}/general.txt`, - `--hostlist=${LISTS_PLACEHOLDER}/google.txt`, - `--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`, - '--methodeol', - '--new', - '--filter-tcp=443', - `--hostlist=${LISTS_PLACEHOLDER}/general.txt`, - `--hostlist=${LISTS_PLACEHOLDER}/google.txt`, - `--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`, - ...AUTO_HOSTLIST_ARGS, - '--split-pos=1', - '--tlsrec=midsld' - ] - }, - { - id: 'oob', - name: 'Разрез с OOB-байтом', - description: 'Внеполосный байт вместо переупорядочивания — третий вариант на выбор.', - platforms: ['darwin'], - tpws: [ - '--filter-tcp=80', - `--hostlist=${LISTS_PLACEHOLDER}/general.txt`, - `--hostlist=${LISTS_PLACEHOLDER}/google.txt`, - `--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`, - '--methodeol', - '--new', - '--filter-tcp=443', - `--hostlist=${LISTS_PLACEHOLDER}/general.txt`, - `--hostlist=${LISTS_PLACEHOLDER}/google.txt`, - `--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`, - ...AUTO_HOSTLIST_ARGS, - '--split-pos=1,midsld', - '--oob' - ] - }, - { - id: 'all-traffic', - name: 'Весь трафик, без списков', - description: - 'Разрез применяется ко всем сайтам, а не только из списка «Обходить». Диагностика.', - platforms: ['darwin'], - tpws: [ - '--filter-tcp=80', - '--methodeol', - '--new', - '--filter-tcp=443', - '--split-pos=1,midsld', - '--disorder' - ] - }, - { - id: 'passthrough', - name: 'Без изменения трафика', - description: - 'Прокси работает, но пакеты не трогает. Нужен, чтобы отделить проблемы сети от стратегии.', - platforms: ['darwin'], - tpws: [] - } -] - -/** - * Стратегия по умолчанию. win32 и linux делят один набор id (оба читают - * `WIN32_STRATEGIES`), поэтому и дефолт у них общий — 'general'; у darwin свой набор. - */ +/** Все три платформы делят один набор id (все читают `WIN32_STRATEGIES`), поэтому и дефолт общий. */ export function defaultStrategyId(platform: NodeJS.Platform): string { - switch (platform) { - case 'win32': - case 'linux': - return 'general' - default: - return 'split' - } + void platform + return 'general' } function resolvePlaceholders(arg: string): string { - return arg - .replaceAll(LISTS_PLACEHOLDER, userListsDir()) - .replaceAll(FAKES_PLACEHOLDER, fakesDir()) - .replaceAll(AUTO_PLACEHOLDER, autoHostlistPath()) + return arg.replaceAll(LISTS_PLACEHOLDER, userListsDir()).replaceAll(FAKES_PLACEHOLDER, fakesDir()) } -/** win32 и linux показывают один и тот же список Flowseal-пресетов — читают его из общего массива. */ -function flowsealStrategies(platform: 'win32' | 'linux'): ZapretStrategy[] { +/** win32, linux и darwin показывают один и тот же список Flowseal-пресетов. */ +function flowsealStrategies(platform: 'win32' | 'linux' | 'darwin'): ZapretStrategy[] { return WIN32_STRATEGIES.map(({ id, name }) => ({ id, name, @@ -180,36 +52,12 @@ function flowsealStrategies(platform: 'win32' | 'linux'): ZapretStrategy[] { /** Пресеты, применимые к текущей платформе. */ export function listStrategies(platform: NodeJS.Platform): ZapretStrategy[] { - if (platform === 'darwin') { - return DARWIN_DEFINITIONS.map(({ id, name, description, platforms }) => ({ - id, - name, - description, - platforms - })) - } + if (platform === 'darwin') return flowsealStrategies('darwin') if (platform === 'win32') return flowsealStrategies('win32') if (platform === 'linux') return flowsealStrategies('linux') return [] } -function findDarwin(strategyId: string): DarwinStrategyDefinition { - const strategy = DARWIN_DEFINITIONS.find((item) => item.id === strategyId) - if (!strategy) throw new Error(`Неизвестная стратегия: ${strategyId}`) - return strategy -} - -/** Полная командная строка tpws в режиме socks5 на localhost. */ -export function tpwsArgs(strategyId: string, port: number): string[] { - return [ - '--socks', - `--port=${port}`, - '--bind-addr=127.0.0.1', - '--maxconn=512', - ...findDarwin(strategyId).tpws.map(resolvePlaceholders) - ] -} - /** Аргументы winws — служба читает их из файла как `winws.exe @strategy.txt`. */ export function winwsArgs(strategyId: string): string[] { const strategy = WIN32_STRATEGIES.find((item) => item.id === strategyId) @@ -283,23 +131,24 @@ export function winwsConfigFile(strategyId: string): EngineConfig { const WF_TCP_PREFIX = '--wf-tcp=' const WF_UDP_PREFIX = '--wf-udp=' -export interface NfqwsConfig extends EngineConfig { - /** Порты для правила nftables `post` (исходящий трафик) — см. `rknboost-helper.sh`. */ +export interface PacketEngineConfig extends EngineConfig { + /** Порты TCP из `--wf-tcp`, формат `80,443,8443` — см. вызывающую сторону за смыслом. */ tcpPorts: string - /** Порты для правил nftables `post`/`pre` (исходящий и ответный UDP). */ + /** Порты UDP из `--wf-udp`, тот же формат. */ udpPorts: string } /** * На Windows `--wf-tcp`/`--wf-udp` — это фильтр самого WinDivert, зашитый в те же аргументы - * winws. На Linux фильтрацию делает nftables снаружи nfqws (см. `rknboost-helper.sh`), а - * сам nfqws эти два аргумента не понимает — вырезаем их и отдаём отдельно вызывающей стороне. + * winws. На Linux и macOS фильтрацию делает внешний слой — nftables (`rknboost-helper.sh`) + * и pf (`resources/darwin-helper/daemon.sh`) соответственно, — а сам движок (nfqws/utunws) + * эти два аргумента не понимает: вырезаем их и отдаём отдельно вызывающей стороне. */ -export function nfqwsConfig(strategyId: string): NfqwsConfig { +export function packetEngineConfig(strategyId: string): PacketEngineConfig { const args = winwsArgs(strategyId) let tcpPorts: string | null = null let udpPorts: string | null = null - const nfqwsArgs: string[] = [] + const engineArgs: string[] = [] for (const arg of args) { if (arg.startsWith(WF_TCP_PREFIX)) { @@ -307,21 +156,21 @@ export function nfqwsConfig(strategyId: string): NfqwsConfig { } else if (arg.startsWith(WF_UDP_PREFIX)) { udpPorts = arg.slice(WF_UDP_PREFIX.length) } else { - nfqwsArgs.push(arg) + engineArgs.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. + // Значение уходит прямо в правило nftables (rknboost-helper.sh) или pf (daemon.sh) без + // дополнительного экранирования — только цифры, запятые и дефисы диапазонов. Если Flowseal + // когда-нибудь передаст что-то ещё (например, `~` — отрицание порта), лучше явная ошибка + // тут, чем нераспознанный текст в командной строке nft/pfctl, которую выполняет root. for (const [flag, ports] of [ ['--wf-tcp', tcpPorts], ['--wf-udp', udpPorts] @@ -331,5 +180,30 @@ export function nfqwsConfig(strategyId: string): NfqwsConfig { } } - return { ...renderConfigFile(strategyId, nfqwsArgs), tcpPorts, udpPorts } + return { ...renderConfigFile(strategyId, engineArgs), tcpPorts, udpPorts } +} + +/** Тонкая обёртка для читаемости на стороне вызова — семантически то же самое. */ +export function nfqwsConfig(strategyId: string): PacketEngineConfig { + return packetEngineConfig(strategyId) +} + +export interface UtunwsConfig extends EngineConfig { + /** Порты TCP в синтаксисе диапазонов pf: `80,443,19294:19344` (везде `:` вместо `-`). */ + tcpPorts: string + /** Порты UDP, тот же синтаксис. */ + udpPorts: string +} + +/** `19294-19344` (формат nftables/WinDivert) → `19294:19344` (формат диапазона в pf). */ +function toPfPortRanges(ports: string): string { + return ports + .split(',') + .map((part) => part.replace('-', ':')) + .join(',') +} + +export function utunwsConfig(strategyId: string): UtunwsConfig { + const { tcpPorts, udpPorts, ...rest } = packetEngineConfig(strategyId) + return { ...rest, tcpPorts: toPfPortRanges(tcpPorts), udpPorts: toPfPortRanges(udpPorts) } } diff --git a/src/main/zapret/strategies.win32.generated.ts b/src/main/zapret/strategies.win32.generated.ts index cac27f3..c790773 100644 --- a/src/main/zapret/strategies.win32.generated.ts +++ b/src/main/zapret/strategies.win32.generated.ts @@ -1,5 +1,5 @@ /** - * СГЕНЕРИРОВАНО `scripts/import-flowseal-strategies.mjs` из + * СГЕНЕРИРОВАНО `scripts/parser-strategies.mjs` из * Flowseal/zapret-discord-youtube 1.10.2 (commit dfd8e613b099676cf2aa7b474ee5923801514dec). * Не редактировать руками — перезапустить генератор и закоммитить результат. * diff --git a/src/main/zapret/utunws.darwin.ts b/src/main/zapret/utunws.darwin.ts new file mode 100644 index 0000000..7484b62 --- /dev/null +++ b/src/main/zapret/utunws.darwin.ts @@ -0,0 +1,172 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { release, tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { runPrivileged } from './elevate.darwin' +import { + bundledFakesDir, + darwinAutostartScriptPath, + darwinInstallScriptPath, + darwinInstalledConfigPath, + darwinPidfilePath, + darwinPlistPath, + darwinStopScriptPath, + utunwsPath +} from './paths' +import { parseStrategyMarker } from './strategies' + +const run = promisify(execFile) + +/** + * utunws собран под `-target …-macos14.0` (см. `scripts/fetch-zapret.mjs`) — на более + * старой системе он либо откажется запускаться, либо упадёт с малопонятной ошибкой + * загрузчика. Darwin major версии X соответствует macOS (X − 9): Darwin 23 = macOS 14 + * (см. `EVERY_RELEASE_DARWIN_VERSION` в документации Apple — Sonoma это первая macOS 14). + */ +const MIN_DARWIN_MAJOR = 23 + +export function assertMacosVersion(): void { + const major = Number.parseInt(release().split('.')[0] ?? '', 10) + if (Number.isFinite(major) && major < MIN_DARWIN_MAJOR) { + throw new Error( + 'Обход блокировок на macOS требует macOS 14 (Sonoma) или новее — движок utunws собран ' + + 'под более новый API ядра и не запустится на этой версии системы.' + ) + } +} + +/** + * `utunws --dry-run @<файл>` — проверяет аргументы фильтра без root: privilege drop и + * открытие utun-интерфейса происходят позже в исполнении, чем разбор аргументов и выход + * (см. `bDry` в `nfq/nfqws.c` форка Flowseal/zapret-mac-discord-youtube). В отличие от + * Linux-`nfqws` здесь не нужен `--qnum` — этот путь специфичен для `#ifdef __linux__`. + */ +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(utunwsPath(), ['--dry-run', `@${configPath}`]) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Стратегия содержит недопустимые параметры utunws: ${message}`, { + cause: error + }) + } finally { + await rm(dir, { recursive: true, force: true }) + } +} + +export interface StartUtunwsOptions { + configBody: string + tcpPorts: string + udpPorts: string + /** Должен ли LaunchDaemon переживать перезагрузку системы без запущенного приложения. */ + autoStart: boolean +} + +/** + * Единственные два места, где приложение просит пароль администратора — включение и + * выключение обхода (см. `runPrivileged()`/`resources/darwin-helper/install.sh`). + */ +export async function startUtunws(options: StartUtunwsOptions): Promise { + const dir = await mkdtemp(join(tmpdir(), 'rknboost-strategy-')) + const configPath = join(dir, 'strategy.cfg') + try { + await writeFile(configPath, options.configBody, 'utf8') + await runPrivileged(darwinInstallScriptPath(), [ + utunwsPath(), + bundledFakesDir(), + configPath, + options.tcpPorts, + options.udpPorts, + options.autoStart ? '1' : '0' + ]) + } finally { + await rm(dir, { recursive: true, force: true }) + } +} + +export async function stopUtunws(): Promise { + await runPrivileged(darwinStopScriptPath(), []) +} + +/** + * Переключает автозапуск LaunchDaemon с системой (RunAtLoad), не трогая уже работающий + * демон и pf-правила — та же безопасность для активного обхода, что у `setServiceAutoStart()` + * на Windows (`sc config start=`, тоже не останавливает работающую службу). + */ +export async function setUtunwsAutoStart(enabled: boolean): Promise { + await runPrivileged(darwinAutostartScriptPath(), [enabled ? '1' : '0']) +} + +export interface UtunwsState { + pid: number | null + strategyId: string | null + /** `RunAtLoad` установленного plist, `null` — если демон не установлен. */ + autoStart: boolean | null +} + +/** На macOS нет `/proc` — `kill(pid, 0)` без реальной отправки сигнала: EPERM тоже «жив». */ +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} + +/** + * Не требует пароля: pidfile и `strategy.cfg` в защищённом каталоге установки помощник + * кладёт с правами, читаемыми без root (см. `install.sh`) — именно затем, чтобы это чтение + * и восстановление состояния в `DarwinEngine.sync()` обходились без диалога авторизации. + */ +export async function readUtunwsState(): Promise { + let pid: number | null = null + try { + const raw = (await readFile(darwinPidfilePath(), 'utf8')).trim() + const parsed = Number.parseInt(raw, 10) + if (Number.isInteger(parsed) && parsed > 0 && isProcessAlive(parsed)) { + pid = parsed + } + } catch { + // pidfile нет или процесс из него уже не живёт — считаем демон не запущенным. + } + + let strategyId: string | null = null + try { + const content = await readFile(darwinInstalledConfigPath(), 'utf8') + strategyId = parseStrategyMarker(content.split('\n')[0]?.trim() ?? null) + } catch { + // strategy.cfg нет — не страшно, id останется null. + } + + const autoStart = await readInstalledAutoStart() + + return { pid, strategyId, autoStart } +} + +/** + * `RunAtLoad` уже установленного LaunchDaemon — читается без root (plist в + * `/Library/LaunchDaemons` мировидим, 0644, как и любой другой системный plist). Источник + * истины для `DarwinEngine.sync()`: реальное состояние демона важнее сохранённого + * приложением выбора, тот же принцип, что `Win32Engine.sync()` берёт `info.startMode` + * у самой службы, а не у `settings.json`. + */ +async function readInstalledAutoStart(): Promise { + try { + const { stdout } = await run('/usr/bin/plutil', [ + '-extract', + 'RunAtLoad', + 'raw', + '-o', + '-', + darwinPlistPath() + ]) + return stdout.trim() === 'true' + } catch { + return null + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index d797a26..0e85854 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -57,8 +57,6 @@ const api = { getZapretStrategies: (): Promise => invoke('zapret:strategies'), startZapret: (strategyId: string): Promise => invoke('zapret:start', strategyId), stopZapret: (): Promise => invoke('zapret:stop'), - setZapretSystemProxy: (enabled: boolean): Promise => - invoke('zapret:set-system-proxy', enabled), setZapretStrategy: (strategyId: string): Promise => invoke('zapret:set-strategy', strategyId), setZapretAutoStart: (enabled: boolean): Promise => @@ -72,8 +70,6 @@ const api = { saveZapretList: (id: ZapretListId, entries: string[]): Promise => invoke('zapret:list-save', id, entries), resetZapretList: (id: ZapretListId): Promise => invoke('zapret:list-reset', id), - getZapretAutoHostlist: (): Promise => invoke('zapret:auto-hostlist'), - clearZapretAutoHostlist: (): Promise => invoke('zapret:auto-hostlist-clear'), /** Запускает проверку и отдаёт список сайтов; результаты — через `onZapretDiagnosticResult`. */ runZapretDiagnostics: (): Promise => invoke('zapret:diagnose'), diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index 7988995..bb8efe6 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -7,12 +7,13 @@ import { LIST_TITLES } from '../lib/zapretListMeta' import ListEditor from './ListEditor' /** - * Настройки: списки сайтов + автозапуск (Windows). Выбор стратегии и проверка + * Настройки: списки сайтов + автозапуск (macOS/Windows). Выбор стратегии и проверка * соединения переехали на главный экран (ZapretCard.tsx) — рядом с кнопкой * включения, где ими и пользуются. Отдельный экран за шестерёнкой в шапке. */ const isWindows = window.api.platform === 'win32' const isDarwin = window.api.platform === 'darwin' +const supportsAutoStart = isWindows || isDarwin const APP_CHANNEL = 'beta' export default function SettingsView({ @@ -24,7 +25,7 @@ export default function SettingsView({ status: ZapretStatus | null onSetAutoStart: (enabled: boolean) => Promise }): React.JSX.Element { - const { lists, autoHostlist, save, reset, clearAuto } = useZapretLists() + const { lists, save, reset } = useZapretLists() const [openListId, setOpenListId] = useState(null) const appVersion = useAppVersion() @@ -53,7 +54,7 @@ export default function SettingsView({

Настройки

- {isWindows && ( + {supportsAutoStart && (
Автозапуск

- Меняется через запрос прав администратора. Если выключить обход кнопкой на главном - экране, при включённом автозапуске он вернётся не раньше следующей перезагрузки. + {isWindows + ? 'Меняется через запрос прав администратора. Если выключить обход кнопкой на ' + + 'главном экране, при включённом автозапуске он вернётся не раньше следующей ' + + 'перезагрузки.' + : 'Меняется через пароль администратора. Действует, пока обход включён: ' + + 'выключение кнопкой на главном экране полностью снимает установку — ' + + 'автозапуск снова понадобится включить вместе с обходом.'}

)} @@ -87,26 +93,6 @@ export default function SettingsView({ {list.entries.length} › ))} - - {/* --hostlist-auto есть только в darwin-профилях (см. strategies.ts) — ни одна из 22 - стратегий Flowseal (Windows и Linux) его не использует, счётчик там всегда пуст. */} - {isDarwin && ( -
- Найдено автоматически - - {autoHostlist.length} - {autoHostlist.length > 0 && ( - - )} - -
- )}
diff --git a/src/renderer/src/hooks/useZapret.ts b/src/renderer/src/hooks/useZapret.ts index f6f5cd5..dab8bd9 100644 --- a/src/renderer/src/hooks/useZapret.ts +++ b/src/renderer/src/hooks/useZapret.ts @@ -14,7 +14,6 @@ export function useZapret(): { log: ZapretLogLine[] start: (strategyId: string) => Promise stop: () => Promise - setSystemProxy: (enabled: boolean) => Promise setStrategy: (strategyId: string) => Promise setAutoStart: (enabled: boolean) => Promise busy: boolean @@ -54,10 +53,6 @@ export function useZapret(): { } }, []) - const setSystemProxy = useCallback(async (enabled: boolean) => { - setStatus(await window.api.setZapretSystemProxy(enabled)) - }, []) - const setStrategy = useCallback(async (strategyId: string) => { setBusy(true) try { @@ -76,5 +71,5 @@ export function useZapret(): { } }, []) - return { status, strategies, log, start, stop, setSystemProxy, setStrategy, setAutoStart, busy } + return { status, strategies, log, start, stop, setStrategy, setAutoStart, busy } } diff --git a/src/renderer/src/hooks/useZapretLists.ts b/src/renderer/src/hooks/useZapretLists.ts index ea91864..be82cca 100644 --- a/src/renderer/src/hooks/useZapretLists.ts +++ b/src/renderer/src/hooks/useZapretLists.ts @@ -2,29 +2,22 @@ import { useCallback, useEffect, useState } from 'react' import type { ZapretList, ZapretListId } from '@shared/ipc-contract' /** - * Списки сайтов + журнал автообнаружения, в синхроне с main-процессом. - * Тот же паттерн, что и `useZapret`/`useTheme`. + * Списки сайтов, в синхроне с main-процессом. Тот же паттерн, что и `useZapret`/`useTheme`. */ export function useZapretLists(): { lists: ZapretList[] - autoHostlist: string[] save: (id: ZapretListId, entries: string[]) => Promise reset: (id: ZapretListId) => Promise - clearAuto: () => Promise loading: boolean } { const [lists, setLists] = useState([]) - const [autoHostlist, setAutoHostlist] = useState([]) const [loading, setLoading] = useState(true) useEffect(() => { - void Promise.all([window.api.getZapretLists(), window.api.getZapretAutoHostlist()]).then( - ([nextLists, nextAuto]) => { - setLists(nextLists) - setAutoHostlist(nextAuto) - setLoading(false) - } - ) + void window.api.getZapretLists().then((nextLists) => { + setLists(nextLists) + setLoading(false) + }) }, []) const save = useCallback(async (id: ZapretListId, entries: string[]) => { @@ -39,10 +32,5 @@ export function useZapretLists(): { return updated }, []) - const clearAuto = useCallback(async () => { - await window.api.clearZapretAutoHostlist() - setAutoHostlist([]) - }, []) - - return { lists, autoHostlist, save, reset, clearAuto, loading } + return { lists, save, reset, loading } } diff --git a/src/shared/ipc-contract.ts b/src/shared/ipc-contract.ts index a986e1f..1e01dfd 100644 --- a/src/shared/ipc-contract.ts +++ b/src/shared/ipc-contract.ts @@ -23,14 +23,16 @@ export interface ThemeState { export type ZapretState = 'stopped' | 'starting' | 'running' | 'stopping' | 'error' /** - * Чем именно обходим на этой платформе: - * • `tpws-socks` — macOS: локальный socks5-прокси, права не нужны; + * Чем именно обходим на этой платформе — на всех трёх один и тот же движок уровня пакетов + * (форки zapret/nfqws), запросы прав отличаются: + * • `utunws-pf` — macOS: LaunchDaemon (`utunws` через utun+BPF), пароль администратора + * на каждое включение/выключение (`osascript`); * • `winws-service` — Windows: служба с драйвером WinDivert; * • `nfqws-nftables` — Linux: демон nfqws + правила nftables (NFQUEUE), root через pkexec - * на каждое включение — ни службы, ни автозапуска здесь нет; + * на каждое включение; * • `unsupported` — платформа без поддержки (не macOS/Windows/Linux). */ -export type ZapretBackend = 'tpws-socks' | 'winws-service' | 'nfqws-nftables' | 'unsupported' +export type ZapretBackend = 'utunws-pf' | 'winws-service' | 'nfqws-nftables' | 'unsupported' export interface ZapretStatus { state: ZapretState @@ -41,13 +43,9 @@ export interface ZapretStatus { startedAt: string | null /** Текст последней ошибки — показываем пользователю как есть. */ error: string | null - /** macOS: адрес socks5-прокси, пока обход работает. На Linux/Windows всегда null. */ - socksAddress: string | null - /** macOS: прописан ли прокси в системных настройках сети. На Linux/Windows не используется. */ - systemProxyApplied: boolean /** Windows: установлена ли служба (её ставит и настраивает само приложение при первом включении). */ serviceInstalled: boolean - /** Windows: стоит ли служба на автозапуск с системой (`sc config start=`). На Linux автозапуска нет. */ + /** macOS/Windows: автозапуск с системой без запущенного приложения. На Linux не используется. */ autoStart: boolean } @@ -118,23 +116,19 @@ export interface IpcHandlers { 'zapret:strategies': () => ZapretStrategy[] 'zapret:start': (strategyId: string) => ZapretStatus 'zapret:stop': () => ZapretStatus - /** macOS: прописать/убрать socks-прокси в системных настройках сети. */ - 'zapret:set-system-proxy': (enabled: boolean) => ZapretStatus /** * Выбор пресета. Если обход выключен — просто запоминается. Если включён — на macOS - * перезапускает tpws, на Windows перенастраивает службу (потребуется UAC), на Linux - * перезапускает nfqws и правила nftables (потребуется пароль через pkexec). + * переустанавливает LaunchDaemon с utunws (потребуется пароль администратора), на Windows + * перенастраивает службу (потребуется UAC), на Linux перезапускает nfqws и правила + * nftables (потребуется пароль через pkexec). */ 'zapret:set-strategy': (strategyId: string) => ZapretStatus - /** Windows: автозапуск службы с системой (`sc config start=`); требует UAC. */ + /** macOS/Windows: автозапуск с системой; требует пароль администратора/UAC. */ 'zapret:set-autostart': (enabled: boolean) => ZapretStatus 'zapret:lists': () => ZapretList[] 'zapret:list-save': (id: ZapretListId, entries: string[]) => ZapretList 'zapret:list-reset': (id: ZapretListId) => ZapretList - /** Список, который zapret пополняет сам через --hostlist-auto (только macOS-профили). */ - 'zapret:auto-hostlist': () => string[] - 'zapret:auto-hostlist-clear': () => void /** * Запустить проверку соединения и сразу вернуть список проверяемых сайтов — сами