Files
NeoTachyon/tachyon/files/usr/lib/service/package.uc
T
Dushnilin 1c48422511 fix(luci): ensure Tachyon menu item reliably appears in LuCI
- Remove 'uci: { tachyon: true }' dependency from menu.d/luci-app-tachyon.json so LuCI does not hide the menu node if UCI config is uncommitted/reloading
- Add portable timeout normalization in install.sh ucode to handle Busybox timeout and systems without timeout applet
- Ensure luci_postinst is called directly in IPK postinst (build.sh) and after install in install.sh
- Restart uhttpd alongside rpcd and purge LuCI caches in post-install and uninstaller
- Fix uninstall.sh to remove packages individually with lock retry and omit non-existent luci-i18n-tachyon-en
- In install_ui_packages, verify that menu.d/luci-app-tachyon.json actually exists on disk before proceeding
2026-09-03 19:14:22 +03:00

322 lines
13 KiB
Ucode

#!/usr/bin/env ucode
let fs = require("fs");
let uci_core = require("core.uci");
let common = require("core.common");
let as_string = common.as_string;
function env(name, fallback) {
let value = getenv(name);
return value == null ? as_string(fallback) : as_string(value);
}
const CONFIG_NAME = env("TACHYON_CONFIG_NAME", "tachyon");
const RT_TABLES_PATH = env("TACHYON_RT_TABLES", "/etc/iproute2/rt_tables");
const BIN_PATH = env("TACHYON_BIN", "/usr/bin/tachyon");
const INIT_PATH = env("TACHYON_INIT", "/etc/init.d/tachyon");
const DNS_APPLY_UC = env("TACHYON_DNS_APPLY_UC", "/usr/lib/tachyon/dns/apply.uc");
const SING_BOX_INIT = env("TACHYON_SING_BOX_INIT", "/etc/init.d/sing-box");
const SING_BOX_BIN = env("TACHYON_SING_BOX_BIN", "/usr/bin/sing-box");
const SING_BOX_CRONET = env("TACHYON_SING_BOX_CRONET", "/usr/lib/libcronet.so");
const SING_BOX_MANAGED_MARKER = env("SB_MANAGED_SERVICE_MARKER", "Tachyon managed sing-box service for binary variants");
const PACKAGE_UPGRADE_STATE = env("TACHYON_PACKAGE_UPGRADE_STATE", "/tmp/tachyon-package-was-running");
const RUNTIME_STATE_DIR = env("TACHYON_RUNTIME_STATE_DIR", "/var/run/tachyon");
const COMPONENT_ACTION_DIR = env("TACHYON_UI_COMPONENT_ACTION_DIR", RUNTIME_STATE_DIR + "/component-actions");
const COMPONENT_UPDATE_CHECK_DIR = env("TACHYON_COMPONENT_UPDATE_CHECK_CACHE_DIR", RUNTIME_STATE_DIR + "/component-update-checks");
const COMPONENT_UPDATE_CHECK_TIMESTAMP = env("TACHYON_COMPONENT_UPDATE_CHECK_STATE_FILE", RUNTIME_STATE_DIR + "/component-update-check.timestamp");
const COMPONENT_ACTION_LOCK = env("TACHYON_COMPONENT_ACTION_LOCK", RUNTIME_STATE_DIR + "/component-action.lock");
const PACKAGE_TEST_MODE = env("TACHYON_PACKAGE_TEST_MODE", "") != "";
// apk aborts and rolls the whole transaction back if a hook outlives its
// patience, and the installer kills it even sooner. Anything that touches
// /etc/init.d/tachyon goes through rc.common, which waits on `flock -w 1000` —
// a stuck start or retry_start_on_wan_up holding that lock would stall the hook
// for a quarter of an hour. Every such call is bounded instead.
const HOOK_COMMAND_TIMEOUT = env("TACHYON_HOOK_COMMAND_TIMEOUT", "20");
// Starting the service legitimately takes longer than probing or stopping it —
// sing-box comes up, nft rules load, subscriptions may be re-read — so it gets
// its own, larger budget. Still bounded: postinst must end.
const HOOK_START_TIMEOUT = env("TACHYON_HOOK_START_TIMEOUT", "60");
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;
}
function command_success_from_args(args) {
return normalize_status(system(command_from_args(args) + " >/dev/null 2>&1")) == 0;
}
// Which spelling of `timeout` this box understands, resolved once. BusyBox
// required `-t SECONDS` on older builds and rejects it on newer ones. Probing is
// done against /bin/true rather than the real command: an unsupported form would
// otherwise mean running the cleanup twice.
let timeout_prefix_cache = null;
function timeout_prefix() {
if (timeout_prefix_cache != null)
return timeout_prefix_cache;
if (normalize_status(system("timeout 1 /bin/true >/dev/null 2>&1")) == 0)
timeout_prefix_cache = [ "timeout" ];
else if (normalize_status(system("timeout -t 1 /bin/true >/dev/null 2>&1")) == 0)
timeout_prefix_cache = [ "timeout", "-t" ];
else
timeout_prefix_cache = []; // no usable timeout binary
return timeout_prefix_cache;
}
// Same as command_success_from_args, but the command cannot outlive `seconds`.
// A timed-out command reports failure (timeout exits 124), which every caller
// here already treats as "could not do it, carry on" — the alternative is an
// upgrade the package manager rolls back.
function bounded_command_success_from_args(args, seconds) {
seconds = as_string(seconds) != "" ? as_string(seconds) : HOOK_COMMAND_TIMEOUT;
let prefix = timeout_prefix();
if (length(prefix) == 0)
return command_success_from_args(args);
let bounded = [];
for (let part in prefix)
push(bounded, part);
push(bounded, seconds);
for (let arg in args)
push(bounded, arg);
return command_success_from_args(bounded);
}
// A kill sweep must not take out the process running it. `ps | grep '/usr/bin/tachyon'`
// matches this very hook — /usr/bin/tachyon is what invoked it — so the naive sweep
// killed its own parent; the parent died on SIGKILL, and apk saw the pre-upgrade hook
// exit 247 (128+119) and reported a failed hook. The ancestor chain of the shell doing
// the sweep is walked out of /proc and skipped. Field 2 after the ")" of /proc/<pid>/stat
// is the ppid; the comm field is stripped first because it may itself contain spaces.
function kill_matching_command(grep_args) {
return "__anc=\" \"; __p=$$; " +
"while [ -n \"$__p\" ] && [ \"$__p\" -gt 1 ] 2>/dev/null; do " +
"__anc=\"$__anc$__p \"; " +
"__p=$(awk '{ sub(/.*\\) /, \"\"); print $2 }' \"/proc/$__p/stat\" 2>/dev/null); " +
"done; " +
"ps 2>/dev/null | grep " + grep_args + " | grep -v grep | grep -v -E 'action[.]uc|updates[.]uc' | awk '{print $1}' | " +
"while read _pid; do case \"$__anc\" in *\" $_pid \"*) continue;; esac; " +
"kill -9 \"$_pid\" 2>/dev/null; done; true";
}
function path_exists(path) {
return fs.stat(as_string(path)) != null;
}
function unlink_if_exists(path) {
if (path_exists(path))
fs.unlink(as_string(path));
}
function remove_rt_tables_entry() {
let data = fs.readfile(RT_TABLES_PATH);
if (data == null)
return true;
let changed = false;
let lines = [];
for (let line in split(data, "\n")) {
if (index(line, "105 tachyon") >= 0) {
changed = true;
continue;
}
push(lines, line);
}
return !changed || fs.writefile(RT_TABLES_PATH, join("\n", lines)) != null;
}
function ascii_lower(value) {
let upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
let lower = "abcdefghijklmnopqrstuvwxyz";
return replace(as_string(value), /[A-Z]/g, function(ch) {
return substr(lower, index(upper, ch), 1);
});
}
function truthy(value) {
value = ascii_lower(trim(as_string(value)));
return value == "1" || value == "true" || value == "yes" || value == "on";
}
function dont_touch_dhcp_enabled() {
return truthy(uci_core.get(CONFIG_NAME + ".settings.dont_touch_dhcp"));
}
function restore_dnsmasq_if_needed() {
if (dont_touch_dhcp_enabled())
return;
bounded_command_success_from_args([ BIN_PATH, "restore_dnsmasq" ]);
if (path_exists(DNS_APPLY_UC))
bounded_command_success_from_args([ "ucode", DNS_APPLY_UC, "failsafe-restore" ]);
}
function remove_managed_sing_box() {
let data = fs.readfile(SING_BOX_INIT);
if (data == null || index(data, SING_BOX_MANAGED_MARKER) < 0)
return;
bounded_command_success_from_args([ SING_BOX_INIT, "stop" ]);
bounded_command_success_from_args([ SING_BOX_INIT, "disable" ]);
unlink_if_exists(SING_BOX_INIT);
unlink_if_exists(SING_BOX_BIN);
unlink_if_exists(SING_BOX_CRONET);
}
function remember_upgrade_state(action) {
// INIT_PATH status goes through rc.common's flock, so it is bounded like every
// other init call here. A timeout is indistinguishable from "not running", and
// that is the safe reading: the worst case is not restarting a service that was
// wedged anyway, versus stalling the upgrade until apk rolls it back.
if (as_string(action) != "upgrade" || !bounded_command_success_from_args([ INIT_PATH, "status" ])) {
unlink_if_exists(PACKAGE_UPGRADE_STATE);
return;
}
fs.writefile(PACKAGE_UPGRADE_STATE, "1\n");
}
function prerm_cleanup(action) {
if (env("IPKG_INSTROOT", "") != "")
return true;
if (!PACKAGE_TEST_MODE) {
// Kill the WAN hotplug monitor and all init.d/tachyon/flock processes FIRST
// to prevent INIT_PATH stop from blocking indefinitely on rc.common flock -w 1000.
// This has to happen before the status probe below, or that probe becomes the
// very thing that waits on the lock those processes hold.
system(kill_matching_command("-E '99-tachyon-wan|flock 1000|init[.]d/tachyon'"));
system(kill_matching_command("-F '/etc/init.d/tachyon'"));
// procd leaks its serialization lock: procd_lock() does `exec 1000>` on
// /var/lock/procd_tachyon.lock and then `flock 1000` with no -w, so every
// background spawn that inherits fd 1000 keeps the lock held after the init
// script exits. Builds before this fix swept /proc/self/fd only up to 999 and
// missed exactly that descriptor. The holders may therefore be pre-fix
// processes with no other reason to die, so they are cleared by descriptor
// rather than by name — otherwise INIT_PATH below blocks forever.
system("for __f in /proc/[0-9]*; do [ -e \"$__f/fd/1000\" ] || continue; " +
"case \"$(readlink \"$__f/fd/1000\" 2>/dev/null)\" in *procd_tachyon*) " +
"__pid=${__f##*/}; [ \"$__pid\" = \"$$\" ] || " +
"case \"$(readlink \"$__f/exe\" 2>/dev/null)\" in *ucode*) continue;; esac; " +
"kill -9 \"$__pid\" 2>/dev/null;; " +
"esac; done; true");
}
remember_upgrade_state(action);
if (!PACKAGE_TEST_MODE) {
// Stop the service using BIN_PATH directly (bypasses rc.common flock)
bounded_command_success_from_args([BIN_PATH, "stop"]);
bounded_command_success_from_args([INIT_PATH, "stop"]);
// Kill any remaining tachyon processes, minus this hook's own ancestors.
system(kill_matching_command("-F '" + BIN_PATH + "'"));
restore_dnsmasq_if_needed();
if (action == "remove")
remove_managed_sing_box();
}
remove_rt_tables_entry(); // best-effort
return true;
}
function postinst_restore() {
if (env("IPKG_INSTROOT", "") != "" || !path_exists(PACKAGE_UPGRADE_STATE))
return true;
if (!PACKAGE_TEST_MODE) {
// Kill any flock waiters and init.d/tachyon processes that appeared since prerm ran.
system(kill_matching_command("-E '99-tachyon-wan|flock 1000'"));
system(kill_matching_command("-F '/etc/init.d/tachyon'"));
}
// Start via INIT_PATH. Bounded: a start that hangs must not take the package
// transaction down with it — the service can be started by hand afterwards,
// a rolled-back upgrade leaves the user on the old build with no recourse.
bounded_command_success_from_args([INIT_PATH, "start"], HOOK_START_TIMEOUT);
unlink_if_exists(PACKAGE_UPGRADE_STATE);
return true;
}
function luci_cache_globs() {
let configured = env("TACHYON_LUCI_CACHE_GLOBS", "");
if (configured != "")
return split(configured, /[ \t\r\n]+/);
return [ "/var/luci-indexcache*", "/tmp/luci-indexcache*", "/var/luci-modulecache*", "/tmp/luci-modulecache*" ];
}
function remove_luci_index_cache() {
for (let pattern in luci_cache_globs()) {
pattern = as_string(pattern);
if (pattern == "")
continue;
for (let path in fs.glob(pattern))
unlink_if_exists(path);
}
}
// A Tachyon self-update is itself a component job: the job installs the package
// whose postinst this is. Wiping the whole directory therefore deleted the state
// file of the job that was still running, so the UI polled a job that no longer
// existed, got no message and no stderr, and rendered its "Failed to execute"
// fallback over an update that had in fact succeeded. Only finished results are
// stale; a job still marked running must survive to write its own result.
function component_job_is_running(path) {
let data = fs.readfile(path);
if (data == null)
return false;
return match(as_string(data), /"running"[ \t]*:[ \t]*(true|1)/) != null;
}
function remove_component_update_cache() {
for (let path in fs.glob(COMPONENT_UPDATE_CHECK_DIR + "/*.json"))
unlink_if_exists(path);
unlink_if_exists(COMPONENT_UPDATE_CHECK_TIMESTAMP);
command_success_from_args([ "rm", "-rf", COMPONENT_ACTION_LOCK ]);
for (let path in fs.glob(COMPONENT_ACTION_DIR + "/*"))
if (!component_job_is_running(path))
unlink_if_exists(path);
}
function luci_postinst() {
remove_luci_index_cache();
remove_component_update_cache();
if (!PACKAGE_TEST_MODE) {
if (path_exists("/etc/init.d/rpcd"))
command_success_from_args([ "/etc/init.d/rpcd", "restart" ]);
if (path_exists("/etc/init.d/uhttpd"))
command_success_from_args([ "/etc/init.d/uhttpd", "restart" ]);
command_success_from_args([ "logger", "-t", "tachyon", "[info] Package defaults applied" ]);
}
return true;
}
let mode = ARGV[0] || "";
if (mode == "prerm")
exit(prerm_cleanup(ARGV[1]) ? 0 : 1);
else if (mode == "postinst")
exit(postinst_restore() ? 0 : 1);
else if (mode == "remove-rt-tables-entry")
exit(remove_rt_tables_entry() ? 0 : 1);
else if (mode == "luci-postinst")
exit(luci_postinst() ? 0 : 1);
else {
warn("Usage: service/package.uc <prerm|postinst|remove-rt-tables-entry|luci-postinst>\n");
exit(1);
}