fix(singbox): prevent cold-start crash loop on missing community rulesets (#46)

- Bundle universal 17-byte empty SRS stub and add is_valid_srs_file/ensure_empty_srs_stub in rulesets.uc
- Add prepare_community_rulesets in lifecycle.uc to pre-fetch via gh-proxy mirrors or initialize safe local stub before sing-box start
- Support local community rulesets and stubs in generator_routes.uc
- Add fallback to component proxy for list downloads in updates.uc and preserve cached stubs
- Skip non-functional jsdelivr for release downloads and prioritize gh-proxy mirrors in core/url.uc
- Add tests/cold_boot_community_stub.sh testing cold boot fallback behavior
This commit is contained in:
Dushnilin
2026-09-07 23:50:01 +03:00
parent 5539b9701f
commit 203bb9b433
7 changed files with 287 additions and 21 deletions
+16 -3
View File
@@ -2140,7 +2140,19 @@ function download_via_proxy_section(settings, purpose) {
if (configured != "")
return configured;
return option(settings, "download_lists_via_proxy_section", "");
let default_section = option(settings, "download_lists_via_proxy_section", "");
if (default_section != "")
return default_section;
// Fallback: if purpose is "lists" and download_components_via_proxy is enabled
if (purpose == "lists" && bool_option(settings, "download_components_via_proxy", false)) {
let comp_section = option(settings, "download_components_via_proxy_section", "");
if (comp_section != "")
return comp_section;
return default_section;
}
return "";
}
function service_proxy_port_for_purpose(purpose) {
@@ -2414,8 +2426,9 @@ function import_community_srs_file(service, settings) {
}
else {
remove_file(persistent_file);
remove_file(cached_file);
log_message("Failed to download preset ruleset " + service + "; skipping it until next update", "warn");
if (!singbox_rulesets_module().is_valid_srs_file(cached_file))
singbox_rulesets_module().ensure_empty_srs_stub(cached_file);
log_message("Failed to download preset ruleset " + service + "; preserving fallback stub until next update", "warn");
ok = false;
}
+4 -3
View File
@@ -294,14 +294,15 @@ function download_candidates(value) {
let is_http = substr(value, 0, 7) == "http://" || substr(value, 0, 8) == "https://";
if (is_github || is_http) {
let jsd = github_to_jsdelivr(value);
let is_release = index(value, "/releases/") >= 0;
let jsd = !is_release ? github_to_jsdelivr(value) : "";
if (jsd != "")
push(candidates, jsd);
let mirrors = [
"https://gh-proxy.com/",
"https://ghproxy.net/",
"https://mirror.ghproxy.com/",
"https://gh-proxy.com/"
"https://mirror.ghproxy.com/"
];
for (let prefix in mirrors) {
if (index(value, prefix) != 0)
@@ -727,6 +727,89 @@ function restore_rulesets_from_cache() {
dir.close();
}
function prepare_community_rulesets() {
let runtime_rulesets_mod = require("singbox.rulesets");
let core_url_mod = require("core.url");
let tmp_dir = TMP_RULESET_FOLDER;
let etc_dir = "/etc/tachyon/rulesets";
command_success_from_args(["mkdir", "-p", tmp_dir]);
let needed = {};
let sections = uci_core.get_all(CONFIG_NAME, "section") || {};
for (let name, section in sections) {
if (!section || !section.enabled || section.enabled == "0")
continue;
let comms = section.community_lists;
if (type(comms) == "string")
comms = split(trim(comms), /[ \t\r\n]+/);
if (type(comms) == "array") {
for (let c in comms) {
c = trim(c);
if (c != "" && runtime_rulesets_mod.is_community(c))
needed[c] = true;
}
}
let rulesets = section.rule_sets;
if (type(rulesets) == "string")
rulesets = split(trim(rulesets), /[ \t\r\n]+/);
if (type(rulesets) == "array") {
for (let r in rulesets) {
r = trim(r);
if (r != "" && runtime_rulesets_mod.is_community(r))
needed[r] = true;
}
}
}
let has_curl = command_status("command -v curl >/dev/null 2>&1") == 0;
for (let service in keys(needed)) {
let tmp_path = tmp_dir + "/community-" + service + ".srs";
let etc_path = etc_dir + "/community-" + service + ".srs";
if (runtime_rulesets_mod.is_valid_srs_file(tmp_path))
continue;
if (runtime_rulesets_mod.is_valid_srs_file(etc_path)) {
let content = fs.readfile(etc_path);
if (content && fs.writefile(tmp_path, content) != null)
continue;
}
let url = runtime_rulesets_mod.community_url(service);
let candidates = type(core_url_mod.download_candidates) == "function" ?
core_url_mod.download_candidates(url) : [ url ];
let fetched = false;
if (has_curl) {
for (let candidate in candidates) {
let dl_tmp = tmp_dir + "/.fetch-" + service + ".tmp";
let args = [ "curl", "-fsSL", "--connect-timeout", "3", "-m", "5", candidate, "-o", dl_tmp ];
if (command_success_from_args(args) && runtime_rulesets_mod.is_valid_srs_file(dl_tmp)) {
let st = fs.stat(dl_tmp);
if (st && st.size >= 100) {
command_success_from_args([ "mv", "-f", dl_tmp, tmp_path ]);
command_success_from_args([ "mkdir", "-p", etc_dir ]);
let cdata = fs.readfile(tmp_path);
if (cdata)
fs.writefile(etc_path, cdata);
log_message("Pre-downloaded community ruleset " + service + " from " + candidate, "info");
fetched = true;
break;
}
}
remove_file(dl_tmp);
}
}
if (!fetched) {
runtime_rulesets_mod.ensure_empty_srs_stub(tmp_path);
log_message("Community ruleset '" + service + "' initialized with local fallback stub to allow sing-box clean start", "info");
}
}
}
function start_main() {
let status;
@@ -749,6 +832,8 @@ function start_main() {
if (status != 0)
return status;
prepare_community_rulesets();
if (!acquire_start_subscription_update_lock())
return 1;
@@ -727,17 +727,37 @@ function ensure_custom_ruleset(config, reference) {
tag_name = "builtin-" + reference + "-ruleset";
kind = "domains";
if (!ruleset_registered(config, tag_name)) {
let rule_set = {
type: "remote",
tag: tag_name,
format: "binary",
url: runtime_rulesets.community_url(reference)
};
let detour = ctx.download_detour_tag(ctx.runtime_settings());
if (is_valid_detour(config, detour))
rule_set.download_detour = detour;
rule_set.update_interval = remote_ruleset_update_interval();
push(config.route.rule_set, rule_set);
let folder = ctx.runtime_ruleset_folder || runtime_ruleset_folder;
let tmp_srs = folder + "/community-" + reference + ".srs";
let etc_srs = "/etc/tachyon/rulesets/community-" + reference + ".srs";
let local_path = null;
if (runtime_rulesets.is_valid_srs_file(tmp_srs) || helpers.file_is_usable(tmp_srs, 16))
local_path = tmp_srs;
else if (runtime_rulesets.is_valid_srs_file(etc_srs) || helpers.file_is_usable(etc_srs, 16))
local_path = etc_srs;
if (local_path != null) {
push(config.route.rule_set, {
type: "local",
tag: tag_name,
format: "binary",
path: local_path
});
}
else {
let rule_set = {
type: "remote",
tag: tag_name,
format: "binary",
url: runtime_rulesets.community_url(reference)
};
let detour = ctx.download_detour_tag(ctx.runtime_settings());
if (is_valid_detour(config, detour))
rule_set.download_detour = detour;
rule_set.update_interval = remote_ruleset_update_interval();
push(config.route.rule_set, rule_set);
}
}
return { tag: tag_name, kind };
}
@@ -795,9 +815,9 @@ function ensure_community_ruleset(config, section_name, community) {
let etc_srs = "/etc/tachyon/rulesets/community-" + community + ".srs";
let local_path = null;
if (helpers.file_is_usable(tmp_srs, 100))
if (runtime_rulesets.is_valid_srs_file(tmp_srs) || helpers.file_is_usable(tmp_srs, 16))
local_path = tmp_srs;
else if (helpers.file_is_usable(etc_srs, 100))
else if (runtime_rulesets.is_valid_srs_file(etc_srs) || helpers.file_is_usable(etc_srs, 16))
local_path = etc_srs;
if (local_path != null) {
+49 -2
View File
@@ -1,6 +1,10 @@
#!/usr/bin/env ucode
let common = require("core.common");
let fs = require("fs");
const EMPTY_SRS_B64 = "U1JTAXjaYgAEAAD//wABAAE=";
const EMPTY_SRS_PATH = "/usr/share/tachyon/rulesets/empty.srs";
const SRS_MAIN_URL = "https://github.com/itdoginfo/allow-domains/releases/latest/download";
const SRS_ADS_HAGEZI_PRO_URL = "https://github.com/zxc-rv/ad-filter/releases/latest/download/adlist.srs";
@@ -126,15 +130,54 @@ function remote_format(reference) {
return file_extension(reference) == "json" ? "source" : "binary";
}
function is_valid_srs_file(path) {
let p = as_string(path);
let st = fs.stat(p);
if (!st || st.size < 17)
return false;
let f = fs.open(p, "r");
if (!f)
return false;
let magic = f.read(3);
f.close();
return magic == "SRS";
}
function ensure_empty_srs_stub(target_path) {
target_path = as_string(target_path);
if (is_valid_srs_file(target_path))
return true;
let slash = rindex(target_path, "/");
if (slash > 0)
common.ensure_dir(substr(target_path, 0, slash));
if (is_valid_srs_file(EMPTY_SRS_PATH)) {
let content = fs.readfile(EMPTY_SRS_PATH);
if (content && fs.writefile(target_path, content) != null)
return true;
}
let b = b64dec(EMPTY_SRS_B64);
if (b && fs.writefile(target_path, b) != null)
return true;
return false;
}
function module_exports() {
return {
EMPTY_SRS_PATH,
EMPTY_SRS_B64,
COMMUNITY_SERVICES,
is_community,
community_url,
hash12,
file_extension,
kind_from_reference_hint,
remote_format
remote_format,
is_valid_srs_file,
ensure_empty_srs_stub
};
}
@@ -151,7 +194,11 @@ else if (mode == "kind-from-reference-hint")
print(kind_from_reference_hint(ARGV[1]), "\n");
else if (mode == "remote-format")
print(remote_format(ARGV[1]), "\n");
else if (mode == "is-valid-srs-file")
exit(is_valid_srs_file(ARGV[1]) ? 0 : 1);
else if (mode == "ensure-empty-srs-stub")
exit(ensure_empty_srs_stub(ARGV[1]) ? 0 : 1);
else {
warn("Usage: singbox/rulesets.uc <file-extension|is-community|kind-from-reference-hint|remote-format> ...\n");
warn("Usage: singbox/rulesets.uc <file-extension|is-community|kind-from-reference-hint|remote-format|is-valid-srs-file|ensure-empty-srs-stub> ...\n");
exit(1);
}
Binary file not shown.
+100
View File
@@ -0,0 +1,100 @@
#!/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"
RULESETS_UC="$TACHYON_LIB/singbox/rulesets.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 is-valid-srs-file with invalid files
touch "$WORK_DIR/empty_zero.srs"
if ucode -L "$TACHYON_LIB" "$RULESETS_UC" is-valid-srs-file "$WORK_DIR/empty_zero.srs" >/dev/null 2>&1; then
fail "zero-byte file must not be recognized as valid SRS"
fi
printf '<html>404 Not Found</html>' > "$WORK_DIR/html_error.srs"
if ucode -L "$TACHYON_LIB" "$RULESETS_UC" is-valid-srs-file "$WORK_DIR/html_error.srs" >/dev/null 2>&1; then
fail "HTML error page must not be recognized as valid SRS"
fi
# 2. Test ensure-empty-srs-stub
STUB_TARGET="$WORK_DIR/rulesets/community-russia_inside.srs"
ucode -L "$TACHYON_LIB" "$RULESETS_UC" ensure-empty-srs-stub "$STUB_TARGET" || \
fail "ensure-empty-srs-stub failed to create stub"
[ -f "$STUB_TARGET" ] || fail "stub target file was not created"
size=$(wc -c < "$STUB_TARGET" | tr -d '[:space:]')
[ "$size" -eq 17 ] || fail "stub file size is $size, expected 17 bytes"
ucode -L "$TACHYON_LIB" "$RULESETS_UC" is-valid-srs-file "$STUB_TARGET" >/dev/null || \
fail "created stub must be recognized as valid SRS"
# 3. Test that generator produces type: local when stub is present
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": "HyDE",
".type": "section",
"enabled": "1",
"action": "connection",
"outbound_jsons": [ "{\"type\":\"direct\"}" ],
"community_lists": [ "russia_inside" ]
}
]
}
JSON
output="$WORK_DIR/out.json"
mkdir -p "$output.section-cache" "$output.rulesets"
# Copy the stub into the generator's rulesets directory (simulating startup preparation)
cp "$STUB_TARGET" "$output.rulesets/community-russia_inside.srs"
ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \
"$WORK_DIR/fixture.json" "$output" "127.0.0.1" "0" "1"
# The generated route.rule_set MUST be "type": "local" pointing to the stub
grep -Fq '"type": "local"' "$output" || \
fail "ruleset must be local when stub exists"
grep -Fq 'community-russia_inside.srs' "$output" || \
fail "ruleset must point to community-russia_inside.srs"
if grep -Fq '"type": "remote"' "$output"; then
fail "must not emit remote ruleset when local stub is present"
fi
# 4. Verify candidate generation in core.url prioritizes gh-proxy and handles releases
url_candidates=$(ucode -L "$TACHYON_LIB" -e '
let url = require("core.url");
let c = url.download_candidates("https://github.com/itdoginfo/allow-domains/releases/latest/download/russia_inside.srs");
for (let u in c) print(u, "\n");
')
echo "$url_candidates" | grep -Fq 'https://gh-proxy.com/https://github.com/itdoginfo/allow-domains/releases/latest/download/russia_inside.srs' || \
fail "download_candidates must include gh-proxy mirror for releases"
if echo "$url_candidates" | grep -Fq 'cdn.jsdelivr.net'; then
fail "download_candidates must not include jsdelivr for release assets"
fi
echo "ALL cold_boot_community_stub tests passed successfully"