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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
@@ -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"
|
||||
Reference in New Issue
Block a user