small-packages/homeproxy/root/usr/share/rpcd/ucode/luci.homeproxy
2026-05-10 09:48:30 +08:00

1128 lines
34 KiB
Plaintext

#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2023-2024 ImmortalWrt.org
*/
'use strict';
import { access, error, lstat, popen, readfile, writefile } from 'fs';
import { cursor } from 'uci';
/* Kanged from ucode/luci */
function shellquote(s) {
return `'${replace(s, "'", "'\\''")}'`;
}
function hasKernelModule(kmod) {
return (system(sprintf('[ -e "/lib/modules/$(uname -r)"/%s ]', shellquote(kmod))) === 0);
}
const HP_DIR = '/etc/homeproxy';
const RUN_DIR = '/var/run/homeproxy';
const methods = {
acllist_read: {
args: { type: 'type' },
call: function(req) {
if (index(['direct_list', 'proxy_list'], req.args?.type) === -1)
return { content: null, error: 'illegal type' };
const filecontent = readfile(`${HP_DIR}/resources/${req.args?.type}.txt`);
return { content: filecontent };
}
},
acllist_write: {
args: { type: 'type', content: 'content' },
call: function(req) {
if (index(['direct_list', 'proxy_list'], req.args?.type) === -1)
return { result: false, error: 'illegal type' };
const file = `${HP_DIR}/resources/${req.args?.type}.txt`;
let content = req.args?.content;
/* Sanitize content */
if (content) {
content = trim(content);
content = replace(content, /\r\n?/g, '\n');
if (!match(content, /\n$/))
content += '\n';
}
system(`mkdir -p ${HP_DIR}/resources`);
writefile(file, content);
return { result: true };
}
},
certificate_write: {
args: { filename: 'filename' },
call: function(req) {
const writeCertificate = (filename, priv) => {
const tmpcert = '/tmp/homeproxy_certificate.tmp';
const filestat = lstat(tmpcert);
if (!filestat || filestat.type !== 'file' || filestat.size <= 0) {
system(`rm -f ${tmpcert}`);
return { result: false, error: 'empty certificate file' };
}
let filecontent = readfile(tmpcert);
if (is_binary(filecontent)) {
system(`rm -f ${tmpcert}`);
return { result: false, error: 'illegal file type: binary' };
}
/* Kanged from luci-proto-openconnect */
const beg = priv ? /^-----BEGIN (RSA|EC) PRIVATE KEY-----$/ : /^-----BEGIN CERTIFICATE-----$/,
end = priv ? /^-----END (RSA|EC) PRIVATE KEY-----$/ : /^-----END CERTIFICATE-----$/,
lines = split(trim(filecontent), /[\r\n]/);
let start = false, i;
for (i = 0; i < length(lines); i++) {
if (match(lines[i], beg))
start = true;
else if (start && !b64dec(lines[i]) && length(lines[i]) !== 64)
break;
}
if (!start || i < length(lines) - 1 || !match(lines[i], end)) {
system(`rm -f ${tmpcert}`);
return { result: false, error: 'this does not look like a correct PEM file' };
}
/* Sanitize certificate */
filecontent = trim(filecontent);
filecontent = replace(filecontent, /\r\n?/g, '\n');
if (!match(filecontent, /\n$/))
filecontent += '\n';
system(`mkdir -p ${HP_DIR}/certs`);
writefile(`${HP_DIR}/certs/${filename}.pem`, filecontent);
system(`rm -f ${tmpcert}`);
return { result: true };
};
const filename = req.args?.filename;
switch (filename) {
case 'client_ca':
case 'server_publickey':
return writeCertificate(filename, false);
break;
case 'server_privatekey':
return writeCertificate(filename, true);
break;
default:
return { result: false, error: 'illegal cerificate filename' };
break;
}
}
},
connection_check: {
args: { site: 'site' },
call: function(req) {
let url;
switch(req.args?.site) {
case 'baidu':
url = 'https://www.baidu.com';
break;
case 'google':
url = 'https://www.google.com';
break;
default:
return { result: false, error: 'illegal site' };
break;
}
return { result: (system(`/usr/bin/wget --spider -qT3 ${url} 2>"/dev/null"`, 3100) === 0) };
}
},
current_node_get: {
call: function() {
const uci = cursor();
uci.load('homeproxy');
const formatNode = (section_id, fallback_name) => {
if (!section_id || section_id in ['nil', 'same'])
return { id: section_id, label: fallback_name || section_id, type: null };
const section = uci.get_all('homeproxy', section_id);
if (!section || section['.type'] !== 'node')
return { id: section_id, label: fallback_name || section_id, type: null };
const address = trim(section.override_address || section.address || '');
const port = trim(section.override_port || section.port || '');
const label = trim(section.label || '') || ((address && port) ? `${address}:${port}` : section_id);
return {
id: section_id,
label,
type: section.type || null
};
};
const main_node = trim(uci.get('homeproxy', 'config', 'main_node') || 'nil');
if (main_node !== 'urltest')
return {
mode: main_node,
active: formatNode(main_node, (main_node === 'nil') ? 'Disable' : null),
error: null
};
const fd = popen('/usr/bin/wget -qO- http://127.0.0.1:9090/proxies/main-out 2>"/dev/null"');
if (!fd)
return {
mode: 'urltest',
active: { id: 'urltest', label: 'URLTest', type: 'urltest' },
error: 'clash_api_unavailable'
};
let payload = '', line;
for (line = fd.read('line'); length(line); line = fd.read('line'))
payload += line;
fd.close();
let data = null;
try {
data = json(payload);
} catch (e) {}
const tag = trim(data?.now || '');
if (!tag)
return {
mode: 'urltest',
active: { id: 'urltest', label: 'URLTest', type: 'urltest' },
error: 'missing_current_proxy'
};
const matched = match(tag, /^cfg-(.*)-out$/);
const section_id = matched ? matched[1] : tag;
return {
mode: 'urltest',
active: formatNode(section_id, tag),
error: null
};
}
},
node_latency_test: {
args: { node: 'node', mode: 'mode' },
call: function(req) {
const error_codes = {
invalid_node: 'invalid_node',
invalid_mode: 'invalid_mode',
mode_mismatch: 'mode_mismatch',
rp_invalid_outbound: 'rp_invalid_outbound',
rp_runtime_prepare_failed: 'rp_runtime_prepare_failed',
rp_runtime_launch_failed: 'rp_runtime_launch_failed',
rp_runtime_timeout: 'rp_runtime_timeout',
rp_delay_timeout: 'rp_delay_timeout',
rp_delay_failed: 'rp_delay_failed',
icmp_probe_unavailable: 'icmp_probe_unavailable',
icmp_timeout: 'icmp_timeout',
icmp_failed: 'icmp_failed',
probe_timeout: 'probe_timeout',
backend_execution_failed: 'backend_execution_failed',
probe_failed: 'probe_failed'
};
const probe_timeout_s = 3;
const probe_timeout_ms = probe_timeout_s * 1000;
const probe_system_timeout_ms = probe_timeout_ms + 250;
const rp_probe_url = 'https://cp.cloudflare.com/generate_204';
const rp_probe_url_encoded = 'https%3A%2F%2Fcp.cloudflare.com%2Fgenerate_204';
const rp_delay_timeout_ms = 5000;
const formatResult = (node, state, latency_ms, err, extras) => ({
node,
state,
latency_ms,
measured_at: time(),
error: err,
icmp_latency_ms: (extras && extras.icmp_latency_ms != null) ? extras.icmp_latency_ms : null,
icmp_error: (extras && extras.icmp_error) ? extras.icmp_error : null,
rp_runtime: (extras && extras.rp_runtime) ? extras.rp_runtime : null
});
const strToBool = (str) => ((str === '1') || null);
const strToInt = (str) => (str != null && trim(sprintf('%s', str)) !== '') ? (int(str) || null) : null;
const strToTime = (str) => (str != null && trim(sprintf('%s', str)) !== '') ? (str + 's') : null;
function removeBlankAttrs(res) {
let content;
if (type(res) === 'object') {
content = {};
map(keys(res), (k) => {
if (type(res[k]) in ['array', 'object'])
content[k] = removeBlankAttrs(res[k]);
else if (res[k] !== null && res[k] !== '')
content[k] = res[k];
});
} else if (type(res) === 'array') {
content = [];
map(res, (k) => {
if (type(k) in ['array', 'object'])
push(content, removeBlankAttrs(k));
else if (k !== null && k !== '')
push(content, k);
});
} else
return res;
return content;
}
const parse_port = (strport) => {
if (type(strport) !== 'array' || length(strport) === 0)
return null;
let ports = [];
for (let i = 0; i < length(strport); i++)
push(ports, int(strport[i]));
return ports;
};
const generate_endpoint = (node_cfg) => {
if (type(node_cfg) !== 'object' || length(node_cfg) === 0)
return null;
return {
type: node_cfg.type,
tag: 'cfg-' + node_cfg['.name'] + '-out',
address: node_cfg.wireguard_local_address,
mtu: strToInt(node_cfg.wireguard_mtu),
private_key: node_cfg.wireguard_private_key,
peers: (node_cfg.type === 'wireguard') ? [
{
address: node_cfg.address,
port: strToInt(node_cfg.port),
allowed_ips: [
'0.0.0.0/0',
'::/0'
],
persistent_keepalive_interval: strToInt(node_cfg.wireguard_persistent_keepalive_interval),
public_key: node_cfg.wireguard_peer_public_key,
pre_shared_key: node_cfg.wireguard_pre_shared_key,
reserved: parse_port(node_cfg.wireguard_reserved)
}
] : null,
system: (node_cfg.type === 'wireguard') ? false : null,
tcp_fast_open: strToBool(node_cfg.tcp_fast_open),
tcp_multi_path: strToBool(node_cfg.tcp_multi_path),
udp_fragment: strToBool(node_cfg.udp_fragment)
};
};
const generate_outbound = (node_cfg) => {
if (type(node_cfg) !== 'object' || length(node_cfg) === 0)
return null;
return {
type: node_cfg.type,
tag: 'cfg-' + node_cfg['.name'] + '-out',
server: node_cfg.address,
server_port: strToInt(node_cfg.port),
server_ports: node_cfg.hysteria_hopping_port,
username: (node_cfg.type !== 'ssh') ? node_cfg.username : null,
user: (node_cfg.type === 'ssh') ? node_cfg.username : null,
password: node_cfg.password,
override_address: node_cfg.override_address,
override_port: strToInt(node_cfg.override_port),
proxy_protocol: strToInt(node_cfg.proxy_protocol),
idle_session_check_interval: strToTime(node_cfg.anytls_idle_session_check_interval),
idle_session_timeout: strToTime(node_cfg.anytls_idle_session_timeout),
min_idle_session: strToInt(node_cfg.anytls_min_idle_session),
hop_interval: strToTime(node_cfg.hysteria_hop_interval),
up_mbps: strToInt(node_cfg.hysteria_up_mbps),
down_mbps: strToInt(node_cfg.hysteria_down_mbps),
obfs: node_cfg.hysteria_obfs_type ? {
type: node_cfg.hysteria_obfs_type,
password: node_cfg.hysteria_obfs_password
} : node_cfg.hysteria_obfs_password,
auth: (node_cfg.hysteria_auth_type === 'base64') ? node_cfg.hysteria_auth_payload : null,
auth_str: (node_cfg.hysteria_auth_type === 'string') ? node_cfg.hysteria_auth_payload : null,
recv_window_conn: strToInt(node_cfg.hysteria_recv_window_conn),
recv_window: strToInt(node_cfg.hysteria_revc_window),
disable_mtu_discovery: strToBool(node_cfg.hysteria_disable_mtu_discovery),
method: node_cfg.shadowsocks_encrypt_method,
plugin: node_cfg.shadowsocks_plugin,
plugin_opts: node_cfg.shadowsocks_plugin_opts,
version: (node_cfg.type === 'shadowtls') ? strToInt(node_cfg.shadowtls_version) : ((node_cfg.type === 'socks') ? node_cfg.socks_version : null),
client_version: node_cfg.ssh_client_version,
host_key: node_cfg.ssh_host_key,
host_key_algorithms: node_cfg.ssh_host_key_algo,
private_key: node_cfg.ssh_priv_key,
private_key_passphrase: node_cfg.ssh_priv_key_pp,
uuid: node_cfg.uuid,
congestion_control: node_cfg.tuic_congestion_control,
udp_relay_mode: node_cfg.tuic_udp_relay_mode,
udp_over_stream: strToBool(node_cfg.tuic_udp_over_stream),
zero_rtt_handshake: strToBool(node_cfg.tuic_enable_zero_rtt),
heartbeat: strToTime(node_cfg.tuic_heartbeat),
flow: node_cfg.vless_flow,
alter_id: strToInt(node_cfg.vmess_alterid),
security: node_cfg.vmess_encrypt,
global_padding: strToBool(node_cfg.vmess_global_padding),
authenticated_length: strToBool(node_cfg.vmess_authenticated_length),
packet_encoding: node_cfg.packet_encoding,
multiplex: (node_cfg.multiplex === '1') ? {
enabled: true,
protocol: node_cfg.multiplex_protocol,
max_connections: strToInt(node_cfg.multiplex_max_connections),
min_streams: strToInt(node_cfg.multiplex_min_streams),
max_streams: strToInt(node_cfg.multiplex_max_streams),
padding: strToBool(node_cfg.multiplex_padding),
brutal: (node_cfg.multiplex_brutal === '1') ? {
enabled: true,
up_mbps: strToInt(node_cfg.multiplex_brutal_up),
down_mbps: strToInt(node_cfg.multiplex_brutal_down)
} : null
} : null,
tls: (node_cfg.tls === '1') ? {
enabled: true,
server_name: node_cfg.tls_sni,
insecure: strToBool(node_cfg.tls_insecure),
alpn: node_cfg.tls_alpn,
min_version: node_cfg.tls_min_version,
max_version: node_cfg.tls_max_version,
cipher_suites: node_cfg.tls_cipher_suites,
certificate_path: node_cfg.tls_cert_path,
ech: (node_cfg.tls_ech === '1') ? {
enabled: true,
config: node_cfg.tls_ech_config,
config_path: node_cfg.tls_ech_config_path
} : null,
utls: node_cfg.tls_utls ? {
enabled: true,
fingerprint: node_cfg.tls_utls
} : null,
reality: (node_cfg.tls_reality === '1') ? {
enabled: true,
public_key: node_cfg.tls_reality_public_key,
short_id: node_cfg.tls_reality_short_id
} : null
} : null,
transport: node_cfg.transport ? {
type: node_cfg.transport,
host: node_cfg.http_host || node_cfg.httpupgrade_host,
path: node_cfg.http_path || node_cfg.ws_path,
headers: node_cfg.ws_host ? {
Host: node_cfg.ws_host
} : null,
method: node_cfg.http_method,
max_early_data: strToInt(node_cfg.websocket_early_data),
early_data_header_name: node_cfg.websocket_early_data_header,
service_name: node_cfg.grpc_servicename,
idle_timeout: node_cfg.http_idle_timeout,
ping_timeout: node_cfg.http_ping_timeout,
permit_without_stream: strToBool(node_cfg.grpc_permit_without_stream)
} : null,
udp_over_tcp: (node_cfg.udp_over_tcp === '1') ? {
enabled: true,
version: strToInt(node_cfg.udp_over_tcp_version)
} : null,
tcp_fast_open: strToBool(node_cfg.tcp_fast_open),
tcp_multi_path: strToBool(node_cfg.tcp_multi_path),
udp_fragment: strToBool(node_cfg.udp_fragment)
};
};
const isControllerPortBusy = (port) => {
const hex_port = sprintf('%04X', int(port));
const tcp_v4 = readfile('/proc/net/tcp') || '';
const tcp_v6 = readfile('/proc/net/tcp6') || '';
for (let line in split(tcp_v4 + '\n' + tcp_v6, /\n/)) {
const fields = split(trim(line), /\s+/);
if (length(fields) < 4)
continue;
const local_addr = split(fields[1], ':');
const state = fields[3];
if (length(local_addr) !== 2)
continue;
if (local_addr[1] === hex_port && state === '0A')
return true;
}
return false;
};
const pickControllerPort = (seed) => {
const min_port = 19090;
const span = 100;
let hash_seed = 0;
for (let i = 0; i < length(seed); i++)
hash_seed += ord(seed, i);
const start = hash_seed % span;
for (let i = 0; i < span; i++) {
const candidate = min_port + ((start + i) % span);
if (candidate === 9090)
continue;
if (!isControllerPortBusy(candidate))
return candidate;
}
return null;
};
const listRuntimePidsByConfig = (config_path) => {
if (!config_path)
return [];
const scan_cmd = sprintf(
'/bin/sh -c %s -- %s',
shellquote('cfg="$1"; for proc in /proc/[0-9]*; do [ -d "$proc" ] || continue; pid="${proc#/proc/}"; [ -r "$proc/cmdline" ] || continue; found_cfg=0; has_sig=0; p1=""; p2=""; set -f; for arg in $(tr "\\000" "\\n" <"$proc/cmdline" 2>/dev/null); do [ "$arg" = "$cfg" ] && found_cfg=1; [ "${p2##*/}" = "sing-box" ] && [ "$p1" = "run" ] && [ "$arg" = "--config" ] && has_sig=1; p2="$p1"; p1="$arg"; done; set +f; [ "$has_sig" -eq 1 ] && [ "$found_cfg" -eq 1 ] && printf "%s\\n" "$pid"; done'),
shellquote(config_path)
);
const fd = popen(scan_cmd);
if (!fd)
return [];
let pids = [];
let line;
for (line = fd.read('line'); line !== null && line !== ''; line = fd.read('line')) {
const pid = trim(line);
if (match(pid, /^[0-9]+$/) && index(pids, pid) === -1)
push(pids, pid);
}
fd.close();
return pids;
};
const isRuntimeProcess = (pid, config_path) => {
if (!pid || !match(pid, /^[0-9]+$/) || !config_path)
return false;
if (system(`/bin/kill -0 ${shellquote(pid)} >/dev/null 2>&1`) !== 0)
return false;
return (index(listRuntimePidsByConfig(config_path), pid) !== -1);
};
const findRuntimePidByConfig = (config_path) => {
const pids = listRuntimePidsByConfig(config_path);
for (let i = 0; i < length(pids); i++) {
const pid = pids[i];
if (system(`/bin/kill -0 ${shellquote(pid)} >/dev/null 2>&1`) === 0)
return pid;
}
return null;
};
const resolveRuntimePid = (runtime_meta) => {
if (!runtime_meta?.config_path)
return null;
const pid_file = trim(readfile(runtime_meta.pid_path) || '');
if (isRuntimeProcess(pid_file, runtime_meta.config_path))
return pid_file;
const discovered = findRuntimePidByConfig(runtime_meta.config_path);
if (discovered)
writefile(runtime_meta.pid_path, discovered + '\n');
return discovered;
};
const cleanupProbeRuntime = (runtime_meta) => {
if (!runtime_meta)
return;
const terminateRuntimePid = (pid) => {
if (!pid)
return;
system(`/bin/kill ${shellquote(pid)} >/dev/null 2>&1`);
system('/bin/sleep 1');
if (system(`/bin/kill -0 ${shellquote(pid)} >/dev/null 2>&1`) === 0)
system(`/bin/kill -9 ${shellquote(pid)} >/dev/null 2>&1`);
};
let pid = resolveRuntimePid(runtime_meta);
while (pid) {
terminateRuntimePid(pid);
system(`/bin/rm -f ${shellquote(runtime_meta.pid_path)}`);
pid = findRuntimePidByConfig(runtime_meta.config_path);
}
system(`/bin/rm -rf ${shellquote(runtime_meta.session_dir)}`);
};
const waitControllerReady = (runtime_meta, timeout_ms) => {
try {
let retries = int((timeout_ms + 999) / 1000);
if (!retries || retries < 1)
retries = 1;
for (let i = 0; i < retries; i++) {
resolveRuntimePid(runtime_meta);
const probe_fd = popen(`/usr/bin/wget -qO- ${shellquote(runtime_meta.controller_url + '/version')} 2>"/dev/null"`);
if (probe_fd) {
let payload = '', line;
while (true) {
line = probe_fd.read('line');
if (line === null || line === '')
break;
payload += line;
}
probe_fd.close();
if (trim(payload))
return true;
}
if (i + 1 < retries)
system('/bin/sleep 1');
}
return false;
} catch (e) {
return false;
}
};
const buildProbeRuntime = (node_name, node_cfg) => {
const runtime_root = RUN_DIR + '/latency-test';
const session_id = sprintf('%s-%d-%d', node_name, time(), int(readfile('/proc/uptime') || '0'));
const session_dir = `${runtime_root}/${session_id}`;
const config_path = `${session_dir}/sing-box.json`;
const log_path = `${session_dir}/sing-box.log`;
const cache_path = `${session_dir}/cache.db`;
const pid_path = `${session_dir}/sing-box.pid`;
const controller_port = pickControllerPort(node_name || 'real-proxy');
if (!controller_port)
return { error: 'no available temporary controller port' };
const selected_tag = 'probe-out';
const config = {
log: {
disabled: false,
level: 'warn',
output: log_path,
timestamp: true
},
outbounds: [
{
type: 'direct',
tag: 'direct-out'
},
{
type: 'block',
tag: 'block-out'
}
],
endpoints: [],
route: {
final: selected_tag,
auto_detect_interface: true
},
experimental: {
clash_api: {
external_controller: `127.0.0.1:${controller_port}`
},
cache_file: {
enabled: true,
path: cache_path
}
}
};
const rendered_outbound = (node_cfg.type === 'wireguard') ? generate_endpoint(node_cfg) : generate_outbound(node_cfg);
if (rendered_outbound && type(rendered_outbound) === 'object')
rendered_outbound.tag = selected_tag;
if (!rendered_outbound || type(rendered_outbound) !== 'object' || rendered_outbound.tag !== selected_tag || !rendered_outbound.type)
return {
error: 'failed to render temporary outbound for selected node',
code: error_codes.rp_invalid_outbound
};
if (node_cfg.type === 'wireguard')
push(config.endpoints, rendered_outbound);
else
push(config.outbounds, rendered_outbound);
if (system(`/bin/mkdir -p ${shellquote(session_dir)}`) !== 0)
return { error: 'failed to prepare temporary runtime directory' };
if (!writefile(config_path, sprintf('%.J\n', removeBlankAttrs(config)))) {
system(`/bin/rm -rf ${shellquote(session_dir)}`);
return { error: 'failed to write temporary sing-box config' };
}
return {
runtime_root,
session_dir,
config_path,
log_path,
cache_path,
pid_path,
controller_port,
controller_url: `http://127.0.0.1:${controller_port}`
};
};
const launchProbeRuntime = (runtime_meta) => {
if (!runtime_meta)
return false;
const launch_cmd = sprintf(
'/usr/bin/sing-box run --config %s >/dev/null 2>&1 & pid=$!; [ -n "$pid" ] && printf "%%s\\n" "$pid" > %s',
shellquote(runtime_meta.config_path),
shellquote(runtime_meta.pid_path)
);
if (system(`/bin/sh -c ${shellquote(launch_cmd)}`) !== 0)
return false;
for (let i = 0; i < 5; i++) {
const pid = resolveRuntimePid(runtime_meta);
if (pid)
return true;
system('/bin/sleep 1');
}
return false;
};
const monotonicMs = () => {
const uptime = trim(readfile('/proc/uptime') || '');
const parsed = match(uptime, /^([0-9]+)(\.([0-9]+))?/);
if (!parsed)
return null;
const sec = int(parsed[1]);
const frac = ((parsed[3] || '') + '000');
const msec = int(substr(frac, 0, 3));
return sec * 1000 + msec;
};
const probeRealProxyDelay = (runtime_meta, node_name) => {
const selected_tag = 'probe-out';
const delay_endpoint = sprintf(
'%s/proxies/%s/delay?url=%s&timeout=%d',
runtime_meta.controller_url,
selected_tag,
rp_probe_url_encoded,
rp_delay_timeout_ms
);
const started_ms = monotonicMs();
const fd = popen(sprintf(
'/usr/bin/wget -qO- -T %d %s 2>"/dev/null"',
int((rp_delay_timeout_ms + 999) / 1000),
shellquote(delay_endpoint)
));
if (!fd)
return {
state: 'timeout',
error: {
code: error_codes.rp_delay_timeout,
message: 'failed to open temporary Clash API delay endpoint'
}
};
let payload = '', line;
while (true) {
line = fd.read('line');
if (line === null || line === '')
break;
payload += line;
}
fd.close();
const ended_ms = monotonicMs();
if (!trim(payload || '')) {
if (started_ms !== null && ended_ms !== null && ended_ms - started_ms >= rp_delay_timeout_ms)
return {
state: 'timeout',
error: {
code: error_codes.rp_delay_timeout,
message: sprintf('temporary Clash API delay request timed out after %dms', rp_delay_timeout_ms)
}
};
return {
state: 'error',
error: {
code: error_codes.rp_delay_failed,
message: 'temporary Clash API delay response is empty'
}
};
}
let data = null;
try {
data = json(payload);
} catch (e) {
return {
state: 'error',
error: {
code: error_codes.rp_delay_failed,
message: 'temporary Clash API delay response is not valid JSON'
}
};
}
const delay_ms = int(data?.delay || '0');
if (delay_ms > 0)
return {
state: 'success',
latency_ms: delay_ms
};
const failure_msg = trim(sprintf('%s', data?.message || data?.error || '')) || 'temporary Clash API delay request failed';
if (match(failure_msg, /timeout/i) || (started_ms !== null && ended_ms !== null && ended_ms - started_ms >= rp_delay_timeout_ms))
return {
state: 'timeout',
error: {
code: error_codes.rp_delay_timeout,
message: failure_msg
}
};
return {
state: 'error',
error: {
code: error_codes.rp_delay_failed,
message: failure_msg
}
};
};
const normalizeLatencyMode = (m) => (m === 'tcp') ? 'icmp' : m;
const node = trim(req.args?.node || '') || null;
const requested_mode = trim(req.args?.mode || '');
const mode = normalizeLatencyMode(requested_mode);
const allowed_modes = ['icmp', 'rp'];
const allowed_configured_modes = ['icmp', 'tcp', 'rp'];
if (!node)
return formatResult(null, 'error', null, {
code: error_codes.invalid_node,
message: 'missing node section id'
});
if (index(allowed_modes, mode) === -1)
return formatResult(node, 'error', null, {
code: error_codes.invalid_mode,
message: sprintf('invalid latency test mode: %s', mode || '(empty)')
});
const uci = cursor();
uci.load('homeproxy');
const configured_mode_raw = trim(uci.get('homeproxy', 'subscription', 'latency_test_mode') || '');
const configured_mode = normalizeLatencyMode(configured_mode_raw);
if (configured_mode_raw && index(allowed_configured_modes, configured_mode_raw) === -1)
return formatResult(node, 'error', null, {
code: error_codes.invalid_mode,
message: sprintf('configured latency test mode is invalid: %s', configured_mode_raw || '(empty)')
});
if (configured_mode && configured_mode !== mode)
return formatResult(node, 'error', null, {
code: error_codes.mode_mismatch,
message: sprintf('request mode %s does not match configured mode %s', mode, configured_mode)
});
const section = uci.get_all('homeproxy', node);
if (!section || section['.type'] !== 'node')
return formatResult(node, 'error', null, {
code: error_codes.invalid_node,
message: 'node section does not exist or is not a node section'
});
const address = trim(section.address || '');
const port = int(section.port || '0');
if (!address || port <= 0 || port > 65535)
return formatResult(node, 'error', null, {
code: error_codes.invalid_node,
message: 'node address or port is invalid'
});
if (mode === 'rp') {
let runtime_meta = null;
let runtime_active = false;
let runtime_info = {
runtime_root: RUN_DIR + '/latency-test',
session_dir: null,
config_path: null,
pid_path: null,
controller: null,
launched: false,
controller_ready: false,
cleaned_up: false
};
runtime_meta = buildProbeRuntime(node, section);
if (runtime_meta?.error)
runtime_info.cleaned_up = true;
if (runtime_meta?.error)
return formatResult(node, 'error', null, {
code: runtime_meta.code || error_codes.rp_runtime_prepare_failed,
message: runtime_meta.error
}, {
rp_runtime: runtime_info
});
runtime_info.session_dir = runtime_meta.session_dir;
runtime_info.config_path = runtime_meta.config_path;
runtime_info.pid_path = runtime_meta.pid_path;
runtime_info.controller = `127.0.0.1:${runtime_meta.controller_port}`;
runtime_active = true;
const finalizeRealProxyResult = (state, latency_ms, err) => {
if (runtime_active && runtime_meta) {
cleanupProbeRuntime(runtime_meta);
runtime_active = false;
runtime_info.cleaned_up = true;
}
return formatResult(node, state, latency_ms, err, {
rp_runtime: runtime_info
});
};
try {
if (!launchProbeRuntime(runtime_meta))
return finalizeRealProxyResult('error', null, {
code: error_codes.rp_runtime_launch_failed,
message: 'failed to launch temporary sing-box runtime'
});
runtime_info.launched = true;
if (!waitControllerReady(runtime_meta, rp_delay_timeout_ms))
return finalizeRealProxyResult('timeout', null, {
code: error_codes.rp_runtime_timeout,
message: 'temporary sing-box runtime controller did not become ready in time'
});
runtime_info.controller_ready = true;
const delay_result = probeRealProxyDelay(runtime_meta, node);
if (delay_result.state === 'success' && delay_result.latency_ms > 0)
return finalizeRealProxyResult('success', delay_result.latency_ms, null);
if (delay_result.state === 'timeout')
return finalizeRealProxyResult('timeout', null, delay_result.error || {
code: error_codes.rp_delay_timeout,
message: sprintf('temporary Clash API delay request timed out after %dms', rp_delay_timeout_ms)
});
return finalizeRealProxyResult('error', null, delay_result.error || {
code: error_codes.rp_delay_failed,
message: sprintf('temporary Clash API delay probe failed for %s', rp_probe_url)
});
} catch (e) {
return finalizeRealProxyResult('error', null, {
code: error_codes.backend_execution_failed,
message: 'unexpected backend error while handling temporary rp runtime'
});
}
}
const ping = access('/bin/ping') ? '/bin/ping' : (access('/usr/bin/ping') ? '/usr/bin/ping' : null);
const runProbe = (command, timeout_ms) => {
const started_ms = monotonicMs();
const probe_exit = system(command, timeout_ms);
const ended_ms = monotonicMs();
let latency_ms = null;
if (started_ms !== null && ended_ms !== null && ended_ms >= started_ms)
latency_ms = ended_ms - started_ms;
return { probe_exit, latency_ms };
};
let icmp_latency_ms = null;
let icmp_error = null;
if (!ping) {
icmp_error = {
code: error_codes.icmp_probe_unavailable,
message: 'missing ping executable for ICMP probe'
};
}
else {
const result = runProbe(
`${ping} -c 1 -W ${probe_timeout_s} ${shellquote(address)} >/dev/null 2>/dev/null`,
probe_system_timeout_ms
);
if (result.probe_exit === 0)
icmp_latency_ms = (result.latency_ms !== null) ? result.latency_ms : 0;
else if (result.probe_exit === -1 || (result.latency_ms !== null && result.latency_ms >= probe_timeout_ms))
icmp_error = {
code: error_codes.icmp_timeout,
message: `ICMP ping timeout after ${probe_timeout_s}s`
};
else if (result.probe_exit === 127)
icmp_error = {
code: error_codes.backend_execution_failed,
message: 'ICMP ping command execution failed'
};
else
icmp_error = {
code: error_codes.icmp_failed,
message: `ICMP ping failed with exit code ${result.probe_exit}`
};
}
if (icmp_latency_ms !== null)
return formatResult(node, 'success', icmp_latency_ms, null, {
icmp_latency_ms,
icmp_error
});
if (icmp_error?.code === error_codes.icmp_timeout)
return formatResult(node, 'timeout', null, {
code: error_codes.probe_timeout,
message: `ICMP probe timed out after ${probe_timeout_s}s`
}, {
icmp_latency_ms,
icmp_error
});
return formatResult(node, 'error', null, icmp_error || {
code: error_codes.probe_failed,
message: 'ICMP probe failed'
}, {
icmp_latency_ms,
icmp_error
});
}
},
log_clean: {
args: { type: 'type' },
call: function(req) {
if (!(req.args?.type in ['homeproxy', 'sing-box-c', 'sing-box-s']))
return { result: false, error: 'illegal type' };
const filestat = lstat(`${RUN_DIR}/${req.args?.type}.log`);
if (filestat)
writefile(`${RUN_DIR}/${req.args?.type}.log`, '');
return { result: true };
}
},
singbox_generator: {
args: { type: 'type', params: 'params' },
call: function(req) {
if (!(req.args?.type in ['ech-keypair', 'uuid', 'reality-keypair', 'vapid-keypair', 'wg-keypair']))
return { result: false, error: 'illegal type' };
const type = req.args?.type;
let result = {};
const fd = popen('/usr/bin/sing-box generate ' + type + ` ${req.args?.params || ''}`);
if (fd) {
let ech_cfg_set = false;
let ech_key_set = false;
for (let line = fd.read('line'); length(line); line = fd.read('line')) {
if (type === 'uuid')
result.uuid = trim(line);
else if (type in ['reality-keypair', 'vapid-keypair', 'wg-keypair']) {
let priv = match(trim(line), /PrivateKey: (.*)/);
if (priv)
result.private_key = priv[1];
let pub = match(trim(line), /PublicKey: (.*)/);
if (pub)
result.public_key = pub[1];
} else if (type in ['ech-keypair']) {
if (trim(line) === '-----BEGIN ECH CONFIGS-----')
ech_cfg_set = true;
else if (trim(line) === '-----BEGIN ECH KEYS-----')
ech_key_set = true;
if (ech_cfg_set)
result.ech_cfg = result.ech_cfg ? result.ech_cfg + '\n' + trim(line) : trim(line) ;
if (ech_key_set)
result.ech_key = result.ech_key ? result.ech_key + '\n' + trim(line) : trim(line) ;
if (trim(line) === '-----END ECH CONFIGS-----')
ech_cfg_set = false;
else if (trim(line) === '-----END ECH KEYS-----')
ech_key_set = false;
}
}
fd.close();
}
return { result };
}
},
singbox_get_features: {
call: function() {
let features = {};
const fd = popen('/usr/bin/sing-box version');
if (fd) {
for (let line = fd.read('line'); length(line); line = fd.read('line')) {
if (match(trim(line), /^sing-box version (.*)/))
features.version = match(trim(line), /^sing-box version (.*)/)[1];
let tags = match(trim(line), /^Tags: (.*)/);
if (tags)
for (let i in split(tags[1], ','))
features[i] = true;
}
fd.close();
}
features.hp_has_ip_full = access('/usr/libexec/ip-full');
features.hp_has_tcp_brutal = hasKernelModule('brutal.ko');
features.hp_has_tproxy = hasKernelModule('nft_tproxy.ko') || access('/etc/modules.d/nft-tproxy');
features.hp_has_tun = hasKernelModule('tun.ko') || access('/etc/modules.d/30-tun');
return features;
}
},
resources_get_version: {
args: { type: 'type' },
call: function(req) {
const version = trim(readfile(`${HP_DIR}/resources/${req.args?.type}.ver`));
return { version: version, error: error() };
}
},
resources_update: {
args: { type: 'type' },
call: function(req) {
if (req.args?.type) {
const type = shellquote(req.args?.type);
const exit_code = system(`${HP_DIR}/scripts/update_resources.sh ${type}`);
return { status: exit_code };
} else
return { status: 255, error: 'illegal type' };
}
}
};
return { 'luci.homeproxy': methods };