fix: resolve Discord Cloudflare voice ports #55, smart detect proxy sections #56, and UI enhancements

This commit is contained in:
Dushnilin
2026-09-15 19:15:57 +03:00
parent 046902d4e9
commit 6978601d4d
20 changed files with 385 additions and 100 deletions
+1 -1
View File
@@ -1361,7 +1361,7 @@ const dictionary = {
"Auto (cooperative with SQM/CAKE)": "Авто (кооперация с SQM/CAKE)",
"QoS Priority Engine (DSCP)": "Движок приоритета QoS (DSCP)",
"Strategy applied successfully and service reloaded!": "Стратегия успешно применена, сервис перезапущен!",
"Subnet %s is a Cloudflare Anycast range used by Discord Voice. Only UDP voice traffic (ports 50000-65535, 3478, 19302, 5000-5020) is routed through this section; web traffic (TCP 80/443) goes direct.": "Подсеть %s — диапазон Cloudflare Anycast, используемый Discord Voice. Только голосовой UDP-трафик (порты 50000-65535, 3478, 19302, 5000-5020) направляется через этот раздел; веб-трафик (TCP 80/443) идет напрямую.",
"Subnet %s is a Cloudflare Anycast range used by Discord Voice. Only UDP voice traffic (ports 50000-65535, 3478, 19294-19344, 5000-5020) is routed through this section; web traffic (TCP 80/443) goes direct.": "Подсеть %s — диапазон Cloudflare Anycast, используемый Discord Voice. Только голосовой UDP-трафик (порты 50000-65535, 3478, 19294-19344, 5000-5020) направляется через этот раздел; веб-трафик (TCP 80/443) идет напрямую.",
"Add URLs or local paths to .lst / .txt lists containing domains and subnets.": "Добавьте URL или локальные пути к спискам .lst / .txt, содержащим домены и подсети.",
"Add URLs or local paths to .lst / .txt lists containing domains. IP entries are ignored.": "Добавьте URL или локальные пути к спискам .lst / .txt, содержащим домены. IP-записи игнорируются.",
};
+2 -2
View File
@@ -12644,8 +12644,8 @@
]
},
{
"call": "Subnet %s is a Cloudflare Anycast range used by Discord Voice. Only UDP voice traffic (ports 50000-65535, 3478, 19302, 5000-5020) is routed through this section; web traffic (TCP 80/443) goes direct.",
"key": "Subnet %s is a Cloudflare Anycast range used by Discord Voice. Only UDP voice traffic (ports 50000-65535, 3478, 19302, 5000-5020) is routed through this section; web traffic (TCP 80/443) goes direct.",
"call": "Subnet %s is a Cloudflare Anycast range used by Discord Voice. Only UDP voice traffic (ports 50000-65535, 3478, 19294-19344, 5000-5020) is routed through this section; web traffic (TCP 80/443) goes direct.",
"key": "Subnet %s is a Cloudflare Anycast range used by Discord Voice. Only UDP voice traffic (ports 50000-65535, 3478, 19294-19344, 5000-5020) is routed through this section; web traffic (TCP 80/443) goes direct.",
"places": [
"../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:11896"
]
+3 -3
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: TACHYON\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-15 12:05+0300\n"
"PO-Revision-Date: 2026-09-15 12:05+0300\n"
"POT-Creation-Date: 2026-09-15 12:34+0300\n"
"PO-Revision-Date: 2026-09-15 12:34+0300\n"
"Last-Translator: Dushnilin <dushnilin@gmail.com>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
@@ -7810,7 +7810,7 @@ msgid "Subnet %s belongs to a shared Anycast/CDN range (%s). Third-party website
msgstr ""
#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:11896
msgid "Subnet %s is a Cloudflare Anycast range used by Discord Voice. Only UDP voice traffic (ports 50000-65535, 3478, 19302, 5000-5020) is routed through this section; web traffic (TCP 80/443) goes direct."
msgid "Subnet %s is a Cloudflare Anycast range used by Discord Voice. Only UDP voice traffic (ports 50000-65535, 3478, 19294-19344, 5000-5020) is routed through this section; web traffic (TCP 80/443) goes direct."
msgstr ""
#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:11387
+4 -4
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: TACHYON\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-15 15:05+0300\n"
"PO-Revision-Date: 2026-09-15 15:05+0300\n"
"POT-Creation-Date: 2026-09-15 15:34+0300\n"
"PO-Revision-Date: 2026-09-15 15:34+0300\n"
"Last-Translator: Dushnilin\n"
"Language-Team: none\n"
"Language: ru\n"
@@ -4868,8 +4868,8 @@ 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 Cloudflare Anycast range used by Discord Voice. Only UDP voice traffic (ports 50000-65535, 3478, 19302, 5000-5020) is routed through this section; web traffic (TCP 80/443) goes direct."
msgstr "Подсеть %s — диапазон Cloudflare Anycast, используемый Discord Voice. Только голосовой UDP-трафик (порты 50000-65535, 3478, 19302, 5000-5020) направляется через этот раздел; веб-трафик (TCP 80/443) идет напрямую."
msgid "Subnet %s is a Cloudflare Anycast range used by Discord Voice. Only UDP voice traffic (ports 50000-65535, 3478, 19294-19344, 5000-5020) is routed through this section; web traffic (TCP 80/443) goes direct."
msgstr "Подсеть %s — диапазон Cloudflare Anycast, используемый Discord Voice. Только голосовой UDP-трафик (порты 50000-65535, 3478, 19294-19344, 5000-5020) направляется через этот раздел; веб-трафик (TCP 80/443) идет напрямую."
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, также будут направляться через эту секцию."
@@ -125,7 +125,7 @@ interface ComponentActionButton {
interface ComponentCard {
component: Tachyon.ComponentName;
column: 0 | 1;
column: 0 | 1 | 2;
title: string;
version: string;
latestVersion?: string;
@@ -1545,7 +1545,7 @@ function getComponentCards(): ComponentCard[] {
},
{
component: 'wdtt',
column: 1,
column: 2,
title: 'WDTT',
version: systemInfoLoading
? _('Loading...')
@@ -1560,7 +1560,7 @@ function getComponentCards(): ComponentCard[] {
},
{
component: 'olcrtc',
column: 1,
column: 2,
title: 'OlcRTC',
version: systemInfoLoading
? _('Loading...')
@@ -1575,7 +1575,7 @@ function getComponentCards(): ComponentCard[] {
},
{
component: 'fptn',
column: 1,
column: 2,
title: 'FPTN',
version: systemInfoLoading
? _('Loading...')
@@ -1590,7 +1590,7 @@ function getComponentCards(): ComponentCard[] {
},
{
component: 'tailscale',
column: 1,
column: 2,
title: 'Tailscale',
version: systemInfoLoading
? _('Loading...')
@@ -2036,22 +2036,19 @@ function renderUpdatesComponents() {
return;
}
const columns = [[], []] as Node[][];
const columns: Node[][] = [[], [], []];
getComponentCards().forEach((card) => {
columns[card.column].push(renderComponentCard(card));
columns[card.column]?.push(renderComponentCard(card));
});
return preserveScrollForPage(() => {
container.replaceChildren(
E(
'div',
{ class: 'tachyon_updates-page__components-column' },
columns[0],
),
E(
'div',
{ class: 'tachyon_updates-page__components-column' },
columns[1],
...columns.map((columnNodes) =>
E(
'div',
{ class: 'tachyon_updates-page__components-column' },
columnNodes,
),
),
);
});
@@ -24,10 +24,10 @@ export const styles = `
.tachyon_updates-page__components-column {
display: flex;
flex: 1 1 auto;
flex: 1 1 0;
flex-direction: column;
gap: 10px;
min-width: max-content;
min-width: 280px;
}
@media (max-width: 760px) {
@@ -172,7 +172,7 @@ export const styles = `
display: flex;
justify-content: flex-start;
align-items: center;
flex-wrap: nowrap;
flex-wrap: wrap;
gap: 6px;
}
@@ -191,7 +191,7 @@ export const styles = `
.tachyon_updates-page__component__variants-buttons {
display: flex;
flex-wrap: nowrap;
flex-wrap: wrap;
gap: 6px;
}
@@ -22103,7 +22103,7 @@ function getComponentCards() {
},
{
component: "wdtt",
column: 1,
column: 2,
title: "WDTT",
version: systemInfoLoading ? _("Loading...") : wdttInstalled ? systemInfo.wdtt_version : _("Not installed"),
latestVersion: getLatestVersion("wdtt"),
@@ -22114,7 +22114,7 @@ function getComponentCards() {
},
{
component: "olcrtc",
column: 1,
column: 2,
title: "OlcRTC",
version: systemInfoLoading ? _("Loading...") : olcrtcInstalled ? systemInfo.olcrtc_version : _("Not installed"),
latestVersion: getLatestVersion("olcrtc"),
@@ -22125,7 +22125,7 @@ function getComponentCards() {
},
{
component: "fptn",
column: 1,
column: 2,
title: "FPTN",
version: systemInfoLoading ? _("Loading...") : fptnInstalled ? systemInfo.fptn_version : _("Not installed"),
latestVersion: getLatestVersion("fptn"),
@@ -22136,7 +22136,7 @@ function getComponentCards() {
},
{
component: "tailscale",
column: 1,
column: 2,
title: "Tailscale",
version: systemInfoLoading ? _("Loading...") : tailscaleInstalled ? systemInfo.tailscale_version : _("Not installed"),
latestVersion: getLatestVersion("tailscale"),
@@ -22516,21 +22516,18 @@ function renderUpdatesComponents() {
if (!container) {
return;
}
const columns = [[], []];
const columns = [[], [], []];
getComponentCards().forEach((card) => {
columns[card.column].push(renderComponentCard(card));
columns[card.column]?.push(renderComponentCard(card));
});
return preserveScrollForPage(() => {
container.replaceChildren(
E(
"div",
{ class: "tachyon_updates-page__components-column" },
columns[0]
),
E(
"div",
{ class: "tachyon_updates-page__components-column" },
columns[1]
...columns.map(
(columnNodes) => E(
"div",
{ class: "tachyon_updates-page__components-column" },
columnNodes
)
)
);
});
@@ -22654,10 +22651,10 @@ var styles6 = `
.tachyon_updates-page__components-column {
display: flex;
flex: 1 1 auto;
flex: 1 1 0;
flex-direction: column;
gap: 10px;
min-width: max-content;
min-width: 280px;
}
@media (max-width: 760px) {
@@ -22802,7 +22799,7 @@ var styles6 = `
display: flex;
justify-content: flex-start;
align-items: center;
flex-wrap: nowrap;
flex-wrap: wrap;
gap: 6px;
}
@@ -22821,7 +22818,7 @@ var styles6 = `
.tachyon_updates-page__component__variants-buttons {
display: flex;
flex-wrap: nowrap;
flex-wrap: wrap;
gap: 6px;
}
@@ -918,20 +918,59 @@ function loadOutboundNameChoices(section_id) {
const task = readOutboundMetadataFromSectionCache(targetId)
.then((metadata) => {
const names = Object.values(plainObject(metadata.names));
const namesMap = plainObject(metadata.names);
const nameCounts = new Map();
Object.values(namesMap).forEach((rawName) => {
const n = `${rawName || ""}`.trim();
if (n) {
nameCounts.set(n, (nameCounts.get(n) || 0) + 1);
}
});
const typeMap = outboundTypeByNameCache.get(targetId) || new Map();
Object.entries(plainObject(metadata.names)).forEach(([tag, name]) => {
const choices = [];
const seenLabels = new Map();
Object.entries(namesMap).forEach(([tag, rawName]) => {
const name = `${rawName || ""}`.trim();
if (!name) {
return;
}
const proto = metadata.protocols ? metadata.protocols[tag] : "";
const trans = metadata.transports ? metadata.transports[tag] : "";
const label = formatOutboundTypeLabel(proto, trans);
if (label) {
typeMap.set(name, label);
const typeLabel = formatOutboundTypeLabel(proto, trans);
if (typeLabel) {
typeMap.set(tag, typeLabel);
typeMap.set(name, typeLabel);
}
const isDuplicateName = (nameCounts.get(name) || 0) > 1;
let choiceValue = name;
let choiceLabel = name;
if (typeLabel) {
choiceLabel = `${name} [${typeLabel}]`;
if (isDuplicateName) {
choiceValue = choiceLabel;
}
}
if (seenLabels.has(choiceLabel)) {
const count = seenLabels.get(choiceLabel) + 1;
seenLabels.set(choiceLabel, count);
choiceLabel = `${choiceLabel} (#${count})`;
choiceValue = choiceLabel;
} else {
seenLabels.set(choiceLabel, 1);
}
if (typeLabel) {
typeMap.set(choiceValue, typeLabel);
}
choices.push({ value: choiceValue, label: choiceLabel });
});
outboundTypeByNameCache.set(targetId, typeMap);
if (targetId !== "Main") {
const mainTypeMap = outboundTypeByNameCache.get("Main") || new Map();
@@ -939,10 +978,7 @@ function loadOutboundNameChoices(section_id) {
outboundTypeByNameCache.set("Main", mainTypeMap);
}
const choices = names
.filter(Boolean)
.filter((name, index, values) => values.indexOf(name) === index)
.sort((a, b) => `${a}`.localeCompare(`${b}`));
choices.sort((a, b) => `${a.label}`.localeCompare(`${b.label}`));
outboundNameChoicesCache.set(targetId, choices);
if (targetId !== "Main") {
@@ -1890,18 +1926,33 @@ function currentOutboundNameChoices(section_id, values) {
const seen = new Set();
const result = [];
const append = (name) => {
const value = `${name || ""}`.trim();
const append = (item) => {
if (!item) {
return;
}
let value = "";
let label = "";
if (typeof item === "object" && item.value != null) {
value = `${item.value || ""}`.trim();
label = `${item.label || item.value || ""}`.trim();
} else {
value = `${item || ""}`.trim();
label = value;
}
if (!value || seen.has(value)) {
return;
}
seen.add(value);
const typeStr = getOutboundTypeForName(section_id, value);
const label =
typeStr && !value.endsWith(`[${typeStr}]`)
? `${value} [${typeStr}]`
: value;
if (
typeStr &&
!label.includes(`[${typeStr}]`) &&
!value.includes(`[${typeStr}]`) &&
!/\[[^\]]+\]$/.test(label)
) {
label = `${label} [${typeStr}]`;
}
result.push({ value, label });
};
@@ -11894,7 +11945,7 @@ async function performTrace(query) {
totalSections: totalSections,
sharedCdnWarning: isDiscordCfVoice
? _(
"Subnet %s is a Cloudflare Anycast range used by Discord Voice. Only UDP voice traffic (ports 50000-65535, 3478, 19302, 5000-5020) is routed through this section; web traffic (TCP 80/443) goes direct.",
"Subnet %s is a Cloudflare Anycast range used by Discord Voice. Only UDP voice traffic (ports 50000-65535, 3478, 19294-19344, 5000-5020) is routed through this section; web traffic (TCP 80/443) goes direct.",
).format(matchedCidr)
: getSharedCdnWarning(matchedCidr),
};
@@ -840,9 +840,17 @@ function createSmartDetectSectionsWidget(section_id) {
"proxy",
"outbound",
"vpn",
"zapret",
"zapret2",
"byedpi",
"awg",
"warp",
"anytls",
"snell",
"mieru",
"sudoku",
"masque",
"openvpn",
"wdtt",
"olcrtc",
"fptn",
];
const allSections = (uci.sections(UCI_PACKAGE, "section") || [])
.filter(function (s) {
+4 -4
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: TACHYON\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-15 15:05+0300\n"
"PO-Revision-Date: 2026-09-15 15:05+0300\n"
"POT-Creation-Date: 2026-09-15 15:34+0300\n"
"PO-Revision-Date: 2026-09-15 15:34+0300\n"
"Last-Translator: Dushnilin\n"
"Language-Team: none\n"
"Language: ru\n"
@@ -4868,8 +4868,8 @@ 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 Cloudflare Anycast range used by Discord Voice. Only UDP voice traffic (ports 50000-65535, 3478, 19302, 5000-5020) is routed through this section; web traffic (TCP 80/443) goes direct."
msgstr "Подсеть %s — диапазон Cloudflare Anycast, используемый Discord Voice. Только голосовой UDP-трафик (порты 50000-65535, 3478, 19302, 5000-5020) направляется через этот раздел; веб-трафик (TCP 80/443) идет напрямую."
msgid "Subnet %s is a Cloudflare Anycast range used by Discord Voice. Only UDP voice traffic (ports 50000-65535, 3478, 19294-19344, 5000-5020) is routed through this section; web traffic (TCP 80/443) goes direct."
msgstr "Подсеть %s — диапазон Cloudflare Anycast, используемый Discord Voice. Только голосовой UDP-трафик (порты 50000-65535, 3478, 19294-19344, 5000-5020) направляется через этот раздел; веб-трафик (TCP 80/443) идет напрямую."
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, также будут направляться через эту секцию."
+3 -3
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: TACHYON\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-15 12:05+0300\n"
"PO-Revision-Date: 2026-09-15 12:05+0300\n"
"POT-Creation-Date: 2026-09-15 12:34+0300\n"
"PO-Revision-Date: 2026-09-15 12:34+0300\n"
"Last-Translator: Dushnilin <dushnilin@gmail.com>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
@@ -7810,7 +7810,7 @@ msgid "Subnet %s belongs to a shared Anycast/CDN range (%s). Third-party website
msgstr ""
#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:11896
msgid "Subnet %s is a Cloudflare Anycast range used by Discord Voice. Only UDP voice traffic (ports 50000-65535, 3478, 19302, 5000-5020) is routed through this section; web traffic (TCP 80/443) goes direct."
msgid "Subnet %s is a Cloudflare Anycast range used by Discord Voice. Only UDP voice traffic (ports 50000-65535, 3478, 19294-19344, 5000-5020) is routed through this section; web traffic (TCP 80/443) goes direct."
msgstr ""
#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:11387
+2 -2
View File
@@ -55,9 +55,9 @@ config settings 'settings'
option game_console_optimizer '0'
list game_console_ips ''
# smart_detect: auto-detects blocked domains from logs and adds them to the first
# section in smart_detect_sections that works via proxy.
# list smart_detect_sections 'zapret-section'
# remote proxy section in smart_detect_sections that works via proxy.
# list smart_detect_sections 'main-proxy'
# list smart_detect_sections 'backup-proxy'
# Ordered: first section where the domain works via proxy wins.
# ─── AI Watchdog settings ─────────────────────────────────────────────
+2 -1
View File
@@ -329,7 +329,8 @@ function is_remote_proxy_action(action) {
return is_connections_action(action) ||
action == "awg" || action == "warp" ||
action == "anytls" || action == "snell" || action == "mieru" || action == "sudoku" ||
action == "masque" || action == "openvpn";
action == "masque" || action == "openvpn" ||
action == "wdtt" || action == "olcrtc" || action == "fptn";
}
function is_wdtt_action(action) {
+3 -3
View File
@@ -272,9 +272,9 @@ function is_cloudflare_shared_cidr(value) {
return false;
}
const DISCORD_VOICE_PORTS_NFT = "5000-5020, 3478, 19302, 50000-65535";
const DISCORD_VOICE_PORT_RANGES = [ "5000:5020", "3478:3478", "19302:19302", "50000:65535" ];
const DEFAULT_DISCORD_VOICE_SUBNETS = [ "162.158.0.0/15", "172.64.0.0/13", "2606:4700::/32" ];
const DISCORD_VOICE_PORTS_NFT = "5000-5020, 3478, 19294-19344, 50000-65535";
const DISCORD_VOICE_PORT_RANGES = [ "5000:5020", "3478:3478", "19294:19344", "50000:65535" ];
const DEFAULT_DISCORD_VOICE_SUBNETS = [ "104.16.0.0/12", "162.158.0.0/15", "172.64.0.0/13", "2606:4700::/32" ];
const DISCORD_DEDICATED_SUBNETS = [ "162.159.128.0/21" ];
return {
+3 -3
View File
@@ -2046,7 +2046,7 @@ function nft_community_subnet_lines(path, service, filter_mode) {
if (data == null) {
if (as_string(service) == "discord") {
if (filter_mode == "only_cloudflare")
return core_ip.DEFAULT_DISCORD_VOICE_SUBNETS || [ "162.158.0.0/15", "172.64.0.0/13", "2606:4700::/32" ];
return core_ip.DEFAULT_DISCORD_VOICE_SUBNETS || [ "104.16.0.0/12", "162.158.0.0/15", "172.64.0.0/13", "2606:4700::/32" ];
if (filter_mode == "exclude_cloudflare" || filter_mode == null)
return core_ip.DISCORD_DEDICATED_SUBNETS || [ "162.159.128.0/21" ];
}
@@ -2073,7 +2073,7 @@ function nft_community_subnet_lines(path, service, filter_mode) {
if (as_string(service) == "discord") {
if (filter_mode == "only_cloudflare" && length(result) == 0)
return core_ip.DEFAULT_DISCORD_VOICE_SUBNETS || [ "162.158.0.0/15", "172.64.0.0/13", "2606:4700::/32" ];
return core_ip.DEFAULT_DISCORD_VOICE_SUBNETS || [ "104.16.0.0/12", "162.158.0.0/15", "172.64.0.0/13", "2606:4700::/32" ];
if ((filter_mode == "exclude_cloudflare" || filter_mode == null) && length(result) == 0)
return core_ip.DISCORD_DEDICATED_SUBNETS || [ "162.159.128.0/21" ];
}
@@ -2098,7 +2098,7 @@ function nft_add_community_subnet_file_to_family_sets(path, table, ipv4_set, ipv
if (as_string(service) == "discord" && ip_ports_v4) {
let cf = nft_community_subnet_lines(path, service, "only_cloudflare");
if (length(cf) > 0) {
let voice_ports = core_ip.DISCORD_VOICE_PORTS_NFT || "5000-5020, 3478, 19302, 50000-65535";
let voice_ports = core_ip.DISCORD_VOICE_PORTS_NFT || "5000-5020, 3478, 19294-19344, 50000-65535";
let cf_ok = nft_add_values_to_family_sets(cf, table, ip_ports_v4, ip_ports_v6, "ip-port-from-ip", voice_ports, chunk_size_text);
ok = ok && cf_ok;
}
+21 -10
View File
@@ -230,18 +230,17 @@ function check_auto_resume_pause() {
// ─── Smart Detect — self-healing routing ─────────────────────────────────────
function smart_detect_get_proxy_sections() {
let c = uci_core.cursor();
if (!c) return [];
c.load(CONFIG_NAME);
let sections = uci_core.section_objects(CONFIG_NAME, "section");
if (!sections || length(sections) == 0) return [];
let connections = require("config.connections");
let secs = [];
c.foreach(CONFIG_NAME, "section", function(s) {
if (s.enabled != "1") return;
for (let s in sections) {
if (s.enabled != "1") continue;
let act = as_string(s.action || "");
if (act != "bypass" && act != "block" && act != "dns" && act != "") {
if (connections.is_remote_proxy_action(act)) {
push(secs, s[".name"]);
}
});
}
return secs;
}
@@ -1488,12 +1487,19 @@ function smart_detect_process_pending() {
// No http/mixed inbound in the generated config: probing is pointless.
if (proxy_addr == "127.0.0.1:") return;
let valid_proxy_map = {};
for (let s in sections) valid_proxy_map[s] = true;
let detect_sections = [];
let raw_list = cfg.smart_detect_sections;
if (type(raw_list) == "array") {
detect_sections = raw_list;
for (let item in raw_list) {
let name = trim(as_string(item));
if (valid_proxy_map[name]) push(detect_sections, name);
}
} else if (raw_list && trim(as_string(raw_list)) != "") {
detect_sections = [ trim(as_string(raw_list)) ];
let name = trim(as_string(raw_list));
if (valid_proxy_map[name]) detect_sections = [ name ];
}
if (length(detect_sections) == 0) {
detect_sections = sections;
@@ -2276,7 +2282,12 @@ else if (mode == "smart-detect-extract-domain") {
print(extracted + "\n");
exit(0);
}
else if (mode == "smart-detect-proxy-sections") {
let secs = smart_detect_get_proxy_sections();
print(join(" ", secs) + "\n");
exit(0);
}
else {
warn("Usage: service/watchdog.uc <start-runtime|stop-runtime|worker|status|ai-heal|ai-status|ai-status-full|smart-detect-extract-domain> ...\n");
warn("Usage: service/watchdog.uc <start-runtime|stop-runtime|worker|status|ai-heal|ai-status|ai-status-full|smart-detect-extract-domain|smart-detect-proxy-sections> ...\n");
exit(1);
}
@@ -243,9 +243,67 @@ function regex_match_set(tags, names, regexes) {
return object_keys_set(runtime_urltest.regex_matching_tag_array(tags, names, regexes));
}
function tag_name_filter_matches(tag, names, name_filter, regex_set) {
function format_outbound_type_label(protocol, transport) {
let p = lc(as_string(protocol || ""));
let norm_proto = "";
if (p == "vless") norm_proto = "VLESS";
else if (p == "vmess") norm_proto = "VMess";
else if (p == "shadowsocks" || p == "ss") norm_proto = "Shadowsocks";
else if (p == "trojan") norm_proto = "Trojan";
else if (p == "wireguard" || p == "wg") norm_proto = "WireGuard";
else if (p == "hysteria2" || p == "hy2") norm_proto = "Hysteria2";
else if (p == "hysteria") norm_proto = "Hysteria";
else if (p == "tuic") norm_proto = "TUIC";
else if (p == "socks" || p == "socks5") norm_proto = "SOCKS5";
else if (p == "http") norm_proto = "HTTP";
else if (p == "direct") norm_proto = "Direct";
else if (p == "block") norm_proto = "Block";
else if (p != "") norm_proto = uc(substr(p, 0, 1)) + substr(p, 1);
let t = lc(as_string(transport || ""));
let norm_trans = "";
if (t == "xhttp") norm_trans = "XHTTP";
else if (t == "ws" || t == "websocket") norm_trans = "WS";
else if (t == "grpc") norm_trans = "gRPC";
else if (t == "http" || t == "h2") norm_trans = "HTTP";
else if (t == "tcp" || t == "raw") norm_trans = "TCP";
else if (t == "quic") norm_trans = "QUIC";
else if (t == "upgrade" || t == "httpupgrade") norm_trans = "HTTPUpgrade";
else if (t != "") norm_trans = uc(t);
if (norm_proto == "" && norm_trans == "")
return "";
if (norm_proto == "")
return norm_trans;
if (norm_trans == "")
return norm_proto;
if (index(uc(norm_proto), uc(norm_trans)) >= 0)
return norm_proto;
if (norm_proto == "WireGuard" || norm_proto == "Hysteria" || norm_proto == "Hysteria2" || norm_proto == "TUIC")
return norm_proto;
return norm_proto + " (" + norm_trans + ")";
}
function tag_display_name_with_type(tag, names, metadata) {
let name = tag_display_name(tag, names);
return array_contains(name_filter, name) || array_contains(name_filter, tag) || regex_set[tag];
let proto = object_or_empty(object_or_empty(metadata).protocols)[tag];
let trans = object_or_empty(object_or_empty(metadata).transports)[tag];
let label = format_outbound_type_label(proto, trans);
return label != "" ? (name + " [" + label + "]") : name;
}
function tag_name_filter_matches(tag, names, name_filter, regex_set, metadata, name_with_type_indexed) {
let name = tag_display_name(tag, names);
if (array_contains(name_filter, name) || array_contains(name_filter, tag) || regex_set[tag])
return true;
if (name_with_type_indexed != null && array_contains(name_filter, name_with_type_indexed))
return true;
if (name_with_type_indexed == null && type(metadata) == "object") {
let name_with_type = tag_display_name_with_type(tag, names, metadata);
if (name_with_type != name && array_contains(name_filter, name_with_type))
return true;
}
return false;
}
function tag_country_filter_matches(tag, countries, country_filter) {
@@ -305,8 +363,14 @@ function urltest_matching_candidate_outbounds(urltest_candidate_tags, names, cou
let additional_set = object_keys_set(additional_matches);
let result = [];
let seen_type_counts = {};
for (let tag in array_or_empty(urltest_candidate_tags)) {
let base_matches = tag_name_filter_matches(tag, names, name_filter, regex_set) ||
let name_with_type = tag_display_name_with_type(tag, names, metadata);
seen_type_counts[name_with_type] = (seen_type_counts[name_with_type] || 0) + 1;
let count = seen_type_counts[name_with_type];
let name_with_type_indexed = count > 1 ? (name_with_type + " (#" + count + ")") : name_with_type;
let base_matches = tag_name_filter_matches(tag, names, name_filter, regex_set, metadata, name_with_type_indexed) ||
tag_country_filter_matches(tag, countries, country_filter);
let matches = additional_set[tag] || base_matches;
if (proxy_parameters_enabled && proxy_parameters_operator == "or") {
@@ -1513,7 +1577,7 @@ function load_community_subnet_cidrs(community, filter_mode) {
if (service == "discord") {
if (filter_mode == "only_cloudflare" && length(cidrs) == 0)
return core_ip.DEFAULT_DISCORD_VOICE_SUBNETS || [ "162.158.0.0/15", "172.64.0.0/13", "2606:4700::/32" ];
return core_ip.DEFAULT_DISCORD_VOICE_SUBNETS || [ "104.16.0.0/12", "162.158.0.0/15", "172.64.0.0/13", "2606:4700::/32" ];
if ((filter_mode == "exclude_cloudflare" || filter_mode == null) && length(cidrs) == 0)
return core_ip.DISCORD_DEDICATED_SUBNETS || [ "162.159.128.0/21" ];
}
@@ -1642,7 +1706,7 @@ function add_combined_route_for_section(config, section) {
let voice_rule = create_section_route_rule();
voice_rule.network = "udp";
voice_rule.ip_cidr = discord_cf_subnets;
voice_rule.port_range = core_ip.DISCORD_VOICE_PORT_RANGES || [ "5000:5020", "3478:3478", "19302:19302", "50000:65535" ];
voice_rule.port_range = core_ip.DISCORD_VOICE_PORT_RANGES || [ "5000:5020", "3478:3478", "19294:19344", "50000:65535" ];
push_section_route_rule(config, voice_rule, target.outbound);
}
+17 -1
View File
@@ -131,6 +131,14 @@ for (let r in config.route.rules) {
warn("Voice UDP rule missing port_range restriction!\n");
exit(3);
}
let has_cf_voice_ports = false;
for (let p in r.port_range) {
if (p == "19294:19344") has_cf_voice_ports = true;
}
if (!has_cf_voice_ports) {
warn("Voice UDP rule missing 19294:19344 port range!\n");
exit(6);
}
found_voice_udp = true;
}
}
@@ -160,10 +168,18 @@ rm -f /tmp/sing-box/rulesets/community-subnets-discord.lst /tmp/sing-box/ruleset
ucode -L "$LIB_DIR" -e '
let generator_routes = require("singbox.generator_routes");
let fallback_cf = generator_routes.load_community_subnet_cidrs("discord", "only_cloudflare");
if (!fallback_cf || length(fallback_cf) < 3) {
if (!fallback_cf || length(fallback_cf) != 4) {
warn("Fallback voice subnets failed: " + length(fallback_cf) + "\n");
exit(1);
}
let has_104 = false;
for (let c in fallback_cf) {
if (c == "104.16.0.0/12") has_104 = true;
}
if (!has_104) {
warn("Fallback voice subnets missing 104.16.0.0/12\n");
exit(2);
}
' || fail "singbox.generator_routes fallback voice subnets failed"
# 5. Test core.ip against discord file lines
+100
View File
@@ -278,4 +278,104 @@ if (selector.default != "proxy-urltest-ut_auto-out")
fail("selector default must be the urltest group");
' "$WORK_DIR/issue89-config.json" || fail "issue89 cascade exclusion regression"
mkdir -p "$WORK_DIR/sub_test/subscriptions"
cat >"$WORK_DIR/sub_test/subscriptions/proxy-subscription-1.json" <<'JSON'
{
"outbounds": [
{ "type": "vless", "tag": "dk1", "remark": "🇩🇰 Denmark ⚡", "server": "127.0.0.1", "server_port": 1101, "uuid": "00000000-0000-4000-8000-000000000001", "transport": { "type": "tcp" } },
{ "type": "vless", "tag": "dk2", "remark": "🇩🇰 Denmark ⚡", "server": "127.0.0.1", "server_port": 1102, "uuid": "00000000-0000-4000-8000-000000000002", "transport": { "type": "xhttp" } },
{ "type": "vless", "tag": "dk3", "remark": "🇩🇰 Denmark ⚡", "server": "127.0.0.1", "server_port": 1103, "uuid": "00000000-0000-4000-8000-000000000003", "transport": { "type": "tcp" } }
]
}
JSON
printf '%s\n' 'https://dup.example/sub' >"$WORK_DIR/sub_test/subscriptions/proxy-subscription-1.url"
: >"$WORK_DIR/sub_test/subscriptions/proxy-subscription-1.user_agent"
cat >"$WORK_DIR/duplicate-names.json" <<'JSON'
{
"settings": {
".name": "settings",
".type": "settings"
},
"section": [
{
".name": "proxy",
".type": "section",
"enabled": "1",
"action": "connection",
"subscription_urls": [ "https://dup.example/sub" ]
}
],
"urltest": [
{
".name": "ut_xhttp_only",
".type": "urltest",
"section": "proxy",
"name": "XHTTP only",
"filter_mode": "include",
"include_outbounds": [ "🇩🇰 Denmark ⚡ [VLESS (XHTTP)]" ]
},
{
".name": "ut_tcp_only",
".type": "urltest",
"section": "proxy",
"name": "TCP only",
"filter_mode": "include",
"include_outbounds": [ "🇩🇰 Denmark ⚡ [VLESS (TCP)]" ]
},
{
".name": "ut_tcp_second",
".type": "urltest",
"section": "proxy",
"name": "TCP #2 only",
"filter_mode": "include",
"include_outbounds": [ "🇩🇰 Denmark ⚡ [VLESS (TCP)] (#2)" ]
},
{
".name": "ut_both_name",
".type": "urltest",
"section": "proxy",
"name": "Both by plain name",
"filter_mode": "include",
"include_outbounds": [ "🇩🇰 Denmark ⚡" ]
}
]
}
JSON
TMP_SUBSCRIPTION_FOLDER="$WORK_DIR/sub_test/subscriptions" \
generate_config "$WORK_DIR/duplicate-names.json" "$WORK_DIR/duplicate-names-config.json"
ucode -e '
let fs = require("fs");
function fail(msg) { die(msg + "\n"); }
function outbound_by_tag(cfg, tag) {
for (let o in cfg.outbounds || [])
if (o && o.tag == tag) return o;
return null;
}
function assert_array(val, exp, label) {
val = val || [];
if (length(val) != length(exp)) fail(label + " len mismatch: " + sprintf("%J", val) + " vs " + sprintf("%J", exp));
for (let i = 0; i < length(exp); i++)
if (val[i] != exp[i]) fail(label + " mismatch: " + sprintf("%J", val));
}
let cfg = json(fs.readfile(ARGV[0]));
let ut_xhttp = outbound_by_tag(cfg, "proxy-urltest-ut_xhttp_only-out");
let ut_tcp = outbound_by_tag(cfg, "proxy-urltest-ut_tcp_only-out");
let ut_tcp2 = outbound_by_tag(cfg, "proxy-urltest-ut_tcp_second-out");
let ut_both = outbound_by_tag(cfg, "proxy-urltest-ut_both_name-out");
if (!ut_xhttp) fail("missing ut_xhttp");
if (!ut_tcp) fail("missing ut_tcp");
if (!ut_tcp2) fail("missing ut_tcp2");
if (!ut_both) fail("missing ut_both");
assert_array(ut_xhttp.outbounds, [ "dk2" ], "XHTTP only must select only the XHTTP node");
assert_array(ut_tcp.outbounds, [ "dk1" ], "TCP only must select only the TCP node");
assert_array(ut_tcp2.outbounds, [ "dk3" ], "TCP #2 only must select only the second TCP node");
assert_array(ut_both.outbounds, [ "dk1", "dk2", "dk3" ], "Plain name must select all three nodes");
' "$WORK_DIR/duplicate-names-config.json" || fail "duplicate outbound name disambiguation failed"
printf 'dashboard server filter checks passed\n'
+40
View File
@@ -88,4 +88,44 @@ do
done
rm -f "$SENTINEL"
# --- smart_detect_get_proxy_sections filtering ---
# Issue #56: zapret, zapret2, byedpi, bypass, block, dns must NEVER be returned as candidate proxy sections.
# Only enabled remote proxy sections (connection, awg, warp, etc.) should be returned.
TMP_DIR="$(mktemp -d /tmp/tachyon_smart_detect_test.XXXXXX)"
trap 'rm -rf "$TMP_DIR"' EXIT
UCI_STATE_TEST="$TMP_DIR/uci.state"
cat > "$UCI_STATE_TEST" << 'EOF'
tachyon.sec_bypass=section
tachyon.sec_bypass.enabled=1
tachyon.sec_bypass.action=bypass
tachyon.sec_zapret=section
tachyon.sec_zapret.enabled=1
tachyon.sec_zapret.action=zapret
tachyon.sec_zapret2=section
tachyon.sec_zapret2.enabled=1
tachyon.sec_zapret2.action=zapret2
tachyon.sec_byedpi=section
tachyon.sec_byedpi.enabled=1
tachyon.sec_byedpi.action=byedpi
tachyon.sec_block=section
tachyon.sec_block.enabled=1
tachyon.sec_block.action=block
tachyon.sec_dns=section
tachyon.sec_dns.enabled=1
tachyon.sec_dns.action=dns
tachyon.sec_proxy=section
tachyon.sec_proxy.enabled=1
tachyon.sec_proxy.action=connection
tachyon.sec_awg=section
tachyon.sec_awg.enabled=1
tachyon.sec_awg.action=awg
tachyon.sec_disabled=section
tachyon.sec_disabled.enabled=0
tachyon.sec_disabled.action=connection
EOF
proxy_secs="$(UCI_STATE="$UCI_STATE_TEST" ucode "$WATCHDOG_UC" smart-detect-proxy-sections 2>/dev/null || true)"
[ "$proxy_secs" = "sec_proxy sec_awg" ] || fail "smart_detect_get_proxy_sections: expected 'sec_proxy sec_awg', got '$proxy_secs'"
printf 'smart_detect_domain_extraction checks passed\n'