🌴 Sync 2026-10-06 02:20:56

This commit is contained in:
github-actions[bot] committed 2026-10-06 02:20:56 +08:00
1 parent 5c201dd3af
commit 487c4fad50
111 files changed
+4641 -746

No files matched your search

+50 -4
View File
@@ -395,19 +395,65 @@ cp "$ROOT/tests/ucode/test_homeproxy_utils.uc" "$WORK/homeproxy-pro/"
# same thing everywhere and needs no network.
#
# On a target /bin/uclient-fetch really exists, so fetchBinary() returns it and
# the same assertions run against the real applet. Both directions are useful:
# the stub catches "an option we invented", the target catches "an option the
# real applet lacks".
# the assertions run against the real applet. Both directions are useful: the
# stub catches "an option we invented", the target catches "an option the real
# applet lacks".
mkdir -p "$WORK/fetchbin"
cp "$ROOT/tests/fixtures/uclient-fetch-stub" "$WORK/fetchbin/uclient-fetch"
chmod +x "$WORK/fetchbin/uclient-fetch"
if ( cd "$WORK/homeproxy-pro" && PATH="$WORK/fetchbin:$PATH" ucode test_homeproxy_utils.uc ); then
# The binary and HTTP-error shapes are something the fetcher has to PRODUCE, so
# on a target the real applet can only produce them by talking to a real server
# - the stub's magic URL paths mean nothing to it (they read as an ordinary URL
# it fails to connect to, which is why those three assertions used to fail on
# every real device). So stand up a throwaway uhttpd over a temp docroot and
# hand the test its base URL via HP_T_HTTP_BASE.
#
# It is a second uhttpd on a high port serving a temp directory, killed right
# after the test and by the trap below: the running LuCI instance, /www and
# port 80 are never touched. If it cannot be started the test reports those
# three as skipped, which is honest; they must never be made to read as a pass.
HP_T_HTTP_BASE=""
HP_T_HTTP_PID=""
hp_http_stop() {
if [ -n "$HP_T_HTTP_PID" ]; then
kill "$HP_T_HTTP_PID" 2> /dev/null
wait "$HP_T_HTTP_PID" 2> /dev/null
HP_T_HTTP_PID=""
fi
}
trap hp_http_stop EXIT INT TERM
if { [ -x /bin/uclient-fetch ] || [ -x /usr/bin/uclient-fetch ]; } &&
command -v uhttpd > /dev/null 2>&1; then
hp_docroot="$WORK/httproot"
rm -rf "$hp_docroot"
mkdir -p "$hp_docroot"
# A body executeCommand() must classify as binary: an .srs magic number
# with NUL bytes in it, the same shape the stub emits.
printf 'SRS\002x\332b\000\014\000\000\001\000\001' > "$hp_docroot/hp-binary.srs"
for hp_port in 18099 18100 18101 18102 18103 18104; do
uhttpd -f -p "127.0.0.1:$hp_port" -h "$hp_docroot" -x /cgi-bin -t 5 -T 5 &
HP_T_HTTP_PID=$!
# Integer sleep on purpose: busybox ash has no fractional wait, and
# uhttpd needs a moment to bind before the first fetch can reach it.
sleep 1
if kill -0 "$HP_T_HTTP_PID" 2> /dev/null; then
HP_T_HTTP_BASE="http://127.0.0.1:$hp_port"
break
fi
HP_T_HTTP_PID=""
done
fi
if ( cd "$WORK/homeproxy-pro" && PATH="$WORK/fetchbin:$PATH" HP_T_HTTP_BASE="$HP_T_HTTP_BASE" ucode test_homeproxy_utils.uc ); then
echo "PASS: executeCommand() regression tests"
else
echo "FAIL: executeCommand() regression tests"
FAILED=1
fi
hp_http_stop
echo "== TLS / transport builder tests =="
rm -rf "$WORK/tls_transport"
@@ -190,13 +190,23 @@ for chain in redirect mangle_prerouting mangle_output; do
FAILED=1
fi
done
v4_rules="$(grep -c 'ip daddr @homeproxy_node_addr_v4 counter return' "$TEMPLATE_SRC" || true)"
if [ "$v4_rules" -eq 3 ]; then
echo "PASS: all three chains carry the node-address return rule"
else
echo "FAIL: expected 3 'ip daddr @homeproxy_node_addr_v4 counter return' rules, found $v4_rules"
FAILED=1
fi
# Asserted per chain, not as a global count. The global count is what this
# check used to be ("expected 3"), and it is exactly how the missing rule went
# unnoticed: the TUN chain needed one and the assertion counted the three that
# already existed. The four chains below are the ones that steer traffic into
# the tunnel or the redirect/tproxy port - each must exempt the node's own
# address once, so a new chain that starts steering traffic has to be added
# here (and the operator reading the diff sees why).
for chain in homeproxy_redirect homeproxy_mangle_prerouting homeproxy_mangle_output homeproxy_mangle_tun; do
body="$(awk -v c="$chain" 'index($0, "chain " c " {") == 1 { f = 1 } f { print } f && /^}/ { exit }' "$TEMPLATE_SRC")"
n="$(printf '%s\n' "$body" | grep -c 'ip daddr @homeproxy_node_addr_v4 counter return' || true)"
if [ "$n" -eq 1 ]; then
echo "PASS: $chain carries exactly one node-address return rule"
else
echo "FAIL: $chain carries $n node-address return rules (expected exactly 1)"
FAILED=1
fi
done
# Probe with ucode, not utpl: `utpl -e` is not an eval flag, it renders the
# argument as template text and always succeeds.
@@ -221,12 +231,48 @@ if ! ucode -e 'require("fw4");' > "/dev/null" 2>&1; then
exit $FAILED
fi
# Where the render reads its UCI from.
#
# * on a target: the real /etc/config - the render has to see the
# configuration that is actually deployed;
# * anywhere else (CI): tests/fixtures/firewall/render.uci, so this layer -
# the only one that turns the template into nft text - runs in CI instead
# of reporting NOT RUN. That is T1's other half: the module it needs is
# staged by the toolchain (tests/toolchain/build-ucode-linux.sh) and the
# configuration it reads is staged here.
#
# HP_T_FW4_CONFIG_DIR overrides both, which is how the fixture path itself is
# exercised on a device (real fw4, fixture config).
FW4_CONFIG_DIR="${HP_T_FW4_CONFIG_DIR:-}"
if [ -z "$FW4_CONFIG_DIR" ] && [ ! -f "/etc/config/homeproxy-pro" ]; then
FW4_CONFIG_DIR="$STAGE/cfg"
mkdir -p "$FW4_CONFIG_DIR"
cp "$ROOT/tests/fixtures/firewall/render.uci" "$FW4_CONFIG_DIR/homeproxy-pro"
cp "$ROOT/tests/fixtures/firewall/dhcp" "$FW4_CONFIG_DIR/dhcp"
fi
# The template is written for a device: it imports homeproxy-pro.uc through
# /etc/homeproxy-pro/scripts/ and reads /etc/homeproxy-pro/resources. Neither exists
# off-target, so render a staged copy with those prefixes rewritten to the
# checkout - the same trick run.sh uses for the absolute import in
# root/usr/share/rpcd/ucode/luci.homeproxy-pro.
sed -e "s#'/etc/homeproxy-pro/#'$ROOT/root/etc/homeproxy-pro/#g" "$TEMPLATE_SRC" > "$STAGED"
# root/usr/share/rpcd/ucode/luci.homeproxy-pro. The cursor is rewritten too when
# the UCI comes from the fixture rather than from /etc/config.
if [ -n "$FW4_CONFIG_DIR" ]; then
sed -e "s#'/etc/homeproxy-pro/#'$ROOT/root/etc/homeproxy-pro/#g" \
-e "s#^const uci = cursor();#const uci = cursor('$FW4_CONFIG_DIR');#" \
"$TEMPLATE_SRC" > "$STAGED"
else
sed -e "s#'/etc/homeproxy-pro/#'$ROOT/root/etc/homeproxy-pro/#g" "$TEMPLATE_SRC" > "$STAGED"
fi
# Hard guard: the cursor rewrite has to have matched, or the render silently
# reads a configuration that is not there (and the assertions would be about a
# ruleset nobody asked for).
if [ -n "$FW4_CONFIG_DIR" ] && ! grep -qF "cursor('$FW4_CONFIG_DIR')" "$STAGED"; then
echo "FAIL: firewall_post.ut: could not point the render at $FW4_CONFIG_DIR"
echo " (the 'const uci = cursor();' anchor no longer matches)"
exit 1
fi
# Hard guard: if the prefix ever changes, the sed silently no-ops and the
# render fails for an unrelated reason, which would look like a template
@@ -266,4 +312,36 @@ if [ "$FAILED" -eq 0 ]; then
echo "PASS: the gfwlist gate follows the dnsmasq nftset capability"
fi
# The wan guard (S4): with `listen_interfaces` empty the intercept layer has to
# exclude the wan zone's devices. Two ways this went wrong on the device and
# both render *something*, which is why the assertion is not just "a guard is
# there":
#
# - r46 shipped a template whose guard silently disappeared: fw4 had no state
# in this process (`zones()` is null outside fw4's own render), the loop
# threw, the catch swallowed it, and the rendered chain had no iifname at
# all - the exact hole the guard was added to close;
# - resolving it from /var/run/fw4.state instead yields the LOGICAL names
# ("wan", "wan_6"), so `meta iifname { wan }` renders and can never match.
#
# Resolving zone devices needs ubus, so off-target runs report SKIP rather than
# pretending: the render needs fw4's real zone data for this one.
GUARD_IFACES="$(grep -oE 'meta iifname( !=)? \{[^}]*\}' "$OUT" | sort -u | tr '\n' ' ')"
case "$GUARD_IFACES" in
*'{ wan }'*|*'{ wan,'*|*'wan_6 }'*|*'wan_6,'*)
echo "FAIL: the wan guard rendered a logical network name, which never matches:"
echo " $GUARD_IFACES"
FAILED=1
;;
esac
if grep -q 'not the WAN side' "$OUT"; then
echo "PASS: the wan guard is rendered (resolved devices: ${GUARD_IFACES:-none})"
elif ubus -v list network.interface > "/dev/null" 2>&1; then
echo "FAIL: the wan guard is missing although fw4 could resolve zone devices"
FAILED=1
else
echo "SKIP: the wan guard needs ubus to resolve zone devices (not available here)"
fi
exit $FAILED
@@ -12,7 +12,7 @@
'use strict';
import { lsdir } from 'fs';
import { executeCommand, isValidCIDR, isValidPEM, redactReason, redactUrl, ruleSetFormatFromBytes,
import { executeCommand, fetchBinary, isValidCIDR, isValidPEM, redactReason, redactUrl, ruleSetFormatFromBytes,
ruleSetFormatFromPath, RULESET_PROBE_BYTES, shellQuote, wGETVerbose } from 'homeproxy-pro';
let failures = 0,
@@ -104,17 +104,66 @@ expect('fetch.not-a-usage-error',
expect('fetch.reports-a-reason', length(fetch.error || '') > 0, true);
expect('fetch.no-content-on-failure', fetch.content, null);
/* The binary and HTTP-error shapes below have to be PRODUCED by the fetcher, so
* which fetcher is in play decides which URL can produce them.
*
* Off-target fetchBinary() resolves to the stub, which manufactures both shapes
* from a magic path in the URL. On a target it returns the real applet, and a
* real applet only produces them by talking to a real server - a magic path
* just looks like an ordinary URL it fails to connect to, which is why these
* three used to fail there. run.sh starts a throwaway uhttpd for exactly this
* and passes its base URL in HP_T_HTTP_BASE.
*
* A real applet with no server (no uhttpd, every candidate port taken) cannot
* be driven at all: report the three as skipped rather than failing, and never
* let them read as a pass. */
const fetchBin = fetchBinary();
const realApplet = substr(fetchBin, 0, 1) === '/';
const httpBase = getenv('HP_T_HTTP_BASE') || '';
const canDriveFetchShapes = httpBase || !realApplet;
if (realApplet && !httpBase)
printf('SKIP: binary / HTTP-error fetch shapes (real applet, no local server)\n');
/* A 200 whose body is binary. executeCommand() nulls stdout for binary
* content, and the "no content but stderr said something" branch then used to
* report it as `fetch failed: … Download completed (34185 bytes)` - a message
* that contradicts itself and sends the user after a network problem they do
* not have. Measured on a device against a real .srs. */
const binfetch = wGETVerbose('http://127.0.0.1:1/HP_T_STUB_BINARY');
expect('fetch.binary-is-not-a-failure',
match(binfetch.error || '', /fetch failed/) == null, true, binfetch.error);
expect('fetch.binary-says-so',
match(binfetch.error || '', /binary/) != null, true, binfetch.error);
expect('fetch.binary-has-no-content', binfetch.content, null);
if (canDriveFetchShapes) {
const binURL = httpBase ? `${httpBase}/hp-binary.srs` : 'http://127.0.0.1:1/HP_T_STUB_BINARY';
const binfetch = wGETVerbose(binURL);
expect('fetch.binary-is-not-a-failure',
match(binfetch.error || '', /fetch failed/) == null, true, binfetch.error);
expect('fetch.binary-says-so',
match(binfetch.error || '', /binary/) != null, true, binfetch.error);
expect('fetch.binary-has-no-content', binfetch.content, null);
}
/* A truncated transfer must not look like a subscription.
*
* The fetch used to be `uclient-fetch -O - | head -c N`, and system() returns
* the last command's status - head's, always 0. A response that declared 244
* bytes and delivered 80 therefore arrived as `{ content: <80 bytes>, error:
* null }`: a half-downloaded list would rewrite the node set and nothing
* anywhere said so. wGETVerbose() now carries the fetcher's own status out of
* the pipeline through a file, and this is the shape that proves it.
*
* The stub is the only thing that can produce "non-empty body AND non-zero
* exit" (a real uhttpd sets Content-Length from the file it serves), so on a
* real-applet run this is skipped on purpose - the same path was measured by
* hand on the device with a truncated response: real uclient-fetch exits 4
* with "Connection reset prematurely" and wGETVerbose() reports
* `fetch exited with status 4: …`. */
if (canDriveFetchShapes) {
if (realApplet) {
printf('SKIP: truncated-transfer shape (the real applet cannot be asked for a short body; device-verified by hand)\n');
} else {
const truncfetch = wGETVerbose('http://127.0.0.1:1/HP_T_STUB_TRUNCATED');
expect('fetch.truncated-is-not-a-success', truncfetch.content, null);
expect('fetch.truncated-reports-the-status',
match(truncfetch.error || '', /status 4/) != null, true, truncfetch.error);
}
}
/* Review H3: the fetcher announces the requested URL on stderr before it reports
* anything else, query string and all, so this is what would reach the log.
@@ -136,11 +185,14 @@ expect('fetch.redacted-still-names-the-target',
/* The HTTP-error shape is the one that leaks - "Downloading '<URL>'" followed
* by "HTTP error 404" - and it is the common case for a subscription URL that
* has expired. */
const httpfetch = wGETVerbose('http://127.0.0.1:1/HP_T_STUB_HTTP_ERROR?token=secret');
expect('fetch.http-error-token-not-in-error',
match(httpfetch.error || '', /token=secret/) == null, true);
expect('fetch.http-error-keeps-the-status',
match(httpfetch.error || '', /HTTP error/) != null, true, httpfetch.error);
if (canDriveFetchShapes) {
const errURL = httpBase ? `${httpBase}/hp-missing?token=secret` : 'http://127.0.0.1:1/HP_T_STUB_HTTP_ERROR?token=secret';
const httpfetch = wGETVerbose(errURL);
expect('fetch.http-error-token-not-in-error',
match(httpfetch.error || '', /token=secret/) == null, true);
expect('fetch.http-error-keeps-the-status',
match(httpfetch.error || '', /HTTP error/) != null, true, httpfetch.error);
}
/* redactReason(): central redaction that protects every wGETVerbose caller.
* Tested in isolation so the assertion does not depend on any fetcher being
@@ -15,6 +15,9 @@
* - a subscription node dropped from the cache is deleted
* - a new node in the cache but not in UCI is added under the
* md5(groupHash + label) section name
* - a node whose label changed but whose content did not keeps its
* section (and with it every reference to it), and is not re-added
* - two stored sections with the same content: only one may claim it
*
* PR-03 changes the input contract: Repository now accepts
* canonical Node objects (post- parser/normalize), and flattens
@@ -413,6 +416,149 @@ expect('scrub: clean state changes nothing', scrub2.changed, 0);
*
* U+4E2D U+6587 -> e4 b8 ad e6 96 87
* U+1F600 -> f0 9f 98 80 */
/* --- a renamed node keeps its section --------------------------------- */
/* Section names are md5(grouphash + label), so a subscription that renames a
* node used to make the repository delete the old section and add an identical
* one under the new name - taking every reference to it (config.main_node,
* main_udp_node, the urltest member lists) with it. Measured on the device
* before this: "Removing node: OLD-NAME." + "Adding node: NEW-NAME.", the
* section name changed, and main_node was switched to the new section. The
* repository recognises the stored section by its *content* now: same server,
* same credentials, new label. */
{
const uci3 = cursor(ARGV[0]);
uci3.load(CFG);
const groupR = 'groupRename';
const u_renamed = 'cfgRENAMED001';
uci3.set(CFG, u_renamed, TYPE);
uci3.set(CFG, u_renamed, 'label', 'old-name');
uci3.set(CFG, u_renamed, 'grouphash', groupR);
uci3.set(CFG, u_renamed, 'type', 'vless');
uci3.set(CFG, u_renamed, 'address', 'rename.example.com');
uci3.set(CFG, u_renamed, 'port', '443');
uci3.commit(CFG);
uci3.load(CFG);
const r_node = canonical_node({
grouphash: groupR,
label: 'new-name',
type: 'vless',
address: 'rename.example.com',
port: '443'
});
const r_cache = { [groupR]: { [md5(groupR + 'new-name')]: r_node } };
const r_result = Repository.apply_nodes(uci3, CFG, TYPE, r_cache, [[ r_node ]], LOG);
expect('rename: not counted as removed', r_result.removed, 0);
expect('rename: not counted as added', r_result.added, 0);
uci3.commit(CFG);
uci3.load(CFG);
expect('rename: section kept under its old name',
'label' in uci3.get_all(CFG, u_renamed), true);
expect('rename: label updated in place',
uci3.get_all(CFG, u_renamed).label, 'new-name');
expect('rename: nothing added under the new name',
'label' in uci3.get_all(CFG, md5(groupR + 'new-name')), false);
}
/* Two stored sections with identical content must not both update themselves
* into the same node: the first one claims the content, the duplicate is
* pruned exactly as before. */
{
const uci4 = cursor(ARGV[0]);
uci4.load(CFG);
const groupD = 'groupDuplicate';
const u_dup_a = 'cfgDUPA00001';
const u_dup_b = 'cfgDUPB00001';
for (let section in [ u_dup_a, u_dup_b ]) {
uci4.set(CFG, section, TYPE);
uci4.set(CFG, section, 'label', section);
uci4.set(CFG, section, 'grouphash', groupD);
uci4.set(CFG, section, 'type', 'vless');
uci4.set(CFG, section, 'address', 'dup.example.com');
uci4.set(CFG, section, 'port', '443');
}
uci4.commit(CFG);
uci4.load(CFG);
const d_node = canonical_node({
grouphash: groupD,
label: 'renamed-dup',
type: 'vless',
address: 'dup.example.com',
port: '443'
});
const d_cache = { [groupD]: { [md5(groupD + 'renamed-dup')]: d_node } };
const d_result = Repository.apply_nodes(uci4, CFG, TYPE, d_cache, [[ d_node ]], LOG);
expect('duplicate content: one section claims it', d_result.added, 0);
expect('duplicate content: the other is pruned', d_result.removed, 1);
uci4.commit(CFG);
uci4.load(CFG);
const survived = filter([ u_dup_a, u_dup_b ], (s) => ('label' in uci4.get_all(CFG, s)));
expect('duplicate content: exactly one section survives', length(survived), 1);
expect('duplicate content: it carries the new label',
uci4.get_all(CFG, survived[0]).label, 'renamed-dup');
}
/* --- the orchestrator's key is the section name ----------------------- */
/* Two servers of one subscription can share a label - it is whatever the
* provider put after '#'. The orchestrator keeps both and separates them by
* the content fingerprint, so the second node sits in the cache under a key
* that is NOT md5(grouphash + label). The repository has to write that key as
* the section name, or both nodes end up in one section and one is lost. */
{
const uci5 = cursor(ARGV[0]);
uci5.load(CFG);
const groupS = 'groupSharedLabel';
const s_first = canonical_node({
grouphash: groupS,
label: 'same-label',
type: 'vless',
address: 'first.example.com',
port: '443'
});
const s_second = canonical_node({
grouphash: groupS,
label: 'same-label',
type: 'vless',
address: 'second.example.com',
port: '443'
});
const disambiguated = md5(groupS + 'same-label' + chr(0x00) + 'different-content');
const s_cache = {
[groupS]: {
[md5(groupS + 'same-label')]: s_first,
[disambiguated]: s_second
}
};
const s_result = Repository.apply_nodes(uci5, CFG, TYPE, s_cache,
[[ s_first ], [ s_second ]], LOG);
expect('shared label: both nodes are added', s_result.added, 2);
uci5.commit(CFG);
uci5.load(CFG);
expect('shared label: the first is under md5(grouphash + label)',
uci5.get_all(CFG, md5(groupS + 'same-label')).address, 'first.example.com');
expect('shared label: the second is under the disambiguated key',
uci5.get_all(CFG, disambiguated).address, 'second.example.com');
}
const MD5_CJK = chr(0xe4) + chr(0xb8) + chr(0xad) + chr(0xe6) + chr(0x96) + chr(0x87);
const MD5_EMOJI = chr(0xf0) + chr(0x9f) + chr(0x98) + chr(0x80);