diff --git a/src/main/zapret/engine.win32.ts b/src/main/zapret/engine.win32.ts index 5545519..5bc06cb 100644 --- a/src/main/zapret/engine.win32.ts +++ b/src/main/zapret/engine.win32.ts @@ -1,11 +1,18 @@ import { execFile } from 'node:child_process' -import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, 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 { + SERVICE_BINARY_NAMES, + assertExecutable, + serviceDataDir, + serviceConfigPath, + winwsPath +} from './paths' import { applyServiceConfig, queryService, @@ -28,18 +35,34 @@ async function readInstalledMarkerLine(): Promise { } } +async function sha256(path: string): Promise { + return createHash('sha256') + .update(await readFile(path)) + .digest('hex') +} + /** - * Служба всегда запускает защищённую копию winws.exe (protectedWinwsPath), а не оригинал - * из папки установки приложения (winwsPath) — значит переустановку/обновление приложения - * по самому пути службы не отследить, как раньше. Вместо этого сравниваем размер и время - * изменения оригинала с уже скопированным файлом: разошлись — обновление zapret вышло - * позже последней копии, нужно копировать заново (а значит и UAC). + * Служба запускает защищённую копию бинарников, а не оригинал из папки приложения — значит + * обновление по самому пути службы не отследить. Сравниваем содержимое: время изменения тут + * не годится совсем, `scripts/fetch-zapret.mjs` кладёт файлы через `copyFile`, а тот ставит + * текущее время — после каждой сборки CI mtime новее, хотя версия zapret запинована и байты + * те же. По mtime мы на ровном месте требовали UAC при каждом обновлении приложения и лезли + * копировать поверх WinDivert64.sys, ещё загруженного в ядро. */ async function binariesNeedCopy(): Promise { + const sourceDir = dirname(winwsPath()) + const targetDir = serviceDataDir() try { - const [source, copy] = await Promise.all([stat(winwsPath()), stat(protectedWinwsPath())]) - return source.size !== copy.size || source.mtimeMs > copy.mtimeMs + for (const name of SERVICE_BINARY_NAMES) { + const [source, copy] = await Promise.all([ + sha256(join(sourceDir, name)), + sha256(join(targetDir, name)) + ]) + if (source !== copy) return true + } + return false } catch { + // Какого-то файла ещё нет (первая установка) или он нечитаем — надёжнее скопировать. return true } } diff --git a/src/main/zapret/paths.ts b/src/main/zapret/paths.ts index ffd3960..2462b2b 100644 --- a/src/main/zapret/paths.ts +++ b/src/main/zapret/paths.ts @@ -56,6 +56,19 @@ export function serviceConfigPath(): string { return join(serviceDataDir(), 'strategy.cfg') } +/** + * Файлы, которые служба запускает из своего защищённого каталога: сам winws и всё, от чего + * он зависит при старте. Список нужен и `engine.win32.ts` (решить, надо ли копировать), + * и повышенному скрипту в `service.win32.ts` (собственно копирование) — держим его здесь, + * чтобы он не разъехался на две копии. + */ +export const SERVICE_BINARY_NAMES = [ + 'winws.exe', + 'cygwin1.dll', + 'WinDivert.dll', + 'WinDivert64.sys' +] as const + /** * Защищённая копия `winws.exe` (+ cygwin1.dll, WinDivert.dll, WinDivert64.sys) — на неё, * а не на оригинал из папки установки приложения, указывает служба. Установка теперь diff --git a/src/main/zapret/service.win32.ts b/src/main/zapret/service.win32.ts index 0b77005..1f1209c 100644 --- a/src/main/zapret/service.win32.ts +++ b/src/main/zapret/service.win32.ts @@ -10,7 +10,7 @@ import { isElevated, runElevatedServiceTask } from './elevated-task.win32' -import { serviceConfigPath, serviceDataDir, winwsPath } from './paths' +import { SERVICE_BINARY_NAMES, serviceConfigPath, serviceDataDir, winwsPath } from './paths' const run = promisify(execFile) @@ -31,6 +31,8 @@ const ERROR_SERVICE_ALREADY_RUNNING = 1056 const ERROR_SERVICE_NOT_ACTIVE = 1062 export type ServiceState = 'Running' | 'Stopped' | 'StartPending' | 'StopPending' | 'Other' +/** То, чего можно дождаться от службы. `Absent` — службы в системе нет (снята). */ +type WaitState = 'Running' | 'Stopped' | 'Absent' export type ServiceStartMode = 'Auto' | 'Manual' | 'Disabled' | 'Other' export interface ServiceInfo { @@ -40,10 +42,6 @@ export interface ServiceInfo { binPath: string } -function sleep(ms: number): Promise { - 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 @@ -81,12 +79,29 @@ function normalizeStartMode(raw: string | undefined): ServiceStartMode { * Опрос через `Get-CimInstance Win32_Service`, а не парсинг вывода `sc query` — у CIM * `State`/`StartMode` это английские константы независимо от языка Windows, а `sc` печатает * локализованный текст, который парсить надёжно нельзя. + * + * Ждём тоже здесь, внутри одного скрипта: запуск powershell.exe стоит около секунды (он + * поднимает .NET), а опросов до перехода службы в нужное состояние бывает с десяток. Раньше + * каждый опрос был отдельным процессом, и включение обхода складывалось в основном из этих + * запусков; теперь на всё ожидание приходится один. Сам опрос внутри CIM — десятки мс. */ -export async function queryService(): Promise { - const script = - `Get-CimInstance Win32_Service -Filter "Name='${SERVICE_NAME}'" | ` + - 'Select-Object State,StartMode,PathName | ConvertTo-Json -Compress' +function queryScript(until: readonly WaitState[], timeoutMs: number): string { + return ` +$targets = @(${until.map((state) => `'${state}'`).join(',')}) +$deadline = [DateTime]::UtcNow.AddMilliseconds(${timeoutMs}) +for (;;) { + $svc = Get-CimInstance Win32_Service -Filter "Name='${SERVICE_NAME}'" | + Select-Object State,StartMode,PathName + $state = if ($svc) { $svc.State } else { 'Absent' } + if ($targets.Count -eq 0 -or $targets -contains $state) { break } + if ([DateTime]::UtcNow -ge $deadline) { break } + Start-Sleep -Milliseconds ${POLL_INTERVAL_MS} +} +if ($svc) { $svc | ConvertTo-Json -Compress } else { 'null' } +` +} +async function runQuery(script: string): Promise { let stdout: string try { ;({ stdout } = await run('powershell.exe', ['-NoProfile', '-Command', script])) @@ -108,6 +123,19 @@ export async function queryService(): Promise { } } +/** Текущее состояние службы; `null` — службы в системе нет. */ +export function queryService(): Promise { + return runQuery(queryScript([], 0)) +} + +/** + * Ждёт одного из состояний и возвращает последнее увиденное — в том числе по таймауту, + * поэтому результат у вызывающего кода обязателен к проверке. + */ +function waitForService(...states: WaitState[]): Promise { + return runQuery(queryScript(states, POLL_TIMEOUT_MS)) +} + function explainScError(error: unknown, verb: string): string { const message = error instanceof Error ? error.message : String(error) if (/access is denied/i.test(message)) { @@ -119,18 +147,6 @@ function explainScError(error: unknown, verb: string): string { return `Не удалось ${verb} службу: ${message}` } -async function pollUntil( - predicate: (info: ServiceInfo | null) => boolean -): Promise { - 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 не загрузился) @@ -147,7 +163,7 @@ export async function startService(): Promise { } } - const info = await pollUntil((i) => i?.state === 'Running' || i?.state === 'Stopped') + const info = await waitForService('Running', 'Stopped') if (info?.state !== 'Running') { throw new Error( `Служба запущена системой, но не перешла в рабочее состояние (текущее: ` + @@ -169,12 +185,11 @@ export async function stopService(): Promise { throw new Error(explainScError(error, 'остановить'), { cause: error }) } } - // `pollUntil` возвращает последнее состояние и по истечении таймаута, независимо от предиката, - // поэтому результат обязательно проверяем. Иначе «остановка», не уложившаяся в POLL_TIMEOUT_MS, - // молча сходила бы за успешную — и вызывающий код (перенастройка стратегии в engine.win32.ts) + // Результат обязательно проверяем: «остановка», не уложившаяся в POLL_TIMEOUT_MS, иначе + // молча сошла бы за успешную — и вызывающий код (перенастройка стратегии в engine.win32.ts) // пошёл бы копировать winws.exe поверх ещё работающего, ровно в ту ошибку, от которой // остановка и защищает. - const info = await pollUntil((i) => i === null || i.state === 'Stopped') + const info = await waitForService('Stopped', 'Absent') if (info && info.state !== 'Stopped') { throw new Error( `Служба не остановилась за ${POLL_TIMEOUT_MS / 1000} с (состояние: ${info.state}).` @@ -270,40 +285,46 @@ function Invoke-ScRaw($rawArgs) { return [pscustomobject]@{ ExitCode = $proc.ExitCode; Output = ("$stdout $stderr").Trim() } } -# WinDivert64.sys — файл драйвера, загруженного в ядро: пока драйвер не выгружен, файл занят, -# и Copy-Item поверх него падает с «Процесс не может получить доступ к файлу ... используется -# другим процессом». SCM рапортует службе состояние Stopped, как только winws.exe отдал сигнал -# об остановке, — выгрузка драйвера происходит уже после этого, поэтому при быстром -# переподключении (выключил и сразу включил, сменил стратегию) перенастройка попадала ровно -# в этот зазор. +# WinDivert64.sys — образ драйвера, загруженного в ядро: пока драйвер не выгружен, файл +# занят, и Copy-Item поверх него падает с «файл используется другим процессом». SCM +# рапортует Stopped, как только winws.exe отдал сигнал, а выгрузка идёт уже после — при +# быстром переподключении попадаем ровно в этот зазор. # -# Отсюда две меры. Первая и главная: не копировать то, что не менялось, — при смене стратегии -# бинарники те же самые, и трогать занятый .sys незачем (Copy-Item сохраняет LastWriteTime -# источника, так что длина + время записи однозначно отличают уже скопированный файл). -# Вторая — на случай, когда копировать действительно надо (обновление приложения принесло -# новый zapret): ждём выгрузки драйвера повторами, а не падаем на первой же попытке. +# Отсюда три ступени. Сравнение по ХЕШУ, а не по времени изменения: fetch-zapret.mjs кладёт +# бинарники через copyFile, тот ставит текущее время, и после каждой сборки CI mtime новее +# при тех же байтах — по времени мы лезли копировать занятый .sys после каждого обновления +# приложения. Дальше короткие повторы — вдруг драйвер вот-вот выгрузится. Последняя ступень: +# уводим занятый файл в сторону переименованием (NTFS разрешает переименовать загруженный +# образ, хотя удалить его не даёт) и кладём новый на освободившееся имя. function Copy-IfChanged($source, $destination) { - $src = Get-Item -LiteralPath $source if (Test-Path -LiteralPath $destination) { - $dst = Get-Item -LiteralPath $destination - if ($dst.Length -eq $src.Length -and $dst.LastWriteTimeUtc -eq $src.LastWriteTimeUtc) { return } + $sourceHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $source).Hash + $copyHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash + if ($sourceHash -eq $copyHash) { return } } - $attempts = 20 - for ($attempt = 1; $attempt -le $attempts; $attempt++) { + for ($attempt = 1; $attempt -le 6; $attempt++) { try { Copy-Item -LiteralPath $source -Destination $destination -Force return } catch { - if ($attempt -eq $attempts) { - throw "Не удалось обновить $destination за 10 с: $($_.Exception.Message) Если обход только что был выключен, драйвер WinDivert ещё выгружается — повторите через несколько секунд." - } Start-Sleep -Milliseconds 500 } } + + try { + $stale = "$destination.old" + Remove-Item -LiteralPath $stale -Force -ErrorAction SilentlyContinue + Move-Item -LiteralPath $destination -Destination $stale -Force + Copy-Item -LiteralPath $source -Destination $destination -Force + } + catch { + throw "Не удалось обновить $destination : $($_.Exception.Message)" + } } + try { $payload = Get-Content -Raw -Path $PayloadPath | ConvertFrom-Json @@ -332,10 +353,15 @@ try { # 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')) { + foreach ($name in $payload.binaryNames) { Copy-IfChanged (Join-Path $sourceDir $name) (Join-Path $dir $name) } + # Хвосты от прошлой перенастройки: тогда файл был занят загруженным драйвером, сейчас + # он, скорее всего, уже свободен. Не вышло — не беда, попробуем в следующий раз. + Get-ChildItem -LiteralPath $dir -Filter '*.old' -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction SilentlyContinue + # sc.exe читает binPath как ОДИН аргумент — значит всю строку нужно взять в одну пару # кавычек, а внутренние кавычки экранировать как \\" (без этого CommandLineToArgvW режет # значение на два токена по пробелу перед "@", и sc create падает с кодом 1639). @@ -500,6 +526,7 @@ export async function applyServiceConfig(options: ApplyServiceConfigOptions): Pr configPath: serviceConfigPath(), configBody: options.configBody, winwsPath: winwsPath(), + binaryNames: SERVICE_BINARY_NAMES, autoStart: options.autoStart }) }