mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 16:44:23 +08:00
457 lines
17 KiB
Ucode
457 lines
17 KiB
Ucode
/*
|
||
* SPDX-License-Identifier: GPL-2.0-only
|
||
*
|
||
* Copyright (C) 2025 ImmortalWrt.org
|
||
*
|
||
* A single self-contained diagnostic report, built in-process for the
|
||
* `debug_report` rpc method (see luci.homeproxy-pro). It exists because the
|
||
* failure modes this plugin has are not visible from the log page: a route
|
||
* that stopped splitting, an nft rule that is not in the fw4 table, a
|
||
* rule-set that never downloaded, a mark that no longer matches anything.
|
||
* Each of those needs a different command to confirm, and collecting them
|
||
* by hand over ssh is slow and easy to get wrong - the commands are spread
|
||
* across `ip rule`, `nft`, the procd state and the resource version files.
|
||
*
|
||
* Two properties are deliberate and are the reason this is not a shell
|
||
* script dropped in beside the other runtime helpers:
|
||
*
|
||
* 1. REDACTION IS DEFAULT-DENY. A node section is a credential store: it
|
||
* holds passwords, uuids, private keys, SNI and the server address. A
|
||
* report that is going to be pasted into an issue tracker has to be safe
|
||
* by construction, so the rule is "if the key looks like a secret, mask
|
||
* it" rather than an enumerated list of keys to mask - the enumerated
|
||
* kind is exactly the one that goes stale when a field is added. The
|
||
* pattern is applied to the key name, so a value is masked no matter
|
||
* which section it came from.
|
||
*
|
||
* 2. EVERY PROBE DEGRADES. A report that aborts because `nft` is missing,
|
||
* or that silently omits a section when a command fails, is worse than
|
||
* no report: the reader cannot tell "not applicable" from "not
|
||
* collected". Each probe therefore reports one of three states -
|
||
* the output, "(unavailable)" for a missing command, or
|
||
* "(no output, exit N)" for one that ran and failed. This is the same
|
||
* distinction health.sh draws between "probe failed" and "cannot probe
|
||
* here", and it is why a testbed without netfilter still produces a
|
||
* usable report.
|
||
*
|
||
* The commands below are fixed literals: nothing in this file is built from
|
||
* configuration, so there is no shell interpolation to get wrong and no
|
||
* argument that would need shellQuote().
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
import { popen } from 'fs';
|
||
|
||
import { HP_DIR, RUN_DIR, shellQuote, udp_transport_verdict } from '../homeproxy-pro.uc';
|
||
|
||
export const DEBUG_REPORT_PATH = RUN_DIR + '/debug.log';
|
||
|
||
/* Per-section output cap. The nft dump is the one section that is expected
|
||
* to be large - a populated fw4 table runs to a few thousand lines, and
|
||
* truncating it is worse than useless because a truncated chain looks like
|
||
* a chain that ends there. The cap is high enough that a real router never
|
||
* reaches it, and reaching it is stated in the output rather than hidden. */
|
||
const SECTION_MAX = 128 * 1024;
|
||
|
||
/* A key whose name matches any of these is masked. Deliberately broad: a
|
||
* false positive costs a line of a report, a false negative ships someone's
|
||
* private key. `method` and `path` are in the list because obfs parameters
|
||
* and websocket paths are credentials in practice; `user` catches both
|
||
* `username` and the access-control user lists; `short` is there for
|
||
* `tls_reality_short_id`, which matched nothing until a canary run put a
|
||
* value in it and found it in the report verbatim.
|
||
*
|
||
* `url` is the one pattern that is NOT a bare substring, and it has to stay
|
||
* that way: a plain `url` alternative matches inside `main_urltest_nodes` and
|
||
* `main_urltest_tolerance` (`main_u-r-l-test`), which silently blanked the
|
||
* urltest pool out of the report. Those lists are how you tell "this node
|
||
* is still in the test pool" from "this node was dropped", which is a
|
||
* question the report exists to answer. Anchoring on a word boundary keeps
|
||
* `url` / `urls` / `subscription_urls` covered and leaves the pool visible. */
|
||
const SECRET_KEY_RE = /pass|secret|token|key|uuid|(^|_)urls?$|address|server|host|sni|user|psk|path|method|param|seed|salt|auth|hosts|short/i;
|
||
|
||
/* Exported for the rpc behaviour test: the redaction rule is the one part of
|
||
* this file with no I/O to observe, so it is the one part that can be
|
||
* asserted directly - a key that stops being recognised is a silent leak. */
|
||
export function isSecretKey(name) {
|
||
/* ucode has no RegExp.prototype.test - the module-level match() is the
|
||
* only way to apply a pattern, and every other file in this tree does it
|
||
* the same way. */
|
||
return type(name) === 'string' && match(name, SECRET_KEY_RE) != null;
|
||
};
|
||
|
||
function maskValue(value) {
|
||
/* A list keeps its length visible: "3 masked entries" tells the reader
|
||
* the subscription imported something, which is a fact worth having,
|
||
* without disclosing any of it. */
|
||
if (type(value) === 'array')
|
||
return value === null || length(value) === 0
|
||
? value
|
||
: sprintf('[%d masked]', length(value));
|
||
|
||
return '***';
|
||
};
|
||
|
||
/* Run one collector. Returns { state, text } where state is 'ok',
|
||
* 'unavailable' or 'failed', so the caller can label it honestly instead of
|
||
* printing an empty block that reads like "nothing to see". */
|
||
function probe(command) {
|
||
if (command == null)
|
||
return { state: 'unavailable', text: '' };
|
||
|
||
const fd = popen(command);
|
||
if (fd == null)
|
||
return { state: 'unavailable', text: '' };
|
||
|
||
let out = fd.read('all');
|
||
fd.close();
|
||
|
||
if (out == null)
|
||
return { state: 'failed', text: '' };
|
||
|
||
out = trim(out);
|
||
|
||
/* popen() gives no exit status, so an empty result is the only signal
|
||
* that the command produced nothing. Whether that is a problem
|
||
* depends on the command, and the section header says which. */
|
||
if (out === '')
|
||
return { state: 'failed', text: '' };
|
||
|
||
if (length(out) > SECTION_MAX)
|
||
out = substr(out, 0, SECTION_MAX) +
|
||
sprintf('\n... [truncated at %d bytes; rerun the command by hand for the full output]\n', SECTION_MAX);
|
||
|
||
return { state: 'ok', text: out };
|
||
};
|
||
|
||
/* The value form of the same collector: trimmed stdout, or null when the
|
||
* command produced nothing. Used where the caller wants a value rather
|
||
* than a rendered block. Deliberately not written with the optional-chaining
|
||
* shorthand, which no other file in this tree uses. */
|
||
function runCommand(command) {
|
||
const fd = popen(command);
|
||
if (fd == null)
|
||
return null;
|
||
|
||
const out = fd.read('all');
|
||
fd.close();
|
||
|
||
if (out == null)
|
||
return null;
|
||
|
||
const t = trim(out);
|
||
return t === '' ? null : t;
|
||
};
|
||
|
||
function runDate() {
|
||
return runCommand('date "+%Y-%m-%d %H:%M:%S %Z"') || 'unknown';
|
||
};
|
||
|
||
/* Render one ```bash fenced block for a command, with the state spelled out
|
||
* when there is no output to show. */
|
||
function block(command, unavailable_label) {
|
||
const r = probe(command);
|
||
|
||
if (r.state === 'ok')
|
||
return sprintf('```shell\n# %s\n%s\n```\n', command, r.text);
|
||
|
||
if (r.state === 'unavailable')
|
||
return sprintf('```\n(unavailable: %s)\n```\n', unavailable_label || command);
|
||
|
||
return '```\n(no output)\n```\n';
|
||
};
|
||
|
||
/* Render a two-column table, or a single "(unavailable)" row. */
|
||
function table(rows, unavailable_label) {
|
||
if (rows == null)
|
||
return sprintf('_%s_\n', unavailable_label || 'unavailable');
|
||
|
||
let out = '| 项目 | 值 |\n|------|-----|\n';
|
||
for (let r in rows)
|
||
out += sprintf('| %s | %s |\n', r[0], r[1]);
|
||
return out;
|
||
};
|
||
|
||
/* `apk list -I <pkg>` prints one line:
|
||
*
|
||
* sing-box-1.14.2-r1 x86_64 {feeds/.../sing-box} (GPL-3.0-or-later) [installed]
|
||
*
|
||
* The package name is the argument, so the version is simply what follows
|
||
* that name's trailing dash up to the first space. A pattern anchored on
|
||
* a negated class instead - `^[^ -]+-([0-9][^ ]*)` - stops at the FIRST
|
||
* dash, so it works for `firewall4` and silently fails for every package
|
||
* whose own name contains one (`sing-box`, `luci-app-homeproxy-pro`), which is
|
||
* most of them. Splitting on the known prefix has no such case.
|
||
*
|
||
* This also avoids `\\s` inside a negated class, whose meaning inside `[...]`
|
||
* is not what it is outside: `homeproxy-pro.uc`'s `replace(reason, /\s+/g, ' ')`
|
||
* collapses whitespace correctly, yet the same `\s` inside `[^\\s-]` did not
|
||
* exclude whitespace, and the version came back with the architecture glued
|
||
* to it. */
|
||
function packageVersion(name) {
|
||
if (system('command -v apk >/dev/null 2>&1') !== 0)
|
||
return null;
|
||
|
||
const fd = popen(sprintf('apk list -I %s 2>/dev/null', shellQuote(name)));
|
||
if (fd == null)
|
||
return null;
|
||
|
||
const line = trim(fd.read('line'));
|
||
fd.close();
|
||
|
||
if (line == null || line === '')
|
||
return null;
|
||
|
||
const prefix = name + '-';
|
||
if (index(line, prefix) !== 0)
|
||
return null;
|
||
|
||
const rest = substr(line, length(prefix));
|
||
const sp = index(rest, ' ');
|
||
const v = (sp == -1) ? rest : substr(rest, 0, sp);
|
||
|
||
return v === '' ? null : v;
|
||
};
|
||
|
||
/* --- UCI summary --------------------------------------------------------- */
|
||
|
||
/* A structural, redacted view of the configuration: counts and switch
|
||
* positions, never a credential. Sections are summarised by type rather
|
||
* than dumped, because a node section has no field that is safe to print
|
||
* and a subscription section has exactly one. */
|
||
function configSummary(uci) {
|
||
const all = uci.get_all('homeproxy-pro') || {};
|
||
const byType = {};
|
||
|
||
for (let sid in all) {
|
||
const section = all[sid];
|
||
const stype = section['.type'] || 'unknown';
|
||
|
||
if (byType[stype] == null)
|
||
byType[stype] = { count: 0, sample: section['.name'] || sid };
|
||
|
||
byType[stype].count++;
|
||
}
|
||
|
||
if (byType == null || length(keys(byType)) === 0)
|
||
return '_configuration is empty_\n';
|
||
|
||
let out = '';
|
||
|
||
for (let t in keys(byType)) {
|
||
out += sprintf('- `%s`: %d section(s)\n', t, byType[t].count);
|
||
}
|
||
|
||
/* The switches that decide what the report is FOR: which mode, which
|
||
* ports, and the mark - these are the first things anyone reading a
|
||
* routing problem looks for, and none of them is sensitive. Each entry
|
||
* is [section, option]. */
|
||
const switches = [
|
||
['config', 'proxy_mode'],
|
||
['config', 'routing_mode'],
|
||
['config', 'main_node'],
|
||
['config', 'main_udp_node'],
|
||
['config', 'ipv6_support'],
|
||
['config', 'log_level'],
|
||
['config', 'bypass_cn_traffic'],
|
||
['infra', 'mixed_port'],
|
||
['infra', 'redirect_port'],
|
||
['infra', 'tproxy_port'],
|
||
['infra', 'dns_port'],
|
||
['infra', 'self_mark'],
|
||
['infra', 'tproxy_mark'],
|
||
['infra', 'tun_mark'],
|
||
['infra', 'table_mark'],
|
||
['infra', 'tun_name'],
|
||
['server', 'enabled']
|
||
];
|
||
|
||
out += '\n| 开关 | 值 |\n|------|-----|\n';
|
||
for (let sw in switches) {
|
||
const v = uci.get('homeproxy-pro', sw[0], sw[1]);
|
||
out += sprintf('| `%s.%s` | %s |\n', sw[0], sw[1],
|
||
(v == null || v === '') ? '_(unset)_' : (isSecretKey(sw[1]) ? maskValue(v) : v));
|
||
}
|
||
|
||
/* The QUIC verdict, because it is the one conclusion in this report that
|
||
* is derived rather than reported: the switches table shows
|
||
* `main_udp_node`, and `main_udp_node` on its own does not say whether
|
||
* QUIC was rejected by the firewall or why. udp_transport_verdict() is
|
||
* the same function firewall_post.ut gates the nft rule with, so this
|
||
* line describes the rule that is actually loaded rather than a second
|
||
* opinion about it. */
|
||
const udp = udp_transport_verdict('homeproxy-pro', uci);
|
||
out += sprintf('\n- UDP path: **%s** - %s\n',
|
||
udp.native ? 'UDP-native' : (udp.configured ? 'not UDP-native' : 'UDP not configured'),
|
||
udp.reason);
|
||
|
||
return out;
|
||
};
|
||
|
||
/* Every non-secret option, flattened, with anything that looks like a
|
||
* credential masked. This is the section that makes the report useful for
|
||
* "which option did I set wrong" - the summary above cannot show them. */
|
||
function fullConfig(uci) {
|
||
const all = uci.get_all('homeproxy-pro') || {};
|
||
let out = '';
|
||
|
||
for (let sid in keys(all)) {
|
||
const section = all[sid];
|
||
const stype = section['.type'] || 'unknown';
|
||
const sname = section['.name'] || sid;
|
||
|
||
out += sprintf('\n### %s `%s`\n\n```\n', stype, sname);
|
||
|
||
for (let k in keys(section)) {
|
||
if (substr(k, 0, 1) === '.')
|
||
continue;
|
||
|
||
const v = section[k];
|
||
|
||
if (isSecretKey(k)) {
|
||
out += sprintf('option %s %s\n', k, maskValue(v));
|
||
} else if (type(v) === 'array') {
|
||
out += sprintf('list %s %s\n', k,
|
||
length(v) === 0 ? '' : join(' ', v));
|
||
} else {
|
||
out += sprintf('option %s %s\n', k, v);
|
||
}
|
||
}
|
||
|
||
out += '```\n';
|
||
}
|
||
|
||
return out == '' ? '_configuration is empty_\n' : out;
|
||
};
|
||
|
||
/* --- resource versions --------------------------------------------------- */
|
||
|
||
/* The .ver files next to each shipped list. A rule-set that has been
|
||
* failing to download shows up here as a version that never moves, which is
|
||
* the cheapest way to tell "the list is stale" from "the list is missing". */
|
||
function resourceVersions() {
|
||
const names = ['china_ip4', 'china_ip6', 'china_list', 'gfw_list'];
|
||
let out = '';
|
||
|
||
for (let n in names) {
|
||
const path = sprintf('%s/resources/%s.ver', HP_DIR, n);
|
||
const fd = popen(sprintf('cat %s 2>/dev/null', shellQuote(path)));
|
||
|
||
if (fd != null) {
|
||
const v = trim(fd.read('all'));
|
||
fd.close();
|
||
out += sprintf('- `%s`: %s\n', n, (v == null || v === '') ? '_(no version file)_' : v);
|
||
} else {
|
||
out += sprintf('- `%s`: _(unavailable)_\n', n);
|
||
}
|
||
}
|
||
|
||
/* The dnsmasq nftset capability marker: init.d writes this at start
|
||
* and firewall_post.ut gates the gfwlist mode on it, so a device whose
|
||
* dnsmasq was built without HAVE_NFTSET silently routes everything
|
||
* direct while DNS still goes through the proxy. */
|
||
const marker = sprintf('%s/resources/.dnsmasq-nftset', HP_DIR);
|
||
const fd = popen(sprintf('cat %s 2>/dev/null', shellQuote(marker)));
|
||
let cap = 'unknown';
|
||
if (fd != null) {
|
||
cap = trim(fd.read('all')) || 'unknown';
|
||
fd.close();
|
||
}
|
||
out += sprintf('- `dnsmasq nftset`: %s', cap == '1' ? 'yes' : (cap == '0' ? 'no (gfwlist mode cannot work)' : cap));
|
||
|
||
return out + '\n';
|
||
};
|
||
|
||
/* --- the report ---------------------------------------------------------- */
|
||
|
||
export function buildDebugReport(uci) {
|
||
const stamp = trim(runDate());
|
||
|
||
let out = '';
|
||
out += '# HomeProxy 诊断报告\n\n';
|
||
out += sprintf('> 生成时间: %s\n', stamp == '' ? '(unknown)' : stamp);
|
||
out += sprintf('> 插件版本: %s\n', packageVersion('luci-app-homeproxy-pro') || 'unknown');
|
||
out += '> 隐私: 凭据字段已按名称脱敏,但本报告仍含公网 IP、内网拓扑和 LAN 主机地址。\n';
|
||
out += '> 外发前请自行确认。\n';
|
||
|
||
/* 1. System */
|
||
out += '\n## 1. 系统\n\n';
|
||
out += block('cat /etc/openwrt_release 2>/dev/null', 'uname -a');
|
||
out += block('uname -a');
|
||
out += block('uptime');
|
||
out += block('df -h / /tmp /etc/homeproxy-pro 2>/dev/null', 'df unavailable');
|
||
out += block('free -m 2>/dev/null', 'free unavailable');
|
||
|
||
/* 2. Dependencies - the table, not a "is it there" yes/no, because the
|
||
* version is what a reader needs when reporting "it does not work on
|
||
* this build". */
|
||
out += '\n## 2. 依赖检查\n\n';
|
||
const deps = ['sing-box', 'firewall4', 'kmod-nft-tproxy', 'kmod-tun',
|
||
'ip-full', 'uclient-fetch', 'ucode', 'ucode-mod-digest'];
|
||
const dep_rows = [];
|
||
|
||
for (let d in deps) {
|
||
const v = packageVersion(d);
|
||
push(dep_rows, [d, v == null ? '_(not reported by apk; may be built in)_' : v]);
|
||
}
|
||
|
||
out += table(dep_rows, 'apk not available on this system');
|
||
|
||
/* 3. Core capabilities */
|
||
out += '\n## 3. 内核与能力\n\n';
|
||
out += block('/usr/bin/sing-box version 2>/dev/null', 'sing-box not installed');
|
||
out += block('lsmod 2>/dev/null | grep -E "tun|nft_tproxy|inet_diag"', 'lsmod unavailable');
|
||
out += block('command -v nft fw4 utpl ucode uci 2>/dev/null', 'PATH lookup unavailable');
|
||
|
||
/* 4. Configuration */
|
||
out += '\n## 4. 配置摘要(脱敏)\n\n';
|
||
out += configSummary(uci);
|
||
|
||
/* 5. Resources */
|
||
out += '\n## 5. 资源版本\n\n';
|
||
out += resourceVersions();
|
||
|
||
/* 6. Service */
|
||
out += '\n## 6. 服务状态\n\n';
|
||
out += block('/etc/init.d/homeproxy-pro info 2>&1', 'init script unavailable');
|
||
out += block('ubus call service list \'"name":"homeproxy-pro"\' 2>/dev/null',
|
||
'ubus unavailable');
|
||
|
||
/* 7. Routing - `ip rule` first, because the mark that routes a
|
||
* connection into the proxy is set there, and a rule that is missing
|
||
* explains a proxy that binds its ports and then times out. */
|
||
out += '\n## 7. 路由策略\n\n';
|
||
out += '### ip rule\n\n';
|
||
out += block('ip rule list');
|
||
out += '### ip route\n\n';
|
||
out += block('ip -4 route list');
|
||
out += block('ip -6 route list');
|
||
out += sprintf('\n**策略路由表**(table_mark / tproxy_mark / tun_mark):\n\n```shell\n# ip route list table 100\n%s\n```\n',
|
||
trim(runCommand('ip route list table 100 2>/dev/null') || '(empty or unavailable)'));
|
||
|
||
/* 8. Firewall - the full fw4 table, not just homeproxy-pro's own chains.
|
||
* The failure this covers is "homeproxy-pro's rules are present and
|
||
* correct but something in fw4 jumps to another chain first", and
|
||
* that is only visible with the surrounding rules in view. */
|
||
out += '\n## 8. 防火墙\n\n';
|
||
out += '_The full `inet fw4` table. homeproxy-pro\'s chains are the ones named '
|
||
+ '`homeproxy_*`; everything else is fw4\'s own._\n\n';
|
||
out += block('nft -a list table inet fw4 2>/dev/null', 'nft unavailable (is firewall4 installed?)');
|
||
|
||
/* 9. Log excerpts - the tail of each log, not the whole file: the
|
||
* interesting part of a failure is the last thing that happened. */
|
||
out += '\n## 9. 日志尾部\n\n';
|
||
for (let l in ['homeproxy-pro', 'sing-box-c', 'sing-box-s']) {
|
||
out += sprintf('### %s.log\n\n', l);
|
||
out += block(sprintf('tail -n 60 %s/%s.log 2>/dev/null', RUN_DIR, l),
|
||
sprintf('%s.log not present', l));
|
||
}
|
||
|
||
/* 10. Full redacted configuration */
|
||
out += '\n## 10. 完整配置(脱敏)\n\n';
|
||
out += fullConfig(uci);
|
||
|
||
return out;
|
||
};
|