mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-07-27 08:31:44 +08:00
958 lines
28 KiB
Plaintext
958 lines
28 KiB
Plaintext
#!/usr/bin/ucode
|
|
/*
|
|
* SPDX-License-Identifier: GPL-2.0-only
|
|
*
|
|
* Copyright (C) 2023-2024 ImmortalWrt.org
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
import { access, lstat, popen, readfile, writefile } from 'fs';
|
|
import { cursor } from 'uci';
|
|
|
|
import {
|
|
removeBlankAttrs, renderEndpoint, renderOutbound
|
|
} from '/etc/homeproxy/scripts/homeproxy.uc';
|
|
|
|
/* 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 RESOURCE_SOURCES = {
|
|
china_ip4: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/surge-rules@release/cncidr.txt',
|
|
china_ip6: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/surge-rules@release/cncidr.txt',
|
|
china_list: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/surge-rules@release/direct.txt',
|
|
gfw_list: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/surge-rules@release/gfw.txt'
|
|
};
|
|
|
|
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, kind) => {
|
|
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 = (kind === 'private') ? /^-----BEGIN (RSA|EC) PRIVATE KEY-----$/ :
|
|
((kind === 'ech') ? /^-----BEGIN ECH CONFIGS-----$/ : /^-----BEGIN CERTIFICATE-----$/),
|
|
end = (kind === 'private') ? /^-----END (RSA|EC) PRIVATE KEY-----$/ :
|
|
((kind === 'ech') ? /^-----END ECH CONFIGS-----$/ : /^-----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`);
|
|
const certfile = `${HP_DIR}/certs/${filename}.pem`;
|
|
writefile(certfile, filecontent);
|
|
if (kind === 'private')
|
|
system(`chmod 600 ${certfile}`);
|
|
system(`rm -f ${tmpcert}`);
|
|
|
|
return { result: true };
|
|
};
|
|
|
|
const filename = req.args?.filename;
|
|
switch (filename) {
|
|
case 'client_ca':
|
|
case 'server_publickey':
|
|
return writeCertificate(filename, 'certificate');
|
|
break;
|
|
case 'client_ech_conf':
|
|
return writeCertificate(filename, 'ech');
|
|
break;
|
|
case 'server_privatekey':
|
|
return writeCertificate(filename, 'private');
|
|
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.address || '');
|
|
const port = trim(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 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') ? renderEndpoint(node_cfg) : renderOutbound(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} ${shellquote(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: {
|
|
call: function() {
|
|
let resources = [];
|
|
for (let resource_type in keys(RESOURCE_SOURCES)) {
|
|
const version = trim(readfile(`${HP_DIR}/resources/${resource_type}.ver`) || '');
|
|
push(resources, {
|
|
type: resource_type,
|
|
version: version || null,
|
|
source: RESOURCE_SOURCES[resource_type]
|
|
});
|
|
}
|
|
return { resources };
|
|
}
|
|
},
|
|
resources_update: {
|
|
call: function() {
|
|
return { status: system(`${HP_DIR}/scripts/update_resources.sh`) };
|
|
}
|
|
}
|
|
};
|
|
|
|
return { 'luci.homeproxy': methods };
|