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;
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 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: "Запись " + domain + " удалена." };
} else {
return { success: false, error: "Запись " + domain + " не найдена." };
}
} 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: "Запись " + domain + " уже есть в секции." };
}
if (uci_core.add_list(option_path, domain)) {
uci_core.commit(CONFIG_NAME);
common.command_status("/usr/bin/tachyon reload");
return { success: true, message: "Запись " + domain + " добавлена." };
}
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,
manage_domain_list,
manage_domain_list_by_section,
get_sections,
get_servers,
reload_tachyon,
toggle_section
};