Initial commit
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
import { connect as netConnect, type Socket } from 'node:net'
|
||||
import { connect as tlsConnect } from 'node:tls'
|
||||
import { net } from 'electron'
|
||||
import type { ZapretDiagnosticResult, ZapretStatus } from '../../shared/ipc-contract'
|
||||
|
||||
// 6 секунд не хватало: десинк с `--dpi-desync-repeats` заметно удлиняет установку соединения,
|
||||
// и медленный, но живой сайт успевал попасть в «Недоступно».
|
||||
const TIMEOUT_MS = 10000
|
||||
const TARGET_PORT = 443
|
||||
const RETRY_DELAY_MS = 300
|
||||
|
||||
interface DiagnosticTarget {
|
||||
id: string
|
||||
label: string
|
||||
host: string
|
||||
}
|
||||
|
||||
/** Сайты, которые нужно проверять в первую очередь — по просьбе пользователя. */
|
||||
const TARGETS: DiagnosticTarget[] = [
|
||||
{ id: 'instagram', label: 'Instagram', host: 'instagram.com' },
|
||||
{ id: 'discord', label: 'Discord', host: 'discord.com' },
|
||||
{ id: 'youtube', label: 'YouTube', host: 'youtube.com' }
|
||||
]
|
||||
|
||||
/**
|
||||
* Расшифровка кодов Chromium: без неё в интерфейсе у всех трёх сайтов одинаковое
|
||||
* «Недоступно», и по нему невозможно отличить «DPI режет» от «домен не резолвится»
|
||||
* или «антивирус перехватывает HTTPS» — а лечатся они совершенно по-разному.
|
||||
*/
|
||||
const ERROR_HINTS: [pattern: RegExp, hint: string][] = [
|
||||
[/ERR_NAME_NOT_RESOLVED|ERR_NAME_RESOLUTION_FAILED/, 'домен не резолвится — блокировка по DNS'],
|
||||
[/ERR_INTERNET_DISCONNECTED|ERR_NETWORK_CHANGED/, 'нет подключения к интернету'],
|
||||
[/ERR_CONNECTION_RESET|ERR_CONNECTION_CLOSED/, 'соединение сброшено — похоже на DPI'],
|
||||
[/ERR_CONNECTION_TIMED_OUT|ERR_TIMED_OUT/, 'таймаут — пакеты теряются молча'],
|
||||
[/ERR_CONNECTION_REFUSED/, 'соединение отклонено'],
|
||||
[/ERR_CERT_|ERR_SSL_/, 'сертификат не принят — вероятно, антивирус перехватывает HTTPS'],
|
||||
[/ERR_PROXY_/, 'мешает системный прокси']
|
||||
]
|
||||
|
||||
function describeNetError(message: string): string {
|
||||
const code = /net::(ERR_[A-Z_]+)/.exec(message)?.[1] ?? message
|
||||
const hint = ERROR_HINTS.find(([pattern]) => pattern.test(message))?.[1]
|
||||
return hint ? `${hint} (${code})` : code
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) и для
|
||||
* выключенного обхода.
|
||||
*
|
||||
* Намеренно через `net` Electron, то есть через сетевой стек Chromium, а НЕ через
|
||||
* `node:tls`. Тест должен отвечать на вопрос «откроется ли этот сайт в браузере», а
|
||||
* Node-стек отвечает на другой: у него собственный список корневых сертификатов (мимо
|
||||
* хранилища Windows — и любой антивирус с проверкой HTTPS ломает проверку на ровном
|
||||
* месте), собственный резолвер (мимо DoH) и заметно более простой ClientHello, чем у
|
||||
* браузера, — а стратегии zapret настраивают именно под браузерный. Из-за этого проверка
|
||||
* показывала «Недоступно» на всех трёх сайтах, которые в браузере при включённом обходе
|
||||
* открывались нормально.
|
||||
*/
|
||||
function checkDirect(host: string): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now()
|
||||
// HEAD, а не GET: нас интересует только сам факт, что до сайта дошли и он ответил,
|
||||
// тело страницы качать незачем. redirect: 'manual' — редирект (например,
|
||||
// instagram.com → www.instagram.com) это тоже ответ, гнаться за ним не нужно.
|
||||
const request = net.request({ method: 'HEAD', url: `https://${host}/`, redirect: 'manual' })
|
||||
let settled = false
|
||||
|
||||
const finish = (error: Error | null): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
request.abort()
|
||||
if (error) reject(new Error(describeNetError(error.message)))
|
||||
else resolve(Date.now() - start)
|
||||
}
|
||||
|
||||
const timer = setTimeout(
|
||||
() => finish(new Error(`net::ERR_TIMED_OUT ${TIMEOUT_MS} мс`)),
|
||||
TIMEOUT_MS
|
||||
)
|
||||
|
||||
request.on('response', () => finish(null))
|
||||
request.on('redirect', () => finish(null))
|
||||
request.on('error', finish)
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
|
||||
/** Тот же успех, но через локальный 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))
|
||||
}
|
||||
|
||||
/**
|
||||
* Одна повторная попытка. Десинк срабатывает не всегда с первого соединения, и браузер это
|
||||
* скрывает: он переустанавливает соединение сам, а пользователь видит только итог — сайт
|
||||
* открылся. Проверка в одну попытку получалась строже браузера и писала «Недоступно» там,
|
||||
* где сайт на самом деле открывается. Ошибку второй попытки отдаём наружу как есть — если
|
||||
* не сработало дважды, показать причину важнее, чем спрятать её за повтором.
|
||||
*/
|
||||
async function withRetry(attempt: () => Promise<number>): Promise<number> {
|
||||
try {
|
||||
return await attempt()
|
||||
} catch {
|
||||
await delay(RETRY_DELAY_MS)
|
||||
return attempt()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Успех означает «до сайта дошли и он ответил» — этого достаточно, чтобы отличить
|
||||
* «DPI оборвал соединение» от «соединение установилось», разбирать ответ незачем.
|
||||
*
|
||||
* `status.socksAddress` заполнен только на macOS и только пока обход запущен — во всех
|
||||
* остальных случаях (Windows, обход выключен, платформа не поддерживается) бьём напрямую:
|
||||
* на Windows десинк уже общесистемный через WinDivert, а без обхода тест просто показывает
|
||||
* то, что есть сейчас.
|
||||
*/
|
||||
export async function runDiagnostics(status: ZapretStatus): Promise<ZapretDiagnosticResult[]> {
|
||||
const socksMatch = status.socksAddress ? /^(.+):(\d+)$/.exec(status.socksAddress) : null
|
||||
|
||||
return Promise.all(
|
||||
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)
|
||||
return { ...target, ok: true, ms, error: null }
|
||||
} catch (error) {
|
||||
return {
|
||||
...target,
|
||||
ok: false,
|
||||
ms: null,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const run = promisify(execFile)
|
||||
|
||||
/**
|
||||
* Аргумент, по которому приложение понимает, что его запустили не как приложение,
|
||||
* а как «повышенного помощника»: отработать задачу службы и выйти, без окна.
|
||||
*/
|
||||
export const ELEVATED_TASK_FLAG = '--elevated-service-task'
|
||||
|
||||
/**
|
||||
* Имена файлов внутри временного каталога задачи. Каталог готовит неповышенная сторона
|
||||
* (`runElevated` в service.win32.ts), читает — повышенная, поэтому имена держим в одном
|
||||
* месте, а не дублируем строками с обеих сторон.
|
||||
*/
|
||||
export const TASK_FILES = {
|
||||
worker: 'worker.ps1',
|
||||
payload: 'payload.json',
|
||||
result: 'result.json'
|
||||
} as const
|
||||
|
||||
let elevationCheck: Promise<boolean> | null = null
|
||||
|
||||
/**
|
||||
* Запущены ли мы уже от администратора. В собранном приложении это всегда так
|
||||
* (`requestedExecutionLevel: requireAdministrator` в electron-builder.yml), но проверяем
|
||||
* фактически, а не по предположению: в dev-режиме манифеста нет, а от неверного ответа
|
||||
* зависит, ждать пользователю запрос UAC или нет. Ответ не меняется в течение работы
|
||||
* процесса, поэтому считаем его один раз.
|
||||
*/
|
||||
export function isElevated(): Promise<boolean> {
|
||||
elevationCheck ??= run('powershell.exe', [
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
'[Security.Principal.WindowsPrincipal]::new(' +
|
||||
'[Security.Principal.WindowsIdentity]::GetCurrent()' +
|
||||
').IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)'
|
||||
])
|
||||
.then(({ stdout }) => stdout.trim().toLowerCase() === 'true')
|
||||
// Не смогли выяснить — считаем, что прав нет: лишний запрос UAC не страшен,
|
||||
// а вот молча провалившаяся настройка службы — страшна.
|
||||
.catch(() => false)
|
||||
|
||||
return elevationCheck
|
||||
}
|
||||
|
||||
/** Каталог задачи из argv, если приложение запустили как помощника. Иначе null. */
|
||||
export function elevatedTaskDir(argv: readonly string[]): string | null {
|
||||
const index = argv.indexOf(ELEVATED_TASK_FLAG)
|
||||
if (index === -1) return null
|
||||
return argv[index + 1] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняется уже с правами администратора: UAC запросили на нас самих, поэтому
|
||||
* дочернему powershell повышаться не нужно — он наследует права.
|
||||
*
|
||||
* Смысл всей этой конструкции в том, ЧТО пользователь видит в окне UAC. Раньше приложение
|
||||
* повышало powershell.exe, и Windows честно показывала «Windows PowerShell» с подписью
|
||||
* Microsoft — запрос выглядел так, будто права просит не то, что запросил пользователь.
|
||||
* Теперь повышается сам RKNboost.exe, и в запросе стоит имя приложения.
|
||||
*
|
||||
* Сам worker.ps1 при этом не изменился — вся логика sc/icacls осталась ровно та же,
|
||||
* поменялось только то, кого повышают.
|
||||
*/
|
||||
export async function runElevatedServiceTask(taskDir: string): Promise<number> {
|
||||
try {
|
||||
await run(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-File',
|
||||
join(taskDir, TASK_FILES.worker),
|
||||
'-PayloadPath',
|
||||
join(taskDir, TASK_FILES.payload),
|
||||
'-ResultPath',
|
||||
join(taskDir, TASK_FILES.result)
|
||||
],
|
||||
{ windowsHide: true }
|
||||
)
|
||||
return 0
|
||||
} catch (error) {
|
||||
const code = (error as { code?: unknown } | undefined)?.code
|
||||
return typeof code === 'number' ? code : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
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'
|
||||
|
||||
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} мс.`)
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS: tpws поднимается обычным пользовательским процессом в режиме socks5
|
||||
* (документация zapret: «Режим --socks не требует повышенных привилегий»),
|
||||
* а трафик заворачивается системной настройкой прокси.
|
||||
*
|
||||
* Важное ограничение платформы: tpws не умеет UDP, поэтому QUIC не обходится.
|
||||
* Практика это скрадывает — при системном socks-прокси браузеры сами уходят с QUIC на TCP.
|
||||
*/
|
||||
export class DarwinEngine extends ZapretEngine {
|
||||
private child: ChildProcessByStdio<null, Readable, Readable> | null = null
|
||||
private port = PREFERRED_PORT
|
||||
/** Просили ли включить системный прокси. Переживает перезапуск tpws. */
|
||||
private wantSystemProxy = true
|
||||
|
||||
constructor() {
|
||||
super('tpws-socks', defaultStrategyId('darwin'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Прошлый запуск мог не снять прокси (например, kill -9). Тогда система смотрит
|
||||
* в мёртвый socks и интернета нет вовсе — чиним до того, как показать окно.
|
||||
*/
|
||||
async recoverFromCrash(): Promise<void> {
|
||||
if (!(await hasStaleSnapshot())) return
|
||||
|
||||
this.appendLog(
|
||||
'info',
|
||||
'Найдены незакрытые настройки прокси от прошлого запуска — восстанавливаю.'
|
||||
)
|
||||
try {
|
||||
await restoreSystemProxy()
|
||||
} catch (error) {
|
||||
this.appendLog('error', error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Вывод 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)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
} 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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
return this.patch({ systemProxyApplied: enabled })
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
await this.cleanup()
|
||||
}
|
||||
|
||||
/**
|
||||
* Порядок важен: сначала снимаем прокси, потом гасим tpws. Наоборот нельзя —
|
||||
* между шагами система осталась бы с прокси, который уже никто не слушает.
|
||||
*/
|
||||
private async cleanup(): Promise<void> {
|
||||
try {
|
||||
await restoreSystemProxy()
|
||||
} catch (error) {
|
||||
this.appendLog('error', error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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(' ')
|
||||
|
||||
// Прокси обязательно снять: иначе система указывает на мёртвый socks и сети не будет.
|
||||
try {
|
||||
await restoreSystemProxy()
|
||||
} catch (error) {
|
||||
this.appendLog('error', error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
this.patch({
|
||||
state: 'error',
|
||||
error: `tpws неожиданно завершился (${reason}). ${details}`.trim(),
|
||||
startedAt: null,
|
||||
socksAddress: null,
|
||||
systemProxyApplied: false
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
import type {
|
||||
ZapretBackend,
|
||||
ZapretLogLine,
|
||||
ZapretStatus,
|
||||
ZapretState
|
||||
} from '../../shared/ipc-contract'
|
||||
/** Сколько строк вывода бинарника держим для UI. */
|
||||
const LOG_LIMIT = 200
|
||||
|
||||
/** Общая часть обоих движков: хранение статуса и рассылка событий в окна. */
|
||||
export abstract class ZapretEngine {
|
||||
protected status: ZapretStatus
|
||||
private readonly log: ZapretLogLine[] = []
|
||||
|
||||
constructor(backend: ZapretBackend, defaultStrategyId: string) {
|
||||
this.status = {
|
||||
state: 'stopped',
|
||||
backend,
|
||||
strategyId: defaultStrategyId,
|
||||
startedAt: null,
|
||||
error: null,
|
||||
socksAddress: null,
|
||||
systemProxyApplied: false,
|
||||
serviceInstalled: false,
|
||||
autoStart: false
|
||||
}
|
||||
}
|
||||
|
||||
getStatus(): ZapretStatus {
|
||||
return { ...this.status }
|
||||
}
|
||||
|
||||
getLog(): ZapretLogLine[] {
|
||||
return [...this.log]
|
||||
}
|
||||
|
||||
abstract start(strategyId: string): Promise<ZapretStatus>
|
||||
abstract stop(): Promise<ZapretStatus>
|
||||
/** Вызывается при выходе из приложения: вернуть систему в исходное состояние. */
|
||||
abstract dispose(): Promise<void>
|
||||
|
||||
/** macOS-специфика; на других платформах переопределять не нужно. */
|
||||
async setSystemProxy(enabled: boolean): Promise<ZapretStatus> {
|
||||
void enabled
|
||||
throw new Error('Системный прокси на этой платформе не настраивается.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Выключен — просто запоминаем выбор (никаких системных действий и никакого UAC).
|
||||
* Работает — перезапускаем с новой стратегией через `start()`; на Windows тот сам
|
||||
* остановит службу перед перенастройкой (см. `Win32Engine.start()`), потому что менять
|
||||
* конфигурацию работающей службы нельзя.
|
||||
*/
|
||||
async setStrategy(strategyId: string): Promise<ZapretStatus> {
|
||||
if (this.status.state !== 'running') {
|
||||
return this.patch({ strategyId })
|
||||
}
|
||||
return this.start(strategyId)
|
||||
}
|
||||
|
||||
/** Windows-специфика (автозапуск службы); на других платформах переопределять не нужно. */
|
||||
async setAutoStart(enabled: boolean): Promise<ZapretStatus> {
|
||||
void enabled
|
||||
throw new Error('Автозапуск на этой платформе не настраивается.')
|
||||
}
|
||||
|
||||
protected patch(changes: Partial<ZapretStatus>): ZapretStatus {
|
||||
this.status = { ...this.status, ...changes }
|
||||
this.broadcast('zapret:status-changed', this.status)
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
protected setState(state: ZapretState, error: string | null = null): ZapretStatus {
|
||||
return this.patch({ state, error })
|
||||
}
|
||||
|
||||
/** Вывод бинарника: копим кольцевым буфером и сразу отдаём в UI. */
|
||||
protected appendLog(level: ZapretLogLine['level'], chunk: string): void {
|
||||
for (const raw of chunk.split('\n')) {
|
||||
const line = raw.trimEnd()
|
||||
if (!line) continue
|
||||
|
||||
const entry: ZapretLogLine = { level, line }
|
||||
this.log.push(entry)
|
||||
if (this.log.length > LOG_LIMIT) this.log.shift()
|
||||
this.broadcast('zapret:log', entry)
|
||||
}
|
||||
}
|
||||
|
||||
private broadcast<T>(channel: string, payload: T): void {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) window.webContents.send(channel, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Заглушка для платформ, под которые мы бинарники не собираем (Linux). */
|
||||
export class UnsupportedEngine extends ZapretEngine {
|
||||
constructor() {
|
||||
super('unsupported', '')
|
||||
this.status.error = 'Обход блокировок доступен только на macOS и Windows.'
|
||||
}
|
||||
|
||||
async start(): Promise<ZapretStatus> {
|
||||
throw new Error('Обход блокировок доступен только на macOS и Windows.')
|
||||
}
|
||||
|
||||
async stop(): Promise<ZapretStatus> {
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import type { ZapretStatus } from '../../shared/ipc-contract'
|
||||
import { ZapretEngine } from './engine'
|
||||
import { assertExecutable, protectedWinwsPath, serviceConfigPath, winwsPath } from './paths'
|
||||
import {
|
||||
applyServiceConfig,
|
||||
queryService,
|
||||
setServiceAutoStart,
|
||||
startService,
|
||||
stopService,
|
||||
type ServiceInfo
|
||||
} from './service.win32'
|
||||
import { defaultStrategyId, winwsConfigFile, type WinwsConfig } from './strategies'
|
||||
|
||||
const run = promisify(execFile)
|
||||
|
||||
/** Первая строка `strategy.cfg` — из `winwsConfigFile()`, формат: `--comment=rknboost:<id>:<hash>`. */
|
||||
async function readInstalledMarkerLine(): Promise<string | null> {
|
||||
try {
|
||||
const content = await readFile(serviceConfigPath(), 'utf8')
|
||||
return content.split('\n')[0]?.trim() ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Служба всегда запускает защищённую копию winws.exe (protectedWinwsPath), а не оригинал
|
||||
* из папки установки приложения (winwsPath) — значит переустановку/обновление приложения
|
||||
* по самому пути службы не отследить, как раньше. Вместо этого сравниваем размер и время
|
||||
* изменения оригинала с уже скопированным файлом: разошлись — обновление zapret вышло
|
||||
* позже последней копии, нужно копировать заново (а значит и UAC).
|
||||
*/
|
||||
async function binariesNeedCopy(): Promise<boolean> {
|
||||
try {
|
||||
const [source, copy] = await Promise.all([stat(winwsPath()), stat(protectedWinwsPath())])
|
||||
return source.size !== copy.size || source.mtimeMs > copy.mtimeMs
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Перенастройка (а значит и UAC) нужна, если службы ещё нет, если защищённая копия
|
||||
* winws.exe отстала от оригинала в папке приложения, или если аргументы стратегии
|
||||
* изменились (маркер в `strategy.cfg` не совпадает с тем, что сгенерировали сейчас).
|
||||
*/
|
||||
async function needsReconfigure(config: WinwsConfig, info: ServiceInfo | null): Promise<boolean> {
|
||||
if (!info) return true
|
||||
if (await binariesNeedCopy()) return true
|
||||
return (await readInstalledMarkerLine()) !== config.marker
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows: winws требует драйвер WinDivert и права администратора, поэтому работает как служба
|
||||
* Windows (`rknboost-zapret`), а не как обычный дочерний процесс приложения.
|
||||
*
|
||||
* UAC запрашивается только при первой установке службы и при смене аргументов (новая стратегия,
|
||||
* переключение автозапуска) — этим занимается `service.win32.ts`. Обычные включение/выключение
|
||||
* идут через `sc start`/`sc stop`, на которые интерактивным пользователям выдаётся право при
|
||||
* установке (`sc sdset`), поэтому UAC для них не нужен.
|
||||
*/
|
||||
export class Win32Engine extends ZapretEngine {
|
||||
constructor() {
|
||||
super('winws-service', defaultStrategyId('win32'))
|
||||
// Решение по умолчанию для первой установки: обход должен работать и после перезагрузки,
|
||||
// не дожидаясь запуска приложения. Дальше пользователь может выключить переключателем.
|
||||
this.status.autoStart = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Вызывается из `recoverZapret()` до открытия окна: подхватывает состояние службы,
|
||||
* которая могла работать ещё при загрузке системы (автозапуск), — иначе UI показал бы
|
||||
* «Выключен» при уже работающем обходе.
|
||||
*/
|
||||
async sync(): Promise<ZapretStatus> {
|
||||
const info = await queryService().catch(() => null)
|
||||
if (!info) {
|
||||
return this.patch({ serviceInstalled: false, state: 'stopped', startedAt: null })
|
||||
}
|
||||
|
||||
const installedLine = await readInstalledMarkerLine()
|
||||
const match = installedLine ? /^--comment=rknboost:([^:]+):/.exec(installedLine) : null
|
||||
const strategyId = match?.[1] ?? this.status.strategyId
|
||||
|
||||
return this.patch({
|
||||
serviceInstalled: true,
|
||||
autoStart: info.startMode === 'Auto',
|
||||
strategyId,
|
||||
state: info.state === 'Running' ? 'running' : 'stopped',
|
||||
// Точный момент запуска нам недоступен — служба могла подняться ещё при загрузке
|
||||
// системы, до старта приложения. Берём момент обнаружения, лишь бы поле не пустовало.
|
||||
startedAt: info.state === 'Running' ? new Date().toISOString() : null,
|
||||
error: null
|
||||
})
|
||||
}
|
||||
|
||||
async start(strategyId: string): Promise<ZapretStatus> {
|
||||
this.patch({ state: 'starting', strategyId, error: null })
|
||||
|
||||
let config: WinwsConfig
|
||||
try {
|
||||
config = winwsConfigFile(strategyId)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return this.patch({ state: 'error', error: message, startedAt: null })
|
||||
}
|
||||
|
||||
try {
|
||||
await assertExecutable(winwsPath())
|
||||
|
||||
const info = await queryService().catch(() => null)
|
||||
if (await needsReconfigure(config, info)) {
|
||||
// Перенастраивать работающую службу нельзя — её сначала надо остановить, иначе
|
||||
// ломается всё сразу: работающий winws держит открытым свой же winws.exe, и
|
||||
// Copy-Item в защищённый каталог падает на занятом файле; --dry-run упирается в
|
||||
// тот же именованный mutex WinDivert («A copy of winws is already running with
|
||||
// the same filter»); а sc config применяется только к СЛЕДУЮЩЕМУ запуску, так что
|
||||
// служба молча продолжила бы работать со старой стратегией.
|
||||
if (info?.state === 'Running') await stopService()
|
||||
|
||||
// --dry-run трогает mutex WinDivert, а не только парсит аргументы, поэтому зовём его
|
||||
// здесь — после остановки и только когда действительно собираемся писать новый конфиг,
|
||||
// а не когда просто подтверждаем уже работающую стратегию.
|
||||
await this.dryRun(config.body)
|
||||
await applyServiceConfig({ configBody: config.body, autoStart: this.status.autoStart })
|
||||
}
|
||||
|
||||
const running = await startService()
|
||||
|
||||
return this.patch({
|
||||
state: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
error: null,
|
||||
serviceInstalled: true,
|
||||
autoStart: running?.startMode === 'Auto'
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
this.appendLog('error', message)
|
||||
return this.patch({ state: 'error', error: message, startedAt: null })
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<ZapretStatus> {
|
||||
this.patch({ state: 'stopping' })
|
||||
try {
|
||||
await stopService()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
this.appendLog('error', message)
|
||||
return this.patch({ state: 'error', error: message })
|
||||
}
|
||||
return this.patch({ state: 'stopped', startedAt: null, error: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Только тип автозапуска службы — если она ещё не установлена, лишь запоминаем выбор
|
||||
* до первого включения (никакого UAC на пустом месте).
|
||||
*/
|
||||
async setAutoStart(enabled: boolean): Promise<ZapretStatus> {
|
||||
if (!this.status.serviceInstalled) {
|
||||
return this.patch({ autoStart: enabled })
|
||||
}
|
||||
await setServiceAutoStart(enabled)
|
||||
return this.patch({ autoStart: enabled })
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
// Служба живёт своей жизнью в системе — выход приложения её не останавливает.
|
||||
}
|
||||
|
||||
/**
|
||||
* `winws.exe --dry-run @<файл>` только проверяет аргументы и завершается без открытия
|
||||
* WinDivert — не требует прав администратора. Ошибку в стратегии видно до запроса UAC,
|
||||
* а не после него.
|
||||
*/
|
||||
private async 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(winwsPath(), ['--dry-run', `@${configPath}`])
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`Стратегия содержит недопустимые параметры winws: ${message}`, {
|
||||
cause: error
|
||||
})
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { readFile, rm } from 'node:fs/promises'
|
||||
import type {
|
||||
ZapretDiagnosticResult,
|
||||
ZapretList,
|
||||
ZapretListId,
|
||||
ZapretLogLine,
|
||||
ZapretStatus,
|
||||
ZapretStrategy
|
||||
} from '../../shared/ipc-contract'
|
||||
import { runDiagnostics } from './diagnostics'
|
||||
import { DarwinEngine } from './engine.darwin'
|
||||
import { ZapretEngine, UnsupportedEngine } from './engine'
|
||||
import { Win32Engine } from './engine.win32'
|
||||
import { readAllLists, resetList, seedLists, writeList } from './lists'
|
||||
import { autoHostlistPath } from './paths'
|
||||
import { listStrategies } from './strategies'
|
||||
|
||||
function createEngine(): ZapretEngine {
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
return new DarwinEngine()
|
||||
case 'win32':
|
||||
return new Win32Engine()
|
||||
default:
|
||||
return new UnsupportedEngine()
|
||||
}
|
||||
}
|
||||
|
||||
const engine = createEngine()
|
||||
|
||||
/**
|
||||
* Готовим списки сайтов и синхронизируем состояние с системой — на macOS чиним прокси
|
||||
* от прошлого аварийного завершения, на Windows подхватываем службу, которая могла работать
|
||||
* ещё до открытия окна (автозапуск с системой). Оба шага нужно сделать до открытия окна.
|
||||
*/
|
||||
export async function recoverZapret(): Promise<void> {
|
||||
await seedLists()
|
||||
if (engine instanceof DarwinEngine) await engine.recoverFromCrash()
|
||||
if (engine instanceof Win32Engine) await engine.sync()
|
||||
}
|
||||
|
||||
export function zapretStatus(): ZapretStatus {
|
||||
return engine.getStatus()
|
||||
}
|
||||
|
||||
export function zapretStrategies(): ZapretStrategy[] {
|
||||
return listStrategies(process.platform)
|
||||
}
|
||||
|
||||
export function zapretLog(): ZapretLogLine[] {
|
||||
return engine.getLog()
|
||||
}
|
||||
|
||||
export function startZapret(strategyId: string): Promise<ZapretStatus> {
|
||||
return engine.start(strategyId)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
export function setZapretAutoStart(enabled: boolean): Promise<ZapretStatus> {
|
||||
return engine.setAutoStart(enabled)
|
||||
}
|
||||
|
||||
/** Вызывать при выходе из приложения — гасит процесс и откатывает системные настройки. */
|
||||
export function disposeZapret(): Promise<void> {
|
||||
return engine.dispose()
|
||||
}
|
||||
|
||||
export function zapretLists(): Promise<ZapretList[]> {
|
||||
return readAllLists()
|
||||
}
|
||||
|
||||
export function saveZapretList(id: ZapretListId, entries: string[]): Promise<ZapretList> {
|
||||
return writeList(id, entries)
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
export function zapretDiagnostics(): Promise<ZapretDiagnosticResult[]> {
|
||||
return runDiagnostics(engine.getStatus())
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { ZapretList, ZapretListId } from '../../shared/ipc-contract'
|
||||
import { bundledListsDir, userListsDir } from './paths'
|
||||
|
||||
/**
|
||||
* Списки сайтов — данные, а не аргументы запуска. Пользователь правит их
|
||||
* полностью свободно (это его решение), включая встроенные из Flowseal.
|
||||
* Рабочая копия лежит в userData и переживает обновление приложения; встроенная
|
||||
* версия в `resources/zapret/lists` только читается — от неё берём исходное
|
||||
* содержимое при первом запуске и при «Сбросить к встроенному».
|
||||
*
|
||||
* Важно: и tpws, и winws сами перечитывают список, если у файла изменились
|
||||
* mtime или размер («automatically reload lists if their modification time or
|
||||
* file size is changed», docs/readme zapret) — перезапускать обход после правки
|
||||
* списка не нужно. Чтобы это гарантированно сработало (а не поймать читателя
|
||||
* на файле, который ещё не дописан), пишем во временный файл и делаем `rename` —
|
||||
* это меняет mtime одним атомарным шагом.
|
||||
*/
|
||||
|
||||
const FILE_NAMES: Record<ZapretListId, string> = {
|
||||
general: 'general.txt',
|
||||
google: 'google.txt',
|
||||
exclude: 'exclude.txt',
|
||||
'ipset-exclude': 'ipset-exclude.txt',
|
||||
'ipset-all': 'ipset-all.txt'
|
||||
}
|
||||
|
||||
export const LIST_IDS = Object.keys(FILE_NAMES) as ZapretListId[]
|
||||
|
||||
/** Списки доменов (--hostlist=...) — в отличие от ipset-exclude/ipset-all, где строки это IP/CIDR. */
|
||||
const HOSTLIST_IDS: ZapretListId[] = ['general', 'google', 'exclude']
|
||||
|
||||
function sha256(text: string): string {
|
||||
return createHash('sha256').update(text).digest('hex')
|
||||
}
|
||||
|
||||
function parseEntries(content: string): string[] {
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.startsWith('#'))
|
||||
}
|
||||
|
||||
function serializeEntries(entries: string[]): string {
|
||||
return entries.join('\n') + (entries.length > 0 ? '\n' : '')
|
||||
}
|
||||
|
||||
const DOMAIN_RE = /^\^?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/
|
||||
const IP_CIDR_RE = /^([0-9a-f:]+|\d{1,3}(\.\d{1,3}){3})(\/\d{1,3})?$/i
|
||||
|
||||
/** Понятная ошибка вместо того, чтобы молча положить в список мусор, который tpws пропустит мимо ушей. */
|
||||
function validateEntry(id: ZapretListId, raw: string): string {
|
||||
const entry = raw.trim().toLowerCase()
|
||||
if (!entry) throw new Error('Пустая строка в списке.')
|
||||
|
||||
const isHostlist = HOSTLIST_IDS.includes(id)
|
||||
const pattern = isHostlist ? DOMAIN_RE : IP_CIDR_RE
|
||||
if (!pattern.test(entry)) {
|
||||
throw new Error(
|
||||
isHostlist
|
||||
? `«${raw}» не похоже на домен (пример: example.com или ^example.com для точного совпадения).`
|
||||
: `«${raw}» не похоже на IP-адрес или подсеть (пример: 192.168.0.0/16).`
|
||||
)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
function bundledPath(id: ZapretListId): string {
|
||||
return join(bundledListsDir(), FILE_NAMES[id])
|
||||
}
|
||||
|
||||
function userPath(id: ZapretListId): string {
|
||||
return join(userListsDir(), FILE_NAMES[id])
|
||||
}
|
||||
|
||||
/** Атомарная запись: временный файл в том же каталоге (для atomic rename на одной ФС) + rename. */
|
||||
async function writeAtomic(path: string, content: string): Promise<void> {
|
||||
const tmp = `${path}.${randomUUID()}.tmp`
|
||||
await writeFile(tmp, content, 'utf8')
|
||||
await rename(tmp, path)
|
||||
}
|
||||
|
||||
async function readBundled(id: ZapretListId): Promise<string> {
|
||||
return readFile(bundledPath(id), 'utf8')
|
||||
}
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await readFile(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Копирует встроенные списки в userData при первом запуске. Идемпотентно — уже существующие не трогает. */
|
||||
export async function seedLists(): Promise<void> {
|
||||
await mkdir(userListsDir(), { recursive: true })
|
||||
for (const id of LIST_IDS) {
|
||||
if (await fileExists(userPath(id))) continue
|
||||
await writeAtomic(userPath(id), await readBundled(id))
|
||||
}
|
||||
}
|
||||
|
||||
async function toZapretList(id: ZapretListId): Promise<ZapretList> {
|
||||
const [userContent, bundledContent] = await Promise.all([
|
||||
readFile(userPath(id), 'utf8').catch(() => readBundled(id)),
|
||||
readBundled(id)
|
||||
])
|
||||
|
||||
return {
|
||||
id,
|
||||
entries: parseEntries(userContent),
|
||||
// Список не тронут пользователем относительно текущей встроенной версии.
|
||||
isDefault: sha256(userContent) === sha256(bundledContent)
|
||||
}
|
||||
}
|
||||
|
||||
export async function readList(id: ZapretListId): Promise<ZapretList> {
|
||||
return toZapretList(id)
|
||||
}
|
||||
|
||||
export async function readAllLists(): Promise<ZapretList[]> {
|
||||
return Promise.all(LIST_IDS.map(readList))
|
||||
}
|
||||
|
||||
export async function writeList(id: ZapretListId, rawEntries: string[]): Promise<ZapretList> {
|
||||
const seen = new Set<string>()
|
||||
const entries: string[] = []
|
||||
for (const raw of rawEntries) {
|
||||
const entry = validateEntry(id, raw)
|
||||
if (seen.has(entry)) continue
|
||||
seen.add(entry)
|
||||
entries.push(entry)
|
||||
}
|
||||
|
||||
await writeAtomic(userPath(id), serializeEntries(entries))
|
||||
return toZapretList(id)
|
||||
}
|
||||
|
||||
export async function resetList(id: ZapretListId): Promise<ZapretList> {
|
||||
await writeAtomic(userPath(id), await readBundled(id))
|
||||
return toZapretList(id)
|
||||
}
|
||||
|
||||
/** Путь к рабочему списку — нужен `strategies.ts`, чтобы подставить его в аргументы движка. */
|
||||
export function listFilePath(id: ZapretListId): string {
|
||||
return userPath(id)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { app } from 'electron'
|
||||
import { access, constants } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
/**
|
||||
* Бинарники zapret кладёт `scripts/fetch-zapret.mjs` в `resources/zapret`.
|
||||
* В упакованном приложении electron-builder переносит их в `extraResources`,
|
||||
* то есть в `process.resourcesPath/zapret` (вне asar — запускать код из архива нельзя).
|
||||
*/
|
||||
function zapretRoot(): string {
|
||||
return app.isPackaged
|
||||
? join(process.resourcesPath, 'zapret')
|
||||
: resolve(app.getAppPath(), 'resources', 'zapret')
|
||||
}
|
||||
|
||||
/** macOS: universal-бинарник tpws (arm64 + x86_64). */
|
||||
export function tpwsPath(): string {
|
||||
return join(zapretRoot(), 'darwin', 'tpws')
|
||||
}
|
||||
|
||||
/** Windows: winws.exe рядом с cygwin1.dll и WinDivert. */
|
||||
export function winwsPath(): string {
|
||||
return join(zapretRoot(), 'win32', 'x64', 'winws.exe')
|
||||
}
|
||||
|
||||
/** Встроенные списки (из Flowseal/zapret-discord-youtube, см. scripts/fetch-zapret.mjs) — только для чтения. */
|
||||
export function bundledListsDir(): string {
|
||||
return join(zapretRoot(), 'lists')
|
||||
}
|
||||
|
||||
/**
|
||||
* Рабочая копия списков — здесь их правит пользователь. Живёт вне сборки
|
||||
* (userData), чтобы пережить обновление приложения: встроенные списки в
|
||||
* `bundledListsDir()` при апдейте перезатираются, а эта копия — нет.
|
||||
*/
|
||||
export function userListsDir(): string {
|
||||
return join(app.getPath('userData'), 'zapret-lists')
|
||||
}
|
||||
|
||||
/** Fake-пакеты (TLS ClientHello, QUIC initial, ...) для стратегий winws. */
|
||||
export function fakesDir(): string {
|
||||
return join(zapretRoot(), 'fakes')
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows: служба `rknboost-zapret` работает от LocalSystem, поэтому её конфиг не может
|
||||
* жить в userData обычного пользователя — нужен каталог машинного уровня. `%ProgramData%`
|
||||
* почти всегда задан; `C:\ProgramData` — тот же путь, на который он указывает по умолчанию.
|
||||
*/
|
||||
export function serviceDataDir(): string {
|
||||
return join(process.env.ProgramData ?? 'C:\\ProgramData', 'rknboost', 'zapret')
|
||||
}
|
||||
|
||||
/** Файл аргументов `winws.exe @strategy.cfg` — пишется только повышенным скриптом службы. */
|
||||
export function serviceConfigPath(): string {
|
||||
return join(serviceDataDir(), 'strategy.cfg')
|
||||
}
|
||||
|
||||
/**
|
||||
* Защищённая копия `winws.exe` (+ cygwin1.dll, WinDivert.dll, WinDivert64.sys) — на неё,
|
||||
* а не на оригинал из папки установки приложения, указывает служба. Установка теперь
|
||||
* per-user (`%LocalAppData%`), эта папка доступна на запись обычному пользователю —
|
||||
* если бы SYSTEM-служба запускала winws.exe прямо оттуда, кто угодно, работающий под
|
||||
* тем же пользователем, мог бы подменить бинарник и получить код с правами SYSTEM.
|
||||
* Копию (как и `strategy.cfg`) кладёт и защищает ACL только повышенный скрипт
|
||||
* (`applyServiceConfig` в service.win32.ts), рядом, в том же serviceDataDir().
|
||||
*/
|
||||
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`.
|
||||
*/
|
||||
export async function assertExecutable(path: string): Promise<void> {
|
||||
try {
|
||||
await access(path, constants.X_OK)
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Не найден исполняемый файл zapret: ${path}\n` +
|
||||
'Скачайте бинарники командой «npm run zapret:fetch».'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { app } from 'electron'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { readFile, writeFile, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const run = promisify(execFile)
|
||||
|
||||
/**
|
||||
* Домены и адреса, которые не должны идти через прокси. Без этого списка отвалится
|
||||
* всё локальное: tpws по документации запрещает подключения к тому же хосту,
|
||||
* на котором работает сам, включая localhost.
|
||||
*/
|
||||
const BYPASS = ['*.local', '169.254/16', 'localhost', '127.0.0.1', '::1']
|
||||
|
||||
interface ServiceProxyState {
|
||||
service: string
|
||||
enabled: boolean
|
||||
server: string
|
||||
port: string
|
||||
bypass: string[]
|
||||
}
|
||||
|
||||
/** Что было до нас — чтобы вернуть настройки ровно в исходное состояние. */
|
||||
interface ProxySnapshot {
|
||||
takenAt: string
|
||||
services: ServiceProxyState[]
|
||||
}
|
||||
|
||||
function snapshotPath(): string {
|
||||
return join(app.getPath('userData'), 'proxy-snapshot.json')
|
||||
}
|
||||
|
||||
/**
|
||||
* `networksetup` требует членства в группе admin. На обычном Mac пользователь в ней
|
||||
* состоит и пароль не спрашивается. Если же включена политика «требовать пароль
|
||||
* администратора для системных настроек», команда падает — тогда повторяем её через
|
||||
* системный диалог авторизации.
|
||||
*/
|
||||
async function networksetup(args: string[]): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await run('/usr/sbin/networksetup', args)
|
||||
return stdout
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (!/root|denied|permission/i.test(message)) throw error
|
||||
|
||||
const command = ['/usr/sbin/networksetup', ...args]
|
||||
.map((part) => `'${part.replaceAll("'", `'\\''`)}'`)
|
||||
.join(' ')
|
||||
|
||||
try {
|
||||
const { stdout } = await run('/usr/bin/osascript', [
|
||||
'-e',
|
||||
`do shell script "${command.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}" with administrator privileges`
|
||||
])
|
||||
return stdout
|
||||
} 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Сетевые сервисы, кроме выключенных: у тех `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
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreService(state: ServiceProxyState): Promise<void> {
|
||||
if (state.enabled && state.server) {
|
||||
await networksetup([
|
||||
'-setsocksfirewallproxy',
|
||||
state.service,
|
||||
state.server,
|
||||
state.port || '1080'
|
||||
])
|
||||
await networksetup(['-setsocksfirewallproxystate', state.service, 'on'])
|
||||
} else {
|
||||
await networksetup(['-setsocksfirewallproxystate', state.service, 'off'])
|
||||
}
|
||||
|
||||
// Пустой список сбрасывается специальным словом Empty — иначе останется наш.
|
||||
const bypass = state.bypass.length > 0 ? state.bypass : ['Empty']
|
||||
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()
|
||||
if (!snapshot) return false
|
||||
|
||||
for (const state of snapshot.services) {
|
||||
await restoreService(state)
|
||||
}
|
||||
await rm(snapshotPath(), { force: true })
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Снимок остался с прошлого запуска — значит приложение завершилось аварийно,
|
||||
* не сняв прокси, и система сейчас смотрит в несуществующий socks. Чиним на старте.
|
||||
*/
|
||||
export async function hasStaleSnapshot(): Promise<boolean> {
|
||||
return (await loadSnapshot()) !== null
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { app } from 'electron'
|
||||
import {
|
||||
ELEVATED_TASK_FLAG,
|
||||
TASK_FILES,
|
||||
isElevated,
|
||||
runElevatedServiceTask
|
||||
} from './elevated-task.win32'
|
||||
import { serviceConfigPath, serviceDataDir, winwsPath } from './paths'
|
||||
|
||||
const run = promisify(execFile)
|
||||
|
||||
export const SERVICE_NAME = 'rknboost-zapret'
|
||||
|
||||
const POLL_INTERVAL_MS = 300
|
||||
const POLL_TIMEOUT_MS = 10000
|
||||
|
||||
/** Код возврата Windows для «пользователь отклонил запрос UAC» (ERROR_CANCELLED). */
|
||||
const UAC_CANCELLED_CODE = 1223
|
||||
|
||||
/**
|
||||
* `sc.exe` выходит с самим кодом ошибки Win32, и это единственный надёжный способ их
|
||||
* различать: текст сообщения локализован, на русской Windows никакой `/not started/`
|
||||
* не сматчится.
|
||||
*/
|
||||
const ERROR_SERVICE_ALREADY_RUNNING = 1056
|
||||
const ERROR_SERVICE_NOT_ACTIVE = 1062
|
||||
|
||||
export type ServiceState = 'Running' | 'Stopped' | 'StartPending' | 'StopPending' | 'Other'
|
||||
export type ServiceStartMode = 'Auto' | 'Manual' | 'Disabled' | 'Other'
|
||||
|
||||
export interface ServiceInfo {
|
||||
state: ServiceState
|
||||
startMode: ServiceStartMode
|
||||
/** Значение `PathName` из CIM — то, что реально прописано в `binPath` службы. */
|
||||
binPath: string
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function exitCodeOf(error: unknown): number {
|
||||
const code = (error as { code?: number | string } | undefined)?.code
|
||||
return typeof code === 'number' ? code : 1
|
||||
}
|
||||
|
||||
function normalizeState(raw: string | undefined): ServiceState {
|
||||
switch (raw) {
|
||||
case 'Running':
|
||||
return 'Running'
|
||||
case 'Stopped':
|
||||
return 'Stopped'
|
||||
case 'Start Pending':
|
||||
return 'StartPending'
|
||||
case 'Stop Pending':
|
||||
return 'StopPending'
|
||||
default:
|
||||
return 'Other'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStartMode(raw: string | undefined): ServiceStartMode {
|
||||
switch (raw) {
|
||||
case 'Auto':
|
||||
return 'Auto'
|
||||
case 'Manual':
|
||||
return 'Manual'
|
||||
case 'Disabled':
|
||||
return 'Disabled'
|
||||
default:
|
||||
return 'Other'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Опрос через `Get-CimInstance Win32_Service`, а не парсинг вывода `sc query` — у CIM
|
||||
* `State`/`StartMode` это английские константы независимо от языка Windows, а `sc` печатает
|
||||
* локализованный текст, который парсить надёжно нельзя.
|
||||
*/
|
||||
export async function queryService(): Promise<ServiceInfo | null> {
|
||||
const script =
|
||||
`Get-CimInstance Win32_Service -Filter "Name='${SERVICE_NAME}'" | ` +
|
||||
'Select-Object State,StartMode,PathName | ConvertTo-Json -Compress'
|
||||
|
||||
let stdout: string
|
||||
try {
|
||||
;({ stdout } = await run('powershell.exe', ['-NoProfile', '-Command', script]))
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Не удалось опросить службу: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
|
||||
const trimmed = stdout.trim()
|
||||
if (!trimmed || trimmed === 'null') return null
|
||||
|
||||
const raw = JSON.parse(trimmed) as { State?: string; StartMode?: string; PathName?: string }
|
||||
return {
|
||||
state: normalizeState(raw.State),
|
||||
startMode: normalizeStartMode(raw.StartMode),
|
||||
binPath: raw.PathName ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
function explainScError(error: unknown, verb: string): string {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (/access is denied/i.test(message)) {
|
||||
return (
|
||||
`Не удалось ${verb} службу: отказано в доступе. Похоже, права для обычных пользователей ` +
|
||||
'не выданы — переустановите приложение.'
|
||||
)
|
||||
}
|
||||
return `Не удалось ${verb} службу: ${message}`
|
||||
}
|
||||
|
||||
async function pollUntil(
|
||||
predicate: (info: ServiceInfo | null) => boolean
|
||||
): Promise<ServiceInfo | null> {
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS
|
||||
for (;;) {
|
||||
const info = await queryService()
|
||||
if (predicate(info)) return info
|
||||
if (Date.now() >= deadline) return info
|
||||
await sleep(POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `sc start` только просит SCM перевести службу в running и возвращается сразу — если не
|
||||
* дождаться реального состояния, свежепадающая служба (например, WinDivert не загрузился)
|
||||
* выглядела бы как успех.
|
||||
*/
|
||||
export async function startService(): Promise<ServiceInfo | null> {
|
||||
try {
|
||||
await run('sc.exe', ['start', SERVICE_NAME])
|
||||
} catch (error) {
|
||||
// «Служба уже запущена» — цель достигнута, не ошибка: так бывает, когда пользователь
|
||||
// выбрал в списке ту же стратегию, что уже работает, и перенастройка не понадобилась.
|
||||
if (exitCodeOf(error) !== ERROR_SERVICE_ALREADY_RUNNING) {
|
||||
throw new Error(explainScError(error, 'запустить'), { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
const info = await pollUntil((i) => i?.state === 'Running' || i?.state === 'Stopped')
|
||||
if (info?.state !== 'Running') {
|
||||
throw new Error(
|
||||
`Служба запущена системой, но не перешла в рабочее состояние (текущее: ` +
|
||||
`${info?.state ?? 'неизвестно'}). Возможная причина — не загрузился драйвер WinDivert ` +
|
||||
'(включён Secure Boot, драйвер без действующей подписи, или Windows 7 без нужного обновления).'
|
||||
)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
export async function stopService(): Promise<ServiceInfo | null> {
|
||||
try {
|
||||
await run('sc.exe', ['stop', SERVICE_NAME])
|
||||
} catch (error) {
|
||||
// «Служба не запущена» — не ошибка с точки зрения пользователя, тот же паттерн, что в
|
||||
// engine.darwin.ts. Раньше это ловилось регуляркой по тексту `/not.*started/` — на русской
|
||||
// Windows она не срабатывала никогда, и остановка уже остановленной службы падала ошибкой.
|
||||
if (exitCodeOf(error) !== ERROR_SERVICE_NOT_ACTIVE) {
|
||||
throw new Error(explainScError(error, 'остановить'), { cause: error })
|
||||
}
|
||||
}
|
||||
// `pollUntil` возвращает последнее состояние и по истечении таймаута, независимо от предиката,
|
||||
// поэтому результат обязательно проверяем. Иначе «остановка», не уложившаяся в POLL_TIMEOUT_MS,
|
||||
// молча сходила бы за успешную — и вызывающий код (перенастройка стратегии в engine.win32.ts)
|
||||
// пошёл бы копировать winws.exe поверх ещё работающего, ровно в ту ошибку, от которой
|
||||
// остановка и защищает.
|
||||
const info = await pollUntil((i) => i === null || i.state === 'Stopped')
|
||||
if (info && info.state !== 'Stopped') {
|
||||
throw new Error(
|
||||
`Служба не остановилась за ${POLL_TIMEOUT_MS / 1000} с (состояние: ${info.state}).`
|
||||
)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
/**
|
||||
* PowerShell 5.1 (то, что стоит на Windows по умолчанию) требует BOM, чтобы правильно
|
||||
* прочитать кириллицу в UTF-8 файле скрипта — без BOM она превращается в кракозябры.
|
||||
*/
|
||||
const UTF8_BOM = '\uFEFF'
|
||||
|
||||
function withBom(script: string): string {
|
||||
return UTF8_BOM + script
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняется без прав администратора: запрашивает повышение через `Start-Process -Verb RunAs`
|
||||
* и ждёт завершения. Если пользователь отклонил UAC, `Start-Process` бросает исключение —
|
||||
* ловим его и выходим с ERROR_CANCELLED, чтобы вызывающий код отличил отказ от реальной ошибки.
|
||||
*
|
||||
* Повышаем САМО приложение (`$AppPath` с флагом `--elevated-service-task`), а не powershell.exe:
|
||||
* Windows пишет в окне UAC имя того файла, который повышают, и запрос от «Windows PowerShell»
|
||||
* с подписью Microsoft выглядит так, будто права просит что-то постороннее. Повышенный
|
||||
* экземпляр приложения уже сам, без второго UAC, запускает worker.ps1 из того же каталога
|
||||
* (см. runElevatedServiceTask в elevated-task.win32.ts).
|
||||
*
|
||||
* `$AppDir` нужен только в dev-режиме: там `$AppPath` — это electron.exe, которому каталог
|
||||
* приложения передаётся первым аргументом. В собранном приложении он пустой.
|
||||
*/
|
||||
const OUTER_SCRIPT = `
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$AppPath,
|
||||
[Parameter(Mandatory=$true)][string]$TaskDir,
|
||||
[string]$AppDir = ''
|
||||
)
|
||||
|
||||
try {
|
||||
$argList = @()
|
||||
if ($AppDir) { $argList += $AppDir }
|
||||
$argList += @('${ELEVATED_TASK_FLAG}', $TaskDir)
|
||||
|
||||
$p = Start-Process -FilePath $AppPath -Verb RunAs -Wait -PassThru -ArgumentList $argList
|
||||
exit $p.ExitCode
|
||||
}
|
||||
catch {
|
||||
exit ${UAC_CANCELLED_CODE}
|
||||
}
|
||||
`
|
||||
|
||||
/**
|
||||
* Работает от администратора. Три действия: установить/перенастроить службу (`install`),
|
||||
* поменять только тип автозапуска (`setAutoStart`), снять службу целиком (`remove`).
|
||||
* Результат пишет в `$ResultPath` — код возврата процесса тут ненадёжен (PowerShell не всегда
|
||||
* пробрасывает его наружу через несколько уровней Start-Process), а JSON-файл однозначен.
|
||||
*/
|
||||
const WORKER_SCRIPT = `
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$PayloadPath,
|
||||
[Parameter(Mandatory=$true)][string]$ResultPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ServiceName = '${SERVICE_NAME}'
|
||||
|
||||
function Write-Result($ok, $errorMessage) {
|
||||
$obj = @{ ok = $ok; error = $errorMessage }
|
||||
$json = $obj | ConvertTo-Json -Compress
|
||||
[System.IO.File]::WriteAllText($ResultPath, $json, (New-Object System.Text.UTF8Encoding($false)))
|
||||
}
|
||||
|
||||
# Прямой вызов "& sc.exe create ... binPath= $binPath" ломает значения с embedded-кавычками:
|
||||
# PowerShell (& / call operator, и точно так же Start-Process -ArgumentList) перепаковывает
|
||||
# аргументы по-своему и режет уже готовую строку binPath на два токена по пробелу перед "@" —
|
||||
# sc.exe получает лишний позиционный аргумент и падает с кодом 1639 (Invalid command line),
|
||||
# хотя та же строка, набранная руками в cmd.exe, разбирается верно. ProcessStartInfo.Arguments —
|
||||
# нижний уровень (.NET передаёт эту строку в CreateProcess как есть, без повторной токенизации
|
||||
# самим PowerShell) — единственный способ гарантированно передать её той же строкой.
|
||||
function Invoke-ScRaw($rawArgs) {
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = 'sc.exe'
|
||||
$psi.Arguments = $rawArgs
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$psi.CreateNoWindow = $true
|
||||
$proc = [System.Diagnostics.Process]::Start($psi)
|
||||
$stdout = $proc.StandardOutput.ReadToEnd()
|
||||
$stderr = $proc.StandardError.ReadToEnd()
|
||||
$proc.WaitForExit()
|
||||
return [pscustomobject]@{ ExitCode = $proc.ExitCode; Output = ("$stdout $stderr").Trim() }
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = Get-Content -Raw -Path $PayloadPath | ConvertFrom-Json
|
||||
|
||||
switch ($payload.action) {
|
||||
'install' {
|
||||
$dir = $payload.serviceDataDir
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
|
||||
# Снимаем наследование прав каталога: конфиг читает служба от SYSTEM, писать в него
|
||||
# должны только SYSTEM и администраторы — иначе обычный пользователь мог бы подменить
|
||||
# аргументы winws, которые выполняются с правами SYSTEM.
|
||||
& icacls $dir /inheritance:r | Out-Null
|
||||
& icacls $dir /grant '*S-1-5-18:(OI)(CI)F' | Out-Null
|
||||
& icacls $dir /grant '*S-1-5-32-544:(OI)(CI)F' | Out-Null
|
||||
& icacls $dir /grant '*S-1-5-32-545:(OI)(CI)RX' | Out-Null
|
||||
|
||||
$configPath = $payload.configPath
|
||||
# UTF-8 без BOM — этого требует сам winws при чтении @<config_file> (docs/windows.md zapret).
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($configPath, $payload.configBody, $utf8NoBom)
|
||||
|
||||
# Служба должна ссылаться на копию winws.exe в этом же защищённом каталоге, а не на
|
||||
# оригинал в папке установки приложения: установка теперь per-user (%LocalAppData%),
|
||||
# эта папка доступна на запись обычному пользователю — SYSTEM-служба, ссылающаяся туда,
|
||||
# была бы локальным повышением привилегий. Копируем winws.exe и рядом лежащие
|
||||
# cygwin1.dll/WinDivert.dll/WinDivert64.sys, от них он зависит при запуске.
|
||||
$sourceDir = Split-Path -Parent $payload.winwsPath
|
||||
$protectedWinws = Join-Path $dir 'winws.exe'
|
||||
foreach ($name in @('winws.exe', 'cygwin1.dll', 'WinDivert.dll', 'WinDivert64.sys')) {
|
||||
Copy-Item -Path (Join-Path $sourceDir $name) -Destination (Join-Path $dir $name) -Force
|
||||
}
|
||||
|
||||
# sc.exe читает binPath как ОДИН аргумент — значит всю строку нужно взять в одну пару
|
||||
# кавычек, а внутренние кавычки экранировать как \\" (без этого CommandLineToArgvW режет
|
||||
# значение на два токена по пробелу перед "@", и sc create падает с кодом 1639).
|
||||
# Тот же приём — в service_create.cmd из zapret-win-bundle.
|
||||
$binPath = '"\\"' + $protectedWinws + '\\" @\\"' + $configPath + '\\""'
|
||||
$startType = if ($payload.autoStart) { 'auto' } else { 'demand' }
|
||||
|
||||
& sc.exe query $ServiceName | Out-Null
|
||||
$serviceExists = ($LASTEXITCODE -eq 0)
|
||||
|
||||
if ($serviceExists) {
|
||||
$scArgs = 'config ' + $ServiceName + ' binPath= ' + $binPath + ' start= ' + $startType
|
||||
$result = Invoke-ScRaw $scArgs
|
||||
if ($result.ExitCode -ne 0) { throw "sc config завершился с кодом $($result.ExitCode) : $($result.Output)" }
|
||||
} else {
|
||||
$scArgs = 'create ' + $ServiceName + ' binPath= ' + $binPath + ' start= ' + $startType + ' DisplayName= "RKNboost: обход блокировок"'
|
||||
$result = Invoke-ScRaw $scArgs
|
||||
if ($result.ExitCode -ne 0) { throw "sc create завершился с кодом $($result.ExitCode) : $($result.Output)" }
|
||||
}
|
||||
& sc.exe description $ServiceName 'Служба RKNboost: обходит блокировки через winws/WinDivert.' | Out-Null
|
||||
|
||||
# Разрешаем интерактивным пользователям запуск/остановку/чтение статуса службы, но не
|
||||
# смену конфигурации и не смену прав — иначе любой пользователь на машине мог бы подменить
|
||||
# аргументы, с которыми служба запускает winws от имени SYSTEM.
|
||||
$sddlLines = & sc.exe sdshow $ServiceName
|
||||
$sddl = ($sddlLines | Where-Object { $_ -match '^D:' } | Select-Object -First 1)
|
||||
if (-not $sddl) { throw 'sc sdshow не вернул дескриптор безопасности' }
|
||||
$sddl = $sddl -replace '\\(A;;[^;]*;;;IU\\)', ''
|
||||
$sddl = $sddl -replace '^D:', 'D:(A;;CCLCSWRPWPLORC;;;IU)'
|
||||
$sdsetOut = & sc.exe sdset $ServiceName $sddl 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { throw "sc sdset завершился с кодом $LASTEXITCODE : $($sdsetOut.Trim())" }
|
||||
|
||||
Write-Result $true $null
|
||||
}
|
||||
'setAutoStart' {
|
||||
$startType = if ($payload.autoStart) { 'auto' } else { 'demand' }
|
||||
$out = & sc.exe config $ServiceName start= $startType 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { throw "sc config завершился с кодом $LASTEXITCODE : $($out.Trim())" }
|
||||
Write-Result $true $null
|
||||
}
|
||||
'remove' {
|
||||
& sc.exe stop $ServiceName 2>$null | Out-Null
|
||||
Start-Sleep -Milliseconds 500
|
||||
& sc.exe delete $ServiceName | Out-Null
|
||||
if (Test-Path $payload.serviceDataDir) {
|
||||
Remove-Item -Path $payload.serviceDataDir -Recurse -Force
|
||||
}
|
||||
Write-Result $true $null
|
||||
}
|
||||
default {
|
||||
throw "Неизвестное действие: $($payload.action)"
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Result $false $_.Exception.Message
|
||||
exit 1
|
||||
}
|
||||
`
|
||||
|
||||
async function readResult(path: string): Promise<{ ok: boolean; error: string | null } | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(path, 'utf8')) as { ok: boolean; error: string | null }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Пишет payload во временный каталог и выполняет `worker.ps1`.
|
||||
*
|
||||
* Если приложение уже запущено от администратора (в собранном виде — всегда), worker
|
||||
* запускается напрямую: ни UAC, ни лишних процессов. Иначе — не более одного запроса UAC
|
||||
* на всё действие: `outer.ps1` (без прав) через `Start-Process -Verb RunAs` поднимает
|
||||
* приложение с флагом задачи, а уже оно запускает worker.
|
||||
*
|
||||
* Результат в обоих случаях читаем из `result.json`, а не из кода возврата процесса.
|
||||
*/
|
||||
async function runElevated(payload: Record<string, unknown>): Promise<void> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'rknboost-svc-'))
|
||||
const workerPath = join(dir, TASK_FILES.worker)
|
||||
const outerPath = join(dir, 'outer.ps1')
|
||||
const payloadPath = join(dir, TASK_FILES.payload)
|
||||
const resultPath = join(dir, TASK_FILES.result)
|
||||
|
||||
try {
|
||||
await writeFile(workerPath, withBom(WORKER_SCRIPT), 'utf8')
|
||||
await writeFile(payloadPath, JSON.stringify(payload), 'utf8')
|
||||
|
||||
let exitCode = 0
|
||||
if (await isElevated()) {
|
||||
// Приложение и так запущено от администратора (в собранном виде — всегда, см.
|
||||
// requestedExecutionLevel в electron-builder.yml). Повышать нечего: запускаем worker
|
||||
// напрямую, без outer.ps1 и без второго экземпляра приложения на каждое действие.
|
||||
exitCode = await runElevatedServiceTask(dir)
|
||||
} else {
|
||||
await writeFile(outerPath, withBom(OUTER_SCRIPT), 'utf8')
|
||||
try {
|
||||
await run('powershell.exe', [
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-File',
|
||||
outerPath,
|
||||
'-AppPath',
|
||||
process.execPath,
|
||||
'-TaskDir',
|
||||
dir,
|
||||
// В dev electron.exe нужен каталог приложения первым аргументом; в сборке — нет.
|
||||
...(app.isPackaged ? [] : ['-AppDir', app.getAppPath()])
|
||||
])
|
||||
} catch (error) {
|
||||
exitCode = exitCodeOf(error)
|
||||
}
|
||||
}
|
||||
|
||||
if (exitCode === UAC_CANCELLED_CODE) {
|
||||
throw new Error('Установка отменена — запрос на права администратора был отклонён.')
|
||||
}
|
||||
|
||||
const result = await readResult(resultPath)
|
||||
if (!result?.ok) {
|
||||
throw new Error(
|
||||
result?.error
|
||||
? `Не удалось настроить службу: ${result.error}`
|
||||
: `Настройка службы завершилась с ошибкой (код ${exitCode}).`
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApplyServiceConfigOptions {
|
||||
/** Тело `strategy.cfg` — из `winwsConfigFile()` в `strategies.ts`. */
|
||||
configBody: string
|
||||
autoStart: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Установить службу (если её ещё нет) или перенастроить существующую — один запрос UAC.
|
||||
* Заодно копирует `winws.exe` + WinDivert из папки установки приложения (per-user,
|
||||
* доступна на запись обычному пользователю) в защищённый каталог службы — см.
|
||||
* `protectedWinwsPath()` в `paths.ts` — и указывает binPath именно туда.
|
||||
* Саму службу не запускает — вызывающий код (`Win32Engine.start()`) делает это отдельным
|
||||
* вызовом `startService()`, который уже не требует прав благодаря ACL, выставленному здесь.
|
||||
*/
|
||||
export async function applyServiceConfig(options: ApplyServiceConfigOptions): Promise<void> {
|
||||
await runElevated({
|
||||
action: 'install',
|
||||
serviceDataDir: serviceDataDir(),
|
||||
configPath: serviceConfigPath(),
|
||||
configBody: options.configBody,
|
||||
winwsPath: winwsPath(),
|
||||
autoStart: options.autoStart
|
||||
})
|
||||
}
|
||||
|
||||
/** Только тип автозапуска — не трогает аргументы winws, отдельный (более лёгкий) UAC-запрос. */
|
||||
export async function setServiceAutoStart(autoStart: boolean): Promise<void> {
|
||||
await runElevated({ action: 'setAutoStart', autoStart })
|
||||
}
|
||||
|
||||
/** Используется деинсталлятором (`build/installer.nsh`) и на случай ручной переустановки. */
|
||||
export async function removeService(): Promise<void> {
|
||||
await runElevated({ action: 'remove', serviceDataDir: serviceDataDir() })
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
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) работает на уровне пакетов через WinDivert. Его пресеты — 22 варианта
|
||||
* из Flowseal/zapret-discord-youtube, перенесённые в `strategies.win32.generated.ts`
|
||||
* генератором `scripts/import-flowseal-strategies.mjs` (не батниками — кодом).
|
||||
*
|
||||
* Оба движка используют одни и те же списки сайтов (`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: []
|
||||
}
|
||||
]
|
||||
|
||||
/** Стратегия по умолчанию — своя на каждой платформе, наборы id не пересекаются. */
|
||||
export function defaultStrategyId(platform: NodeJS.Platform): string {
|
||||
return platform === 'win32' ? 'general' : 'split'
|
||||
}
|
||||
|
||||
function resolvePlaceholders(arg: string): string {
|
||||
return arg
|
||||
.replaceAll(LISTS_PLACEHOLDER, userListsDir())
|
||||
.replaceAll(FAKES_PLACEHOLDER, fakesDir())
|
||||
.replaceAll(AUTO_PLACEHOLDER, autoHostlistPath())
|
||||
}
|
||||
|
||||
/** Пресеты, применимые к текущей платформе. */
|
||||
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 WIN32_STRATEGIES.map(({ id, name }) => ({
|
||||
id,
|
||||
name,
|
||||
// У Flowseal нет описаний стратегий — только имена вариантов, подобранных под провайдера.
|
||||
description: 'Стратегия из подборки Flowseal/zapret-discord-youtube.',
|
||||
platforms: ['win32']
|
||||
}))
|
||||
}
|
||||
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.exe @strategy.cfg`, ограничение размера у самого winws — 16 КБ. */
|
||||
const MAX_CONFIG_FILE_BYTES = 16000
|
||||
|
||||
/**
|
||||
* winws разбирает файл конфигурации через POSIX `wordexp()` (см. `nfq/nfqws.c` в поставке
|
||||
* zapret) — без кавычек он режет аргумент по пробелам и съедает обратные слэши в путях вида
|
||||
* `C:\Users\...`. Каждый аргумент поэтому кладём в одинарные кавычки, а `'` внутри значения
|
||||
* экранируем как `'\''` — тем же приёмом, что и `networksetup()` в `proxy.darwin.ts` для shell.
|
||||
*/
|
||||
function quoteConfigArg(arg: string): string {
|
||||
return `'${arg.replaceAll("'", `'\\''`)}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* Первая строка — маркер: по нему `Win32Engine.sync()` узнаёт, какая стратегия уже установлена
|
||||
* в службу, не перечитывая все аргументы. Сам маркер — штатная опция `--comment` winws,
|
||||
* в работу процесса не вмешивается.
|
||||
*/
|
||||
function configMarker(strategyId: string, body: string): string {
|
||||
const hash = createHash('sha256').update(body).digest('hex').slice(0, 12)
|
||||
return `--comment=rknboost:${strategyId}:${hash}`
|
||||
}
|
||||
|
||||
export interface WinwsConfig {
|
||||
/** Содержимое `strategy.cfg` — UTF-8 без BOM, как требует winws (docs/windows.md zapret). */
|
||||
body: string
|
||||
/** Маркер первой строки — сравнить с уже установленным, чтобы понять, нужна ли перенастройка. */
|
||||
marker: string
|
||||
}
|
||||
|
||||
/** Рендерит стратегию в файл аргументов для `winws.exe @<file>` — см. `WinwsConfig`. */
|
||||
export function winwsConfigFile(strategyId: string): WinwsConfig {
|
||||
const args = winwsArgs(strategyId).map(quoteConfigArg).join('\n')
|
||||
const marker = configMarker(strategyId, args)
|
||||
const body = `${marker}\n${args}\n`
|
||||
|
||||
if (Buffer.byteLength(body, 'utf8') > MAX_CONFIG_FILE_BYTES) {
|
||||
throw new Error(
|
||||
`Стратегия «${strategyId}» не помещается в лимит winws на файл конфигурации (16 КБ).`
|
||||
)
|
||||
}
|
||||
|
||||
return { body, marker }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user