Добавлена сборка под 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.
336 lines
15 KiB
TypeScript
336 lines
15 KiB
TypeScript
import { createHash } from 'node:crypto'
|
||
import type { ZapretStrategy } from '../../shared/ipc-contract'
|
||
import { autoHostlistPath, fakesDir, userListsDir } from './paths'
|
||
import { WIN32_STRATEGIES } from './strategies.win32.generated'
|
||
|
||
/**
|
||
* Пресеты обхода.
|
||
*
|
||
* Универсального набора параметров не существует: что сработает, зависит от DPI
|
||
* конкретного провайдера.
|
||
*
|
||
* `tpws` (macOS) — прокси уровня TCP-соединений, а не пакетов: умеет только резать/
|
||
* переупорядочивать сегменты (`--split-pos`, `--disorder`, `--oob`, `--tlsrec`), но не
|
||
* умеет ни fake-пакетов, ни UDP/QUIC вообще. Профили ниже — свои, под это ограничение.
|
||
*
|
||
* `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`):
|
||
* `{LISTS}/<id>.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 свой набор.
|
||
*/
|
||
export function defaultStrategyId(platform: NodeJS.Platform): string {
|
||
switch (platform) {
|
||
case 'win32':
|
||
case 'linux':
|
||
return 'general'
|
||
default:
|
||
return 'split'
|
||
}
|
||
}
|
||
|
||
function resolvePlaceholders(arg: string): string {
|
||
return arg
|
||
.replaceAll(LISTS_PLACEHOLDER, userListsDir())
|
||
.replaceAll(FAKES_PLACEHOLDER, fakesDir())
|
||
.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') {
|
||
return DARWIN_DEFINITIONS.map(({ id, name, description, platforms }) => ({
|
||
id,
|
||
name,
|
||
description,
|
||
platforms
|
||
}))
|
||
}
|
||
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)
|
||
if (!strategy) throw new Error(`Неизвестная стратегия: ${strategyId}`)
|
||
return strategy.args.map(resolvePlaceholders)
|
||
}
|
||
|
||
/** Читают winws и nfqws через `@strategy.cfg` — ограничение размера общее у обоих, 16 КБ. */
|
||
const MAX_CONFIG_FILE_BYTES = 16000
|
||
|
||
/**
|
||
* winws и nfqws — один и тот же парсер аргументов (`nfq/nfqws.c` в поставке zapret,
|
||
* winws — его сборка под WinDivert), файл конфигурации разбирают через POSIX `wordexp()` —
|
||
* без кавычек он режет аргумент по пробелам и съедает обратные слэши в путях вида
|
||
* `C:\Users\...`. Каждый аргумент поэтому кладём в одинарные кавычки, а `'` внутри значения
|
||
* экранируем как `'\''` — тем же приёмом, что и `networksetup()` в `proxy.darwin.ts` для shell.
|
||
*/
|
||
function quoteConfigArg(arg: string): string {
|
||
return `'${arg.replaceAll("'", `'\\''`)}'`
|
||
}
|
||
|
||
/**
|
||
* Первая строка — маркер: по нему `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}`
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
/** Общий рендер: кавычки, маркер первой строкой, проверка лимита в 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/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 }
|
||
}
|