release: 1.3.24 — тест утечки IP/DNS из роутера, оптимизация запуска списков хостов и локализация

- feat(diagnostics): router-level IP & DNS leak detection
  * одновременный опрос прямого WAN сокета (SO_MARK 0x08000000) и локального прокси (127.0.0.1:4534)
  * выявление утечки реального публичного IP адреса
  * тестирование восходящих DNS-резолверов по протоколу bash.ws и обнаружение DNS провайдера
  * CLI команды: tachyon leak_check [--pretty], check_ip_leak, check_dns_leak
  * LuCI модальное окно с детализированными таблицами изоляции IP и анализа резолверов
- fix(hosts): устранение ложного предупреждения [warn] No hosts list URLs configured
  * запуск HOSTS_UC в lifecycle.uc только при наличии активных списков хостов
  * hosts.uc возвращает success:true и код 0 без лишних записей в syslog при отсутствии URL
  * добавлена проверка has-hosts-list-update-sources в state.uc
- i18n: полная синхронизация русской локализации в каталогах gettext PO/POT
- bump: версия пакета 1.3.24
This commit is contained in:
Dushnilin
2026-09-09 17:28:51 +03:00
parent a54917501c
commit e523474631
23 changed files with 8542 additions and 5866 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+132 -12
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: TACHYON\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-08 09:57+0300\n"
"PO-Revision-Date: 2026-09-08 09:57+0300\n"
"POT-Creation-Date: 2026-09-09 17:09+0300\n"
"PO-Revision-Date: 2026-09-09 17:09+0300\n"
"Last-Translator: Dushnilin\n"
"Language-Team: none\n"
"Language: ru\n"
@@ -50,6 +50,12 @@ msgstr "⚠️ Бенчмарк завершен. Не найдено рабоч
msgid "⚠️ Benchmark stopped"
msgstr "⚠️ Бенчмарк остановлен"
msgid "⚠️ CRITICAL IP LEAK: Your real public IP is exposed through the proxy outbound!"
msgstr "⚠️ КРИТИЧЕСКАЯ УТЕЧКА IP: Ваш реальный публичный IP раскрыт через исходящий прокси!"
msgid "⚠️ DNS LEAK DETECTED: DNS queries are leaking to your local Internet Service Provider!"
msgstr "⚠️ ОБНАРУЖЕНА УТЕЧКА DNS: DNS-запросы утекают к вашему местному интернет-провайдеру!"
msgid "⚠️ If all Bootstrap DNS fail 3 times, queries will be sent to your ISP's DNS in plaintext. Only use as a last resort to prevent complete internet loss."
msgstr "⚠️ Если все Bootstrap DNS завершатся с ошибкой 3 раза, запросы будут отправлены в DNS вашего провайдера в открытом виде. Используйте только в крайнем случае для предотвращения полной потери интернета."
@@ -155,9 +161,21 @@ msgstr "🛑 Остановить бенчмарк"
msgid "🛠️ My Custom Strategies Only"
msgstr "🛠️ Только мои стратегии"
msgid "🛡️ IP & DNS Leak Test"
msgstr "🛡️ Проверка утечек IP и DNS"
msgid "🛡️ SECURE: No DNS leaks detected. All queries resolve through non-ISP upstream resolvers."
msgstr "🛡️ ЗАЩИЩЕНО: Утечек DNS не обнаружено. Все запросы обрабатываются независимыми резолверами."
msgid "🛡️ SECURE: No IP leak detected. Real WAN IP is concealed behind proxy outbound."
msgstr "🛡️ ЗАЩИЩЕНО: Утечек IP не обнаружено. Реальный WAN IP скрыт за исходящим прокси."
msgid "🟣 Twitch Suite (Live Stream + HLS CDN)"
msgstr "🟣 Twitch Набор (Прямой эфир + HLS CDN)"
msgid "127.0.0.1:4534 (sing-box mixed)"
msgstr "127.0.0.1:4534 (смешанный sing-box)"
msgid "Блокировка по IP (Таймаут TCP SYN)"
msgstr "Блокировка по IP (Таймаут TCP SYN)"
@@ -485,6 +503,9 @@ msgstr "Генератор AmneziaWG"
msgid "AmneziaWG Version"
msgstr "Версия AmneziaWG"
msgid "An unexpected error occurred during test"
msgstr "Произошла непредвиденная ошибка во время проверки"
msgid "Anonymized support report copied to clipboard"
msgstr "Анонимизированный отчет скопирован в буфер обмена"
@@ -641,6 +662,9 @@ msgstr "Ср. задержка"
msgid "Backend validation failed: %s"
msgstr "Ошибка проверки бэкенда: %s"
msgid "Baseline"
msgstr "Базовый уровень"
msgid "Basic connectivity"
msgstr "Базовая связность"
@@ -791,6 +815,9 @@ msgstr "Стратегия ByeDPI"
msgid "ByeDPI strategy cannot be empty"
msgstr "Стратегия ByeDPI не может быть пустой"
msgid "Bypasses proxy (SO_MARK 0x08000000)"
msgstr "В обход прокси (SO_MARK 0x08000000)"
msgid "Bypasses UDP traffic for selected game consoles (PS5/Xbox) to achieve NAT Type 1 / 2 (Full Cone NAT) for P2P matchmaking, while keeping TCP (PSN/Auth) routed through the proxy."
msgstr "Обходит UDP-трафик для выбранных игровых консолей (PS5/Xbox) для достижения NAT Type 1 / 2 (Full Cone NAT) для P2P-матчмейкинга, сохраняя TCP (PSN/Auth) через прокси."
@@ -1055,6 +1082,9 @@ msgstr "Подключено"
msgid "Connected Devices"
msgstr "Подключенные устройства"
msgid "Connection Path"
msgstr "Маршрут соединения"
msgid "Connection URL"
msgstr "URL подключения"
@@ -1100,6 +1130,12 @@ msgstr "Основные настройки и параметры"
msgid "Could not compare FakeIP and control public IPs"
msgstr "Не удалось сравнить публичные IP FakeIP и контрольной проверки"
msgid "Could not contact leak test endpoints. Please ensure router has internet access."
msgstr "Не удалось связаться с серверами проверки утечек. Убедитесь, что у роутера есть доступ в интернет."
msgid "Country"
msgstr "Страна"
msgid "Country code (e.g. ru, us, de)"
msgstr "Код страны (напр. ru, us, de)"
@@ -1271,6 +1307,9 @@ msgstr "Прямой прокси"
msgid "Direct WAN"
msgstr "Прямой WAN"
msgid "Direct WAN (ISP)"
msgstr "Напрямую в WAN (провайдер)"
msgid "Disable"
msgstr "Отключить"
@@ -1382,6 +1421,9 @@ msgstr "Быстрый DNS-кэш"
msgid "DNS Unavailability Timeout"
msgstr "Таймаут недоступности DNS"
msgid "DNS Upstream Resolver Analysis (bash.ws protocol)"
msgstr "Анализ восходящих DNS-резолверов (протокол bash.ws)"
msgid "Do not panic, everything can be fixed, just..."
msgstr "Не паникуйте, всё можно исправить, просто..."
@@ -1524,7 +1566,7 @@ msgid "e.g. SHA256 — leave empty for default"
msgstr "напр. SHA256 — оставьте пустым для значения по умолчанию"
msgid "Each entry must be \"proto\", \"proto:port\" or \"proto:port-port\" (tcp/udp), e.g. tcp:6881,udp:6881-6889"
msgstr "Каждая запись — \"proto\", \"proto:port\" или \"proto:port-port\" (tcp/udp), напр. tcp:6881,udp:6881-6889"
msgstr "Каждая запись должна быть \"proto\", \"proto:port\" или \"proto:port-port\" (tcp/udp), например: tcp:6881,udp:6881-6889"
msgid "Edit"
msgstr "Изменить"
@@ -1802,6 +1844,9 @@ msgstr "Не удалось определить тип DPI"
msgid "Failed to execute"
msgstr "Не удалось выполнить"
msgid "Failed to execute IP and DNS leak check"
msgstr "Не удалось выполнить проверку утечек IP и DNS"
msgid "Failed to fetch releases"
msgstr "Не удалось получить список релизов"
@@ -2348,6 +2393,9 @@ msgstr "Полезный груз init (I1)"
msgid "Init timeout (Itime)"
msgstr "Таймаут init (Itime)"
msgid "Initializing router-level IP & DNS leak test..."
msgstr "Инициализация проверки утечек IP и DNS с роутера..."
msgid "Install"
msgstr "Установить"
@@ -2517,7 +2565,7 @@ msgid "Invalid Shadowsocks URL: missing credentials"
msgstr "Неверный URL Shadowsocks: отсутствуют учетные данные"
msgid "Invalid Shadowsocks URL: missing method and password separator \":\""
msgstr "Неверный Shadowsocks URL: отсутствует разделитель метода и пароля \":\""
msgstr "Неверный URL Shadowsocks: отсутствует разделитель метода и пароля \":\""
msgid "Invalid Shadowsocks URL: missing port"
msgstr "Неверный URL Shadowsocks: отсутствует порт"
@@ -2660,6 +2708,12 @@ msgstr "Изолировать трафик BitTorrent и направить е
msgid "Isolate Local LAN"
msgstr "Изолировать локальную сеть"
msgid "ISP / Organization"
msgstr "Провайдер / Организация"
msgid "ISP DNS LEAK"
msgstr "УТЕЧКА DNS К ПРОВАЙДЕРУ"
msgid "Issues"
msgstr "Проблемы"
@@ -2780,6 +2834,15 @@ msgstr "Установлена последняя версия"
msgid "Latest version:"
msgstr "Последняя версия:"
msgid "Leak detection completed"
msgstr "Проверка утечек завершена"
msgid "Leak detection failed to complete"
msgstr "Не удалось завершить проверку утечек"
msgid "LEAKED"
msgstr "УТЕЧКА"
msgid "Leave empty to omit this header"
msgstr "Оставьте пустым, чтобы пропустить этот заголовок"
@@ -2858,6 +2921,9 @@ msgstr "Загрузка..."
msgid "Local Address"
msgstr "Локальный адрес"
msgid "Location"
msgstr "Местоположение"
msgid "Log Level"
msgstr "Уровень логирования"
@@ -3078,13 +3144,13 @@ msgid "No active routing sections found."
msgstr "Активные секции маршрутизации не найдены."
msgid "No benchmark data yet. Click \"Start Benchmark\" to begin."
msgstr "Нет данных бенчмарка. Нажмите «Начать тест», чтобы запустить проверку."
msgstr "Данные тестирования отсутствуют. Нажмите \"Запустить тест\" для начала."
msgid "No benchmark history yet. Run a benchmark to see results here."
msgstr "История бенчмарков пуста. Запустите бенчмарк, чтобы увидеть результаты здесь."
msgid "No benchmark results yet. Configure options and click \"Start Benchmark\"."
msgstr "Результатов бенчмарка пока нет. Настройте параметры и нажмите «Запустить бенчмарк»."
msgstr "Результаты тестирования пока отсутствуют. Настройте параметры и нажмите \"Запустить тест\"."
msgid "No check targets found in the configuration."
msgstr "Цели для проверки не найдены в конфигурации."
@@ -3113,6 +3179,12 @@ msgstr "DHCP-клиенты не обнаружены"
msgid "No diagnostic history available yet."
msgstr "История проверок пока пуста."
msgid "No DNS resolvers captured via proxy test. Proxy may be offline or blocking test subdomains."
msgstr "В тесте через прокси резолверы не зафиксированы. Возможно, прокси не работает или блокирует тестовые поддомены."
msgid "No DNS resolvers recorded"
msgstr "DNS-резолверы не зафиксированы"
msgid "No domains detected yet"
msgstr "Домены пока не обнаружены"
@@ -3242,6 +3314,12 @@ msgstr "Обфускация"
msgid "Obfuscation password"
msgstr "Пароль обфускации"
msgid "Observed Public IP"
msgstr "Внешний публичный IP"
msgid "OFFLINE"
msgstr "ОФЛАЙН"
msgid "Ollama (Local PC / Server)"
msgstr "Ollama (локальный ПК / сервер)"
@@ -3312,13 +3390,13 @@ msgid "Optimize RAM memory"
msgstr "Оптимизировать память (RAM)"
msgid "Option \"%s\" contains an invalid input value."
msgstr "Параметр \"%s\" содержит недопустимое значение."
msgstr "Опция \"%s\" содержит недопустимое значение."
msgid "Optional — leave empty for default"
msgstr "Опционально — оставьте пустым для значения по умолчанию"
msgid "Optional context (e.g. \"Rostelecom, YouTube 4K buffering is slow\")..."
msgstr "Дополнительный контекст (напр. «Ростелеком, YouTube 4K долго буферизуется»)..."
msgstr "Дополнительный контекст (напр. \"Ростелеком, долго грузится YouTube 4K\")..."
msgid "Optional tls-auth or tls-crypt key"
msgstr "Опциональный ключ tls-auth или tls-crypt"
@@ -3440,6 +3518,9 @@ msgstr "Ожидает запуска"
msgid "Pending..."
msgstr "Ожидание..."
msgid "Performs simultaneous outbound checks via direct WAN (bypassing Sing-box redirect) and via proxy (127.0.0.1:4534) to verify that your real IP and DNS queries are not leaking to your ISP."
msgstr "Выполняет одновременные исходящие запросы напрямую в WAN (в обход правил Sing-box) и через прокси (127.0.0.1:4534), проверяя, что ваш реальный IP и DNS-запросы не утекают провайдеру."
msgid "Periodically check the current level and switch to a faster server even when the current one works."
msgstr "Периодически проверять текущий уровень и переключаться на более быстрый сервер, даже если текущий работает."
@@ -3462,7 +3543,7 @@ msgid "Pin on dashboard"
msgstr "Закрепить на дашборде"
msgid "Pin P2P isolation to the torrent client's own ports, comma separated. Each entry is \"proto\", \"proto:port\" or \"proto:port-port\" (tcp/udp). Without a port the whole protocol is routed direct."
msgstr "Закрепить изоляцию P2P за портами торрент-клиента, через запятую. Каждая запись — \"протокол\", \"протокол:порт\" или \"протокол:порт-порт\" (tcp/udp). Без порта весь протокол уходит напрямую."
msgstr "Привязка изоляции P2P к портам торрент-клиента через запятую. Каждая запись: \"proto\", \"proto:port\" или \"proto:port-port\" (tcp/udp). Без указания порта весь протокол направляется напрямую."
msgid "Plain-UDP DNS servers used as a last resort when all primary DNS fail. These are always unencrypted (no DoH/DoT). Example: 1.1.1.1, 8.8.8.8. Unlike the ISP fallback toggle, these are your own chosen servers."
msgstr "Обычные UDP DNS-серверы, используемые как резерв при недоступности всех основных DNS. Всегда работают без шифрования (без DoH/DoT). Пример: 1.1.1.1, 8.8.8.8. В отличие от аварийного DNS провайдера, это выбранные вами серверы."
@@ -3620,9 +3701,15 @@ msgstr "Интервал проверки здоровья прокси (с)"
msgid "Proxy Health Check URL"
msgstr "URL проверки здоровья прокси"
msgid "Proxy is offline or unreachable on 127.0.0.1:4534."
msgstr "Прокси выключен или недоступен на 127.0.0.1:4534."
msgid "Proxy link is unavailable"
msgstr "Ссылка прокси недоступна"
msgid "Proxy Outbound"
msgstr "Исходящий прокси (туннель)"
msgid "Proxy Section for Bot"
msgstr "Секция прокси для бота"
@@ -3635,9 +3722,15 @@ msgstr "PSK"
msgid "Public host"
msgstr "Публичный хост"
msgid "Public IP Address Isolation"
msgstr "Изоляция публичного IP-адреса"
msgid "QR code is too large"
msgstr "QR-код слишком большой"
msgid "Querying WAN direct socket and proxy outbound on 127.0.0.1:4534..."
msgstr "Опрос прямого WAN-сокета и исходящего прокси на 127.0.0.1:4534..."
msgid "QUIC (HTTP/3)"
msgstr "QUIC (HTTP/3)"
@@ -3653,6 +3746,9 @@ msgstr "Оценка"
msgid "RDP"
msgstr "RDP"
msgid "Re-run Leak Test"
msgstr "Повторить проверку утечек"
msgid "Read error"
msgstr "Ошибка чтения"
@@ -3791,6 +3887,9 @@ msgstr "Определить шлюз"
msgid "Resolve real IP for routing"
msgstr "Разрешать реальный IP для маршрутизации"
msgid "Resolver IP"
msgstr "IP резолвера"
msgid "Resource selection by hostname inside nfqws is not supported here; sing-box selects resources before NFQUEUE."
msgstr "Выбор ресурсов по имени хоста внутри nfqws не поддерживается здесь; sing-box выбирает ресурсы до NFQUEUE."
@@ -3884,6 +3983,12 @@ msgstr "Направлять весь не-локальный LAN-трафик
msgid "Route bot API requests through this section. Leave empty to use the default mixed proxy."
msgstr "Маршрутизировать запросы API бота через эту секцию. Оставьте пустым для использования смешанного прокси по умолчанию."
msgid "Route community list subnets (IP/CIDR)"
msgstr "Маршрутизировать подсети списков сообщества (IP/CIDR)"
msgid "Route IP subnets associated with built-in community lists (e.g. Discord voice/media). Disable this if community subnets overlap with CDN/Anycast networks (such as Cloudflare/Google Cloud) and interfere with unrelated services (e.g. game launchers)."
msgstr "Маршрутизировать IP-подсети из списков сообщества (например, голос/медиа Discord). Отключите, если подсети сообщества пересекаются с CDN/Anycast сетями (Cloudflare/Google Cloud) и мешают другим сервисам."
msgid "Route main DNS requests through the selected section."
msgstr "Маршрутизировать основные DNS-запросы через выбранную секцию."
@@ -3971,6 +4076,9 @@ msgstr "Выполняется диагностика…"
msgid "Russia (ru)"
msgstr "Россия (ru)"
msgid "SAFE"
msgstr "БЕЗОПАСНО"
msgid "SafeSearch"
msgstr "SafeSearch"
@@ -4040,6 +4148,9 @@ msgstr "Секция для регистрации Cloudflare WARP API. Если
msgid "Sections"
msgstr "Секции"
msgid "SECURE"
msgstr "ЗАЩИЩЕНО"
msgid "Security"
msgstr "Безопасность"
@@ -4625,6 +4736,9 @@ msgstr "Tachyon: Бенчмарк и автоподбор DNS"
msgid "Tachyon Error"
msgstr "Ошибка Tachyon"
msgid "Tachyon IP & DNS Leak Detection"
msgstr "Проверка утечек IP и DNS Tachyon"
msgid "Tachyon manages ciadpi pid files itself, so pidfile options are not allowed here."
msgstr "Tachyon управляет файлами pid ciadpi самостоятельно, поэтому параметры pidfile здесь не допускаются."
@@ -4769,6 +4883,9 @@ msgstr "Тестирование"
msgid "Testing bypass strategies in isolated sandbox..."
msgstr "Тестирование стратегий обхода в изолированной песочнице..."
msgid "Testing DNS leak upstream resolvers with bash.ws protocol..."
msgstr "Тестирование восходящих DNS-резолверов по протоколу bash.ws..."
msgid "Testing strategy"
msgstr "Тестирование стратегии"
@@ -5156,6 +5273,9 @@ msgstr "Отдано"
msgid "Upstream Mode"
msgstr "Режим DNS Upstream"
msgid "Upstream Provider / ASN"
msgstr "Восходящий провайдер / ASN"
msgid "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, hy2://, or tuic://"
msgstr "URL должен начинаться с vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, hy2:// или tuic://"
@@ -5273,6 +5393,9 @@ msgstr "Значение должно быть положительным цел
msgid "Value must not contain control characters"
msgstr "Значение не должно содержать символы управления"
msgid "Verdict"
msgstr "Статус"
msgid "Verifying router and service readiness..."
msgstr "Проверка готовности роутера и служб..."
@@ -5837,9 +5960,6 @@ msgstr "Маршрут"
msgid "Resolved IP / DNS"
msgstr "IP / DNS"
msgid "Verdict"
msgstr "Статус"
msgid "Check domain or IP (e.g. example.com)..."
msgstr "Проверить домен или IP (напр. example.com)..."
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "fe-app-tachyon",
"version": "1.3.23",
"version": "1.3.24",
"license": "MIT",
"type": "module",
"scripts": {
@@ -1664,4 +1664,28 @@ export const TachyonShellMethods = {
},
};
},
leakCheck: async (): Promise<
Tachyon.MethodResponse<Tachyon.LeakCheckResult>
> => {
const response = await executeShellCommand({
command: '/usr/bin/tachyon',
args: [Tachyon.AvailableMethods.LEAK_CHECK],
timeout: 25000,
});
const parsed = parseJsonObjectOutput<Tachyon.LeakCheckResult>(
response.stdout,
);
if ((response.code ?? 0) !== 0 || !parsed) {
return {
success: false,
error: response.stderr || _('Failed to execute IP and DNS leak check'),
};
}
return {
success: true,
data: parsed,
};
},
};
@@ -41,6 +41,7 @@ import {
renderServiceCheckModal,
renderAiChatModal,
renderStrategyFuzzerModal,
renderLeakCheckModal,
} from './partials';
import { TachyonShellMethods } from '../../methods';
import { getConfigSections } from '../../methods/custom/getConfigSections';
@@ -1584,6 +1585,10 @@ function handleOpenAiChat() {
renderAiChatModal();
}
function handleOpenLeakCheck() {
renderLeakCheckModal();
}
function handleOpenStrategyFuzzer() {
getConfigSections()
.then((sections) => {
@@ -1919,6 +1924,12 @@ function renderDiagnosticAvailableActionsWidget() {
disabled: utilityActionsDisabled,
onClick: handleCheckServicesAction,
},
testLeaks: {
visible: true,
loading: false,
disabled: false,
onClick: handleOpenLeakCheck,
},
viewLogs: {
loading: diagnosticsActions.viewLogs.loading,
visible: true,
@@ -6,3 +6,4 @@ export * from './renderAiChatModal';
export * from './renderStrategyFuzzerModal';
export * from './renderSystemInfo';
export * from './renderDnsBenchmarkModal';
export * from './renderLeakCheckModal';
@@ -35,6 +35,7 @@ interface IRenderAvailableActionsProps {
showSingBoxConfig: ActionProps;
generateBugReport: ActionProps;
checkServices: ActionProps;
testLeaks?: ActionProps;
}
export function renderAvailableActions({
@@ -53,6 +54,7 @@ export function renderAvailableActions({
showSingBoxConfig,
generateBugReport,
checkServices,
testLeaks,
}: IRenderAvailableActionsProps) {
return E('div', { class: 'tachyon_diagnostic-page__right-bar__actions' }, [
E('b', {}, _('Available actions')),
@@ -174,6 +176,16 @@ export function renderAvailableActions({
disabled: checkServices.disabled,
}),
]),
...insertIf(!!testLeaks?.visible, [
renderButton({
classNames: ['cbi-button-action'],
onClick: testLeaks!.onClick,
icon: renderCircleCheckBigIcon24,
text: _('🛡️ IP & DNS Leak Test'),
loading: testLeaks!.loading,
disabled: testLeaks!.disabled,
}),
]),
...insertIf(viewLogs.visible, [
renderButton({
onClick: viewLogs.onClick,
@@ -0,0 +1,410 @@
import { TachyonShellMethods } from '../../../methods/shell';
import { renderButton } from '../../../../partials';
import { Tachyon } from '../../../types';
export function renderLeakCheckModal() {
let isRunning = false;
const progressBar = E('div', {
style:
'width: 0%; height: 6px; background: linear-gradient(90deg, #007bff, #28a745); border-radius: 3px; transition: width 0.4s ease;',
});
const progressContainer = E(
'div',
{
style:
'width: 100%; height: 6px; background: rgba(128,128,128,0.2); border-radius: 3px; overflow: hidden; margin-bottom: 14px;',
},
[progressBar],
);
const statusLabel = E(
'div',
{
style:
'font-size: 13px; font-weight: 500; margin-bottom: 12px; color: var(--text-color-medium, #6c757d);',
},
_('Initializing router-level IP & DNS leak test...'),
);
const resultsContainer = E('div', {
style: 'display: none; margin-bottom: 16px;',
});
const startTest = async () => {
if (isRunning) return;
isRunning = true;
resultsContainer.style.display = 'none';
progressContainer.style.display = 'block';
progressBar.style.width = '30%';
statusLabel.textContent = _(
'Querying WAN direct socket and proxy outbound on 127.0.0.1:4534...',
);
if (retryBtn) (retryBtn as HTMLButtonElement).disabled = true;
// Advance progress smoothly
const timer = setTimeout(() => {
progressBar.style.width = '70%';
statusLabel.textContent = _(
'Testing DNS leak upstream resolvers with bash.ws protocol...',
);
}, 1500);
try {
const response = await TachyonShellMethods.leakCheck();
clearTimeout(timer);
progressBar.style.width = '100%';
if (response.success && response.data) {
statusLabel.textContent = _('Leak detection completed');
setTimeout(() => {
progressContainer.style.display = 'none';
progressBar.style.width = '0%';
}, 400);
renderResults(response.data);
} else {
const err =
!response.success && response.error
? response.error
: _('Leak detection failed to complete');
progressContainer.style.display = 'none';
statusLabel.textContent = err;
resultsContainer.innerHTML = '';
resultsContainer.appendChild(
E(
'div',
{ class: 'alert-message warning' },
err ||
_(
'Could not contact leak test endpoints. Please ensure router has internet access.',
),
),
);
resultsContainer.style.display = 'block';
}
} catch (e) {
clearTimeout(timer);
progressContainer.style.display = 'none';
statusLabel.textContent = _('An unexpected error occurred during test');
resultsContainer.innerHTML = '';
resultsContainer.appendChild(
E(
'div',
{ class: 'alert-message warning' },
e instanceof Error ? e.message : String(e),
),
);
resultsContainer.style.display = 'block';
} finally {
isRunning = false;
if (retryBtn) (retryBtn as HTMLButtonElement).disabled = false;
}
};
const renderResults = (data: Tachyon.LeakCheckResult) => {
resultsContainer.innerHTML = '';
const { ip_leak, dns_leak } = data;
// --- 1. IP Leak Section ---
const ipAlertClass = !ip_leak.proxy_online
? 'alert-message warning'
: ip_leak.leaked
? 'alert-message danger'
: 'alert-message success';
const ipAlertText = !ip_leak.proxy_online
? _('Proxy is offline or unreachable on 127.0.0.1:4534.')
: ip_leak.leaked
? _(
'⚠️ CRITICAL IP LEAK: Your real public IP is exposed through the proxy outbound!',
)
: _(
'🛡️ SECURE: No IP leak detected. Real WAN IP is concealed behind proxy outbound.',
);
const ipTable = E(
'table',
{
class: 'table cbi-section-table',
style: 'width: 100%; margin-bottom: 12px; font-size: 12px;',
},
[
E('thead', {}, [
E('tr', { class: 'tr cbi-section-table-titles' }, [
E('th', { class: 'th' }, _('Connection Path')),
E('th', { class: 'th' }, _('Observed Public IP')),
E('th', { class: 'th' }, _('Location')),
E('th', { class: 'th' }, _('ISP / Organization')),
E('th', { class: 'th', style: 'text-align: center;' }, _('Status')),
]),
]),
E('tbody', {}, [
E('tr', { class: 'tr cbi-section-table-row' }, [
E('td', { class: 'td' }, [
E('b', {}, _('Direct WAN (ISP)')),
E(
'div',
{
style:
'font-size: 11px; color: var(--text-color-medium, #6c757d);',
},
_('Bypasses proxy (SO_MARK 0x08000000)'),
),
]),
E('td', { class: 'td' }, [E('code', {}, ip_leak.direct_ip || '—')]),
E(
'td',
{ class: 'td' },
[ip_leak.direct_country, ip_leak.direct_city]
.filter(Boolean)
.join(', ') || '—',
),
E('td', { class: 'td' }, ip_leak.direct_isp || '—'),
E('td', { class: 'td', style: 'text-align: center;' }, [
E(
'span',
{
class: 'badge',
style:
'background: var(--text-color-medium, #6c757d); color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 11px;',
},
_('Baseline'),
),
]),
]),
E('tr', { class: 'tr cbi-section-table-row' }, [
E('td', { class: 'td' }, [
E('b', {}, _('Proxy Outbound')),
E(
'div',
{
style:
'font-size: 11px; color: var(--text-color-medium, #6c757d);',
},
_('127.0.0.1:4534 (sing-box mixed)'),
),
]),
E('td', { class: 'td' }, [E('code', {}, ip_leak.proxy_ip || '—')]),
E(
'td',
{ class: 'td' },
[ip_leak.proxy_country, ip_leak.proxy_city]
.filter(Boolean)
.join(', ') || '—',
),
E('td', { class: 'td' }, ip_leak.proxy_org || '—'),
E('td', { class: 'td', style: 'text-align: center;' }, [
!ip_leak.proxy_online
? E(
'span',
{
class: 'badge',
style:
'background: #fd7e14; color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 11px;',
},
_('OFFLINE'),
)
: ip_leak.leaked
? E(
'span',
{
class: 'badge',
style:
'background: #dc3545; color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 11px;',
},
_('LEAKED'),
)
: E(
'span',
{
class: 'badge',
style:
'background: #28a745; color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 11px;',
},
_('SECURE'),
),
]),
]),
]),
],
);
const ipSection = E(
'div',
{
class: 'cbi-section',
style:
'margin-bottom: 20px; border: 1px solid var(--border-color, rgba(128,128,128,0.2)); border-radius: 6px; padding: 12px;',
},
[
E('h4', { style: 'margin-top: 0; margin-bottom: 8px;' }, [
'🌐 ',
_('Public IP Address Isolation'),
]),
E('div', { class: ipAlertClass, style: 'margin-bottom: 12px;' }, [
ipAlertText,
]),
ipTable,
],
);
// --- 2. DNS Leak Section ---
const dnsAlertClass = dns_leak.dns_leaked
? 'alert-message danger'
: dns_leak.dns_servers.length > 0
? 'alert-message success'
: 'alert-message info';
const dnsAlertText = dns_leak.dns_leaked
? _(
'⚠️ DNS LEAK DETECTED: DNS queries are leaking to your local Internet Service Provider!',
)
: dns_leak.dns_servers.length > 0
? _(
'🛡️ SECURE: No DNS leaks detected. All queries resolve through non-ISP upstream resolvers.',
)
: _(
'No DNS resolvers captured via proxy test. Proxy may be offline or blocking test subdomains.',
);
const dnsTableRows = (dns_leak.dns_servers || []).map((s) =>
E('tr', { class: 'tr cbi-section-table-row' }, [
E('td', { class: 'td' }, [E('code', {}, s.ip)]),
E('td', { class: 'td' }, s.country || '—'),
E('td', { class: 'td' }, s.isp || '—'),
E('td', { class: 'td', style: 'text-align: center;' }, [
s.is_isp
? E(
'span',
{
class: 'badge',
style:
'background: #dc3545; color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 11px;',
},
_('ISP DNS LEAK'),
)
: E(
'span',
{
class: 'badge',
style:
'background: #28a745; color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 11px;',
},
_('SAFE'),
),
]),
]),
);
const dnsTable = E(
'table',
{
class: 'table cbi-section-table',
style: 'width: 100%; font-size: 12px; margin-bottom: 12px;',
},
[
E('thead', {}, [
E('tr', { class: 'tr cbi-section-table-titles' }, [
E('th', { class: 'th' }, _('Resolver IP')),
E('th', { class: 'th' }, _('Country')),
E('th', { class: 'th' }, _('Upstream Provider / ASN')),
E(
'th',
{ class: 'th', style: 'text-align: center;' },
_('Verdict'),
),
]),
]),
E(
'tbody',
{},
dnsTableRows.length > 0
? dnsTableRows
: [
E('tr', { class: 'tr' }, [
E(
'td',
{
class: 'td',
colSpan: 4,
style: 'text-align: center; opacity: 0.7;',
},
_('No DNS resolvers recorded'),
),
]),
],
),
],
);
const dnsSection = E(
'div',
{
class: 'cbi-section',
style:
'border: 1px solid var(--border-color, rgba(128,128,128,0.2)); border-radius: 6px; padding: 12px;',
},
[
E('h4', { style: 'margin-top: 0; margin-bottom: 8px;' }, [
'🔍 ',
_('DNS Upstream Resolver Analysis (bash.ws protocol)'),
]),
E('div', { class: dnsAlertClass, style: 'margin-bottom: 12px;' }, [
dnsAlertText,
]),
dnsTable,
],
);
resultsContainer.appendChild(ipSection);
resultsContainer.appendChild(dnsSection);
resultsContainer.style.display = 'block';
};
const retryBtn = renderButton({
classNames: ['cbi-button-action'],
onClick: startTest,
text: `🔄 ${_('Re-run Leak Test')}`,
});
const closeBtn = renderButton({
classNames: ['cbi-button'],
onClick: () => {
if (ui.hideModal) ui.hideModal();
},
text: _('Close'),
});
const modalContent = E('div', { style: 'padding: 8px;' }, [
E(
'p',
{
style:
'font-size: 13px; color: var(--text-color-medium, #6c757d); margin-bottom: 14px;',
},
_(
'Performs simultaneous outbound checks via direct WAN (bypassing Sing-box redirect) and via proxy (127.0.0.1:4534) to verify that your real IP and DNS queries are not leaking to your ISP.',
),
),
statusLabel,
progressContainer,
resultsContainer,
E(
'div',
{
style:
'display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; border-top: 1px solid var(--border-color, rgba(128,128,128,0.2)); padding-top: 12px;',
},
[retryBtn, closeBtn],
),
]);
ui.showModal(`🛡️ ${_('Tachyon IP & DNS Leak Detection')}`, modalContent);
// Auto-run test on open
startTest();
}
@@ -0,0 +1,221 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => {
class MockMutationObserver {
observe() {}
disconnect() {}
takeRecords() {
return [];
}
}
(globalThis as any).MutationObserver = MockMutationObserver;
function createDummyElement(tag: string) {
return {
tagName: tag.toUpperCase(),
children: [] as any[],
style: {} as Record<string, string>,
textContent: '',
innerHTML: '',
setAttribute() {},
getAttribute() {
return null;
},
addEventListener() {},
querySelectorAll: () => [],
querySelector: () => null,
replaceChildren: function (...nodes: any[]) {
(this as any).children = nodes;
},
appendChild: function (node: any) {
(this as any).children.push(node);
},
append: function (...nodes: any[]) {
nodes.forEach((node) => (this as any).children.push(node));
},
classList: {
add() {},
remove() {},
},
};
}
(globalThis as any).document = {
body: {} as any,
createElement: (tag: string) => createDummyElement(tag) as any,
createElementNS: (_ns: string, tag: string) =>
createDummyElement(tag) as any,
getElementById: () => null,
querySelector: () => null,
querySelectorAll: () => [],
};
(globalThis as any).E = (tag: string, attrs?: any, children?: any) => {
const el = (globalThis as any).document.createElement(tag);
if (attrs) {
const { style, ...rest } = attrs;
Object.assign(el, rest);
if (style && typeof style === 'string') {
style.split(';').forEach((rule: string) => {
const [k, v] = rule.split(':').map((s: string) => s.trim());
if (k && v) {
el.style[k] = v;
}
});
} else if (style && typeof style === 'object') {
Object.assign(el.style, style);
}
}
if (children) {
if (Array.isArray(children)) {
children.forEach((c) => {
if (c) el.appendChild(c);
});
} else {
el.appendChild(children);
}
}
return el;
};
(globalThis as any)._ = (str: string) => str;
(globalThis as any).rpc = { declare: vi.fn() };
(globalThis as any).uci = { sections: vi.fn().mockResolvedValue([]) };
(globalThis as any).localStorage = { getItem: vi.fn(), setItem: vi.fn() };
const showModal = vi.fn();
const hideModal = vi.fn();
(globalThis as any).ui = {
showModal,
hideModal,
addNotification: vi.fn(),
};
return {
showModal,
hideModal,
};
});
import { renderLeakCheckModal } from '../partials/renderLeakCheckModal';
import { TachyonShellMethods } from '../../../methods/shell';
describe('renderLeakCheckModal', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.showModal.mockReset();
vi.spyOn(TachyonShellMethods, 'leakCheck').mockResolvedValue({
success: true,
data: {
ip_leak: {
leaked: false,
direct_ip: '95.173.136.25',
direct_country: 'Russia',
direct_isp: 'Rostelecom',
proxy_ip: '185.220.101.5',
proxy_country: 'Netherlands',
proxy_org: 'Mullvad',
proxy_online: true,
},
dns_leak: {
dns_leaked: false,
direct_ip: '95.173.136.25',
proxy_ip: '185.220.101.5',
dns_servers: [
{
ip: '1.1.1.1',
country: 'United States',
isp: 'Cloudflare',
is_isp: false,
},
],
direct_dns_servers: [
{
ip: '212.188.4.10',
country: 'Russia',
isp: 'Rostelecom',
is_isp: true,
},
],
proxy_online: true,
},
},
});
});
it('renders and displays the IP & DNS Leak Detection modal', () => {
renderLeakCheckModal();
expect(mocks.showModal).toHaveBeenCalledTimes(1);
expect(mocks.showModal).toHaveBeenCalledWith(
expect.stringContaining('Tachyon IP & DNS Leak Detection'),
expect.anything(),
);
});
it('calls TachyonShellMethods.leakCheck on start', async () => {
const leakCheckSpy = vi.spyOn(TachyonShellMethods, 'leakCheck');
renderLeakCheckModal();
expect(leakCheckSpy).toHaveBeenCalledTimes(1);
});
it('handles leak detection when IP is leaked', async () => {
vi.spyOn(TachyonShellMethods, 'leakCheck').mockResolvedValue({
success: true,
data: {
ip_leak: {
leaked: true,
direct_ip: '95.173.136.25',
proxy_ip: '95.173.136.25',
proxy_online: true,
},
dns_leak: {
dns_leaked: true,
direct_ip: '95.173.136.25',
proxy_ip: '95.173.136.25',
dns_servers: [
{
ip: '212.188.4.10',
country: 'Russia',
isp: 'Rostelecom',
is_isp: true,
},
],
direct_dns_servers: [],
proxy_online: true,
},
},
});
renderLeakCheckModal();
expect(mocks.showModal).toHaveBeenCalledTimes(1);
});
it('handles offline proxy gracefully', async () => {
vi.spyOn(TachyonShellMethods, 'leakCheck').mockResolvedValue({
success: true,
data: {
ip_leak: {
leaked: false,
direct_ip: '95.173.136.25',
proxy_ip: '—',
proxy_online: false,
},
dns_leak: {
dns_leaked: false,
direct_ip: '95.173.136.25',
proxy_ip: '—',
dns_servers: [],
direct_dns_servers: [],
proxy_online: false,
},
},
});
renderLeakCheckModal();
expect(mocks.showModal).toHaveBeenCalledTimes(1);
});
});
+40
View File
@@ -117,6 +117,9 @@ export namespace Tachyon {
DNS_BENCHMARK_STATUS = 'dns_benchmark_status',
DNS_BENCHMARK_STOP = 'dns_benchmark_stop',
DNS_BENCHMARK_APPLY = 'dns_benchmark_apply',
LEAK_CHECK = 'leak_check',
CHECK_IP_LEAK = 'check_ip_leak',
CHECK_DNS_LEAK = 'check_dns_leak',
}
export enum AvailableClashAPIMethods {
@@ -1166,4 +1169,41 @@ export namespace Tachyon {
started_at: number;
finished_at: number | null;
}
export interface IPLeakResult {
leaked: boolean;
direct_ip: string;
direct_country?: string;
direct_country_code?: string;
direct_city?: string;
direct_isp?: string;
proxy_ip: string;
proxy_country?: string;
proxy_country_code?: string;
proxy_city?: string;
proxy_org?: string;
proxy_online: boolean;
}
export interface DNSResolverInfo {
ip: string;
country: string;
isp: string;
is_isp: boolean;
}
export interface DNSLeakResult {
dns_leaked: boolean;
direct_ip: string;
proxy_ip: string;
dns_servers: DNSResolverInfo[];
direct_dns_servers: DNSResolverInfo[];
proxy_online: boolean;
}
export interface LeakCheckResult {
ip_leak: IPLeakResult;
dns_leak: DNSLeakResult;
timestamp?: number;
}
}
@@ -3433,6 +3433,9 @@ var Tachyon;
AvailableMethods2["DNS_BENCHMARK_STATUS"] = "dns_benchmark_status";
AvailableMethods2["DNS_BENCHMARK_STOP"] = "dns_benchmark_stop";
AvailableMethods2["DNS_BENCHMARK_APPLY"] = "dns_benchmark_apply";
AvailableMethods2["LEAK_CHECK"] = "leak_check";
AvailableMethods2["CHECK_IP_LEAK"] = "check_ip_leak";
AvailableMethods2["CHECK_DNS_LEAK"] = "check_dns_leak";
})(AvailableMethods = Tachyon2.AvailableMethods || (Tachyon2.AvailableMethods = {}));
let AvailableClashAPIMethods;
((AvailableClashAPIMethods2) => {
@@ -4724,6 +4727,26 @@ var TachyonShellMethods = {
recommendation: parsed.recommendation
}
};
},
leakCheck: async () => {
const response = await executeShellCommand({
command: "/usr/bin/tachyon",
args: [Tachyon.AvailableMethods.LEAK_CHECK],
timeout: 25e3
});
const parsed = parseJsonObjectOutput(
response.stdout
);
if ((response.code ?? 0) !== 0 || !parsed) {
return {
success: false,
error: response.stderr || _("Failed to execute IP and DNS leak check")
};
}
return {
success: true,
data: parsed
};
}
};
@@ -11320,7 +11343,8 @@ function renderAvailableActions({
viewLogs,
showSingBoxConfig,
generateBugReport,
checkServices
checkServices,
testLeaks
}) {
return E("div", { class: "tachyon_diagnostic-page__right-bar__actions" }, [
E("b", {}, _("Available actions")),
@@ -11442,6 +11466,16 @@ function renderAvailableActions({
disabled: checkServices.disabled
})
]),
...insertIf(!!testLeaks?.visible, [
renderButton({
classNames: ["cbi-button-action"],
onClick: testLeaks.onClick,
icon: renderCircleCheckBigIcon24,
text: _("\u{1F6E1}\uFE0F IP & DNS Leak Test"),
loading: testLeaks.loading,
disabled: testLeaks.disabled
})
]),
...insertIf(viewLogs.visible, [
renderButton({
onClick: viewLogs.onClick,
@@ -14709,6 +14743,336 @@ function renderDnsBenchmarkModal() {
startBenchmark();
}
// src/tachyon/tabs/diagnostic/partials/renderLeakCheckModal.ts
function renderLeakCheckModal() {
let isRunning = false;
const progressBar = E("div", {
style: "width: 0%; height: 6px; background: linear-gradient(90deg, #007bff, #28a745); border-radius: 3px; transition: width 0.4s ease;"
});
const progressContainer = E(
"div",
{
style: "width: 100%; height: 6px; background: rgba(128,128,128,0.2); border-radius: 3px; overflow: hidden; margin-bottom: 14px;"
},
[progressBar]
);
const statusLabel = E(
"div",
{
style: "font-size: 13px; font-weight: 500; margin-bottom: 12px; color: var(--text-color-medium, #6c757d);"
},
_("Initializing router-level IP & DNS leak test...")
);
const resultsContainer = E("div", {
style: "display: none; margin-bottom: 16px;"
});
const startTest = async () => {
if (isRunning) return;
isRunning = true;
resultsContainer.style.display = "none";
progressContainer.style.display = "block";
progressBar.style.width = "30%";
statusLabel.textContent = _(
"Querying WAN direct socket and proxy outbound on 127.0.0.1:4534..."
);
if (retryBtn) retryBtn.disabled = true;
const timer = setTimeout(() => {
progressBar.style.width = "70%";
statusLabel.textContent = _(
"Testing DNS leak upstream resolvers with bash.ws protocol..."
);
}, 1500);
try {
const response = await TachyonShellMethods.leakCheck();
clearTimeout(timer);
progressBar.style.width = "100%";
if (response.success && response.data) {
statusLabel.textContent = _("Leak detection completed");
setTimeout(() => {
progressContainer.style.display = "none";
progressBar.style.width = "0%";
}, 400);
renderResults(response.data);
} else {
const err = !response.success && response.error ? response.error : _("Leak detection failed to complete");
progressContainer.style.display = "none";
statusLabel.textContent = err;
resultsContainer.innerHTML = "";
resultsContainer.appendChild(
E(
"div",
{ class: "alert-message warning" },
err || _(
"Could not contact leak test endpoints. Please ensure router has internet access."
)
)
);
resultsContainer.style.display = "block";
}
} catch (e) {
clearTimeout(timer);
progressContainer.style.display = "none";
statusLabel.textContent = _("An unexpected error occurred during test");
resultsContainer.innerHTML = "";
resultsContainer.appendChild(
E(
"div",
{ class: "alert-message warning" },
e instanceof Error ? e.message : String(e)
)
);
resultsContainer.style.display = "block";
} finally {
isRunning = false;
if (retryBtn) retryBtn.disabled = false;
}
};
const renderResults = (data) => {
resultsContainer.innerHTML = "";
const { ip_leak, dns_leak } = data;
const ipAlertClass = !ip_leak.proxy_online ? "alert-message warning" : ip_leak.leaked ? "alert-message danger" : "alert-message success";
const ipAlertText = !ip_leak.proxy_online ? _("Proxy is offline or unreachable on 127.0.0.1:4534.") : ip_leak.leaked ? _(
"\u26A0\uFE0F CRITICAL IP LEAK: Your real public IP is exposed through the proxy outbound!"
) : _(
"\u{1F6E1}\uFE0F SECURE: No IP leak detected. Real WAN IP is concealed behind proxy outbound."
);
const ipTable = E(
"table",
{
class: "table cbi-section-table",
style: "width: 100%; margin-bottom: 12px; font-size: 12px;"
},
[
E("thead", {}, [
E("tr", { class: "tr cbi-section-table-titles" }, [
E("th", { class: "th" }, _("Connection Path")),
E("th", { class: "th" }, _("Observed Public IP")),
E("th", { class: "th" }, _("Location")),
E("th", { class: "th" }, _("ISP / Organization")),
E("th", { class: "th", style: "text-align: center;" }, _("Status"))
])
]),
E("tbody", {}, [
E("tr", { class: "tr cbi-section-table-row" }, [
E("td", { class: "td" }, [
E("b", {}, _("Direct WAN (ISP)")),
E(
"div",
{
style: "font-size: 11px; color: var(--text-color-medium, #6c757d);"
},
_("Bypasses proxy (SO_MARK 0x08000000)")
)
]),
E("td", { class: "td" }, [E("code", {}, ip_leak.direct_ip || "\u2014")]),
E(
"td",
{ class: "td" },
[ip_leak.direct_country, ip_leak.direct_city].filter(Boolean).join(", ") || "\u2014"
),
E("td", { class: "td" }, ip_leak.direct_isp || "\u2014"),
E("td", { class: "td", style: "text-align: center;" }, [
E(
"span",
{
class: "badge",
style: "background: var(--text-color-medium, #6c757d); color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 11px;"
},
_("Baseline")
)
])
]),
E("tr", { class: "tr cbi-section-table-row" }, [
E("td", { class: "td" }, [
E("b", {}, _("Proxy Outbound")),
E(
"div",
{
style: "font-size: 11px; color: var(--text-color-medium, #6c757d);"
},
_("127.0.0.1:4534 (sing-box mixed)")
)
]),
E("td", { class: "td" }, [E("code", {}, ip_leak.proxy_ip || "\u2014")]),
E(
"td",
{ class: "td" },
[ip_leak.proxy_country, ip_leak.proxy_city].filter(Boolean).join(", ") || "\u2014"
),
E("td", { class: "td" }, ip_leak.proxy_org || "\u2014"),
E("td", { class: "td", style: "text-align: center;" }, [
!ip_leak.proxy_online ? E(
"span",
{
class: "badge",
style: "background: #fd7e14; color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 11px;"
},
_("OFFLINE")
) : ip_leak.leaked ? E(
"span",
{
class: "badge",
style: "background: #dc3545; color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 11px;"
},
_("LEAKED")
) : E(
"span",
{
class: "badge",
style: "background: #28a745; color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 11px;"
},
_("SECURE")
)
])
])
])
]
);
const ipSection = E(
"div",
{
class: "cbi-section",
style: "margin-bottom: 20px; border: 1px solid var(--border-color, rgba(128,128,128,0.2)); border-radius: 6px; padding: 12px;"
},
[
E("h4", { style: "margin-top: 0; margin-bottom: 8px;" }, [
"\u{1F310} ",
_("Public IP Address Isolation")
]),
E("div", { class: ipAlertClass, style: "margin-bottom: 12px;" }, [
ipAlertText
]),
ipTable
]
);
const dnsAlertClass = dns_leak.dns_leaked ? "alert-message danger" : dns_leak.dns_servers.length > 0 ? "alert-message success" : "alert-message info";
const dnsAlertText = dns_leak.dns_leaked ? _(
"\u26A0\uFE0F DNS LEAK DETECTED: DNS queries are leaking to your local Internet Service Provider!"
) : dns_leak.dns_servers.length > 0 ? _(
"\u{1F6E1}\uFE0F SECURE: No DNS leaks detected. All queries resolve through non-ISP upstream resolvers."
) : _(
"No DNS resolvers captured via proxy test. Proxy may be offline or blocking test subdomains."
);
const dnsTableRows = (dns_leak.dns_servers || []).map(
(s) => E("tr", { class: "tr cbi-section-table-row" }, [
E("td", { class: "td" }, [E("code", {}, s.ip)]),
E("td", { class: "td" }, s.country || "\u2014"),
E("td", { class: "td" }, s.isp || "\u2014"),
E("td", { class: "td", style: "text-align: center;" }, [
s.is_isp ? E(
"span",
{
class: "badge",
style: "background: #dc3545; color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 11px;"
},
_("ISP DNS LEAK")
) : E(
"span",
{
class: "badge",
style: "background: #28a745; color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 11px;"
},
_("SAFE")
)
])
])
);
const dnsTable = E(
"table",
{
class: "table cbi-section-table",
style: "width: 100%; font-size: 12px; margin-bottom: 12px;"
},
[
E("thead", {}, [
E("tr", { class: "tr cbi-section-table-titles" }, [
E("th", { class: "th" }, _("Resolver IP")),
E("th", { class: "th" }, _("Country")),
E("th", { class: "th" }, _("Upstream Provider / ASN")),
E(
"th",
{ class: "th", style: "text-align: center;" },
_("Verdict")
)
])
]),
E(
"tbody",
{},
dnsTableRows.length > 0 ? dnsTableRows : [
E("tr", { class: "tr" }, [
E(
"td",
{
class: "td",
colSpan: 4,
style: "text-align: center; opacity: 0.7;"
},
_("No DNS resolvers recorded")
)
])
]
)
]
);
const dnsSection = E(
"div",
{
class: "cbi-section",
style: "border: 1px solid var(--border-color, rgba(128,128,128,0.2)); border-radius: 6px; padding: 12px;"
},
[
E("h4", { style: "margin-top: 0; margin-bottom: 8px;" }, [
"\u{1F50D} ",
_("DNS Upstream Resolver Analysis (bash.ws protocol)")
]),
E("div", { class: dnsAlertClass, style: "margin-bottom: 12px;" }, [
dnsAlertText
]),
dnsTable
]
);
resultsContainer.appendChild(ipSection);
resultsContainer.appendChild(dnsSection);
resultsContainer.style.display = "block";
};
const retryBtn = renderButton({
classNames: ["cbi-button-action"],
onClick: startTest,
text: `\u{1F504} ${_("Re-run Leak Test")}`
});
const closeBtn = renderButton({
classNames: ["cbi-button"],
onClick: () => {
if (ui.hideModal) ui.hideModal();
},
text: _("Close")
});
const modalContent = E("div", { style: "padding: 8px;" }, [
E(
"p",
{
style: "font-size: 13px; color: var(--text-color-medium, #6c757d); margin-bottom: 14px;"
},
_(
"Performs simultaneous outbound checks via direct WAN (bypassing Sing-box redirect) and via proxy (127.0.0.1:4534) to verify that your real IP and DNS queries are not leaking to your ISP."
)
),
statusLabel,
progressContainer,
resultsContainer,
E(
"div",
{
style: "display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; border-top: 1px solid var(--border-color, rgba(128,128,128,0.2)); padding-top: 12px;"
},
[retryBtn, closeBtn]
)
]);
ui.showModal(`\u{1F6E1}\uFE0F ${_("Tachyon IP & DNS Leak Detection")}`, modalContent);
startTest();
}
// src/helpers/normalizeCompiledVersion.ts
function normalizeCompiledVersion(version, commitSha) {
if (!version || version.includes("COMPILED")) {
@@ -16424,6 +16788,9 @@ async function handleRunAiDoctor() {
function handleOpenAiChat() {
renderAiChatModal();
}
function handleOpenLeakCheck() {
renderLeakCheckModal();
}
function handleOpenStrategyFuzzer() {
getConfigSections().then((sections) => {
const ruleNames = sections.filter((s) => s[".type"] === "section" || s[".type"] === "rule").map((s) => s.name || s[".name"]).filter((n) => Boolean(n));
@@ -16711,6 +17078,12 @@ function renderDiagnosticAvailableActionsWidget() {
disabled: utilityActionsDisabled,
onClick: handleCheckServicesAction
},
testLeaks: {
visible: true,
loading: false,
disabled: false,
onClick: handleOpenLeakCheck
},
viewLogs: {
loading: diagnosticsActions.viewLogs.loading,
visible: true,
+132 -12
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: TACHYON\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-08 09:57+0300\n"
"PO-Revision-Date: 2026-09-08 09:57+0300\n"
"POT-Creation-Date: 2026-09-09 17:09+0300\n"
"PO-Revision-Date: 2026-09-09 17:09+0300\n"
"Last-Translator: Dushnilin\n"
"Language-Team: none\n"
"Language: ru\n"
@@ -50,6 +50,12 @@ msgstr "⚠️ Бенчмарк завершен. Не найдено рабоч
msgid "⚠️ Benchmark stopped"
msgstr "⚠️ Бенчмарк остановлен"
msgid "⚠️ CRITICAL IP LEAK: Your real public IP is exposed through the proxy outbound!"
msgstr "⚠️ КРИТИЧЕСКАЯ УТЕЧКА IP: Ваш реальный публичный IP раскрыт через исходящий прокси!"
msgid "⚠️ DNS LEAK DETECTED: DNS queries are leaking to your local Internet Service Provider!"
msgstr "⚠️ ОБНАРУЖЕНА УТЕЧКА DNS: DNS-запросы утекают к вашему местному интернет-провайдеру!"
msgid "⚠️ If all Bootstrap DNS fail 3 times, queries will be sent to your ISP's DNS in plaintext. Only use as a last resort to prevent complete internet loss."
msgstr "⚠️ Если все Bootstrap DNS завершатся с ошибкой 3 раза, запросы будут отправлены в DNS вашего провайдера в открытом виде. Используйте только в крайнем случае для предотвращения полной потери интернета."
@@ -155,9 +161,21 @@ msgstr "🛑 Остановить бенчмарк"
msgid "🛠️ My Custom Strategies Only"
msgstr "🛠️ Только мои стратегии"
msgid "🛡️ IP & DNS Leak Test"
msgstr "🛡️ Проверка утечек IP и DNS"
msgid "🛡️ SECURE: No DNS leaks detected. All queries resolve through non-ISP upstream resolvers."
msgstr "🛡️ ЗАЩИЩЕНО: Утечек DNS не обнаружено. Все запросы обрабатываются независимыми резолверами."
msgid "🛡️ SECURE: No IP leak detected. Real WAN IP is concealed behind proxy outbound."
msgstr "🛡️ ЗАЩИЩЕНО: Утечек IP не обнаружено. Реальный WAN IP скрыт за исходящим прокси."
msgid "🟣 Twitch Suite (Live Stream + HLS CDN)"
msgstr "🟣 Twitch Набор (Прямой эфир + HLS CDN)"
msgid "127.0.0.1:4534 (sing-box mixed)"
msgstr "127.0.0.1:4534 (смешанный sing-box)"
msgid "Блокировка по IP (Таймаут TCP SYN)"
msgstr "Блокировка по IP (Таймаут TCP SYN)"
@@ -485,6 +503,9 @@ msgstr "Генератор AmneziaWG"
msgid "AmneziaWG Version"
msgstr "Версия AmneziaWG"
msgid "An unexpected error occurred during test"
msgstr "Произошла непредвиденная ошибка во время проверки"
msgid "Anonymized support report copied to clipboard"
msgstr "Анонимизированный отчет скопирован в буфер обмена"
@@ -641,6 +662,9 @@ msgstr "Ср. задержка"
msgid "Backend validation failed: %s"
msgstr "Ошибка проверки бэкенда: %s"
msgid "Baseline"
msgstr "Базовый уровень"
msgid "Basic connectivity"
msgstr "Базовая связность"
@@ -791,6 +815,9 @@ msgstr "Стратегия ByeDPI"
msgid "ByeDPI strategy cannot be empty"
msgstr "Стратегия ByeDPI не может быть пустой"
msgid "Bypasses proxy (SO_MARK 0x08000000)"
msgstr "В обход прокси (SO_MARK 0x08000000)"
msgid "Bypasses UDP traffic for selected game consoles (PS5/Xbox) to achieve NAT Type 1 / 2 (Full Cone NAT) for P2P matchmaking, while keeping TCP (PSN/Auth) routed through the proxy."
msgstr "Обходит UDP-трафик для выбранных игровых консолей (PS5/Xbox) для достижения NAT Type 1 / 2 (Full Cone NAT) для P2P-матчмейкинга, сохраняя TCP (PSN/Auth) через прокси."
@@ -1055,6 +1082,9 @@ msgstr "Подключено"
msgid "Connected Devices"
msgstr "Подключенные устройства"
msgid "Connection Path"
msgstr "Маршрут соединения"
msgid "Connection URL"
msgstr "URL подключения"
@@ -1100,6 +1130,12 @@ msgstr "Основные настройки и параметры"
msgid "Could not compare FakeIP and control public IPs"
msgstr "Не удалось сравнить публичные IP FakeIP и контрольной проверки"
msgid "Could not contact leak test endpoints. Please ensure router has internet access."
msgstr "Не удалось связаться с серверами проверки утечек. Убедитесь, что у роутера есть доступ в интернет."
msgid "Country"
msgstr "Страна"
msgid "Country code (e.g. ru, us, de)"
msgstr "Код страны (напр. ru, us, de)"
@@ -1271,6 +1307,9 @@ msgstr "Прямой прокси"
msgid "Direct WAN"
msgstr "Прямой WAN"
msgid "Direct WAN (ISP)"
msgstr "Напрямую в WAN (провайдер)"
msgid "Disable"
msgstr "Отключить"
@@ -1382,6 +1421,9 @@ msgstr "Быстрый DNS-кэш"
msgid "DNS Unavailability Timeout"
msgstr "Таймаут недоступности DNS"
msgid "DNS Upstream Resolver Analysis (bash.ws protocol)"
msgstr "Анализ восходящих DNS-резолверов (протокол bash.ws)"
msgid "Do not panic, everything can be fixed, just..."
msgstr "Не паникуйте, всё можно исправить, просто..."
@@ -1524,7 +1566,7 @@ msgid "e.g. SHA256 — leave empty for default"
msgstr "напр. SHA256 — оставьте пустым для значения по умолчанию"
msgid "Each entry must be \"proto\", \"proto:port\" or \"proto:port-port\" (tcp/udp), e.g. tcp:6881,udp:6881-6889"
msgstr "Каждая запись — \"proto\", \"proto:port\" или \"proto:port-port\" (tcp/udp), напр. tcp:6881,udp:6881-6889"
msgstr "Каждая запись должна быть \"proto\", \"proto:port\" или \"proto:port-port\" (tcp/udp), например: tcp:6881,udp:6881-6889"
msgid "Edit"
msgstr "Изменить"
@@ -1802,6 +1844,9 @@ msgstr "Не удалось определить тип DPI"
msgid "Failed to execute"
msgstr "Не удалось выполнить"
msgid "Failed to execute IP and DNS leak check"
msgstr "Не удалось выполнить проверку утечек IP и DNS"
msgid "Failed to fetch releases"
msgstr "Не удалось получить список релизов"
@@ -2348,6 +2393,9 @@ msgstr "Полезный груз init (I1)"
msgid "Init timeout (Itime)"
msgstr "Таймаут init (Itime)"
msgid "Initializing router-level IP & DNS leak test..."
msgstr "Инициализация проверки утечек IP и DNS с роутера..."
msgid "Install"
msgstr "Установить"
@@ -2517,7 +2565,7 @@ msgid "Invalid Shadowsocks URL: missing credentials"
msgstr "Неверный URL Shadowsocks: отсутствуют учетные данные"
msgid "Invalid Shadowsocks URL: missing method and password separator \":\""
msgstr "Неверный Shadowsocks URL: отсутствует разделитель метода и пароля \":\""
msgstr "Неверный URL Shadowsocks: отсутствует разделитель метода и пароля \":\""
msgid "Invalid Shadowsocks URL: missing port"
msgstr "Неверный URL Shadowsocks: отсутствует порт"
@@ -2660,6 +2708,12 @@ msgstr "Изолировать трафик BitTorrent и направить е
msgid "Isolate Local LAN"
msgstr "Изолировать локальную сеть"
msgid "ISP / Organization"
msgstr "Провайдер / Организация"
msgid "ISP DNS LEAK"
msgstr "УТЕЧКА DNS К ПРОВАЙДЕРУ"
msgid "Issues"
msgstr "Проблемы"
@@ -2780,6 +2834,15 @@ msgstr "Установлена последняя версия"
msgid "Latest version:"
msgstr "Последняя версия:"
msgid "Leak detection completed"
msgstr "Проверка утечек завершена"
msgid "Leak detection failed to complete"
msgstr "Не удалось завершить проверку утечек"
msgid "LEAKED"
msgstr "УТЕЧКА"
msgid "Leave empty to omit this header"
msgstr "Оставьте пустым, чтобы пропустить этот заголовок"
@@ -2858,6 +2921,9 @@ msgstr "Загрузка..."
msgid "Local Address"
msgstr "Локальный адрес"
msgid "Location"
msgstr "Местоположение"
msgid "Log Level"
msgstr "Уровень логирования"
@@ -3078,13 +3144,13 @@ msgid "No active routing sections found."
msgstr "Активные секции маршрутизации не найдены."
msgid "No benchmark data yet. Click \"Start Benchmark\" to begin."
msgstr "Нет данных бенчмарка. Нажмите «Начать тест», чтобы запустить проверку."
msgstr "Данные тестирования отсутствуют. Нажмите \"Запустить тест\" для начала."
msgid "No benchmark history yet. Run a benchmark to see results here."
msgstr "История бенчмарков пуста. Запустите бенчмарк, чтобы увидеть результаты здесь."
msgid "No benchmark results yet. Configure options and click \"Start Benchmark\"."
msgstr "Результатов бенчмарка пока нет. Настройте параметры и нажмите «Запустить бенчмарк»."
msgstr "Результаты тестирования пока отсутствуют. Настройте параметры и нажмите \"Запустить тест\"."
msgid "No check targets found in the configuration."
msgstr "Цели для проверки не найдены в конфигурации."
@@ -3113,6 +3179,12 @@ msgstr "DHCP-клиенты не обнаружены"
msgid "No diagnostic history available yet."
msgstr "История проверок пока пуста."
msgid "No DNS resolvers captured via proxy test. Proxy may be offline or blocking test subdomains."
msgstr "В тесте через прокси резолверы не зафиксированы. Возможно, прокси не работает или блокирует тестовые поддомены."
msgid "No DNS resolvers recorded"
msgstr "DNS-резолверы не зафиксированы"
msgid "No domains detected yet"
msgstr "Домены пока не обнаружены"
@@ -3242,6 +3314,12 @@ msgstr "Обфускация"
msgid "Obfuscation password"
msgstr "Пароль обфускации"
msgid "Observed Public IP"
msgstr "Внешний публичный IP"
msgid "OFFLINE"
msgstr "ОФЛАЙН"
msgid "Ollama (Local PC / Server)"
msgstr "Ollama (локальный ПК / сервер)"
@@ -3312,13 +3390,13 @@ msgid "Optimize RAM memory"
msgstr "Оптимизировать память (RAM)"
msgid "Option \"%s\" contains an invalid input value."
msgstr "Параметр \"%s\" содержит недопустимое значение."
msgstr "Опция \"%s\" содержит недопустимое значение."
msgid "Optional — leave empty for default"
msgstr "Опционально — оставьте пустым для значения по умолчанию"
msgid "Optional context (e.g. \"Rostelecom, YouTube 4K buffering is slow\")..."
msgstr "Дополнительный контекст (напр. «Ростелеком, YouTube 4K долго буферизуется»)..."
msgstr "Дополнительный контекст (напр. \"Ростелеком, долго грузится YouTube 4K\")..."
msgid "Optional tls-auth or tls-crypt key"
msgstr "Опциональный ключ tls-auth или tls-crypt"
@@ -3440,6 +3518,9 @@ msgstr "Ожидает запуска"
msgid "Pending..."
msgstr "Ожидание..."
msgid "Performs simultaneous outbound checks via direct WAN (bypassing Sing-box redirect) and via proxy (127.0.0.1:4534) to verify that your real IP and DNS queries are not leaking to your ISP."
msgstr "Выполняет одновременные исходящие запросы напрямую в WAN (в обход правил Sing-box) и через прокси (127.0.0.1:4534), проверяя, что ваш реальный IP и DNS-запросы не утекают провайдеру."
msgid "Periodically check the current level and switch to a faster server even when the current one works."
msgstr "Периодически проверять текущий уровень и переключаться на более быстрый сервер, даже если текущий работает."
@@ -3462,7 +3543,7 @@ msgid "Pin on dashboard"
msgstr "Закрепить на дашборде"
msgid "Pin P2P isolation to the torrent client's own ports, comma separated. Each entry is \"proto\", \"proto:port\" or \"proto:port-port\" (tcp/udp). Without a port the whole protocol is routed direct."
msgstr "Закрепить изоляцию P2P за портами торрент-клиента, через запятую. Каждая запись — \"протокол\", \"протокол:порт\" или \"протокол:порт-порт\" (tcp/udp). Без порта весь протокол уходит напрямую."
msgstr "Привязка изоляции P2P к портам торрент-клиента через запятую. Каждая запись: \"proto\", \"proto:port\" или \"proto:port-port\" (tcp/udp). Без указания порта весь протокол направляется напрямую."
msgid "Plain-UDP DNS servers used as a last resort when all primary DNS fail. These are always unencrypted (no DoH/DoT). Example: 1.1.1.1, 8.8.8.8. Unlike the ISP fallback toggle, these are your own chosen servers."
msgstr "Обычные UDP DNS-серверы, используемые как резерв при недоступности всех основных DNS. Всегда работают без шифрования (без DoH/DoT). Пример: 1.1.1.1, 8.8.8.8. В отличие от аварийного DNS провайдера, это выбранные вами серверы."
@@ -3620,9 +3701,15 @@ msgstr "Интервал проверки здоровья прокси (с)"
msgid "Proxy Health Check URL"
msgstr "URL проверки здоровья прокси"
msgid "Proxy is offline or unreachable on 127.0.0.1:4534."
msgstr "Прокси выключен или недоступен на 127.0.0.1:4534."
msgid "Proxy link is unavailable"
msgstr "Ссылка прокси недоступна"
msgid "Proxy Outbound"
msgstr "Исходящий прокси (туннель)"
msgid "Proxy Section for Bot"
msgstr "Секция прокси для бота"
@@ -3635,9 +3722,15 @@ msgstr "PSK"
msgid "Public host"
msgstr "Публичный хост"
msgid "Public IP Address Isolation"
msgstr "Изоляция публичного IP-адреса"
msgid "QR code is too large"
msgstr "QR-код слишком большой"
msgid "Querying WAN direct socket and proxy outbound on 127.0.0.1:4534..."
msgstr "Опрос прямого WAN-сокета и исходящего прокси на 127.0.0.1:4534..."
msgid "QUIC (HTTP/3)"
msgstr "QUIC (HTTP/3)"
@@ -3653,6 +3746,9 @@ msgstr "Оценка"
msgid "RDP"
msgstr "RDP"
msgid "Re-run Leak Test"
msgstr "Повторить проверку утечек"
msgid "Read error"
msgstr "Ошибка чтения"
@@ -3791,6 +3887,9 @@ msgstr "Определить шлюз"
msgid "Resolve real IP for routing"
msgstr "Разрешать реальный IP для маршрутизации"
msgid "Resolver IP"
msgstr "IP резолвера"
msgid "Resource selection by hostname inside nfqws is not supported here; sing-box selects resources before NFQUEUE."
msgstr "Выбор ресурсов по имени хоста внутри nfqws не поддерживается здесь; sing-box выбирает ресурсы до NFQUEUE."
@@ -3884,6 +3983,12 @@ msgstr "Направлять весь не-локальный LAN-трафик
msgid "Route bot API requests through this section. Leave empty to use the default mixed proxy."
msgstr "Маршрутизировать запросы API бота через эту секцию. Оставьте пустым для использования смешанного прокси по умолчанию."
msgid "Route community list subnets (IP/CIDR)"
msgstr "Маршрутизировать подсети списков сообщества (IP/CIDR)"
msgid "Route IP subnets associated with built-in community lists (e.g. Discord voice/media). Disable this if community subnets overlap with CDN/Anycast networks (such as Cloudflare/Google Cloud) and interfere with unrelated services (e.g. game launchers)."
msgstr "Маршрутизировать IP-подсети из списков сообщества (например, голос/медиа Discord). Отключите, если подсети сообщества пересекаются с CDN/Anycast сетями (Cloudflare/Google Cloud) и мешают другим сервисам."
msgid "Route main DNS requests through the selected section."
msgstr "Маршрутизировать основные DNS-запросы через выбранную секцию."
@@ -3971,6 +4076,9 @@ msgstr "Выполняется диагностика…"
msgid "Russia (ru)"
msgstr "Россия (ru)"
msgid "SAFE"
msgstr "БЕЗОПАСНО"
msgid "SafeSearch"
msgstr "SafeSearch"
@@ -4040,6 +4148,9 @@ msgstr "Секция для регистрации Cloudflare WARP API. Если
msgid "Sections"
msgstr "Секции"
msgid "SECURE"
msgstr "ЗАЩИЩЕНО"
msgid "Security"
msgstr "Безопасность"
@@ -4625,6 +4736,9 @@ msgstr "Tachyon: Бенчмарк и автоподбор DNS"
msgid "Tachyon Error"
msgstr "Ошибка Tachyon"
msgid "Tachyon IP & DNS Leak Detection"
msgstr "Проверка утечек IP и DNS Tachyon"
msgid "Tachyon manages ciadpi pid files itself, so pidfile options are not allowed here."
msgstr "Tachyon управляет файлами pid ciadpi самостоятельно, поэтому параметры pidfile здесь не допускаются."
@@ -4769,6 +4883,9 @@ msgstr "Тестирование"
msgid "Testing bypass strategies in isolated sandbox..."
msgstr "Тестирование стратегий обхода в изолированной песочнице..."
msgid "Testing DNS leak upstream resolvers with bash.ws protocol..."
msgstr "Тестирование восходящих DNS-резолверов по протоколу bash.ws..."
msgid "Testing strategy"
msgstr "Тестирование стратегии"
@@ -5156,6 +5273,9 @@ msgstr "Отдано"
msgid "Upstream Mode"
msgstr "Режим DNS Upstream"
msgid "Upstream Provider / ASN"
msgstr "Восходящий провайдер / ASN"
msgid "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, hy2://, or tuic://"
msgstr "URL должен начинаться с vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, hy2:// или tuic://"
@@ -5273,6 +5393,9 @@ msgstr "Значение должно быть положительным цел
msgid "Value must not contain control characters"
msgstr "Значение не должно содержать символы управления"
msgid "Verdict"
msgstr "Статус"
msgid "Verifying router and service readiness..."
msgstr "Проверка готовности роутера и служб..."
@@ -5837,9 +5960,6 @@ msgstr "Маршрут"
msgid "Resolved IP / DNS"
msgstr "IP / DNS"
msgid "Verdict"
msgstr "Статус"
msgid "Check domain or IP (e.g. example.com)..."
msgstr "Проверить домен или IP (напр. example.com)..."
File diff suppressed because it is too large Load Diff
+6
View File
@@ -168,6 +168,9 @@ function show_help() {
print(" Validate an NFQWS2 strategy and print JSON result\n");
print(" validate_byedpi_strategy_json\n");
print(" Validate a ByeDPI ciadpi strategy and print JSON result\n");
print(" leak_check [--pretty] Test for IP and DNS leaks (direct vs proxy outbound)\n");
print(" check_ip_leak Check IP leak status (JSON)\n");
print(" check_dns_leak Check DNS leak status via bash.ws (JSON)\n");
}
function command_spec(command) {
@@ -275,6 +278,9 @@ function command_spec(command) {
validate_nfqws_strategy_json: [ "diagnostics/runtime.uc", "validate-nfqws-strategy-json", 1 ],
validate_nfqws2_strategy_json: [ "diagnostics/runtime.uc", "validate-nfqws2-strategy-json", 1 ],
validate_byedpi_strategy_json: [ "diagnostics/runtime.uc", "validate-byedpi-strategy-json", 1 ],
leak_check: [ "diagnostics/leak_check.uc", "leak-check", 2 ],
check_ip_leak: [ "diagnostics/leak_check.uc", "ip-leak", 0 ],
check_dns_leak: [ "diagnostics/leak_check.uc", "dns-leak", 0 ],
fuzzer_start: [ "diagnostics/fuzzer.uc", "start", 6 ],
fuzzer_status: [ "diagnostics/fuzzer.uc", "status", 0 ],
fuzzer_stop: [ "diagnostics/fuzzer.uc", "stop", 0 ],
+7 -3
View File
@@ -227,9 +227,13 @@ function get_hosts_urls() {
function hosts_list_update(target_url) {
let urls = target_url != null && target_url != "" ? [target_url] : get_hosts_urls();
if (length(urls) == 0) {
log("No hosts list URLs configured", "warn");
print('{"success":false,"error":"no_urls","entries":0}');
return false;
if (target_url != null && target_url != "") {
log("Specified hosts list URL is empty", "warn");
print('{"success":false,"error":"no_urls","entries":0}');
return false;
}
print('{"success":true,"entries":0,"sources":0}');
return true;
}
mkdir_p(HOSTS_TMP_DIR);
@@ -0,0 +1,433 @@
#!/usr/bin/env ucode
let fs = require("fs");
let common = require("core.common");
let constants = require("core.constants");
let uci_core = require("core.uci");
let as_string = common.as_string;
let command_capture = common.command_capture;
let command_status = common.command_status;
let shell_quote = common.shell_quote;
const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon";
let _has_so_mark_cached = null;
function curl_supports_so_mark() {
if (_has_so_mark_cached != null)
return _has_so_mark_cached;
_has_so_mark_cached = (command_status("curl --so-mark 0 -V >/dev/null 2>&1") == 0);
return _has_so_mark_cached;
}
/**
* Detect active WAN network interface/device name.
*/
function get_wan_interface() {
try {
let cursor = uci_core.cursor();
if (cursor) {
cursor.load("network");
let dev = cursor.get("network", "wan", "device") || cursor.get("network", "wan", "ifname");
if (dev != null && dev != "")
return trim(as_string(dev));
}
} catch (e) {
// Fallback to routing inspection below
}
let route_res = command_capture("ip -4 route show default 2>/dev/null");
if (route_res && route_res.status == 0 && route_res.output != "") {
let m = match(route_res.output, /dev\s+([a-zA-Z0-9_\.\-]+)/);
if (m && m[1])
return trim(as_string(m[1]));
}
return "";
}
/**
* Read upstream ISP DNS servers assigned by DHCP/PPPoE from standard OpenWrt resolv files.
*/
function get_direct_dns_servers() {
let servers = [];
let seen = {};
let candidate_paths = [
"/tmp/resolv.conf.d/resolv.conf.auto",
"/tmp/resolv.conf.auto",
"/tmp/resolv.conf"
];
for (let path in candidate_paths) {
let content = fs.readfile(path);
if (content == null)
continue;
let lines = split(as_string(content), "\n");
for (let line in lines) {
line = trim(line);
if (substr(line, 0, 11) == "nameserver ") {
let ns = trim(substr(line, 11));
if (ns != "" && !seen[ns] &&
ns != "127.0.0.1" && ns != "127.0.0.42" && ns != "127.0.0.43" &&
ns != "::1" && index(ns, "127.0.") < 0) {
seen[ns] = true;
push(servers, {
ip: ns,
country: "",
isp: "ISP / WAN Local",
is_isp: true
});
}
}
}
if (length(servers) > 0)
break;
}
return servers;
}
/**
* Generate curl CLI flags for direct WAN routing bypassing Tachyon tproxy.
*/
function get_direct_curl_flags(wan_iface) {
let parts = [];
if (wan_iface != null && wan_iface != "") {
push(parts, "--interface " + shell_quote(wan_iface));
}
if (curl_supports_so_mark()) {
let mark = constants.NFT_OUTBOUND_MARK || "0x08000000";
push(parts, "--so-mark " + shell_quote(mark));
}
return join(" ", parts);
}
/**
* Query public IP details with fallback services.
*/
function fetch_ip_info(use_proxy, wan_iface, mixed_port) {
let proxy_flag = use_proxy ? ("--proxy http://127.0.0.1:" + mixed_port + " ") : "";
let direct_flag = use_proxy ? "" : (get_direct_curl_flags(wan_iface) + " ");
let flags = "-s -m 7 --connect-timeout 4 " + proxy_flag + direct_flag;
// 1. Try ipleak.net JSON API
let res = command_capture("curl " + flags + "https://ipleak.net/json/ 2>/dev/null");
if (res && res.status == 0 && res.output != "") {
try {
let data = json(res.output);
if (type(data) == "object" && data.ip != null && data.ip != "") {
return {
ip: as_string(data.ip),
country: as_string(data.country_name || ""),
country_code: as_string(data.country_code || ""),
city: as_string(data.city_name || ""),
isp: as_string(data.isp_name || ""),
ok: true
};
}
} catch (e) {
// Fall through to fallback
}
}
// 2. Fallback to api.ipify.org
res = command_capture("curl -s -m 5 --connect-timeout 3 " + proxy_flag + direct_flag + "'https://api.ipify.org?format=json' 2>/dev/null");
if (res && res.status == 0 && res.output != "") {
try {
let data = json(res.output);
if (type(data) == "object" && data.ip != null && data.ip != "") {
return {
ip: as_string(data.ip),
country: "",
country_code: "",
city: "",
isp: "",
ok: true
};
}
} catch (e) {
// Fall through
}
}
// 3. Fallback to ifconfig.me
res = command_capture("curl -s -m 5 --connect-timeout 3 " + proxy_flag + direct_flag + "https://ifconfig.me/ip 2>/dev/null");
if (res && res.status == 0 && res.output != "") {
let ip = trim(as_string(res.output));
if (ip != "" && match(ip, /^[0-9a-fA-F\.\:]+$/)) {
return {
ip: ip,
country: "",
country_code: "",
city: "",
isp: "",
ok: true
};
}
}
return {
ip: "—",
country: "",
country_code: "",
city: "",
isp: "",
ok: false
};
}
/**
* Check for IP Leaks by comparing direct WAN IP and proxy outbound IP.
*/
function check_ip_leak(wan_iface, mixed_port) {
mixed_port = mixed_port || common.get_mixed_port();
wan_iface = wan_iface != null ? wan_iface : get_wan_interface();
let direct = fetch_ip_info(false, wan_iface, mixed_port);
let proxy = fetch_ip_info(true, wan_iface, mixed_port);
let proxy_online = (proxy.ok && proxy.ip != "—");
let leaked = true;
if (proxy_online && direct.ok && direct.ip != "—") {
leaked = (proxy.ip == direct.ip);
} else if (proxy_online) {
leaked = false;
} else {
leaked = false;
}
return {
leaked: leaked,
direct_ip: direct.ip,
direct_country: direct.country,
direct_country_code: direct.country_code,
direct_city: direct.city,
direct_isp: direct.isp,
proxy_ip: proxy.ip,
proxy_country: proxy.country,
proxy_country_code: proxy.country_code,
proxy_city: proxy.city,
proxy_org: proxy.isp,
proxy_online: proxy_online
};
}
/**
* Check for DNS Leaks using the bash.ws DNS Leak detection protocol.
*/
function check_dns_leak(wan_iface, mixed_port, direct_ip, proxy_ip) {
mixed_port = mixed_port || common.get_mixed_port();
wan_iface = wan_iface != null ? wan_iface : get_wan_interface();
let direct_dns_servers = get_direct_dns_servers();
let direct_flags = get_direct_curl_flags(wan_iface);
let proxy_flag = "--proxy http://127.0.0.1:" + mixed_port;
// 1. Get unique leak tokens from bash.ws/id
let res_direct_id = command_capture("curl -s -m 6 --connect-timeout 4 " + direct_flags + " https://bash.ws/id 2>/dev/null");
let direct_id = (res_direct_id && res_direct_id.status == 0) ? trim(as_string(res_direct_id.output)) : "";
let res_proxy_id = command_capture("curl -s -m 6 --connect-timeout 4 " + proxy_flag + " https://bash.ws/id 2>/dev/null");
let proxy_id = (res_proxy_id && res_proxy_id.status == 0) ? trim(as_string(res_proxy_id.output)) : "";
// 2. Fire parallel DNS queries for bash.ws subdomains
if (direct_id != "" || proxy_id != "") {
let parallel_cmd = "{ ";
if (direct_id != "") {
for (let i = 1; i <= 6; i++) {
parallel_cmd += sprintf("curl -s -m 3 %s http://%d.%s.bash.ws >/dev/null 2>&1 & ", direct_flags, i, direct_id);
}
}
if (proxy_id != "") {
for (let i = 1; i <= 6; i++) {
parallel_cmd += sprintf("curl -s -m 3 %s http://%d.%s.bash.ws >/dev/null 2>&1 & ", proxy_flag, i, proxy_id);
}
}
parallel_cmd += "wait; } 2>/dev/null";
system(parallel_cmd);
// Allow 1.5s propagation time for upstream resolvers
system("sleep 1.5 2>/dev/null || sleep 2 2>/dev/null");
}
// 3. Fetch test results from bash.ws
let direct_resolvers = [];
if (direct_id != "") {
let res = command_capture("curl -s -m 6 --connect-timeout 4 " + direct_flags + " 'https://bash.ws/dnsleak/test/" + direct_id + "?json' 2>/dev/null");
if (res && res.status == 0 && res.output != "") {
try {
let parsed = json(res.output);
if (type(parsed) == "array")
direct_resolvers = parsed;
} catch (e) {}
}
}
let proxy_resolvers = [];
if (proxy_id != "") {
let res = command_capture("curl -s -m 6 --connect-timeout 4 " + proxy_flag + " 'https://bash.ws/dnsleak/test/" + proxy_id + "?json' 2>/dev/null");
if (res && res.status == 0 && res.output != "") {
try {
let parsed = json(res.output);
if (type(parsed) == "array")
proxy_resolvers = parsed;
} catch (e) {}
}
}
// 4. Build lookup table of direct / ISP DNS resolvers
let direct_map = {};
for (let s in direct_dns_servers) {
if (s.ip) direct_map[s.ip] = true;
}
for (let r in direct_resolvers) {
if (r.ip) direct_map[r.ip] = true;
}
let formatted_proxy_servers = [];
let leak_found = false;
for (let pr in proxy_resolvers) {
if (type(pr) != "object" || !pr.ip) continue;
let is_direct_leak = (direct_map[pr.ip] == true);
if (is_direct_leak) {
leak_found = true;
}
push(formatted_proxy_servers, {
ip: pr.ip,
country: as_string(pr.country || pr.country_name || ""),
isp: as_string(pr.name || pr.asn || "Unknown"),
is_isp: is_direct_leak
});
}
let formatted_direct_servers = [];
for (let dr in direct_resolvers) {
if (type(dr) != "object" || !dr.ip) continue;
push(formatted_direct_servers, {
ip: dr.ip,
country: as_string(dr.country || dr.country_name || ""),
isp: as_string(dr.name || dr.asn || "ISP Upstream"),
is_isp: true
});
}
// Fallback: If bash.ws returned no direct resolvers, populate with OpenWrt system resolv servers
if (length(formatted_direct_servers) == 0 && length(direct_dns_servers) > 0) {
formatted_direct_servers = direct_dns_servers;
}
let proxy_online = (proxy_id != "" || (proxy_ip != null && proxy_ip != "" && proxy_ip != "—"));
let dns_leaked = false;
if (proxy_online && length(formatted_proxy_servers) > 0) {
dns_leaked = leak_found;
} else if (proxy_online && length(formatted_proxy_servers) == 0) {
dns_leaked = false;
}
// If proxy IP equals direct IP, then DNS is definitely not shielded
if (proxy_ip != null && direct_ip != null && proxy_ip != "—" && direct_ip != "—" && proxy_ip == direct_ip) {
dns_leaked = true;
}
return {
dns_leaked: dns_leaked,
direct_ip: direct_ip || "—",
proxy_ip: proxy_ip || "—",
dns_servers: formatted_proxy_servers,
direct_dns_servers: formatted_direct_servers,
proxy_online: proxy_online
};
}
/**
* Unified diagnostic check for both IP and DNS leaks.
*/
function run_leak_check(wan_iface, mixed_port) {
mixed_port = mixed_port || common.get_mixed_port();
wan_iface = wan_iface != null ? wan_iface : get_wan_interface();
let ip_res = check_ip_leak(wan_iface, mixed_port);
let dns_res = check_dns_leak(wan_iface, mixed_port, ip_res.direct_ip, ip_res.proxy_ip);
return {
ip_leak: ip_res,
dns_leak: dns_res,
timestamp: time()
};
}
function print_cli_summary(res) {
print("\n=== TACHYON IP & DNS LEAK TEST REPORT ===\n\n");
print(sprintf("Direct WAN IP : %s (%s, %s)\n", res.ip_leak.direct_ip, res.ip_leak.direct_country || "Unknown", res.ip_leak.direct_isp || "ISP"));
print(sprintf("Proxy Outbound: %s (%s, %s)\n", res.ip_leak.proxy_ip, res.ip_leak.proxy_country || "Unknown", res.ip_leak.proxy_org || "Proxy"));
if (res.ip_leak.proxy_online) {
if (res.ip_leak.leaked) {
print("IP Status : ❌ LEAK DETECTED (Direct and Proxy IP match!)\n");
} else {
print("IP Status : ✅ SECURE (Real WAN IP is hidden behind proxy)\n");
}
} else {
print("IP Status : ⚪ Proxy is offline or unreachable\n");
}
print("\n--- DNS Resolvers Detected ---\n");
if (length(res.dns_leak.dns_servers) > 0) {
for (let s in res.dns_leak.dns_servers) {
let flag = s.is_isp ? "❌ ISP LEAK" : "✅ SECURE";
print(sprintf(" [%s] %s (%s - %s)\n", flag, s.ip, s.country, s.isp));
}
} else {
print(" (No proxy DNS queries recorded or proxy offline)\n");
}
if (res.dns_leak.dns_leaked) {
print("\nDNS Status : ❌ DNS LEAK DETECTED (Queries reach your local ISP!)\n\n");
} else {
print("\nDNS Status : ✅ SECURE (All DNS routed through encrypted/proxy resolvers)\n\n");
}
}
// --- CLI Dispatcher ---
let mode = ARGV[0] || "";
if (mode == "leak-check" || mode == "leak_check") {
let res = run_leak_check(null, null);
if (ARGV[1] == "--pretty" || ARGV[1] == "-p") {
print_cli_summary(res);
} else {
print(sprintf("%J\n", res));
}
exit(0);
}
else if (mode == "ip-leak" || mode == "check_ip_leak") {
let res = check_ip_leak(null, null);
print(sprintf("%J\n", res));
exit(0);
}
else if (mode == "dns-leak" || mode == "check_dns_leak") {
let res = check_dns_leak(null, null, null, null);
print(sprintf("%J\n", res));
exit(0);
}
return {
get_wan_interface,
get_direct_dns_servers,
get_direct_curl_flags,
fetch_ip_info,
check_ip_leak,
check_dns_leak,
run_leak_check
};
+21
View File
@@ -257,6 +257,24 @@ function run_speedtest() {
};
}
function run_leak_check() {
let leak_mod = require("diagnostics.leak_check");
if (!leak_mod) return null;
return leak_mod.run_leak_check(null, null);
}
function run_ip_leak_check() {
let leak_mod = require("diagnostics.leak_check");
if (!leak_mod) return null;
return leak_mod.check_ip_leak(null, null);
}
function run_dns_leak_check() {
let leak_mod = require("diagnostics.leak_check");
if (!leak_mod) return null;
return leak_mod.check_dns_leak(null, null, null, null);
}
function manage_domain_list(action_type, domain, do_delete) {
let c = uci_core.cursor();
if (!c) return { success: false, error: "Не удалось инициализировать UCI" };
@@ -372,6 +390,9 @@ return {
get_clash_connections,
check_connection,
run_speedtest,
run_leak_check,
run_ip_leak_check,
run_dns_leak_check,
manage_domain_list,
manage_domain_list_by_section,
get_sections,
+3 -2
View File
@@ -847,7 +847,8 @@ function start_main() {
if (status != 0)
return status;
module_background(HOSTS_UC, [ "list-update" ]);
if (module_success(STATE_UC, [ "has-hosts-list-update-sources" ]))
module_background(HOSTS_UC, [ "list-update" ]);
status = module_status(SINGBOX_UC, [ "configure-service" ]);
if (status != 0)
@@ -1402,7 +1403,7 @@ function reload(reason) {
if (plan.needs_byedpi_restart == 1)
module_success(BYEDPI_UC, [ "stop-runtime" ]);
if (plan.needs_hosts_update == 1)
if (plan.needs_hosts_update == 1 && module_success(STATE_UC, [ "has-hosts-list-update-sources" ]))
module_success(HOSTS_UC, [ "list-update" ]);
if (plan.needs_nft_rebuild == 1) {
+25
View File
@@ -1787,6 +1787,27 @@ function has_subscription_update_sources_from_sections(sections) {
return false;
}
function has_hosts_list_update_sources_from_sections(sections) {
for (let section in sections) {
section = object_or_empty(section);
if (option(section, "action", "") != "hosts")
continue;
if (!bool_option(section, "enabled", true))
continue;
let urls = option(section, "hosts_list_urls", "");
if (type(urls) == "array") {
for (let u in urls) {
if (trim(as_string(u)) != "")
return true;
}
} else if (trim(as_string(urls)) != "") {
return true;
}
}
return false;
}
function fixture_sections(path) {
let data = object_or_empty(read_json_file(path));
connections.set_item_sections_from_data(data);
@@ -2143,6 +2164,10 @@ else if (mode == "has-nft-list-update-sources")
exit(has_nft_list_update_sources_from_sections(uci_sections("section")) ? 0 : 1);
else if (mode == "has-nft-list-update-sources-fixture")
exit(has_nft_list_update_sources_from_sections(fixture_sections(ARGV[1])) ? 0 : 1);
else if (mode == "has-hosts-list-update-sources")
exit(has_hosts_list_update_sources_from_sections(uci_sections("section")) ? 0 : 1);
else if (mode == "has-hosts-list-update-sources-fixture")
exit(has_hosts_list_update_sources_from_sections(fixture_sections(ARGV[1])) ? 0 : 1);
else {
warn("Usage: service/state.uc <operation> ...\n");
exit(1);
+5
View File
@@ -74,6 +74,11 @@ echo "=== Test: hosts_list_status with no URLs ==="
output=$(ucode "$HOSTS_UC" list-status 2>/dev/null || true)
assert_eq "false" "$(echo "$output" | grep -o '"cache_exists":[a-z]*' | cut -d: -f2)" "cache_exists should be false initially"
# Run hosts_list_update with no URLs (should succeed cleanly with success: true and exit 0)
echo "=== Test: hosts_list_update with no URLs ==="
update_output=$(ucode "$HOSTS_UC" list-update 2>/dev/null || true)
assert_eq "true" "$(echo "$update_output" | grep -o '"success":[a-z]*' | cut -d: -f2)" "list-update should succeed when no URLs configured"
# --- Test 5: Safe name length for long percent-encoded URLs ---
echo "=== Test: safe_name length for long URLs ==="
long_url_safe_len=$(ucode -e '
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib"
LEAK_CHECK="$TACHYON_LIB/diagnostics/leak_check.uc"
BIN="$ROOT_DIR/tachyon/files/usr/bin/tachyon"
API_UC="$TACHYON_LIB/service/api.uc"
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
# ─── 1. Syntax check & module load ──────────────────────────────────────────
ucode -L "$TACHYON_LIB" -c "$LEAK_CHECK" || fail "leak_check.uc must pass ucode syntax check"
ucode -S -L "$TACHYON_LIB" -c "$LEAK_CHECK" || fail "leak_check.uc must pass strict ucode syntax check"
# ─── 2. Test logic with synthetic inputs ────────────────────────────────────
cat >"$WORK_DIR/test_leak_logic.uc" <<'UCODE'
let leak_mod = require("diagnostics.leak_check");
if (type(leak_mod) != "object") {
print("ERR: leak_check module must export object\n");
exit(1);
}
// Test exports
if (type(leak_mod.check_ip_leak) != "function" ||
type(leak_mod.check_dns_leak) != "function" ||
type(leak_mod.run_leak_check) != "function") {
print("ERR: leak_check module missing required functions\n");
exit(2);
}
// Test direct curl flags generation
let flags = leak_mod.get_direct_curl_flags("eth0");
if (index(flags, "--interface 'eth0'") < 0 && index(flags, "--interface eth0") < 0) {
print("ERR: direct curl flags must specify wan interface\n");
exit(3);
}
print("OK\n");
exit(0);
UCODE
ucode -L "$TACHYON_LIB" "$WORK_DIR/test_leak_logic.uc" | grep -q "OK" ||
fail "leak_check.uc unit logic validation failed"
# ─── 3. Test CLI registration in /usr/bin/tachyon ───────────────────────────
grep -Fq "leak_check" "$BIN" || fail "/usr/bin/tachyon must register leak_check"
grep -Fq "check_ip_leak" "$BIN" || fail "/usr/bin/tachyon must register check_ip_leak"
grep -Fq "check_dns_leak" "$BIN" || fail "/usr/bin/tachyon must register check_dns_leak"
# ─── 4. Test api.uc integration ─────────────────────────────────────────────
grep -Fq "run_leak_check" "$API_UC" || fail "api.uc must export run_leak_check"
grep -Fq "run_ip_leak_check" "$API_UC" || fail "api.uc must export run_ip_leak_check"
grep -Fq "run_dns_leak_check" "$API_UC" || fail "api.uc must export run_dns_leak_check"
printf 'PASS: leak_check\n'
+39
View File
@@ -1598,4 +1598,43 @@ if state_ucode has-remote-sing-box-ruleset-sources-fixture "$WORK_DIR/disabled-o
fail "disabled rule should not require remote sing-box ruleset refresh"
fi
cat >"$WORK_DIR/hosts-source.json" <<'JSON'
{
"section": [
{
".name": "my_hosts",
".type": "section",
"action": "hosts",
"enabled": "1",
"hosts_list_urls": [ "https://example.com/hosts.txt" ]
}
]
}
JSON
state_ucode has-hosts-list-update-sources-fixture "$WORK_DIR/hosts-source.json" >/dev/null ||
fail "enabled hosts section with URL should require hosts list update"
cat >"$WORK_DIR/hosts-no-url.json" <<'JSON'
{
"section": [
{
".name": "my_hosts",
".type": "section",
"action": "hosts",
"enabled": "1",
"dns_hosts": "127.0.0.1 example.com"
}
]
}
JSON
if state_ucode has-hosts-list-update-sources-fixture "$WORK_DIR/hosts-no-url.json" >/dev/null 2>&1; then
fail "hosts section without remote URLs should not require hosts list update"
fi
if state_ucode has-hosts-list-update-sources-fixture "$WORK_DIR/general-only-source.json" >/dev/null 2>&1; then
fail "proxy sections without hosts should not require hosts list update"
fi
printf 'runtime state predicate checks passed\n'