#!/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 };