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:
Dushnilin
2026-08-07 11:46:52 +03:00
parent 33387ace3d
commit 4e419d5aea
12 changed files with 383 additions and 41 deletions
+22 -5
View File
@@ -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()));
+14 -2
View File
@@ -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() {
+40 -1
View File
@@ -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],
+56 -14
View File
@@ -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;
+2 -1
View File
@@ -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;
+56 -13
View File
@@ -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);
} }
+4 -1
View File
@@ -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
View File
@@ -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'
+91
View File
@@ -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'
+35
View File
@@ -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'
+23
View File
@@ -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'