3627 lines
130 KiB
Ucode
3627 lines
130 KiB
Ucode
#!/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_ip_module_or_null() {
|
||
try {
|
||
return require("core.ip");
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
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 LIST_UPDATE_STATUS_FILE = getenv("TACHYON_LIST_UPDATE_STATUS_FILE") || RUNTIME_STATE_DIR + "/list-update.status";
|
||
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_json_file = common.write_json_file;
|
||
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;
|
||
}
|
||
|
||
let file_first_line = common.print_file_first_line;
|
||
let file_first_line_value = common.file_first_line;
|
||
|
||
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);
|
||
if (data == null || trim(as_string(data)) == "")
|
||
return text_first_chars(fallback, max_chars);
|
||
|
||
let raw_lines = split(as_string(data), /\r?\n/);
|
||
let lines = [];
|
||
for (let l in raw_lines) {
|
||
let t = trim(l);
|
||
if (t != "")
|
||
push(lines, t);
|
||
}
|
||
|
||
if (length(lines) == 0)
|
||
return text_first_chars(fallback, max_chars);
|
||
|
||
// 1. Detect ucode runtime/syntax exception
|
||
let first_line = lines[0];
|
||
let lower_first = lc(first_line);
|
||
if (index(lower_first, "type error:") == 0 ||
|
||
index(lower_first, "syntax error:") == 0 ||
|
||
index(lower_first, "reference error:") == 0 ||
|
||
index(lower_first, "runtime error:") == 0 ||
|
||
index(lower_first, "unhandled exception:") == 0 ||
|
||
index(lower_first, "error:") == 0) {
|
||
let loc = "";
|
||
if (length(lines) > 1 && (index(lines[1], "In ") == 0 || index(lines[1], "in ") == 0)) {
|
||
let m = match(lines[1], /([a-zA-Z0-9_.-]+),[ \t]*line[ \t]*([0-9]+)/);
|
||
if (m)
|
||
loc = " (" + m[1] + ":" + m[2] + ")";
|
||
}
|
||
let msg = first_line + loc;
|
||
return text_first_chars(msg, max_chars);
|
||
}
|
||
|
||
// 2. Discard trailing pointer / caret / delimiter lines
|
||
while (length(lines) > 0) {
|
||
let last = lines[length(lines) - 1];
|
||
if (index(last, "Near here") >= 0 || match(last, /^[ \t\-_^]*$/) || match(last, /^[-=~_]{4,}$/)) {
|
||
pop(lines);
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (length(lines) == 0)
|
||
return text_first_chars(fallback, max_chars);
|
||
|
||
// 3. Check if any line has an explicit error prefix
|
||
for (let l in lines) {
|
||
let lower_l = lc(l);
|
||
if (index(lower_l, "error:") >= 0 ||
|
||
index(lower_l, "fatal:") >= 0 ||
|
||
index(lower_l, "failed:") >= 0 ||
|
||
index(lower_l, "failure:") >= 0) {
|
||
return text_first_chars(l, max_chars);
|
||
}
|
||
}
|
||
|
||
// 4. Fallback to the last non-empty line
|
||
let result = lines[length(lines) - 1];
|
||
return text_first_chars(result, max_chars);
|
||
}
|
||
|
||
let section_name = common.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 == "wdtt" || component == "olcrtc" || component == "fptn" || 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) {
|
||
// BusyBox `crontab -l` exits with code 1 when the crontab is empty or
|
||
// absent; command_output_from_args returns "" in that case and a
|
||
// subsequent write would wipe all user cron lines. Read the file
|
||
// directly to avoid this.
|
||
let crontab = as_string(fs.readfile("/etc/crontabs/root") || "");
|
||
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) {
|
||
// Same as remove_cron_jobs: read /etc/crontabs/root directly to avoid
|
||
// BusyBox's `crontab -l` exit-code-1 on an empty or absent file.
|
||
let result = cron_refresh_apply_result(
|
||
settings,
|
||
sections,
|
||
as_string(fs.readfile("/etc/crontabs/root") || ""),
|
||
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", "wdtt", "olcrtc", "fptn" ]) {
|
||
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, extra) {
|
||
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");
|
||
if (module_success([ LIB_DIR + "/providers/wdtt/runtime.uc", "installed" ]))
|
||
push(result, "wdtt");
|
||
if (module_success([ LIB_DIR + "/providers/olcrtc/runtime.uc", "installed" ]))
|
||
push(result, "olcrtc");
|
||
if (module_success([ LIB_DIR + "/providers/fptn/runtime.uc", "installed" ]))
|
||
push(result, "fptn");
|
||
|
||
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;
|
||
|
||
let default_section = option(settings, "download_lists_via_proxy_section", "");
|
||
if (default_section != "")
|
||
return default_section;
|
||
|
||
// Fallback: if purpose is "lists" and download_components_via_proxy is enabled
|
||
if (purpose == "lists" && bool_option(settings, "download_components_via_proxy", false)) {
|
||
let comp_section = option(settings, "download_components_via_proxy_section", "");
|
||
if (comp_section != "")
|
||
return comp_section;
|
||
return default_section;
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
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) {
|
||
let slash = rindex(path, "/");
|
||
let filename = slash >= 0 ? substr(path, slash + 1) : path;
|
||
let persistent_path = "/etc/tachyon/rulesets/" + filename;
|
||
if (routing_rulesets_module().has_rules(path)) {
|
||
if (filename != "") {
|
||
ensure_dir("/etc/tachyon/rulesets");
|
||
copy_file(path, persistent_path);
|
||
}
|
||
return true;
|
||
}
|
||
if (filename != "" && routing_rulesets_module().has_rules(persistent_path)) {
|
||
log_message("Ruleset " + filename + " is empty; restoring from persistent cache", "info");
|
||
ensure_dir(TMP_RULESET_FOLDER);
|
||
copy_file(persistent_path, 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 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 ];
|
||
let downloaded = false;
|
||
|
||
for (let candidate in candidates) {
|
||
if (download_to_file(candidate, tmpfile, service_proxy_address(settings, "lists")) &&
|
||
file_nonempty(tmpfile) &&
|
||
singbox_rulesets_module().is_valid_srs_file(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 + (candidate != url ? " via mirror " + candidate : ""), "info");
|
||
downloaded = true;
|
||
break;
|
||
}
|
||
remove_file(tmpfile);
|
||
}
|
||
|
||
let ok = true;
|
||
if (downloaded) {
|
||
// Cached successfully
|
||
}
|
||
else if (singbox_rulesets_module().is_valid_srs_file(persistent_file) && helpers.file_is_usable(persistent_file, 100)) {
|
||
ensure_dir(TMP_RULESET_FOLDER);
|
||
copy_file(persistent_file, cached_file);
|
||
log_message("Using persistent cached ruleset for " + service + " after download failure", "warn");
|
||
}
|
||
else {
|
||
remove_file(persistent_file);
|
||
if (!singbox_rulesets_module().is_valid_srs_file(cached_file))
|
||
singbox_rulesets_module().ensure_empty_srs_stub(cached_file);
|
||
log_message("Failed to download preset ruleset " + service + "; preserving fallback stub 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;
|
||
}
|
||
cleanup_empty_ruleset(remote_ruleset_path(section, "domains"));
|
||
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;
|
||
}
|
||
}
|
||
cleanup_empty_ruleset(remote_ruleset_path(section, "subnets"));
|
||
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 update_list_status(running, success, progress, message) {
|
||
ensure_dir(RUNTIME_STATE_DIR);
|
||
write_json_file(LIST_UPDATE_STATUS_FILE, {
|
||
running: running,
|
||
success: success,
|
||
progress: progress,
|
||
message: message,
|
||
timestamp: now_seconds()
|
||
});
|
||
}
|
||
|
||
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 reload_singbox_after_list_update() {
|
||
let sing_box_pid = trim(module_output([ LIB_DIR + "/service/state.uc", "sing-box-service-runtime-pid" ]));
|
||
if (sing_box_pid == "" || int(sing_box_pid) <= 0)
|
||
return true;
|
||
let sing_box_config_path = option(uci_settings(), "config_path", "") || "/etc/sing-box/config.json";
|
||
let sing_box_config_hash_before = file_md5(sing_box_config_path);
|
||
if (!singbox_runtime_success([ "init-config", "0", "1", "1" ]))
|
||
return false;
|
||
let sing_box_config_hash_after = file_md5(sing_box_config_path);
|
||
if (sing_box_config_hash_before != sing_box_config_hash_after) {
|
||
log_message("Rulesets updated on disk; reloading sing-box with new configuration", "info");
|
||
module_success([ DNS_FAILOVER_UC, "stop-runtime" ]);
|
||
module_success([ PRIORITY_UC, "stop-runtime" ]);
|
||
let ok = service_state_success([
|
||
"reload-sing-box-runtime",
|
||
sing_box_pid,
|
||
sing_box_config_hash_before,
|
||
sing_box_config_hash_after,
|
||
"1"
|
||
]);
|
||
module_success([ PRIORITY_UC, "start-runtime" ]);
|
||
module_success([ DNS_FAILOVER_UC, "start-runtime" ]);
|
||
write_current_reload_state_clean();
|
||
return ok;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function list_update() {
|
||
log_message("Starting lists update", "info");
|
||
if (!list_update_pid_begin())
|
||
exit(0);
|
||
|
||
update_list_status(true, null, "starting", "Starting lists update");
|
||
|
||
let settings = uci_settings();
|
||
let proxy_address = service_proxy_address(settings, "lists");
|
||
if (!dns_probe_passed(proxy_address)) {
|
||
update_list_status(false, false, "error", "DNS probe failed");
|
||
list_update_pid_end();
|
||
exit(0);
|
||
}
|
||
github_probe(proxy_address);
|
||
|
||
log_message("Downloading and processing lists", "info");
|
||
update_list_status(true, null, "downloading", "Downloading and processing lists");
|
||
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");
|
||
update_list_status(true, null, "applying", "Applying rules and subnet lists");
|
||
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");
|
||
update_list_status(false, false, "error", "Could not acquire reload lock for nft apply");
|
||
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");
|
||
reload_singbox_after_list_update();
|
||
update_list_status(false, true, "done", "Lists and rule sets successfully updated");
|
||
}
|
||
else {
|
||
log_message("Lists update failed", "info");
|
||
update_list_status(false, false, "failed", "Lists update failed");
|
||
}
|
||
|
||
list_update_pid_end();
|
||
exit(ok ? 0 : 1);
|
||
}
|
||
|
||
function list_update_async() {
|
||
let existing_pid = trim(as_string(fs.readfile(LIST_UPDATE_PID_FILE) || ""));
|
||
if (existing_pid != "" && runtime_pid_running(existing_pid)) {
|
||
write_json({
|
||
success: true,
|
||
running: true,
|
||
message: "Lists update is already running"
|
||
});
|
||
return;
|
||
}
|
||
|
||
update_list_status(true, null, "starting", "Starting lists update");
|
||
|
||
let cmd = command_env({
|
||
TACHYON_CONFIG_NAME: CONFIG_NAME,
|
||
TACHYON_LIB: LIB_DIR,
|
||
TACHYON_BIN: BIN_PATH
|
||
}) + " " + BIN_PATH + " list_update";
|
||
|
||
system(common.background_command(cmd));
|
||
|
||
write_json({
|
||
success: true,
|
||
running: true,
|
||
message: "Lists update started"
|
||
});
|
||
}
|
||
|
||
function list_update_status() {
|
||
let pid = trim(as_string(fs.readfile(LIST_UPDATE_PID_FILE) || ""));
|
||
let running = (pid != "" && runtime_pid_running(pid));
|
||
let status_data = object_or_empty(read_json_file(LIST_UPDATE_STATUS_FILE));
|
||
|
||
if (running) {
|
||
write_json({
|
||
success: true,
|
||
running: true,
|
||
progress: status_data.progress || "updating",
|
||
message: status_data.message || "Updating lists...",
|
||
timestamp: status_data.timestamp
|
||
});
|
||
} else {
|
||
write_json({
|
||
success: status_data.success != null ? status_data.success : true,
|
||
running: false,
|
||
progress: status_data.progress || "idle",
|
||
message: status_data.message || "Idle",
|
||
timestamp: status_data.timestamp || file_first_line_value(LIST_UPDATE_STATE_FILE)
|
||
});
|
||
}
|
||
}
|
||
|
||
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 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-async")
|
||
list_update_async();
|
||
else if (mode == "list-update-status")
|
||
list_update_status();
|
||
else if (mode == "list-update-if-due")
|
||
list_update_if_due();
|
||
else if (mode == "stop-list-update")
|
||
stop_list_update();
|
||
else if (mode == "reload-singbox-after-list-update")
|
||
exit(reload_singbox_after_list_update() ? 0 : 1);
|
||
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);
|
||
} |