Files
NeoTachyon/tachyon/files/usr/lib/components/updates.uc
T

3421 lines
122 KiB
Ucode
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env ucode
let fs = require("fs");
let common = require("core.common");
let helpers = require("core.helpers");
let uci_core = require("core.uci");
let connections = require("config.connections");
function core_url_module_or_null() {
try {
return require("core.url");
} catch (e) {
return null;
}
}
const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon";
const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon";
const BIN_PATH = getenv("TACHYON_BIN") || "/usr/bin/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 LIST_UPDATE_STATE_FILE = getenv("TACHYON_LIST_UPDATE_STATE_FILE") || RUNTIME_STATE_DIR + "/list-update.timestamp";
const LIST_UPDATE_PID_FILE = getenv("TACHYON_LIST_UPDATE_PID_FILE") || "/var/run/tachyon_list_update.pid";
const SUBSCRIPTION_UPDATE_STATE_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR") || RUNTIME_STATE_DIR + "/subscription-update";
const SUBSCRIPTION_JOB_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_JOB_DIR") || "/var/run/tachyon/subscription-update-jobs";
const SUBSCRIPTION_UPDATE_LOCK_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_LOCK_DIR") || RUNTIME_STATE_DIR + "/subscription-update.lock";
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 RELOAD_STATE_FILE = getenv("TACHYON_RELOAD_STATE_FILE") || RUNTIME_STATE_DIR + "/reload-state";
const RELOAD_STATE_FORMAT = getenv("TACHYON_RELOAD_STATE_FORMAT") || "1";
const RULE_CONDITION_CACHE_DIR = getenv("TACHYON_RULE_CONDITION_CACHE_DIR") || RUNTIME_STATE_DIR + "/rule-condition-cache";
const RELOAD_LOCK_DIR = getenv("TACHYON_RELOAD_LOCK_DIR") || "/var/run/tachyon.reload.lock";
const SERVICE_INIT = getenv("TACHYON_SERVICE_INIT") || "/etc/init.d/tachyon";
const PRIORITY_UC = getenv("TACHYON_PRIORITY_UC") || LIB_DIR + "/singbox/priority.uc";
const DNS_FAILOVER_UC = getenv("TACHYON_DNS_FAILOVER_UC") || LIB_DIR + "/singbox/dns_failover.uc";
const COMPONENT_JOB_DIR = getenv("UPDATES_JOB_DIR") || getenv("TACHYON_UI_COMPONENT_ACTION_DIR") || "/var/run/tachyon/component-actions";
const COMPONENT_UPDATE_CHECK_CACHE_DIR = getenv("TACHYON_COMPONENT_UPDATE_CHECK_CACHE_DIR") || RUNTIME_STATE_DIR + "/component-update-checks";
const COMPONENT_UPDATE_CHECK_STATE_FILE = getenv("TACHYON_COMPONENT_UPDATE_CHECK_STATE_FILE") || RUNTIME_STATE_DIR + "/component-update-check.timestamp";
const COMPONENT_UPDATE_CHECK_LOCK_DIR = getenv("TACHYON_COMPONENT_UPDATE_CHECK_LOCK_DIR") || RUNTIME_STATE_DIR + "/component-update-check.lock";
const COMPONENT_JOB_FINISHED_TTL_MINUTES = getenv("UPDATES_JOB_FINISHED_TTL_MINUTES") || "60";
const COMPONENT_JOB_ORPHAN_OUTPUT_TTL_MINUTES = getenv("UPDATES_JOB_ORPHAN_OUTPUT_TTL_MINUTES") || "60";
const COMPONENT_JOB_STALE_GRACE_SECONDS = getenv("UPDATES_JOB_STALE_GRACE_SECONDS") || getenv("TACHYON_UI_ACTION_STALE_GRACE_SECONDS") || "15";
const SUBSCRIPTION_JOB_FINISHED_TTL_MINUTES = getenv("TACHYON_SUBSCRIPTION_UPDATE_JOB_FINISHED_TTL_MINUTES") || "60";
const SUBSCRIPTION_JOB_ORPHAN_OUTPUT_TTL_MINUTES = getenv("TACHYON_SUBSCRIPTION_UPDATE_JOB_ORPHAN_OUTPUT_TTL_MINUTES") || "60";
const SUBSCRIPTION_JOB_STALE_GRACE_SECONDS = getenv("TACHYON_UI_ACTION_STALE_GRACE_SECONDS") || "15";
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_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_DISCORD_SET_NAME = getenv("NFT_DISCORD_SET_NAME") || "tachyon_discord_subnets";
const NFT_DISCORD6_SET_NAME = getenv("NFT_DISCORD6_SET_NAME") || "tachyon_discord_subnets6";
const NFT_INTERFACE_SET_NAME = getenv("NFT_INTERFACE_SET_NAME") || "tachyon_interfaces";
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 GITHUB_RAW_URL = getenv("GITHUB_RAW_URL") || "https://raw.githubusercontent.com/itdoginfo/allow-domains/main";
const BUILTIN_SUBNET_URLS = {
twitter: [ getenv("SUBNETS_TWITTER") || GITHUB_RAW_URL + "/Subnets/IPv4/twitter.lst", getenv("SUBNETS_TWITTER6") || GITHUB_RAW_URL + "/Subnets/IPv6/twitter.lst" ],
meta: [ getenv("SUBNETS_META") || GITHUB_RAW_URL + "/Subnets/IPv4/meta.lst", getenv("SUBNETS_META6") || GITHUB_RAW_URL + "/Subnets/IPv6/meta.lst" ],
discord: [ getenv("SUBNETS_DISCORD") || GITHUB_RAW_URL + "/Subnets/IPv4/discord.lst", getenv("SUBNETS_DISCORD6") || GITHUB_RAW_URL + "/Subnets/IPv6/discord.lst" ],
roblox: [ getenv("SUBNETS_ROBLOX") || GITHUB_RAW_URL + "/Subnets/IPv4/roblox.lst" ],
telegram: [ getenv("SUBNETS_TELEGRAM") || GITHUB_RAW_URL + "/Subnets/IPv4/telegram.lst", getenv("SUBNETS_TELEGRAM6") || GITHUB_RAW_URL + "/Subnets/IPv6/telegram.lst" ],
cloudflare: [ getenv("SUBNETS_CLOUDFLARE") || GITHUB_RAW_URL + "/Subnets/IPv4/cloudflare.lst", getenv("SUBNETS_CLOUDFLARE6") || GITHUB_RAW_URL + "/Subnets/IPv6/cloudflare.lst" ],
hetzner: [ getenv("SUBNETS_HETZNER") || GITHUB_RAW_URL + "/Subnets/IPv4/hetzner.lst", getenv("SUBNETS_HETZNER6") || GITHUB_RAW_URL + "/Subnets/IPv6/hetzner.lst" ],
ovh: [ getenv("SUBNETS_OVH") || GITHUB_RAW_URL + "/Subnets/IPv4/ovh.lst", getenv("SUBNETS_OVH6") || GITHUB_RAW_URL + "/Subnets/IPv6/ovh.lst" ],
digitalocean: [ getenv("SUBNETS_DIGITALOCEAN") || GITHUB_RAW_URL + "/Subnets/IPv4/digitalocean.lst", getenv("SUBNETS_DIGITALOCEAN6") || GITHUB_RAW_URL + "/Subnets/IPv6/digitalocean.lst" ],
cloudfront: [ getenv("SUBNETS_CLOUDFRONT") || GITHUB_RAW_URL + "/Subnets/IPv4/cloudfront.lst", getenv("SUBNETS_CLOUDFRONT6") || GITHUB_RAW_URL + "/Subnets/IPv6/cloudfront.lst" ]
};
let rule_config = null;
let routing_rulesets_module_value = null;
let singbox_rulesets_module_value = null;
function routing_rulesets_module() {
if (routing_rulesets_module_value == null)
routing_rulesets_module_value = require("routing.rulesets");
return routing_rulesets_module_value;
}
function singbox_rulesets_module() {
if (singbox_rulesets_module_value == null)
singbox_rulesets_module_value = require("singbox.rulesets");
return singbox_rulesets_module_value;
}
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 = common.command_success;
let command_success_from_args = common.command_success_from_args;
let read_json_file = common.read_json_file;
let write_json = common.write_json;
let write_file = common.write_file;
let remove_file = common.remove_file;
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 list_option = common.list_option;
function read_stdin() {
let data = fs.readfile("/dev/stdin");
return data == null ? "" : data;
}
function command_env(assignments) {
let parts = [];
for (let name, value in assignments)
push(parts, name + "=" + shell_quote(value));
return join(" ", parts);
}
function file_md5(path) {
path = as_string(path);
if (path == "" || fs.stat(path) == null)
return "";
let fields = split(trim(command_output(command_from_args([ "md5sum", path ]))), /[ \t\r\n]+/);
return length(fields) > 0 ? as_string(fields[0]) : "";
}
function command_exists(name) {
return command_success_from_args([ "command", "-v", name ]);
}
// curl --dns-servers requires libcurl built with c-ares (not available on all OpenWrt builds).
// Fall back to --doh-url https://1.1.1.1/dns-query if --dns-servers is unsupported.
let _curl_has_dns_servers = null;
let _curl_has_doh_url = null;
function curl_push_dns_servers(args) {
if (_curl_has_dns_servers === null)
_curl_has_dns_servers = (system("curl --dns-servers 8.8.8.8 -V >/dev/null 2>&1") == 0);
if (_curl_has_dns_servers) {
push(args, "--dns-servers", "8.8.8.8,1.1.1.1");
return;
}
if (_curl_has_doh_url === null)
_curl_has_doh_url = (system("curl --doh-url https://1.1.1.1/dns-query -V >/dev/null 2>&1") == 0);
if (_curl_has_doh_url) {
push(args, "--doh-url", "https://1.1.1.1/dns-query");
}
}
function now_seconds() {
return int(clock()[0]);
}
function ensure_dir(path) {
return command_success_from_args([ "mkdir", "-p", path ]);
}
function log_message(message, level) {
level = as_string(level || "info");
command_success_from_args([ "logger", "-t", "tachyon", "[" + level + "] " + as_string(message) ]);
}
function json_text(value) {
return sprintf("%J", value) + "\n";
}
function write_state_file(path, value) {
path = as_string(path);
let stamp = clock();
let tmp_path = sprintf("%s.%d.%d.tmp", path, stamp[0], stamp[1]);
if (!write_file(tmp_path, json_text(value))) {
fs.unlink(tmp_path);
return false;
}
if (!fs.rename(tmp_path, path)) {
fs.unlink(tmp_path);
return false;
}
return true;
}
function file_first_line(path) {
let data = fs.readfile(path);
if (data == null)
exit(1);
let newline = index(data, "\n");
print(newline >= 0 ? substr(data, 0, newline) : data, "\n");
}
function file_first_line_value(path) {
let data = fs.readfile(path);
if (data == null)
return "";
let newline = index(data, "\n");
return 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 arg_number(value) {
value = as_string(value);
if (value == "" || match(value, /[^0-9-]/))
return 0;
return int(value);
}
function text_first_chars(value, max_chars) {
value = as_string(value);
max_chars = int(max_chars || "0", 10) || 0;
return max_chars > 0 && length(value) > max_chars ? substr(value, 0, max_chars) : value;
}
function file_last_nonblank_line_value(path, fallback, max_chars) {
let data = fs.readfile(path);
let result = "";
if (data != null) {
for (let line in split(as_string(data), "\n"))
if (match(line, /^[[:space:]]*$/) == null)
result = line;
}
if (result == "")
result = as_string(fallback);
return text_first_chars(result, max_chars);
}
function section_name(section) {
return as_string(object_or_empty(section)[".name"]);
}
function file_exists_value(path) {
return fs.stat(as_string(path)) != null;
}
function file_nonempty(path) {
return helpers.file_is_usable(path, 0);
}
function copy_file(source, target) {
let data = fs.readfile(as_string(source));
if (data == null)
return false;
return write_file(target, data);
}
function parent_dir(path) {
path = as_string(path);
let slash = rindex(path, "/");
return slash >= 0 ? substr(path, 0, slash) : "";
}
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 remove_files(paths) {
for (let path in paths)
if (as_string(path) != "")
remove_file(path);
}
function owner_pid() {
let pid = trim(command_output_from_args([ "sh", "-c", "echo $PPID" ]));
return match(pid, /^[0-9]+$/) != null ? pid : "0";
}
function runtime_pid_running(pid) {
pid = as_string(pid);
return match(pid, /^[0-9]+$/) != null && command_success_from_args([ "kill", "-0", pid ]);
}
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 list_option_values(section, key) {
return whitespace_items(object_or_empty(section)[key]);
}
function uci_sections(type_name) {
return uci_core.section_objects(CONFIG_NAME, as_string(type_name));
}
function uci_settings() {
return object_or_empty(uci_core.get_all(CONFIG_NAME, "settings"));
}
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_status(args) {
return command_status(module_command(args));
}
function module_success(args) {
return module_status(args) == 0;
}
function module_output(args) {
return command_output(module_command(args));
}
function nft_module_success(args) {
let command_args = [ LIB_DIR + "/nft/apply.uc" ];
for (let arg in args)
push(command_args, arg);
return module_success(command_args);
}
function service_state_success(args) {
let command_args = [ LIB_DIR + "/service/state.uc" ];
for (let arg in args)
push(command_args, arg);
return module_success(command_args);
}
function acquire_runtime_lock(lock_dir, wait) {
return service_state_success([
wait ? "acquire-runtime-dir-lock-wait" : "acquire-runtime-dir-lock",
lock_dir,
owner_pid(),
"300"
]);
}
function release_runtime_lock(lock_dir) {
service_state_success([ "release-runtime-dir-lock", lock_dir ]);
}
function unsigned_number(value) {
value = as_string(value);
if (value == "" || match(value, /[^0-9]/) != null)
return null;
return int(value);
}
function update_due_status(now_value, last_run_value, interval_value) {
let now = unsigned_number(now_value);
let interval = unsigned_number(interval_value);
if (now == null || interval == null || interval <= 0)
return 2;
let last_run = unsigned_number(last_run_value);
if (last_run == null)
last_run = 0;
if (last_run > 0 && now - last_run < interval)
return 1;
return 0;
}
function update_is_due(now_value, last_run_value, interval_value) {
exit(update_due_status(now_value, last_run_value, interval_value));
}
function duration_to_seconds_value(value) {
let rest = as_string(value);
if (rest == "")
return null;
let total = 0.0;
let multipliers = {
ns: 0.000000001,
us: 0.000001,
ms: 0.001,
s: 1,
m: 60,
h: 3600,
d: 86400
};
while (rest != "") {
let matched = match(rest, /^([0-9]+(\.[0-9]+)?)(ns|us|ms|s|m|h|d)/);
if (!matched)
return null;
let token = as_string(matched[0]);
let amount = matched[1] * 1;
let unit = matched[3];
total = total + amount * multipliers[unit];
rest = substr(rest, length(token));
}
if (total <= 0)
return null;
return int(total + 0.5);
}
function duration_to_seconds(value) {
let seconds = duration_to_seconds_value(value);
if (seconds == null)
exit(1);
print(seconds, "\n");
}
function due_check_cron_schedule_text(value) {
let seconds = arg_number(value);
if (seconds <= 60)
return "* * * * *";
if (seconds % 86400 == 0)
return "0 0 * * *";
if (seconds % 3600 == 0) {
let hours = seconds / 3600;
if (hours >= 1 && hours <= 23)
return hours == 1 ? "0 * * * *" : "0 */" + hours + " * * *";
}
if (seconds % 60 == 0) {
let minutes = seconds / 60;
if (minutes >= 1 && minutes <= 59)
return minutes == 1 ? "* * * * *" : "*/" + minutes + " * * * *";
}
return "* * * * *";
}
function due_check_cron_schedule(value) {
print(due_check_cron_schedule_text(value), "\n");
}
function update_cron_job(interval, command, bin, marker) {
let seconds = duration_to_seconds_value(interval);
if (seconds == null)
exit(1);
print(due_check_cron_schedule_text(seconds), " ", as_string(bin), " ", as_string(command), " ", as_string(marker), "\n");
}
function subscription_update_cron_job(min_interval_seconds, bin, marker) {
let seconds = arg_number(min_interval_seconds);
if (seconds <= 0)
exit(1);
print(due_check_cron_schedule_text(seconds), " ", as_string(bin), " subscription_update_if_due ", as_string(marker), "\n");
}
function subscription_update_interval_plan() {
let min_interval = 0;
for (let line in split(read_stdin(), "\n")) {
line = as_string(line);
if (line == "")
continue;
let separator = index(line, "\t");
let section = separator >= 0 ? substr(line, 0, separator) : line;
let interval = separator >= 0 ? substr(line, separator + 1) : "";
if (interval == "")
continue;
let seconds = duration_to_seconds_value(interval);
if (seconds == null) {
print("error\t", section, "\t", interval, "\n");
continue;
}
if (min_interval == 0 || seconds < min_interval)
min_interval = seconds;
}
print("min\t", min_interval, "\n");
}
function list_has_remote_references(value) {
for (let item in split(as_string(value), /[ \t\r\n]+/)) {
if (match(item, /^https?:\/\//) != null)
return true;
}
return false;
}
function rule_has_list_update_source(section) {
section = object_or_empty(section);
if (rule_config == null)
rule_config = require("config.rule");
if (!bool_option(section, "enabled", true))
return false;
if (option(section, "action", "") == "dns")
return list_has_remote_references(option(section, "domain_ip_lists", ""));
if (option(section, "action", "") == "hosts")
return false;
return (
rule_config.has_community_subnet_list(connections.community_lists_value(section)) ||
option(section, "remote_domain_lists", "") != "" ||
option(section, "remote_subnet_lists", "") != "" ||
length(connections.rule_sets_with_subnets(section)) > 0 ||
list_has_remote_references(option(section, "domain_ip_lists", ""))
);
}
function has_list_update_sources(sections, settings) {
if (settings == null)
settings = uci_settings();
if (bool_option(settings, "download_all_presets", false))
return true;
for (let section in sections)
if (rule_has_list_update_source(section))
return true;
return false;
}
function settings_update_interval(settings) {
settings = object_or_empty(settings);
if (!bool_option(settings, "list_update_enabled", true))
return "";
let value = option(settings, "update_interval", "1d");
return value != "" ? value : "1d";
}
function settings_component_update_check_interval(settings) {
settings = object_or_empty(settings);
if (!bool_option(settings, "component_update_check_enabled", false))
return "";
let value = option(settings, "component_update_check_interval", "1d");
return value != "" ? value : "1d";
}
function normalize_component_name(component) {
component = as_string(component);
if (component == "sing-box" || component == "singbox")
return "sing_box";
if (component == "tachyon")
return "tachyon";
return component;
}
function valid_component_name(component) {
component = normalize_component_name(component);
return component == "tachyon" || component == "sing_box" || component == "zapret" ||
component == "zapret2" || component == "byedpi" || component == "tailscale";
}
function settings_component_auto_update_enabled(settings) {
settings = object_or_empty(settings);
return bool_option(settings, "component_auto_update_enabled", false);
}
function settings_component_auto_update_mode(settings) {
settings = object_or_empty(settings);
let mode = option(settings, "component_auto_update_mode", "immediate");
return (mode == "schedule" || mode == "scheduled") ? "schedule" : "immediate";
}
function settings_component_auto_update_time(settings) {
settings = object_or_empty(settings);
let time = trim(as_string(option(settings, "component_auto_update_time", "04:00")));
return match(time, /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/) != null ? time : "04:00";
}
function settings_component_auto_update_targets(settings) {
settings = object_or_empty(settings);
let raw = list_option(settings, "component_auto_update_targets");
if (length(raw) == 0 || (length(raw) == 1 && raw[0] == "all"))
return [ "all" ];
return raw;
}
function component_is_auto_update_target(settings, component) {
let targets = settings_component_auto_update_targets(settings);
component = normalize_component_name(component);
for (let target in targets) {
if (target == "all" || normalize_component_name(target) == component)
return true;
}
return false;
}
function section_subscription_update_interval(section) {
let result = "";
let result_seconds = 0;
for (let entry in connections.subscription_urls(section)) {
if (!connections.subscription_update_enabled(section, entry))
continue;
let value = connections.subscription_update_interval(section, entry);
if (value == "")
value = "1h";
let seconds = duration_to_seconds_value(value);
if (result == "" || (seconds != null && (result_seconds == 0 || seconds < result_seconds))) {
result = value;
result_seconds = seconds == null ? 0 : seconds;
}
}
return result;
}
function section_is_subscription_proxy(section) {
return bool_option(section, "enabled", true) &&
connections.is_connections_action(option(section, "action", "")) &&
length(connections.subscription_urls(section)) > 0;
}
function line_contains_any_marker(line, markers) {
line = as_string(line);
for (let marker in markers) {
marker = as_string(marker);
if (marker != "" && index(line, marker) >= 0)
return true;
}
return false;
}
function filter_cron_markers_text(data, markers) {
data = as_string(data);
if (data == "")
return "";
let lines = split(data, "\n");
let has_trailing_newline = substr(data, length(data) - 1) == "\n";
let result = "";
for (let i = 0; i < length(lines); i++) {
let line = as_string(lines[i]);
if (i == length(lines) - 1 && has_trailing_newline && line == "")
continue;
if (line_contains_any_marker(line, markers))
continue;
result += line + "\n";
}
return result;
}
function filter_cron_markers(markers) {
print(filter_cron_markers_text(read_stdin(), markers));
}
function cron_refresh_plan_rows(settings, sections, bin, list_marker, subscription_marker, component_marker) {
let status = 0;
let rows = [];
if (has_list_update_sources(sections, settings)) {
let interval = settings_update_interval(settings);
if (interval == "") {
push(rows, "list-disabled");
}
else {
let seconds = duration_to_seconds_value(interval);
if (seconds == null) {
push(rows, "list-error\t" + as_string(interval));
status = 1;
}
else {
push(rows, "list\t" + due_check_cron_schedule_text(seconds) + " " + as_string(bin) + " list_update_if_due " + as_string(list_marker));
}
}
}
let min_interval = 0;
let subscription_source_count = 0;
for (let section in sections) {
section = object_or_empty(section);
if (!section_is_subscription_proxy(section))
continue;
let interval = section_subscription_update_interval(section);
if (interval == "")
continue;
subscription_source_count++;
let seconds = duration_to_seconds_value(interval);
if (seconds == null) {
push(rows, "subscription-error\t" + section_name(section) + "\t" + as_string(interval));
continue;
}
if (min_interval == 0 || seconds < min_interval)
min_interval = seconds;
}
if (subscription_source_count > 0) {
if (min_interval <= 0)
status = 1;
else
push(rows, "subscription\t" + due_check_cron_schedule_text(min_interval) + " " + as_string(bin) + " subscription_update_if_due " + as_string(subscription_marker));
}
let component_interval = settings_component_update_check_interval(settings);
if (component_interval != "") {
let component_seconds = duration_to_seconds_value(component_interval);
if (component_seconds == null) {
push(rows, "component-error\t" + as_string(component_interval));
status = 1;
}
else {
push(rows, "component\t" + due_check_cron_schedule_text(component_seconds) + " " + as_string(bin) + " component_updates_if_due " + as_string(component_marker));
}
}
if (settings_component_auto_update_enabled(settings) &&
settings_component_auto_update_mode(settings) == "schedule") {
let time_str = settings_component_auto_update_time(settings);
let parts = split(time_str, ":");
let hour = int(parts[0] || "4");
let minute = int(parts[1] || "0");
let cron_time = sprintf("%d %d * * *", minute, hour);
push(rows, "component-auto-update\t" + cron_time + " " + as_string(bin) + " component_auto_update_apply " + as_string(component_marker));
}
return {
status,
rows
};
}
function print_cron_refresh_plan(result) {
result = object_or_empty(result);
for (let row in result.rows)
print(row, "\n");
exit(int(result.status || 0));
}
function cron_refresh_plan(settings, sections, bin, list_marker, subscription_marker, component_marker) {
print_cron_refresh_plan(cron_refresh_plan_rows(settings, sections, bin, list_marker, subscription_marker, component_marker));
}
// Older releases wrote tachyon cron jobs WITHOUT the marker comment; those
// lines survive marker-based filtering, stack up across upgrades and make
// crond run the same job twice per tick. Drop unmarked lines that invoke a
// tachyon-owned cron command via our binary.
function strip_unmarked_tachyon_cron_lines(data, bin, markers) {
data = as_string(data);
if (data == "")
return "";
let escaped_bin = replace(as_string(bin), /([^0-9A-Za-z_])/g, "\\$1");
let pattern = "^[^#\\n]*" + escaped_bin + "[ \\t]+(list_update|subscription_update|component_updates_if_due|component_auto_update_apply)([ \\t]|$)";
let lines = split(data, "\n");
let has_trailing_newline = substr(data, length(data) - 1) == "\n";
let result = "";
for (let i = 0; i < length(lines); i++) {
let line = as_string(lines[i]);
if (i == length(lines) - 1 && has_trailing_newline && line == "")
continue;
if (line_contains_any_marker(line, markers))
continue;
if (match(line, pattern) != null)
continue;
result += line + "\n";
}
return result;
}
function cron_refresh_apply_result(settings, sections, existing_crontab, bin, list_marker, subscription_marker, component_marker) {
let plan = cron_refresh_plan_rows(settings, sections, bin, list_marker, subscription_marker, component_marker);
let filtered_crontab = filter_cron_markers_text(existing_crontab, [ list_marker, subscription_marker, component_marker ]);
let cron_jobs = "";
let logs = [ { level: "info", message: "The cron job removed" } ];
let tab = "\t";
for (let row in plan.rows) {
let line = as_string(row);
let separator = index(line, tab);
let type = separator >= 0 ? substr(line, 0, separator) : line;
let rest = separator >= 0 ? substr(line, separator + 1) : "";
if (type == "list") {
cron_jobs += rest + "\n";
push(logs, { level: "info", message: "The cron job has been created: " + rest });
}
else if (type == "list-disabled") {
push(logs, { level: "info", message: "Remote list auto-update is disabled" });
}
else if (type == "list-error") {
push(logs, { level: "error", message: "Invalid update_interval value: " + rest });
}
else if (type == "subscription") {
cron_jobs += rest + "\n";
push(logs, { level: "info", message: "The subscription cron job has been created: " + rest });
}
else if (type == "subscription-error") {
let section_separator = index(rest, tab);
let section = section_separator >= 0 ? substr(rest, 0, section_separator) : rest;
let interval = section_separator >= 0 ? substr(rest, section_separator + 1) : "";
push(logs, { level: "error", message: "Invalid subscription_update_interval value for rule '" + section + "': " + interval });
}
else if (type == "component") {
cron_jobs += rest + "\n";
push(logs, { level: "info", message: "The component update check cron job has been created: " + rest });
}
else if (type == "component-auto-update") {
cron_jobs += rest + "\n";
push(logs, { level: "info", message: "The component auto-update cron job has been created: " + rest });
}
else if (type == "component-error") {
push(logs, { level: "error", message: "Invalid component_update_check_interval value: " + rest });
}
}
return {
status: int(plan.status || 0),
crontab: strip_unmarked_tachyon_cron_lines(
filtered_crontab, bin, [ list_marker, subscription_marker, component_marker ]
) + (int(plan.status || 0) == 0 ? cron_jobs : ""),
logs
};
}
function write_crontab_text(text) {
let tmp = trim(command_output_from_args([ "mktemp" ]));
if (tmp == "")
return false;
if (fs.writefile(tmp, as_string(text)) == null) {
fs.unlink(tmp);
return false;
}
let ok = command_success_from_args([ "crontab", tmp ]);
fs.unlink(tmp);
return ok;
}
// Older releases wrote tachyon cron jobs WITHOUT the marker comment; those
// lines survive marker-based filtering, stack up across upgrades and make
// crond run the same job twice per tick. Drop unmarked lines that invoke a
// tachyon-owned cron command via our binary.
function log_cron_apply_result(result) {
for (let item in array_or_empty(object_or_empty(result).logs))
log_message(item.message, item.level);
}
function remove_cron_jobs(list_marker, subscription_marker, component_marker) {
let crontab = command_output_from_args([ "crontab", "-l" ]);
let markers = [ list_marker, subscription_marker, component_marker ];
let result = {
crontab: strip_unmarked_tachyon_cron_lines(
filter_cron_markers_text(crontab, markers), BIN_PATH, markers),
logs: [ { level: "info", message: "The cron job removed" } ]
};
if (!write_crontab_text(result.crontab))
exit(1);
log_cron_apply_result(result);
}
function refresh_cron_from_sources(settings, sections, bin, list_marker, subscription_marker, component_marker) {
let result = cron_refresh_apply_result(
settings,
sections,
command_output_from_args([ "crontab", "-l" ]),
bin,
list_marker,
subscription_marker,
component_marker
);
if (!write_crontab_text(result.crontab))
exit(1);
log_cron_apply_result(result);
exit(result.status);
}
function list_update_due_status(settings, timestamp_path, now) {
let interval = settings_update_interval(settings);
if (interval == "")
exit(1);
let seconds = duration_to_seconds_value(interval);
if (seconds == null) {
print("error\t", interval, "\n");
exit(2);
}
exit(update_due_status(now, file_first_line_value(timestamp_path), seconds));
}
function subscription_update_section_due_status(section, timestamp_path, now) {
section = object_or_empty(section);
let interval = section_subscription_update_interval(section);
if (interval == "")
exit(1);
let seconds = duration_to_seconds_value(interval);
if (seconds == null) {
print("error\t", interval, "\n");
exit(2);
}
exit(update_due_status(now, file_first_line_value(timestamp_path), seconds));
}
function stdin_first_ipv4_line() {
for (let line in split(read_stdin(), "\n")) {
line = as_string(line);
if (match(line, /^[0-9]+\./) != null) {
print(line, "\n");
return;
}
}
}
function json_length(path) {
let value = read_json_file(path);
if (type(value) == "array" || type(value) == "object")
print(length(value), "\n");
else
print("0\n");
}
function job_pid(path) {
let value = read_json_file(path);
if (type(value) == "object" && value.pid != null)
print(as_string(value.pid), "\n");
}
function subscription_job_state_path(job_dir, job_id) {
job_dir = as_string(job_dir);
job_id = as_string(job_id);
if (job_id == "" || job_id == "." || job_id == ".." || match(job_id, /[^A-Za-z0-9._-]/) != null)
exit(1);
print(job_dir, "/", job_id, ".json\n");
}
function subscription_job_json_response(success, job_id, message) {
write_json({
success: arg_bool(success),
job_id: as_string(job_id),
message: as_string(message)
});
}
function subscription_running_job_state_value(section, source_index, started_at) {
return {
success: true,
running: true,
kind: "subscription",
message: "Subscription update is running",
section: as_string(section),
source_index: as_string(source_index),
pid: null,
started_at: arg_number(started_at),
updated_at: null,
exit_code: null
};
}
function subscription_running_job_state(section, source_index, started_at) {
write_json(subscription_running_job_state_value(section, source_index, started_at));
}
function job_started_at_within_grace(value, now, grace_seconds) {
let started_at = arg_number(value);
now = arg_number(now);
grace_seconds = arg_number(grace_seconds);
if (started_at <= 0 || now <= 0)
return false;
return now - started_at < grace_seconds;
}
function job_pid_valid(pid) {
pid = as_string(pid);
return pid != "" && match(pid, /^[0-9]+$/) != null;
}
function set_subscription_running_job_pid(path, pid) {
pid = as_string(pid);
if (!job_pid_valid(pid))
return false;
let value = object_or_empty(read_json_file(path));
if (value.running === true) {
value.pid = pid;
return write_state_file(path, value);
}
return false;
}
function subscription_job_refresh_plan(path, now, grace_seconds) {
let value = read_json_file(path);
if (type(value) != "object" || value.running !== true) {
print("skip\n");
return;
}
let within_grace = job_started_at_within_grace(value.started_at, now, grace_seconds);
let pid = as_string(value.pid || "");
if (!job_pid_valid(pid)) {
print(within_grace ? "skip\n" : "stale\n");
return;
}
print("pid\t", pid, "\t", within_grace ? "0" : "1", "\n");
}
function subscription_finished_job_state_value(success, message, exit_code, updated_at, section, source_index, started_at) {
return {
success: arg_bool(success),
running: false,
kind: "subscription",
message: as_string(message),
section: as_string(section),
source_index: as_string(source_index),
pid: null,
started_at: arg_number(started_at),
exit_code: arg_number(exit_code),
updated_at: arg_number(updated_at)
};
}
function subscription_finished_job_state(success, message, exit_code, updated_at, section, source_index, started_at) {
write_json(subscription_finished_job_state_value(success, message, exit_code, updated_at, section, source_index, started_at));
}
function subscription_stale_job_state_value(updated_at, section, source_index, started_at) {
return {
success: false,
running: false,
kind: "subscription",
message: "Subscription update worker exited unexpectedly",
section: as_string(section),
source_index: as_string(source_index),
pid: null,
started_at: arg_number(started_at),
exit_code: null,
updated_at: arg_number(updated_at)
};
}
function subscription_stale_job_state(updated_at, section, source_index, started_at) {
write_json(subscription_stale_job_state_value(updated_at, section, source_index, started_at));
}
function subscription_status_error(message) {
write_json({
success: false,
running: false,
message: as_string(message),
exit_code: null
});
}
function subscription_status_error_exit(message) {
subscription_status_error(message);
exit(1);
}
function valid_subscription_job_id(job_id) {
job_id = as_string(job_id);
return job_id != "" && job_id != "." && job_id != ".." && match(job_id, /[^A-Za-z0-9._-]/) == null;
}
function subscription_job_state_path_value(job_dir, job_id) {
if (!valid_subscription_job_id(job_id))
return "";
return as_string(job_dir) + "/" + as_string(job_id) + ".json";
}
function subscription_job_id() {
let stamp = clock();
return sprintf("%d-%d", stamp[0], stamp[1]);
}
function ensure_subscription_runtime_dirs() {
let command = command_env({
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
}) + " " + command_from_args([
"ucode",
"-L", LIB_DIR,
LIB_DIR + "/subscription/cache.uc",
"ensure-runtime-dirs"
]);
return command_success(command) && ensure_dir(SUBSCRIPTION_JOB_DIR);
}
function subscription_job_output_path(job_id) {
return SUBSCRIPTION_JOB_DIR + "/" + as_string(job_id) + ".out";
}
function subscription_job_output_path_from_state(path) {
path = as_string(path);
if (length(path) >= 5 && substr(path, length(path) - 5) == ".json")
return substr(path, 0, length(path) - 5) + ".out";
return path + ".out";
}
function remove_subscription_job_state(path) {
remove_file(path);
remove_file(subscription_job_output_path_from_state(path));
}
function subscription_job_running_is(path, expected) {
let value = read_json_file(path);
let running = type(value) == "object" && value.running === true;
return running == arg_bool(expected);
}
function subscription_cleanup_jobs() {
ensure_dir(SUBSCRIPTION_JOB_DIR);
command_success_from_args([
"find",
SUBSCRIPTION_JOB_DIR,
"-type", "f",
"-name", "*.out",
"-mmin", "+" + as_string(SUBSCRIPTION_JOB_ORPHAN_OUTPUT_TTL_MINUTES),
"-delete"
]);
let old = command_output_from_args([
"find",
SUBSCRIPTION_JOB_DIR,
"-type", "f",
"-name", "*.json",
"-mmin", "+" + as_string(SUBSCRIPTION_JOB_FINISHED_TTL_MINUTES)
]);
for (let path in split(old, "\n")) {
path = trim(as_string(path));
if (path != "" && subscription_job_running_is(path, false))
remove_subscription_job_state(path);
}
}
function pid_running(pid) {
pid = as_string(pid);
return job_pid_valid(pid) && command_success_from_args([ "kill", "-0", pid ]);
}
// A bare liveness check is not enough: after a tachyon self-reinstall the
// service restart recycles the worker's pid quickly, and any new process would
// keep the job "running" forever. The pid must still belong to our worker.
function pid_is_component_worker(pid) {
pid = as_string(pid);
if (!job_pid_valid(pid))
return false;
let cmdline = fs.readfile("/proc/" + pid + "/cmdline");
return index(as_string(cmdline), "component-action-worker") >= 0;
}
function write_subscription_stale_job_state(path) {
let value = object_or_empty(read_json_file(path));
return write_state_file(path, subscription_stale_job_state_value(
now_seconds(),
value.section || "",
value.source_index || "",
value.started_at || 0
));
}
function refresh_subscription_running_job_state(path) {
let value = read_json_file(path);
if (type(value) != "object" || value.running !== true)
return;
let now = now_seconds();
let within_grace = job_started_at_within_grace(value.started_at, now, SUBSCRIPTION_JOB_STALE_GRACE_SECONDS);
let pid = as_string(value.pid || "");
if (!job_pid_valid(pid)) {
if (!within_grace)
write_subscription_stale_job_state(path);
return;
}
if (pid_running(pid))
return;
if (within_grace)
return;
command_success_from_args([ "sleep", "1" ]);
value = read_json_file(path);
if (type(value) != "object" || value.running !== true)
return;
if (pid_running(pid))
return;
write_subscription_stale_job_state(path);
}
function finish_subscription_job(path, exit_code, output_file) {
let value = object_or_empty(read_json_file(path));
let success = arg_number(exit_code) == 0;
let message = file_last_nonblank_line_value(
output_file,
success ? "Subscription update completed" : "Subscription update failed",
240
);
let ok = write_state_file(path, subscription_finished_job_state_value(
success,
message,
exit_code,
now_seconds(),
value.section || "",
value.source_index || "",
value.started_at || 0
));
remove_file(output_file);
return ok;
}
function subscription_worker_env() {
return {
TACHYON_CONFIG_NAME: CONFIG_NAME,
TACHYON_LIB: LIB_DIR,
TACHYON_BIN: BIN_PATH,
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_UPDATE_JOB_DIR: SUBSCRIPTION_JOB_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_SUBSCRIPTION_UPDATE_JOB_FINISHED_TTL_MINUTES: SUBSCRIPTION_JOB_FINISHED_TTL_MINUTES,
TACHYON_SUBSCRIPTION_UPDATE_JOB_ORPHAN_OUTPUT_TTL_MINUTES: SUBSCRIPTION_JOB_ORPHAN_OUTPUT_TTL_MINUTES,
TACHYON_UI_ACTION_STALE_GRACE_SECONDS: SUBSCRIPTION_JOB_STALE_GRACE_SECONDS
};
}
function launch_subscription_worker(args) {
let command_args = [ "ucode", "-L", LIB_DIR, LIB_DIR + "/components/updates.uc" ];
for (let arg in args)
push(command_args, arg);
let command = common.background_command_with_pid(
command_env(subscription_worker_env()) + " " + command_from_args(command_args));
return trim(command_output("sh -c " + shell_quote(command)));
}
function subscription_update_worker(state_file, output_file, section, source_index) {
let status = command_status(command_from_args([
BIN_PATH,
"subscription_update",
as_string(section),
as_string(source_index)
]) + " >" + shell_quote(output_file) + " 2>&1");
finish_subscription_job(state_file, status, output_file);
}
function subscription_update_async(section, source_index) {
if (!ensure_subscription_runtime_dirs()) {
subscription_job_json_response(false, "", "Failed to create subscription update state directory");
exit(1);
}
subscription_cleanup_jobs();
let job_id = subscription_job_id();
let state_file = subscription_job_state_path_value(SUBSCRIPTION_JOB_DIR, job_id);
if (state_file == "") {
subscription_job_json_response(false, "", "Failed to prepare subscription update job");
exit(1);
}
if (!write_state_file(state_file, subscription_running_job_state_value(section, source_index, now_seconds()))) {
subscription_job_json_response(false, "", "Failed to write subscription update state");
exit(1);
}
let output_file = subscription_job_output_path(job_id);
let pid = launch_subscription_worker([
"subscription-update-worker",
state_file,
output_file,
as_string(section),
as_string(source_index)
]);
if (pid == "" || !set_subscription_running_job_pid(state_file, pid)) {
if (pid != "")
command_success_from_args([ "kill", pid ]);
subscription_job_json_response(false, "", "Failed to write subscription update worker pid");
exit(1);
}
subscription_job_json_response(true, job_id, "Subscription update started");
}
function subscription_update_status(job_id) {
ensure_dir(SUBSCRIPTION_JOB_DIR);
subscription_cleanup_jobs();
let state_file = subscription_job_state_path_value(SUBSCRIPTION_JOB_DIR, job_id);
if (state_file == "")
subscription_status_error_exit("Invalid subscription update job id");
if (fs.stat(state_file) == null)
subscription_status_error_exit("Subscription update job was not found");
refresh_subscription_running_job_state(state_file);
print(as_string(fs.readfile(state_file)));
}
function component_job_json_response(success, job_id, message) {
write_json({
success: arg_bool(success),
job_id: as_string(job_id),
message: as_string(message)
});
}
function component_action_status_error(message) {
write_json({
success: false,
running: false,
kind: "component",
component: "unknown",
action: "status",
message: as_string(message),
current_version: "",
latest_version: "",
changed: 0,
status: "",
exit_code: null
});
}
function component_action_status_error_exit(message) {
component_action_status_error(message);
exit(1);
}
function component_running_job_state_value(component, action, started_at) {
return {
success: true,
running: true,
kind: "component",
component: as_string(component),
action: as_string(action),
message: "Component action is running",
pid: null,
started_at: arg_number(started_at),
updated_at: null,
current_version: "",
latest_version: "",
changed: 0,
status: "",
exit_code: null
};
}
function component_update_check_cache_path(component) {
component = normalize_component_name(component);
if (!valid_component_name(component))
return "";
return COMPONENT_UPDATE_CHECK_CACHE_DIR + "/" + component + ".json";
}
function component_update_check_cache_enabled() {
return settings_component_update_check_interval(uci_settings()) != "";
}
function component_update_check_result_cacheable(value) {
value = object_or_empty(value);
let status = as_string(value.status || "");
return value.success === true && as_string(value.action) == "check_update" &&
valid_component_name(value.component) &&
(status == "latest" || status == "outdated" || status == "dev" || status == "outdated_same_release");
}
function cache_component_update_check_result(value, notify_update) {
value = object_or_empty(value);
if (!component_update_check_result_cacheable(value))
return false;
let component = normalize_component_name(value.component);
let path = component_update_check_cache_path(component);
if (path == "" || !ensure_dir(COMPONENT_UPDATE_CHECK_CACHE_DIR))
return false;
let previous = object_or_empty(read_json_file(path));
let cached = {
success: true,
component,
action: "check_update",
message: as_string(value.message),
current_version: as_string(value.current_version),
latest_version: as_string(value.latest_version),
release_url: as_string(value.release_url),
changed: 0,
status: as_string(value.status),
updated_at: now_seconds()
};
// Preserve commit SHA info so UI can display "installed: abc1234, available: def5678"
if (as_string(value.current_sha) != "")
cached.current_sha = as_string(value.current_sha);
if (as_string(value.latest_sha) != "")
cached.latest_sha = as_string(value.latest_sha);
// Build fingerprints answer "is this the build the release publishes now?" for
// releases that carry no commit SHA at all.
if (as_string(value.current_build) != "")
cached.current_build = as_string(value.current_build);
if (as_string(value.latest_build) != "")
cached.latest_build = as_string(value.latest_build);
if (!write_state_file(path, cached))
return false;
// A rebuild keeps latest_version, so the build identity has to take part in the
// "is this new to us?" test or same-tag rebuilds would never be logged.
let build_key = as_string(cached.latest_sha) != "" ? as_string(cached.latest_sha) : as_string(cached.latest_build);
let previous_build_key = as_string(previous.latest_sha) != "" ? as_string(previous.latest_sha) : as_string(previous.latest_build);
if (arg_bool(notify_update) && (cached.status == "outdated" || cached.status == "outdated_same_release") &&
(as_string(previous.status) != cached.status ||
as_string(previous.latest_version) != cached.latest_version ||
previous_build_key != build_key)) {
log_message("[component-update] " + component + " " + cached.latest_version +
(cached.status == "outdated_same_release" && build_key != "" ? " (build " + build_key + ")" : ""), "info");
}
return true;
}
function update_component_check_cache_from_action(value) {
value = object_or_empty(value);
let component = normalize_component_name(value.component);
let action = as_string(value.action);
let path = component_update_check_cache_path(component);
if (path == "")
return;
if (action == "remove" && value.success === true) {
remove_file(path);
return;
}
if (!component_update_check_cache_enabled())
return;
if (action == "check_update") {
cache_component_update_check_result(value, false);
return;
}
if (value.success === true && (action == "install" || action == "reinstall" || match(action, /^install_/) != null)) {
let latest_version = as_string(value.latest_version);
let current_ver = as_string(value.current_version);
if (latest_version == "")
latest_version = current_ver;
if (current_ver == "" || current_ver != latest_version)
current_ver = latest_version;
// The action reports which build it actually put on disk. Carrying it into
// the cache is what keeps a same-tag rebuild from re-appearing as an update
// right after it was installed — and, if the install silently changed
// nothing, from being hidden behind a bare "latest".
cache_component_update_check_result({
success: true,
component,
action: "check_update",
message: "Latest version is installed",
current_version: current_ver,
latest_version,
release_url: as_string(value.release_url),
status: "latest",
current_sha: as_string(value.current_sha),
latest_sha: as_string(value.latest_sha),
current_build: as_string(value.current_build),
latest_build: as_string(value.latest_build)
}, false);
}
}
function clear_component_update_check_cache() {
for (let path in fs.glob(COMPONENT_UPDATE_CHECK_CACHE_DIR + "/*.json"))
remove_file(path);
remove_file(COMPONENT_UPDATE_CHECK_STATE_FILE);
}
function component_update_check_cache() {
let enabled = component_update_check_cache_enabled();
let results = [];
if (enabled) {
for (let component in [ "tachyon", "sing_box", "zapret", "zapret2", "byedpi" ]) {
let value = read_json_file(component_update_check_cache_path(component));
if (component_update_check_result_cacheable(value))
push(results, value);
}
}
write_json({ enabled, results });
}
function valid_component_job_id(job_id) {
job_id = as_string(job_id);
return job_id != "" && job_id != "." && job_id != ".." && match(job_id, /[^A-Za-z0-9._-]/) == null;
}
function component_job_state_path_value(job_id) {
if (!valid_component_job_id(job_id))
return "";
return COMPONENT_JOB_DIR + "/" + as_string(job_id) + ".json";
}
function component_job_id() {
let stamp = clock();
return sprintf("%d-%d", stamp[0], stamp[1]);
}
function component_job_output_path(job_id) {
return COMPONENT_JOB_DIR + "/" + as_string(job_id) + ".out";
}
function component_job_log_path(job_id) {
return COMPONENT_JOB_DIR + "/" + as_string(job_id) + ".log";
}
function component_job_output_path_from_state(path) {
let output_path = as_string(path);
if (length(path) >= 5 && substr(path, length(path) - 5) == ".json")
return substr(path, 0, length(path) - 5) + ".out";
return path + ".out";
}
function remove_component_job_state(path) {
let output_path = component_job_output_path_from_state(path);
remove_file(path);
remove_file(output_path);
remove_file(output_path + ".json");
}
function component_job_running_is(path, expected) {
let value = read_json_file(path);
let running = type(value) == "object" && value.running === true;
return running == arg_bool(expected);
}
function ensure_component_runtime_dirs() {
return ensure_dir(COMPONENT_JOB_DIR);
}
function write_component_stale_job_state(path) {
let value = object_or_empty(read_json_file(path));
value.success = false;
value.running = false;
value.kind = "component";
value.message = "Component action job is stale or the worker process exited unexpectedly";
value.changed = 0;
value.status = "";
value.exit_code = null;
value.updated_at = now_seconds();
return write_state_file(path, value);
}
function refresh_component_running_job_state(path) {
let value = read_json_file(path);
if (type(value) != "object" || value.running !== true)
return;
let now = now_seconds();
let within_grace = job_started_at_within_grace(value.started_at, now, COMPONENT_JOB_STALE_GRACE_SECONDS);
let pid = as_string(value.pid || "");
if (!job_pid_valid(pid)) {
if (!within_grace)
write_component_stale_job_state(path);
return;
}
if ((pid_running(pid) && pid_is_component_worker(pid)) || within_grace)
return;
command_success_from_args([ "sleep", "1" ]);
value = read_json_file(path);
if (type(value) != "object" || value.running !== true)
return;
if (pid_running(pid) && pid_is_component_worker(pid))
return;
write_component_stale_job_state(path);
}
function path_basename_without_suffix(path, suffix) {
let parts = split(as_string(path), "/");
let name = length(parts) > 0 ? as_string(parts[length(parts) - 1]) : "";
suffix = as_string(suffix);
if (length(name) >= length(suffix) && substr(name, length(name) - length(suffix)) == suffix)
return substr(name, 0, length(name) - length(suffix));
return name;
}
function component_cleanup_jobs() {
ensure_dir(COMPONENT_JOB_DIR);
for (let path in fs.glob(COMPONENT_JOB_DIR + "/*.json"))
refresh_component_running_job_state(path);
for (let output_path in fs.glob(COMPONENT_JOB_DIR + "/*.out")) {
let state_file = component_job_state_path_value(path_basename_without_suffix(output_path, ".out"));
if (state_file == "" || fs.stat(state_file) == null || !component_job_running_is(state_file, true)) {
remove_file(output_path);
remove_file(output_path + ".stderr");
remove_file(output_path + ".json");
}
}
command_success_from_args([
"find",
COMPONENT_JOB_DIR,
"-type", "f",
"-name", "*.out",
"-mmin", "+" + as_string(COMPONENT_JOB_ORPHAN_OUTPUT_TTL_MINUTES),
"-delete"
]);
command_success_from_args([
"find",
COMPONENT_JOB_DIR,
"-type", "f",
"-name", "*.out.stderr",
"-mmin", "+" + as_string(COMPONENT_JOB_ORPHAN_OUTPUT_TTL_MINUTES),
"-delete"
]);
command_success_from_args([
"find",
COMPONENT_JOB_DIR,
"-type", "f",
"-name", "*.log",
"-mmin", "+" + as_string(COMPONENT_JOB_ORPHAN_OUTPUT_TTL_MINUTES),
"-delete"
]);
command_success_from_args([
"find",
COMPONENT_JOB_DIR,
"-type", "f",
"-name", "*.out.json",
"-mmin", "+" + as_string(COMPONENT_JOB_ORPHAN_OUTPUT_TTL_MINUTES),
"-delete"
]);
command_success_from_args([
"find",
COMPONENT_JOB_DIR,
"-type", "f",
"-name", "*.json.*",
"-mmin", "+10",
"-delete"
]);
let old = command_output_from_args([
"find",
COMPONENT_JOB_DIR,
"-type", "f",
"-name", "*.json",
"-mmin", "+" + as_string(COMPONENT_JOB_FINISHED_TTL_MINUTES)
]);
for (let path in split(old, "\n")) {
path = trim(as_string(path));
if (path != "" && component_job_running_is(path, false))
remove_component_job_state(path);
}
}
function set_component_running_job_pid(path, pid) {
pid = as_string(pid);
if (!job_pid_valid(pid))
return false;
let value = object_or_empty(read_json_file(path));
if (value.running === true) {
value.pid = pid;
return write_state_file(path, value);
}
return false;
}
function output_json_object(path) {
let data = as_string(fs.readfile(path) || "");
try {
let value = json(data);
if (type(value) == "object")
return value;
}
catch (e) {
// A job state file written by a worker that died mid-write is not JSON
// as a whole. The line scan below recovers the last complete object in
// it, which is the point of the fallback.
}
let result = null;
for (let line in split(data, "\n")) {
line = trim(as_string(line));
let start = index(line, "{");
if (start < 0)
continue;
try {
let value = json(substr(line, start));
if (type(value) == "object")
result = value;
}
catch (e) {
// A partial line is expected here - that is what made the whole-file
// parse above fail. Skip it and keep the last complete one.
}
}
return result;
}
function component_fallback_job_state(component, action, message, exit_code, updated_at) {
return {
success: false,
running: false,
kind: "component",
component: as_string(component),
action: as_string(action),
message: as_string(message),
current_version: "",
latest_version: "",
changed: 0,
status: "",
exit_code: arg_number(exit_code),
updated_at: arg_number(updated_at)
};
}
function finish_component_job(path, component, action, exit_code, output_file) {
let updated_at = now_seconds();
let value = output_json_object(output_file);
let ok;
if (type(value) == "object") {
value.running = false;
value.kind = "component";
value.exit_code = arg_number(exit_code);
value.updated_at = updated_at;
update_component_check_cache_from_action(value);
ok = write_state_file(path, value);
}
else {
// No JSON at all means the worker really did die before printing its
// result. Its stderr, kept out of the result channel above, holds the
// reason; fall back to the result file only if stderr is empty.
let raw_output = file_last_nonblank_line_value(output_file + ".stderr", "", 240);
if (raw_output == "")
raw_output = file_last_nonblank_line_value(output_file, "Failed to execute", 240);
ok = write_state_file(path, component_fallback_job_state(component, action, raw_output, exit_code, updated_at));
}
remove_file(output_file);
remove_file(output_file + ".stderr");
remove_file(output_file + ".json");
return ok;
}
function component_worker_env() {
return {
TACHYON_CONFIG_NAME: CONFIG_NAME,
TACHYON_LIB: LIB_DIR,
TACHYON_BIN: BIN_PATH,
TACHYON_SERVICE_INIT: SERVICE_INIT,
UPDATES_JOB_DIR: COMPONENT_JOB_DIR,
UPDATES_JOB_FINISHED_TTL_MINUTES: COMPONENT_JOB_FINISHED_TTL_MINUTES,
UPDATES_JOB_ORPHAN_OUTPUT_TTL_MINUTES: COMPONENT_JOB_ORPHAN_OUTPUT_TTL_MINUTES,
UPDATES_JOB_STALE_GRACE_SECONDS: COMPONENT_JOB_STALE_GRACE_SECONDS,
TACHYON_UI_COMPONENT_ACTION_TRACKED: "1"
};
}
function launch_component_worker(args) {
let command_args = [ "ucode", "-L", LIB_DIR, LIB_DIR + "/components/updates.uc" ];
for (let arg in args)
push(command_args, arg);
let command = common.background_command_with_pid(
command_env(component_worker_env()) + " " + command_from_args(command_args));
return trim(command_output("sh -c " + shell_quote(command)));
}
function component_action_worker(state_file, output_file, component, action) {
component = normalize_component_name(component);
// stderr gets its own file instead of being merged into the result channel.
// Merging put apk/opkg warnings and init-script chatter into the same file as
// the JSON result - the whole-file parse then failed, the line scan found no
// object, and a successful action was reported to the UI as "Failed to
// execute" with the tail of a log as its message.
let worker_env = component_worker_env();
worker_env.UPDATES_JOB_LOG = substr(output_file, 0, length(output_file) - 4) + ".log";
let action_args = [
"ucode",
"-L", LIB_DIR,
LIB_DIR + "/components/action.uc",
"component-action",
as_string(component),
as_string(action)
];
if (as_string(extra || "") != "")
push(action_args, as_string(extra));
let command = command_env(worker_env) + " " +
command_from_args(action_args) + " >" + shell_quote(output_file) + " 2>" + shell_quote(output_file + ".stderr");
let status = command_status(command);
finish_component_job(state_file, component, action, status, output_file);
}
function component_action_async_job(component, action, extra) {
component = normalize_component_name(component);
if (!ensure_component_runtime_dirs())
return { success: false, job_id: "", message: "Failed to create component action state directory" };
component_cleanup_jobs();
let job_id = component_job_id();
let state_file = component_job_state_path_value(job_id);
if (state_file == "")
return { success: false, job_id: "", message: "Failed to prepare component action job" };
let running_state = component_running_job_state_value(component, action, now_seconds());
running_state.log_path = component_job_log_path(job_id);
if (as_string(extra || "") != "")
running_state.target_version = as_string(extra);
if (!write_state_file(state_file, running_state))
return { success: false, job_id: "", message: "Failed to write component action state" };
let output_file = component_job_output_path(job_id);
let worker_args = [
"component-action-worker",
state_file,
output_file,
as_string(component),
as_string(action)
];
if (as_string(extra || "") != "")
push(worker_args, as_string(extra));
let pid = launch_component_worker(worker_args);
if (pid == "" || !set_component_running_job_pid(state_file, pid)) {
if (pid != "")
command_success_from_args([ "kill", pid ]);
return { success: false, job_id: "", message: "Failed to write component action worker pid" };
}
return { success: true, job_id, message: "Component action started" };
}
function component_action_async(component, action, extra) {
let res = component_action_async_job(component, action, extra);
component_job_json_response(res.success, res.job_id, res.message);
if (!res.success)
exit(1);
}
function component_has_running_job() {
if (!ensure_component_runtime_dirs())
return false;
component_cleanup_jobs();
for (let path in fs.glob(COMPONENT_JOB_DIR + "/*.json")) {
let val = object_or_empty(read_json_file(path));
if (val.running === true)
return true;
}
return false;
}
function component_action_status(job_id) {
ensure_dir(COMPONENT_JOB_DIR);
component_cleanup_jobs();
let state_file = component_job_state_path_value(job_id);
if (state_file == "")
component_action_status_error_exit("Invalid component action job id");
if (fs.stat(state_file) == null)
component_action_status_error_exit("Component action job was not found");
refresh_component_running_job_state(state_file);
print(as_string(fs.readfile(state_file)));
}
function component_action_log(job_id, offset) {
let log_path = component_job_log_path(job_id);
let data = fs.readfile(log_path);
if (data == null) {
write_json({ success: false, log: "", offset: 0 });
exit(1);
}
let start = int(offset || 0);
if (start < 0)
start = 0;
data = as_string(data);
if (start > length(data))
start = length(data);
write_json({ success: true, log: substr(data, start), offset: length(data) });
}
function automatic_component_check_names() {
let result = [ "tachyon" ];
if (fs.stat("/usr/bin/sing-box") != null)
push(result, "sing_box");
if (module_success([ LIB_DIR + "/providers/zapret/runtime.uc", "installed" ]))
push(result, "zapret");
if (module_success([ LIB_DIR + "/providers/zapret2/runtime.uc", "installed" ]))
push(result, "zapret2");
if (module_success([ LIB_DIR + "/providers/byedpi/runtime.uc", "installed" ]))
push(result, "byedpi");
return result;
}
function run_automatic_component_update_check(component) {
let output_file = temp_path();
if (output_file == "")
return false;
let command = command_env(component_worker_env()) + " " +
command_from_args([
"ucode",
"-L", LIB_DIR,
LIB_DIR + "/components/action.uc",
"component-action",
as_string(component),
"check_update"
]) + " >" + shell_quote(output_file) + " 2>/dev/null";
let status = command_status(command);
let value = output_json_object(output_file);
remove_file(output_file);
if (status != 0 || !component_update_check_result_cacheable(value))
return false;
return cache_component_update_check_result(value, true);
}
function trigger_component_auto_update(component, target_version) {
component = normalize_component_name(component);
if (component_has_running_job()) {
log_message("[auto-update] Skipping auto-update for " + component + ": another component update is already running", "warn");
return false;
}
log_message("[auto-update] Initiating automatic update for " + component + (target_version != "" ? " to version " + target_version : ""), "info");
let res = component_action_async_job(component, "update");
if (!res.success) {
log_message("[auto-update] Failed to start auto-update for " + component + ": " + res.message, "error");
return false;
}
return true;
}
function component_auto_update_apply() {
let settings = uci_settings();
if (!settings_component_auto_update_enabled(settings)) {
log_message("[auto-update] Automatic component updates are disabled", "info");
exit(0);
}
let checked_components = automatic_component_check_names();
for (let component in checked_components) {
if (!component_is_auto_update_target(settings, component))
continue;
run_automatic_component_update_check(component);
let cached = object_or_empty(read_json_file(component_update_check_cache_path(component)));
if (cached.status == "outdated" || cached.status == "outdated_same_release") {
if (trigger_component_auto_update(component, cached.latest_version)) {
break;
}
}
}
exit(0);
}
function component_updates_if_due() {
let interval = settings_component_update_check_interval(uci_settings());
if (interval == "")
exit(0);
let seconds = duration_to_seconds_value(interval);
if (seconds == null) {
log_message("Invalid component_update_check_interval value: " + interval, "error");
exit(1);
}
let due = update_due_status(
now_seconds(),
file_first_line_value(COMPONENT_UPDATE_CHECK_STATE_FILE),
seconds
);
if (due == 1)
exit(0);
if (due != 0)
exit(1);
if (!acquire_runtime_lock(COMPONENT_UPDATE_CHECK_LOCK_DIR, false))
exit(0);
due = update_due_status(
now_seconds(),
file_first_line_value(COMPONENT_UPDATE_CHECK_STATE_FILE),
seconds
);
if (due != 0) {
release_runtime_lock(COMPONENT_UPDATE_CHECK_LOCK_DIR);
exit(due == 1 ? 0 : 1);
}
ensure_dir(COMPONENT_UPDATE_CHECK_CACHE_DIR);
for (let component in automatic_component_check_names())
run_automatic_component_update_check(component);
let settings = uci_settings();
if (settings_component_auto_update_enabled(settings) &&
settings_component_auto_update_mode(settings) == "immediate") {
for (let component in automatic_component_check_names()) {
if (!component_is_auto_update_target(settings, component))
continue;
let cached = object_or_empty(read_json_file(component_update_check_cache_path(component)));
if (cached.status == "outdated" || cached.status == "outdated_same_release") {
if (trigger_component_auto_update(component, cached.latest_version)) {
break;
}
}
}
}
if (!write_file(COMPONENT_UPDATE_CHECK_STATE_FILE, as_string(now_seconds()) + "\n")) {
release_runtime_lock(COMPONENT_UPDATE_CHECK_LOCK_DIR);
log_message("Failed to write component update check timestamp", "error");
exit(1);
}
release_runtime_lock(COMPONENT_UPDATE_CHECK_LOCK_DIR);
}
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 download_to_file(url, filepath, proxy_address) {
let core_url_mod = core_url_module_or_null();
let candidates = core_url_mod && type(core_url_mod.download_candidates) == "function" ? core_url_mod.download_candidates(url) : [ url ];
for (let candidate in candidates) {
let attempt = 1;
while (attempt <= 2) {
let ok = false;
if (command_exists("curl")) {
let curl_args = [ "curl", "--connect-timeout", "5", "-m", "10", "-fsSL" ];
if (as_string(proxy_address) != "") {
push(curl_args, "-x", "http://" + as_string(proxy_address));
} else {
// Bootstrap DNS: use public resolvers in case Tachyon DNS isn't active yet
// (e.g., during startup before sing-box is running)
curl_push_dns_servers(curl_args);
}
push(curl_args, candidate);
push(curl_args, "-o");
push(curl_args, filepath);
ok = command_success_from_args(curl_args);
} else {
let command = command_from_args([ "wget", "-q", "-T", "10", "-O", filepath, candidate ]);
if (as_string(proxy_address) != "")
command = "http_proxy=" + shell_quote("http://" + as_string(proxy_address)) +
" https_proxy=" + shell_quote("http://" + as_string(proxy_address)) + " " + command;
ok = command_success(command);
}
if (ok && file_nonempty(filepath)) {
if (candidate != url)
log_message("Successfully downloaded " + as_string(url) + " via mirror " + candidate, "info");
return true;
}
attempt++;
}
// If a service proxy was configured but all proxy attempts failed (e.g., the
// proxy port was not yet ready after a component update restart), retry once
// directly using bootstrap DNS. This prevents a temporarily unavailable proxy
// from permanently blocking list updates.
if (as_string(proxy_address) != "" && command_exists("curl")) {
log_message("Download via service proxy failed for " + as_string(candidate) + "; retrying directly with bootstrap DNS", "warn");
let fallback_args = [
"curl", "--connect-timeout", "5", "-m", "10", "-fsSL"
];
curl_push_dns_servers(fallback_args);
push(fallback_args, candidate, "-o", filepath);
if (command_success_from_args(fallback_args) && file_nonempty(filepath)) {
if (candidate != url)
log_message("Successfully downloaded " + as_string(url) + " via mirror " + candidate + " (direct, proxy unavailable)", "info");
return true;
}
}
}
if (file_nonempty(filepath)) {
log_message("Failed to download " + as_string(url) + " (tried direct and ghproxy mirrors); keeping existing cached file on disk", "warn");
return true;
}
return false;
}
function convert_crlf_to_lf(path) {
let data = fs.readfile(as_string(path));
if (data == null || index(data, "\r") < 0)
return;
log_message("Converting CRLF line endings to LF in " + as_string(path), "debug");
write_file(path, replace(data, /\r/g, ""));
}
function ruleset_module_success(args) {
let command_args = [ LIB_DIR + "/routing/rulesets.uc" ];
for (let arg in args)
push(command_args, arg);
return module_success(command_args);
}
function domain_ip_list_ruleset_path(section) {
return TMP_RULESET_FOLDER + "/" + routing_rulesets_module().ruleset_tag(section_name(section), "lists", "") + ".json";
}
function remote_ruleset_path(section, kind) {
return TMP_RULESET_FOLDER + "/" + routing_rulesets_module().ruleset_tag(section_name(section), "remote", kind) + ".json";
}
function reset_domain_ip_list_ruleset(section) {
let path = domain_ip_list_ruleset_path(section);
ensure_dir(TMP_RULESET_FOLDER);
remove_file(path);
return ruleset_module_success([ "create-source", path ]);
}
function ensure_ruleset_source(path) {
ensure_dir(TMP_RULESET_FOLDER);
if (file_exists_value(path))
return true;
return ruleset_module_success([ "create-source", path ]);
}
function cleanup_empty_ruleset(path) {
if (routing_rulesets_module().has_rules(path))
return true;
remove_file(path);
return false;
}
function add_plain_subnet_file_to_nft_for_section(section, filepath) {
if (!file_nonempty(filepath))
return true;
return nft_module_success([
"nft-add-subnet-file-for-uci-section",
section_name(section),
filepath,
NFT_TABLE_NAME,
NFT_COMMON_SET_NAME,
NFT_IP_PORT_SET_NAME,
"5000",
NFT_COMMON6_SET_NAME,
NFT_IP_PORT6_SET_NAME
]);
}
function add_json_ruleset_subnets_to_nft_for_section(section, json_file, label) {
let unscoped_tmpfile = temp_path();
let scoped_tmpfile = temp_path();
if (unscoped_tmpfile == "" || scoped_tmpfile == "") {
remove_files([ unscoped_tmpfile, scoped_tmpfile ]);
return false;
}
let ok = nft_module_success([
"nft-add-json-ruleset-subnets-for-uci-section",
section_name(section),
json_file,
label,
NFT_TABLE_NAME,
NFT_COMMON_SET_NAME,
NFT_IP_PORT_SET_NAME,
unscoped_tmpfile,
scoped_tmpfile,
"5000",
NFT_COMMON6_SET_NAME,
NFT_IP_PORT6_SET_NAME
]);
remove_files([ unscoped_tmpfile, scoped_tmpfile ]);
return ok;
}
function import_domain_ip_list_file_into_rulesets(filepath, section) {
if (!file_exists_value(filepath))
return true;
let domains_tmpfile = temp_path();
let subnets_tmpfile = temp_path();
if (domains_tmpfile == "" || subnets_tmpfile == "") {
remove_files([ domains_tmpfile, subnets_tmpfile ]);
return false;
}
let ruleset_filepath = domain_ip_list_ruleset_path(section);
let ok = nft_module_success([ "split-domain-subnet-file", filepath, domains_tmpfile, subnets_tmpfile ]);
let domains_only = option(section, "action", "") == "dns" || option(section, "action", "") == "hosts";
if (ok)
ok = ruleset_module_success([ "import-plain-list", domains_tmpfile, ruleset_filepath, "domain_suffix", "domains", "5000" ]);
if (ok && !domains_only)
ok = ruleset_module_success([ "import-plain-list", subnets_tmpfile, ruleset_filepath, "ip_cidr", "subnets", "5000" ]);
// NOTE: nft apply for domain/ip list subnets is intentionally deferred to Phase 2
// (apply_domain_ip_list_subnets_to_nft_from_rule) so it runs under RELOAD_LOCK_DIR.
remove_files([ domains_tmpfile, subnets_tmpfile ]);
return ok;
}
// Применяет суbnets из уже сохранённого ruleset-файла domain_ip_list к nft.
// Вызывается в Phase 2 list_update() — под RELOAD_LOCK_DIR.
function apply_domain_ip_list_subnets_to_nft_from_rule(section) {
if (!bool_option(section, "enabled", true))
return true;
if (option(section, "action", "") == "dns")
return true;
let ruleset_path = domain_ip_list_ruleset_path(section);
if (!file_exists_value(ruleset_path))
return true;
return add_json_ruleset_subnets_to_nft_for_section(section, ruleset_path, "domain-ip-list");
}
function import_domain_ip_list_reference_into_rulesets(reference, section, settings) {
reference = as_string(reference);
if (match(reference, /^https?:\/\//) == null)
return import_domain_ip_list_file_into_rulesets(reference, section);
let tmpfile = temp_path();
if (tmpfile == "")
return false;
let ok = true;
if (download_to_file(reference, tmpfile, service_proxy_address(settings, "lists")) && file_nonempty(tmpfile)) {
convert_crlf_to_lf(tmpfile);
ok = import_domain_ip_list_file_into_rulesets(tmpfile, section);
}
else {
log_message("Failed to download remote domain/IP list " + reference + "; skipping it until the next successful update", "warn");
}
remove_file(tmpfile);
return ok;
}
function rebuild_domain_ip_lists_from_rule(section, settings) {
if (!bool_option(section, "enabled", true))
return true;
let references = list_option_values(section, "domain_ip_lists");
if (length(references) == 0)
return true;
if (!reset_domain_ip_list_ruleset(section))
return false;
let ok = true;
for (let reference in references)
if (!import_domain_ip_list_reference_into_rulesets(reference, section, settings))
ok = false;
cleanup_empty_ruleset(domain_ip_list_ruleset_path(section));
return ok;
}
function import_community_srs_file(service, settings) {
service = as_string(service);
if (!singbox_rulesets_module().is_community(service))
return true;
let url = singbox_rulesets_module().community_url(service);
let cached_file = TMP_RULESET_FOLDER + "/community-" + service + ".srs";
let persistent_file = "/etc/tachyon/rulesets/community-" + service + ".srs";
let tmpfile = temp_path();
if (tmpfile == "")
return false;
let ok = true;
if (download_to_file(url, tmpfile, service_proxy_address(settings, "lists")) && file_nonempty(tmpfile) && helpers.file_is_usable(tmpfile, 100)) {
ensure_dir(TMP_RULESET_FOLDER);
copy_file(tmpfile, cached_file);
ensure_dir("/etc/tachyon/rulesets");
copy_file(tmpfile, persistent_file);
log_message("Successfully cached preset ruleset " + service, "info");
}
else if (helpers.file_is_usable(persistent_file, 100)) {
ensure_dir(TMP_RULESET_FOLDER);
copy_file(persistent_file, cached_file);
}
else {
remove_file(persistent_file);
remove_file(cached_file);
log_message("Failed to download preset ruleset " + service + "; skipping it until next update", "warn");
ok = false;
}
remove_file(tmpfile);
return ok;
}
function import_all_preset_lists(settings) {
log_message("Pre-downloading all preset lists and databases", "info");
let ok = true;
for (let service, urls in BUILTIN_SUBNET_URLS) {
if (type(urls) != "array")
continue;
let cached_file = TMP_RULESET_FOLDER + "/community-subnets-" + as_string(service) + ".lst";
let persistent_file = "/etc/tachyon/rulesets/community-subnets-" + as_string(service) + ".lst";
let combined_lines = [];
for (let url in urls) {
let tmpfile = temp_path();
if (tmpfile == "") {
ok = false;
continue;
}
let downloaded = download_to_file(url, tmpfile, service_proxy_address(settings, "lists")) && file_nonempty(tmpfile);
if (!downloaded) {
remove_file(tmpfile);
if (file_exists_value(persistent_file)) {
tmpfile = persistent_file;
} else {
log_message("Failed to download built-in preset subnet list " + as_string(service) + "; skipping it until next update", "warn");
continue;
}
}
let lines = split(as_string(fs.readfile(tmpfile)), "\n");
for (let l in lines) {
l = trim(replace(as_string(l), /\r/g, ""));
if (l != "" && substr(l, 0, 1) != "#")
push(combined_lines, l);
}
if (downloaded)
remove_file(tmpfile);
}
if (length(combined_lines) > 0) {
ensure_dir(TMP_RULESET_FOLDER);
write_file(cached_file, join("\n", combined_lines) + "\n");
ensure_dir("/etc/tachyon/rulesets");
write_file(persistent_file, join("\n", combined_lines) + "\n");
}
}
let community_services = keys(singbox_rulesets_module().COMMUNITY_SERVICES);
for (let service in community_services) {
if (!import_community_srs_file(service, settings))
ok = false;
}
return ok;
}
function import_builtin_subnets_from_rule(section, settings) {
if (!bool_option(section, "enabled", true))
return true;
if (option(section, "action", "") == "dns" || option(section, "action", "") == "hosts")
return true;
let ok = true;
for (let service in connections.community_lists(section)) {
if (!singbox_rulesets_module().is_community(service))
continue;
import_community_srs_file(service, settings);
let urls = BUILTIN_SUBNET_URLS[as_string(service)];
if (type(urls) != "array")
continue;
let cached_file = TMP_RULESET_FOLDER + "/community-subnets-" + as_string(service) + ".lst";
let persistent_file = "/etc/tachyon/rulesets/community-subnets-" + as_string(service) + ".lst";
let combined_lines = [];
for (let url in urls) {
let tmpfile = temp_path();
if (tmpfile == "") {
ok = false;
continue;
}
let downloaded = download_to_file(url, tmpfile, service_proxy_address(settings, "lists")) && file_nonempty(tmpfile);
if (!downloaded) {
remove_file(tmpfile);
if (file_exists_value(persistent_file)) {
tmpfile = persistent_file;
} else {
log_message("Failed to download built-in " + as_string(service) + " subnet list; skipping it until next update", "warn");
continue;
}
}
let lines = split(as_string(fs.readfile(tmpfile)), "\n");
for (let l in lines) {
l = trim(replace(as_string(l), /\r/g, ""));
if (l != "" && substr(l, 0, 1) != "#")
push(combined_lines, l);
}
nft_module_success([
"nft-add-community-subnet-file-for-uci-section",
section_name(section),
service,
tmpfile,
NFT_TABLE_NAME,
NFT_COMMON_SET_NAME,
NFT_IP_PORT_SET_NAME,
NFT_INTERFACE_SET_NAME,
NFT_DISCORD_SET_NAME,
NFT_FAKEIP_MARK,
"5000",
NFT_COMMON6_SET_NAME,
NFT_IP_PORT6_SET_NAME,
NFT_DISCORD6_SET_NAME
]);
if (downloaded)
remove_file(tmpfile);
}
if (length(combined_lines) > 0) {
ensure_dir(TMP_RULESET_FOLDER);
write_file(cached_file, join("\n", combined_lines) + "\n");
ensure_dir("/etc/tachyon/rulesets");
write_file(persistent_file, join("\n", combined_lines) + "\n");
system(common.background_command("/etc/init.d/tachyon reload"));
}
}
return ok;
}
function import_custom_ruleset_subnets_from_local(path, format, section, label) {
if (!file_exists_value(path)) {
log_message("Local rule set file " + as_string(path) + " not found", "error");
return false;
}
let json_tmpfile = temp_path();
if (json_tmpfile == "")
return false;
let ok = true;
if (as_string(format) == "binary") {
if (!command_success_from_args([ "sing-box", "rule-set", "decompile", path, "-o", json_tmpfile ])) {
log_message("Failed to decompile rule set " + as_string(path), "error");
ok = false;
}
}
else if (!copy_file(path, json_tmpfile)) {
log_message("Failed to copy source rule set file " + as_string(path), "error");
ok = false;
}
if (ok && !add_json_ruleset_subnets_to_nft_for_section(section, json_tmpfile, label))
ok = false;
remove_file(json_tmpfile);
return ok;
}
function import_custom_ruleset_subnets_from_remote(url, format, section, label, settings) {
let remote_tmpfile = temp_path();
let json_tmpfile = temp_path();
if (remote_tmpfile == "" || json_tmpfile == "") {
remove_files([ remote_tmpfile, json_tmpfile ]);
return false;
}
if (!download_to_file(url, remote_tmpfile, service_proxy_address(settings, "lists")) || !file_nonempty(remote_tmpfile)) {
log_message("Failed to download remote rule set " + as_string(url) + "; skipping it until the next successful update", "warn");
remove_files([ remote_tmpfile, json_tmpfile ]);
return true;
}
let ok = true;
if (as_string(format) == "binary") {
if (!command_success_from_args([ "sing-box", "rule-set", "decompile", remote_tmpfile, "-o", json_tmpfile ])) {
log_message("Failed to decompile rule set " + as_string(url), "error");
ok = false;
}
}
else if (!copy_file(remote_tmpfile, json_tmpfile)) {
log_message("Failed to copy downloaded source rule set " + as_string(url), "error");
ok = false;
}
if (ok && !add_json_ruleset_subnets_to_nft_for_section(section, json_tmpfile, label))
ok = false;
remove_files([ remote_tmpfile, json_tmpfile ]);
return ok;
}
function import_rule_sets_with_subnets_from_rule(section, settings) {
if (!bool_option(section, "enabled", true))
return true;
if (option(section, "action", "") == "dns" || option(section, "action", "") == "hosts")
return true;
let references = connections.rule_sets_with_subnets(section);
if (length(references) == 0)
return true;
log_message("Importing subnets from rule sets with subnets for '" + section_name(section) + "' section", "info");
let ok = true;
for (let reference in references) {
reference = as_string(reference);
log_message("Importing subnets from rule set reference for '" + section_name(section) + "': " + reference, "info");
let extension = singbox_rulesets_module().file_extension(reference);
if (match(reference, /^\/.*\.srs$/) != null) {
if (!import_custom_ruleset_subnets_from_local(reference, "binary", section, "Rule set " + reference))
ok = false;
}
else if (match(reference, /^\/.*\.json$/) != null) {
if (!import_custom_ruleset_subnets_from_local(reference, "source", section, "Rule set " + reference))
ok = false;
}
else if (match(reference, /^https?:\/\//) != null) {
let format = extension == "json" ? "source" : (extension == "srs" ? "binary" : singbox_rulesets_module().remote_format(reference));
if (!import_custom_ruleset_subnets_from_remote(reference, format, section, "Rule set " + reference, settings))
ok = false;
}
else {
log_message("Unsupported rule set reference for subnet import: " + reference, "error");
ok = false;
}
}
return ok;
}
function import_domains_from_remote_plain_file(url, section, settings) {
let tmpfile = temp_path();
if (tmpfile == "")
return false;
if (!download_to_file(url, tmpfile, service_proxy_address(settings, "lists")) || !file_nonempty(tmpfile)) {
log_message("Failed to download remote domain list " + as_string(url) + "; skipping it until the next successful update", "warn");
remove_file(tmpfile);
return true;
}
convert_crlf_to_lf(tmpfile);
let ruleset_path = remote_ruleset_path(section, "domains");
let ok = ensure_ruleset_source(ruleset_path) &&
ruleset_module_success([ "import-plain-list", tmpfile, ruleset_path, "domain_suffix", "domains", "5000" ]);
remove_file(tmpfile);
return ok;
}
function import_domains_from_remote_domain_lists(section, settings) {
if (!bool_option(section, "enabled", true))
return true;
let references = list_option_values(section, "remote_domain_lists");
if (length(references) == 0)
return true;
log_message("Importing domains from remote domain lists for '" + section_name(section) + "' section", "info");
let ok = true;
for (let url in references) {
log_message("Importing domains from URL: " + as_string(url), "info");
let extension = singbox_rulesets_module().file_extension(url);
log_message("Detected file extension: '" + extension + "'", "debug");
if (extension == "json" || extension == "srs") {
log_message("No update needed - sing-box manages updates automatically.", "info");
continue;
}
log_message("Import domains from a remote plain-text list", "info");
if (!import_domains_from_remote_plain_file(url, section, settings))
ok = false;
}
return ok;
}
function import_subnets_from_remote_json_file(url, section, settings) {
let json_tmpfile = temp_path();
if (json_tmpfile == "")
return false;
if (!download_to_file(url, json_tmpfile, service_proxy_address(settings, "lists")) || !file_nonempty(json_tmpfile)) {
log_message("Failed to download remote JSON subnet list " + as_string(url) + "; skipping it until the next successful update", "warn");
remove_file(json_tmpfile);
return true;
}
let ok = add_json_ruleset_subnets_to_nft_for_section(section, json_tmpfile, "Remote JSON rule set " + as_string(url));
if (!ok)
log_message("Failed to add subnets from remote JSON list " + as_string(url) + " to nftables", "error");
remove_file(json_tmpfile);
return ok;
}
function import_subnets_from_remote_srs_file(url, section, settings) {
let binary_tmpfile = temp_path();
let json_tmpfile = temp_path();
if (binary_tmpfile == "" || json_tmpfile == "") {
remove_files([ binary_tmpfile, json_tmpfile ]);
return false;
}
if (!download_to_file(url, binary_tmpfile, service_proxy_address(settings, "lists")) || !file_nonempty(binary_tmpfile)) {
log_message("Failed to download remote SRS subnet list " + as_string(url) + "; skipping it until the next successful update", "warn");
remove_files([ binary_tmpfile, json_tmpfile ]);
return true;
}
let ok = command_success_from_args([ "sing-box", "rule-set", "decompile", binary_tmpfile, "-o", json_tmpfile ]);
if (!ok)
log_message("Failed to decompile binary rule set file", "error");
if (ok && !add_json_ruleset_subnets_to_nft_for_section(section, json_tmpfile, "Remote SRS rule set " + as_string(url))) {
log_message("Failed to add subnets from remote SRS list " + as_string(url) + " to nftables", "error");
ok = false;
}
remove_files([ binary_tmpfile, json_tmpfile ]);
return ok;
}
function import_subnets_from_remote_plain_file(url, section, settings) {
let tmpfile = temp_path();
if (tmpfile == "")
return false;
if (!download_to_file(url, tmpfile, service_proxy_address(settings, "lists")) || !file_nonempty(tmpfile)) {
log_message("Failed to download remote plain subnet list " + as_string(url) + "; skipping it until the next successful update", "warn");
remove_file(tmpfile);
return true;
}
convert_crlf_to_lf(tmpfile);
let ruleset_path = remote_ruleset_path(section, "subnets");
let ok = ensure_ruleset_source(ruleset_path) &&
ruleset_module_success([ "import-plain-list", tmpfile, ruleset_path, "ip_cidr", "subnets", "5000" ]);
if (ok)
ok = add_plain_subnet_file_to_nft_for_section(section, tmpfile);
remove_file(tmpfile);
return ok;
}
function import_subnets_from_remote_subnet_lists(section, settings) {
if (!bool_option(section, "enabled", true))
return true;
let references = list_option_values(section, "remote_subnet_lists");
if (length(references) == 0)
return true;
log_message("Importing subnets from remote subnet lists for '" + section_name(section) + "' section", "info");
let ok = true;
for (let url in references) {
log_message("Importing subnets from URL: " + as_string(url), "info");
let extension = singbox_rulesets_module().file_extension(url);
log_message("Detected file extension: '" + extension + "'", "debug");
if (extension == "json") {
log_message("Import subnets from a remote JSON list", "info");
if (!import_subnets_from_remote_json_file(url, section, settings))
ok = false;
}
else if (extension == "srs") {
log_message("Import subnets from a remote SRS list", "info");
if (!import_subnets_from_remote_srs_file(url, section, settings))
ok = false;
}
else {
log_message("Import subnets from a remote plain-text list", "info");
if (!import_subnets_from_remote_plain_file(url, section, settings))
ok = false;
}
}
return ok;
}
function list_update_pid_begin() {
let existing_pid = trim(as_string(fs.readfile(LIST_UPDATE_PID_FILE) || ""));
let current_pid = owner_pid();
if (existing_pid != "" && existing_pid != current_pid && runtime_pid_running(existing_pid)) {
log_message("Another lists update is already running, skipping", "info");
return false;
}
ensure_parent_dir(LIST_UPDATE_PID_FILE);
write_file(LIST_UPDATE_PID_FILE, current_pid + "\n");
return true;
}
function list_update_pid_end() {
remove_file(LIST_UPDATE_PID_FILE);
}
function service_proxy_is_ready(proxy_address) {
// Probe the service proxy by attempting a connection through it to a
// well-known public IP (no DNS resolution required). If the port is not
// yet open — e.g., sing-box is still starting after a component update —
// curl returns exit 7 (CURLE_COULDNT_CONNECT) or 28 (timeout).
// Any other result means the port is accepting connections.
let status = command_status(
command_from_args([
"curl", "-s", "--connect-timeout", "2", "-m", "4",
"-x", "http://" + as_string(proxy_address),
"http://1.1.1.1/", "-o", "/dev/null"
]) + " 2>/dev/null"
);
return status != 7 && status != 28;
}
function dns_probe_passed(proxy_address) {
if (as_string(proxy_address) != "") {
// When a service proxy is configured, verify it is actually accepting
// TCP connections before proceeding — sing-box may still be starting
// up after a component update or restart and the proxy port may not be
// ready even though the process is already running.
let attempt = 1;
while (attempt <= 10) {
if (service_proxy_is_ready(proxy_address)) {
log_message("Service proxy is ready", "info");
return true;
}
log_message("Service proxy not yet ready [" + attempt + "/10]", "info");
command_success_from_args([ "sleep", "3" ]);
attempt++;
}
log_message("Service proxy is not accepting connections after 10 attempts; skipping list update", "info");
return false;
}
let attempt = 1;
while (attempt <= 10) {
let output = command_output_from_args([ "dig", "+short", "openwrt.org", "A", "+timeout=3", "+tries=1" ]);
for (let line in split(output, "\n")) {
if (match(as_string(line), /^[0-9]+\./) != null) {
log_message("DNS check passed", "info");
return true;
}
}
log_message("DNS is unavailable [" + attempt + "/10]", "info");
command_success_from_args([ "sleep", "3" ]);
attempt++;
}
log_message("DNS check failed after 10 attempts; skipping remote lists update until the next attempt", "info");
return false;
}
function github_probe(proxy_address) {
let attempt = 1;
let timeout = 5;
while (attempt <= 10) {
let args = [ "curl", "-s", "-m", "" + timeout ];
if (as_string(proxy_address) != "") {
push(args, "-x");
push(args, "http://" + as_string(proxy_address));
}
push(args, "https://github.com");
if (command_success_from_args(args)) {
log_message(as_string(proxy_address) != "" ? "GitHub connection check passed (via proxy)" : "GitHub connection check passed", "info");
return true;
}
log_message("GitHub is unavailable [" + attempt + "/10] (max-timeout=" + timeout + ")", "info");
if (timeout < 10)
timeout++;
command_success_from_args([ "sleep", "3" ]);
attempt++;
}
log_message("GitHub connection check failed after 10 attempts; trying configured list downloads individually", "info");
return false;
}
function write_list_update_timestamp(timestamp) {
ensure_dir(RUNTIME_STATE_DIR);
write_file(LIST_UPDATE_STATE_FILE, as_string(timestamp) + "\n");
}
function list_update() {
log_message("Starting lists update", "info");
if (!list_update_pid_begin())
exit(0);
let settings = uci_settings();
let proxy_address = service_proxy_address(settings, "lists");
if (!dns_probe_passed(proxy_address)) {
list_update_pid_end();
exit(0);
}
github_probe(proxy_address);
log_message("Downloading and processing lists", "info");
let sections = uci_sections("section");
let ok = true;
// Фаза 1: скачивание файлов (без блокировки, не трогает nft)
if (bool_option(settings, "download_all_presets", false)) {
if (!import_all_preset_lists(settings))
ok = false;
}
for (let section in sections)
if (!rebuild_domain_ip_lists_from_rule(section, settings))
ok = false;
for (let section in sections)
if (!import_domains_from_remote_domain_lists(section, settings))
ok = false;
// Фаза 2: применение к nft (захватываем лок reload, чтобы не конфликтовать
// с reload-firewall, который пересоздаёт таблицы и сеты nftables)
log_message("Waiting for reload lock to apply nft rules", "debug");
if (!acquire_runtime_lock(RELOAD_LOCK_DIR, true)) {
log_message("Could not acquire reload lock for nft apply; saving pending marker for next attempt", "warn");
// Without this marker the deferred apply is lost for good — nothing else
// records that the nft phase still owes work. An empty catch here meant
// the lists were updated on disk and never reached nftables, with the
// "saving pending marker" line above as the only (untrue) evidence.
let marker_written = false;
try {
marker_written = fs.writefile("/tmp/tachyon_list_update_pending_nft", as_string(now_seconds())) != null;
}
catch (e) {
log_message("Failed to save pending nft marker, this list update will not be re-applied: " + as_string(e), "err");
}
if (!marker_written)
log_message("Failed to save pending nft marker, this list update will not be re-applied", "err");
list_update_pid_end();
exit(1);
}
for (let section in sections)
if (!import_builtin_subnets_from_rule(section, settings))
ok = false;
for (let section in sections)
if (!import_subnets_from_remote_subnet_lists(section, settings))
ok = false;
for (let section in sections)
if (!import_rule_sets_with_subnets_from_rule(section, settings))
ok = false;
// Fix race condition #2: subnets из domain/ip lists тоже применяем под локом
for (let section in sections)
if (!apply_domain_ip_list_subnets_to_nft_from_rule(section))
ok = false;
release_runtime_lock(RELOAD_LOCK_DIR);
if (ok) {
write_list_update_timestamp(now_seconds());
log_message("Lists update completed successfully", "info");
}
else {
log_message("Lists update failed", "info");
}
list_update_pid_end();
exit(ok ? 0 : 1);
}
function list_update_if_due() {
let interval = settings_update_interval(uci_settings());
if (interval == "")
exit(1);
let seconds = duration_to_seconds_value(interval);
if (seconds == null) {
log_message("Invalid update_interval value: " + interval, "error");
exit(1);
}
let status = update_due_status(now_seconds(), file_first_line_value(LIST_UPDATE_STATE_FILE), seconds);
if (status == 0)
list_update();
if (status == 1)
exit(0);
exit(1);
}
function stop_list_update() {
let pid = trim(as_string(fs.readfile(LIST_UPDATE_PID_FILE) || ""));
if (pid != "" && runtime_pid_running(pid)) {
command_success_from_args([ "kill", pid ]);
log_message("Stopped list_update", "info");
}
remove_file(LIST_UPDATE_PID_FILE);
}
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,
SB_SERVICE_MIXED_INBOUND_ADDRESS,
SB_SERVICE_MIXED_INBOUND_PORT,
SB_VARIANT_STATE_FILE
};
}
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 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 subscription_cache_success(args) {
let result = subscription_cache_capture(args);
if (result.output != "")
for (let line in split(result.output, "\n"))
if (trim(as_string(line)) != "")
log_message("subscription cache: " + line, "debug");
return result.status == 0;
}
function log_file_lines_from_text(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 singbox_runtime_success(args) {
let command_args = [ LIB_DIR + "/singbox/runtime.uc" ];
for (let arg in args)
push(command_args, arg);
let result = module_env_capture(subscription_cache_env(), command_args);
if (result.output != "")
log_file_lines_from_text(result.output, "debug", "sing-box runtime: ");
return result.status == 0;
}
function write_current_reload_state_clean() {
return service_state_success([
"write-current-reload-state-clean",
RELOAD_STATE_FILE,
RELOAD_STATE_FORMAT,
RULE_CONDITION_CACHE_DIR
]);
}
function mark_pending_reload(reason) {
service_state_success([ "mark-pending-reload", PENDING_RELOAD_FILE, reason ]);
}
function run_pending_reload_if_requested() {
service_state_success([ "run-pending-reload-if-requested", PENDING_RELOAD_FILE, SERVICE_INIT ]);
}
function subscription_update_common_locked(force, target_section, target_source_index) {
let result = subscription_cache_capture([
"update-request",
force ? "1" : "0",
as_string(target_section),
as_string(target_source_index)
]);
if (result.status != 0) {
log_file_lines_from_text(result.output, "error", "subscription update: ");
return false;
}
let fields = split(trim(result.output), /[ \t\r\n]+/);
let updated = arg_number(fields[0] || "0");
let failed = arg_number(fields[1] || "0");
let unchanged = arg_number(fields[2] || "0");
let superseded = arg_number(fields[3] || "0");
if (updated == 0) {
if (superseded > 0) {
log_message("Subscription update was superseded by newer configuration", "info");
return true;
}
if (failed > 0) {
log_message("Subscription update finished with errors; keeping the last working cache", "info");
return false;
}
if (unchanged > 0)
log_message("Subscription update completed: no changes detected", "info");
else
log_message("No subscription rules are due for update", "info");
return true;
}
log_message("Reloading sing-box to apply updated subscriptions", "info");
if (!module_success([ LIB_DIR + "/server/service.uc", "prepare-all-defaults" ]))
return false;
if (!module_success([ LIB_DIR + "/config/validator.uc", "validate-runtime" ])) {
log_message("Runtime config validation failed. Aborted.", "fatal");
return false;
}
if (!singbox_runtime_success([ "configure-service" ]))
return false;
let sing_box_config_path = option(uci_settings(), "config_path", "");
let sing_box_config_hash_before = file_md5(sing_box_config_path);
let sing_box_pid_before = trim(module_output([ LIB_DIR + "/service/state.uc", "sing-box-service-runtime-pid" ]));
module_success([ DNS_FAILOVER_UC, "stop-runtime" ]);
if (!singbox_runtime_success([ "init-config", "0", "1", "1" ])) {
module_success([ DNS_FAILOVER_UC, "start-runtime" ]);
log_message("Failed to rebuild sing-box after subscription update", "error");
return false;
}
module_success([ PRIORITY_UC, "stop-runtime" ]);
if (!service_state_success([
"reload-sing-box-runtime",
sing_box_pid_before,
sing_box_config_hash_before,
file_md5(sing_box_config_path)
])) {
module_success([ PRIORITY_UC, "start-runtime" ]);
module_success([ DNS_FAILOVER_UC, "start-runtime" ]);
return false;
}
if (!module_success([ PRIORITY_UC, "start-runtime" ])) {
log_message("Failed to restart Priority runtime after subscription update", "error");
return false;
}
if (!module_success([ DNS_FAILOVER_UC, "start-runtime" ])) {
log_message("Failed to restart DNS failover runtime after subscription update", "error");
return false;
}
if (!write_current_reload_state_clean())
return false;
if (failed > 0)
log_message("Subscription update applied for changed rules; failed rules kept their previous cache", "info");
else
log_message("Subscription update completed", "info");
return true;
}
function subscription_update_common(force, target_section, target_source_index) {
if (!subscription_cache_success([ "ensure-runtime-dirs" ]))
exit(1);
force = !!force;
if (!acquire_runtime_lock(SUBSCRIPTION_UPDATE_LOCK_DIR, force)) {
log_message("Subscription update is already running", "info");
if (force)
mark_pending_reload("subscription_update_busy");
return force ? 1 : 0;
}
if (!acquire_runtime_lock(RELOAD_LOCK_DIR, force)) {
release_runtime_lock(SUBSCRIPTION_UPDATE_LOCK_DIR);
log_message("Tachyon reload is already running; skipping subscription update", "info");
if (force)
mark_pending_reload("reload_busy");
return force ? 1 : 0;
}
let ok = subscription_update_common_locked(force, target_section, target_source_index);
release_runtime_lock(RELOAD_LOCK_DIR);
release_runtime_lock(SUBSCRIPTION_UPDATE_LOCK_DIR);
run_pending_reload_if_requested();
return ok ? 0 : 1;
}
function subscription_update_if_due() {
log_message("Starting due subscription update", "info");
exit(subscription_update_common(false, "", ""));
}
function subscription_update(target_section, target_source_index) {
if (as_string(target_section) != "")
log_message("Starting subscription update for rule '" + as_string(target_section) + "'", "info");
else
log_message("Starting subscription update", "info");
exit(subscription_update_common(true, target_section, target_source_index));
}
function fixture_section_list(data, type_name) {
type_name = as_string(type_name || "section");
let value = object_or_empty(data)[type_name];
if (type(value) == "array")
return value;
if (type(value) == "object")
return [ value ];
let plural = object_or_empty(data)[type_name + "s"];
return type(plural) == "array" ? plural : [];
}
function fixture_cron_refresh_plan(path, bin, list_marker, subscription_marker, component_marker) {
let data = object_or_empty(read_json_file(path));
connections.set_item_sections_from_data(data);
cron_refresh_plan(
object_or_empty(data.settings),
fixture_section_list(data),
bin,
list_marker,
subscription_marker,
component_marker
);
}
function fixture_cron_refresh_apply(path, existing_crontab_path, bin, list_marker, subscription_marker, component_marker) {
let data = object_or_empty(read_json_file(path));
connections.set_item_sections_from_data(data);
let result = cron_refresh_apply_result(
object_or_empty(data.settings),
fixture_section_list(data),
fs.readfile(as_string(existing_crontab_path)) || "",
bin,
list_marker,
subscription_marker,
component_marker
);
write_json({
crontab: result.crontab,
logs: result.logs
});
exit(result.status);
}
function fixture_section_by_name(data, target_name) {
target_name = as_string(target_name);
for (let section in fixture_section_list(data))
if (section_name(section) == target_name)
return section;
return {};
}
function uci_cron_refresh_plan(bin, list_marker, subscription_marker, component_marker) {
cron_refresh_plan(
uci_settings(),
uci_sections("section"),
bin,
list_marker,
subscription_marker,
component_marker
);
}
function uci_refresh_cron(bin, list_marker, subscription_marker, component_marker) {
let settings = uci_settings();
if (settings_component_update_check_interval(settings) == "")
clear_component_update_check_cache();
refresh_cron_from_sources(
settings,
uci_sections("section"),
bin,
list_marker,
subscription_marker,
component_marker
);
}
function uci_list_update_due_status(timestamp_path, now) {
list_update_due_status(uci_settings(), timestamp_path, now);
}
function uci_subscription_update_section_due_status(section_name, timestamp_path, now) {
subscription_update_section_due_status(object_or_empty(uci_core.get_all(CONFIG_NAME, section_name)), timestamp_path, now);
}
function fixture_list_update_due_status(path, timestamp_path, now) {
let data = object_or_empty(read_json_file(path));
connections.set_item_sections_from_data(data);
list_update_due_status(object_or_empty(data.settings), timestamp_path, now);
}
function fixture_subscription_update_section_due_status(path, section_name_value, timestamp_path, now) {
let data = object_or_empty(read_json_file(path));
connections.set_item_sections_from_data(data);
subscription_update_section_due_status(fixture_section_by_name(data, section_name_value), timestamp_path, now);
}
function print_builtin_subnet_urls(service) {
for (let url in array_or_empty(BUILTIN_SUBNET_URLS[as_string(service)]))
print(url, "\n");
}
let mode = ARGV[0] || "";
if (mode == "json-length")
json_length(ARGV[1]);
else if (mode == "update-is-due")
update_is_due(ARGV[1], ARGV[2], ARGV[3]);
else if (mode == "duration-to-seconds")
duration_to_seconds(ARGV[1]);
else if (mode == "builtin-subnet-urls")
print_builtin_subnet_urls(ARGV[1]);
else if (mode == "due-check-cron-schedule")
due_check_cron_schedule(ARGV[1]);
else if (mode == "list-update-cron-job")
update_cron_job(ARGV[1], "list_update_if_due", ARGV[2], ARGV[3]);
else if (mode == "subscription-update-cron-job")
subscription_update_cron_job(ARGV[1], ARGV[2], ARGV[3]);
else if (mode == "subscription-update-interval-plan")
subscription_update_interval_plan();
else if (mode == "cron-refresh-plan")
uci_cron_refresh_plan(ARGV[1], ARGV[2], ARGV[3], ARGV[4]);
else if (mode == "cron-refresh-plan-fixture")
fixture_cron_refresh_plan(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]);
else if (mode == "refresh-cron-from-uci")
uci_refresh_cron(ARGV[1], ARGV[2], ARGV[3], ARGV[4]);
else if (mode == "refresh-cron-fixture")
fixture_cron_refresh_apply(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6]);
else if (mode == "remove-cron-jobs")
remove_cron_jobs(ARGV[1], ARGV[2], ARGV[3]);
else if (mode == "list-update")
list_update();
else if (mode == "list-update-if-due")
list_update_if_due();
else if (mode == "stop-list-update")
stop_list_update();
else if (mode == "list-update-due-status")
uci_list_update_due_status(ARGV[1], ARGV[2]);
else if (mode == "list-update-due-status-fixture")
fixture_list_update_due_status(ARGV[1], ARGV[2], ARGV[3]);
else if (mode == "subscription-update-section-due-status")
uci_subscription_update_section_due_status(ARGV[1], ARGV[2], ARGV[3]);
else if (mode == "subscription-update-section-due-status-fixture")
fixture_subscription_update_section_due_status(ARGV[1], ARGV[2], ARGV[3], ARGV[4]);
else if (mode == "file-first-line")
file_first_line(ARGV[1]);
else if (mode == "stdin-first-ipv4-line")
stdin_first_ipv4_line();
else if (mode == "filter-cron-markers")
filter_cron_markers([ARGV[1], ARGV[2]]);
else if (mode == "job-pid")
job_pid(ARGV[1]);
else if (mode == "subscription-job-state-path")
subscription_job_state_path(ARGV[1], ARGV[2]);
else if (mode == "subscription-job-json-response")
subscription_job_json_response(ARGV[1], ARGV[2], ARGV[3]);
else if (mode == "subscription-running-job-state")
subscription_running_job_state(ARGV[1], ARGV[2], ARGV[3]);
else if (mode == "subscription-job-refresh-plan")
subscription_job_refresh_plan(ARGV[1], ARGV[2], ARGV[3]);
else if (mode == "subscription-finished-job-state")
subscription_finished_job_state(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7]);
else if (mode == "subscription-stale-job-state")
subscription_stale_job_state(ARGV[1], ARGV[2], ARGV[3], ARGV[4]);
else if (mode == "subscription-status-error")
subscription_status_error(ARGV[1]);
else if (mode == "subscription-cleanup-jobs")
subscription_cleanup_jobs();
else if (mode == "subscription-update-worker")
subscription_update_worker(ARGV[1], ARGV[2], ARGV[3], ARGV[4]);
else if (mode == "subscription-update")
subscription_update(ARGV[1], ARGV[2]);
else if (mode == "subscription-update-if-due")
subscription_update_if_due();
else if (mode == "subscription-update-async")
subscription_update_async(ARGV[1], ARGV[2]);
else if (mode == "subscription-update-status")
subscription_update_status(ARGV[1]);
else if (mode == "component-action-worker")
component_action_worker(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]);
else if (mode == "component-action-async")
component_action_async(ARGV[1], ARGV[2], ARGV[3]);
else if (mode == "component-action-status")
component_action_status(ARGV[1]);
else if (mode == "component-action-log")
component_action_log(ARGV[1], ARGV[2]);
else if (mode == "component-updates-if-due")
component_updates_if_due();
else if (mode == "component-auto-update-apply")
component_auto_update_apply();
else if (mode == "component-update-check-cache")
component_update_check_cache();
else {
warn("Usage: components/updates.uc <operation> ...\n");
exit(1);
}