diff --git a/luci-app-homeproxy/Makefile b/luci-app-homeproxy/Makefile index 86c173161..6b6daa1bb 100644 --- a/luci-app-homeproxy/Makefile +++ b/luci-app-homeproxy/Makefile @@ -22,8 +22,8 @@ LUCI_DEPENDS:= \ +ucode-mod-digest PKG_NAME:=luci-app-homeproxy -PKG_VERSION:=27.911.1.14 -PKG_RELEASE:=16 +PKG_VERSION:=27.919.1.14 +PKG_RELEASE:=17 define Package/luci-app-homeproxy/conffiles /etc/config/homeproxy diff --git a/luci-app-homeproxy/README.md b/luci-app-homeproxy/README.md index 0a9613559..369228a13 100644 --- a/luci-app-homeproxy/README.md +++ b/luci-app-homeproxy/README.md @@ -86,6 +86,19 @@ | --- | --- | --- | | 重构和修复错误 | 重构:前端 TLS/传输表单收敛、generator 共享 TLS/transport 构建、`parse_uri` 拆为 13 个协议函数
测试:协议单测 153 条、generator 回归、LuCI 表单快照
修复:`executeCommand` 清理、dnsmasq 路径告警、fw4 清单单源、启动日志、PEM 校验、`wGET` 失败原因
工程:提高中文翻译率 CI、架构核实 |
parse_uri 对比零差异、generator JSON 逐字节一致、表单快照逐字段一致
顺带修复 3 个既有缺陷:证书上传、fw4 清理回滚、dnsmasq 路径 | +## 稳定性修复(r12) + +修掉两个会让功能整体不可用的缺陷,均由 issue 复现确认。 + +| 领域 | 变更 | +| --- | --- | +| **订阅抓取**(#5) | 抓取前先探测 wget 实现:GNU wget 用 `-nv`(静音进度条但保留失败原因),uclient-fetch / busybox wget 用 `-q`;`--user-agent` / `--timeout` 一并换成三家通用的 `-U` / `-T`。r11 引入的 `-nv` 是 GNU 专有选项,而 OpenWrt / ImmortalWrt 的 `/usr/bin/wget` 默认由 uclient-fetch 提供,参数解析阶段即失败,导致所有订阅更新以 `no valid node found` 结束 | +| **自定义路由**(#3 #4) | 修复 `routing_mode='custom'` 完全无法生成配置:`direct_overrides` 的使用早于声明(ucode 不做变量提升,strict 模式下直接抛未声明变量);本地规则集残留 legacy `download_detour` 字段,被 sing-box 判为 `unknown field` 而拒绝整个配置 | +| **预设规则集** | `geoip-cn` / `geosite-cn` / `geosite-noncn` 改用 SagerNet 上游 raw URL,并经 `main-out` 下载:直连受 DNS 污染时会超时并静默沿用缓存;每天约 250 KB 的代理开销可忽略,节点引导不受影响 | +| **依赖** | Makefile 补上 `+ip-full`、`+kmod-tun` | + +> 回归保障:订阅抓取用 stub wget 覆盖 GNU / 非 GNU 两条分支,并在装有 `/bin/uclient-fetch` 的目标上直接执行生成的命令行;自定义路由新增 `custom.uci` fixture(本地规则集 + 直连节点覆盖 + 路由规则)。 + ## 运行要求 - ImmortalWrt / OpenWrt ≥ 24.10+(apk 或 opkg 均可安装) diff --git a/luci-app-homeproxy/root/etc/homeproxy/scripts/homeproxy.uc b/luci-app-homeproxy/root/etc/homeproxy/scripts/homeproxy.uc index 93e2d7e59..60898c0b8 100644 --- a/luci-app-homeproxy/root/etc/homeproxy/scripts/homeproxy.uc +++ b/luci-app-homeproxy/root/etc/homeproxy/scripts/homeproxy.uc @@ -91,10 +91,44 @@ export function getTime(epoch) { }; +/* + * GNU wget and OpenWrt's default /usr/bin/wget do not accept the same flags. + * On OpenWrt/ImmortalWrt that path comes from the uclient-fetch package + * (`ALTERNATIVES:=200:/usr/bin/wget:/bin/uclient-fetch`, `PROVIDES:=wget` in + * package/libs/uclient) and busybox ships no wget applet, so the effective + * wget is uclient-fetch unless wget-ssl happens to be installed on top of it. + * GNU-only flags break the fetch outright: uclient-fetch's getopt rejects + * `-nv` with "unrecognized option: n" before a single request is sent. + * + * The probe below tells the two apart. `-nv` (--no-verbose) is how GNU wget + * mutes the progress meter while still printing the failure reason on stderr; + * `-q` is the only meter switch uclient-fetch and busybox wget share. + */ +export function classifyWgetFlavor(exitcode, stdout) { + if (exitcode !== 0 || !stdout) + return 'compat'; + + return match(stdout, /GNU Wget/) ? 'gnu' : 'compat'; +}; + +let wget_flavor = null; + +export function wgetFlavor() { + if (wget_flavor === null) { + const probe = executeCommand('/usr/bin/wget --version') || {}; + + wget_flavor = classifyWgetFlavor(probe.exitcode, probe.stdout); + } + + return wget_flavor; +}; + /* * Fetch a URL and report both the body and, on failure, the reason. The * reason is wget's own stderr (whitespace collapsed, length-capped) so the * caller can tell a DNS failure from a timeout or a TLS handshake error. + * Note that uclient-fetch's -q hides most failure messages, so on such a + * target the reason is often empty and only the exit code is left. */ export function wGETVerbose(url, ua) { if (!url || type(url) !== 'string') @@ -103,8 +137,10 @@ export function wGETVerbose(url, ua) { if (!ua) ua = 'Wget/1.21 (HomeProxy, like v2rayN)'; - /* -nv (not -q) so wget still reports *why* a fetch failed on stderr */ - const output = executeCommand(`/usr/bin/wget -nv -O- --user-agent ${shellQuote(ua)} --timeout=10 ${shellQuote(url)}`) || {}; + /* -U/-T are understood by GNU wget, uclient-fetch and busybox wget alike; + * the long forms --user-agent/--timeout are not, busybox wget in + * particular knows neither of them. */ + const output = executeCommand(`/usr/bin/wget ${wgetFlavor() === 'gnu' ? '-nv' : '-q'} -O- -U ${shellQuote(ua)} -T 10 ${shellQuote(url)}`) || {}; if (output.exitcode !== 0) { let reason = trim(output.stderr || ''); reason = reason ? replace(reason, /\s+/g, ' ') : 'no error output'; diff --git a/luci-app-homeproxy/tests/README.md b/luci-app-homeproxy/tests/README.md index 8283c9b83..033b3bac3 100644 --- a/luci-app-homeproxy/tests/README.md +++ b/luci-app-homeproxy/tests/README.md @@ -38,6 +38,8 @@ sh tests/ucode/run.sh "$PWD" | `tests/luci-form-snapshot.js` | Dumps every option (name, kind, title, description, depends, values, datatype/default/…) of the node and server views. Diffs against `tests/snapshots/{node,server}.json`, so a refactor that changes a field or its visibility fails. | | `tests/ucode/test_homeproxy_utils.uc` | `executeCommand()` return shape, stderr/exit-code capture, binary detection, and a descriptor-leak check (200 calls). | | `tests/ucode/test_homeproxy_utils_inject.uc` | The failure path of `executeCommand()`: the script stages a copy of `homeproxy.uc` whose `system()` call is replaced by `die()`, then checks that the exception still propagates and that neither descriptor leaks. | +| `tests/ucode/test_wget_flavor.uc` | The `wget --version` probe (`classifyWgetFlavor()`) and the command line `wGETVerbose()` builds from it. `run.sh` points the module at a stub wget and runs the file twice — once answering like GNU wget (expects `-nv`), once like uclient-fetch (expects `-q` and no GNU-only option at all). | +| `tests/ucode/test_wget_verbose_uclient.uc` | Runs `wGETVerbose()` against the target's real `/bin/uclient-fetch` (skipped where it is absent). The request goes to a closed loopback port, so the point is only that uclient-fetch gets past getopt — a GNU-only flag in the command line fails the test. | | `tests/ucode/test_fw4_names.sh` | Keeps the fw4 chain/set inventory in `scripts/fw4_names.sh` (used by `init.d/homeproxy` to clean up on stop) in sync with the objects declared in `scripts/firewall_post.ut`. | | `tests/ucode/test_parse_uri.uc` | Per-scheme assertions on the `parse_uri()` result: anytls, http/https, hysteria, hysteria2/hy2, snell, socks(4/4a/5/5h), shadowsocks (SIP002 base64 + plain + plugin, Shadowrocket), trojan (ws/grpc), tuic, vless (reality/ws/http/httpupgrade), vmess (ws/h2/httpupgrade/grpc) and SIP008 objects, plus the rejection paths (unsupported kcp/quic, no QUIC support, invalid port, unknown scheme). | | `tests/ucode/test_generators.sh` | Runs `generate_client.uc`/`generate_server.uc` against the UCI fixtures in `tests/fixtures/generators/` inside a scratch directory (the `uci` cursor and `HP_DIR`/`RUN_DIR` are redirected) and validates each result with `sing-box check`. | diff --git a/luci-app-homeproxy/tests/ucode/run.sh b/luci-app-homeproxy/tests/ucode/run.sh index 7606a80ae..3664f6459 100755 --- a/luci-app-homeproxy/tests/ucode/run.sh +++ b/luci-app-homeproxy/tests/ucode/run.sh @@ -81,6 +81,68 @@ else FAILED=1 fi +echo "== wget flavour selection ==" +# A stub /usr/bin/wget whose --version answer comes from the environment, so +# both flavours can be exercised on a single host. The empty-answer/non-zero +# case is uclient-fetch, whose getopt rejects --version just like it rejects +# -nv before sending anything. +rm -rf "$WORK/wget_flavor" +mkdir -p "$WORK/wget_flavor" +cat > "$WORK/wget_flavor/wget-stub" <<'STUB' +#!/bin/sh +if [ "$1" = "--version" ]; then + printf '%s\n' "$HP_WGET_STUB_VERSION" + exit "${HP_WGET_STUB_VERSION_RC:-0}" +fi +for arg in "$@"; do printf '%s\n' "$arg"; done >> "$HP_WGET_STUB_LOG" +printf 'stub body\n' +STUB +chmod +x "$WORK/wget_flavor/wget-stub" +sed "s|/usr/bin/wget|$WORK/wget_flavor/wget-stub|" \ + "$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/wget_flavor/homeproxy.uc" +cp "$ROOT/tests/ucode/test_wget_flavor.uc" "$WORK/wget_flavor/" + +for flavor in gnu compat; do + if [ "$flavor" = "gnu" ]; then + version="GNU Wget 1.25.0 built on linux-gnu." + rc=0 + else + version="" + rc=1 + fi + rm -f "$WORK/wget_flavor/args.log" + if ( cd "$WORK/wget_flavor" && + HP_WGET_EXPECT_FLAVOR="$flavor" \ + HP_WGET_STUB_VERSION="$version" \ + HP_WGET_STUB_VERSION_RC="$rc" \ + HP_WGET_STUB_LOG="$WORK/wget_flavor/args.log" \ + ucode test_wget_flavor.uc ); then + echo "PASS: wget flavour selection ($flavor)" + else + echo "FAIL: wget flavour selection ($flavor)" + FAILED=1 + fi +done + +echo "== real uclient-fetch compatibility ==" +# uclient-fetch is the default /usr/bin/wget on OpenWrt/ImmortalWrt, so a +# GNU-only option anywhere in the generated command line has to fail here. +if [ -x /bin/uclient-fetch ]; then + rm -rf "$WORK/uclient" + mkdir -p "$WORK/uclient" + sed "s|/usr/bin/wget|/bin/uclient-fetch|" \ + "$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/uclient/homeproxy.uc" + cp "$ROOT/tests/ucode/test_wget_verbose_uclient.uc" "$WORK/uclient/" + if ( cd "$WORK/uclient" && ucode test_wget_verbose_uclient.uc ); then + echo "PASS: uclient-fetch accepts the generated command line" + else + echo "FAIL: uclient-fetch rejects the generated command line" + FAILED=1 + fi +else + echo "SKIP: /bin/uclient-fetch not installed on this host" +fi + echo "== generator regression tests ==" sh "$ROOT/tests/ucode/test_generators.sh" "$ROOT" "$WORK/generators" || FAILED=1 diff --git a/luci-app-homeproxy/tests/ucode/test_wget_flavor.uc b/luci-app-homeproxy/tests/ucode/test_wget_flavor.uc new file mode 100644 index 000000000..40f2ca163 --- /dev/null +++ b/luci-app-homeproxy/tests/ucode/test_wget_flavor.uc @@ -0,0 +1,89 @@ +#!/usr/bin/ucode +/* + * SPDX-License-Identifier: GPL-2.0-only + * + * Copyright (C) 2026 ImmortalWrt.org + * + * Regression tests for the wget flavour probe and for the command line + * wGETVerbose() builds from it. + * + * OpenWrt/ImmortalWrt's default /usr/bin/wget is uclient-fetch, which rejects + * GNU-only flags such as -nv with "unrecognized option: n" *before* it opens a + * connection, so a single GNU-ism turns every subscription update into + * "no valid node found". run.sh stages a copy of homeproxy.uc whose + * /usr/bin/wget points at a stub; HP_WGET_STUB_VERSION steers the answer of + * the `--version` probe (empty output plus a non-zero exit mimics + * uclient-fetch, whose getopt rejects the option and returns 1). + */ + +'use strict'; + +import { readfile } from 'fs'; +import { classifyWgetFlavor, wGETVerbose, wgetFlavor } from 'homeproxy'; + +let failures = 0, checks = 0; + +function expect(name, actual, want) { + checks++; + if (sprintf('%J', actual) !== sprintf('%J', want)) { + printf('FAIL %s: expected %J, got %J\n', name, want, actual); + failures++; + } +} + +function has(list, value) { + for (let v in list) + if (v === value) + return true; + + return false; +} + +/* The value following the first occurrence of `key` */ +function after(list, key) { + let prev = null; + + for (let v in list) { + if (prev === key) + return v; + + prev = v; + } + + return null; +} + +/* Classification of the `wget --version` probe. */ +expect('gnu.version', classifyWgetFlavor(0, 'GNU Wget 1.25.0 built on linux-gnu.\n'), 'gnu'); +expect('gnu.multiline', classifyWgetFlavor(0, "GNU Wget 1.21.4 built on linux-gnu.\n\nCopyright (C) 2015 Free Software Foundation.\n"), 'gnu'); +expect('uclient.rejects-version', classifyWgetFlavor(1, null), 'compat'); +expect('busybox.version', classifyWgetFlavor(0, 'BusyBox v1.36.1 (2024-01-01) multi-call binary.\n'), 'compat'); +expect('empty.stdout', classifyWgetFlavor(0, ''), 'compat'); +expect('missing.binary', classifyWgetFlavor(127, null), 'compat'); + +/* wgetFlavor() probes the stub and caches the answer. */ +const want_flavor = getenv('HP_WGET_EXPECT_FLAVOR'); +expect('flavor', wgetFlavor(), want_flavor); +expect('flavor.cached', wgetFlavor(), want_flavor); + +const res = wGETVerbose('https://example.invalid/subs?token=abc', 'HomeProxy test UA'); +expect('content', res.content, 'stub body'); +expect('error', res.error, null); + +const log = readfile(getenv('HP_WGET_STUB_LOG')); +const args = log ? split(trim(log), '\n') : []; + +expect('agent flag', has(args, '-U'), true); +expect('agent value', after(args, '-U'), 'HomeProxy test UA'); +expect('timeout flag', after(args, '-T'), '10'); +expect('output flag', has(args, '-O-'), true); +expect('url', has(args, 'https://example.invalid/subs?token=abc'), true); + +/* -nv is GNU wget's --no-verbose; every other wget on the target rejects it, + * and -q is the meter switch they all share. */ +expect('meter flag', has(args, want_flavor === 'gnu' ? '-nv' : '-q'), true); +expect('no gnu-only flag', has(args, '-nv'), want_flavor === 'gnu'); +expect('no long options', has(args, '--user-agent') || has(args, '--timeout=10'), false); + +printf('%d checks, %d failures\n', checks, failures); +exit(failures === 0 ? 0 : 1); diff --git a/luci-app-homeproxy/tests/ucode/test_wget_verbose_uclient.uc b/luci-app-homeproxy/tests/ucode/test_wget_verbose_uclient.uc new file mode 100644 index 000000000..2fcafc73d --- /dev/null +++ b/luci-app-homeproxy/tests/ucode/test_wget_verbose_uclient.uc @@ -0,0 +1,42 @@ +#!/usr/bin/ucode +/* + * SPDX-License-Identifier: GPL-2.0-only + * + * Copyright (C) 2026 ImmortalWrt.org + * + * Feeds the command line wGETVerbose() builds to the target's real + * uclient-fetch - the default /usr/bin/wget on OpenWrt/ImmortalWrt. run.sh + * rewrites the module's wget path to /bin/uclient-fetch and skips this file + * where that binary does not exist. + * + * A closed loopback port is the entire request: what matters is that + * uclient-fetch gets past getopt. Before the flavour probe existed it did not - + * `-nv` made it print "unrecognized option: n" and exit before the request was + * sent, so every subscription fetch came back empty. Any failure observed here + * must therefore come from the network, never from the option parser. + */ + +'use strict'; + +import { wGETVerbose, wgetFlavor } from 'homeproxy'; + +let failures = 0, checks = 0; + +function expect(name, actual, want) { + checks++; + if (sprintf('%J', actual) !== sprintf('%J', want)) { + printf('FAIL %s: expected %J, got %J\n', name, want, actual); + failures++; + } +} + +/* uclient-fetch has no --version and rejects it, so it is classified compat. */ +expect('flavor', wgetFlavor(), 'compat'); + +const res = wGETVerbose('http://127.0.0.1:1/subs', 'HomeProxy test UA'); +expect('no content', res.content, null); +expect('not an option error', match(res.error || '', /unrecognized option/) == null, true); +expect('error carries the exit code', match(res.error || '', /^wget exited with status \d+: /) != null, true); + +printf('%d checks, %d failures\n', checks, failures); +exit(failures === 0 ? 0 : 1); diff --git a/wwand/Makefile b/wwand/Makefile index f35e1e5c0..a1a944c81 100644 --- a/wwand/Makefile +++ b/wwand/Makefile @@ -49,12 +49,12 @@ include $(TOPDIR)/rules.mk PKG_NAME:=wwand -PKG_VERSION:=1.6.7_p17 -PKG_RELEASE:=45 +PKG_VERSION:=1.6.7_p21 +PKG_RELEASE:=46 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git -PKG_SOURCE_VERSION:=245058186b1ee22b8e6eb92ca9745d4ea811b116 +PKG_SOURCE_VERSION:=910e38b7e4c92e273b91aeead3bea7e2f7104a27 PKG_MIRROR_HASH:=skip PKG_LICENSE:=GPL-2.0-only