#!/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'; /* Kanged from ucode/luci */ function shellquote(s) { return `'${replace(s, "'", "'\\''")}'`; } /* Optional GitHub mirror (uci homeproxy.config.github_mirror), used ONLY as a * FALLBACK: gh_fetch tries GitHub first and swaps to the mirror only if GitHub * fails — a healthy GitHub is never bypassed. Mirrors core_mgmt.uc's helpers. */ function gh_mirror_base() { let base = null; const fd = popen('uci -q get homeproxy.config.github_mirror 2>/dev/null'); if (fd) { base = trim(fd.read('all')); fd.close(); } return (base && length(base)) ? replace(base, /\/+$/, '') : null; } function gh_fetch(url, dest, timeout_ms) { let rc = system(`wget -qO ${shellquote(dest)} --timeout=15 ${shellquote(url)} 2>/dev/null`, timeout_ms); if (rc !== 0) { const base = gh_mirror_base(); const m = base ? match(url, /^https:\/\/github\.com(\/[^\/]+\/[^\/]+\/releases\/.+)$/) : null; if (m) rc = system(`wget -qO ${shellquote(dest)} --timeout=15 ${shellquote(base + m[1])} 2>/dev/null`, timeout_ms); } return rc; } function hasKernelModule(kmod) { const modname = replace(replace(kmod, /\.ko$/, ''), /-/g, '_'); return !!access('/sys/module/' + modname); } const HP_DIR = '/etc/homeproxy'; const RUN_DIR = '/var/run/homeproxy'; function get_active_core() { /* Mirror init.d / generate_client.uc precedence: honor preferred_core when that core * is installed, else auto (hiddify-core first, then sing-box). Otherwise file presence * alone would report the wrong core when both are installed. */ let preferred = null; let pfd = popen('uci get homeproxy.config.preferred_core 2>/dev/null'); if (pfd) { preferred = trim(pfd.read('all')); pfd.close(); } const have_hiddify = access('/usr/bin/hiddify-core'); const have_singbox = access('/usr/bin/sing-box'); const HID = { path: '/usr/bin/hiddify-core', type: 'hiddify', proc_name: 'hiddify-core' }; const SB = { path: '/usr/bin/sing-box', type: 'singbox', proc_name: 'sing-box' }; if (preferred === 'hiddify' && have_hiddify) return HID; if (preferred === 'singbox' && have_singbox) return SB; if (have_hiddify) return HID; if (have_singbox) return SB; let custom_path = null; let custom_type = null; let fd = popen('uci get homeproxy.config.custom_core_path 2>/dev/null'); if (fd) { custom_path = trim(fd.read('all')); fd.close(); } fd = popen('uci get homeproxy.config.custom_core_type 2>/dev/null'); if (fd) { custom_type = trim(fd.read('all')); fd.close(); } if (length(custom_path) > 0 && access(custom_path)) return { path: custom_path, type: length(custom_type) > 0 ? custom_type : 'singbox', proc_name: custom_type === 'hiddify' ? 'hiddify-core' : 'sing-box', custom: true }; return null; } 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; case 'yandex': url = 'https://ya.ru'; break; case 'speedtest': url = 'https://www.speedtest.net'; break; case 'youtube': url = 'https://www.youtube.com'; break; default: return { result: false, error: 'illegal site' }; break; } return { result: (system(`/usr/bin/wget --spider -qT3 ${url} 2>"/dev/null"`, 3100) === 0) }; } }, log_clean: { args: { type: 'type' }, call: function(req) { if (!(req.args?.type in ['homeproxy', 'hiddify-c'])) 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 core = get_active_core(); const core_binary = core ? core.path : null; if (core) { features.core_type = core.type; if (core.custom) features.core_custom = true; } if (core_binary) { const fd = popen(core_binary + ' version'); if (fd) { const out = fd.read('all'); fd.close(); const verMatch = match(out, / version v?(\S+)/); if (verMatch) features.version = verMatch[1]; const tagsMatch = match(out, /\nTags: ([^\n]+)/); if (tagsMatch) for (let tag in split(tagsMatch[1], ',')) features[trim(tag)] = true; } } 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'); features.available_cores = []; if (access('/usr/bin/hiddify-core')) push(features.available_cores, 'hiddify'); if (access('/usr/bin/sing-box')) push(features.available_cores, 'singbox'); return features; } }, detect_custom_core: { args: { path: 'path' }, call: function(req) { const path = req.args?.path; if (!path || length(path) === 0) return { result: false, error: 'No path provided' }; if (!access(path)) return { result: false, error: 'File not found: ' + path }; let out = ''; let fd = popen(shellquote(path) + ' version 2>&1'); if (fd) { out = trim(fd.read('all')); fd.close(); } if (length(out) === 0) return { result: false, error: 'Binary produced no output' }; let core_type = null; if (match(out, /hiddify/)) core_type = 'hiddify'; else if (match(out, /sing.box|singbox/)) core_type = 'singbox'; if (!core_type) return { result: false, error: 'Unknown binary type. Output: ' + substr(out, 0, 120) }; const verMatch = match(out, / version v?(\S+)/); const version = verMatch ? verMatch[1] : null; system('uci set homeproxy.config.custom_core_path=' + shellquote(path)); system('uci set homeproxy.config.custom_core_type=' + shellquote(core_type)); system('uci commit homeproxy'); return { result: true, type: core_type, version: version }; } }, clash_ip_info: { call: function() { const fd = popen('wget -qO- --timeout=3 http://127.0.0.1:9090/proxies'); if (!fd) return { error: 'failed to run wget' }; const raw = trim(fd.read('all')); fd.close(); if (!length(raw)) return { error: 'Clash API not reachable — regenerate config and restart homeproxy' }; let data; try { data = json(raw); } catch(e) { return { error: 'invalid JSON from Clash API' }; } const proxies = data?.proxies || {}; const lastHistory = (name) => { const h = proxies[name]?.history; return (h && length(h)) ? h[length(h) - 1] : null; }; /* Direct IP: from direct-out node */ const directEntry = lastHistory('direct-out'); /* Proxy IP: follow GLOBAL.now to the active leaf node */ let proxyEntry = null; let proxyNodeName = null; const nonProxy = (name) => name === 'direct-out' || name === 'block-out' || !name; const globalNow = proxies['GLOBAL']?.now; if (globalNow && proxies[globalNow] && !nonProxy(globalNow)) { const g = proxies[globalNow]; if (g.type === 'URLTest' || g.type === 'Fallback' || g.type === 'Selector') proxyNodeName = g.now; else proxyNodeName = globalNow; } /* Fallback: first URLTest/Fallback/Selector group */ if (!proxyNodeName) { for (let name in proxies) { if (name === 'GLOBAL') continue; const p = proxies[name]; if ((p.type === 'URLTest' || p.type === 'Fallback' || p.type === 'Selector') && p.now) { proxyNodeName = p.now; break; } } } /* Last fallback: main-out (route-based modes use it directly) */ if (!proxyNodeName && proxies['main-out']) proxyNodeName = 'main-out'; if (proxyNodeName) proxyEntry = lastHistory(proxyNodeName); const toEntry = (h, name) => h ? { ip: h.ipinfo?.ip, country: h.ipinfo?.country_code, org: h.ipinfo?.org, delay: h.delay, node: name || null } : null; return { direct: toEntry(directEntry, null), proxy: toEntry(proxyEntry, proxyNodeName) }; } }, clash_proxies: { call: function() { const fd = popen('wget -qO- --timeout=3 http://127.0.0.1:9090/proxies'); if (!fd) return { error: 'curl not available' }; const raw = trim(fd.read('all')); fd.close(); if (!length(raw)) return { error: 'Clash API not reachable — regenerate config and restart homeproxy' }; let data; try { data = json(raw); } catch(e) { return { error: 'invalid JSON from Clash API: ' + raw.slice(0, 80) }; } const groups = {}; for (let name in data?.proxies) { const p = data.proxies[name]; if (p.type === 'URLTest' || p.type === 'Fallback' || p.type === 'Selector') groups[name] = { type: p.type, now: p.now, all: p.all }; } return { groups }; } }, clash_active_node: { args: { tag: 'tag' }, call: function(req) { const fd = popen('wget -qO- --timeout=3 http://127.0.0.1:9090/proxies'); if (!fd) return { error: 'failed to run wget' }; const raw = trim(fd.read('all')); fd.close(); if (!length(raw)) return { error: 'Clash API not reachable' }; let data; try { data = json(raw); } catch(e) { return { error: 'invalid JSON from Clash API' }; } const proxies = data?.proxies || {}; const lastDelay = (name) => { const h = proxies[name]?.history; return (h && length(h)) ? h[length(h) - 1].delay : null; }; /* Focused query: resolve a specific group/leaf (e.g. 'main-udp-out' for the * dedicated UDP node) instead of following GLOBAL. */ const wantTag = req?.args?.tag; if (wantTag) { const p = proxies[wantTag]; if (!p) return { error: 'no active proxy node' }; if ((p.type === 'URLTest' || p.type === 'Fallback' || p.type === 'Selector') && p.now) return { node: p.now, type: proxies[p.now]?.type || null, delay: lastDelay(p.now), group: wantTag, group_type: p.type }; return { node: wantTag, type: p.type || null, delay: lastDelay(wantTag), group: null, group_type: null }; } /* Follow GLOBAL -> proxy group -> leaf node */ let groupName = null; let groupType = null; let nodeName = null; const nonProxy = (name) => name === 'direct-out' || name === 'block-out' || !name; const globalNow = proxies['GLOBAL']?.now; if (globalNow && proxies[globalNow] && !nonProxy(globalNow)) { const g = proxies[globalNow]; if (g.type === 'URLTest' || g.type === 'Fallback' || g.type === 'Selector') { groupName = globalNow; groupType = g.type; nodeName = g.now; } else { /* GLOBAL points directly to a leaf node */ nodeName = globalNow; } } /* Fallback: first URLTest/Fallback/Selector group */ if (!nodeName) { for (let name in proxies) { if (name === 'GLOBAL') continue; const p = proxies[name]; if ((p.type === 'URLTest' || p.type === 'Fallback' || p.type === 'Selector') && p.now) { groupName = name; groupType = p.type; nodeName = p.now; break; } } } /* Last fallback: main-out (route-based modes) */ if (!nodeName && proxies['main-out']) nodeName = 'main-out'; if (!nodeName) return { error: 'no active proxy node' }; return { node: nodeName, type: proxies[nodeName]?.type || null, delay: lastDelay(nodeName), group: groupName, group_type: groupType }; } }, diag_core_check: { call: function() { const core = get_active_core(); const binary = core ? core.path : null; const proc_name = core ? core.proc_name : 'sing-box'; const result = { hiddify_installed: !!access('/usr/bin/hiddify-core'), singbox_installed: !!access('/usr/bin/sing-box'), binary: binary, version: null, running: false, pid: null, byedpi_installed: !!access('/usr/bin/ciadpi'), byedpi_running: false, byedpi_pid: null, zapret_installed: !!access('/opt/zapret2/nfq2/nfqws2'), zapret_running: false, zapret_pid: null, listen_ports: [] }; /* ByeDPI (ciadpi) runs independently of the core, so detect it regardless. * Use pidof, not `pgrep -x` — BusyBox pgrep -x matches argv0 (/usr/bin/ciadpi), * not the bare name, so it misses the process. */ let bfd = popen('pidof ciadpi 2>/dev/null'); if (bfd) { const bpid = trim(bfd.read('all')); bfd.close(); result.byedpi_running = length(bpid) > 0; result.byedpi_pid = length(bpid) > 0 ? bpid : null; } /* Zapret (nfqws2) is the homeproxy-launched packet mangler — detect like ByeDPI. */ let zfd = popen('pidof nfqws2 2>/dev/null'); if (zfd) { const zpid = trim(zfd.read('all')); zfd.close(); result.zapret_running = length(zpid) > 0; result.zapret_pid = length(zpid) > 0 ? zpid : null; } if (!binary) return result; let fd = popen(binary + ' version 2>&1'); if (fd) { result.version = trim(fd.read('all')); fd.close(); } fd = popen('pidof ' + shellquote(proc_name) + ' 2>/dev/null'); if (fd) { const pid = trim(fd.read('all')); fd.close(); result.running = length(pid) > 0; result.pid = length(pid) > 0 ? pid : null; } fd = popen('netstat -tlnup 2>/dev/null'); if (fd) { const ports = []; for (let line = fd.read('line'); length(line); line = fd.read('line')) { if (match(line, /hiddify|sing.box|ciadpi/)) push(ports, trim(line)); } fd.close(); result.listen_ports = ports; } return result; } }, diag_config_check: { call: function() { const core = get_active_core(); const binary = core ? core.path : null; const config_path = RUN_DIR + '/hiddify-c.json'; if (!binary) return { valid: false, check_output: 'No core binary found', stats: {} }; const stat = lstat(config_path); if (!stat) return { valid: false, check_output: 'Config not found: ' + config_path, stats: {} }; /* JSON parse first — it's the validity signal for cores without a `check` command */ let stats = { outbounds: 0, rules: 0, inbounds: 0, dns_servers: 0 }; let parse_ok = false; const content = readfile(config_path); if (content) { try { const cfg = json(content); stats.outbounds = length(cfg.outbounds || []); stats.rules = length((cfg.route || {}).rules || []); stats.inbounds = length(cfg.inbounds || []); stats.dns_servers = length(((cfg.dns || {}).servers) || []); parse_ok = true; } catch(e) {} } let valid, check_output; if (core.type === 'singbox') { /* sing-box has a real config validator */ let fd = popen(binary + ' check -c ' + shellquote(config_path) + ' 2>&1'); if (fd) { check_output = trim(fd.read('all')); fd.close(); } valid = (system(binary + ' check -c ' + shellquote(config_path) + ' >/dev/null 2>&1') === 0); } else { /* hiddify-core (HiddifyCli) has no `check` subcommand — fall back to JSON parse */ valid = parse_ok; check_output = parse_ok ? 'Config parses (JSON OK). Deep validation unavailable — hiddify-core has no "check" command.' : 'Config is not valid JSON.'; } return { valid: valid, check_output: check_output, size_bytes: stat.size, stats: stats }; } }, diag_dns_ru: { call: function() { let fd = popen('uci get homeproxy.config.routing_mode 2>/dev/null'); if (!fd) return { skip: true }; const mode = trim(fd.read('all')); fd.close(); /* The DNS test runs in every selective mode. Region is data: the resolver * tag and the domestic anchor domain (must be in the region's geosite so it * routes to the region resolver) differ; the secure-dns test is shared. */ const REGION_DIAG = { proxy_banned_ru: { tag: 'russia-dns', label: 'Russia', domain: 'mail.ru' }, bypass_cn: { tag: 'region-dns', label: 'China', domain: 'baidu.com' }, bypass_ir: { tag: 'region-dns', label: 'Iran', domain: 'aparat.com' } }; const rd = REGION_DIAG[mode]; if (!rd) return { skip: true }; /* Get the region resolver + secure-dns server address from running config */ const content = readfile(RUN_DIR + '/hiddify-c.json'); if (!content) return { error: 'Config not found — is the service running?' }; let cfg; try { cfg = json(content); } catch(e) { return { error: 'Config parse error' }; } let region_server = null; let secure_server = null; const dns_servers = (cfg.dns || {}).servers || []; for (let s in dns_servers) { if (s.tag === rd.tag) region_server = s.server || s.address; if (s.tag === 'secure-dns') secure_server = s.server || s.address; } if (!region_server) return { error: rd.tag + ' server not found in config — regenerate and restart' }; /* Test 1: domestic anchor via default resolver — if HomeProxy routes it to the region resolver, it resolves */ let region_ok = false; let region_out = ''; fd = popen('nslookup ' + rd.domain + ' 2>&1'); if (fd) { region_out = trim(fd.read('all')); fd.close(); } region_ok = !!match(region_out, /Name:/); /* Test 2: andrevi.ch via default resolver — HomeProxy routes to secure-dns via proxy */ let secure_ok = false; let secure_out = ''; fd = popen('nslookup andrevi.ch 2>&1'); if (fd) { secure_out = trim(fd.read('all')); fd.close(); } secure_ok = !!match(secure_out, /Name:/); return { region_label: rd.label, region_domain: rd.domain, region_server: region_server, region_ok: region_ok, region_output: region_out, secure_server: secure_server, bootstrap: region_server, secure_ok: secure_ok, secure_output: secure_out }; } }, diag_nftables: { call: function() { let nft_lines = []; let fd = popen('nft list table inet fw4 2>/dev/null'); if (fd) { for (let line = fd.read('line'); length(line); line = fd.read('line')) { if (match(line, /homeproxy/)) push(nft_lines, trim(line)); } fd.close(); } let uci_lines = []; fd = popen('uci show homeproxy 2>&1'); if (fd) { for (let line = fd.read('line'); length(line); line = fd.read('line')) { const l = trim(line); if (match(l, /(proxy_mode|redirect_port|tproxy_port|dns_port|bypass|firewall|intercept)/)) push(uci_lines, l); } fd.close(); } const nft_present = length(nft_lines) > 0; /* Zapret queue chain: its counter rules don't contain the word "homeproxy", * so they are NOT in nft_lines above. Pull the chain explicitly so the packet * counters (mark 110 tcp/udp → NFQUEUE) are visible. */ let zapret_enabled = false; let zfd = popen('uci -q get homeproxy.config.zapret_enabled 2>/dev/null'); if (zfd) { zapret_enabled = trim(zfd.read('all')) === '1'; zfd.close(); } let zapret_running = false; zfd = popen('pidof nfqws2 2>/dev/null'); if (zfd) { zapret_running = length(trim(zfd.read('all'))) > 0; zfd.close(); } let zq_lines = []; fd = popen('nft list chain inet fw4 homeproxy_zapret_queue 2>/dev/null'); if (fd) { for (let line = fd.read('line'); length(line); line = fd.read('line')) { const l = trim(line); if (match(l, /(mark|queue|counter|chain)/)) push(zq_lines, l); } fd.close(); } /* UDP TPROXY chains: the full mangle chain plus its _port/_mark companions, * so the counter on the "tproxy → core" rule (UDP into HomeProxy) is visible * alongside the rules that return UDP before it. These chains only exist when * the UDP-tproxy firewall block was emitted (proxy_mode=tproxy + a UDP node). */ let udp_lines = []; let uchains = ['homeproxy_mangle_tproxy_port', 'homeproxy_mangle_mark', 'homeproxy_mangle_tproxy']; for (let ch in uchains) { fd = popen('nft list chain inet fw4 ' + ch + ' 2>/dev/null'); if (fd) { for (let line = fd.read('line'); length(line); line = fd.read('line')) { const l = trim(line); if (length(l) > 0) push(udp_lines, l); } fd.close(); } } return { nft_present: nft_present, nft_rules: join('\n', nft_lines), uci_firewall: join('\n', uci_lines), zapret_enabled: zapret_enabled, zapret_running: zapret_running, zapret_queue: join('\n', zq_lines), udp_tproxy: join('\n', udp_lines) }; } }, diag_service_restart: { call: function() { const exit_code = system('/etc/init.d/homeproxy restart >/dev/null 2>&1'); return { result: exit_code === 0, exit_code: exit_code }; } }, diag_report: { call: function() { const lines = []; const add = (s) => push(lines, s); add('# HomeProxy Diagnostics Report'); let fd = popen('date 2>/dev/null'); if (fd) { add('# Generated: ' + trim(fd.read('all'))); fd.close(); } add(''); add('## System'); fd = popen('cat /etc/openwrt_release 2>/dev/null'); if (fd) { add(trim(fd.read('all'))); fd.close(); } fd = popen('uname -a 2>/dev/null'); if (fd) { add('Kernel: ' + trim(fd.read('all'))); fd.close(); } add(''); add('## Core Binary'); const core = get_active_core(); const binary = core ? core.path : null; const proc_name = core ? core.proc_name : 'sing-box'; if (binary) { fd = popen(binary + ' version 2>&1'); if (fd) { add(trim(fd.read('all'))); fd.close(); } fd = popen('pidof ' + shellquote(proc_name) + ' 2>/dev/null'); if (fd) { const pid = trim(fd.read('all')); fd.close(); add('PID: ' + (length(pid) > 0 ? pid : 'not running')); } } else { add('No core binary found'); } add(''); add('## ByeDPI (ciadpi)'); if (access('/usr/bin/ciadpi')) { add('Installed: yes'); fd = popen('pgrep -x ciadpi 2>/dev/null'); if (fd) { const bpid = trim(fd.read('all')); fd.close(); add('PID: ' + (length(bpid) > 0 ? bpid : 'not running')); } } else { add('Installed: no'); } add(''); add('## Zapret (zapret2/nfqws2)'); if (access('/opt/zapret2/nfq2/nfqws2')) { add('Installed: yes'); fd = popen('uci -q get homeproxy.config.zapret_enabled 2>/dev/null'); let zen = '0'; if (fd) { zen = trim(fd.read('all')); fd.close(); } add('Enabled: ' + (zen === '1' ? 'yes' : 'no')); fd = popen('pidof nfqws2 2>/dev/null'); if (fd) { const zpid = trim(fd.read('all')); fd.close(); add('PID: ' + (length(zpid) > 0 ? zpid : 'not running')); } fd = popen('uci -q get homeproxy.config.zapret_voice 2>/dev/null'); let zvoice = '0'; if (fd) { zvoice = trim(fd.read('all')); fd.close(); } add('Discord calls: ' + (zvoice === '1' ? 'on' : 'off')); fd = popen('uci -q get homeproxy.config.zapret_cmd_opts 2>/dev/null'); if (fd) { const zstrat = trim(fd.read('all')); fd.close(); if (length(zstrat)) add('Strategy: ' + zstrat); } fd = popen('nft list chain inet fw4 homeproxy_zapret_queue 2>/dev/null'); if (fd) { let any = false; for (let line = fd.read('line'); length(line); line = fd.read('line')) { const l = trim(line); if (match(l, /(mark|queue)/)) { add(' ' + l); any = true; } } fd.close(); if (!any) add('Queue chain: not present (firewall rule missing?)'); } } else { add('Installed: no'); } add(''); add('## Config Stats'); const config_path = RUN_DIR + '/hiddify-c.json'; const stat = lstat(config_path); if (stat) { add('Size: ' + stat.size + ' bytes'); const content = readfile(config_path); if (content) { try { const cfg = json(content); add('Outbounds: ' + length(cfg.outbounds || [])); add('Rules: ' + length((cfg.route || {}).rules || [])); add('Inbounds: ' + length(cfg.inbounds || [])); add('DNS servers: ' + length(((cfg.dns || {}).servers) || [])); } catch(e) { add('Parse error: ' + e); } } } else { add('Config file not found'); } add(''); add('## UCI Config (sanitized)'); fd = popen('uci show homeproxy 2>&1'); if (fd) { /* uci show prints multi-line option values (PEM keys — ssh_priv_key / * ssh_host_key) across several physical lines; only the first carries * `key=`, so a naive per-line redaction leaks the whole key body on the * lines after it. Every real uci line begins with `homeproxy.`; the value * continuation lines never do. So after redacting an option, drop the * following non-`homeproxy.` lines until the next option begins. */ let skip_value_body = false; for (let line = fd.read('line'); length(line); line = fd.read('line')) { let out = trim(line); let is_uci_line = match(out, /^homeproxy\./); if (skip_value_body && !is_uci_line) continue; skip_value_body = false; if (is_uci_line && match(out, /(password|private_key|pre_shared_key|uuid|token|secret|subscription_url|address|tls_sni|tls_reality_public_key|tls_reality_short_id|grouphash|username|grpc_servicename|http_path|xhttp_download_server|naive_extra_headers|ssh_priv_key|ssh_host_key)=/)) { out = replace(out, /=.*$/, '=[REDACTED]'); skip_value_body = true; } add(out); } fd.close(); } add(''); add('## Listening Ports'); fd = popen('netstat -tlnup 2>/dev/null'); if (fd) { for (let line = fd.read('line'); length(line); line = fd.read('line')) { if (match(line, /hiddify|sing.box|ciadpi/)) add(trim(line)); } fd.close(); } add(''); add('## Kernel Modules'); fd = popen('lsmod 2>/dev/null | grep -E "nft_tproxy|tun|xt_socket"'); if (fd) { const out = trim(fd.read('all')); fd.close(); add(length(out) > 0 ? out : '(none matched)'); } add(''); add('## DNS Tests'); let mode_fd = popen('uci get homeproxy.config.routing_mode 2>/dev/null'); if (mode_fd) { const routing_mode = trim(mode_fd.read('all')); mode_fd.close(); const REGION_DIAG = { proxy_banned_ru: { tag: 'russia-dns', label: 'Russia', domain: 'mail.ru' }, bypass_cn: { tag: 'region-dns', label: 'China', domain: 'baidu.com' }, bypass_ir: { tag: 'region-dns', label: 'Iran', domain: 'aparat.com' } }; const rd = REGION_DIAG[routing_mode]; if (rd) { let region_server = null; let secure_server = null; const dns_servers = ((() => { try { return json(readfile(RUN_DIR + '/hiddify-c.json')); } catch(e) { return {}; } })().dns || {}).servers || []; for (let s in dns_servers) { if (s.tag === rd.tag) region_server = s.server || s.address; if (s.tag === 'secure-dns') secure_server = s.server || s.address; } add('Mode: ' + routing_mode); add(rd.label + ' DNS server: ' + (region_server || 'not found')); add('Secure DNS server: ' + (secure_server || 'not found')); add('Bootstrap: ' + (region_server || 'not found')); let dns_fd = popen('nslookup ' + rd.domain + ' 2>&1'); if (dns_fd) { add('nslookup ' + rd.domain + ':\n' + trim(dns_fd.read('all'))); dns_fd.close(); } dns_fd = popen('nslookup andrevi.ch 2>&1'); if (dns_fd) { add('nslookup andrevi.ch:\n' + trim(dns_fd.read('all'))); dns_fd.close(); } } else { add('Mode: ' + routing_mode + ' (DNS test runs in selective modes only)'); } } add(''); add('## Connectivity'); /* Same site probes as the interactive Connectivity card (connection_check), * captured into the report so a copy/download carries them. Exit-IP rows * (Direct IP / Proxy IP) are intentionally omitted — they are interactive, * hiddify-only, and expose addresses. */ let conn_sites = [ ['Baidu', 'https://www.baidu.com'], ['Google', 'https://www.google.com'], ['YouTube', 'https://www.youtube.com'], ['Yandex', 'https://ya.ru'], ['Speedtest', 'https://www.speedtest.net'] ]; for (let site in conn_sites) { const conn_ok = (system(`/usr/bin/wget --spider -qT3 ${site[1]} 2>"/dev/null"`, 3100) === 0); add(sprintf('%-11s %s', site[0] + ':', conn_ok ? 'OK' : 'FAIL')); } add(''); add('## HomeProxy Log (last 50 lines)'); fd = popen('tail -n 50 ' + RUN_DIR + '/homeproxy.log 2>/dev/null'); if (fd) { for (let line = fd.read('line'); length(line); line = fd.read('line')) add(replace(trim(line), /https?:\/\/[^ \t\n]+/, '[REDACTED_URL]')); fd.close(); } add(''); add('## Core Log (last 50 lines)'); fd = popen('tail -n 50 ' + RUN_DIR + '/hiddify-c.log 2>/dev/null'); if (fd) { for (let line = fd.read('line'); length(line); line = fd.read('line')) add(replace(trim(line), /https?:\/\/[^ \t\n]+/, '[REDACTED_URL]')); fd.close(); } return { report: join('\n', lines) }; } }, byedpi_status: { call: function(req) { const installed = !!access('/usr/bin/ciadpi'); let version = null; if (installed) { if (access('/usr/bin/apk')) { const fd = popen('apk info byedpi 2>/dev/null | head -1'); if (fd) { const out = trim(fd.read('all')); fd.close(); const m = match(out, /byedpi-([0-9][0-9.]*)/); if (m) version = m[1]; } } else if (access('/bin/opkg')) { const fd = popen('opkg status byedpi 2>/dev/null'); if (fd) { const out = fd.read('all'); fd.close(); const m = match(out, /Version: ([0-9][0-9.]*)/); if (m) version = m[1]; } } } let running = false; const fd2 = popen('pidof ciadpi 2>/dev/null'); if (fd2) { running = length(trim(fd2.read('all'))) > 0; fd2.close(); } let pkg_manager = null; if (access('/usr/bin/apk') || access('/sbin/apk') || access('/usr/sbin/apk')) pkg_manager = 'apk'; else if (access('/bin/opkg') || access('/usr/bin/opkg')) pkg_manager = 'opkg'; let arch = null; const afd = popen("awk -F\\' '/DISTRIB_ARCH/ {print $2}' /etc/openwrt_release 2>/dev/null"); if (afd) { arch = trim(afd.read('all')); afd.close(); } return { installed, version, running, pkg_manager, arch }; } }, byedpi_prepare_install: { call: function(req) { let pkg_manager = null; if (access('/usr/bin/apk') || access('/sbin/apk') || access('/usr/sbin/apk')) pkg_manager = 'apk'; else if (access('/bin/opkg') || access('/usr/bin/opkg')) pkg_manager = 'opkg'; if (!pkg_manager) return { error: 'No supported package manager (apk/opkg)' }; let arch = null; const afd = popen("awk -F\\' '/DISTRIB_ARCH/ {print $2}' /etc/openwrt_release 2>/dev/null"); if (afd) { arch = trim(afd.read('all')); afd.close(); } if (!length(arch)) return { error: 'Cannot detect architecture' }; const token_fd = popen('uci get homeproxy.config.github_token 2>/dev/null'); let token = null; if (token_fd) { token = trim(token_fd.read('all')); token_fd.close(); } const auth = length(token) ? `--header="Authorization: token ${token}"` : ''; const api_fd = popen(`wget -qO- ${auth} "https://api.github.com/repos/1andrevich/ByeDPI-OpenWrt/releases/latest" 2>/dev/null`); if (!api_fd) return { error: 'Failed to fetch release info' }; const api_out = api_fd.read('all'); api_fd.close(); const tag_m = match(api_out, /"tag_name"\s*:\s*"([^"]+)"/); if (!tag_m) return { error: 'Cannot parse release tag' }; const tag = tag_m[1]; const version = replace(tag, /^v/, ''); const ext = (pkg_manager === 'apk') ? 'apk' : 'ipk'; const dl_url = `https://github.com/1andrevich/ByeDPI-OpenWrt/releases/download/${tag}/byedpi_${version}_${arch}.${ext}`; const tmp_path = `/tmp/byedpi_${version}_${arch}.${ext}`; return { dl_url, tmp_path, pkg_manager, version }; } }, byedpi_install_pkg: { args: { tmp_path: 'tmp_path', pkg_manager: 'pkg_manager' }, call: function(req) { const tmp_path = req.args?.tmp_path; const pkg_manager = req.args?.pkg_manager; if (!tmp_path || !pkg_manager) return { result: false, error: 'Missing arguments' }; if (!access(tmp_path)) return { result: false, error: 'Package file not found' }; let ret; if (pkg_manager === 'apk') { /* Signing key (see zapret_install_pkg): skip the GitHub fetch if it's * already present (a provisioning tool may pre-place it); otherwise * a SHORT-timeout best-effort wget so a throttled GitHub can't hang the * ubus call. Install trusted if the key is there, else --allow-untrusted. */ if (!access('/etc/apk/keys/homeproxy-hiddify.pub')) { if (gh_fetch('https://github.com/1andrevich/homeproxy-hiddify/releases/latest/download/homeproxy-hiddify.pub', '/tmp/homeproxy-hiddify.pub', 20000) === 0) system('[ -s /tmp/homeproxy-hiddify.pub ] && cp /tmp/homeproxy-hiddify.pub /etc/apk/keys/ 2>/dev/null; rm -f /tmp/homeproxy-hiddify.pub'); } if (access('/etc/apk/keys/homeproxy-hiddify.pub')) ret = system(`apk add ${shellquote(tmp_path)}`, 120000); else ret = system(`apk add --allow-untrusted ${shellquote(tmp_path)}`, 120000); } else { ret = system(`opkg install ${shellquote(tmp_path)}`); } system(`rm -f ${shellquote(tmp_path)}`); /* opkg/apk can exit non-zero on a SUCCESSFUL install (e.g. the package's * postinst starts its own service and that step fails while every file * still lands). Trust the installed binary (same probe as byedpi_status), * not the exit code. */ const installed = !!access('/usr/bin/ciadpi'); if (installed) { system('/etc/init.d/ciadpi stop 2>/dev/null; true'); system('/etc/init.d/ciadpi disable 2>/dev/null; true'); } return { result: installed }; } }, byedpi_remove: { call: function(req) { /* Stop the running ciadpi BEFORE pulling the package. Otherwise the live * process keeps running (Linux keeps a deleted binary's process alive via its * inode), and byedpi_status — which is just `pidof ciadpi` — still reports * "running" until the next homeproxy restart. So: disable it in config (so * homeproxy's procd instance stops and won't respawn), stop/disable the * standalone service, reload homeproxy, then kill any leftover (e.g. * strategy-test) instances — and only then remove the package. */ system("uci set homeproxy.config.byedpi_enabled='0'; uci commit homeproxy 2>/dev/null; true"); system('/etc/init.d/ciadpi stop 2>/dev/null; /etc/init.d/ciadpi disable 2>/dev/null; true'); system('/etc/init.d/homeproxy reload 2>/dev/null; true'); system('pkill -f ciadpi 2>/dev/null; true'); let ret; if (access('/usr/bin/apk')) ret = system('apk del byedpi'); else if (access('/bin/opkg')) ret = system('opkg remove byedpi'); else return { result: false, error: 'No package manager' }; return { result: ret === 0 }; } }, zapret_status: { call: function(req) { const installed = !!access('/opt/zapret2/nfq2/nfqws2'); let version = null; if (installed) { if (access('/usr/bin/apk')) { const fd = popen('apk info zapret2 2>/dev/null | head -1'); if (fd) { const out = trim(fd.read('all')); fd.close(); const m = match(out, /zapret2-([0-9][0-9.]*)/); if (m) version = m[1]; } } else if (access('/bin/opkg')) { const fd = popen('opkg status zapret2 2>/dev/null'); if (fd) { const out = fd.read('all'); fd.close(); const m = match(out, /Version: ([0-9][0-9.]*)/); if (m) version = m[1]; } } } let running = false; const fd2 = popen('pidof nfqws2 2>/dev/null'); if (fd2) { running = length(trim(fd2.read('all'))) > 0; fd2.close(); } let pkg_manager = null; if (access('/usr/bin/apk') || access('/sbin/apk') || access('/usr/sbin/apk')) pkg_manager = 'apk'; else if (access('/bin/opkg') || access('/usr/bin/opkg')) pkg_manager = 'opkg'; /* kmod_ok: nfqws2's `nft ... queue num` needs the NFQUEUE kernel module * (kmod-nft-queue). Without it the firewall rule fails to load and nft * rejects the whole fw4 set. Check the package first, then a loaded module * as a fallback (built-in / already-inserted kernels). */ let kmod_ok = false; if (pkg_manager === 'apk') { const kf = popen('apk info -e kmod-nft-queue 2>/dev/null'); if (kf) { kmod_ok = length(trim(kf.read('all'))) > 0; kf.close(); } } else if (pkg_manager === 'opkg') { const kf = popen('opkg list-installed kmod-nft-queue 2>/dev/null'); if (kf) { kmod_ok = length(trim(kf.read('all'))) > 0; kf.close(); } } if (!kmod_ok) { const kf = popen('grep -qE "(^|[[:space:]])(nft_queue|nfnetlink_queue)([[:space:]]|$)" /proc/modules 2>/dev/null && echo y'); if (kf) { kmod_ok = trim(kf.read('all')) === 'y'; kf.close(); } } let arch = null; const afd = popen("awk -F\\' '/DISTRIB_ARCH/ {print $2}' /etc/openwrt_release 2>/dev/null"); if (afd) { arch = trim(afd.read('all')); afd.close(); } return { installed, version, running, pkg_manager, arch, kmod_ok }; } }, zapret_prepare_install: { call: function(req) { let pkg_manager = null; if (access('/usr/bin/apk') || access('/sbin/apk') || access('/usr/sbin/apk')) pkg_manager = 'apk'; else if (access('/bin/opkg') || access('/usr/bin/opkg')) pkg_manager = 'opkg'; if (!pkg_manager) return { error: 'No supported package manager (apk/opkg)' }; let arch = null; const afd = popen("awk -F\\' '/DISTRIB_ARCH/ {print $2}' /etc/openwrt_release 2>/dev/null"); if (afd) { arch = trim(afd.read('all')); afd.close(); } if (!length(arch)) return { error: 'Cannot detect architecture' }; /* Asset names are version-less, so no GitHub API call is needed — * releases/latest/download/ resolves to the newest release. */ const ext = (pkg_manager === 'apk') ? 'apk' : 'ipk'; const dl_url = `https://github.com/1andrevich/zapret2-openwrt/releases/latest/download/zapret2_${arch}.${ext}`; const tmp_path = `/tmp/zapret2_${arch}.${ext}`; return { dl_url, tmp_path, pkg_manager }; } }, zapret_install_pkg: { args: { tmp_path: 'tmp_path', pkg_manager: 'pkg_manager' }, call: function(req) { const tmp_path = req.args?.tmp_path; const pkg_manager = req.args?.pkg_manager; if (!tmp_path || !pkg_manager) return { result: false, error: 'Missing arguments' }; if (!access(tmp_path)) return { result: false, error: 'Package file not found' }; let ret; if (pkg_manager === 'apk') { /* Signing key: if it's ALREADY present (e.g. pre-placed by a provisioning * tool, or a prior install), skip the GitHub fetch entirely. Only * if it's missing do a SHORT-timeout best-effort wget — the old un-timed * one hung on a throttled GitHub until the ubus call timed out (exit 249). * Then install trusted if the key is there, else --allow-untrusted. */ if (!access('/etc/apk/keys/zapret2-1andrevich.pub')) { if (gh_fetch('https://github.com/1andrevich/zapret2-openwrt/releases/latest/download/zapret2-1andrevich.pub', '/tmp/zapret2-1andrevich.pub', 20000) === 0) system('[ -s /tmp/zapret2-1andrevich.pub ] && cp /tmp/zapret2-1andrevich.pub /etc/apk/keys/ 2>/dev/null; rm -f /tmp/zapret2-1andrevich.pub'); } if (access('/etc/apk/keys/zapret2-1andrevich.pub')) ret = system(`apk add ${shellquote(tmp_path)}`, 120000); else ret = system(`apk add --allow-untrusted ${shellquote(tmp_path)}`, 120000); } else { system('opkg update 2>/dev/null'); ret = system(`opkg install ${shellquote(tmp_path)}`); } system(`rm -f ${shellquote(tmp_path)}`); /* opkg/apk can exit non-zero on a SUCCESSFUL install (e.g. the package's * postinst starts its own service and that step fails while every file * still lands). Trust the installed binary (same probe as zapret_status), * not the exit code. */ const installed = !!access('/opt/zapret2/nfq2/nfqws2'); if (installed) { /* Keep the package's own service out of the way — HomeProxy runs its own * nfqws2 instance (qnum 200) and installs the NFQUEUE rule itself. */ system('/etc/init.d/zapret2 stop 2>/dev/null; true'); system('/etc/init.d/zapret2 disable 2>/dev/null; true'); } return { result: installed }; } }, zapret_remove: { call: function(req) { system("uci set homeproxy.config.zapret_enabled='0'; uci commit homeproxy 2>/dev/null; true"); system('/etc/init.d/zapret2 stop 2>/dev/null; /etc/init.d/zapret2 disable 2>/dev/null; true'); system('/etc/init.d/homeproxy reload 2>/dev/null; true'); system('pkill -f nfqws2 2>/dev/null; true'); let ret; if (access('/usr/bin/apk')) ret = system('apk del zapret2'); else if (access('/bin/opkg')) ret = system('opkg remove zapret2'); else return { result: false, error: 'No package manager' }; return { result: ret === 0 }; } }, zapret_resolve_hosts: { call: function(req) { /* Resolve the tester's 4 fixed test hosts ONCE (bounded + retried), via * the router's normal resolver — the same secure-DNS/direct path the live * config uses for these blocked domains, so the IP stays representative for * the TLS probe. The full test calls this up front and feeds the IPs to * every candidate, so the sweep doesn't re-hit DNS 36×. */ const fd = popen(`sh /etc/homeproxy/scripts/zapret_resolve.sh 2>/dev/null`); let out = ''; if (fd) { out = trim(fd.read('all')); fd.close(); } if (!length(out)) out = '{"ok":0,"error":"resolver produced no output"}'; return { output: out }; } }, zapret_strategy_test: { args: { cmd_opts: 'cmd_opts', ips: 'ips' }, call: function(req) { if (!access('/opt/zapret2/nfq2/nfqws2')) return { output: '{"ok":0,"total":0,"error":"zapret2 not installed"}' }; const cmd_opts = req.args?.cmd_opts || ''; /* Optional pre-resolved "tag=ip …" from zapret_resolve_hosts; if empty the * tester resolves the hosts itself (standalone call). */ const ips = req.args?.ips || ''; /* The tester script runs a candidate nfqws2 on a temp queue scoped to the * test IPs, probes the TLS handshakes, and prints one JSON line. It self- * tears-down (trap + 30s watchdog), so live traffic/queue is never left * touched. We just relay its JSON for the UI to parse. */ const fd = popen(`sh /etc/homeproxy/scripts/zapret_test.sh ${shellquote(cmd_opts)} ${shellquote(ips)} 2>/dev/null`); let out = ''; if (fd) { out = trim(fd.read('all')); fd.close(); } if (!length(out)) out = '{"ok":0,"total":0,"error":"tester produced no output"}'; return { output: out }; } }, byedpi_strategy_test: { args: { cmd_opts: 'cmd_opts', port: 'port' }, call: function(req) { if (!access('/usr/bin/ciadpi')) return { result: false, error: 'ciadpi not installed' }; const cmd_opts = req.args?.cmd_opts || '--disorder 1'; const test_port = req.args?.port || 15335; const has_curl = !!access('/usr/bin/curl'); /* Test panel. Pass = the TLS handshake completes (judged below by time_appconnect), * which is the precise DPI-bypass signal — not the HTTP status. YouTube probes the * real video CDN (redirector.googlevideo.com), not the www page: the video path is * what users care about and what the DPI actually blocks. It returns 404 with no * path requested — irrelevant, we judge the handshake. Discord/Telegram/Speedtest * cover the Cloudflare/other edges for a destination spread. */ const hosts = [ { tag: 'yt', label: 'YouTube (video)', url: 'https://redirector.googlevideo.com' }, { tag: 'tg', label: 'Telegram', url: 'https://telegram.org' }, { tag: 'dc', label: 'Discord', url: 'https://discord.com' }, { tag: 'st', label: 'Speedtest.net', url: 'https://www.speedtest.net' } ]; /* Detect redirect mode: homeproxy_output_redir intercepts router-originated * TCP via the OUTPUT chain. Flush it while testing so curl traffic from ciadpi * goes direct; restore immediately after. LAN clients are unaffected (they * go through dstnat → homeproxy_redirect_lanac, a separate path). */ let out_chain_nfproto = null; const cfd2 = popen('nft list chain inet fw4 homeproxy_output_redir 2>/dev/null'); if (cfd2) { const out = cfd2.read('all'); cfd2.close(); if (length(trim(out)) > 0) { out_chain_nfproto = (index(out, 'ipv6') >= 0) ? '{ ipv4, ipv6 }' : 'ipv4'; system('nft flush chain inet fw4 homeproxy_output_redir 2>/dev/null; true'); } } /* The flush also wiped the ByeDPI skgid exclusion (if present) — capture the gid * so we can rebuild it on restore, or the live ByeDPI egress would loop afterwards. */ let byedpi_gid = null; const efd = popen('uci get homeproxy.config.byedpi_enabled 2>/dev/null'); if (efd) { if (trim(efd.read('all')) === '1') { byedpi_gid = '8181'; const gfd = popen('uci get homeproxy.infra.byedpi_gid 2>/dev/null'); if (gfd) { const g = trim(gfd.read('all')); if (length(g)) byedpi_gid = g; gfd.close(); } } efd.close(); } /* Restore command (no nested function — those can crash rpcd call bodies). Rebuilds * the skgid exclusion first (if ByeDPI is on), then the redirect jump. */ let restore_cmd = 'true'; if (out_chain_nfproto != null) { restore_cmd = ''; if (byedpi_gid != null) restore_cmd += `nft add rule inet fw4 homeproxy_output_redir meta skgid ${byedpi_gid} counter return 2>/dev/null; `; restore_cmd += `nft add rule inet fw4 homeproxy_output_redir meta nfproto ${out_chain_nfproto} meta l4proto tcp jump homeproxy_redirect 2>/dev/null; true`; } system(`pkill -f 'ciadpi.*${test_port}' 2>/dev/null; true`); system(`/usr/bin/ciadpi -i 127.0.0.1 -p ${test_port} ${cmd_opts} &`); system('sleep 0.5'); let running = false; const pfd = popen(`pgrep -f 'ciadpi.*${test_port}' 2>/dev/null`); if (pfd) { running = length(trim(pfd.read('all'))) > 0; pfd.close(); } if (!running) { system(restore_cmd); return { result: false, error: 'ciadpi did not start — check arguments', method: 'startup' }; } if (!has_curl) { system(`pkill -f 'ciadpi.*${test_port}' 2>/dev/null; true`); system(restore_cmd); return { result: true, method: 'startup', results: [] }; } /* Probe every host in parallel through the one ciadpi instance. Each background * job writes " ": time_appconnect is the * pass signal (non-zero = the TLS handshake completed, i.e. the desync got the * ClientHello past the DPI), and curl_exit explains a failure (tls reset vs timeout * vs dns). 15s max-time gives slow/adaptive strategies room to land. */ const tmp = `/tmp/byedpi_test_${test_port}`; system(`rm -rf ${tmp}; mkdir -p ${tmp} 2>/dev/null; true`); let cmd = ''; for (let i = 0; i < length(hosts); i++) cmd += `( out=$(curl -s --socks5-hostname 127.0.0.1:${test_port} -o /dev/null -w '%{http_code} %{time_appconnect}' --connect-timeout 5 --max-time 15 ${shellquote(hosts[i].url)} 2>/dev/null); echo "$out $?" > ${tmp}/${i} ) & `; cmd += 'wait'; system(cmd); let results = []; let passed = 0; for (let i = 0; i < length(hosts); i++) { const raw = trim(readfile(`${tmp}/${i}`) || ''); const parts = split(raw, ' '); const code = parts[0] || '000'; const appconnect = parts[1] || '0'; const rc = parts[2] || ''; /* Pass = the TLS handshake completed (time_appconnect > 0) — the precise * DPI-bypass signal. The HTTP status is irrelevant (a 404 from googlevideo is a * pass). The old criterion (2xx/3xx only) produced false negatives: 4xx replies * and slow adaptive handshakes were wrongly failed. */ const ok = (+appconnect > 0); if (ok) passed++; push(results, { tag: hosts[i].tag, label: hosts[i].label, host: hosts[i].url, code: code, tls: appconnect, ok: ok, reason: ok ? null : (rc === '6') ? 'dns' : (rc === '7') ? 'refused' : (rc === '28') ? 'timeout' : (rc in ['35', '51', '53', '56', '58', '59', '60']) ? 'tls' : 'fail' }); } system(`rm -rf ${tmp} 2>/dev/null; true`); system(`pkill -f 'ciadpi.*${test_port}' 2>/dev/null; true`); system(restore_cmd); return { result: passed === length(hosts), passed: passed, total: length(hosts), method: 'curl', results: results }; } }, curl_status: { call: function(req) { const installed = !!access('/usr/bin/curl'); let pkg_manager = null; if (access('/usr/bin/apk') || access('/sbin/apk') || access('/usr/sbin/apk')) pkg_manager = 'apk'; else if (access('/bin/opkg') || access('/usr/bin/opkg')) pkg_manager = 'opkg'; return { installed, pkg_manager }; } }, curl_install: { call: function(req) { let cmd; if (access('/usr/bin/apk') || access('/sbin/apk') || access('/usr/sbin/apk')) cmd = 'apk add curl 2>&1'; else if (access('/bin/opkg') || access('/usr/bin/opkg')) cmd = 'opkg install curl 2>&1'; else return { result: false, error: 'No package manager found' }; let pipe = popen(cmd, 'r'); let output = pipe.read('all'); let rc = pipe.close(); return { result: rc === 0, error: rc !== 0 ? trim(output) : null }; } }, curl_remove: { call: function(req) { let cmd; if (access('/usr/bin/apk') || access('/sbin/apk') || access('/usr/sbin/apk')) cmd = 'apk del curl 2>&1'; else if (access('/bin/opkg') || access('/usr/bin/opkg')) cmd = 'opkg remove curl 2>&1'; else return { result: false, error: 'No package manager found' }; let pipe = popen(cmd, 'r'); let output = pipe.read('all'); let rc = pipe.close(); return { result: rc === 0, error: rc !== 0 ? trim(output) : null }; } } }; return { 'luci.homeproxy': methods };