Fix subscription proxy groups in generator

This commit is contained in:
ushan0v
2026-07-11 00:10:54 +03:00
parent 932bd725a8
commit 4f6c692163
4 changed files with 229 additions and 17 deletions
+99 -12
View File
@@ -128,7 +128,7 @@ function uci_cursor() {
}
function runtime_generate_unsupported(reason) {
warn("unsupported: ", reason, "\n");
warn(reason, "\n");
exit(2);
}
@@ -164,6 +164,17 @@ function internal_flag(value) {
return value === true || value == 1 || value == "1" || value == "true" || value == "yes";
}
function subscription_group_outbound(outbound) {
if (type(outbound) != "object")
return false;
let t = as_string(outbound.type);
return (t == "selector" || t == "urltest") && internal_flag(outbound.__podkop_allow_group);
}
function subscription_hidden_outbound(outbound) {
return type(outbound) == "object" && internal_flag(outbound.__podkop_hidden);
}
function tag(base, postfix) {
return runtime_constants.tag(base, postfix);
}
@@ -365,6 +376,8 @@ function supported_subscription_outbound(outbound) {
if (type(outbound) != "object")
return false;
let t = as_string(outbound.type);
if (subscription_group_outbound(outbound))
return true;
if (t == "direct" || t == "selector" || t == "urltest" || t == "dns" || t == "block")
return false;
return t == "vless" || t == "vmess" || t == "trojan" || t == "shadowsocks" ||
@@ -382,6 +395,38 @@ function copy_subscription_outbound(outbound, new_tag) {
return copy;
}
function rewrite_subscription_outbound_references(outbounds, tag_map) {
for (let outbound in outbounds) {
if (type(outbound) != "object")
continue;
let detour = as_string(outbound.detour || "");
if (detour != "" && tag_map[detour])
outbound.detour = tag_map[detour];
if (type(outbound.outbounds) == "array") {
let rewritten = [];
for (let tag_name in outbound.outbounds) {
tag_name = as_string(tag_name);
if (tag_map[tag_name])
push(rewritten, tag_map[tag_name]);
}
outbound.outbounds = rewritten;
}
}
}
function subscription_skip_summary(skipped) {
let parts = [];
for (let t in sort(keys(skipped)))
push(parts, skipped[t] + "x " + t);
return join("; ", parts);
}
function reportable_skipped_subscription_type(t) {
return t != "direct" && t != "selector" && t != "urltest" && t != "dns" && t != "block";
}
function unique_tag(base, taken) {
base = as_string(base);
if (base == "")
@@ -396,7 +441,7 @@ function unique_tag(base, taken) {
return base + "-overflow";
}
function add_subscription_source_with_state(config, section_name, source_index, source_entry, taken, selector_tags, state) {
function add_subscription_source_with_state(config, section_name, source_index, source_entry, taken, selector_tags, detour_tags, state) {
let source_section = runtime_subscription.source_id(section_name, source_index);
if (!runtime_subscription.source_cache_is_current(source_section, source_entry))
return 0;
@@ -406,21 +451,60 @@ function add_subscription_source_with_state(config, section_name, source_index,
return 0;
runtime_subscription.merge_source_metadata(state, section_name, source_section, source_index, source_entry);
let prepared = [];
let source_indices = [];
let display_names = [];
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))
runtime_generate_unsupported("subscription outbound type is not supported by sing-box config generation");
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;
}
let display_name = as_string(outbound.remark || outbound.tag || ("server-" + (i + 1)));
let base = as_string(outbound.tag || outbound.remark || ("server-" + (i + 1)));
let new_tag = unique_tag(base, taken);
taken[new_tag] = true;
push(config.outbounds, copy_subscription_outbound(outbound, new_tag));
if (!internal_flag(outbound.__podkop_hidden)) {
push(selector_tags, new_tag);
runtime_subscription.remember_visible_outbound(state, new_tag, source_section, source_index, i + 1, display_name, outbound);
tag_map[base] = new_tag;
if (as_string(outbound.tag || "") != "")
tag_map[as_string(outbound.tag)] = new_tag;
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(display_names, display_name);
push(group_flags, subscription_group_outbound(outbound));
push(hidden_flags, subscription_hidden_outbound(outbound));
}
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);
let added = 0;
for (let i = 0; i < length(prepared); i++) {
let outbound = prepared[i];
let is_group = group_flags[i] === true;
if (is_group && length(array_or_empty(outbound.outbounds)) == 0) {
warn("skipped empty subscription group for rule '", section_name, "': ", as_string(display_names[i] || outbound.tag || "unknown"), "\n");
continue;
}
push(config.outbounds, outbound);
added++;
if (!is_group)
push(detour_tags, outbound.tag);
if (hidden_flags[i] !== true) {
push(selector_tags, outbound.tag);
runtime_subscription.remember_visible_outbound(state, outbound.tag, source_section, source_index, source_indices[i], display_names[i], outbound);
}
}
return length(outbounds);
return added;
}
function duration_to_seconds(value) {
@@ -1114,19 +1198,22 @@ function add_proxy_outbound(config, section, taken) {
let subscription_urls = list_option(section, "subscription_urls");
let selector_tags = [];
let detour_tags = [];
let state = runtime_subscription.new_section_state(section_name);
let udp_over_tcp = bool_option(section, "enable_udp_over_tcp", false);
let manual_links = list_option(section, "selector_proxy_links");
for (let i = 0; i < length(manual_links); i++)
for (let i = 0; i < length(manual_links); i++) {
add_manual_proxy_link(config, state, section_name, i + 1, manual_links[i], udp_over_tcp, taken, selector_tags);
push(detour_tags, selector_tags[length(selector_tags) - 1]);
}
for (let i = 0; i < length(subscription_urls); i++)
add_subscription_source_with_state(config, section_name, i + 1, subscription_urls[i], taken, selector_tags, state);
add_subscription_source_with_state(config, section_name, i + 1, subscription_urls[i], taken, selector_tags, detour_tags, state);
if (length(selector_tags) == 0)
runtime_generate_unsupported("proxy section has no usable outbounds");
apply_detour_to_outbounds(config, selector_tags, outbound_detour_tag_for_section(section));
apply_detour_to_outbounds(config, detour_tags, outbound_detour_tag_for_section(section));
add_proxy_selector(config, section, selector_tags, state);
if (!atomic_write_json_file(runtime_subscription.section_cache_path(section_name), state))
runtime_generate_unsupported("failed to write section cache for " + section_name);
-1
View File
@@ -682,7 +682,6 @@ function init_config(populate_nft, caches_prepared, no_refresh) {
if (reason == "")
reason = "exit status " + generate_status;
log_message("Failed to generate sing-box configuration: " + reason, "fatal");
log_file_lines(runtime_log, "debug", "sing-box config generator: ");
remove_files([ temp_config, runtime_log ]);
exit(1);
}
+6 -4
View File
@@ -198,10 +198,12 @@ function merge_source_metadata(state, section_name, source_section, source_index
function remember_visible_outbound(state, tag_name, source_section, source_index, source_outbound_index, display_name, outbound) {
if (type(state) != "object")
return;
state.linkRefs[tag_name] = {
sourceSection: source_section,
sourceIndex: source_outbound_index
};
let outbound_type = as_string(outbound.type || "");
if (outbound_type != "selector" && outbound_type != "urltest")
state.linkRefs[tag_name] = {
sourceSection: source_section,
sourceIndex: source_outbound_index
};
state.outboundMetadata.names[tag_name] = display_name;
let server = as_string(outbound.server || "");
if (server != "")
@@ -40,6 +40,12 @@ grep -Fq 'mode == "service-listen-address"' "$SINGBOX_RUNTIME_UC" ||
fail "singbox/runtime.uc must own service listen address detection"
grep -Fq 'mode == "service-proxy-address"' "$SINGBOX_RUNTIME_UC" ||
fail "singbox/runtime.uc must own service proxy address detection"
if grep -Fq 'log_file_lines(runtime_log, "debug", "sing-box config generator: ");' "$SINGBOX_RUNTIME_UC"; then
fail "singbox/runtime.uc must not duplicate generator failure output as debug log"
fi
if grep -Fq 'warn("unsupported: "' "$SINGBOX_GENERATOR_UC"; then
fail "singbox/generator.uc must emit concise generator failure reasons"
fi
if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"' "$SINGBOX_RUNTIME_UC" >/dev/null 2>&1; then
fail "singbox/runtime.uc must use core.uci instead of owning direct UCI cursor or CLI calls"
fi
@@ -101,6 +107,30 @@ generate_config_with_subscription_cache() {
"$fixture" "$output" "127.0.0.1" "0"
}
cat >"$WORK_DIR/no-enabled-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": []
}
JSON
if ucode -L "$PODKOP_LIB" "$SINGBOX_GENERATOR_UC" generate-config-fixture \
"$WORK_DIR/no-enabled-fixture.json" "$WORK_DIR/no-enabled.json" "127.0.0.1" "0" \
>"$WORK_DIR/no-enabled.stdout" 2>"$WORK_DIR/no-enabled.stderr"; then
fail "generator should reject a config without enabled sections"
fi
if grep -Fq 'unsupported:' "$WORK_DIR/no-enabled.stderr"; then
fail "generator failure reason must not include redundant unsupported prefix"
fi
grep -Fxq 'no enabled sections' "$WORK_DIR/no-enabled.stderr" ||
fail "generator failure reason should be concise"
cat >"$WORK_DIR/generator-uci.state" <<'EOF_UCI'
podkop-plus.settings=settings
podkop-plus.settings.dns_server=1.1.1.1
@@ -515,6 +545,40 @@ cat >"$WORK_DIR/subscription-metadata-fixture.json" <<'JSON'
}
JSON
cat >"$WORK_DIR/subscription-group-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": "detour",
".type": "section",
"enabled": "1",
"action": "outbound",
"outbound_json": "{\"type\":\"socks\",\"server\":\"127.0.0.1\",\"server_port\":1081}",
"domain_suffix": [ "detour.example" ]
},
{
".name": "grouped",
".type": "section",
"enabled": "1",
"action": "proxy",
"subscription_urls": [
"https://example.com/group.json | Happ"
],
"outbound_detour_enabled": "1",
"outbound_detour_section": "detour",
"domain_suffix": [ "grouped.example" ]
}
]
}
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'
@@ -532,6 +596,46 @@ printf '%s' 'v2rayN' >"$WORK_DIR/subscriptions/test-subscription-1.user_agent"
printf '%s' 'v2rayN' >"$WORK_DIR/persistent-subscription-cache/proxy-subscription-1.user_agent"
printf '%s' 'sing-box/default' >"$WORK_DIR/persistent-subscription-cache/proxy-subscription-2.user_agent"
printf '%s' 'v2rayN' >"$WORK_DIR/persistent-subscription-cache/test-subscription-1.user_agent"
cat >"$WORK_DIR/subscriptions/grouped-subscription-1.json" <<'JSON'
{
"outbounds": [
{
"type": "urltest",
"tag": "Provider Group",
"outbounds": [ "grouped-out", "leaf" ],
"url": "https://www.gstatic.com/generate_204",
"interval": "10m",
"tolerance": 50,
"remark": "Provider Group",
"__podkop_allow_group": true
},
{
"type": "vless",
"tag": "grouped-out",
"server": "127.0.0.1",
"server_port": 443,
"uuid": "00000000-0000-4000-8000-000000000001",
"tls": { "enabled": true, "server_name": "example.com" },
"__podkop_hidden": true
},
{
"type": "vless",
"tag": "leaf",
"server": "127.0.0.2",
"server_port": 443,
"uuid": "00000000-0000-4000-8000-000000000002",
"tls": { "enabled": true, "server_name": "example.org" },
"__podkop_hidden": true
},
{
"type": "direct",
"tag": "provider-direct"
}
]
}
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"
generate_config "$WORK_DIR/disabled-updates-fixture.json" "$WORK_DIR/disabled.json"
generate_config "$WORK_DIR/default-updates-fixture.json" "$WORK_DIR/default.json"
@@ -547,6 +651,7 @@ generate_config "$WORK_DIR/mwan3-auto-fixture.json" "$WORK_DIR/mwan3-auto.json"
generate_config "$WORK_DIR/mwan3-pinned-fixture.json" "$WORK_DIR/mwan3-pinned.json" 1
generate_config "$WORK_DIR/domain-ip-rulesets-fixture.json" "$WORK_DIR/domain-ip-rulesets.json"
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"
ucode -e '
let fs = require("fs");
@@ -743,6 +848,25 @@ for (let rule in generated_list.rules || []) {
}
assert(has_domain && has_ip, "generated domain/IP source ruleset contents");
let subscription_group = cfg("subscription-group");
let provider_group = outbound(subscription_group, "Provider Group");
assert(provider_group && provider_group.type == "urltest", "provider URLTest group imported");
assert(length(provider_group.outbounds) == 2, "provider URLTest group keeps leaf references");
assert(provider_group.outbounds[0] == "grouped-out-1", "provider URLTest group leaf reference retagged");
assert(provider_group.outbounds[1] == "leaf", "provider URLTest group second leaf reference kept");
assert(provider_group.detour == null, "provider URLTest group does not receive outbound detour");
let grouped_leaf = outbound(subscription_group, "grouped-out-1");
let second_leaf = outbound(subscription_group, "leaf");
assert(grouped_leaf && grouped_leaf.detour == "detour-out", "hidden subscription leaf receives outbound detour");
assert(second_leaf && second_leaf.detour == "detour-out", "second hidden subscription leaf receives outbound detour");
assert(outbound(subscription_group, "provider-direct") == null, "provider direct outbound skipped");
let grouped_selector = outbound(subscription_group, "grouped-out");
assert(grouped_selector && length(grouped_selector.outbounds) == 1 && grouped_selector.outbounds[0] == "Provider Group", "selector exposes provider group only");
let grouped_state = cfg("subscription-group.json.section-cache/grouped");
assert(grouped_state.outboundMetadata.names["Provider Group"] == "Provider Group", "provider group metadata visible");
assert(grouped_state.outboundMetadata.names["grouped-out-1"] == null, "hidden leaf metadata not visible");
assert(grouped_state.linkRefs["Provider Group"] == null, "provider group has no source link ref");
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_metadata = as_array(proxy_cache.subscriptionMetadata);