Initial commit

This commit is contained in:
2026-09-03 03:48:00 +03:00
commit 635c920be7
57 changed files with 14375 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
import { access, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { app, dialog, type BrowserWindow } from 'electron'
import { GITHUB_URL } from '../shared/links'
import { openExternal } from './window'
/**
* Метка «приветствие уже показывали». Лежит в userData, поэтому переживает обновление
* приложения — окно показывается один раз за всё время, а не после каждой установки.
*/
function markerPath(): string {
return join(app.getPath('userData'), 'first-run.json')
}
async function alreadyShown(): Promise<boolean> {
try {
await access(markerPath())
return true
} catch {
return false
}
}
/**
* Приветственное окно при первом запуске: просьба поддержать проект звёздочкой.
*
* Рисует его сама ОС (`dialog.showMessageBox`), а не HTML-страница: так окно получается
* маленьким, модальным к главному и выглядит системным, а не как ещё один экран приложения.
*/
export async function showFirstRunSupportDialog(window: BrowserWindow): Promise<void> {
if (await alreadyShown()) return
// Метку ставим ДО показа, а не после ответа: если приложение закроют прямо на диалоге,
// лучше не показать его больше никогда, чем спрашивать при каждом запуске.
try {
await writeFile(markerPath(), JSON.stringify({ shownAt: new Date().toISOString() }), 'utf8')
} catch {
// Не смогли записать метку — покажем окно ещё раз в следующий запуск. Не повод падать.
}
const { response } = await dialog.showMessageBox(window, {
type: 'info',
buttons: ['Перейти', 'Закрыть'],
defaultId: 0,
cancelId: 1,
noLink: true,
title: 'Поддержите RKNboost',
message: 'Поддержите проект',
detail: 'Поддержите проект звездочкой на GitHub, и расскажите друзьям :З'
})
// openExternal (window.ts) проверяет протокол по белому списку — ссылка уходит
// в системный браузер, а не открывается внутри приложения.
if (response === 0) openExternal(GITHUB_URL)
}
+77
View File
@@ -0,0 +1,77 @@
import { app, BrowserWindow } from 'electron'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { createWindow } from './window'
import { showFirstRunSupportDialog } from './first-run'
import { registerIpcHandlers } from './ipc'
import { disposeZapret, recoverZapret } from './zapret'
import { elevatedTaskDir, runElevatedServiceTask } from './zapret/elevated-task.win32'
const APP_ID = 'com.rknboost.app'
// Приложение может быть запущено само собой через UAC как «повышенный помощник» — тогда
// оно не открывает окно и не занимает единственный экземпляр (иначе отдало бы фокус уже
// запущенной обычной копии и вышло, не сделав работу), а выполняет задачу службы и выходит.
const elevatedTask = process.platform === 'win32' ? elevatedTaskDir(process.argv) : null
if (elevatedTask) {
void app.whenReady().then(async () => {
app.exit(await runElevatedServiceTask(elevatedTask))
})
} else if (!app.requestSingleInstanceLock()) {
// Одна копия приложения на систему: второй запуск отдаёт фокус уже открытому окну.
app.quit()
} else {
app.on('second-instance', () => {
const [window] = BrowserWindow.getAllWindows()
if (!window) {
createWindow()
return
}
if (window.isMinimized()) window.restore()
window.focus()
})
app.whenReady().then(() => {
// Нужно Windows для группировки в панели задач и уведомлений.
electronApp.setAppUserModelId(APP_ID)
// F12 открывает DevTools в dev-режиме, Ctrl/Cmd+R не перезагружает прод-сборку.
app.on('browser-window-created', (_event, window) => {
optimizer.watchWindowShortcuts(window)
})
registerIpcHandlers()
// На macOS чинит прокси, оставшийся от аварийно завершённого прошлого запуска,
// до того как окно вообще откроется.
void recoverZapret().then(() => {
const window = createWindow()
// Ждём первого кадра: диалог должен появиться поверх готового окна, а не пустого.
window.once('ready-to-show', () => void showFirstRunSupportDialog(window))
})
// macOS: клик по иконке в доке при закрытых окнах открывает окно заново.
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// Windows / Linux: закрыли последнее окно — вышли. macOS: приложение живёт до Cmd+Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
// Гасим tpws/winws и откатываем системный прокси до фактического выхода —
// иначе процесс останется висеть, а на macOS ещё и интернет пропадёт.
let zapretCleaned = false
app.on('before-quit', (event) => {
if (zapretCleaned) return
event.preventDefault()
void disposeZapret().finally(() => {
zapretCleaned = true
app.quit()
})
})
}
+78
View File
@@ -0,0 +1,78 @@
import { BrowserWindow, ipcMain, nativeTheme, type IpcMainInvokeEvent } from 'electron'
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,
zapretStrategies
} from './zapret'
/**
* Обёртка над `ipcMain.handle`, которая сверяет имя канала, аргументы и результат
* с контрактом из `src/shared/ipc-contract.ts`.
*/
function handle<K extends IpcChannel>(
channel: K,
handler: (
event: IpcMainInvokeEvent,
...args: Parameters<IpcHandlers[K]>
) => ReturnType<IpcHandlers[K]> | Promise<ReturnType<IpcHandlers[K]>>
): void {
ipcMain.handle(channel, handler as Parameters<typeof ipcMain.handle>[1])
}
/** Окно, из которого пришёл запрос (а не «первое попавшееся») — для диалога подтверждения. */
function windowOf(event: IpcMainInvokeEvent): BrowserWindow | null {
return BrowserWindow.fromWebContents(event.sender)
}
function themeState(): ThemeState {
return {
source: nativeTheme.themeSource,
shouldUseDarkColors: nativeTheme.shouldUseDarkColors
}
}
export function registerIpcHandlers(): void {
handle('theme:get', () => themeState())
// Кнопка «Удалить приложение» есть в интерфейсе только на Windows (SettingsView.tsx),
// но канал регистрируем всегда — так и должно быть по контракту, просто на других
// платформах он никогда не вызывается.
handle('app:uninstall', (event) => {
if (process.platform === 'win32') void uninstallApp(windowOf(event))
})
handle('zapret:status', () => zapretStatus())
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())
handle('zapret:diagnose', () => zapretDiagnostics())
// Системная тема поменялась (например, пользователь включил тёмный режим в ОС) —
// сообщаем всем открытым окнам.
nativeTheme.on('updated', () => {
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('theme:changed', themeState())
}
})
}
+145
View File
@@ -0,0 +1,145 @@
import { execFile, spawn } from 'node:child_process'
import { promisify } from 'node:util'
import {
app,
dialog,
type BrowserWindow,
type MessageBoxOptions,
type MessageBoxReturnValue
} from 'electron'
const run = promisify(execFile)
/** dialog.showMessageBox без родительского окна — отдельная перегрузка, без окна её и зовём. */
function showDialog(
window: BrowserWindow | null,
options: MessageBoxOptions
): Promise<MessageBoxReturnValue> {
return window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options)
}
/**
* Установщик (per-user, `perMachine: false` в electron-builder.yml) регистрирует
* деинсталлятор в HKCU, не в HKLM — читать оттуда можно без прав администратора.
* Ищем по DisplayName, а не по фиксированному GUID/пути: имя ключа реестра
* electron-builder генерирует детерминированно из `appId`, но полагаться на точное
* значение — лишний повод сломаться при следующей пересборке.
*/
interface UninstallCommand {
command: string
args: string[]
}
/**
* `UninstallString` записывает сам electron-builder (templates/nsis/include/installer.nsh:122)
* строкой `'"$2" $0'`, где `$2` — путь к деинсталлятору, а `$0` — `/currentuser` для
* per-user установки (или `/allusers`). То есть путь в кавычках И аргумент после него:
*
* "C:\Users\...\AppData\Local\Programs\RKNboost\Uninstall RKNboost.exe" /currentuser
*
* Аргумент обязателен: без него деинсталлятор не знает, из какой ветки реестра сниматься.
* Разбираем строку на команду и аргументы, а не пытаемся скормить её `spawn` целиком —
* иначе весь текст вместе с кавычками уходит в имя файла и получается ENOENT.
*/
function parseUninstallString(raw: string): UninstallCommand | null {
const trimmed = raw.trim()
if (!trimmed) return null
const quoted = /^"([^"]+)"\s*(.*)$/.exec(trimmed)
if (quoted) {
return { command: quoted[1], args: quoted[2].split(/\s+/).filter(Boolean) }
}
// Без кавычек путь бывает только если в нём нет пробелов — тогда первое слово и есть команда.
const [command, ...args] = trimmed.split(/\s+/)
return command ? { command, args } : null
}
/**
* Установщик (per-user, `perMachine: false` в electron-builder.yml) регистрирует
* деинсталлятор в HKCU, не в HKLM — читать оттуда можно без прав администратора.
* Ищем по DisplayName, а не по фиксированному GUID/пути: имя ключа реестра
* electron-builder генерирует детерминированно из `appId`, но полагаться на точное
* значение — лишний повод сломаться при следующей пересборке.
*/
async function findUninstallCommand(): Promise<UninstallCommand | null> {
const script =
"Get-ChildItem 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall' " +
'| Get-ItemProperty ' +
"| Where-Object { $_.DisplayName -eq 'RKNboost' } " +
'| Select-Object -First 1 -ExpandProperty UninstallString'
let stdout: string
try {
;({ stdout } = await run('powershell.exe', ['-NoProfile', '-Command', script]))
} catch {
return null
}
return parseUninstallString(stdout)
}
/**
* `spawn` сообщает о ненайденном файле не исключением, а событием `error` на уже
* созданном объекте — без этого обработчика ENOENT всплывал бы необработанным
* отказом промиса в main-процессе. Выходим только после события `spawn`, то есть
* когда деинсталлятор точно стартовал: закрыться раньше значит не закрыться вовсе.
*/
function launchUninstaller({ command, args }: UninstallCommand): Promise<void> {
return new Promise((resolve, reject) => {
// detached: деинсталлятор должен пережить закрытие текущего процесса.
const child = spawn(command, args, { detached: true, stdio: 'ignore' })
child.once('error', reject)
child.once('spawn', () => {
child.unref()
resolve()
})
})
}
/**
* Подтверждение нативным диалогом ОС (не веб-страницей — так надёжнее для
* действия, которое стирает и приложение, и списки сайтов пользователя),
* затем запуск деинсталлятора и выход — деинсталлятор не может удалить
* запущенное приложение.
*/
export async function uninstallApp(window: BrowserWindow | null): Promise<void> {
const { response } = await showDialog(window, {
type: 'warning',
buttons: ['Отмена', 'Удалить'],
defaultId: 0,
cancelId: 0,
noLink: true,
title: 'Удалить RKNboost',
message: 'Удалить RKNboost с этого компьютера?',
detail:
'Приложение закроется и запустится стандартный деинсталлятор Windows. ' +
'Вместе с ним будут удалены списки сайтов и служба обхода блокировок. ' +
'Windows может запросить права администратора.'
})
if (response !== 1) return
const uninstall = await findUninstallCommand()
if (!uninstall) {
await showDialog(window, {
type: 'error',
title: 'Не удалось найти деинсталлятор',
message: 'Удалите приложение вручную через «Параметры Windows → Приложения».'
})
return
}
try {
await launchUninstaller(uninstall)
} catch (error) {
await showDialog(window, {
type: 'error',
title: 'Не удалось запустить деинсталлятор',
message: 'Удалите приложение вручную через «Параметры Windows → Приложения».',
detail: error instanceof Error ? error.message : String(error)
})
return
}
app.quit()
}
+77
View File
@@ -0,0 +1,77 @@
import { BrowserWindow, shell } from 'electron'
import { join } from 'node:path'
import { is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { ALLOWED_EXTERNAL_PROTOCOLS } from '../shared/ipc-contract'
function protocolOf(url: string): string | null {
try {
return new URL(url).protocol
} catch {
return null
}
}
/** Открыть ссылку во внешнем браузере, но только если протокол в белом списке. */
export function openExternal(url: string): void {
const protocol = protocolOf(url)
if (protocol && (ALLOWED_EXTERNAL_PROTOCOLS as readonly string[]).includes(protocol)) {
void shell.openExternal(url)
} else {
console.warn(`[security] заблокирована внешняя ссылка: ${url}`)
}
}
/** Главное (и единственное) окно приложения. */
export function createWindow(): BrowserWindow {
const window = new BrowserWindow({
width: 440,
height: 660,
minWidth: 360,
// С запасом на самое высокое обычное состояние главного экрана (ошибка + три
// результата проверки), чтобы полоса прокрутки в карточке не появлялась на ровном месте.
minHeight: 560,
show: false,
autoHideMenuBar: true,
backgroundColor: '#1a1611',
// Иконка нужна только Linux — на macOS и Windows её берут из собранного бандла.
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
// Значения по умолчанию у Electron уже безопасные, но фиксируем их явно,
// чтобы случайная правка была заметна в ревью.
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
webSecurity: true
}
})
// Показываем окно только когда отрисован первый кадр — без «белой вспышки».
window.on('ready-to-show', () => window.show())
// window.open(...) и target="_blank" уходят в системный браузер, а не в новое окно Electron.
window.webContents.setWindowOpenHandler(({ url }) => {
openExternal(url)
return { action: 'deny' }
})
// Запрещаем renderer'у уходить с локальной страницы (защита от навигации на чужой origin).
window.webContents.on('will-navigate', (event, url) => {
const devServerUrl = process.env['ELECTRON_RENDERER_URL']
const isDevServer = Boolean(devServerUrl && url.startsWith(devServerUrl))
if (!isDevServer && !url.startsWith('file://')) {
event.preventDefault()
openExternal(url)
}
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
void window.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
void window.loadFile(join(__dirname, '../renderer/index.html'))
}
return window
}
+205
View File
@@ -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)
}
}
})
)
}
+90
View File
@@ -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
}
}
+243
View File
@@ -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
})
}
}
+114
View File
@@ -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> {}
}
+197
View File
@@ -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 })
}
}
}
+110
View File
@@ -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())
}
+151
View File
@@ -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)
}
+94
View File
@@ -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».'
)
}
}
+167
View File
@@ -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
}
+471
View File
@@ -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() })
}
+247
View File
@@ -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