fix: OpenWrt 25.12 compatibility fixes, startup speedup, and Telegram polling fix
- wget: remove -t flag incompatible with BusyBox 25.12+, add curl-first fallback in download_to_file() with wget as fallback - startup/reload speedup (5 improvements): - cut sing-box verification timeout from 45 iterations to 15 - remove redundant second stability wait for sing-box process - batch requirement check + runtime validation into single ucode fork - replace dnsmasq full restart with procd reload (config-conditional bounce) - move telegram and watchdog runtime start to background - telegram: fix "API failure" spam - empty result[] from getUpdates is now treated as a successful idle poll, not an error that triggers backoff - watchdog SmartDetect: extract domain from proxy and VPN URLs for DNS health check, use real domains (google.com/example.com) instead of provider-specific endpoints that trigger false alerts - tests: new invariants for wget -t prohibition, telegram empty-result handling, DNS apply reload-vs-restart, and combined validation mode ownership
This commit is contained in:
@@ -167,6 +167,10 @@ function command_success_from_args(args) {
|
|||||||
return system(command_from_args(args) + " >/dev/null 2>&1") == 0;
|
return system(command_from_args(args) + " >/dev/null 2>&1") == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function command_exists(name) {
|
||||||
|
return command_success_from_args([ "command", "-v", name ]);
|
||||||
|
}
|
||||||
|
|
||||||
function now_seconds() {
|
function now_seconds() {
|
||||||
return int(clock()[0]);
|
return int(clock()[0]);
|
||||||
}
|
}
|
||||||
@@ -1966,12 +1970,25 @@ function download_to_file(url, filepath, proxy_address) {
|
|||||||
for (let candidate in candidates) {
|
for (let candidate in candidates) {
|
||||||
let attempt = 1;
|
let attempt = 1;
|
||||||
while (attempt <= 2) {
|
while (attempt <= 2) {
|
||||||
let command = command_from_args([ "wget", "-q", "-T", "10", "-t", "2", "-O", filepath, candidate ]);
|
let ok = false;
|
||||||
if (as_string(proxy_address) != "")
|
|
||||||
command = "http_proxy=" + shell_quote("http://" + as_string(proxy_address)) +
|
|
||||||
" https_proxy=" + shell_quote("http://" + as_string(proxy_address)) + " " + command;
|
|
||||||
|
|
||||||
if (command_success(command) && file_nonempty(filepath)) {
|
if (command_exists("curl")) {
|
||||||
|
let curl_args = [ "curl", "--connect-timeout", "5", "-m", "10", "-fsSL" ];
|
||||||
|
if (as_string(proxy_address) != "")
|
||||||
|
push(curl_args, "-x", "http://" + as_string(proxy_address));
|
||||||
|
push(curl_args, candidate);
|
||||||
|
push(curl_args, "-o");
|
||||||
|
push(curl_args, filepath);
|
||||||
|
ok = command_success_from_args(curl_args);
|
||||||
|
} else {
|
||||||
|
let command = command_from_args([ "wget", "-q", "-T", "10", "-O", filepath, candidate ]);
|
||||||
|
if (as_string(proxy_address) != "")
|
||||||
|
command = "http_proxy=" + shell_quote("http://" + as_string(proxy_address)) +
|
||||||
|
" https_proxy=" + shell_quote("http://" + as_string(proxy_address)) + " " + command;
|
||||||
|
ok = command_success(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ok && file_nonempty(filepath)) {
|
||||||
if (candidate != url)
|
if (candidate != url)
|
||||||
log_message("Successfully downloaded " + as_string(url) + " via mirror " + candidate, "info");
|
log_message("Successfully downloaded " + as_string(url) + " via mirror " + candidate, "info");
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -2224,6 +2224,14 @@ else if (mode == "check-requirements")
|
|||||||
check_runtime_requirements();
|
check_runtime_requirements();
|
||||||
else if (mode == "validate-runtime")
|
else if (mode == "validate-runtime")
|
||||||
validate_runtime_config(context_from_runtime());
|
validate_runtime_config(context_from_runtime());
|
||||||
|
// Both start-path validations in one process: the runtime context is expensive to
|
||||||
|
// assemble (it shells out for versions and installed-package markers) and each mode
|
||||||
|
// built its own copy. check_runtime_requirements() exits non-zero on failure, so
|
||||||
|
// reaching the second call already means the requirements passed.
|
||||||
|
else if (mode == "check-requirements-and-validate-runtime") {
|
||||||
|
check_runtime_requirements();
|
||||||
|
validate_runtime_config(context_from_runtime());
|
||||||
|
}
|
||||||
else if (mode == "validate-runtime-fixture") {
|
else if (mode == "validate-runtime-fixture") {
|
||||||
use_fixture_cursor(ARGV[1]);
|
use_fixture_cursor(ARGV[1]);
|
||||||
validate_runtime_config(context_from_json(ARGV[2], context_from_runtime()));
|
validate_runtime_config(context_from_json(ARGV[2], context_from_runtime()));
|
||||||
|
|||||||
@@ -78,6 +78,18 @@ function restart_dnsmasq() {
|
|||||||
return run("[ -x " + shell_quote(DNSMASQ_INIT) + " ] && " + shell_quote(DNSMASQ_INIT) + " restart");
|
return run("[ -x " + shell_quote(DNSMASQ_INIT) + " ] && " + shell_quote(DNSMASQ_INIT) + " restart");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// procd regenerates dnsmasq.conf on reload and only bounces the instance when the
|
||||||
|
// generated config actually changed, so a reload applies our UCI edits without the
|
||||||
|
// unconditional stop/start (and the dropped in-flight queries) of a full restart.
|
||||||
|
// Older or non-procd dnsmasq init scripts have no usable reload, so fall back.
|
||||||
|
function reload_dnsmasq() {
|
||||||
|
if (run("[ -x " + shell_quote(DNSMASQ_INIT) + " ] && " + shell_quote(DNSMASQ_INIT) + " reload"))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
log("dnsmasq reload failed; falling back to a full restart", "warn");
|
||||||
|
return restart_dnsmasq();
|
||||||
|
}
|
||||||
|
|
||||||
function dnsmasq_legacy_instance_exists() {
|
function dnsmasq_legacy_instance_exists() {
|
||||||
return uci_exists("dhcp.tachyon");
|
return uci_exists("dhcp.tachyon");
|
||||||
}
|
}
|
||||||
@@ -296,7 +308,7 @@ function dnsmasq_configure(force) {
|
|||||||
dnsmasq_configure_default_instance();
|
dnsmasq_configure_default_instance();
|
||||||
uci_commit("dhcp");
|
uci_commit("dhcp");
|
||||||
|
|
||||||
return restart_dnsmasq();
|
return reload_dnsmasq();
|
||||||
}
|
}
|
||||||
|
|
||||||
function dnsmasq_restore(force, quiet) {
|
function dnsmasq_restore(force, quiet) {
|
||||||
@@ -317,7 +329,7 @@ function dnsmasq_restore(force, quiet) {
|
|||||||
dnsmasq_restore_default_instance();
|
dnsmasq_restore_default_instance();
|
||||||
uci_commit("dhcp");
|
uci_commit("dhcp");
|
||||||
|
|
||||||
return restart_dnsmasq();
|
return reload_dnsmasq();
|
||||||
}
|
}
|
||||||
|
|
||||||
function failsafe_restore() {
|
function failsafe_restore() {
|
||||||
|
|||||||
@@ -526,13 +526,50 @@ function start_plan(reason, owner_pid, settings, bin_ok) {
|
|||||||
shell_assignment("INITD_BADWAN_NETDEV", plan.badwan_netdev);
|
shell_assignment("INITD_BADWAN_NETDEV", plan.badwan_netdev);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BOOT_START_UPTIME_SECONDS = int(getenv("TACHYON_BOOT_START_UPTIME_SECONDS") || "120");
|
||||||
|
const BOOT_START_VERIFY_TIMEOUT = getenv("TACHYON_BOOT_START_VERIFY_TIMEOUT") || "45";
|
||||||
|
|
||||||
|
// Seconds since boot, or null when /proc/uptime cannot be read.
|
||||||
|
function system_uptime_seconds() {
|
||||||
|
let data = fs.readfile("/proc/uptime");
|
||||||
|
if (data == null)
|
||||||
|
return null;
|
||||||
|
let field = split(trim(as_string(data)), /[ \t]+/)[0];
|
||||||
|
if (field == null || match(field, /^[0-9]+(\.[0-9]+)?$/) == null)
|
||||||
|
return null;
|
||||||
|
return int(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cold boot and WAN-up retries race the network coming up, so sing-box can take far
|
||||||
|
// longer to reach a stable state than on a start the user asked for. rc.common calls
|
||||||
|
// boot() as a plain argument-less start, so the reason alone cannot tell the two
|
||||||
|
// apart: a start early in uptime is treated as cold. Those paths keep the generous
|
||||||
|
// verification window, everything else uses the shorter default from lifecycle.uc.
|
||||||
|
function start_verify_timeout(reason, uptime_seconds) {
|
||||||
|
if (as_string(reason) == "triggered")
|
||||||
|
return BOOT_START_VERIFY_TIMEOUT;
|
||||||
|
|
||||||
|
if (uptime_seconds == null)
|
||||||
|
uptime_seconds = system_uptime_seconds();
|
||||||
|
if (uptime_seconds == null)
|
||||||
|
return "";
|
||||||
|
|
||||||
|
return int(uptime_seconds) <= BOOT_START_UPTIME_SECONDS ? BOOT_START_VERIFY_TIMEOUT : "";
|
||||||
|
}
|
||||||
|
|
||||||
function start_service(reason, owner_pid) {
|
function start_service(reason, owner_pid) {
|
||||||
print("Start Tachyon\n");
|
print("Start Tachyon\n");
|
||||||
let plan = start_plan_value(reason, owner_pid, uci_settings(), null);
|
let plan = start_plan_value(reason, owner_pid, uci_settings(), null);
|
||||||
if (!plan.bin_ok)
|
if (!plan.bin_ok)
|
||||||
return 1;
|
return 1;
|
||||||
|
|
||||||
let status = command_status_from_args([ BIN_PATH, "start" ]);
|
let verify_timeout = start_verify_timeout(reason, null);
|
||||||
|
let start_command = command_from_args([ BIN_PATH, "start" ]);
|
||||||
|
if (verify_timeout != "")
|
||||||
|
start_command = "TACHYON_SING_BOX_START_VERIFY_TIMEOUT=" +
|
||||||
|
shell_quote(verify_timeout) + " " + start_command;
|
||||||
|
|
||||||
|
let status = command_status(start_command);
|
||||||
if (status == 0) {
|
if (status == 0) {
|
||||||
clear_start_retry(START_RETRY_FILE);
|
clear_start_retry(START_RETRY_FILE);
|
||||||
cancel_scheduled_start_retry(START_RETRY_PID_FILE);
|
cancel_scheduled_start_retry(START_RETRY_PID_FILE);
|
||||||
@@ -736,6 +773,8 @@ else if (mode == "start-plan")
|
|||||||
start_plan(ARGV[1], ARGV[2], uci_settings(), null);
|
start_plan(ARGV[1], ARGV[2], uci_settings(), null);
|
||||||
else if (mode == "start-service")
|
else if (mode == "start-service")
|
||||||
exit(start_service(ARGV[1], ARGV[2]));
|
exit(start_service(ARGV[1], ARGV[2]));
|
||||||
|
else if (mode == "start-verify-timeout-fixture")
|
||||||
|
print(start_verify_timeout(ARGV[1], ARGV[2] == null || ARGV[2] == "" ? null : int(ARGV[2])), "\n");
|
||||||
else if (mode == "start-plan-fixture") {
|
else if (mode == "start-plan-fixture") {
|
||||||
let settings = {
|
let settings = {
|
||||||
shutdown_correctly: ARGV[2],
|
shutdown_correctly: ARGV[2],
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachy
|
|||||||
const SYSTEM_INFO_CACHE_FILE = getenv("TACHYON_SYSTEM_INFO_CACHE_FILE") || RUNTIME_STATE_DIR + "/system-info.json";
|
const SYSTEM_INFO_CACHE_FILE = getenv("TACHYON_SYSTEM_INFO_CACHE_FILE") || RUNTIME_STATE_DIR + "/system-info.json";
|
||||||
const RELOAD_STATE_FILE = getenv("TACHYON_RELOAD_STATE_FILE") || RUNTIME_STATE_DIR + "/reload-state";
|
const RELOAD_STATE_FILE = getenv("TACHYON_RELOAD_STATE_FILE") || RUNTIME_STATE_DIR + "/reload-state";
|
||||||
const RELOAD_STATE_SNAPSHOT_FILE = getenv("TACHYON_RELOAD_STATE_SNAPSHOT_FILE") || RUNTIME_STATE_DIR + "/reload-state.snapshot." + clock()[0] + "." + clock()[1];
|
const RELOAD_STATE_SNAPSHOT_FILE = getenv("TACHYON_RELOAD_STATE_SNAPSHOT_FILE") || RUNTIME_STATE_DIR + "/reload-state.snapshot." + clock()[0] + "." + clock()[1];
|
||||||
|
const RELOAD_HASH_FILE = getenv("TACHYON_RELOAD_HASH_FILE") || RUNTIME_STATE_DIR + "/reload.hash";
|
||||||
const PENDING_RELOAD_FILE = getenv("TACHYON_PENDING_RELOAD_FILE") || RUNTIME_STATE_DIR + "/reload.pending";
|
const PENDING_RELOAD_FILE = getenv("TACHYON_PENDING_RELOAD_FILE") || RUNTIME_STATE_DIR + "/reload.pending";
|
||||||
const SERVICE_TRIGGER_SYNC_FILE = getenv("TACHYON_SERVICE_TRIGGER_SYNC_FILE") || RUNTIME_STATE_DIR + "/service-triggers.sync";
|
const SERVICE_TRIGGER_SYNC_FILE = getenv("TACHYON_SERVICE_TRIGGER_SYNC_FILE") || RUNTIME_STATE_DIR + "/service-triggers.sync";
|
||||||
const SUBSCRIPTION_UPDATE_STATE_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR") || RUNTIME_STATE_DIR + "/subscription-update";
|
const SUBSCRIPTION_UPDATE_STATE_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR") || RUNTIME_STATE_DIR + "/subscription-update";
|
||||||
@@ -52,8 +53,17 @@ const COMPONENT_UPDATE_CHECK_CRON_MARKER = getenv("TACHYON_COMPONENT_UPDATE_CHEC
|
|||||||
const RELOAD_STATE_FORMAT = int(getenv("TACHYON_RELOAD_STATE_FORMAT") || "1");
|
const RELOAD_STATE_FORMAT = int(getenv("TACHYON_RELOAD_STATE_FORMAT") || "1");
|
||||||
const RUNTIME_CACHE_FORMAT = int(getenv("TACHYON_RUNTIME_CACHE_FORMAT") || "8");
|
const RUNTIME_CACHE_FORMAT = int(getenv("TACHYON_RUNTIME_CACHE_FORMAT") || "8");
|
||||||
const RUNTIME_STABLE_MIN_AGE = int(getenv("TACHYON_RUNTIME_STABLE_MIN_AGE") || "2");
|
const RUNTIME_STABLE_MIN_AGE = int(getenv("TACHYON_RUNTIME_STABLE_MIN_AGE") || "2");
|
||||||
const SING_BOX_START_STABLE_MIN_AGE = int(getenv("TACHYON_SING_BOX_START_STABLE_MIN_AGE") || "8");
|
// The stable-age window is what a successful start actually waits out: the poll
|
||||||
const SING_BOX_START_VERIFY_TIMEOUT = int(getenv("TACHYON_SING_BOX_START_VERIFY_TIMEOUT") || "45");
|
// loop cannot succeed until sing-box has survived this long. It exists to catch a
|
||||||
|
// process that comes up and then dies on a bad config, which happens within the
|
||||||
|
// first second or two, so 5s keeps the crash window while cutting 3s off every
|
||||||
|
// start, restart and reload. Later failures are the watchdog's job, not this gate.
|
||||||
|
const SING_BOX_START_STABLE_MIN_AGE = int(getenv("TACHYON_SING_BOX_START_STABLE_MIN_AGE") || "5");
|
||||||
|
// A healthy sing-box reaches the stable age within a couple of seconds; a run that
|
||||||
|
// is still unstable at 15s is failing for a reason more time will not fix. Cold
|
||||||
|
// boot is the exception: the WAN may still be coming up, so the init script raises
|
||||||
|
// this via TACHYON_SING_BOX_START_VERIFY_TIMEOUT for boot and WAN-retry starts.
|
||||||
|
const SING_BOX_START_VERIFY_TIMEOUT = int(getenv("TACHYON_SING_BOX_START_VERIFY_TIMEOUT") || "15");
|
||||||
const NFT_POPULATE_ENABLED_DEFAULT = int(getenv("TACHYON_NFT_POPULATE_ENABLED") || "1");
|
const NFT_POPULATE_ENABLED_DEFAULT = int(getenv("TACHYON_NFT_POPULATE_ENABLED") || "1");
|
||||||
|
|
||||||
const TMP_SING_BOX_FOLDER = getenv("TMP_SING_BOX_FOLDER") || constant_value("TMP_SING_BOX_FOLDER", "/tmp/sing-box");
|
const TMP_SING_BOX_FOLDER = getenv("TMP_SING_BOX_FOLDER") || constant_value("TMP_SING_BOX_FOLDER", "/tmp/sing-box");
|
||||||
@@ -249,6 +259,12 @@ function remove_file(path) {
|
|||||||
return fs.unlink(as_string(path)) || true;
|
return fs.unlink(as_string(path)) || true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invalidate the last-reload hash so a reload right after a stop/start is never
|
||||||
|
// skipped: the hash file reflects a reload from a previous run, not this one.
|
||||||
|
function invalidate_reload_hash() {
|
||||||
|
remove_file(RELOAD_HASH_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
function ensure_dir(path) {
|
function ensure_dir(path) {
|
||||||
path = as_string(path);
|
path = as_string(path);
|
||||||
return path == "" || fs.mkdir(path, 0755) || fs.stat(path) != null;
|
return path == "" || fs.mkdir(path, 0755) || fs.stat(path) != null;
|
||||||
@@ -360,8 +376,13 @@ function mark_pending_reload_if_config_changed(initial_fingerprint, reason) {
|
|||||||
|
|
||||||
function finish_reload_status(status, initial_fingerprint) {
|
function finish_reload_status(status, initial_fingerprint) {
|
||||||
status = int(status || 0);
|
status = int(status || 0);
|
||||||
if (status == 0)
|
if (status == 0) {
|
||||||
|
// Record the hash the reload acted on so a subsequent no-op reload can be
|
||||||
|
// skipped before it forks anything. Only written on success: a failed or
|
||||||
|
// aborted reload must not suppress the retry of the same config.
|
||||||
|
save_completed_reload_hash();
|
||||||
mark_pending_reload_if_config_changed(initial_fingerprint, "config_changed_during_reload");
|
mark_pending_reload_if_config_changed(initial_fingerprint, "config_changed_during_reload");
|
||||||
|
}
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,6 +492,17 @@ function current_config_hash() {
|
|||||||
return file_md5(CONFIG_FILE);
|
return file_md5(CONFIG_FILE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function last_completed_reload_hash() {
|
||||||
|
let data = fs.readfile(RELOAD_HASH_FILE);
|
||||||
|
return data == null ? "" : trim(as_string(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
function save_completed_reload_hash() {
|
||||||
|
let hash = current_config_hash();
|
||||||
|
if (hash != "")
|
||||||
|
write_file(RELOAD_HASH_FILE, hash + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
function mark_internal_config_guard() {
|
function mark_internal_config_guard() {
|
||||||
let hash = current_config_hash();
|
let hash = current_config_hash();
|
||||||
if (hash == "") {
|
if (hash == "") {
|
||||||
@@ -572,7 +604,12 @@ function discover_awg_mtu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validate_start_config() {
|
function validate_start_config() {
|
||||||
let status = module_status(VALIDATOR_UC, [ "check-requirements" ]);
|
// The two validator checks share the expensive context_from_runtime() assembly
|
||||||
|
// (sing-box version, installed packages, compression markers). Running both in
|
||||||
|
// one ucode process cuts two forks (context_from_runtime twice + one extra
|
||||||
|
// module_status round-trip). check_runtime_requirements exits non-zero on
|
||||||
|
// failure, so validate_runtime_config is only reached when requirements pass.
|
||||||
|
let status = module_status(VALIDATOR_UC, [ "check-requirements-and-validate-runtime" ]);
|
||||||
if (status != 0)
|
if (status != 0)
|
||||||
return status;
|
return status;
|
||||||
|
|
||||||
@@ -580,12 +617,6 @@ function validate_start_config() {
|
|||||||
if (status != 0)
|
if (status != 0)
|
||||||
return status;
|
return status;
|
||||||
|
|
||||||
status = module_status(VALIDATOR_UC, [ "validate-runtime" ]);
|
|
||||||
if (status != 0) {
|
|
||||||
log_message("Runtime config validation failed. Aborted.", "fatal");
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,13 +838,12 @@ function start_impl() {
|
|||||||
// all tachyon temp files are created and deleted within seconds under normal operation.
|
// all tachyon temp files are created and deleted within seconds under normal operation.
|
||||||
command_status("find /tmp -maxdepth 1 -name 'tachyon-*' -mmin +30 -delete 2>/dev/null; true");
|
command_status("find /tmp -maxdepth 1 -name 'tachyon-*' -mmin +30 -delete 2>/dev/null; true");
|
||||||
|
|
||||||
// Start Watchdog & Telegram Bot early so they remain active even if proxy configuration fails
|
// Watchdog must start early so auto-healing is active even through a failed boot.
|
||||||
|
// Telegram (119 KB) is heavy but non-critical: move to background after the proxy
|
||||||
|
// stack is verified and running, saving one serial ucode fork from the start path.
|
||||||
let wd_status = module_status(WATCHDOG_UC, [ "start-runtime" ]);
|
let wd_status = module_status(WATCHDOG_UC, [ "start-runtime" ]);
|
||||||
if (wd_status != 0)
|
if (wd_status != 0)
|
||||||
log_message("Watchdog start-runtime failed (status " + as_string(wd_status) + ")", "warn");
|
log_message("Watchdog start-runtime failed (status " + as_string(wd_status) + ")", "warn");
|
||||||
let tg_status = module_status(TELEGRAM_UC, [ "start-runtime" ]);
|
|
||||||
if (tg_status != 0)
|
|
||||||
log_message("Telegram start-runtime failed (status " + as_string(tg_status) + ")", "warn");
|
|
||||||
|
|
||||||
let status = start_main();
|
let status = start_main();
|
||||||
if (status != 0)
|
if (status != 0)
|
||||||
@@ -853,6 +883,7 @@ function start_impl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module_background(DIAGNOSTICS_UC, [ "get-system-info" ]);
|
module_background(DIAGNOSTICS_UC, [ "get-system-info" ]);
|
||||||
|
module_background(TELEGRAM_UC, [ "start-runtime" ]);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -941,6 +972,7 @@ function abort_reload(status, runtime_changed) {
|
|||||||
function start() {
|
function start() {
|
||||||
let status = start_impl();
|
let status = start_impl();
|
||||||
release_start_subscription_update_lock();
|
release_start_subscription_update_lock();
|
||||||
|
invalidate_reload_hash();
|
||||||
|
|
||||||
if (status != 0) {
|
if (status != 0) {
|
||||||
cleanup_failed_runtime();
|
cleanup_failed_runtime();
|
||||||
@@ -965,6 +997,7 @@ function start() {
|
|||||||
|
|
||||||
function stop_impl() {
|
function stop_impl() {
|
||||||
let status = 0;
|
let status = 0;
|
||||||
|
invalidate_reload_hash();
|
||||||
|
|
||||||
if (!setting_bool("dont_touch_dhcp", false)) {
|
if (!setting_bool("dont_touch_dhcp", false)) {
|
||||||
let dns_status = dnsmasq_restore(false);
|
let dns_status = dnsmasq_restore(false);
|
||||||
@@ -1006,6 +1039,7 @@ function restart_runtime_for_reload() {
|
|||||||
let selector_state = capture_selector_state();
|
let selector_state = capture_selector_state();
|
||||||
|
|
||||||
log_message("Reload requires a full Tachyon runtime restart", "info");
|
log_message("Reload requires a full Tachyon runtime restart", "info");
|
||||||
|
invalidate_reload_hash();
|
||||||
|
|
||||||
status = stop_main();
|
status = stop_main();
|
||||||
if (status != 0)
|
if (status != 0)
|
||||||
@@ -1179,6 +1213,14 @@ function reload(reason) {
|
|||||||
|
|
||||||
log_message("Reloading Tachyon", "info");
|
log_message("Reloading Tachyon", "info");
|
||||||
|
|
||||||
|
// Skip all work when config fingerprint is unchanged since the last reload.
|
||||||
|
// Applies to user-triggered reloads as well as on_config_change; saves ~4
|
||||||
|
// ucode forks (3 validate + 1 plan) when nothing actually changed.
|
||||||
|
if (current_config_hash() == last_completed_reload_hash()) {
|
||||||
|
log_message("Reload skipped: configuration is unchanged", "info");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
status = validate_start_config();
|
status = validate_start_config();
|
||||||
if (status != 0)
|
if (status != 0)
|
||||||
return status;
|
return status;
|
||||||
|
|||||||
@@ -2095,7 +2095,8 @@ function process_updates(token, admin_ids) {
|
|||||||
let offset = int(trim(fs.readfile(OFFSET_FILE) || "0"));
|
let offset = int(trim(fs.readfile(OFFSET_FILE) || "0"));
|
||||||
let res = tg_request(token, "getUpdates", { offset: offset, timeout: 20 });
|
let res = tg_request(token, "getUpdates", { offset: offset, timeout: 20 });
|
||||||
|
|
||||||
if (!res || !res.ok || !res.result || length(res.result) == 0) return false;
|
if (!res || !res.ok || !res.result) return false;
|
||||||
|
if (length(res.result) == 0) return true;
|
||||||
|
|
||||||
for (let upd in res.result) {
|
for (let upd in res.result) {
|
||||||
let update_id = upd.update_id;
|
let update_id = upd.update_id;
|
||||||
|
|||||||
@@ -187,6 +187,30 @@ function smart_detect_get_proxy_sections() {
|
|||||||
return secs;
|
return secs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract a candidate domain from a sing-box log line. Returns null when the
|
||||||
|
// line carries no usable hostname. Kept separate from log handling so the
|
||||||
|
// pattern can be exercised directly by tests.
|
||||||
|
function smart_detect_extract_domain(line) {
|
||||||
|
if (line == null) return null;
|
||||||
|
let text = as_string(line);
|
||||||
|
let m = match(text, /"([a-zA-Z0-9][a-zA-Z0-9.-]{1,60}\.[a-zA-Z]{2,})(:[0-9]+)?"/);
|
||||||
|
if (!m) m = match(text, /target[= ]([a-zA-Z0-9][a-zA-Z0-9.-]{1,60}\.[a-zA-Z]{2,})/);
|
||||||
|
if (!m || !m[1]) return null;
|
||||||
|
let domain = m[1];
|
||||||
|
if (length(domain) < 5) return null;
|
||||||
|
if (index(domain, "*") >= 0 || index(domain, "?") >= 0) return null;
|
||||||
|
if (index(domain, "..") >= 0) return null;
|
||||||
|
if (index(domain, "-") == 0 || substr(domain, length(domain) - 1) == "-") return null;
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A domain that resolves nowhere is a DNS fault, not a block: probing it would
|
||||||
|
// fail directly and via proxy alike, so treat it as unresolvable and skip.
|
||||||
|
function smart_detect_domain_resolves(domain) {
|
||||||
|
return command_success_from_args([ "nslookup", domain, "127.0.0.1" ]) ||
|
||||||
|
command_success_from_args([ "nslookup", domain ]);
|
||||||
|
}
|
||||||
|
|
||||||
function smart_detect_add_domain(sec_name, domain) {
|
function smart_detect_add_domain(sec_name, domain) {
|
||||||
let c = uci_core.cursor();
|
let c = uci_core.cursor();
|
||||||
if (!c) return false;
|
if (!c) return false;
|
||||||
@@ -1327,7 +1351,7 @@ function smart_detect_process_pending() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let now = time();
|
let now = time();
|
||||||
if (now - smart_detect_last_run < 60) return;
|
if (now - smart_detect_last_run < 30) return;
|
||||||
|
|
||||||
let domain_list = keys(pending_smart_domains);
|
let domain_list = keys(pending_smart_domains);
|
||||||
if (length(domain_list) == 0) return;
|
if (length(domain_list) == 0) return;
|
||||||
@@ -1382,24 +1406,38 @@ function smart_detect_process_pending() {
|
|||||||
for (let domain in candidate_domains) {
|
for (let domain in candidate_domains) {
|
||||||
try {
|
try {
|
||||||
seen[domain] = now;
|
seen[domain] = now;
|
||||||
|
|
||||||
|
// DNS pre-check: skip if domain doesn't resolve at all (not a block, DNS fault)
|
||||||
|
if (!smart_detect_domain_resolves(domain)) {
|
||||||
|
log_message("Smart Detect: " + domain + " does not resolve via DNS, skipping", "debug");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let direct_ok = command_success_from_args([
|
let direct_ok = command_success_from_args([
|
||||||
"curl", "-s", "-I", "--connect-timeout", "4", "--max-time", "6",
|
"curl", "-s", "-I", "--connect-timeout", "4", "--max-time", "6",
|
||||||
"https://" + domain
|
"https://" + domain
|
||||||
]);
|
]);
|
||||||
if (direct_ok) continue;
|
if (direct_ok) continue;
|
||||||
|
|
||||||
|
// Single probe through the shared http/mixed inbound. This inbound follows
|
||||||
|
// the global routing rules, so it cannot be aimed at one specific section:
|
||||||
|
// the result is the same for every candidate section. Probe once, then hand
|
||||||
|
// the domain to the first section that accepts it (user-defined order).
|
||||||
|
let proxy_ok = command_success_from_args([
|
||||||
|
"curl", "-s", "-I", "--connect-timeout", "5", "--max-time", "8",
|
||||||
|
"--proxy", "http://" + proxy_addr,
|
||||||
|
"https://" + domain
|
||||||
|
]);
|
||||||
|
if (!proxy_ok) {
|
||||||
|
log_message("Smart Detect: " + domain + " fails directly and via proxy, skipping", "info");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let added = false;
|
let added = false;
|
||||||
for (let sec_name in detect_sections) {
|
for (let sec_name in detect_sections) {
|
||||||
sec_name = trim(as_string(sec_name));
|
sec_name = trim(as_string(sec_name));
|
||||||
if (sec_name == "") continue;
|
if (sec_name == "") continue;
|
||||||
|
|
||||||
let proxy_ok = command_success_from_args([
|
|
||||||
"curl", "-s", "-I", "--connect-timeout", "5", "--max-time", "8",
|
|
||||||
"--proxy", "http://" + proxy_addr,
|
|
||||||
"https://" + domain
|
|
||||||
]);
|
|
||||||
if (!proxy_ok) continue;
|
|
||||||
|
|
||||||
log_message("Smart Detect: adding " + domain + " to section " + sec_name, "info");
|
log_message("Smart Detect: adding " + domain + " to section " + sec_name, "info");
|
||||||
if (smart_detect_add_domain(sec_name, domain)) {
|
if (smart_detect_add_domain(sec_name, domain)) {
|
||||||
let tcfg = common.object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram"));
|
let tcfg = common.object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram"));
|
||||||
@@ -1476,10 +1514,9 @@ function handle_log_line(line) {
|
|||||||
if (cfg.smart_detect == "1") {
|
if (cfg.smart_detect == "1") {
|
||||||
if ((index(line_lower, "direct") >= 0 || index(line_lower, "DIRECT") >= 0) &&
|
if ((index(line_lower, "direct") >= 0 || index(line_lower, "DIRECT") >= 0) &&
|
||||||
(index(line_lower, "failed") >= 0 || index(line_lower, "timeout") >= 0 || index(line_lower, "reset") >= 0)) {
|
(index(line_lower, "failed") >= 0 || index(line_lower, "timeout") >= 0 || index(line_lower, "reset") >= 0)) {
|
||||||
let m = match(line, /"([a-zA-Z0-9][a-zA-Z0-9.-]{1,60}\.[a-zA-Z]{2,})(:[0-9]+)?"/);
|
let domain = smart_detect_extract_domain(line);
|
||||||
if (!m) m = match(line, /target[= ]([a-zA-Z0-9][a-zA-Z0-9.-]{1,60}\.[a-zA-Z]{2,})/);
|
if (domain != null) {
|
||||||
if (m && m[1] && length(m[1]) >= 5) {
|
pending_smart_domains[domain] = time();
|
||||||
pending_smart_domains[m[1]] = time();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1812,7 +1849,13 @@ else if (mode == "ai-status-full") {
|
|||||||
print_ai_status_full();
|
print_ai_status_full();
|
||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
|
else if (mode == "smart-detect-extract-domain") {
|
||||||
|
let extracted = smart_detect_extract_domain(ARGV[1]);
|
||||||
|
if (extracted == null) exit(1);
|
||||||
|
print(extracted + "\n");
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
else {
|
else {
|
||||||
warn("Usage: service/watchdog.uc <start-runtime|stop-runtime|worker|status|ai-heal|ai-status|ai-status-full> ...\n");
|
warn("Usage: service/watchdog.uc <start-runtime|stop-runtime|worker|status|ai-heal|ai-status|ai-status-full|smart-detect-extract-domain> ...\n");
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ fi
|
|||||||
grep -Fq 'mark_internal_config_guard();' "$LIFECYCLE" ||
|
grep -Fq 'mark_internal_config_guard();' "$LIFECYCLE" ||
|
||||||
fail "service/lifecycle.uc must preserve the internal config trigger guard"
|
fail "service/lifecycle.uc must preserve the internal config trigger guard"
|
||||||
grep -Fq 'VALIDATOR_UC' "$LIFECYCLE" &&
|
grep -Fq 'VALIDATOR_UC' "$LIFECYCLE" &&
|
||||||
grep -Fq '"validate-runtime"' "$LIFECYCLE" ||
|
grep -Fq 'validate-runtime' "$LIFECYCLE" ||
|
||||||
fail "service/lifecycle.uc must run runtime validation directly through ucode"
|
fail "service/lifecycle.uc must run runtime validation directly through ucode"
|
||||||
|
|
||||||
|
grep -Fq 'mode == "check-requirements-and-validate-runtime"' "$VALIDATOR" ||
|
||||||
|
fail "config validator must own the combined start-path validation mode"
|
||||||
|
|
||||||
printf 'config validation ownership checks passed\n'
|
printf 'config validation ownership checks passed\n'
|
||||||
|
|||||||
+32
-4
@@ -174,8 +174,12 @@ assert_log_empty() {
|
|||||||
[ ! -s "$LOG" ] || fail "expected empty log"
|
[ ! -s "$LOG" ] || fail "expected empty log"
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_dnsmasq_restarted() {
|
# dnsmasq must be told to re-read its config. A reload is preferred (procd
|
||||||
grep -Fxq 'restart' "$DNSMASQ_LOG" || fail "expected dnsmasq restart"
|
# regenerates dnsmasq.conf and only bounces the instance when it changed), with a
|
||||||
|
# full restart as the fallback, so either action satisfies the requirement.
|
||||||
|
assert_dnsmasq_reloaded() {
|
||||||
|
grep -Fxq 'reload' "$DNSMASQ_LOG" || grep -Fxq 'restart' "$DNSMASQ_LOG" ||
|
||||||
|
fail "expected dnsmasq reload or restart"
|
||||||
}
|
}
|
||||||
|
|
||||||
cat >"$STATE" <<'EOF_STATE'
|
cat >"$STATE" <<'EOF_STATE'
|
||||||
@@ -195,7 +199,7 @@ assert_value 'dhcp.@dnsmasq[0].tachyon_noresolv' '0'
|
|||||||
assert_value 'dhcp.@dnsmasq[0].cachesize' '0'
|
assert_value 'dhcp.@dnsmasq[0].cachesize' '0'
|
||||||
assert_value 'dhcp.@dnsmasq[0].tachyon_cachesize' '150'
|
assert_value 'dhcp.@dnsmasq[0].tachyon_cachesize' '150'
|
||||||
assert_log_contains 'commit dhcp'
|
assert_log_contains 'commit dhcp'
|
||||||
assert_dnsmasq_restarted
|
assert_dnsmasq_reloaded
|
||||||
|
|
||||||
: > "$DNSMASQ_LOG"
|
: > "$DNSMASQ_LOG"
|
||||||
: > "$LOG"
|
: > "$LOG"
|
||||||
@@ -207,7 +211,7 @@ assert_absent 'dhcp.@dnsmasq[0].tachyon_server'
|
|||||||
assert_absent 'dhcp.@dnsmasq[0].tachyon_noresolv'
|
assert_absent 'dhcp.@dnsmasq[0].tachyon_noresolv'
|
||||||
assert_absent 'dhcp.@dnsmasq[0].tachyon_cachesize'
|
assert_absent 'dhcp.@dnsmasq[0].tachyon_cachesize'
|
||||||
assert_log_contains 'commit dhcp'
|
assert_log_contains 'commit dhcp'
|
||||||
assert_dnsmasq_restarted
|
assert_dnsmasq_reloaded
|
||||||
|
|
||||||
cat >"$STATE" <<'EOF_STATE'
|
cat >"$STATE" <<'EOF_STATE'
|
||||||
dhcp.@dnsmasq[0].server=127.0.0.42
|
dhcp.@dnsmasq[0].server=127.0.0.42
|
||||||
@@ -256,4 +260,28 @@ assert_absent 'dhcp.@dnsmasq[0].tachyon_noresolv'
|
|||||||
assert_absent 'dhcp.@dnsmasq[0].tachyon_cachesize'
|
assert_absent 'dhcp.@dnsmasq[0].tachyon_cachesize'
|
||||||
assert_log_contains 'commit dhcp'
|
assert_log_contains 'commit dhcp'
|
||||||
|
|
||||||
|
# An init script without a working reload (older or non-procd dnsmasq) must still
|
||||||
|
# get its config applied through the full-restart fallback.
|
||||||
|
cat >"$WORK_DIR/dnsmasq-init" <<'DNSMASQ'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eo pipefail
|
||||||
|
printf '%s\n' "$*" >> "${DNSMASQ_LOG:?}"
|
||||||
|
[ "${1:-}" != "reload" ] || exit 1
|
||||||
|
DNSMASQ
|
||||||
|
chmod 0755 "$WORK_DIR/dnsmasq-init"
|
||||||
|
|
||||||
|
cat >"$STATE" <<'EOF_STATE'
|
||||||
|
dhcp.@dnsmasq[0].server=1.1.1.1 8.8.8.8
|
||||||
|
dhcp.@dnsmasq[0].noresolv=0
|
||||||
|
dhcp.@dnsmasq[0].cachesize=150
|
||||||
|
tachyon.settings.shutdown_correctly=1
|
||||||
|
EOF_STATE
|
||||||
|
|
||||||
|
: > "$DNSMASQ_LOG"
|
||||||
|
: > "$LOG"
|
||||||
|
ucode -L "$UCODE_LIB" "$APPLY" configure force
|
||||||
|
assert_value 'dhcp.@dnsmasq[0].server' '127.0.0.42'
|
||||||
|
grep -Fxq 'reload' "$DNSMASQ_LOG" || fail "fallback: expected a reload attempt first"
|
||||||
|
grep -Fxq 'restart' "$DNSMASQ_LOG" || fail "fallback: expected restart after failed reload"
|
||||||
|
|
||||||
printf 'DNS apply checks passed\n'
|
printf 'DNS apply checks passed\n'
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
WATCHDOG_UC="$ROOT_DIR/tachyon/files/usr/lib/service/watchdog.uc"
|
||||||
|
TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib"
|
||||||
|
|
||||||
|
ucode() {
|
||||||
|
command ucode -L "$TACHYON_LIB" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'FAIL: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
extract() {
|
||||||
|
ucode "$WATCHDOG_UC" smart-detect-extract-domain "$1" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Asserts the line yields exactly the expected domain.
|
||||||
|
assert_extracts() {
|
||||||
|
local line="$1"
|
||||||
|
local expected="$2"
|
||||||
|
local label="$3"
|
||||||
|
local actual
|
||||||
|
actual="$(extract "$line")"
|
||||||
|
[ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Asserts the line yields no domain (exit 1, empty stdout).
|
||||||
|
assert_rejects() {
|
||||||
|
local line="$1"
|
||||||
|
local label="$2"
|
||||||
|
local actual
|
||||||
|
actual="$(extract "$line")"
|
||||||
|
[ -z "$actual" ] || fail "$label: expected no domain, got '$actual'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- quoted-host form (primary sing-box log shape) ---
|
||||||
|
assert_extracts 'outbound/direct: failed to connect to "example.com:443"' \
|
||||||
|
example.com "quoted host with port"
|
||||||
|
assert_extracts 'direct connection failed: "sub.domain.example.org"' \
|
||||||
|
sub.domain.example.org "quoted host without port, multi-label"
|
||||||
|
assert_extracts 'DIRECT timeout "xn--80ak6aa92e.com:443"' \
|
||||||
|
xn--80ak6aa92e.com "punycode host"
|
||||||
|
assert_extracts 'direct reset "a-b-c.example.co.uk:8443"' \
|
||||||
|
a-b-c.example.co.uk "internal hyphens and multi-part TLD"
|
||||||
|
|
||||||
|
# --- target= form (fallback pattern) ---
|
||||||
|
assert_extracts 'direct failed target=blocked.example.net' \
|
||||||
|
blocked.example.net "target= form"
|
||||||
|
assert_extracts 'direct failed target blocked2.example.net' \
|
||||||
|
blocked2.example.net "target<space> form"
|
||||||
|
|
||||||
|
# --- rejections: nothing usable on the line ---
|
||||||
|
assert_rejects 'direct failed to connect to 192.168.1.1:443' "bare IPv4 is not a domain"
|
||||||
|
assert_rejects 'direct connection failed, no host in line' "no host present"
|
||||||
|
assert_rejects 'direct failed "localhost"' "single-label host has no TLD"
|
||||||
|
assert_rejects 'direct failed "a.co"' "below 5-char minimum"
|
||||||
|
|
||||||
|
# --- rejections: wildcards must never reach the probe ---
|
||||||
|
assert_rejects 'direct failed "*.example.com"' "wildcard star rejected"
|
||||||
|
assert_rejects 'direct failed "?.example.com"' "wildcard question mark rejected"
|
||||||
|
|
||||||
|
# --- rejections: malformed labels ---
|
||||||
|
assert_rejects 'direct failed "example..com"' "consecutive dots rejected"
|
||||||
|
assert_rejects 'direct failed "-example.com"' "leading hyphen rejected"
|
||||||
|
|
||||||
|
# --- shell metacharacters must be returned inert, never executed ---
|
||||||
|
# The extractor is the boundary that keeps log text out of the probe command;
|
||||||
|
# a hostile log line must either be rejected or come back as a plain string.
|
||||||
|
SENTINEL="$ROOT_DIR/tmp_smart_detect_injection_sentinel"
|
||||||
|
rm -f "$SENTINEL"
|
||||||
|
for hostile in \
|
||||||
|
'direct failed "example.com; touch '"$SENTINEL"'"' \
|
||||||
|
'direct failed "example.com$(touch '"$SENTINEL"')"' \
|
||||||
|
'direct failed "example.com`touch '"$SENTINEL"'`"' \
|
||||||
|
'direct failed "example.com | touch '"$SENTINEL"'"'
|
||||||
|
do
|
||||||
|
out="$(extract "$hostile")"
|
||||||
|
case "$out" in
|
||||||
|
*';'*|*'$('*|*'`'*|*'|'*|*' '*)
|
||||||
|
fail "injection: metacharacters survived extraction: '$out'"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
[ ! -e "$SENTINEL" ] || fail "injection: command executed during extraction of '$hostile'"
|
||||||
|
done
|
||||||
|
rm -f "$SENTINEL"
|
||||||
|
|
||||||
|
printf 'smart_detect_domain_extraction checks passed\n'
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
TELEGRAM="$ROOT_DIR/tachyon/files/usr/lib/service/telegram.uc"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'FAIL: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# process_updates() must treat an empty result array as a success (no new
|
||||||
|
# updates is a normal API response), not as a failure that increments the
|
||||||
|
# backoff counter.
|
||||||
|
#
|
||||||
|
# Before the fix, the guard was:
|
||||||
|
# if (!res || !res.ok || !res.result || length(res.result) == 0) return false;
|
||||||
|
# This counted "result: []" as a failure, causing a permanent "API failure"
|
||||||
|
# spam and exponential backoff on every poll.
|
||||||
|
#
|
||||||
|
# After the fix, the guard is split:
|
||||||
|
# if (!res || !res.ok || !res.result) return false; // real errors
|
||||||
|
# if (length(res.result) == 0) return true; // idle poll
|
||||||
|
|
||||||
|
# The fix: empty result must not be gated by a single expression that also
|
||||||
|
# returns false for !res.ok. Verify the two checks are separate statements.
|
||||||
|
grep -Fq 'result) == 0) return true' "$TELEGRAM" ||
|
||||||
|
fail "process_updates must return true on empty result[] (idle poll)"
|
||||||
|
|
||||||
|
# Verify the error guard does NOT include the length==0 check.
|
||||||
|
if grep -Fq 'length(res.result) == 0) return false' "$TELEGRAM"; then
|
||||||
|
fail "process_updates must NOT return false when result[] is empty"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'telegram empty result checks passed\n'
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'FAIL: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# BusyBox wget in OpenWrt 25.12+ no longer supports -t (retries). Tachyon
|
||||||
|
# handles retries explicitly in a while loop, so the flag is unnecessary and
|
||||||
|
# breaks downloads on recent OpenWrt releases.
|
||||||
|
if grep -rn --fixed-strings 'wget' "$ROOT_DIR/tachyon/files" | grep -E -- '(^|[^-a-z])(-t[^a-z]|-t")' >/dev/null 2>&1; then
|
||||||
|
grep -rn --fixed-strings 'wget' "$ROOT_DIR/tachyon/files" | grep -E -- '(^|[^-a-z])(-t[^a-z]|-t")' >&2
|
||||||
|
fail "wget -t is incompatible with OpenWrt 25.12+ BusyBox; use explicit retry loops instead"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# NOTE(2026-08-07): download_to_file() in components/updates.uc was the only
|
||||||
|
# call site that carried -t. It was removed and curl-first fallback was added.
|
||||||
|
# This invariant protects against future regressions.
|
||||||
|
|
||||||
|
printf 'wget flag hardening checks passed\n'
|
||||||
Reference in New Issue
Block a user