fix: sanitize Discord community subnets and resolve WAN monitor hotplug spam (fixes #50, #51)

- Fix #50: Discord community subnets hijacking Cloudflare Anycast traffic:
  * Filter broad Cloudflare shared subnets (104.16.0.0/12, 172.64.0.0/13, etc.) for Discord preset in generator_routes.uc, nft/apply.uc, and updates.uc.
  * Add tachyon resolve_domain CLI and runtime.uc helper for DNS queries with rpcd ACL support.
  * Update LuCI Routing Rule Tracer to resolve queried domains against section subnets/CIDRs (including community subnets) and show shared CDN/Anycast warnings.
  * Add unit tests in tests/community_subnets_sanitization.sh.

- Fix #51: Telegram bot spamming WAN up/down notifications on hotplug:
  * Check enable_badwan_interface_monitoring, telegram.enabled, and badwan_monitored_interfaces list in 99-tachyon-wan-monitor.
  * Add background debounce with atomic directory locking and ubus interface status verification.
  * Implement state transition tracking to only notify on actual UP->DOWN and DOWN->UP changes without duplicates.
  * Preserve retry_start_on_wan_up behavior on ifup.
  * Add comprehensive integration test suite in tests/wan_monitor_hotplug.sh.
This commit is contained in:
Dushnilin
2026-09-11 23:07:10 +03:00
parent 5933fd2f0c
commit 0ea1c73832
16 changed files with 4456 additions and 3398 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+89 -74
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: TACHYON\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-11 18:51+0300\n"
"PO-Revision-Date: 2026-09-11 18:51+0300\n"
"POT-Creation-Date: 2026-09-11 22:54+0300\n"
"PO-Revision-Date: 2026-09-11 22:54+0300\n"
"Last-Translator: Dushnilin\n"
"Language-Team: none\n"
"Language: ru\n"
@@ -26,6 +26,9 @@ msgstr "Списки .lst"
msgid "(provider default)"
msgstr "(по умолчанию провайдера)"
msgid "+ add"
msgstr "+ добавить"
msgid "+ Add JSON outbound"
msgstr "+ Добавить JSON outbound"
@@ -62,6 +65,9 @@ msgstr "⚠️ Если все Bootstrap DNS завершатся с ошибк
msgid "⚠️ If all Main 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 "⚠️ Если все основные DNS завершатся с ошибкой 3 раза, запросы будут отправлены в DNS вашего провайдера в открытом виде. Используйте только в крайнем случае для предотвращения полной потери интернета."
msgid "⚠️ Server received fakes"
msgstr "⚠️ Сервер получил фейки"
msgid "⚡ Fast (>1MB/s)"
msgstr "⚡ Быстрые (>1 МБ/с)"
@@ -86,12 +92,18 @@ msgstr "✅ Только рабочие"
msgid "✈️ Telegram Suite (Web + Bot API)"
msgstr "✈️ Telegram (Веб + Bot API)"
msgid "✓ 32KB Streamed"
msgstr "✓ 32KB Прокачано"
msgid "✔ Online"
msgstr "✔ В сети"
msgid "✔ Running"
msgstr "✔ Работает"
msgid "✗ Throttled at 16KB"
msgstr "✗ Задушено на 16KB"
msgid "✘ Offline"
msgstr "✘ Не в сети"
@@ -170,12 +182,6 @@ 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)"
msgid "Обнаружена блокировка по IP! Ресурс блокируется на сетевом уровне (нет ответа на TCP SYN). Методы обхода DPI (Zapret / ByeDPI) бессильны для этого адреса — настройте маршрутизацию через прокси/VPN (Sing-box) для данного домена!"
msgstr "Обнаружена блокировка по IP! Ресурс блокируется на сетевом уровне (нет ответа на TCP SYN). Методы обхода DPI (Zapret / ByeDPI) бессильны для этого адреса — настройте маршрутизацию через прокси/VPN (Sing-box) для данного домена!"
msgid "Accept external proxy connections and route them with sing-box."
msgstr "Принимать внешние прокси-подключения и маршрутизировать их через sing-box."
@@ -518,6 +524,9 @@ msgstr "Версия AmneziaWG"
msgid "An unexpected error occurred during test"
msgstr "Произошла непредвиденная ошибка во время проверки"
msgid "Analysis inconclusive."
msgstr "Анализ не дал однозначного результата."
msgid "Anonymized support report copied to clipboard"
msgstr "Анонимизированный отчет скопирован в буфер обмена"
@@ -755,6 +764,9 @@ msgstr "Заблокированные сайты"
msgid "Blocked Sites (Domains)"
msgstr "Заблокированные сайты (домены)"
msgid "Blocked Sites (Only block domains specified below)"
msgstr "Заблокированные сайты (Блокировать только указанные домены)"
msgid "Blocks access to private/LAN IP addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, etc.) for selected users before applying routing mode."
msgstr "Блокирует доступ к приватным/локальным IP-адресам (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 и др.) для указанных пользователей перед применением режима маршрутизации."
@@ -1382,6 +1394,9 @@ msgstr "DNS"
msgid "DNS Auto-Tuner & Benchmark"
msgstr "Автоподбор и бенчмарк DNS"
msgid "DNS Blocking"
msgstr "Блокировка DNS"
msgid "DNS Check Interval"
msgstr "Интервал проверки DNS"
@@ -1418,6 +1433,9 @@ msgstr "Протокол DNS, используемый резолвером"
msgid "DNS requests through section"
msgstr "DNS-запросы через секцию"
msgid "DNS resolution failed — likely DNS-level blocking or hijacking."
msgstr "Сбой разрешения DNS — вероятно, блокировка или перехват на уровне DNS."
msgid "DNS Rewrite TTL"
msgstr "TTL DNS-записей"
@@ -1466,6 +1484,12 @@ msgstr "Таймаут недоступности DNS"
msgid "DNS Upstream Resolver Analysis (bash.ws protocol)"
msgstr "Анализ восходящих DNS-резолверов (протокол bash.ws)"
msgid "DNS: Resolved %s to %s"
msgstr "DNS: Разрешено %s в %s"
msgid "DNS: Resolved to %s (no subnet rules matched)"
msgstr "DNS: Разрешено в %s (нет совпадений по правилам подсетей)"
msgid "Do not panic, everything can be fixed, just..."
msgstr "Не паникуйте, всё можно исправить, просто..."
@@ -1568,6 +1592,9 @@ msgstr "Загружено"
msgid "Downloading and applying rule sets and lists..."
msgstr "Загрузка и применение наборов правил и списков..."
msgid "DPI bandwidth throttling or delay detected — connection throttled during data transfer."
msgstr "Обнаружено замедление DPI — соединение заблокировано или задушено при передаче данных."
msgid "DPI Detected"
msgstr "DPI обнаружен"
@@ -1730,6 +1757,9 @@ msgstr "Конец интервала расписания (например, 08
msgid "End Time (HH:MM)"
msgstr "Время окончания (ЧЧ:ММ)"
msgid "endpoints OK"
msgstr "эндпоинтов доступно"
msgid "Enforce SafeSearch & YouTube Restricted Mode"
msgstr "Безопасный поиск (SafeSearch) и строгий режим YouTube"
@@ -2747,9 +2777,15 @@ msgstr "IP-адреса (IP CIDR)"
msgid "IP and DNS leak check timed out"
msgstr "Время ожидания проверки утечек IP и DNS истекло"
msgid "IP Block (TCP SYN Timeout)"
msgstr "Блокировка по IP (Таймаут TCP SYN)"
msgid "IP Frag Bounds (--ip-frag)"
msgstr "Границы фрагментации IP (--ip-frag)"
msgid "IP-level blocking detected! The resource is blocked at the network level (no TCP SYN response). DPI bypass methods (Zapret / ByeDPI) cannot bypass this address — configure proxy/VPN routing (Sing-box) for this domain!"
msgstr "Обнаружена блокировка по IP! Ресурс блокируется на сетевом уровне (нет ответа на TCP SYN). Методы обхода DPI (Zapret / ByeDPI) бессильны для этого адреса — настройте маршрутизацию через прокси/VPN (Sing-box) для данного домена!"
msgid "IPs"
msgstr "IP"
@@ -3218,6 +3254,9 @@ msgstr "История бенчмарков пуста. Запустите бе
msgid "No benchmark results yet. Configure options and click \"Start Benchmark\"."
msgstr "Результаты тестирования пока отсутствуют. Настройте параметры и нажмите \"Запустить тест\"."
msgid "No Blocking Detected"
msgstr "Блокировка не обнаружена"
msgid "No check targets found in the configuration."
msgstr "Цели для проверки не найдены в конфигурации."
@@ -4505,6 +4544,9 @@ msgstr "Настройки"
msgid "Settings have been reset to defaults. The page will reload."
msgstr "Настройки сброшены к значениям по умолчанию. Страница будет перезагружена."
msgid "Shared CDN / Anycast Warning"
msgstr "Предупреждение: общий диапазон CDN / Anycast"
msgid "Shopping"
msgstr "Магазины"
@@ -4772,6 +4814,12 @@ msgstr "Строгий ECH + DoQ DNS Enforcer"
msgid "STUN (P2P Calls)"
msgstr "STUN (P2P-звонки)"
msgid "Subnet %s belongs to a shared Anycast/CDN range (%s). Third-party websites hosted on this CDN will also be routed through this section."
msgstr "Подсеть %s входит в публичный Anycast/CDN-диапазон (%s). Сторонние сайты, размещенные на этом CDN, также будут направляться через эту секцию."
msgid "Subnet %s is a shared Anycast/CDN range (%s). Third-party websites hosted on this CDN will also be routed through this section."
msgstr "Подсеть %s является публичным Anycast/CDN-диапазоном (%s). Сторонние сайты, размещенные на этом CDN, также будут направляться через эту секцию."
msgid "Subnets from the list will be extracted and added to nftables"
msgstr "Подсети из списка будут извлечены и добавлены в nftables"
@@ -4940,6 +4988,9 @@ msgstr "Пакет Tailscale установлен, но ни одна серве
msgid "Target"
msgstr "Цель"
msgid "Target accessible — no DPI blocking detected."
msgstr "Цель доступна — блокировок DPI не обнаружено."
msgid "Target Family Profiles"
msgstr "Семейные профили"
@@ -4949,6 +5000,15 @@ msgstr "Область действия"
msgid "Target Service / Suite"
msgstr "Целевой сервис / Набор"
msgid "TCP connect timed out before TLS handshake — host is blocked at the IP layer. DPI bypass cannot unblock this; route via Sing-box VPN/Proxy outbound instead."
msgstr "Таймаут TCP-подключения до рукопожатия TLS — узел заблокирован на уровне IP. Методы обхода DPI не помогут; настройте маршрутизацию через прокси/VPN Sing-box."
msgid "TCP Reset Injection"
msgstr "Внедрение пакетов TCP Reset (RST)"
msgid "TCP RST received from DPI — active TCP reset injection detected."
msgstr "Получен TCP RST от ТСПУ/DPI — зафиксирована активная инъекция сброса соединения."
msgid "TCP Window Sizes (wsize)"
msgstr "Размеры окна TCP (wsize)"
@@ -5021,6 +5081,9 @@ msgstr "Тестирование стратегии"
msgid "Testing URL"
msgstr "URL проверки"
msgid "Testing:"
msgstr "Тестирование:"
msgid "Testing..."
msgstr "Тестирование..."
@@ -5087,6 +5150,9 @@ msgstr "Все настройки Tachyon будут удалены и восс
msgid "This will replace the current Tachyon settings with the snapshot"
msgstr "Это заменит текущие настройки Tachyon на снапшот"
msgid "Throttling / Deep Inspection"
msgstr "Замедление / Глубокий анализ пакетов"
msgid "Thu"
msgstr "Чт"
@@ -5318,6 +5384,9 @@ msgstr "Неизвестный параметр ByeDPI: %s"
msgid "Unknown country"
msgstr "Неизвестная страна"
msgid "Unknown DPI Pattern"
msgstr "Неизвестный тип DPI"
msgid "Unknown error"
msgstr "Неизвестная ошибка"
@@ -5708,9 +5777,15 @@ msgstr "sing-box outbound для Zapret настроен"
msgid "Zapret sing-box outbound is not configured"
msgstr "sing-box outbound для Zapret не настроен"
msgid "Zapret v1"
msgstr "Zapret v1"
msgid "Zapret v1 (nfqws)"
msgstr "Zapret v1 (nfqws)"
msgid "Zapret v2"
msgstr "Zapret v2"
msgid "Zapret v2 (nfqws2)"
msgstr "Zapret v2 (nfqws2)"
@@ -5744,6 +5819,12 @@ msgstr "Правила маршрутизации sing-box для Zapret2 нас
msgid "Zapret2 sing-box route rules are not configured"
msgstr "Правила маршрутизации sing-box для Zapret2 не настроены"
msgid "Блокировка по IP (Таймаут TCP SYN)"
msgstr "Блокировка по IP (Таймаут TCP SYN)"
msgid "Обнаружена блокировка по IP! Ресурс блокируется на сетевом уровне (нет ответа на TCP SYN). Методы обхода DPI (Zapret / ByeDPI) бессильны для этого адреса — настройте маршрутизацию через прокси/VPN (Sing-box) для данного домена!"
msgstr "Обнаружена блокировка по IP! Ресурс блокируется на сетевом уровне (нет ответа на TCP SYN). Методы обхода DPI (Zapret / ByeDPI) бессильны для этого адреса — настройте маршрутизацию через прокси/VPN (Sing-box) для данного домена!"
msgid "Block (RKN)"
msgstr "RKN Block"
@@ -6506,72 +6587,6 @@ msgstr "Скачивание и обработка списков"
msgid "Applying rules and subnet lists"
msgstr "Применение правил и списков подсетей"
msgid "+ add"
msgstr "+ добавить"
msgid "⚠️ Server received fakes"
msgstr "⚠️ Сервер получил фейки"
msgid "✓ 32KB Streamed"
msgstr "✓ 32KB Прокачано"
msgid "✗ Throttled at 16KB"
msgstr "✗ Задушено на 16KB"
msgid "Analysis inconclusive."
msgstr "Анализ не дал однозначного результата."
msgid "Blocked Sites (Only block domains specified below)"
msgstr "Заблокированные сайты (Блокировать только указанные домены)"
msgid "DNS Blocking"
msgstr "Блокировка DNS"
msgid "DNS resolution failed — likely DNS-level blocking or hijacking."
msgstr "Сбой разрешения DNS — вероятно, блокировка или перехват на уровне DNS."
msgid "DPI bandwidth throttling or delay detected — connection throttled during data transfer."
msgstr "Обнаружено замедление DPI — соединение заблокировано или задушено при передаче данных."
msgid "endpoints OK"
msgstr "эндпоинтов доступно"
msgid "IP Block (TCP SYN Timeout)"
msgstr "Блокировка по IP (Таймаут TCP SYN)"
msgid "IP-level blocking detected! The resource is blocked at the network level (no TCP SYN response). DPI bypass methods (Zapret / ByeDPI) cannot bypass this address — configure proxy/VPN routing (Sing-box) for this domain!"
msgstr "Обнаружена блокировка по IP! Ресурс блокируется на сетевом уровне (нет ответа на TCP SYN). Методы обхода DPI (Zapret / ByeDPI) бессильны для этого адреса — настройте маршрутизацию через прокси/VPN (Sing-box) для данного домена!"
msgid "No Blocking Detected"
msgstr "Блокировка не обнаружена"
msgid "Target accessible — no DPI blocking detected."
msgstr "Цель доступна — блокировок DPI не обнаружено."
msgid "TCP connect timed out before TLS handshake — host is blocked at the IP layer. DPI bypass cannot unblock this; route via Sing-box VPN/Proxy outbound instead."
msgstr "Таймаут TCP-подключения до рукопожатия TLS — узел заблокирован на уровне IP. Методы обхода DPI не помогут; настройте маршрутизацию через прокси/VPN Sing-box."
msgid "TCP Reset Injection"
msgstr "Внедрение пакетов TCP Reset (RST)"
msgid "TCP RST received from DPI — active TCP reset injection detected."
msgstr "Получен TCP RST от ТСПУ/DPI — зафиксирована активная инъекция сброса соединения."
msgid "Testing:"
msgstr "Тестирование:"
msgid "Throttling / Deep Inspection"
msgstr "Замедление / Глубокий анализ пакетов"
msgid "Unknown DPI Pattern"
msgstr "Неизвестный тип DPI"
msgid "Zapret v1"
msgstr "Zapret v1"
msgid "Zapret v2"
msgstr "Zapret v2"
msgid "Allow only: "
msgstr "Разрешить только: "
@@ -11238,26 +11238,171 @@ const ACTION_COLORS = {
hosts: "#2ecc71",
};
const CLOUDFLARE_SHARED_CIDRS = [
"104.16.0.0/12",
"104.24.0.0/14",
"172.64.0.0/13",
"162.158.0.0/15",
"108.162.192.0/18",
"190.93.240.0/20",
"188.114.96.0/20",
"197.234.240.0/22",
"198.41.128.0/17",
"162.159.0.0/16",
"173.245.48.0/20",
"103.21.244.0/22",
"103.22.200.0/22",
"103.31.4.0/22",
"141.101.64.0/18",
"2606:4700::/32",
"2400:cb00::/32",
"2405:b500::/32",
"2803:f800::/32",
"2a06:98c0::/29",
"2c0f:f248::/32",
];
function ip4ToInt(ip) {
return (
ip
.split(".")
.reduce((int, octet) => (int << 8) + parseInt(octet, 10), 0) >>> 0
);
const octets = ip.split(".");
if (octets.length !== 4) return null;
let res = 0;
for (let i = 0; i < 4; i++) {
const oct = parseInt(octets[i], 10);
if (isNaN(oct) || oct < 0 || oct > 255) return null;
res = ((res << 8) + oct) >>> 0;
}
return res;
}
function ipv6ToBigInt(ip) {
try {
ip = ip.toLowerCase();
if (ip.includes(".")) {
const lastColon = ip.lastIndexOf(":");
if (lastColon === -1) return null;
const v4Part = ip.substring(lastColon + 1);
const v4Octets = v4Part.split(".");
if (v4Octets.length !== 4) return null;
const hexPart1 = (
(parseInt(v4Octets[0], 10) << 8) |
parseInt(v4Octets[1], 10)
).toString(16);
const hexPart2 = (
(parseInt(v4Octets[2], 10) << 8) |
parseInt(v4Octets[3], 10)
).toString(16);
ip = ip.substring(0, lastColon + 1) + hexPart1 + ":" + hexPart2;
}
let parts = ip.split("::");
if (parts.length > 2) return null;
let head = parts[0] ? parts[0].split(":").filter(Boolean) : [];
let tail =
parts.length === 2 && parts[1] ? parts[1].split(":").filter(Boolean) : [];
if (parts.length === 1 && head.length !== 8) return null;
if (parts.length === 2 && head.length + tail.length > 7) return null;
let middleCount = 8 - (head.length + tail.length);
let full = [...head];
for (let i = 0; i < middleCount; i++) full.push("0");
full = full.concat(tail);
let res = 0n;
for (let i = 0; i < 8; i++) {
let val = BigInt(parseInt(full[i], 16));
if (val < 0n || val > 0xffffn) return null;
res = (res << 16n) | val;
}
return res;
} catch (e) {
return null;
}
}
function ipMatchesCidr(ip, cidr) {
const parts = cidr.split("/");
const subnet = parts[0];
const bits = parts[1] ? parseInt(parts[1], 10) : 32;
if (!ip || !cidr) return false;
const parts = cidr.trim().split("/");
const subnet = parts[0].trim();
const bitsStr = parts[1];
if (bits === 0) return true;
const isV6 = ip.includes(":") || subnet.includes(":");
if (isV6) {
const bits = bitsStr !== undefined ? parseInt(bitsStr, 10) : 128;
if (isNaN(bits) || bits < 0 || bits > 128) return false;
if (bits === 0) return true;
const ipBig = ipv6ToBigInt(ip);
const subnetBig = ipv6ToBigInt(subnet);
if (ipBig === null || subnetBig === null) return false;
const shift = 128n - BigInt(bits);
return (ipBig >> shift) === (subnetBig >> shift);
} else {
const bits = bitsStr !== undefined ? parseInt(bitsStr, 10) : 32;
if (isNaN(bits) || bits < 0 || bits > 32) return false;
if (bits === 0) return true;
const ipInt = ip4ToInt(ip);
const subnetInt = ip4ToInt(subnet);
if (ipInt === null || subnetInt === null) return false;
const mask = (0xffffffff << (32 - bits)) >>> 0;
return (ipInt & mask) === (subnetInt & mask);
}
}
const ipInt = ip4ToInt(ip);
const subnetInt = ip4ToInt(subnet);
function getSharedCdnWarning(cidr) {
if (!cidr) return null;
const normalized = cidr.trim();
for (const cfCidr of CLOUDFLARE_SHARED_CIDRS) {
if (normalized === cfCidr) {
return _(
"Subnet %s is a shared Anycast/CDN range (%s). Third-party websites hosted on this CDN will also be routed through this section.",
).format(normalized, "Cloudflare");
}
}
const ipOnly = normalized.split("/")[0];
for (const cfCidr of CLOUDFLARE_SHARED_CIDRS) {
if (ipMatchesCidr(ipOnly, cfCidr)) {
return _(
"Subnet %s belongs to a shared Anycast/CDN range (%s). Third-party websites hosted on this CDN will also be routed through this section.",
).format(normalized, `Cloudflare ${cfCidr}`);
}
}
return null;
}
const mask = (~0 << (32 - bits)) >>> 0;
return (ipInt & mask) === (subnetInt & mask);
let _subnetListCache = {};
async function readSubnetListFile(service) {
if (!service) return [];
if (_subnetListCache[service]) return _subnetListCache[service];
const paths = [
`/tmp/sing-box/rulesets/community-subnets-${service}.lst`,
`/etc/tachyon/rulesets/community-subnets-${service}.lst`,
];
for (const p of paths) {
try {
let content = await fs.read(p).catch(() => null);
if (!content) {
const res = await fs.exec("/bin/cat", [p]).catch(() => null);
if (res && res.code === 0 && res.stdout) {
content = res.stdout;
}
}
if (content) {
const lines = content
.split("\n")
.map((l) => l.trim().replace(/\r/g, ""))
.filter((l) => l && !l.startsWith("#"));
if (lines.length > 0) {
_subnetListCache[service] = lines;
return lines;
}
}
} catch (e) {}
}
_subnetListCache[service] = [];
return [];
}
function matchIpInCidrs(ip, cidrs) {
@@ -11470,6 +11615,48 @@ async function matchCommunityList(secName, community, query, type) {
return false;
}
async function resolveDomainToIps(domain) {
domain = (domain || "").trim().toLowerCase();
if (!domain) return [];
// 1. Try Tachyon CLI: /usr/bin/tachyon resolve_domain <domain>
try {
const res = await fs
.exec("/usr/bin/tachyon", ["resolve_domain", domain])
.catch(() => null);
if (res && res.code === 0 && res.stdout) {
const parsed = JSON.parse(res.stdout.trim());
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed;
}
}
} catch (e) {}
// 2. Fallback: nslookup via /usr/bin/nslookup
try {
const res = await fs
.exec("/usr/bin/nslookup", [domain])
.catch(() => null);
if (res && res.stdout) {
const ips = [];
const lines = res.stdout.split("\n");
for (const line of lines) {
const trimmed = line.trim();
const m = trimmed.match(/^Address\s*\d*:\s*([0-9a-fA-F:.]+)/);
if (m) {
const addr = m[1].split("#")[0].trim();
if (!addr.startsWith("127.") && addr !== "::1" && addr !== "") {
if (!ips.includes(addr)) ips.push(addr);
}
}
}
if (ips.length > 0) return ips;
}
} catch (e) {}
return [];
}
async function performTrace(query) {
query = (query || "").trim();
if (!query) return { matched: false };
@@ -11645,6 +11832,22 @@ async function performTrace(query) {
uci.get(UCI_PACKAGE, secName, "community_lists"),
);
for (const community of communityLists) {
const subnets = await readSubnetListFile(community);
const matchedCidr = matchIpInCidrs(queryForMatching, subnets);
if (matchedCidr) {
return {
matched: true,
sectionName: secName,
label: label,
action: action,
ruleType: "Community Subnets (" + community + ")",
pattern: matchedCidr,
matchedIp: queryForMatching,
priority: i + 1,
totalSections: totalSections,
sharedCdnWarning: getSharedCdnWarning(matchedCidr),
};
}
if (
await matchCommunityList(secName, community, queryForMatching, type)
) {
@@ -11779,6 +11982,149 @@ async function performTrace(query) {
}
}
if (type === "domain") {
// No direct domain rule matched. Resolve domain to IP addresses and check IP routing rules
const resolvedIps = await resolveDomainToIps(queryForMatching);
if (resolvedIps && resolvedIps.length > 0) {
for (let i = 0; i < sections.length; i++) {
const sec = sections[i];
const secName = sec[".name"];
if (uci.get(UCI_PACKAGE, secName, "enabled") === "0") continue;
const label = uci.get(UCI_PACKAGE, secName, "label") || secName;
const action = uci.get(UCI_PACKAGE, secName, "action") || "connection";
const ipCidr = normalizeOptionValues(
uci.get(UCI_PACKAGE, secName, "ip_cidr"),
);
const listsRuleset = await readRulesetFile(
`/tmp/sing-box/rulesets/${secName}-lists-ruleset.json`,
);
const remoteSubnetRuleset = await readRulesetFile(
`/tmp/sing-box/rulesets/${secName}-remote-subnet-ruleset.json`,
);
const communitySubnetsEnabled =
uci.get(UCI_PACKAGE, secName, "community_subnets") !== "0";
const communityLists = communitySubnetsEnabled
? normalizeOptionValues(
uci.get(UCI_PACKAGE, secName, "community_lists"),
)
: [];
for (const ip of resolvedIps) {
const matchedCidr = matchIpInCidrs(ip, ipCidr);
if (matchedCidr) {
return {
matched: true,
sectionName: secName,
label: label,
action: action,
ruleType: "Resolved IP -> IPs (UCI)",
pattern: `${matchedCidr} (resolved: ${ip})`,
resolvedDomain: queryForMatching,
resolvedIps: resolvedIps,
matchedIp: ip,
priority: i + 1,
totalSections: totalSections,
sharedCdnWarning: getSharedCdnWarning(matchedCidr),
};
}
if (listsRuleset) {
const matchedListIp = matchIpInRuleset(ip, listsRuleset);
if (matchedListIp) {
const domainIpLists = normalizeOptionValues(
uci.get(UCI_PACKAGE, secName, "domain_ip_lists"),
);
return {
matched: true,
sectionName: secName,
label: label,
action: action,
ruleType: "Resolved IP -> IP/Subnet List (community/plain)",
pattern: `${matchedListIp} (resolved: ${ip})`,
sourceUrls: domainIpLists,
resolvedDomain: queryForMatching,
resolvedIps: resolvedIps,
matchedIp: ip,
priority: i + 1,
totalSections: totalSections,
sharedCdnWarning: getSharedCdnWarning(matchedListIp),
};
}
}
if (remoteSubnetRuleset) {
const matchedRemoteIp = matchIpInRuleset(ip, remoteSubnetRuleset);
if (matchedRemoteIp) {
const domainIpLists = normalizeOptionValues(
uci.get(UCI_PACKAGE, secName, "domain_ip_lists"),
);
return {
matched: true,
sectionName: secName,
label: label,
action: action,
ruleType: "Resolved IP -> Remote Subnet List",
pattern: `${matchedRemoteIp} (resolved: ${ip})`,
sourceUrls: domainIpLists,
resolvedDomain: queryForMatching,
resolvedIps: resolvedIps,
matchedIp: ip,
priority: i + 1,
totalSections: totalSections,
sharedCdnWarning: getSharedCdnWarning(matchedRemoteIp),
};
}
}
for (const community of communityLists) {
const subnets = await readSubnetListFile(community);
const matchedCommunityCidr = matchIpInCidrs(ip, subnets);
if (matchedCommunityCidr) {
return {
matched: true,
sectionName: secName,
label: label,
action: action,
ruleType: `Resolved IP -> Community Subnets (${community})`,
pattern: `${matchedCommunityCidr} (resolved: ${ip})`,
resolvedDomain: queryForMatching,
resolvedIps: resolvedIps,
matchedIp: ip,
priority: i + 1,
totalSections: totalSections,
sharedCdnWarning: getSharedCdnWarning(matchedCommunityCidr),
};
}
if (await matchCommunityList(secName, community, ip, "ip")) {
return {
matched: true,
sectionName: secName,
label: label,
action: action,
ruleType: `Resolved IP -> Community List (${community})`,
pattern: `${ip} (resolved: ${ip})`,
resolvedDomain: queryForMatching,
resolvedIps: resolvedIps,
matchedIp: ip,
priority: i + 1,
totalSections: totalSections,
sharedCdnWarning: getSharedCdnWarning(ip),
};
}
}
}
}
return {
matched: false,
resolvedDomain: queryForMatching,
resolvedIps: resolvedIps,
};
}
}
return { matched: false };
}
@@ -11932,9 +12278,46 @@ function createTracerSearchWidget(sectionRef) {
]),
],
),
...(result.resolvedIps && result.resolvedIps.length > 0
? [
E(
"div",
{
style:
"margin-top: 4px; font-size: 85%; color: var(--text-color-medium, #666);",
},
_("DNS: Resolved %s to %s").format(
result.resolvedDomain,
result.resolvedIps.join(", "),
),
),
]
: []),
],
);
resultsEl.appendChild(matchDiv);
if (result.sharedCdnWarning) {
const warnDiv = E(
"div",
{
style:
"margin-top: 8px; padding: 0.5rem 0.7rem; border-radius: 4px; background: rgba(243, 156, 18, 0.12); border-left: 3px solid var(--warning-color, #e67e22); font-size: 85%; color: var(--text-color-high, #333); line-height: 1.4;",
},
[
E(
"strong",
{
style:
"color: var(--warning-color, #d35400); display: block; margin-bottom: 2px;",
},
["⚠️ ", _("Shared CDN / Anycast Warning")],
),
E("span", {}, result.sharedCdnWarning),
],
);
resultsEl.appendChild(warnDiv);
}
} else {
const noMatchDiv = E(
"div",
@@ -11945,6 +12328,20 @@ function createTracerSearchWidget(sectionRef) {
[
E("strong", {}, _("No Custom Rules Matched") + ": "),
_("Traffic will go through the default route (Direct/Default)."),
...(result && result.resolvedIps && result.resolvedIps.length > 0
? [
E(
"div",
{
style:
"margin-top: 4px; font-size: 85%; color: var(--text-color-medium, #666);",
},
_("DNS: Resolved to %s (no subnet rules matched)").format(
result.resolvedIps.join(", "),
),
),
]
: []),
],
);
resultsEl.appendChild(noMatchDiv);
+89 -74
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: TACHYON\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-11 18:51+0300\n"
"PO-Revision-Date: 2026-09-11 18:51+0300\n"
"POT-Creation-Date: 2026-09-11 22:54+0300\n"
"PO-Revision-Date: 2026-09-11 22:54+0300\n"
"Last-Translator: Dushnilin\n"
"Language-Team: none\n"
"Language: ru\n"
@@ -26,6 +26,9 @@ msgstr "Списки .lst"
msgid "(provider default)"
msgstr "(по умолчанию провайдера)"
msgid "+ add"
msgstr "+ добавить"
msgid "+ Add JSON outbound"
msgstr "+ Добавить JSON outbound"
@@ -62,6 +65,9 @@ msgstr "⚠️ Если все Bootstrap DNS завершатся с ошибк
msgid "⚠️ If all Main 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 "⚠️ Если все основные DNS завершатся с ошибкой 3 раза, запросы будут отправлены в DNS вашего провайдера в открытом виде. Используйте только в крайнем случае для предотвращения полной потери интернета."
msgid "⚠️ Server received fakes"
msgstr "⚠️ Сервер получил фейки"
msgid "⚡ Fast (>1MB/s)"
msgstr "⚡ Быстрые (>1 МБ/с)"
@@ -86,12 +92,18 @@ msgstr "✅ Только рабочие"
msgid "✈️ Telegram Suite (Web + Bot API)"
msgstr "✈️ Telegram (Веб + Bot API)"
msgid "✓ 32KB Streamed"
msgstr "✓ 32KB Прокачано"
msgid "✔ Online"
msgstr "✔ В сети"
msgid "✔ Running"
msgstr "✔ Работает"
msgid "✗ Throttled at 16KB"
msgstr "✗ Задушено на 16KB"
msgid "✘ Offline"
msgstr "✘ Не в сети"
@@ -170,12 +182,6 @@ 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)"
msgid "Обнаружена блокировка по IP! Ресурс блокируется на сетевом уровне (нет ответа на TCP SYN). Методы обхода DPI (Zapret / ByeDPI) бессильны для этого адреса — настройте маршрутизацию через прокси/VPN (Sing-box) для данного домена!"
msgstr "Обнаружена блокировка по IP! Ресурс блокируется на сетевом уровне (нет ответа на TCP SYN). Методы обхода DPI (Zapret / ByeDPI) бессильны для этого адреса — настройте маршрутизацию через прокси/VPN (Sing-box) для данного домена!"
msgid "Accept external proxy connections and route them with sing-box."
msgstr "Принимать внешние прокси-подключения и маршрутизировать их через sing-box."
@@ -518,6 +524,9 @@ msgstr "Версия AmneziaWG"
msgid "An unexpected error occurred during test"
msgstr "Произошла непредвиденная ошибка во время проверки"
msgid "Analysis inconclusive."
msgstr "Анализ не дал однозначного результата."
msgid "Anonymized support report copied to clipboard"
msgstr "Анонимизированный отчет скопирован в буфер обмена"
@@ -755,6 +764,9 @@ msgstr "Заблокированные сайты"
msgid "Blocked Sites (Domains)"
msgstr "Заблокированные сайты (домены)"
msgid "Blocked Sites (Only block domains specified below)"
msgstr "Заблокированные сайты (Блокировать только указанные домены)"
msgid "Blocks access to private/LAN IP addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, etc.) for selected users before applying routing mode."
msgstr "Блокирует доступ к приватным/локальным IP-адресам (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 и др.) для указанных пользователей перед применением режима маршрутизации."
@@ -1382,6 +1394,9 @@ msgstr "DNS"
msgid "DNS Auto-Tuner & Benchmark"
msgstr "Автоподбор и бенчмарк DNS"
msgid "DNS Blocking"
msgstr "Блокировка DNS"
msgid "DNS Check Interval"
msgstr "Интервал проверки DNS"
@@ -1418,6 +1433,9 @@ msgstr "Протокол DNS, используемый резолвером"
msgid "DNS requests through section"
msgstr "DNS-запросы через секцию"
msgid "DNS resolution failed — likely DNS-level blocking or hijacking."
msgstr "Сбой разрешения DNS — вероятно, блокировка или перехват на уровне DNS."
msgid "DNS Rewrite TTL"
msgstr "TTL DNS-записей"
@@ -1466,6 +1484,12 @@ msgstr "Таймаут недоступности DNS"
msgid "DNS Upstream Resolver Analysis (bash.ws protocol)"
msgstr "Анализ восходящих DNS-резолверов (протокол bash.ws)"
msgid "DNS: Resolved %s to %s"
msgstr "DNS: Разрешено %s в %s"
msgid "DNS: Resolved to %s (no subnet rules matched)"
msgstr "DNS: Разрешено в %s (нет совпадений по правилам подсетей)"
msgid "Do not panic, everything can be fixed, just..."
msgstr "Не паникуйте, всё можно исправить, просто..."
@@ -1568,6 +1592,9 @@ msgstr "Загружено"
msgid "Downloading and applying rule sets and lists..."
msgstr "Загрузка и применение наборов правил и списков..."
msgid "DPI bandwidth throttling or delay detected — connection throttled during data transfer."
msgstr "Обнаружено замедление DPI — соединение заблокировано или задушено при передаче данных."
msgid "DPI Detected"
msgstr "DPI обнаружен"
@@ -1730,6 +1757,9 @@ msgstr "Конец интервала расписания (например, 08
msgid "End Time (HH:MM)"
msgstr "Время окончания (ЧЧ:ММ)"
msgid "endpoints OK"
msgstr "эндпоинтов доступно"
msgid "Enforce SafeSearch & YouTube Restricted Mode"
msgstr "Безопасный поиск (SafeSearch) и строгий режим YouTube"
@@ -2747,9 +2777,15 @@ msgstr "IP-адреса (IP CIDR)"
msgid "IP and DNS leak check timed out"
msgstr "Время ожидания проверки утечек IP и DNS истекло"
msgid "IP Block (TCP SYN Timeout)"
msgstr "Блокировка по IP (Таймаут TCP SYN)"
msgid "IP Frag Bounds (--ip-frag)"
msgstr "Границы фрагментации IP (--ip-frag)"
msgid "IP-level blocking detected! The resource is blocked at the network level (no TCP SYN response). DPI bypass methods (Zapret / ByeDPI) cannot bypass this address — configure proxy/VPN routing (Sing-box) for this domain!"
msgstr "Обнаружена блокировка по IP! Ресурс блокируется на сетевом уровне (нет ответа на TCP SYN). Методы обхода DPI (Zapret / ByeDPI) бессильны для этого адреса — настройте маршрутизацию через прокси/VPN (Sing-box) для данного домена!"
msgid "IPs"
msgstr "IP"
@@ -3218,6 +3254,9 @@ msgstr "История бенчмарков пуста. Запустите бе
msgid "No benchmark results yet. Configure options and click \"Start Benchmark\"."
msgstr "Результаты тестирования пока отсутствуют. Настройте параметры и нажмите \"Запустить тест\"."
msgid "No Blocking Detected"
msgstr "Блокировка не обнаружена"
msgid "No check targets found in the configuration."
msgstr "Цели для проверки не найдены в конфигурации."
@@ -4505,6 +4544,9 @@ msgstr "Настройки"
msgid "Settings have been reset to defaults. The page will reload."
msgstr "Настройки сброшены к значениям по умолчанию. Страница будет перезагружена."
msgid "Shared CDN / Anycast Warning"
msgstr "Предупреждение: общий диапазон CDN / Anycast"
msgid "Shopping"
msgstr "Магазины"
@@ -4772,6 +4814,12 @@ msgstr "Строгий ECH + DoQ DNS Enforcer"
msgid "STUN (P2P Calls)"
msgstr "STUN (P2P-звонки)"
msgid "Subnet %s belongs to a shared Anycast/CDN range (%s). Third-party websites hosted on this CDN will also be routed through this section."
msgstr "Подсеть %s входит в публичный Anycast/CDN-диапазон (%s). Сторонние сайты, размещенные на этом CDN, также будут направляться через эту секцию."
msgid "Subnet %s is a shared Anycast/CDN range (%s). Third-party websites hosted on this CDN will also be routed through this section."
msgstr "Подсеть %s является публичным Anycast/CDN-диапазоном (%s). Сторонние сайты, размещенные на этом CDN, также будут направляться через эту секцию."
msgid "Subnets from the list will be extracted and added to nftables"
msgstr "Подсети из списка будут извлечены и добавлены в nftables"
@@ -4940,6 +4988,9 @@ msgstr "Пакет Tailscale установлен, но ни одна серве
msgid "Target"
msgstr "Цель"
msgid "Target accessible — no DPI blocking detected."
msgstr "Цель доступна — блокировок DPI не обнаружено."
msgid "Target Family Profiles"
msgstr "Семейные профили"
@@ -4949,6 +5000,15 @@ msgstr "Область действия"
msgid "Target Service / Suite"
msgstr "Целевой сервис / Набор"
msgid "TCP connect timed out before TLS handshake — host is blocked at the IP layer. DPI bypass cannot unblock this; route via Sing-box VPN/Proxy outbound instead."
msgstr "Таймаут TCP-подключения до рукопожатия TLS — узел заблокирован на уровне IP. Методы обхода DPI не помогут; настройте маршрутизацию через прокси/VPN Sing-box."
msgid "TCP Reset Injection"
msgstr "Внедрение пакетов TCP Reset (RST)"
msgid "TCP RST received from DPI — active TCP reset injection detected."
msgstr "Получен TCP RST от ТСПУ/DPI — зафиксирована активная инъекция сброса соединения."
msgid "TCP Window Sizes (wsize)"
msgstr "Размеры окна TCP (wsize)"
@@ -5021,6 +5081,9 @@ msgstr "Тестирование стратегии"
msgid "Testing URL"
msgstr "URL проверки"
msgid "Testing:"
msgstr "Тестирование:"
msgid "Testing..."
msgstr "Тестирование..."
@@ -5087,6 +5150,9 @@ msgstr "Все настройки Tachyon будут удалены и восс
msgid "This will replace the current Tachyon settings with the snapshot"
msgstr "Это заменит текущие настройки Tachyon на снапшот"
msgid "Throttling / Deep Inspection"
msgstr "Замедление / Глубокий анализ пакетов"
msgid "Thu"
msgstr "Чт"
@@ -5318,6 +5384,9 @@ msgstr "Неизвестный параметр ByeDPI: %s"
msgid "Unknown country"
msgstr "Неизвестная страна"
msgid "Unknown DPI Pattern"
msgstr "Неизвестный тип DPI"
msgid "Unknown error"
msgstr "Неизвестная ошибка"
@@ -5708,9 +5777,15 @@ msgstr "sing-box outbound для Zapret настроен"
msgid "Zapret sing-box outbound is not configured"
msgstr "sing-box outbound для Zapret не настроен"
msgid "Zapret v1"
msgstr "Zapret v1"
msgid "Zapret v1 (nfqws)"
msgstr "Zapret v1 (nfqws)"
msgid "Zapret v2"
msgstr "Zapret v2"
msgid "Zapret v2 (nfqws2)"
msgstr "Zapret v2 (nfqws2)"
@@ -5744,6 +5819,12 @@ msgstr "Правила маршрутизации sing-box для Zapret2 нас
msgid "Zapret2 sing-box route rules are not configured"
msgstr "Правила маршрутизации sing-box для Zapret2 не настроены"
msgid "Блокировка по IP (Таймаут TCP SYN)"
msgstr "Блокировка по IP (Таймаут TCP SYN)"
msgid "Обнаружена блокировка по IP! Ресурс блокируется на сетевом уровне (нет ответа на TCP SYN). Методы обхода DPI (Zapret / ByeDPI) бессильны для этого адреса — настройте маршрутизацию через прокси/VPN (Sing-box) для данного домена!"
msgstr "Обнаружена блокировка по IP! Ресурс блокируется на сетевом уровне (нет ответа на TCP SYN). Методы обхода DPI (Zapret / ByeDPI) бессильны для этого адреса — настройте маршрутизацию через прокси/VPN (Sing-box) для данного домена!"
msgid "Block (RKN)"
msgstr "RKN Block"
@@ -6506,72 +6587,6 @@ msgstr "Скачивание и обработка списков"
msgid "Applying rules and subnet lists"
msgstr "Применение правил и списков подсетей"
msgid "+ add"
msgstr "+ добавить"
msgid "⚠️ Server received fakes"
msgstr "⚠️ Сервер получил фейки"
msgid "✓ 32KB Streamed"
msgstr "✓ 32KB Прокачано"
msgid "✗ Throttled at 16KB"
msgstr "✗ Задушено на 16KB"
msgid "Analysis inconclusive."
msgstr "Анализ не дал однозначного результата."
msgid "Blocked Sites (Only block domains specified below)"
msgstr "Заблокированные сайты (Блокировать только указанные домены)"
msgid "DNS Blocking"
msgstr "Блокировка DNS"
msgid "DNS resolution failed — likely DNS-level blocking or hijacking."
msgstr "Сбой разрешения DNS — вероятно, блокировка или перехват на уровне DNS."
msgid "DPI bandwidth throttling or delay detected — connection throttled during data transfer."
msgstr "Обнаружено замедление DPI — соединение заблокировано или задушено при передаче данных."
msgid "endpoints OK"
msgstr "эндпоинтов доступно"
msgid "IP Block (TCP SYN Timeout)"
msgstr "Блокировка по IP (Таймаут TCP SYN)"
msgid "IP-level blocking detected! The resource is blocked at the network level (no TCP SYN response). DPI bypass methods (Zapret / ByeDPI) cannot bypass this address — configure proxy/VPN routing (Sing-box) for this domain!"
msgstr "Обнаружена блокировка по IP! Ресурс блокируется на сетевом уровне (нет ответа на TCP SYN). Методы обхода DPI (Zapret / ByeDPI) бессильны для этого адреса — настройте маршрутизацию через прокси/VPN (Sing-box) для данного домена!"
msgid "No Blocking Detected"
msgstr "Блокировка не обнаружена"
msgid "Target accessible — no DPI blocking detected."
msgstr "Цель доступна — блокировок DPI не обнаружено."
msgid "TCP connect timed out before TLS handshake — host is blocked at the IP layer. DPI bypass cannot unblock this; route via Sing-box VPN/Proxy outbound instead."
msgstr "Таймаут TCP-подключения до рукопожатия TLS — узел заблокирован на уровне IP. Методы обхода DPI не помогут; настройте маршрутизацию через прокси/VPN Sing-box."
msgid "TCP Reset Injection"
msgstr "Внедрение пакетов TCP Reset (RST)"
msgid "TCP RST received from DPI — active TCP reset injection detected."
msgstr "Получен TCP RST от ТСПУ/DPI — зафиксирована активная инъекция сброса соединения."
msgid "Testing:"
msgstr "Тестирование:"
msgid "Throttling / Deep Inspection"
msgstr "Замедление / Глубокий анализ пакетов"
msgid "Unknown DPI Pattern"
msgstr "Неизвестный тип DPI"
msgid "Zapret v1"
msgstr "Zapret v1"
msgid "Zapret v2"
msgstr "Zapret v2"
msgid "Allow only: "
msgstr "Разрешить только: "
File diff suppressed because it is too large Load Diff
@@ -59,6 +59,12 @@
],
"/bin/cat": [
"exec"
],
"/usr/bin/nslookup": [
"exec"
],
"/bin/busybox": [
"exec"
]
},
"ubus": {
@@ -1,5 +1,5 @@
#!/bin/sh
# Only process ifup and ifdown events for WAN interfaces
# Hotplug handler for WAN retry on boot and interface state monitoring
case "$INTERFACE" in
wan*|wwan*|lte*|modem*)
@@ -11,25 +11,80 @@ case "$INTERFACE" in
;;
esac
# Rate limit Telegram notifications: max 1 notification per 60 seconds
COOLDOWN_FILE="/tmp/tachyon_wan_monitor_cooldown"
NOW=$(date +%s)
if [ -f "$COOLDOWN_FILE" ]; then
LAST=$(cat "$COOLDOWN_FILE" 2>/dev/null || echo 0)
DIFF=$((NOW - LAST))
[ "$DIFF" -lt 60 ] && exit 0
fi
echo "$NOW" > "$COOLDOWN_FILE"
case "$INTERFACE" in
wan*|wwan*|lte*|modem*)
case "$ACTION" in
ifup)
/usr/bin/tachyon telegram send "🟢 Интерфейс *${INTERFACE}* поднялся!"
;;
ifdown)
/usr/bin/tachyon telegram send "⚠️ Интерфейс *${INTERFACE}* упал!"
;;
esac
;;
case "$ACTION" in
ifup|ifdown) ;;
*) exit 0 ;;
esac
# 1. Check if interface monitoring is enabled in settings
[ "$(uci -q get tachyon.settings.enable_badwan_interface_monitoring)" = "1" ] || exit 0
# 2. Check if Telegram bot is enabled
[ "$(uci -q get tachyon.telegram.enabled)" = "1" ] || exit 0
# 3. Check if current interface is in badwan_monitored_interfaces
MONITORED="$(uci -q get tachyon.settings.badwan_monitored_interfaces)"
[ -n "$MONITORED" ] || exit 0
MATCHED=0
for iface in $MONITORED; do
if [ "$iface" = "$INTERFACE" ]; then
MATCHED=1
break
fi
done
[ "$MATCHED" -eq 1 ] || exit 0
# 4. Handle debounce, state tracking, and notifications in background to avoid blocking hotplug queue
(
STATE_DIR="/var/run/tachyon"
mkdir -p "$STATE_DIR" 2>/dev/null
LOCK_DIR="${STATE_DIR}/wan_monitor_${INTERFACE}.lock"
# Acquire per-interface lock with retry (max 5 seconds)
acquired=0
for _ in 1 2 3 4 5; do
if mkdir "$LOCK_DIR" 2>/dev/null; then
acquired=1
break
fi
sleep 1
done
[ "$acquired" -eq 1 ] || exit 0
trap 'rmdir "$LOCK_DIR" 2>/dev/null' EXIT INT TERM
# Debounce delay to let interface state settle
sleep 2
# Query actual interface status from ubus
STATUS_JSON="$(ubus -S call "network.interface.${INTERFACE}" status 2>/dev/null)"
CURRENT_STATE="down"
case "$STATUS_JSON" in
*'"up": true'*|*'"up":true'*)
CURRENT_STATE="up"
;;
esac
STATE_FILE="${STATE_DIR}/wan_state_${INTERFACE}"
PREV_STATE=""
[ -f "$STATE_FILE" ] && PREV_STATE="$(cat "$STATE_FILE" 2>/dev/null)"
TACHYON_BIN="/usr/bin/tachyon"
[ -x "$TACHYON_BIN" ] || TACHYON_BIN="$(command -v tachyon 2>/dev/null || echo /usr/bin/tachyon)"
if [ "$CURRENT_STATE" = "up" ]; then
if [ "$PREV_STATE" = "down" ]; then
echo "up" > "$STATE_FILE"
"$TACHYON_BIN" telegram send "🟢 Интерфейс *${INTERFACE}* поднялся!"
elif [ -z "$PREV_STATE" ]; then
echo "up" > "$STATE_FILE"
fi
elif [ "$CURRENT_STATE" = "down" ]; then
if [ "$PREV_STATE" != "down" ]; then
echo "down" > "$STATE_FILE"
"$TACHYON_BIN" telegram send "⚠️ Интерфейс *${INTERFACE}* упал!"
fi
fi
) >/dev/null 2>&1 &
+3 -1
View File
@@ -318,7 +318,9 @@ function command_spec(command) {
dns_benchmark_stop: [ "dns/benchmark.uc", "benchmark_stop", 0 ],
dns_benchmark_apply: [ "dns/benchmark.uc", "benchmark_apply", 0 ],
import_settings: [ "config/migration.uc", "import-settings", 1 ],
"import-settings": [ "config/migration.uc", "import-settings", 1 ]
"import-settings": [ "config/migration.uc", "import-settings", 1 ],
resolve_domain: [ "diagnostics/runtime.uc", "resolve-domain", 1 ],
"resolve-domain": [ "diagnostics/runtime.uc", "resolve-domain", 1 ]
};
return commands[as_string(command)];
+22 -2
View File
@@ -6,6 +6,14 @@ let helpers = require("core.helpers");
let uci_core = require("core.uci");
let connections = require("config.connections");
function core_ip_module_or_null() {
try {
return require("core.ip");
} catch (e) {
return null;
}
}
function core_url_module_or_null() {
try {
return require("core.url");
@@ -2518,11 +2526,15 @@ function import_all_preset_lists(settings) {
}
}
let core_ip = core_ip_module_or_null();
let lines = split(as_string(fs.readfile(tmpfile)), "\n");
for (let l in lines) {
l = trim(replace(as_string(l), /\r/g, ""));
if (l != "" && substr(l, 0, 1) != "#")
if (l != "" && substr(l, 0, 1) != "#") {
if (as_string(service) == "discord" && core_ip && core_ip.is_cloudflare_shared_cidr(l))
continue;
push(combined_lines, l);
}
}
if (downloaded)
@@ -2584,12 +2596,20 @@ function import_builtin_subnets_from_rule(section, settings) {
}
}
let core_ip = core_ip_module_or_null();
let lines = split(as_string(fs.readfile(tmpfile)), "\n");
let filtered_for_nft = [];
for (let l in lines) {
l = trim(replace(as_string(l), /\r/g, ""));
if (l != "" && substr(l, 0, 1) != "#")
if (l != "" && substr(l, 0, 1) != "#") {
if (as_string(service) == "discord" && core_ip && core_ip.is_cloudflare_shared_cidr(l))
continue;
push(combined_lines, l);
push(filtered_for_nft, l);
}
}
if (as_string(service) == "discord")
write_file(tmpfile, join("\n", filtered_for_nft) + "\n");
nft_module_success([
"nft-add-community-subnet-file-for-uci-section",
+22 -1
View File
@@ -254,6 +254,24 @@ function normalize_to_cidrs(items) {
return result;
}
const CLOUDFLARE_SHARED_CIDRS = [
"104.16.0.0/12", "104.24.0.0/14", "172.64.0.0/13", "162.158.0.0/15",
"108.162.192.0/18", "190.93.240.0/20", "188.114.96.0/20", "197.234.240.0/22",
"198.41.128.0/17", "162.159.0.0/16", "173.245.48.0/20", "103.21.244.0/22",
"103.22.200.0/22", "103.31.4.0/22", "141.101.64.0/18",
"2606:4700::/32", "2400:cb00::/32", "2405:b500::/32", "2803:f800::/32",
"2a06:98c0::/29", "2c0f:f248::/32"
];
function is_cloudflare_shared_cidr(value) {
value = trim(as_string(value));
for (let c in CLOUDFLARE_SHARED_CIDRS) {
if (value == c)
return true;
}
return false;
}
return {
valid_ipv4,
valid_ipv4_cidr,
@@ -268,5 +286,8 @@ return {
format_ipv6_tproxy_target,
ipv6_supported,
resolve_mac_to_ips,
normalize_to_cidrs
normalize_to_cidrs,
CLOUDFLARE_SHARED_CIDRS,
is_cloudflare_shared_cidr
};
@@ -5115,6 +5115,58 @@ function extract_ruleset(tag) {
return 0;
}
function resolve_domain_cli(domain) {
domain = trim(as_string(domain));
if (domain == "") {
print("[]\n");
return 0;
}
let m_dom = match(domain, /^https?:\/\/([^/:]+)/i);
if (m_dom && m_dom[1])
domain = m_dom[1];
else {
let parts = split(domain, "/");
domain = split(parts[0], ":")[0];
}
domain = trim(domain);
let ips = [];
let seen = {};
let add_ip = function(ip) {
ip = trim(as_string(ip));
if (ip == "" || ip == "127.0.0.1" || ip == "::1" || ip == "0.0.0.0" || seen[ip])
return;
if (!core_ip.valid_ip(ip))
return;
seen[ip] = true;
push(ips, ip);
};
let cmd_local = command_output_from_args([ "nslookup", domain, "127.0.0.1" ]);
let cmd_def = command_output_from_args([ "nslookup", domain ]);
let outputs = [ cmd_local, cmd_def ];
for (let out in outputs) {
if (!out) continue;
let past_header = false;
for (let line in split(out, "\n")) {
line = trim(as_string(line));
if (match(line, /^(Name:|Non-authoritative answer:)/i))
past_header = true;
if (past_header) {
let m = match(line, /Address(?:[ \t]+[0-9]+)?:[ \t]*([0-9a-fA-F:.]+)/);
if (m && m[1] && m[1] != "127.0.0.1" && m[1] != "::1")
add_ip(m[1]);
}
}
}
print(sprintf("%J\n", ips));
return 0;
}
let mode = ARGV[0] || "";
if (mode == "extract-ruleset")
@@ -5230,6 +5282,8 @@ else if (mode == "validate-nfqws2-strategy-json")
exit(validate_nfqws2_strategy_json(ARGV[1] || ""));
else if (mode == "validate-byedpi-strategy-json")
exit(validate_byedpi_strategy_json(ARGV[1] || ""));
else if (mode == "resolve-domain")
exit(resolve_domain_cli(ARGV[1] || ""));
else {
warn("Usage: diagnostics/runtime.uc <operation> ...\n");
exit(1);
+44 -4
View File
@@ -2005,6 +2005,39 @@ function nft_add_csv_chunks_to_family_sets(csv, table, ipv4_set, ipv6_set, kind,
nft_add_csv_chunks_to_set(csv, table, ipv6_set, kind, ports_csv, chunk_size_text, 6);
}
function nft_community_subnet_lines(path, service) {
let data = fs.readfile(path);
if (data == null)
return [];
let result = [];
for (let line in split(as_string(data), "\n")) {
line = trim(replace(as_string(line), /\r/g, ""));
if (line == "" || substr(line, 0, 1) == "#")
continue;
if (as_string(service) == "discord" && core_ip.is_cloudflare_shared_cidr(line))
continue;
push(result, line);
}
return result;
}
function nft_add_values_to_family_sets(values, table, ipv4_set, ipv6_set, kind, ports_csv, chunk_size_text) {
let prep4 = nft_build_chunks_from_values(values, kind, ports_csv, chunk_size_text, 4);
let ok4 = nft_add_chunks_to_set(table, ipv4_set, prep4.chunks, prep4.invalid);
let prep6 = nft_build_chunks_from_values(values, kind, ports_csv, chunk_size_text, 6);
let ok6 = nft_add_chunks_to_set(table, ipv6_set, prep6.chunks, prep6.invalid);
return ok4 && ok6;
}
function nft_add_community_subnet_file_to_family_sets(path, table, ipv4_set, ipv6_set, service, chunk_size_text) {
let lines = nft_community_subnet_lines(path, service);
if (length(lines) == 0)
return true;
return nft_add_values_to_family_sets(lines, table, ipv4_set, ipv6_set, "ips", "", chunk_size_text);
}
function nft_add_inline_ip_cidr_matchers(csv, ports_csv, table, common_set, ip_port_set, chunk_size_text, common6_set, ip_port6_set) {
if (as_string(csv) == "")
return true;
@@ -2658,7 +2691,7 @@ function nft_populate_runtime_set_for_section(section, deferred_sections, table,
];
for (let path in cached_paths) {
if (helpers.file_is_usable(path, 50)) {
nft_add_file_chunks_to_family_sets(path, table, sets.subnets, sets.subnets6, "ips", "", "5000");
nft_add_community_subnet_file_to_family_sets(path, table, sets.subnets, sets.subnets6, service, "5000");
break;
}
}
@@ -2739,10 +2772,17 @@ function nft_add_community_subnet_file_for_section(section, service, filepath, t
if (!bool_option(section, "community_subnets", true))
return true;
if (section_needs_priority_sets(section))
return nft_add_subnet_file_for_section(section, filepath, table, common_set, ip_port_set, chunk_size_text, common6_set, ip_port6_set);
let ports = section_rule_ports_csv(section);
let sets = section_priority_sets(section);
let common_v4 = section_needs_priority_sets(section) ? sets.subnets : common_set;
let common_v6 = section_needs_priority_sets(section) ? sets.subnets6 : default_arg(common6_set, "tachyon_subnets6");
return nft_add_file_chunks_to_family_sets(filepath, table, common_set, default_arg(common6_set, "tachyon_subnets6"), "ips", "", chunk_size_text);
if (section_needs_priority_sets(section) && ports != "" && !section_has_destination_matchers(section)) {
let lines = nft_community_subnet_lines(filepath, service);
return nft_add_values_to_family_sets(lines, table, sets.ip_ports, sets.ip6_ports, "ip-port-from-ip", ports, chunk_size_text);
}
return nft_add_community_subnet_file_to_family_sets(filepath, table, common_v4, common_v6, service, chunk_size_text);
}
function nft_add_subnet_file_for_uci_section(section_name, filepath, table, common_set, ip_port_set, chunk_size_text, common6_set, ip_port6_set) {
@@ -1456,8 +1456,11 @@ function load_community_subnet_cidrs(community) {
let cidrs = [];
for (let line in split(as_string(content), "\n")) {
line = trim(replace(as_string(line), /\r/g, ""));
if (line != "" && substr(line, 0, 1) != "#" && match(line, /^[0-9a-fA-F:.]+(\/[0-9]+)?$/) && !match(line, /\.$/))
if (line != "" && substr(line, 0, 1) != "#" && match(line, /^[0-9a-fA-F:.]+(\/[0-9]+)?$/) && !match(line, /\.$/)) {
if (service == "discord" && core_ip.is_cloudflare_shared_cidr(line))
continue;
push(cidrs, line);
}
}
if (length(cidrs) > 0)
return cidrs;
@@ -2023,5 +2026,6 @@ return {
add_server_routes,
outbound_supports_udp,
push_section_route_rule,
is_valid_detour
is_valid_detour,
load_community_subnet_cidrs
};
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LIB_DIR="$ROOT_DIR/tachyon/files/usr/lib"
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
TMP_DIR="$(mktemp -d /tmp/tachyon_community_subnets_test.XXXXXX)"
trap 'rm -rf "$TMP_DIR"' EXIT
DISCORD_LST="$TMP_DIR/community-subnets-discord.lst"
OTHER_LST="$TMP_DIR/community-subnets-other.lst"
cat > "$DISCORD_LST" << 'EOF'
# Discord community subnets
66.22.196.0/22
104.16.0.0/12
172.64.0.0/13
162.158.0.0/15
2606:4700::/32
162.159.128.0/21
EOF
cat > "$OTHER_LST" << 'EOF'
# Other service
104.16.0.0/12
1.2.3.0/24
EOF
# 1. Test core.ip is_cloudflare_shared_cidr
ucode -L "$LIB_DIR" -e '
let core_ip = require("core.ip");
if (!core_ip.is_cloudflare_shared_cidr("104.16.0.0/12")) exit(1);
if (!core_ip.is_cloudflare_shared_cidr("172.64.0.0/13")) exit(2);
if (!core_ip.is_cloudflare_shared_cidr("162.158.0.0/15")) exit(3);
if (!core_ip.is_cloudflare_shared_cidr("2606:4700::/32")) exit(4);
if (core_ip.is_cloudflare_shared_cidr("66.22.196.0/22")) exit(5);
if (core_ip.is_cloudflare_shared_cidr("162.159.128.0/21")) exit(6);
if (core_ip.is_cloudflare_shared_cidr("1.1.1.1/32")) exit(7);
' || fail "core.ip is_cloudflare_shared_cidr validation failed"
# 2. Test singbox.generator_routes load_community_subnet_cidrs filtering
# Create temporary /tmp/sing-box/rulesets if needed or link to TMP_DIR
mkdir -p /tmp/sing-box/rulesets
cp "$DISCORD_LST" /tmp/sing-box/rulesets/community-subnets-discord.lst
cp "$OTHER_LST" /tmp/sing-box/rulesets/community-subnets-other.lst
ucode -L "$LIB_DIR" -e '
let generator_routes = require("singbox.generator_routes");
let discord_cidrs = generator_routes.load_community_subnet_cidrs("discord");
if (!discord_cidrs || length(discord_cidrs) != 2) {
warn("Unexpected discord_cidrs length: " + length(discord_cidrs) + "\n");
exit(1);
}
for (let c in discord_cidrs) {
if (c == "104.16.0.0/12" || c == "172.64.0.0/13" || c == "162.158.0.0/15" || c == "2606:4700::/32") {
warn("Found Cloudflare CIDR in discord_cidrs: " + c + "\n");
exit(2);
}
}
if (discord_cidrs[0] != "66.22.196.0/22" || discord_cidrs[1] != "162.159.128.0/21") {
warn("Expected subnets not found: " + discord_cidrs + "\n");
exit(3);
}
// Ensure non-discord service is NOT stripped of Cloudflare CIDRs
let other_cidrs = generator_routes.load_community_subnet_cidrs("other");
if (length(other_cidrs) != 2) exit(4);
' || fail "singbox.generator_routes load_community_subnet_cidrs filtering failed"
# Clean up /tmp test files
rm -f /tmp/sing-box/rulesets/community-subnets-discord.lst /tmp/sing-box/rulesets/community-subnets-other.lst
# 3. Test core.ip against discord file lines
ucode -L "$LIB_DIR" -e '
let fs = require("fs");
let core_ip = require("core.ip");
let data = fs.readfile("'"$DISCORD_LST"'");
let result = [];
for (let line in split(data, "\n")) {
line = trim(replace(line, /\r/g, ""));
if (line == "" || substr(line, 0, 1) == "#") continue;
if (core_ip.is_cloudflare_shared_cidr(line)) continue;
push(result, line);
}
if (length(result) != 2) exit(1);
if (result[0] != "66.22.196.0/22" || result[1] != "162.159.128.0/21") exit(2);
' || fail "community subnet lines filtering failed"
# 4. Test diagnostics/runtime.uc resolve-domain mode
output="$(ucode -L "$LIB_DIR" "$LIB_DIR/diagnostics/runtime.uc" resolve-domain "127.0.0.1" 2>/dev/null || true)"
# Output should be valid JSON array (e.g. ["127.0.0.1"] or [] if nslookup filters loopback)
echo "$output" | grep -q '^\[' || fail "diagnostics/runtime.uc resolve-domain must output a JSON array"
printf 'All community subnets sanitization tests PASSED!\n'
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MONITOR_SCRIPT="$ROOT_DIR/tachyon/files/etc/hotplug.d/iface/99-tachyon-wan-monitor"
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
[ -f "$MONITOR_SCRIPT" ] || fail "99-tachyon-wan-monitor not found"
TMP_DIR="$(mktemp -d /tmp/tachyon_wan_monitor_test.XXXXXX)"
trap 'rm -rf "$TMP_DIR"' EXIT
BIN_DIR="$TMP_DIR/bin"
VAR_RUN="$TMP_DIR/var_run"
mkdir -p "$BIN_DIR" "$VAR_RUN"
# Mock uci
cat > "$BIN_DIR/uci" << 'EOF'
#!/bin/sh
cmd="$1"
shift
case "$cmd" in
-q)
subcmd="$1"
shift
if [ "$subcmd" = "get" ]; then
key="$1"
case "$key" in
tachyon.settings.enable_badwan_interface_monitoring)
cat "${TEST_UCI_DIR}/enable_badwan" 2>/dev/null || echo "0"
;;
tachyon.telegram.enabled)
cat "${TEST_UCI_DIR}/telegram_enabled" 2>/dev/null || echo "0"
;;
tachyon.settings.badwan_monitored_interfaces)
cat "${TEST_UCI_DIR}/monitored_ifaces" 2>/dev/null || echo ""
;;
*)
echo ""
;;
esac
fi
;;
esac
EOF
chmod +x "$BIN_DIR/uci"
# Mock ubus
cat > "$BIN_DIR/ubus" << 'EOF'
#!/bin/sh
# Check if call network.interface.<iface> status
for arg in "$@"; do
case "$arg" in
network.interface.*)
iface="${arg#network.interface.}"
state="$(cat "${TEST_UCI_DIR}/ubus_state_${iface}" 2>/dev/null || echo "up")"
if [ "$state" = "up" ]; then
echo '{"up": true, "pending": false, "available": true}'
else
echo '{"up": false, "pending": false, "available": false}'
fi
exit 0
;;
esac
done
echo '{}'
EOF
chmod +x "$BIN_DIR/ubus"
# Mock logger
cat > "$BIN_DIR/logger" << 'EOF'
#!/bin/sh
echo "$@" >> "${TEST_LOG_FILE}"
EOF
chmod +x "$BIN_DIR/logger"
# Mock tachyon cli (for send_telegram_notification)
cat > "$BIN_DIR/tachyon" << 'EOF'
#!/bin/sh
echo "$@" >> "${TEST_TELEGRAM_LOG}"
exit 0
EOF
chmod +x "$BIN_DIR/tachyon"
export PATH="$BIN_DIR:$PATH"
export TEST_UCI_DIR="$TMP_DIR/uci"
export TEST_LOG_FILE="$TMP_DIR/logger.log"
export TEST_TELEGRAM_LOG="$TMP_DIR/telegram.log"
mkdir -p "$TEST_UCI_DIR"
# Patch monitor script temporarily to use our isolated /var/run/tachyon dir and fast debounce (0.1s instead of 2s)
TEST_MONITOR="$TMP_DIR/monitor_under_test.sh"
sed -e "s|/var/run/tachyon|${VAR_RUN}|g" \
-e "s|sleep 2|sleep 0.1|g" \
"$MONITOR_SCRIPT" > "$TEST_MONITOR"
chmod +x "$TEST_MONITOR"
# Test 1: Monitoring disabled (enable_badwan = 0)
echo "0" > "$TEST_UCI_DIR/enable_badwan"
echo "1" > "$TEST_UCI_DIR/telegram_enabled"
echo "wan" > "$TEST_UCI_DIR/monitored_ifaces"
echo "down" > "$TEST_UCI_DIR/ubus_state_wan"
ACTION="ifdown" INTERFACE="wan" sh "$TEST_MONITOR"
sleep 0.2
[ ! -f "$TEST_TELEGRAM_LOG" ] || fail "Test 1 failed: notification sent when enable_badwan=0"
# Test 2: Telegram disabled (telegram_enabled = 0)
echo "1" > "$TEST_UCI_DIR/enable_badwan"
echo "0" > "$TEST_UCI_DIR/telegram_enabled"
ACTION="ifdown" INTERFACE="wan" sh "$TEST_MONITOR"
sleep 0.2
[ ! -f "$TEST_TELEGRAM_LOG" ] || fail "Test 2 failed: notification sent when telegram.enabled=0"
# Test 3: Unmonitored interface (wan6 triggered, only wan is monitored)
echo "1" > "$TEST_UCI_DIR/enable_badwan"
echo "1" > "$TEST_UCI_DIR/telegram_enabled"
echo "wan" > "$TEST_UCI_DIR/monitored_ifaces"
echo "down" > "$TEST_UCI_DIR/ubus_state_wan6"
ACTION="ifdown" INTERFACE="wan6" sh "$TEST_MONITOR"
sleep 0.2
[ ! -f "$TEST_TELEGRAM_LOG" ] || fail "Test 3 failed: notification sent for unmonitored interface wan6"
# Test 4: Monitored interface drops (wan ifdown, initial up -> down transition)
echo "1" > "$TEST_UCI_DIR/enable_badwan"
echo "1" > "$TEST_UCI_DIR/telegram_enabled"
echo "wan" > "$TEST_UCI_DIR/monitored_ifaces"
echo "up" > "$VAR_RUN/wan_state_wan" # interface was previously UP
echo "down" > "$TEST_UCI_DIR/ubus_state_wan"
ACTION="ifdown" INTERFACE="wan" sh "$TEST_MONITOR"
# Wait for background verification
sleep 0.3
[ -f "$TEST_TELEGRAM_LOG" ] || fail "Test 4 failed: no notification sent on UP -> DOWN transition"
grep -Fq "Интерфейс *wan* упал!" "$TEST_TELEGRAM_LOG" || fail "Test 4 failed: expected drop message not found in log"
[ "$(cat "$VAR_RUN/wan_state_wan")" = "down" ] || fail "Test 4 failed: state file not set to down"
# Test 5: Duplicate hotplug ifdown while state is already down (NO repeated spam)
rm -f "$TEST_TELEGRAM_LOG"
ACTION="ifdown" INTERFACE="wan" sh "$TEST_MONITOR"
sleep 0.3
[ ! -f "$TEST_TELEGRAM_LOG" ] || fail "Test 5 failed: duplicate notification sent on repeated ifdown without state change"
# Test 6: Interface recovers (wan ifup, transition down -> up)
rm -f "$TEST_TELEGRAM_LOG"
echo "up" > "$TEST_UCI_DIR/ubus_state_wan"
ACTION="ifup" INTERFACE="wan" sh "$TEST_MONITOR"
sleep 0.3
[ -f "$TEST_TELEGRAM_LOG" ] || fail "Test 6 failed: no notification sent on DOWN -> UP recovery"
grep -Fq "Интерфейс *wan* поднялся!" "$TEST_TELEGRAM_LOG" || fail "Test 6 failed: expected recovery message not found in log"
[ "$(cat "$VAR_RUN/wan_state_wan")" = "up" ] || fail "Test 6 failed: state file not set to up"
# Test 7: Duplicate hotplug ifup while state is already up (NO duplicate message)
rm -f "$TEST_TELEGRAM_LOG"
ACTION="ifup" INTERFACE="wan" sh "$TEST_MONITOR"
sleep 0.3
[ ! -f "$TEST_TELEGRAM_LOG" ] || fail "Test 7 failed: duplicate notification sent on repeated ifup without state change"
printf 'All 7 WAN monitor hotplug tests PASSED!\n'