From 44a1777c0dbb1aa3cd6505f85d02919d6960cf09 Mon Sep 17 00:00:00 2001 From: Dushnilin Date: Thu, 17 Sep 2026 08:13:11 +0300 Subject: [PATCH] fix(singbox): separate dns query and response rulesets for sing-box 1.14+ compatibility --- fe-app-tachyon/locales/tachyon.pot | 4 +- fe-app-tachyon/locales/tachyon.ru.po | 4 +- luci-app-tachyon/po/ru/tachyon.po | 4 +- luci-app-tachyon/po/templates/tachyon.pot | 4 +- tachyon/files/usr/lib/config/connections.uc | 1 + tachyon/files/usr/lib/singbox/generator.uc | 1 + .../files/usr/lib/singbox/generator_routes.uc | 153 +++++-- tachyon/files/usr/lib/singbox/rulesets.uc | 69 ++- tests/singbox_114_dns_rules.sh | 414 ++++++++++++++++++ tests/singbox_rulesets.sh | 22 + 10 files changed, 636 insertions(+), 40 deletions(-) create mode 100644 tests/singbox_114_dns_rules.sh diff --git a/fe-app-tachyon/locales/tachyon.pot b/fe-app-tachyon/locales/tachyon.pot index 1df1a0f0..e7366820 100644 --- a/fe-app-tachyon/locales/tachyon.pot +++ b/fe-app-tachyon/locales/tachyon.pot @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: TACHYON\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-16 19:54+0300\n" -"PO-Revision-Date: 2026-09-16 19:54+0300\n" +"POT-Creation-Date: 2026-09-17 05:13+0300\n" +"PO-Revision-Date: 2026-09-17 05:13+0300\n" "Last-Translator: Dushnilin \n" "Language-Team: LANGUAGE \n" "Language: \n" diff --git a/fe-app-tachyon/locales/tachyon.ru.po b/fe-app-tachyon/locales/tachyon.ru.po index 47173e65..a6b436aa 100644 --- a/fe-app-tachyon/locales/tachyon.ru.po +++ b/fe-app-tachyon/locales/tachyon.ru.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: TACHYON\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-16 22:54+0300\n" -"PO-Revision-Date: 2026-09-16 22:54+0300\n" +"POT-Creation-Date: 2026-09-17 08:13+0300\n" +"PO-Revision-Date: 2026-09-17 08:13+0300\n" "Last-Translator: Dushnilin\n" "Language-Team: none\n" "Language: ru\n" diff --git a/luci-app-tachyon/po/ru/tachyon.po b/luci-app-tachyon/po/ru/tachyon.po index 47173e65..a6b436aa 100644 --- a/luci-app-tachyon/po/ru/tachyon.po +++ b/luci-app-tachyon/po/ru/tachyon.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: TACHYON\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-16 22:54+0300\n" -"PO-Revision-Date: 2026-09-16 22:54+0300\n" +"POT-Creation-Date: 2026-09-17 08:13+0300\n" +"PO-Revision-Date: 2026-09-17 08:13+0300\n" "Last-Translator: Dushnilin\n" "Language-Team: none\n" "Language: ru\n" diff --git a/luci-app-tachyon/po/templates/tachyon.pot b/luci-app-tachyon/po/templates/tachyon.pot index 1df1a0f0..e7366820 100644 --- a/luci-app-tachyon/po/templates/tachyon.pot +++ b/luci-app-tachyon/po/templates/tachyon.pot @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: TACHYON\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-16 19:54+0300\n" -"PO-Revision-Date: 2026-09-16 19:54+0300\n" +"POT-Creation-Date: 2026-09-17 05:13+0300\n" +"PO-Revision-Date: 2026-09-17 05:13+0300\n" "Last-Translator: Dushnilin \n" "Language-Team: LANGUAGE \n" "Language: \n" diff --git a/tachyon/files/usr/lib/config/connections.uc b/tachyon/files/usr/lib/config/connections.uc index f6569a16..a4a556d9 100644 --- a/tachyon/files/usr/lib/config/connections.uc +++ b/tachyon/files/usr/lib/config/connections.uc @@ -7,6 +7,7 @@ let as_string = common.as_string; let object_or_empty = common.object_or_empty; let bool_value_common = common.bool_value; let remove_file = common.remove_file; +let list_option = common.list_option; function bool_value(value, fallback) { if (value == null || value == "") diff --git a/tachyon/files/usr/lib/singbox/generator.uc b/tachyon/files/usr/lib/singbox/generator.uc index 9748afbc..2309e716 100644 --- a/tachyon/files/usr/lib/singbox/generator.uc +++ b/tachyon/files/usr/lib/singbox/generator.uc @@ -592,6 +592,7 @@ function base_config(settings, service_address, runtime_context) { : false); let is_sb_1_14_plus = is_sb_1_14_plus_detected(sb_version_val); + ctx.is_sb_1_14_plus = function() { return is_sb_1_14_plus; }; let route_section = runtime_route.config(settings, runtime_context); if (is_sb_1_14_plus) diff --git a/tachyon/files/usr/lib/singbox/generator_routes.uc b/tachyon/files/usr/lib/singbox/generator_routes.uc index 14422d52..aedf6427 100644 --- a/tachyon/files/usr/lib/singbox/generator_routes.uc +++ b/tachyon/files/usr/lib/singbox/generator_routes.uc @@ -907,7 +907,7 @@ function ensure_custom_ruleset(config, reference) { if (runtime_rulesets.is_community(reference)) { tag_name = "builtin-" + reference + "-ruleset"; - kind = "domains"; + kind = runtime_rulesets.community_kind ? runtime_rulesets.community_kind(reference) : "domains"; if (!ruleset_registered(config, tag_name)) { let folder = ctx.runtime_ruleset_folder || runtime_ruleset_folder; let tmp_srs = folder + "/community-" + reference + ".srs"; @@ -1149,7 +1149,7 @@ function ensure_community_ruleset(config, section_name, community) { } return { tag: tag_name, - kind: "domains" + kind: runtime_rulesets.community_kind ? runtime_rulesets.community_kind(community) : "domains" }; } @@ -1424,25 +1424,31 @@ function add_dns_action_rules_for_section(config, section) { let domain_suffix = domains.domain_suffix; let domain_keyword = domains.domain_keyword; let domain_regex = domains.domain_regex; - let rule_set_tags = []; + let query_rule_set_tags = []; + let response_rule_set_tags = []; let section_name = section[".name"]; let source_ip_cidr = core_ip.normalize_to_cidrs(legacy_condition_values(section, "source_ip_cidr")); let fully_routed_ips = core_ip.normalize_to_cidrs(list_option(section, "fully_routed_ips")); for (let community in connections.community_lists(section)) { let ensured = ensure_community_ruleset(config, section_name, as_string(community)); - push(rule_set_tags, ensured.tag); + if (ensured.kind == "domains") + push(query_rule_set_tags, ensured.tag); + else if (ensured.kind == "subnets" || ensured.kind == "mixed") + push(response_rule_set_tags, ensured.tag); + else + push(response_rule_set_tags, ensured.tag); } for (let reference in connections.rule_sets(section)) { let ensured = ensure_custom_ruleset(config, as_string(reference)); if (ensured != null && ensured.kind == "domains") - push(rule_set_tags, ensured.tag); + push(query_rule_set_tags, ensured.tag); } add_domain_ip_list_ruleset( config, section_name, [], - rule_set_tags, + query_rule_set_tags, list_option(section, "domain_ip_lists"), true ); @@ -1482,17 +1488,58 @@ function add_dns_action_rules_for_section(config, section) { add_source_dns_matchers(dns_rule, source_ip_cidr); push_dns_matcher_rule(config, dns_rule); } - if (length(rule_set_tags) > 0) { - let dns_rule = { - action: "route", - server: server_tag, - rewrite_ttl, - rule_set: single_or_array(rule_set_tags) - }; - add_source_dns_matchers(dns_rule, source_ip_cidr); - push_dns_matcher_rule(config, dns_rule); + + let is_1_14 = ctx.is_sb_1_14_plus && ctx.is_sb_1_14_plus(); + if (is_1_14) { + if (length(query_rule_set_tags) > 0) { + let dns_rule = { + action: "route", + server: server_tag, + rewrite_ttl, + rule_set: single_or_array(query_rule_set_tags) + }; + add_source_dns_matchers(dns_rule, source_ip_cidr); + push_dns_matcher_rule(config, dns_rule); + } + if (length(response_rule_set_tags) > 0) { + let eval_rule = { + action: "evaluate", + server: runtime_constants.DNS_SERVER_TAG + }; + add_source_dns_matchers(eval_rule, source_ip_cidr); + push_dns_matcher_rule(config, eval_rule); + + let dns_rule = { + action: "route", + server: server_tag, + rewrite_ttl, + rule_set: single_or_array(response_rule_set_tags), + match_response: true + }; + add_source_dns_matchers(dns_rule, source_ip_cidr); + push_dns_matcher_rule(config, dns_rule); + } } - if (!has_inline_domains && length(rule_set_tags) == 0 && length(fully_routed_ips) == 0) + else { + let all_rule_set_tags = []; + for (let tag in query_rule_set_tags) + push(all_rule_set_tags, tag); + for (let tag in response_rule_set_tags) + push(all_rule_set_tags, tag); + + if (length(all_rule_set_tags) > 0) { + let dns_rule = { + action: "route", + server: server_tag, + rewrite_ttl, + rule_set: single_or_array(all_rule_set_tags) + }; + add_source_dns_matchers(dns_rule, source_ip_cidr); + push_dns_matcher_rule(config, dns_rule); + } + } + + if (!has_inline_domains && length(query_rule_set_tags) == 0 && length(response_rule_set_tags) == 0 && length(fully_routed_ips) == 0) ctx.runtime_generate_unsupported("DNS action '" + section_name + "' has no domain matchers"); } @@ -1749,7 +1796,8 @@ function add_combined_route_for_section(config, section) { let source_ip_cidr = core_ip.normalize_to_cidrs(legacy_condition_values(section, "source_ip_cidr")); let excluded_cidrs = core_ip.normalize_to_cidrs(list_option(section, "excluded_ips")); let rule_set_tags = []; - let dns_rule_set_tags = []; + let dns_query_rule_set_tags = []; + let dns_response_rule_set_tags = []; let section_name = section[".name"]; add_excluded_protocol_rule(config, section); @@ -1762,7 +1810,13 @@ function add_combined_route_for_section(config, 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 (ensured.kind == "domains") + push(dns_query_rule_set_tags, ensured.tag); + else if (ensured.kind == "subnets" || ensured.kind == "mixed") + push(dns_response_rule_set_tags, ensured.tag); + else + push(dns_response_rule_set_tags, ensured.tag); + if (include_community_subnets) { for (let cidr in load_community_subnet_cidrs(community, "exclude_cloudflare")) push(ip_cidr, cidr); @@ -1778,7 +1832,7 @@ function add_combined_route_for_section(config, section) { continue; push(rule_set_tags, ensured.tag); if (ensured.kind == "domains") - push(dns_rule_set_tags, ensured.tag); + push(dns_query_rule_set_tags, ensured.tag); } for (let reference in connections.rule_sets_with_subnets(section)) { let ensured = ensure_custom_ruleset(config, as_string(reference)); @@ -1786,13 +1840,13 @@ function add_combined_route_for_section(config, section) { continue; push(rule_set_tags, ensured.tag); if (ensured.kind == "domains") - push(dns_rule_set_tags, ensured.tag); + push(dns_query_rule_set_tags, ensured.tag); } add_domain_ip_list_ruleset( config, section_name, rule_set_tags, - dns_rule_set_tags, + dns_query_rule_set_tags, list_option(section, "domain_ip_lists"), false ); @@ -1914,15 +1968,54 @@ function add_combined_route_for_section(config, section) { add_source_dns_matchers(dns_rule, source_ip_cidr); push_dns_matcher_rule(config, apply_excluded_source_ips(dns_rule, excluded_cidrs)); } - if (length(dns_rule_set_tags) > 0) { - let dns_rule = { - action: "route", - server: section_dns_server(section), - rewrite_ttl, - rule_set: single_or_array(dns_rule_set_tags) - }; - add_source_dns_matchers(dns_rule, source_ip_cidr); - push_dns_matcher_rule(config, apply_excluded_source_ips(dns_rule, excluded_cidrs)); + let is_1_14 = ctx.is_sb_1_14_plus && ctx.is_sb_1_14_plus(); + if (is_1_14) { + if (length(dns_query_rule_set_tags) > 0) { + let dns_rule = { + action: "route", + server: section_dns_server(section), + rewrite_ttl, + rule_set: single_or_array(dns_query_rule_set_tags) + }; + add_source_dns_matchers(dns_rule, source_ip_cidr); + push_dns_matcher_rule(config, apply_excluded_source_ips(dns_rule, excluded_cidrs)); + } + if (length(dns_response_rule_set_tags) > 0) { + let eval_rule = { + action: "evaluate", + server: runtime_constants.DNS_SERVER_TAG + }; + add_source_dns_matchers(eval_rule, source_ip_cidr); + push_dns_matcher_rule(config, apply_excluded_source_ips(eval_rule, excluded_cidrs)); + + let dns_rule = { + action: "route", + server: section_dns_server(section), + rewrite_ttl, + rule_set: single_or_array(dns_response_rule_set_tags), + match_response: true + }; + add_source_dns_matchers(dns_rule, source_ip_cidr); + push_dns_matcher_rule(config, apply_excluded_source_ips(dns_rule, excluded_cidrs)); + } + } + else { + let all_dns_tags = []; + for (let tag in dns_query_rule_set_tags) + push(all_dns_tags, tag); + for (let tag in dns_response_rule_set_tags) + push(all_dns_tags, tag); + + if (length(all_dns_tags) > 0) { + let dns_rule = { + action: "route", + server: section_dns_server(section), + rewrite_ttl, + rule_set: single_or_array(all_dns_tags) + }; + add_source_dns_matchers(dns_rule, source_ip_cidr); + push_dns_matcher_rule(config, apply_excluded_source_ips(dns_rule, excluded_cidrs)); + } } } diff --git a/tachyon/files/usr/lib/singbox/rulesets.uc b/tachyon/files/usr/lib/singbox/rulesets.uc index cca7599e..f342ff17 100644 --- a/tachyon/files/usr/lib/singbox/rulesets.uc +++ b/tachyon/files/usr/lib/singbox/rulesets.uc @@ -48,7 +48,54 @@ const COMMUNITY_SERVICES = { geoip_ru: true, geosite_ru: true, geoip_us: true, - geoip_cn: true + geoip_cn: true, + google_meet: true +}; + +// Classification of community rule-sets based on upstream rule-set generation. +// Upstream allow-domains (itdoginfo/allow-domains convert.py) compiles: +// - general lists (russia_inside, russia_outside, ukraine_inside) and category lists as domain-only ("domains"); +// - SUBNET_SERVICES (discord, meta, twitter, telegram, cloudflare, hetzner, ovh, digitalocean, cloudfront, roblox, google_meet) +// as mixed containing both domain_suffix and ip_cidr ("mixed"); +// - geoip_* lists from MetaCubeX/meta-rules-dat contain only ip_cidr ("subnets"); +// - geosite_* lists from MetaCubeX/meta-rules-dat contain only domains ("domains"); +// - external lists (github, twitch, ads_hagezi_pro, supercell) are domain-only ("domains"). +// +// In sing-box 1.14+, referencing a rule-set with ip_cidr in a DNS rule without match_response: true +// is treated as a deprecated legacy address filter and rejected if query_type is present in DNS configuration. +const COMMUNITY_SUBNET_SERVICES = { + discord: true, + meta: true, + twitter: true, + telegram: true, + cloudflare: true, + hetzner: true, + ovh: true, + digitalocean: true, + cloudfront: true, + roblox: true, + google_meet: true +}; + +const COMMUNITY_DOMAIN_SERVICES = { + russia_inside: true, + russia_outside: true, + ukraine_inside: true, + geoblock: true, + block: true, + porn: true, + news: true, + anime: true, + youtube: true, + hdrezka: true, + tiktok: true, + google_ai: true, + google_play: true, + hodca: true, + ads_hagezi_pro: true, + supercell: true, + github: true, + twitch: true }; let as_string = common.as_string; @@ -62,6 +109,19 @@ function is_community(name) { return false; } +function community_kind(name) { + name = as_string(name); + if (match(name, /^geoip_[a-z]{2}$/) != null) + return "subnets"; + if (match(name, /^geosite_[a-z]{2}$/) != null) + return "domains"; + if (COMMUNITY_SUBNET_SERVICES[name] === true) + return "mixed"; + if (COMMUNITY_DOMAIN_SERVICES[name] === true) + return "domains"; + return "unknown"; +} + function community_url(name) { name = as_string(name); if (name == "ads_hagezi_pro") @@ -179,8 +239,11 @@ function module_exports() { EMPTY_SRS_PATH, EMPTY_SRS_B64, COMMUNITY_SERVICES, + COMMUNITY_SUBNET_SERVICES, + COMMUNITY_DOMAIN_SERVICES, is_community, community_url, + community_kind, hash12, file_extension, kind_from_reference_hint, @@ -200,6 +263,8 @@ if (mode == "file-extension") print(file_extension(ARGV[1]), "\n"); else if (mode == "is-community") exit(is_community(ARGV[1]) ? 0 : 1); +else if (mode == "community-kind") + print(community_kind(ARGV[1]), "\n"); else if (mode == "kind-from-reference-hint") print(kind_from_reference_hint(ARGV[1]), "\n"); else if (mode == "remote-format") @@ -211,6 +276,6 @@ else if (mode == "is-valid-srs-file") else if (mode == "ensure-empty-srs-stub") exit(ensure_empty_srs_stub(ARGV[1]) ? 0 : 1); else { - warn("Usage: singbox/rulesets.uc ...\n"); + warn("Usage: singbox/rulesets.uc ...\n"); exit(1); } \ No newline at end of file diff --git a/tests/singbox_114_dns_rules.sh b/tests/singbox_114_dns_rules.sh new file mode 100644 index 00000000..a92b83a2 --- /dev/null +++ b/tests/singbox_114_dns_rules.sh @@ -0,0 +1,414 @@ +#!/usr/bin/env bash +set -eo pipefail + +TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$TEST_DIR/.." && pwd)" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +GENERATOR_UC="$TACHYON_LIB/singbox/generator.uc" + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +# 1. Test sing-box 1.14+ with domain-only list (youtube) +# Should only generate query rule, NO evaluate rule, NO match_response +printf 'v1.14.1\n' > "$WORK_DIR/sb_v14" + +cat > "$WORK_DIR/fixture_domain_only.json" << 'EOF' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "enabled": "1", + "dns_type": "udp", + "dns_server": "1.1.1.1", + "service_listen_address": "127.0.0.1" + }, + "section": [ + { + ".name": "sec_yt", + ".type": "section", + "enabled": "1", + "action": "connection", + "outbound_jsons": [ "{\"type\":\"direct\",\"tag\":\"sec_yt-out\"}" ], + "community_lists": [ "youtube" ] + } + ] +} +EOF + +output_dom="$WORK_DIR/out_dom.json" +mkdir -p "$output_dom.section-cache" "$output_dom.rulesets" +SB_VERSION_STATE_FILE="$WORK_DIR/sb_v14" \ +ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \ + "$WORK_DIR/fixture_domain_only.json" "$output_dom" "127.0.0.1" "0" "1" + +# Verify query rule exists and NO evaluate rule exists +if grep -Fq '"action": "evaluate"' "$output_dom"; then + fail "domain-only list must NOT generate evaluate DNS rule" +fi +if grep -Fq '"match_response"' "$output_dom"; then + fail "domain-only list must NOT have match_response" +fi +grep -Fq '"action": "route"' "$output_dom" || fail "domain-only list must generate route DNS rule" + +# 2. Test sing-box 1.14+ with mixed list (telegram) +# Should generate evaluate rule and match_response: true on response rule +cat > "$WORK_DIR/fixture_mixed.json" << 'EOF' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "enabled": "1", + "dns_type": "udp", + "dns_server": "1.1.1.1", + "service_listen_address": "127.0.0.1" + }, + "section": [ + { + ".name": "sec_tg", + ".type": "section", + "enabled": "1", + "action": "connection", + "outbound_jsons": [ "{\"type\":\"direct\",\"tag\":\"sec_tg-out\"}" ], + "community_lists": [ "telegram" ] + } + ] +} +EOF + +output_mixed="$WORK_DIR/out_mixed.json" +mkdir -p "$output_mixed.section-cache" "$output_mixed.rulesets" +SB_VERSION_STATE_FILE="$WORK_DIR/sb_v14" \ +ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \ + "$WORK_DIR/fixture_mixed.json" "$output_mixed" "127.0.0.1" "0" "1" + +grep -Fq '"action": "evaluate"' "$output_mixed" || fail "mixed list must generate evaluate DNS rule" +grep -Fq '"match_response": true' "$output_mixed" || fail "mixed list must have match_response: true" + +# 3. Test sing-box 1.14+ with BOTH domain-only (russia_inside) and mixed (telegram) +# This is the exact reproduction case from user bug report +cat > "$WORK_DIR/fixture_user_case.json" << 'EOF' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "enabled": "1", + "dns_type": "udp", + "dns_server": "1.1.1.1", + "service_listen_address": "127.0.0.1" + }, + "section": [ + { + ".name": "Main", + ".type": "section", + "enabled": "1", + "action": "connection", + "outbound_jsons": [ "{\"type\":\"direct\",\"tag\":\"Main-out\"}" ], + "community_lists": [ "russia_inside", "telegram" ], + "community_subnets": "1" + } + ] +} +EOF + +output_user="$WORK_DIR/out_user.json" +mkdir -p "$output_user.section-cache" "$output_user.rulesets" +SB_VERSION_STATE_FILE="$WORK_DIR/sb_v14" \ +ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \ + "$WORK_DIR/fixture_user_case.json" "$output_user" "127.0.0.1" "0" "1" + +# Verify structure with ucode: +# Check that query rule has russia_inside and NOT telegram +# Check that response rule has telegram and match_response: true +ucode -L "$TACHYON_LIB" -e ' +let common = require("core.common"); +let fs = require("fs"); +let cfg = common.read_json_file("'"$output_user"'"); +if (!cfg || !cfg.dns || !cfg.dns.rules) { + warn("Failed to read generated config\n"); + exit(1); +} + +let found_https_reject = false; +let query_rule = null; +let eval_rule = null; +let resp_rule = null; + +for (let r in cfg.dns.rules) { + if (r.action == "reject" && r.query_type && r.query_type[0] == "HTTPS") + found_https_reject = true; + if (r.action == "route" && !r.match_response && r.rule_set) { + let rs = type(r.rule_set) == "array" ? r.rule_set : [ r.rule_set ]; + for (let s in rs) { + if (index(s, "russia_inside") >= 0) + query_rule = r; + } + } + if (r.action == "evaluate") + eval_rule = r; + if (r.action == "route" && r.match_response === true && r.rule_set) { + let rs = type(r.rule_set) == "array" ? r.rule_set : [ r.rule_set ]; + for (let s in rs) { + if (index(s, "telegram") >= 0) + resp_rule = r; + } + } +} + +if (!found_https_reject) { + warn("HTTPS reject rule missing\n"); + exit(2); +} +if (!query_rule) { + warn("query rule for russia_inside missing\n"); + exit(3); +} +if (!eval_rule) { + warn("eval_rule missing for telegram response\n"); + exit(4); +} +if (!resp_rule) { + warn("resp_rule missing for telegram\n"); + exit(5); +} + +// Ensure telegram is NOT in query_rule +let qrs = type(query_rule.rule_set) == "array" ? query_rule.rule_set : [ query_rule.rule_set ]; +for (let s in qrs) { + if (index(s, "telegram") >= 0) { + warn("telegram leaked into query_rule!\n"); + exit(6); + } +} +' || fail "user reproduction verification failed" + +# 4. Test source-based routing and excluded_ips with mixed ruleset on sing-box 1.14+ +cat > "$WORK_DIR/fixture_scoped.json" << 'EOF' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "enabled": "1", + "dns_type": "udp", + "dns_server": "1.1.1.1", + "service_listen_address": "127.0.0.1" + }, + "section": [ + { + ".name": "sec_scoped", + ".type": "section", + "enabled": "1", + "action": "connection", + "outbound_jsons": [ "{\"type\":\"direct\",\"tag\":\"sec_scoped-out\"}" ], + "source_ip_cidr": [ "192.168.1.50/32" ], + "excluded_ips": [ "192.168.1.200/32" ], + "community_lists": [ "telegram" ] + } + ] +} +EOF + +output_scoped="$WORK_DIR/out_scoped.json" +mkdir -p "$output_scoped.section-cache" "$output_scoped.rulesets" +SB_VERSION_STATE_FILE="$WORK_DIR/sb_v14" \ +ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \ + "$WORK_DIR/fixture_scoped.json" "$output_scoped" "127.0.0.1" "0" "1" + +# Verify eval_rule and resp_rule both have source matching / excluded IPs +ucode -L "$TACHYON_LIB" -e ' +let common = require("core.common"); +let cfg = common.read_json_file("'"$output_scoped"'"); +let eval_rule = null; +let resp_rule = null; + +for (let r in cfg.dns.rules) { + if (r.action == "evaluate") + eval_rule = r; + if (r.action == "route" && (r.match_response === true || (r.rules && r.rules[0] && r.rules[0].match_response === true))) + resp_rule = r; +} + +if (!eval_rule) { + warn("eval_rule missing in scoped section\n"); + exit(1); +} +if (!resp_rule) { + warn("resp_rule missing in scoped section\n"); + exit(2); +} + +// Check source scoping on eval_rule +if (eval_rule.type == "logical") { + let has_invert = false; + for (let child in eval_rule.rules) { + if (child.invert && child.source_ip_cidr) has_invert = true; + } + if (!has_invert) exit(3); +} else if (!eval_rule.source_ip_cidr) { + exit(4); +} + +// Check source scoping on resp_rule +if (resp_rule.type == "logical") { + let has_invert = false; + for (let child in resp_rule.rules) { + if (child.invert && child.source_ip_cidr) has_invert = true; + } + if (!has_invert) exit(5); +} else if (!resp_rule.source_ip_cidr) { + exit(6); +} +' || fail "scoped section evaluate/response source matcher verification failed" + +# 5. Test community_subnets=0 with mixed list +cat > "$WORK_DIR/fixture_subnets0.json" << 'EOF' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "enabled": "1", + "dns_type": "udp", + "dns_server": "1.1.1.1", + "service_listen_address": "127.0.0.1" + }, + "section": [ + { + ".name": "sec_no_sub", + ".type": "section", + "enabled": "1", + "action": "connection", + "outbound_jsons": [ "{\"type\":\"direct\",\"tag\":\"sec_no_sub-out\"}" ], + "community_lists": [ "telegram" ], + "community_subnets": "0" + } + ] +} +EOF + +output_subnets0="$WORK_DIR/out_subnets0.json" +mkdir -p "$output_subnets0.section-cache" "$output_subnets0.rulesets" +SB_VERSION_STATE_FILE="$WORK_DIR/sb_v14" \ +ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \ + "$WORK_DIR/fixture_subnets0.json" "$output_subnets0" "127.0.0.1" "0" "1" + +grep -Fq '"action": "evaluate"' "$output_subnets0" || fail "community_subnets=0 with mixed list must still generate evaluate" +grep -Fq '"match_response": true' "$output_subnets0" || fail "community_subnets=0 with mixed list must still have match_response" + +# 6. Test multiple routing sections with different kinds (domain, mixed, subnets) +cat > "$WORK_DIR/fixture_multi.json" << 'EOF' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "enabled": "1", + "dns_type": "udp", + "dns_server": "1.1.1.1", + "service_listen_address": "127.0.0.1" + }, + "section": [ + { + ".name": "sec_dom", + ".type": "section", + "enabled": "1", + "action": "connection", + "outbound_jsons": [ "{\"type\":\"direct\",\"tag\":\"sec_dom-out\"}" ], + "community_lists": [ "youtube" ] + }, + { + ".name": "sec_mixed", + ".type": "section", + "enabled": "1", + "action": "connection", + "outbound_jsons": [ "{\"type\":\"direct\",\"tag\":\"sec_mixed-out\"}" ], + "community_lists": [ "discord" ], + "routed_dns_enabled": "1", + "routed_dns_type": "udp", + "routed_dns_server": "8.8.8.8" + }, + { + ".name": "sec_subnet", + ".type": "section", + "enabled": "1", + "action": "connection", + "outbound_jsons": [ "{\"type\":\"direct\",\"tag\":\"sec_subnet-out\"}" ], + "community_lists": [ "geoip_nl" ] + } + ] +} +EOF + +output_multi="$WORK_DIR/out_multi.json" +mkdir -p "$output_multi.section-cache" "$output_multi.rulesets" +SB_VERSION_STATE_FILE="$WORK_DIR/sb_v14" \ +ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \ + "$WORK_DIR/fixture_multi.json" "$output_multi" "127.0.0.1" "0" "1" + +# 7. Test sing-box < 1.14 backwards compatibility (e.g. v1.13.5) +# Must generate single legacy rule without evaluate and without match_response +printf 'v1.13.5\n' > "$WORK_DIR/sb_v13" + +output_v13="$WORK_DIR/out_v13.json" +mkdir -p "$output_v13.section-cache" "$output_v13.rulesets" +SB_VERSION_STATE_FILE="$WORK_DIR/sb_v13" \ +ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \ + "$WORK_DIR/fixture_user_case.json" "$output_v13" "127.0.0.1" "0" "1" + +if grep -Fq '"evaluate"' "$output_v13"; then + fail "sing-box < 1.14 must NOT contain evaluate rule" +fi +if grep -Fq '"match_response"' "$output_v13"; then + fail "sing-box < 1.14 must NOT contain match_response" +fi + +# Verify in v13 that both russia_inside and telegram are in the legacy rule +ucode -L "$TACHYON_LIB" -e ' +let common = require("core.common"); +let cfg = common.read_json_file("'"$output_v13"'"); +let legacy_rule = null; + +for (let r in cfg.dns.rules) { + if (r.action == "route" && r.rule_set) { + let rs = type(r.rule_set) == "array" ? r.rule_set : [ r.rule_set ]; + let has_ru = false; + let has_tg = false; + for (let s in rs) { + if (index(s, "russia_inside") >= 0) has_ru = true; + if (index(s, "telegram") >= 0) has_tg = true; + } + if (has_ru && has_tg) + legacy_rule = r; + } +} + +if (!legacy_rule) { + warn("Legacy rule combining both rulesets not found on v1.13\n"); + exit(1); +} +' || fail "sing-box < 1.14 legacy rule combining verification failed" + +# 8. Validate generated configs against real sing-box 1.14 binary (if available) +SB_BIN="" +if command -v sing-box >/dev/null 2>&1; then + SB_BIN="sing-box" +elif [ -x "/tmp/sing-box-bin" ]; then + SB_BIN="/tmp/sing-box-bin" +fi + +if [ -n "$SB_BIN" ]; then + INSTALLED_SB_VER="$("$SB_BIN" version 2>/dev/null | head -n1 | grep -oE '[0-9]+\.[0-9]+' | head -n1 || echo '0.0')" + if [ "$(printf '%s\n1.14\n' "$INSTALLED_SB_VER" | sort -V | head -n1)" = "1.14" ]; then + "$SB_BIN" check -c "$output_user" || fail "sing-box 1.14 check failed on generated user config!" + "$SB_BIN" check -c "$output_dom" || fail "sing-box 1.14 check failed on domain-only config!" + "$SB_BIN" check -c "$output_mixed" || fail "sing-box 1.14 check failed on mixed config!" + "$SB_BIN" check -c "$output_scoped" || fail "sing-box 1.14 check failed on scoped config!" + "$SB_BIN" check -c "$output_subnets0" || fail "sing-box 1.14 check failed on subnets0 config!" + "$SB_BIN" check -c "$output_multi" || fail "sing-box 1.14 check failed on multi config!" + fi +fi + +echo "sing-box 1.14 DNS rules tests passed" diff --git a/tests/singbox_rulesets.sh b/tests/singbox_rulesets.sh index ae602056..ef76c0c9 100644 --- a/tests/singbox_rulesets.sh +++ b/tests/singbox_rulesets.sh @@ -58,5 +58,27 @@ assert_eq "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/sing/geo/g "$(ucode -L "$TACHYON_LIB" -e 'let rulesets = require("singbox.rulesets"); print(rulesets.community_url("twitch"));')" \ "twitch community ruleset url" +assert_eq mixed \ + "$(ucode -L "$TACHYON_LIB" "$RULESETS_UC" community-kind telegram)" \ + "telegram community kind" +assert_eq mixed \ + "$(ucode -L "$TACHYON_LIB" "$RULESETS_UC" community-kind discord)" \ + "discord community kind" +assert_eq domains \ + "$(ucode -L "$TACHYON_LIB" "$RULESETS_UC" community-kind youtube)" \ + "youtube community kind" +assert_eq domains \ + "$(ucode -L "$TACHYON_LIB" "$RULESETS_UC" community-kind russia_inside)" \ + "russia_inside community kind" +assert_eq domains \ + "$(ucode -L "$TACHYON_LIB" "$RULESETS_UC" community-kind geosite_ru)" \ + "geosite_ru community kind" +assert_eq subnets \ + "$(ucode -L "$TACHYON_LIB" "$RULESETS_UC" community-kind geoip_ru)" \ + "geoip_ru community kind" +assert_eq unknown \ + "$(ucode -L "$TACHYON_LIB" "$RULESETS_UC" community-kind unknown_service)" \ + "unknown_service community kind" + printf 'singbox rulesets checks passed\n'