fix: разделение правил маршрутизации секции (fix коллизии AND) и поддержка http/https ссылок

This commit is contained in:
Dushnilin
2026-09-06 19:23:07 +03:00
parent 625715fed0
commit fbeadb48ed
6 changed files with 384 additions and 40 deletions
-10
View File
@@ -2276,22 +2276,12 @@ function nft_populate_runtime_set_for_section(section, deferred_sections, table,
"/tmp/sing-box/rulesets/community-subnets-" + service + ".lst",
"/etc/tachyon/rulesets/community-subnets-" + service + ".lst"
];
let found = false;
for (let path in cached_paths) {
if (helpers.file_is_usable(path, 50)) {
nft_add_file_chunks_to_family_sets(path, table, sets.subnets, sets.subnets6, "ips", "", "5000");
found = true;
break;
}
}
if (!found) {
if (service == "discord") {
nft_add_set_elements(table, sets.subnets, "162.158.0.0/15, 172.64.0.0/13, 138.128.136.0/21");
}
else if (service == "telegram") {
nft_add_set_elements(table, sets.subnets, "149.154.160.0/20, 91.108.4.0/22, 91.108.8.0/22, 91.108.12.0/22, 91.108.16.0/22, 91.108.56.0/22, 5.28.192.0/18, 91.105.192.0/23, 95.161.64.0/20, 185.76.151.0/24, 194.221.0.0/16");
}
}
}
}
@@ -643,8 +643,11 @@ function is_valid_detour(config, tag) {
if (tag == runtime_constants.DIRECT_OUTBOUND_TAG || tag == runtime_constants.BYPASS_OUTBOUND_TAG)
return true;
for (let out in array_or_empty(config.outbounds)) {
if (type(out) == "object" && out.tag == tag)
if (type(out) == "object" && out.tag == tag) {
if (out.tag == "DPI-out" || (out.type == "socks" && (out.server == "127.0.0.1" || out.server == "::1")))
return false;
return true;
}
}
return false;
}
@@ -1284,18 +1287,6 @@ function load_community_subnet_cidrs(community) {
return cidrs;
}
}
// Hardcoded seed fallback for cold boot before list-update downloads .lst
if (service == "discord") {
return [ "162.158.0.0/15", "172.64.0.0/13", "138.128.136.0/21" ];
}
if (service == "telegram") {
return [
"149.154.160.0/20", "91.108.4.0/22", "91.108.8.0/22",
"91.108.12.0/22", "91.108.16.0/22", "91.108.56.0/22",
"5.28.192.0/18", "91.105.192.0/23", "95.161.64.0/20",
"185.76.151.0/24", "194.221.0.0/16"
];
}
return [];
}
@@ -1321,10 +1312,8 @@ function add_combined_route_for_section(config, section) {
let ensured = ensure_community_ruleset(config, section_name, service);
push(rule_set_tags, ensured.tag);
push(dns_rule_set_tags, ensured.tag);
if (service != "cloudflare" && service != "cloudfront" && service != "hetzner" && service != "ovh" && service != "digitalocean") {
for (let cidr in load_community_subnet_cidrs(community))
push(ip_cidr, cidr);
}
for (let cidr in load_community_subnet_cidrs(community))
push(ip_cidr, cidr);
}
for (let reference in connections.rule_sets(section)) {
let ensured = ensure_custom_ruleset(config, as_string(reference));
@@ -1355,9 +1344,9 @@ function add_combined_route_for_section(config, section) {
if (target.unsupported)
ctx.runtime_generate_unsupported(target.unsupported);
let has_domain_or_ruleset = length(domain) > 0 || length(domain_suffix) > 0 ||
length(domain_keyword) > 0 || length(domain_regex) > 0 ||
length(rule_set_tags) > 0;
let has_domain = length(domain) > 0 || length(domain_suffix) > 0 ||
length(domain_keyword) > 0 || length(domain_regex) > 0;
let has_ruleset = length(rule_set_tags) > 0;
let has_ip_cidr = length(ip_cidr) > 0;
let create_section_route_rule = function() {
@@ -1375,14 +1364,12 @@ function add_combined_route_for_section(config, section) {
return r;
};
if (has_domain_or_ruleset) {
if (has_domain) {
let domain_rule = create_section_route_rule();
add_domain_array(domain_rule, "domain", domain);
add_domain_array(domain_rule, "domain_suffix", domain_suffix);
add_domain_array(domain_rule, "domain_keyword", domain_keyword);
add_domain_array(domain_rule, "domain_regex", domain_regex);
if (length(rule_set_tags) > 0)
domain_rule.rule_set = single_or_array(rule_set_tags);
let resolve = runtime_route.resolve_rule_for_section(section, domain_rule);
if (type(resolve) == "object" && resolve.warning)
@@ -1393,6 +1380,19 @@ function add_combined_route_for_section(config, section) {
push_section_route_rule(config, domain_rule, target.outbound);
}
if (has_ruleset) {
let rs_rule = create_section_route_rule();
rs_rule.rule_set = single_or_array(rule_set_tags);
let resolve = runtime_route.resolve_rule_for_section(section, rs_rule);
if (type(resolve) == "object" && resolve.warning)
warn(resolve.warning, "\n");
else if (type(resolve) == "object" && resolve.rule)
push(config.route.rules, resolve.rule);
push_section_route_rule(config, rs_rule, target.outbound);
}
if (has_ip_cidr) {
let ip_rule = create_section_route_rule();
ip_rule.ip_cidr = ip_cidr;
@@ -1426,7 +1426,7 @@ function add_combined_route_for_section(config, section) {
}
}
if (!has_domain_or_ruleset && !has_ip_cidr && length(country_list) == 0) {
if (!has_domain && !has_ruleset && !has_ip_cidr && 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 ||
@@ -1629,20 +1629,20 @@ function add_service_route_rules(config, sections) {
push(detour_domains, host);
}
if (length(detour_ips) > 0) {
push(config.route.rules, {
push_section_route_rule(config, {
action: "route",
inbound: tproxy_inbound_matcher(),
outbound: detour_target,
ip_cidr: single_or_array(detour_ips)
});
}, detour_target);
}
if (length(detour_domains) > 0) {
push(config.route.rules, {
push_section_route_rule(config, {
action: "route",
inbound: tproxy_inbound_matcher(),
outbound: detour_target,
domain: single_or_array(detour_domains)
});
}, detour_target);
}
}
}
+49 -1
View File
@@ -29,7 +29,9 @@ function is_supported_share_link(line) {
starts_with(line, "socks://") ||
starts_with(line, "socks4://") ||
starts_with(line, "socks4a://") ||
starts_with(line, "socks5://");
starts_with(line, "socks5://") ||
starts_with(line, "http://") ||
starts_with(line, "https://");
}
function split_csv(value) {
@@ -880,6 +882,50 @@ function process_socks(raw, url) {
return outbound;
}
function process_http(raw, url) {
let port = url.port;
if (port == null || port == "")
port = url.scheme == "https" ? 443 : 80;
if (url.host == "" || is_dummy_server(url.host, port) || !valid_port(port))
return null;
let username = "", password = "";
if (url.userinfo != "") {
let colon = index(url.userinfo, ":");
if (colon >= 0) {
username = urldecode(substr(url.userinfo, 0, colon));
password = urldecode(substr(url.userinfo, colon + 1));
if (username == password)
password = "";
}
else {
username = urldecode(url.userinfo);
}
}
let outbound = {
type: "http",
tag: url.fragment != "" ? url.fragment : (url.host + ":" + port),
share_link: raw,
server: url.host,
server_port: int(port)
};
if (username != "")
outbound.username = username;
if (password != "")
outbound.password = password;
if (url.scheme == "https") {
let tls = { enabled: true };
if ((url.query.sni || "") != "")
tls.server_name = url.query.sni;
if (is_true(url.query.allowInsecure || url.query.insecure))
tls.insecure = true;
outbound.tls = tls;
}
return outbound;
}
function is_shadowsocks_userinfo_format(value) {
if (type(value) != "string")
return false;
@@ -1183,6 +1229,8 @@ function parse_share_link(line) {
return process_tuic(line, url);
if (match(url.scheme, /^socks/))
return process_socks(line, url);
if (url.scheme == "http" || url.scheme == "https")
return process_http(line, url);
return null;
}
@@ -242,6 +242,33 @@ function serialize_socks(outbound) {
return scheme + "://" + auth + host_port(outbound.server, outbound.server_port) + fragment(outbound);
}
function serialize_http(outbound) {
if (as_string(outbound.server) == "" || outbound.server_port == null)
return "";
let is_tls = type(outbound.tls) == "object" && (outbound.tls.enabled === true || outbound.tls.enabled == 1);
let scheme = is_tls ? "https" : "http";
let auth = "";
if (as_string(outbound.username) != "") {
auth = uri_encode(outbound.username);
if (as_string(outbound.password) != "")
auth += ":" + uri_encode(outbound.password);
auth += "@";
}
let params = [];
if (is_tls && type(outbound.tls) == "object") {
let tls = outbound.tls;
if (tls.server_name)
add_query(params, "sni", tls.server_name);
if (tls.insecure === true)
add_query(params, "insecure", "1");
}
return scheme + "://" + auth + host_port(outbound.server, outbound.server_port) + query_string(params) + fragment(outbound);
}
function serialize_hysteria2(outbound) {
let port = hysteria2_server_ports_uri(outbound);
if (port == "" && outbound.server_port != null)
@@ -365,6 +392,8 @@ function serialize_outbound_link(outbound) {
return serialize_shadowsocks(outbound);
if (outbound_type == "socks")
return serialize_socks(outbound);
if (outbound_type == "http")
return serialize_http(outbound);
if (outbound_type == "hysteria2")
return serialize_hysteria2(outbound);
if (outbound_type == "tuic")
@@ -379,7 +408,8 @@ function is_copyable_link(value) {
let prefixes = [
"vless://", "vmess://", "trojan://", "ss://", "ssr://",
"hysteria2://", "hy2://", "tuic://",
"socks4://", "socks4a://", "socks5://"
"socks4://", "socks4a://", "socks5://",
"http://", "https://"
];
for (let prefix in prefixes) {
if (starts_with(value, prefix))
+133
View File
@@ -0,0 +1,133 @@
#!/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 coexistence: section with both domains and community_lists MUST generate separate rules
cat >"$WORK_DIR/fixture.json" <<'JSON'
{
"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": "mixed_sec",
".type": "section",
"enabled": "1",
"action": "connection",
"outbound_jsons": [ "{\"type\":\"direct\"}" ],
"domain_suffix": [ "customdomain.org" ],
"community_lists": [ "discord" ]
}
]
}
JSON
output="$WORK_DIR/out.json"
mkdir -p "$output.section-cache" "$output.rulesets"
ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \
"$WORK_DIR/fixture.json" "$output" "127.0.0.1" "0" "1"
# Verify route rules in out.json
ucode -e '
let fs = require("fs");
let raw = fs.readfile("'"$output"'");
let cfg = json(raw);
let rules = cfg.route.rules || [];
let found_domain_only = false;
let found_ruleset_only = false;
let found_combined = false;
for (let r in rules) {
if (r.domain_suffix && r.rule_set) {
found_combined = true;
}
if (r.domain_suffix && !r.rule_set) {
for (let d in r.domain_suffix) {
if (d == "customdomain.org")
found_domain_only = true;
}
}
if (r.rule_set && !r.domain_suffix) {
let rs_str = sprintf("%s", r.rule_set);
if (index(rs_str, "discord") >= 0)
found_ruleset_only = true;
}
}
if (found_combined) {
print("ERROR: found merged domain_suffix AND rule_set in single rule! Sing-box treats this as logical AND!\n");
exit(1);
}
if (!found_domain_only) {
print("ERROR: domain_suffix rule missing for customdomain.org\n");
exit(2);
}
if (!found_ruleset_only) {
print("ERROR: rule_set rule missing for discord\n");
exit(3);
}
' || fail "Coexistence check failed: domain and ruleset rules are not cleanly separated"
# 2. Verify DPI-out is never selected as download_detour
cat >"$WORK_DIR/fixture_dpi.json" <<'JSON'
{
"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": "dpi_sec",
".type": "section",
"enabled": "1",
"action": "connection",
"outbound_jsons": [
"{\"tag\":\"DPI-out\",\"type\":\"socks\",\"server\":\"127.0.0.1\",\"server_port\":1080}"
],
"community_lists": [ "discord" ]
}
]
}
JSON
output_dpi="$WORK_DIR/out_dpi.json"
mkdir -p "$output_dpi.section-cache" "$output_dpi.rulesets"
ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \
"$WORK_DIR/fixture_dpi.json" "$output_dpi" "127.0.0.1" "0" "1"
ucode -e '
let fs = require("fs");
let raw = fs.readfile("'"$output_dpi"'");
let cfg = json(raw);
let rulesets = cfg.route.rule_set || [];
for (let rs in rulesets) {
if (rs.download_detour == "DPI-out") {
print("ERROR: DPI-out was selected as download_detour!\n");
exit(1);
}
}
' || fail "DPI-out must never be set as download_detour"
printf "Route rules coexistence and detour checks passed\n"
+143
View File
@@ -0,0 +1,143 @@
#!/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"
PARSER_UC="$TACHYON_LIB/subscription/parser.uc"
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 parsing http:// and https:// share links
cat >"$WORK_DIR/http_links.txt" <<'EOF'
http://admin:secret123@192.168.1.50:8080#My-Http-Proxy
https://user:pass@proxy.example.com:8443?sni=secure.example.com&insecure=1#My-Https-Proxy
EOF
output_json="$WORK_DIR/parsed.json"
ucode -L "$TACHYON_LIB" "$PARSER_UC" normalize-uri-list "$WORK_DIR/http_links.txt" "$output_json"
ucode -e '
let fs = require("fs");
let parsed = json(fs.readfile("'"$output_json"'"));
let list = parsed.outbounds || [];
if (length(list) != 2) {
print("Expected 2 outbounds, got " + length(list) + "\n");
exit(1);
}
let http = list[0];
if (http.type != "http" || http.server != "192.168.1.50" || http.server_port != 8080 ||
http.username != "admin" || http.password != "secret123" || http.tag != "My-Http-Proxy") {
print("HTTP outbound fields incorrect: " + sprintf("%J", http) + "\n");
exit(2);
}
let https = list[1];
if (https.type != "http" || https.server != "proxy.example.com" || https.server_port != 8443 ||
https.username != "user" || https.password != "pass" || https.tag != "My-Https-Proxy" ||
!https.tls || !https.tls.enabled || https.tls.server_name != "secure.example.com" || !https.tls.insecure) {
print("HTTPS outbound fields incorrect: " + sprintf("%J", https) + "\n");
exit(3);
}
' || fail "HTTP/HTTPS share link parsing failed"
# 2. Test serializing http/https outbound to share link
ucode -L "$TACHYON_LIB" -e '
let share = require("subscription.share_link");
let http_out = {
type: "http",
server: "1.2.3.4",
server_port: 8080,
username: "alice",
password: "foo",
tag: "test-http"
};
let link = share.serialize_outbound_link(http_out);
if (link != "http://alice:foo@1.2.3.4:8080#test-http") {
print("Failed http serialize: " + link + "\n");
exit(1);
}
let https_out = {
type: "http",
server: "secure.net",
server_port: 443,
tls: {
enabled: true,
server_name: "sni.secure.net",
insecure: true
},
tag: "test-https"
};
let link_tls = share.serialize_outbound_link(https_out);
if (link_tls != "https://secure.net:443?sni=sni.secure.net&insecure=1#test-https") {
print("Failed https serialize: " + link_tls + "\n");
exit(2);
}
' || fail "HTTP/HTTPS serialization failed"
# 3. Test UDP reject rule generation for HTTP outbound to prevent sing-box crash
cat >"$WORK_DIR/fixture.json" <<'JSON'
{
"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": "http_sec",
".type": "section",
"enabled": "1",
"action": "connection",
"outbound_jsons": [
"{\"tag\":\"http-out\",\"type\":\"http\",\"server\":\"1.2.3.4\",\"server_port\":8080}"
],
"domain_suffix": [ "example.com" ]
}
]
}
JSON
gen_out="$WORK_DIR/gen.json"
mkdir -p "$gen_out.section-cache" "$gen_out.rulesets"
ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \
"$WORK_DIR/fixture.json" "$gen_out" "127.0.0.1" "0" "1"
ucode -e '
let fs = require("fs");
let cfg = json(fs.readfile("'"$gen_out"'"));
let rules = cfg.route.rules || [];
let found_reject = false;
let found_http_route = false;
for (let r in rules) {
if (r.network == "udp" && (r.action == "reject" || r.outbound == "block")) {
found_reject = true;
}
if (r.outbound == "http_sec-out") {
found_http_route = true;
}
}
if (!found_reject) {
print("ERROR: Expected UDP reject rule before HTTP outbound route rule!\n");
exit(1);
}
if (!found_http_route) {
print("ERROR: Expected route rule for http_sec-out!\n");
exit(2);
}
' || fail "UDP reject rule for HTTP outbound failed"
printf "Subscription HTTP tests passed\n"