Files
NeoTachyon/tachyon/files/usr/lib/singbox/runtime.uc
T

1174 lines
47 KiB
Ucode

#!/usr/bin/env ucode
let fs = require("fs");
let uci_core = require("core.uci");
let common = require("core.common");
let runtime_dns = require("singbox.dns");
const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon";
const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon";
const TMP_SING_BOX_FOLDER = getenv("TMP_SING_BOX_FOLDER") || "/tmp/sing-box";
const TMP_RULESET_FOLDER = getenv("TMP_RULESET_FOLDER") || TMP_SING_BOX_FOLDER + "/rulesets";
const TMP_SUBSCRIPTION_FOLDER = getenv("TMP_SUBSCRIPTION_FOLDER") || TMP_SING_BOX_FOLDER + "/subscriptions";
const RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon";
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 RUNTIME_CACHE_FORMAT_FILE = getenv("TACHYON_RUNTIME_CACHE_FORMAT_FILE") || RUNTIME_STATE_DIR + "/cache-format";
const RUNTIME_CACHE_FORMAT = getenv("TACHYON_RUNTIME_CACHE_FORMAT") || "8";
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 PENDING_RELOAD_FILE = getenv("TACHYON_PENDING_RELOAD_FILE") || RUNTIME_STATE_DIR + "/reload.pending";
const SERVICE_INIT = getenv("TACHYON_SERVICE_INIT") || "/etc/init.d/tachyon";
const NFT_TABLE_NAME = getenv("NFT_TABLE_NAME") || "TachyonTable";
const NFT_COMMON_SET_NAME = getenv("NFT_COMMON_SET_NAME") || "tachyon_subnets";
const NFT_COMMON6_SET_NAME = getenv("NFT_COMMON6_SET_NAME") || "tachyon_subnets6";
const NFT_PORT_SET_NAME = getenv("NFT_PORT_SET_NAME") || "tachyon_ports";
const NFT_IP_PORT_SET_NAME = getenv("NFT_IP_PORT_SET_NAME") || "tachyon_ip_ports";
const NFT_IP_PORT6_SET_NAME = getenv("NFT_IP_PORT6_SET_NAME") || "tachyon_ip6_ports";
const NFT_INTERFACE_SET_NAME = getenv("NFT_INTERFACE_SET_NAME") || "tachyon_interfaces";
const NFT_LOCALV4_SET_NAME = getenv("NFT_LOCALV4_SET_NAME") || "localv4";
const NFT_LOCALV6_SET_NAME = getenv("NFT_LOCALV6_SET_NAME") || "localv6";
const NFT_FAKEIP_MARK = getenv("NFT_FAKEIP_MARK") || "0x04000000";
const SB_SERVICE_MIXED_INBOUND_ADDRESS = getenv("SB_SERVICE_MIXED_INBOUND_ADDRESS") || "127.0.0.1";
const SB_SERVICE_MIXED_INBOUND_PORT = getenv("SB_SERVICE_MIXED_INBOUND_PORT") || "4534";
const SB_VARIANT_STATE_FILE = getenv("SB_VARIANT_STATE_FILE") || "/etc/tachyon/sing-box-variant";
const SB_VERSION_STATE_FILE = getenv("SB_VERSION_STATE_FILE") || "/etc/tachyon/sing-box-version";
const SB_MANAGED_SERVICE_MARKER = getenv("SB_MANAGED_SERVICE_MARKER") || "Tachyon managed sing-box service for binary variants";
let as_string = common.as_string;
let shell_quote = common.shell_quote;
let command_from_args = common.command_from_args;
let command_output = common.command_output;
let command_output_from_args = common.command_output_from_args;
let command_status = common.command_status;
let command_success_from_args = common.command_success_from_args;
let command_status_from_args = common.command_status_from_args;
let object_or_empty = common.object_or_empty;
let array_or_empty = common.array_or_empty;
let option = common.option;
let bool_option = common.bool_option;
let file_exists = common.file_exists;
let remove_file = common.remove_file;
function command_env(assignments) {
let parts = [];
for (let name, value in assignments)
push(parts, name + "=" + shell_quote(value));
return join(" ", parts);
}
function command_output_lenient(command) {
let pipe = fs.popen(command, "r");
if (!pipe)
return "";
let data = pipe.read("all");
pipe.close();
return data != null ? as_string(data) : "";
}
function command_exists(name) {
return command_success_from_args([ "command", "-v", name ]);
}
function write_file(path, value) {
return fs.writefile(as_string(path), as_string(value)) != null;
}
function remove_files(paths) {
for (let path in paths)
if (as_string(path) != "")
remove_file(path);
}
function file_first_line(path) {
let data = fs.readfile(as_string(path));
if (data == null)
return "";
let newline = index(data, "\n");
return trim(newline >= 0 ? substr(data, 0, newline) : data);
}
function arg_bool(value) {
value = lc(as_string(value));
return value == "true" || value == "1" || value == "yes" || value == "on";
}
function whitespace_items(value) {
let result = [];
if (type(value) == "array") {
for (let item in value) {
item = as_string(item);
if (item != "")
push(result, item);
}
return result;
}
for (let item in split(trim(as_string(value)), /[ \t\r\n]+/))
if (item != "")
push(result, item);
return result;
}
function parent_dir(path) {
path = as_string(path);
let slash = rindex(path, "/");
return slash >= 0 ? substr(path, 0, slash) : "";
}
function ensure_dir(path) {
return command_success_from_args([ "mkdir", "-p", path ]);
}
function ensure_parent_dir(path) {
let dir = parent_dir(path);
return dir == "" || dir == "." || ensure_dir(dir);
}
function temp_path() {
return trim(command_output_from_args([ "mktemp", "/tmp/tachyon-XXXXXX" ]));
}
function md5_file(path) {
let line = trim(command_output_from_args([ "md5sum", as_string(path) ]));
return length(line) >= 32 ? substr(line, 0, 32) : "";
}
function first_line_last_field(value) {
value = as_string(value);
let newline = index(value, "\n");
let line = trim(newline >= 0 ? substr(value, 0, newline) : value);
let fields = split(line, /[ \t\r\n]+/);
return length(fields) > 0 ? as_string(fields[length(fields) - 1]) : "";
}
// The version string is taken from stdout whatever the exit status: sing-box
// prints "sing-box version X" first and may still exit non-zero (a warning about
// a missing config, a signal from a concurrent service stop). Gating on the exit
// code made the dashboard fall back to "unknown" for a perfectly working binary.
function sing_box_version_output() {
return command_exists("sing-box") ? command_output_lenient(command_from_args([ "sing-box", "version" ]) + " 2>/dev/null") : "";
}
function sing_box_marker_is(value) {
return file_first_line(SB_VARIANT_STATE_FILE) == as_string(value);
}
function sing_box_version_state() {
return file_first_line(SB_VERSION_STATE_FILE);
}
function sing_box_write_version_state(version) {
version = as_string(version);
return version != "" && ensure_parent_dir(SB_VERSION_STATE_FILE) && write_file(SB_VERSION_STATE_FILE, version + "\n");
}
function sing_box_clear_version_state() {
remove_file(SB_VERSION_STATE_FILE);
return true;
}
function sing_box_restore_version_state(version) {
version = as_string(version);
return version != "" ? sing_box_write_version_state(version) : sing_box_clear_version_state();
}
function sing_box_variant_marker() {
return file_first_line(SB_VARIANT_STATE_FILE);
}
function sing_box_write_variant_marker(variant) {
variant = as_string(variant);
return variant != "" && ensure_parent_dir(SB_VARIANT_STATE_FILE) && write_file(SB_VARIANT_STATE_FILE, variant + "\n");
}
function sing_box_clear_variant_marker() {
remove_file(SB_VARIANT_STATE_FILE);
return true;
}
function sing_box_restore_variant_marker(variant) {
variant = as_string(variant);
return variant != "" ? sing_box_write_variant_marker(variant) : sing_box_clear_variant_marker();
}
function sing_box_version() {
if (!command_exists("sing-box"))
return "";
if (sing_box_marker_is("extended-compressed") || sing_box_marker_is("lx"))
return sing_box_version_state();
return first_line_last_field(sing_box_version_output());
}
function sing_box_version_is_extended(value) {
return index(as_string(value), "extended") >= 0;
}
function sing_box_version_is_lx(value) {
return index(as_string(value), "-lx") >= 0;
}
function sing_box_is_extended(value) {
value = as_string(value);
if (value == "" && command_exists("sing-box") && (sing_box_marker_is("extended-compressed") || sing_box_marker_is("extended") || sing_box_marker_is("lx")))
return true;
value = value != "" ? value : sing_box_version();
return sing_box_version_is_extended(value) || sing_box_version_is_lx(value);
}
function sing_box_is_lx(value) {
value = as_string(value);
if (value == "" && command_exists("sing-box") && sing_box_marker_is("lx"))
return true;
return sing_box_version_is_lx(value != "" ? value : sing_box_version());
}
function output_has_build_tag(output, tag) {
tag = as_string(tag);
if (tag == "")
return false;
for (let item in split(trim(replace(as_string(output), /[,: \t\r\n]+/g, " ")), " "))
if (as_string(item) == tag)
return true;
return false;
}
function sing_box_supports_tailscale(version, version_output) {
version = as_string(version);
version_output = as_string(version_output);
if (command_exists("sing-box") && (sing_box_marker_is("extended-compressed") || sing_box_marker_is("lx")))
return true;
if (sing_box_is_extended(version) || sing_box_is_lx(version))
return true;
if (version_output != "")
return output_has_build_tag(version_output, "with_tailscale");
return output_has_build_tag(sing_box_version_output(), "with_tailscale");
}
function sing_box_supports_xhttp(version, version_output) {
version = as_string(version);
version_output = as_string(version_output);
if (command_exists("sing-box") && (sing_box_marker_is("extended-compressed") || sing_box_marker_is("lx")))
return true;
if (sing_box_is_extended(version) || sing_box_is_lx(version))
return true;
if (version_output != "")
return output_has_build_tag(version_output, "with_xhttp");
return output_has_build_tag(sing_box_version_output(), "with_xhttp");
}
function module_command(args) {
let command_args = [ "ucode", "-L", LIB_DIR ];
for (let arg in args)
push(command_args, arg);
return command_from_args(command_args);
}
function module_success(args) {
return command_status(module_command(args)) == 0;
}
function sing_box_package_installed(name) {
return module_success([ LIB_DIR + "/core/packages.uc", "installed", as_string(name) ]);
}
function sing_box_is_tiny(version, version_output) {
version = as_string(version);
version_output = as_string(version_output);
if (command_exists("sing-box") && (sing_box_marker_is("extended-compressed") || sing_box_marker_is("lx")))
return false;
let computed_version = version != "" ? version : sing_box_version();
if (sing_box_is_extended(computed_version) || sing_box_is_lx(computed_version))
return false;
if (sing_box_package_installed("sing-box-tiny"))
return true;
if (!sing_box_marker_is("tiny"))
return false;
return !sing_box_supports_tailscale(version, version_output);
}
function sing_box_variant() {
let version = "";
if (sing_box_marker_is("lx"))
return "lx";
if (sing_box_marker_is("extended-compressed"))
return "extended-compressed";
if (!command_exists("sing-box"))
return "not-installed";
version = sing_box_version();
if (sing_box_is_lx(version))
return "lx";
if (sing_box_is_extended(version))
return sing_box_marker_is("extended-compressed") ? "extended-compressed" : "extended";
if (sing_box_is_tiny(version, ""))
return "tiny";
return "stable";
}
function log_message(message, level) {
level = as_string(level || "info");
command_success_from_args([ "logger", "-t", "tachyon", "[" + level + "] " + as_string(message) ]);
}
function module_env_capture(env, args) {
let output_path = temp_path();
if (output_path == "")
return { status: 1, output: "" };
let status = command_status(command_env(env) + " " + module_command(args) + " >" + shell_quote(output_path) + " 2>&1");
let output = as_string(fs.readfile(output_path) || "");
remove_file(output_path);
return { status, output };
}
function uci_settings() {
return object_or_empty(uci_core.get_all(CONFIG_NAME, "settings"));
}
function uci_path(config, section, key) {
return as_string(config) + "." + as_string(section) + "." + as_string(key);
}
function uci_get_option(config, section, key) {
return trim(uci_core.get(uci_path(config, section, key)));
}
function ensure_sing_box_main_section() {
if (uci_core.exists("sing-box.main"))
return true;
return uci_core.set_section("sing-box.main", "sing-box");
}
function uci_set_option(config, section, key, value) {
if (!uci_core.exists(as_string(config) + "." + as_string(section)))
return false;
return uci_core.set(uci_path(config, section, key), value);
}
function uci_commit(config) {
return uci_core.commit(config);
}
function owner_pid() {
let pid = trim(command_output_from_args([ "sh", "-c", "echo $PPID" ]));
return match(pid, /^[0-9]+$/) != null ? pid : "0";
}
function managed_service_installed() {
let data = fs.readfile("/etc/init.d/sing-box");
return data != null && index(as_string(data), SB_MANAGED_SERVICE_MARKER) >= 0 && index(as_string(data), "GOGC=\"50\"") >= 0;
}
function managed_service_text() {
return "#!/bin/sh /etc/rc.common\n" +
"# " + SB_MANAGED_SERVICE_MARKER + "\n\n" +
"USE_PROCD=1\n" +
"START=99\n" +
"PROG=\"/usr/bin/sing-box\"\n\n" +
"start_service() {\n" +
" [ -d /dev/net ] || mkdir -p /dev/net\n" +
" [ -c /dev/net/tun ] || mknod /dev/net/tun c 10 200 2>/dev/null || true\n" +
" modprobe tun 2>/dev/null || true\n" +
" modprobe inet_diag 2>/dev/null || true\n\n" +
" config_load \"sing-box\"\n" +
" local enabled config_file working_directory\n" +
" local log_stderr\n\n" +
" config_get_bool enabled \"main\" \"enabled\" \"0\"\n" +
" [ \"$enabled\" -eq \"1\" ] || return 0\n\n" +
" config_get config_file \"main\" \"conffile\" \"/etc/sing-box/config.json\"\n" +
" config_get working_directory \"main\" \"workdir\" \"/usr/share/sing-box\"\n" +
" config_get_bool log_stderr \"main\" \"log_stderr\" \"1\"\n\n" +
" procd_open_instance\n" +
" procd_set_param command \"$PROG\" run -c \"$config_file\" -D \"$working_directory\"\n" +
" procd_set_param file \"$config_file\"\n" +
" procd_set_param stderr \"$log_stderr\"\n" +
" procd_set_param limits core=\"unlimited\"\n" +
" procd_set_param limits nofile=\"1000000 1000000\"\n" +
" procd_set_param env GODEBUG=\"madvdontneed=1\" GOGC=\"50\"\n" +
" procd_set_param term_timeout 15\n" +
" procd_set_param respawn\n" +
" procd_close_instance\n" +
"}\n\n" +
"service_triggers() {\n" +
" procd_add_reload_trigger \"sing-box\"\n" +
"}\n";
}
function sing_box_compressed_marker_set() {
return trim(as_string(fs.readfile(SB_VARIANT_STATE_FILE) || "")) == "extended-compressed";
}
function install_managed_service_script() {
let tmp = "/etc/init.d/sing-box.tachyon." + owner_pid();
if (!write_file(tmp, managed_service_text()))
return false;
if (!command_success_from_args([ "chmod", "0755", tmp ])) {
remove_file(tmp);
return false;
}
return fs.rename(tmp, "/etc/init.d/sing-box");
}
function remove_managed_service_script() {
if (!managed_service_installed())
return;
command_success_from_args([ "/etc/init.d/sing-box", "stop" ]);
command_success_from_args([ "/etc/init.d/sing-box", "disable" ]);
remove_file("/etc/init.d/sing-box");
}
function disable_service_config() {
if (!ensure_sing_box_main_section())
return;
uci_set_option("sing-box", "main", "enabled", "0");
uci_commit("sing-box");
}
function prepare_service_disabled() {
disable_service_config();
if (file_exists("/etc/init.d/sing-box")) {
command_success_from_args([ "/etc/init.d/sing-box", "stop" ]);
command_success_from_args([ "/etc/init.d/sing-box", "disable" ]);
}
}
function configure_service() {
let settings = uci_settings();
if (!managed_service_installed() && !install_managed_service_script()) {
log_message("Failed to install managed sing-box service. Aborted.", "fatal");
exit(1);
}
if (!ensure_sing_box_main_section())
exit(1);
let changed = false;
if (uci_get_option("sing-box", "main", "enabled") != "1") {
if (!uci_set_option("sing-box", "main", "enabled", "1"))
exit(1);
changed = true;
log_message("sing-box service has been enabled", "info");
}
if (uci_get_option("sing-box", "main", "user") != "root") {
if (!uci_set_option("sing-box", "main", "user", "root"))
exit(1);
changed = true;
log_message("sing-box service user has been changed to root", "info");
}
let config_path = option(settings, "config_path", "");
let conffile = uci_get_option("sing-box", "main", "conffile");
if (conffile != config_path) {
if (!uci_set_option("sing-box", "main", "conffile", config_path))
exit(1);
changed = true;
log_message("sing-box service config path set to " + config_path, "info");
}
if (changed && !uci_commit("sing-box"))
exit(1);
if (file_exists("/etc/rc.d/S99sing-box")) {
log_message("Disabling standalone sing-box autostart", "info");
command_success_from_args([ "/etc/init.d/sing-box", "disable" ]);
}
for (let comp_svc in [ "forkop", "podkop", "netshift" ]) {
if (file_exists("/etc/init.d/" + comp_svc)) {
command_status_from_args([ "/etc/init.d/" + comp_svc, "stop" ]);
command_status_from_args([ "/etc/init.d/" + comp_svc, "disable" ]);
}
for (let rc_link in (fs.glob("/etc/rc.d/*" + comp_svc) || [])) {
remove_file(rc_link);
}
}
}
function download_via_proxy_option_for_purpose(purpose) {
purpose = as_string(purpose || "lists");
if (purpose == "lists")
return "download_lists_via_proxy";
if (purpose == "components")
return "download_components_via_proxy";
return "";
}
function download_via_proxy_section_option_for_purpose(purpose) {
purpose = as_string(purpose || "lists");
if (purpose == "lists")
return "download_lists_via_proxy_section";
if (purpose == "components")
return "download_components_via_proxy_section";
return "";
}
function download_via_proxy_section(settings, purpose) {
let enabled_option = download_via_proxy_option_for_purpose(purpose);
if (enabled_option == "" || !bool_option(settings, enabled_option, false))
return "";
let section_option = download_via_proxy_section_option_for_purpose(purpose);
let configured = section_option != "" ? option(settings, section_option, "") : "";
if (configured != "")
return configured;
return option(settings, "download_lists_via_proxy_section", "");
}
function service_proxy_port_for_purpose(purpose) {
return int(SB_SERVICE_MIXED_INBOUND_PORT) + (as_string(purpose || "lists") == "components" ? 1 : 0);
}
function service_proxy_address(settings, purpose) {
return download_via_proxy_section(settings, purpose) != "" ?
SB_SERVICE_MIXED_INBOUND_ADDRESS + ":" + service_proxy_port_for_purpose(purpose) : "";
}
function ip_addr_first_inet4(data) {
for (let line in split(as_string(data), "\n")) {
let matched = match(line, /inet[ \t]+([0-9.]+)\//);
if (matched)
return as_string(matched[1]);
}
return "";
}
function network_interface_ipv4(name) {
let data = command_output_from_args([ "ubus", "call", "network.interface." + as_string(name), "status" ]);
try {
let value = json(data);
let addresses = array_or_empty(object_or_empty(value)["ipv4-address"]);
if (length(addresses) > 0)
return as_string(object_or_empty(addresses[0]).address);
}
catch (e) {
// An interface that is down answers with an error rather than a status
// object. The empty string the caller gets back means the same thing.
}
return "";
}
function device_ipv4_address_value(device) {
return ip_addr_first_inet4(command_output_from_args([ "ip", "-4", "addr", "show", "dev", as_string(device) ]));
}
// Returns true for RFC-1918 private IPv4 addresses (10/8, 172.16/12, 192.168/16).
// Used to filter out WAN/public addresses when enumerating interfaces.
function is_private_ipv4(addr) {
addr = as_string(addr);
return match(addr, /^10\./) != null ||
match(addr, /^172\.(1[6-9]|2[0-9]|3[01])\./) != null ||
match(addr, /^192\.168\./) != null;
}
// Last-resort fallback for non-standard setups (e.g. x86/64 where the LAN
// interface is named "eth1", "enp2s0", etc. — not "lan" or "br-lan").
// Calls `ubus call network.interface dump` to get all UCI interfaces, then
// returns the first private IPv4 found on a non-loopback, non-WAN interface.
function network_interface_dump_first_private_ipv4() {
let data = command_output_from_args([ "ubus", "call", "network.interface", "dump" ]);
try {
let value = json(data);
let interfaces = array_or_empty(object_or_empty(value)["interface"]);
for (let iface in interfaces) {
iface = object_or_empty(iface);
let name = as_string(iface["interface"] || "");
// Skip obviously non-LAN interfaces.
if (name == "wan" || name == "wan6" || name == "loopback" || name == "")
continue;
for (let addr_obj in array_or_empty(iface["ipv4-address"])) {
let addr = as_string(object_or_empty(addr_obj).address);
if (addr != "" && is_private_ipv4(addr))
return addr;
}
}
}
catch (e) {}
return "";
}
function service_listen_address_value(settings) {
let configured = option(settings, "service_listen_address", "");
if (configured != "") {
log_message("service_listen_address is set manually; automatic listen-address detection is skipped", "debug");
return configured;
}
let address = network_interface_ipv4("lan");
if (address != "")
return address;
for (let iface in whitespace_items(option(settings, "source_network_interfaces", "br-lan"))) {
address = network_interface_ipv4(iface);
if (address != "")
return address;
address = device_ipv4_address_value(iface);
if (address != "")
return address;
}
// Third-tier fallback: enumerate all UCI interfaces via ubus dump.
// Handles x86/64 and other non-standard setups where LAN is not named
// "lan"/"br-lan". Skips wan, wan6, loopback and any public addresses.
address = network_interface_dump_first_private_ipv4();
if (address != "") {
log_message("listen-address auto-detected via interface dump: " + address + ". Set service_listen_address in UCI to pin it.", "warn");
return address;
}
log_message("Failed to determine the listening IP address. Please open an issue to report this problem: https://github.com/Dushnilin/tachyon/issues", "error");
return "";
}
function subscription_cache_env() {
return {
TACHYON_CONFIG_NAME: CONFIG_NAME,
TACHYON_LIB: LIB_DIR,
TMP_SING_BOX_FOLDER,
TMP_RULESET_FOLDER,
TMP_SUBSCRIPTION_FOLDER,
TACHYON_RUNTIME_STATE_DIR: RUNTIME_STATE_DIR,
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_RUNTIME_CACHE_FORMAT_FILE: RUNTIME_CACHE_FORMAT_FILE,
TACHYON_RUNTIME_CACHE_FORMAT: 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_PENDING_RELOAD_FILE: PENDING_RELOAD_FILE,
TACHYON_SERVICE_INIT: SERVICE_INIT
};
}
function subscription_cache_capture(args) {
let command_args = [ LIB_DIR + "/subscription/cache.uc" ];
for (let arg in args)
push(command_args, arg);
return module_env_capture(subscription_cache_env(), command_args);
}
function log_lines(text, level, prefix) {
for (let line in split(as_string(text), "\n"))
if (trim(as_string(line)) != "")
log_message(as_string(prefix) + as_string(line), level);
}
function first_nonblank_line(path) {
let data = as_string(fs.readfile(path) || "");
for (let line in split(data, "\n"))
if (trim(as_string(line)) != "")
return as_string(line);
return "";
}
function last_nonblank_line(path) {
let result = "";
let data = as_string(fs.readfile(path) || "");
for (let line in split(data, "\n"))
if (trim(as_string(line)) != "")
result = as_string(line);
return result;
}
function first_error_line(path) {
let data = as_string(fs.readfile(path) || "");
for (let line in split(data, "\n")) {
let text = as_string(line);
if (trim(text) == "")
continue;
if (match(text, /error|fail|invalid|unexpected|near here/i) != null)
return text;
}
return "";
}
function generator_failure_reason(path, status) {
let reason = first_error_line(path);
if (reason == "")
reason = last_nonblank_line(path);
return reason != "" ? reason : "exit status " + status;
}
function log_file_lines(path, level, prefix) {
log_lines(fs.readfile(path), level, prefix);
}
function sing_box_check(config_path, output_path) {
let status = command_status(
command_from_args([ "sing-box", "-c", config_path, "check" ]) +
" >" + shell_quote(output_path) + " 2>&1"
);
let reason = status == 0 ? "" : first_nonblank_line(output_path);
if (status != 0 && reason == "")
reason = "exit status " + status;
return { status, reason };
}
function prepare_subscription_caches(prepared, no_refresh) {
let result = subscription_cache_capture([ "prepare-caches", "runtime", prepared ? "1" : "0", no_refresh ? "1" : "0" ]);
if (result.status != 0) {
log_message("Subscription caches are not ready for sing-box config generation. Aborted.", "fatal");
log_lines(result.output, "debug", "subscription cache: ");
return null;
}
return trim(result.output);
}
function save_config_file(temp_file_path, config_path) {
let current_hash = md5_file(config_path);
let temp_hash = md5_file(temp_file_path);
if (current_hash != temp_hash) {
log_message("sing-box configuration changed; updating " + config_path, "info");
if (!ensure_parent_dir(config_path))
return false;
return command_success_from_args([ "mv", "-f", temp_file_path, config_path ]);
}
log_message("sing-box configuration is unchanged", "info");
remove_file(temp_file_path);
return true;
}
function replace_dns_server(config, replacement) {
let servers = array_or_empty(object_or_empty(config.dns).servers);
for (let i = 0; i < length(servers); i++) {
if (as_string(object_or_empty(servers[i]).tag) == as_string(replacement.tag)) {
servers[i] = replacement;
return true;
}
}
return false;
}
function patch_dns_config(state_path) {
let settings = uci_settings();
let candidate_state = common.read_json_file(state_path);
let expected = runtime_dns.state_template(settings);
if (!runtime_dns.state_matches(expected, candidate_state)) {
log_message("DNS failover state does not match the current UCI configuration", "warn");
exit(2);
}
let config_path = option(settings, "config_path", "");
let config = common.read_json_file(config_path);
if (config_path == "" || type(config) != "object") {
log_message("Cannot read the current sing-box configuration for DNS failover", "error");
exit(1);
}
let main = runtime_dns.server_config(settings, candidate_state);
let bootstrap = runtime_dns.bootstrap_config(settings, candidate_state);
if (main.unsupported || !replace_dns_server(config, main) || !replace_dns_server(config, bootstrap)) {
log_message("Cannot locate or build canonical DNS servers for failover", "error");
exit(1);
}
let backup_path = temp_path();
let temp_config = temp_path();
if (backup_path == "" || temp_config == "" ||
fs.writefile(backup_path, fs.readfile(config_path)) == null ||
!common.write_json_file(temp_config, config, 2)) {
remove_files([ backup_path, temp_config ]);
exit(1);
}
let check_log = temp_path();
let check_result = check_log == ""
? { status: 1, reason: "unable to create check output file" }
: sing_box_check(temp_config, check_log);
if (check_result.status != 0) {
log_message("DNS failover produced an invalid sing-box configuration: " + check_result.reason, "error");
remove_files([ backup_path, temp_config, check_log ]);
exit(1);
}
remove_file(check_log);
let changed = md5_file(config_path) != md5_file(temp_config);
if (!save_config_file(temp_config, config_path)) {
remove_file(backup_path);
exit(1);
}
if (!changed) {
remove_file(backup_path);
print("0\n");
return;
}
print("1\t", backup_path, "\n");
}
function restore_dns_config(backup_path) {
let config_path = option(uci_settings(), "config_path", "");
if (config_path == "" || !file_exists(backup_path))
return false;
return command_success_from_args([ "mv", "-f", backup_path, config_path ]);
}
const EMPTY_SRS_BINARY = hexdec("5352530278da6200040000ffff00010001");
function ensure_local_rulesets_exist(temp_config) {
try {
let cfg_raw = as_string(fs.readfile(temp_config) || "");
let cfg_obj = length(cfg_raw) > 0 ? json(cfg_raw) : null;
if (type(cfg_obj) == "object" && type(cfg_obj.route) == "object" && type(cfg_obj.route.rule_set) == "array") {
command_success_from_args(["mkdir", "-p", TMP_RULESET_FOLDER]);
for (let rs in cfg_obj.route.rule_set) {
if (type(rs) == "object" && rs.type == "local" && rs.path) {
let st = fs.stat(rs.path);
if (!st || st.size < 17) {
let bname = replace(rs.path, /^.*\//, "");
let etc_file = "/etc/tachyon/rulesets/" + bname;
let etc_st = fs.stat(etc_file);
if (etc_st && etc_st.size >= 17) {
let content = fs.readfile(etc_file);
if (content) fs.writefile(rs.path, content);
} else if (rs.format == "source" || match(rs.path, /\.json$/)) {
fs.writefile(rs.path, sprintf("%J", { version: 3, rules: [] }));
} else {
fs.writefile(rs.path, EMPTY_SRS_BINARY);
}
}
}
}
}
} catch (e) {}
}
function init_config(populate_nft, caches_prepared, no_refresh) {
let settings = uci_settings();
let config_path = option(settings, "config_path", "");
if (config_path == "") {
log_message("sing-box config path is empty. Aborted.", "fatal");
exit(1);
}
// Cleanup stale temp config files from previous failed runs
try {
let stale = trim(command_output_from_args([ "find", "/tmp", "-maxdepth", "1", "-name", "config.json.*.tmp.*", "-mmin", "+5", "-type", "f" ]));
for (let path in split(stale, "\n"))
if (trim(path) != "") remove_file(trim(path));
}
catch (e) {
// Housekeeping for temp files left by earlier failed runs. Failing here
// leaves a few stale files in /tmp and must not stop the start.
}
let mwan3_active = module_success([ LIB_DIR + "/config/validator.uc", "mwan3-is-active" ]);
let output_interface = option(settings, "output_network_interface", "");
if (mwan3_active && output_interface != "")
log_message("mwan3 is active and Output Network Interface is set to '" + output_interface + "'; sing-box egress is pinned to this interface", "warn");
else if (mwan3_active)
log_message("mwan3 is active; disabling sing-box auto_detect_interface so mwan3 can control egress routing", "warn");
let deferred_sections = prepare_subscription_caches(caches_prepared, no_refresh);
if (deferred_sections == null)
exit(1);
let temp_config = temp_path();
let runtime_log = temp_path();
if (temp_config == "" || runtime_log == "") {
remove_files([ temp_config, runtime_log ]);
exit(1);
}
let generate_status = command_status(
module_command([
LIB_DIR + "/singbox/generator.uc",
"generate-config",
temp_config,
service_listen_address_value(settings),
mwan3_active ? "1" : "0",
sing_box_supports_xhttp(sing_box_version()) ? "1" : "0"
]) + " >" + shell_quote(runtime_log) + " 2>&1"
);
if (generate_status != 0) {
let reason = generator_failure_reason(runtime_log, generate_status);
log_message("Failed to generate sing-box configuration: " + reason, "fatal");
log_file_lines(runtime_log, "debug", "sing-box config generator output: ");
remove_files([ temp_config, runtime_log ]);
exit(1);
}
log_file_lines(runtime_log, "warn", "sing-box config generator: ");
ensure_local_rulesets_exist(temp_config);
let check_result = sing_box_check(temp_config, runtime_log);
while (check_result.status != 0) {
// Graceful degradation: if the installed sing-box does not support an inbound
// field that was added for newer versions (e.g. close_timeout for MTProto
// hot-reload fix, issue #15, prefer_ip, tolerate_time_skewness), strip it
// from all inbounds and retry. Loops to handle multiple unknown fields
// across retries (e.g. prefer_ip then tolerate_time_skewness).
let user_field_m = match(check_result.reason, /inbounds\[\d+\]\.users\[\d+\]\.(\w+): json: unknown field/);
let field_m = match(check_result.reason, /inbounds\[\d+\]\.(\w+): json: unknown field/);
let stripped = false;
if (user_field_m) {
let unknown_field = user_field_m[1];
log_message("Installed sing-box does not support inbound user field '" + unknown_field + "'; retrying without it", "warn");
let cfg_text = as_string(fs.readfile(temp_config) || "");
let cfg = length(cfg_text) > 0 ? json(cfg_text) : null;
if (type(cfg) == "object" && type(cfg.inbounds) == "array") {
for (let inb in cfg.inbounds) {
if (type(inb) == "object" && type(inb.users) == "array") {
for (let u in inb.users) {
if (type(u) == "object")
delete u[unknown_field];
}
}
}
write_file(temp_config, sprintf("%J", cfg));
stripped = true;
}
}
else if (field_m) {
let unknown_field = field_m[1];
log_message("Installed sing-box does not support inbound field '" + unknown_field + "'; retrying without it", "warn");
let cfg_text = as_string(fs.readfile(temp_config) || "");
let cfg = length(cfg_text) > 0 ? json(cfg_text) : null;
if (type(cfg) == "object" && type(cfg.inbounds) == "array") {
for (let inb in cfg.inbounds) {
if (type(inb) == "object")
delete inb[unknown_field];
}
write_file(temp_config, sprintf("%J", cfg));
stripped = true;
}
}
else {
let ep_field_m = match(check_result.reason, /endpoints\[\d+\]\.(\w+): json: unknown field/);
let out_field_m = match(check_result.reason, /outbounds\[\d+\]\.(\w+): json: unknown field/);
if (ep_field_m) {
let unknown_field = ep_field_m[1];
log_message("Installed sing-box does not support endpoint field '" + unknown_field + "'; adjusting configuration", "warn");
let cfg_text = as_string(fs.readfile(temp_config) || "");
let cfg = length(cfg_text) > 0 ? json(cfg_text) : null;
if (type(cfg) == "object" && type(cfg.endpoints) == "array") {
for (let ep in cfg.endpoints) {
if (type(ep) == "object") {
if (unknown_field == "amnezia" && type(ep.amnezia) == "object") {
for (let k, v in ep.amnezia) {
if (ep[k] == null)
ep[k] = v;
}
}
delete ep[unknown_field];
}
}
write_file(temp_config, sprintf("%J", cfg));
stripped = true;
}
}
else if (out_field_m) {
let unknown_field = out_field_m[1];
log_message("Installed sing-box does not support outbound field '" + unknown_field + "'; retrying without it", "warn");
let cfg_text = as_string(fs.readfile(temp_config) || "");
let cfg = length(cfg_text) > 0 ? json(cfg_text) : null;
if (type(cfg) == "object" && type(cfg.outbounds) == "array") {
for (let outb in cfg.outbounds) {
if (type(outb) == "object")
delete outb[unknown_field];
}
write_file(temp_config, sprintf("%J", cfg));
stripped = true;
}
}
else if (match(check_result.reason, /experimental\.cache_file\.store_dns/)) {
log_message("Installed sing-box rejected store_dns: " + check_result.reason + "; switching to store_rdrc and retrying", "warn");
let cfg_text = as_string(fs.readfile(temp_config) || "");
let cfg = length(cfg_text) > 0 ? json(cfg_text) : null;
if (type(cfg) == "object" && type(cfg.experimental) == "object" && type(cfg.experimental.cache_file) == "object") {
delete cfg.experimental.cache_file.store_dns;
cfg.experimental.cache_file.store_rdrc = true;
write_file(temp_config, sprintf("%J", cfg));
stripped = true;
}
}
else if (match(check_result.reason, /experimental\.cache_file\.store_rdrc/)) {
log_message("Installed sing-box rejected store_rdrc: " + check_result.reason + "; switching to store_dns and retrying", "warn");
let cfg_text = as_string(fs.readfile(temp_config) || "");
let cfg = length(cfg_text) > 0 ? json(cfg_text) : null;
if (type(cfg) == "object" && type(cfg.experimental) == "object" && type(cfg.experimental.cache_file) == "object") {
delete cfg.experimental.cache_file.store_rdrc;
cfg.experimental.cache_file.store_dns = true;
write_file(temp_config, sprintf("%J", cfg));
stripped = true;
}
}
else if (match(check_result.reason, /experimental\.cache_file\.(\w+): json: unknown field/)) {
let cf_m = match(check_result.reason, /experimental\.cache_file\.(\w+): json: unknown field/);
let unknown_field = cf_m[1];
log_message("Installed sing-box does not support cache_file field '" + unknown_field + "'; stripping and retrying", "warn");
let cfg_text = as_string(fs.readfile(temp_config) || "");
let cfg = length(cfg_text) > 0 ? json(cfg_text) : null;
if (type(cfg) == "object" && type(cfg.experimental) == "object" && type(cfg.experimental.cache_file) == "object") {
delete cfg.experimental.cache_file[unknown_field];
write_file(temp_config, sprintf("%J", cfg));
stripped = true;
}
}
else if (match(check_result.reason, /outbounds\[\d+\]\.transport: unknown transport type: (\w+)/)) {
let tr_m = match(check_result.reason, /outbounds\[\d+\]\.transport: unknown transport type: (\w+)/);
let bad_transport = tr_m[1];
let out_idx_m = match(check_result.reason, /outbounds\[(\d+)\]/);
let out_idx = out_idx_m ? int(out_idx_m[1]) : -1;
log_message("Installed sing-box does not support transport '" + bad_transport + "' on outbound index " + out_idx + "; stripping outbound and retrying", "warn");
let cfg_text = as_string(fs.readfile(temp_config) || "");
let cfg = length(cfg_text) > 0 ? json(cfg_text) : null;
if (type(cfg) == "object" && type(cfg.outbounds) == "array" && out_idx >= 0 && out_idx < length(cfg.outbounds)) {
let bad_tag = cfg.outbounds[out_idx].tag;
splice(cfg.outbounds, out_idx, 1);
for (let outb in cfg.outbounds) {
if (type(outb) == "object" && type(outb.outbounds) == "array") {
let filtered = [];
for (let tag in outb.outbounds) {
if (tag != bad_tag)
push(filtered, tag);
}
outb.outbounds = filtered;
}
}
write_file(temp_config, sprintf("%J", cfg));
stripped = true;
}
}
}
if (!stripped)
break;
check_result = sing_box_check(temp_config, runtime_log);
}
if (check_result.status != 0) {
log_message("Generated sing-box configuration is invalid: " + check_result.reason + ". Aborted.", "fatal");
remove_files([ temp_config, runtime_log ]);
exit(1);
}
if (populate_nft && !module_success([
LIB_DIR + "/nft/apply.uc",
"nft-populate-runtime-sets-from-uci",
"1",
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
])) {
log_message("Failed to update nftables runtime sets from the generated sing-box configuration. Aborted.", "fatal");
remove_files([ temp_config, runtime_log ]);
exit(1);
}
if (!save_config_file(temp_config, config_path)) {
remove_file(runtime_log);
exit(1);
}
remove_file(runtime_log);
print(deferred_sections, "\n");
}
let mode = ARGV[0] || "";
if (mode == "configure-service")
configure_service();
else if (mode == "init-config")
init_config(arg_bool(ARGV[1] || "1"), arg_bool(ARGV[2] || "0"), arg_bool(ARGV[3] || "0"));
else if (mode == "save-config-file-fixture")
exit(save_config_file(ARGV[1] || "", ARGV[2] || "") ? 0 : 1);
else if (mode == "check-config-fixture") {
let result = sing_box_check(ARGV[1] || "", ARGV[2] || "");
if (result.reason != "")
print(result.reason, "\n");
exit(result.status);
}
else if (mode == "generator-failure-reason-fixture")
print(generator_failure_reason(ARGV[1] || "", int(ARGV[2] || "1")), "\n");
else if (mode == "patch-dns-config")
patch_dns_config(ARGV[1] || "");
else if (mode == "restore-dns-config")
exit(restore_dns_config(ARGV[1] || "") ? 0 : 1);
else if (mode == "managed-service-installed")
exit(managed_service_installed() ? 0 : 1);
else if (mode == "remove-managed-service-script")
remove_managed_service_script();
else if (mode == "prepare-service-disabled")
prepare_service_disabled();
else if (mode == "service-proxy-address")
print(service_proxy_address(uci_settings(), ARGV[1] || "lists"), "\n");
else if (mode == "service-listen-address") {
let address = service_listen_address_value(uci_settings());
if (address == "")
exit(1);
print(address, "\n");
}
else if (mode == "device-ipv4-address") {
let address = device_ipv4_address_value(ARGV[1]);
if (address == "")
exit(1);
print(address, "\n");
}
else if (mode == "ip-addr-first-inet4")
print(ip_addr_first_inet4(fs.readfile("/dev/stdin")), "\n");
else if (mode == "version")
print(sing_box_version(), "\n");
else if (mode == "version-output")
print(sing_box_version_output());
else if (mode == "version-from-output")
print(first_line_last_field(fs.readfile("/dev/stdin")), "\n");
else if (mode == "read-version-state")
print(sing_box_version_state(), "\n");
else if (mode == "write-version-state")
exit(sing_box_write_version_state(ARGV[1]) ? 0 : 1);
else if (mode == "clear-version-state")
exit(sing_box_clear_version_state() ? 0 : 1);
else if (mode == "restore-version-state")
exit(sing_box_restore_version_state(ARGV[1]) ? 0 : 1);
else if (mode == "read-variant-marker")
print(sing_box_variant_marker(), "\n");
else if (mode == "write-variant-marker")
exit(sing_box_write_variant_marker(ARGV[1]) ? 0 : 1);
else if (mode == "clear-variant-marker")
exit(sing_box_clear_variant_marker() ? 0 : 1);
else if (mode == "restore-variant-marker")
exit(sing_box_restore_variant_marker(ARGV[1]) ? 0 : 1);
else if (mode == "marker-is")
exit(sing_box_marker_is(ARGV[1]) ? 0 : 1);
else if (mode == "is-extended")
exit(sing_box_is_extended(ARGV[1]) ? 0 : 1);
else if (mode == "is-lx")
exit(sing_box_is_lx(ARGV[1]) ? 0 : 1);
else if (mode == "is-tiny")
exit(sing_box_is_tiny(ARGV[1], ARGV[2]) ? 0 : 1);
else if (mode == "supports-tailscale")
exit(sing_box_supports_tailscale(ARGV[1], ARGV[2]) ? 0 : 1);
else if (mode == "variant")
print(sing_box_variant(), "\n");
else {
warn("Usage: singbox/runtime.uc <operation> ...\n");
exit(1);
}