Files
senboost/src/main/zapret/nfqws.linux.ts
T
maeneko c71a27c9a3 Переименование проекта: RKNboost → SenBoost
Затрагивает не только видимые строки, но и системные идентификаторы: appId
(com.senboost.app), служба Windows (senboost-zapret) и её каталог в %ProgramData%,
LaunchDaemon и pf-якорь на macOS (com.senboost.zapret, com.apple/senboost),
каталог /run/senboost и таблица nftables на Linux, маркер стратегии в strategy.cfg
(--comment=senboost:<id>:<hash>), а также каталог userData, который Electron
выводит из имени приложения.
2026-09-04 14:41:56 +03:00

227 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { execFile, spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
access,
chmod,
constants,
copyFile,
mkdir,
mkdtemp,
readdir,
readFile,
rm,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import {
bundledFakesDir,
bundledHelperPath,
linuxInstalledConfigPath,
linuxPidfilePath,
linuxStagingDir,
nfqwsPath,
stagedFakesDir,
stagedHelperPath,
stagedNfqwsPath
} from './paths'
import { parseStrategyMarker } from './strategies'
const run = promisify(execFile)
async function sha256(path: string): Promise<string> {
return createHash('sha256')
.update(await readFile(path))
.digest('hex')
}
async function filesIdentical(a: string, b: string): Promise<boolean> {
try {
const [hashA, hashB] = await Promise.all([sha256(a), sha256(b)])
return hashA === hashB
} catch {
// Копии ещё нет (первый запуск) или она нечитаема — надёжнее перекопировать.
return false
}
}
/**
* Копирует nfqws, помощника и fake-пакеты из `resources/zapret`/`resources/linux-helper`
* в `linuxStagingDir()` — см. её комментарий в paths.ts про то, почему это не оптимизация,
* а необходимость под AppImage. Сравниваем содержимое, а не mtime — тот же мотив, что у
* `binariesNeedCopy()` в engine.win32.ts: `copyFile` всегда ставит текущее время, хотя
* зафиксированная версия zapret даёт одни и те же байты между сборками.
*/
export async function stageRuntime(): Promise<void> {
await mkdir(linuxStagingDir(), { recursive: true })
await mkdir(stagedFakesDir(), { recursive: true })
if (!(await filesIdentical(nfqwsPath(), stagedNfqwsPath()))) {
await copyFile(nfqwsPath(), stagedNfqwsPath())
}
await chmod(stagedNfqwsPath(), 0o755)
if (!(await filesIdentical(bundledHelperPath(), stagedHelperPath()))) {
await copyFile(bundledHelperPath(), stagedHelperPath())
}
await chmod(stagedHelperPath(), 0o755)
const fakesSrcDir = bundledFakesDir()
for (const name of await readdir(fakesSrcDir)) {
const src = join(fakesSrcDir, name)
const dst = join(stagedFakesDir(), name)
if (!(await filesIdentical(src, dst))) {
await copyFile(src, dst)
}
}
}
/**
* Номер очереди NFQUEUE — держать в синхроне с `QUEUE_NUM` в `senboost-helper.sh`
* (там же и объяснение выбора числа). В TS он нужен только для `--dry-run` ниже: сам
* помощник получает его не отсюда, а как захардкоженное значение в своём коде.
*/
const NFQUEUE_NUM = 220
/**
* `nfqws --dry-run @<файл>` — проверяет аргументы фильтра, root не нужен (в отличие от
* рабочего запуска демона). Запускаем бинарник из сборки напрямую: dry-run идёт от uid
* самого приложения, а не root, так что FUSE-примонтированный AppImage тут не помеха —
* ограничение касается только `pkexec` (см. linuxStagingDir()).
*
* В отличие от `Win32Engine['dryRun']` (там `winws.exe --dry-run @file` без доп. флагов
* достаточно) nfqws требует `--qnum` даже для проверки — «Need queue number (--qnum)»,
* даже когда реальная очередь не открывается. Значение то же, что использует помощник
* при настоящем запуске: раз номер не совпадёт — `nft`-правила и демон всё равно будут
* рассинхронизированы, но сам факт валидности остальных аргументов dry-run проверяет верно.
*/
export async function dryRun(configBody: string): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), 'senboost-dryrun-'))
const configPath = join(dir, 'dry-run.cfg')
try {
await writeFile(configPath, configBody, 'utf8')
await run(nfqwsPath(), ['--dry-run', `--qnum=${NFQUEUE_NUM}`, `@${configPath}`])
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw new Error(`Стратегия содержит недопустимые параметры nfqws: ${message}`, {
cause: error
})
} finally {
await rm(dir, { recursive: true, force: true })
}
}
/** Отрендеренный конфиг лежит здесь до pkexec — сам помощник, уже под root, копирует его в /run. */
async function writeStagedConfig(body: string): Promise<string> {
const path = join(linuxStagingDir(), 'pending-strategy.cfg')
await writeFile(path, body, 'utf8')
return path
}
/** Хвост stderr помощника — тот же лимит и мотив, что у `stderrTail` в engine.darwin.ts. */
const STDERR_TAIL_LIMIT = 4000
function runHelper(args: string[]): Promise<{ code: number; stderr: string }> {
return new Promise((resolveHelper) => {
const child = spawn('pkexec', [stagedHelperPath(), ...args], {
stdio: ['ignore', 'ignore', 'pipe']
})
let stderr = ''
child.stderr.on('data', (chunk: Buffer) => {
stderr = (stderr + chunk.toString('utf8')).slice(-STDERR_TAIL_LIMIT)
})
// pkexec не найден — spawn сообщает об этом событием 'error', а не кодом выхода
// (см. launchUninstaller() в uninstall.win32.ts, та же причина).
child.on('error', (error) => resolveHelper({ code: 127, stderr: error.message }))
child.on('close', (code) => resolveHelper({ code: code ?? 1, stderr }))
})
}
export interface StartNfqwsOptions {
configBody: string
tcpPorts: string
udpPorts: string
}
/** Единственные два места, где приложение просит root — включение и выключение обхода. */
export async function startNfqws(options: StartNfqwsOptions): Promise<void> {
await stageRuntime()
const configPath = await writeStagedConfig(options.configBody)
const { code, stderr } = await runHelper([
'start',
stagedNfqwsPath(),
configPath,
options.tcpPorts,
options.udpPorts
])
if (code !== 0) {
throw new Error(
`Не удалось запустить обход (pkexec, код ${code})` +
(stderr ? `: ${stderr}` : ' — запрос прав администратора отклонён или прерван.')
)
}
}
export async function stopNfqws(): Promise<void> {
// Помощник может понадобиться, даже если start() в эту сессию ещё не звали — например,
// sync() увидел уже работающий с прошлого сеанса демон, и первое действие пользователя
// в новом запуске приложения — «Выключить».
await stageRuntime()
const { code, stderr } = await runHelper(['stop'])
if (code !== 0) {
throw new Error(
`Не удалось остановить обход (pkexec, код ${code})` +
(stderr ? `: ${stderr}` : ' — запрос прав администратора отклонён или прерван.')
)
}
}
export interface NfqwsState {
pid: number | null
strategyId: string | null
}
/** `/proc/<pid>` — существование каталога о процессе видно без прав, сигнал слать не нужно. */
export async function isProcessAlive(pid: number): Promise<boolean> {
try {
await access(`/proc/${pid}`, constants.F_OK)
return true
} catch {
return false
}
}
/**
* Не требует root: `nfqws.pid` и `strategy.cfg` в `/run/senboost` помощник кладёт с правами
* 0644 (см. senboost-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 }
}