Files
tglock/ui/main.ts
T
Никита Sonic 8ce9355243 fix: медиа и «прокси не настроен»: пакет на кадр, настоящие фронты, kwsN-1 только для медиа; beta.19 (#75)
* fix(tunnel): медиа и «прокси не настроен»: один пакет на кадр, настоящие фронты, kwsN-1 только для медиа

Три причины, каждая проверена на живом Telegram запросом req_DH_params:
сервер сверяет номер DC внутри него с собой и отвечает -444 на чужой.

- WebSocket-фронт разбирает один пакет на входящий кадр, остаток ждёт
  следующего. Клиент пишет запросы пачками, TGLock слал их одним кадром,
  и всё, что шло вторым, застревало: текст проходил, файлы вставали.
  Теперь поток режется на пакеты, как в Flowseal/tg-ws-proxy. Пачка через
  beta.18 висит на всех DC, через эту сборку принимается на всех.
- Кластер kwsN-1 отвергает обычное соединение с -444, а Telegram Desktop
  в ответ выключает прокси как настроенный неверно. При сбое kwsN основное
  соединение откатывалось именно туда. Теперь kwsN-1 только для медиа.
- Адреса из списка DC не говорят на TLS ни из какой сети (проверено с чистой
  сети), поэтому первый маршрут каждого DC не работал никогда. Закреплены
  настоящие фронты, и DC1, DC3, DC5 стали доступны по WebSocket, чего не было.
  У CDN203 своего фронта нет: остались TCP, Worker и CF-прокси.

Обрыв туннеля, через который уже шли данные, больше не ставит маршрут на
паузу. Таймаут соединения 8 с, как в tg-ws-proxy. probe_proxy.mjs получил
--identity и --batched: они и нашли эти баги.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: журнал ошибок в диагностике, --log-file для роутеров; 2.0.0-beta.19

«Скопировать диагностику» отдавала только счётчики, и по ней нельзя было
понять, какой маршрут не ответил. Теперь в отчёте есть строки сбоев туннелей,
а домены Worker и CF-прокси в них заменены на <домен>.

--log-file и --log-max-mb (log_file, log_max_mb в настройках), как в
tg-ws-proxy: журнал идёт в файл с ротацией, а не в системный журнал роутера.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(routes): запасной WebSocket на порту 80 родных адресов, включая CDN203

Родные адреса Telegram принимают WebSocket без TLS на порту 80. Измерено
req_DH_params: DC определяется адресом, имя хоста не важно, а 91.105.192.100
отдаёт ключи CDN. Для CDN203, откуда часто идёт видео, это первый прямой
WebSocket: на 443 фронта у него нет, и там, где провайдер закрыл только 443,
до него раньше было не достать без Worker.

Защита та же, что у родного TCP: внутри тот же обфусцированный MTProto. В Host
идёт адрес, имени Telegram в открытом виде нет. Маршрут разрешён только до
закреплённых адресов и пробуется последним.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): паузы маршрутов по результатам ревью, пределы и журнал

- Обрыв не наказывает маршрут, только если туннель работал и получал ответы
  15 с. Одного ответа мало: маршрут, который ответил раз и умер, оставался
  предпочтительным навсегда.
- Клиент спросил, не дождался ответа за 5 с и ушёл: маршрут считается
  неработающим. Иначе CDN, принявший TCP и молчащий, держал первое место, и
  очередь до порта 80 не доходила.
- Предел пакета 2 МиБ вместо 16: чужое устройство в LAN-режиме не заставит
  роутер копить десятки мегабайт на соединение.
- Журнал: разовая ошибка записи его больше не выключает, неудачная ротация
  не теряет бэкап и держит предел, права 0600 и на уже существующем файле.
- В отчёт диагностики не попадает Worker, заданный адресом.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 07:32:03 +00:00

601 lines
23 KiB
TypeScript

