Filter unsupported XHTTP subscription outbounds

This commit is contained in:
ushan0v
2026-07-12 01:36:45 +03:00
parent b7ff705a24
commit 9881dc2b50
3 changed files with 367 additions and 27 deletions
+147 -25
View File
@@ -19,6 +19,7 @@ let uci = null;
let fixture_uci_data = null;
let runtime_settings_cache = null;
let runtime_ruleset_folder = runtime_constants.TMP_RULESET_FOLDER;
let runtime_supports_xhttp = true;
let as_string = common.as_string;
let read_json_file = common.read_json_file;
@@ -509,6 +510,102 @@ function supported_subscription_outbound(outbound) {
t == "socks" || t == "http" || t == "hysteria2";
}
function outbound_uses_xhttp(outbound) {
return type(outbound) == "object" && type(outbound.transport) == "object" &&
lc(as_string(outbound.transport.type || "")) == "xhttp";
}
function ensure_explicit_outbound_supported(outbound, source, name) {
if (!runtime_supports_xhttp && outbound_uses_xhttp(outbound))
runtime_generate_unsupported(as_string(source) + " '" + as_string(name) + "' uses XHTTP transport, but sing-box-extended is not installed");
}
function subscription_outbound_display_name(outbound) {
return type(outbound) == "object"
? as_string(outbound.remark || outbound.tag || "unknown")
: "unknown";
}
function add_subscription_reference(refs, value) {
value = as_string(value);
if (value != "")
refs[value] = true;
}
function subscription_reference_set(outbounds) {
let refs = {};
for (let outbound in array_or_empty(outbounds)) {
if (type(outbound) != "object")
continue;
add_subscription_reference(refs, outbound.tag);
add_subscription_reference(refs, outbound.remark);
}
return refs;
}
function subscription_reference_available(reference, source_refs, retained_refs) {
reference = as_string(reference);
return reference == "" || !source_refs[reference] || retained_refs[reference];
}
function subscription_group_has_retained_member(outbound, retained_refs) {
for (let reference in array_or_empty(outbound.outbounds))
if (retained_refs[as_string(reference)])
return true;
return false;
}
function warn_skipped_subscription_outbound(section_name, outbound, reason) {
warn("skipped incompatible subscription outbound for rule '", section_name, "': ",
subscription_outbound_display_name(outbound), " (", reason, ")\n");
}
function compatible_subscription_outbounds(outbounds, section_name) {
let source_refs = subscription_reference_set(outbounds);
let retained = [];
for (let outbound in array_or_empty(outbounds)) {
if (!supported_subscription_outbound(outbound))
continue;
if (!runtime_supports_xhttp && outbound_uses_xhttp(outbound)) {
warn_skipped_subscription_outbound(section_name, outbound, "XHTTP requires sing-box-extended");
continue;
}
push(retained, outbound);
}
while (true) {
let retained_refs = subscription_reference_set(retained);
let next = [];
let changed = false;
for (let outbound in retained) {
let detour = as_string(outbound.detour || "");
if (!subscription_reference_available(detour, source_refs, retained_refs)) {
warn_skipped_subscription_outbound(section_name, outbound,
"detour depends on unavailable outbound '" + detour + "'");
changed = true;
continue;
}
if (subscription_group_outbound(outbound) &&
!subscription_group_has_retained_member(outbound, retained_refs)) {
warn_skipped_subscription_outbound(section_name, outbound, "group has no compatible outbounds");
changed = true;
continue;
}
push(next, outbound);
}
retained = next;
if (!changed)
return retained;
}
}
function subscription_source_outbound_index(source_outbounds, outbound) {
for (let i = 0; i < length(source_outbounds); i++)
if (source_outbounds[i] === outbound)
return i + 1;
return 0;
}
function copy_subscription_outbound(outbound, new_tag) {
let copy = {};
for (let key, value in outbound) {
@@ -530,16 +627,23 @@ function copy_subscription_outbound(outbound, new_tag) {
return copy;
}
function rewrite_subscription_outbound_references(outbounds, tag_map) {
function string_array_contains(values, needle) {
for (let value in array_or_empty(values))
if (as_string(value) == as_string(needle))
return true;
return false;
}
function rewrite_subscription_outbound_references(outbounds, tag_map, source_refs) {
for (let outbound in outbounds) {
if (type(outbound) != "object")
continue;
for (let key in [ "detour", "default" ]) {
let reference = as_string(outbound[key] || "");
if (reference != "" && tag_map[reference])
outbound[key] = tag_map[reference];
}
let detour = as_string(outbound.detour || "");
if (detour != "" && tag_map[detour])
outbound.detour = tag_map[detour];
else if (detour != "" && source_refs[detour])
delete outbound.detour;
if (type(outbound.outbounds) == "array") {
let rewritten = [];
@@ -549,6 +653,16 @@ function rewrite_subscription_outbound_references(outbounds, tag_map) {
push(rewritten, tag_map[tag_name]);
}
outbound.outbounds = rewritten;
let default_tag = as_string(outbound.default || "");
if (default_tag != "" && tag_map[default_tag])
default_tag = tag_map[default_tag];
if (default_tag == "" || !string_array_contains(rewritten, default_tag))
default_tag = length(rewritten) > 0 ? rewritten[0] : "";
if (default_tag != "")
outbound.default = default_tag;
else
delete outbound.default;
}
}
}
@@ -628,10 +742,20 @@ function add_subscription_source_with_state(config, section, source_index, sourc
))
return 0;
let outbounds = runtime_subscription.read_source_outbounds(source_section);
if (length(outbounds) == 0)
let source_outbounds = runtime_subscription.read_source_outbounds(source_section);
if (length(source_outbounds) == 0)
return 0;
let skipped = {};
for (let outbound in source_outbounds) {
if (supported_subscription_outbound(outbound))
continue;
let t = type(outbound) == "object" ? as_string(outbound.type || "missing-type") : "non-object";
if (reportable_skipped_subscription_type(t))
skipped[t] = (skipped[t] || 0) + 1;
}
let outbounds = compatible_subscription_outbounds(source_outbounds, section_name);
if (show_metadata !== false)
runtime_subscription.merge_source_metadata(state, section_name, source_section, source_index, source_entry);
let visibility_refs = subscription_visibility_refs(outbounds);
@@ -645,15 +769,8 @@ function add_subscription_source_with_state(config, section, source_index, sourc
let group_flags = [];
let hidden_flags = [];
let tag_map = {};
let skipped = {};
for (let i = 0; i < length(outbounds); i++) {
let outbound = outbounds[i];
if (!supported_subscription_outbound(outbound)) {
let t = type(outbound) == "object" ? as_string(outbound.type || "missing-type") : "non-object";
if (reportable_skipped_subscription_type(t))
skipped[t] = (skipped[t] || 0) + 1;
continue;
}
if (include_urltest_groups === false && subscription_urltest_group_outbound(outbound))
continue;
let display_name = as_string(outbound.remark || outbound.tag || ("server-" + (i + 1)));
@@ -670,7 +787,7 @@ function add_subscription_source_with_state(config, section, source_index, sourc
if (as_string(outbound.remark || "") != "")
tag_map[as_string(outbound.remark)] = new_tag;
push(prepared, copy_subscription_outbound(outbound, new_tag));
push(source_indices, i + 1);
push(source_indices, subscription_source_outbound_index(source_outbounds, outbound));
push(display_names, display_name);
push(source_links, as_string(outbound.share_link || ""));
push(group_flags, subscription_group_outbound(outbound));
@@ -680,7 +797,7 @@ function add_subscription_source_with_state(config, section, source_index, sourc
if (length(keys(skipped)) > 0)
warn("skipped unsupported subscription outbounds for rule '", section_name, "': ", subscription_skip_summary(skipped), "\n");
rewrite_subscription_outbound_references(prepared, tag_map);
rewrite_subscription_outbound_references(prepared, tag_map, subscription_reference_set(source_outbounds));
let added = 0;
for (let i = 0; i < length(prepared); i++) {
let outbound = prepared[i];
@@ -1780,13 +1897,14 @@ function add_manual_proxy_link(config, state, section_name, manual_index, link,
taken[tag_name] = true;
let outbound = manual_link_outbound(link, tag_name);
let display_name = url_fragment(link);
if (display_name == "")
display_name = tag_name;
ensure_explicit_outbound_supported(outbound, "manual outbound", display_name);
push(config.outbounds, outbound);
push(selector_tags, tag_name);
push(urltest_candidate_tags, tag_name);
let display_name = url_fragment(link);
if (display_name == "")
display_name = tag_name;
state.links[tag_name] = as_string(link);
state.outboundMetadata.names[tag_name] = display_name;
if (as_string(outbound.server || "") != "")
@@ -1913,6 +2031,7 @@ function prepare_json_connection_outbounds(section, taken) {
let legacy_tag = connection_item_tag(section[".name"], "json", i + 1);
let base = display_name != "" ? display_name : legacy_tag;
let tag_name = unique_tag(base, taken);
ensure_explicit_outbound_supported(outbound, "JSON outbound", display_name != "" ? display_name : legacy_tag);
taken[tag_name] = true;
if (display_name != "" && !tag_map[display_name])
tag_map[display_name] = tag_name;
@@ -2575,7 +2694,10 @@ function add_server_routes(config, servers, sections) {
}
}
function generate_config(output_path, service_address, mwan3_active) {
function generate_config(output_path, service_address, mwan3_active, supports_xhttp) {
runtime_supports_xhttp = supports_xhttp == null || as_string(supports_xhttp) == ""
? true
: cli_bool(supports_xhttp);
let cursor = uci_cursor();
cursor.load(CONFIG_NAME);
runtime_settings_cache = object_or_empty(cursor.get_all(CONFIG_NAME, "settings"));
@@ -2610,11 +2732,11 @@ function generate_config(output_path, service_address, mwan3_active) {
}
}
function generate_config_fixture(fixture_path, output_path, service_address, mwan3_active) {
function generate_config_fixture(fixture_path, output_path, service_address, mwan3_active, supports_xhttp) {
use_fixture_cursor(fixture_path);
runtime_subscription.set_section_cache_dir(output_path + ".section-cache");
runtime_ruleset_folder = output_path + ".rulesets";
generate_config(output_path, service_address, mwan3_active);
generate_config(output_path, service_address, mwan3_active, supports_xhttp);
}
function merge_object_values(target, source) {
@@ -3028,9 +3150,9 @@ function route_rule_has_resolve_matchers(service_tag, tag) {
let mode = ARGV[0] || "";
if (mode == "generate-config")
generate_config(ARGV[1], ARGV[2], ARGV[3]);
generate_config(ARGV[1], ARGV[2], ARGV[3], ARGV[4]);
else if (mode == "generate-config-fixture")
generate_config_fixture(ARGV[1], ARGV[2], ARGV[3], ARGV[4]);
generate_config_fixture(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]);
else if (mode == "stdin-length")
stdin_length();
else if (mode == "stdin-contains")
+2 -1
View File
@@ -770,7 +770,8 @@ function init_config(populate_nft, caches_prepared, no_refresh) {
"generate-config",
temp_config,
service_listen_address_value(settings),
mwan3_active ? "1" : "0"
mwan3_active ? "1" : "0",
sing_box_is_extended(sing_box_version()) ? "1" : "0"
]) + " >" + shell_quote(runtime_log) + " 2>&1"
);
if (generate_status != 0) {
+218 -1
View File
@@ -99,12 +99,13 @@ generate_config() {
generate_config_with_subscription_cache() {
local fixture="$1"
local output="$2"
local supports_xhttp="${3:-1}"
mkdir -p "$output.section-cache" "$output.rulesets"
TMP_SUBSCRIPTION_FOLDER="$WORK_DIR/subscriptions" \
FORKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR="$WORK_DIR/persistent-subscription-cache" \
ucode -L "$FORKOP_LIB" "$FORKOP_LIB/singbox/generator.uc" generate-config-fixture \
"$fixture" "$output" "127.0.0.1" "0"
"$fixture" "$output" "127.0.0.1" "0" "$supports_xhttp"
}
cat >"$WORK_DIR/no-enabled-fixture.json" <<'JSON'
@@ -614,6 +615,112 @@ cat >"$WORK_DIR/subscription-group-disabled-fixture.json" <<'JSON'
}
JSON
cat >"$WORK_DIR/subscription-xhttp-fixture.json" <<'JSON'
{
"settings": {
".name": "settings",
".type": "settings",
"config_path": "/tmp/sing-box/config.json",
"dns_server": "1.1.1.1",
"service_listen_address": "127.0.0.1"
},
"section": [
{
".name": "xhttp",
".type": "section",
"enabled": "1",
"action": "connection",
"subscription_urls": [ "https://example.com/xhttp.json" ],
"subscription_url_settings": "{\"https://example.com/xhttp.json\":{\"user_agent\":\"Happ\"}}",
"domain_suffix": [ "xhttp.example" ]
}
],
"urltest": [
{
".name": "ut_xhttp",
".type": "urltest",
"section": "xhttp",
"name": "Compatible URLTest"
}
],
"priority_group": [
{
".name": "pg_xhttp",
".type": "priority_group",
"section": "xhttp",
"name": "Compatible Priority"
}
],
"priority_level": [
{
".name": "pl_xhttp",
".type": "priority_level",
"group": "pg_xhttp",
"name": "All compatible",
"order": "0",
"filter_mode": "disabled"
}
]
}
JSON
cat >"$WORK_DIR/subscription-only-xhttp-fixture.json" <<'JSON'
{
"settings": {
".name": "settings",
".type": "settings",
"config_path": "/tmp/sing-box/config.json",
"dns_server": "1.1.1.1",
"service_listen_address": "127.0.0.1"
},
"section": [
{
".name": "only_xhttp",
".type": "section",
"enabled": "1",
"action": "connection",
"subscription_urls": [ "https://example.com/only-xhttp.json" ],
"subscription_url_settings": "{\"https://example.com/only-xhttp.json\":{\"user_agent\":\"Happ\"}}",
"domain_suffix": [ "only-xhttp.example" ]
}
]
}
JSON
cat >"$WORK_DIR/manual-xhttp-fixture.json" <<'JSON'
{
"settings": { ".name": "settings", ".type": "settings", "dns_server": "1.1.1.1" },
"section": [
{
".name": "manual_xhttp",
".type": "section",
"enabled": "1",
"action": "connection",
"selector_proxy_links": [
"vless://00000000-0000-4000-8000-000000000001@xhttp.example:443?encryption=none&security=tls&sni=xhttp.example&type=xhttp#Manual XHTTP"
]
}
]
}
JSON
cat >"$WORK_DIR/json-xhttp-fixture.json" <<'JSON'
{
"settings": { ".name": "settings", ".type": "settings", "dns_server": "1.1.1.1" },
"section": [
{
".name": "json_xhttp",
".type": "section",
"enabled": "1",
"action": "connection",
"outbound_jsons": [
"{\"type\":\"vless\",\"tag\":\"JSON XHTTP\",\"server\":\"xhttp.example\",\"server_port\":443,\"uuid\":\"00000000-0000-4000-8000-000000000002\",\"transport\":{\"type\":\"xhttp\"}}"
]
}
]
}
JSON
mkdir -p "$WORK_DIR/subscriptions" "$WORK_DIR/persistent-subscription-cache"
for source in proxy-subscription-1 proxy-subscription-2 test-subscription-1; do
cat >"$WORK_DIR/subscriptions/$source.json" <<'JSON'
@@ -675,6 +782,74 @@ cat >"$WORK_DIR/subscriptions/grouped-subscription-1.json" <<'JSON'
JSON
printf '%s' 'https://example.com/group.json' >"$WORK_DIR/subscriptions/grouped-subscription-1.url"
printf '%s' 'Happ' >"$WORK_DIR/subscriptions/grouped-subscription-1.user_agent"
cat >"$WORK_DIR/subscriptions/xhttp-subscription-1.json" <<'JSON'
{
"outbounds": [
{
"type": "urltest",
"tag": "Provider XHTTP Group",
"outbounds": [ "xhttp-node", "plain-node" ],
"default": "xhttp-node",
"url": "https://www.gstatic.com/generate_204",
"remark": "Provider XHTTP Group",
"__forkop_allow_group": true
},
{
"type": "vless",
"tag": "xhttp-node",
"remark": "Moscow XHTTP",
"server": "127.0.0.10",
"server_port": 443,
"uuid": "00000000-0000-4000-8000-000000000010",
"transport": { "type": "xhttp" },
"__forkop_hidden": true
},
{
"type": "socks",
"tag": "plain-node",
"remark": "Plain node",
"server": "127.0.0.11",
"server_port": 1080,
"__forkop_hidden": true
},
{
"type": "socks",
"tag": "depends-on-xhttp",
"remark": "Depends on XHTTP",
"server": "127.0.0.12",
"server_port": 1080,
"detour": "xhttp-node"
},
{
"type": "socks",
"tag": "recursive-dependent",
"remark": "Recursive dependent",
"server": "127.0.0.13",
"server_port": 1080,
"detour": "depends-on-xhttp"
}
]
}
JSON
printf '%s' 'https://example.com/xhttp.json' >"$WORK_DIR/subscriptions/xhttp-subscription-1.url"
printf '%s' 'Happ' >"$WORK_DIR/subscriptions/xhttp-subscription-1.user_agent"
cat >"$WORK_DIR/subscriptions/only_xhttp-subscription-1.json" <<'JSON'
{
"outbounds": [
{
"type": "vless",
"tag": "only-xhttp-node",
"remark": "Only XHTTP node",
"server": "127.0.0.20",
"server_port": 443,
"uuid": "00000000-0000-4000-8000-000000000020",
"transport": { "type": "xhttp" }
}
]
}
JSON
printf '%s' 'https://example.com/only-xhttp.json' >"$WORK_DIR/subscriptions/only_xhttp-subscription-1.url"
printf '%s' 'Happ' >"$WORK_DIR/subscriptions/only_xhttp-subscription-1.user_agent"
generate_config "$WORK_DIR/disabled-updates-fixture.json" "$WORK_DIR/disabled.json"
generate_config "$WORK_DIR/default-updates-fixture.json" "$WORK_DIR/default.json"
@@ -692,6 +867,31 @@ generate_config "$WORK_DIR/domain-ip-rulesets-fixture.json" "$WORK_DIR/domain-ip
generate_config_with_subscription_cache "$WORK_DIR/subscription-metadata-fixture.json" "$WORK_DIR/subscription-metadata.json"
generate_config_with_subscription_cache "$WORK_DIR/subscription-group-fixture.json" "$WORK_DIR/subscription-group.json"
generate_config_with_subscription_cache "$WORK_DIR/subscription-group-disabled-fixture.json" "$WORK_DIR/subscription-group-disabled.json"
generate_config_with_subscription_cache "$WORK_DIR/subscription-xhttp-fixture.json" "$WORK_DIR/subscription-xhttp-extended.json" 1
generate_config_with_subscription_cache "$WORK_DIR/subscription-xhttp-fixture.json" "$WORK_DIR/subscription-xhttp-stable.json" 0 \
2>"$WORK_DIR/subscription-xhttp-stable.stderr"
grep -Fq "Moscow XHTTP (XHTTP requires sing-box-extended)" "$WORK_DIR/subscription-xhttp-stable.stderr" ||
fail "stable sing-box XHTTP warning should identify the incompatible subscription outbound"
grep -Fq "Recursive dependent (detour depends on unavailable outbound 'depends-on-xhttp')" "$WORK_DIR/subscription-xhttp-stable.stderr" ||
fail "stable sing-box XHTTP filtering should report recursively pruned detours"
if generate_config_with_subscription_cache "$WORK_DIR/subscription-only-xhttp-fixture.json" "$WORK_DIR/subscription-only-xhttp.json" 0 \
>"$WORK_DIR/subscription-only-xhttp.stdout" 2>"$WORK_DIR/subscription-only-xhttp.stderr"; then
fail "stable sing-box should reject a subscription section with only XHTTP outbounds"
fi
grep -Fq "Only XHTTP node (XHTTP requires sing-box-extended)" "$WORK_DIR/subscription-only-xhttp.stderr" ||
fail "all-XHTTP subscription failure should identify the incompatible outbound"
for fixture in manual-xhttp json-xhttp; do
if ucode -L "$FORKOP_LIB" "$SINGBOX_GENERATOR_UC" generate-config-fixture \
"$WORK_DIR/$fixture-fixture.json" "$WORK_DIR/$fixture-stable.json" "127.0.0.1" "0" "0" \
>"$WORK_DIR/$fixture-stable.stdout" 2>"$WORK_DIR/$fixture-stable.stderr"; then
fail "stable sing-box should reject explicit $fixture configuration"
fi
grep -Fq "uses XHTTP transport, but sing-box-extended is not installed" "$WORK_DIR/$fixture-stable.stderr" ||
fail "explicit $fixture XHTTP failure should explain the extended requirement"
generate_config "$WORK_DIR/$fixture-fixture.json" "$WORK_DIR/$fixture-extended.json"
done
ucode -e '
let fs = require("fs");
@@ -938,6 +1138,23 @@ let disabled_grouped_state = cfg("subscription-group-disabled.json.section-cache
assert(disabled_grouped_state.urltestGroups["Provider Group"] == null, "skipped provider group is not cached as URLTest");
assert(disabled_grouped_state.linkRefs["grouped-out-1"].sourceIndex == 2, "visible leaf keeps source link ref when group import is disabled");
let xhttp_stable = cfg("subscription-xhttp-stable");
assert(outbound(xhttp_stable, "xhttp-node") == null, "stable sing-box excludes XHTTP subscription leaf");
assert(outbound(xhttp_stable, "depends-on-xhttp") == null, "stable sing-box excludes outbound detouring through XHTTP");
assert(outbound(xhttp_stable, "recursive-dependent") == null, "stable sing-box recursively excludes XHTTP-dependent detours");
let stable_provider_group = outbound(xhttp_stable, "Provider XHTTP Group");
assert(stable_provider_group && length(stable_provider_group.outbounds) == 1 && stable_provider_group.outbounds[0] == "plain-node", "provider group removes XHTTP member");
assert(stable_provider_group.default == "plain-node", "provider group default falls back to a compatible member");
let stable_urltest = outbound(xhttp_stable, "xhttp-urltest-ut_xhttp-out");
assert(stable_urltest && length(stable_urltest.outbounds) == 1 && stable_urltest.outbounds[0] == "plain-node", "URLTest receives only compatible subscription leaves");
let stable_priority = outbound(xhttp_stable, "xhttp-priority-pg_xhttp-out");
assert(stable_priority && length(stable_priority.outbounds) == 1 && stable_priority.outbounds[0] == "plain-node", "Priority receives only compatible subscription leaves");
let xhttp_extended = cfg("subscription-xhttp-extended");
assert(outbound(xhttp_extended, "xhttp-node") != null, "extended sing-box keeps XHTTP subscription leaf");
assert(outbound(xhttp_extended, "depends-on-xhttp").detour == "xhttp-node", "extended sing-box keeps XHTTP detour chain");
assert(outbound(xhttp_extended, "recursive-dependent").detour == "depends-on-xhttp", "extended sing-box keeps recursive detour chain");
let proxy_cache = json(fs.readfile(dir + "/subscription-metadata.json.section-cache/proxy.json"));
let test_cache = json(fs.readfile(dir + "/subscription-metadata.json.section-cache/test.json"));
let proxy_share_link_cached = false;