- fix(zapret2): add safe_str fallback and avoid default --filter-tcp=443 injection on UDP rules in prepare_strategy_args() - fix(providers): make individual rule failures non-fatal in start_runtime() so one bad rule does not rollback all running rules - fix(binaries): dynamic candidate resolution for nfqws and nfqws2 across standard OpenWrt and custom paths (/opt/zapret2/nfq2, /opt/zapret2/nfq, /opt/zapret2, /usr/bin) - fix(nft): add raw_output notrack chain and mangle_output/forward counter return rules for desync marks (0x40000000, 0x20000000) and musl/nobody skuid (2147483647, 65534) to prevent EPERM rawsend failure on reload - fix(reload): ensure nft_rebuild is triggered on zapret and zapret2 runtime updates - fix(singbox): reject mieru outbounds from rule_set download_detour and http_clients dial_detour to prevent fatal startup crash loop - fix(singbox): detect sing-box 1.14+ and omit deprecated download_detour on remote rule-sets when http_clients is active - fix(action): pre-seed /etc/config/zapret2 with enabled=0 via core.uci before package install to prevent conflicting upstream daemon startup - test(ruleset): add verification tests for mieru download_detour and dial_detour rejection
420 lines
14 KiB
Ucode
420 lines
14 KiB
Ucode
let fs = require("fs");
|
|
let uci_core = require("core.uci");
|
|
let common = require("core.common");
|
|
let constants = require("core.constants");
|
|
|
|
const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon";
|
|
const PAUSE_FILE = "/tmp/tachyon_paused_until";
|
|
const WATCHDOG_PID_FILE = "/var/run/tachyon_watchdog.pid";
|
|
|
|
let as_string = common.as_string;
|
|
let command_capture = common.command_capture;
|
|
let command_success_from_args = common.command_success_from_args;
|
|
let command_from_args = common.command_from_args;
|
|
let command_output = common.command_output;
|
|
|
|
// Helper to get clash url
|
|
function get_clash_url(endpoint) {
|
|
let host = "127.0.0.1:9090"; // default fallback
|
|
let config_data = fs.readfile("/etc/sing-box/config.json");
|
|
if (config_data) {
|
|
try {
|
|
let sb_cfg = json(config_data);
|
|
let ext = sb_cfg.experimental?.clash_api?.external_controller;
|
|
if (ext) {
|
|
let parts = split(ext, ":");
|
|
let ip = (length(parts) > 1) ? parts[0] : "";
|
|
let port = (length(parts) > 1) ? parts[length(parts) - 1] : "9090";
|
|
if (ip == "0.0.0.0" || ip == "") {
|
|
host = "127.0.0.1:" + port;
|
|
} else {
|
|
host = ext;
|
|
}
|
|
}
|
|
}
|
|
catch (e) {
|
|
// Falls through to the default host below. The clash API is optional
|
|
// and every caller already treats an unreachable one as "no data".
|
|
}
|
|
}
|
|
return "http://" + host + "/" + endpoint;
|
|
}
|
|
|
|
// ─── API Functions ───────────────────────────────────────────────────────────
|
|
|
|
function get_pause_remaining() {
|
|
let val = trim(fs.readfile(PAUSE_FILE) || "");
|
|
if (val == "") return 0;
|
|
let until = int(val);
|
|
let now = time();
|
|
if (until <= now) return 0;
|
|
return until - now;
|
|
}
|
|
|
|
function process_running_by_pidfile(pidfile) {
|
|
let pid = trim(fs.readfile(pidfile) || "");
|
|
return pid != "" && match(pid, /^[0-9]+$/) != null &&
|
|
command_success_from_args(["kill", "-0", pid]);
|
|
}
|
|
|
|
function get_clash_proxies_data() {
|
|
let args = [ "curl", "-s", "-m", "5", "--connect-timeout", "3", get_clash_url("proxies") ];
|
|
let res = command_capture(command_from_args(args));
|
|
if (res && res.status == 0 && res.output != "") {
|
|
try {
|
|
return json(res.output);
|
|
} catch (e) {
|
|
warn("api: failed to parse proxies JSON: " + as_string(e) + "\n");
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function get_system_status() {
|
|
let status_obj = {};
|
|
|
|
// CPU
|
|
let stat = fs.readfile("/proc/loadavg") || "";
|
|
let load = split(stat, / /);
|
|
status_obj.cpu = (length(load) > 0) ? load[0] : "unknown";
|
|
|
|
// RAM
|
|
let mem = fs.readfile("/proc/meminfo") || "";
|
|
let total_kb = 0, avail_kb = 0;
|
|
for (let line in split(mem, "\n")) {
|
|
if (index(line, "MemTotal:") == 0) {
|
|
total_kb = int(split(trim(line), /[ \t]+/)[1]);
|
|
} else if (index(line, "MemAvailable:") == 0) {
|
|
avail_kb = int(split(trim(line), /[ \t]+/)[1]);
|
|
}
|
|
}
|
|
status_obj.ram_total = total_kb ? int(total_kb / 1024) : 0;
|
|
status_obj.ram_avail = avail_kb ? int(avail_kb / 1024) : 0;
|
|
|
|
// Uptime
|
|
let upt = fs.readfile("/proc/uptime") || "";
|
|
let up_sec = double(split(upt, / /)[0] || 0);
|
|
let d = int(up_sec / 86400);
|
|
let h = int((up_sec - (d * 86400)) / 3600);
|
|
let m = int((up_sec - (d * 86400) - (h * 3600)) / 60);
|
|
status_obj.uptime = sprintf("%d дн. %02d:%02d", d, h, m);
|
|
|
|
// sing-box status
|
|
let sb_running = command_success_from_args(["pidof", "sing-box"]);
|
|
status_obj.singbox_running = sb_running;
|
|
status_obj.singbox = sb_running ? "🟢 running" : "🔴 stopped";
|
|
let sb_variant = trim(as_string(fs.readfile("/etc/tachyon/sing-box-variant") || ""));
|
|
if (sb_variant != "")
|
|
status_obj.singbox_variant = sb_variant;
|
|
|
|
// zapret2 status
|
|
let z2_installed = fs.stat("/opt/zapret2/nfq2/nfqws2") != null ||
|
|
fs.stat("/opt/zapret2/nfq/nfqws2") != null ||
|
|
fs.stat("/opt/zapret2/nfqws2") != null ||
|
|
fs.stat("/usr/bin/nfqws2") != null;
|
|
status_obj.zapret2_installed = z2_installed;
|
|
if (z2_installed) {
|
|
let z2_running = command_success_from_args(["pidof", "nfqws2"]);
|
|
status_obj.zapret2_running = z2_running;
|
|
status_obj.zapret2 = z2_running ? "🟢 running" : "🔴 stopped";
|
|
}
|
|
|
|
status_obj.tachyon_running = command_success_from_args(["/etc/init.d/tachyon", "status"]);
|
|
|
|
// Active servers for all Selector groups
|
|
status_obj.active_servers = {};
|
|
|
|
let pdata = get_clash_proxies_data();
|
|
if (pdata && pdata.proxies) {
|
|
for (let gname in keys(pdata.proxies)) {
|
|
let p = pdata.proxies[gname];
|
|
if (p.type == "Selector" || p.type == "URLTest" || p.type == "Fallback") {
|
|
if (p.now) {
|
|
let srv_name = p.now;
|
|
let latency = "N/A";
|
|
let srv = pdata.proxies[srv_name];
|
|
if (srv && type(srv.history) == "array" && length(srv.history) > 0) {
|
|
let last = srv.history[length(srv.history) - 1];
|
|
if (last && last.delay) latency = last.delay;
|
|
}
|
|
status_obj.active_servers[gname] = { server: srv_name, latency: latency };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Watchdog status
|
|
status_obj.watchdog_running = process_running_by_pidfile(WATCHDOG_PID_FILE);
|
|
|
|
// Pause state
|
|
status_obj.pause_remaining = get_pause_remaining();
|
|
|
|
// IPs
|
|
status_obj.lan_ip = as_string(common.command_output("uci -q get network.lan.ipaddr"));
|
|
status_obj.wan_ip = as_string(common.command_output("ip route get 8.8.8.8 2>/dev/null | grep -Eo 'src [0-9.]+' | awk '{print $2}'"));
|
|
|
|
// Tachyon Version
|
|
let ver = "unknown";
|
|
let opkg_out = common.command_output("opkg status tachyon 2>/dev/null | grep Version");
|
|
if (opkg_out && trim(opkg_out) != "") {
|
|
ver = split(trim(opkg_out), " ")[1];
|
|
} else {
|
|
let apk_out = common.command_output("apk list tachyon 2>/dev/null");
|
|
if (apk_out && trim(apk_out) != "") {
|
|
let m = match(apk_out, /tachyon-([0-9A-Za-z._+-]+)/);
|
|
if (m && m[1]) ver = m[1];
|
|
}
|
|
}
|
|
status_obj.tachyon_version = ver || "unknown";
|
|
status_obj.tachyon_commit_sha = constants.TACHYON_COMMIT_SHA && !match(constants.TACHYON_COMMIT_SHA, /COMPILED/) ? constants.TACHYON_COMMIT_SHA : "";
|
|
|
|
return status_obj;
|
|
}
|
|
|
|
function clash_request(method, endpoint, payload) {
|
|
let url = get_clash_url(endpoint);
|
|
// ucode has no Math global (math is a separate loadable module), so the
|
|
// microsecond field of clock() supplies the uniqueness suffix, the same way
|
|
// temp paths are built elsewhere in this codebase.
|
|
let payload_path = "/tmp/clash_payload_" + method + "_" + time() + "_" + clock()[1] + ".json";
|
|
let res = null;
|
|
try {
|
|
let args = [ "curl", "-s", "-m", "5", "--connect-timeout", "3", "-X", method ];
|
|
if (payload) {
|
|
fs.writefile(payload_path, sprintf("%J", payload));
|
|
push(args, "-H", "Content-Type: application/json", "-d", "@" + payload_path);
|
|
}
|
|
push(args, url);
|
|
res = command_capture(command_from_args(args));
|
|
}
|
|
catch (e) {
|
|
if (payload) {
|
|
try { fs.unlink(payload_path); } catch(err) {}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (payload) {
|
|
try { fs.unlink(payload_path); } catch(err) {}
|
|
}
|
|
|
|
if (!res || res.status != 0 || res.output == "") {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return json(res.output);
|
|
}
|
|
catch (e) {
|
|
return res.output;
|
|
}
|
|
}
|
|
|
|
function get_clash_connections() {
|
|
let args = [ "curl", "-s", "-m", "5", "--connect-timeout", "3", get_clash_url("connections") ];
|
|
let res = command_capture(command_from_args(args));
|
|
if (res && res.status == 0 && res.output != "") {
|
|
try {
|
|
return json(res.output);
|
|
}
|
|
catch (e) {
|
|
// Falls through to null, which every caller already handles as "the
|
|
// clash API gave us nothing" - the same result as a failed curl.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function http_probe(use_proxy) {
|
|
// curl exits 0 for any completed HTTP exchange, including 4xx/5xx, so the
|
|
// status code has to be inspected explicitly.
|
|
let cmd = "curl -I -s -o /dev/null -m 8 --connect-timeout 4 -w '%{http_code}' " +
|
|
(use_proxy ? "--proxy http://127.0.0.1:" + common.get_mixed_port() + " " : "") +
|
|
"https://www.google.com";
|
|
let res = command_capture(cmd);
|
|
if (!res || res.status != 0) return false;
|
|
let code = int(trim(as_string(res.output || "0")));
|
|
return code >= 200 && code < 400;
|
|
}
|
|
|
|
function check_connection() {
|
|
return { direct: http_probe(false), proxy: http_probe(true) };
|
|
}
|
|
|
|
function run_speedtest() {
|
|
// Direct speedtest
|
|
let res_direct = command_capture("curl -s -m 15 --connect-timeout 6 -w '%{speed_download}' -o /dev/null https://speed.cloudflare.com/__down?bytes=5242880");
|
|
let direct_speed = (res_direct && res_direct.status == 0) ? double(res_direct.output || 0) : 0;
|
|
let direct_mbps = (direct_speed * 8) / 1000000;
|
|
|
|
// Proxy speedtest
|
|
let res_proxy = command_capture("curl -s -m 15 --connect-timeout 6 --proxy http://127.0.0.1:" + common.get_mixed_port() + " -w '%{speed_download}' -o /dev/null https://speed.cloudflare.com/__down?bytes=5242880");
|
|
let proxy_speed = (res_proxy && res_proxy.status == 0) ? double(res_proxy.output || 0) : 0;
|
|
let proxy_mbps = (proxy_speed * 8) / 1000000;
|
|
|
|
return {
|
|
direct_mbps,
|
|
proxy_mbps,
|
|
direct_ok: direct_speed > 0,
|
|
proxy_ok: proxy_speed > 0
|
|
};
|
|
}
|
|
|
|
function run_leak_check() {
|
|
let leak_mod = require("diagnostics.leak_check");
|
|
if (!leak_mod) return null;
|
|
return leak_mod.run_leak_check(null, null);
|
|
}
|
|
|
|
function run_ip_leak_check() {
|
|
let leak_mod = require("diagnostics.leak_check");
|
|
if (!leak_mod) return null;
|
|
return leak_mod.check_ip_leak(null, null);
|
|
}
|
|
|
|
function run_dns_leak_check() {
|
|
let leak_mod = require("diagnostics.leak_check");
|
|
if (!leak_mod) return null;
|
|
return leak_mod.check_dns_leak(null, null, null, null);
|
|
}
|
|
|
|
function run_leak_check_async() {
|
|
let leak_mod = require("diagnostics.leak_check");
|
|
if (!leak_mod) return null;
|
|
return leak_mod.start_leak_check_async();
|
|
}
|
|
|
|
function run_leak_check_status(job_id) {
|
|
let leak_mod = require("diagnostics.leak_check");
|
|
if (!leak_mod) return null;
|
|
return leak_mod.get_leak_check_status(job_id);
|
|
}
|
|
|
|
function manage_domain_list(action_type, domain, do_delete) {
|
|
let c = uci_core.cursor();
|
|
if (!c) return { success: false, error: "Не удалось инициализировать UCI" };
|
|
c.load(CONFIG_NAME);
|
|
|
|
let target_section = null;
|
|
c.foreach(CONFIG_NAME, "section", function(s) {
|
|
if (s.enabled == "1" && s.action == action_type) {
|
|
target_section = s[".name"];
|
|
return false;
|
|
}
|
|
});
|
|
|
|
if (!target_section) {
|
|
return { success: false, error: "Не найдено активное правило с действием '" + action_type + "'." };
|
|
}
|
|
|
|
return manage_domain_list_by_section(target_section, domain, do_delete);
|
|
}
|
|
|
|
function manage_domain_list_by_section(sec_name, domain, do_delete) {
|
|
let c = uci_core.cursor();
|
|
if (!c) return { success: false, error: "Не удалось инициализировать UCI" };
|
|
c.load(CONFIG_NAME);
|
|
|
|
let s = c.get_all(CONFIG_NAME, sec_name);
|
|
if (!s) {
|
|
return { success: false, error: "Секция '" + sec_name + "' не найдена." };
|
|
}
|
|
|
|
let option_path = CONFIG_NAME + "." + sec_name + ".domain";
|
|
|
|
if (do_delete) {
|
|
if (uci_core.del_list(option_path, domain)) {
|
|
uci_core.commit(CONFIG_NAME);
|
|
common.command_status("/usr/bin/tachyon reload");
|
|
return { success: true, message: "Запись <code>" + domain + "</code> удалена." };
|
|
} else {
|
|
return { success: false, error: "Запись <code>" + domain + "</code> не найдена." };
|
|
}
|
|
} else {
|
|
let current_val = uci_core.get(option_path);
|
|
let exists = false;
|
|
if (current_val != "") {
|
|
for (let item in split(current_val, " ")) {
|
|
if (item == domain) {
|
|
exists = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (exists) {
|
|
return { success: false, error: "Запись <code>" + domain + "</code> уже есть в секции." };
|
|
}
|
|
|
|
if (uci_core.add_list(option_path, domain)) {
|
|
uci_core.commit(CONFIG_NAME);
|
|
common.command_status("/usr/bin/tachyon reload");
|
|
return { success: true, message: "Запись <code>" + domain + "</code> добавлена." };
|
|
}
|
|
return { success: false, error: "Не удалось добавить запись." };
|
|
}
|
|
}
|
|
|
|
function get_sections() {
|
|
let c = uci_core.cursor();
|
|
if (!c) return [];
|
|
c.load(CONFIG_NAME);
|
|
|
|
let sections = [];
|
|
c.foreach(CONFIG_NAME, "section", function(s) {
|
|
push(sections, s);
|
|
});
|
|
return sections;
|
|
}
|
|
|
|
function get_servers() {
|
|
let c = uci_core.cursor();
|
|
if (!c) return [];
|
|
c.load(CONFIG_NAME);
|
|
|
|
let servers = [];
|
|
c.foreach(CONFIG_NAME, "server", function(s) {
|
|
push(servers, s);
|
|
});
|
|
return servers;
|
|
}
|
|
|
|
function reload_tachyon() {
|
|
return common.command_status("/usr/bin/tachyon reload") == 0;
|
|
}
|
|
|
|
function toggle_section(sec_name) {
|
|
let c = uci_core.cursor();
|
|
if (!c) return false;
|
|
c.load(CONFIG_NAME);
|
|
|
|
let s = c.get_all(CONFIG_NAME, sec_name);
|
|
if (!s) return false;
|
|
|
|
let new_val = (s.enabled == "1") ? "0" : "1";
|
|
uci_core.set(CONFIG_NAME + "." + sec_name + ".enabled", new_val);
|
|
uci_core.commit(CONFIG_NAME);
|
|
reload_tachyon();
|
|
return true;
|
|
}
|
|
return {
|
|
get_pause_remaining,
|
|
process_running_by_pidfile,
|
|
get_system_status,
|
|
get_clash_proxies_data,
|
|
clash_request,
|
|
get_clash_connections,
|
|
check_connection,
|
|
run_speedtest,
|
|
run_leak_check,
|
|
run_leak_check_async,
|
|
run_leak_check_status,
|
|
run_ip_leak_check,
|
|
run_dns_leak_check,
|
|
manage_domain_list,
|
|
manage_domain_list_by_section,
|
|
get_sections,
|
|
get_servers,
|
|
reload_tachyon,
|
|
toggle_section
|
|
};
|