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
+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"