From 4b7faf18a858026dec8e886a05f84a1415397787 Mon Sep 17 00:00:00 2001 From: Dushnilin Date: Sun, 13 Sep 2026 11:06:28 +0300 Subject: [PATCH] fix(discord): restore voice UDP routing on Cloudflare Anycast while isolating web traffic - Preserve raw Cloudflare subnets in community-subnets-discord.lst in updates.uc instead of destructive deletion. - In singbox/generator_routes.uc: * Exclude Cloudflare Anycast CIDRs from general IP route rules (preventing web hijacking). * Generate a dedicated Discord Voice route rule for Cloudflare subnets scoped to network: 'udp' and voice port ranges (50000-65535, 3478, 19302, 5000-5020). * Provide DEFAULT_DISCORD_VOICE_SUBNETS fallback in core/ip.uc. - In nft/apply.uc: * Route Cloudflare subnets to ip_ports set with Discord voice ports (5000-5020, 3478, 19302, 50000-65535). * Automatically enable ip_port rules when section has community_subnets for Discord. - In section.js (Routing Rule Tracer): * Filter out Cloudflare subnets when resolving domain web traffic to Discord community subnets. * Accurately display voice UDP routing notice for Anycast ranges. - Update tests/community_subnets_sanitization.sh with voice UDP and sing-box validation. --- .../resources/view/tachyon/section.js | 29 ++++- tachyon/files/usr/lib/components/updates.uc | 16 +-- tachyon/files/usr/lib/core/ip.uc | 9 +- tachyon/files/usr/lib/nft/apply.uc | 59 +++++++--- .../files/usr/lib/singbox/generator_routes.uc | 39 +++++-- tests/community_subnets_sanitization.sh | 107 +++++++++++++++++- 6 files changed, 215 insertions(+), 44 deletions(-) diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js index 78fdc224..608af432 100644 --- a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js @@ -11394,6 +11394,19 @@ function getSharedCdnWarning(cidr) { return null; } +function isCloudflareSharedCidr(cidr) { + if (!cidr) return false; + const normalized = cidr.trim(); + for (const cfCidr of CLOUDFLARE_SHARED_CIDRS) { + if (normalized === cfCidr) return true; + } + const ipOnly = normalized.split("/")[0]; + for (const cfCidr of CLOUDFLARE_SHARED_CIDRS) { + if (ipMatchesCidr(ipOnly, cfCidr)) return true; + } + return false; +} + let _subnetListCache = {}; async function readSubnetListFile(service) { @@ -11861,17 +11874,24 @@ async function performTrace(query) { const subnets = await readSubnetListFile(community); const matchedCidr = matchIpInCidrs(queryForMatching, subnets); if (matchedCidr) { + const isDiscordCfVoice = community === "discord" && isCloudflareSharedCidr(matchedCidr); return { matched: true, sectionName: secName, label: label, action: action, - ruleType: "Community Subnets (" + community + ")", + ruleType: isDiscordCfVoice + ? "Community Subnets (" + community + " Voice UDP)" + : "Community Subnets (" + community + ")", pattern: matchedCidr, matchedIp: queryForMatching, priority: i + 1, totalSections: totalSections, - sharedCdnWarning: getSharedCdnWarning(matchedCidr), + 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.", + ).format(matchedCidr) + : getSharedCdnWarning(matchedCidr), }; } if ( @@ -12105,7 +12125,10 @@ async function performTrace(query) { } for (const community of communityLists) { - const subnets = await readSubnetListFile(community); + let subnets = await readSubnetListFile(community); + if (community === "discord") { + subnets = subnets.filter((s) => !isCloudflareSharedCidr(s)); + } const matchedCommunityCidr = matchIpInCidrs(ip, subnets); if (matchedCommunityCidr) { return { diff --git a/tachyon/files/usr/lib/components/updates.uc b/tachyon/files/usr/lib/components/updates.uc index 75ddf09e..caa0ca8a 100644 --- a/tachyon/files/usr/lib/components/updates.uc +++ b/tachyon/files/usr/lib/components/updates.uc @@ -2519,15 +2519,11 @@ 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 (as_string(service) == "discord" && core_ip && core_ip.is_cloudflare_shared_cidr(l)) - continue; + if (l != "" && substr(l, 0, 1) != "#") push(combined_lines, l); - } } if (downloaded) @@ -2589,20 +2585,12 @@ 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 (as_string(service) == "discord" && core_ip && core_ip.is_cloudflare_shared_cidr(l)) - continue; + if (l != "" && substr(l, 0, 1) != "#") 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", diff --git a/tachyon/files/usr/lib/core/ip.uc b/tachyon/files/usr/lib/core/ip.uc index 27caf890..db4f6ac6 100644 --- a/tachyon/files/usr/lib/core/ip.uc +++ b/tachyon/files/usr/lib/core/ip.uc @@ -272,6 +272,10 @@ 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" ]; + return { valid_ipv4, valid_ipv4_cidr, @@ -288,6 +292,9 @@ return { resolve_mac_to_ips, normalize_to_cidrs, CLOUDFLARE_SHARED_CIDRS, - is_cloudflare_shared_cidr + is_cloudflare_shared_cidr, + DISCORD_VOICE_PORTS_NFT, + DISCORD_VOICE_PORT_RANGES, + DEFAULT_DISCORD_VOICE_SUBNETS }; diff --git a/tachyon/files/usr/lib/nft/apply.uc b/tachyon/files/usr/lib/nft/apply.uc index 5276f5c1..a798a742 100644 --- a/tachyon/files/usr/lib/nft/apply.uc +++ b/tachyon/files/usr/lib/nft/apply.uc @@ -659,6 +659,12 @@ function section_priority_needs_plain_ip_rules(section) { } function section_priority_needs_ip_port_rules(section) { + if (bool_option(section, "community_subnets", true)) { + for (let community in connections.community_lists(section)) { + if (as_string(community) == "discord") + return true; + } + } return section_has_nft_ip_matchers(section) && (section_rule_ports_csv(section) != "" || length(connections.rule_sets_with_subnets(section)) > 0); } @@ -2020,21 +2026,35 @@ 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) { +function nft_community_subnet_lines(path, service, filter_mode) { let data = fs.readfile(path); - if (data == null) + if (data == null) { + if (as_string(service) == "discord" && 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 []; + } 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); + let is_cf = core_ip.is_cloudflare_shared_cidr(line); + if (filter_mode == "only_cloudflare") { + if (as_string(service) == "discord" && is_cf) + push(result, line); + } else if (filter_mode == "exclude_cloudflare" || filter_mode == null) { + if (as_string(service) == "discord" && is_cf) + continue; + push(result, line); + } else { + push(result, line); + } } + if (as_string(service) == "discord" && 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 result; } @@ -2046,11 +2066,22 @@ function nft_add_values_to_family_sets(values, table, ipv4_set, ipv6_set, kind, 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_community_subnet_file_to_family_sets(path, table, ipv4_set, ipv6_set, service, chunk_size_text, ip_ports_v4, ip_ports_v6) { + let non_cf = nft_community_subnet_lines(path, service, "exclude_cloudflare"); + let ok = true; + if (length(non_cf) > 0) + ok = nft_add_values_to_family_sets(non_cf, table, ipv4_set, ipv6_set, "ips", "", chunk_size_text); + + 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 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; + } + } + + return ok; } function nft_add_inline_ip_cidr_matchers(csv, ports_csv, table, common_set, ip_port_set, chunk_size_text, common6_set, ip_port6_set) { @@ -2706,7 +2737,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_community_subnet_file_to_family_sets(path, table, sets.subnets, sets.subnets6, service, "5000"); + nft_add_community_subnet_file_to_family_sets(path, table, sets.subnets, sets.subnets6, service, "5000", sets.ip_ports, sets.ip6_ports); break; } } @@ -2791,13 +2822,15 @@ function nft_add_community_subnet_file_for_section(section, service, filepath, t 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"); + let ip_port_v4 = section_needs_priority_sets(section) ? sets.ip_ports : ip_port_set; + let ip_port_v6 = section_needs_priority_sets(section) ? sets.ip6_ports : default_arg(ip_port6_set, "tachyon_ip6_ports"); if (section_needs_priority_sets(section) && ports != "" && !section_has_destination_matchers(section)) { - let lines = nft_community_subnet_lines(filepath, service); + let lines = nft_community_subnet_lines(filepath, service, "all"); 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); + return nft_add_community_subnet_file_to_family_sets(filepath, table, common_v4, common_v6, service, chunk_size_text, ip_port_v4, ip_port_v6); } 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) { diff --git a/tachyon/files/usr/lib/singbox/generator_routes.uc b/tachyon/files/usr/lib/singbox/generator_routes.uc index 3a522952..a6509199 100644 --- a/tachyon/files/usr/lib/singbox/generator_routes.uc +++ b/tachyon/files/usr/lib/singbox/generator_routes.uc @@ -1467,29 +1467,41 @@ function add_protocol_matchers(rule, section) { } -function load_community_subnet_cidrs(community) { +function load_community_subnet_cidrs(community, filter_mode) { let service = as_string(community); let paths = [ "/tmp/sing-box/rulesets/community-subnets-" + service + ".lst", "/etc/tachyon/rulesets/community-subnets-" + service + ".lst", (ctx.runtime_ruleset_folder || runtime_ruleset_folder || "/tmp/sing-box/rulesets") + "/community-subnets-" + service + ".lst" ]; + let cidrs = []; for (let path in paths) { let content = fs.readfile(path); if (content != null && content != "") { - 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 (service == "discord" && core_ip.is_cloudflare_shared_cidr(line)) - continue; - push(cidrs, line); + let is_cf = core_ip.is_cloudflare_shared_cidr(line); + if (filter_mode == "only_cloudflare") { + if (service == "discord" && is_cf) + push(cidrs, line); + } else if (filter_mode == "exclude_cloudflare" || filter_mode == null) { + if (service == "discord" && is_cf) + continue; + push(cidrs, line); + } else { + push(cidrs, line); + } } } if (length(cidrs) > 0) return cidrs; } } + + if (service == "discord" && 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 []; } @@ -1511,14 +1523,19 @@ function add_combined_route_for_section(config, section) { let include_community_subnets = bool_option(section, "community_subnets", true); + let discord_cf_subnets = []; for (let community in connections.community_lists(section)) { let service = as_string(community); let ensured = ensure_community_ruleset(config, section_name, service); push(rule_set_tags, ensured.tag); push(dns_rule_set_tags, ensured.tag); if (include_community_subnets) { - for (let cidr in load_community_subnet_cidrs(community)) + for (let cidr in load_community_subnet_cidrs(community, "exclude_cloudflare")) push(ip_cidr, cidr); + if (service == "discord") { + for (let cidr in load_community_subnet_cidrs(community, "only_cloudflare")) + push(discord_cf_subnets, cidr); + } } } for (let reference in connections.rule_sets(section)) { @@ -1605,6 +1622,14 @@ function add_combined_route_for_section(config, section) { push_section_route_rule(config, ip_rule, target.outbound); } + if (length(discord_cf_subnets) > 0) { + 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" ]; + push_section_route_rule(config, voice_rule, target.outbound); + } + let country_list = connections.geoip_country_list(section); let country_mode = connections.geoip_country_mode(section); @@ -1632,7 +1657,7 @@ function add_combined_route_for_section(config, section) { } } - if (!has_domain && !has_ruleset && !has_ip_cidr && length(country_list) == 0) { + if (!has_domain && !has_ruleset && !has_ip_cidr && length(discord_cf_subnets) == 0 && length(country_list) == 0) { let fallback_rule = create_section_route_rule(); let has_any_matcher = fallback_rule.source_ip_cidr != null || fallback_rule.port != null || fallback_rule.port_range != null || diff --git a/tests/community_subnets_sanitization.sh b/tests/community_subnets_sanitization.sh index 428712cb..6e56f4ce 100644 --- a/tests/community_subnets_sanitization.sh +++ b/tests/community_subnets_sanitization.sh @@ -43,7 +43,7 @@ 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 +# 2. Test singbox.generator_routes load_community_subnet_cidrs filtering and voice extraction # 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 @@ -58,7 +58,7 @@ if (!discord_cidrs || length(discord_cidrs) != 2) { } 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"); + warn("Found Cloudflare CIDR in general discord_cidrs: " + c + "\n"); exit(2); } } @@ -67,34 +67,129 @@ if (discord_cidrs[0] != "66.22.196.0/22" || discord_cidrs[1] != "162.159.128.0/2 exit(3); } +// Ensure load_community_subnet_cidrs("discord", "only_cloudflare") extracts the 4 Cloudflare CIDRs for voice +let discord_cf = generator_routes.load_community_subnet_cidrs("discord", "only_cloudflare"); +if (length(discord_cf) != 4) { + warn("Expected 4 Cloudflare voice subnets, got: " + length(discord_cf) + "\n"); + exit(4); +} + // 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); +if (length(other_cidrs) != 2) exit(5); ' || fail "singbox.generator_routes load_community_subnet_cidrs filtering failed" +# 3. Test sing-box route generation for Discord section +# Keep the test community-subnets-discord.lst present +cp "$DISCORD_LST" /tmp/sing-box/rulesets/community-subnets-discord.lst + +ucode -L "$LIB_DIR" -e ' +let fs = require("fs"); +let generator_routes = require("singbox.generator_routes"); +generator_routes.init({ + runtime_settings: () => ({}), + runtime_ruleset_folder: "/tmp/sing-box/rulesets", + download_detour_tag: (settings, kind) => "direct-out", + runtime_generate_unsupported: (msg) => { warn(msg + "\n"); exit(1); } +}); + +let config = { + route: { rules: [] }, + dns: { rules: [] }, + outbounds: [ + { type: "direct", tag: "direct-out" }, + { type: "direct", tag: "sec_discord-out" } + ] +}; +let section = { + ".name": "sec_discord", + "action": "outbound", + "outbound": "direct-out", + "community_lists": [ "discord" ], + "community_subnets": "1" +}; + +generator_routes.add_combined_route_for_section(config, section); + +let found_general_ip = false; +let found_voice_udp = false; + +for (let r in config.route.rules) { + if (r.ip_cidr && !r.network) { + // General IP rule must only contain non-Cloudflare CIDRs + for (let c in r.ip_cidr) { + if (c == "104.16.0.0/12" || c == "172.64.0.0/13" || c == "162.158.0.0/15" || c == "2606:4700::/32") { + warn("Cloudflare CIDR leaked into general IP rule: " + c + "\n"); + exit(2); + } + } + found_general_ip = true; + } + if (r.ip_cidr && r.network == "udp") { + // Voice rule must have port_range restricting to voice ports + if (!r.port_range || length(r.port_range) == 0) { + warn("Voice UDP rule missing port_range restriction!\n"); + exit(3); + } + found_voice_udp = true; + } +} + +if (!found_general_ip) { + warn("Missing general IP route rule for Discord\n"); + exit(4); +} +if (!found_voice_udp) { + warn("Missing dedicated UDP voice route rule for Discord Cloudflare Anycast subnets\n"); + exit(5); +} + +fs.writefile("/tmp/test_discord_singbox.json", sprintf("%J", config)); +' || fail "sing-box Discord route generation verification failed" + +# Verify sing-box parses the generated route rules without error +sing-box check -c /tmp/test_discord_singbox.json || fail "sing-box check failed on generated Discord voice rules" +rm -f /tmp/test_discord_singbox.json + # 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 +# 4. Test fallback to DEFAULT_DISCORD_VOICE_SUBNETS when ruleset file is missing / empty +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) { + warn("Fallback voice subnets failed: " + length(fallback_cf) + "\n"); + exit(1); +} +' || fail "singbox.generator_routes fallback voice subnets failed" + +# 5. 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 = []; +let cf_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; + if (core_ip.is_cloudflare_shared_cidr(line)) { + push(cf_result, 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); +if (length(cf_result) != 4) exit(3); ' || fail "community subnet lines filtering failed" -# 4. Test diagnostics/runtime.uc resolve-domain mode +# 6. 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' +