Версия 0.5.3
CI / Линт и типы (push) Canceled after 0s
CI / Сборка (Windows) (push) Canceled after 0s
CI / Сборка (Linux) (push) Canceled after 0s

Добавлена сборка под Linux: nfqws + правила nftables (NFQUEUE) вместо
socks-прокси или службы Windows. Повышение прав — pkexec на каждое
включение/выключение, без службы и без автозапуска.

Стратегии переиспользуют существующий набор Flowseal (strategies.win32.generated.ts):
--wf-tcp/--wf-udp уходят в правила nftables, остальное — в nfqws как есть.
Все правила nftables держат bypass, поэтому упавший или выключенный демон
не рвёт пользователю сеть.

Помощник (resources/linux-helper/rknboost-helper.sh) и бинарник nfqws перед
каждым запуском копируются в userData: root не может прочитать файлы внутри
AppImage (FUSE-примонтирован без allow_other), а обычная файловая система
на диске ему доступна независимо от прав.

Заодно:
- починен баг генератора стратегий (^! не разэкранировался в !, ломало
  fake-tls-auto и на Windows тоже);
- карточка обхода теперь показывает текст ошибки независимо от state;
- счётчик auto-hostlist в настройках больше не привязан к !isWindows;
- CI: отдельная джоба сборки под Linux на ubuntu-latest.
This commit is contained in:
2026-09-04 10:26:00 +03:00
parent 0842c4d4a5
commit 2df1c00516
20 changed files with 956 additions and 78 deletions
+43
View File
@@ -0,0 +1,43 @@
import { access, constants } from 'node:fs/promises'
/**
* `pkexec` из состава polkit — им поднимаем root для nftables/nfqws. Проверяем по конкретным
* путям, а не через `PATH`: тот же мотив, что у `find_nft()` в `rknboost-helper.sh` — санитайз
* окружения инструментами вроде pkexec/env-с-урезанным-PATH встречается достаточно часто,
* чтобы не полагаться на переменную окружения при поиске самого пути к элевации.
*/
const PKEXEC_CANDIDATES = ['/usr/bin/pkexec', '/bin/pkexec']
/** `nft` — тот же список путей, что перебирает `find_nft()` в помощнике (держим в синхроне). */
const NFT_CANDIDATES = ['/usr/sbin/nft', '/sbin/nft', '/usr/local/sbin/nft', '/usr/bin/nft']
async function findExecutable(candidates: readonly string[]): Promise<string | null> {
for (const candidate of candidates) {
try {
await access(candidate, constants.X_OK)
return candidate
} catch {
// пробуем следующий путь
}
}
return null
}
/**
* Проверка окружения до первого запроса пароля — тот же мотив, что у `assertExecutable()`
* в `paths.ts`: без неё пользователь увидел бы «spawn pkexec ENOENT» или пустой отказ nft
* внутри чужого stderr и не понял бы, что установить.
*/
export async function assertLinuxDependencies(): Promise<void> {
if (!(await findExecutable(PKEXEC_CANDIDATES))) {
throw new Error(
'Не найден pkexec — установите пакет polkit ' +
'(Debian/Ubuntu: «sudo apt install policykit-1»).'
)
}
if (!(await findExecutable(NFT_CANDIDATES))) {
throw new Error(
'Не найдена утилита nft — установите пакет nftables (Debian/Ubuntu: «sudo apt install nftables»).'
)
}
}
+123
View File
@@ -0,0 +1,123 @@
import type { ZapretStatus } from '../../shared/ipc-contract'
import { assertLinuxDependencies } from './deps.linux'
import { ZapretEngine } from './engine'
import { dryRun, isProcessAlive, readNfqwsState, startNfqws, stopNfqws } from './nfqws.linux'
import { defaultStrategyId, nfqwsConfig } from './strategies'
/** Как часто опрашивать /proc — демон нам не дочерний процесс, экзит-событие поймать некому. */
const WATCH_INTERVAL_MS = 3000
/**
* Linux: nfqws (движок zapret на уровне пакетов, как и Windows-версия) + правила nftables
* NFQUEUE. Ни служба, ни автозапуск не заведены (см. README.md) — каждое включение просит
* пароль через `pkexec` (см. `nfqws.linux.ts`), а сам демон и его правила переживают выход
* из приложения: `bypass` на очереди означает, что их отсутствие не рвёт пользователю сеть
* (см. `rknboost-helper.sh`), поэтому `dispose()` намеренно пуст — как и на Windows.
*/
export class LinuxEngine extends ZapretEngine {
private watchTimer: ReturnType<typeof setInterval> | null = null
constructor() {
super('nfqws-nftables', defaultStrategyId('linux'))
}
/**
* Вызывается из `recoverZapret()` до открытия окна — как и `Win32Engine.sync()`, подхватывает
* состояние, оставшееся от предыдущего запуска приложения (демон root-процессом переживает
* закрытие приложения, автозапуска с системой при этом нет — состояние может быть только
* «работает с прошлой сессии» или «выключен»).
*/
async sync(): Promise<ZapretStatus> {
const { pid, strategyId } = await readNfqwsState()
if (pid === null) {
this.stopWatch()
return this.patch({ state: 'stopped', startedAt: null, error: null })
}
this.startWatch(pid)
return this.patch({
state: 'running',
strategyId: strategyId ?? this.status.strategyId,
// Момент реального запуска нам не известен — демон мог подняться в прошлой сессии
// приложения. Тот же компромисс, что и в Win32Engine.sync().
startedAt: new Date().toISOString(),
error: null
})
}
async start(strategyId: string): Promise<ZapretStatus> {
this.patch({ state: 'starting', strategyId, error: null })
try {
// Всё, что можно проверить без пароля, проверяем в этом порядке: сначала формат
// самой стратегии, потом окружение, потом сами аргументы через --dry-run — тот же
// принцип, что и у Win32Engine.start(), только тут это не опционально, а единственный
// шанс не побеспокоить пользователя запросом root ради заведомо неверных данных.
const config = nfqwsConfig(strategyId)
await assertLinuxDependencies()
await dryRun(config.body)
await startNfqws({
configBody: config.body,
tcpPorts: config.tcpPorts,
udpPorts: config.udpPorts
})
const { pid } = await readNfqwsState()
if (pid !== null) this.startWatch(pid)
return this.patch({ state: 'running', startedAt: new Date().toISOString(), error: null })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
this.appendLog('error', message)
return this.patch({ state: 'error', error: message, startedAt: null })
}
}
async stop(): Promise<ZapretStatus> {
this.patch({ state: 'stopping' })
this.stopWatch()
try {
await stopNfqws()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
this.appendLog('error', message)
return this.patch({ state: 'error', error: message })
}
return this.patch({ state: 'stopped', startedAt: null, error: null })
}
async dispose(): Promise<void> {
// Демон и правила nftables живут своей жизнью в системе — выход приложения их не
// останавливает (см. комментарий класса). Опрос /proc всё равно больше не нужен.
this.stopWatch()
}
/**
* Демон нам не дочерний процесс (владеет им root, а не Electron) — событие завершения
* поймать некому, поэтому опрашиваем `/proc/<pid>` по таймеру. `bypass` в правилах
* nftables делает мёртвый демон безопасным для сети, но не для интерфейса: без этого
* опроса карточка продолжала бы показывать «Активен» после падения nfqws.
*/
private startWatch(pid: number): void {
this.stopWatch()
this.watchTimer = setInterval(() => {
void isProcessAlive(pid).then((alive) => {
if (alive || this.status.state !== 'running') return
this.stopWatch()
this.appendLog('error', `nfqws (pid ${pid}) больше не запущен`)
this.patch({
state: 'error',
error: 'Процесс nfqws неожиданно завершился.',
startedAt: null
})
})
}, WATCH_INTERVAL_MS)
}
private stopWatch(): void {
if (this.watchTimer) clearInterval(this.watchTimer)
this.watchTimer = null
}
}
+3 -3
View File
@@ -116,15 +116,15 @@ export abstract class ZapretEngine {
}
}
/** Заглушка для платформ, под которые мы бинарники не собираем (Linux). */
/** Заглушка для платформ, под которые мы бинарники не собираем (FreeBSD и прочее — не macOS/Windows/Linux). */
export class UnsupportedEngine extends ZapretEngine {
constructor() {
super('unsupported', '')
this.status.error = 'Обход блокировок доступен только на macOS и Windows.'
this.status.error = 'Обход блокировок доступен только на macOS, Windows и Linux.'
}
async start(): Promise<ZapretStatus> {
throw new Error('Обход блокировок доступен только на macOS и Windows.')
throw new Error('Обход блокировок доступен только на macOS, Windows и Linux.')
}
async stop(): Promise<ZapretStatus> {
+9 -5
View File
@@ -21,7 +21,12 @@ import {
stopService,
type ServiceInfo
} from './service.win32'
import { defaultStrategyId, winwsConfigFile, type WinwsConfig } from './strategies'
import {
defaultStrategyId,
type EngineConfig,
parseStrategyMarker,
winwsConfigFile
} from './strategies'
const run = promisify(execFile)
@@ -72,7 +77,7 @@ async function binariesNeedCopy(): Promise<boolean> {
* winws.exe отстала от оригинала в папке приложения, или если аргументы стратегии
* изменились (маркер в `strategy.cfg` не совпадает с тем, что сгенерировали сейчас).
*/
async function needsReconfigure(config: WinwsConfig, info: ServiceInfo | null): Promise<boolean> {
async function needsReconfigure(config: EngineConfig, info: ServiceInfo | null): Promise<boolean> {
if (!info) return true
if (await binariesNeedCopy()) return true
return (await readInstalledMarkerLine()) !== config.marker
@@ -107,8 +112,7 @@ export class Win32Engine extends ZapretEngine {
}
const installedLine = await readInstalledMarkerLine()
const match = installedLine ? /^--comment=rknboost:([^:]+):/.exec(installedLine) : null
const strategyId = match?.[1] ?? this.status.strategyId
const strategyId = parseStrategyMarker(installedLine) ?? this.status.strategyId
return this.patch({
serviceInstalled: true,
@@ -125,7 +129,7 @@ export class Win32Engine extends ZapretEngine {
async start(strategyId: string): Promise<ZapretStatus> {
this.patch({ state: 'starting', strategyId, error: null })
let config: WinwsConfig
let config: EngineConfig
try {
config = winwsConfigFile(strategyId)
} catch (error) {
+7 -2
View File
@@ -12,6 +12,7 @@ import { loadSettings } from '../settings'
import { DIAGNOSTIC_TARGETS, runDiagnostics } from './diagnostics'
import { DarwinEngine } from './engine.darwin'
import { ZapretEngine, UnsupportedEngine } from './engine'
import { LinuxEngine } from './engine.linux'
import { Win32Engine } from './engine.win32'
import { readAllLists, resetList, seedLists, writeList } from './lists'
import { autoHostlistPath } from './paths'
@@ -23,6 +24,8 @@ function createEngine(): ZapretEngine {
return new DarwinEngine()
case 'win32':
return new Win32Engine()
case 'linux':
return new LinuxEngine()
default:
return new UnsupportedEngine()
}
@@ -33,8 +36,9 @@ const engine = createEngine()
/**
* Готовим списки сайтов, возвращаем выбор пользователя из прошлого запуска и синхронизируем
* состояние с системой — на macOS чиним прокси от прошлого аварийного завершения, на Windows
* подхватываем службу, которая могла работать ещё до открытия окна (автозапуск с системой).
* Все шаги нужно сделать до открытия окна.
* подхватываем службу, которая могла работать ещё до открытия окна (автозапуск с системой),
* на Linux — демон nfqws, переживший закрытие приложения (автозапуска у него нет, но сам
* процесс и правила nftables продолжают работать). Все шаги нужно сделать до открытия окна.
*/
export async function recoverZapret(): Promise<void> {
await seedLists()
@@ -52,6 +56,7 @@ export async function recoverZapret(): Promise<void> {
if (engine instanceof DarwinEngine) await engine.recoverFromCrash()
if (engine instanceof Win32Engine) await engine.sync()
if (engine instanceof LinuxEngine) await engine.sync()
}
function knownStrategyId(strategyId: string | null): string | null {
+214
View File
@@ -0,0 +1,214 @@
import { execFile, spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
access,
chmod,
constants,
copyFile,
mkdir,
mkdtemp,
readdir,
readFile,
rm,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import {
bundledFakesDir,
bundledHelperPath,
linuxInstalledConfigPath,
linuxPidfilePath,
linuxStagingDir,
nfqwsPath,
stagedFakesDir,
stagedHelperPath,
stagedNfqwsPath
} from './paths'
import { parseStrategyMarker } from './strategies'
const run = promisify(execFile)
async function sha256(path: string): Promise<string> {
return createHash('sha256')
.update(await readFile(path))
.digest('hex')
}
async function filesIdentical(a: string, b: string): Promise<boolean> {
try {
const [hashA, hashB] = await Promise.all([sha256(a), sha256(b)])
return hashA === hashB
} catch {
// Копии ещё нет (первый запуск) или она нечитаема — надёжнее перекопировать.
return false
}
}
/**
* Копирует nfqws, помощника и fake-пакеты из `resources/zapret`/`resources/linux-helper`
* в `linuxStagingDir()` — см. её комментарий в paths.ts про то, почему это не оптимизация,
* а необходимость под AppImage. Сравниваем содержимое, а не mtime — тот же мотив, что у
* `binariesNeedCopy()` в engine.win32.ts: `copyFile` всегда ставит текущее время, хотя
* зафиксированная версия zapret даёт одни и те же байты между сборками.
*/
export async function stageRuntime(): Promise<void> {
await mkdir(linuxStagingDir(), { recursive: true })
await mkdir(stagedFakesDir(), { recursive: true })
if (!(await filesIdentical(nfqwsPath(), stagedNfqwsPath()))) {
await copyFile(nfqwsPath(), stagedNfqwsPath())
}
await chmod(stagedNfqwsPath(), 0o755)
if (!(await filesIdentical(bundledHelperPath(), stagedHelperPath()))) {
await copyFile(bundledHelperPath(), stagedHelperPath())
}
await chmod(stagedHelperPath(), 0o755)
const fakesSrcDir = bundledFakesDir()
for (const name of await readdir(fakesSrcDir)) {
const src = join(fakesSrcDir, name)
const dst = join(stagedFakesDir(), name)
if (!(await filesIdentical(src, dst))) {
await copyFile(src, dst)
}
}
}
/**
* `nfqws --dry-run @<файл>` — проверяет только аргументы фильтра, root не нужен (в отличие
* от рабочего запуска демона). Запускаем бинарник из сборки напрямую: dry-run идёт от uid
* самого приложения, а не root, так что FUSE-примонтированный AppImage тут не помеха —
* ограничение касается только `pkexec` (см. linuxStagingDir()). Дословный аналог
* `Win32Engine['dryRun']`.
*/
export async function dryRun(configBody: string): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), 'rknboost-dryrun-'))
const configPath = join(dir, 'dry-run.cfg')
try {
await writeFile(configPath, configBody, 'utf8')
await run(nfqwsPath(), ['--dry-run', `@${configPath}`])
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw new Error(`Стратегия содержит недопустимые параметры nfqws: ${message}`, {
cause: error
})
} finally {
await rm(dir, { recursive: true, force: true })
}
}
/** Отрендеренный конфиг лежит здесь до pkexec — сам помощник, уже под root, копирует его в /run. */
async function writeStagedConfig(body: string): Promise<string> {
const path = join(linuxStagingDir(), 'pending-strategy.cfg')
await writeFile(path, body, 'utf8')
return path
}
/** Хвост stderr помощника — тот же лимит и мотив, что у `stderrTail` в engine.darwin.ts. */
const STDERR_TAIL_LIMIT = 4000
function runHelper(args: string[]): Promise<{ code: number; stderr: string }> {
return new Promise((resolveHelper) => {
const child = spawn('pkexec', [stagedHelperPath(), ...args], {
stdio: ['ignore', 'ignore', 'pipe']
})
let stderr = ''
child.stderr.on('data', (chunk: Buffer) => {
stderr = (stderr + chunk.toString('utf8')).slice(-STDERR_TAIL_LIMIT)
})
// pkexec не найден — spawn сообщает об этом событием 'error', а не кодом выхода
// (см. launchUninstaller() в uninstall.win32.ts, та же причина).
child.on('error', (error) => resolveHelper({ code: 127, stderr: error.message }))
child.on('close', (code) => resolveHelper({ code: code ?? 1, stderr }))
})
}
export interface StartNfqwsOptions {
configBody: string
tcpPorts: string
udpPorts: string
}
/** Единственные два места, где приложение просит root — включение и выключение обхода. */
export async function startNfqws(options: StartNfqwsOptions): Promise<void> {
await stageRuntime()
const configPath = await writeStagedConfig(options.configBody)
const { code, stderr } = await runHelper([
'start',
stagedNfqwsPath(),
configPath,
options.tcpPorts,
options.udpPorts
])
if (code !== 0) {
throw new Error(
`Не удалось запустить обход (pkexec, код ${code})` +
(stderr ? `: ${stderr}` : ' — запрос прав администратора отклонён или прерван.')
)
}
}
export async function stopNfqws(): Promise<void> {
// Помощник может понадобиться, даже если start() в эту сессию ещё не звали — например,
// sync() увидел уже работающий с прошлого сеанса демон, и первое действие пользователя
// в новом запуске приложения — «Выключить».
await stageRuntime()
const { code, stderr } = await runHelper(['stop'])
if (code !== 0) {
throw new Error(
`Не удалось остановить обход (pkexec, код ${code})` +
(stderr ? `: ${stderr}` : ' — запрос прав администратора отклонён или прерван.')
)
}
}
export interface NfqwsState {
pid: number | null
strategyId: string | null
}
/** `/proc/<pid>` — существование каталога о процессе видно без прав, сигнал слать не нужно. */
export async function isProcessAlive(pid: number): Promise<boolean> {
try {
await access(`/proc/${pid}`, constants.F_OK)
return true
} catch {
return false
}
}
/**
* Не требует root: `nfqws.pid` и `strategy.cfg` в `/run/rknboost` помощник кладёт с правами
* 0644 (см. rknboost-helper.sh) именно затем, чтобы это чтение — и восстановление состояния
* в `LinuxEngine.sync()` — обходилось без пароля.
*/
export async function readNfqwsState(): Promise<NfqwsState> {
let pid: number | null = null
try {
const raw = (await readFile(linuxPidfilePath(), 'utf8')).trim()
const parsed = Number.parseInt(raw, 10)
if (Number.isInteger(parsed) && parsed > 0 && (await isProcessAlive(parsed))) {
pid = parsed
}
} catch {
// pidfile нет или процесс из него уже не живёт — считаем демон не запущенным.
}
let strategyId: string | null = null
try {
const content = await readFile(linuxInstalledConfigPath(), 'utf8')
strategyId = parseStrategyMarker(content.split('\n')[0]?.trim() ?? null)
} catch {
// strategy.cfg нет — не страшно, id останется null.
}
return { pid, strategyId }
}
+72 -2
View File
@@ -23,6 +23,71 @@ export function winwsPath(): string {
return join(zapretRoot(), 'win32', 'x64', 'winws.exe')
}
/** Linux: nfqws в составе сборки — для `--dry-run` (без root, читает свой же uid). */
export function nfqwsPath(): string {
return join(zapretRoot(), 'linux', 'x64', 'nfqws')
}
/**
* Linux: привилегированный помощник в составе сборки (`resources/linux-helper/`, отдельный
* `extraResources` в electron-builder.yml — не качается `fetch-zapret.mjs`, лежит в репозитории).
*/
export function bundledHelperPath(): string {
return app.isPackaged
? join(process.resourcesPath, 'linux-helper', 'rknboost-helper.sh')
: resolve(app.getAppPath(), 'resources', 'linux-helper', 'rknboost-helper.sh')
}
/**
* Linux: каталог, куда `nfqws`, помощник и fake-пакеты копируются перед КАЖДЫМ запуском
* (см. `nfqws.linux.ts`). Не оптимизация — необходимость: в AppImage `resources/zapret`
* примонтирован через FUSE без `allow_other`, и root (а `pkexec` поднимает именно root)
* туда просто не достучится — ни исполнить бинарник, ни прочитать fake-пакет во время
* работы. `userData` — обычная файловая система на диске; root видит любой файл на ней
* независимо от прав (DAC_OVERRIDE), так что копия здесь читается без проблем что для
* AppImage, что для .deb (там `resources/zapret` и так root-owned, копия просто лишняя,
* но не вредная — один код-путь на обе цели дешевле двух).
*/
export function linuxStagingDir(): string {
return join(app.getPath('userData'), 'linux-runtime')
}
export function stagedNfqwsPath(): string {
return join(linuxStagingDir(), 'nfqws')
}
export function stagedHelperPath(): string {
return join(linuxStagingDir(), 'rknboost-helper.sh')
}
/** Стейджинговая копия `fakesDir()` — то, что реально читает nfqws (см. её комментарий). */
export function stagedFakesDir(): string {
return join(linuxStagingDir(), 'fakes')
}
/** Встроенные fake-пакеты — источник для копирования в `stagedFakesDir()`. */
export function bundledFakesDir(): string {
return join(zapretRoot(), 'fakes')
}
/**
* Рабочий каталог демона — pidfile и `strategy.cfg` (последний — источник маркера стратегии
* для `LinuxEngine.sync()`, читается без root: помощник кладёт его с правами 0644). Создаёт
* и чистит сам `rknboost-helper.sh`. `/run` — стандартный FHS-путь для данных, не переживающих
* перезагрузку: nfqws её тоже не переживает, автозапуска на Linux нет (см. README.md).
*/
export function linuxRuntimeDir(): string {
return '/run/rknboost'
}
export function linuxPidfilePath(): string {
return join(linuxRuntimeDir(), 'nfqws.pid')
}
export function linuxInstalledConfigPath(): string {
return join(linuxRuntimeDir(), 'strategy.cfg')
}
/** Встроенные списки (из Flowseal/zapret-discord-youtube, см. scripts/fetch-zapret.mjs) — только для чтения. */
export function bundledListsDir(): string {
return join(zapretRoot(), 'lists')
@@ -37,9 +102,14 @@ export function userListsDir(): string {
return join(app.getPath('userData'), 'zapret-lists')
}
/** Fake-пакеты (TLS ClientHello, QUIC initial, ...) для стратегий winws. */
/**
* Fake-пакеты (TLS ClientHello, QUIC initial, ...) — путь, который резолвится в
* `{FAKES}` (`strategies.ts`) и должен суметь прочитать сам движок при работе, не только
* при запуске. На Linux это стейджинговая копия (`stagedFakesDir()`) — см. её комментарий
* про FUSE; на остальных платформах движок и так работает под тем же uid, что видит сборку.
*/
export function fakesDir(): string {
return join(zapretRoot(), 'fakes')
return process.platform === 'linux' ? stagedFakesDir() : join(zapretRoot(), 'fakes')
}
/**
+117 -29
View File
@@ -13,11 +13,14 @@ import { WIN32_STRATEGIES } from './strategies.win32.generated'
* переупорядочивать сегменты (`--split-pos`, `--disorder`, `--oob`, `--tlsrec`), но не
* умеет ни fake-пакетов, ни UDP/QUIC вообще. Профили ниже — свои, под это ограничение.
*
* `winws` (Windows) работает на уровне пакетов через WinDivert. Его пресеты — 22 варианта
* из Flowseal/zapret-discord-youtube, перенесённые в `strategies.win32.generated.ts`
* генератором `scripts/import-flowseal-strategies.mjs` (не батниками — кодом).
* `winws` (Windows) и `nfqws` (Linux) — один и тот же движок zapret на уровне пакетов
* (winws — сборка nfq/nfqws.c под WinDivert вместо netfilter), поэтому делят один набор
* пресетов — 22 варианта из Flowseal/zapret-discord-youtube, перенесённые в
* `strategies.win32.generated.ts` генератором `scripts/import-flowseal-strategies.mjs`
* (не батниками — кодом). На Linux `--wf-tcp`/`--wf-udp` из тех же аргументов уходят не в
* nfqws, а в правила nftables — см. `nfqwsConfig()` ниже и `resources/linux-helper/`.
*
* Оба движка используют одни и те же списки сайтов (`src/main/zapret/lists.ts`):
* Все три движка используют одни и те же списки сайтов (`src/main/zapret/lists.ts`):
* `{LISTS}/<id>.txt` — путь резолвится в `resolvePlaceholders()` ниже.
*/
interface DarwinStrategyDefinition extends ZapretStrategy {
@@ -143,9 +146,18 @@ const DARWIN_DEFINITIONS: DarwinStrategyDefinition[] = [
}
]
/** Стратегия по умолчанию — своя на каждой платформе, наборы id не пересекаются. */
/**
* Стратегия по умолчанию. win32 и linux делят один набор id (оба читают
* `WIN32_STRATEGIES`), поэтому и дефолт у них общий — 'general'; у darwin свой набор.
*/
export function defaultStrategyId(platform: NodeJS.Platform): string {
return platform === 'win32' ? 'general' : 'split'
switch (platform) {
case 'win32':
case 'linux':
return 'general'
default:
return 'split'
}
}
function resolvePlaceholders(arg: string): string {
@@ -155,6 +167,17 @@ function resolvePlaceholders(arg: string): string {
.replaceAll(AUTO_PLACEHOLDER, autoHostlistPath())
}
/** win32 и linux показывают один и тот же список Flowseal-пресетов — читают его из общего массива. */
function flowsealStrategies(platform: 'win32' | 'linux'): ZapretStrategy[] {
return WIN32_STRATEGIES.map(({ id, name }) => ({
id,
name,
// У Flowseal нет описаний стратегий — только имена вариантов, подобранных под провайдера.
description: 'Стратегия из подборки Flowseal/zapret-discord-youtube.',
platforms: [platform]
}))
}
/** Пресеты, применимые к текущей платформе. */
export function listStrategies(platform: NodeJS.Platform): ZapretStrategy[] {
if (platform === 'darwin') {
@@ -165,15 +188,8 @@ export function listStrategies(platform: NodeJS.Platform): ZapretStrategy[] {
platforms
}))
}
if (platform === 'win32') {
return WIN32_STRATEGIES.map(({ id, name }) => ({
id,
name,
// У Flowseal нет описаний стратегий — только имена вариантов, подобранных под провайдера.
description: 'Стратегия из подборки Flowseal/zapret-discord-youtube.',
platforms: ['win32']
}))
}
if (platform === 'win32') return flowsealStrategies('win32')
if (platform === 'linux') return flowsealStrategies('linux')
return []
}
@@ -201,12 +217,13 @@ export function winwsArgs(strategyId: string): string[] {
return strategy.args.map(resolvePlaceholders)
}
/** Читает служба: `winws.exe @strategy.cfg`, ограничение размера у самого winws — 16 КБ. */
/** Читают winws и nfqws через `@strategy.cfg` — ограничение размера общее у обоих, 16 КБ. */
const MAX_CONFIG_FILE_BYTES = 16000
/**
* winws разбирает файл конфигурации через POSIX `wordexp()` (см. `nfq/nfqws.c` в поставке
* zapret) — без кавычек он режет аргумент по пробелам и съедает обратные слэши в путях вида
* winws и nfqws — один и тот же парсер аргументов (`nfq/nfqws.c` в поставке zapret,
* winws — его сборка под WinDivert), файл конфигурации разбирают через POSIX `wordexp()` —
* без кавычек он режет аргумент по пробелам и съедает обратные слэши в путях вида
* `C:\Users\...`. Каждый аргумент поэтому кладём в одинарные кавычки, а `'` внутри значения
* экранируем как `'\''` — тем же приёмом, что и `networksetup()` в `proxy.darwin.ts` для shell.
*/
@@ -215,33 +232,104 @@ function quoteConfigArg(arg: string): string {
}
/**
* Первая строка — маркер: по нему `Win32Engine.sync()` узнаёт, какая стратегия уже установлена
* в службу, не перечитывая все аргументы. Сам маркер — штатная опция `--comment` winws,
* в работу процесса не вмешивается.
* Первая строка — маркер: по нему `Win32Engine.sync()`/`LinuxEngine.sync()` узнают, какая
* стратегия уже установлена, не перечитывая все аргументы. Сам маркер — штатная опция
* `--comment` (есть и у winws, и у nfqws), в работу процесса не вмешивается.
*/
function configMarker(strategyId: string, body: string): string {
const hash = createHash('sha256').update(body).digest('hex').slice(0, 12)
return `--comment=rknboost:${strategyId}:${hash}`
}
export interface WinwsConfig {
/** Содержимое `strategy.cfg` — UTF-8 без BOM, как требует winws (docs/windows.md zapret). */
const MARKER_PATTERN = /^--comment=rknboost:([^:]+):/
/**
* Обратная операция к `configMarker()` — достаёт id стратегии из первой строки уже
* установленного конфига. Общий разбор для `Win32Engine.sync()` и `LinuxEngine.sync()`,
* чтобы регэксп маркера не разъехался на два места.
*/
export function parseStrategyMarker(line: string | null): string | null {
if (!line) return null
return MARKER_PATTERN.exec(line)?.[1] ?? null
}
export interface EngineConfig {
/** Содержимое `strategy.cfg` — UTF-8 без BOM, как требует winws/nfqws (docs/windows.md zapret). */
body: string
/** Маркер первой строки — сравнить с уже установленным, чтобы понять, нужна ли перенастройка. */
marker: string
}
/** Рендерит стратегию в файл аргументов для `winws.exe @<file>` — см. `WinwsConfig`. */
export function winwsConfigFile(strategyId: string): WinwsConfig {
const args = winwsArgs(strategyId).map(quoteConfigArg).join('\n')
const marker = configMarker(strategyId, args)
const body = `${marker}\n${args}\n`
/** Общий рендер: кавычки, маркер первой строкой, проверка лимита в 16 КБ. */
function renderConfigFile(strategyId: string, args: string[]): EngineConfig {
const quoted = args.map(quoteConfigArg).join('\n')
const marker = configMarker(strategyId, quoted)
const body = `${marker}\n${quoted}\n`
if (Buffer.byteLength(body, 'utf8') > MAX_CONFIG_FILE_BYTES) {
throw new Error(
`Стратегия «${strategyId}» не помещается в лимит winws на файл конфигурации (16 КБ).`
`Стратегия «${strategyId}» не помещается в лимит winws/nfqws на файл конфигурации (16 КБ).`
)
}
return { body, marker }
}
/** Рендерит стратегию в файл аргументов для `winws.exe @<file>` — см. `EngineConfig`. */
export function winwsConfigFile(strategyId: string): EngineConfig {
return renderConfigFile(strategyId, winwsArgs(strategyId))
}
const WF_TCP_PREFIX = '--wf-tcp='
const WF_UDP_PREFIX = '--wf-udp='
export interface NfqwsConfig extends EngineConfig {
/** Порты для правила nftables `post` (исходящий трафик) — см. `rknboost-helper.sh`. */
tcpPorts: string
/** Порты для правил nftables `post`/`pre` (исходящий и ответный UDP). */
udpPorts: string
}
/**
* На Windows `--wf-tcp`/`--wf-udp` — это фильтр самого WinDivert, зашитый в те же аргументы
* winws. На Linux фильтрацию делает nftables снаружи nfqws (см. `rknboost-helper.sh`), а
* сам nfqws эти два аргумента не понимает — вырезаем их и отдаём отдельно вызывающей стороне.
*/
export function nfqwsConfig(strategyId: string): NfqwsConfig {
const args = winwsArgs(strategyId)
let tcpPorts: string | null = null
let udpPorts: string | null = null
const nfqwsArgs: string[] = []
for (const arg of args) {
if (arg.startsWith(WF_TCP_PREFIX)) {
tcpPorts = arg.slice(WF_TCP_PREFIX.length)
} else if (arg.startsWith(WF_UDP_PREFIX)) {
udpPorts = arg.slice(WF_UDP_PREFIX.length)
} else {
nfqwsArgs.push(arg)
}
}
// На всех 22 стратегиях сейчас ровно по одному --wf-tcp/--wf-udp (проверено вручную при
// добавлении Linux), но если очередной апдейт Flowseal формат изменит — лучше явная ошибка
// здесь, чем nftables-правило без портов.
if (tcpPorts === null || udpPorts === null) {
throw new Error(`Стратегия «${strategyId}»: не найден --wf-tcp/--wf-udp в аргументах.`)
}
// Значение уходит прямо в множество nft-правила (rknboost-helper.sh) без дополнительного
// экранирования — только цифры, запятые и дефисы диапазонов. Если Flowseal когда-нибудь
// передаст что-то ещё (например, `~` — отрицание порта), лучше явная ошибка тут, чем
// нераспознанный текст в командной строке nft, которую выполняет root.
for (const [flag, ports] of [
['--wf-tcp', tcpPorts],
['--wf-udp', udpPorts]
] as const) {
if (!/^\d+(-\d+)?(,\d+(-\d+)?)*$/.test(ports)) {
throw new Error(`Стратегия «${strategyId}»: неожиданный формат ${flag}=${ports}`)
}
}
return { ...renderConfigFile(strategyId, nfqwsArgs), tcpPorts, udpPorts }
}
@@ -1581,7 +1581,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [
'--dpi-desync-repeats=11',
'--dpi-desync-fooling=badseq',
'--dpi-desync-fake-tls=0x00000000',
'--dpi-desync-fake-tls=^!',
'--dpi-desync-fake-tls=!',
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
'--new',
'--filter-tcp=443',
@@ -1592,7 +1592,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [
'--dpi-desync-repeats=11',
'--dpi-desync-fooling=badseq',
'--dpi-desync-fake-tls=0x00000000',
'--dpi-desync-fake-tls=^!',
'--dpi-desync-fake-tls=!',
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
'--new',
'--filter-tcp=80,443',
@@ -1604,7 +1604,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [
'--dpi-desync-repeats=11',
'--dpi-desync-fooling=badseq',
'--dpi-desync-fake-tls=0x00000000',
'--dpi-desync-fake-tls=^!',
'--dpi-desync-fake-tls=!',
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
'--dpi-desync-fake-http={FAKES}/tls_clienthello_max_ru.bin',
'--new',
@@ -1625,7 +1625,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [
'--dpi-desync-repeats=11',
'--dpi-desync-fooling=badseq',
'--dpi-desync-fake-tls=0x00000000',
'--dpi-desync-fake-tls=^!',
'--dpi-desync-fake-tls=!',
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
'--dpi-desync-fake-http={FAKES}/tls_clienthello_max_ru.bin',
'--new',
@@ -1639,7 +1639,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [
'--dpi-desync-repeats=11',
'--dpi-desync-fooling=badseq',
'--dpi-desync-fake-tls=0x00000000',
'--dpi-desync-fake-tls=^!',
'--dpi-desync-fake-tls=!',
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
'--dpi-desync-fake-http={FAKES}/tls_clienthello_max_ru.bin',
'--dpi-desync-fake-unknown=0x00000000',