import { invoke } from "@tauri-apps/api/core";
import "./styles.css";
type View = "home" | "settings" | "diagnostics";
type Status = {
running: boolean;
activeConnections: number;
tunnels: number;
dataCenter: number | null;
route: string;
failures: number;
routeFailures: number;
/// Отклонено политикой «в LAN-режиме только Telegram».
blocked: number;
/// Клиенты, которые дошли, но не сумели договориться о рукопожатии.
unknownClients: number;
/// Соединения, которые открылись и ничего не прислали до таймаута.
silentClients: number;
uptimeSeconds: number;
port: number;
/// Адрес для других устройств. Приходит только в LAN-режиме.
shareAddress: string | null;
logs: LogLine[];
};
type LogLine = {
timestamp: string;
message: string;
error: boolean;
};
type Settings = {
lanMode: boolean;
port: number;
workerDomain: string;
cfProxyDomain: string;
};
const root = document.querySelector<HTMLDivElement>("#app")!;
let view: View = "home";
let status: Status = {
running: false,
activeConnections: 0,
tunnels: 0,
dataCenter: null,
route: "Маршрут ещё не выбран",
failures: 0,
routeFailures: 0,
blocked: 0,
unknownClients: 0,
silentClients: 0,
uptimeSeconds: 0,
port: 1080,
shareAddress: null,
logs: [],
};
let settings: Settings = { lanMode: false, port: 1080, workerDomain: "", cfProxyDomain: "" };
let busy = false;
let toastTimer: number | undefined;
let toastMessage = "";
let toastError = false;
const icons = {
arrowLeft: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>`,
settings: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.86 2.86-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1.1V21H9.6v-.1A1.7 1.7 0 0 0 8.5 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06L3.7 16.94l.06-.06A1.7 1.7 0 0 0 4.1 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1.1-.4H2.3V9.6h.1A1.7 1.7 0 0 0 4.1 8.5a1.7 1.7 0 0 0-.34-1.88l-.06-.06L6.56 3.7l.06.06A1.7 1.7 0 0 0 8.5 4.1a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 0 .4-1.1V2.3h4v.1A1.7 1.7 0 0 0 15 4.1a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.86 2.86-.06.06A1.7 1.7 0 0 0 19.4 8.5a1.7 1.7 0 0 0 .6 1 1.7 1.7 0 0 0 1.1.4h.1v4h-.1a1.7 1.7 0 0 0-1.7 1.1Z"/></svg>`,
activity: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h4l2.4-7 5.1 14 2.4-7H21"/></svg>`,
plane: `<svg viewBox="0 0 64 64" aria-hidden="true"><path fill="currentColor" d="M52.4 11.7 8.2 28.8c-3 1.2-3 2.8-.6 3.5l11.3 3.5 4.4 13.4c.5 1.5.3 2.1 1.8 2.1 1.2 0 1.7-.5 2.4-1.2l5.5-5.3 11.5 8.5c2.1 1.2 3.7.6 4.2-2l7.6-35.8c.8-3.1-1.2-4.5-3.9-3.8ZM22.7 35l22.1-13.9c1.1-.7 2.1-.3 1.3.4L27.9 38l-.7 7.4L22.7 35Z"/></svg>`,
};
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function formatUptime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const rest = seconds % 60;
return [hours, minutes, rest].map((value) => value.toString().padStart(2, "0")).join(":");
}
function connectionState(): { title: string; subtitle: string; className: string } {
if (!status.running) {
return {
title: "Защита выключена",
subtitle: "Нажмите кнопку, чтобы подключить Telegram",
className: "offline",
};
}
if (status.failures > 0 && status.tunnels === 0) {
return {
title: "Ищем новый маршрут",
subtitle: "Переключаемся на резервное подключение",
className: "warning",
};
}
if (status.tunnels > 0) {
return {
title: "Telegram на связи",
subtitle: "Соединение защищено и работает",
className: "online",
};
}
return {
title: "Защита включена",
subtitle: "Откройте Telegram — маршрут готов",
className: "online",
};
}
// Статус приходит раз в секунду. Если каждый раз переписывать root.innerHTML,
// кнопки становятся новыми узлами, и тап, начатый на старой кнопке и
// законченный на новой, Chromium не превращает в click. Поэтому разметка
// накладывается на уже существующие узлы: меняются только текст и атрибуты,
// а элемент заменяется лишь там, где на его месте теперь другой тег или id.
function patchRoot(html: string): void {
const next = document.createElement("template");
next.innerHTML = html;
patchChildren(root, next.content);
}
function patchChildren(current: Node, next: Node): void {
const nextChildren = Array.from(next.childNodes);
nextChildren.forEach((nextChild, index) => {
const currentChild = current.childNodes[index];
if (!currentChild) current.appendChild(nextChild);
else if (!sameSlot(currentChild, nextChild)) current.replaceChild(nextChild, currentChild);
else patchNode(currentChild, nextChild);
});
while (current.childNodes.length > nextChildren.length) current.lastChild?.remove();
}
function sameSlot(current: Node, next: Node): boolean {
if (current.nodeType !== next.nodeType || current.nodeName !== next.nodeName) return false;
return !(current instanceof Element && next instanceof Element) || current.id === next.id;
}
function patchNode(current: Node, next: Node): void {
if (!(current instanceof Element && next instanceof Element)) {
if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
return;
}
for (const { name } of Array.from(current.attributes)) {
if (!next.hasAttribute(name)) current.removeAttribute(name);
}
for (const { name, value } of Array.from(next.attributes)) {
if (current.getAttribute(name) !== value) current.setAttribute(name, value);
}
patchChildren(current, next);
}
function shell(content: string, pageClass = ""): string {
return `
<main class="app-shell ${pageClass}">
<div class="drag-region" data-tauri-drag-region></div>
${content}
<div id="toast" class="toast${toastMessage ? " visible" : ""}${toastError ? " error" : ""}" role="status">${escapeHtml(toastMessage)}</div>
</main>
`;
}
function renderHome(): void {
const connection = connectionState();
patchRoot(shell(`
<section class="home">
<header class="brand">
<div class="wordmark"><span>TG</span>Lock</div>
<p>Свободный Telegram. В один клик.</p>
</header>
<div class="hero ${connection.className}">
<div class="orb-field">
<div class="orbit orbit-one"></div>
<div class="orbit orbit-two"></div>
<div class="orb">
<div class="orb-shine"></div>
${icons.plane}
</div>
</div>
<div class="connection-copy">
<div class="status-line">
<span class="status-dot"></span>
<h1>${connection.title}</h1>
</div>
<p>${connection.subtitle}</p>
</div>
</div>
<button id="power" class="power-button ${status.running ? "stop" : ""}" ${busy ? "disabled" : ""}>
<span>${busy ? "Подождите…" : status.running ? "Выключить" : "Включить защиту"}</span>
<span class="button-arrow">→</span>
</button>
${status.running ? `<div class="connection-actions">
<button id="open-telegram" class="nav-button">Открыть Telegram</button>
<button id="copy-link" class="nav-button">Скопировать ссылку</button>
</div>` : ""}
<div class="route-pill">
<span class="route-pulse"></span>
<span>${escapeHtml(status.route)}</span>
<span class="route-separator">·</span>
<span>порт ${status.port}</span>
</div>
${status.shareAddress ? `
<div class="share-card">
<div class="share-label">Адрес для других устройств</div>
<button id="copy-address" class="share-address" title="Нажмите, чтобы скопировать">
${escapeHtml(status.shareAddress)}
</button>
<div class="share-hint">
Впишите его в Telegram на телефоне. Не <code>127.0.0.1</code> — на другом
устройстве это означает само устройство.
</div>
</div>` : ""}
<nav class="bottom-nav" aria-label="Разделы">
<button id="settings-nav" class="nav-button">
<span class="nav-icon">${icons.settings}</span>
<span>Настройки</span>
</button>
<div class="nav-divider"></div>
<button id="diagnostics-nav" class="nav-button">
<span class="nav-icon">${icons.activity}</span>
<span>Диагностика</span>
</button>
</nav>
</section>
`, "home-page"));
}
function pageHeader(title: string, subtitle: string): string {
return `
<header class="page-header">
<button class="back-button" id="back" aria-label="Назад">${icons.arrowLeft}</button>
<div>
<h1>${title}</h1>
<p>${subtitle}</p>
</div>
</header>
`;
}
function renderSettings(): void {
patchRoot(shell(`
<section class="subpage">
${pageHeader("Настройки", "Подключение и локальная сеть")}
<form id="settings-form" class="settings-form">
<div class="setting-card">
<label class="field-label" for="worker">Cloudflare Worker</label>
<input
id="worker"
name="worker"
type="text"
value="${escapeHtml(settings.workerDomain)}"
placeholder="example.workers.dev"
autocomplete="off"
${status.running ? "disabled" : ""}
/>
<p class="field-hint">Необязательно. Используется как резервный маршрут.</p>
</div>
<div class="setting-card">
<label class="field-label" for="cf-proxy">Свой домен на Cloudflare</label>
<input
id="cf-proxy"
name="cf-proxy"
type="text"
value="${escapeHtml(settings.cfProxyDomain)}"
placeholder="example.com"
autocomplete="off"
${status.running ? "disabled" : ""}
/>
<p class="field-hint">Необязательно. CF-прокси без Worker: записи kws1…kws5 и kws203 на адреса Telegram.</p>
</div>
<div class="setting-list">
<label class="setting-row" for="lan-mode">
<span>
<strong>Доступ из локальной сети</strong>
<small>Подключение других устройств</small>
</span>
<span class="switch">
<input id="lan-mode" type="checkbox" ${settings.lanMode ? "checked" : ""} ${status.running ? "disabled" : ""}/>
<span class="switch-track"></span>
</span>
</label>
<div class="setting-rule"></div>
<label class="setting-row compact" for="port">
<span>
<strong>Локальный порт</strong>
<small>SOCKS5 и MTProto</small>
</span>
<input id="port" class="port-input" type="number" min="1" max="65535" value="${settings.port}" ${status.running ? "disabled" : ""}/>
</label>
</div>
${status.running ? `<div class="locked-note"><span></span>Выключите защиту, чтобы изменить настройки</div>` : ""}
<button class="save-button" type="submit" ${status.running || busy ? "disabled" : ""}>
${busy ? "Сохраняю…" : "Сохранить настройки"}
</button>
</form>
</section>
`, "subpage-shell"));
}
function renderDiagnostics(): void {
const recentLogs = status.logs.slice(-4).reverse();
patchRoot(shell(`
<section class="subpage">
${pageHeader("Диагностика", "Состояние маршрута в реальном времени")}
<div class="connection-banner ${status.running ? "active" : ""}">
<span class="banner-icon">${icons.activity}</span>
<div>
<strong>${status.running ? "Сервис работает" : "Сервис остановлен"}</strong>
<small>${status.running ? "Локальный прокси принимает подключения" : "Включите защиту на главном экране"}</small>
</div>
</div>
<div class="metrics-grid">
<article class="metric-card">
<span>Соединения</span>
<strong>${status.activeConnections}</strong>
</article>
<article class="metric-card">
<span>Туннели</span>
<strong>${status.tunnels}</strong>
</article>
<article class="metric-card">
<span>Дата-центр</span>
<strong>${status.dataCenter ? `DC${status.dataCenter}` : "—"}</strong>
</article>
<article class="metric-card">
<span>Время работы</span>
<strong class="time">${formatUptime(status.uptimeSeconds)}</strong>
</article>
<article class="metric-card">
<span>Падений маршрутов</span>
<strong>${status.routeFailures}</strong>
</article>
<article class="metric-card">
<span>Отклонено</span>
<strong>${status.blocked}</strong>
</article>
<article class="metric-card">
<span>Не опознаны</span>
<strong>${status.unknownClients}</strong>
</article>
<article class="metric-card">
<span>Промолчали</span>
<strong>${status.silentClients}</strong>
</article>
</div>
<p class="field-hint">
«Падений маршрутов» больше нуля при работающем Telegram — это норма:
значит закреплённый адрес недоступен и подключение идёт через запасной.
Число в багрепорте помогает понять, что именно перебиралось.
</p>
<p class="field-hint">
«Отклонено» — запросы, которые LAN-режим не пропустил: он ходит только
по адресам Telegram. Если с телефона ничего не работает, а здесь ноль и
соединений тоже ноль, значит телефон до этого компьютера не дошёл —
дело в сети или брандмауэре. Какие именно адреса отклонены, видно ниже.
</p>
<p class="field-hint">
«Не опознаны» — клиенты, которые дошли до прокси, но договориться с ними
не удалось. Почти всегда это старая ссылка: секрет в Telegram остался от
прошлого запуска и больше не совпадает. Тогда Telegram пишет «прокси
настроен неверно», а адрес такого клиента появится в журнале ниже.
</p>
<button id="copy-diagnostics" class="save-button">Скопировать диагностику</button>
<div class="log-panel">
<div class="log-heading">
<span>Последние события</span>
<span class="live-label"><i></i>LIVE</span>
</div>
<div class="logs">
${recentLogs.length
? recentLogs
.map(
(line) => `
<div class="log-line ${line.error ? "error" : ""}">
<time>${line.timestamp}</time>
<span>${escapeHtml(line.message)}</span>
</div>`,
)
.join("")
: `<div class="empty-log">События появятся после включения защиты</div>`}
</div>
</div>
</section>
`, "subpage-shell"));
}
// Обработчики висят на #app, а не на кнопках: patchRoot сохраняет кнопки,
// но новые (например, «Открыть Telegram» после включения) создаёт сам.
const clickActions: Record<string, () => void> = {
power: toggleProtection,
"open-telegram": openTelegram,
"copy-link": copyTelegramLink,
"copy-address": copyShareAddress,
"settings-nav": () => navigate("settings"),
"diagnostics-nav": () => navigate("diagnostics"),
"copy-diagnostics": copyDiagnostics,
back: () => navigate("home"),
};
root.addEventListener("click", (event) => {
const button = (event.target as Element).closest<HTMLButtonElement>("button[id]");
if (button && !button.disabled) clickActions[button.id]?.();
});
root.addEventListener("submit", (event) => {
if ((event.target as Element).id === "settings-form") void saveSettings(event);
});
function render(): void {
if (view === "settings") renderSettings();
else if (view === "diagnostics") renderDiagnostics();
else renderHome();
}
function navigate(next: View): void {
view = next;
render();
}
async function refreshStatus(): Promise<void> {
try {
status = await invoke<Status>("get_status");
if (view !== "settings") render();
} catch (error) {
showToast(String(error), true);
}
}
async function toggleProtection(): Promise<void> {
if (busy) return;
busy = true;
renderHome();
try {
status = status.running
? await invoke<Status>("stop_proxy")
: await invoke<Status>("start_proxy");
} catch (error) {
showToast(String(error), true);
} finally {
busy = false;
await refreshStatus();
}
}
async function saveSettings(event: Event): Promise<void> {
event.preventDefault();
if (busy || status.running) return;
const worker = document.querySelector<HTMLInputElement>("#worker");
const cfProxy = document.querySelector<HTMLInputElement>("#cf-proxy");
const lanMode = document.querySelector<HTMLInputElement>("#lan-mode");
const port = document.querySelector<HTMLInputElement>("#port");
if (!worker || !cfProxy || !lanMode || !port) return;
const parsedPort = Number(port.value);
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
showToast("Порт должен быть от 1 до 65535", true);
return;
}
busy = true;
settings = {
workerDomain: worker.value.trim(),
cfProxyDomain: cfProxy.value.trim(),
lanMode: lanMode.checked,
port: parsedPort,
};
renderSettings();
try {
settings = await invoke<Settings>("save_settings", { settings });
showToast("Настройки сохранены");
window.setTimeout(() => navigate("home"), 350);
} catch (error) {
showToast(String(error), true);
} finally {
busy = false;
}
}
async function openTelegram(): Promise<void> {
try { await invoke("open_telegram"); }
catch (error) { showToast(String(error), true); }
}
async function copyTelegramLink(): Promise<void> {
try {
const link = await invoke<string>("get_telegram_link");
await navigator.clipboard.writeText(link);
showToast("Ссылка скопирована; она содержит секрет прокси");
} catch (error) {
showToast(`Не удалось скопировать ссылку: ${String(error)}`, true);
}
}
// Отчёт публикуют в issue: домен Worker'а и свой домен CF-прокси туда не идут,
// Worker можно задать и адресом, поэтому оставляются только адреса Telegram.
// Они и имена Telegram нужны, чтобы понять, какой маршрут не ответил.
function publicLogLine(message: string): string {
return message.replace(/[\w-]+(?:\.[\w-]+)+/g, (host) =>
/^(149\.154|91\.105|91\.108)\.[\d.]+$/.test(host) || host.endsWith(".telegram.org") ? host : "<домен>");
}
function failureLog(): string[] {
const failure = /^(Не поднялся туннель|Сбой туннеля|Обрыв туннеля)/;
const paused = /на паузе после ошибок/;
return status.logs
.filter(({ message }) => failure.test(message) && !paused.test(message))
.slice(-20)
.map(({ timestamp, message }) => `${timestamp} ${publicLogLine(message)}`);
}
async function copyDiagnostics(): Promise<void> {
try {
// Public reports exclude proxy links and secrets.
const report = {
application: "TGLock",
version: "2.0.0-beta.19",
running: status.running,
activeConnections: status.activeConnections,
tunnels: status.tunnels,
dataCenter: status.dataCenter,
route: status.route,
failures: status.failures,
routeFailures: status.routeFailures,
blocked: status.blocked,
unknownClients: status.unknownClients,
silentClients: status.silentClients,
uptimeSeconds: status.uptimeSeconds,
port: status.port,
lanMode: settings.lanMode,
failureLog: failureLog(),
};
await navigator.clipboard.writeText(JSON.stringify(report, null, 2));
showToast("Диагностика скопирована без секретов и своих доменов");
} catch { showToast("Буфер обмена недоступен", true); }
}
async function copyShareAddress(): Promise<void> {
const address = status.shareAddress;
if (!address) return;
try {
await navigator.clipboard.writeText(address);
showToast("Адрес скопирован");
} catch {
// Буфер обмена может быть недоступен — адрес и так виден на экране.
showToast("Скопируйте адрес вручную", true);
}
}
function showToast(message: string, error = false): void {
window.clearTimeout(toastTimer);
toastMessage = message.replace(/^["']|["']$/g, "");
toastError = error;
window.requestAnimationFrame(() => {
const toast = document.querySelector<HTMLDivElement>("#toast");
if (!toast) return;
toast.textContent = message.replace(/^["']|["']$/g, "");
toast.className = `toast visible${error ? " error" : ""}`;
toastTimer = window.setTimeout(() => {
toastMessage = "";
document.querySelector("#toast")?.classList.remove("visible");
}, 4500);
});
}
async function bootstrap(): Promise<void> {
try {
[status, settings] = await Promise.all([
invoke<Status>("get_status"),
invoke<Settings>("get_settings"),
]);
} catch {
// The browser preview intentionally uses the initial local state.
}
render();
window.setInterval(refreshStatus, 1000);
}
void bootstrap();