diff --git a/tachyon/files/usr/lib/nft/apply.uc b/tachyon/files/usr/lib/nft/apply.uc index 3d00ee1f..693a7124 100644 --- a/tachyon/files/usr/lib/nft/apply.uc +++ b/tachyon/files/usr/lib/nft/apply.uc @@ -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"); - } - } } } diff --git a/tachyon/files/usr/lib/singbox/generator_routes.uc b/tachyon/files/usr/lib/singbox/generator_routes.uc index 2cd1d232..b8f77602 100644 --- a/tachyon/files/usr/lib/singbox/generator_routes.uc +++ b/tachyon/files/usr/lib/singbox/generator_routes.uc @@ -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); } } } diff --git a/tachyon/files/usr/lib/subscription/parser.uc b/tachyon/files/usr/lib/subscription/parser.uc index fab5b880..81dbd766 100644 --- a/tachyon/files/usr/lib/subscription/parser.uc +++ b/tachyon/files/usr/lib/subscription/parser.uc @@ -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; } diff --git a/tachyon/files/usr/lib/subscription/share_link.uc b/tachyon/files/usr/lib/subscription/share_link.uc index 04fde679..e387c50b 100644 --- a/tachyon/files/usr/lib/subscription/share_link.uc +++ b/tachyon/files/usr/lib/subscription/share_link.uc @@ -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)) diff --git a/tests/route_rules_coexistence.sh b/tests/route_rules_coexistence.sh new file mode 100644 index 00000000..c71f366e --- /dev/null +++ b/tests/route_rules_coexistence.sh @@ -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" diff --git a/tests/subscription_http.sh b/tests/subscription_http.sh new file mode 100644 index 00000000..b8af0b02 --- /dev/null +++ b/tests/subscription_http.sh @@ -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"