From 9cebcdaeaf3047f3eedf7ed330637a3cd246cf6b Mon Sep 17 00:00:00 2001 From: Asofwar <7397608+Asofwar@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:51:07 +0000 Subject: [PATCH 1/9] generator: a reused server name selects only its current owner in URLTest and Priority filters --- prokop/files/usr/lib/singbox/generator.uc | 13 +++++++-- tests/urltest_reused_name_filter.sh | 34 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) create mode 100755 tests/urltest_reused_name_filter.sh diff --git a/prokop/files/usr/lib/singbox/generator.uc b/prokop/files/usr/lib/singbox/generator.uc index 93e30bedd..9b9cc3a0a 100644 --- a/prokop/files/usr/lib/singbox/generator.uc +++ b/prokop/files/usr/lib/singbox/generator.uc @@ -1447,12 +1447,15 @@ function regex_match_set(tags, names, regexes) { return object_keys_set(runtime_urltest.regex_matching_tag_array(tags, names, regexes)); } -function tag_name_filter_matches(tag, names, name_filter, regex_set, metadata) { +// A previous name of a renamed server (filterNames) selects it only while no +// candidate carries that name now: a name given to another server selects +// that server alone. +function tag_name_filter_matches(tag, names, name_filter, regex_set, metadata, current_names) { let name = tag_display_name(tag, names); if (array_contains(name_filter, name) || regex_set[tag]) return true; for (let previous_name in array_or_empty(object_or_empty(object_or_empty(metadata).filterNames)[tag])) - if (array_contains(name_filter, previous_name)) + if (!current_names[previous_name] && array_contains(name_filter, previous_name)) return true; return false; } @@ -1527,9 +1530,10 @@ function urltest_matching_candidate_outbounds(urltest_candidate_tags, names, cou let base_filter_configured = name_or_country_filter_configured(name_filter, regexes, country_filter); let additional_set = object_keys_set(additional_matches); let result = []; + current_names = object_or_empty(current_names); for (let tag in array_or_empty(urltest_candidate_tags)) { - let base_matches = tag_name_filter_matches(tag, names, name_filter, regex_set, metadata) || + let base_matches = tag_name_filter_matches(tag, names, name_filter, regex_set, metadata, current_names) || tag_country_filter_matches(tag, countries, country_filter); let matches = additional_set[tag] || base_matches; if (proxy_parameters_enabled && proxy_parameters_operator == "or") { @@ -1602,6 +1606,9 @@ function filter_candidate_outbounds(filter_mode, urltest_candidate_tags, names, "or", exclude_protocols, exclude_transports, + let current_names = {}; + for (let tag in array_or_empty(urltest_candidate_tags)) + current_names[tag_display_name(tag, names)] = true; exclude_securities, exclude_additional_matches ); diff --git a/tests/urltest_reused_name_filter.sh b/tests/urltest_reused_name_filter.sh new file mode 100755 index 000000000..9a179f4d6 --- /dev/null +++ b/tests/urltest_reused_name_filter.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# A name filter of URLTest/Priority: a previous name of a renamed server +# selects it only while no candidate carries that name now (D1). +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +GENERATOR_UC="$ROOT_DIR/prokop/files/usr/lib/singbox/generator.uc" + +{ + cat <<'UCODE' +function array_or_empty(v) { return type(v) == "array" ? v : []; } +function object_or_empty(v) { return type(v) == "object" ? v : {}; } +function as_string(v) { return v == null ? "" : "" + v; } +UCODE + for name in array_contains tag_display_name tag_name_filter_matches; do + sed -n "/^function $name(/,/^}/p" "$GENERATOR_UC" + done + cat <<'UCODE' +function check(ok, message) { if (!ok) die("FAIL: " + message + "\n"); } +let names = { fresh: "Berlin #1", renamed: "Berlin #2" }; +let metadata = { filterNames: { renamed: [ "Berlin #1" ] } }; +let current = { "Berlin #1": true, "Berlin #2": true }; +check(tag_name_filter_matches("fresh", names, [ "Berlin #1" ], {}, metadata, current), "the current owner of a name matches"); +check(!tag_name_filter_matches("renamed", names, [ "Berlin #1" ], {}, metadata, current), "a reused previous name does not select the renamed server"); +check(tag_name_filter_matches("renamed", names, [ "Berlin #2" ], {}, metadata, current), "the renamed server matches its new name"); +delete current["Berlin #1"]; +check(tag_name_filter_matches("renamed", names, [ "Berlin #1" ], {}, metadata, current), "a plain rename keeps the filter on the server"); +current["Berlin #1"] = true; +check(tag_name_filter_matches("renamed", names, [], { renamed: true }, metadata, current), "a regex match does not depend on names"); +check(!tag_name_filter_matches("renamed", names, [ "Other" ], {}, metadata, current), "an unrelated name does not match"); +check(tag_name_filter_matches("renamed", names, [ "Berlin #1" ], {}, metadata), "without the candidate names the history still applies"); +print("urltest reused name filter checks passed\n"); +UCODE +} | ucode - From f42dfbb2f16cdedd4ef3beba0ad1e1015593ac4a Mon Sep 17 00:00:00 2001 From: Asofwar <7397608+Asofwar@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:51:21 +0000 Subject: [PATCH 2/9] generator: leave out xHTTP settings sing-box-extended 2.7.3 refuses for the whole configuration --- prokop/files/usr/lib/singbox/generator.uc | 84 ++++++++++++++++++++-- tests/xhttp_core_refused_settings.sh | 85 +++++++++++++++++++++++ 2 files changed, 165 insertions(+), 4 deletions(-) create mode 100755 tests/xhttp_core_refused_settings.sh diff --git a/prokop/files/usr/lib/singbox/generator.uc b/prokop/files/usr/lib/singbox/generator.uc index 9b9cc3a0a..5b50c2236 100644 --- a/prokop/files/usr/lib/singbox/generator.uc +++ b/prokop/files/usr/lib/singbox/generator.uc @@ -772,6 +772,76 @@ function drop_unknown_xhttp_fields(options, dropped) { } } +// The named session ID alphabets of sing-box-extended (and Xray). +const XHTTP_SESSION_ID_TABLES = { + ALPHABET: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + Alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", + BASE36: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", + Base62: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", + HEX: "0123456789ABCDEF", + alphabet: "abcdefghijklmnopqrstuvwxyz", + base36: "0123456789abcdefghijklmnopqrstuvwxyz", + hex: "0123456789abcdef", + number: "0123456789" +}; +// The fewest session IDs a table and length must allow (2 << 30). +const XHTTP_SESSION_ID_MIN_ROOM = 2147483648; + +// [ from, to ] of an xHTTP range as sing-box reads it (a number, "from-to" +// or { from, to }); [ 0, 0 ] when absent or unreadable. +function xhttp_range_bounds(value) { + if (type(value) == "int" || type(value) == "double") + return [ int(value), int(value) ]; + if (type(value) == "object") + return [ int(value.from || 0), int(value.to || 0) ]; + let text = trim(as_string(value)); + let parts = match(text, /^([0-9]+)(-([0-9]+))?$/); + if (parts == null) + return [ 0, 0 ]; + return [ int(parts[1]), int(parts[3] != null ? parts[3] : parts[1]) ]; +} + +// Whether a session ID table and length pass the check sing-box-extended +// 2.7.3 makes on the whole configuration: a length from 1 and room for +// 2^31 IDs, ASCII only. An older core used such a table with no length as +// no table at all (a UUID session ID). +function xhttp_session_id_settings_valid(options) { + let table = as_string(options.session_id_table); + table = XHTTP_SESSION_ID_TABLES[table] || table; + if (match(table, /^[ -~]+$/) == null) + return false; + let bounds = xhttp_range_bounds(options.session_id_length); + if (bounds[0] <= 0 || bounds[1] < bounds[0]) + return false; + let room = 0; + for (let n = bounds[0]; n <= bounds[1] && room < XHTTP_SESSION_ID_MIN_ROOM; n++) + room += length(table) ** n; + return room >= XHTTP_SESSION_ID_MIN_ROOM; +} + +// Settings that every core of sing-box-extended 2.7.3 or newer refuses for +// the whole configuration, removed from one xHTTP options object (the +// outbound's transport or its download options). +function drop_refused_xhttp_settings(options, dropped) { + if (type(options) != "object") + return; + if (exists(options, "session_id_table") && !xhttp_session_id_settings_valid(options)) { + delete options.session_id_table; + delete options.session_id_length; + push(dropped, "session_id_table"); + } + if (options.mode == "stream-one" && exists(options, "download")) { + delete options.download; + push(dropped, "download"); + } + let xmux = options.xmux; + if (type(xmux) == "object" && xhttp_range_bounds(xmux.max_connections)[1] > 0 && + xhttp_range_bounds(xmux.max_concurrency)[1] > 0) { + delete xmux.max_connections; + push(dropped, "xmux.max_connections"); + } +} + // The settings this core does not read are left out of every xHTTP // outbound, also its download options, with a warning: the node may then // not reach its server, but the configuration stays valid. Its ranges are @@ -780,6 +850,12 @@ function drop_unsupported_xhttp_settings(config) { for (let outbound in array_or_empty(config.outbounds)) { if (!outbound_uses_xhttp(outbound)) continue; + let refused = []; + drop_refused_xhttp_settings(outbound.transport, refused); + drop_refused_xhttp_settings(outbound.transport.download, refused); + if (length(refused) > 0) + warn("xHTTP settings of outbound '", as_string(outbound.tag), "' that sing-box refuses for the whole configuration were left out: ", + join(", ", uniq(refused)), "\n"); let dropped = []; drop_unknown_xhttp_fields(outbound.transport, dropped); drop_unknown_xhttp_fields(outbound.transport.download, dropped); @@ -1454,6 +1530,7 @@ function tag_name_filter_matches(tag, names, name_filter, regex_set, metadata, c let name = tag_display_name(tag, names); if (array_contains(name_filter, name) || regex_set[tag]) return true; + current_names = object_or_empty(current_names); for (let previous_name in array_or_empty(object_or_empty(object_or_empty(metadata).filterNames)[tag])) if (!current_names[previous_name] && array_contains(name_filter, previous_name)) return true; @@ -1529,8 +1606,10 @@ function urltest_matching_candidate_outbounds(urltest_candidate_tags, names, cou let regex_set = regex_match_set(urltest_candidate_tags, names, regexes); let base_filter_configured = name_or_country_filter_configured(name_filter, regexes, country_filter); let additional_set = object_keys_set(additional_matches); + let current_names = {}; + for (let tag in array_or_empty(urltest_candidate_tags)) + current_names[tag_display_name(tag, names)] = true; let result = []; - current_names = object_or_empty(current_names); for (let tag in array_or_empty(urltest_candidate_tags)) { let base_matches = tag_name_filter_matches(tag, names, name_filter, regex_set, metadata, current_names) || @@ -1606,9 +1685,6 @@ function filter_candidate_outbounds(filter_mode, urltest_candidate_tags, names, "or", exclude_protocols, exclude_transports, - let current_names = {}; - for (let tag in array_or_empty(urltest_candidate_tags)) - current_names[tag_display_name(tag, names)] = true; exclude_securities, exclude_additional_matches ); diff --git a/tests/xhttp_core_refused_settings.sh b/tests/xhttp_core_refused_settings.sh new file mode 100755 index 000000000..445a31119 --- /dev/null +++ b/tests/xhttp_core_refused_settings.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +# sing-box-extended 2.7.3 refuses the whole configuration for an xHTTP +# session ID table without a length or with room for fewer than 2^31 IDs, +# and for xmux max_connections together with max_concurrency (D4). The +# generator leaves such a setting out of the one outbound, with a warning; +# an older core used a table without a length as no table at all. With +# $PROKOP_TEST_SING_BOX_EXTENDED every result passes "sing-box check". + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LIB="$ROOT_DIR/prokop/files/usr/lib" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT HUP INT TERM +fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; } + +# generate NAME EXTRA_JSON: the transport of the generated xHTTP outbound +# of a share link with that "extra", in $WORK/NAME.transport.json. +generate() { + local extra + extra="$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$2")" + cat >"$WORK/$1.fixture.json" <"$WORK/$1.stderr" || + fail "the configuration of $1 was not generated: $(cat "$WORK/$1.stderr")" + node -e ' + const c = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + const t = c.outbounds.find((o) => o.transport && o.transport.type === "xhttp").transport; + require("fs").writeFileSync(process.argv[2], JSON.stringify(t));' "$WORK/$1.json" "$WORK/$1.transport.json" +} +# has NAME KEY: whether the transport of NAME has KEY (a.b for nested keys). +has() { + node -e ' + let v = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + for (const k of process.argv[2].split(".")) v = v == null ? undefined : v[k]; + process.exit(v === undefined ? 1 : 0);' "$WORK/$1.transport.json" "$2" +} +refused() { grep -Fq "sing-box refuses for the whole configuration were left out: $2" "$WORK/$1.stderr"; } + +generate nolength '{"sessionIDTable":"hex"}' +! has nolength session_id_table || fail "a table without a length was kept" +refused nolength session_id_table || fail "the left-out table was not reported: $(cat "$WORK/nolength.stderr")" + +generate short '{"sessionIDTable":"hex","sessionIDLength":"4-4"}' +! has short session_id_table && ! has short session_id_length || fail "a table with room for 16^4 IDs was kept" + +generate small '{"sessionIDTable":"number","sessionIDLength":"1-9"}' +! has small session_id_table || fail "a table with room for fewer than 2^31 IDs was kept" + +generate enough '{"sessionIDTable":"number","sessionIDLength":"1-10"}' +has enough session_id_table && has enough session_id_length || fail "a table with room for 2^31 IDs was left out" +[ ! -s "$WORK/enough.stderr" ] || ! grep -Fq "refuses" "$WORK/enough.stderr" || fail "a valid table was reported" + +generate custom '{"sessionIDTable":"abcdef","sessionIDLength":"8-16"}' +has custom session_id_table || fail "a custom table with enough room was left out" + +generate xmux '{"xmux":{"maxConcurrency":"16-32","maxConnections":"4"}}' +has xmux xmux.max_concurrency && ! has xmux xmux.max_connections || fail "xmux kept max_connections with max_concurrency" +refused xmux xmux.max_connections || fail "the left-out xmux setting was not reported" + +generate xmuxone '{"xmux":{"maxConnections":"4"}}' +has xmuxone xmux.max_connections || fail "xmux lost max_connections alone" + +SBX="${PROKOP_TEST_SING_BOX_EXTENDED:-}" +if [ -z "$SBX" ] || [ ! -x "$SBX" ]; then + printf 'xhttp_core_refused_settings: OK (real sing-box-extended not checked: set PROKOP_TEST_SING_BOX_EXTENDED)\n' + exit 0 +fi +for name in nolength short small enough custom xmux xmuxone; do + node -e ' + const c = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + const o = c.outbounds.find((x) => x.transport && x.transport.type === "xhttp"); + process.stdout.write(JSON.stringify({ outbounds: [o] }));' "$WORK/$name.json" >"$WORK/check-$name.json" + "$SBX" check -c "$WORK/check-$name.json" >"$WORK/check.log" 2>&1 || + fail "sing-box-extended refused the $name outbound: $(cat "$WORK/check.log")" +done +echo "xhttp_core_refused_settings: OK" From c978f58468f29e03f6f531f65da52ca65688d128 Mon Sep 17 00:00:00 2001 From: Asofwar <7397608+Asofwar@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:51:21 +0000 Subject: [PATCH 3/9] parser: the session ID table check works with musl's regex (no repeat count above 255) --- prokop/files/usr/lib/subscription/parser.uc | 4 +++- tests/ucode_regex_repeat_limit.sh | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100755 tests/ucode_regex_repeat_limit.sh diff --git a/prokop/files/usr/lib/subscription/parser.uc b/prokop/files/usr/lib/subscription/parser.uc index 77f7a50f8..5f0681e47 100644 --- a/prokop/files/usr/lib/subscription/parser.uc +++ b/prokop/files/usr/lib/subscription/parser.uc @@ -322,8 +322,10 @@ function xhttp_extended_value(kind, value, mode) { return xhttp_positive_integer_value(value); if (kind == "key") return match(text, /^[A-Za-z0-9_.-]{1,64}$/) != null ? text : null; + // musl's regcomp (OpenWrt) refuses a repeat count above 255: the length + // is checked apart from the pattern. if (kind == "table") - return match(text, /^[!-~]{1,256}$/) != null ? text : null; + return length(text) <= 256 && match(text, /^[!-~]+$/) != null ? text : null; if (kind == "method") { text = uc(text); if (match(text, /^[A-Z]{1,16}$/) == null) diff --git a/tests/ucode_regex_repeat_limit.sh b/tests/ucode_regex_repeat_limit.sh new file mode 100755 index 000000000..b23d32172 --- /dev/null +++ b/tests/ucode_regex_repeat_limit.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# musl's regcomp, which ucode uses on OpenWrt, refuses a repeat count above +# RE_DUP_MAX (255): such a pattern is a runtime "Syntax error" on the router +# while a glibc build accepts it. No pattern of Prokop's may use one. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +found="$(grep -rnoE '\{[0-9]*,?[0-9]*\}' --include='*.uc' "$ROOT_DIR/prokop/files" "$ROOT_DIR/luci-app-prokop" 2>/dev/null | + awk -F: '{ n = $NF; gsub(/[{}]/, "", n); split(n, b, ","); if (b[1] + 0 > 255 || b[2] + 0 > 255) print }' || true)" +if [ -n "$found" ]; then + printf 'FAIL: a regex repeat count above 255 fails on musl:\n%s\n' "$found" >&2 + exit 1 +fi +printf 'ucode_regex_repeat_limit: PASS\n' From c07fe4a0baa139f375dae302f1ad1d35d9cf88f8 Mon Sep 17 00:00:00 2001 From: Asofwar <7397608+Asofwar@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:51:21 +0000 Subject: [PATCH 4/9] subscriptions: a scheduled update does not reload sing-box for new server names alone --- prokop/files/usr/lib/subscription/cache.uc | 14 ++- prokop/files/usr/lib/subscription/parser.uc | 21 +++-- tests/subscription_names_only_update.sh | 99 +++++++++++++++++++++ 3 files changed, 126 insertions(+), 8 deletions(-) create mode 100755 tests/subscription_names_only_update.sh diff --git a/prokop/files/usr/lib/subscription/cache.uc b/prokop/files/usr/lib/subscription/cache.uc index 567e6c100..18eee0b35 100644 --- a/prokop/files/usr/lib/subscription/cache.uc +++ b/prokop/files/usr/lib/subscription/cache.uc @@ -1953,6 +1953,10 @@ function get_subscription_download_proxy_address(section_name_value, sections, p return address; } +// An update the schedule started (not the user): it does not reload sing-box +// for new names alone (download_subscription_into_cache). +let scheduled_update = false; + function download_subscription_into_cache(section_name_value, subscription_url, subscription_json_path, subscription_url_cache_path, service_proxy_address_value, subscription_user_agent, subscription_hwid, source_index, cache_section, metadata_output_path, sections) { ensure_dir(TMP_SUBSCRIPTION_FOLDER); let subscription_user_agent_cache_path = source_user_agent_path(TMP_SUBSCRIPTION_FOLDER, cache_section); @@ -2054,7 +2058,14 @@ function download_subscription_into_cache(section_name_value, subscription_url, return 2; } - if (file_nonempty(subscription_json_path) && parser.runtime_outbounds_equal(normalized_tmpfile, subscription_json_path)) { + // A scheduled update also keeps the applied list when only the names + // changed: panels write traffic or days left into every name, and a + // reload for that drops every proxied connection. A manual update or + // the next real change applies the names. + let runtime_unchanged = file_nonempty(subscription_json_path) && + (parser.runtime_outbounds_equal(normalized_tmpfile, subscription_json_path) || + (scheduled_update && parser.runtime_outbounds_equal(normalized_tmpfile, subscription_json_path, true))); + if (runtime_unchanged) { // Keep the already applied representation and outbound order. The // refreshed file differs only in metadata ignored by the runtime // comparison or in provider ordering, neither of which should @@ -2573,6 +2584,7 @@ function print_subscription_update_summary(summary) { function subscription_update_request(force_value, target_section_name, target_source_index) { let force = as_string(force_value) == "1"; + scheduled_update = !force; let sections = uci_sections(); let summary = empty_subscription_update_summary(); diff --git a/prokop/files/usr/lib/subscription/parser.uc b/prokop/files/usr/lib/subscription/parser.uc index 5f0681e47..91b66ae8b 100644 --- a/prokop/files/usr/lib/subscription/parser.uc +++ b/prokop/files/usr/lib/subscription/parser.uc @@ -501,7 +501,10 @@ function canonical_runtime_value(value) { return value; } -function runtime_outbounds_signature(path) { +// without_names: the name of each entry (its tag) is left out, so two lists +// that differ only in the names a provider writes (traffic or days left) +// compare equal. A reference to another entry by name still counts. +function runtime_outbounds_signature(path, without_names) { let subscription = read_json_file(path); if (type(subscription) != "object" || type(subscription.outbounds) != "array") return null; @@ -512,17 +515,21 @@ function runtime_outbounds_signature(path) { // Nested arrays remain order-sensitive because their order may be part of // an individual outbound's runtime configuration. let result = []; - for (let outbound in subscription.outbounds) - push(result, sprintf("%J", canonical_runtime_value(outbound))); + for (let outbound in subscription.outbounds) { + let value = canonical_runtime_value(outbound); + if (without_names && type(value) == "object") + delete value.tag; + push(result, sprintf("%J", value)); + } sort(result, function(a, b) { return a < b ? -1 : (a > b ? 1 : 0); }); return result; } -function runtime_outbounds_equal(left_path, right_path) { - let left = runtime_outbounds_signature(left_path); - let right = runtime_outbounds_signature(right_path); +function runtime_outbounds_equal(left_path, right_path, without_names) { + let left = runtime_outbounds_signature(left_path, without_names); + let right = runtime_outbounds_signature(right_path, without_names); if (left == null || right == null) return false; @@ -3666,7 +3673,7 @@ else if (mode == "normalize-content-validated") else if (mode == "try-decode-gzip-content") ok = try_decode_gzip_content_file(ARGV[1]); else if (mode == "runtime-outbounds-equal") - ok = runtime_outbounds_equal(ARGV[1], ARGV[2]); + ok = runtime_outbounds_equal(ARGV[1], ARGV[2], ARGV[3] == "without-names"); else if (mode == "normalized-skipped-warning") ok = normalized_skipped_warning(ARGV[1]); else if (mode == "metadata-headers-json") diff --git a/tests/subscription_names_only_update.sh b/tests/subscription_names_only_update.sh new file mode 100755 index 000000000..742949fc0 --- /dev/null +++ b/tests/subscription_names_only_update.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -eo pipefail + +# A scheduled subscription update whose servers changed only their names +# (panels write traffic or days left into every name) keeps the applied +# list: no sing-box reload drops the proxied connections for that. A manual +# update applies the names, and a scheduled one any real change. +# +# subscription/cache.uc runs for real; curl is a stub that answers with one +# VLESS link whose name and port come from files. + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROKOP_LIB="$ROOT_DIR/prokop/files/usr/lib" +CACHE_UC="$PROKOP_LIB/subscription/cache.uc" +WORK_DIR="$(mktemp -d)" + +cleanup() { rm -rf "$WORK_DIR"; } +trap cleanup EXIT +fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; } + +mkdir -p "$WORK_DIR/bin" +cat >"$WORK_DIR/bin/curl" <<'SH' +#!/usr/bin/env bash +out="" +while [ "$#" -gt 0 ]; do + [ "$1" = -o ] && { out="$2"; shift; } + shift +done +printf 'vless://11111111-1111-1111-1111-111111111111@192.0.2.10:%s?security=none&type=tcp#%s\n' \ + "$(cat "$NODE_PORT_FILE")" "$(cat "$NODE_NAME_FILE")" >"$out" +SH +printf '#!/bin/sh\nexit 0\n' >"$WORK_DIR/bin/logger" +chmod +x "$WORK_DIR/bin/"* + +export PATH="$WORK_DIR/bin:$PATH" +export NODE_NAME_FILE="$WORK_DIR/name" NODE_PORT_FILE="$WORK_DIR/port" +export TMP_SING_BOX_FOLDER="$WORK_DIR/sing-box" +export TMP_SUBSCRIPTION_FOLDER="$WORK_DIR/sing-box/subscriptions" +export PROKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR="$WORK_DIR/persistent" +export PROKOP_RUNTIME_STATE_DIR="$WORK_DIR/run" +export PROKOP_SUBSCRIPTION_METADATA_DIR="$WORK_DIR/run/subscription-metadata" +export PROKOP_OUTBOUND_METADATA_DIR="$WORK_DIR/run/outbound-metadata" +export PROKOP_SECTION_CACHE_DIR="$WORK_DIR/run/section-cache" +export PROKOP_SUBSCRIPTION_LINKS_DIR="$WORK_DIR/run/subscription-links" +export PROKOP_SUBSCRIPTION_UPDATE_STATE_DIR="$WORK_DIR/run/subscription-update" +export PROKOP_UCI_STATE_FILE="$WORK_DIR/uci.state" +export PROKOP_LIB + +{ + printf 'prokop.settings=settings\n' + printf 'prokop.vpn=section\nprokop.vpn.enabled=1\nprokop.vpn.action=connection\nprokop.vpn.proxy_config_type=subscription\n' + printf 'prokop.s1=subscription_url\nprokop.s1.section=vpn\nprokop.s1.url=https://sub.example/api/sub\n' + printf 'prokop.s1.subscription_update_interval=1m\n' +} >"$WORK_DIR/uci.state" + +cache() { ucode -L "$PROKOP_LIB" "$CACHE_UC" "$@"; } +# request FORCE: "updated failed unchanged superseded" of an update request; +# a scheduled one (0) is made due by forgetting its last run. +request() { + rm -f "$PROKOP_SUBSCRIPTION_UPDATE_STATE_DIR/vpn.timestamp" + cache update-request "$1" 2>"$WORK_DIR/request.err" | tail -n 1 | tr "\t" " " +} +applied_name() { + node -e ' + const fs = require("fs"), path = require("path"); + const dir = process.argv[1]; + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json") && !f.includes("metadata")); + for (const f of files) { + const v = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); + if (Array.isArray(v.outbounds) && v.outbounds.length) { process.stdout.write(v.outbounds[0].tag); process.exit(0); } + } + process.exit(1);' "$TMP_SUBSCRIPTION_FOLDER" +} + +printf 'Node 10 GB left' >"$NODE_NAME_FILE" +printf 443 >"$NODE_PORT_FILE" +cache update-source vpn 1 https://sub.example/api/sub >"$WORK_DIR/first.log" 2>&1 || + fail "the first update failed: $(cat "$WORK_DIR/first.log")" +[ "$(applied_name)" = "Node 10 GB left" ] || fail "the first update applied '$(applied_name)'" + +# 1. Scheduled, names only: unchanged, the applied name stays. +printf 'Node 9 GB left' >"$NODE_NAME_FILE" +result="$(request 0)" +[ "${result%% *}" = 0 ] || fail "a scheduled update of new names only counted as updated: '$result' $(cat "$WORK_DIR/request.err")" +[ "$(applied_name)" = "Node 10 GB left" ] || fail "a scheduled update applied new names only: $(applied_name)" + +# 2. Manual: the names are applied. +result="$(request 1)" +[ "${result%% *}" = 1 ] || fail "a manual update of new names was not applied: '$result'" +[ "$(applied_name)" = "Node 9 GB left" ] || fail "a manual update did not apply the name: $(applied_name)" + +# 3. Scheduled, a real change: applied with its names. +printf 'Node 8 GB left' >"$NODE_NAME_FILE" +printf 8443 >"$NODE_PORT_FILE" +result="$(request 0)" +[ "${result%% *}" = 1 ] || fail "a scheduled update of a changed server was not applied: '$result' $(cat "$WORK_DIR/request.err")" +[ "$(applied_name)" = "Node 8 GB left" ] || fail "a scheduled real change did not bring its names: $(applied_name)" + +printf 'subscription_names_only_update: PASS\n' From d785acd2117319b3025b745bf0a7664023734c24 Mon Sep 17 00:00:00 2001 From: Asofwar <7397608+Asofwar@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:51:21 +0000 Subject: [PATCH 5/9] nft: only TCP and UDP to a proxy section's subnets get the TPROXY mark --- prokop/files/usr/lib/nft/apply.uc | 10 ++++++++-- tests/nft_apply.sh | 4 ++-- tests/nft_device_mac.sh | 2 +- tests/nft_device_mac_real.sh | 8 ++++---- tests/singbox_dpi.sh | 2 +- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/prokop/files/usr/lib/nft/apply.uc b/prokop/files/usr/lib/nft/apply.uc index a5e198077..df4662f0d 100644 --- a/prokop/files/usr/lib/nft/apply.uc +++ b/prokop/files/usr/lib/nft/apply.uc @@ -945,8 +945,14 @@ function nft_add_section_priority_rules(table, section, interface_set, localv4_s if (needs_udp_ip_port_rules && !nft_add_subnet_ports(table, sets.udp_subnet_ports, core_ip.DISCORD_VOICE_PORTS_NFT)) return false; let has_port_only_matchers = section_has_nft_port_only_matchers(section); - let match_ip4 = [ "ip", "daddr", "@" + as_string(sets.subnets) ]; - let match_ip6 = [ "ip6", "daddr", "@" + as_string(sets.subnets6) ]; + // A captured packet gets the mark of the TPROXY local route, which takes + // only TCP and UDP: an ICMP echo to a listed subnet would be answered by + // the router itself and other protocols lost. They keep the main table. + // The kill-switch (its own verdict) still stops every protocol. + let capture_l4 = section_priority_action(section) == "capture" && nft_priority_verdict_override == null ? + [ "meta", "l4proto", "{ tcp, udp }" ] : []; + let match_ip4 = [ "ip", "daddr", "@" + as_string(sets.subnets), ...capture_l4 ]; + let match_ip6 = [ "ip6", "daddr", "@" + as_string(sets.subnets6), ...capture_l4 ]; let match_ip_port4_tcp = [ "ip", "daddr", ".", "tcp", "dport", "@" + as_string(sets.ip_ports) ]; let match_ip_port4_udp = [ "ip", "daddr", ".", "udp", "dport", "@" + as_string(sets.ip_ports) ]; let match_ip_port6_tcp = [ "ip6", "daddr", ".", "tcp", "dport", "@" + as_string(sets.ip6_ports) ]; diff --git a/tests/nft_apply.sh b/tests/nft_apply.sh index e9ce57848..7650055cf 100644 --- a/tests/nft_apply.sh +++ b/tests/nft_apply.sh @@ -342,7 +342,7 @@ assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tProkopTable\tpriority_rules\t assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tProkopTable\tpriority_rules\tiifname\t@prokop_interfaces\tip6\tsaddr\t@prokop_rule_bypass_first_fully_sources6\tip6\tdaddr\t!=\t@localv6\tip6\tdaddr\t!=\tfc00::/18\tcounter\taccept' "bypass fully routed6 real IP fast path" assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tProkopTable\tpriority_rules\tiifname\t@prokop_interfaces\tip\tsaddr\t@prokop_rule_wide_proxy_fully_sources\tip\tdaddr\t!=\t@localv4\tmeta\tl4proto\ttcp\tmeta\tmark\tset\t0x00100000\tcounter\taccept' "proxy fully routed capture path" assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tProkopTable\tpriority_rules\tiifname\t@prokop_interfaces\tip\tdaddr\t!=\t@localv4\tip\tdaddr\t!=\t198.18.0.0/15\tip\tdaddr\t@prokop_rule_bypass_first_subnets\tcounter\taccept' "bypass priority accept rule" -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tProkopTable\tpriority_rules\tiifname\t@prokop_interfaces\tip\tdaddr\t!=\t@localv4\tip\tdaddr\t@prokop_rule_wide_proxy_subnets\tmeta\tmark\tset\t0x00100000\tcounter\taccept' "proxy priority capture rule" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tProkopTable\tpriority_rules\tiifname\t@prokop_interfaces\tip\tdaddr\t!=\t@localv4\tip\tdaddr\t@prokop_rule_wide_proxy_subnets\tmeta\tl4proto\t{ tcp, udp }\tmeta\tmark\tset\t0x00100000\tcounter\taccept' "proxy priority capture rule" assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tProkopTable\tpriority_rules\tiifname\t@prokop_interfaces\tip\tdaddr\t!=\t@localv4\tip\tdaddr\t!=\t198.18.0.0/15\ttcp\tdport\t@prokop_rule_port_bypass_ports\tcounter\taccept' "port-only bypass priority rule" # FakeIP addresses are never fast-pathed by a bypass rule (UC-029), in both # chains and families; capture rules keep matching them. @@ -353,7 +353,7 @@ if grep -F $'\tmeta\tmark\tset\t' "$NFT_LOG" | grep -Fv 'fully_sources' | grep - fi assert_line_before "$NFT_LOG" \ $'nft\tadd\trule\tinet\tProkopTable\tpriority_rules\tiifname\t@prokop_interfaces\tip\tdaddr\t!=\t@localv4\tip\tdaddr\t!=\t198.18.0.0/15\tip\tdaddr\t@prokop_rule_bypass_first_subnets\tcounter\taccept' \ - $'nft\tadd\trule\tinet\tProkopTable\tpriority_rules\tiifname\t@prokop_interfaces\tip\tdaddr\t!=\t@localv4\tip\tdaddr\t@prokop_rule_wide_proxy_subnets\tmeta\tmark\tset\t0x00100000\tcounter\taccept' \ + $'nft\tadd\trule\tinet\tProkopTable\tpriority_rules\tiifname\t@prokop_interfaces\tip\tdaddr\t!=\t@localv4\tip\tdaddr\t@prokop_rule_wide_proxy_subnets\tmeta\tl4proto\t{ tcp, udp }\tmeta\tmark\tset\t0x00100000\tcounter\taccept' \ "bypass priority order" assert_line_before "$NFT_LOG" \ $'nft\tadd\trule\tinet\tProkopTable\tpriority_rules\tiifname\t@prokop_interfaces\tip\tsaddr\t@prokop_rule_bypass_first_fully_sources\tip\tdaddr\t!=\t@localv4\tip\tdaddr\t!=\t198.18.0.0/15\tcounter\taccept' \ diff --git a/tests/nft_device_mac.sh b/tests/nft_device_mac.sh index d454cff3f..8c48c6fe9 100755 --- a/tests/nft_device_mac.sh +++ b/tests/nft_device_mac.sh @@ -62,7 +62,7 @@ has "$NFT_LOG" "nft add set inet ProkopTable prokop_rule_tv_excluded_sources_mac has "$NFT_LOG" "nft add chain inet ProkopTable prokop_rule_tv_mac_excl { }" "exclusion chain" has "$NFT_LOG" "nft add rule inet ProkopTable prokop_rule_tv_mac_excl ether saddr @prokop_rule_tv_excluded_sources_mac return" "excluded MAC returns" has "$NFT_LOG" "nft add rule inet ProkopTable prokop_rule_tv_mac_excl meta mark set 0x00100000 counter accept" "the rest is captured" -has "$NFT_LOG" "nft add rule inet ProkopTable priority_rules iifname @prokop_interfaces ether saddr @prokop_rule_tv_sources_mac ip daddr != @localv4 ip daddr @prokop_rule_tv_subnets jump prokop_rule_tv_mac_excl" "MAC filter rule" +has "$NFT_LOG" "nft add rule inet ProkopTable priority_rules iifname @prokop_interfaces ether saddr @prokop_rule_tv_sources_mac ip daddr != @localv4 ip daddr @prokop_rule_tv_subnets meta l4proto { tcp, udp } jump prokop_rule_tv_mac_excl" "MAC filter rule" # A MAC-only filter has no address rule, and nothing in output. lacks "$NFT_LOG" "ip saddr @prokop_rule_tv_sources " "no address variant without addresses" lacks "$NFT_LOG" "priority_output_rules" "router-originated traffic has no MAC" diff --git a/tests/nft_device_mac_real.sh b/tests/nft_device_mac_real.sh index 0508e8b55..ff431bd47 100755 --- a/tests/nft_device_mac_real.sh +++ b/tests/nft_device_mac_real.sh @@ -176,12 +176,12 @@ LIVE="$WORK_DIR/mac.nft.live" fail "source-aware DNS MAC set: '$(set_elements "$LIVE" prokop_dns_sources_mac)'" ok "MAC sets created and filled" -live_has "$LIVE" "iifname @$INTERFACES ip saddr @prokop_rule_tv_sources ip saddr != @prokop_rule_tv_excluded_sources ip daddr != @localv4 ip daddr @prokop_rule_tv_subnets jump prokop_rule_tv_mac_excl" "address variant of the filter" -live_has "$LIVE" "iifname @$INTERFACES ether saddr @prokop_rule_tv_sources_mac ip saddr != @prokop_rule_tv_excluded_sources ip daddr != @localv4 ip daddr @prokop_rule_tv_subnets jump prokop_rule_tv_mac_excl" "MAC variant of the filter" +live_has "$LIVE" "iifname @$INTERFACES ip saddr @prokop_rule_tv_sources ip saddr != @prokop_rule_tv_excluded_sources ip daddr != @localv4 ip daddr @prokop_rule_tv_subnets meta l4proto { tcp, udp } jump prokop_rule_tv_mac_excl" "address variant of the filter" +live_has "$LIVE" "iifname @$INTERFACES ether saddr @prokop_rule_tv_sources_mac ip saddr != @prokop_rule_tv_excluded_sources ip daddr != @localv4 ip daddr @prokop_rule_tv_subnets meta l4proto { tcp, udp } jump prokop_rule_tv_mac_excl" "MAC variant of the filter" live_has "$LIVE" "ether saddr @prokop_rule_tv_excluded_sources_mac return" "exclusion chain returns for excluded MACs" live_has "$LIVE" "iifname @$INTERFACES ether saddr @prokop_rule_forced_fully_sources_mac ip daddr != @localv4 meta l4proto tcp meta mark set $FAKEIP_MARK counter accept" "forced device by MAC" live_has "$LIVE" "iifname @$INTERFACES ether saddr @prokop_dns_sources_mac udp dport 53 counter redirect to :$SOURCE_DNS_PORT" "DNS of MAC devices to the source-aware inbound" -live_has "$LIVE" "iifname @$INTERFACES ip daddr != @localv4 ip daddr @prokop_rule_allowed_subnets jump prokop_rule_allowed_mac_excl" "MAC-only exclusion without filter" +live_has "$LIVE" "iifname @$INTERFACES ip daddr != @localv4 ip daddr @prokop_rule_allowed_subnets meta l4proto { tcp, udp } jump prokop_rule_allowed_mac_excl" "MAC-only exclusion without filter" # Router-originated traffic has no link layer: no ether match in output, and # the address variant there gives the plain verdict. awk '/chain priority_output_rules/,/^\t}/' "$LIVE" >"$WORK_DIR/output.rules" @@ -240,7 +240,7 @@ EOF apply_state "$WORK_DIR/ip.uci" "$WORK_DIR/ip.nft" live_lacks "$WORK_DIR/ip.nft" "ether" "address-only batch" live_lacks "$WORK_DIR/ip.nft" "_mac" "address-only batch" -live_has "$WORK_DIR/ip.nft.live" "iifname @$INTERFACES ip saddr @prokop_rule_tv_sources ip saddr != @prokop_rule_tv_excluded_sources ip daddr != @localv4 ip daddr @prokop_rule_tv_subnets meta mark set $FAKEIP_MARK counter accept" "address rule as before" +live_has "$WORK_DIR/ip.nft.live" "iifname @$INTERFACES ip saddr @prokop_rule_tv_sources ip saddr != @prokop_rule_tv_excluded_sources ip daddr != @localv4 ip daddr @prokop_rule_tv_subnets meta l4proto { tcp, udp } meta mark set $FAKEIP_MARK counter accept" "address rule as before" PROKOP_UCI_STATE_FILE="$WORK_DIR/ip.uci" nft_uc killswitch-render "$TABLE" ProkopKillswitch "$WORK_DIR/ip-ks.nft" "$FAKEIP_RANGE" "$FAKEIP6_RANGE" >/dev/null || fail "address-only kill-switch render failed" live_lacks "$WORK_DIR/ip-ks.nft" "ether" "address-only kill-switch" diff --git a/tests/singbox_dpi.sh b/tests/singbox_dpi.sh index a7e08ad0b..caba1b5a2 100755 --- a/tests/singbox_dpi.sh +++ b/tests/singbox_dpi.sh @@ -215,7 +215,7 @@ JSON NFT_LOG="$WORK/nft.log" PATH="$WORK/bin:$PATH" PROKOP_NFT_SUBNET_CACHE_DIR="$WORK/nft-cache" \ ucode -L "$LIB" "$LIB/nft/apply.uc" nft-add-section-priority-rules-fixture "$WORK/nft.json" ProkopTable prokop_interfaces localv4 localv6 0x00100000 || fail "nft priority rules failed" -grep -Fq $'priority_rules\tiifname\t@prokop_interfaces\tip\tdaddr\t!=\t@localv4\tip\tdaddr\t@prokop_rule_yt_subnets\tmeta\tmark\tset\t0x00100000\tcounter\taccept' "$WORK/nft.log" || +grep -Fq $'priority_rules\tiifname\t@prokop_interfaces\tip\tdaddr\t!=\t@localv4\tip\tdaddr\t@prokop_rule_yt_subnets\tmeta\tl4proto\t{ tcp, udp }\tmeta\tmark\tset\t0x00100000\tcounter\taccept' "$WORK/nft.log" || fail "nft does not capture the rule's addresses: $(cat "$WORK/nft.log")" # --- the site check, the read-only view and autotune ------------------------------- From 4ad67008963d4b46df71729caced3beb35e23714 Mon Sep 17 00:00:00 2001 From: Asofwar <7397608+Asofwar@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:51:21 +0000 Subject: [PATCH 6/9] dns failover: the log names the priority, never the server address --- prokop/files/usr/lib/singbox/dns_failover.uc | 5 +++-- tests/dns_failover.sh | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/prokop/files/usr/lib/singbox/dns_failover.uc b/prokop/files/usr/lib/singbox/dns_failover.uc index c9ff4d2a3..0f9fb0aee 100644 --- a/prokop/files/usr/lib/singbox/dns_failover.uc +++ b/prokop/files/usr/lib/singbox/dns_failover.uc @@ -237,9 +237,10 @@ function apply_selections(state, selections) { state.main_index = candidate.main_index; state.bootstrap_index = candidate.bootstrap_index; for (let item in changed) { - let values = item.kind == "main" ? state.main_servers : state.bootstrap_servers; let selected = item.selected; - log_message("switched " + item.kind + " DNS to priority " + as_string(selected.index + 1) + " (" + values[selected.index] + ", " + selected.reason + ")", "info"); + // A DoH address can carry a private token in its path or query: the + // log names the priority and the reason, never the server itself. + log_message("switched " + item.kind + " DNS to priority " + as_string(selected.index + 1) + " (" + selected.reason + ")", "info"); } return true; } diff --git a/tests/dns_failover.sh b/tests/dns_failover.sh index e6e983c61..15de5f173 100644 --- a/tests/dns_failover.sh +++ b/tests/dns_failover.sh @@ -17,6 +17,12 @@ fail() { exit 1 } +# A DoH address can carry a private token in its path or query: the switch +# message names the priority and the reason, never the configured server. +if grep -Eq 'log_message\(.*(values\[|main_servers|bootstrap_servers)' "$FAILOVER"; then + fail "a DNS failover log message can disclose the configured server" +fi + generate() { local fixture="$1" local output="$2" From 5379e40c1fb60c5c8a33e0c264526b133d2e8c84 Mon Sep 17 00:00:00 2001 From: Asofwar <7397608+Asofwar@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:51:21 +0000 Subject: [PATCH 7/9] sing-box service: no core dumps into /tmp --- prokop/files/usr/lib/singbox/managed_service.uc | 2 +- tests/singbox_managed_service_text.sh | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/prokop/files/usr/lib/singbox/managed_service.uc b/prokop/files/usr/lib/singbox/managed_service.uc index 0108b1985..87bcd74b4 100644 --- a/prokop/files/usr/lib/singbox/managed_service.uc +++ b/prokop/files/usr/lib/singbox/managed_service.uc @@ -45,7 +45,7 @@ function text() { " procd_open_instance\n" + " procd_set_param command \"$PROG\" run -c \"$config_file\" -D \"$working_directory\"\n" + " procd_set_param stderr \"$log_stderr\"\n" + - " procd_set_param limits core=\"unlimited\"\n" + + " procd_set_param limits core=\"0\"\n" + " procd_set_param limits nofile=\"1000000 1000000\"\n" + " procd_set_param respawn\n" + " procd_set_param term_timeout " + TERM_TIMEOUT + "\n" + diff --git a/tests/singbox_managed_service_text.sh b/tests/singbox_managed_service_text.sh index 02892495f..d1199330c 100644 --- a/tests/singbox_managed_service_text.sh +++ b/tests/singbox_managed_service_text.sh @@ -56,6 +56,13 @@ term_timeout="$(ucode -L "$LIB" -e 'print(require("singbox.managed_service").tex fail "term_timeout $term_timeout is not between procd's 5 s and the 15 s transition wait" ok "the managed sing-box init script gives sing-box $term_timeout s to stop" +# OpenWrt writes core dumps to /tmp (kernel.core_pattern), which is RAM: a +# crash of sing-box, respawned by procd, must not fill it with dumps. +ucode -L "$LIB" -e 'print(require("singbox.managed_service").text())' | + grep -qx ' procd_set_param limits core="0"' || + fail "the managed sing-box init script allows core dumps" +ok "the managed sing-box init script writes no core dumps" + # ---- 2. every writer writes the same script --------------------------------------- if ! unshare -rm true 2>/dev/null; then From 61c3ad2fe77a5319bf8a3f48d395b064a66a73ac Mon Sep 17 00:00:00 2001 From: Asofwar <7397608+Asofwar@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:51:22 +0000 Subject: [PATCH 8/9] smart detect: wait by time, busybox sleep takes whole seconds only --- prokop/files/usr/lib/experiments/smart_detect.uc | 12 +++++++++--- tests/busybox_fractional_sleep.sh | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) create mode 100755 tests/busybox_fractional_sleep.sh diff --git a/prokop/files/usr/lib/experiments/smart_detect.uc b/prokop/files/usr/lib/experiments/smart_detect.uc index 493e5dc6e..3c3f230a3 100644 --- a/prokop/files/usr/lib/experiments/smart_detect.uc +++ b/prokop/files/usr/lib/experiments/smart_detect.uc @@ -12,6 +12,10 @@ const CONFIG = getenv("SB_CONFIG") || "/etc/sing-box/config.json"; const BIN = getenv("SB_BIN") || "/usr/bin/sing-box"; const PROC = getenv("PROKOP_SMART_DETECT_PROC") || "/proc"; const PORTS = { "11EE": 4590, "11EF": 4591 }; +// A tenth of a second where sleep takes fractions, a second on OpenWrt, +// whose busybox sleep refuses "0.1". +function nap() { system("sleep 0.1 2>/dev/null || sleep 1"); } + function domain(host) { if (length(host) > 253 || match(host, /^[a-z0-9.-]+$/) == null || ip.valid_ip(host)) return false; let labels = split(host, "."); @@ -165,12 +169,14 @@ function run(host, target) { if (child) child.close(); if (!identity.record(pidfile, pid)) return c.fail("probe_start_failed"); // Wait for both ports; probes are never sent to an unrelated listener. + // OpenWrt's busybox sleep takes whole seconds only: the wait is bounded + // by time, not by a count of naps. let ready = false; - for (let attempt = 0; attempt < 20; attempt++) { + for (let deadline = time() + 3; time() <= deadline;) { if (identity.matches(pidfile, BIN, args, true, true) == "") break; ready = ports_owned(probe_listeners(), pid); if (ready) break; - system("sleep 0.1"); + nap(); } if (!ready) return c.fail("probe_not_ready"); let direct = probe(host, 4590), proxy = probe(host, 4591), result = verdict(direct, proxy); @@ -190,7 +196,7 @@ function run(host, target) { try { result = work(); } catch (e) { result = c.fail("probe_failed"); } { identity.signal(pidfile, BIN, args, true, "TERM", true); - for (let attempt = 0; attempt < 10 && identity.matches(pidfile, BIN, args, true, true) != ""; attempt++) system("sleep 0.1"); + for (let deadline = time() + 2; time() <= deadline && identity.matches(pidfile, BIN, args, true, true) != "";) nap(); identity.signal(pidfile, BIN, args, true, "KILL", true); fs.unlink(pidfile); fs.unlink(DIR + "/probe.json"); c.locks.release(DIR + "/lock", c.self()); diff --git a/tests/busybox_fractional_sleep.sh b/tests/busybox_fractional_sleep.sh new file mode 100755 index 000000000..3158ddc96 --- /dev/null +++ b/tests/busybox_fractional_sleep.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# OpenWrt's busybox sleep takes whole seconds only ("sleep 0.1" fails at +# once): a wait loop that relies on it alone spins without waiting. Every +# fractional sleep of Prokop's falls back to a whole second. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +found="$(grep -rnE 'sleep 0\.[0-9]' "$ROOT_DIR/prokop/files" "$ROOT_DIR/luci-app-prokop" "$ROOT_DIR/install.sh" 2>/dev/null | + grep -vE 'sleep 0\.[0-9]+ 2>/dev/null \|\| sleep [1-9]' || true)" +if [ -n "$found" ]; then + printf 'FAIL: a fractional sleep without a whole-second fallback:\n%s\n' "$found" >&2 + exit 1 +fi +printf 'busybox_fractional_sleep: PASS\n' From 3d2119e013d9671796dafccb460a5acbafcfef7b Mon Sep 17 00:00:00 2001 From: Asofwar <7397608+Asofwar@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:51:22 +0000 Subject: [PATCH 9/9] RELEASE_VERSION 2.32.0, release notes --- RELEASE_VERSION | 2 +- docs/releases/2.32.0.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 docs/releases/2.32.0.md diff --git a/RELEASE_VERSION b/RELEASE_VERSION index bafceb320..7cca401c7 100644 --- a/RELEASE_VERSION +++ b/RELEASE_VERSION @@ -1 +1 @@ -2.31.0 +2.32.0 diff --git a/docs/releases/2.32.0.md b/docs/releases/2.32.0.md new file mode 100644 index 000000000..ef36215ca --- /dev/null +++ b/docs/releases/2.32.0.md @@ -0,0 +1,29 @@ +## Исправления по мотивам других форков, совместимость с sing-box-extended 2.7.3 + +Семь ошибок и одно улучшение подписок. Пять ошибок нашлись при сравнении с другими форками (Screamshow, slayer326, Tachyon) и с новой версией ядра sing-box-extended, ещё две — при проверке на ucode и busybox из OpenWrt. Обновление ставится поверх, настройки не меняются, ничего делать вручную не нужно. + +## Ядро sing-box-extended 2.7.3 + +Вышла версия sing-box-extended 2.7.3 на основе sing-box 1.14.3. В ней исправлена ошибка 2.7.2: UDP с доменным адресом (QUIC/HTTP/3 к доменам из списков) не проходил через группы selector и urltest. Обновить ядро можно кнопкой «Обновить» у sing-box в «Компонентах». Prokop 2.32.0 учитывает новые проверки конфигурации в 2.7.3 (см. ниже), поэтому ставить 2.32.0 лучше до обновления ядра. + +## Что изменилось + +- **Фильтр по имени сервера в группах URLTest и «Приоритет».** Чтобы фильтр не терял сервер после переименования, Prokop помнит прежние имена серверов. Если старое имя переименованного сервера провайдер отдавал новому серверу, фильтр выбирал оба. Теперь прежнее имя действует, только пока его не носит другой сервер. +- **Ping к адресам из списков прокси-секций.** ICMP и другие протоколы, кроме TCP и UDP, к подсетям прокси-секции получали метку прокси и уходили в локальный маршрут. На ping отвечал сам роутер, и казалось, что адрес доступен, а traceroute ничего не показывал. Теперь в прокси уходят только TCP и UDP, остальное идёт обычным маршрутом. Kill-switch по-прежнему блокирует для защищённых секций любой протокол. +- **Адрес DNS-сервера больше не пишется в журнал.** При переключении резервного DNS в системный журнал записывался адрес сервера. У личного DoH (NextDNS, AdGuard и т. п.) в адресе есть идентификатор или токен. Теперь в журнале только номер приоритета и причина. +- **Узел xHTTP больше не ломает всю конфигурацию на sing-box-extended 2.7.3.** Ядро 2.7.3 отвергает конфигурацию целиком, если у узла xHTTP задана таблица session ID без длины или с длиной, которая даёт меньше 2³¹ вариантов, а также если в xmux заданы одновременно `max_connections` и `max_concurrency`. Prokop теперь убирает такие настройки у этого узла и пишет предупреждение в журнал. На 2.7.2 таблица без длины и так не использовалась. +- **Ссылки xHTTP с таблицей session ID снова разбираются на роутере.** С версии 2.28.0 проверка значения `sessionIDTable` использовала регулярное выражение, которое библиотека musl в OpenWrt не принимает. Подписка или ссылка с таким узлом не разбиралась на роутере, хотя тесты проходили. Новый тест следит, чтобы таких выражений больше не было. +- **Smart Detect ждёт запуска пробы.** busybox в OpenWrt не понимает `sleep 0.1`, поэтому ожидание запуска временного sing-box проходило мгновенно, и проба часто заканчивалась ошибкой «проба не готова». Остановка пробы тоже не ждала её завершения. Теперь ожидание ограничено временем (3 и 2 секунды). +- **sing-box не пишет дамп памяти в /tmp.** Для ядра, которое ставит сам Prokop, был разрешён дамп памяти без ограничения. OpenWrt пишет такие дампы в `/tmp`, то есть в оперативную память. При падении sing-box в нативном коде дамп размером с процесс заполнял память, а procd перезапускал sing-box до пяти раз. Теперь дампы отключены. Init-скрипт ядра обновится при следующем запуске Prokop. + +## Подписки: смена только имён серверов + +Панели вроде Remnawave пишут в имя каждого сервера остаток трафика или дней. Из-за этого каждое плановое обновление подписки считалось изменением и перезапускало sing-box, а перезапуск рвёт все соединения через прокси. Теперь плановое обновление не применяет подписку, если у серверов поменялись только имена, а сами серверы прежние. Новые имена появятся при ручном обновлении подписки, при следующем настоящем изменении серверов или после перезапуска Prokop. + +## Что проверено + +- PASS: автоматические тесты бэкенда, полный прогон. Новые тесты падают без исправлений и проходят с ними: `urltest_reused_name_filter`, `xhttp_core_refused_settings`, `subscription_names_only_update`, `ucode_regex_repeat_limit`, `busybox_fractional_sleep`, дополнены `dns_failover`, `singbox_managed_service_text`, `nft_apply`, `nft_device_mac`. +- PASS: конфигурации из `xhttp_core_refused_settings` и `xhttp_remnawave_settings` проходят `sing-box check` настоящего sing-box-extended 2.7.3 (linux-amd64). Без исправления 2.7.3 отвергает узел с таблицей session ID без длины («session_id_length.from must be greater than 0») и с короткой длиной («session_id_table or session_id_length is too small»). +- PASS: ошибка регулярного выражения и `sleep 0.1` воспроизведены на ucode и busybox из OpenWrt 25.12.6 (x86_64, тот же musl, что на роутере). +- NOT TESTED: на роутере. +- NOT TESTED: ping к подсетям секции после исправления на настоящем трафике; правило проверено тестами nftables.