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