#!/usr/bin/env ucode let fs = require("fs"); let constants = require("core.constants"); let uci_core = require("core.uci"); let common = require("core.common"); let as_string = common.as_string; function constant_value(name, fallback) { let value = constants[name]; return value == null ? as_string(fallback) : as_string(value); } const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || constant_value("TACHYON_CONFIG_NAME", "tachyon"); const CONFIG_FILE = getenv("TACHYON_CONFIG_FILE") || "/etc/config/" + CONFIG_NAME; const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon"; const BIN_PATH = getenv("TACHYON_BIN") || constant_value("TACHYON_BIN", "/usr/bin/tachyon"); const SERVICE_INIT = getenv("TACHYON_SERVICE_INIT") || constant_value("TACHYON_SERVICE_INIT", "/etc/init.d/tachyon"); const SERVICE_NAME = getenv("TACHYON_SERVICE_NAME") || constant_value("TACHYON_SERVICE_NAME", "tachyon"); const LUCI_VIEW_DIR = getenv("TACHYON_LUCI_VIEW_DIR") || constant_value("TACHYON_LUCI_VIEW_DIR", "/www/luci-static/resources/view/tachyon"); const LUCI_I18N_DOMAIN = getenv("TACHYON_LUCI_I18N_DOMAIN") || constant_value("TACHYON_LUCI_I18N_DOMAIN", "tachyon"); const RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon"; 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_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 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_LINKS_DIR = getenv("TACHYON_SUBSCRIPTION_LINKS_DIR") || RUNTIME_STATE_DIR + "/subscription-links"; const SUBSCRIPTION_METADATA_DIR = getenv("TACHYON_SUBSCRIPTION_METADATA_DIR") || RUNTIME_STATE_DIR + "/subscription-metadata"; const OUTBOUND_METADATA_DIR = getenv("TACHYON_OUTBOUND_METADATA_DIR") || RUNTIME_STATE_DIR + "/outbound-metadata"; const SECTION_CACHE_DIR = getenv("TACHYON_SECTION_CACHE_DIR") || RUNTIME_STATE_DIR + "/section-cache"; const RULE_CONDITION_CACHE_DIR = getenv("TACHYON_RULE_CONDITION_CACHE_DIR") || RUNTIME_STATE_DIR + "/rule-condition-cache"; const RUNTIME_CACHE_FORMAT_FILE = getenv("TACHYON_RUNTIME_CACHE_FORMAT_FILE") || RUNTIME_STATE_DIR + "/cache-format"; const PERSISTENT_SUBSCRIPTION_CACHE_DIR = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR") || "/etc/tachyon/subscription-cache"; const PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE") || PERSISTENT_SUBSCRIPTION_CACHE_DIR + "/cache-format"; const PERSISTENT_SUBSCRIPTION_CACHE_FORMAT = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT") || "7"; const SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE = getenv("TACHYON_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE") || RUNTIME_STATE_DIR + "/subscription-bootstrap-retry.pid"; const DNS_FAILOVER_STATE_FILE = getenv("TACHYON_DNS_FAILOVER_STATE_FILE") || RUNTIME_STATE_DIR + "/dns-failover.json"; const DNS_FAILOVER_PID_FILE = getenv("TACHYON_DNS_FAILOVER_PID_FILE") || RUNTIME_STATE_DIR + "/dns-failover.pid"; const SUBSCRIPTION_UPDATE_LOCK_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_LOCK_DIR") || RUNTIME_STATE_DIR + "/subscription-update.lock"; const RELOAD_LOCK_DIR = getenv("TACHYON_RELOAD_LOCK_DIR") || "/var/run/tachyon.reload.lock"; const INTERNAL_CONFIG_TRIGGER_GUARD = getenv("TACHYON_INTERNAL_CONFIG_TRIGGER_GUARD") || "/var/run/tachyon.internal-config-change"; const LIST_UPDATE_CRON_MARKER = getenv("TACHYON_LIST_UPDATE_CRON_MARKER") || "# tachyon-list-update"; const SUBSCRIPTION_UPDATE_CRON_MARKER = getenv("TACHYON_SUBSCRIPTION_UPDATE_CRON_MARKER") || "# tachyon-subscription-update"; const COMPONENT_UPDATE_CHECK_CRON_MARKER = getenv("TACHYON_COMPONENT_UPDATE_CHECK_CRON_MARKER") || "# tachyon-component-update-check"; const RELOAD_STATE_FORMAT = int(getenv("TACHYON_RELOAD_STATE_FORMAT") || "1"); const RUNTIME_CACHE_FORMAT = int(getenv("TACHYON_RUNTIME_CACHE_FORMAT") || "8"); const RUNTIME_STABLE_MIN_AGE = int(getenv("TACHYON_RUNTIME_STABLE_MIN_AGE") || "2"); // The stable-age window is what a successful start actually waits out: the poll // 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") || "60"); 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_RULESET_FOLDER = getenv("TMP_RULESET_FOLDER") || constant_value("TMP_RULESET_FOLDER", TMP_SING_BOX_FOLDER + "/rulesets"); const TMP_SUBSCRIPTION_FOLDER = getenv("TMP_SUBSCRIPTION_FOLDER") || constant_value("TMP_SUBSCRIPTION_FOLDER", TMP_SING_BOX_FOLDER + "/subscriptions"); const RT_TABLE_NAME = constant_value("RT_TABLE_NAME", "tachyon"); const NFT_TABLE_NAME = constant_value("NFT_TABLE_NAME", "TachyonTable"); const NFT_LOCALV4_SET_NAME = constant_value("NFT_LOCALV4_SET_NAME", "localv4"); const NFT_LOCALV6_SET_NAME = constant_value("NFT_LOCALV6_SET_NAME", "localv6"); const NFT_COMMON_SET_NAME = constant_value("NFT_COMMON_SET_NAME", "tachyon_subnets"); const NFT_COMMON6_SET_NAME = constant_value("NFT_COMMON6_SET_NAME", "tachyon_subnets6"); const NFT_PORT_SET_NAME = constant_value("NFT_PORT_SET_NAME", "tachyon_ports"); const NFT_IP_PORT_SET_NAME = constant_value("NFT_IP_PORT_SET_NAME", "tachyon_ip_ports"); const NFT_IP_PORT6_SET_NAME = constant_value("NFT_IP_PORT6_SET_NAME", "tachyon_ip6_ports"); const NFT_INTERFACE_SET_NAME = constant_value("NFT_INTERFACE_SET_NAME", "tachyon_interfaces"); const NFT_FAKEIP_MARK = constant_value("NFT_FAKEIP_MARK", "0x04000000"); const NFT_OUTBOUND_MARK = constant_value("NFT_OUTBOUND_MARK", "0x08000000"); const SB_FAKEIP_INET4_RANGE = constant_value("SB_FAKEIP_INET4_RANGE", "198.18.0.0/15"); const SB_FAKEIP_INET6_RANGE = constant_value("SB_FAKEIP_INET6_RANGE", "fc00::/18"); const SB_TPROXY_INBOUND6_ADDRESS = constant_value("SB_TPROXY_INBOUND6_ADDRESS", "::1"); const SB_TPROXY_INBOUND_PORT = constant_value("SB_TPROXY_INBOUND_PORT", "1602"); const SB_SERVICE_MIXED_INBOUND_ADDRESS = constant_value("SB_SERVICE_MIXED_INBOUND_ADDRESS", "127.0.0.1"); const SB_SERVICE_MIXED_INBOUND_PORT = constant_value("SB_SERVICE_MIXED_INBOUND_PORT", "4534"); const SB_VARIANT_STATE_FILE = constant_value("SB_VARIANT_STATE_FILE", "/etc/tachyon/sing-box-variant"); const SB_VERSION_STATE_FILE = constant_value("SB_VERSION_STATE_FILE", "/etc/tachyon/sing-box-version"); const ZAPRET_PROVIDER_NFQWS_BIN = constant_value("ZAPRET_PROVIDER_NFQWS_BIN", "/opt/zapret/nfq/nfqws"); const ZAPRET_ROUTE_MARK_BASE = constant_value("ZAPRET_ROUTE_MARK_BASE", "0x01000000"); const ZAPRET_QUEUE_BASE = constant_value("ZAPRET_QUEUE_BASE", "4000"); const ZAPRET_DESYNC_MARK = constant_value("ZAPRET_DESYNC_MARK", "0x40000000"); const ZAPRET_DESYNC_MARK_POSTNAT = constant_value("ZAPRET_DESYNC_MARK_POSTNAT", "0x20000000"); const ZAPRET2_PROVIDER_NFQWS2_BIN = constant_value("ZAPRET2_PROVIDER_NFQWS2_BIN", "/opt/zapret2/nfq2/nfqws2"); const ZAPRET2_ROUTE_MARK_BASE = constant_value("ZAPRET2_ROUTE_MARK_BASE", "0x02000000"); const ZAPRET2_QUEUE_BASE = constant_value("ZAPRET2_QUEUE_BASE", "4300"); const ZAPRET2_DESYNC_MARK = constant_value("ZAPRET2_DESYNC_MARK", "0x40000000"); const ZAPRET2_DESYNC_MARK_POSTNAT = constant_value("ZAPRET2_DESYNC_MARK_POSTNAT", "0x20000000"); const BYEDPI_BIN = constant_value("BYEDPI_BIN", "/usr/bin/ciadpi"); const DNS_APPLY_UC = LIB_DIR + "/dns/apply.uc"; const VALIDATOR_UC = LIB_DIR + "/config/validator.uc"; const SERVER_UC = LIB_DIR + "/server/service.uc"; const NFT_UC = LIB_DIR + "/nft/apply.uc"; const SINGBOX_UC = LIB_DIR + "/singbox/runtime.uc"; const PRIORITY_UC = LIB_DIR + "/singbox/priority.uc"; const DNS_FAILOVER_UC = LIB_DIR + "/singbox/dns_failover.uc"; const SUBSCRIPTION_CACHE_UC = LIB_DIR + "/subscription/cache.uc"; const UPDATES_UC = LIB_DIR + "/components/updates.uc"; const HOSTS_UC = LIB_DIR + "/components/hosts.uc"; const STATE_UC = LIB_DIR + "/service/state.uc"; const RELOAD_UC = LIB_DIR + "/service/reload.uc"; const UI_UC = LIB_DIR + "/service/ui.uc"; const DIAGNOSTICS_UC = LIB_DIR + "/diagnostics/runtime.uc"; const ZAPRET_UC = LIB_DIR + "/providers/zapret/runtime.uc"; const ZAPRET2_UC = LIB_DIR + "/providers/zapret2/runtime.uc"; const BYEDPI_UC = LIB_DIR + "/providers/byedpi/runtime.uc"; const TAILSCALE_UC = LIB_DIR + "/providers/tailscale/runtime.uc"; const WDTT_UC = LIB_DIR + "/providers/wdtt/runtime.uc"; const OLCRTC_UC = LIB_DIR + "/providers/olcrtc/runtime.uc"; const PARENTAL_QUOTA_UC = LIB_DIR + "/service/parental_quota.uc"; const PACKAGES_UC = LIB_DIR + "/core/packages.uc"; const WATCHDOG_UC = LIB_DIR + "/service/watchdog.uc"; const TELEGRAM_UC = LIB_DIR + "/service/telegram.uc"; let start_subscription_update_lock_held = false; let subscription_caches_prepared = getenv("TACHYON_SUBSCRIPTION_CACHES_PREPARED") || "0"; let subscription_runtime_no_refresh = getenv("TACHYON_SUBSCRIPTION_RUNTIME_NO_REFRESH") || "0"; let subscription_deferred_sections = ""; let nft_populate_enabled = NFT_POPULATE_ENABLED_DEFAULT; let rule_condition_cache_enabled = 0; let startup_config_fingerprint = ""; let shell_quote = common.shell_quote; let command_from_args = common.command_from_args; function normalize_status(status) { status = int(status); if (status == -1) return 255; let signal = status & 127; if (signal != 0) return 128 + signal; return (status >> 8) & 255; } let command_status = common.command_status; function command_capture(command) { let pipe = fs.popen(command, "r"); if (!pipe) return { status: 1, output: "" }; let data = pipe.read("all"); let status = normalize_status(pipe.close()); return { status, output: data == null ? "" : as_string(data) }; } let command_output = common.command_output; let read_json_file = common.read_json_file; let write_json = common.write_json; function command_success(command) { return command_status(command + " >/dev/null 2>&1") == 0; } let command_status_from_args = common.command_status_from_args; let command_output_from_args = common.command_output_from_args; let command_success_from_args = common.command_success_from_args; function external_config_fingerprint() { let data = fs.readfile(CONFIG_FILE); if (data == null) return ""; let lines = []; for (let line in split(as_string(data), "\n")) if (match(as_string(line), /^[ \t]*option[ \t]+shutdown_correctly([ \t]|$)/) == null) push(lines, line); return join("\n", lines); } function owner_pid() { let status = fs.readfile("/proc/self/status") || ""; let m = match(status, /PPid:\s+([0-9]+)/); return m ? m[1] : "0"; } let bool_text = common.bool_value; let object_or_empty = common.object_or_empty; let array_or_empty = common.array_or_empty; function string_array_contains(values, needle) { needle = as_string(needle); if (needle == "") return false; for (let item in array_or_empty(values)) if (as_string(item) == needle) return true; return false; } let write_file = common.write_file; let remove_file = common.remove_file; // 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); } let ensure_dir = common.ensure_dir; function log_message(message, level) { level = as_string(level || "info"); command_success_from_args([ "logger", "-t", SERVICE_NAME, "[" + level + "] " + as_string(message) ]); } function lifecycle_env() { return { TACHYON_CONFIG_NAME: CONFIG_NAME, TACHYON_LIB: LIB_DIR, TACHYON_BIN: BIN_PATH, TACHYON_SERVICE_INIT: SERVICE_INIT, TACHYON_RUNTIME_STATE_DIR: RUNTIME_STATE_DIR, TACHYON_SYSTEM_INFO_CACHE_FILE: SYSTEM_INFO_CACHE_FILE, TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR: SUBSCRIPTION_UPDATE_STATE_DIR, TACHYON_SUBSCRIPTION_LINKS_DIR: SUBSCRIPTION_LINKS_DIR, TACHYON_SUBSCRIPTION_METADATA_DIR: SUBSCRIPTION_METADATA_DIR, TACHYON_OUTBOUND_METADATA_DIR: OUTBOUND_METADATA_DIR, TACHYON_SECTION_CACHE_DIR: SECTION_CACHE_DIR, TACHYON_RULE_CONDITION_CACHE_DIR: RULE_CONDITION_CACHE_DIR, TACHYON_RUNTIME_CACHE_FORMAT_FILE: RUNTIME_CACHE_FORMAT_FILE, TACHYON_RUNTIME_CACHE_FORMAT: as_string(RUNTIME_CACHE_FORMAT), TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR: PERSISTENT_SUBSCRIPTION_CACHE_DIR, TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE, TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT, TACHYON_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE: SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE, TACHYON_DNS_FAILOVER_STATE_FILE: DNS_FAILOVER_STATE_FILE, TACHYON_DNS_FAILOVER_PID_FILE: DNS_FAILOVER_PID_FILE, TACHYON_SUBSCRIPTION_UPDATE_LOCK_DIR: SUBSCRIPTION_UPDATE_LOCK_DIR, TACHYON_PENDING_RELOAD_FILE: PENDING_RELOAD_FILE, TACHYON_RELOAD_LOCK_DIR: RELOAD_LOCK_DIR, TMP_SING_BOX_FOLDER: TMP_SING_BOX_FOLDER, TMP_RULESET_FOLDER: TMP_RULESET_FOLDER, TMP_SUBSCRIPTION_FOLDER: TMP_SUBSCRIPTION_FOLDER, SB_SERVICE_MIXED_INBOUND_ADDRESS: SB_SERVICE_MIXED_INBOUND_ADDRESS, SB_SERVICE_MIXED_INBOUND_PORT: SB_SERVICE_MIXED_INBOUND_PORT, SB_VARIANT_STATE_FILE: SB_VARIANT_STATE_FILE, SB_VERSION_STATE_FILE: SB_VERSION_STATE_FILE, ZAPRET_PROVIDER_NFQWS_BIN: ZAPRET_PROVIDER_NFQWS_BIN, ZAPRET2_PROVIDER_NFQWS2_BIN: ZAPRET2_PROVIDER_NFQWS2_BIN, BYEDPI_BIN: BYEDPI_BIN, TACHYON_RULE_CONDITION_CACHE_ENABLED: as_string(rule_condition_cache_enabled) }; } function command_env(assignments) { let parts = []; for (let name, value in assignments) push(parts, as_string(name) + "=" + shell_quote(value)); return join(" ", parts); } function module_args(module_path, args) { let result = [ "ucode", "-L", LIB_DIR, module_path ]; for (let arg in (type(args) == "array" ? args : [])) push(result, arg); return result; } function module_command(module_path, args) { return command_env(lifecycle_env()) + " " + command_from_args(module_args(module_path, args)); } function module_capture(module_path, args) { return command_capture(module_command(module_path, args)); } function module_status(module_path, args) { return command_status(module_command(module_path, args)); } function module_success(module_path, args) { return module_status(module_path, args) == 0; } function mark_pending_reload(reason) { return module_success(STATE_UC, [ "mark-pending-reload", PENDING_RELOAD_FILE, reason ]); } function pending_reload_log_context(reason) { reason = as_string(reason); if (reason == "config_changed_during_reload") return "current reload"; return "startup"; } function mark_pending_reload_if_config_changed(initial_fingerprint, reason) { initial_fingerprint = as_string(initial_fingerprint); if (initial_fingerprint == "") return false; let current_fingerprint = external_config_fingerprint(); if (current_fingerprint == "" || current_fingerprint == initial_fingerprint) return false; let context = pending_reload_log_context(reason); if (mark_pending_reload(reason)) { log_message("Configuration changed during " + context + "; queued reload after " + context + " completes", "info"); return true; } log_message("Configuration changed during " + context + ", but pending reload could not be queued", "warn"); return false; } function file_md5(path) { path = as_string(path); if (path == "" || fs.stat(path) == null) return ""; let output = command_output("md5sum " + shell_quote(path) + " 2>/dev/null"); let fields = split(trim(output), /[ \t\r\n]+/); return length(fields) > 0 ? as_string(fields[0]) : ""; } function current_config_hash() { 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 finish_reload_status(status, initial_fingerprint) { status = int(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"); } return status; } function module_output(module_path, args) { let result = module_capture(module_path, args); return result.status == 0 ? result.output : ""; } function selector_state_from_proxies_payload(payload) { let result = {}; let proxies = object_or_empty(object_or_empty(payload).proxies); for (let tag, proxy in proxies) { proxy = object_or_empty(proxy); let proxy_type = lc(as_string(proxy.type || "")); let selected = as_string(proxy.now || ""); if (proxy_type == "selector" && string_array_contains(proxy.all, selected)) result[as_string(tag)] = selected; } return result; } function selector_restore_pairs(snapshot, payload) { let result = []; snapshot = object_or_empty(snapshot); let proxies = object_or_empty(object_or_empty(payload).proxies); for (let group, selected in snapshot) { group = as_string(group); selected = as_string(selected); if (group == "" || selected == "") continue; let proxy = object_or_empty(proxies[group]); if (lc(as_string(proxy.type || "")) != "selector") continue; if (!string_array_contains(proxy.all, selected)) continue; if (as_string(proxy.now || "") == selected) continue; push(result, { group, proxy: selected }); } return result; } function clash_api_json(action, arg1, arg2, arg3) { let result = module_capture(DIAGNOSTICS_UC, [ "clash-api", action, as_string(arg1 || ""), as_string(arg2 || ""), as_string(arg3 || "") ]); if (result.status != 0) return null; try { return json(result.output); } catch (e) { return null; } } function capture_selector_state() { return selector_state_from_proxies_payload(clash_api_json("get_proxies")); } function restore_selector_state(snapshot) { let pairs = selector_restore_pairs(snapshot, clash_api_json("get_proxies")); for (let pair in pairs) module_success(DIAGNOSTICS_UC, [ "clash-api", "set_group_proxy", pair.group, pair.proxy, "" ]); } function module_background(module_path, args) { return command_status(common.background_command(module_command(module_path, args))) == 0; } function config_get(path, fallback) { path = as_string(path); if (!uci_core.exists(path)) return as_string(fallback); return trim(uci_core.get(path)); } function config_set(path, value) { return uci_core.set(path, value); } function mark_internal_config_guard() { let hash = current_config_hash(); if (hash == "") { fs.unlink(INTERNAL_CONFIG_TRIGGER_GUARD); return; } let stamp = clock(); let tmp_path = INTERNAL_CONFIG_TRIGGER_GUARD + "." + stamp[0] + "." + stamp[1]; fs.writefile(tmp_path, as_string(stamp[0]) + "\n" + hash + "\n"); if (!fs.rename(tmp_path, INTERNAL_CONFIG_TRIGGER_GUARD)) fs.unlink(tmp_path); } function config_commit() { if (!uci_core.commit(CONFIG_NAME)) return 1; mark_internal_config_guard(); return 0; } function mark_runtime_stopped_clean() { let status = 0; if (!config_set(CONFIG_NAME + ".settings.shutdown_correctly", "1")) status = 1; else { let commit_status = config_commit(); if (commit_status != 0) status = commit_status; } module_success(STATE_UC, [ "clear-reload-state", RELOAD_STATE_FILE, RELOAD_STATE_SNAPSHOT_FILE ]); return status; } function setting_bool(name, fallback) { let value = config_get(CONFIG_NAME + ".settings." + as_string(name), fallback ? "1" : "0"); return bool_text(value); } function dns_apply_status(args) { return module_status(DNS_APPLY_UC, args); } function dns_apply_success(args) { return dns_apply_status(args) == 0; } function dnsmasq_configure(force) { let args = [ "configure" ]; if (force) push(args, "force"); return dns_apply_status(args); } function dnsmasq_restore(force) { let args = [ "restore" ]; if (force) push(args, "force"); return dns_apply_status(args); } function dnsmasq_restore_fail_safe() { return dns_apply_status([ "failsafe-restore" ]); } function dnsmasq_has_tachyon_managed_state() { return dns_apply_success([ "has-managed-state" ]); } function discover_awg_mtu() { let sections = uci_core.get_all(CONFIG_NAME, "outbound") || {}; let changed = false; for (let section in sections) { if (section.action == "awg" && (section.awg_mtu == "" || section.awg_mtu == "0" || section.awg_mtu == null)) { let endpoint = section.awg_server_address; if (endpoint) { log_message("Auto-discovering optimal MTU for AWG section '" + section[".name"] + "'...", "info"); let best_mtu = 1500; let test_mtus = [1500, 1400, 1280, 1200]; for (let test_mtu in test_mtus) { let payload = test_mtu - 28; let result = command_status(command_from_args(["ping", "-c", "1", "-W", "1", "-M", "do", "-s", as_string(payload), endpoint])); if (result == 0) { best_mtu = test_mtu; break; } } log_message(sprintf("Discovered optimal MTU %d for %s", best_mtu, section[".name"]), "info"); command_status(command_from_args(["uci", "set", "tachyon." + section[".name"] + ".awg_mtu=" + as_string(best_mtu)])); changed = true; } } } if (changed) { mark_internal_config_guard(); if (!command_success_from_args(["uci", "commit", "tachyon"])) log_message("AWG MTU: uci commit tachyon failed", "warn"); else mark_internal_config_guard(); } return 0; } function validate_start_config() { // 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) return status; status = module_status(SERVER_UC, [ "prepare-all-defaults" ]); if (status != 0) return status; return 0; } function acquire_start_subscription_update_lock() { if (module_success(STATE_UC, [ "acquire-runtime-dir-lock-wait", SUBSCRIPTION_UPDATE_LOCK_DIR, owner_pid(), "300" ])) { start_subscription_update_lock_held = true; return true; } log_message("Subscription update is already running during startup. Aborted.", "fatal"); return false; } function release_start_subscription_update_lock() { if (!start_subscription_update_lock_held) return; module_success(STATE_UC, [ "release-runtime-dir-lock", SUBSCRIPTION_UPDATE_LOCK_DIR ]); start_subscription_update_lock_held = false; } function resolve_zapret_bin() { let candidate_bins = [ getenv("ZAPRET_NFQWS_BIN"), getenv("ZAPRET_PROVIDER_NFQWS_BIN"), ZAPRET_PROVIDER_NFQWS_BIN, "/opt/zapret/nfq/nfqws", "/opt/zapret/nfqws", "/usr/bin/nfqws" ]; for (let b in candidate_bins) { if (b && fs.stat(b) != null) return b; } return ZAPRET_PROVIDER_NFQWS_BIN; } function resolve_zapret2_bin() { let candidate_bins = [ getenv("ZAPRET2_NFQWS2_BIN"), getenv("ZAPRET2_PROVIDER_NFQWS2_BIN"), ZAPRET2_PROVIDER_NFQWS2_BIN, "/opt/zapret2/nfq2/nfqws2", "/opt/zapret2/nfq/nfqws2", "/opt/zapret2/nfqws2", "/usr/bin/nfqws2" ]; for (let b in candidate_bins) { if (b && fs.stat(b) != null) return b; } return ZAPRET2_PROVIDER_NFQWS2_BIN; } function nft_rebuild_runtime() { let zapret_bin = resolve_zapret_bin(); let zapret2_bin = resolve_zapret2_bin(); return module_status(NFT_UC, [ "nft-rebuild-runtime-from-uci", RT_TABLE_NAME, NFT_TABLE_NAME, NFT_LOCALV4_SET_NAME, NFT_COMMON_SET_NAME, NFT_PORT_SET_NAME, NFT_IP_PORT_SET_NAME, NFT_INTERFACE_SET_NAME, NFT_FAKEIP_MARK, NFT_OUTBOUND_MARK, SB_FAKEIP_INET4_RANGE, SB_TPROXY_INBOUND_PORT, zapret_bin, ZAPRET_ROUTE_MARK_BASE, ZAPRET_QUEUE_BASE, ZAPRET_DESYNC_MARK, ZAPRET_DESYNC_MARK_POSTNAT, zapret2_bin, ZAPRET2_ROUTE_MARK_BASE, ZAPRET2_QUEUE_BASE, ZAPRET2_DESYNC_MARK, ZAPRET2_DESYNC_MARK_POSTNAT, NFT_LOCALV6_SET_NAME, NFT_COMMON6_SET_NAME, NFT_IP_PORT6_SET_NAME, SB_FAKEIP_INET6_RANGE, SB_TPROXY_INBOUND6_ADDRESS ]); } function nft_populate_runtime_sets() { return module_status(NFT_UC, [ "nft-populate-runtime-sets-from-uci", as_string(nft_populate_enabled), subscription_deferred_sections, NFT_TABLE_NAME, NFT_COMMON_SET_NAME, NFT_PORT_SET_NAME, NFT_IP_PORT_SET_NAME, NFT_INTERFACE_SET_NAME, NFT_LOCALV4_SET_NAME, NFT_FAKEIP_MARK, NFT_COMMON6_SET_NAME, NFT_IP_PORT6_SET_NAME, NFT_LOCALV6_SET_NAME ]); } function reload_firewall() { let status = nft_rebuild_runtime(); if (status != 0) return status; return nft_populate_runtime_sets(); } function singbox_init_config() { let result = module_capture(SINGBOX_UC, [ "init-config", as_string(nft_populate_enabled), subscription_caches_prepared, subscription_runtime_no_refresh ]); if (result.status == 0) { let out = trim(result.output); if (out != "") subscription_deferred_sections = out; subscription_caches_prepared = "1"; } return result.status; } function refresh_cron() { return module_status(UPDATES_UC, [ "refresh-cron-from-uci", BIN_PATH, LIST_UPDATE_CRON_MARKER, SUBSCRIPTION_UPDATE_CRON_MARKER, COMPONENT_UPDATE_CHECK_CRON_MARKER ]); } function remove_cron_jobs() { return module_status(UPDATES_UC, [ "remove-cron-jobs", LIST_UPDATE_CRON_MARKER, SUBSCRIPTION_UPDATE_CRON_MARKER, COMPONENT_UPDATE_CHECK_CRON_MARKER ]); } function prepare_subscription_caches(mode) { let result = module_capture(SUBSCRIPTION_CACHE_UC, [ "prepare-caches", mode, subscription_caches_prepared, subscription_runtime_no_refresh ]); if (result.status == 0) { subscription_deferred_sections = trim(result.output); subscription_caches_prepared = "1"; } return result.status; } function restore_rulesets_from_cache() { let etc_dir = "/etc/tachyon/rulesets"; let tmp_dir = TMP_RULESET_FOLDER; command_success_from_args(["mkdir", "-p", tmp_dir]); let dir = fs.opendir(etc_dir); if (!dir) return; let entry; while ((entry = dir.read()) != null) { if (!match(entry, /\.(srs|lst|json)$/)) continue; let tmp_path = tmp_dir + "/" + entry; let etc_path = etc_dir + "/" + entry; let tmp_st = fs.stat(tmp_path); let etc_st = fs.stat(etc_path); if (etc_st && etc_st.size > 0 && (!tmp_st || tmp_st.size == 0)) { let content = fs.readfile(etc_path); if (content) fs.writefile(tmp_path, content); } } 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; log_message("Starting Tachyon", "info"); status = validate_start_config(); if (status != 0) return status; startup_config_fingerprint = external_config_fingerprint(); status = module_status(NFT_UC, [ "ensure-bridge-netfilter-disabled" ]); if (status != 0) return status; module_success(STATE_UC, [ "sync-time-if-needed" ]); status = module_status(SUBSCRIPTION_CACHE_UC, [ "ensure-runtime-dirs" ]); restore_rulesets_from_cache(); if (status != 0) return status; prepare_community_rulesets(); if (!acquire_start_subscription_update_lock()) return 1; status = prepare_subscription_caches("startup"); if (status != 0) { log_message("Subscription caches are not ready. Aborted.", "fatal"); return status; } status = nft_rebuild_runtime(); if (status != 0) return status; if (module_success(STATE_UC, [ "has-hosts-list-update-sources" ])) module_background(HOSTS_UC, [ "list-update" ]); status = module_status(SINGBOX_UC, [ "configure-service" ]); if (status != 0) return status; status = singbox_init_config(); if (status != 0) return status; status = refresh_cron(); if (status != 0) return status; module_success(BYEDPI_UC, [ "start-runtime" ]); for (let comp_svc in [ "forkop", "podkop", "netshift" ]) { if (fs.stat("/etc/init.d/" + comp_svc) != null) { command_status_from_args([ "/etc/init.d/" + comp_svc, "stop" ]); } } let sb_start = command_success_from_args([ "/etc/init.d/sing-box", "start" ]); if (!sb_start) { log_message("Failed to start sing-box. Aborted.", "fatal"); return 1; } status = module_status(STATE_UC, [ "wait-tachyon-stable-start", RT_TABLE_NAME, NFT_TABLE_NAME, NFT_FAKEIP_MARK, as_string(SING_BOX_START_STABLE_MIN_AGE), as_string(SING_BOX_START_VERIFY_TIMEOUT) ]); if (status != 0) { log_message("Startup verification failed after Tachyon was started; rolling back DNS changes", "warn"); return status; } status = module_status(PRIORITY_UC, [ "start-runtime" ]); if (status != 0) { log_message("Failed to start Priority runtime. Aborted.", "fatal"); return status; } status = module_status(SUBSCRIPTION_CACHE_UC, [ "run-deferred-bootstrap", subscription_deferred_sections ]); if (status != 0) return status; release_start_subscription_update_lock(); module_success(ZAPRET_UC, [ "start-runtime" ]); module_success(ZAPRET2_UC, [ "start-runtime" ]); module_success(WDTT_UC, [ "start-runtime" ]); module_success(OLCRTC_UC, [ "start-runtime" ]); module_success(PARENTAL_QUOTA_UC, [ "install-cron" ]); module_success(PARENTAL_QUOTA_UC, [ "tick" ]); module_background(UI_UC, [ "latency-boot-sweep" ]); module_background(UPDATES_UC, [ "list-update" ]); module_background(LIB_DIR + "/service/lifecycle.uc", [ "discover-awg-mtu" ]); return 0; } function start_impl() { fs.writefile("/var/run/tachyon/starting", as_string(time())); // (e.g. config generator killed during restart). 30-min threshold is safe since // 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"); // 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" ]); if (wd_status != 0) log_message("Watchdog start-runtime failed (status " + as_string(wd_status) + ")", "warn"); if (time() < 1704067200) { log_message("System clock is in the past (RTC desync); performing quick NTP synchronization...", "warn"); command_status("ntpd -q -p 194.190.168.1 -p 216.239.35.0 -p 162.159.200.1 >/dev/null 2>&1 || true"); } let status = start_main(); if (status != 0) return status; if (!setting_bool("dont_touch_dhcp", false)) { status = dnsmasq_configure(false); if (status != 0) return status; } else if (dnsmasq_has_tachyon_managed_state()) { status = dnsmasq_restore(true); if (status != 0) return status; } if (!config_set(CONFIG_NAME + ".settings.shutdown_correctly", "0")) return 1; status = config_commit(); if (status != 0) return status; status = module_status(STATE_UC, [ "write-current-reload-state-clean", RELOAD_STATE_FILE, as_string(RELOAD_STATE_FORMAT), RULE_CONDITION_CACHE_DIR ]); if (status != 0) return status; status = module_status(DNS_FAILOVER_UC, [ "start-runtime" ]); if (status != 0) { log_message("Failed to start DNS failover runtime", "fatal"); return status; } module_background(DIAGNOSTICS_UC, [ "get-system-info" ]); module_background(TELEGRAM_UC, [ "start-runtime" ]); fs.unlink("/var/run/tachyon/starting"); return 0; } function stop_main() { let status = 0; log_message("Stopping Tachyon", "info"); if (!module_success(DNS_FAILOVER_UC, [ "stop-runtime" ])) log_message("DNS failover stop failed (non-fatal)", "warn"); if (!module_success(PRIORITY_UC, [ "stop-runtime" ])) log_message("Priority daemon stop failed (non-fatal)", "warn"); module_success(SUBSCRIPTION_CACHE_UC, [ "stop-deferred-bootstrap-worker" ]); module_success(UPDATES_UC, [ "stop-list-update" ]); remove_cron_jobs(); // command_success_from_args([ "find", TMP_RULESET_FOLDER, "-mindepth", "1", "-maxdepth", "1", "-type", "f", "-delete" ]); if (!module_success(ZAPRET_UC, [ "stop-runtime" ])) log_message("Zapret stop failed (non-fatal)", "warn"); if (!module_success(ZAPRET2_UC, [ "stop-runtime" ])) log_message("Zapret2 stop failed (non-fatal)", "warn"); if (!module_success(BYEDPI_UC, [ "stop-runtime" ])) log_message("ByeDPI stop failed (non-fatal)", "warn"); if (!module_success(WDTT_UC, [ "stop-runtime" ])) log_message("WDTT stop failed (non-fatal)", "warn"); if (!module_success(OLCRTC_UC, [ "stop-runtime" ])) log_message("OlcRTC stop failed (non-fatal)", "warn"); if (!module_success(TAILSCALE_UC, [ "stop-runtime" ])) log_message("Tailscale stop failed (non-fatal)", "warn"); module_success(PARENTAL_QUOTA_UC, [ "remove-cron" ]); module_success(TELEGRAM_UC, [ "stop-runtime" ]); if (command_success_from_args([ "nft", "list", "table", "inet", NFT_TABLE_NAME ])) { if (!command_success_from_args([ "nft", "delete", "table", "inet", NFT_TABLE_NAME ])) log_message("Failed to delete nft table " + NFT_TABLE_NAME, "warn"); } if (module_success(NFT_UC, [ "tproxy-marking-rule4-present", RT_TABLE_NAME, NFT_FAKEIP_MARK ])) command_success_from_args([ "ip", "-4", "rule", "del", "fwmark", NFT_FAKEIP_MARK + "/" + NFT_FAKEIP_MARK, "table", RT_TABLE_NAME, "priority", "105" ]); if (module_success(NFT_UC, [ "tproxy-marking-rule6-present", RT_TABLE_NAME, NFT_FAKEIP_MARK ])) command_success_from_args([ "ip", "-6", "rule", "del", "fwmark", NFT_FAKEIP_MARK + "/" + NFT_FAKEIP_MARK, "table", RT_TABLE_NAME, "priority", "105" ]); if (module_success(NFT_UC, [ "tproxy-route4-present", RT_TABLE_NAME ])) command_success_from_args([ "ip", "route", "flush", "table", RT_TABLE_NAME ]); if (module_success(NFT_UC, [ "tproxy-route6-present", RT_TABLE_NAME ])) command_success_from_args([ "ip", "-6", "route", "flush", "table", RT_TABLE_NAME ]); let sing_box_status = command_status_from_args([ "/etc/init.d/sing-box", "stop" ]); command_success_from_args([ "killall", "-q", "-9", "sing-box" ]); if (sing_box_status != 0) status = sing_box_status; return status; } function cleanup_failed_runtime() { fs.unlink("/var/run/tachyon/starting"); fs.unlink("/var/run/tachyon/reloading"); let status = 0; log_message("Cleaning up Tachyon runtime after failed start/reload", "info"); let stop_status = stop_main(); if (stop_status != 0) status = stop_status; let dns_status = dnsmasq_restore_fail_safe(); if (dns_status != 0 && status == 0) status = dns_status; let mark_status = mark_runtime_stopped_clean(); if (mark_status != 0 && status == 0) status = mark_status; if (status != 0) log_message("Failed to fully clean up Tachyon runtime after start/reload failure", "warn"); return status; } function abort_reload(status, runtime_changed) { status = int(status || 0); if (status == 0) status = 1; if (runtime_changed) cleanup_failed_runtime(); else remove_file(RELOAD_STATE_SNAPSHOT_FILE); return status; } function start() { let status = start_impl(); release_start_subscription_update_lock(); invalidate_reload_hash(); if (status != 0) { cleanup_failed_runtime(); return status; } mark_pending_reload_if_config_changed(startup_config_fingerprint, "config_changed_during_start"); status = module_status(STATE_UC, [ "wait-sing-box-service-stable", as_string(SING_BOX_START_STABLE_MIN_AGE), as_string(SING_BOX_START_VERIFY_TIMEOUT) ]); if (status != 0) { log_message("Startup verification failed after Tachyon was started; rolling back DNS changes", "warn"); cleanup_failed_runtime(); return status; } module_status(STATE_UC, [ "run-pending-reload-if-requested", PENDING_RELOAD_FILE, SERVICE_INIT ]); return 0; } function stop_impl() { let status = 0; invalidate_reload_hash(); if (!setting_bool("dont_touch_dhcp", false)) { let dns_status = dnsmasq_restore(false); if (dns_status != 0) status = dns_status; } else if (dnsmasq_has_tachyon_managed_state()) { let dns_status = dnsmasq_restore(true); if (dns_status != 0) status = dns_status; } let runtime_status = stop_main(); if (runtime_status != 0) status = runtime_status; if (!config_set(CONFIG_NAME + ".settings.shutdown_correctly", "1")) status = 1; else { let commit_status = config_commit(); if (commit_status != 0) status = commit_status; } module_success(STATE_UC, [ "clear-reload-state", RELOAD_STATE_FILE, RELOAD_STATE_SNAPSHOT_FILE ]); if (status != 0) dnsmasq_restore_fail_safe(); return status; } function stop() { return stop_impl(); } function restart_runtime_for_reload() { let status; let selector_state = capture_selector_state(); log_message("Reload requires a full Tachyon runtime restart", "info"); invalidate_reload_hash(); status = stop_main(); if (status != 0) return status; status = start_impl(); if (status != 0) { cleanup_failed_runtime(); return status; } status = module_status(STATE_UC, [ "wait-sing-box-service-stable", as_string(SING_BOX_START_STABLE_MIN_AGE), as_string(SING_BOX_START_VERIFY_TIMEOUT) ]); if (status != 0) { log_message("Reload runtime restart verification failed after Tachyon was started; rolling back DNS changes", "fatal"); cleanup_failed_runtime(); return status; } restore_selector_state(selector_state); remove_file(RELOAD_STATE_SNAPSHOT_FILE); return 0; } function write_service_trigger_sync_state(changed) { ensure_dir(RUNTIME_STATE_DIR); write_file(SERVICE_TRIGGER_SYNC_FILE, as_string(changed) + "\n"); } function parse_reload_plan(output) { let result = { changed_service_triggers: 0, changed_dnsmasq: 0, changed_sing_box: 0, changed_nft: 0, changed_zapret_queue: 0, changed_zapret_runtime: 0, changed_zapret2_queue: 0, changed_zapret2_runtime: 0, changed_byedpi_runtime: 0, changed_cron: 0, changed_list: 0, changed_hosts_list: 0, needs_sing_box_reload: 0, needs_nft_rebuild: 0, needs_zapret_restart: 0, needs_zapret2_restart: 0, needs_byedpi_restart: 0, needs_wdtt_restart: 0, needs_olcrtc_restart: 0, needs_dnsmasq_configure: 0, needs_dnsmasq_restore: 0, needs_cron_refresh: 0, needs_list_update: 0, needs_hosts_update: 0, has_work: 0 }; for (let line in split(as_string(output), "\n")) { line = as_string(line); if (line == "") continue; let fields = split(line, "\t"); if (length(fields) < 2) continue; let key = fields[0]; if (result[key] != null) result[key] = int(fields[1] || 0); } return result; } function append_reload_action(actions, enabled, label) { if (!(enabled === true || int(enabled || 0) == 1)) return actions; return actions + (actions != "" ? ", " : "") + label; } function reload_actions_summary(plan) { let actions = ""; actions = append_reload_action(actions, plan.needs_sing_box_reload, "sing-box"); actions = append_reload_action(actions, plan.needs_nft_rebuild, "nftables"); actions = append_reload_action(actions, plan.needs_zapret_restart, "Zapret"); actions = append_reload_action(actions, plan.needs_zapret2_restart, "Zapret2"); actions = append_reload_action(actions, plan.needs_byedpi_restart, "ByeDPI"); actions = append_reload_action(actions, plan.needs_wdtt_restart, "WDTT"); actions = append_reload_action(actions, plan.needs_olcrtc_restart, "OlcRTC"); actions = append_reload_action(actions, plan.needs_dnsmasq_configure || plan.needs_dnsmasq_restore, "dnsmasq"); actions = append_reload_action(actions, plan.needs_cron_refresh, "scheduled jobs"); actions = append_reload_action(actions, plan.needs_list_update, "remote lists"); actions = append_reload_action(actions, plan.needs_hosts_update, "hosts lists"); return actions; } function release_reload_lock() { module_success(STATE_UC, [ "release-runtime-dir-lock", RELOAD_LOCK_DIR ]); } function sing_box_runtime_pid() { let result = module_capture(STATE_UC, [ "sing-box-service-runtime-pid" ]); return result.status == 0 ? trim(result.output) : ""; } function wait_dns_failover_state(candidate_state_path, attempts) { attempts = int(attempts || 1); for (let i = 0; i < attempts; i++) { let status = module_status(DNS_FAILOVER_UC, [ "verify-state", candidate_state_path ]); if (status == 0) return 0; if (i + 1 < attempts) command_success_from_args([ "sleep", "1" ]); } return 1; } function dns_failover_apply(candidate_state_path) { candidate_state_path = as_string(candidate_state_path); if (candidate_state_path == "" || fs.stat(candidate_state_path) == null) return 1; if (!module_success(STATE_UC, [ "acquire-runtime-dir-lock-wait", RELOAD_LOCK_DIR, owner_pid(), "2" ])) return 2; let patch_result = module_capture(SINGBOX_UC, [ "patch-dns-config", candidate_state_path ]); if (patch_result.status != 0) { release_reload_lock(); return patch_result.status; } let fields = split(trim(patch_result.output), "\t"); let changed = as_string(fields[0]) == "1"; let backup_path = length(fields) > 1 ? as_string(fields[1]) : ""; let status = 0; if (changed) { let sing_box_pid_before = sing_box_runtime_pid(); status = module_status(STATE_UC, [ "hup-sing-box-runtime" ]); if (status == 0) status = wait_dns_failover_state(candidate_state_path, 5); if (status != 0) { log_message("sing-box SIGHUP DNS reload did not become ready; trying service reload", "warn"); status = module_status(STATE_UC, [ "reload-sing-box-runtime", sing_box_pid_before, "dns-before", "dns-after" ]); if (status == 0) status = wait_dns_failover_state(candidate_state_path, 8); } } if (status == 0 && !module_success(DNS_FAILOVER_UC, [ "commit-state", candidate_state_path ])) status = 1; if (status != 0 && backup_path != "") { log_message("DNS failover apply failed; restoring the previous sing-box configuration", "error"); if (module_success(SINGBOX_UC, [ "restore-dns-config", backup_path ])) { let sing_box_pid_before_restore = sing_box_runtime_pid(); module_success(STATE_UC, [ "reload-sing-box-runtime", sing_box_pid_before_restore, "dns-failed", "dns-restored" ]); } } if (backup_path != "") remove_file(backup_path); release_reload_lock(); return status; } function reload(reason) { let status; let force_runtime_reload = as_string(reason || "") == "on_config_change" ? 0 : 1; let reload_config_fingerprint = external_config_fingerprint(); rule_condition_cache_enabled = force_runtime_reload; 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 (reason != "force" && current_config_hash() == last_completed_reload_hash()) { log_message("Reload skipped: configuration is unchanged", "info"); return 0; } status = validate_start_config(); if (status != 0) return status; status = module_status(SUBSCRIPTION_CACHE_UC, [ "ensure-runtime-dirs" ]); if (status != 0) return status; if (!module_success(STATE_UC, [ "tachyon-running", RT_TABLE_NAME, NFT_TABLE_NAME, NFT_FAKEIP_MARK ])) { log_message("Runtime state is incomplete; restarting Tachyon runtime", "info"); return finish_reload_status(restart_runtime_for_reload(), reload_config_fingerprint); } remove_file(RELOAD_STATE_SNAPSHOT_FILE); status = module_status(STATE_UC, [ "capture-reload-state", RELOAD_STATE_SNAPSHOT_FILE, as_string(RELOAD_STATE_FORMAT) ]); if (status != 0) return status; let current_reload_state_file = trim(command_output_from_args([ "mktemp", "/tmp/tachyon-XXXXXX" ])); if (current_reload_state_file == "") return abort_reload(1, false); status = module_status(STATE_UC, [ "write-captured-reload-state", current_reload_state_file, RELOAD_STATE_SNAPSHOT_FILE, as_string(RELOAD_STATE_FORMAT), "", "0", "0" ]); if (status != 0) { remove_file(current_reload_state_file); return abort_reload(status, false); } let dnsmasq_managed_state = dnsmasq_has_tachyon_managed_state() ? 1 : 0; let list_update_sources = module_success(STATE_UC, [ "has-list-update-sources" ]) ? 1 : 0; let nft_list_update_sources = module_success(STATE_UC, [ "has-nft-list-update-sources" ]) ? 1 : 0; let runtime_cache_needs_rebuild = 0; if (as_string(getenv("TACHYON_RUNTIME_CACHE_INVALIDATED") || "0") == "1" || module_success(SUBSCRIPTION_CACHE_UC, [ "runtime-cache-needs-rebuild", SECTION_CACHE_DIR ])) runtime_cache_needs_rebuild = 1; let plan_result = module_capture(RELOAD_UC, [ "plan-state-files", RELOAD_STATE_FILE, current_reload_state_file, as_string(force_runtime_reload), as_string(dnsmasq_managed_state), as_string(list_update_sources), as_string(nft_list_update_sources), as_string(runtime_cache_needs_rebuild) ]); remove_file(current_reload_state_file); if (plan_result.status != 0) { if (plan_result.status == 2) { log_message("Reload state is unavailable; restarting Tachyon runtime", "info"); return finish_reload_status(restart_runtime_for_reload(), reload_config_fingerprint); } return abort_reload(plan_result.status, false); } let plan = parse_reload_plan(plan_result.output); write_service_trigger_sync_state(plan.changed_service_triggers); if (plan.has_work == 0) { status = module_status(STATE_UC, [ "write-captured-reload-state", RELOAD_STATE_FILE, RELOAD_STATE_SNAPSHOT_FILE, as_string(RELOAD_STATE_FORMAT), RULE_CONDITION_CACHE_DIR, "1", "1" ]); if (status == 0) log_message("Reload skipped: runtime-relevant configuration is unchanged", "info"); return finish_reload_status(status, reload_config_fingerprint); } let actions = reload_actions_summary(plan); if (actions != "") log_message("Applying reload changes: " + actions, "info"); if (plan.needs_zapret_restart == 1) module_success(ZAPRET_UC, [ "stop-runtime" ]); if (plan.needs_zapret2_restart == 1) module_success(ZAPRET2_UC, [ "stop-runtime" ]); if (plan.needs_byedpi_restart == 1) module_success(BYEDPI_UC, [ "stop-runtime" ]); if (plan.needs_wdtt_restart == 1) module_success(WDTT_UC, [ "stop-runtime" ]); if (plan.needs_olcrtc_restart == 1) module_success(OLCRTC_UC, [ "stop-runtime" ]); if (plan.needs_hosts_update == 1 && module_success(STATE_UC, [ "has-hosts-list-update-sources" ])) module_success(HOSTS_UC, [ "list-update" ]); if (plan.needs_nft_rebuild == 1) { log_message("Rebuilding nftables rules", "info"); status = nft_rebuild_runtime(); if (status != 0) return abort_reload(status, true); } if (plan.needs_sing_box_reload == 1) { module_success(DNS_FAILOVER_UC, [ "stop-runtime" ]); module_success(PRIORITY_UC, [ "stop-runtime" ]); status = module_status(SINGBOX_UC, [ "configure-service" ]); if (status != 0) return abort_reload(status, true); let sing_box_config_path = config_get(CONFIG_NAME + ".settings.config_path", ""); let sing_box_config_hash_before = file_md5(sing_box_config_path); let sing_box_pid_result = module_capture(STATE_UC, [ "sing-box-service-runtime-pid" ]); let sing_box_pid_before = sing_box_pid_result.status == 0 ? trim(sing_box_pid_result.output) : ""; nft_populate_enabled = plan.needs_nft_rebuild == 1 ? 1 : 0; status = singbox_init_config(); if (status != 0) return abort_reload(status, true); nft_populate_enabled = NFT_POPULATE_ENABLED_DEFAULT; status = module_status(STATE_UC, [ "reload-sing-box-runtime", sing_box_pid_before, sing_box_config_hash_before, file_md5(sing_box_config_path), as_string(force_runtime_reload) ]); if (status != 0) return abort_reload(status, true); status = module_status(STATE_UC, [ "wait-sing-box-service-stable", as_string(SING_BOX_START_STABLE_MIN_AGE), as_string(SING_BOX_START_VERIFY_TIMEOUT) ]); if (status != 0) { log_message("Reload verification failed after sing-box was reloaded; stopping Tachyon runtime", "fatal"); cleanup_failed_runtime(); return status; } status = module_status(PRIORITY_UC, [ "start-runtime" ]); if (status != 0) { log_message("Failed to start Priority runtime after sing-box reload", "fatal"); cleanup_failed_runtime(); return status; } status = module_status(DNS_FAILOVER_UC, [ "start-runtime" ]); if (status != 0) { log_message("Failed to restart DNS failover runtime after sing-box reload", "fatal"); cleanup_failed_runtime(); return status; } } else if (plan.needs_nft_rebuild == 1 && nft_populate_enabled == 1) { status = nft_populate_runtime_sets(); if (status != 0) return abort_reload(status, true); } if (plan.needs_zapret_restart == 1) module_success(ZAPRET_UC, [ "start-runtime" ]); if (plan.needs_zapret2_restart == 1) module_success(ZAPRET2_UC, [ "start-runtime" ]); if (plan.needs_wdtt_restart == 1) module_success(WDTT_UC, [ "start-runtime" ]); if (plan.needs_olcrtc_restart == 1) module_success(OLCRTC_UC, [ "start-runtime" ]); if (plan.needs_byedpi_restart == 1) module_success(BYEDPI_UC, [ "start-runtime" ]); // Native Tailscale must be up before sing-box so tailnet routes win over // policy routing from the very first packet. module_success(TAILSCALE_UC, [ "start-runtime" ]); // Minute-tick cron for parental daily time quotas (idempotent install). module_success(PARENTAL_QUOTA_UC, [ "install-cron" ]); module_success(PARENTAL_QUOTA_UC, [ "tick" ]); if (plan.needs_dnsmasq_configure == 1) { status = dnsmasq_configure(true); if (status != 0) return abort_reload(status, true); module_success(STATE_UC, [ "capture-reload-state", RELOAD_STATE_SNAPSHOT_FILE, as_string(RELOAD_STATE_FORMAT) ]); } else if (plan.needs_dnsmasq_restore == 1) { status = dnsmasq_restore(true); if (status != 0) return abort_reload(status, true); module_success(STATE_UC, [ "capture-reload-state", RELOAD_STATE_SNAPSHOT_FILE, as_string(RELOAD_STATE_FORMAT) ]); } if (plan.needs_cron_refresh == 1) { status = refresh_cron(); if (status != 0) return abort_reload(status, false); } if (plan.needs_list_update == 1) module_background(UPDATES_UC, [ "list-update" ]); module_background(TELEGRAM_UC, [ "start-runtime" ]); return finish_reload_status(module_status(STATE_UC, [ "write-captured-reload-state", RELOAD_STATE_FILE, RELOAD_STATE_SNAPSHOT_FILE, as_string(RELOAD_STATE_FORMAT), RULE_CONDITION_CACHE_DIR, "1", "1" ]), reload_config_fingerprint); } function reload_tracked(reason) { if (as_string(getenv("TACHYON_UI_ACTION_TRACKED") || "0") == "1") return reload(reason); let job_id = trim(module_output(UI_UC, [ "service-action-begin-if-idle", "reload", "runtime_reload" ])); if (job_id != "") module_success(UI_UC, [ "service-action-update-pid", job_id, owner_pid() ]); let status = reload(reason); if (job_id != "") module_success(UI_UC, [ "service-action-finish-after-command", "reload", job_id, as_string(status) ]); return status; } function restart() { log_message("Restarting Tachyon", "info"); let selector_state = capture_selector_state(); let status = stop_impl(); if (status != 0) return status; status = start_impl(); if (status != 0) { cleanup_failed_runtime(); return status; } if (module_success(STATE_UC, [ "tachyon-stably-running", RT_TABLE_NAME, NFT_TABLE_NAME, NFT_FAKEIP_MARK, as_string(RUNTIME_STABLE_MIN_AGE) ])) { restore_selector_state(selector_state); return 0; } log_message("Restart verification failed after Tachyon was started; stopping Tachyon runtime", "fatal"); cleanup_failed_runtime(); return 1; } function package_manager_remove_if_installed(package_name) { package_name = as_string(package_name); if (command_success_from_args([ "sh", "-c", "command -v apk" ])) { if (command_success_from_args([ "apk", "info", "-e", package_name ])) command_success_from_args([ "apk", "del", package_name ]); return; } if (module_success(PACKAGES_UC, [ "opkg-installed", package_name ])) command_success_from_args([ "opkg", "remove", "--force-depends", package_name ]); } function uninstall() { log_message("Uninstalling Tachyon", "info"); if (fs.stat(SERVICE_INIT) != null) { stop(); command_success_from_args([ SERVICE_INIT, "disable" ]); } dnsmasq_restore_fail_safe(); if (fs.stat("/etc/init.d/tachyon") != null) { command_success_from_args([ "/etc/init.d/tachyon", "stop" ]); command_success_from_args([ "/etc/init.d/tachyon", "disable" ]); } package_manager_remove_if_installed("luci-i18n-tachyon-ru"); package_manager_remove_if_installed("luci-app-tachyon"); if (module_success(SINGBOX_UC, [ "managed-service-installed" ])) { module_success(SINGBOX_UC, [ "remove-managed-service-script" ]); remove_file("/usr/bin/sing-box"); remove_file("/usr/lib/libcronet.so"); } command_success_from_args([ "rm", "-rf", "/usr/lib/tachyon" ]); command_success_from_args([ "rm", "-rf", LUCI_VIEW_DIR ]); remove_file(SERVICE_INIT); remove_file(BIN_PATH); remove_file("/usr/share/luci/menu.d/luci-app-tachyon.json"); remove_file("/usr/share/rpcd/acl.d/luci-app-tachyon.json"); remove_file("/etc/uci-defaults/50_luci-tachyon"); command_success_from_args([ "find", "/usr/lib/lua/luci/i18n", "-maxdepth", "1", "-type", "f", "-name", LUCI_I18N_DOMAIN + ".*.lmo", "-delete" ]); remove_file("/usr/lib/lua/luci/i18n/" + LUCI_I18N_DOMAIN + ".ru.lua"); remove_file("/usr/lib/lua/luci/i18n/" + LUCI_I18N_DOMAIN + ".en.lua"); command_success("rm -f /var/luci-indexcache* /tmp/luci-indexcache* 2>/dev/null"); if (fs.stat("/etc/init.d/rpcd") != null) command_success_from_args([ "/etc/init.d/rpcd", "reload" ]); print("{\"removed\":true}\n"); return 0; } function enable_service() { let res = command_status_from_args([ SERVICE_INIT, "enable" ]); uci_core.set(CONFIG_NAME, "settings", "enabled", "1"); uci_core.commit(CONFIG_NAME); return res; } function disable_service() { let res = command_status_from_args([ SERVICE_INIT, "disable" ]); uci_core.set(CONFIG_NAME, "settings", "enabled", "0"); uci_core.commit(CONFIG_NAME); return res; } let mode = ARGV[0] || ""; let status = 1; if (mode == "main") status = start_main(); else if (mode == "start") status = start(); else if (mode == "stop") status = stop(); else if (mode == "reload-firewall") status = reload_firewall(); else if (mode == "reload") status = reload_tracked(ARGV[1] || ""); else if (mode == "dns-failover-apply") status = dns_failover_apply(ARGV[1] || ""); else if (mode == "restart") status = restart(); else if (mode == "enable") status = enable_service(); else if (mode == "disable") status = disable_service(); else if (mode == "selector-state-from-proxies-fixture") { write_json(selector_state_from_proxies_payload(read_json_file(ARGV[1]))); status = 0; } else if (mode == "selector-restore-pairs-fixture") { write_json(selector_restore_pairs(read_json_file(ARGV[1]), read_json_file(ARGV[2]))); status = 0; } else if (mode == "dnsmasq-restore" || mode == "restore-dnsmasq") status = dnsmasq_restore_fail_safe(); else if (mode == "discover-awg-mtu") status = discover_awg_mtu(); else if (mode == "uninstall") status = uninstall(); else { warn("Usage: service/lifecycle.uc ...\n"); status = 1; } release_start_subscription_update_lock(); exit(status);