Files
senboost/src/main/zapret/diagnostics.ts
T
maeneko 563d8b7292 update: обновления 0.5.0
- Исправлен баг при переподключении "Не удалось настроить службу: Процесс не может получить доступ к файлу "C:\ProgramData\rknboost\zapret\WinDivert64.sys", так как этот файл используется другим процессом."

- Теперь проверка дает результат сразу асихронно, а не ожидая ответа от всех.

- Также программа сохраняет что было выбрано при последнем запуске
2026-09-03 07:52:41 +03:00

212 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { connect as netConnect, type Socket } from 'node:net'
import { connect as tlsConnect } from 'node:tls'
import { net } from 'electron'
import type {
ZapretDiagnosticResult,
ZapretDiagnosticTarget,
ZapretStatus
} from '../../shared/ipc-contract'
// 6 секунд не хватало: десинк с `--dpi-desync-repeats` заметно удлиняет установку соединения,
// и медленный, но живой сайт успевал попасть в «Недоступно».
const TIMEOUT_MS = 10000
const TARGET_PORT = 443
const RETRY_DELAY_MS = 300
/** Сайты, которые нужно проверять в первую очередь — по просьбе пользователя. */
export const DIAGNOSTIC_TARGETS: ZapretDiagnosticTarget[] = [
{ 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, а без обхода тест просто показывает
* то, что есть сейчас.
*
* Каждый сайт отдаёт свой результат в `onResult` сам по себе, не дожидаясь остальных:
* проверки идут параллельно и заканчиваются вразнобой (живой сайт отвечает за десятки
* миллисекунд, заблокированный — только по таймауту в 10 с), и общий ответ одним куском
* означал бы пустой экран всё это время. Промис резолвится, когда отработали все, —
* по нему вызывающий код понимает, что проверка закончена.
*/
export async function runDiagnostics(
status: ZapretStatus,
onResult: (result: ZapretDiagnosticResult) => void
): Promise<void> {
const socksMatch = status.socksAddress ? /^(.+):(\d+)$/.exec(status.socksAddress) : null
await Promise.all(
DIAGNOSTIC_TARGETS.map(async (target) => {
const attempt = (): Promise<number> =>
socksMatch
? checkViaSocks(target.host, socksMatch[1], Number(socksMatch[2]))
: checkDirect(target.host)
try {
const ms = await withRetry(attempt)
onResult({ ...target, ok: true, ms, error: null })
} catch (error) {
onResult({
...target,
ok: false,
ms: null,
error: error instanceof Error ? error.message : String(error)
})
}
})
)
}