Версия 0.6.0
Заменяет tpws на utunws (форк nfqws под utun+BPF из Flowseal/zapret-mac-discord-youtube) на macOS: движок уровня пакетов вместо socks5-прокси даёт macOS те же 22 стратегии Flowseal, что у Windows и Linux, включая fake-пакеты и десинк UDP/QUIC — раньше tpws этого не умел вовсе. Обход теперь ставится как LaunchDaemon (resources/darwin-helper/), пароль администратора спрашивается при каждом включении/выключении, тот же принцип, что pkexec на Linux. Системный прокси и авто-список hostlist-auto больше не нужны и убраны. README: Windows отмечена как гарантированно рабочая, macOS и Linux — как частично.
This commit is contained in:
@@ -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 с таймаута), — тогда показывать результат уже некому.
|
||||
|
||||
@@ -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<void> {
|
||||
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<number> {
|
||||
})
|
||||
}
|
||||
|
||||
/** Тот же успех, но через локальный SOCKS5 tpws — путь для macOS, пока обход включён. */
|
||||
function checkViaSocks(host: string, socksHost: string, socksPort: number): Promise<number> {
|
||||
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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -171,11 +103,6 @@ async function withRetry(attempt: () => Promise<number>): Promise<number> {
|
||||
* Успех означает «до сайта дошли и он ответил» — этого достаточно, чтобы отличить
|
||||
* «DPI оборвал соединение» от «соединение установилось», разбирать ответ незачем.
|
||||
*
|
||||
* `status.socksAddress` заполнен только на macOS и только пока обход запущен — во всех
|
||||
* остальных случаях (Windows, обход выключен, платформа не поддерживается) бьём напрямую:
|
||||
* на Windows десинк уже общесистемный через WinDivert, а без обхода тест просто показывает
|
||||
* то, что есть сейчас.
|
||||
*
|
||||
* Каждый сайт отдаёт свой результат в `onResult` сам по себе, не дожидаясь остальных:
|
||||
* проверки идут параллельно и заканчиваются вразнобой (живой сайт отвечает за десятки
|
||||
* миллисекунд, заблокированный — только по таймауту в 10 с), и общий ответ одним куском
|
||||
@@ -183,20 +110,12 @@ async function withRetry(attempt: () => Promise<number>): Promise<number> {
|
||||
* по нему вызывающий код понимает, что проверка закончена.
|
||||
*/
|
||||
export async function runDiagnostics(
|
||||
status: ZapretStatus,
|
||||
onResult: (result: ZapretDiagnosticResult) => void
|
||||
): Promise<void> {
|
||||
const socksMatch = status.socksAddress ? /^(.+):(\d+)$/.exec(status.socksAddress) : null
|
||||
|
||||
await Promise.all(
|
||||
DIAGNOSTIC_TARGETS.map(async (target) => {
|
||||
const attempt = (): Promise<number> =>
|
||||
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({
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
+110
-198
@@ -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<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer()
|
||||
server.once('error', () => resolve(false))
|
||||
server.listen(port, HOST, () => server.close(() => resolve(true)))
|
||||
})
|
||||
}
|
||||
|
||||
async function pickPort(): Promise<number> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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, Readable, Readable> | null = null
|
||||
private port = PREFERRED_PORT
|
||||
/** Просили ли включить системный прокси. Переживает перезапуск tpws. */
|
||||
private wantSystemProxy = true
|
||||
private watchTimer: ReturnType<typeof setInterval> | 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<void> {
|
||||
if (!(await hasStaleSnapshot())) return
|
||||
async sync(): Promise<ZapretStatus> {
|
||||
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<ZapretStatus> {
|
||||
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<ZapretStatus> {
|
||||
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<ZapretStatus> {
|
||||
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<void> {
|
||||
await this.cleanup()
|
||||
return this.patch({ state: 'stopped', startedAt: null, error: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Порядок важен: сначала снимаем прокси, потом гасим tpws. Наоборот нельзя —
|
||||
* между шагами система осталась бы с прокси, который уже никто не слушает.
|
||||
* Только RunAtLoad уже установленного LaunchDaemon — если демона ещё нет, лишь запоминаем
|
||||
* выбор до первого включения (никакого диалога авторизации на пустом месте), тот же
|
||||
* принцип, что у `Win32Engine.setAutoStart()`.
|
||||
*/
|
||||
private async cleanup(): Promise<void> {
|
||||
try {
|
||||
await restoreSystemProxy()
|
||||
} catch (error) {
|
||||
this.appendLog('error', error instanceof Error ? error.message : String(error))
|
||||
async setAutoStart(enabled: boolean): Promise<ZapretStatus> {
|
||||
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<void>((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<void> {
|
||||
const reason = signal ? `сигнал ${signal}` : `код ${code}`
|
||||
const details = stderrTail.trim().split('\n').slice(-3).join(' ')
|
||||
async dispose(): Promise<void> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void>
|
||||
|
||||
/** macOS-специфика; на других платформах переопределять не нужно. */
|
||||
async setSystemProxy(enabled: boolean): Promise<ZapretStatus> {
|
||||
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<ZapretStatus> {
|
||||
void enabled
|
||||
throw new Error('Автозапуск на этой платформе не настраивается.')
|
||||
|
||||
+16
-31
@@ -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<void> {
|
||||
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<void> {
|
||||
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<ZapretStatus> {
|
||||
return engine.stop()
|
||||
}
|
||||
|
||||
export function setZapretSystemProxy(enabled: boolean): Promise<ZapretStatus> {
|
||||
return engine.setSystemProxy(enabled)
|
||||
}
|
||||
|
||||
export function setZapretStrategy(strategyId: string): Promise<ZapretStatus> {
|
||||
return engine.setStrategy(strategyId)
|
||||
}
|
||||
@@ -114,23 +116,6 @@ export function resetZapretList(id: ZapretListId): Promise<ZapretList> {
|
||||
return resetList(id)
|
||||
}
|
||||
|
||||
/** Домены, которые zapret нашёл сам через `--hostlist-auto` (только macOS-профили). */
|
||||
export async function zapretAutoHostlist(): Promise<string[]> {
|
||||
try {
|
||||
const content = await readFile(autoHostlistPath(), 'utf8')
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearZapretAutoHostlist(): Promise<void> {
|
||||
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
|
||||
|
||||
+70
-15
@@ -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`.
|
||||
|
||||
@@ -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<string> {
|
||||
try {
|
||||
@@ -58,48 +67,14 @@ async function networksetup(args: string[]): Promise<string> {
|
||||
} 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<string[]> {
|
||||
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<ServiceProxyState> {
|
||||
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<void> {
|
||||
await writeFile(snapshotPath(), JSON.stringify(snapshot, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
async function loadSnapshot(): Promise<ProxySnapshot | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(snapshotPath(), 'utf8')) as ProxySnapshot
|
||||
@@ -126,26 +101,6 @@ async function restoreService(state: ServiceProxyState): Promise<void> {
|
||||
await networksetup(['-setproxybypassdomains', state.service, ...bypass])
|
||||
}
|
||||
|
||||
/** Прописать socks-прокси во все активные сетевые сервисы, запомнив прежнее состояние. */
|
||||
export async function applySystemProxy(host: string, port: number): Promise<void> {
|
||||
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<boolean> {
|
||||
const snapshot = await loadSnapshot()
|
||||
@@ -159,7 +114,7 @@ export async function restoreSystemProxy(): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Снимок остался с прошлого запуска — значит приложение завершилось аварийно,
|
||||
* Снимок остался с прошлого запуска старой версии — значит tpws завершился аварийно,
|
||||
* не сняв прокси, и система сейчас смотрит в несуществующий socks. Чиним на старте.
|
||||
*/
|
||||
export async function hasStaleSnapshot(): Promise<boolean> {
|
||||
|
||||
+59
-185
@@ -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}/<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 свой набор.
|
||||
*/
|
||||
/** Все три платформы делят один набор 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) }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* СГЕНЕРИРОВАНО `scripts/import-flowseal-strategies.mjs` из
|
||||
* СГЕНЕРИРОВАНО `scripts/parser-strategies.mjs` из
|
||||
* Flowseal/zapret-discord-youtube 1.10.2 (commit dfd8e613b099676cf2aa7b474ee5923801514dec).
|
||||
* Не редактировать руками — перезапустить генератор и закоммитить результат.
|
||||
*
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await runPrivileged(darwinStopScriptPath(), [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключает автозапуск LaunchDaemon с системой (RunAtLoad), не трогая уже работающий
|
||||
* демон и pf-правила — та же безопасность для активного обхода, что у `setServiceAutoStart()`
|
||||
* на Windows (`sc config start=`, тоже не останавливает работающую службу).
|
||||
*/
|
||||
export async function setUtunwsAutoStart(enabled: boolean): Promise<void> {
|
||||
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<UtunwsState> {
|
||||
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<boolean | null> {
|
||||
try {
|
||||
const { stdout } = await run('/usr/bin/plutil', [
|
||||
'-extract',
|
||||
'RunAtLoad',
|
||||
'raw',
|
||||
'-o',
|
||||
'-',
|
||||
darwinPlistPath()
|
||||
])
|
||||
return stdout.trim() === 'true'
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -57,8 +57,6 @@ const api = {
|
||||
getZapretStrategies: (): Promise<ZapretStrategy[]> => invoke('zapret:strategies'),
|
||||
startZapret: (strategyId: string): Promise<ZapretStatus> => invoke('zapret:start', strategyId),
|
||||
stopZapret: (): Promise<ZapretStatus> => invoke('zapret:stop'),
|
||||
setZapretSystemProxy: (enabled: boolean): Promise<ZapretStatus> =>
|
||||
invoke('zapret:set-system-proxy', enabled),
|
||||
setZapretStrategy: (strategyId: string): Promise<ZapretStatus> =>
|
||||
invoke('zapret:set-strategy', strategyId),
|
||||
setZapretAutoStart: (enabled: boolean): Promise<ZapretStatus> =>
|
||||
@@ -72,8 +70,6 @@ const api = {
|
||||
saveZapretList: (id: ZapretListId, entries: string[]): Promise<ZapretList> =>
|
||||
invoke('zapret:list-save', id, entries),
|
||||
resetZapretList: (id: ZapretListId): Promise<ZapretList> => invoke('zapret:list-reset', id),
|
||||
getZapretAutoHostlist: (): Promise<string[]> => invoke('zapret:auto-hostlist'),
|
||||
clearZapretAutoHostlist: (): Promise<void> => invoke('zapret:auto-hostlist-clear'),
|
||||
|
||||
/** Запускает проверку и отдаёт список сайтов; результаты — через `onZapretDiagnosticResult`. */
|
||||
runZapretDiagnostics: (): Promise<ZapretDiagnosticTarget[]> => invoke('zapret:diagnose'),
|
||||
|
||||
@@ -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<void>
|
||||
}): React.JSX.Element {
|
||||
const { lists, autoHostlist, save, reset, clearAuto } = useZapretLists()
|
||||
const { lists, save, reset } = useZapretLists()
|
||||
const [openListId, setOpenListId] = useState<ZapretListId | null>(null)
|
||||
const appVersion = useAppVersion()
|
||||
|
||||
@@ -53,7 +54,7 @@ export default function SettingsView({
|
||||
<h2 className="settings__title">Настройки</h2>
|
||||
</div>
|
||||
|
||||
{isWindows && (
|
||||
{supportsAutoStart && (
|
||||
<div className="settings__group">
|
||||
<span className="settings__label">Автозапуск</span>
|
||||
<button
|
||||
@@ -61,14 +62,19 @@ export default function SettingsView({
|
||||
className="settings-row"
|
||||
onClick={() => void onSetAutoStart(!status?.autoStart)}
|
||||
>
|
||||
<span>Запускать вместе с Windows</span>
|
||||
<span>Запускать вместе с {isWindows ? 'Windows' : 'macOS'}</span>
|
||||
<span className={`chip ${status?.autoStart ? 'chip--success' : 'chip--neutral'}`}>
|
||||
{status?.autoStart ? 'Включён' : 'Выключен'}
|
||||
</span>
|
||||
</button>
|
||||
<p className="list-editor__hint">
|
||||
Меняется через запрос прав администратора. Если выключить обход кнопкой на главном
|
||||
экране, при включённом автозапуске он вернётся не раньше следующей перезагрузки.
|
||||
{isWindows
|
||||
? 'Меняется через запрос прав администратора. Если выключить обход кнопкой на ' +
|
||||
'главном экране, при включённом автозапуске он вернётся не раньше следующей ' +
|
||||
'перезагрузки.'
|
||||
: 'Меняется через пароль администратора. Действует, пока обход включён: ' +
|
||||
'выключение кнопкой на главном экране полностью снимает установку — ' +
|
||||
'автозапуск снова понадобится включить вместе с обходом.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -87,26 +93,6 @@ export default function SettingsView({
|
||||
<span className="settings-row__count">{list.entries.length} ›</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* --hostlist-auto есть только в darwin-профилях (см. strategies.ts) — ни одна из 22
|
||||
стратегий Flowseal (Windows и Linux) его не использует, счётчик там всегда пуст. */}
|
||||
{isDarwin && (
|
||||
<div className="settings-row settings-row--static">
|
||||
<span>Найдено автоматически</span>
|
||||
<span className="settings-row__count">
|
||||
{autoHostlist.length}
|
||||
{autoHostlist.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="settings-row__clear"
|
||||
onClick={() => void clearAuto()}
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="card settings__support">
|
||||
|
||||
@@ -14,7 +14,6 @@ export function useZapret(): {
|
||||
log: ZapretLogLine[]
|
||||
start: (strategyId: string) => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
setSystemProxy: (enabled: boolean) => Promise<void>
|
||||
setStrategy: (strategyId: string) => Promise<void>
|
||||
setAutoStart: (enabled: boolean) => Promise<void>
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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<ZapretList>
|
||||
reset: (id: ZapretListId) => Promise<ZapretList>
|
||||
clearAuto: () => Promise<void>
|
||||
loading: boolean
|
||||
} {
|
||||
const [lists, setLists] = useState<ZapretList[]>([])
|
||||
const [autoHostlist, setAutoHostlist] = useState<string[]>([])
|
||||
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 }
|
||||
}
|
||||
|
||||
+11
-17
@@ -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
|
||||
|
||||
/**
|
||||
* Запустить проверку соединения и сразу вернуть список проверяемых сайтов — сами
|
||||
|
||||
Reference in New Issue
Block a user