🎉 Sync 2026-09-27 21:03:26

This commit is contained in:
github-actions[bot] committed 2026-09-27 21:03:26 +08:00
1 parent f4559bbed1
commit 065763f7fd
673 files changed
+261285 -34160

No files matched your search

@@ -0,0 +1,292 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*
* Test double for root/etc/homeproxy/scripts/homeproxy.uc.
*
* Only `validation()` is stubbed: the real one shells out to
* /sbin/validate_data, which does not exist outside OpenWrt. That stand-in
* reproduces the `libvalidate` decisions for the five datatypes the callers
* actually use - see the comment on validation() below for the contract and
* the known differences. Everything else (isEmpty, decodeBase64Str, parseURL,
* shellQuote, redactUrl) is a verbatim copy so the parser tests exercise the
* same string handling as production. Keep these in sync when homeproxy.uc
* changes; tests/ucode/test_mock_sync.sh enforces that for everything except
* validation().
*/
import { urldecode_params } from 'luci.http';
/* Verbatim copy of homeproxy.uc:shellQuote - migrate_config.uc imports it to
* build the crontab rewrite, so it has to exist here too. Compared against
* production by tests/ucode/test_mock_sync.sh. */
export function shellQuote(s) {
return `'${replace(s, "'", "'\\''")}'`;
};
export function isEmpty(res) {
return !res || res === 'nil' || (type(res) in ['array', 'object'] && length(res) === 0);
};
export function decodeBase64Str(str) {
if (isEmpty(str))
return null;
str = trim(str);
str = replace(str, /_/g, '/');
str = replace(str, /-/g, '+');
const padding = length(str) % 4;
if (padding)
str = str + substr('====', padding);
return b64dec(str);
};
/* Verbatim copy of homeproxy.uc:redactUrl - decoder.uc imports it so the
* subscription token stays out of the parse-failed log line. */
export function redactUrl(url) {
if (!url || type(url) !== 'string')
return '';
let u = url;
/* userinfo: scheme://user:pass@host -> scheme://***@host */
const at = index(u, '@');
const scheme = match(u, /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//);
if (scheme && at !== -1 && at > length(scheme[0]))
u = substr(u, 0, length(scheme[0])) + '***' + substr(u, at);
/* path and query: keep the authority, mark the rest. Splitting them
* rather than masking from the first '/' keeps the structural markers
* (`/***?***`) so a reader can still tell a path from a query, and it is
* why the authority end is the *earlier* of the two separators. */
const scheme_end = scheme ? length(scheme[0]) : 0;
const rest = substr(u, scheme_end);
const path_at = index(rest, '/');
const query_at = index(rest, '?');
const has_path = path_at !== -1 && (query_at === -1 || path_at < query_at);
let authority_end = length(rest);
if (has_path)
authority_end = path_at;
else if (query_at !== -1)
authority_end = query_at;
let tail = '';
if (has_path)
tail += '/***';
if (query_at !== -1)
tail += '?***';
u = substr(u, 0, scheme_end + authority_end) + tail;
return u;
};
/* inet_pton(AF_INET, ...): four decimal octets, 0-255, no leading zeros.
* A helper rather than a call through validation(), because ucode resolves
* module names lexically and validation() is declared below these. */
function isValidIp4(data) {
if (match(data, /^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/) === null)
return false;
for (let octet in split(data, '.'))
if (int(octet) > 255 || (length(octet) > 1 && substr(octet, 0, 1) === '0'))
return false;
return true;
};
/* inet_pton(AF_INET6, ...) for the textual forms a share link can carry:
* at most one "::", 1-4 hex digits per group, exactly eight groups unless
* "::" fills the gap, and an optional dotted-quad tail (the last 32 bits)
* that counts as two groups. A "%zone" suffix, a CIDR prefix and surrounding
* whitespace are all rejected, exactly as inet_pton rejects them. */
function isValidIp6(data) {
if (match(data, /^[0-9A-Fa-f:.]+$/) == null)
return false;
/* A dotted-quad must be the tail; rewrite it as the two hex groups it
* stands for, so the rest of the parse only sees "[0-9A-Fa-f:]". */
if (index(data, '.') !== -1) {
const quad = match(data, /^(.*:)([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})$/);
if (quad === null || !isValidIp4(quad[2]))
return false;
const octets = split(quad[2], '.');
data = quad[1] + sprintf('%02x%02x:%02x%02x',
int(octets[0]), int(octets[1]), int(octets[2]), int(octets[3]));
}
const dbl = index(data, '::');
let left = data, right = '';
if (dbl !== -1) {
if (index(substr(data, dbl + 2), '::') !== -1)
return false;
left = substr(data, 0, dbl);
right = substr(data, dbl + 2);
}
let explicit = 0;
for (let part in [ left, right ]) {
if (part === '')
continue;
const groups = split(part, ':');
for (let group in groups)
if (match(group, /^[0-9A-Fa-f]{1,4}$/) === null)
return false;
explicit += length(groups);
}
/* "::" has to stand for at least one group, so with it at most seven
* groups may be written out; without it there must be exactly eight. */
return dbl === -1 ? explicit === 8 : explicit < 8;
};
/* Pure-ucode stand-in for the datatypes homeproxy.uc asks /sbin/validate_data
* to check. Production shells out to OpenWrt's `libvalidate` (ubox
* src/validate.c); off-target this function is what the parser unit tests and
* tests/toolchain/validate-data.sh exercise, so it answers the same way the
* real binary does for those five types:
*
* ip4addr inet_pton(AF_INET) - four decimal octets, 0-255, no leading
* zeros ("01.2.3.4" and "1.2.3.04" are invalid)
* ip6addr inet_pton(AF_INET6) - see isValidIp6 above; "2001:db8::1",
* "::ffff:1.2.3.4" and "1:2:3:4:5:6:7:8" are
* valid, "12345::1" and "fe80::1%eth0" are not
* hostname dt_type_hostname - dot-separated labels of [A-Za-z0-9_-], each
* 1..63 chars, the final label 1..255 chars;
* "-a", "a-" and "a_b" are valid, "a..b",
* "a%b", "foo." and ".foo" are not
* host hostname || ip4addr || ip6addr
* port dt_type_port - strtoul(v, &e, 10) with the whole string consumed
* and the value compared as a signed 32-bit
* int, so "-1" and " 80" are accepted and
* "65536"/"80 " are not
*
* Known differences, none reachable from the callers:
* - only those five types are modelled. The real binary also validates
* macaddr, cidr, netmask, file/directory, uci references, ...; an unknown
* type exits non-zero there (falsy) and returns false here.
* - strtoul()'s saturation above 2^64 is not reproduced: a digit string
* that long would wrap instead of clamping, so a port > 2^64 might
* disagree. No share link carries one.
*
* tests/ucode/test_mock_sync.sh deliberately excludes this function from its
* production-vs-mock corpus (it is a stand-in, not a copy); it is pinned by
* the parser tests and by tests/toolchain/validate-data.sh. */
export function validation(datatype, data) {
if (!datatype || !data)
return null;
switch (datatype) {
case 'port': {
const parts = match(data, /^[ \t\n\v\f\r]*([+-]?)([0-9]+)$/);
if (parts === null)
return false;
let n = 0;
for (let digit in split(parts[2], ''))
n = (n * 10 + int(digit)) % 4294967296;
if (parts[1] === '-' && n !== 0)
n = 4294967296 - n;
if (n > 2147483647)
n -= 4294967296;
return n <= 65535;
}
case 'ip4addr':
return isValidIp4(data);
case 'ip6addr':
return isValidIp6(data);
case 'hostname':
/* dt_type_hostname: every dot-terminated label is 1..63 chars, the
* last one 1..255, all from [A-Za-z0-9_-]. */
return match(data, /^([A-Za-z0-9_-]{1,63}\.)*[A-Za-z0-9_-]{1,255}$/) !== null;
case 'host':
return validation('ip4addr', data) === true ||
validation('ip6addr', data) === true ||
validation('hostname', data) === true;
default:
return false;
}
};
export function parseURL(url) {
if (type(url) !== 'string')
return null;
const services = {
http: '80',
https: '443'
};
const objurl = {};
objurl.href = url;
url = replace(url, /#(.+)$/, (_, val) => {
objurl.hash = val;
return '';
});
url = replace(url, /^(\w[A-Za-z0-9\+\-\.]+):/, (_, val) => {
objurl.protocol = val;
return '';
});
url = replace(url, /\?(.+)/, (_, val) => {
objurl.search = val;
objurl.searchParams = urldecode_params(val);
return '';
});
url = replace(url, /^\/\/([^\/]+)/, (_, val) => {
val = replace(val, /^([^@]+)@/, (_, val) => {
objurl.userinfo = val;
return '';
});
val = replace(val, /:(\d+)$/, (_, val) => {
objurl.port = val;
return '';
});
if (validation('ip4addr', val) ||
validation('ip6addr', replace(val, /\[|\]/g, '')) ||
validation('hostname', val))
objurl.hostname = val;
return '';
});
objurl.pathname = url || '/';
if (!objurl.protocol || !objurl.hostname)
return null;
if (objurl.userinfo) {
objurl.userinfo = replace(objurl.userinfo, /:(.+)$/, (_, val) => {
objurl.password = val;
return '';
});
if (match(objurl.userinfo, /^[A-Za-z0-9\+\-\_\.]+$/)) {
objurl.username = objurl.userinfo;
delete objurl.userinfo;
} else {
delete objurl.userinfo;
delete objurl.password;
}
};
if (!objurl.port)
objurl.port = services[objurl.protocol];
objurl.host = objurl.hostname + (objurl.port ? `:${objurl.port}` : '');
objurl.origin = `${objurl.protocol}://${objurl.host}`;
return objurl;
};
@@ -0,0 +1,75 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* homeproxy module shim for subscription/fetcher unit tests.
*
* The real homeproxy.uc wraps wGETVerbose around `executeCommand(
* /usr/bin/wget ...)`, which means a unit test that imports it
* would shell out to the network. Tests stage this file as
* `homeproxy` in their -L directory so the fetcher picks up the
* shim's wGETVerbose instead.
*
* The shim reads HP_TEST_WGET_CONTENT / HP_TEST_WGET_ERROR globals
* (set by the test before each fetch call) and returns them. The
* two other names the fetcher imports (isEmpty, redactUrl) are
* short enough to copy verbatim from homeproxy.uc so the shim is
* fully self-contained - the real homeproxy.uc also imports
* validate_data / popen / a long list of luci.* modules that the
* testbed does not have on PATH, and pulling the full file would
* pull those imports along.
*
* The `global` prefix is the only cross-module shared state ucode
* exposes without a dependency-injection hook, and the test is the
* only writer.
*/
export function wGETVerbose(url, _ua) {
const content = global.HP_TEST_WGET_CONTENT;
const error = global.HP_TEST_WGET_ERROR;
return { content: content, error: error };
};
export function isEmpty(res) {
return !res || res === 'nil' || (type(res) in ['array', 'object'] && length(res) === 0);
};
/* Verbatim copy of homeproxyuc:redactUrl - keeps the security
* behaviour covered in test_subscription_fetcher.uc. */
export function redactUrl(url) {
if (!url || type(url) !== 'string')
return '';
let u = url;
/* userinfo: scheme://user:pass@host -> scheme://***@host */
const at = index(u, '@');
const scheme = match(u, /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//);
if (scheme && at !== -1 && at > length(scheme[0]))
u = substr(u, 0, length(scheme[0])) + '***' + substr(u, at);
/* path and query: keep the authority, mark the rest. Splitting them
* rather than masking from the first '/' keeps the structural markers
* (`/***?***`) so a reader can still tell a path from a query, and it is
* why the authority end is the *earlier* of the two separators. */
const scheme_end = scheme ? length(scheme[0]) : 0;
const rest = substr(u, scheme_end);
const path_at = index(rest, '/');
const query_at = index(rest, '?');
const has_path = path_at !== -1 && (query_at === -1 || path_at < query_at);
let authority_end = length(rest);
if (has_path)
authority_end = path_at;
else if (query_at !== -1)
authority_end = query_at;
let tail = '';
if (has_path)
tail += '/***';
if (query_at !== -1)
tail += '?***';
u = substr(u, 0, scheme_end + authority_end) + tail;
return u;
};
@@ -0,0 +1,50 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Test double for root/etc/homeproxy/scripts/homeproxy.uc, scoped to
* what firewall_pre.uc imports: isEmpty, RUN_DIR, validation.
*
* RUN_DIR is read from the HP_FW_RUN_DIR global so the test can
* point the script at a scratch dir; firewall_pre.uc writes
* fw4_forward.nft / fw4_input.nft into RUN_DIR, and the test must
* not clobber /var/run/homeproxy on a host that happens to have it.
*
* validation() is the same pure-ucode stand-in the parser tests use
* (the production one shells out to /sbin/validate_data, which does
* not exist off-target). Keep it in sync with mocks/homeproxy.uc.
*/
export function isEmpty(res) {
return !res || res === 'nil' || (type(res) in ['array', 'object'] && length(res) === 0);
};
export const RUN_DIR = global.HP_FW_RUN_DIR;
/* Pure-ucode stand-in for /sbin/validate_data (port / ip4 / ip6 / hostname). */
export function validation(datatype, data) {
if (!datatype || !data)
return null;
switch (datatype) {
case 'port':
return match(data, /^\d+$/) != null && int(data) >= 0 && int(data) <= 65535;
case 'ip4addr':
if (match(data, /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/) == null)
return false;
for (let octet in split(data, '.'))
if (int(octet) > 255)
return false;
return true;
case 'ip6addr':
return match(data, /^[0-9a-fA-F:]+$/) != null && match(data, /::.*::/) == null;
case 'hostname':
return match(data, /^[A-Za-z0-9_][A-Za-z0-9_%-.]*[A-Za-z0-9]$/) != null ||
match(data, /^[A-Za-z0-9_]$/) != null;
case 'host':
return validation('ip4addr', data) === true ||
validation('ip6addr', data) === true ||
validation('hostname', data) === true;
default:
return null;
}
};
+502
View File
@@ -0,0 +1,502 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Run the ucode-level tests: the parse_uri unit tests, the fw4 inventory check
# and the generator regression fixtures. Requires ucode; sing-box is needed for
# the generator cases (they validate the emitted config with `sing-box check`).
#
# Usage: sh tests/ucode/run.sh <repo-root> [work-dir]
ROOT="${1:-.}"
WORK="${2:-/tmp/hp-ucode-tests}"
ROOT="$(cd "$ROOT" && pwd)"
FAILED=0
if ! command -v ucode > "/dev/null" 2>&1; then
echo "NOT RUN: ucode is not on PATH."
echo " Build the toolchain first (Linux; the target and CI are both"
echo " Linux, and sing-box only accepts routing_mark there), or use"
echo " tests/run.sh, which copies the checkout to a device when the"
echo " host has no ucode:"
echo " sh tests/toolchain/build-ucode-linux.sh"
exit 2
fi
# The target is OpenWrt (Linux), and this layer validates the generated
# configuration with sing-box. routing_mark - the SO_MARK that keeps
# sing-box's own proxy connection out of the nft redirect chain - is a
# Linux-only field: `sing-box check` rejects it everywhere else, so a
# non-Linux host cannot tell a correct configuration from one that lost the
# field. The suite used to paper over that by rewriting the field to null
# before testing, which is exactly how the regression stayed invisible; saying
# "cannot run here" is the honest version of the same fact.
if [ "$(uname -s)" != "Linux" ] && [ "${HP_ALLOW_NONLINUX_UCODE:-0}" != "1" ]; then
echo "NOT RUN: the ucode layer requires Linux (this host is $(uname -s))."
echo " Run it in a Linux container or with"
echo " HP_TEST_HOST=root@<test-machine>; tests/README.md has both."
echo " HP_ALLOW_NONLINUX_UCODE=1 forces the run anyway - expect the"
echo " routing_mark assertions to fail, because the local sing-box"
echo " rejects that field."
exit 2
fi
# homeproxy.uc validates data through /sbin/validate_data, which only exists on
# a target. Off-target the generator cases would fail on the very first
# hostname check, so point them at the local stand-in (which in turn uses the
# same validation code as the parser unit tests). On a target the production
# binary is used unchanged.
if [ ! -x /sbin/validate_data ] && [ -x "$ROOT/tests/toolchain/validate-data.sh" ]; then
HP_VALIDATE_DATA="$ROOT/tests/toolchain/validate-data.sh"
export HP_VALIDATE_DATA
fi
# Nothing in this suite is target-only any more. Every source compiles with
# the toolchain from tests/toolchain/build-ucode-*.sh (which ships utpl, the
# luci.* ucode modules and a matching sing-box), and the files that import
# through an absolute /etc/homeproxy/... path are rewritten to the checkout
# below. A missing piece of the toolchain now FAILS instead of being skipped:
# the old target-only skips are exactly what let a non-compiling
# update_subscriptions.uc, and a destructuring statement, reach the device.
SCRIPTS_DIR="$ROOT/root/etc/homeproxy/scripts"
mkdir -p "$WORK/syntax"
echo "== ucode grammar canary =="
if ! sh "$ROOT/tests/ucode/test_ucode_grammar.sh"; then
echo "FAIL: ucode grammar does not match the target dialect"
FAILED=1
fi
echo "== ucode syntax check =="
for file in "$SCRIPTS_DIR"/*.uc \
"$SCRIPTS_DIR"/subscription/*.uc \
"$SCRIPTS_DIR"/config/*.uc \
"$SCRIPTS_DIR"/generator/*.uc \
"$ROOT"/root/usr/share/rpcd/ucode/*; do
[ -f "$file" ] || continue
# Modules (with export statements) cannot be compiled as a program; they
# are loaded through `import` below instead.
case "$file" in
*homeproxy.uc|*firewall_utils.uc|*/parser/*.uc|*/subscription/*.uc|*/config/*.uc|*/generator/*.uc) continue ;;
esac
# luci.homeproxy imports homeproxy.uc through an absolute /etc/... path
# that does not exist off-target. Compile a rewritten copy instead of
# skipping the file.
target="$file"
case "$file" in
*/usr/share/rpcd/ucode/*)
target="$WORK/syntax/$(basename "$file")"
sed "s#'/etc/homeproxy/scripts/#'$SCRIPTS_DIR/#g" "$file" > "$target" ;;
esac
if ! ucode -L "$SCRIPTS_DIR" -c -o "/dev/null" "$target" 2> "$WORK/syntax.err"; then
echo "FAIL: ${file#"$ROOT"/}"
head -8 "$WORK/syntax.err"
FAILED=1
fi
done
# Modules are syntax-checked by loading them through `import`. The
# -e expression is a no-op program; the import itself is what we
# want to validate. Only top-level module names are searched in the
# -L tree, so anything in a subdirectory is loaded by absolute path
# in the second loop below.
for module in homeproxy firewall_utils; do
if ! ucode -L "$ROOT/root/etc/homeproxy/scripts" -e "import * as m from \"$module\";" 2> "$WORK/syntax.err"; then
echo "FAIL: module $module"
head -8 "$WORK/syntax.err"
FAILED=1
fi
done
# `parser/*.uc` (added in PR-02), `config/*.uc` and `generator/*.uc` are
# imported through relative paths, so a syntax error there only surfaces
# when a generator or a test runs; import them explicitly too so the
# failure names the module. `generator/client.uc` and `generator/server.uc`
# are the public entry points and pull the rest of that subtree in
# transitively, which is the cheapest way to syntax-check it.
for module in parser/uri parser/protocols parser/validator parser/normalize parser/flatten parser/mapping \
subscription/filter subscription/decoder subscription/fetcher subscription/repository \
config/loader config/model config/adapter \
generator/client generator/server; do
if ! ucode -L "$SCRIPTS_DIR" -e "import * as m from \"$SCRIPTS_DIR/$module.uc\";" 2> "$WORK/syntax.err"; then
echo "FAIL: module $module"
head -8 "$WORK/syntax.err"
FAILED=1
fi
done
[ "$FAILED" -eq 0 ] && echo "PASS: all ucode sources compile"
echo "== fw4 chain/set inventory =="
sh "$ROOT/tests/ucode/test_fw4_names.sh" "$ROOT" || FAILED=1
echo "== shell syntax check =="
# init.d/homeproxy and the runtime helpers are shell, so no ucode check covers
# them. They decide whether a bad configuration can leave the router without
# a service, so a syntax error there is as bad as one in the generators.
for file in "$ROOT"/root/etc/init.d/* \
"$ROOT"/root/etc/homeproxy/scripts/*.sh \
"$ROOT"/root/etc/homeproxy/scripts/runtime/*.sh; do
[ -f "$file" ] || continue
if ! sh -n "$file" 2> "$WORK/syntax.err"; then
echo "FAIL: ${file#"$ROOT"/}"
head -5 "$WORK/syntax.err"
FAILED=1
fi
done
echo "== runtime configuration transaction =="
sh "$ROOT/tests/runtime/test_config_transaction.sh" "$ROOT" || FAILED=1
echo "== dnsmasq snippet writer (review L6) =="
# Counts dnsmasq restarts through a stub: an unchanged snippet set must not
# restart dnsmasq, a changed one must. Pure shell, no ucode needed.
sh "$ROOT/tests/runtime/test_dns_snippets.sh" "$ROOT" "$WORK/dns-snippets" || FAILED=1
echo "== runtime extraction equivalence (PR-05) =="
# Drives the init script through a stubbed environment and compares the
# resulting command/file trace against the pre-PR-05 trace. It needs no
# ucode, but it belongs in this suite so CI runs it on every PR.
# Pass this run's work dir: the test defaults to its own mktemp -d, but keeping
# it under one root makes a failed run easier to inspect and avoids a second
# temporary tree.
sh "$ROOT/tests/runtime/test_runtime_extraction.sh" "$ROOT" "$WORK/runtime-extraction" || FAILED=1
echo "== firewall template rendering =="
# utpl ships with ucode (it is a symlink to the same binary), so this check
# runs off-target too. A toolchain without it is incomplete, not a reason to
# skip the only test that guards the fw4 statement layout.
if ! command -v utpl > "/dev/null" 2>&1; then
echo "FAIL: utpl is missing; build the toolchain (tests/toolchain/build-ucode-*.sh)"
FAILED=1
else
sh "$ROOT/tests/ucode/test_firewall_template.sh" "$ROOT" || FAILED=1
fi
echo "== firewall_pre generator behaviour =="
# Covers the generator that decides *which* nft statements exist, not just
# whether the template renders. Its two validation guards are the reason a
# malformed server section cannot take the whole fw4 ruleset down with it,
# so they get asserted directly rather than only through a golden file.
sh "$ROOT/tests/ucode/test_firewall_pre.sh" "$ROOT" "$WORK/firewall_pre" || FAILED=1
echo "== firewall field validators =="
# The pure-function helpers firewall_post.ut routes every UCI field through
# (review H1). Pure functions, no fw4 module needed - so they run on a
# development host while the renderer above stays NOT RUN, and the renderer
# cannot regress silently because the validators are independently pinned
# here. Guard 11 in tests/arch-guard.sh covers the template still calls
# them.
sh "$ROOT/tests/ucode/test_firewall_validators.sh" "$ROOT" "$WORK/firewall_validators" || FAILED=1
echo "== parse_uri unit tests =="
# PR-02 moved the share-link parsers from parse_uri.uc to
# scripts/parser/{uri,protocols,validator,normalize,mapping}.uc.
# Stage the whole parser/ tree so test_parse_uri.uc can `import` from
# it the same way the production orchestrator does.
rm -rf "$WORK/parse_uri"
mkdir -p "$WORK/parse_uri/parser"
cp "$ROOT/root/etc/homeproxy/scripts/parser/"*.uc "$WORK/parse_uri/parser/"
cp "$ROOT/tests/ucode/mocks/homeproxy.uc" "$WORK/parse_uri/"
cp "$ROOT/tests/ucode/test_parse_uri.uc" "$WORK/parse_uri/"
if ( cd "$WORK/parse_uri" && ucode -L "$WORK/parse_uri" test_parse_uri.uc ); then
echo "PASS: parse_uri unit tests"
else
echo "FAIL: parse_uri unit tests"
FAILED=1
fi
echo "== parser/normalize unit tests =="
# PR-02: parser/normalize.uc turns the parser's flat UCI-key output
# into the canonical Node shape the Adapter reads. The staged
# $WORK/parse_uri/ already has parser/ and the homeproxy mock, so we
# just drop the test next to test_parse_uri.uc.
cp "$ROOT/tests/ucode/test_parser_normalize.uc" "$WORK/parse_uri/"
if ( cd "$WORK/parse_uri" && ucode -L "$WORK/parse_uri" test_parser_normalize.uc ); then
echo "PASS: parser/normalize unit tests"
else
echo "FAIL: parser/normalize unit tests"
FAILED=1
fi
echo "== parser/flatten round-trip tests =="
# PR-03: parser/flatten.uc is the canonical -> flat inverse. The
# round-trip invariant is that flatten(normalize(parse_uri(uri)))
# produces the same flat UCI dict the parser would have written
# directly, so Repository.apply_nodes can write it verbatim and
# the Loader's next read yields the same canonical Node.
cp "$ROOT/tests/ucode/test_parser_flatten.uc" "$WORK/parse_uri/"
if ( cd "$WORK/parse_uri" && ucode -L "$WORK/parse_uri" test_parser_flatten.uc ); then
echo "PASS: parser/flatten round-trip tests"
else
echo "FAIL: parser/flatten round-trip tests"
FAILED=1
fi
echo "== subscription filter unit tests =="
# The production modules live at root/etc/homeproxy/scripts/subscription/
# *.uc and are imported by update_subscriptions.uc with a relative path.
# For the unit tests we want the modules on a flat search path so their
# `from 'homeproxy'` import resolves to the mock, so we stage them at
# the work dir top level (drop the subscription/ prefix) and let the
# test files import them as bare names.
rm -rf "$WORK/subscription"
# Mirror the production layout: the module under test lives in subscription/ and
# reaches homeproxy.uc one level up (a relative import - production cannot rely on
# a module search path, see the note above the syntax check). The tests are the
# entry scripts and import the module under test by name, so -L points at
# subscription/.
rm -rf "$WORK/subscription"
mkdir -p "$WORK/subscription"
cp "$ROOT/tests/ucode/mocks/homeproxy.uc" "$WORK/homeproxy.uc"
cp "$ROOT/root/etc/homeproxy/scripts/subscription/filter.uc" "$WORK/subscription/filter.uc"
cp "$ROOT/root/etc/homeproxy/scripts/subscription/decoder.uc" "$WORK/subscription/decoder.uc"
# The filter test also asserts that applying the policy to the canonical Node
# writes the same UCI keys the old flat order did, so it drives the real
# normalize()/flatten() pair. Those live in parser/ and import ./mapping.uc
# relatively, so all three are staged together next to the test.
mkdir -p "$WORK/parser"
cp "$ROOT/root/etc/homeproxy/scripts/parser/normalize.uc" \
"$ROOT/root/etc/homeproxy/scripts/parser/flatten.uc" \
"$ROOT/root/etc/homeproxy/scripts/parser/mapping.uc" "$WORK/parser/"
cp "$ROOT/tests/ucode/test_subscription_filter.uc" "$WORK/"
cp "$ROOT/tests/ucode/test_subscription_decoder.uc" "$WORK/"
if ( cd "$WORK" && ucode -L "$WORK/subscription" test_subscription_filter.uc ); then
echo "PASS: subscription filter unit tests"
else
echo "FAIL: subscription filter unit tests"
FAILED=1
fi
if ( cd "$WORK" && ucode -L "$WORK/subscription" test_subscription_decoder.uc ); then
echo "PASS: subscription decoder unit tests"
else
echo "FAIL: subscription decoder unit tests"
FAILED=1
fi
echo "== subscription repository integration test =="
# The repository writes to a real UCI cursor, so its testbed needs
# the uci + digest shared objects. tests/ucode/run.sh exports
# UCODE_MODULES_DIR for that; the test runner script itself stages
# a sandboxed config dir with seed sections.
sh "$ROOT/tests/ucode/test_subscription_repository.sh" "$ROOT" "$WORK/subscription_repo" || FAILED=1
echo "== subscription updater actually runs =="
# Drives update_subscriptions.uc end to end, which nothing did before: the
# script read subscription_urls off the wrong object, so `call(main)` never
# ran and both the LuCI button and the cron entry were silent no-ops. The
# URL points at a closed port, so main() fails before its reload call.
sh "$ROOT/tests/ucode/test_subscription_updater_runs.sh" "$ROOT" "$WORK/updater_runs" || FAILED=1
echo "== rpcd method behaviour =="
# The rpcd module used to be syntax-checked and never executed, which is how
# certificate_write('client_ech_conf') stayed broken from the initial commit:
# the frontend called it, the ACL granted it, and the backend had no case.
sh "$ROOT/tests/ucode/test_rpc_methods.sh" "$ROOT" "$WORK/rpc_methods" || FAILED=1
echo "== the scripts run the way production runs them =="
# init.d/homeproxy calls `ucode -S "$HP_DIR/scripts/generate_client.uc"` and the
# cron entry runs update_subscriptions.uc through its shebang - neither passes a
# module search path. Everything else in this suite passes -L (the harness has
# to, to reach the staged tree), which is exactly why a bare
# `import ... from 'homeproxy'` inside a subdirectory compiled here for months
# and failed on the first real install: ucode resolves a bare specifier relative
# to the IMPORTING module's directory, so config/model.uc looked in config/.
#
# This runs the real script with no -L at all. Only the runtime paths are
# redirected, the same way the generator cases do it.
PROD="$WORK/prod-invocation"
rm -rf "$PROD"
mkdir -p "$PROD/scripts" "$PROD/cfg" "$PROD/run" "$PROD/resources"
cp -R "$ROOT/root/etc/homeproxy/scripts/." "$PROD/scripts/"
sed -e "s#^export const RUN_DIR = '/var/run/homeproxy';#export const RUN_DIR = '$PROD/run';#" \
-e "s#^export const HP_DIR = '/etc/homeproxy';#export const HP_DIR = '$PROD';#" \
-e "s#^export const UCICONFIG_DIR = '/etc/config';#export const UCICONFIG_DIR = '$PROD/cfg';#" \
"$PROD/scripts/homeproxy.uc" > "$PROD/scripts/homeproxy.uc.new"
mv -f "$PROD/scripts/homeproxy.uc.new" "$PROD/scripts/homeproxy.uc"
cat > "$PROD/cfg/homeproxy" <<'PRODCFG'
config homeproxy 'config'
option routing_mode 'bypass_mainland_china'
option proxy_mode 'tun'
option main_node 'nil'
option ipv6_support '0'
PRODCFG
# The assertion is about module resolution specifically: whether the generated
# config then satisfies `sing-box check` is what the generator cases cover, and
# this fixture is deliberately too small for that.
( cd "$PROD" && ucode -S scripts/generate_client.uc ) > "$PROD/out.txt" 2>&1
if grep -q "Unable to resolve path for module" "$PROD/out.txt"; then
echo "FAIL: generate_client.uc cannot resolve its modules without -L:"
grep -m2 "Unable to resolve path for module" "$PROD/out.txt"
FAILED=1
else
echo "PASS: generate_client.uc resolves its modules with no search path"
fi
# The syntax check above imports every module with -L, so it cannot see this;
# and neither can the updater test, which also passes -L.
( cd "$PROD" && ucode -S scripts/update_subscriptions.uc ) > "$PROD/out2.txt" 2>&1
if grep -q "Unable to resolve path for module" "$PROD/out2.txt"; then
echo "FAIL: update_subscriptions.uc cannot resolve its modules without -L:"
grep -m2 "Unable to resolve path for module" "$PROD/out2.txt"
FAILED=1
else
echo "PASS: update_subscriptions.uc resolves its modules with no search path"
fi
echo "== resource blob digest helper (B2) =="
# resource_blob_sha.uc is what the resource updater verifies downloads with, so
# its digest has to be git's own: sha1("blob <len>\0" + content). The expected
# values in the test are `git hash-object` output, and it re-checks the
# repository's real resource files against git when the host has it.
sh "$ROOT/tests/ucode/test_resource_blob_sha.sh" "$ROOT" "$WORK/resource_blob_sha" || FAILED=1
echo "== test doubles still match production =="
# The mocks copy isEmpty/decodeBase64Str/parseURL/redactUrl from production and
# their headers say to keep them in sync; nothing enforced it, and the fetcher's
# redaction security assertion is made against the copy. Compared on a shared
# corpus, so the mock may be reformatted but not changed in behaviour.
sh "$ROOT/tests/ucode/test_mock_sync.sh" "$ROOT" "$WORK/mock_sync" || FAILED=1
echo "== the factory configuration loads =="
# root/etc/config/homeproxy ships to every new install and was never read by
# any test; json-assets.js checks its grammar, this drives the real Loader.
sh "$ROOT/tests/ucode/test_factory_config.sh" "$ROOT" "$WORK/factory_config" || FAILED=1
echo "== homeproxy helper tests =="
rm -rf "$WORK/homeproxy"
mkdir -p "$WORK/homeproxy"
cp "$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" "$WORK/homeproxy/"
cp "$ROOT/tests/ucode/test_homeproxy_utils.uc" "$WORK/homeproxy/"
if ( cd "$WORK/homeproxy" && ucode test_homeproxy_utils.uc ); then
echo "PASS: executeCommand() regression tests"
else
echo "FAIL: executeCommand() regression tests"
FAILED=1
fi
echo "== TLS / transport builder tests =="
rm -rf "$WORK/tls_transport"
mkdir -p "$WORK/tls_transport"
cp "$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" "$WORK/tls_transport/"
cp "$ROOT/tests/ucode/test_tls_transport.uc" "$WORK/tls_transport/"
if ( cd "$WORK/tls_transport" && ucode test_tls_transport.uc ); then
echo "PASS: TLS / transport builder tests"
else
echo "FAIL: TLS / transport builder tests"
FAILED=1
fi
echo "== subscription fetcher tests =="
rm -rf "$WORK/fetcher"
mkdir -p "$WORK/fetcher/subscription"
cp "$ROOT/tests/ucode/mocks/homeproxy_fetcher.uc" "$WORK/fetcher/homeproxy.uc"
cp "$ROOT/root/etc/homeproxy/scripts/subscription/fetcher.uc" "$WORK/fetcher/subscription/fetcher.uc"
cp "$ROOT/tests/ucode/test_subscription_fetcher.uc" "$WORK/fetcher/"
if ( cd "$WORK/fetcher" && ucode -L "$WORK/fetcher/subscription" test_subscription_fetcher.uc ); then
echo "PASS: subscription fetcher tests"
else
echo "FAIL: subscription fetcher tests"
FAILED=1
fi
echo "== migrate_config regressions =="
sh "$ROOT/tests/ucode/test_migrate_config.sh" "$ROOT" "$WORK/migrate" || FAILED=1
echo "== executeCommand() failure-path test =="
rm -rf "$WORK/homeproxy_inject"
mkdir -p "$WORK/homeproxy_inject"
sed 's|^\t\texitcode = system(.*);|\t\tdie("injected failure");|' \
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/homeproxy_inject/homeproxy.uc"
# Hard guard: if the anchor stops matching, the sed no-ops and the test
# passes without ever exercising the exceptional path - the same trap the
# other staged rewrites guard against. Refuse to run instead.
if ! grep -q 'die("injected failure")' "$WORK/homeproxy_inject/homeproxy.uc"; then
echo "FAIL: executeCommand() failure path: could not inject the failure"
echo " (the 'exitcode = system(...)' anchor no longer matches)"
exit 1
fi
cp "$ROOT/tests/ucode/test_homeproxy_utils_inject.uc" "$WORK/homeproxy_inject/"
if ( cd "$WORK/homeproxy_inject" && ucode test_homeproxy_utils_inject.uc 2>"/dev/null" ); then
echo "PASS: executeCommand() failure path"
else
echo "FAIL: executeCommand() failure path"
FAILED=1
fi
echo "== generator regression tests =="
sh "$ROOT/tests/ucode/test_generators.sh" "$ROOT" "$WORK/generators" || FAILED=1
echo "== golden protocol snapshot =="
sh "$ROOT/tests/ucode/test_golden_outbounds.sh" "$ROOT" "$WORK/golden" || FAILED=1
echo "== inbound adapter unit tests =="
# PR-04: InboundFactory's protocol-shape decisions (snell / shadowsocks get
# no users[] block, vless / vmess keep flow / alterId per-user, the snell
# listener set, the server-only TLS tail, hysteria v1 vs v2 obfs, and the
# per-protocol credential requirements). The golden snapshot pins the
# emitted JSON for the fixture; this pins the decisions behind it.
rm -rf "$WORK/inbound_adapter"
mkdir -p "$WORK/inbound_adapter/config" "$WORK/inbound_adapter/parser"
# The adapter needs the real homeproxy.uc (buildTLSObject /
# buildTransportObject / strTo* / parse_port), not the test double; the
# golden tests stage it the same way. HP_VALIDATE_DATA lets a development
# host replace /sbin/validate_data, which homeproxy.uc calls.
INBOUND_VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
sed -e "s#/sbin/validate_data#${INBOUND_VALIDATE_DATA}#" \
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/inbound_adapter/homeproxy.uc"
cp "$ROOT/root/etc/homeproxy/scripts/config/loader.uc" "$WORK/inbound_adapter/config/"
cp "$ROOT/root/etc/homeproxy/scripts/config/model.uc" "$WORK/inbound_adapter/config/"
cp "$ROOT/root/etc/homeproxy/scripts/config/adapter.uc" "$WORK/inbound_adapter/config/"
cp "$ROOT/root/etc/homeproxy/scripts/parser/"*.uc "$WORK/inbound_adapter/parser/"
cp "$ROOT/tests/ucode/test_inbound_adapter.uc" "$WORK/inbound_adapter/"
if ( cd "$WORK/inbound_adapter" && ucode -L "$WORK/inbound_adapter" test_inbound_adapter.uc ); then
echo "PASS: inbound adapter unit tests"
else
echo "FAIL: inbound adapter unit tests"
FAILED=1
fi
echo "== generator tag helpers =="
# get_outbound() emits `cfg-<node>-out`; isDirectOutboundTag() is asked about
# that emitted tag, so it has to undo the wrapper before looking the node up.
# It did not, and every routing_node answered "not direct".
rm -rf "$WORK/gen_common"
mkdir -p "$WORK/gen_common/config" "$WORK/gen_common/parser" "$WORK/gen_common/generator"
GEN_VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
sed -e "s#/sbin/validate_data#${GEN_VALIDATE_DATA}#" \
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/gen_common/homeproxy.uc"
cp "$ROOT/root/etc/homeproxy/scripts/config/loader.uc" "$WORK/gen_common/config/"
cp "$ROOT/root/etc/homeproxy/scripts/config/model.uc" "$WORK/gen_common/config/"
cp "$ROOT/root/etc/homeproxy/scripts/config/adapter.uc" "$WORK/gen_common/config/"
cp "$ROOT/root/etc/homeproxy/scripts/parser/"*.uc "$WORK/gen_common/parser/"
cp "$ROOT/root/etc/homeproxy/scripts/generator/"*.uc "$WORK/gen_common/generator/"
cp "$ROOT/tests/ucode/test_generator_common.uc" "$WORK/gen_common/"
if ( cd "$WORK/gen_common" && ucode -L "$WORK/gen_common" test_generator_common.uc ); then
echo "PASS: generator tag helpers"
else
echo "FAIL: generator tag helpers"
FAILED=1
fi
echo "== golden inbound snapshot =="
# PR-04: the server-side counterpart. Before this the server path had no
# snapshot, which is how the fixture's unused `listen_port` option survived
# (the generated config carried no listen_port at all and sing-box accepted
# it, so nothing ever noticed).
sh "$ROOT/tests/ucode/test_golden_inbounds.sh" "$ROOT" "$WORK/golden-inbounds" || FAILED=1
echo "== protocol inventory =="
sh "$ROOT/tests/ucode/test_protocol_inventory.sh" "$ROOT" "$WORK/inventory" || FAILED=1
echo "== domain model skeleton =="
sh "$ROOT/tests/ucode/test_domain_model_skeleton.sh" "$ROOT" "$WORK/domain_model" || FAILED=1
exit $FAILED
@@ -0,0 +1,61 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Stage A1.1 skeleton test: the production Config Loader (root/etc/homeproxy/
# scripts/config/loader.uc) must read the UCI fixture and produce a
# HomeProxyConfig whose general / infra / nodes sub-objects match the
# expected values. The other five sub-objects (dns, routing, endpoints,
# access_control, server) are intentionally empty `{}` until A1.2 lands.
#
# Staging layout:
# $WORK/scripts/ - test_domain_model_skeleton.uc + homeproxy.uc
# $WORK/scripts/config/ - loader.uc + model.uc + adapter.uc
# $WORK/scripts/config/homeproxy - UCI file (from fixture)
#
# The test runs `cd $WORK/scripts && ucode test_domain_model_skeleton.uc`,
# so Loader.load('./config') finds both the config/loader.uc module
# (relative-imported as ./config/loader.uc) and the homeproxy UCI file
# in the same directory.
#
# Usage: sh tests/ucode/test_domain_model_skeleton.sh <repo-root> [work-dir]
ROOT="${1:-.}"
WORK="${2:-/tmp/hp-domain-model-test}"
ROOT="$(cd "$ROOT" && pwd)"
FAILED=0
rm -rf "$WORK"
mkdir -p "$WORK/scripts/config"
# HP_VALIDATE_DATA lets a development host replace /sbin/validate_data (see
# tests/README.md); on a target the production path is kept.
VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
sed -e "s#/sbin/validate_data#${VALIDATE_DATA}#" \
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/scripts/homeproxy.uc"
cp "$ROOT/root/etc/homeproxy/scripts/config/loader.uc" "$WORK/scripts/config/"
cp "$ROOT/root/etc/homeproxy/scripts/config/model.uc" "$WORK/scripts/config/"
cp "$ROOT/root/etc/homeproxy/scripts/config/adapter.uc" "$WORK/scripts/config/"
# PR-02: parse_uri.uc moved into scripts/parser/. Stage the whole
# tree so the Loader's '../parser/mapping.uc' import resolves inside
# the sandbox.
mkdir -p "$WORK/scripts/parser"
cp "$ROOT/root/etc/homeproxy/scripts/parser/"*.uc "$WORK/scripts/parser/"
cp "$ROOT/tests/ucode/test_domain_model_skeleton.uc" "$WORK/scripts/"
# Use the dedicated domain-model fixture (tests/fixtures/generators/domain.uci),
# which carries every UCI section the Loader is supposed to read. The
# generator regression suite uses client.uci and custom.uci; this fixture
# is only consumed by the domain-model test.
cp "$ROOT/tests/fixtures/generators/domain.uci" "$WORK/scripts/config/homeproxy"
if ( cd "$WORK/scripts" && ucode -L "$WORK/scripts" test_domain_model_skeleton.uc ); then
echo "PASS: domain model skeleton"
else
echo "FAIL: domain model skeleton"
FAILED=1
fi
exit $FAILED
@@ -0,0 +1,263 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Stage A1.2 domain-model test: assert that Loader.load(fixture) populates
* every HomeProxyConfig sub-object from the corresponding UCI section(s).
*
* general <- config (single)
* infra <- infra (single)
* nodes <- node (list)
* dns <- dns (single) + dns_server (list) + dns_rule (list)
* routing <- routing (single) + routing_node / routing_rule /
* ruleset (lists)
* access_control <- control (single) + subscription (single)
* server <- server (single) + server (list)
* endpoints <- DERIVED (stays {}; A3 / A4 fill it)
*
* The fixture (tests/fixtures/generators/domain.uci) carries at least one
* row per section so the assertions exercise both single-section reads
* and list reads, plus list-valued options (`wan_proxy_ipv4_ips`,
* `filter_keywords`, `subscription_url`).
*
* Run from tests/ucode/run.sh, which stages this file next to a scratch
* homeproxy.uc and config/loader.uc + config/model.uc.
*/
'use strict';
import { Loader } from './config/loader.uc';
import { Config, Node, ConfigQuery } from './config/model.uc';
let failures = 0;
let checks = 0;
function expect(name, actual, expected) {
checks++;
if (sprintf('%J', actual) !== sprintf('%J', expected)) {
printf('FAIL %s: expected %J, got %J\n', name, expected, actual);
failures++;
}
}
function expect_null(name, value) {
checks++;
if (value !== null && value !== undefined) {
printf('FAIL %s: expected null, got %J\n', name, value);
failures++;
}
}
function by_id(items, id) {
for (let it in items)
if (it.id === id)
return it;
return null;
}
const config = Loader.load('./config');
/* --- general --------------------------------------------------------- */
expect('general.routing_mode', config.general.routing_mode, 'bypass_mainland_china');
expect('general.proxy_mode', config.general.proxy_mode, 'redirect_tproxy');
expect('general.main_node', config.general.main_node, 'urltest');
expect('general.main_udp_node', config.general.main_udp_node, 'nil');
expect('general.ipv6_support', config.general.ipv6_support, '0');
/* --- infra ----------------------------------------------------------- */
expect('infra.dns_port', config.infra.dns_port, '5333');
expect('infra.mixed_port', config.infra.mixed_port, '5330');
expect('infra.self_mark', config.infra.self_mark, '100');
expect('infra.tun_name', config.infra.tun_name, 'singtun0');
expect('infra.tproxy_mark', config.infra.tproxy_mark, '101');
expect('infra.tun_addr4', config.infra.tun_addr4, '172.19.0.1/30');
expect('infra.ntp_server', config.infra.ntp_server, 'time.apple.com');
expect('infra.common_port', config.infra.common_port, '22,53,80,443');
/* --- nodes ----------------------------------------------------------- */
expect('nodes.length', length(config.nodes), 2);
const n_anytls = by_id(config.nodes, 'n_anytls');
const n_snell = by_id(config.nodes, 'n_snell');
expect('n_anytls.type', n_anytls?.type, 'anytls');
expect('n_anytls.credentials.password', n_anytls?.credentials?.password, 'secret');
expect('n_snell.credentials.psk', n_snell?.credentials?.psk, 'psk123456789012');
expect('n_snell.credentials.userkey', n_snell?.credentials?.userkey, 'ukey');
/* --- dns ------------------------------------------------------------- */
expect('dns.settings.default_strategy', config.dns?.settings?.default_strategy, 'prefer_ipv4');
expect('dns.settings.default_server', config.dns?.settings?.default_server, 'default-dns');
expect('dns.settings.optimistic_cache', config.dns?.settings?.optimistic_cache, '0');
expect('dns.settings.dns_timeout', config.dns?.settings?.dns_timeout, '5s');
expect('dns.servers.length', length(config.dns?.servers), 1);
expect('dns.servers[0].type', config.dns?.servers?.[0]?.type, 'udp');
expect('dns.servers[0].server', config.dns?.servers?.[0]?.server, '8.8.8.8');
expect('dns.servers[0].name', config.dns?.servers?.[0]?.name, 'ds_main');
expect('dns.rules.length', length(config.dns?.rules), 1);
expect('dns.rules[0].action', config.dns?.rules?.[0]?.action, 'hijack-dns');
expect('dns.rules[0].name', config.dns?.rules?.[0]?.name, 'dr_hijack');
/* PR-01: dns_rule has `enabled '1'` in the fixture, the loader must
* surface it as a real boolean. */
expect('dns.rules[0].enabled', config.dns?.rules?.[0]?.enabled, true);
/* PR-01: the UCI pseudo-fields (.name / .index / .type) must not
* leak into the loaded shape - the loader drops them via
* normalize_section(). */
for (let s in (config.dns?.servers || [])) {
if ('.name' in s || '.index' in s || '.type' in s) {
printf('FAIL dns.servers normalisation: pseudo-field leaked %J\n', s);
failures++;
}
}
checks++;
for (let r in (config.dns?.rules || [])) {
if ('.name' in r || '.index' in r || '.type' in r) {
printf('FAIL dns.rules normalisation: pseudo-field leaked %J\n', r);
failures++;
}
}
checks++;
/* PR-01: sections with `enabled` must have it coerced to boolean.
* The fixture carries no `enabled` option on dns_server/dns_rule, so
* `enabled` is absent rather than coerced - the check is "no
* string-shaped `enabled` value sneaked through". */
for (let s in (config.dns?.servers || [])) {
if ('enabled' in s && type(s.enabled) !== 'bool') {
printf('FAIL dns.servers.enabled: not coerced to boolean, got %J\n', s.enabled);
failures++;
}
}
checks++;
for (let r in (config.dns?.rules || [])) {
if ('enabled' in r && type(r.enabled) !== 'bool') {
printf('FAIL dns.rules.enabled: not coerced to boolean, got %J\n', r.enabled);
failures++;
}
}
checks++;
/* absent options must NOT appear in settings */
if ('client_subnet' in (config.dns?.settings || {})) {
printf('FAIL dns.settings.client_subnet: should be omitted, got %J\n', config.dns.settings.client_subnet);
failures++;
}
checks++;
/* --- routing --------------------------------------------------------- */
expect('routing.settings.default_outbound', config.routing?.settings?.default_outbound, 'direct-out');
expect('routing.settings.default_outbound_dns', config.routing?.settings?.default_outbound_dns, 'default-dns');
expect('routing.settings.find_neighbor', config.routing?.settings?.find_neighbor, '0');
expect('routing.settings.udp_timeout', config.routing?.settings?.udp_timeout, '5m');
expect('routing.settings.tcpip_stack', config.routing?.settings?.tcpip_stack, 'system');
expect('routing.nodes.length', length(config.routing?.nodes), 1);
expect('routing.nodes[0].label', config.routing?.nodes?.[0]?.label, 'main');
expect('routing.nodes[0].node', config.routing?.nodes?.[0]?.node, 'urltest');
expect('routing.rules.length', length(config.routing?.rules), 1);
expect('routing.rules[0].action', config.routing?.rules?.[0]?.action, 'route');
expect('routing.rules[0].name', config.routing?.rules?.[0]?.name, 'rr_sniff');
expect('routing.rulesets.length', length(config.routing?.rulesets), 1);
expect('routing.rulesets[0].type', config.routing?.rulesets?.[0]?.type, 'remote');
expect('routing.rulesets[0].url', config.routing?.rulesets?.[0]?.url, 'https://example.com/cn.list');
expect('routing.rulesets[0].download_detour', config.routing?.rulesets?.[0]?.download_detour, 'main-out');
expect('routing.rulesets[0].name', config.routing?.rulesets?.[0]?.name, 'rs_cn');
/* PR-01: same normalisation guarantees for the routing lists. */
for (let n in (config.routing?.nodes || [])) {
if ('.name' in n || '.index' in n || '.type' in n) {
printf('FAIL routing.nodes normalisation: pseudo-field leaked %J\n', n);
failures++;
}
}
checks++;
for (let r in (config.routing?.rules || [])) {
if ('.name' in r || '.index' in r || '.type' in r) {
printf('FAIL routing.rules normalisation: pseudo-field leaked %J\n', r);
failures++;
}
}
checks++;
for (let rs in (config.routing?.rulesets || [])) {
if ('.name' in rs || '.index' in rs || '.type' in rs) {
printf('FAIL routing.rulesets normalisation: pseudo-field leaked %J\n', rs);
failures++;
}
}
checks++;
expect('routing.nodes[0].name', config.routing?.nodes?.[0]?.name, 'rn_main');
/* --- access_control -------------------------------------------------- */
expect('access_control.control.lan_proxy_mode', config.access_control?.control?.lan_proxy_mode, 'disabled');
expect_null('access_control.control.bind_interface', config.access_control?.control?.bind_interface);
expect('access_control.wan_proxy_ipv4_ips', config.access_control?.wan_proxy_ipv4_ips, ['91.108.4.0/22']);
expect('access_control.wan_proxy_ipv6_ips', config.access_control?.wan_proxy_ipv6_ips, ['2001:67c:4e8::/48']);
expect('access_control.subscription.auto_update', config.access_control?.subscription?.auto_update, '0');
expect('access_control.subscription.packet_encoding', config.access_control?.subscription?.packet_encoding, 'xudp');
expect('access_control.subscription.filter_nodes', config.access_control?.subscription?.filter_nodes, 'blacklist');
expect('access_control.subscription_urls', config.access_control?.subscription_urls, ['https://example.com/sub']);
expect('access_control.filter_keywords', config.access_control?.filter_keywords,
['重置|到期', 'Expiration']);
/* --- server ---------------------------------------------------------- */
expect('server.settings.enabled', config.server?.settings?.enabled, '1');
expect('server.settings.log_level', config.server?.settings?.log_level, 'warn');
expect('server.inbounds.length', length(config.server?.inbounds), 1);
expect('server.inbounds[0].type', config.server?.inbounds?.[0]?.type, 'vless');
expect('server.inbounds[0].port', config.server?.inbounds?.[0]?.port, '443');
/* PR-04: server inbounds are Inbound domain objects now, so `id` is the
* UCI section name and `name` is the user-facing label (falling back to
* the section name). Before PR-04 they were bare normalised sections and
* `name` held the section name. */
expect('server.inbounds[0].id', config.server?.inbounds?.[0]?.id, 's_vless');
expect('server.inbounds[0].name', config.server?.inbounds?.[0]?.name, 'server-vless');
expect('server.inbounds[0].enabled', config.server?.inbounds?.[0]?.enabled, true);
/* PR-01: server inbounds must be normalised too - no UCI pseudo-field
* may leak into the Inbound shape. */
for (let ib in (config.server?.inbounds || [])) {
if ('.name' in ib || '.index' in ib || '.type' in ib) {
printf('FAIL server.inbounds normalisation: pseudo-field leaked %J\n', ib);
failures++;
}
}
checks++;
/* PR-04: the Inbound sub-objects must be present so the Adapter can
* shape sing-box JSON without falling back to flat UCI reads. */
expect('server.inbounds[0].credentials.uuid', config.server?.inbounds?.[0]?.credentials?.uuid,
'3af88561-9c69-4b19-8f7e-f08d580bc339');
expect('server.inbounds[0].tls.enabled', config.server?.inbounds?.[0]?.tls?.enabled, '1');
expect('server.inbounds[0].tls.server_name', config.server?.inbounds?.[0]?.tls?.server_name,
'server.example.com');
expect('server.inbounds[0].protocol_options.flow', config.server?.inbounds?.[0]?.protocol_options?.flow,
null);
expect('server.inbounds[0].common keys are the listener set',
sort(keys(config.server?.inbounds?.[0]?.common || {})),
sort(['bind_interface', 'reuse_addr', 'tcp_fast_open', 'tcp_multi_path',
'udp_fragment', 'udp_timeout', 'network']));
/* --- PR-01 removed the Config.endpoints placeholder and the dead
* ConfigQuery.endpoints / node_ids / main_node_id helpers; the
* remaining helpers (node_by_id, find_by_name, main_udp_node_id)
* are still wired up. */
expect('main_udp_node_id', ConfigQuery.main_udp_node_id(config), 'nil');
expect('node_by_id(n_anytls).type', ConfigQuery.node_by_id(config, 'n_anytls')?.type, 'anytls');
expect('node_by_id(missing)', ConfigQuery.node_by_id(config, 'missing'), null);
/* find_by_name is exercised against the normalised routing_node list:
* `name` is the UCI section name and the lookup must find it. */
{
let rn = null;
for (let n in (config.routing?.nodes || []))
if (n.name === 'rn_main') rn = n;
expect('find_by_name(routing.nodes, rn_main).node',
rn?.node, 'urltest');
}
printf('%d checks, %d failures\n', checks, failures);
exit(failures === 0 ? 0 : 1);
+93
View File
@@ -0,0 +1,93 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# The factory configuration must be readable by the Loader.
#
# root/etc/config/homeproxy ships as the package conffile and is what every
# fresh install starts from, but nothing ever read it: the form tests render
# against their own fixtures, the generator tests stage a fixture, and the two
# factory-ish UCI files were only ever checked by eye. A typo in a section name
# or an option the Loader looks for under a different name would ship to every
# new install and only show up as a form full of defaults.
#
# tests/json-assets.js validates the file's grammar; this drives it through the
# real Loader, which is what actually consumes it.
#
# Usage: sh tests/ucode/test_factory_config.sh <repo-root> [workdir]
set -u
ROOT="$(cd "${1:-$(dirname "$0")/../..}" && pwd)"
WORK="${2:-/tmp/hp-factory-config}"
if ! command -v ucode > "/dev/null" 2>&1; then
echo "NOT RUN: ucode is not on PATH."
exit 2
fi
rm -rf "$WORK"
mkdir -p "$WORK/scripts" "$WORK/cfg"
cp -R "$ROOT/root/etc/homeproxy/scripts/." "$WORK/scripts/"
# The factory config is the UCI file itself; the Loader reads <dir>/homeproxy.
cp "$ROOT/root/etc/config/homeproxy" "$WORK/cfg/homeproxy"
FAILED=0
if ucode -L "$WORK/scripts" -L "$WORK" -e '
import { Loader } from "'"$WORK"'/scripts/config/loader.uc";
const dm = Loader.load("'"$WORK"'/cfg");
const g = dm.general || {};
let checks = 0, failures = 0;
function expect(name, got, want) {
checks++;
if (sprintf("%J", got) === sprintf("%J", want)) return;
failures++;
printf("FAIL %s: expected %J, got %J\n", name, want, got);
}
/* Values that are actually written in root/etc/config/homeproxy. If one of
* them changes, this test changes with it - the point is that the Loader
* reads the shipped file at all, not that these particular defaults are
* permanent. */
expect("main.routing_mode", g.routing_mode, "bypass_mainland_china");
expect("main.proxy_mode", g.proxy_mode, "redirect_tproxy");
expect("main.main_node", g.main_node, "nil");
expect("main.main_udp_node", g.main_udp_node, "same");
expect("main.log_level", g.log_level, "warn");
/* Values that come from OTHER sections of the same file. Asserting only
* that `dm.routing` exists is vacuous - the Loader always builds the
* object, from defaults when the section is missing - so a renamed section
* slipped straight through the first version of this test. Reading an
* actual value through each section is what makes the rename visible. */
expect("routing.settings.default_outbound",
dm.routing.settings.default_outbound, "direct-out");
expect("routing.settings.default_outbound_dns",
dm.routing.settings.default_outbound_dns, "default-dns");
expect("routing.settings.find_neighbor",
dm.routing.settings.find_neighbor, "0");
expect("dns.settings.default_strategy",
dm.dns.settings.default_strategy, "prefer_ipv4");
expect("dns.settings.default_server",
dm.dns.settings.default_server, "default-dns");
expect("access_control.control.lan_proxy_mode",
dm.access_control.control.lan_proxy_mode, "disabled");
expect("access_control.wan_proxy_ipv4_ips is non-empty",
length(dm.access_control.wan_proxy_ipv4_ips) > 0, true);
/* The shipped default must not already point at a node. */
expect("main.main_node is nil", dm.general.main_node, "nil");
printf("factory config: %d checks, %d failures\n", checks, failures);
exit(failures ? 1 : 0);
'; then
echo "PASS: the factory configuration loads through the Loader"
else
echo "FAIL: the factory configuration does not load through the Loader"
FAILED=1
fi
rm -rf "$WORK"
exit $FAILED
@@ -0,0 +1,313 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Behaviour tests for firewall_pre.uc, the fw4 pre-rules generator.
#
# firewall_post.ut (the template firewall_pre.uc feeds) already has a
# rendering test, but the *generator* that decides which nft
# statements exist at all had none. Its two validation guards exist
# precisely because a malformed server section would otherwise be
# interpolated into a raw `meta l4proto ... th dport ...` statement:
#
# - an invalid port emits `th dport <garbage>` (nft rejects the
# whole ruleset, so the firewall fails to load and the router
# loses its ruleset, not just the homeproxy rules)
# - an invalid network is interpolated verbatim into the l4proto
# position
#
# Each scenario runs in a fresh process: firewall_pre.uc has no
# exports and its body executes at import time, so one process can
# only exercise one configuration.
#
# Cursor + RUN_DIR redirection: firewall_pre.uc reads UCI and writes
# its fragments relative to RUN_DIR. The staged *copy* is rewritten
# so the cursor takes the sandbox dir and RUN_DIR comes from the
# mock. Production source keeps the plain `cursor()` call - the
# project removed production test seams with HP_TEST_HOOK and this
# keeps it that way.
#
# Usage: sh tests/ucode/test_firewall_pre.sh <repo-root> [work-dir]
ROOT="${1:-.}"
WORK="${2:-/tmp/hp-firewall-pre-test}"
ROOT="$(cd "$ROOT" && pwd)"
FAILED=0
STAGE="$WORK/scripts"
rm -rf "$WORK"
mkdir -p "$STAGE"
cp "$ROOT/tests/ucode/mocks/homeproxy_firewall.uc" "$STAGE/homeproxy.uc"
cp "$ROOT/tests/ucode/test_firewall_pre.uc" "$STAGE/"
# Stage a cursor-redirected copy of firewall_pre.uc, with the shebang
# dropped.
#
# The cursor redirect is the usual no-test-seam-in-production staging: the
# test layer rewrites the one line that would otherwise open the live
# /etc/config.
#
# The shebang is dropped because the target ucode rejects a `#!` line when
# the file is loaded as a *module* ("Unexpected character"), and this test
# imports firewall_pre.uc. Production runs it as a program
# (`ucode "$HP_DIR/scripts/firewall_pre.uc"`), where the shebang is both
# allowed and meaningful, so this is a staging concern and not something to
# change in the source.
sed -e '1{/^#!\/usr\/bin\/ucode$/d;}' \
-e 's|^const uci = cursor();$|const uci = cursor(ARGV[0]);|' \
"$ROOT/root/etc/homeproxy/scripts/firewall_pre.uc" > "$STAGE/firewall_pre.uc"
# Hard guard: if an anchor stops matching, the sed no-ops. Losing the
# cursor redirect means the test would read the *real* /etc/config and write
# real nft fragments into /var/run; losing the shebang strip means the
# module cannot be imported at all. Refuse to run rather than silently
# testing the live configuration.
if ! grep -q 'cursor(ARGV\[0\])' "$STAGE/firewall_pre.uc"; then
echo "FAIL: firewall_pre: could not redirect the UCI cursor"
echo " (the 'const uci = cursor();' anchor no longer matches)"
exit 1
fi
if head -1 "$STAGE/firewall_pre.uc" | grep -q '^#!'; then
echo "FAIL: firewall_pre: the shebang was not stripped"
echo " (the '#!/usr/bin/ucode' anchor no longer matches)"
exit 1
fi
# seed <scenario> <extra UCI lines...>
seed() {
scenario="$1"
shift
mkdir -p "$WORK/$scenario/sandbox" "$WORK/$scenario/run"
{
printf "config homeproxy 'config'\n"
printf "\toption routing_mode '%s'\n" "$ROUTING_MODE"
printf "\toption proxy_mode '%s'\n" "$PROXY_MODE"
printf "\toption main_node '%s'\n" "$MAIN_NODE"
printf "config homeproxy 'infra'\n"
printf "\toption tun_name '%s'\n" "${TUN_NAME:-singtun0}"
printf "config homeproxy 'server'\n"
printf "\toption enabled '%s'\n" "$SERVER_ENABLED"
for line in "$@"; do
printf '%s\n' "$line"
done
} > "$WORK/$scenario/sandbox/homeproxy"
}
run_scenario() {
scenario="$1"
if ( cd "$STAGE" && ucode -L "$STAGE" \
test_firewall_pre.uc \
"$WORK/$scenario/sandbox" \
"$WORK/$scenario/run" \
"$scenario" > "$WORK/$scenario/out.txt" 2>&1 ); then
cat "$WORK/$scenario/out.txt"
else
echo "FAIL: firewall_pre scenario '$scenario'"
cat "$WORK/$scenario/out.txt"
FAILED=1
fi
}
# --- scenario: tun mode emits the tun accept pair ---------------------
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='tun' MAIN_NODE='urltest' SERVER_ENABLED='0'
seed tun
run_scenario tun
# --- scenario: tun mode with no outbound node emits nothing -----------
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='tun' MAIN_NODE='nil' SERVER_ENABLED='0'
seed no-tun
run_scenario no-tun
# --- scenario: a hostile tun_name is refused, not interpolated --------
# It reaches the nft file fw4 loads as root, and the file's own threat model
# already validates the server port and network for the same reason.
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='tun' MAIN_NODE='urltest' SERVER_ENABLED='0'
TUN_NAME='singtun0 } ; chain evil { type filter hook prerouting priority 0; policy accept; }'
seed bad-tun
run_scenario bad-tun
if ! grep -q 'WARN: ignoring invalid tun_name' "$WORK/bad-tun/out.txt"; then
echo "FAIL: firewall_pre: the invalid tun_name was refused without a warning"
FAILED=1
fi
# Whether a rule was emitted is asserted in the driver (bad-tun expects no
# forward and no input fragment). Grepping out.txt for the payload would be a
# false positive: the warning quotes the rejected value.
unset TUN_NAME
# --- a valid tun_name still reaches the ruleset ------------------------
# The driver asserts the exact rules; this only confirms the guard did not
# disable the feature wholesale.
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='tun' MAIN_NODE='urltest' SERVER_ENABLED='0'
TUN_NAME='singtun9'
seed good-tun
run_scenario good-tun
unset TUN_NAME
# --- scenario: an enabled server with a valid port --------------------
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='redirect_tproxy' MAIN_NODE='urltest' SERVER_ENABLED='1'
seed server "config server 'srv_ok'
option enabled '1'
option firewall '1'
option port '443'"
run_scenario server
# --- scenario: an explicit tcp network narrows l4proto ----------------
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='redirect_tproxy' MAIN_NODE='urltest' SERVER_ENABLED='1'
seed server-network "config server 'srv_tcp'
option enabled '1'
option firewall '1'
option port '8080'
option network 'tcp'"
run_scenario server-network
# --- scenario: invalid port is skipped, not interpolated --------------
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='redirect_tproxy' MAIN_NODE='urltest' SERVER_ENABLED='1'
seed invalid-port "config server 'srv_bad'
option enabled '1'
option firewall '1'
option port 'not-a-port'"
run_scenario invalid-port
if ! grep -q 'WARN: skipping server srv_bad: invalid port' "$WORK/invalid-port/out.txt"; then
echo "FAIL: firewall_pre: the invalid port was skipped without a warning"
FAILED=1
fi
# --- scenario: invalid network is skipped, not interpolated -----------
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='redirect_tproxy' MAIN_NODE='urltest' SERVER_ENABLED='1'
seed invalid-network "config server 'srv_badnet'
option enabled '1'
option firewall '1'
option port '443'
option network 'sctp'"
run_scenario invalid-network
if ! grep -q 'WARN: skipping server srv_badnet: invalid network' "$WORK/invalid-network/out.txt"; then
echo "FAIL: firewall_pre: the invalid network was skipped without a warning"
FAILED=1
fi
# --- scenario: a hostile section name is refused, not interpolated -----
# firewall_pre.uc interpolates the section name into the nft comment, and that
# guard cannot be exercised through a config *file*: libuci accepts only
# [A-Za-z0-9_] in a section name, so a name carrying `"`/`}` - the only kind
# that can close the comment - never survives the parser ("invalid character in
# name field" on load, "Invalid argument" on `uci set`). The guard is the
# second line of defense for any path that hands the script a name libuci never
# validated, so this stage supplies one directly: the copy imports a cursor
# stub instead of libuci and yields the name verbatim.
#
# The stub reads the name from <sandbox>/section-name, which lets the same
# stage cover both halves: a name libuci would accept still gets its rule (so
# the stub really does reach the emit path and the refusal below is the guard,
# not a stub that emits nothing), and a hostile one is skipped.
#
# Grep for the warning, not the payload: the warning quotes the rejected name,
# so a payload grep would match even when the guard worked - the same false
# positive the bad-tun scenario calls out.
MOCK_STAGE="$WORK/mock-stage"
mkdir -p "$MOCK_STAGE"
cp "$ROOT/tests/ucode/mocks/homeproxy_firewall.uc" "$MOCK_STAGE/homeproxy.uc"
cp "$ROOT/tests/ucode/test_firewall_pre.uc" "$MOCK_STAGE/"
# The cursor stub. It replaces libuci for this stage only; print / writefile and
# the rest of the runtime stay real.
cat > "$MOCK_STAGE/test_uci.uc" <<'STUB'
/* Cursor stub for the section-name scenario in tests/ucode/test_firewall_pre.sh.
* It yields a single `server` section whose name comes verbatim from
* <sandbox>/section-name - including names libuci refuses, so the firewall_pre
* guard is the only thing between the name and the nft comment. Everything
* else matches the file-based fixture (config / infra / server globals). */
import { readfile } from 'fs';
export function cursor(dir) {
const name = trim(readfile(dir + '/section-name'));
const globals = {
config: { routing_mode: 'bypass_mainland_china', proxy_mode: 'redirect_tproxy', main_node: 'urltest' },
infra: { tun_name: 'singtun0' },
server: { enabled: '1' }
};
function load() {
return true;
}
function get(cfg, section, option) {
const entry = globals[section];
return entry ? entry[option] : null;
}
function foreach(cfg, type, cb) {
if (type === 'server')
cb({ '.name': name, enabled: '1', firewall: '1', port: '443' });
}
return { load: load, get: get, foreach: foreach };
};
STUB
sed -e '1{/^#!\/usr\/bin\/ucode$/d;}' \
-e "s|^import { cursor } from 'uci';\$|import { cursor } from './test_uci.uc';|" \
-e 's|^const uci = cursor();$|const uci = cursor(ARGV[0]);|' \
"$ROOT/root/etc/homeproxy/scripts/firewall_pre.uc" > "$MOCK_STAGE/firewall_pre.uc"
# Same hard guards as above: a no-op sed must not silently fall back to libuci
# (the hostile name would vanish and the scenario would pass for the wrong
# reason) or to the live /etc/config.
if ! grep -q "from './test_uci.uc'" "$MOCK_STAGE/firewall_pre.uc"; then
echo "FAIL: firewall_pre: could not redirect the cursor to the test stub"
echo " (the \"import { cursor } from 'uci';\" anchor no longer matches)"
exit 1
fi
if ! grep -q 'cursor(ARGV\[0\])' "$MOCK_STAGE/firewall_pre.uc"; then
echo "FAIL: firewall_pre: could not redirect the UCI cursor"
echo " (the 'const uci = cursor();' anchor no longer matches)"
exit 1
fi
run_mock_scenario() {
scenario="$1"
name="$2"
mkdir -p "$WORK/$scenario/sandbox" "$WORK/$scenario/run"
printf '%s' "$name" > "$WORK/$scenario/sandbox/section-name"
if ( cd "$MOCK_STAGE" && ucode -L "$MOCK_STAGE" \
test_firewall_pre.uc \
"$WORK/$scenario/sandbox" \
"$WORK/$scenario/run" \
"$scenario" > "$WORK/$scenario/out.txt" 2>&1 ); then
cat "$WORK/$scenario/out.txt"
else
echo "FAIL: firewall_pre scenario '$scenario'"
cat "$WORK/$scenario/out.txt"
FAILED=1
fi
}
run_mock_scenario mock-good-section-name 'srv_ok_mock'
run_mock_scenario invalid-section-name 'srv" } ; chain evil { type filter hook prerouting priority 0; policy accept; }'
if ! grep -q 'WARN: skipping server .*: invalid section name' "$WORK/invalid-section-name/out.txt"; then
echo "FAIL: firewall_pre: the hostile section name was not refused with a warning"
FAILED=1
fi
# --- scenario: firewall='0' opts a server out entirely ----------------
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='redirect_tproxy' MAIN_NODE='urltest' SERVER_ENABLED='1'
seed firewall-off "config server 'srv_manual'
option enabled '1'
option firewall '0'
option port '443'"
run_scenario firewall-off
# --- scenario: tun and server rules coexist ---------------------------
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='tun' MAIN_NODE='urltest' SERVER_ENABLED='1'
seed both "config server 'srv_ok'
option enabled '1'
option firewall '1'
option port '443'"
run_scenario both
[ "$FAILED" -eq 0 ] && echo "PASS: firewall_pre behaviour tests"
exit $FAILED
@@ -0,0 +1,148 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Driver for tests/ucode/test_firewall_pre.sh.
*
* firewall_pre.uc is the fw4 pre-rules generator: it reads UCI and
* writes the two nft fragments (fw4_forward.nft / fw4_input.nft)
* that firewall_post.ut then includes. It has real branching - the
* tun accept pair, the per-server accept rules, and two validation
* guards that skip a malformed server rather than emitting a broken
* nft statement - and none of it had a test.
*
* The driver imports the (staged, cursor-redirected) script, then
* asserts on the generated fragments. Each scenario is a separate
* import in a fresh process, because the module body runs once per
* process; the shell wrapper drives the scenarios.
*
* Invoked as: ucode -L <stage> test_firewall_pre.uc <sandbox> <run-dir> <scenario>
*/
'use strict';
import { access, readfile } from 'fs';
global.HP_FW_RUN_DIR = ARGV[1];
import 'firewall_pre';
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++;
}
}
const forward_file = ARGV[1] + '/fw4_forward.nft';
const input_file = ARGV[1] + '/fw4_input.nft';
const forward = access(forward_file) ? trim(readfile(forward_file)) : null;
const input = access(input_file) ? trim(readfile(input_file)) : null;
switch (ARGV[2]) {
case 'tun':
/* tun_mode routing: forward accepts traffic leaving the tun, input
* accepts traffic arriving on it. */
expect('tun forward', forward,
'oifname singtun0 counter accept comment "!homeproxy: accept tun forward"');
expect('tun input', input,
'iifname singtun0 counter accept comment "!homeproxy: accept tun input"');
break;
case 'no-tun':
/* proxy_mode is tun but the outbound node is 'nil', so no tun rules
* are emitted at all. */
expect('no-tun forward', forward, null);
expect('no-tun input', input, null);
break;
case 'bad-tun':
/* A tun_name that is not a valid interface name must not reach the
* ruleset: the value is interpolated into an nft file that fw4 loads as
* root, and `;`/`}` need no newline to break out of the rule. */
expect('bad-tun forward', forward, null);
expect('bad-tun input', input, null);
break;
case 'good-tun':
/* The same path with a valid name still emits the pair, so the guard
* above cannot pass by disabling the feature. */
expect('good-tun forward', forward,
'oifname singtun9 counter accept comment "!homeproxy: accept tun forward"');
expect('good-tun input', input,
'iifname singtun9 counter accept comment "!homeproxy: accept tun input"');
break;
case 'server':
/* server enabled + firewall='1' + a valid port: one accept rule per
* server section, keyed on the destination port. */
expect('server input', input,
'meta l4proto { tcp, udp } th dport 443 counter accept comment "!homeproxy: accept server srv_ok"');
break;
case 'server-network':
/* an explicit network narrows the l4proto match. */
expect('server-network input', input,
'meta l4proto tcp th dport 8080 counter accept comment "!homeproxy: accept server srv_tcp"');
break;
case 'invalid-port':
/* a non-numeric port would emit `th dport not-a-port` - broken nft.
* The guard skips the server instead, and says why on stdout. */
expect('invalid-port input', input, null);
break;
case 'invalid-network':
/* a network outside {tcp, udp, {tcp,udp}} would be interpolated
* straight into the nft statement. The guard skips it. */
expect('invalid-network input', input, null);
break;
case 'invalid-section-name':
/* The section name lands inside an nft comment, in a file fw4 loads as
* root, so a name containing a closing quote would end the comment and
* turn the rest of the name into nft syntax. libuci refuses such a name
* before it can reach this loop (see the shell wrapper), so this case is
* driven through a cursor stub that yields it verbatim: the server must
* be skipped, not interpolated. */
expect('invalid-section-name input', input, null);
break;
case 'mock-good-section-name':
/* The same stub with a name libuci would accept: the rule is emitted,
* which is what makes the `null` above the guard's doing rather than the
* stub never reaching the emit path. */
expect('mock-good-section-name input', input,
'meta l4proto { tcp, udp } th dport 443 counter accept comment "!homeproxy: accept server srv_ok_mock"');
break;
case 'firewall-off':
/* firewall='0' means the user opted this server out of the automatic
* rule; nothing is emitted. */
expect('firewall-off input', input, null);
break;
case 'both':
/* tun + server together: both fragments exist, each with its own
* statements. */
expect('both forward', forward,
'oifname singtun0 counter accept comment "!homeproxy: accept tun forward"');
expect('both input has tun', match(input, /iifname singtun0 counter accept/) != null, true);
expect('both input has server', match(input, /th dport 443 counter accept/) != null, true);
break;
default:
printf('FAIL: unknown scenario %s\n', ARGV[2]);
exit(1);
}
if (failures > 0)
printf('FAIL: %s: %d/%d checks failed\n', ARGV[2], failures, checks);
else
printf('PASS: %s: %d checks\n', ARGV[2], checks);
exit(failures > 0 ? 1 : 0);
@@ -0,0 +1,189 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Render firewall_post.ut and verify the homeproxy fw4 objects survive as real
# nft statements.
#
# The template opens with `{%-`, which trims the whitespace in front of it: any
# text placed above that tag loses its trailing newline and is glued onto the
# first generated statement, turning it into a comment. That silently dropped
# every homeproxy chain/set from the fw4 ruleset once, so keep this check.
#
# Two layers, because the render needs the real `fw4` ucode module which only
# exists on a device (OpenWrt's firewall4 package ships /usr/share/ucode/fw4.uc):
#
# 1. a source-level assertion, always run, that the glue condition itself
# cannot come back - `{%-` must be the first thing after the shebang, so
# there is no preceding text whose newline could be trimmed;
# 2. the render + structural assertions, run whenever `fw4` is resolvable,
# skipped with an explicit NOT RUN otherwise. A stub fw4 would make this
# layer run everywhere, but it would also mean asserting on a ruleset the
# real fw4 never produced, so the skip is kept and made *enforceable*
# instead: HP_REQUIRE_FW4=1 turns it into a failure. tests/run.sh sets that
# in its ssh branch, because a target always has firewall4 - so the one
# environment that can run this layer must run it, and a target that
# somehow cannot is a failure rather than a quiet NOT RUN.
#
# `utpl` is not the blocker: it ships with ucode, which
# tests/toolchain/build-ucode-linux.sh builds. The missing piece is
# firewall4's /usr/share/ucode/fw4.uc.
#
# Usage: sh tests/ucode/test_firewall_template.sh <repo-root> [work-dir]
ROOT="${1:-.}"
ROOT="$(cd "$ROOT" && pwd)"
FAILED=0
TEMPLATE_SRC="$ROOT/root/etc/homeproxy/scripts/firewall_post.ut"
# --- layer 1: the glue condition cannot come back ------------------------
# `{%-` trims the whitespace before it, so anything other than the shebang
# ahead of the tag ends up glued onto the first rendered line. Read the tag
# position directly instead of trusting a comment.
first_tag_line="$(grep -n '{%-' "$TEMPLATE_SRC" | head -1 | cut -d: -f1)"
if [ -z "$first_tag_line" ]; then
echo "FAIL: firewall_post.ut has no '{%-' template tag"
FAILED=1
else
# Lines 1..(tag-1) must be blank or the shebang.
bad="$(sed -n "1,$((first_tag_line - 1))p" "$TEMPLATE_SRC" |
grep -nvE '^[[:space:]]*$|^#!/' || true)"
if [ -n "$bad" ]; then
echo "FAIL: text precedes the '{%-' tag and will be glued onto the first statement"
echo "$bad" | head -3
FAILED=1
fi
fi
# --- R-08: the gfwlist gate follows the dnsmasq nftset capability ----------
# The gate compares the destination against the set dnsmasq fills with
# `nftset=`. On a dnsmasq built without HAVE_NFTSET that set stays empty and
# `ip daddr != @<empty set>` is true for every packet, so gfwlist mode would
# silently route everything direct. The template therefore reads the
# capability from resources/.dnsmasq-nftset - written by init.d before the
# render - and must omit the gate when it says "no".
#
# This is asserted at the source level, not by rendering gfwlist mode: the
# renderer runs with the device's own UCI (routing_mode = bypass_mainland_china
# on any normal router, including this suite's host), so the gfwlist branch is
# not reachable from here at all. What is checkable is that every gate is
# wrapped and that the helper reads the marker the way init.d writes it.
# The declaration `{% if (routing_mode === 'gfwlist'): %}` in front of the set
# is data, not a gate; the gates are the same condition with a trailing colon.
GATED="$(grep -c "routing_mode === 'gfwlist' && dnsmasq_nftset()" "$TEMPLATE_SRC")"
UNGATED="$(grep -c "routing_mode === 'gfwlist'):" "$TEMPLATE_SRC")"
if [ "$GATED" -lt 4 ]; then
echo "FAIL: only $GATED gfwlist gates carry the nftset capability check (expected 4:"
echo " the LAN redirect, the UDP tproxy, the local output and the include chain)"
FAILED=1
fi
# Exactly one ungated condition is expected: the one that declares the set the
# gates match against. Every additional one is a gate, and a gate over an empty
# set is true for every packet.
if [ "$UNGATED" -ne 1 ]; then
echo "FAIL: $UNGATED ungated 'routing_mode === 'gfwlist'' conditions in the template"
echo " (exactly 1 is expected - the set declaration; the rest are gates)"
FAILED=1
fi
if ! grep -q "resources_dir + '/.dnsmasq-nftset'" "$TEMPLATE_SRC"; then
echo "FAIL: the template does not read resources/.dnsmasq-nftset, the marker init.d writes"
FAILED=1
fi
if ! grep -q '\.dnsmasq-nftset' "$ROOT/root/etc/init.d/homeproxy"; then
echo "FAIL: init.d no longer records the dnsmasq nftset capability for the template"
FAILED=1
fi
# --- layer 2: the render -------------------------------------------------
# The render output and its stderr live beside the staged template, never at a
# fixed /tmp path. A caller that passes a work dir (tests/ucode/run.sh hands
# every sub-suite a directory under its own $WORK) gets the pieces under it;
# with no argument each run gets its own mktemp -d instead. Either way no two
# runs share a path, and the whole stage goes away on every exit path - this
# used to leak a directory per run (50 of them had accumulated on the test
# device), which is both untidy and a determinism problem.
if [ -n "${2:-}" ]; then
STAGE="$2/firewall_template"
rm -rf "$STAGE"
mkdir -p "$STAGE"
OWN_STAGE=0
else
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/hp-fwtpl.XXXXXX")"
OWN_STAGE=1
fi
STAGED="$STAGE/firewall_post.ut"
OUT="$STAGE/rendered.nft"
ERR="$STAGE/render.err"
trap '[ "$OWN_STAGE" = 1 ] && rm -rf "$STAGE"' EXIT INT TERM
# Probe with ucode, not utpl: `utpl -e` is not an eval flag, it renders the
# argument as template text and always succeeds.
if ! ucode -e 'require("fw4");' > "/dev/null" 2>&1; then
echo "NOT RUN: the 'fw4' ucode module is not available (device-only), render skipped"
# A skipped layer must not be able to hide a broken template. Firewall4 is
# always installed where homeproxy runs, so the on-target suite sets
# HP_REQUIRE_FW4=1 (tests/run.sh does it in the ssh branch) and a skip there
# is a failure: it means the render stopped being exercised in the one
# environment that can exercise it. `utpl` itself is not the blocker - it
# ships with ucode, which the toolchain builds - the missing piece is
# firewall4's /usr/share/ucode/fw4.uc, and stubbing that would mean
# asserting on a ruleset the real fw4 never produced.
if [ "${HP_REQUIRE_FW4:-0}" = "1" ]; then
echo "FAIL: HP_REQUIRE_FW4=1 but 'fw4' is not resolvable, so the render"
echo " layer cannot run here and the template is unverified"
exit 1
fi
[ "$FAILED" -eq 0 ] && echo "PASS: firewall_post.ut keeps '{%-' directly after the shebang"
exit $FAILED
fi
# The template is written for a device: it imports homeproxy.uc through
# /etc/homeproxy/scripts/ and reads /etc/homeproxy/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.
sed -e "s#'/etc/homeproxy/#'$ROOT/root/etc/homeproxy/#g" "$TEMPLATE_SRC" > "$STAGED"
# 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
# regression. Refuse to run instead.
if grep -q "'/etc/homeproxy/" "$STAGED"; then
echo "FAIL: firewall_post.ut: could not rewrite the device paths"
echo " (the '/etc/homeproxy/' prefix anchor no longer matches)"
exit 1
fi
if ! utpl -S "$STAGED" > "$OUT" 2> "$ERR"; then
echo "FAIL: could not render firewall_post.ut"
head -5 "$ERR"
exit 1
fi
# Declared unconditionally by the template, so they must always be present as
# standalone statements.
for name in homeproxy_local_addr_v4 homeproxy_wan_proxy_addr_v4; do
if ! grep -q "^set $name {" "$OUT"; then
echo "FAIL: 'set $name {' is missing from the rendered firewall template"
grep -n "$name" "$OUT" | head -2
FAILED=1
fi
done
# A statement glued onto the preceding comment looks like
# "# <comment text>.set homeproxy_x {" -- exactly the failure mode above.
if grep -nE "^#[^!].*homeproxy_[a-z0-9_]+ \{" "$OUT" > "/dev/null"; then
echo "FAIL: an nft statement is glued onto a comment line"
grep -nE "^#[^!].*homeproxy_[a-z0-9_]+ \{" "$OUT" | head -2
FAILED=1
fi
if [ "$FAILED" -eq 0 ]; then
echo "PASS: firewall_post.ut renders homeproxy objects as standalone nft statements"
echo "PASS: the gfwlist gate follows the dnsmasq nftset capability"
fi
exit $FAILED
@@ -0,0 +1,51 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Direct unit tests for the firewall field validators
# (root/etc/homeproxy/scripts/firewall_utils.uc).
#
# Why a separate file rather than extending test_firewall_template.sh:
# the rendering test needs the device-only `fw4` ucode module, so on a
# development host it only runs the source-level glue precondition; it
# cannot exercise the validators themselves. The validators are pure
# functions, so a self-contained ucode test runs anywhere ucode runs.
# Together they are the report's H1: the renderer cannot regress
# silently because the validators are independently pinned here.
#
# Usage: sh tests/ucode/test_firewall_validators.sh <repo-root> [work-dir]
ROOT="${1:-.}"
WORK="${2:-/tmp/hp-firewall-validators-test}"
ROOT="$(cd "$ROOT" && pwd)"
if ! command -v ucode > "/dev/null" 2>&1; then
echo "NOT RUN: ucode is not on PATH."
echo " Build the testbed toolchain first (tests/toolchain/build-ucode-linux.sh)."
exit 2
fi
rm -rf "$WORK"
mkdir -p "$WORK"
# The validators import `isEmpty` from homeproxy.uc. Stage a copy that
# just exports it - the validators under test are the only thing this
# process exercises, so pulling in the full homeproxy.uc (with its
# validate_data / popen / luci.* imports) would break the unit test on
# a host that lacks /sbin/validate_data.
cat > "$WORK/homeproxy.uc" <<'HPEOF'
export function isEmpty(res) {
return !res || res === 'nil' || (type(res) in ['array', 'object'] && length(res) === 0);
};
HPEOF
cp "$ROOT/root/etc/homeproxy/scripts/firewall_utils.uc" "$WORK/firewall_utils.uc"
cp "$ROOT/tests/ucode/test_firewall_validators.uc" "$WORK/test_firewall_validators.uc"
if ( cd "$WORK" && ucode test_firewall_validators.uc ); then
echo "PASS: firewall field validators drop invalid entries"
exit 0
else
echo "FAIL: firewall field validators"
exit 1
fi
@@ -0,0 +1,216 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Direct unit tests for the field validators that firewall_post.ut routes
* every UCI-derived value through (the H1 fix from the 2026-09-16 review).
*
* What this catches that the rendering test cannot:
*
* The renderer (tests/ucode/test_firewall_template.sh) needs the device-only
* `fw4` ucode module, so on a development host it only runs the source-level
* precondition check (the `{%-` glue guard). The validators themselves are
* pure functions: they take a string or a list and return either an nft
* anonymous-set spelling or null. This file exercises them directly, so a
* regex tightening, an accidental inversion of the predicate, or a missing
* drop branch shows up as a unit-test failure here even when the renderer
* layer is offline.
*
* What the tests cover:
*
* isValidIPv4 - accept dotted-quad with optional /CIDR, reject anything
* that is not (e.g. "1.2.3", "1.2.3.4.5", "1.2.3.4 ", "abc").
* isValidMAC - accept six colon-separated hex octets (case-insensitive),
* reject anything else ("GG" octet, "1:2:3:4:5", "1:2:3:4:5:6:7").
* isValidIface - accept alnum + . _ -, reject whitespace, brackets, slashes,
* quotes, semicolons, the obvious injection characters.
* isValidPort - accept 1..65535, reject 0, negatives, decimals, alpha.
*
* ipv4_to_nftarr / mac_to_nftarr / iface_to_nftarr - the canonical case
* (all entries valid), the all-poison case (every entry
* dropped, returns null), and the mixed case (poison
* entries stripped, valid entries kept).
*
* ports_to_nftarr - the comma-separated UCI string the routing_port option
* actually ships: a clean list, an all-poison list, and
* the report's example ("22,7890; reject,443,foo" ->
* "{ 22, 443 }").
*
* Reverse-proving: removing any drop branch - or relaxing a regex - makes
* the matching test fail, the way guard 11 in tests/arch-guard.sh would
* make a future regression in firewall_post.ut itself fail. The two together
* are the report's "fix + reverse-verify" loop.
*
* Run from tests/ucode/test_firewall_validators.sh, which stages
* firewall_utils.uc (and the homeproxy.uc bits the validators depend on)
* in $WORK.
*/
'use strict';
import {
isValidIPv4, ipv4_to_nftarr,
isValidMAC, mac_to_nftarr,
isValidIface, iface_to_nftarr,
isValidPort, ports_to_nftarr
} from 'firewall_utils';
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++;
}
}
/* isValidIPv4 ------------------------------------------------------------ */
{
expect('isValidIPv4 accept 1.2.3.4', isValidIPv4('1.2.3.4'), true);
expect('isValidIPv4 accept 255.255.255.255', isValidIPv4('255.255.255.255'), true);
expect('isValidIPv4 accept 10.0.0.0/8', isValidIPv4('10.0.0.0/8'), true);
expect('isValidIPv4 accept 192.168.1.0/24', isValidIPv4('192.168.1.0/24'), true);
expect('isValidIPv4 accept 0.0.0.0/0', isValidIPv4('0.0.0.0/0'), true);
expect('isValidIPv4 reject 1.2.3', isValidIPv4('1.2.3'), false);
expect('isValidIPv4 reject 1.2.3.4.5', isValidIPv4('1.2.3.4.5'), false);
expect('isValidIPv4 reject "1.2.3.4 "', isValidIPv4('1.2.3.4 '), false);
expect('isValidIPv4 reject " 1.2.3.4"', isValidIPv4(' 1.2.3.4'), false);
expect('isValidIPv4 reject abc', isValidIPv4('abc'), false);
expect('isValidIPv4 reject empty', isValidIPv4(''), false);
expect('isValidIPv4 reject null', isValidIPv4(null), false);
expect('isValidIPv4 reject number', isValidIPv4(123), false);
/* The H1 injection: a value that closes a nft set. */
expect('isValidIPv4 reject injection',
isValidIPv4('1.2.3.4 } ; reject ; set x {'), false);
}
/* isValidMAC ------------------------------------------------------------- */
{
expect('isValidMAC accept aa:bb:cc:dd:ee:ff', isValidMAC('aa:bb:cc:dd:ee:ff'), true);
expect('isValidMAC accept AA:BB:CC:DD:EE:FF', isValidMAC('AA:BB:CC:DD:EE:FF'), true);
expect('isValidMAC reject GG octet',
isValidMAC('00:11:22:33:44:GG'), false);
expect('isValidMAC reject too few octets',
isValidMAC('aa:bb:cc:dd:ee'), false);
expect('isValidMAC reject too many octets',
isValidMAC('aa:bb:cc:dd:ee:ff:00'), false);
expect('isValidMAC reject dash separator',
isValidMAC('aa-bb-cc-dd-ee-ff'), false);
expect('isValidMAC reject empty', isValidMAC(''), false);
}
/* isValidIface ----------------------------------------------------------- */
{
expect('isValidIface accept eth0', isValidIface('eth0'), true);
expect('isValidIface accept br-lan', isValidIface('br-lan'), true);
expect('isValidIface accept eth0.10', isValidIface('eth0.10'), true);
expect('isValidIface accept lo', isValidIface('lo'), true);
expect('isValidIface reject space',
isValidIface('eth 0'), false);
expect('isValidIface reject slash',
isValidIface('eth0/1'), false);
expect('isValidIface reject semicolon',
isValidIface('eth0;reject'), false);
expect('isValidIface reject injection',
isValidIface('eth0 } reject ; set x {'), false);
}
/* isValidPort ------------------------------------------------------------ */
{
expect('isValidPort reject 0', isValidPort('0'), false);
expect('isValidPort accept 22', isValidPort('22'), true);
expect('isValidPort accept 65535', isValidPort('65535'), true);
expect('isValidPort reject 65536', isValidPort('65536'), false);
expect('isValidPort reject -1', isValidPort('-1'), false);
expect('isValidPort reject 22.5', isValidPort('22.5'), false);
expect('isValidPort reject abc', isValidPort('abc'), false);
expect('isValidPort reject empty', isValidPort(''), false);
expect('isValidPort reject injection',
isValidPort('7890; reject'), false);
}
/* ipv4_to_nftarr --------------------------------------------------------- */
{
/* Canonical case: every entry valid. */
expect('ipv4_to_nftarr clean',
ipv4_to_nftarr(['10.0.0.1', '192.168.1.0/24']),
'{ 10.0.0.1, 192.168.1.0/24 }');
/* All-poison: every entry dropped, returns null. */
expect('ipv4_to_nftarr all-poison returns null',
ipv4_to_nftarr(['1.2.3.4 } ; reject ; set x {',
'not.an.ip', '5']),
null);
/* Mixed: poison entries stripped, valid kept. */
expect('ipv4_to_nftarr mixed drops the poison',
ipv4_to_nftarr(['10.0.0.1', '1.2.3.4 } ; reject ; set x {', '192.168.1.1']),
'{ 10.0.0.1, 192.168.1.1 }');
/* Empty list. */
expect('ipv4_to_nftarr empty returns null',
ipv4_to_nftarr([]), null);
expect('ipv4_to_nftarr null returns null',
ipv4_to_nftarr(null), null);
/* Dedup. */
expect('ipv4_to_nftarr dedups',
ipv4_to_nftarr(['10.0.0.1', '10.0.0.1', '10.0.0.2']),
'{ 10.0.0.1, 10.0.0.2 }');
}
/* mac_to_nftarr ---------------------------------------------------------- */
{
expect('mac_to_nftarr clean',
mac_to_nftarr(['aa:bb:cc:dd:ee:ff', '00:11:22:33:44:55']),
'{ aa:bb:cc:dd:ee:ff, 00:11:22:33:44:55 }');
expect('mac_to_nftarr all-poison returns null',
mac_to_nftarr(['00:11:22:33:44:GG',
'aa-bb-cc-dd-ee-ff',
'too:short']),
null);
expect('mac_to_nftarr mixed drops the poison',
mac_to_nftarr(['aa:bb:cc:dd:ee:ff', '00:11:22:33:44:GG', '11:22:33:44:55:66']),
'{ aa:bb:cc:dd:ee:ff, 11:22:33:44:55:66 }');
}
/* iface_to_nftarr -------------------------------------------------------- */
{
expect('iface_to_nftarr clean',
iface_to_nftarr(['eth0', 'br-lan']),
'{ eth0, br-lan }');
expect('iface_to_nftarr all-poison returns null',
iface_to_nftarr(['eth0 } reject ; set x {',
'eth 0',
'eth0/1']),
null);
expect('iface_to_nftarr mixed drops the poison',
iface_to_nftarr(['eth0', 'eth0;reject', 'br-lan']),
'{ eth0, br-lan }');
}
/* ports_to_nftarr -------------------------------------------------------- */
{
/* The exact injection the report called out. */
expect('ports_to_nftarr clean',
ports_to_nftarr('22,53,80,443'),
'{ 22, 53, 80, 443 }');
expect('ports_to_nftarr all-poison returns null',
ports_to_nftarr('abc,7890; reject,-1,65536'),
null);
expect('ports_to_nftarr mixed drops the poison',
ports_to_nftarr('22,7890; reject,443,foo'),
'{ 22, 443 }');
expect('ports_to_nftarr empty returns null',
ports_to_nftarr(''), null);
expect('ports_to_nftarr handles whitespace',
ports_to_nftarr(' 22 , 53 , 80 '),
'{ 22, 53, 80 }');
expect('ports_to_nftarr handles common defaults',
ports_to_nftarr('22,53,80,143,443,465,587,853,873,993,995,8080,8443,9418'),
'{ 22, 53, 80, 143, 443, 465, 587, 853, 873, 993, 995, 8080, 8443, 9418 }');
}
if (failures > 0)
printf('FAIL: %d/%d checks failed\n', failures, checks);
else
printf('PASS: %d checks\n', checks);
exit(failures > 0 ? 1 : 0);
@@ -0,0 +1,67 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# The fw4 chain/set inventory in scripts/fw4_names.sh must stay in sync with
# the objects declared by scripts/firewall_post.ut: the first drives the
# cleanup in init.d/homeproxy, the second creates them. A drift means either a
# leftover object or a delete of something that never existed (which used to
# abort the whole cleanup batch).
#
# Usage: sh tests/ucode/test_fw4_names.sh <repo-root>
ROOT="${1:-.}"
ROOT="$(cd "$ROOT" && pwd)"
NAMES="$ROOT/root/etc/homeproxy/scripts/fw4_names.sh"
POST="$ROOT/root/etc/homeproxy/scripts/firewall_post.ut"
PRE="$ROOT/root/etc/homeproxy/scripts/firewall_pre.uc"
FAILED=0
# shellcheck source=/dev/null
. "$NAMES"
check_group() {
group="$1"
list="$(printf '%s\n' $2 | tr '\n' ' ')"
declared="$(printf '%s\n' $3 | tr '\n' ' ')"
missing=""
extra=""
for name in $declared; do
case " $list " in
*" $name "*) ;;
*) missing="$missing $name" ;;
esac
done
for name in $list; do
case " $declared " in
*" $name "*) ;;
*) extra="$extra $name" ;;
esac
done
if [ -z "$missing$extra" ]; then
echo "PASS: $group inventory matches firewall_post.ut"
else
[ -n "$missing" ] && echo "FAIL: $group declared in firewall_post.ut but missing from fw4_names.sh:$missing"
[ -n "$extra" ] && echo "FAIL: $group listed in fw4_names.sh but not declared in firewall_post.ut:$extra"
FAILED=1
fi
}
post_chains="$(grep -oE '^chain homeproxy_[a-z0-9_]+' "$POST" | awk '{print $2}' | sort -u)"
post_sets="$(grep -oE '^set homeproxy_[a-z0-9_]+' "$POST" | awk '{print $2}' | sort -u)"
check_group "chain" "$HP_FW4_CHAINS" "$post_chains"
check_group "set" "$HP_FW4_SETS" "$post_sets"
# firewall_pre.uc only writes forward/input accept rules; it must not own any
# chain/set of its own.
pre_refs="$(grep -oE 'homeproxy_[a-z0-9_]+' "$PRE" | sort -u)"
if [ -n "$pre_refs" ]; then
echo "NOTE: firewall_pre.uc references fw4 objects:$pre_refs"
fi
exit $FAILED
@@ -0,0 +1,65 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* generator/common.uc's tag helpers, driven with a hand-built domain model.
*
* The one that regressed: isDirectOutboundTag() is called with the tag the
* generator *emits* (get_outbound() builds `cfg-<node>-out`), not with the
* routing_node section name, and it looked the value up in both tables
* unchanged. Every routing_node therefore answered "not direct", so
* build_http_clients() kept a detour on a rule-set download in pure TUN mode -
* where sing-box refuses to detour into an empty direct outbound and rejects
* the whole configuration.
*
* Run by tests/ucode/run.sh from a staging directory that holds homeproxy.uc,
* config/ and parser/.
*/
'use strict';
import { isDirectOutboundTag, get_outbound } from 'generator/common.uc';
let checks = 0, failures = 0;
function expect(name, actual, want) {
checks++;
if (actual !== want) {
failures++;
printf('FAIL %s: got %s, want %s\n', name, actual, want);
}
}
/* A domain model with just the tables the helpers walk. */
const dm = {
nodes: [
{ id: 'n_direct', type: 'direct' },
{ id: 'n_proxy', type: 'vless' },
{ id: 'n_wg', type: 'wireguard' }
],
routing: {
nodes: [
{ name: 'rn_direct', node: 'n_direct' },
{ name: 'rn_group', node: 'urltest' }
]
}
};
expect('literal direct-out', isDirectOutboundTag('direct-out', dm), true);
expect('literal block-out', isDirectOutboundTag('block-out', dm), false);
expect('unknown tag', isDirectOutboundTag('cfg-n_missing-out', dm), false);
/* The emitted form, which is what the caller actually passes. */
expect('direct node via cfg- tag', isDirectOutboundTag('cfg-n_direct-out', dm), true);
expect('proxy node via cfg- tag', isDirectOutboundTag('cfg-n_proxy-out', dm), false);
expect('wireguard node via cfg- tag', isDirectOutboundTag('cfg-n_wg-out', dm), false);
/* A routing_node that points at a direct node is direct; one that points at
* the urltest group is a group, not a direct outbound. */
expect('routing_node to direct node', isDirectOutboundTag('cfg-rn_direct-out', dm), true);
expect('routing_node to urltest', isDirectOutboundTag('cfg-rn_group-out', dm), false);
/* The tag the helper is asked about has to be the tag the generator emits. */
expect('get_outbound emits cfg- prefix', get_outbound('rn_direct', dm), 'cfg-n_direct-out');
printf('%d checks, %d failures\n', checks, failures);
exit(failures ? 1 : 0);
+643
View File
@@ -0,0 +1,643 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Run generate_client.uc / generate_server.uc against the UCI fixtures and let
# sing-box validate the result. The generators read /etc/config and write
# /var/run, so this script materialises an isolated copy of them: homeproxy.uc
# is rewritten to point HP_DIR/RUN_DIR at a scratch directory, and the uci
# cursor is pointed at the fixture config.
#
# Stage PHASE 4: the generators are now split into generator/*.uc modules
# and the production entry points (scripts/generate_client.uc and
# scripts/generate_server.uc) are 10-line CLI shells that just load the UCI,
# call the module's generate(), and run the atomic write + sing-box check.
# The testbed no longer needs to sed-substitute a `__LOADER_DIR__` token
# because that mechanism is gone - the staged scripts/ subtree is a regular
# directory and `Loader.load()` takes the path as an argument.
#
# Usage: sh tests/ucode/test_generators.sh <repo-root> [work-dir]
ROOT="${1:-.}"
WORK="${2:-/tmp/hp-generator-test}"
ROOT="$(cd "$ROOT" && pwd)"
FAILED=0
# The local rule-set fixture has to live under /tmp/homeproxy_ (see below), so
# it cannot sit inside $WORK; this is the per-run root that holds it instead.
# Created lazily by the first case that needs one.
RULESET_ROOT=""
cleanup() {
[ -n "$RULESET_ROOT" ] && rm -rf "$RULESET_ROOT"
}
trap cleanup EXIT INT TERM
run_case() {
name="$1"
fixture="$2"
generator="$3"
outfile="$4"
# Optional: a sed expression applied to the staged fixture, for cases that
# differ from the shared fixture by one UCI option. Cheaper and more
# explicit than a near-copy fixture file, and the expression is visible at
# the call site next to the assertion it serves.
variation="${5:-}"
dir="$WORK/$name"
rm -rf "$dir"
mkdir -p "$dir/config" "$dir/run" "$dir/scripts" "$dir/scripts/config" "$dir/scripts/generator" "$dir/resources" "$dir/ruleset"
: > "$dir/resources/direct_list.txt"
: > "$dir/resources/proxy_list.txt"
if grep -q "__RULESET_DIR__" "$fixture"; then
# The fixture needs a real local rule-set on disk. The path must
# live under /tmp/homeproxy_* (validateHomeProxyPath() in
# homeproxy.uc whitelists /etc/homeproxy/ and /tmp/homeproxy_
# only, and the custom fixture exercises the local-rule-set
# path whitelist gate introduced by the security patch).
printf '%s' '{"version":1,"rules":[{"domain_suffix":["example.com"]}]}' > "$dir/ruleset/src.json"
if ! sing-box rule-set compile "$dir/ruleset/src.json" -o "$dir/ruleset/test.srs"; then
echo "FAIL: $name: could not compile the local rule-set fixture"
FAILED=1
return
fi
# Stage the ruleset under a /tmp/homeproxy_* directory so the
# whitelist recognises the staging path. Production paths
# typically live at /etc/homeproxy/ruleset/...
#
# The path cannot move under $WORK (validateHomeProxyPath() in
# homeproxy.uc whitelists /etc/homeproxy/ and /tmp/homeproxy_
# only), but it can still be per-run: mktemp gives each run its
# own tree, and the trap removes it even when a case bails out
# early. The old fixed /tmp/homeproxy_test_ruleset/$name leaked
# one directory per run and let two concurrent runs overwrite
# each other's compiled .srs.
if [ -z "$RULESET_ROOT" ]; then
RULESET_ROOT="$(mktemp -d /tmp/homeproxy_test_ruleset.XXXXXX)"
fi
HP_RULESET="$RULESET_ROOT/$name"
mkdir -p "$HP_RULESET"
cp "$dir/ruleset/test.srs" "$HP_RULESET/test.srs"
sed "s#__RULESET_DIR__#$HP_RULESET#" "$fixture" > "$dir/config/homeproxy"
else
cp "$fixture" "$dir/config/homeproxy"
fi
if [ -n "$variation" ]; then
sed -e "$variation" "$dir/config/homeproxy" > "$dir/config/homeproxy.sed" \
|| { echo "FAIL: $name: the fixture variation could not be applied"; FAILED=1; return; }
if cmp -s "$dir/config/homeproxy" "$dir/config/homeproxy.sed"; then
# A variation that matched nothing would run the same case twice
# and report a pass for behaviour that was never exercised.
echo "FAIL: $name: the fixture variation matched nothing ($variation)"
FAILED=1
return
fi
mv "$dir/config/homeproxy.sed" "$dir/config/homeproxy"
fi
# HP_VALIDATE_DATA lets a development host replace /sbin/validate_data
# (see tests/README.md); on a target the production path is kept.
#
# UCICONFIG_DIR is the UCI confdir the Loader hands to cursor(). On a
# target it is /etc/config; here it has to point at the fixture staged
# below as $dir/config/homeproxy. Rewriting the constant is what keeps
# the staging seam out of the source files - staging it as
# HP_DIR + '/config' instead would silently diverge from production,
# which is exactly the bug this constant replaced.
VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
sed -e "s#^export const HP_DIR = '/etc/homeproxy';#export const HP_DIR = '$dir';#" \
-e "s#^export const RUN_DIR = '/var/run/homeproxy';#export const RUN_DIR = '$dir/run';#" \
-e "s#^export const UCICONFIG_DIR = '/etc/config';#export const UCICONFIG_DIR = '$dir/config';#" \
-e "s#/sbin/validate_data#${VALIDATE_DATA}#" \
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$dir/scripts/homeproxy.uc"
# Stage the config/ subtree (Loader / Model / Adapter, imported via
# the relative path "../config/*.uc" by the generator modules).
cp "$ROOT/root/etc/homeproxy/scripts/config/loader.uc" "$dir/scripts/config/"
cp "$ROOT/root/etc/homeproxy/scripts/config/model.uc" "$dir/scripts/config/"
cp "$ROOT/root/etc/homeproxy/scripts/config/adapter.uc" "$dir/scripts/config/"
# PR-02: config/loader.uc imports '../parser/mapping.uc', so the
# parser tree has to be staged as a sibling of config/ or the
# generator cannot even load the configuration.
mkdir -p "$dir/scripts/parser"
cp "$ROOT/root/etc/homeproxy/scripts/parser/"*.uc "$dir/scripts/parser/"
# Stage the generator/ subtree that PHASE 4 introduced. The CLI
# shells (scripts/generate_*.uc) import from generator/; the
# modules in turn import from common.uc, dns.uc, ... inside the
# same directory.
cp "$ROOT/root/etc/homeproxy/scripts/generator/"*.uc "$dir/scripts/generator/"
# Stage the CLI shells themselves. This is the entry point the
# production init.d runs (`ucode -S generate_client.uc`); the
# test exercises the same code path end-to-end, not a parallel
# test-only driver.
cp "$ROOT/root/etc/homeproxy/scripts/$generator" "$dir/scripts/$generator"
# No platform patching here on purpose. The suite used to rewrite
# generator/client.uc's `routing_mark` to null on Darwin, because the
# macOS sing-box rejects that Linux-only field - which meant the one
# field the product needs on its target platform was silently deleted
# before every test, and a regression in it could never be seen. The
# off-target layer is Linux-only now (see tests/README.md); a host that
# cannot validate the generated configuration says so instead of
# weakening it.
# stderr is kept so a test can assert on warnings (e.g. a pruned urltest
# candidate) as well as on the generated JSON.
if ! ( cd "$dir/scripts" && ucode -L "$dir/scripts" "$generator" 2> "$dir/generate.err" ); then
echo "FAIL: $name: $generator exited non-zero"
head -5 "$dir/generate.err"
cp "$dir/generate.err" "$WORK/$name.err" 2>"/dev/null"
FAILED=1
return
fi
if [ ! -f "$dir/run/$outfile" ]; then
echo "FAIL: $name: $outfile was not generated"
FAILED=1
return
fi
if ! sing-box check --config "$dir/run/$outfile"; then
echo "FAIL: $name: sing-box rejected the generated $outfile"
FAILED=1
return
fi
echo "PASS: $name ($(wc -c < "$dir/run/$outfile") bytes)"
}
# run_case_type_error <case-name> <expected message fragment> <run_case args...>
# Stages a case the same way run_case() does and asserts that generation FAILS
# with a message naming the offender. The generator's die() paths are what keep
# a dangling reference from reaching sing-box, where the failure would be a
# rejected configuration and a reload that silently keeps the previous one.
run_case_type_error() {
local name="$1" expect="$2"; shift 2
# run_case() reports a non-zero generator as its own failure and sets the
# global FAILED - but here that non-zero exit *is* the expected outcome, so
# the flag is saved and restored around the call. Its return status is
# always 0 (the function ends with `echo "PASS: ..."`), so the refusal is
# observed through the artifacts instead: the generator's stderr, and the
# absence of the configuration it would otherwise write.
local saved_failed="$FAILED"
run_case "$name" "$@" > "$WORK/$name.out" 2>&1
FAILED="$saved_failed"
local err="$WORK/$name.err"
if [ -f "$WORK/$name/run/sing-box-c.json" ]; then
echo "FAIL: $name: a configuration was generated, but it must be refused"
FAILED=1
elif [ ! -s "$err" ]; then
echo "FAIL: $name: generation produced no diagnostic:"
sed -n '1,5p' "$WORK/$name.out"
FAILED=1
elif grep -q "$expect" "$err"; then
echo "PASS: $name: refused with a message naming the offender"
else
echo "FAIL: $name: the diagnostic does not name the offender (expected '$expect'):"
head -3 "$err"
FAILED=1
fi
# Without this the caller's status is the grep's, and a passing case reports
# failure to the suite.
return 0
}
# The fixture's infra.self_mark, i.e. the value every node outbound has to
# carry as routing_mark in the redirect modes.
fixture_self_mark() {
sed -n "s/^[[:space:]]*option self_mark '\([0-9]*\)'.*/\1/p" "$1" | head -1
}
# Read one field out of a generated configuration. ucode is already required
# by this suite, and a JSON walk is stronger than a grep: it cannot be
# satisfied by a coincidental match elsewhere in the file.
mkdir -p "$WORK"
cat > "$WORK/probe.uc" <<'EOF'
'use strict';
import { readfile } from 'fs';
const config = json(readfile(ARGV[0]));
switch (ARGV[1]) {
case 'main-dns-server':
for (let s in (config.dns?.servers || []))
if (s.tag === 'main-dns') {
printf('%s\n', s.server ?? '');
exit(0);
}
printf('\n');
break;
case 'route-final':
printf('%s\n', config.route?.final ?? '');
break;
case 'has-outbound-tag':
for (let o in (config.outbounds || []))
if (o.tag === ARGV[2]) {
printf('yes\n');
exit(0);
}
printf('no\n');
break;
case 'rule-set-count':
printf('%d\n', length(config.route?.rule_set || []));
break;
default:
printf('unknown probe\n');
break;
}
EOF
config_field() {
ucode "$WORK/probe.uc" "$1" "$2" "${3:-}" 2>"/dev/null"
}
# Read one field out of a generated configuration. ucode is already required
# by this suite, and a JSON walk is stronger than a grep: it cannot be
# satisfied by a coincidental match elsewhere in the file.
run_case client "$ROOT/tests/fixtures/generators/client.uci" generate_client.uc sing-box-c.json
# The preset remote rule-sets must be fetched through the node. A direct
# download depends on the CDN staying reachable from mainland China and fails
# intermittently under DNS pollution, which shows up as "open connection to
# <ip>:443 using outbound/direct[direct]: i/o timeout" in sing-box-c.log.
if grep -q '"detour": "direct-out"' "$WORK/client/run/sing-box-c.json"; then
echo "FAIL: client: a remote rule-set would still be downloaded directly"
FAILED=1
fi
if ! grep -q '"detour": "main-out"' "$WORK/client/run/sing-box-c.json"; then
echo "FAIL: client: no remote rule-set is configured to download through main-out"
FAILED=1
fi
# --- the product default proxy mode --------------------------------------
#
# client.uci runs `proxy_mode 'tun'`, where self_mark is empty and no outbound
# carries routing_mark. That made it the only end-to-end fixture for as long
# as the default mode (redirect_tproxy) was never generated here - and the
# adapter's COMMON_FIELDS neutralized routing_mark (it listed the key as null
# *after* the literal that sets it), so every node outbound lost the mark that
# keeps sing-box's own proxy connection out of the nft redirect chain. This
# case covers the default mode and asserts the mark on the emitted bytes.
run_case redirect "$ROOT/tests/fixtures/generators/redirect.uci" generate_client.uc sing-box-c.json
redir_fixture="$ROOT/tests/fixtures/generators/redirect.uci"
redir_json="$WORK/redirect/run/sing-box-c.json"
redir_mark="$(fixture_self_mark "$redir_fixture")"
if [ -z "$redir_mark" ]; then
echo "FAIL: redirect: $redir_fixture no longer declares infra.self_mark, so the"
echo " routing_mark assertion would pass vacuously"
FAILED=1
elif [ ! -f "$redir_json" ]; then
echo "FAIL: redirect: no config was generated"
FAILED=1
else
if ! grep -q '"tag": "redirect-in"' "$redir_json"; then
echo "FAIL: redirect: proxy_mode=redirect_tproxy did not emit redirect-in"
FAILED=1
fi
# tproxy-in is gated on a dedicated UDP node, exactly like the nft
# tproxy chain in firewall_post.ut. This fixture leaves main_udp_node at
# 'nil', so the inbound must NOT be emitted: the old code emitted it
# anyway with listen_port 0 - context.uc only assigns tproxy_port when a
# UDP node exists - binding a random UDP port that no rule ever points
# at. The positive case is redirect-udp below.
if grep -q '"tag": "tproxy-in"' "$redir_json"; then
echo "FAIL: redirect: main_udp_node=nil emitted tproxy-in, which no nft rule"
echo " points at (see the tproxy_port gate in generator/inbound.uc)"
FAILED=1
fi
if grep -q '"listen_port": 0' "$redir_json"; then
echo "FAIL: redirect: a generated inbound has listen_port 0:"
grep -n -B 3 '"listen_port": 0' "$redir_json" | head -8
FAILED=1
fi
# Which outbounds must carry the mark: every outbound that dials. Group
# outbounds (urltest/selector) must NOT - sing-box rejects the field on
# them with `json: unknown field "routing_mark"` - and block-out never
# dials. A structural walk is used instead of a grep count so the
# group/leaf distinction cannot silently rot into a vacuous assertion.
cat > "$WORK/markcheck.uc" <<'EOF'
'use strict';
import { readfile } from 'fs';
const want = +ARGV[0];
const config = json(readfile(ARGV[1]));
let checked = 0, problems = 0;
for (let ob in (config.outbounds || [])) {
if (index(['block', 'urltest', 'selector'], ob.type) !== -1)
continue;
checked++;
if (ob.routing_mark !== want)
problems++;
}
printf('%d %d\n', checked, problems);
EOF
mark_result="$(ucode "$WORK/markcheck.uc" "$redir_mark" "$redir_json" 2>"/dev/null")"
mark_checked="${mark_result%% *}"
mark_problems="${mark_result##* }"
case "$mark_checked" in
''|*[!0-9]*)
echo "FAIL: redirect: could not check the emitted outbounds ($redir_json)"
FAILED=1
;;
0)
echo "FAIL: redirect: no dialling outbound was emitted, the assertion is vacuous"
FAILED=1
;;
*)
if [ "$mark_problems" -ne 0 ]; then
echo "FAIL: redirect: $mark_problems of $mark_checked dialling outbounds do not"
echo " carry routing_mark=$redir_mark (see COMMON_FIELDS in config/adapter.uc);"
echo " without it sing-box does not mark its own sockets and the nft OUTPUT"
echo " redirect chain loops the proxy connection back into its own inbound"
FAILED=1
else
echo "PASS: redirect: all $mark_checked dialling outbounds carry routing_mark=$redir_mark"
fi
;;
esac
fi
# The UDP tproxy path: a dedicated UDP node makes context.uc assign
# tproxy_port, firewall_post.ut emit the tproxy chain, and inbound.uc emit the
# tproxy-in that chain redirects to. No fixture covered this path before -
# every one either used tun or left main_udp_node at 'nil'.
run_case redirect-udp "$ROOT/tests/fixtures/generators/redirect.uci" generate_client.uc sing-box-c.json \
"s/^\([[:space:]]*\)option main_udp_node '.*'/\1option main_udp_node 'same'/"
rudp_json="$WORK/redirect-udp/run/sing-box-c.json"
if [ ! -f "$rudp_json" ]; then
echo "FAIL: redirect-udp: no config was generated"
FAILED=1
elif ! grep -q '"tag": "tproxy-in"' "$rudp_json"; then
echo "FAIL: redirect-udp: main_udp_node=same did not emit tproxy-in"
FAILED=1
elif ! grep -A 4 '"tag": "tproxy-in"' "$rudp_json" | grep -q '"listen_port": 5332'; then
echo "FAIL: redirect-udp: tproxy-in does not listen on the configured tproxy port:"
grep -A 4 '"tag": "tproxy-in"' "$rudp_json" | head -6
FAILED=1
else
echo "PASS: redirect-udp: a dedicated UDP node emits tproxy-in on 5332"
fi
# H1: custom-only scalars must not survive into a preset mode.
#
# route.uc and outbound.uc choose their branch with `isEmpty(default_outbound)`
# while the preset DNS builder keys off main_node. A residual
# routing.default_outbound (LuCI hides the field in preset modes but does not
# clear it) therefore switched generation onto the custom branch, the DNS block
# silently lost main-dns/china-dns, and the rule-set tags the leftover custom
# rules referenced were never built - `sing-box check` rejected the whole
# configuration, so the reload kept the old one and the user only saw "my
# change did not take". client.uci is the preset path with no main node; the
# expression below is a no-op unless generation writes the field.
run_case preset-stale-default-outbound "$ROOT/tests/fixtures/generators/client.uci" generate_client.uc sing-box-c.json \
"s#^\\([[:space:]]*\\)option proxy_mode .*#\\1option proxy_mode 'redirect_tproxy'\\n\\1option main_udp_node 'same'\\n\\1option default_outbound 'direct-out'#"
# A residual routing.default_outbound must not switch a preset-mode router onto
# the custom branch: the preset DNS builder keys off main_node and bails out,
# so the DNS block would lose main-dns/china-dns, and the leftover custom rules
# reference rule-set tags that only custom mode builds. The expression above
# inserts the stale option; with the mode gate in build_context() it is never
# read, so the generated config must still be the preset one.
pso_json="$WORK/preset-stale-default-outbound/run/sing-box-c.json"
if [ ! -f "$pso_json" ]; then
echo "FAIL: preset-stale-default-outbound: no config was generated"
FAILED=1
else
for probe in '"tag": "main-dns"' '"tag": "china-dns"'; do
if ! grep -q "$probe" "$pso_json"; then
echo "FAIL: preset-stale-default-outbound: the preset DNS path is missing $probe"
echo " a residual routing.default_outbound put generation on the custom branch"
FAILED=1
fi
done
if ! grep -q '"final": "main-out"' "$pso_json"; then
echo "FAIL: preset-stale-default-outbound: route.final is not main-out"
FAILED=1
fi
# find_neighbor is emitted only by the custom route builder.
if grep -q '"find_neighbor"' "$pso_json"; then
echo "FAIL: preset-stale-default-outbound: find_neighbor leaked into the preset config"
FAILED=1
fi
if ! grep -q '"type": "redirect"' "$pso_json"; then
echo "FAIL: preset-stale-default-outbound: the redirect inbound is missing"
FAILED=1
fi
fi
run_case wan-dns "$ROOT/tests/fixtures/generators/client.uci" generate_client.uc sing-box-c.json \
"s/^\([[:space:]]*\)option dns_server '.*'/\1option dns_server 'wan'/"
wan_json="$WORK/wan-dns/run/sing-box-c.json"
if [ ! -f "$wan_json" ]; then
echo "FAIL: wan-dns: no config was generated"
FAILED=1
else
wan_server="$(config_field "$wan_json" main-dns-server)"
case "$wan_server" in
'')
echo "FAIL: wan-dns: main-dns has no server at all (dns_server='wan' was dropped)"
FAILED=1
;;
wan)
echo "FAIL: wan-dns: the literal 'wan' was published as the main DNS hostname;"
echo " sing-box would try to resolve a host by that name"
FAILED=1
;;
*)
echo "PASS: wan-dns: dns_server='wan' resolved to $wan_server"
;;
esac
fi
# --- A1: the generator is a pure function of its arguments ----------------
#
# generator/client.uc used to resolve its own runtime environment - ubus for
# the WAN resolver, readfile() for the two domain-resource lists - while being
# documented as a pure function. "Generate the same config twice" was therefore
# not guaranteed, and reload's preflight generation was not provably the
# artifact start_service regenerated. The impure step now lives in the CLI
# shell, which hands the values to generate(dm, env).
#
# Both halves of that contract are pinned here, through the production entry
# point (not a test-only driver): the same inputs must produce byte-identical
# output, and a changed GenerationContext input must reach the generator. The
# comparison is exact string equality rather than a hash, because busybox has
# no cksum and macOS has no md5sum by default.
det_dir="$WORK/client"
det_gen="generate_client.uc"
det_out="$det_dir/run/sing-box-c.json"
det_first="$det_dir/determinism-first.json"
cp "$det_out" "$det_first"
( cd "$det_dir/scripts" && ucode -L "$det_dir/scripts" "$det_gen" ) >"/dev/null" 2>&1
if [ "$(cat "$det_out")" = "$(cat "$det_first")" ]; then
echo "PASS: the same generation inputs produce byte-identical output"
else
echo "FAIL: two identical generation runs produced different output"
FAILED=1
fi
# direct_list.txt is read by the CLI and passed in as env.direct_domain_list.
# If that plumbing were lost - the exact shape of the old hidden read - the file
# would be ignored and the output would not move.
printf 'determinism.example.com\n' > "$det_dir/resources/direct_list.txt"
( cd "$det_dir/scripts" && ucode -L "$det_dir/scripts" "$det_gen" ) >"/dev/null" 2>&1
if [ "$(cat "$det_out")" != "$(cat "$det_first")" ]; then
echo "PASS: a changed GenerationContext input changes the generated output"
else
echo "FAIL: the domain-resource list did not reach the generator"
FAILED=1
fi
if ! grep -qF 'determinism.example.com' "$det_out"; then
echo "FAIL: the domain from direct_list.txt is absent from the generated config"
FAILED=1
fi
run_case custom "$ROOT/tests/fixtures/generators/custom.uci" generate_client.uc sing-box-c.json
# P1-6: the main-node reference is mode-dependent, and LuCI hides config.main_node
# in custom mode without clearing it (`rmempty = false`). A router that was
# configured in a preset mode and then switched to custom therefore keeps the
# old value - and reading it in custom mode switched the generator back to the
# preset path, dropping every routing_rule/rule_set the user configured.
run_case custom-stale-main-node "$ROOT/tests/fixtures/generators/custom.uci" generate_client.uc sing-box-c.json \
"s/^\([[:space:]]*\)option log_level 'error'/\1option log_level 'error'\n\1option main_node 'n_direct'/"
stale_json="$WORK/custom-stale-main-node/run/sing-box-c.json"
if [ ! -f "$stale_json" ]; then
echo "FAIL: custom-stale-main-node: no config was generated"
FAILED=1
else
stale_final="$(config_field "$stale_json" route-final)"
stale_rulesets="$(config_field "$stale_json" rule-set-count)"
stale_main_out="$(config_field "$stale_json" has-outbound-tag main-out)"
if [ "$stale_main_out" = "yes" ]; then
echo "FAIL: custom: a residual config.main_node switched the generator back to the"
echo " preset path (the generated config has main-out), so every routing_rule"
echo " and rule_set the user configured was dropped"
FAILED=1
elif [ "$stale_final" != "direct-out" ]; then
echo "FAIL: custom: route.final is '$stale_final', expected the fixture's custom"
echo " default_outbound (direct-out)"
FAILED=1
elif [ "$stale_rulesets" = "0" ]; then
echo "FAIL: custom: the fixture's local rule_set is missing from the generated config"
FAILED=1
else
echo "PASS: custom: a residual main_node does not override custom routing"
fi
fi
run_case server "$ROOT/tests/fixtures/generators/server.uci" generate_server.uc sing-box-s.json
# WireGuard is emitted as a sing-box endpoint, not an outbound, and it has its
# own builder. A3 converted the call sites to pass a Node but left
# generate_endpoint() reading flat UCI keys, so the key material silently
# disappeared and sing-box rejected the config. `sing-box check` alone is not
# a strong enough guard (a config with no server at all can still be valid),
# so assert the endpoint actually carries the fixture's keys.
run_case wireguard "$ROOT/tests/fixtures/generators/wireguard.uci" generate_client.uc sing-box-c.json
wg_json="$WORK/wireguard/run/sing-box-c.json"
if [ ! -f "$wg_json" ]; then
echo "FAIL: wireguard: no config was generated"
FAILED=1
else
if ! grep -qF '"type": "wireguard"' "$wg_json"; then
echo "FAIL: wireguard: no wireguard endpoint in the generated config"
FAILED=1
fi
if ! grep -qF 'iKaNuoWRQTFPD5V3OoMNdMshsMgU9t7rolJNpgNx+UM=' "$wg_json"; then
echo "FAIL: wireguard: the endpoint lost its private key"
FAILED=1
fi
if ! grep -qF 'DDcdTHUv0Q6XYDf9l93jzwwuoY/G1TC+g74QH0A9HmM=' "$wg_json"; then
echo "FAIL: wireguard: the peer lost its public key"
FAILED=1
fi
if ! grep -qF '"172.16.0.2/32"' "$wg_json"; then
echo "FAIL: wireguard: the endpoint lost its local address list"
FAILED=1
fi
fi
# A broken urltest candidate must be pruned, not fatal: the old behaviour was
# a die() that left the router with no configuration at all.
run_case partial_invalid "$ROOT/tests/fixtures/generators/partial_invalid.uci" generate_client.uc sing-box-c.json
pi_json="$WORK/partial_invalid/run/sing-box-c.json"
if [ ! -f "$pi_json" ]; then
echo "FAIL: partial_invalid: a single broken urltest node aborted the whole config"
FAILED=1
else
if ! grep -qF '"cfg-n_ok-out"' "$pi_json"; then
echo "FAIL: partial_invalid: the buildable candidate was dropped too"
FAILED=1
fi
if grep -qF '"cfg-n_broken-out"' "$pi_json"; then
echo "FAIL: partial_invalid: the broken candidate was emitted"
FAILED=1
fi
if ! grep -q "skipping urltest candidate 'n_broken'" "$WORK/partial_invalid/generate.err"; then
echo "FAIL: partial_invalid: the broken candidate was dropped without a warning"
FAILED=1
fi
fi
# A direct node as the main node leaves main-out with no fields of its own, and
# sing-box refuses to detour into an empty direct outbound - both the main-dns
# server and the rule-set http_client used to, so the service never started.
# `sing-box check` accepts the file (only `sing-box run` rejects it), so assert
# on the generated JSON rather than trusting check.
run_case direct_main "$ROOT/tests/fixtures/generators/direct_main.uci" generate_client.uc sing-box-c.json
dm_json="$WORK/direct_main/run/sing-box-c.json"
if [ ! -f "$dm_json" ]; then
echo "FAIL: direct_main: no config was generated"
FAILED=1
else
if ! grep -q '"tag": "main-out"' "$dm_json"; then
echo "FAIL: direct_main: main-out is missing from the generated config"
FAILED=1
fi
if grep -q '"detour": "main-out"' "$dm_json"; then
echo "FAIL: direct_main: something still detours into the empty direct main-out:"
grep -n '"detour": "main-out"' "$dm_json" | head -3
echo " sing-box run rejects it with 'detour to an empty direct outbound makes no sense'"
FAILED=1
fi
fi
# H3: a reference to a dns_server or ruleset that is disabled (or deleted) used
# to be emitted verbatim as `cfg-<name>-dns` / `cfg-<name>-rule`. Nothing
# defines those tags, so sing-box rejected the whole configuration with
# "dns server not found" / "rule-set not found" - and because the rejected
# generation never reaches the running service, the reload kept the previous
# configuration and the user only saw that their change did not take.
run_case_type_error dangling-resolver "does not exist or is disabled" \
"$ROOT/tests/fixtures/generators/custom.uci" generate_client.uc sing-box-c.json \
"s#domain_resolver 'default-dns'#domain_resolver 'rs_local_gone'#"
exit ${FAILED:-0}
@@ -0,0 +1,182 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Golden snapshot for the server inbound adapter (PR-04).
#
# Builds one inbound per protocol with tests/fixtures/generators/server.uci
# and diffs the result against tests/snapshots/generator/inbounds.json.
# Adding a protocol field, renaming a sing-box option or reordering the
# inbound field tables shows up as a reviewable diff instead of passing
# silently.
#
# This is the server-side counterpart of test_golden_outbounds.sh. Before
# PR-04 the server path had no snapshot at all, which is how the fixture's
# `listen_port` (an option neither the form nor the generator uses) stayed
# in place: the generated config simply had no listen_port and
# `sing-box check` accepted it.
#
# HP_UPDATE_SNAPSHOTS=1 sh tests/ucode/test_golden_inbounds.sh <repo-root>
# regenerates the snapshot (review the diff before committing it).
#
# Without sing-box on PATH the diff still runs but the schema check is only
# reported as NOT RUN (degraded). HP_REQUIRE_SINGBOX=1 turns that into a
# failure; it mirrors HP_REQUIRE_FW4 in test_firewall_template.sh.
#
# Usage: sh tests/ucode/test_golden_inbounds.sh <repo-root> [work-dir]
ROOT="${1:-.}"
WORK="${2:-/tmp/hp-golden-inbounds-test}"
ROOT="$(cd "$ROOT" && pwd)"
SNAPSHOT="$ROOT/tests/snapshots/generator/inbounds.json"
FIXTURE="$ROOT/tests/fixtures/generators/server.uci"
FAILED=0
rm -rf "$WORK"
mkdir -p "$WORK/scripts/config" "$WORK/scripts/parser"
# HP_VALIDATE_DATA lets a development host replace /sbin/validate_data; the
# loader itself does not validate, but homeproxy.uc is imported by the adapter.
VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
sed -e "s#/sbin/validate_data#${VALIDATE_DATA}#" \
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/scripts/homeproxy.uc"
cp "$ROOT/root/etc/homeproxy/scripts/config/loader.uc" "$WORK/scripts/config/"
cp "$ROOT/root/etc/homeproxy/scripts/config/model.uc" "$WORK/scripts/config/"
cp "$ROOT/root/etc/homeproxy/scripts/config/adapter.uc" "$WORK/scripts/config/"
cp "$ROOT/root/etc/homeproxy/scripts/parser/"*.uc "$WORK/scripts/parser/"
cp "$FIXTURE" "$WORK/scripts/config/homeproxy"
# One inbound per server section, keyed by the UCI section name so the
# snapshot is stable across runs and only changes when the adapter or the
# fixture changes.
#
# The ACME provider carries an absolute data_directory (HP_DIR + '/certs'),
# which differs between the work dir and a target device. Normalise it to a
# placeholder so the snapshot is portable; this is the same "path-only
# difference" allowance the outbound snapshot relies on.
# ucode resolves module-level names lexically, so the helper has to be
# declared above its caller.
cat > "$WORK/scripts/golden.uc" <<'EOF'
'use strict';
import { InboundFactory } from './config/adapter.uc';
import { Loader } from './config/loader.uc';
/* Replace the absolute certs directory with a placeholder. */
function rewrite_paths(tls) {
if (tls && tls.certificate_provider)
tls.certificate_provider.data_directory = '<HP_DIR>/certs';
return tls;
}
const dm = Loader.load('./config');
const inbounds = {};
for (let inbound in (dm.server.inbounds || [])) {
const built = InboundFactory.tryCreate(inbound);
if (length(built.problems))
die(sprintf('inbound %s: %s\n', inbound.id, join('; ', built.problems)));
built.inbound.tls = rewrite_paths(built.inbound.tls);
inbounds[inbound.id] = built.inbound;
}
printf('%.J\n', inbounds);
EOF
if ! ( cd "$WORK/scripts" && ucode -L "$WORK/scripts" golden.uc > "$WORK/inbounds.json" 2> "$WORK/golden.err" ); then
echo "FAIL: could not build the protocol inbounds"
head -8 "$WORK/golden.err"
exit 1
fi
if [ ! -s "$WORK/inbounds.json" ]; then
echo "FAIL: the adapter produced no inbounds (fixture not loaded?)"
exit 1
fi
# An empty result is the failure mode of a UCI syntax error in the fixture:
# uci.load() yields nothing and the adapter happily builds zero inbounds.
protocols="$(grep -c '"tag"' "$WORK/inbounds.json")"
if [ "$protocols" -lt 6 ]; then
echo "FAIL: only $protocols protocol inbounds were built; the fixture did not load correctly"
head -5 "$WORK/inbounds.json"
exit 1
fi
if [ "${HP_UPDATE_SNAPSHOTS:-0}" = "1" ]; then
mkdir -p "$(dirname "$SNAPSHOT")"
cp "$WORK/inbounds.json" "$SNAPSHOT"
echo "UPDATED: ${SNAPSHOT#"$ROOT"/}"
exit 0
fi
if [ ! -s "$SNAPSHOT" ]; then
echo "FAIL: $SNAPSHOT is missing; regenerate it with HP_UPDATE_SNAPSHOTS=1"
exit 1
fi
# `cmp` rather than `diff` for the verdict: busybox on the target has no
# `diff`, and this suite runs there as well as on a development host.
if cmp -s "$SNAPSHOT" "$WORK/inbounds.json"; then
echo "PASS: $protocols protocol inbounds match the golden snapshot"
else
echo "FAIL: the generated inbounds differ from the golden snapshot"
if command -v diff > "/dev/null" 2>&1; then
diff -u "$SNAPSHOT" "$WORK/inbounds.json" | head -40
else
echo " (no 'diff' here; compare ${SNAPSHOT#"$ROOT"/} with $WORK/inbounds.json)"
fi
echo " (review the change; if it is intended, re-run with HP_UPDATE_SNAPSHOTS=1)"
FAILED=1
fi
# The snapshot diff catches a *change*; this catches a *wrong* value that was
# snapshotted. Every emitted inbound is handed to the real schema checker.
#
# The data_directory placeholder is only valid inside the snapshot, so the
# schema check runs against the un-rewritten config.
if command -v sing-box > "/dev/null" 2>&1; then
cat > "$WORK/scripts/schema.uc" <<'EOF'
'use strict';
import { writefile } from 'fs';
import { InboundFactory } from './config/adapter.uc';
import { Loader } from './config/loader.uc';
const dm = Loader.load('./config');
const list = [];
for (let inbound in (dm.server.inbounds || []))
push(list, InboundFactory.create(inbound));
writefile('schema.json', sprintf('%.J\n', { inbounds: list }));
EOF
if ( cd "$WORK/scripts" && ucode -L "$WORK/scripts" schema.uc ) \
&& sing-box check --config "$WORK/scripts/schema.json"; then
echo "PASS: sing-box check accepted every golden inbound"
else
echo "FAIL: sing-box rejected one of the golden inbounds"
FAILED=1
fi
else
echo "NOT RUN: sing-box is not on PATH, schema check skipped"
# A skipped schema check must not read as "verified". The snapshot diff
# above catches a *change* and says nothing about whether the emitted JSON
# is one sing-box would accept, so say plainly that this run is degraded.
# HP_REQUIRE_SINGBOX=1 (the same shape as HP_REQUIRE_FW4 in
# test_firewall_template.sh) turns the skip into a failure for callers that
# know sing-box must be there - a target always has it.
echo " DEGRADED: the golden inbounds were only diffed against the"
echo " snapshot, not verified against the real sing-box schema"
if [ "${HP_REQUIRE_SINGBOX:-0}" = "1" ]; then
echo "FAIL: HP_REQUIRE_SINGBOX=1 but sing-box is not on PATH, so the"
echo " golden inbounds are unverified"
FAILED=1
fi
fi
exit $FAILED
+183
View File
@@ -0,0 +1,183 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Golden snapshot for the protocol adapter.
#
# Builds one outbound per protocol with tests/fixtures/generators/outbounds.uci
# and diffs the result against tests/snapshots/generator/outbounds.json.
# Adding a protocol field, renaming a sing-box option or reordering the field
# tables shows up as a reviewable diff instead of passing silently.
#
# This replaces tests/ucode/test_demo_architecture.sh, which imported the
# production OutboundFactory and compared it against generate_outbound() - the
# same function - so it could never fail.
#
# HP_UPDATE_SNAPSHOTS=1 sh tests/ucode/test_golden_outbounds.sh <repo-root>
# regenerates the snapshot (review the diff before committing it).
#
# Without sing-box on PATH the diff still runs but the schema check is only
# reported as NOT RUN (degraded). HP_REQUIRE_SINGBOX=1 turns that into a
# failure; it mirrors HP_REQUIRE_FW4 in test_firewall_template.sh.
#
# Usage: sh tests/ucode/test_golden_outbounds.sh <repo-root> [work-dir]
ROOT="${1:-.}"
WORK="${2:-/tmp/hp-golden-test}"
ROOT="$(cd "$ROOT" && pwd)"
SNAPSHOT="$ROOT/tests/snapshots/generator/outbounds.json"
FIXTURE="$ROOT/tests/fixtures/generators/outbounds.uci"
FAILED=0
rm -rf "$WORK"
mkdir -p "$WORK/scripts/config"
# HP_VALIDATE_DATA lets a development host replace /sbin/validate_data; the
# loader itself does not validate, but homeproxy.uc is imported by the adapter.
VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
sed -e "s#/sbin/validate_data#${VALIDATE_DATA}#" \
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/scripts/homeproxy.uc"
cp "$ROOT/root/etc/homeproxy/scripts/config/loader.uc" "$WORK/scripts/config/"
cp "$ROOT/root/etc/homeproxy/scripts/config/model.uc" "$WORK/scripts/config/"
cp "$ROOT/root/etc/homeproxy/scripts/config/adapter.uc" "$WORK/scripts/config/"
# PR-02: config/loader.uc imports '../parser/mapping.uc'; stage the
# parser tree as a sibling of config/ or nothing loads.
mkdir -p "$WORK/scripts/parser"
cp "$ROOT/root/etc/homeproxy/scripts/parser/"*.uc "$WORK/scripts/parser/"
cp "$FIXTURE" "$WORK/scripts/config/homeproxy"
# One outbound per node, in fixture order. %.J keeps the JSON value-exact and
# the key order is the field-table order, so the snapshot is stable across
# runs and only changes when the adapter changes.
cat > "$WORK/scripts/golden.uc" <<'EOF'
'use strict';
import { Loader } from './config/loader.uc';
import { OutboundFactory } from './config/adapter.uc';
const dm = Loader.load('./config');
const outbounds = {};
for (let node in dm.nodes)
outbounds[node.id] = OutboundFactory.create(node, dm.infra.self_mark);
printf('%.J\n', outbounds);
EOF
if ! ( cd "$WORK/scripts" && ucode -L "$WORK/scripts" golden.uc > "$WORK/outbounds.json" 2> "$WORK/golden.err" ); then
echo "FAIL: could not build the protocol outbounds"
head -8 "$WORK/golden.err"
exit 1
fi
if [ ! -s "$WORK/outbounds.json" ]; then
echo "FAIL: the adapter produced no outbounds (fixture not loaded?)"
exit 1
fi
# An empty result is the failure mode of a UCI syntax error in the fixture
# (e.g. a C-style comment, which UCI does not accept): uci.load() yields
# nothing and the adapter happily builds zero outbounds. Fail loudly instead.
protocols="$(grep -c '"tag"' "$WORK/outbounds.json")"
if [ "$protocols" -lt 10 ]; then
echo "FAIL: only $protocols protocol outbounds were built; the fixture did not load correctly"
head -5 "$WORK/outbounds.json"
exit 1
fi
# The runtime-owned fields the literal in build_outbound() sets are applied
# BEFORE the COMMON_FIELDS table, so listing one of them there (even as null)
# silently deletes it. routing_mark is exactly that case: without it sing-box
# does not mark its own sockets and the nft redirect chain loops the proxy
# connection back into sing-box's own redirect inbound.
#
# Deliberately checked against the emitted JSON rather than through
# `sing-box check`: routing_mark is a Linux-only field, so the schema check
# below accepts it on Linux and rejects it elsewhere, which is how this
# regression survived a green suite. Asserting on the bytes is
# platform-independent.
SELF_MARK="$(sed -n "s/^[[:space:]]*option self_mark '\([0-9]*\)'.*/\1/p" "$FIXTURE" | head -1)"
if [ -z "$SELF_MARK" ]; then
echo "FAIL: $FIXTURE declares no infra.self_mark, so the mark cannot be asserted"
exit 1
fi
marked="$(grep -c "\"routing_mark\": $SELF_MARK" "$WORK/outbounds.json")"
if [ "$marked" -ne "$protocols" ]; then
echo "FAIL: $protocols node outbounds were built, but only $marked carry routing_mark=$SELF_MARK"
echo " every node outbound must carry the runtime mark (see COMMON_FIELDS in"
echo " config/adapter.uc): without it the nft OUTPUT redirect chain sends"
echo " sing-box's own proxy connection back into its redirect inbound"
exit 1
fi
echo "PASS: every node outbound carries routing_mark=$SELF_MARK"
if [ "${HP_UPDATE_SNAPSHOTS:-0}" = "1" ]; then
mkdir -p "$(dirname "$SNAPSHOT")"
cp "$WORK/outbounds.json" "$SNAPSHOT"
echo "UPDATED: ${SNAPSHOT#"$ROOT"/}"
exit 0
fi
if [ ! -s "$SNAPSHOT" ]; then
echo "FAIL: $SNAPSHOT is missing; regenerate it with HP_UPDATE_SNAPSHOTS=1"
exit 1
fi
# `cmp` rather than `diff` for the verdict: busybox on the target has no
# `diff`, and this suite runs there as well as on a development host.
if cmp -s "$SNAPSHOT" "$WORK/outbounds.json"; then
echo "PASS: $protocols protocol outbounds match the golden snapshot"
else
echo "FAIL: the generated outbounds differ from the golden snapshot"
if command -v diff > "/dev/null" 2>&1; then
diff -u "$SNAPSHOT" "$WORK/outbounds.json" | head -40
else
echo " (no 'diff' here; compare ${SNAPSHOT#"$ROOT"/} with $WORK/outbounds.json)"
fi
echo " (review the change; if it is intended, re-run with HP_UPDATE_SNAPSHOTS=1)"
FAILED=1
fi
# The snapshot diff catches a *change*; this catches a *wrong* value that was
# snapshotted. Every emitted outbound is handed to the real schema checker.
if command -v sing-box > "/dev/null" 2>&1; then
cp "$WORK/outbounds.json" "$WORK/scripts/outbounds.json"
cat > "$WORK/scripts/wrap.uc" <<'EOF'
'use strict';
import { readfile, writefile } from 'fs';
const outbounds = json(readfile('outbounds.json'));
const list = [];
for (let id in keys(outbounds))
push(list, outbounds[id]);
writefile('schema.json', sprintf('%.J\n', { outbounds: list }));
EOF
if ( cd "$WORK/scripts" && ucode -L "$WORK/scripts" wrap.uc ) \
&& sing-box check --config "$WORK/scripts/schema.json"; then
echo "PASS: sing-box check accepted every golden outbound"
else
echo "FAIL: sing-box rejected one of the golden outbounds"
FAILED=1
fi
else
echo "NOT RUN: sing-box is not on PATH, schema check skipped"
# A skipped schema check must not read as "verified". The snapshot diff
# above catches a *change* and says nothing about whether the emitted JSON
# is one sing-box would accept, so say plainly that this run is degraded.
# HP_REQUIRE_SINGBOX=1 (the same shape as HP_REQUIRE_FW4 in
# test_firewall_template.sh) turns the skip into a failure for callers that
# know sing-box must be there - a target always has it.
echo " DEGRADED: the golden outbounds were only diffed against the"
echo " snapshot, not verified against the real sing-box schema"
if [ "${HP_REQUIRE_SINGBOX:-0}" = "1" ]; then
echo "FAIL: HP_REQUIRE_SINGBOX=1 but sing-box is not on PATH, so the"
echo " golden outbounds are unverified"
FAILED=1
fi
fi
exit $FAILED
@@ -0,0 +1,155 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*
* Regression tests for homeproxy.uc helpers, focused on executeCommand():
* return shape, stderr/exit-code capture, binary detection and (most
* importantly) that the temporary descriptors are closed on every run.
*/
'use strict';
import { lsdir } from 'fs';
import { executeCommand, isValidPEM, redactReason, redactUrl, shellQuote, wGETVerbose } 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 fd_count() {
let n = 0;
for (let _entry in lsdir('/proc/self/fd'))
n++;
return n;
}
/* successful command */
const ok = executeCommand('echo', 'hello');
expect('ok.exitcode', ok.exitcode, 0);
expect('ok.stdout', ok.stdout, 'hello\n');
expect('ok.stderr', ok.stderr, '');
expect('ok.binary', ok.binary, false);
expect('ok.command', ok.command, 'echo hello');
/* failing command keeps stderr and the exit code */
const bad = executeCommand('sh', '-c', shellQuote('echo oops >&2; exit 3'));
expect('bad.exitcode', bad.exitcode, 3);
expect('bad.stderr', bad.stderr, 'oops\n');
expect('bad.stdout', bad.stdout, '');
/* nondescript exit code */
expect('false.exitcode', executeCommand('false').exitcode, 1);
/* binary output is detected and withheld */
const bin = executeCommand('sh', '-c', shellQuote('printf "\\001\\002\\003"'));
expect('bin.binary', bin.binary, true);
expect('bin.stdout', bin.stdout, null);
/* executeCommand() must be able to return one byte past HP_FETCH_CAP, or
* wGETVerbose()'s "response exceeds the limit" branch can never fire: the
* reader used to stop at 512 KiB, so every subscription between 512 KiB and
* 5 MiB arrived at the parser as a truncated body with error: null - silently
* truncated, and reported as complete. 600 KiB sits between the old reader
* cap and HP_FETCH_CAP, so this fails on that code and passes on the fix. */
const BIG_LEN = 600 * 1024;
const big = executeCommand('sh', '-c', shellQuote('head -c ' + BIG_LEN + ' /dev/zero | tr "\\0" a'));
expect('big.exitcode', big.exitcode, 0);
expect('big.stdout.length', length(big.stdout || ''), BIG_LEN);
/* isValidPEM(): certificate vs private key, boundaries and body */
const pem_cert = '-----BEGIN CERTIFICATE-----\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n-----END CERTIFICATE-----';
const pem_key = '-----BEGIN RSA PRIVATE KEY-----\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n-----END RSA PRIVATE KEY-----';
expect('pem.cert', isValidPEM(pem_cert, false), true);
expect('pem.cert-as-key', isValidPEM(pem_cert, true), false);
expect('pem.key', isValidPEM(pem_key, true), true);
expect('pem.key-as-cert', isValidPEM(pem_key, false), false);
expect('pem.garbage', isValidPEM('not a pem at all', false), false);
expect('pem.empty', isValidPEM('', false), false);
/* wGETVerbose() must not hand wget an option the target's wget does not have.
* It used to pass --max-filesize, which busybox wget does not support: wget
* exited 2 with "unrecognized option" before making a request, so every
* subscription fetch failed while the test suite stayed green (the fetcher
* tests mock wGETVerbose itself). A closed local port makes the fetch fail
* fast either way; what is asserted is that it fails for a network reason and
* not a usage one. */
const wget = wGETVerbose('http://127.0.0.1:1/never');
expect('wget.not-a-usage-error',
match(wget.error || '', /unrecognized option|invalid option|Usage:/) == null, true);
expect('wget.reports-a-reason', length(wget.error || '') > 0, true);
expect('wget.no-content-on-failure', wget.content, null);
/* Review H3: an HTTP-level wget failure echoes the target, query string and
* all, so `<URL>?token=secret: 404 Not Found` is what would reach the log.
* A *connection* failure is the cheap case to reproduce here, but wget prints
* only `failed: Connection refused.` for it - no URL - so this fetch proves
* the end-to-end path hands back no token, not that redaction ran. The
* redactReason() block below covers redaction itself, using the shapes wget
* actually emits. */
const tokwget = wGETVerbose('http://127.0.0.1:1/?token=secret');
expect('wget.token-not-in-error',
match(tokwget.error || '', /token=secret/) == null, true);
/* redactReason(): central redaction that protects every wGETVerbose caller.
* Tested in isolation so the assertion does not depend on wget being
* present - this runs anywhere ucode runs. */
{
/* Canonical wget failure shape: '<URL>: <reason>' - the URL must lose
* its query string and userinfo. */
const r1 = redactReason('https://user:tok@host.example.com/path?q=token=secret: Bad port \'80080\'.');
expect('redactReason.query',
match(r1, /\?\*\*\*/) != null, true);
expect('redactReason.userinfo',
match(r1, /user:tok/) == null, true);
expect('redactReason.host-preserved',
match(r1, /host\.example\.com/) != null, true);
expect('redactReason.reason-preserved',
match(r1, /Bad port/) != null, true);
/* A second URL in the same message - both must be redacted. */
const r2 = redactReason('first https://a.example/?token=A then https://b.example/?token=B end');
expect('redactReason.both-redacted',
match(r2, /token=A/) == null && match(r2, /token=B/) == null, true);
/* No URL at all: returned unchanged. */
expect('redactReason.no-url-unchanged',
redactReason('wget: bad port'), 'wget: bad port');
/* Empty / non-string: returned unchanged (the function is safe to
* apply to the trimmed stderr without a guard). */
expect('redactReason.empty', redactReason(''), '');
expect('redactReason.null', redactReason(null), null);
expect('redactReason.non-string', redactReason(123), 123);
/* URL with port: the port must survive (the path is what we keep;
* only query and userinfo go). */
const r3 = redactReason('wget: https://host:80080/path?q=token=secret: failed');
expect('redactReason.port-survives',
match(r3, /host:80080/) != null, true);
expect('redactReason.port-query-redacted',
match(r3, /token=secret/) == null, true);
}
/* descriptors must not leak across calls */
const before = fd_count();
for (let i = 0; i < 200; i++)
executeCommand('true');
const after = fd_count();
checks++;
if (after > before) {
printf('FAIL descriptor leak: %d -> %d after 200 calls\n', before, after);
failures++;
}
printf('%d checks, %d failures\n', checks, failures);
exit(failures === 0 ? 0 : 1);
@@ -0,0 +1,53 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*
* Exceptional-path test for executeCommand(): the copy of homeproxy.uc staged
* next to this file has its `system()` call replaced by `die()`, so every call
* throws before the descriptors are read. ucode has no `finally`, so this
* checks that the catch block still closes both descriptors.
*/
'use strict';
import { lsdir } from 'fs';
import { executeCommand } from 'homeproxy';
let failures = 0;
function fd_count() {
let n = 0;
for (let _entry in lsdir('/proc/self/fd'))
n++;
return n;
}
const before = fd_count();
let caught = 0;
for (let i = 0; i < 50; i++) {
try {
executeCommand('true');
} catch (e) {
caught++;
}
}
const after = fd_count();
if (caught !== 50) {
printf('FAIL: expected 50 propagated exceptions, got %d\n', caught);
failures++;
}
if (after > before) {
printf('FAIL descriptor leak on the exceptional path: %d -> %d\n', before, after);
failures++;
}
if (failures === 0)
printf('PASS: injected failure propagated and both descriptors were closed\n');
exit(failures === 0 ? 0 : 1);
@@ -0,0 +1,457 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* PR-04 (Protocol Adapter Completion): unit tests for InboundFactory, the
* server-side half of the adapter.
*
* The inbound golden snapshot (tests/ucode/test_golden_inbounds.sh) pins
* what the adapter *emits* for the fixture's seven protocols. This test
* covers the decisions that a snapshot cannot express:
*
* - a protocol with no users[] block (snell, shadowsocks) must not get
* one: sing-box interprets a snell users entry as an extra user key
* and rejects the config outright ("snell: bad user key")
* - vless / vmess carry flow / alterId per user, never top-level
* - the snell listener set omits udp_fragment / udp_timeout / network
* even when the section carries them
* - REQUIRED_INBOUND_CREDENTIALS is enforced by problems()/buildable()
* - the server-only TLS tail reaches buildTLSObject() under the UCI
* names it reads (key_path, ech_key, reality, ACME)
* - hysteria v1 emits obfs as a string while hysteria2 emits the
* {type, password, ...} object
*
* Run through tests/ucode/run.sh, which stages config/ + parser/ next to
* the homeproxy test double.
*/
'use strict';
import { Inbound, INBOUND_CREDENTIALS, INBOUND_OPTIONS } from './config/model.uc';
import { InboundFactory } from './config/adapter.uc';
let failures = 0, checks = 0;
function expect(name, actual, expected) {
checks++;
if (sprintf('%J', actual) !== sprintf('%J', expected)) {
printf('FAIL %s: expected %J, got %J\n', name, expected, actual);
failures++;
}
}
function not_in(name, obj, key) {
checks++;
if (key in obj) {
printf('FAIL %s: %s should be absent, got %J\n', name, key, obj[key]);
failures++;
}
}
/* Build an Inbound the way the Loader would, from a flat option map.
* `opts` uses UCI option names; the tables in model.uc do the renaming,
* so this helper exercises the same canonical boundary the Loader does. */
function make_inbound(id, type, opts) {
const get = (name) => (name in opts) ? opts[name] : null;
const credentials = {};
const protocol_options = {};
for (let canonical, uci in (INBOUND_CREDENTIALS[type] || {}))
credentials[canonical] = get(uci);
for (let canonical, uci in (INBOUND_OPTIONS[type] || {}))
protocol_options[canonical] = get(uci);
const tls = {
enabled: get('tls'),
server_name: get('tls_sni'),
insecure: get('tls_insecure'),
alpn: get('tls_alpn'),
min_version: get('tls_min_version'),
max_version: get('tls_max_version'),
handshake_timeout: get('tls_handshake_timeout'),
cipher_suites: get('tls_cipher_suites'),
cert_path: get('tls_cert_path'),
ech: {
enabled: get('tls_ech'),
config: get('tls_ech_config'),
config_path: get('tls_ech_config_path')
},
utls: { fingerprint: get('tls_utls') },
reality: {
enabled: get('tls_reality'),
public_key: get('tls_reality_public_key'),
short_id: get('tls_reality_short_id')
}
};
return Inbound.create({
id: id,
name: opts.label || id,
type: type,
enabled: true,
address: get('address'),
port: get('port'),
firewall: get('firewall'),
common: {
bind_interface: get('bind_interface'),
reuse_addr: get('reuse_addr'),
tcp_fast_open: get('tcp_fast_open'),
tcp_multi_path: get('tcp_multi_path'),
udp_fragment: get('udp_fragment'),
udp_timeout: get('udp_timeout'),
network: get('network')
},
credentials: credentials,
tls: tls,
tls_server: {
key_path: get('tls_key_path'),
ech_key: get('tls_ech_key'),
reality_private_key: get('tls_reality_private_key'),
reality_max_time_difference: get('tls_reality_max_time_difference'),
reality_server_addr: get('tls_reality_server_addr'),
reality_server_port: get('tls_reality_server_port'),
acme: get('tls_acme'),
acme_domain: get('tls_acme_domain'),
acme_email: get('tls_acme_email'),
acme_provider: get('tls_acme_provider'),
dns01_challenge: get('tls_dns01_challenge'),
dns01_provider: get('tls_dns01_provider')
},
transport: {
type: get('transport'),
host: get('ws_host') || get('httpupgrade_host') || get('http_host'),
path: get('ws_path') || get('http_path'),
headers: get('ws_host') ? { Host: get('ws_host') } : null,
service_name: get('grpc_servicename')
},
multiplex: {
enabled: get('multiplex'),
padding: get('multiplex_padding'),
brutal: {
enabled: get('multiplex_brutal'),
up_mbps: get('multiplex_brutal_up'),
down_mbps: get('multiplex_brutal_down')
}
},
protocol_options: protocol_options
});
}
/* --- snell: no users[], reduced listener set ------------------------- */
{
/* Deliberately set the fields snell must not be given, so the test
* fails if INBOUND_COMMON_OMIT is ever dropped. */
const snell = make_inbound('s_snell', 'snell', {
port: '8448',
password: 'pskvalue',
snell_version: '5',
snell_obfs_mode: 'http',
udp_fragment: '1',
udp_timeout: '300',
network: 'tcp'
});
const out = InboundFactory.create(snell);
expect('snell: type', out.type, 'snell');
expect('snell: tag', out.tag, 'cfg-s_snell-in');
expect('snell: listen defaults to ::', out.listen, '::');
expect('snell: listen_port', out.listen_port, 8448);
expect('snell: psk', out.psk, 'pskvalue');
expect('snell: version', out.version, 5);
expect('snell: obfs_mode', out.obfs_mode, 'http');
not_in('snell', out, 'users');
not_in('snell', out, 'udp_fragment');
not_in('snell', out, 'udp_timeout');
not_in('snell', out, 'network');
/* no `mode`: v6-only, sing-box 1.14 rejects it. */
not_in('snell', out, 'mode');
}
/* --- shadowsocks: no users[], top-level method + password ------------ */
{
const ss = make_inbound('s_ss', 'shadowsocks', {
address: '10.0.0.1',
port: '8447',
password: 'secret',
shadowsocks_encrypt_method: 'aes-256-gcm',
network: 'tcp',
udp_timeout: '300'
});
const out = InboundFactory.create(ss);
expect('ss: listen honours the address', out.listen, '10.0.0.1');
expect('ss: method', out.method, 'aes-256-gcm');
expect('ss: password', out.password, 'secret');
expect('ss: network is kept', out.network, 'tcp');
expect('ss: udp_timeout is a duration', out.udp_timeout, '300s');
not_in('ss', out, 'users');
}
/* --- vless: flow is per-user, never top-level ------------------------ */
{
const vless = make_inbound('s_vless', 'vless', {
port: '443',
uuid: 'uuid-x',
vless_flow: 'xtls-rprx-vision',
tls: '1',
tls_sni: 'v.example.com'
});
const out = InboundFactory.create(vless);
expect('vless: user name', out.users[0].name, 'cfg-s_vless-server');
expect('vless: user uuid', out.users[0].uuid, 'uuid-x');
expect('vless: user flow', out.users[0].flow, 'xtls-rprx-vision');
not_in('vless', out, 'flow');
not_in('vless', out, 'uuid');
expect('vless: tls enabled', out.tls.enabled, true);
expect('vless: tls server_name', out.tls.server_name, 'v.example.com');
}
/* --- vmess: alterId is per-user, never top-level --------------------- */
{
const vmess = make_inbound('s_vmess', 'vmess', {
port: '443',
uuid: 'uuid-y',
vmess_alterid: '1'
});
const out = InboundFactory.create(vmess);
expect('vmess: user alterId', out.users[0].alterId, 1);
not_in('vmess', out, 'alterId');
not_in('vmess', out, 'alter_id');
/* vmess_alterid='0' is the modern (AEAD) setting, but the shared
* strToInt() helper maps '0' to null (`int('0') || null`), so the
* field is omitted and sing-box applies its own default. That is
* pre-existing behaviour of the common helper, not something PR-04
* changes; pinned here so a future strToInt() fix shows up as a
* deliberate diff rather than a surprise. */
const vmess0 = InboundFactory.create(make_inbound('s_vmess0', 'vmess', {
port: '443',
uuid: 'uuid-y',
vmess_alterid: '0'
}));
not_in('vmess0: user alterId', vmess0.users[0], 'alterId');
}
/* --- http / mixed / socks / naive: user has no name ------------------ */
{
const http = make_inbound('s_http', 'http', {
port: '8080',
username: 'u',
password: 'p'
});
const out = InboundFactory.create(http);
expect('http: user username', out.users[0].username, 'u');
expect('http: user has no name', out.users[0].name, null);
}
/* --- hysteria v1 vs hysteria2 obfs shape ----------------------------- */
{
const hy1 = make_inbound('s_hy1', 'hysteria', {
port: '36712',
hysteria_auth_type: 'string',
hysteria_auth_payload: 'secret',
hysteria_obfs_password: 'obfspass',
hysteria_up_mbps: '100',
hysteria_down_mbps: '500',
hysteria_masquerade: 'https://example.com',
tls: '1',
tls_sni: 'h.example.com'
});
const out = InboundFactory.create(hy1);
expect('hy1: obfs is a plain string', out.obfs, 'obfspass');
expect('hy1: up_mbps', out.up_mbps, 100);
expect('hy1: down_mbps', out.down_mbps, 500);
expect('hy1: masquerade', out.masquerade, 'https://example.com');
expect('hy1: user auth_str', out.users[0].auth_str, 'secret');
not_in('hy1: user auth', out.users[0], 'auth');
}
{
const hy2 = make_inbound('s_hy2', 'hysteria2', {
port: '8445',
password: 'secret',
hysteria_obfs_type: 'salamander',
hysteria_obfs_password: 'obfspass',
hysteria_obfs_min_packet_size: '512',
hysteria_obfs_max_packet_size: '1200'
});
const out = InboundFactory.create(hy2);
expect('hy2: obfs.type', out.obfs.type, 'salamander');
expect('hy2: obfs.password', out.obfs.password, 'obfspass');
expect('hy2: obfs.min_packet_size', out.obfs.min_packet_size, 512);
expect('hy2: obfs.max_packet_size', out.obfs.max_packet_size, 1200);
/* auth is not modelled on hysteria2's options row, so neither
* spelling is emitted. */
not_in('hy2: user auth', out.users[0], 'auth');
not_in('hy2: user auth_str', out.users[0], 'auth_str');
}
/* --- tuic ------------------------------------------------------------ */
{
const tuic = make_inbound('s_tuic', 'tuic', {
port: '8449',
uuid: 'uuid-z',
password: 'pw',
tuic_congestion_control: 'bbr',
tuic_auth_timeout: '3',
tuic_enable_zero_rtt: '1',
tuic_heartbeat: '10',
tls: '1',
tls_sni: 't.example.com'
});
const out = InboundFactory.create(tuic);
expect('tuic: congestion_control', out.congestion_control, 'bbr');
expect('tuic: auth_timeout', out.auth_timeout, '3s');
expect('tuic: zero_rtt_handshake', out.zero_rtt_handshake, true);
expect('tuic: heartbeat', out.heartbeat, '10s');
expect('tuic: user uuid', out.users[0].uuid, 'uuid-z');
}
/* --- anytls: padding_scheme ----------------------------------------- */
{
const anytls = make_inbound('s_anytls', 'anytls', {
port: '8444',
password: 'secret',
anytls_padding_scheme: 'stop=8'
});
const out = InboundFactory.create(anytls);
expect('anytls: padding_scheme', out.padding_scheme, 'stop=8');
expect('anytls: user password', out.users[0].password, 'secret');
}
/* --- multiplex (server shape: no dialling knobs) -------------------- */
{
const trojan = make_inbound('s_trojan', 'trojan', {
port: '8446',
password: 'secret',
multiplex: '1',
multiplex_padding: '1'
});
const out = InboundFactory.create(trojan);
expect('trojan: multiplex enabled', out.multiplex.enabled, true);
expect('trojan: multiplex padding', out.multiplex.padding, true);
/* The server side has no protocol / max_connections / min_streams /
* max_streams: those are client dialling knobs. */
not_in('trojan: multiplex', out.multiplex, 'protocol');
not_in('trojan: multiplex', out.multiplex, 'max_connections');
not_in('trojan: multiplex', out.multiplex, 'min_streams');
not_in('trojan: multiplex', out.multiplex, 'max_streams');
}
/* --- the server-only TLS tail reaches buildTLSObject() --------------- */
{
const inbound = make_inbound('s_tls', 'vless', {
port: '443',
uuid: 'u',
tls: '1',
tls_sni: 't.example.com',
tls_key_path: '/etc/homeproxy/certs/t.key',
tls_ech_key: 'ECHKEY',
tls_acme: '1',
tls_acme_domain: 't.example.com',
tls_acme_email: 'admin@example.com',
tls_acme_provider: 'letsencrypt'
});
const out = InboundFactory.create(inbound);
expect('tls: key_path', out.tls.key_path, '/etc/homeproxy/certs/t.key');
expect('tls: ech.key', out.tls.ech.key, ['ECHKEY']);
expect('tls: acme type', out.tls.certificate_provider.type, 'acme');
expect('tls: acme domain is an array', out.tls.certificate_provider.domain, ['t.example.com']);
expect('tls: acme email', out.tls.certificate_provider.email, 'admin@example.com');
expect('tls: acme provider', out.tls.certificate_provider.provider, 'letsencrypt');
/* A client-side field must not appear on a server inbound. */
not_in('tls', out.tls, 'insecure');
not_in('tls', out.tls, 'utls');
}
{
/* reality: the server side gets the private key + the handshake
* address, the client side's public key stays out. */
const inbound = make_inbound('s_reality', 'vless', {
port: '443',
uuid: 'u',
tls: '1',
tls_reality: '1',
tls_sni: 'r.example.com',
tls_reality_private_key: 'PRIVKEY',
tls_reality_short_id: 'abcd',
tls_reality_server_addr: 'hw.example.com',
tls_reality_server_port: '443'
});
const out = InboundFactory.create(inbound);
expect('reality: private_key', out.tls.reality.private_key, 'PRIVKEY');
expect('reality: short_id', out.tls.reality.short_id, 'abcd');
expect('reality: handshake server', out.tls.reality.handshake.server, 'hw.example.com');
expect('reality: handshake server_port', out.tls.reality.handshake.server_port, 443);
not_in('reality', out.tls.reality, 'public_key');
}
/* --- validation ------------------------------------------------------ */
expect('vless without uuid is not buildable',
InboundFactory.buildable(make_inbound('x', 'vless', { port: '443' })), false);
expect('vless with uuid is buildable',
InboundFactory.buildable(make_inbound('x', 'vless', { port: '443', uuid: 'u' })), true);
expect('snell without psk is not buildable',
InboundFactory.buildable(make_inbound('x', 'snell', { port: '443' })), false);
expect('snell with psk is buildable',
InboundFactory.buildable(make_inbound('x', 'snell', { port: '443', password: 'k' })), true);
expect('shadowsocks without password is not buildable',
InboundFactory.buildable(make_inbound('x', 'shadowsocks', { port: '443' })), false);
expect('http needs no credential',
InboundFactory.buildable(make_inbound('x', 'http', { port: '8080' })), true);
{
const bad = InboundFactory.tryCreate(make_inbound('s_bad', 'vless', { port: '443' }));
expect('tryCreate returns no inbound on problems', bad.inbound, null);
expect('tryCreate names the missing credential',
length(filter(bad.problems, (p) => match(p, /requires uuid/))), 1);
}
{
/* The port check is protocol-agnostic and lives on Inbound.validate(). */
const bad = InboundFactory.tryCreate(make_inbound('s_badport', 'http', { port: '99999' }));
expect('tryCreate rejects an out-of-range port', bad.inbound, null);
expect('tryCreate reports the port',
length(filter(bad.problems, (p) => match(p, /invalid port/))), 1);
}
/* --- transport ------------------------------------------------------- */
{
const ws = make_inbound('s_ws', 'vless', {
port: '443',
uuid: 'u',
transport: 'ws',
ws_host: 'a.example.com',
ws_path: '/ws'
});
const out = InboundFactory.create(ws);
expect('transport: type', out.transport.type, 'ws');
expect('transport: path', out.transport.path, '/ws');
expect('transport: host from headers', out.transport.host, 'a.example.com');
}
printf('%d checks, %d failures\n', checks, failures);
exit(failures === 0 ? 0 : 1);
@@ -0,0 +1,170 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Stage PHASE 9 close-out item: migrate_config.uc has never had a
# direct test. The migration runs on every UCI commit and was only
# ever exercised by full upgrade tests on a target, so a developer
# could break a step and never see it fail.
#
# The runner stages a sandboxed UCI dir with a file that needs every
# migration step it can see at once:
#
# infra.china_dns_port -> deleted (chinadns-ng removed)
# config.china_dns_server -> 'wan_114' mapped to 114.114.114.114
# infra.github_token -> moved to config
# infra.ntp_server -> defaulted to 'nil'
# infra.tun_gso -> deleted (sb 1.11)
# config.routing_port='all' -> deleted
# dns.default_server='block-dns' -> 'default-dns' + final-block rule
# dns.independent_cache -> deleted (sb 1.14)
# dns.cache_file_store_rdrc -> renamed to cache_file_store_dns
# dns.cache_file_rdrc_timeout -> deleted
# dns_server.address (legacy) -> split into type/server/server_port
# dns_server.strategy/client_subnet -> moved to the referencing rule
# dns_server.address='rcode://x' -> predefined rcode rule
# dns_rule.server='block-dns' -> action='reject'
# dns_rule.rule_set_ipcidr_match_source -> renamed (sb 1.10)
# routing_rule.outbound='block-out' -> action='reject' (sb 1.11)
# server.auto_firewall='1' -> per-server firewall='1'
# server.sniff_override -> deleted (sb 1.11)
#
# The cursor override flows through HP_MIGRATE_UCI_DIR (see the
# comment at the top of migrate_config.uc); production keeps the
# default cursor() because the global is unset there.
#
# Usage: sh tests/ucode/test_migrate_config.sh <repo-root> [work-dir]
ROOT="${1:-.}"
WORK="${2:-/tmp/hp-migrate-test}"
ROOT="$(cd "$ROOT" && pwd)"
FAILED=0
SANDBOX="$WORK/sandbox"
STAGE="$WORK/scripts"
rm -rf "$WORK"
mkdir -p "$SANDBOX" "$STAGE"
# cursor(dir) reads <dir>/homeproxy directly (NOT <dir>/config/homeproxy).
cat > "$SANDBOX/homeproxy" <<'EOF'
config homeproxy 'config'
option log_level ''
option china_dns_server 'wan_114'
option routing_port 'all'
config homeproxy 'infra'
option self_mark '100'
option china_dns_port '5354'
option github_token 'secret'
config homeproxy 'dns'
option default_server 'block-dns'
option independent_cache '1'
option cache_file_store_rdrc '1'
option cache_file_rdrc_timeout '60'
config homeproxy 'server'
option auto_firewall '1'
config dns_server 'ds_default'
option address 'tls://1.1.1.1:853'
option strategy 'prefer_ipv4'
option client_subnet '1.2.3.0/24'
config dns_server 'ds_rcode'
option address 'rcode://name_error'
config dns_rule 'dr_block'
option server 'block-dns'
config dns_rule 'dr_uses_default'
option server 'ds_default'
config dns_rule 'dr_stale'
option rule_set_ipcidr_match_source '1'
config routing_rule 'rr_block'
option outbound 'block-out'
config server 'srv_one'
option type 'shadowsocks'
option sniff_override '1'
EOF
# Stage the mock homeproxy + migrate_config.uc + test driver next to
# each other so the bare-name imports resolve through -L.
#
# migrate_config.uc runs at import time and writes through a UCI
# cursor that defaults to /etc/config. The test must not touch the
# real UCI tree, so the staged *copy* is rewritten to take the
# sandbox dir from ARGV[0]. Production source keeps the plain
# cursor() call - the project moved away from test seams in
# production code when HP_TEST_HOOK was removed, and re-adding one
# would go backwards.
cp "$ROOT/tests/ucode/mocks/homeproxy.uc" "$STAGE/"
cp "$ROOT/root/etc/homeproxy/scripts/migrate_config.uc" "$STAGE/"
cp "$ROOT/tests/ucode/test_migrate_config.uc" "$STAGE/"
# The shebang is dropped in the staged copy: migrate_config.uc is a
# program in production (`ucode -S .../migrate_config.uc`, where the
# shebang is fine), but this test *imports* it, and the target ucode
# rejects a `#!` line in a module ("Unexpected character" at line 1,
# followed by cascading lexer errors).
# The crontab path is a literal in the source (/etc/crontabs/root). Point the
# staged copy at the sandbox too, or the run would edit the device's real
# crontab - the migration is supposed to do that once on a real upgrade, but a
# test must not.
sed -e '1{/^#!\/usr\/bin\/ucode$/d;}' \
-e 's|^const uci = cursor();$|const uci = cursor(ARGV[0]);|' \
-e "s|'/etc/crontabs/root'|'$SANDBOX/crontab'|" \
"$STAGE/migrate_config.uc" > "$STAGE/migrate_config.uc.new"
# Hard guard: if an anchor ever stops matching (someone reformats the
# line), the sed silently no-ops. Losing the cursor redirect would make the
# migration run against the real /etc/config - which on a target would
# rewrite the live configuration; losing the shebang strip means the module
# cannot be imported at all. Refuse to run instead.
if ! grep -q 'cursor(ARGV\[0\])' "$STAGE/migrate_config.uc.new"; then
echo "FAIL: migrate_config regressions: could not redirect the UCI cursor"
echo " (the 'const uci = cursor();' anchor no longer matches)"
exit 1
fi
if head -1 "$STAGE/migrate_config.uc.new" | grep -q '^#!'; then
echo "FAIL: migrate_config regressions: the shebang was not stripped"
echo " (the '#!/usr/bin/ucode' anchor no longer matches)"
exit 1
fi
if ! grep -q "'$SANDBOX/crontab'" "$STAGE/migrate_config.uc.new"; then
echo "FAIL: migrate_config regressions: could not redirect the crontab path"
echo " (the \"'/etc/crontabs/root'\" anchor no longer matches)"
exit 1
fi
# A crontab that still carries the legacy entry, plus one line that must
# survive. The migration has to remove exactly the former.
cat > "$SANDBOX/crontab" <<'CRONTAB'
0 3 * * * /etc/homeproxy/scripts/update_crond.sh
*/5 * * * * /usr/bin/echo keep-me
CRONTAB
mv "$STAGE/migrate_config.uc.new" "$STAGE/migrate_config.uc"
if ( cd "$STAGE" && ucode -L "$STAGE" test_migrate_config.uc "$SANDBOX" ); then
echo "PASS: migrate_config regressions"
else
echo "FAIL: migrate_config regressions"
FAILED=1
fi
# The crontab cleanup, asserted from the shell so the .uc test does not have to
# know about files. The old form ran `sed -i ... 2>/dev/null`, so on a host
# whose sed has no bare -i (BSD) it failed silently and left the entry behind -
# and the marker was set anyway, so the migration never retried.
if grep -q 'update_crond.sh' "$SANDBOX/crontab"; then
echo "FAIL: migrate_config regressions: the legacy update_crond.sh crontab entry survived"
grep -n 'update_crond.sh' "$SANDBOX/crontab" | head -2
FAILED=1
fi
if ! grep -q 'keep-me' "$SANDBOX/crontab"; then
echo "FAIL: migrate_config regressions: the crontab rewrite dropped an unrelated line"
cat "$SANDBOX/crontab"
FAILED=1
fi
if ! grep -q "option crontab '1'" "$SANDBOX/homeproxy"; then
echo "FAIL: migrate_config regressions: the crontab marker was not set after a successful edit"
FAILED=1
fi
exit $FAILED
@@ -0,0 +1,144 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Driver for tests/ucode/test_migrate_config.sh.
*
* migrate_config.uc has no exports: the migration executes at import
* time and writes through a UCI cursor. The staged copy of
* migrate_config.uc has been rewritten by the runner so its cursor
* reads ARGV[0] (the sandbox dir) instead of /etc/config - so this
* driver just imports it and lets the side effect run.
*
* The assertions below then re-read the sandbox and check the
* post-state step by step, so a failure names the migration step
* that regressed rather than "migrate broke something".
*/
'use strict';
import { cursor } from 'uci';
import 'migrate_config';
const uci = cursor(ARGV[0]);
uci.load('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++;
}
}
/* --- infra section ---------------------------------------------------- */
/* chinadns-ng removed: china_dns_port deleted. */
expect('infra.china_dns_port deleted', uci.get('homeproxy', 'infra', 'china_dns_port'), null);
/* github_token moved from infra to config. */
expect('config.github_token set', uci.get('homeproxy', 'config', 'github_token'), 'secret');
expect('infra.github_token deleted', uci.get('homeproxy', 'infra', 'github_token'), null);
/* ntp_server introduced, defaults to the 'nil' sentinel. */
expect('infra.ntp_server default', uci.get('homeproxy', 'infra', 'ntp_server'), 'nil');
/* tun_gso deprecated in sing-box 1.11: absent here, so nothing to do -
* assert it stays absent rather than flapping to a default. */
expect('infra.tun_gso stays absent', uci.get('homeproxy', 'infra', 'tun_gso'), null);
/* --- config section --------------------------------------------------- */
/* single-value chinadns server: legacy 'wan_114' maps to 114.114.114.114. */
expect('config.china_dns_server→114', uci.get('homeproxy', 'config', 'china_dns_server'), '114.114.114.114');
/* log_level introduced, defaults to 'warn'. */
expect('config.log_level default', uci.get('homeproxy', 'config', 'log_level'), 'warn');
/* routing_port='all' means "every port" = no option at all. */
expect('config.routing_port deleted', uci.get('homeproxy', 'config', 'routing_port'), null);
/* --- dns section ------------------------------------------------------ */
/* default_server='block-dns' becomes 'default-dns' plus an appended
* reject rule that reproduces the old "block everything" behaviour. */
expect('dns.default_server→default-dns', uci.get('homeproxy', 'dns', 'default_server'), 'default-dns');
expect('dns final-block action', uci.get('homeproxy', '_migration_dns_final_block', 'action'), 'reject');
expect('dns final-block enabled', uci.get('homeproxy', '_migration_dns_final_block', 'enabled'), '1');
/* sing-box 1.14 deprecated DNS semantics. */
expect('dns.independent_cache deleted', uci.get('homeproxy', 'dns', 'independent_cache'), null);
expect('dns.store_rdrc→store_dns', uci.get('homeproxy', 'dns', 'cache_file_store_dns'), '1');
expect('dns.store_rdrc deleted', uci.get('homeproxy', 'dns', 'cache_file_store_rdrc'), null);
expect('dns.rdrc_timeout deleted', uci.get('homeproxy', 'dns', 'cache_file_rdrc_timeout'), null);
/* --- dns_server: legacy address form ---------------------------------- */
/* 'tls://1.1.1.1:853' splits into type/server/server_port, and the
* legacy address key is dropped so the generator stops reading it. */
expect('ds_default.type', uci.get('homeproxy', 'ds_default', 'type'), 'tls');
expect('ds_default.server', uci.get('homeproxy', 'ds_default', 'server'), '1.1.1.1');
expect('ds_default.server_port', uci.get('homeproxy', 'ds_default', 'server_port'), '853');
expect('ds_default.address deleted', uci.get('homeproxy', 'ds_default', 'address'), null);
/* strategy / client_subnet moved out of the dns_server and into the
* dns_rule that referenced it. */
expect('ds_default.strategy deleted', uci.get('homeproxy', 'ds_default', 'strategy'), null);
expect('ds_default.client_subnet deleted', uci.get('homeproxy', 'ds_default', 'client_subnet'), null);
expect('dr_uses_default.strategy', uci.get('homeproxy', 'dr_uses_default', 'strategy'), 'prefer_ipv4');
expect('dr_uses_default.client_subnet', uci.get('homeproxy', 'dr_uses_default', 'client_subnet'), '1.2.3.0/24');
/* RCode-as-address migrated into the predefined-rcode rule form; the
* dns_server section itself is removed. */
expect('ds_rcode section deleted', uci.get_all('homeproxy', 'ds_rcode'), null);
/* --- dns_rule --------------------------------------------------------- */
/* server='block-dns' moved into action='reject'. */
expect('dr_block.action=reject', uci.get('homeproxy', 'dr_block', 'action'), 'reject');
expect('dr_block.server deleted', uci.get('homeproxy', 'dr_block', 'server'), null);
/* rule_set_ipcidr_match_source renamed to rule_set_ip_cidr_match_source. */
expect('dr_stale.old key deleted', uci.get('homeproxy', 'dr_stale', 'rule_set_ipcidr_match_source'), null);
expect('dr_stale.new key set', uci.get('homeproxy', 'dr_stale', 'rule_set_ip_cidr_match_source'), '1');
/* a rule with no action gets the 'route' default. */
expect('dr_uses_default.action=route', uci.get('homeproxy', 'dr_uses_default', 'action'), 'route');
/* --- routing_rule ----------------------------------------------------- */
/* outbound='block-out' moved into action='reject'. */
expect('rr_block.action=reject', uci.get('homeproxy', 'rr_block', 'action'), 'reject');
expect('rr_block.outbound deleted', uci.get('homeproxy', 'rr_block', 'outbound'), null);
/* --- server ----------------------------------------------------------- */
/* auto_firewall was a single global flag; it is redistributed onto
* each server section as firewall='1'. */
expect('srv_one.firewall set', uci.get('homeproxy', 'srv_one', 'firewall'), '1');
expect('server.auto_firewall deleted', uci.get('homeproxy', 'server', 'auto_firewall'), null);
/* sniff_override deprecated in sing-box 1.11. */
expect('srv_one.sniff_override deleted', uci.get('homeproxy', 'srv_one', 'sniff_override'), null);
/* server log_level introduced, defaults to 'warn'. */
expect('server.log_level default', uci.get('homeproxy', 'server', 'log_level'), 'warn');
/* --- migration bookkeeping -------------------------------------------- */
/* the migration marker section must survive so the crontab cleanup
* does not run twice. */
expect('migration.crontab marker', uci.get('homeproxy', 'migration', 'crontab'), '1');
/* --- summary ---------------------------------------------------------- */
if (failures > 0)
printf('FAIL: %d/%d checks failed\n', failures, checks);
else
printf('PASS: %d checks\n', checks);
exit(failures > 0 ? 1 : 0);
+153
View File
@@ -0,0 +1,153 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# The test doubles must still behave like the production code they copy.
#
# tests/ucode/mocks/homeproxy.uc says of isEmpty/decodeBase64Str/parseURL:
# "verbatim copy so the parser tests exercise the same string handling as
# production. Keep these in sync when homeproxy.uc changes." Nothing enforced
# that sentence. tests/ucode/mocks/homeproxy_fetcher.uc carries the same note
# for redactUrl, and the *security* assertion in
# test_subscription_fetcher.uc - that a subscription token cannot reappear in
# the log - is made against that copy, not against the production function.
#
# Editing the production function alone would therefore leave every test green
# while the behaviour they claim to check had changed. This compares the two
# implementations on a shared corpus, which is what "in sync" has to mean: the
# mock is allowed to be reformatted (redactUrl's copy already reorders a couple
# of statements), only the results must agree.
#
# validation() is deliberately NOT compared. It is a pure-ucode stand-in for
# /sbin/validate_data, so it differs from the real thing by design; it is
# pinned by its own unit tests instead.
#
# Usage: sh tests/ucode/test_mock_sync.sh <repo-root> [workdir]
set -u
ROOT="$(cd "${1:-$(dirname "$0")/../..}" && pwd)"
WORK="${2:-/tmp/hp-mock-sync}"
if ! command -v ucode > "/dev/null" 2>&1; then
echo "NOT RUN: ucode is not on PATH."
exit 2
fi
rm -rf "$WORK"
mkdir -p "$WORK"
# production parseURL() validates the hostname by shelling out to the hardcoded
# /sbin/validate_data. On a device that binary exists; off-target it does not,
# and production then returns null for every URL while the mock - which carries
# its own pure-ucode validation() - parses them happily. An environment variable
# would not help: the path is a literal in the source, so the staged copy has to
# be rewritten, exactly as tests/ucode/test_generators.sh does it. Without this
# the test passed on the device and failed in CI, which is how it was found.
VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
if [ ! -x "$VALIDATE_DATA" ] && [ -x "$ROOT/tests/toolchain/validate-data.sh" ]; then
VALIDATE_DATA="$ROOT/tests/toolchain/validate-data.sh"
fi
mkdir -p "$WORK/scripts"
sed -e "s#/sbin/validate_data#$VALIDATE_DATA#" \
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/scripts/homeproxy.uc"
cat > "$WORK/sync.uc" <<'DRIVER'
import {
isEmpty as p_isEmpty,
decodeBase64Str as p_decodeBase64Str,
redactUrl as p_redactUrl,
parseURL as p_parseURL,
shellQuote as p_shellQuote
} from '@@SCRIPTS@@/homeproxy.uc';
import {
isEmpty as m_isEmpty,
decodeBase64Str as m_decodeBase64Str,
parseURL as m_parseURL,
shellQuote as m_shellQuote
} from '@@MOCK-HP@@';
import { redactUrl as f_redactUrl } from '@@MOCK-FETCHER@@';
let checks = 0, failures = 0;
function same(name, a, b) {
checks++;
const sa = sprintf('%J', a), sb = sprintf('%J', b);
if (sa === sb) return;
failures++;
printf('FAIL %s: production %s vs mock %s\n', name, sa, sb);
}
/* --- isEmpty ------------------------------------------------------------ */
const empty_corpus = ['', null, 'nil', [], {}, [1], { a: 1 }, 0, 'x', '0', false, true, [ [] ]];
for (let v in empty_corpus)
same(sprintf('isEmpty(%J)', v), p_isEmpty(v), m_isEmpty(v));
/* --- decodeBase64Str ---------------------------------------------------- */
const b64_corpus = [
'', 'aGVsbG8=', 'aGVsbG8', 'aGVsbG8h', 'a-b_c', 'a+b/c', '!!!!',
'AA==', 'AAA=', 'AAAA', ' aGVsbG8= ', 'not base64 at all', '===='
];
for (let v in b64_corpus)
same(sprintf('decodeBase64Str(%J)', v), p_decodeBase64Str(v), m_decodeBase64Str(v));
/* --- shellQuote --------------------------------------------------------- */
const quote_corpus = [
'', 'plain', "it's", "a'b'c", 'has space', '"double"', '$var', '`cmd`',
'; rm -rf /', 'a\\b', "mix'\"$`"
];
for (let v in quote_corpus)
same(sprintf('shellQuote(%J)', v), p_shellQuote(v), m_shellQuote(v));
/* --- redactUrl: the fetcher mock's copy -------------------------------- */
const url_corpus = [
'',
'https://host/path',
'https://user:pass@host/path?token=secret',
'https://user@host/path',
'https://host/path?a=1&b=2',
'https://host/path?a=1#frag',
'ss://method:password@host:8388#label',
'not a url',
'https://host/path?',
'://@?'
];
for (let v in url_corpus)
same(sprintf('redactUrl(%J)', v), p_redactUrl(v), f_redactUrl(v));
/* --- parseURL: only inputs where the real validate_data and the stand-in
* agree, since validation() is allowed to differ. ------------------------ */
const parse_corpus = [
'https://example.com/path',
'https://example.com:8443/path?x=1#h',
'http://user:pass@example.com/p',
'https://1.2.3.4/p',
'https://[2001:db8::1]:443/p',
'ftp://example.com/p',
'',
'not a url',
'https://',
'https:///path'
];
for (let v in parse_corpus)
same(sprintf('parseURL(%J)', v), p_parseURL(v), m_parseURL(v));
printf('mock sync: %d checks, %d failures\n', checks, failures);
exit(failures ? 1 : 0);
DRIVER
sed -e "s#@@SCRIPTS@@#$WORK/scripts#" \
-e "s#@@MOCK-HP@@#$ROOT/tests/ucode/mocks/homeproxy.uc#" \
-e "s#@@MOCK-FETCHER@@#$ROOT/tests/ucode/mocks/homeproxy_fetcher.uc#" \
"$WORK/sync.uc" > "$WORK/sync.uc.new"
mv -f "$WORK/sync.uc.new" "$WORK/sync.uc"
if ucode -L "$ROOT/root/etc/homeproxy/scripts" "$WORK/sync.uc"; then
echo "PASS: the test doubles agree with production"
rm -rf "$WORK"
exit 0
else
echo "FAIL: a test double has drifted from the production function it copies"
rm -rf "$WORK"
exit 1
fi
@@ -0,0 +1,188 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*
* Unit tests for the share-link parsers in scripts/parser/. Run
* through tests/ucode/run.sh, which stages the whole parser/ tree
* next to the mocked homeproxy.uc.
*
* PR-02: parse_uri() now dispatches from scripts/parser/uri.uc,
* pulling the per-scheme branches from parser/protocols.uc and the
* categorised post-parse checks from parser/validator.uc. The import
* below is left as `from 'parser/uri.uc'` so the staged work dir
* (where parser/ is a sibling of the test) resolves identically.
*/
'use strict';
import { parse_uri } from 'parser/uri.uc';
const FEATURES = { with_quic: true, with_utls: true };
const NO_QUIC = { with_quic: false, with_utls: true };
const LOG = (..._args) => {};
let failures = 0,
checks = 0;
function expect_fields(name, cfg, expected) {
if (type(cfg) !== 'object') {
printf('FAIL %s: expected an object, got %J\n', name, cfg);
failures++;
return;
}
for (let key in expected) {
checks++;
if (sprintf('%J', cfg[key]) !== sprintf('%J', expected[key])) {
printf('FAIL %s: %s expected %J, got %J\n', name, key, expected[key], cfg[key]);
failures++;
}
}
}
function expect_null(name, cfg) {
checks++;
if (cfg !== null) {
printf('FAIL %s: expected null, got %J\n', name, cfg);
failures++;
}
}
/* anytls */
expect_fields('anytls', parse_uri('anytls://secret@a.example.com:443?sni=a.example.com&insecure=1#AnyTLS-Node', FEATURES, LOG), {
label: 'AnyTLS-Node', type: 'anytls', address: 'a.example.com', port: '443',
password: 'secret', tls: '1', tls_sni: 'a.example.com', tls_insecure: '1'
});
/* http / https */
expect_fields('https', parse_uri('https://user:pass@b.example.com:8443#HTTPS-Node', FEATURES, LOG), {
label: 'HTTPS-Node', type: 'http', address: 'b.example.com', port: '8443',
username: 'user', password: 'pass', tls: '1'
});
expect_fields('http', parse_uri('http://c.example.com:8080#HTTP-Node', FEATURES, LOG), {
label: 'HTTP-Node', type: 'http', address: 'c.example.com', port: '8080', tls: '0'
});
/* hysteria */
expect_fields('hysteria', parse_uri('hysteria://d.example.com:36712?auth=pass&peer=d.example.com&insecure=1&upmbps=100&downmbps=200&alpn=h3&protocol=udp#Hy1', FEATURES, LOG), {
label: 'Hy1', type: 'hysteria', address: 'd.example.com', port: '36712',
hysteria_protocol: 'udp', hysteria_auth_type: 'string', hysteria_auth_payload: 'pass',
hysteria_up_mbps: '100', hysteria_down_mbps: '200',
tls: '1', tls_insecure: '1', tls_sni: 'd.example.com', tls_alpn: 'h3'
});
/* hysteria2 */
expect_fields('hysteria2', parse_uri('hysteria2://pass@e.example.com:443?sni=e.example.com&obfs=salamander&obfs-password=op#Hy2', FEATURES, LOG), {
label: 'Hy2', type: 'hysteria2', address: 'e.example.com', port: '443',
password: 'pass', hysteria_obfs_type: 'salamander', hysteria_obfs_password: 'op',
tls: '1', tls_insecure: '0', tls_sni: 'e.example.com'
});
expect_fields('hy2-alias', parse_uri('hy2://pass@e.example.com:443#Hy2Alias', FEATURES, LOG), {
label: 'Hy2Alias', type: 'hysteria2', address: 'e.example.com', port: '443'
});
/* snell */
expect_fields('snell', parse_uri('snell://f.example.com:443?psk=pskvalue&version=4&obfs=http&obfs-host=bing.com&reuse=1#Snell', FEATURES, LOG), {
label: 'Snell', type: 'snell', address: 'f.example.com', port: '443',
password: 'pskvalue', snell_version: '4', snell_obfs_mode: 'http',
snell_obfs_host: 'bing.com', snell_reuse: '1'
});
/* socks */
expect_fields('socks5', parse_uri('socks5://user:pass@g.example.com:1080#Socks', FEATURES, LOG), {
label: 'Socks', type: 'socks', address: 'g.example.com', port: '1080',
username: 'user', password: 'pass', socks_version: '5'
});
expect_fields('socks4', parse_uri('socks4://h.example.com:1080#Socks4', FEATURES, LOG), {
socks_version: '4'
});
/* shadowsocks: SIP002 with base64 user info */
expect_fields('ss-sip002-b64', parse_uri('ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@i.example.com:8388#SS-Node', FEATURES, LOG), {
label: 'SS-Node', type: 'shadowsocks', address: 'i.example.com', port: '8388',
shadowsocks_encrypt_method: 'aes-256-gcm', password: 'password'
});
/* shadowsocks: SIP002 with plugin */
expect_fields('ss-sip002-plugin', parse_uri('ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@j.example.com:8388?plugin=simple-obfs%3Bobfs%3Dhttp#SS-Plugin', FEATURES, LOG), {
label: 'SS-Plugin', type: 'shadowsocks', address: 'j.example.com', port: '8388',
shadowsocks_plugin: 'obfs-local', shadowsocks_plugin_opts: 'obfs=http'
});
/* shadowsocks: Shadowrocket whole-payload base64 */
expect_fields('ss-shadowrocket', parse_uri('ss://YWVzLTI1Ni1nY206cGFzc3dvcmRAay5leGFtcGxlLmNvbTo4Mzg4#SS-SR', FEATURES, LOG), {
label: 'SS-SR', type: 'shadowsocks', address: 'k.example.com', port: '8388',
shadowsocks_encrypt_method: 'aes-256-gcm', password: 'password'
});
/* trojan */
expect_fields('trojan-ws', parse_uri('trojan://pass@l.example.com:443?type=ws&host=l.example.com&path=%2Fpath%3Fed%3D2048&sni=l.example.com#TrojanWs', FEATURES, LOG), {
label: 'TrojanWs', type: 'trojan', address: 'l.example.com', port: '443', password: 'pass',
transport: 'ws', ws_host: 'l.example.com', ws_path: '/path',
websocket_early_data: '2048', websocket_early_data_header: 'Sec-WebSocket-Protocol',
tls: '1', tls_sni: 'l.example.com'
});
expect_fields('trojan-grpc', parse_uri('trojan://pass@l.example.com:443?type=grpc&serviceName=gs#TrojanGrpc', FEATURES, LOG), {
transport: 'grpc', grpc_servicename: 'gs'
});
/* tuic */
expect_fields('tuic', parse_uri('tuic://tuic-uuid:pass@m.example.com:443?congestion_control=bbr&udp_relay_mode=native&alpn=h3&sni=m.example.com#Tuic', FEATURES, LOG), {
label: 'Tuic', type: 'tuic', address: 'm.example.com', port: '443',
uuid: 'tuic-uuid', password: 'pass', tuic_congestion_control: 'bbr',
tuic_udp_relay_mode: 'native', tls: '1', tls_sni: 'm.example.com', tls_alpn: [ 'h3' ]
});
/* vless */
expect_fields('vless-reality-grpc', parse_uri('vless://vless-uuid@n.example.com:443?security=reality&pbk=PUBKEY&sid=abcd&fp=chrome&type=grpc&serviceName=gs&sni=n.example.com&flow=xtls-rprx-vision#Vless', FEATURES, LOG), {
label: 'Vless', type: 'vless', address: 'n.example.com', port: '443', uuid: 'vless-uuid',
transport: 'grpc', grpc_servicename: 'gs', tls: '1', tls_sni: 'n.example.com',
tls_reality: '1', tls_reality_public_key: 'PUBKEY', tls_reality_short_id: 'abcd',
tls_utls: 'chrome', vless_flow: 'xtls-rprx-vision'
});
expect_fields('vless-ws-early-data', parse_uri('vless://vless-uuid@o.example.com:443?security=tls&type=ws&host=o.example.com&path=%2Fws%3Fed%3D1024&sni=o.example.com&alpn=h2%2Chttp%2F1.1#VlessWs', FEATURES, LOG), {
type: 'vless', transport: 'ws', ws_host: 'o.example.com', ws_path: '/ws',
websocket_early_data: '1024', websocket_early_data_header: 'Sec-WebSocket-Protocol',
tls: '1', tls_alpn: [ 'h2', 'http/1.1' ]
});
/* vmess */
expect_fields('vmess-ws', parse_uri('vmess://eyJ2IjoiMiIsInBzIjoiVk1lc3MiLCJhZGQiOiJwLmV4YW1wbGUuY29tIiwicG9ydCI6IjQ0MyIsImlkIjoiM2FmODg1NjEtOWM2OS00YjE5LThmN2UtZjA4ZDU4MGJjMzM5IiwiYWlkIjoiMCIsIm5ldCI6IndzIiwidHlwZSI6Im5vbmUiLCJob3N0IjoicC5leGFtcGxlLmNvbSIsInBhdGgiOiIvd3MiLCJ0bHMiOiJ0bHMiLCJzbmkiOiJwLmV4YW1wbGUuY29tIn0=', FEATURES, LOG), {
label: 'VMess', type: 'vmess', address: 'p.example.com', port: '443',
uuid: '3af88561-9c69-4b19-8f7e-f08d580bc339', vmess_alterid: '0', vmess_encrypt: 'auto',
vmess_global_padding: '1', transport: 'ws', ws_host: 'p.example.com', ws_path: '/ws',
tls: '1', tls_sni: 'p.example.com'
});
/* sip008 object */
expect_fields('sip008', parse_uri({
nodetype: 'sip008', remarks: 'SIP008', server: 'q.example.com', server_port: 8388,
method: 'aes-256-gcm', password: 'pw'
}, FEATURES, LOG), {
label: 'SIP008', type: 'shadowsocks', address: 'q.example.com', port: 8388,
shadowsocks_encrypt_method: 'aes-256-gcm', password: 'pw'
});
/* rejection paths */
expect_null('vless-kcp-unsupported', parse_uri('vless://u@r.example.com:443?type=kcp#Kcp', FEATURES, LOG));
expect_null('hysteria-without-quic', parse_uri('hysteria://s.example.com:443?auth=a#NoQuic', NO_QUIC, LOG));
expect_null('tuic-without-quic', parse_uri('tuic://u:p@s.example.com:443#NoQuic', NO_QUIC, LOG));
expect_null('invalid-port', parse_uri('vless://u@t.example.com:99999?security=tls#BadPort', FEATURES, LOG));
expect_null('unknown-scheme', parse_uri('unknown://u@t.example.com:443', FEATURES, LOG));
/* A malformed shadowsocks userinfo must be rejected, not raise.
*
* ss:// carries its method:password base64-encoded in the userinfo.
* decodeBase64Str() returns null for anything that is not valid base64, and
* ucode's split(null, ...) is null, so indexing [0] raised a ReferenceError.
* parse_uri() does not catch it: the RPC import path happens to, but
* update_subscriptions.uc does not, so ONE bad link aborted the whole update
* and nothing was imported. A dot is not base64, so this is a realistic typo.
* A link with no usable userinfo is simply not a node we can build. */
expect_null('ss-nonbase64-userinfo', parse_uri('ss://a.b@1.2.3.4:8388#x', FEATURES, LOG));
expect_null('ss-empty-userinfo', parse_uri('ss://@1.2.3.4:8388#x', FEATURES, LOG));
printf('%d checks, %d failures\n', checks, failures);
exit(failures === 0 ? 0 : 1);
@@ -0,0 +1,217 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* PR-03 (Subscription Transaction Boundary): the round-trip
* invariant for the canonical-Node pipeline. For every scheme the
* parser accepts, parse_uri() -> normalize() -> flatten() must
* produce a flat UCI dict that is field-for-field equal to what
* the parser would have written directly. Repository then writes
* that flat dict to UCI, so a saved canonical Node round-trips
* back into an equivalent canonical Node when the Loader next
* reads it.
*
* Two important properties this test pins:
*
* - canonical-to-flat mapping is consistent: parser and
* flatten() agree on every UCI key (otherwise the
* Repository's "delete what is no longer carried" walk
* would silently drop fields the parser still emits);
* - canonical -> flat -> canonical is identity for the data
* the Loader reads (the Repository's actual contract).
*
* Run through tests/ucode/run.sh's parser tree staging (same as
* test_parse_uri.uc): the staged work dir has the production
* parser/ tree plus the homeproxy mock.
*/
'use strict';
import { parse_uri } from 'parser/uri.uc';
import { normalize } from 'parser/normalize.uc';
import { flatten } from 'parser/flatten.uc';
const FEATURES = { with_quic: true, with_utls: true };
const LOG = (..._args) => {};
let failures = 0, checks = 0;
function expect(name, got, want) {
checks++;
if (sprintf('%J', got) !== sprintf('%J', want)) {
printf('FAIL %s: got %J want %J\n', name, got, want);
failures++;
}
}
/* The invariant that matters for the Repository: every value the parser
* actually set must survive parse -> normalize -> flatten unchanged.
*
* Nulls are excluded on purpose - removeBlankAttrs()/flatten() drop them
* and the Loader reads an absent option as null, so a null round-trip is
* observably lossless. A non-null value that disappears is data loss:
* the Repository writes `flatten()`'s output, so on the next subscription
* update that UCI option would be *deleted* from the stored node.
*
* The reverse direction is checked too: flatten() must not invent keys
* the parser never produced, or the Repository would write options the
* node form does not own. */
function round_trip(name, uri, features) {
const flat = parse_uri(uri, features, LOG);
/* A sample the parser rejects is a broken fixture, not a reason to skip.
* Returning silently left that scheme's round trip unverified while the
* suite stayed green. */
if (!flat) {
checks++;
failures++;
printf('FAIL %s: parse_uri() rejected a sample this test is meant to cover\n', name);
return;
}
const node = normalize(flat);
const back = flatten(node);
/* 1. every non-null parser value survives, unchanged. */
for (let k, v in flat) {
if (v === null)
continue;
expect(sprintf('%s: %s survives round-trip', name, k), back[k], v);
}
/* 2. flatten() only emits keys the parser produced (plus the
* metadata the orchestrator attaches). */
const flat_keys = keys(flat);
for (let k, v in back) {
if (v === null)
continue;
if (index(flat_keys, k) === -1)
expect(sprintf('%s: %s is not a parser field', name, k), k, null);
}
/* 3. and the canonical Node must carry the identity the Loader's
* read path would reconstruct. */
expect(sprintf('%s: canonical type', name), node.type, flat.type);
expect(sprintf('%s: canonical address', name), node.address, flat.address);
expect(sprintf('%s: canonical port', name), node.port, flat.port);
expect(sprintf('%s: canonical name from label', name), node.name, flat.label);
}
/* The same URI corpus test_parse_uri.uc asserts on, so any
* regression in the mapping table will surface in BOTH tests.
* Array destructuring is rejected by the target ucode, so each
* sample is a 3-element object the loop indexes manually. */
const SAMPLES = [
{ name: 'anytls', uri: 'anytls://secret@a.example.com:443?sni=a.example.com#AnyTLS', features: FEATURES },
{ name: 'https', uri: 'https://user:pass@b.example.com:8443#HTTPS', features: FEATURES },
{ name: 'http', uri: 'http://c.example.com:8080#HTTP', features: FEATURES },
{ name: 'hy1', uri: 'hysteria://d.example.com:36712?auth=pass&peer=d.example.com&protocol=udp#Hy1', features: FEATURES },
{ name: 'hy2', uri: 'hysteria2://pass@e.example.com:443?sni=e.example.com&obfs=salamander&obfs-password=op#Hy2', features: FEATURES },
{ name: 'hy2al', uri: 'hy2://pass@e.example.com:443#Hy2Alias', features: FEATURES },
{ name: 'snell', uri: 'snell://f.example.com:443?psk=pskvalue&version=4&obfs=http&obfs-host=bing.com#Snell', features: FEATURES },
{ name: 'socks5', uri: 'socks5://user:pass@g.example.com:1080#Socks', features: FEATURES },
{ name: 'ss', uri: 'ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@i.example.com:8388#SS', features: FEATURES },
{ name: 'trojan', uri: 'trojan://pass@l.example.com:443?type=grpc&serviceName=gs&sni=l.example.com#Trojan', features: FEATURES },
{ name: 'tuic', uri: 'tuic://tuic-uuid:pass@m.example.com:443?congestion_control=bbr&sni=m.example.com#Tuic', features: FEATURES },
{ name: 'vless-r', uri: 'vless://vless-uuid@n.example.com:443?security=reality&pbk=PUBKEY&sid=abcd&type=grpc&serviceName=gs&sni=n.example.com#Vless', features: FEATURES },
{ name: 'vmess', uri: 'vmess://eyJ2IjoiMiIsInBzIjoiVk1lc3MiLCJhZGQiOiJwLmV4YW1wbGUuY29tIiwicG9ydCI6IjQ0MyIsImlkIjoiM2FmODg1NjEtOWM2OS00YjE5LThmN2UtZjA4ZDU4MGJjMzM5IiwiYWlkIjoiMCIsIm5ldCI6IndzIiwidHlwZSI6Im5vbmUiLCJob3N0IjoicC5leGFtcGxlLmNvbSIsInBhdGgiOiIvd3MiLCJ0bHMiOiJ0bHMiLCJzbmkiOiJwLmV4YW1wbGUuY29tIn0=', features: FEATURES }
];
/* ucode's for-in over an array yields the elements, not the indices. */
for (let s in SAMPLES)
round_trip(s.name, s.uri, s.features);
/* --- explicit canonical -> flat shape checks --------------------------- */
/* vless reality: many sub-objects must round-trip cleanly. */
{
const flat = parse_uri(
'vless://vless-uuid@n.example.com:443?security=reality&pbk=PUBKEY&sid=abcd&type=grpc&serviceName=gs&sni=n.example.com#Vless',
FEATURES, LOG
);
const node = normalize(flat);
const back = flatten(node);
expect('vless.reality: tls_reality_public_key',
back.tls_reality_public_key, flat.tls_reality_public_key);
expect('vless.reality: tls_reality_short_id',
back.tls_reality_short_id, flat.tls_reality_short_id);
expect('vless.reality: tls_sni',
back.tls_sni, flat.tls_sni);
expect('vless.reality: grpc_servicename',
back.grpc_servicename, flat.grpc_servicename);
expect('vless.reality: vless_flow',
back.vless_flow, flat.vless_flow);
}
/* shadowsocks plugin + udp_over_tcp must survive. */
{
const flat = parse_uri(
'ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@i.example.com:8388#SS',
FEATURES, LOG
);
const node = normalize(flat);
const back = flatten(node);
expect('ss: shadowsocks_encrypt_method',
back.shadowsocks_encrypt_method, flat.shadowsocks_encrypt_method);
expect('ss: label survives canonical->flat',
back.label, flat.label);
}
/* --- grouphash metadata pass-through ---------------------------------- */
/* Repository attaches grouphash to the canonical Node after the
* orchestrator builds it. flatten() must copy grouphash through
* because the Repository's foreach walks `node_cache` keyed by
* grouphash, not by what flatten emits. */
{
const flat = parse_uri(
'vless://vless-uuid@n.example.com:443?security=reality&pbk=PUBKEY&sid=abcd&sni=n.example.com#Vless',
FEATURES, LOG
);
const node = normalize(flat);
node.grouphash = 'groupHash-XYZ';
const back = flatten(node);
expect('grouphash: passed through',
back.grouphash, 'groupHash-XYZ');
}
/* isExisting is the within-run marker Repository sets after a
* successful write; it must NOT be emitted into UCI (else it
* becomes a stale field on the next round). */
{
const flat = parse_uri(
'vless://vless-uuid@n.example.com:443?sni=n.example.com#Vless',
FEATURES, LOG
);
const node = normalize(flat);
node.isExisting = true;
const back = flatten(node);
expect('isExisting: NOT in flat output',
'isExisting' in back, false);
}
/* --- null / empty canonical values are dropped ------------------------ */
/* Empty / null canonical values must not show up as `key ''`
* entries in UCI - the Loader treats null and absent the same,
* so the round-trip is still lossless. */
{
const node = {
id: null,
name: 'X',
type: 'vless',
address: 'a',
port: '1',
common: {},
credentials: { uuid: 'u' },
tls: { enabled: null, ech: {}, utls: {}, reality: {} },
transport: { type: null },
multiplex: { enabled: null, brutal: {} },
protocol_options: { flow: '' }
};
const back = flatten(node);
expect('flatten: empty string protocol_option is dropped',
'flow' in back, false);
}
printf('%d checks, %d failures\n', checks, failures);
exit(failures === 0 ? 0 : 1);
@@ -0,0 +1,164 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* PR-02 (Parser Normalization): the parser writes a flat UCI-key
* dict, normalize() turns it into the canonical Node shape the
* Adapter reads. This test pins the contract so the mapping table
* (parser/mapping.uc), the Loader's PROTOCOL_OPTIONS (now derived
* from it) and the parser's normalize() cannot drift apart.
*
* Run through tests/ucode/run.sh's parser tree staging (same as
* test_parse_uri.uc): the staged work dir has the production
* parser/ tree plus the homeproxy mock.
*/
'use strict';
import { normalize } from 'parser/normalize.uc';
let checks = 0, failures = 0;
function expect(name, got, want) {
checks++;
if (got !== want) {
printf('FAIL %s: got %J want %J\n', name, got, want);
failures++;
}
}
function expect_keys(name, got, want) {
checks++;
const keys_got = sort(keys(got));
const keys_want = sort(want);
const same = length(keys_got) === length(keys_want);
if (!same || filter(keys_got, (k, i) => k !== keys_want[i]).length) {
printf('FAIL %s: keys got %J want %J\n', name, keys_got, keys_want);
failures++;
}
}
/* --- null in -> null out -------------------------------------------- */
expect('normalize(null)', normalize(null), null);
/* --- shadowsocks: tls_sni + shadowsocks_* UCI keys -> canonical Node */
const ss_flat = {
label: 'ss1',
type: 'shadowsocks',
address: '1.2.3.4',
port: '8388',
password: 'pw',
shadowsocks_encrypt_method: 'aes-256-gcm',
shadowsocks_plugin: 'obfs-local',
shadowsocks_plugin_opts: 'obfs=http;obfs-host=bing.com',
udp_over_tcp: '1',
udp_over_tcp_version: '2'
};
const ss_node = normalize(ss_flat);
expect('ss.type', ss_node.type, 'shadowsocks');
expect('ss.address', ss_node.address, '1.2.3.4');
expect('ss.port', ss_node.port, '8388');
expect('ss.credentials.password', ss_node.credentials.password, 'pw');
expect('ss.credentials.method', ss_node.credentials.method, 'aes-256-gcm');
expect('ss.protocol_options.plugin', ss_node.protocol_options.plugin, 'obfs-local');
expect('ss.protocol_options.plugin_opts', ss_node.protocol_options.plugin_opts, 'obfs=http;obfs-host=bing.com');
expect('ss.protocol_options.udp_over_tcp', ss_node.protocol_options.udp_over_tcp, '1');
expect('ss.protocol_options.udp_over_tcp_version', ss_node.protocol_options.udp_over_tcp_version, '2');
/* shadowsocks has no transport, multiplex, tls enabled: */
expect('ss.transport.type', ss_node.transport.type, null);
/* --- vless: flow + packet_encoding -> canonical; tls shape filled */
const vless_flat = {
label: 'vl1',
type: 'vless',
address: '5.6.7.8',
port: '443',
uuid: 'uuid-x',
transport: 'ws',
tls: '1',
tls_sni: 'vl.example.com',
tls_alpn: ['h2', 'http/1.1'],
tls_reality: '1',
tls_reality_public_key: 'PUBKEY',
tls_reality_short_id: 'abcd',
tls_utls: 'chrome',
vless_flow: 'xtls-rprx-vision',
packet_encoding: 'xudp',
ws_host: 'ws.example.com',
ws_path: '/ws'
};
const vless_node = normalize(vless_flat);
expect('vless.type', vless_node.type, 'vless');
expect('vless.credentials.uuid', vless_node.credentials.uuid, 'uuid-x');
expect('vless.protocol_options.flow', vless_node.protocol_options.flow, 'xtls-rprx-vision');
expect('vless.protocol_options.packet_encoding', vless_node.protocol_options.packet_encoding, 'xudp');
expect('vless.tls.enabled', vless_node.tls.enabled, '1');
expect('vless.tls.server_name', vless_node.tls.server_name, 'vl.example.com');
expect('vless.tls.reality.enabled', vless_node.tls.reality.enabled, '1');
expect('vless.tls.reality.public_key', vless_node.tls.reality.public_key, 'PUBKEY');
expect('vless.tls.utls.fingerprint', vless_node.tls.utls.fingerprint, 'chrome');
expect('vless.transport.type', vless_node.transport.type, 'ws');
expect('vless.transport.path', vless_node.transport.path, '/ws');
/* A websocket transport keeps its host in `headers.Host`, not `host`
* (only the http / httpupgrade transports use `host`), matching the
* Loader's load_transport() shape and buildTransportObject()'s
* expectation. */
expect('vless.transport.host', vless_node.transport.host, null);
expect('vless.transport.headers', vless_node.transport.headers?.Host, 'ws.example.com');
/* --- hysteria2: hy_* UCI -> protocol_options.hop_interval / obfs_type */
const hy2_flat = {
label: 'hy2',
type: 'hysteria2',
address: 'a.b.c',
port: '443',
password: 'pw',
hysteria_obfs_type: 'salamander',
hysteria_obfs_password: 'obfspw',
hysteria_up_mbps: '100',
hysteria_hop_interval: '30',
tls: '1',
tls_sni: 'hy2.example.com'
};
const hy2_node = normalize(hy2_flat);
expect('hy2.protocol_options.obfs_type', hy2_node.protocol_options.obfs_type, 'salamander');
expect('hy2.protocol_options.obfs_password', hy2_node.protocol_options.obfs_password, 'obfspw');
expect('hy2.protocol_options.up_mbps', hy2_node.protocol_options.up_mbps, '100');
expect('hy2.protocol_options.hop_interval', hy2_node.protocol_options.hop_interval, '30');
/* protocol_options must NOT carry over fields that are not in PROTOCOL_TO_UCI;
* the parser may write extra keys (e.g. from the form) that should be
* dropped at the canonical boundary. */
expect_keys('hy2.protocol_options keys',
hy2_node.protocol_options,
['obfs_type', 'obfs_password', 'up_mbps', 'hop_interval']);
/* --- direct: protocol_options carries override_address/port */
const direct_flat = {
label: 'direct1',
type: 'direct',
address: '10.0.0.1',
port: '80',
override_address: '1.0.0.1',
override_port: '443'
};
const direct_node = normalize(direct_flat);
expect('direct.protocol_options.override_address', direct_node.protocol_options.override_address, '1.0.0.1');
expect('direct.protocol_options.override_port', direct_node.protocol_options.override_port, '443');
/* direct has no credentials: */
expect_keys('direct.credentials keys', direct_node.credentials, []);
/* --- empty/null UCI values are dropped from protocol_options (the
* Adapter's removeBlankAttrs would drop them again at emit time, but
* keeping normalize strict means a stale `flow: ''` from a recent
* share link does not appear in REQUIRED_CREDENTIALS or similar
* checks that walk the canonical shape). */
const empty_flat = {
type: 'vless',
address: 'a',
port: '1',
uuid: 'u',
vless_flow: '',
packet_encoding: null
};
const empty_node = normalize(empty_flat);
expect_keys('empty.protocol_options keys', empty_node.protocol_options, []);
printf('%d checks, %d failures\n', checks, failures);
exit(failures === 0 ? 0 : 1);
@@ -0,0 +1,225 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Protocol inventory invariant.
#
# The protocol surface is spread over several places that must agree. PR-02
# moved two of them, so the paths here are the current ones:
#
# parser/uri.uc scheme -> type (what a share link becomes)
# model.uc CREDENTIALS (which UCI option holds each credential)
# parser/mapping.uc PROTOCOL_TO_UCI (which UCI option holds each protocol option;
# the Loader imports it as PROTOCOL_OPTIONS)
# adapter.uc REQUIRED_CREDENTIALS (what a buildable node needs)
# adapter.uc OPTION_FIELDS (what becomes a sing-box field)
# snapshots/generator/outbounds.json (what is actually emitted)
#
# The frontend has a matching check of its own,
# tests/frontend-protocol-inventory.js: the browser's protocol list must be a
# subset of what the backend models, which is the direction this shape test
# cannot see.
#
# Adding a protocol to one of them and forgetting another is the failure mode
# this test exists for: WireGuard shipped with no loader row (so the endpoint
# builder read fields that did not exist) and SSH shipped with no loader row
# and the wrong credential option name. Neither failed any test.
#
# Usage: sh tests/ucode/test_protocol_inventory.sh <repo-root> [work-dir]
ROOT="${1:-.}"
WORK="${2:-/tmp/hp-inventory-test}"
ROOT="$(cd "$ROOT" && pwd)"
SCRIPTS="$ROOT/root/etc/homeproxy/scripts"
SNAPSHOT="$ROOT/tests/snapshots/generator/outbounds.json"
FAILED=0
rm -rf "$WORK"
mkdir -p "$WORK/parser" "$WORK/inventory/config"
# --- stage 1: what does the parser produce? ------------------------------
# PR-02 moved the parsers to scripts/parser/{uri,protocols,validator,
# normalize,mapping}.uc; stage the whole tree plus the homeproxy mock
# so the share-link importer can find every import it needs.
cp "$ROOT/tests/ucode/mocks/homeproxy.uc" "$WORK/parser/homeproxy.uc"
# The test module imports 'parser/uri.uc', which resolves relative to
# the -L search root, so the parser tree must be staged as a `parser/`
# subdirectory of that root (not flattened into it).
mkdir -p "$WORK/parser/parser"
cp "$SCRIPTS/parser/"*.uc "$WORK/parser/parser/"
# The Loader imports PROTOCOL_OPTIONS from '../parser/mapping.uc'.
# After staging, the loader lives at $WORK/inventory/config/loader.uc,
# so the parser tree must also live at $WORK/inventory/parser/ for the
# import to resolve. Mirror it once now so inventory.uc below can use
# the loader as-is.
mkdir -p "$WORK/inventory/parser"
cp "$SCRIPTS/parser/"*.uc "$WORK/inventory/parser/"
cat > "$WORK/parser/types.uc" <<'EOF'
'use strict';
import { parse_uri } from 'parser/uri.uc';
const FEATURES = { with_quic: true, with_utls: true };
const LOG = function() {};
/* One sample per scheme the parser accepts. The values are the same ones
* test_parse_uri.uc asserts on, so this list stays honest. */
const SAMPLES = [
'anytls://secret@a.example.com:443?sni=a.example.com#AnyTLS',
'https://user:pass@b.example.com:8443#HTTPS',
'http://c.example.com:8080#HTTP',
'hysteria://d.example.com:36712?auth=pass&peer=d.example.com&protocol=udp#Hy1',
'hysteria2://pass@e.example.com:443?sni=e.example.com&obfs=salamander&obfs-password=op#Hy2',
'hy2://pass@e.example.com:443#Hy2Alias',
'snell://f.example.com:443?psk=pskvalue&version=4&obfs=http&obfs-host=bing.com#Snell',
'socks5://user:pass@g.example.com:1080#Socks',
'ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@i.example.com:8388#SS',
'trojan://pass@l.example.com:443?type=grpc&serviceName=gs&sni=l.example.com#Trojan',
'tuic://tuic-uuid:pass@m.example.com:443?congestion_control=bbr&sni=m.example.com#Tuic',
'vless://vless-uuid@n.example.com:443?security=reality&pbk=PUBKEY&sid=abcd&type=grpc&serviceName=gs&sni=n.example.com#Vless',
'vmess://eyJ2IjoiMiIsInBzIjoiVk1lc3MiLCJhZGQiOiJwLmV4YW1wbGUuY29tIiwicG9ydCI6IjQ0MyIsImlkIjoiM2FmODg1NjEtOWM2OS00YjE5LThmN2UtZjA4ZDU4MGJjMzM5IiwiYWlkIjoiMCIsIm5ldCI6IndzIiwidHlwZSI6Im5vbmUiLCJob3N0IjoicC5leGFtcGxlLmNvbSIsInBhdGgiOiIvd3MiLCJ0bHMiOiJ0bHMiLCJzbmkiOiJwLmV4YW1wbGUuY29tIn0='
];
let types = [];
for (let uri in SAMPLES) {
const config = parse_uri(uri, FEATURES, LOG);
if (config)
push(types, config.type);
}
printf('%.J\n', types);
EOF
if ! ( cd "$WORK/parser" && ucode -L "$WORK/parser" types.uc > "$WORK/parser/types.json" 2> "$WORK/parser/types.err" ); then
echo "FAIL: could not run the share-link parsers"
head -8 "$WORK/parser/types.err"
exit 1
fi
# --- stage 2: do the tables and the golden snapshot agree? ---------------
VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
sed -e "s#/sbin/validate_data#${VALIDATE_DATA}#" \
"$SCRIPTS/homeproxy.uc" > "$WORK/inventory/homeproxy.uc"
cp "$SCRIPTS/config/loader.uc" "$WORK/inventory/config/"
cp "$SCRIPTS/config/model.uc" "$WORK/inventory/config/"
cp "$SCRIPTS/config/adapter.uc" "$WORK/inventory/config/"
cp "$WORK/parser/types.json" "$WORK/inventory/parser-types.json"
cp "$SNAPSHOT" "$WORK/inventory/outbounds.json"
cat > "$WORK/inventory/inventory.uc" <<'EOF'
'use strict';
import { readfile } from 'fs';
import {
CREDENTIALS, INBOUND_CREDENTIALS, INBOUND_OPTIONS
} from './config/model.uc';
/* PR-02 moved the client option table to parser/mapping.uc; the Loader
* imports it from there as PROTOCOL_OPTIONS. */
import { PROTOCOL_TO_UCI as PROTOCOL_OPTIONS } from './parser/mapping.uc';
import { OPTION_FIELDS, REQUIRED_CREDENTIALS } from './config/adapter.uc';
const parser_types = json(readfile('parser-types.json')) || [];
const golden = json(readfile('outbounds.json')) || {};
/* Types the adapter turns into a sing-box *outbound*. */
const emitted_types = [];
for (let id in keys(golden))
push(emitted_types, golden[id].type);
/* Protocols that are modelled but deliberately produce something other than
* an outbound: WireGuard becomes an endpoint (generate_endpoint()), and is
* covered by tests/fixtures/generators/wireguard.uci instead of the golden
* snapshot. */
const ENDPOINT_TYPES = ['wireguard'];
let failures = 0, checks = 0;
function check(what, ok, detail) {
checks++;
if (ok)
return;
printf('FAIL %s%s\n', what, detail ? ': ' + detail : '');
failures++;
}
function contains(list, value) {
return index(list, value) !== -1;
}
/* 1. Every protocol any layer names must exist in the loader's option table;
* that table is what makes the protocol reachable from UCI at all. */
const LAYERS = {
'OPTION_FIELDS': keys(OPTION_FIELDS),
'REQUIRED_CREDENTIALS': keys(REQUIRED_CREDENTIALS),
'CREDENTIALS': keys(CREDENTIALS),
'ENDPOINT_TYPES': ENDPOINT_TYPES
};
for (let layer in keys(LAYERS)) {
for (let name in LAYERS[layer]) {
check(sprintf('%s.%s is modelled by loader PROTOCOL_OPTIONS', layer, name),
name in PROTOCOL_OPTIONS);
}
}
/* 2. Every type the parser can produce must be fully modelled: the loader
* must know its options and the adapter must be able to emit it. */
/* Non-empty guard: with an empty parser-types.json every check below would
* vanish and the section would "pass" by having nothing to check - which is
* how a parser that stopped producing types could go unnoticed. */
check('the parser produced at least one type to cross-check', length(parser_types) > 0);
for (let type_name in parser_types) {
check(sprintf("parser type '%s' is in PROTOCOL_OPTIONS", type_name),
type_name in PROTOCOL_OPTIONS);
check(sprintf("parser type '%s' is in OPTION_FIELDS", type_name),
type_name in OPTION_FIELDS);
check(sprintf("parser type '%s' has a golden outbound", type_name),
contains(emitted_types, type_name));
}
/* 3. Every protocol the adapter can emit, and every protocol it requires
* credentials for, must be pinned by the golden snapshot - otherwise a
* field regression in that protocol is invisible. */
for (let type_name in keys(OPTION_FIELDS)) {
check(sprintf("OPTION_FIELDS type '%s' has a golden outbound", type_name),
contains(emitted_types, type_name));
}
for (let type_name in keys(REQUIRED_CREDENTIALS)) {
if (contains(ENDPOINT_TYPES, type_name))
continue;
check(sprintf("REQUIRED_CREDENTIALS type '%s' has a golden outbound", type_name),
contains(emitted_types, type_name));
}
/* 4. The server side of the surface must be modelled too. Every protocol
* with a per-protocol option row must also have a credential row, or
* the Loader reads options for a protocol whose credentials it never
* loads. (The reverse does not hold: trojan / shadowtls / http /
* mixed / naive / socks have credentials and no per-protocol
* options.) */
for (let name in keys(INBOUND_OPTIONS)) {
check(sprintf("INBOUND_OPTIONS.%s has an INBOUND_CREDENTIALS row", name),
name in INBOUND_CREDENTIALS);
}
/* 5. The golden snapshot must not carry a protocol nobody models. */
for (let type_name in emitted_types) {
check(sprintf("golden type '%s' is in PROTOCOL_OPTIONS", type_name),
type_name in PROTOCOL_OPTIONS);
}
printf('%d checks, %d failures\n', checks, failures);
exit(failures ? 1 : 0);
EOF
if ( cd "$WORK/inventory" && ucode -L "$WORK/inventory" inventory.uc ); then
echo "PASS: protocol inventory is consistent"
else
echo "FAIL: protocol inventory is inconsistent"
FAILED=1
fi
exit $FAILED
@@ -0,0 +1,107 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# resource_blob_sha.uc: the git blob id helper the resource updater verifies
# downloads with.
#
# The check it feeds is only as good as this digest, so the expected values
# here are git's own: `git hash-object` on the same bytes, which is the oracle
# the helper has to reproduce (sha1("blob <len>\0" + content)). The fixtures
# are chosen to cover the two things an implementation gets wrong quietly:
# the empty file (a real blob id, not "no digest") and content whose byte
# length differs from its character count.
#
# Usage: sh tests/ucode/test_resource_blob_sha.sh <repo-root> [workdir]
set -u
ROOT="$(cd "${1:-$(dirname "$0")/../..}" && pwd)"
WORK="${2:-/tmp/hp-blob-sha-test}"
if ! command -v ucode > "/dev/null" 2>&1; then
echo "NOT RUN: ucode is not on PATH."
exit 2
fi
HELPER="$ROOT/root/etc/homeproxy/scripts/resource_blob_sha.uc"
rm -rf "$WORK"
mkdir -p "$WORK"
FAILED=0
CHECKS=0
FAILURES=0
expect() {
# expect <name> <actual> <expected>
CHECKS=$((CHECKS + 1))
if [ "$2" = "$3" ]; then
echo "PASS: $1"
else
echo "FAIL: $1 (expected '$3', got '$2')"
FAILED=1
FAILURES=$((FAILURES + 1))
fi
}
blob_sha() {
ucode -S "$HELPER" "$1" 2>"/dev/null"
}
# --- fixtures ---------------------------------------------------------------
: > "$WORK/empty"
printf 'abc' > "$WORK/abc"
printf '中文abc\n' > "$WORK/utf8"
printf 'line one\nline two\n' > "$WORK/two-lines"
# Expected values are git's own, printed at the end of this script so they can
# be re-derived with `git hash-object <file>` after any change to the helper's
# contract. They are literals rather than a live `git hash-object` call because
# the target this runs on has no git - and a test that silently loses its
# oracle on the device is worse than a literal with this comment on it.
expect "empty file" "$(blob_sha "$WORK/empty")" "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
expect "abc" "$(blob_sha "$WORK/abc")" "f2ba8f84ab5c1bce84a7b441cb1959cfc7093b7f"
expect "two lines" "$(blob_sha "$WORK/two-lines")" "e5c5c5583f49a34e86ce622b59363df99e09d4c6"
expect "multibyte content" "$(blob_sha "$WORK/utf8")" "eadc1642bc94f8074dc8e3b1be0e3f0885d3f912"
# The helper must not invent a digest for something it cannot read: the
# updater treats an empty answer as "cannot verify" and refuses, so a
# non-empty answer here would turn an unreadable file into a passing check.
expect "missing file produces no digest" "$(blob_sha "$WORK/does-not-exist")" ""
ucode -S "$HELPER" >/dev/null 2>&1
expect "no argument exits non-zero" "$?" "1"
ucode -S "$HELPER" "$WORK/does-not-exist" >/dev/null 2>&1
expect "unreadable file exits non-zero" "$?" "1"
# Two different contents must not collide (the trivial "always returns a
# constant" regression).
if [ -n "$(blob_sha "$WORK/abc")" ] && [ "$(blob_sha "$WORK/abc")" != "$(blob_sha "$WORK/two-lines")" ]; then
CHECKS=$((CHECKS + 1))
echo "PASS: different contents produce different digests"
else
CHECKS=$((CHECKS + 1))
FAILED=1
FAILURES=$((FAILURES + 1))
echo "FAIL: different contents produced the same digest"
fi
# The repository's own resource files are the real inputs: verify them against
# git when the host has it (the CI runner and a developer machine do; a router
# does not). This is the check that would notice the helper drifting from git's
# format on a file it actually has to hash.
if command -v git > "/dev/null" 2>&1 && [ -d "$ROOT/.git" ]; then
for f in "root/etc/homeproxy/resources/china_ip4.txt" \
"root/etc/homeproxy/resources/gfw_list.txt"; do
[ -f "$ROOT/$f" ] || continue
expect "matches git hash-object: $(basename "$f")" \
"$(blob_sha "$ROOT/$f")" "$(git hash-object "$ROOT/$f")"
done
fi
printf '%d checks, %d failures\n' "$CHECKS" "$FAILURES"
if [ "$FAILED" != "0" ]; then
echo "BLOB SHA TESTS FAILED"
exit 1
fi
echo "BLOB SHA TESTS PASSED"
exit 0
+272
View File
@@ -0,0 +1,272 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Behaviour tests for the rpcd module (root/usr/share/rpcd/ucode/luci.homeproxy).
#
# Until now that file was only ever compiled for syntax. tests/ucode/run.sh
# rewrites its absolute imports and runs `ucode -c`, and nothing called a single
# one of its ten methods - which is why the "Upload ECH config" button could
# stay 100% broken: the frontend has called
# certificate_write('client_ech_conf') since the initial commit, the ACL
# granted the write, and the backend simply had no case for that name.
#
# This drives the methods directly with a fake request, the same way
# tests/frontend-validators.js drives the frontend's validate callbacks.
#
# Usage: sh tests/ucode/test_rpc_methods.sh <repo-root> [workdir]
set -u
ROOT="$(cd "${1:-$(dirname "$0")/../..}" && pwd)"
WORK="${2:-/tmp/hp-rpc-methods}"
if ! command -v ucode > "/dev/null" 2>&1; then
echo "NOT RUN: ucode is not on PATH."
exit 2
fi
rm -rf "$WORK"
mkdir -p "$WORK/scripts"
cp -R "$ROOT/root/etc/homeproxy/scripts/." "$WORK/scripts/"
# stage_rewrite <src> <dst>: point a file's absolute /etc/homeproxy paths at the
# sandbox, and nothing else. Both the imports and the runtime directories have
# to move, or the test would write real certificates into /etc/homeproxy/certs.
# The certificate staging prefix is moved too (it is a literal /tmp path inside
# luci.homeproxy): the driver then writes and checks the staged upload under
# $WORK/tmp, so the test never touches a fixed global path and two concurrent
# runs cannot delete each other's upload between the stage and the call.
stage_rewrite() {
sed -e "s#/etc/homeproxy/scripts/#$WORK/scripts/#g" \
-e "s#^const HP_DIR = '/etc/homeproxy';#const HP_DIR = '$WORK';#" \
-e "s#^export const HP_DIR = '/etc/homeproxy';#export const HP_DIR = '$WORK';#" \
-e "s#^const RUN_DIR = '/var/run/homeproxy';#const RUN_DIR = '$WORK/run';#" \
-e "s#^export const RUN_DIR = '/var/run/homeproxy';#export const RUN_DIR = '$WORK/run';#" \
-e "s#^export const UCICONFIG_DIR = '/etc/config';#export const UCICONFIG_DIR = '$WORK/cfg';#" \
-e "s#/tmp/homeproxy_cert_#$WORK/tmp/homeproxy_cert_#g" \
"$1" > "$2"
}
stage_rewrite "$WORK/scripts/homeproxy.uc" "$WORK/scripts/homeproxy.uc.new"
mv -f "$WORK/scripts/homeproxy.uc.new" "$WORK/scripts/homeproxy.uc"
stage_rewrite "$ROOT/root/usr/share/rpcd/ucode/luci.homeproxy" "$WORK/rpc.uc"
# update_subscriptions.call() execs the staged updater through /bin/sh, so its
# shebang has to name the ucode that is running this test. On a target that is
# /usr/bin/ucode and nothing needs rewriting; on the off-target testbed the
# toolchain is built into a private prefix (tests/toolchain/build-ucode-*.sh)
# by design, and CI has nothing at /usr/bin/ucode at all - the first version of
# this test came back as "not found" (exit 127) there and passed on the device,
# which is the kind of environment dependence the staging is supposed to
# remove. The rewrite is asserted, not assumed.
UCODE_BIN="$(command -v ucode)"
if [ "$UCODE_BIN" != "/usr/bin/ucode" ]; then
# `|` as the delimiter, not `#`: the pattern and the replacement both
# contain `#!`, and busybox sed rejects the escaped form ("bad option in
# substitution expression") while GNU sed accepts it.
sed -e "1s|^#!/usr/bin/ucode\$|#!$UCODE_BIN|" \
"$WORK/scripts/update_subscriptions.uc" > "$WORK/scripts/update_subscriptions.uc.new"
mv -f "$WORK/scripts/update_subscriptions.uc.new" "$WORK/scripts/update_subscriptions.uc"
if ! head -n 1 "$WORK/scripts/update_subscriptions.uc" | grep -qxF "#!$UCODE_BIN"; then
echo "FAIL: could not point the staged updater's shebang at $UCODE_BIN"
head -n 1 "$WORK/scripts/update_subscriptions.uc"
exit 1
fi
fi
# After the rewrite, not before: the sed above creates the new file with the
# default umask, so a chmod applied to the original would be lost with the mv
# and /bin/sh would refuse to exec it (exit 126).
chmod +x "$WORK/scripts/update_subscriptions.uc"
mkdir -p "$WORK/certs" "$WORK/run" "$WORK/cfg" "$WORK/tmp"
cat > "$WORK/driver_body.uc" <<'DRIVER'
/* Appended to a copy of the module, so this runs in the module's own scope and
* drives its `methods` table exactly as rpcd would hand it to a request. */
/* writefile/readfile/access come from the module's own `fs` import above;
* only what is not already in scope is imported here. */
const WORK = '@@WORK@@';
const rpc = methods;
let checks = 0, failures = 0;
function check(what, ok, detail) {
checks++;
if (ok) return;
failures++;
printf('FAIL %s%s\n', what, detail != null ? ': ' + detail : '');
}
const CERT = '-----BEGIN CERTIFICATE-----\n' +
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n' +
'-----END CERTIFICATE-----\n';
const KEY = '-----BEGIN RSA PRIVATE KEY-----\n' +
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n' +
'-----END RSA PRIVATE KEY-----\n';
const ECH = '-----BEGIN ECH CONFIGS-----\n' +
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n' +
'-----END ECH CONFIGS-----\n';
/* The upload path is what the frontend's ui.uploadFile writes and what the
* module reads. The staged module's /tmp/homeproxy_cert_ prefix is rewritten
* to this run's work dir, so stage() and the module agree without either
* touching a fixed /tmp path. */
const STAGE_DIR = '@@STAGE@@';
function stage(filename, content) {
writefile(sprintf('%s/homeproxy_cert_%s.tmp', STAGE_DIR, filename), content);
}
function tmp_exists(filename) {
return access(sprintf('%s/homeproxy_cert_%s.tmp', STAGE_DIR, filename));
}
function call(filename) {
return rpc.certificate_write.call({ args: { filename: filename } });
}
/* --- the four names the frontend actually sends ------------------------- */
const names = ['client_ca', 'server_publickey', 'server_privatekey', 'client_ech_conf'];
const bodies = [CERT, CERT, KEY, ECH];
for (let i = 0; i < length(names); i++) {
const name = names[i];
stage(name, bodies[i]);
const ret = call(name);
check(sprintf('%s: accepted', name), ret && ret.result === true,
ret && (ret.error || sprintf('result=%J', ret.result)));
if (ret && ret.result === true) {
const stored = readfile(sprintf('%s/certs/%s.pem', WORK, name));
check(sprintf('%s: stored with a trailing newline', name),
stored != null && match(stored, /\n$/));
check(sprintf('%s: staging file removed after success', name), !tmp_exists(name));
}
}
/* --- the type checks still hold ----------------------------------------- */
stage('client_ca', KEY);
check('client_ca rejects a private key', call('client_ca').result === false);
stage('server_privatekey', CERT);
check('server_privatekey rejects a certificate', call('server_privatekey').result === false);
/* An ECH config is not a certificate: it has its own PEM markers, so the
* certificate validator must not be reused for it. */
stage('client_ech_conf', CERT);
check('client_ech_conf rejects a certificate body', call('client_ech_conf').result === false);
stage('client_ech_conf', 'not a pem at all');
check('client_ech_conf rejects garbage', call('client_ech_conf').result === false);
/* --- unknown names and empty uploads ------------------------------------ */
stage('client_ca', CERT);
const bogus = call('not_a_certificate');
check('an unknown filename is refused', bogus.result === false);
check('an unknown filename says why', bogus.error === 'illegal cerificate filename',
bogus.error);
writefile(sprintf('%s/homeproxy_cert_client_ca.tmp', STAGE_DIR), '');
check('an empty upload is refused', call('client_ca').error === 'empty certificate file');
system(sprintf('rm -f %s/homeproxy_cert_client_ca.tmp', STAGE_DIR));
check('a missing upload is refused', call('client_ca').result === false);
/* No method may throw on a request with no arguments at all. */
let threw = null;
try { rpc.certificate_write.call({}); } catch (e) { threw = sprintf('%s: %s', e.type, e.message); }
check('certificate_write tolerates an empty request', threw == null, threw);
/* update_subscriptions is async: the LuCI button used to block inside one
* XHR for the whole 5-15 s pipeline (wget + UCI commit + reload + health
* gate), which exceeded the browser XHR timeout on every browser we tried.
* The ubus method now spawns the script detached and returns immediately.
* What the test asserts is exactly the contract the frontend relies on:
* the call returns synchronously (without the script's exit status), the
* result flag says the script was started, and async:true tells the
* caller "don't wait, poll status instead".
*
* Asserting the lock file is present at the same instant is racy in this
* sandbox: the early-exit path (no subscription_url) runs the whole
* take-lock / load-state / release-lock sequence in microseconds, so the
* lock may already be gone by the time this script runs an `access()` on
* it. Polling the status RPC instead would be timing-dependent in the
* same way. The contract is the return shape; that is what we pin. */
{
const ret = rpc.update_subscriptions.call({ args: {} });
check('update_subscriptions returns an object', type(ret) === 'object');
check('update_subscriptions marks the call as async',
ret.async === true,
sprintf('got %J', ret));
check('update_subscriptions does not surface exit status or captured stdio',
ret.exitcode === null && !(('stdout' in ret) || ('stderr' in ret)),
sprintf('got %J', ret));
/* The companion update_subscriptions_status RPC reads the lock + the
* last log lines. What we pin here is the *shape* of the response:
* `running` is a boolean, `log_tail` is a string. A real
* running/false toggle only matters when there is a live lock file,
* which the sandbox cannot keep around long enough to observe
* deterministically. */
const sret = rpc.update_subscriptions_status.call({ args: {} });
check('update_subscriptions_status returns an object', type(sret) === 'object');
check('update_subscriptions_status.running is a bool',
type(sret.running) === 'bool',
sprintf('got %J', sret));
check('update_subscriptions_status.log_tail is a string',
type(sret.log_tail) === 'string',
sprintf('got %J', sret));
}
/* Every method rpcd exposes, so none can be shipped without ever having been
* executed. Four of these were referenced by no test at all. */
const all_methods = [
'acllist_read', 'acllist_write', 'certificate_write', 'connection_check',
'log_clean', 'node_parse', 'resources_get_version', 'resources_update',
'singbox_generator', 'singbox_get_features', 'update_subscriptions',
'update_subscriptions_status'
];
for (let m in all_methods) {
check(sprintf('%s is exposed with a call()', m),
type(rpc[m]) === 'object' && type(rpc[m].call) === 'function');
let t = null, ret = null;
try { ret = rpc[m].call({ args: {} }); } catch (e) { t = sprintf('%s: %s', e.type, e.message); }
check(sprintf('%s tolerates an empty request', m), t == null, t);
check(sprintf('%s answers with an object', m), ret == null || type(ret) === 'object',
sprintf('got %J', ret));
}
/* The argument validators must reject, not act on, an unknown value. */
check('acllist_read rejects an unknown list type',
rpc.acllist_read.call({ args: { type: 'not_a_list' } }).result === false ||
rpc.acllist_read.call({ args: { type: 'not_a_list' } }).error != null);
check('connection_check rejects an unknown site',
rpc.connection_check.call({ args: { site: 'not_a_site' } }).result === false);
check('log_clean rejects an unknown log type',
rpc.log_clean.call({ args: { type: '../etc/passwd' } }).status !== 0);
printf('rpc methods: %d checks, %d failures\n', checks, failures);
exit(failures ? 1 : 0);
DRIVER
# The module ends with `return { 'luci.homeproxy': methods };`, which makes it a
# program rather than an importable module - that is how rpcd runs it. Drop
# that one line and append the driver, so the module's top-level code builds
# `methods` and the driver drives it in the same scope. Nothing else about the
# file changes.
sed '$d' "$WORK/rpc.uc" > "$WORK/run.uc"
sed -e "s#@@WORK@@#$WORK#" -e "s#@@STAGE@@#$WORK/tmp#" "$WORK/driver_body.uc" >> "$WORK/run.uc"
if ucode -L "$WORK/scripts" -L "$WORK" "$WORK/run.uc"; then
echo "PASS: rpc method behaviour"
rm -rf "$WORK"
exit 0
else
echo "FAIL: rpc method behaviour"
rm -rf "$WORK"
exit 1
fi
@@ -0,0 +1,109 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* B1.1: unit tests for subscription/decoder.uc. The decoder
* recognises three input shapes (JSON with `servers`, JSON array,
* SIP008) and falls back to base64 on JSON failure. Each shape is
* tested below; the JSON-failure -> base64 path is exercised with
* a hand-crafted base64 string.
*
* Run through tests/ucode/run.sh, which stages the module next to
* tests/ucode/mocks/homeproxy.uc (which carries decodeBase64Str).
*/
'use strict';
import { decode } from 'decoder';
const LOG = (..._args) => {};
let failures = 0;
let checks = 0;
function expect(name, actual, expected) {
checks++;
if (sprintf('%J', actual) !== sprintf('%J', expected)) {
printf('FAIL %s: expected %J, got %J\n', name, expected, actual);
failures++;
}
}
/* --- empty / falsy content short-circuits --- */
expect('empty string', decode('', LOG, 'sub'), []);
expect('null content', decode(null, LOG, 'sub'), []);
/* --- JSON with 'servers' key (clash proxy-provider) --- */
/* These two shapes used to decode to [] - see the note in
* subscription/decoder.uc: the SIP008 probe read `.server` off a
* string, ucode threw, the JSON try/catch swallowed it and the
* base64 fallback failed. Both are legitimate subscription
* payloads whose entries are plain share links, so the assertion is
* now "the links survive" rather than "the old bug is preserved"
* (plan 3.2: do not keep a bug as the contract). */
{
const out = decode('{"servers":["vless://a", "trojan://b"]}', LOG, 'sub');
expect('json servers: length', length(out), 2);
expect('json servers: entry 0', out[0], 'vless://a');
expect('json servers: entry 1', out[1], 'trojan://b');
}
/* --- JSON array of URI strings --- */
{
const out = decode('["vless://a", "trojan://b"]', LOG, 'sub');
expect('json uri array: length', length(out), 2);
expect('json uri array: entry 1', out[1], 'trojan://b');
}
/* --- mixed: SIP008 objects and share links in one list --- */
{
const mixed = '[{"server":"1.2.3.4","server_port":8388,'
+ '"password":"x","method":"chacha20-ietf-poly1305"},'
+ '"vless://a"]';
const out = decode(mixed, LOG, 'sub');
expect('mixed: length', length(out), 2);
expect('mixed: object tagged', out[0].nodetype, 'sip008');
expect('mixed: link kept', out[1], 'vless://a');
}
/* --- SIP008: array of objects with server+method --- */
{
const sip = '[{"server":"1.2.3.4","server_port":8388,'
+ '"password":"x","method":"chacha20-ietf-poly1305"},'
+ '{"server":"5.6.7.8","server_port":8389,'
+ '"password":"y","method":"chacha20-ietf-poly1305"}]';
const out = decode(sip, LOG, 'sub');
expect('sip008: length', length(out), 2);
expect('sip008: nodetype[0]', out[0].nodetype, 'sip008');
expect('sip008: nodetype[1]', out[1].nodetype, 'sip008');
expect('sip008: server[0] kept', out[0].server, '1.2.3.4');
}
/* --- base64 fallback when JSON parse fails --- */
{
/* "vless://first\ntrojan://second" base64-encoded. Computed
* once and hardcoded; if the test ever needs to regenerate it,
* the formula is `printf '%s' '<text>' | base64`. */
const b64 = 'dmxlc3M6Ly9maXJzdAp0cm9qYW46Ly9zZWNvbmQ=';
const out = decode(b64, LOG, 'sub');
expect('base64: length', length(out), 2);
expect('base64: first', out[0], 'vless://first');
expect('base64: second', out[1], 'trojan://second');
}
/* --- total failure (neither JSON nor base64) returns [] --- */
{
const out = decode('!@#$% not json and not base64!', LOG, 'sub');
expect('total failure: empty', length(out), 0);
}
/* Note: a *mixed* list (first entry is a SIP008 object, rest are
* URI strings) would crash in ucode on `nodes[1].nodetype = ...`
* because strings are immutable. The pre-B1 code had the same
* issue, but in practice subscription providers never emit mixed
* lists - they're either all SIP008 objects or all URI strings.
* Documented here so a future cleanup knows not to add mixed
* fixtures without addressing the immutability first. */
printf('%d checks, %d failures\n', checks, failures);
exit(failures ? 1 : 0);
@@ -0,0 +1,136 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Direct unit tests for subscription/fetcher.uc. The fetcher is a
* thin wrapper around wGETVerbose (subscription byte fetch + log +
* return), so the unit-testable surface is narrow:
*
* - empty content (network error / wget returned no body):
* - logs the failure with a redacted URL
* - returns { content: null, error: '...' }
* - non-empty content:
* - returns { content: <string>, error: null } with no log call
*
* The wGETVerbose used by the fetcher is imported from `homeproxy`.
* Tests stage a `homeproxy` module that overrides only wGETVerbose
* (everything else falls through to the real homeproxy.uc, so
* redactUrl/isEmpty are not stubbed). The override reads the
* pre-canned response / exit code from globals - global is the
* only cross-module shared state ucode exposes without a real
* dependency-injection hook.
*
* REDACTION COVERAGE
* ------------------
* redactUrl() is exported from homeproxyuc and is what stops the
* subscription token from landing in /var/run/homeproxy/homeproxy.log.
* The fetcher is the only place that logs a subscription URL on the
* failure path; this test makes sure the redacted URL reaches the
* log sink rather than the original (a security regression would
* fail here).
*/
'use strict';
import { fetch } from 'fetcher';
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++;
}
}
/* Capture the log() callback into a local buffer so we can inspect
* what the fetcher decided to log. */
function make_log_capture() {
let buf = [];
const log = (...args) => { push(buf, join(' ', args)); };
return { buf, log };
}
/* Test 1: empty content -> failure logged + null returned.
* Global override returns { content: '', error: '...' }. */
{
const cap = make_log_capture();
/* globals stub */
global.HP_TEST_WGET_CONTENT = '';
global.HP_TEST_WGET_ERROR = 'connection refused';
const r = fetch('https://user:tok@host.example.com/path?q=token=secret', null, cap.log);
expect('empty.content', r.content, null);
expect('empty.error is what wGETVerbose returned', r.error, 'connection refused');
if (length(cap.buf) !== 1) {
printf('FAIL empty.log count: expected 1, got %d\n', length(cap.buf));
failures++;
} else {
checks++;
/* The URL in the log must be redacted: no user:tok, no token=secret. */
const line = cap.buf[0];
if (index(line, 'tok') !== -1 || index(line, 'token=secret') !== -1) {
printf('FAIL empty.log redaction: token leaked: %s\n', line);
failures++;
} else {
checks++;
printf('PASS empty.log redaction: %s\n', line);
}
}
}
/* Test 2: non-empty content -> no log, body round-trips, error null. */
{
const cap = make_log_capture();
global.HP_TEST_WGET_CONTENT = 'stub-body';
global.HP_TEST_WGET_ERROR = null;
const r = fetch('https://host.example.com/path?q=anything', null, cap.log);
expect('ok.content', r.content, 'stub-body');
expect('ok.error', r.error, null);
if (length(cap.buf) !== 0) {
printf('FAIL ok.log: expected 0, got %d (%J)\n', length(cap.buf), cap.buf);
failures++;
} else {
checks++;
}
}
/* Test 3: the path carries the token too. Many providers hand out
* https://host:port/<user>/<token> with no query string at all - that is
* what the device this was found on is configured with - and the redaction
* used to keep the path while claiming homeproxy.log is world-readable.
* The host and port have to survive, or the message stops being useful. */
{
const cap = make_log_capture();
global.HP_TEST_WGET_CONTENT = '';
global.HP_TEST_WGET_ERROR = 'connection refused';
const r = fetch('https://bwg.example.net:2345/wjp/SECRETPATHTOKEN', null, cap.log);
expect('path.content', r.content, null);
if (length(cap.buf) !== 1) {
printf('FAIL path.log count: expected 1, got %d\n', length(cap.buf));
failures++;
} else {
checks++;
const line = cap.buf[0];
if (index(line, 'SECRETPATHTOKEN') !== -1 || index(line, 'wjp') !== -1) {
printf('FAIL path.log redaction: the path token leaked: %s\n', line);
failures++;
} else if (index(line, 'bwg.example.net:2345') === -1) {
printf('FAIL path.log redaction: host and port must survive: %s\n', line);
failures++;
} else {
checks++;
printf('PASS path.log redaction: %s\n', line);
}
}
}
/* --- summary ----------------------------------------------------------- */
if (failures > 0)
printf('FAIL: %d/%d checks failed\n', failures, checks);
else
printf('PASS: %d checks\n', checks);
exit(failures > 0 ? 1 : 0);
@@ -0,0 +1,190 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Unit tests for subscription/filter.uc. Both check() and
* apply_policy() are pure - no UCI access, no module state - so
* the tests can hand them ordinary values and a no-op logger.
* apply_policy() takes a canonical Node, so the equivalence block at
* the end also drives the real normalize()/flatten() pair to prove the
* move off the flat dict did not change what reaches UCI.
*
* Run through tests/ucode/run.sh, which stages the module next to
* tests/ucode/mocks/homeproxy.uc so the `from 'homeproxy'` import
* resolves to the test double, and parser/ next to the test so the
* round-trip imports resolve.
*/
'use strict';
import { check, apply_policy } from 'filter';
import { normalize } from './parser/normalize.uc';
import { flatten } from './parser/flatten.uc';
const LOG = (..._args) => {};
let failures = 0;
let checks = 0;
function expect(name, actual, expected) {
checks++;
if (sprintf('%J', actual) !== sprintf('%J', expected)) {
printf('FAIL %s: expected %J, got %J\n', name, expected, actual);
failures++;
}
}
/* --- check(): disabled mode passes everything through --- */
expect('disabled + match', check('foo', 'disabled', ['foo'], LOG), false);
expect('disabled + miss', check('foo-bar', 'disabled', ['baz'], LOG), false);
expect('disabled + no kw', check('foo', 'disabled', [], LOG), false);
/* --- check(): empty inputs short-circuit --- */
expect('empty name', check(null, 'blacklist', ['x'], LOG), false);
expect('empty keywords', check('foo', 'blacklist', [], LOG), false);
/* --- check(): blacklist --- */
expect('blacklist hit', check('foo-bar', 'blacklist', ['bar'], LOG), true);
expect('blacklist miss', check('foo-bar', 'blacklist', ['baz'], LOG), false);
expect('blacklist multi-hit', check('foo-bar', 'blacklist', ['baz','bar'],LOG), true);
expect('blacklist invalid-rx skipped',
check('foo', 'blacklist', ['[unterminated'], LOG), false);
/* --- check(): whitelist inverts the result --- */
expect('whitelist hit (kept)', check('foo-bar', 'whitelist', ['bar'], LOG), false);
expect('whitelist miss (dropp)', check('foo-bar', 'whitelist', ['baz'], LOG), true);
/* whitelist with no keywords: the early-return guard (isEmpty(keywords))
* fires before the whitelist inversion, so the node is kept. Documenting
* this here so a future cleanup of the early-return does not silently
* change the behaviour. */
expect('whitelist empty kw (kept)', check('foo-bar', 'whitelist', [], LOG), false);
/* --- apply_policy(): canonical Node, tls.insecure override --- */
{
const node = apply_policy(
{ type: 'vless', tls: { enabled: '1' }, protocol_options: {} },
{ allow_insecure: '1', packet_encoding: 'xudp' }
);
expect('tls.enabled=1 + allow_insecure=1 sets tls.insecure', node.tls.insecure, '1');
expect('tls.enabled=1 + allow_insecure=1 also sets packet_encoding',
node.protocol_options.packet_encoding, 'xudp');
}
{
const node = apply_policy(
{ type: 'vless', tls: { enabled: '1' }, protocol_options: {} },
{ allow_insecure: '0', packet_encoding: 'xudp' }
);
expect('tls.enabled=1 + allow_insecure=0 leaves tls.insecure absent',
'insecure' in node.tls, false);
}
{
const node = apply_policy(
{ type: 'vless', tls: { enabled: '0' }, protocol_options: {} },
{ allow_insecure: '1', packet_encoding: 'xudp' }
);
expect('tls.enabled=0 + allow_insecure=1 leaves tls.insecure absent',
'insecure' in node.tls, false);
}
/* --- apply_policy(): packet_encoding only for vless/vmess --- */
{
const node = apply_policy(
{ type: 'trojan', tls: { enabled: '0' }, protocol_options: {} },
{ allow_insecure: '0', packet_encoding: 'xudp' }
);
expect('trojan + packet_encoding not set',
'packet_encoding' in node.protocol_options, false);
}
{
const node = apply_policy(
{ type: 'vmess', tls: { enabled: '0' }, protocol_options: {} },
{ allow_insecure: '0', packet_encoding: 'xudp' }
);
expect('vmess + packet_encoding set', node.protocol_options.packet_encoding, 'xudp');
}
/* A node that did not go through normalize() has no protocol_options; the flat
* version created that key unconditionally, so this must too. */
{
const node = apply_policy({ type: 'vless', tls: { enabled: '0' } },
{ allow_insecure: '0', packet_encoding: 'xudp' });
expect('a node without protocol_options gets one',
node.protocol_options.packet_encoding, 'xudp');
}
/* --- apply_policy(): null / undefined guard --- */
expect('apply_policy(null) returns null',
apply_policy(null, { allow_insecure: '0', packet_encoding: 'xudp' }), null);
/* --- equivalence: flat order and canonical order write the same UCI --------
*
* The policy step used to run on the parser's flat dict with normalize() after
* it; it runs on the canonical Node now. What reaches the router is
* flatten(node), so the two orders are equivalent exactly when that output is
* identical - asserted here for every combination the tweaks can produce.
*
* The old order is written out as a local helper rather than left in the
* module: keeping a copy of the thing being replaced is what makes this an
* equivalence proof instead of a restatement of the new code.
*/
function legacy_flat_policy(flat, opts) {
if (flat.tls === '1' && opts.allow_insecure === '1')
flat.tls_insecure = '1';
if (flat.type in ['vless', 'vmess'])
flat.packet_encoding = opts.packet_encoding;
return flat;
}
/* Fresh object per call: apply_policy mutates, so a shared fixture would leak
* one case's tweaks into the next. */
function flat_vless() {
return { type: 'vless', address: 'a.example.com', port: '443', label: 'n1',
tls: '1', tls_sni: 'a.example.com',
uuid: '11111111-2222-3333-4444-555555555555', flow: 'xtls-rprx-vision' };
}
function flat_vless_notls() {
return { type: 'vless', address: 'a.example.com', port: '443', label: 'n1',
tls: '0', uuid: 'u' };
}
function flat_vmess() {
return { type: 'vmess', address: 'b.example.com', port: '8443', label: 'n2',
tls: '0', uuid: 'u' };
}
function flat_trojan() {
return { type: 'trojan', address: 'c.example.com', port: '443', label: 'n3',
tls: '1', password: 'p' };
}
function equivalence(name, fixture, opts) {
const legacy = flatten(normalize(legacy_flat_policy(fixture(), opts)));
const node = normalize(fixture());
apply_policy(node, opts);
expect(name, sprintf('%J', flatten(node)), sprintf('%J', legacy));
}
equivalence('equivalence: vless + tls + allow_insecure', flat_vless,
{ allow_insecure: '1', packet_encoding: 'xudp' });
equivalence('equivalence: vless + tls, allow_insecure off', flat_vless,
{ allow_insecure: '0', packet_encoding: 'xudp' });
equivalence('equivalence: vless without tls, allow_insecure on', flat_vless_notls,
{ allow_insecure: '1', packet_encoding: 'packet' });
equivalence('equivalence: vmess + packet_encoding', flat_vmess,
{ allow_insecure: '1', packet_encoding: 'xudp' });
equivalence('equivalence: trojan, no packet_encoding path', flat_trojan,
{ allow_insecure: '1', packet_encoding: 'xudp' });
/* Both sides could drop the tweak and still be "equivalent", so assert the
* tweaks actually reach the UCI-key output. */
{
const node = normalize(flat_vless());
apply_policy(node, { allow_insecure: '1', packet_encoding: 'xudp' });
const out = flatten(node);
expect('the tweaks reach the UCI-key output (tls_insecure)', out.tls_insecure, '1');
expect('the tweaks reach the UCI-key output (packet_encoding)',
out.packet_encoding, 'xudp');
}
printf('%d checks, %d failures\n', checks, failures);
exit(failures ? 1 : 0);
@@ -0,0 +1,82 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# B1.2: integration test for subscription/repository.uc. Unlike
# the filter/decoder unit tests, this one writes to a real UCI
# cursor, so the runner stages a sandboxed config dir with a
# pre-existing homeproxy file and points the cursor at it.
#
# Staging layout:
# $WORK/sandbox/homeproxy UCI file with seed sections
# $WORK/sandbox/ cursor root
# $WORK/scripts/ test + homeproxy mock + module
#
# cursor(dir) reads from <dir>/<name> directly (NOT <dir>/config/
# <name>), so the seed file lives at the top of the sandbox dir.
#
# Run from tests/ucode/run.sh, which exports UCODE_MODULES_DIR so
# `import { cursor } from 'uci'` and `import { md5 } from 'digest'`
# resolve against the local testbed modules.
#
# Usage: sh tests/ucode/test_subscription_repository.sh <repo-root> [work-dir]
ROOT="${1:-.}"
WORK="${2:-/tmp/hp-subscription-repo-test}"
ROOT="$(cd "$ROOT" && pwd)"
FAILED=0
SANDBOX="$WORK/sandbox"
STAGE="$WORK/scripts"
rm -rf "$WORK"
mkdir -p "$SANDBOX" "$STAGE"
# Seed the UCI file. The cursor reads <sandbox>/homeproxy (NOT
# <sandbox>/config/homeproxy); see the comment above.
uci_seed() {
printf 'config homeproxy %s\n' "$1" >> "$SANDBOX/homeproxy"
shift
for kv in "$@"; do
key="${kv%%=*}"
val="${kv#*=}"
printf '\toption %s %s\n' "$key" "$val" >> "$SANDBOX/homeproxy"
done
}
# The real /etc/config/homeproxy always carries `config homeproxy 'config'`;
# the sandbox needs it too, because libuci's uci_set() creates an anonymous
# section when the named one does not exist, and the main_node assertions
# read the options back by the section name 'config'.
uci_seed config routing_mode=bypass_mainland_china main_node=urltest
uci_seed cfgUSER0001 label=user-only type=vless address=user.example.com
uci_seed cfgKEEP00001 label=kept-node grouphash=test-group type=vless \
address=old.example.com stale_field=remove-me
uci_seed cfgDROP00001 label=dropped-node grouphash=test-group type=vless \
address=gone.example.com
# Stage the mock + the module + the test alongside each other so
# bare-name imports (`from 'homeproxy'`, `from 'repository'`) resolve
# via the work-dir -L path.
#
# PR-03: Repository imports parser/flatten.uc, which in turn imports
# parser/mapping.uc. Stage the whole parser/ tree into a sibling
# `parser/` directory so the bare-name imports inside it resolve.
cp "$ROOT/tests/ucode/mocks/homeproxy.uc" "$STAGE/"
# repository.uc imports '../parser/flatten.uc', so parser/ must be a
# sibling of the directory holding repository.uc (STAGE/subscription/),
# not of STAGE itself.
mkdir -p "$STAGE/subscription" "$STAGE/parser"
cp "$ROOT/root/etc/homeproxy/scripts/parser/"*.uc "$STAGE/parser/"
cp "$ROOT/root/etc/homeproxy/scripts/subscription/repository.uc" "$STAGE/subscription/"
cp "$ROOT/tests/ucode/test_subscription_repository.uc" "$STAGE/"
if ( cd "$STAGE" && ucode -L "$STAGE" test_subscription_repository.uc "$SANDBOX" ); then
echo "PASS: subscription repository integration test"
else
echo "FAIL: subscription repository integration test"
FAILED=1
fi
exit $FAILED
@@ -0,0 +1,411 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* PR-03 (Subscription Transaction Boundary): integration test for
* subscription/repository.uc. The repository writes to the UCI
* cursor that is passed in, which means the test needs a sandboxed
* config dir. tests/ucode/test_subscription_repository.sh stages
* the dir + the pre-existing UCI file and points the cursor at the
* staging root. The test then exercises the four paths the
* orchestrator relies on:
*
* - a user-created node (no grouphash) is preserved
* - a subscription node still in the cache is updated in place
* - 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
*
* PR-03 changes the input contract: Repository now accepts
* canonical Node objects (post- parser/normalize), and flattens
* them via parser/flatten internally. The orchestrator hands
* Repository.normalize(parse_uri(uri)) directly; the test builds
* the canonical shape explicitly.
*
* Returns 0 on success, 1 on any mismatch. Counts and section
* names are checked with cursor.get_all() against the live cursor
* so the test does not need to parse the UCI file by hand.
*/
'use strict';
import { cursor } from 'uci';
import { md5 } from 'digest';
import { readfile } from 'fs';
import { Repository } from './subscription/repository.uc';
/* PR-03: helper for building canonical Node fixtures. The
* orchestrator gets canonical Nodes from normalize(parse_uri(...));
* the test feeds them by hand so the repository's input contract
* (canonical Node, not flat UCI) is what gets exercised. */
function canonical_node(opts) {
return {
id: null,
name: opts.label,
type: opts.type,
address: opts.address,
port: opts.port || null,
common: {},
credentials: {},
tls: { enabled: null, ech: {}, utls: {}, reality: {} },
transport: { type: null },
multiplex: { enabled: null, brutal: {} },
protocol_options: {},
grouphash: opts.grouphash
/* No `label`: normalize() sets `name` and the orchestrator adds
* `label` only when carrying it over. This helper used to set
* `label` too, which is exactly why the section-name collision
* below went unnoticed - it made the fixture agree with the
* repository instead of with the model. */
};
}
const CFG = 'homeproxy';
const TYPE = 'node';
/* A2 (single commit): the repository must stage mutations and commit nothing.
* Unlike the cursor-based checks below, "did it commit" cannot be read out of
* the cursor - a commit is exactly what makes the cursor and the file agree -
* so the assertion is behavioural: the file on disk is byte-identical before
* and after each repository call, and only the test's own commit (standing in
* for the orchestrator's) makes the change durable. */
const CONFIG_PATH = ARGV[0] + '/' + CFG;
/* apply_main_node_refs() reports the urltest prune through the log()
* callback and the main_node switches through the returned result.log,
* so the test needs to see both. */
const seen = [];
const LOG = (...args) => push(seen, join(' ', args));
let failures = 0;
let checks = 0;
function expect(name, actual, expected) {
checks++;
if (sprintf('%J', actual) !== sprintf('%J', expected)) {
printf('FAIL %s: expected %J, got %J\n', name, expected, actual);
failures++;
}
}
const uci = cursor(ARGV[0]);
uci.load(CFG);
/* groupHash is what the orchestrator hands the repository. It
* is a per-subscription-url identifier - any opaque string the
* orchestrator likes, as long as it stays stable across fetches.
* Two distinct URLs would get two distinct groupHash values, so
* the test uses a single group to focus on the per-node logic. */
const groupHash = 'test-group';
/* Pre-existing UCI state: a user node, a subscription node that
* the new cache will update, and a subscription node the new
* cache will drop. The cursor.commit() below persists them so
* the repository's foreach() sees them when it runs. */
const u_user = 'cfgUSER0001';
const u_keep = 'cfgKEEP00001';
const u_drop = 'cfgDROP00001';
uci.set(CFG, u_user, TYPE);
uci.set(CFG, u_user, 'label', 'user-only');
uci.set(CFG, u_user, 'type', 'vless');
uci.set(CFG, u_user, 'address', 'user.example.com');
uci.set(CFG, u_keep, TYPE);
uci.set(CFG, u_keep, 'label', 'kept-node');
uci.set(CFG, u_keep, 'grouphash', groupHash);
uci.set(CFG, u_keep, 'type', 'vless');
uci.set(CFG, u_keep, 'address', 'old.example.com');
uci.set(CFG, u_keep, 'stale_field', 'remove-me');
uci.set(CFG, u_drop, TYPE);
uci.set(CFG, u_drop, 'label', 'dropped-node');
uci.set(CFG, u_drop, 'grouphash', groupHash);
uci.set(CFG, u_drop, 'type', 'vless');
uci.set(CFG, u_drop, 'address', 'gone.example.com');
uci.commit(CFG);
/* What the orchestrator would have built during the fetch phase
* for a successful subscription update. Two canonical Nodes from
* the cache:
* - the updated version of u_keep (with new address)
* - a brand-new node (will be added under md5(group+label))
* u_drop is intentionally absent -> the repository must delete it. */
const kept_updated = canonical_node({
grouphash: groupHash,
label: 'kept-node',
type: 'vless',
address: 'new.example.com',
port: '443'
});
const new_label = 'fresh-node';
const new_node = canonical_node({
grouphash: groupHash,
label: new_label,
type: 'trojan',
address: 'fresh.example.com',
port: '8443'
});
const node_cache = {
[groupHash]: {
[u_keep]: kept_updated,
[md5(groupHash + new_label)]: new_node
}
};
const node_result = [[kept_updated, new_node]];
const disk_before_apply = readfile(CONFIG_PATH);
const result = Repository.apply_nodes(uci, CFG, TYPE, node_cache, node_result, LOG);
expect('counts: added', result.added, 1);
expect('counts: removed', result.removed, 1);
expect('commit ownership: apply_nodes left the file untouched',
readfile(CONFIG_PATH), disk_before_apply);
/* The caller's single transaction boundary (A2). Everything below reads the
* on-disk state, so this commit is what makes it visible. */
uci.commit(CFG);
/* Reload to see the on-disk state. */
uci.load(CFG);
/* User node preserved verbatim. */
{
const cfg = uci.get_all(CFG, u_user);
expect('user: section exists', 'address' in cfg, true);
expect('user: address kept', cfg.address, 'user.example.com');
expect('user: no grouphash leaked in',
'grouphash' in cfg, false);
}
/* Updated node: the new address is written, the field the new config
* no longer carries is dropped, and a field that exists only in the
* new config (port, in this fixture) is ADDED. The old
* implementation walked only the stored section's keys, so a
* subscription that started sending a new option could never update
* an existing node; that behaviour was previously locked in here as a
* "quirk" and is now asserted to be fixed. */
{
const cfg = uci.get_all(CFG, u_keep);
expect('kept: section exists', 'address' in cfg, true);
expect('kept: address updated', cfg.address, 'new.example.com');
expect('kept: stale_field gone', 'stale_field' in cfg, false);
expect('kept: new field added', cfg.port, '443');
}
/* Dropped node is gone. */
{
const cfg = uci.get_all(CFG, u_drop);
/* get_all on a deleted section returns the section metadata
* (with .name) but no actual options. The cleanest check is
* that the label is no longer present. */
expect('drop: section removed', 'label' in cfg, false);
}
/* New node added under the md5(group+label) name. */
{
const nameHash = md5(groupHash + new_label);
const cfg = uci.get_all(CFG, nameHash);
expect('new: section added', 'address' in cfg, true);
expect('new: address correct', cfg.address, 'fresh.example.com');
expect('new: type correct', cfg.type, 'trojan');
expect('new: grouphash set', cfg.grouphash, groupHash);
}
/* --- PR-03: apply_main_node_refs ------------------------------------ */
/* Use a fresh cursor so the apply_nodes state above does not pollute the
* urltest-list assertions below. */
const uci2 = cursor(ARGV[0]);
uci2.load(CFG);
/* The sandbox's first `node` section (file order) is cfgUSER0001, which
* is what apply_main_node_refs() falls back to when a main_node target is
* gone. Assert against it explicitly rather than hard-coding a value that
* only holds for one sandbox layout. */
const FIRST_NODE = 'cfgUSER0001';
const groupB = 'groupB';
const n_alive = 'cfgALIVE0001';
/* Deliberately never created: the point of the prune case is an
* urltest_nodes entry whose section no longer exists. */
const n_gone = 'cfgGONE00001';
uci2.set(CFG, n_alive, TYPE);
uci2.set(CFG, n_alive, 'label', 'alive-node');
uci2.set(CFG, n_alive, 'grouphash', groupB);
uci2.set(CFG, n_alive, 'type', 'vless');
uci2.set(CFG, n_alive, 'address', 'alive.example.com');
uci2.set(CFG, 'config', 'main_node', 'urltest');
uci2.set(CFG, 'config', 'main_urltest_nodes', [n_alive, n_gone]);
uci2.commit(CFG);
uci2.load(CFG);
const disk_before_refs = readfile(CONFIG_PATH);
const main_refs = Repository.apply_main_node_refs(
uci2, CFG, 'config', TYPE,
{ main_node: 'urltest', main_udp_node: 'nil', has_nodes: true },
LOG
);
expect('main_node_refs: main_node kept as urltest',
main_refs.main_node, 'urltest');
expect('commit ownership: apply_main_node_refs left the file untouched',
readfile(CONFIG_PATH), disk_before_refs);
/* The caller's transaction boundary again (A2); the repository never commits,
* not even on the reconcile paths that used to commit for themselves. */
uci2.commit(CFG);
uci2.load(CFG);
expect('main_node_refs: dead reference pruned',
sort(uci2.get(CFG, 'config', 'main_urltest_nodes') || []), [n_alive]);
expect('main_node_refs: log line emitted for the prune',
length(filter(seen, (l) => match(l, /removing from urltest/))),
1);
/* "main_node target is gone": point main_node at a section that does not
* exist. The Repository must switch to the first node in the file. */
uci2.set(CFG, 'config', 'main_node', 'cfgNOPEEEEEE');
uci2.commit(CFG);
uci2.load(CFG);
const disk_before_refs2 = readfile(CONFIG_PATH);
const main_refs2 = Repository.apply_main_node_refs(
uci2, CFG, 'config', TYPE,
{ main_node: 'cfgNOPEEEEEE', main_udp_node: 'nil', has_nodes: true },
LOG
);
expect('main_node_refs: missing target switched to first_server',
main_refs2.main_node, FIRST_NODE);
expect('main_node_refs: switch logged',
length(filter(main_refs2.log, (l) => match(l, /switching to/))),
1);
/* A2: the switch is staged, not committed. */
expect('commit ownership: the main_node switch is not committed by the repository',
readfile(CONFIG_PATH), disk_before_refs2);
uci2.commit(CFG);
uci2.load(CFG);
expect('main_node_refs: switch persisted to UCI',
uci2.get(CFG, 'config', 'main_node'), FIRST_NODE);
/* The "no nodes at all" reset path needs a node-free config: delete every
* node section, then run the reconcile with main_node set. */
for (let s in (uci2.get(CFG, 'config') ? [] : []))
;
uci2.foreach(CFG, TYPE, (section) => uci2.delete(CFG, section['.name']));
uci2.commit(CFG);
uci2.load(CFG);
expect('main_node_refs: no node sections left',
uci2.get_first(CFG, TYPE), null);
const disk_before_refs3 = readfile(CONFIG_PATH);
const main_refs3 = Repository.apply_main_node_refs(
uci2, CFG, 'config', TYPE,
{ main_node: 'urltest', main_udp_node: 'urltest', has_nodes: false },
LOG
);
expect('main_node_refs: no-nodes path resets main_node to nil',
main_refs3.main_node, 'nil');
expect('main_node_refs: no-nodes path resets main_udp_node to nil',
main_refs3.main_udp_node, 'nil');
/* A2: the reset-to-nil path used to commit for itself; it must not any more. */
expect('main_node_refs: no-nodes path did not commit',
readfile(CONFIG_PATH), disk_before_refs3);
uci2.commit(CFG);
uci2.load(CFG);
expect('main_node_refs: no-nodes path wrote nil to UCI',
uci2.get(CFG, 'config', 'main_node'), 'nil');
expect('main_node_refs: no-nodes path wrote nil to UCI for udp',
uci2.get(CFG, 'config', 'main_udp_node'), 'nil');
expect('main_node_refs: no-nodes path logged the disable',
length(filter(main_refs3.log, (l) => match(l, /disable tproxy/))),
1);
/* --- PR-03: scrub_stale_urltest_refs ------------------------------- */
/* A routing_node whose urltest_nodes mixes a live and a missing node. */
const rn_x = 'rn_test';
const n_other = 'cfgOTHER0001';
uci2.set(CFG, n_other, TYPE);
uci2.set(CFG, n_other, 'label', 'other-node');
uci2.set(CFG, n_other, 'type', 'vless');
uci2.set(CFG, n_other, 'address', 'other.example.com');
uci2.set(CFG, rn_x, 'routing_node');
uci2.set(CFG, rn_x, 'node', 'urltest');
uci2.set(CFG, rn_x, 'urltest_nodes', [n_other, n_gone]);
uci2.commit(CFG);
uci2.load(CFG);
const disk_before_scrub = readfile(CONFIG_PATH);
const scrub = Repository.scrub_stale_urltest_refs(uci2, CFG, LOG);
expect('scrub: one routing_node rewritten', scrub.changed, 1);
expect('commit ownership: scrub left the file untouched',
readfile(CONFIG_PATH), disk_before_scrub);
uci2.commit(CFG);
uci2.load(CFG);
expect('scrub: live ref preserved',
sort(uci2.get(CFG, rn_x, 'urltest_nodes') || []), [n_other]);
/* An already-clean routing_node must not be rewritten. */
const scrub2 = Repository.scrub_stale_urltest_refs(uci2, CFG, LOG);
expect('scrub: clean state changes nothing', scrub2.changed, 0);
/* --- RFC 1321 MD5 vectors: the cross-language half of the contract ------ */
/* update_subscriptions.uc computes a node's `grouphash` as md5(url, fragment
* stripped), and the Repository above names each section
* md5(grouphash + label). The browser compares *its own* MD5 -
* homeproxy.js calcStringMD5(), which cannot use crypto.subtle because the
* Web Crypto spec has no MD5 - against that grouphash to decide which
* subscription tab a section belongs to, and whether it is a subscription
* node at all.
*
* So the two languages have to agree exactly, and nothing in the suite
* checked it: there was no MD5 vector anywhere, on either side. The failure
* mode is quiet - a digest that does not match does not raise, it just drops
* the subscription's nodes out of their tab. tests/frontend-md5.js pins the
* browser side to these same vectors; this block pins the ucode side, which
* is what turns "each side is standard" into "the two sides are equal".
*
* The last three cover what the browser implementation used to get wrong: it
* normalised CRLF to LF before hashing, and it encoded UTF-8 one UTF-16 code
* unit at a time. They are built with chr() rather than spelled as literals
* so the bytes under test are unambiguous:
*
* U+4E2D U+6587 -> e4 b8 ad e6 96 87
* U+1F600 -> f0 9f 98 80 */
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);
expect('md5: RFC 1321 A.5 ""', md5(''), 'd41d8cd98f00b204e9800998ecf8427e');
expect('md5: RFC 1321 A.5 "a"', md5('a'), '0cc175b9c0f1b6a831c399e269772661');
expect('md5: RFC 1321 A.5 "abc"', md5('abc'), '900150983cd24fb0d6963f7d28e17f72');
expect('md5: RFC 1321 A.5 "message digest"',
md5('message digest'), 'f96b697d7cb7938d525a2f31aaf161d0');
expect('md5: RFC 1321 A.5 a-z',
md5('abcdefghijklmnopqrstuvwxyz'), 'c3fcd3d76192e4007dfb496cca67e13b');
expect('md5: RFC 1321 A.5 alphanumeric',
md5('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'),
'd174ab98d277d9f5a5611c2c9f419d9f');
expect('md5: RFC 1321 A.5 eighty digits',
md5('12345678901234567890123456789012345678901234567890123456789012345678901234567890'),
'57edf4a22be3c955ac49da2e2107b67a');
expect('md5: CJK hashes as UTF-8', md5(MD5_CJK), 'a7bac2239fcdcb3a067903d8077c4a07');
expect('md5: an astral char is one 4-byte sequence',
md5(MD5_EMOJI), '2a02eac39d716a70ecf37579185927b6');
expect('md5: CRLF is hashed as-is', md5(chr(0x0d) + chr(0x0a)),
'81051bcc2cf1bedf378224b0a93e2877');
/* The grouphash shape itself: this is md5 of a subscription URL, i.e. what
* update_subscriptions.uc writes and what node.js has to reproduce after
* stripping the "#label" fragment. */
expect('md5: the grouphash shape',
md5('https://example.com/sub?token=abc'), '89167ef6961b44a5ba6e799f7cf8cd30');
printf('%d checks, %d failures\n', checks, failures);
exit(failures ? 1 : 0);
@@ -0,0 +1,204 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# The subscription updater has to actually run.
#
# update_subscriptions.uc used to read subscription_urls and filter_keywords
# off access_control.subscription, while the Loader puts them on access_control
# itself. Both came back [], so `if (!isEmpty(subscription_urls)) call(main)`
# was false: the script exited 0 having logged nothing, and both the LuCI
# "Update nodes from subscriptions" button and the cron entry were silent
# no-ops. Nothing caught it because no test had ever executed main() - the
# repository test calls apply_nodes() directly, and the model test asserted the
# *correct* shape, which only made the consumer's wrong read look fine.
#
# So this test drives the real script end to end and asserts the one property
# that was missing: given a configured subscription URL, the updater must
# attempt an update and say something about the outcome. The URL points at a
# closed port, so the attempt fails fast and main() returns before the
# `/etc/init.d/homeproxy reload` at its end - the device is not touched.
#
# Usage: sh tests/ucode/test_subscription_updater_runs.sh <repo-root> [workdir]
set -u
ROOT="$(cd "${1:-$(dirname "$0")/../..}" && pwd)"
WORK="${2:-/tmp/hp-updater-test}"
if ! command -v ucode > "/dev/null" 2>&1; then
echo "NOT RUN: ucode is not on PATH."
exit 2
fi
rm -rf "$WORK"
mkdir -p "$WORK/scripts" "$WORK/cfg" "$WORK/run"
cp -R "$ROOT/root/etc/homeproxy/scripts/." "$WORK/scripts/"
# Shadow `touch` so the test can see that a run refreshes its lock: the lock's
# mtime is the only thing that separates a live run from one a kill left
# behind, and the fetch loop (one wget per URL, ten seconds each) is where a
# run spends its time. The stub records the call and forwards to the real one.
REAL_TOUCH="$(command -v touch)"
mkdir -p "$WORK/bin"
cat > "$WORK/bin/touch" <<EOF
#!/bin/sh
printf '%s\n' "\$*" >> "$WORK/touch.log"
exec "$REAL_TOUCH" "\$@"
EOF
chmod +x "$WORK/bin/touch"
PATH="$WORK/bin:$PATH"
export PATH
# Redirect the package's runtime dir into the sandbox so the test does not
# append to the device's live /var/run/homeproxy/homeproxy.log, and point the
# configuration directory at the sandbox. The updater reads the config
# *inside* the lock now (A4), and it takes the directory from homeproxy.uc's
# UCICONFIG_DIR, so both rewrites land in this one file.
sed -e "s#^export const RUN_DIR = '/var/run/homeproxy';#export const RUN_DIR = '$WORK/run';#" \
-e "s#^export const UCICONFIG_DIR = '/etc/config';#export const UCICONFIG_DIR = '$WORK/cfg';#" \
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/scripts/homeproxy.uc"
# An unpatched copy would read - and on a real device write - the live
# /etc/config/homeproxy. Fail here instead of silently testing the wrong file.
if ! grep -q "^export const UCICONFIG_DIR = '$WORK/cfg';" "$WORK/scripts/homeproxy.uc"; then
echo "FAIL: could not point the updater at the sandbox config (anchor moved)"
rm -rf "$WORK"
exit 1
fi
# Point the updater's Loader at the sandbox config. Portable sed: write to a
# temp file and rename, so this works under both busybox and BSD sed.
#
# The updater resolves the directory through homeproxy.uc's UCICONFIG_DIR (see
# the rewrite above), so this step only needs to confirm the staged copy is the
# one that was rewritten - it no longer contains a literal config path.
if ! grep -q "^export const UCICONFIG_DIR = '$WORK/cfg';" "$WORK/scripts/homeproxy.uc"; then
echo "FAIL: the staged homeproxy.uc does not point at the sandbox config"
rm -rf "$WORK"
exit 1
fi
cat > "$WORK/cfg/homeproxy" <<-EOF
config homeproxy 'config'
option routing_mode 'proxy'
option main_node 'nil'
config homeproxy 'subscription'
list subscription_url 'https://127.0.0.1:1/never'
option filter_nodes 'disabled'
option auto_update '0'
EOF
LOGFILE="$WORK/run/homeproxy.log"
FAILED=0
if ( cd "$WORK/scripts" && ucode -L "$WORK/scripts" update_subscriptions.uc ) > "$WORK/stdout" 2>&1; then
:
else
echo "FAIL: update_subscriptions.uc exited non-zero"
cat "$WORK/stdout"
FAILED=1
fi
# The property under test: a configured URL means the updater must try. Before
# the fix the log stayed empty because main() was never reached.
if [ -s "$LOGFILE" ]; then
echo "PASS: the updater attempted an update and logged the outcome"
sed 's/^/ /' "$LOGFILE"
else
echo "FAIL: the updater logged nothing - main() was not reached, so the"
echo " subscription URL was read from the wrong level again"
FAILED=1
fi
# And the log must name the failure rather than claim success.
if grep -q "Successfully updated subscriptions" "$LOGFILE" 2>/dev/null; then
echo "FAIL: the updater reported success against an unreachable subscription"
FAILED=1
fi
# The run has to keep its own lock fresh for as long as it works: nothing
# refreshed the mtime while the fetch was in progress, so a run slower than
# LOCK_STALE could have its lock reclaimed as stale and then race the second
# run through the same read-modify-write.
if grep -q "update_subscriptions.lock" "$WORK/touch.log" 2>/dev/null; then
echo "PASS: the updater refreshes its lock while it works"
else
echo "FAIL: the lock's mtime is never refreshed - a run longer than the stale"
echo " window can have its lock broken underneath it"
FAILED=1
fi
# --- the lock ------------------------------------------------------------
# The cron entry and the LuCI button can both start this script, and an update
# is a read-modify-write of the whole configuration, so two runs interleaving
# them is last-writer-wins. A fresh lock must make the second run stand down...
LOCKDIR="$WORK/run/update_subscriptions.lock"
rm -f "$LOGFILE"
mkdir -p "$LOCKDIR"
# A4: the lock is now taken *before* the configuration is read, so a run that
# stands down must not have touched the config at all - and in particular must
# not have written the recovery snapshot the failure path would restore.
cp "$WORK/cfg/homeproxy" "$WORK/cfg.before-standdown"
if ( cd "$WORK/scripts" && ucode -L "$WORK/scripts" update_subscriptions.uc ) > "$WORK/stdout2" 2>&1; then
:
else
echo "FAIL: the updater exited non-zero while another run held the lock"
FAILED=1
fi
if grep -q "already running" "$LOGFILE" 2>/dev/null; then
echo "PASS: a held lock makes the updater stand down"
else
echo "FAIL: the updater ignored an existing lock"
sed 's/^/ /' "$LOGFILE"
FAILED=1
fi
if cmp -s "$WORK/cfg.before-standdown" "$WORK/cfg/homeproxy"; then
echo "PASS: a run that lost the lock left the configuration untouched"
else
echo "FAIL: a run that lost the lock still rewrote the configuration"
FAILED=1
fi
if [ -e "$WORK/cfg/homeproxy.hp-restore" ]; then
echo "FAIL: a run that lost the lock wrote a recovery snapshot"
FAILED=1
fi
# ...and a lock left behind by a killed process must not block every future
# update, so one older than the stale window is broken.
rm -f "$LOGFILE"
rm -rf "$LOCKDIR"
mkdir -p "$LOCKDIR"
touch -t 202001010000 "$LOCKDIR"
if ( cd "$WORK/scripts" && ucode -L "$WORK/scripts" update_subscriptions.uc ) > "$WORK/stdout3" 2>&1; then
:
else
echo "FAIL: the updater exited non-zero on a stale lock"
FAILED=1
fi
if grep -q "Breaking a stale update lock" "$LOGFILE" 2>/dev/null; then
echo "PASS: a stale lock is broken rather than blocking forever"
else
echo "FAIL: a stale lock was not broken - every future update would be skipped"
sed 's/^/ /' "$LOGFILE"
FAILED=1
fi
if [ -d "$LOCKDIR" ]; then
echo "FAIL: the lock was left behind after the run"
FAILED=1
fi
rm -rf "$WORK"
if [ "$FAILED" != 0 ]; then
exit 1
fi
exit 0
@@ -0,0 +1,195 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Direct unit tests for the TLS / transport object builders. Before
* this commit every TLS / transport code path went through the
* generator fixtures or the golden JSON snapshot - both end-to-end,
* both slow, and neither pointed a finger at the builder that
* actually broke. This file imports the builders directly and runs
* the shape checks that the generator was previously trusted to
* enforce, including the sing-box 1.14 constraints that the builders
* themselves carry (cert_path whitelist, no insecure on server-side,
* reality private_key only on server-side, etc.).
*
* load_tls / load_transport are also imported but only exercised via
* the simplest possible get()-stub - they are field-flattening
* wrappers whose only real correctness criterion is "the right UCI
* name maps to the right builder field", which the snapshot
* regression already covers transitively.
*/
'use strict';
import { buildTLSObject, buildTransportObject, validateHomeProxyPath, validateCertificatePath, HP_DIR } 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++;
}
}
/* --- buildTLSObject ---------------------------------------------------- */
/* 1. disabled -> null. The generator treats null as "TLS off"; if the
* builder ever started emitting an object when tls.enabled !== '1',
* sing-box would happily accept it and silently enable TLS. */
expect('disabled→null', buildTLSObject({ enabled: '0', server_name: 'example.com' }, false), null);
expect('null tls→null', buildTLSObject(null, false), null);
/* 2. client-side bare minimum: enabled + server_name round-trip. */
expect('client.enabled', buildTLSObject({ enabled: '1', server_name: 's.example.com' }, false).enabled, true);
expect('client.server_name', buildTLSObject({ enabled: '1', server_name: 's.example.com' }, false).server_name, 's.example.com');
expect('client.insecure (null on raw)', buildTLSObject({ enabled: '1', server_name: 's' }, false).insecure, null);
/* 3. insecure=true only meaningful on the client side; the server
* builder always emits null for `insecure` because TLS servers
* do not skip verification. A previous version emitted
* insecure=true on server, which made sing-box refuse to start. */
const clientInsecure = buildTLSObject({ enabled: '1', server_name: 's', insecure: '1' }, false);
expect('client.insecure=1 parsed', clientInsecure.insecure, true);
const serverInsecure = buildTLSObject({ enabled: '1', server_name: 's', insecure: '1' }, true);
expect('server.insecure forced null', serverInsecure.insecure, null);
/* 4. cert_path whitelist - the validator accepts /etc/homeproxy/... and
* /tmp/homeproxy_..., and rejects everything else (including
* /etc/passwd and relative paths). This is the security patch
* that backs the §4 path-whitelist entry; a regression here would
* let UCI drive sing-box into reading arbitrary files as root. */
expect('cert.path /etc/homeproxy', validateHomeProxyPath('/etc/homeproxy/certs/server_publickey.pem'), true);
expect('cert.path /tmp/homeproxy_', validateHomeProxyPath('/tmp/homeproxy_test/foo.pem'), true);
expect('cert.path /etc/passwd', validateHomeProxyPath('/etc/passwd'), false);
expect('cert.path /var/run/cron', validateHomeProxyPath('/var/run/cron'), false);
expect('cert.path relative', validateHomeProxyPath('relative/path.pem'), false);
expect('cert.path null', validateHomeProxyPath(null), false);
expect('cert.path empty', validateHomeProxyPath(''), false);
const clientCert = buildTLSObject({ enabled: '1', server_name: 's', cert_path: '/etc/homeproxy/certs/foo.pem' }, false);
expect('client.cert_path accepted', clientCert.certificate_path, '/etc/homeproxy/certs/foo.pem');
const clientCertBad = buildTLSObject({ enabled: '1', server_name: 's', cert_path: '/etc/passwd' }, false);
expect('client.cert_path rejected→null', clientCertBad.certificate_path, null);
/* 4b. Certificate paths are a *different* policy from rule-set paths.
* A TLS certificate normally lives under /etc/ssl/ or under the ACME
* state in /etc/acme/, and both sing-box jails mount those directories
* (runtime/service.sh). validateHomeProxyPath() only knows
* /etc/homeproxy/ and /tmp/homeproxy_, so a certificate the UI offered
* from /etc/ssl/ was accepted there and then silently dropped on the
* way into sing-box - certificate_path became null and the TLS listener
* failed with nothing pointing at the path. CERT_PATH_ROOTS is the
* backend half of the policy the frontend validator mirrors (guard 29). */
expect('cert.paths /etc/ssl accepted', validateCertificatePath('/etc/ssl/certs/srv.pem'), true);
expect('cert.paths /etc/acme accepted', validateCertificatePath('/etc/acme/example.com/cert.pem'), true);
expect('cert.paths /etc/homeproxy/certs accepted', validateCertificatePath('/etc/homeproxy/certs/srv.pem'), true);
expect('cert.paths /tmp/homeproxy_ rejected',
validateCertificatePath('/tmp/homeproxy_test/foo.pem'), false);
expect('cert.paths /etc/passwd rejected', validateCertificatePath('/etc/passwd'), false);
expect('cert.paths traversal rejected', validateCertificatePath('/etc/ssl/../shadow'), false);
expect('cert.paths bare root rejected', validateCertificatePath('/etc/ssl/'), false);
expect('cert.paths relative rejected', validateCertificatePath('etc/ssl/srv.pem'), false);
expect('cert.paths null rejected', validateCertificatePath(null), false);
expect('cert.paths empty rejected', validateCertificatePath(''), false);
/* The two policies must stay disjoint in the direction that matters: a
* rule-set path may not reach into /etc/ssl/, and a certificate does not need
* /tmp/homeproxy_ (that root is for upload staging). */
expect('rule-set path still rejects /etc/ssl', validateHomeProxyPath('/etc/ssl/certs/srv.pem'), false);
const sslCert = buildTLSObject({ enabled: '1', server_name: 's', cert_path: '/etc/ssl/certs/srv.pem' }, false);
expect('client.cert_path /etc/ssl kept', sslCert.certificate_path, '/etc/ssl/certs/srv.pem');
const sslServerKey = buildTLSObject({ enabled: '1', server_name: 's', cert_path: '/etc/acme/c.pem' }, true,
{ tls_key_path: '/etc/ssl/private/k.pem' });
expect('server.key_path /etc/ssl kept', sslServerKey.key_path, '/etc/ssl/private/k.pem');
const traversalCert = buildTLSObject({ enabled: '1', server_name: 's', cert_path: '/etc/ssl/../shadow' }, false);
expect('client.cert_path traversal→null', traversalCert.certificate_path, null);
/* 4c. The client ECH config path is a file sing-box opens as root too, and it
* used to reach the config unvalidated. */
const echOk = buildTLSObject(
{ enabled: '1', server_name: 's', ech: { enabled: '1', config_path: '/etc/homeproxy/certs/ech.pem' } }, false);
expect('client.ech.config_path accepted', echOk.ech.config_path, '/etc/homeproxy/certs/ech.pem');
const echBad = buildTLSObject(
{ enabled: '1', server_name: 's', ech: { enabled: '1', config_path: '/etc/passwd' } }, false);
expect('client.ech.config_path rejected→null', echBad.ech.config_path, null);
/* 5. utls: client-only. The server builder must never emit utls.
* Servers using uTLS would silently break sing-box. */
const clientUtls = buildTLSObject({ enabled: '1', server_name: 's', utls: { fingerprint: 'chrome' } }, false);
expect('client.utls.fingerprint', clientUtls.utls.fingerprint, 'chrome');
const serverUtls = buildTLSObject({ enabled: '1', server_name: 's', utls: { fingerprint: 'chrome' } }, true);
expect('server.utls forced null', serverUtls.utls, null);
/* 6. reality: client gets public_key only; server gets private_key.
* Mixing them up would let a malicious UCI value plant the wrong
* key on the wrong side. */
const clientReality = buildTLSObject({
enabled: '1', server_name: 's',
reality: { enabled: '1', public_key: 'PUB', short_id: 'abc' }
}, false);
expect('client.reality.public_key', clientReality.reality.public_key, 'PUB');
expect('client.reality.private_key', clientReality.reality.private_key, null);
const serverReality = buildTLSObject({
enabled: '1', server_name: 's',
reality: { enabled: '1', public_key: 'PUB', short_id: 'abc' }
}, true, { tls_reality_private_key: 'PRIV', tls_reality_server_addr: 'dest.example.com', tls_reality_server_port: '443' });
expect('server.reality.short_id', serverReality.reality.short_id, 'abc');
expect('server.reality.private_key', serverReality.reality.private_key, 'PRIV');
expect('server.reality.public_key', serverReality.reality.public_key, null);
/* 7. ECH: client uses config/config_path; server uses key. */
const clientEch = buildTLSObject({ enabled: '1', server_name: 's',
ech: { enabled: '1', config: 'AAAA', config_path: '/etc/homeproxy/certs/client_ech_conf.pem' } }, false);
expect('client.ech.config', clientEch.ech.config, 'AAAA');
const serverEch = buildTLSObject({ enabled: '1', server_name: 's' }, true, { tls_ech_key: 'AAAA\nBBBB' });
expect('server.ech key parsed', serverEch.ech.key[0], 'AAAA');
expect('server.ech.key[1]', serverEch.ech.key[1], 'BBBB');
/* --- buildTransportObject ---------------------------------------------- */
/* 1. empty transport -> null. */
expect('transport.empty→null', buildTransportObject({}, false), null);
expect('transport.no type→null', buildTransportObject({ type: null }, false), null);
expect('transport.null→null', buildTransportObject(null, false), null);
/* 2. ws transport round-trip. */
const ws = buildTransportObject({
type: 'ws', host: 'h.example.com', path: '/ws?ed=2048',
headers: { Host: 'h.example.com' }
}, false);
expect('ws.type', ws.type, 'ws');
expect('ws.host', ws.host, 'h.example.com');
expect('ws.path', ws.path, '/ws?ed=2048');
expect('ws.headers', ws.headers.Host, 'h.example.com');
/* 3. grpc transport. The permit_without_stream flag is client-only;
* the server builder must drop it because sing-box rejects it on
* a server inbound (sing-box 1.14 listed it as a known bug). */
const clientGrpc = buildTransportObject({
type: 'grpc', service_name: 'svc', permit_without_stream: '1'
}, false);
expect('client.grpc.permit', clientGrpc.permit_without_stream, true);
const serverGrpc = buildTransportObject({
type: 'grpc', service_name: 'svc', permit_without_stream: '1'
}, true);
expect('server.grpc.permit forced null', serverGrpc.permit_without_stream, null);
/* 4. httpupgrade transport. */
const hu = buildTransportObject({ type: 'httpupgrade', host: 'h.example.com', path: '/' }, false);
expect('httpupgrade.type', hu.type, 'httpupgrade');
expect('httpupgrade.host', hu.host, 'h.example.com');
/* --- summary ----------------------------------------------------------- */
if (failures > 0)
printf('FAIL: %d/%d checks failed\n', failures, checks);
else
printf('PASS: %d checks\n', checks);
exit(failures > 0 ? 1 : 0);
@@ -0,0 +1,99 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# ucode grammar canary.
#
# The package has to parse on the ucode that ImmortalWrt/OpenWrt snapshots
# ship (2026.01.16~85922056 at the time of writing), and that ucode is
# STRICTER than current upstream HEAD:
#
# * `export function name() { ... }` must be terminated by ';'
# (upstream relaxed this in b885dd0f, whose own test suite uses the
# semicolon-free form; openwrt/openwrt@main now pins that revision)
# * object destructuring (`const { a } = x;`) is rejected
#
# A toolchain built from ucode's default branch therefore accepts code that
# no router can parse, and the whole suite stays green while the package is
# dead on arrival. That is exactly how the A/B refactor shipped five modules
# (config/loader.uc, subscription/{filter,decoder,fetcher,repository}.uc) that
# failed to compile on a target, plus an update_subscriptions.uc that used
# destructuring.
#
# This check pins the *dialect* rather than a revision: it asserts that the
# ucode under test still rejects those two constructs, so a permissive
# toolchain fails here instead of hiding a target regression. It also asserts
# that the constructs the package legitimately relies on still compile, so
# pinning ucode to something too old fails here too.
#
# Set HP_ALLOW_PERMISSIVE_UCODE=1 to demote the "must be rejected" failures to
# warnings when deliberately probing a newer ucode.
#
# Usage: sh tests/ucode/test_ucode_grammar.sh
WORK="$(mktemp -d "${TMPDIR:-/tmp}/hp-ucode-grammar.XXXXXX")" || exit 1
trap 'rm -rf "$WORK"' EXIT INT TERM
if ! command -v ucode > "/dev/null" 2>&1; then
echo "NOT RUN: ucode is not on PATH"
exit 2
fi
FAILED=0
ALLOW_PERMISSIVE="${HP_ALLOW_PERMISSIVE_UCODE:-0}"
# compile <name> <source...> : write a module and try to load it. `export`
# is only legal in a module, so the probe must go through `import` rather than
# `ucode -c` on the file directly.
compiles() {
name="$1"
shift
file="$WORK/$name.uc"
printf '%s\n' "$*" > "$file"
ucode -e "import * as m from \"$file\";" >/dev/null 2>&1
}
# --- constructs the package relies on: these MUST compile -----------------
accept() {
name="$1"
src="$2"
if compiles "$name" "$src"; then
echo "PASS: $name compiles"
else
echo "FAIL: $name does not compile (toolchain too old or broken)"
FAILED=1
fi
}
# --- constructs the target rejects: these MUST fail ----------------------
reject() {
name="$1"
src="$2"
if compiles "$name" "$src"; then
if [ "$ALLOW_PERMISSIVE" = "1" ]; then
echo "WARN: $name compiles (permissive ucode allowed by HP_ALLOW_PERMISSIVE_UCODE=1)"
else
echo "FAIL: $name compiles, but the target ucode rejects it"
echo " the toolchain is more permissive than ImmortalWrt/OpenWrt 2026.01.16"
echo " (pin tests/toolchain/build-ucode-*.sh UCODE_REV to a strict revision)"
FAILED=1
fi
else
echo "PASS: $name rejected by ucode (matches the target)"
fi
}
accept exported_function_strict 'export function f() { return 1; };'
accept plain_function 'function g() { return 1; }'
accept const_export 'export const c = 1;'
accept computed_key_object "export const t = { ['a']: 1 };"
accept optional_chaining_and_nullish 'export function g(o) { return o?.a ?? 1; };'
accept object_spread 'export function h() { return { ...{ a: 1 } }; };'
accept template_literal 'export function i(v) { return `x${v}`; };'
reject exported_function_without_semicolon 'export function f() { return 1; }'
reject object_destructuring 'export function h(o) { const { a } = o; return a; };'
reject array_destructuring 'export function i(a) { const [x] = a; return x; };'
exit $FAILED