Files
NeoTachyon/tachyon/files/usr/lib/diagnostics/service_check.uc
T

2081 lines
80 KiB
Ucode
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env ucode
// Tachyon Service Check - probe engine for Tachyon.
//
// Проверяет доступность сервисов так, как это делает клиент сети: имя резолвится
// через тот же dnsmasq -> sing-box, а соединение уходит из роутера и попадает в
// цепочку mangle_output -> tproxy, то есть тем же путём, что трафик из LAN.
// В режиме netns трафик реально входит через br-lan из отдельного namespace,
// что дополнительно воспроизводит правила секций, привязанные к source IP.
let fs = require("fs");
let common = require("core.common");
const LIB_DIR = getenv("TACHYON_SC_LIB") || "/usr/lib/tachyon-servicecheck";
const ENGINE = LIB_DIR + "/probe.uc";
const PROFILES_OVERRIDE = "/etc/tachyon/servicecheck_profiles.json";
const PROFILES_DEFAULT = "/usr/share/tachyon/servicecheck_profiles.json";
const STATE_DIR = "/var/run/tachyon-servicecheck";
const TACHYON_BIN = "/usr/bin/tachyon";
const DEFAULT_SING_BOX_CONFIG = "/etc/sing-box/config.json";
const NETNS_NAME = "fkpsc";
const NETNS_VETH_HOST = "fkpsc0";
const NETNS_VETH_PEER = "fkpsc1";
const XHTTP_PATCH = "";
const ICMP_TPROXY_PATCH = "";
const CONFIG_DIR = "/etc/tachyon/servicecheck";
const GEMINI_API_KEY_FILE = CONFIG_DIR + "/gemini_api_key";
const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
const DNS_TIMEOUT = 3;
const CONNECT_TIMEOUT = 7;
const TOTAL_TIMEOUT = 15;
const SLOW_THRESHOLD_MS = 5000;
const JOB_MAX_AGE = 1800;
function as_string(value) {
return value == null ? "" : "" + value;
}
function object_or_empty(value) {
return type(value) == "object" ? value : {};
}
function array_or_empty(value) {
return type(value) == "array" ? value : [];
}
function shell_quote(value) {
return "'" + replace(as_string(value), /'/g, "'\\''") + "'";
}
function command_from_args(args) {
let parts = [];
for (let arg in args)
push(parts, shell_quote(arg));
return join(" ", parts);
}
function normalize_status(status) {
status = int(status);
if (status < 0)
return 1;
return status > 255 ? int(status / 256) : status;
}
function now_ms() {
let value = null;
try {
value = clock(true);
}
catch (e) {
value = null;
}
if (type(value) != "array")
value = clock();
if (type(value) != "array")
return 0;
return value[0] * 1000 + int(value[1] / 1000000);
}
function now_seconds() {
let value = clock();
return type(value) == "array" ? int(value[0]) : 0;
}
function capture(command) {
let pipe = fs.popen(command, "r");
if (!pipe)
return { status: 1, output: "" };
let data = pipe.read("all");
let status = normalize_status(pipe.close());
return { status, output: data == null ? "" : as_string(data) };
}
function capture_args(args, merge_stderr) {
return capture(command_from_args(args) + (merge_stderr ? " 2>&1" : " 2>/dev/null"));
}
function run_quiet(args) {
return normalize_status(system(command_from_args(args) + " >/dev/null 2>&1")) == 0;
}
function command_exists(name) {
return run_quiet([ "command", "-v", as_string(name) ]);
}
function write_json(value) {
print(sprintf("%J", value), "\n");
}
function xhttp_patch() {
if (fs.stat(XHTTP_PATCH) == null) {
write_json({ success: false, message: "xHTTP-патч не установлен в пакет" });
return 1;
}
let result = capture_args([ "sh", XHTTP_PATCH ], true);
write_json({ success: result.status == 0, code: result.status, output: trim(as_string(result.output)) });
return result.status;
}
function icmp_tproxy_patch() {
if (fs.stat(ICMP_TPROXY_PATCH) == null) {
write_json({ success: false, message: "ICMP/TProxy-патч не установлен в пакет" });
return 1;
}
let result = capture_args([ "sh", ICMP_TPROXY_PATCH ], true);
write_json({ success: result.status == 0, code: result.status, output: trim(as_string(result.output)) });
return result.status;
}
function available_fixes() {
if (fs.stat(TACHYON_BIN) == null)
return [];
return [
{
id: "xhttp_import",
title: "Фикс xHTTP импорта подписок",
description: "Исправляет импорт дополнительных полей xHTTP из подписок в parser.uc Tachyon.",
risk: "Создаётся резервная копия parser.uc; патч проверяется через ucode до замены."
},
{
id: "icmp_tproxy",
title: "Фикс ping/ICMP для правил подсетей",
description: "Ограничивает метку TProxy в priority_rules протоколами TCP и UDP, чтобы ICMP не уходил в локальный TProxy-сокет.",
risk: "Создаётся резервная копия nft/apply.uc; патч проверяется через ucode. Работающий Tachyon перезапускается, live-правила проверяются, а при ошибке выполняется откат."
}
];
}
function list_fixes() {
write_json({ success: true, fixes: available_fixes() });
return 0;
}
function run_fix(id) {
if (fs.stat(TACHYON_BIN) == null) {
write_json({ success: false, message: "фиксы доступны только при установленном Tachyon" });
return 1;
}
if (as_string(id) == "xhttp_import")
return xhttp_patch();
if (as_string(id) == "icmp_tproxy")
return icmp_tproxy_patch();
write_json({ success: false, message: "неизвестный фикс Forkop" });
return 1;
}
function parse_json(value) {
try {
return json(as_string(value));
}
catch (e) {
return null;
}
}
function read_json_file(path) {
let data = fs.readfile(as_string(path));
return data == null ? null : parse_json(data);
}
function trim_newlines(value) {
return replace(as_string(value), /[\r\n]+$/g, "");
}
function words(value) {
value = trim(as_string(value));
return value == "" ? [] : split(value, /[ \t\r\n]+/);
}
// curl отдаёт тайминги как "0.123". Собирать число по частям нельзя: деление двух
// целых в ucode целочисленное, и дробная часть схлопывается в ноль. Умножение же
// приводит числовую строку к double, поэтому проверяем формат и умножаем.
function number_value(value) {
value = trim(as_string(value));
if (match(value, /^[0-9]+(\.[0-9]+)?$/) == null)
return 0;
return value * 1;
}
function temp_path(suffix) {
return sprintf("/tmp/.forkop-sc-%d-%d.%s", now_seconds(), int(now_ms() % 1000000), as_string(suffix));
}
function ensure_state_dir() {
run_quiet([ "mkdir", "-p", STATE_DIR ]);
}
function profiles_file() {
let override = getenv("FORKOP_SC_PROFILES");
if (as_string(override) != "")
return as_string(override);
if (fs.stat(PROFILES_OVERRIDE) != null)
return PROFILES_OVERRIDE;
return PROFILES_DEFAULT;
}
// ---------------------------------------------------------------------------
// Сеть: разбор адресов
// ---------------------------------------------------------------------------
function valid_ipv4(value) {
value = trim(as_string(value));
let matched = match(value, /^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/);
if (matched == null)
return false;
for (let i = 1; i <= 4; i++)
if (int(matched[i]) > 255)
return false;
return true;
}
function valid_domain(value) {
value = trim(as_string(value));
if (length(value) < 1 || length(value) > 253 || index(value, "..") >= 0)
return false;
for (let label in split(value, ".")) {
if (length(label) < 1 || length(label) > 63 ||
match(label, /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$/) == null)
return false;
}
return true;
}
function valid_custom_host(value) {
value = trim(as_string(value));
if (valid_ipv4(value))
return true;
// Не принимаем ошибочный IPv4 вроде 999.1.1.1 за обычное доменное имя.
if (match(value, /^[0-9.]+$/) != null)
return false;
return valid_domain(value);
}
function ipv4_to_int(value) {
let parts = split(trim(as_string(value)), ".");
if (length(parts) != 4)
return -1;
return int(parts[0]) * 16777216 + int(parts[1]) * 65536 + int(parts[2]) * 256 + int(parts[3]);
}
function int_to_ipv4(value) {
value = int(value);
let a = int(value / 16777216) % 256;
let b = int(value / 65536) % 256;
let c = int(value / 256) % 256;
let d = value % 256;
return sprintf("%d.%d.%d.%d", a, b, c, d);
}
function prefix_mask(prefix) {
prefix = int(prefix);
if (prefix <= 0)
return 0;
if (prefix >= 32)
return 4294967295;
return (4294967295 << (32 - prefix)) & 4294967295;
}
function ipv4_in_cidr(ip, cidr) {
let parts = split(as_string(cidr), "/");
if (length(parts) != 2 || !valid_ipv4(ip) || !valid_ipv4(parts[0]))
return false;
let mask = prefix_mask(parts[1]);
return (ipv4_to_int(ip) & mask) == (ipv4_to_int(parts[0]) & mask);
}
function ipv4_in_any_cidr(ip, cidrs) {
for (let cidr in array_or_empty(cidrs))
if (ipv4_in_cidr(ip, cidr))
return true;
return false;
}
// ---------------------------------------------------------------------------
// Окружение роутера
// ---------------------------------------------------------------------------
function uci_get(path) {
let result = capture_args([ "uci", "-q", "get", as_string(path) ]);
return result.status == 0 ? trim_newlines(result.output) : "";
}
function backend_id() {
let override = lc(trim(as_string(getenv("TACHYON_SC_BACKEND"))));
if (override == "tachyon")
return override;
if (fs.stat(TACHYON_BIN) != null)
return "tachyon";
return "none";
}
function backend_name(id) {
id = as_string(id);
if (id == "tachyon")
return "Tachyon";
return "Sing-box";
}
function sing_box_config_path() {
let overridden = trim(as_string(getenv("TACHYON_SC_SING_BOX_CONFIG")));
if (overridden != "")
return overridden;
if (backend_id() == "tachyon") {
let configured = trim(uci_get("tachyon.settings.config_path"));
if (configured != "")
return configured;
}
return DEFAULT_SING_BOX_CONFIG;
}
function lan_interface() {
let namespace = backend_id() == "podkop" ? "podkop" : "forkop";
let configured = words(uci_get(namespace + ".settings.source_network_interfaces"));
if (length(configured) > 0)
return configured[0];
return "br-lan";
}
function interface_ipv4(interface) {
let output = capture_args([ "ip", "-4", "-o", "addr", "show", "dev", as_string(interface) ], false).output;
for (let line in split(output, "\n")) {
let matched = match(trim(as_string(line)), /inet[ \t]+([0-9.]+)\/([0-9]+)/);
if (matched != null)
return { address: matched[1], prefix: int(matched[2]) };
}
return null;
}
function fakeip_ranges() {
let ranges = [];
let config = read_json_file(sing_box_config_path());
let dns = object_or_empty(object_or_empty(config).dns);
for (let server in array_or_empty(dns.servers)) {
server = object_or_empty(server);
if (as_string(server.type) == "fakeip") {
if (as_string(server.inet4_range) != "")
push(ranges, as_string(server.inet4_range));
}
}
let legacy = object_or_empty(dns.fakeip);
if (as_string(legacy.inet4_range) != "")
push(ranges, as_string(legacy.inet4_range));
if (length(ranges) == 0)
push(ranges, "198.18.0.0/15");
return ranges;
}
function forkop_running() {
let backend = backend_id();
let result;
if (backend == "forkop")
result = capture_args([ FORKOP_BIN, "get_status" ], false);
else if (backend == "podkop")
result = capture_args([ PODKOP_BIN, "get_sing_box_status" ], false);
else
return false;
if (result.status != 0) {
if (backend == "podkop")
return run_quiet([ "pgrep", "-f", "sing-box" ]);
return false;
}
let status = object_or_empty(parse_json(result.output));
if (status.running === true || int(status.running) == 1)
return true;
if (backend == "podkop" && status.running == null)
return run_quiet([ "pgrep", "-f", "sing-box" ]);
return false;
}
// Сборки busybox сильно разнятся: где-то nc умеет -z и -w, где-то это
// "Usage: nc [IPADDR PORT]" вообще без флагов. Пробуем от лучшего к худшему.
function nc_option_rejected(command) {
let output = lc(as_string(capture(command).output));
return index(output, "invalid option") >= 0 || index(output, "unrecognized option") >= 0 ||
index(output, "usage:") >= 0 || index(output, "bad port") >= 0;
}
function detect_nc_mode() {
if (!command_exists("nc"))
return "none";
if (!nc_option_rejected("nc -z -w 1 127.0.0.1 1 2>&1"))
return "zero";
if (!nc_option_rejected("nc -w 1 127.0.0.1 1 2>&1"))
return "wait";
return "plain";
}
function capabilities() {
let backend = backend_id();
let running = forkop_running();
let interface = lan_interface();
let address = interface_ipv4(interface);
let nc_mode = detect_nc_mode();
return {
curl: command_exists("curl"),
uclient_fetch: command_exists("uclient-fetch") || command_exists("wget"),
dig: command_exists("dig"),
nslookup: command_exists("nslookup"),
nc: nc_mode != "none",
nc_mode,
timeout_cmd: command_exists("timeout"),
netns: run_quiet([ "ip", "netns", "list" ]) && command_exists("ip"),
lan_interface: interface,
lan_address: address == null ? "" : address.address,
lan_prefix: address == null ? 0 : address.prefix,
fakeip_ranges: fakeip_ranges(),
backend,
backend_name: backend_name(backend),
backend_installed: backend != "none",
backend_running: running,
forkop_installed: fs.stat(FORKOP_BIN) != null,
podkop_installed: fs.stat(PODKOP_BIN) != null,
fixes_available: backend == "forkop" && fs.stat(FORKOP_BIN) != null,
sing_box_config: sing_box_config_path(),
// Старое поле оставлено для совместимости со страницей предыдущих версий.
forkop_running: running,
profiles_version: int(object_or_empty(read_json_file(profiles_file())).version || 0)
};
}
// ---------------------------------------------------------------------------
// Профили
// ---------------------------------------------------------------------------
function load_profiles() {
let data = object_or_empty(read_json_file(profiles_file()));
return array_or_empty(data.profiles);
}
function validate_profiles_config(config) {
if (type(config) != "object" || type(config.profiles) != "array")
return "корневой объект должен содержать массив profiles";
let profiles = array_or_empty(config.profiles);
if (length(profiles) == 0)
return "список profiles не может быть пустым";
if (length(profiles) > 100)
return "допускается не более 100 профилей";
let seen = {};
let allowed_kinds = { https: true, http: true, tcp: true, udp: true, udp_dns: true };
for (let profile in profiles) {
profile = object_or_empty(profile);
let id = as_string(profile.id);
let title = trim(as_string(profile.title));
let targets = array_or_empty(profile.targets);
if (match(id, /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/) == null)
return "некорректный id профиля: " + id;
if (seen[id])
return "id профиля повторяется: " + id;
seen[id] = true;
if (title == "")
return "у профиля " + id + " не задан title";
if (length(targets) == 0 || length(targets) > 50)
return "у профиля " + id + " должно быть от 1 до 50 целей";
for (let target in targets) {
target = object_or_empty(target);
let kind = as_string(target.kind || "https");
let host = trim(as_string(target.host));
let port = int(target.port || (kind == "http" ? 80 : 443));
if (!allowed_kinds[kind])
return "неподдерживаемый kind в профиле " + id + ": " + kind;
if (host == "" || match(host, /[ \t\r\n\/]/) != null)
return "некорректный host в профиле " + id;
if (port < 1 || port > 65535)
return "некорректный port в профиле " + id;
}
}
return "";
}
function profiles_get() {
let path = profiles_file();
let config = read_json_file(path);
if (type(config) != "object") {
write_json({ success: false, message: "не удалось прочитать список проверок" });
return 1;
}
write_json({ success: true, source: path == PROFILES_OVERRIDE ? "custom" : "built-in", config });
return 0;
}
function profiles_save(payload) {
payload = as_string(payload);
if (length(payload) == 0 || length(payload) > 131072) {
write_json({ success: false, message: "размер списка должен быть от 1 байта до 128 КиБ" });
return 1;
}
let config = parse_json(payload);
let error = validate_profiles_config(config);
if (error != "") {
write_json({ success: false, message: error });
return 1;
}
if (!run_quiet([ "mkdir", "-p", "/etc/forkop-servicecheck" ])) {
write_json({ success: false, message: "не удалось создать /etc/forkop-servicecheck" });
return 1;
}
let temporary = PROFILES_OVERRIDE + ".tmp";
if (fs.writefile(temporary, sprintf("%J", config) + "\n") == null ||
!run_quiet([ "chmod", "0644", temporary ]) ||
!run_quiet([ "mv", "-f", temporary, PROFILES_OVERRIDE ])) {
fs.unlink(temporary);
write_json({ success: false, message: "не удалось сохранить пользовательский список" });
return 1;
}
write_json({ success: true, message: "пользовательский список сохранён", profiles: length(config.profiles) });
return 0;
}
function profiles_reset() {
if (fs.stat(PROFILES_OVERRIDE) != null && fs.unlink(PROFILES_OVERRIDE) == null) {
write_json({ success: false, message: "не удалось удалить пользовательский список" });
return 1;
}
write_json({ success: true, message: "восстановлен встроенный список" });
return 0;
}
function target_label(target) {
target = object_or_empty(target);
if (as_string(target.label) != "")
return as_string(target.label);
if (as_string(target.kind) == "tcp" || as_string(target.kind) == "udp" || as_string(target.kind) == "udp_dns")
return sprintf("%s:%d", as_string(target.host), int(target.port || 443));
return as_string(target.host) + as_string(target.path || "/");
}
function target_url(target) {
target = object_or_empty(target);
let scheme = as_string(target.kind) == "http" ? "http" : "https";
let port = int(target.port || 0);
let host = as_string(target.host);
let authority = port > 0 && port != 443 && port != 80 ? host + ":" + as_string(port) : host;
let path = as_string(target.path || "/");
if (substr(path, 0, 1) != "/")
path = "/" + path;
return scheme + "://" + authority + path;
}
// ---------------------------------------------------------------------------
// Пробы
// ---------------------------------------------------------------------------
function prefixed_args(ctx, args) {
let full = [];
for (let arg in array_or_empty(ctx.exec_prefix))
push(full, arg);
for (let arg in args)
push(full, arg);
return full;
}
function probe_dns(ctx, host) {
if (valid_ipv4(host))
return { ok: true, ip: host, fakeip: false, ms: 0, error: "", skipped: true };
let started = now_ms();
let addresses = [];
if (ctx.tools.dig) {
let result = capture_args(prefixed_args(ctx, [
"dig", "@" + ctx.resolver, as_string(host), "A", "+short",
"+timeout=" + as_string(DNS_TIMEOUT), "+tries=1"
]), false);
for (let line in split(result.output, "\n")) {
line = trim(as_string(line));
if (valid_ipv4(line))
push(addresses, line);
}
}
else if (ctx.tools.nslookup) {
let result = capture_args(prefixed_args(ctx, [ "nslookup", as_string(host), ctx.resolver ]), true);
// busybox печатает "Address 1: 1.2.3.4 host.example", хвост после адреса игнорируем.
for (let line in split(result.output, "\n")) {
let matched = match(trim(as_string(line)), /^Address[ \t0-9]*:[ \t]+([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)/);
if (matched != null && valid_ipv4(matched[1]) && matched[1] != ctx.resolver)
push(addresses, matched[1]);
}
}
let elapsed = now_ms() - started;
if (length(addresses) == 0)
return { ok: false, ip: "", fakeip: false, ms: elapsed, error: "нет A-записи" };
return {
ok: true,
ip: addresses[0],
all: addresses,
fakeip: ipv4_in_any_cidr(addresses[0], ctx.fakeip_ranges),
ms: elapsed,
error: ""
};
}
function curl_failure_verdict(status, output) {
output = lc(as_string(output));
if (status == 6)
return { verdict: "dns_fail", message: "имя не резолвится" };
if (status == 7)
return { verdict: "tcp_refused", message: "не удалось установить TCP-соединение" };
if (status == 28)
return { verdict: "timeout", message: "таймаут соединения" };
// Три "ошибки TLS" у curl имеют совершенно разные причины, и валить их в одну
// строку - значит прятать диагноз. 35 - хендшейк оборвали (типовая подпись DPI),
// 51 - сертификат отклонён, 60 - сертификат не удалось проверить, а это почти
// всегда сбитые часы роутера (нет RTC) или отсутствующий ca-bundle.
if (status == 35)
return { verdict: "tls_handshake", message: "TLS-хендшейк оборван — похоже на DPI или несовместимый TLS" };
if (status == 51)
return { verdict: "tls_cert_rejected", message: "сертификат сервера отклонён" };
if (status == 60)
return { verdict: "tls_cert_untrusted", message: "сертификат не проверен — проверьте часы роутера и наличие ca-bundle" };
if (status == 52)
return { verdict: "tls_reset", message: "сервер закрыл соединение без ответа" };
if (status == 56)
return { verdict: "tls_reset", message: "соединение сброшено при передаче данных" };
if (status == 47)
return { verdict: "redirect_loop", message: "сервис отвечает, но перенаправляет по кругу" };
if (index(output, "connection reset") >= 0)
return { verdict: "tls_reset", message: "соединение сброшено" };
return { verdict: "failed", message: trim(as_string(output)) != "" ? trim(as_string(output)) : sprintf("curl завершился с кодом %d", status) };
}
function http_probe_curl(ctx, target) {
let url = target_url(target);
let format = "%{http_code}|%{time_namelookup}|%{time_connect}|%{time_appconnect}|%{time_starttransfer}|%{time_total}|%{remote_ip}";
let args = prefixed_args(ctx, [
"curl", "-sS", "-o", "/dev/null", "-L", "--max-redirs", "5",
"--connect-timeout", as_string(CONNECT_TIMEOUT),
"--max-time", as_string(TOTAL_TIMEOUT),
"-A", USER_AGENT,
"-w", format,
url
]);
let error_file = temp_path("curl-err");
let result = capture(command_from_args(args) + " 2>" + shell_quote(error_file));
let stderr = as_string(fs.readfile(error_file) || "");
fs.unlink(error_file);
if (result.status != 0) {
let failure = curl_failure_verdict(result.status, stderr);
return {
reached: false,
code: 0,
remote_ip: "",
tcp_ms: 0,
tls_ms: 0,
total_ms: 0,
verdict: failure.verdict,
message: failure.message
};
}
let fields = split(trim(as_string(result.output)), "|");
if (length(fields) < 7)
return { reached: false, code: 0, remote_ip: "", tcp_ms: 0, tls_ms: 0, total_ms: 0, verdict: "failed", message: "не удалось разобрать ответ curl" };
let namelookup = number_value(fields[1]) * 1000;
let connect = number_value(fields[2]) * 1000;
let appconnect = number_value(fields[3]) * 1000;
let total = number_value(fields[5]) * 1000;
return {
reached: true,
code: int(fields[0]),
remote_ip: as_string(fields[6]),
dns_ms: int(namelookup),
tcp_ms: int(connect > namelookup ? connect - namelookup : connect),
tls_ms: int(appconnect > connect ? appconnect - connect : 0),
total_ms: int(total),
verdict: "",
message: ""
};
}
function uclient_failure_verdict(output) {
output = lc(as_string(output));
if (index(output, "download completed") >= 0)
return null;
if (index(output, "failed to resolve") >= 0 || index(output, "name or service not known") >= 0)
return { verdict: "dns_fail", message: "имя не резолвится" };
if (index(output, "connection refused") >= 0)
return { verdict: "tcp_refused", message: "соединение отклонено" };
if (index(output, "timed out") >= 0 || index(output, "timeout") >= 0)
return { verdict: "timeout", message: "таймаут соединения" };
if (index(output, "connection reset") >= 0)
return { verdict: "tls_reset", message: "соединение сброшено" };
if (index(output, "ssl") >= 0 || index(output, "tls") >= 0)
return { verdict: "tls_error", message: "ошибка TLS" };
if (index(output, "failed to establish connection") >= 0)
return { verdict: "timeout", message: "не удалось установить соединение" };
return { verdict: "failed", message: trim(as_string(output)) };
}
function http_probe_uclient(ctx, target) {
let url = target_url(target);
let binary = command_exists("uclient-fetch") ? "uclient-fetch" : "wget";
let started = now_ms();
// Без -q: именно в stderr uclient-fetch и busybox wget пишут причину отказа,
// а -T понимают обе реализации, в отличие от --timeout=.
let result = capture_args(prefixed_args(ctx, [
binary, "-T", as_string(CONNECT_TIMEOUT), "-O", "/dev/null", url
]), true);
let elapsed = now_ms() - started;
let output = as_string(result.output);
let code_match = match(output, /HTTP error ([0-9]{3})/);
if (code_match != null)
return {
reached: true,
code: int(code_match[1]),
remote_ip: "",
tcp_ms: 0,
tls_ms: 0,
total_ms: elapsed,
verdict: "",
message: ""
};
if (result.status == 0)
return {
reached: true,
code: 200,
remote_ip: "",
tcp_ms: 0,
tls_ms: 0,
total_ms: elapsed,
verdict: "",
message: "код ответа определён приблизительно (нет curl)"
};
let failure = uclient_failure_verdict(output);
if (failure == null)
return { reached: true, code: 200, remote_ip: "", tcp_ms: 0, tls_ms: 0, total_ms: elapsed, verdict: "", message: "" };
return {
reached: false,
code: 0,
remote_ip: "",
tcp_ms: 0,
tls_ms: 0,
total_ms: elapsed,
verdict: failure.verdict,
message: failure.message
};
}
// Проверка чистого TCP через curl. Протокол на порту нам безразличен: важно лишь,
// дошло ли дело до установленного соединения. curl это и говорит кодом выхода -
// 7 и 28 означают, что соединиться не удалось, а ошибки TLS уровнем выше (35, 52,
// 56) означают, что TCP как раз поднялся. Так обходимся без nc, флаги которого
// в busybox-сборках непредсказуемы.
function tcp_probe_curl(ctx, target) {
let host = as_string(target.host);
let port = int(target.port || 443);
let url = "https://" + host + ":" + as_string(port) + "/";
let args = prefixed_args(ctx, [
"curl", "-sS", "-o", "/dev/null",
"--connect-timeout", as_string(CONNECT_TIMEOUT),
"--max-time", as_string(CONNECT_TIMEOUT + 2),
"-w", "%{time_connect}|%{remote_ip}",
url
]);
let started = now_ms();
let result = capture(command_from_args(args) + " 2>/dev/null");
let elapsed = now_ms() - started;
let status = result.status;
let fields = split(trim(as_string(result.output)), "|");
let connect_ms = length(fields) > 0 ? int(number_value(fields[0]) * 1000) : 0;
let remote_ip = length(fields) > 1 ? as_string(fields[1]) : "";
if (status == 6)
return { reached: false, code: 0, remote_ip: "", tcp_ms: 0, tls_ms: 0, total_ms: elapsed, verdict: "dns_fail", message: "имя не резолвится" };
if (status == 7)
return { reached: false, code: 0, remote_ip: "", tcp_ms: 0, tls_ms: 0, total_ms: elapsed, verdict: "tcp_refused", message: "не удалось установить TCP-соединение" };
// curl can time out after the TCP handshake while it waits for an HTTPS
// response. Raw TCP services can accept connections on port 443 without
// speaking TLS/HTTP. In that case time_connect/remote_ip
// prove that the TCP target is reachable, which is all this probe tests.
if (status == 28 && connect_ms <= 0 && remote_ip == "")
return { reached: false, code: 0, remote_ip: "", tcp_ms: 0, tls_ms: 0, total_ms: elapsed, verdict: "timeout", message: "таймаут TCP-соединения" };
let measured = connect_ms > 0 ? connect_ms : elapsed;
return { reached: true, code: 0, remote_ip, tcp_ms: measured, tls_ms: 0, total_ms: measured, verdict: "", message: "" };
}
function tcp_probe_nc(ctx, target) {
let host = as_string(target.host);
let port = as_string(int(target.port || 443));
let mode = as_string(ctx.tools.nc_mode);
let args = [];
if (mode == "zero")
args = [ "nc", "-z", "-w", as_string(CONNECT_TIMEOUT), host, port ];
else if (mode == "wait")
args = [ "nc", "-w", as_string(CONNECT_TIMEOUT), host, port ];
else if (ctx.tools.timeout_cmd)
args = [ "timeout", as_string(CONNECT_TIMEOUT), "nc", host, port ];
else
return { reached: false, code: 0, tcp_ms: 0, tls_ms: 0, total_ms: 0, remote_ip: "", verdict: "skipped", message: "nc без поддержки таймаута, а curl не установлен" };
let started = now_ms();
let status = normalize_status(system(command_from_args(prefixed_args(ctx, args)) + " </dev/null >/dev/null 2>&1"));
let elapsed = now_ms() - started;
if (status == 0)
return { reached: true, code: 0, remote_ip: "", tcp_ms: elapsed, tls_ms: 0, total_ms: elapsed, verdict: "", message: "" };
let timed_out = elapsed >= CONNECT_TIMEOUT * 1000 - 500;
return {
reached: false,
code: 0,
remote_ip: "",
tcp_ms: 0,
tls_ms: 0,
total_ms: elapsed,
verdict: timed_out ? "timeout" : "tcp_refused",
message: timed_out ? "таймаут TCP-соединения" : "TCP-соединение отклонено"
};
}
function tcp_probe(ctx, target) {
if (ctx.tools.curl)
return tcp_probe_curl(ctx, target);
return tcp_probe_nc(ctx, target);
}
// Google возвращает отдельный FAILED_PRECONDITION для регионов, в которых
// Gemini API недоступен. Обычная HTTPS-проверка этого не отличает от рабочего
// API с ошибкой авторизации, поэтому для профиля Gemini разбираем JSON-ответ.
function read_gemini_api_key() {
return trim(as_string(fs.readfile(GEMINI_API_KEY_FILE) || ""));
}
function probe_gemini_geo(ctx, target) {
let api_key = read_gemini_api_key();
if (api_key == "")
return { reached: false, code: 0, tcp_ms: 0, tls_ms: 0, total_ms: 0, remote_ip: "", verdict: "skipped", message: "укажите API-ключ Gemini в настройках карточки" };
let url = "https://" + as_string(target.host) + as_string(target.path);
let body_file = temp_path("gemini-body");
let error_file = temp_path("gemini-err");
let config_file = temp_path("gemini-curl");
// Ключ не передаётся в argv curl и не виден в списке процессов. Временный
// config доступен только root и удаляется сразу после завершения запроса.
if (fs.writefile(config_file, "header = \"x-goog-api-key: " + api_key + "\"\n") == null)
return { reached: false, code: 0, tcp_ms: 0, tls_ms: 0, total_ms: 0, remote_ip: "", verdict: "gemini_geo_error", message: "не удалось подготовить запрос Gemini API" };
run_quiet([ "chmod", "600", config_file ]);
let started = now_ms();
let format = "%{http_code}|%{remote_ip}|%{time_total}";
let args = prefixed_args(ctx, [
"curl", "--config", config_file, "-sS",
"-o", body_file,
"--connect-timeout", as_string(CONNECT_TIMEOUT),
"--max-time", as_string(TOTAL_TIMEOUT),
"-A", USER_AGENT,
"-w", format,
url
]);
let result = capture(command_from_args(args) + " 2>" + shell_quote(error_file));
let stderr = as_string(fs.readfile(error_file) || "");
let body_raw = as_string(fs.readfile(body_file) || "");
fs.unlink(config_file);
fs.unlink(error_file);
fs.unlink(body_file);
let elapsed = now_ms() - started;
if (result.status != 0) {
let failure = curl_failure_verdict(result.status, stderr);
return {
reached: false,
code: 0,
remote_ip: "",
tcp_ms: 0,
tls_ms: 0,
total_ms: int(elapsed),
verdict: failure.verdict,
message: failure.message
};
}
let fields = split(trim(as_string(result.output)), "|");
let http_code = int(fields[0]);
let remote_ip = as_string(fields[1]);
let total_ms = int(number_value(fields[2]) * 1000);
let body = parse_json(body_raw);
body = type(body) == "object" ? body : {};
let error_obj = object_or_empty(body.error);
let error_status = as_string(error_obj.status);
let error_message = as_string(error_obj.message);
let error_lc = lc(error_message);
let verdict = "gemini_geo_error";
let message = "";
if (http_code == 200) {
verdict = "gemini_geo_ok";
message = "Gemini API доступен в этом регионе";
}
else if (error_status == "FAILED_PRECONDITION" || index(error_lc, "user location is not supported") >= 0) {
verdict = "gemini_geo_blocked";
message = "регион VPN-сервера недоступен для Gemini — смените сервер на другой регион";
}
else if (error_status == "INVALID_ARGUMENT" || index(error_lc, "api key not valid") >= 0) {
verdict = "gemini_api_key_invalid";
message = "API-ключ Gemini невалиден — обновите его в настройках карточки";
}
else if (http_code == 401 || http_code == 403) {
if (index(error_lc, "location") >= 0 || index(error_lc, "supported") >= 0) {
verdict = "gemini_geo_blocked";
message = "регион недоступен для Gemini API — смените VPN-сервер";
}
else {
verdict = "gemini_api_key_invalid";
message = error_message != "" ? error_message : "Google отклонил API-ключ Gemini";
}
}
else {
message = error_message != ""
? sprintf("неожиданный ответ Gemini API: HTTP %d — %s", http_code, error_message)
: sprintf("неожиданный ответ Gemini API: HTTP %d", http_code);
}
return {
reached: true,
code: http_code,
remote_ip,
tcp_ms: 0,
tls_ms: 0,
total_ms: total_ms > 0 ? total_ms : int(elapsed),
verdict,
message
};
}
function udp_probe(ctx, target) {
if (!ctx.tools.nc)
return { reached: false, code: 0, tcp_ms: 0, tls_ms: 0, total_ms: 0, remote_ip: "", verdict: "skipped", message: "нет nc для UDP-проверки" };
let started = now_ms();
let args = [ "nc", "-u" ];
if (ctx.tools.nc_mode == "zero")
push(args, "-z");
if (ctx.tools.nc_mode != "plain")
push(args, "-w", "2");
push(args, as_string(target.host), as_string(int(target.port || 0)));
let status = normalize_status(system(command_from_args(prefixed_args(ctx, args)) + " </dev/null >/dev/null 2>&1"));
let elapsed = now_ms() - started;
// UDP не подтверждает доставку без ответа приложения. Это best-effort:
// успешная отправка означает, что маршрут и локальный сокет доступны.
if (status == 0)
return { reached: true, code: 0, remote_ip: "", tcp_ms: elapsed, tls_ms: 0, total_ms: elapsed, verdict: "udp_unconfirmed", message: "UDP-пакет отправлен, но протокол не подтвердил доставку" };
return { reached: true, code: 0, remote_ip: "", tcp_ms: elapsed, tls_ms: 0, total_ms: elapsed, verdict: "udp_unconfirmed", message: "UDP-ответ не получен; это не доказывает блокировку трафика" };
}
function udp_dns_probe(ctx, target) {
if (!ctx.tools.nslookup)
return { reached: false, code: 0, tcp_ms: 0, tls_ms: 0, total_ms: 0, remote_ip: "", verdict: "skipped", message: "нет nslookup для двусторонней UDP-проверки" };
let query = as_string(target.query || "example.com");
let started = now_ms();
let result = capture_args(prefixed_args(ctx, [ "nslookup", query, as_string(target.host) ]), true);
let elapsed = now_ms() - started;
let output = lc(as_string(result.output));
let ok = result.status == 0 && index(output, "address") >= 0;
return {
reached: ok,
code: 0,
remote_ip: as_string(target.host),
tcp_ms: elapsed,
tls_ms: 0,
total_ms: elapsed,
verdict: ok ? "" : "timeout",
message: ok ? "получен ответ DNS по UDP" : "нет ответа DNS по UDP"
};
}
function connection_probe(ctx, target) {
if (as_string(target.kind) == "gemini_geo") {
if (!ctx.tools.curl)
return { reached: false, code: 0, tcp_ms: 0, tls_ms: 0, total_ms: 0, remote_ip: "", verdict: "skipped", message: "для геопроверки Gemini нужен curl" };
return probe_gemini_geo(ctx, target);
}
if (as_string(target.kind) == "udp_dns")
return udp_dns_probe(ctx, target);
if (as_string(target.kind) == "udp")
return udp_probe(ctx, target);
if (as_string(target.kind) == "tcp") {
if (!ctx.tools.curl && !ctx.tools.nc)
return { reached: false, code: 0, tcp_ms: 0, tls_ms: 0, total_ms: 0, remote_ip: "", verdict: "skipped", message: "нет ни curl, ни nc" };
return tcp_probe(ctx, target);
}
if (ctx.tools.curl)
return http_probe_curl(ctx, target);
if (ctx.tools.uclient_fetch)
return http_probe_uclient(ctx, target);
return { reached: false, code: 0, tcp_ms: 0, tls_ms: 0, total_ms: 0, remote_ip: "", verdict: "skipped", message: "нет ни curl, ни uclient-fetch" };
}
// ---------------------------------------------------------------------------
// Определение маршрута через Clash API
// ---------------------------------------------------------------------------
function clash_connections() {
let backend = backend_id();
let result;
if (backend == "forkop") {
result = capture_args([ FORKOP_BIN, "clash_api", "get_connections" ], false);
}
else if (backend == "podkop") {
let config = object_or_empty(read_json_file(sing_box_config_path()));
let clash = object_or_empty(object_or_empty(config.experimental).clash_api);
let controller = trim(as_string(clash.external_controller));
let secret = as_string(clash.secret);
if (controller == "")
controller = "127.0.0.1:9090";
if (match(controller, /^0\.0\.0\.0:/) != null)
controller = replace(controller, /^0\.0\.0\.0:/, "127.0.0.1:");
if (index(controller, "http://") != 0 && index(controller, "https://") != 0)
controller = "http://" + controller;
let args = [ "curl", "-sS", "--connect-timeout", "2", "--max-time", "4" ];
if (secret == "")
secret = uci_get("podkop.settings.yacd_secret_key");
if (secret != "") {
push(args, "-H");
push(args, "Authorization: Bearer " + secret);
}
push(args, controller + "/connections");
result = capture_args(args, false);
}
else {
return [];
}
if (result.status != 0)
return [];
let parsed = parse_json(result.output);
if (type(parsed) == "array")
return parsed;
return array_or_empty(object_or_empty(parsed).connections);
}
function route_for(connections, host, remote_ip, port) {
host = lc(as_string(host));
remote_ip = as_string(remote_ip);
port = int(port || 0);
for (let connection in connections) {
connection = object_or_empty(connection);
let metadata = object_or_empty(connection.metadata);
let connection_host = lc(as_string(metadata.host));
let destination = as_string(metadata.destinationIP);
let destination_port = int(metadata.destinationPort || 0);
if ((connection_host != "" && connection_host == host) ||
(remote_ip != "" && destination == remote_ip)) {
if (port > 0 && destination_port > 0 && destination_port != port)
continue;
let chains = array_or_empty(connection.chains);
return {
seen: true,
outbound: length(chains) > 0 ? join(" ← ", chains) : ""
};
}
}
return { seen: false, outbound: "" };
}
function outbound_for(connections, host, remote_ip) {
return route_for(connections, host, remote_ip, 0).outbound;
}
// Держим отдельное TCP-соединение открытым несколько секунд и в это время
// опрашиваем Clash API. Так маршрут определяется заметно надёжнее, чем после
// короткого HTTP-запроса, который часто успевает закрыться до get_connections.
function detect_live_route(ctx, host, port, remote_ip) {
let unknown = { attempted: false, seen: false, outbound: "" };
if (!ctx.backend_running)
return unknown;
let command = "";
let nc_mode = as_string(ctx.tools.nc_mode);
if (ctx.tools.nc && (nc_mode == "zero" || nc_mode == "wait")) {
let args = prefixed_args(ctx, [ "nc", "-w", "6", as_string(host), as_string(port) ]);
command = "( sleep 5 ) | " + command_from_args(args);
}
else if (ctx.tools.curl) {
let url = "https://" + as_string(host) + ":" + as_string(port) + "/";
let args = prefixed_args(ctx, [
"curl", "-k", "-sS", "-L", "--connect-timeout", "4", "--max-time", "6",
"--limit-rate", "1", "-o", "/dev/null", url
]);
command = command_from_args(args);
}
else {
return unknown;
}
let launched = normalize_status(system(common.background_command(command))) == 0;
if (!launched)
return unknown;
for (let attempt = 0; attempt < 6; attempt++) {
run_quiet([ "sleep", "1" ]);
let route = route_for(clash_connections(), host, remote_ip, port);
if (route.seen)
return { attempted: true, seen: true, outbound: route.outbound };
}
return { attempted: true, seen: false, outbound: "" };
}
// ---------------------------------------------------------------------------
// Вердикты
// ---------------------------------------------------------------------------
function expected_codes(target) {
let expect = array_or_empty(object_or_empty(target).expect);
return length(expect) > 0 ? expect : [ 200 ];
}
function code_expected(code, target) {
code = int(code);
for (let value in expected_codes(target))
if (int(value) == code)
return true;
return false;
}
function target_verdict(target, dns, connection) {
if (as_string(connection.verdict) == "skipped")
return { state: "skipped", verdict: "skipped", message: as_string(connection.message) };
if (as_string(connection.verdict) == "udp_unconfirmed")
return { state: "warning", verdict: "udp_unconfirmed", message: as_string(connection.message) };
if (!dns.ok && !valid_ipv4(as_string(target.host)))
return { state: "error", verdict: "dns_fail", message: "DNS не отдал адрес: " + as_string(dns.error) };
if (!connection.reached) {
let verdict = as_string(connection.verdict);
// Круг редиректов означает, что сервис отвечает: сеть до него рабочая,
// поэтому это замечание, а не отказ.
if (verdict == "redirect_loop")
return { state: "warning", verdict, message: as_string(connection.message) };
return { state: "error", verdict: verdict != "" ? verdict : "failed", message: as_string(connection.message) };
}
if (as_string(target.kind) == "gemini_geo") {
if (connection.verdict == "gemini_geo_ok")
return { state: "success", verdict: "ok", message: as_string(connection.message) };
if (connection.verdict == "gemini_geo_blocked")
return { state: "warning", verdict: "geo_blocked", message: as_string(connection.message) };
if (connection.verdict == "gemini_api_key_invalid")
return { state: "warning", verdict: "gemini_api_key_invalid", message: as_string(connection.message) };
return { state: "error", verdict: as_string(connection.verdict) != "" ? as_string(connection.verdict) : "failed", message: as_string(connection.message) };
}
if (as_string(target.kind) == "tcp" || as_string(target.kind) == "udp" || as_string(target.kind) == "udp_dns")
return int(connection.total_ms) > SLOW_THRESHOLD_MS
? { state: "warning", verdict: "slow", message: "соединение установлено, но медленно" }
: { state: "success", verdict: "ok", message: "" };
let code = int(connection.code);
let expected = code_expected(code, target);
// Мы проверяем достижимость, а не семантику API: раз пришёл любой HTTP-ответ,
// значит DNS, TCP, TLS и HTTP отработали и путь до сервиса живой. Коды вроде
// 404 на голом корне CDN - это норма, а не проблема сети. Особые случаи ниже.
if (code >= 500)
return { state: "warning", verdict: "http_server_error", message: sprintf("сервис отвечает ошибкой %d", code) };
if (!expected && (code == 403 || code == 451))
return { state: "warning", verdict: "geo_blocked", message: sprintf("ответ %d — похоже на блокировку по IP или региону", code) };
if (int(connection.total_ms) > SLOW_THRESHOLD_MS)
return { state: "warning", verdict: "slow", message: sprintf("работает, но медленно: %d мс", int(connection.total_ms)) };
if (!expected)
return { state: "success", verdict: "ok", message: sprintf("ответ %d — нетипичный, но соединение проходит", code) };
return { state: "success", verdict: "ok", message: "" };
}
function service_state(items) {
let required_total = 0;
let required_ok = 0;
let has_warning = false;
for (let item in items) {
if (item.optional || item.state == "skipped")
continue;
required_total++;
if (item.state == "success")
required_ok++;
else if (item.state == "warning") {
required_ok++;
has_warning = true;
}
}
if (required_total == 0)
return { state: "warning", description: "проверить не удалось" };
if (required_ok == 0)
return { state: "error", description: "не работает" };
if (required_ok < required_total)
return { state: "warning", description: sprintf("работает частично (%d из %d)", required_ok, required_total) };
if (has_warning)
return { state: "warning", description: "работает с замечаниями" };
return { state: "success", description: "работает" };
}
// ---------------------------------------------------------------------------
// Запуск проверки
// ---------------------------------------------------------------------------
// ВАЖНО: в ucode нет подъёма объявлений - функция, объявленная ниже по файлу,
// не видна объявленной выше (вызов компилируется как обращение к глобали и падает
// в рантайме). Поэтому порядок объявлений здесь значим: сначала зависимости.
function netns_teardown() {
run_quiet([ "ip", "netns", "del", NETNS_NAME ]);
run_quiet([ "ip", "link", "del", NETNS_VETH_HOST ]);
run_quiet([ "rm", "-rf", "/etc/netns/" + NETNS_NAME ]);
}
function leased_addresses() {
let leases = {};
let data = fs.readfile("/tmp/dhcp.leases");
if (data == null)
return leases;
for (let line in split(as_string(data), "\n")) {
let fields = words(line);
if (length(fields) >= 3 && valid_ipv4(fields[2]))
leases[fields[2]] = true;
}
return leases;
}
function address_free(candidate, leases) {
if (leases[candidate])
return false;
// Не используем ICMP: некоторые провайдеры и сетевые фильтры реагируют на
// массовые echo-запросы. Проверяем только ARP/маршрут через HTTPS.
if (command_exists("curl")) {
let result = capture_args([ "curl", "-k", "-sS", "-o", "/dev/null",
"--connect-timeout", "1", "--max-time", "2", "https://" + candidate + "/" ], false);
// Любой ответ HTTPS означает занятый адрес; ошибки маршрута/таймаута
// оставляют возможность использовать кандидат.
return result.status == 6 || result.status == 7 || result.status == 28;
}
return true;
}
function pick_client_ip(caps, requested) {
requested = as_string(requested);
if (valid_ipv4(requested))
return requested;
let prefix = int(caps.lan_prefix);
if (!valid_ipv4(caps.lan_address) || prefix < 16 || prefix > 30)
return "";
let mask = prefix_mask(prefix);
let router_value = ipv4_to_int(caps.lan_address);
let network = router_value & mask;
let size = 1 << (32 - prefix);
let broadcast = network + size - 1;
let leases = leased_addresses();
// Берём адреса с верхнего края подсети - там DHCP-пул OpenWrt заканчивается раньше.
for (let offset = 1; offset <= 8; offset++) {
let candidate_value = broadcast - offset;
if (candidate_value <= network || candidate_value == router_value)
continue;
let candidate = int_to_ipv4(candidate_value);
if (address_free(candidate, leases))
return candidate;
}
return "";
}
function netns_setup(caps, requested_ip) {
if (!caps.netns)
return { ok: false, error: "режим клиента недоступен: нет поддержки ip netns" };
netns_teardown();
let client_ip = pick_client_ip(caps, requested_ip);
if (client_ip == "")
return { ok: false, error: "не удалось подобрать свободный IP в LAN" };
let prefix = as_string(int(caps.lan_prefix));
let steps = [
[ "ip", "netns", "add", NETNS_NAME ],
[ "ip", "link", "add", NETNS_VETH_HOST, "type", "veth", "peer", "name", NETNS_VETH_PEER ],
[ "ip", "link", "set", NETNS_VETH_PEER, "netns", NETNS_NAME ],
[ "ip", "link", "set", NETNS_VETH_HOST, "master", caps.lan_interface ],
[ "ip", "link", "set", NETNS_VETH_HOST, "up" ],
[ "ip", "netns", "exec", NETNS_NAME, "ip", "link", "set", "lo", "up" ],
[ "ip", "netns", "exec", NETNS_NAME, "ip", "addr", "add", client_ip + "/" + prefix, "dev", NETNS_VETH_PEER ],
[ "ip", "netns", "exec", NETNS_NAME, "ip", "link", "set", NETNS_VETH_PEER, "up" ],
[ "ip", "netns", "exec", NETNS_NAME, "ip", "route", "add", "default", "via", caps.lan_address ]
];
for (let step in steps) {
if (!run_quiet(step)) {
netns_teardown();
return { ok: false, error: "не удалось выполнить: " + join(" ", step) };
}
}
run_quiet([ "mkdir", "-p", "/etc/netns/" + NETNS_NAME ]);
if (fs.writefile("/etc/netns/" + NETNS_NAME + "/resolv.conf", "nameserver " + caps.lan_address + "\n") == null) {
netns_teardown();
return { ok: false, error: "не удалось записать resolv.conf для namespace" };
}
return { ok: true, client_ip, error: "" };
}
function build_context(mode, client_ip) {
let caps = capabilities();
let ctx = {
tools: {
curl: caps.curl,
uclient_fetch: caps.uclient_fetch,
dig: caps.dig,
nslookup: caps.nslookup,
nc: caps.nc,
nc_mode: caps.nc_mode,
timeout_cmd: caps.timeout_cmd
},
fakeip_ranges: caps.fakeip_ranges,
resolver: "127.0.0.1",
exec_prefix: [],
mode: as_string(mode) == "netns" ? "netns" : "router",
lan_interface: caps.lan_interface,
lan_address: caps.lan_address,
backend: caps.backend,
backend_name: caps.backend_name,
backend_running: caps.backend_running,
forkop_running: caps.forkop_running,
client_ip: "",
netns_error: ""
};
if (ctx.mode != "netns")
return ctx;
let netns = netns_setup(caps, client_ip);
if (!netns.ok) {
ctx.mode = "router";
ctx.netns_error = netns.error;
return ctx;
}
ctx.exec_prefix = [ "ip", "netns", "exec", NETNS_NAME ];
ctx.resolver = caps.lan_address != "" ? caps.lan_address : "127.0.0.1";
ctx.client_ip = netns.client_ip;
ctx.netns_active = true;
return ctx;
}
function probe_target(ctx, target) {
target = object_or_empty(target);
let host = as_string(target.host);
let dns = probe_dns(ctx, host);
let connection = { reached: false, code: 0, tcp_ms: 0, tls_ms: 0, total_ms: 0, remote_ip: "", verdict: "", message: "" };
if (dns.ok)
connection = connection_probe(ctx, target);
else
connection.verdict = "dns_fail";
let verdict = target_verdict(target, dns, connection);
return {
label: target_label(target),
kind: as_string(target.kind || "https"),
host,
port: int(target.port || (as_string(target.kind) == "http" ? 80 : 443)),
url: as_string(target.kind) == "tcp" ? "" : target_url(target),
optional: target.optional === true,
dns_ok: dns.ok,
dns_ip: as_string(dns.ip),
dns_fakeip: dns.fakeip === true,
dns_ms: int(dns.ms),
remote_ip: as_string(connection.remote_ip),
http_code: int(connection.code),
tcp_ms: int(connection.tcp_ms),
tls_ms: int(connection.tls_ms),
total_ms: int(connection.total_ms),
outbound: "",
state: verdict.state,
verdict: verdict.verdict,
message: verdict.message
};
}
function probe_service(ctx, profile) {
profile = object_or_empty(profile);
let items = [];
for (let target in array_or_empty(profile.targets))
push(items, probe_target(ctx, target));
// Атрибуция маршрута - best effort: Clash API отдаёт только живые соединения,
// а короткие HTTP-запросы к моменту опроса часто уже закрыты. Поэтому один
// запрос на сервис, а не на каждую цель, и пустой результат не считается ошибкой.
if (ctx.backend_running) {
let connections = clash_connections();
if (length(connections) > 0) {
for (let item in items) {
if (!item.state || item.state == "skipped")
continue;
item.outbound = outbound_for(connections, item.host, item.remote_ip);
}
}
}
let summary = service_state(items);
return {
id: as_string(profile.id),
title: as_string(profile.title),
group: as_string(profile.group),
description: as_string(profile.description),
state: summary.state,
description_result: summary.description,
items
};
}
function custom_check(host, port, mode, client_ip) {
host = lc(trim(as_string(host)));
if (length(host) > 0 && substr(host, length(host) - 1, 1) == ".")
host = substr(host, 0, length(host) - 1);
port = int(port || 443);
if (!valid_custom_host(host))
return { success: false, message: "Введите корректный IPv4-адрес или домен без протокола и пути" };
if (port < 1 || port > 65535)
return { success: false, message: "TCP-порт должен быть от 1 до 65535" };
let ctx = build_context(mode, client_ip);
let dns = probe_dns(ctx, host);
let remote_ip = dns.ok ? as_string(dns.ip) : (valid_ipv4(host) ? host : "");
let live_route = detect_live_route(ctx, host, port, remote_ip);
let item = probe_target(ctx, {
kind: "tcp",
host,
port,
label: host + ":" + as_string(port)
});
let through_sing_box = null;
let route_status = "unknown";
let route_message = "Маршрут не удалось подтвердить через Clash API.";
let evidence = "";
if (!ctx.backend_running) {
through_sing_box = false;
route_status = "direct";
evidence = "backend_stopped";
route_message = ctx.backend_name + " остановлен: соединение не проходит через sing-box.";
}
else if (live_route.seen) {
through_sing_box = true;
route_status = "sing-box";
evidence = "clash_api";
item.outbound = live_route.outbound;
route_message = live_route.outbound != ""
? "Соединение найдено в sing-box; outbound: " + live_route.outbound
: "Соединение найдено среди активных соединений sing-box.";
}
else if (item.dns_fakeip) {
through_sing_box = true;
route_status = "sing-box";
evidence = "fakeip";
route_message = "Домен получил FakeIP, который обслуживается sing-box.";
}
else if (live_route.attempted && item.state != "error" && item.state != "skipped") {
through_sing_box = false;
route_status = "direct";
evidence = "clash_api_absent";
route_message = "Соединение доступно, но за время теста не появилось в Clash API: маршрут идёт мимо sing-box.";
}
else if (item.state == "error") {
route_message = "Соединение не установлено, поэтому определить маршрут нельзя.";
}
if (ctx.netns_active)
netns_teardown();
return {
success: true,
target: host,
port,
mode: ctx.mode,
requested_mode: as_string(mode) == "netns" ? "netns" : "router",
client_ip: as_string(ctx.client_ip),
netns_error: as_string(ctx.netns_error),
backend: ctx.backend,
backend_name: ctx.backend_name,
backend_running: ctx.backend_running,
forkop_running: ctx.forkop_running,
route: {
status: route_status,
through_sing_box,
outbound: as_string(item.outbound),
evidence,
message: route_message
},
item
};
}
function selected_profiles(ids) {
ids = trim(as_string(ids));
let all = load_profiles();
if (ids == "" || ids == "all")
return all;
let wanted = {};
for (let id in split(ids, ","))
wanted[trim(as_string(id))] = true;
let result = [];
for (let profile in all)
if (wanted[as_string(object_or_empty(profile).id)])
push(result, profile);
return result;
}
// Состояние фоновых заданий. Объявлено до run_check, потому что тот пишет в него
// прогресс по ходу проверки, а forward-ссылок ucode не прощает.
function job_id_valid(value) {
value = as_string(value);
return value != "" && match(value, /^[A-Za-z0-9_-]+$/) != null;
}
function job_path(job_id) {
return job_id_valid(job_id) ? STATE_DIR + "/" + as_string(job_id) + ".json" : "";
}
function write_state(path, value) {
ensure_state_dir();
return fs.writefile(as_string(path), sprintf("%J", value) + "\n") != null;
}
function update_progress(path, done, total, services) {
let state = object_or_empty(read_json_file(path));
if (!state.running)
return;
state.progress = { done: int(done), total: int(total) };
state.services = services;
write_state(path, state);
}
function run_check(ids, mode, client_ip, progress_path) {
let profiles = selected_profiles(ids);
let ctx = build_context(mode, client_ip);
let services = [];
let total = 0;
for (let profile in profiles)
total += length(array_or_empty(object_or_empty(profile).targets));
let done = 0;
for (let profile in profiles) {
let result = probe_service(ctx, profile);
push(services, result);
done += length(result.items);
if (as_string(progress_path) != "")
update_progress(progress_path, done, total, services);
}
if (ctx.netns_active)
netns_teardown();
return {
generated_at: now_seconds(),
mode: ctx.mode,
requested_mode: as_string(mode) == "netns" ? "netns" : "router",
netns_error: as_string(ctx.netns_error),
client_ip: as_string(ctx.client_ip),
resolver: ctx.resolver,
backend: ctx.backend,
backend_name: ctx.backend_name,
backend_running: ctx.backend_running,
forkop_running: ctx.forkop_running,
tools: ctx.tools,
fakeip_ranges: ctx.fakeip_ranges,
progress: { done, total },
services
};
}
// ---------------------------------------------------------------------------
// Фоновые задания
// ---------------------------------------------------------------------------
function cleanup_jobs() {
ensure_state_dir();
let now = now_seconds();
for (let path in fs.glob(STATE_DIR + "/*.json")) {
let state = object_or_empty(read_json_file(path));
let started = int(state.started_at || 0);
if (started > 0 && now - started > JOB_MAX_AGE)
fs.unlink(path);
}
}
function start_job(ids, mode, client_ip) {
ensure_state_dir();
cleanup_jobs();
let job_id = sprintf("sc-%d-%d", now_seconds(), int(now_ms() % 100000));
let path = job_path(job_id);
if (path == "") {
write_json({ success: false, message: "не удалось создать задание" });
return 1;
}
let profiles = selected_profiles(ids);
let total = 0;
for (let profile in profiles)
total += length(array_or_empty(object_or_empty(profile).targets));
if (length(profiles) == 0) {
write_json({ success: false, message: "не выбран ни один сервис" });
return 1;
}
let state = {
kind: "service_check",
job_id,
running: true,
success: false,
started_at: now_seconds(),
finished_at: 0,
mode: as_string(mode) == "netns" ? "netns" : "router",
ids: as_string(ids),
progress: { done: 0, total },
services: []
};
if (!write_state(path, state)) {
write_json({ success: false, message: "не удалось записать состояние задания" });
return 1;
}
let worker = command_from_args([
"ucode", "-L", LIB_DIR, ENGINE, "worker", path, as_string(ids), as_string(mode), as_string(client_ip)
]);
system("(" + worker + " >/dev/null 2>&1 &)");
write_json({ success: true, job_id, progress: state.progress });
return 0;
}
function worker(path, ids, mode, client_ip) {
let result = run_check(ids, mode, client_ip, path);
let state = object_or_empty(read_json_file(path));
state.running = false;
state.success = true;
state.finished_at = now_seconds();
state.services = result.services;
state.resolver = result.resolver;
state.tools = result.tools;
state.fakeip_ranges = result.fakeip_ranges;
state.progress = result.progress;
state.mode = result.mode;
state.netns_error = result.netns_error;
state.client_ip = result.client_ip;
state.backend = result.backend;
state.backend_name = result.backend_name;
state.backend_running = result.backend_running;
state.forkop_running = result.forkop_running;
write_state(path, state);
return 0;
}
function job_status(job_id) {
let path = job_path(job_id);
if (path == "" || fs.stat(path) == null) {
write_json({ success: false, running: false, message: "задание не найдено" });
return 1;
}
let state = object_or_empty(read_json_file(path));
if (length(keys(state)) == 0) {
write_json({ success: false, running: false, message: "не удалось прочитать состояние задания" });
return 1;
}
write_json(state);
return 0;
}
function list_profiles() {
let data = object_or_empty(read_json_file(profiles_file()));
let profiles = [];
for (let profile in array_or_empty(data.profiles)) {
profile = object_or_empty(profile);
push(profiles, {
id: as_string(profile.id),
title: as_string(profile.title),
group: as_string(profile.group),
description: as_string(profile.description),
targets: length(array_or_empty(profile.targets))
});
}
write_json({ version: int(data.version || 0), updated: as_string(data.updated), profiles });
return 0;
}
function gemini_key_set(value) {
let key = trim(as_string(value));
if (match(key, /^[A-Za-z0-9_-]{20,200}$/) == null) {
write_json({ success: false, message: "ключ имеет недопустимый формат" });
return 1;
}
run_quiet([ "mkdir", "-p", CONFIG_DIR ]);
if (fs.writefile(GEMINI_API_KEY_FILE, key + "\n") == null) {
write_json({ success: false, message: "не удалось сохранить ключ" });
return 1;
}
run_quiet([ "chmod", "600", GEMINI_API_KEY_FILE ]);
write_json({ success: true, configured: true, message: "ключ сохранён" });
return 0;
}
function gemini_key_reset() {
fs.unlink(GEMINI_API_KEY_FILE);
write_json({ success: true, configured: false, message: "ключ удалён" });
return 0;
}
function gemini_key_status() {
let configured = read_gemini_api_key() != "";
write_json({ success: true, configured, message: configured ? "используется пользовательский ключ" : "ключ не задан" });
return 0;
}
// ---------------------------------------------------------------------------
function tachyon_get_targets(all_mode) {
let uci = require("uci");
let cursor = uci.cursor();
cursor.load("tachyon");
let all_profiles_json = fs.readfile(PROFILES_OVERRIDE) || fs.readfile(PROFILES_DEFAULT);
let all_profiles = json(all_profiles_json) || { profiles: [] };
let profiles_map = {};
for (let i = 0; i < length(all_profiles.profiles); i++) {
let p = all_profiles.profiles[i];
profiles_map[p.id] = p;
}
let targets = [];
let seen_domains = {};
if (all_mode == "all") {
// 1. Include ALL Community Profiles
for (let i = 0; i < length(all_profiles.profiles); i++) {
let profile = all_profiles.profiles[i];
let profile_title = profile.title || profile.name || profile.id;
for (let j = 0; j < length(profile.targets); j++) {
let t = profile.targets[j];
let host = t.host || t.query || "";
if (!host || seen_domains[host]) continue;
seen_domains[host] = true;
push(targets, {
section: profile_title,
route_type: "auto",
domain: host,
port: t.port || (t.kind == "https" ? 443 : 80),
kind: t.kind,
path: t.path || "/",
expect: t.expect || [200, 301, 302],
target_label: t.label || host
});
}
}
// 2. Include custom user domains from all UCI sections
cursor.foreach("tachyon", "section", function(s) {
let raw_name = as_string(s['.name']);
let custom_label = as_string(s.label);
let has_custom_label = custom_label != "" && custom_label != raw_name && match(custom_label, /^section_\d+$/) == null && match(custom_label, /^cfg[0-9a-fA-F]+$/) == null;
let label = has_custom_label ? custom_label : "Свои домены";
let route_type = s.action || "direct";
if (s.action == "zapret" && s.zapret_strategy) route_type = "zapret (" + s.zapret_strategy + ")";
else if (s.action == "zapret2" && s.zapret2_strategy) route_type = "zapret2 (" + s.zapret2_strategy + ")";
else if (s.action == "byedpi" && s.byedpi_strategy) route_type = "byedpi (" + s.byedpi_strategy + ")";
let custom_domains = [];
let lists = type(s.user_domains) == "array" ? s.user_domains : [ s.user_domains ];
for (let d in lists) {
if (!d) continue;
for (let line in split(as_string(d), /[\n\r,]+/)) {
let domain_str = trim(replace(line, /^\*?\./, ""));
if (domain_str != "" && substr(domain_str, 0, 1) != "#")
push(custom_domains, domain_str);
}
}
if (s.user_domains_text) {
for (let line in split(as_string(s.user_domains_text), /[\n\r,]+/)) {
let domain_str = trim(replace(line, /^\*?\./, ""));
if (domain_str != "" && substr(domain_str, 0, 1) != "#")
push(custom_domains, domain_str);
}
}
for (let cd in custom_domains) {
if (seen_domains[cd]) continue;
seen_domains[cd] = true;
push(targets, {
section: label,
route_type: route_type,
domain: cd,
port: 443,
kind: "https",
path: "/",
expect: [200, 301, 302, 307, 400, 403, 404, 405, 426],
target_label: cd
});
}
});
write_json(targets);
return;
}
// Active mode (default): Check targets in active enabled UCI sections
let seen_domains = {};
const profile_aliases = {
"meta": "instagram",
"twitter": "x",
"cloudflare": "baseline",
"google_ai": "gemini",
"geoblock": "blocked_ru",
"russia_inside": "blocked_ru",
"russia_outside": "blocked_ru",
"hodca": "hdrezka"
};
cursor.foreach("tachyon", "section", function(s) {
if (s.enabled == "0" || s.enabled == "false") return;
let profiles_to_check = [];
let raw_community = s.community_lists;
const resolve_profile = function(id) {
let real_id = profile_aliases[id] || id;
if (profiles_map[real_id]) return profiles_map[real_id];
return null;
};
if (type(raw_community) == "array") {
for (let item in raw_community) {
if (!item) continue;
for (let part in split(as_string(item), /[\s,]+/)) {
let id = trim(part);
let p = resolve_profile(id);
if (p) push(profiles_to_check, p);
}
}
} else if (type(raw_community) == "string") {
for (let part in split(as_string(raw_community), /[\s,]+/)) {
let id = trim(part);
let p = resolve_profile(id);
if (p) push(profiles_to_check, p);
}
}
let raw_name = as_string(s['.name']);
let custom_label = as_string(s.label);
let has_custom_label = custom_label != "" && custom_label != raw_name && match(custom_label, /^section_\d+$/) == null && match(custom_label, /^cfg[0-9a-fA-F]+$/) == null;
let route_type = s.action || "direct";
if (s.action == "zapret" && s.zapret_strategy) route_type = "zapret (" + s.zapret_strategy + ")";
else if (s.action == "zapret2" && s.zapret2_strategy) route_type = "zapret2 (" + s.zapret2_strategy + ")";
else if (s.action == "byedpi" && s.byedpi_strategy) route_type = "byedpi (" + s.byedpi_strategy + ")";
for (let i = 0; i < length(profiles_to_check); i++) {
let profile = profiles_to_check[i];
let profile_title = profile.title || profile.name || profile.id;
let display_section = has_custom_label ? custom_label : profile_title;
for (let j = 0; j < length(profile.targets); j++) {
let t = profile.targets[j];
let host = t.host || t.query || "";
if (!host || seen_domains[host]) continue;
seen_domains[host] = true;
push(targets, {
section: display_section,
route_type: route_type,
domain: host,
port: t.port || (t.kind == "https" ? 443 : 80),
kind: t.kind,
path: t.path || "/",
expect: t.expect || [200, 301, 302],
target_label: t.label || host
});
}
}
let custom_domains = [];
let lists = type(s.user_domains) == "array" ? s.user_domains : [ s.user_domains ];
for (let d in lists) {
if (!d) continue;
for (let line in split(as_string(d), /[\n\r,]+/)) {
let domain_str = trim(replace(line, /^\*?\./, ""));
if (domain_str != "" && substr(domain_str, 0, 1) != "#" && substr(domain_str, 0, 2) != "//")
push(custom_domains, domain_str);
}
}
if (s.user_domains_text) {
for (let line in split(as_string(s.user_domains_text), /[\n\r,]+/)) {
let domain_str = trim(replace(line, /^\*?\./, ""));
if (domain_str != "" && substr(domain_str, 0, 1) != "#" && substr(domain_str, 0, 2) != "//")
push(custom_domains, domain_str);
}
}
let custom_section = has_custom_label ? custom_label : "Свои домены";
for (let cd in custom_domains) {
if (!cd || seen_domains[cd]) continue;
seen_domains[cd] = true;
push(targets, {
section: custom_section,
route_type: route_type,
domain: cd,
port: 443,
kind: "https",
path: "/",
expect: [200, 301, 302, 307, 400, 403, 404, 405, 426],
target_label: cd
});
}
});
write_json(targets);
}
function tachyon_check_target(target_json_str) {
let t = json(target_json_str);
if (!t) {
write_json([{ success: false, status_class: "Error parsing target" }]);
return;
}
if (!t.host && t.domain) t.host = t.domain;
let ctx = build_context("router", "");
let item = probe_target(ctx, t);
let http_time = item.total_ms - item.tcp_ms - item.tls_ms;
if (http_time < 0) http_time = 0;
let result = [{
section: t.section || "Custom",
route_type: t.route_type || "auto",
domain: t.target_label || t.host,
ip: item.remote_ip || item.dns_ip || "",
dns_ms: item.dns_ms || 0,
tcp_ms: item.tcp_ms || 0,
tls_ms: item.tls_ms || 0,
http_ms: http_time || 0,
status_class: item.state == "success" ? "OK" : (item.message || item.verdict || "Error"),
success: item.state == "success"
}];
write_json(result);
}
let mode = as_string(ARGV[0]);
if (mode == "get-targets") {
tachyon_get_targets(ARGV[1]);
} else if (mode == "check-domain" && ARGV[1]) {
tachyon_check_target(ARGV[1]);
} else if (mode == "check-custom" && ARGV[1]) {
let custom_target = {
kind: "https",
host: ARGV[1],
port: 443,
path: "/",
expect: [200, 301, 302, 307, 400, 403, 404, 405, 426]
};
tachyon_check_target(sprintf("%J", custom_target));
} else {
warn("Unknown mode: ", mode, "\n");
exit(1);
}