fix(singbox): separate dns query and response rulesets for sing-box 1.14+ compatibility

This commit is contained in:
Dushnilin
2026-09-17 08:13:11 +03:00
parent 67761143f5
commit 44a1777c0d
10 changed files with 636 additions and 40 deletions
+2 -2
View File
@@ -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 <dushnilin@gmail.com>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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 <dushnilin@gmail.com>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
@@ -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 == "")
@@ -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)
+123 -30
View File
@@ -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));
}
}
}
+67 -2
View File
@@ -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 <file-extension|is-community|kind-from-reference-hint|remote-format|is-valid-srs-file|ensure-empty-srs-stub> ...\n");
warn("Usage: singbox/rulesets.uc <file-extension|is-community|community-kind|kind-from-reference-hint|remote-format|is-valid-srs-file|ensure-empty-srs-stub> ...\n");
exit(1);
}
+414
View File
@@ -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"
+22
View File
@@ -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'