🎉 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

+427
View File
@@ -0,0 +1,427 @@
# luci-app-homeproxy tests
Automated checks for the pieces that `sing-box check` cannot validate: the
share-link parsers, the UCI→JSON generators and the LuCI form definitions.
## Running
```sh
tests/run.sh
```
* The **LuCI form snapshots** run locally and only need `node`. They re-render
the node/server views through a mock LuCI runtime and diff the result against
`tests/snapshots/*.json`.
* The **ucode tests** need `ucode`; the generator cases additionally run
`sing-box check`, so they need sing-box ≥ 1.14 as well (available on an
OpenWrt/ImmortalWrt target). `tests/run.sh` picks its branch in this order:
1. **local toolchain** — if both `ucode` and `sing-box` are on `PATH`, the
layer runs here, gated on sing-box ≥ 1.14;
2. **ssh** — otherwise, if `$HP_TEST_HOST` is set, the checkout is copied to
that machine and run there;
3. **skip** — otherwise the layer is skipped with a clear message.
The local toolchain is checked **first** on purpose: the testbed is the
documented way to run this layer off-target, so an unset `$HP_TEST_HOST` must
not shadow it. A skip is reported as `SKIP`, never as a pass: the run ends
with exit 3 and a `PARTIAL:` line unless `HP_ALLOW_PARTIAL=1` is set.
```sh
tests/toolchain/build-ucode-linux.sh # Linux host, then:
export PATH="$HOME/.local/ucode-testbed/bin:$PATH"
tests/run.sh # branch 1
HP_TEST_HOST=root@<test-machine> tests/run.sh # branch 2
HP_TEST_DIR=/tmp/hp-tests tests/run.sh
```
`tests/run.sh` will not ssh into any guessed address, and the production
router (`192.168.1.1` or anything in `192.168.1.1:*`) is refused by the
on-target workflow regardless.
### Test statistics
The guard / check / test-file counts quoted in the top-level `README.md` are
measured, not hand-maintained: `tests/print-stats.sh` prints one `key=value`
line each and is the number's single source.
```sh
sh tests/print-stats.sh
# guards=<n>
# checks=<n>
# test_files=<n>
```
`checks` costs one `tests/arch-guard.sh` run, because that is where the total is
counted; the script propagates the guard's exit status, so a red guard cannot be
reported as a green statistic. `test_files` counts files under `tests/` minus the
gitignored `.DS_Store`, which keeps the number the same before and after the
commit that adds a test file (and equal to `git ls-files tests | wc -l`).
### The staging policy: never install
The ssh path **copies the checkout and runs it in place**. It never installs the
package and never runs `apk` or `opkg` on the target. That is a rule, not a
preference.
Installing on a live device runs the package manager, which rewrites
`/etc/config/homeproxy` from the feed package — and on 2026-09-15 that destroyed
the test machine's node configuration. There was no backup and no snapshot, and
six nodes plus the `dns`, `server` and `subscription` sections were
unrecoverable. Staging cannot do that: everything it writes lives under
`$HP_TEST_DIR` and nothing outlives the run.
Two consequences are printed rather than left to be discovered. `tests/run.sh`
reports both versions before staging:
```
== target and source versions (staging; the package is not installed) ==
source : 28.9.1.14-r1
target : luci-app-homeproxy-26.236.50544~cb5d434
sing-box: 1.14.1
```
* the target may have a different build of the app installed, or none. **What is
tested is the checkout, not what the device runs** — so "it passes on the
device" never means "the installed package is good".
* `sing-box` on the target is whatever the target has. The local branch gates on
≥ 1.14; the staging branch records the version instead, because it cannot
install one.
If a test genuinely needs the installed package, that is a different test and
needs a different safety story (a config backup, taken first, on the device).
`tests/run.sh` exits non-zero if anything fails. The ucode part can also be run
on its own on a target:
```sh
sh tests/ucode/run.sh "$PWD"
```
### The standard device regression
One command, no install, safe to run against a target that is also in use:
```sh
HP_TEST_HOST=root@<test-machine> sh tests/run.sh # stage + run everywhere
HP_TEST_HOST=root@<test-machine> HP_REQUIRE_FW4=0 sh tests/run.sh # target without firewall4
```
`HP_REQUIRE_FW4` defaults to `1` on this branch and is validated as `0`/`1`;
the on-target workflow's `require_fw4` input maps onto it. Both the script and
the workflow print which value was used, so a fw4 layer that reported `NOT RUN`
is visible rather than inferred.
Installing the package and reloading the router is **not** part of this command.
If a change needs that (anything in `/etc/init.d/homeproxy`,
`scripts/runtime/*.sh`, or the generation path), do it as a separate, deliberate
step — and take `/etc/config/homeproxy` plus `/etc/homeproxy` aside first, with
the previous release's package kept for re-install. `apk` keeps a modified
conffile and drops a `<file>.apk-new` next to it; check `md5sum` on the config
before and after and delete the `.apk-new` files only once that matches.
## The off-target toolchain (Linux only)
The ucode layer needs a Linux host: ucode's `uci`/`ubus` modules link against
the OpenWrt libraries, `utpl` renders the fw4 template through the `fw4` ucode
module, and `sing-box check` rejects the Linux-only `routing_mark` field
everywhere else. The toolchain is therefore built from source into a private
prefix **on Linux**:
```sh
sh tests/toolchain/build-ucode-linux.sh
export PATH="$HOME/.local/ucode-testbed/bin:$PATH"
tests/run.sh
```
There is deliberately no macOS variant. One existed, and to make the fixtures
pass on Darwin it rewrote `routing_mark` to `null` inside the generator copy —
so the one field that keeps sing-box's proxy connection out of the nft redirect
chain was deleted before every test, and the regression that deleted it in
production (see guard 32) stayed invisible. A host that cannot validate the
generated configuration says so instead of weakening it.
On a development machine, run the whole suite in a Linux container:
```sh
docker run --rm -v "$PWD:/w" -w /w debian:stable-slim sh -c '
apt-get update -qq &&
apt-get install -y -qq --no-install-recommends build-essential ca-certificates
cmake curl gettext git libjson-c-dev libmd-dev libssl-dev meson ninja-build
pkg-config python3 zlib1g-dev &&
sh tests/toolchain/build-ucode-linux.sh &&
PATH="$HOME/.local/ucode-testbed/bin:$PATH" sh tests/run.sh'
```
The script installs into `~/.local/ucode-testbed` and removes nothing outside
that directory plus the clone/build scratch tree (`/tmp/ucode-build` by
default), so dropping the prefix undoes it completely. It builds:
| Component | Why |
| --- | --- |
| `libubox`, `libuci`, `libubus` | ucode's `uci`/`ubus`/`uloop` plugins need them |
| `libmd` | the `digest` module (`ucode-mod-digest` in the package Makefile) |
| `ucode` | the interpreter itself, plus `utpl`/`ucc`. Pinned to the revision ImmortalWrt/OpenWrt snapshots ship as `2026.01.16~85922056` — see "Why ucode is pinned" below |
| `liblucihttp` | `luci.http` is a thin wrapper over this C module and `homeproxy.uc` imports it |
| `luci` ucode sources | `http.uc`, `sys.uc`, … installed to `<prefix>/share/ucode/luci` like OpenWrt does |
| `sing-box` | the official 1.14.0 release binary; the fixtures are validated with `sing-box check` and the package targets 1.14 |
The script ends with a module import check (`lucihttp`, `luci.http`,
`luci.sys`, `uci`, `ubus`, `digest`, …), a check that `utpl` exists and that the
ucode grammar is still the strict one the package targets, and a
`urldecode_params()` behaviour assertion. It exits non-zero if any of them fail.
`tests/run.sh` refuses to run the fixtures against an older `sing-box`
(`FAIL: sing-box >= 1.14 required`), so keep the prefix first in `PATH`.
### What a host without the toolchain reports
The pure-shell, node and python suites still run, and `tests/run.sh` ends with
**exit 3 and `PARTIAL: … the ucode layer did not run`** rather than a pass: a
skipped layer is not evidence. `HP_ALLOW_PARTIAL=1` turns that run back into
"the subset passed" (exit 0) for callers that only want the fast layers; CI
does not set it, so a toolchain that fails to build there fails the job.
### Why ucode is pinned
`UCODE_REV` in the toolchain script is the revision the ImmortalWrt/OpenWrt
snapshot ships (`2026.01.16~85922056`). That ucode is stricter than current
upstream HEAD: it requires a terminating `;` after `export function ... }` and
rejects object and array destructuring, both of which upstream relaxed
afterwards (`openwrt/openwrt@main` pins `b885dd0f`, whose own test suite uses
the semicolon-free form). Building ucode from the default branch therefore
compiles code that no router accepts, which is how five refactor modules
shipped unparseable while CI stayed green.
`tests/ucode/test_ucode_grammar.sh` pins the *dialect* rather than trusting the
revision: it asserts the toolchain still rejects the two constructs and still
accepts the ones the package uses. It runs at the end of the toolchain build
and as the first step of `tests/ucode/run.sh`, so a permissive toolchain fails
loudly instead of turning into a green run. Set
`HP_ALLOW_PERMISSIVE_UCODE=1` to demote that to a warning while deliberately
probing a newer ucode.
**If the canary reports `exported_function_without_semicolon compiles, but the
target ucode rejects it`, the problem is usually the local build, not the pin.**
A testbed built before `UCODE_REV` was introduced tracks upstream HEAD and is
too permissive; rebuild it with `tests/toolchain/build-ucode-linux.sh` and the
canary passes. This is worth doing rather than reaching
for `HP_ALLOW_PERMISSIVE_UCODE=1`: on a permissive toolchain the canary's
"rejected" probes pass for the wrong reason, and a real
missing-`;`/destructuring regression would go unnoticed — which is precisely
how the generator subtree shipped unloadable.
### What the local testbed cannot cover
Nothing is skipped any more. The suite used to report four checks as `SKIP` on
a development host, and those skips are exactly what let a non-compiling
`update_subscriptions.uc` (and, once that was fixed, a destructuring statement)
reach a device:
| Was skipped | Now |
| --- | --- |
| `scripts/update_subscriptions.uc` | compiled like every other source; `luci.sys` comes from the toolchain |
| `scripts/firewall_pre.uc` | compiled; its `homeproxy` import resolves through `-L` |
| `rpcd/ucode/luci.homeproxy` | compiled from a copy whose absolute `/etc/homeproxy/...` imports are rewritten to the checkout |
| `tests/ucode/test_firewall_template.sh` | runs; `utpl` is a symlink to `ucode` and ships with the toolchain |
A missing `utpl`, or any other gap in the toolchain, now FAILS instead of
silently reducing coverage.
The one remaining `NOT RUN` is honest rather than a skip: the render half of
`tests/ucode/test_firewall_template.sh` needs the device-only `fw4` ucode
module. Its source-level half always runs, and it checks the actual
precondition of the bug that test exists for (nothing but the shebang may
precede the `{%-` tag, otherwise the trimmed newline glues the first generated
statement onto a comment). Stubbing `fw4` would let the render run everywhere,
but the assertions would then be about a ruleset the real `fw4` never
produced — see that script's header for the reasoning.
It is not left as a bare skip, though: `HP_REQUIRE_FW4=1` turns the skip into a
failure, and `tests/run.sh` defaults it to 1 in the ssh branch. A target always
has firewall4, so the one environment able to run the render must run it — and a
target that somehow cannot now fails instead of quietly reporting `NOT RUN`. The
default can be turned off for a target that legitimately lacks fw4
(`HP_REQUIRE_FW4=0`, or `require_fw4: false` on the on-target workflow).
Two further host differences are bridged so the remaining checks still run:
* **`/sbin/validate_data`** — `homeproxy.uc` shells out to this OpenWrt helper
for hostname/address/port validation. `tests/toolchain/validate-data.sh`
reproduces the caller's contract (exit 0 = valid) using the same validation
code as the parser unit tests; `tests/ucode/run.sh` exports it as
`HP_VALIDATE_DATA` when `/sbin/validate_data` is absent, and
`tests/ucode/test_generators.sh` substitutes it while staging the generator.
* **`routing_mark`** — a Linux-only `SO_MARK` socket option in sing-box (1.14
has no portable `set_mark` route action), so redirect/tproxy configs cannot
pass `sing-box check` outside Linux. This is a property of the *host*, not of
the product: the ucode layer runs on Linux (the toolchain, CI and the target
all are), and the assertion that the mark is emitted reads the generated JSON
instead of asking the local sing-box about it. The suite used to rewrite the
generated field to `null` on Darwin instead — which is how the regression
that deleted it in production stayed invisible (see guard 32).
## What is covered
| Path | Checks |
| --- | --- |
| `tests/i18n-coverage.py` | Reports how many `po/templates/homeproxy.pot` strings have a non-fuzzy, non-empty `po/zh_Hans/homeproxy.po` translation. Technical tokens that stay as-is are listed in `tests/i18n-ignore.txt`; anything else missing fails the run (`--fail-below 100`) and adds a job-summary entry. The release workflow (`build.yml`) deliberately warns instead of failing, so a translation gap cannot block packaging - the PR gates already catch it. |
| `tests/luci-form-snapshot.js` | Dumps every option (name, kind, title, description, depends, values, datatype/default/…) of the node, client and server views. Diffs against `tests/snapshots/{node,client,server}.json`, so a refactor that changes a field or its visibility fails. |
| `tests/ucode/test_homeproxy_utils.uc` | `executeCommand()` return shape, stderr/exit-code capture, binary detection, and a descriptor-leak check (200 calls). |
| `tests/ucode/test_resource_blob_sha.sh` | `resource_blob_sha.uc`, the digest the resource updater verifies downloads with. The expected values are git's own (`git hash-object` on the same bytes), covering the two cases 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. Unreadable input must produce *no* digest and exit non-zero - the updater treats an empty answer as "cannot verify" and refuses, so a made-up digest would turn an unreadable file into a passing check. Re-checks the repository's real resource files against git when the host has it. |
| `tests/ucode/test_homeproxy_utils_inject.uc` | The failure path of `executeCommand()`: the script stages a copy of `homeproxy.uc` whose `system()` call is replaced by `die()`, then checks that the exception still propagates and that neither descriptor leaks. |
| `tests/ucode/test_tls_transport.uc` | Direct calls into `buildTLSObject()` / `buildTransportObject()`: client-vs-server-only fields (no `insecure` on a server inbound, no `utls` on a server, `public_key` only on the client and `private_key` only on the server for reality), the ECH client/server split, and the `validateHomeProxyPath()` gate on `cert_path` / `key_path`. This is the boundary the generator fixtures only exercised end-to-end, so it is where the "server emitted insecure=true" and "reality key on the wrong side" classes of bug get named. |
| `tests/ucode/test_subscription_fetcher.uc` | `subscription/fetcher.uc` against a shimmed `wGETVerbose`: empty content logs a **redacted** URL and returns `{content:null,error}`; non-empty content returns the body and logs nothing. The redaction assertions are what stop the subscription token from reappearing in `homeproxy.log`: the userinfo, the **path** (providers hand out `https://host:port/<user>/<token>` with no query string) and the query string are all gone, while the host and port survive so the message still says which endpoint failed. The mocks carry verbatim copies of `redactUrl()`, and arch-guard 35 fails when one of them drifts from `homeproxy.uc`. |
| `tests/ucode/test_migrate_config.sh` | Runs `migrate_config.uc` (36 checks) against a sandboxed UCI file that needs every migration step it can see at once — the 1.14 DNS renames, the legacy `dns_server.address` split, `rcode://` → predefined rule, `rule_set_ipcidr_match_source` rename, `block-out`/`block-dns` → `action='reject'`, `auto_firewall` redistribution, and the `block-dns` default-server replacement. The staged copy's cursor is redirected at the sandbox; the runner aborts if that rewrite stops matching rather than letting the migration touch the live `/etc/config`. |
| `tests/ucode/test_firewall_pre.sh` | Behaviour tests for `firewall_pre.uc` (8 scenarios, one process each because the script has no exports): the tun accept pair, the per-server accept rules, the explicit-network narrowing, and — most importantly — that an invalid port or network skips the server with a WARN instead of interpolating garbage into an nft statement, which would make nft reject the entire ruleset. |
| `tests/ucode/test_fw4_names.sh` | Keeps the fw4 chain/set inventory in `scripts/fw4_names.sh` (used by `init.d/homeproxy` to clean up on stop) in sync with the objects declared in `scripts/firewall_post.ut`. |
| `tests/ucode/test_parse_uri.uc` | Per-scheme assertions on the `parse_uri()` result: anytls, http/https, hysteria, hysteria2/hy2, snell, socks(4/4a/5/5h), shadowsocks (SIP002 base64 + plain + plugin, Shadowrocket), trojan (ws/grpc), tuic, vless (reality/ws/http/httpupgrade), vmess (ws/h2/httpupgrade/grpc) and SIP008 objects, plus the rejection paths (unsupported kcp/quic, no QUIC support, invalid port, unknown scheme). |
| `tests/ucode/test_parser_normalize.uc` | Asserts that `parser/normalize.uc` correctly maps the parser's flat UCI-key output to the canonical Node shape (`tls`, `transport`, `multiplex`, `common`, `credentials`, `protocol_options`); pins the contract between the parser, the Loader's `PROTOCOL_OPTIONS` (now derived from `parser/mapping.uc`) and the Adapter. |
| `tests/ucode/test_parser_flatten.uc` | Round-trip invariant for the canonical-Node pipeline: `flatten(normalize(parse_uri(uri)))` must equal the parser's flat UCI dict field-for-field for every supported scheme. This is what stops the parser, the Loader's `PROTOCOL_OPTIONS` (now derived from `parser/mapping.uc`) and the Repository's flatten from drifting apart. |
| `tests/ucode/test_subscription_repository.uc` | Integration test for `subscription/repository.uc`: stages a sandboxed UCI file with a user node, a kept subscription node, and a dropped subscription node, then runs `Repository.apply_nodes` and asserts (a) the user node is untouched, (b) the kept node has the new fields and no stale fields, (c) the dropped node is gone, (d) a brand-new node is added under `md5(group+label)`. PR-03 added the `apply_main_node_refs` paths (urltest prune + missing-target switch + reset-to-nil) and the `scrub_stale_urltest_refs` pass to the same test. |
| `tests/ucode/test_generators.sh` | Runs `generate_client.uc`/`generate_server.uc` against the UCI fixtures in `tests/fixtures/generators/` inside a scratch directory (the `uci` cursor and `HP_DIR`/`RUN_DIR` are redirected) and validates each result with `sing-box check`. The `redirect` case is the product's default `proxy_mode` and asserts, from the emitted JSON, that every dialling outbound carries the runtime `routing_mark`. |
| `tests/toolchain/build-ucode-linux.sh` | Builds the local ucode toolchain described above; not part of `tests/run.sh`. |
| `tests/toolchain/validate-data.sh` | Off-target stand-in for `/sbin/validate_data`, used through `HP_VALIDATE_DATA`; not part of `tests/run.sh`. |
| `tests/ucode/test_ucode_grammar.sh` | Pins the ucode dialect: the toolchain must reject `export function ... }` without `;` and object/array destructuring, and must accept the constructs the package uses (`?.`/`??`, object spread, computed keys, template literals). |
| `tests/ucode/test_generators.sh` (wireguard case) | Asserts the WireGuard fixture keeps its private key, peer public key and local address list, so the endpoint builder cannot silently regress to reading flat UCI option names. |
| `tests/ucode/test_golden_outbounds.sh` | Builds one outbound per protocol from `tests/fixtures/generators/outbounds.uci` and diffs the result against `tests/snapshots/generator/outbounds.json`, so a field change for any protocol is a reviewable diff. Regenerate with `HP_UPDATE_SNAPSHOTS=1`. |
| `tests/ucode/test_golden_inbounds.sh` | PR-04: the server-side counterpart. Builds one inbound per protocol from `tests/fixtures/generators/server.uci` and diffs the result against `tests/snapshots/generator/inbounds.json`. The ACME `data_directory` is normalised to `<HP_DIR>/certs` so the snapshot is portable across hosts. Before this existed the server path had no snapshot at all, which is how the fixture's unused `listen_port` option survived. Regenerate with `HP_UPDATE_SNAPSHOTS=1`. |
| `tests/ucode/test_inbound_adapter.uc` | PR-04: `InboundFactory`'s protocol-shape decisions — snell / shadowsocks must get no `users[]` block (a snell users entry is read as an extra user key and makes sing-box reject the section), vless / vmess keep `flow` / `alterId` per-user, the snell listener set omits `udp_fragment` / `udp_timeout` / `network`, the server-only TLS tail (key material, ECH key, REALITY private key + handshake, ACME) reaches `buildTLSObject()`, hysteria v1 emits `obfs` as a string while hysteria2 emits the object, and the per-protocol credential requirements are enforced. |
| `tests/ucode/test_protocol_inventory.sh` | Cross-checks the protocol surface: every type named by `parse_uri.uc`, `CREDENTIALS`, `PROTOCOL_OPTIONS`, `REQUIRED_CREDENTIALS`, `OPTION_FIELDS` and the golden snapshot must agree. This is the check that catches "added a protocol to one table and forgot another". |
| `tests/frontend-protocol-inventory.js` | The frontend's single ordered protocol table (`homeproxy.js`'s `protocols`) against the backend tables that decide what the generators can build: every offered type must be modelled (`PROTOCOL_TO_UCI` on the client side, `INBOUND_CREDENTIALS` on the server side), every outbound-capable protocol (`OPTION_FIELDS`, `REQUIRED_CREDENTIALS`) must be selectable in the node form, both rendered orders are pinned, and protocols the backend models but no form offers must be listed as deliberate decisions. This is the check that would have caught `snell`: it had a form block, a credentials row and a golden outbound, but was missing from the node form's value list so it could not be selected. Node-only, no ucode needed. |
| `tests/frontend-rpc-inventory.js` | The frontend RPC boundary: exactly one `rpc.declare` site (inside `homeproxy.js`'s `rpcCall`), no view wrapping a call in `L.resolveDefault`, `rpcCall` itself catching and falling back and reporting, and no view left with an unused `require rpc`. Source-level on purpose - the behaviour it protects needs a browser, but "nobody reintroduces a second declaration site" does not. Comments are stripped before matching, because a naive grep matches the doc comment that quotes the old idiom. |
| `tests/frontend-validators.js` | Drives the shared form validators with a fake form context. The snapshots cannot cover these: a `validate` callback is a function, and the dump skips function-valued properties. Sharing the password validator between the node and server forms added the 2022-blake3 key-length check to the node form, which the server form had and the node form did not - a client could save a key that makes the generated configuration fail to decode. Reverse-proved: deleting that branch fails six checks. |
| `tests/lib/luci-module.js` | The off-target LuCI module loader (`'require x as y';` rewritten into a dependency lookup), shared by the snapshot renderer and the frontend invariant tests. Its default `_()` returns a LuCI String object with `.format`, because the form code calls both halves. |
| `tests/runtime/test_config_transaction.sh` | The `runtime/` helpers `init.d/homeproxy` leans on: the known-good copy, the fallback when generation produced nothing, the rollback, and the health probe. Pure shell, runs anywhere. |
| `tests/runtime/test_dns_snippets.sh` | The dnsmasq snippet writer's incremental behaviour (review L6). Counts `init.d/dnsmasq restart` calls through a stub and asserts that an identical second write does *not* restart (a restart flushes every client's DNS cache), while a changed resource list, routing mode, DNS port or `ipv6_support` setting does. Also pins that a mode change drops the snippet the previous mode produced, and that removing a resource list removes its snippet so the next call is stable again. Pure shell, runs anywhere. |
| `tests/runtime/test_health_probe.sh` | The functional health probes (`runtime/health.sh`): `hp_probe_dns` / `hp_probe_tcp` answer 0 / 1 / 2 and keep "cannot probe here" (no tool, no port) distinct from "the probe failed"; `hp_run_probes` *reports* by default and only fails the gate when `health_probe_strict` is on, and never fails it for a probe that could not run; `hp_config_port_for_tag` reads the probe ports out of the running configuration in both the generator's pretty shape and the compact one. `nslookup` and `nc` are stubbed, so it is pure shell and runs anywhere. |
| `tests/runtime/test_crontab_perms.sh` | `hp_crontab_drop()` (runtime/service.sh) edits `/etc/crontabs/root` through a temporary file, and `mv` replaces the file rather than its contents - so the new one inherited the shell's umask (0644) while procd creates the root crontab as 0600. The test drives the function against a sandboxed crontab and pins: the entry is dropped, other jobs survive, the mode is 0600 (including a file an older version had already loosened), a missing crontab is reported as a failure and is not created. Pure shell, runs anywhere. |
| `tests/runtime/test_resource_update.sh` | The content-verification path of `scripts/update_resources.sh`: the download URL is pinned to a commit, and the file is installed only when its git blob id matches the one GitHub reports for that path and commit. Drives all four outcomes (match, mismatch, no digest from the API, no local digest) with `wget` / `jsonfilter` / `ucode` / `uci` / `flock` stubbed and the script's absolute paths rewritten into a sandbox, asserting in each failure case that the installed list and its `.ver` are left untouched - the point of the check is that it refuses, so a driver that only walked the happy path would keep passing with the check deleted. Pure shell, runs anywhere. |
| `tests/runtime/test_runtime_extraction.sh` | Drives `init.d/homeproxy` through a stubbed environment (fake `ip` / `nft` / `fw4` / `ucode` / `sing-box` / `uci` / `netstat` / `nslookup` / `nc`, fake procd and jsonfilter state, fixture UCI) across five scenarios and diffs the resulting command + file trace against a baseline. Two baselines exist and are captured with the SAME harness, so the diff between them is exactly the intentional change: `tests/fixtures/runtime/trace.pre-pr05.txt` (the 517-line init script before PHASE 7 moved the plumbing out — the record that the extraction was behaviour-preserving) and `tests/fixtures/runtime/trace.golden.txt` (after the health-gate fix). Scenario D is the P0 regression: a candidate whose `mixed_port` is already taken must be rejected by the health gate, the previous known-good must survive, and the reload must roll back onto it. The generated fixture configuration is pretty-printed like the generator's own `%.J` output, because the runtime reads that file (`hp_config_ports`, `hp_config_port_for_tag`) and a compact fixture would test a shape production never writes. Pure shell, no ucode needed. Regenerate with `HP_UPDATE_GOLDEN=1` (optionally `HP_GOLDEN=` / `HP_INITD=` to target another baseline or revision). |
**`HP_UPDATE_GOLDEN=1` is a baseline-update mode, not a test.** It copies the
trace the run just produced over the baseline and exits 0 *before* the golden
comparison and every behavioural assertion after it
(`tests/runtime/test_runtime_extraction.sh:682-687`), so the exit status of a
regenerating run says nothing about the diff it wrote. That is the "test bakes a
wrong output into a snapshot" risk the review named: the scenarios did run, but
only the review of the written file
(`git diff tests/fixtures/runtime/trace.golden.txt`) can tell whether the new
baseline is correct. `tests/ucode/test_golden_outbounds.sh` is arranged the other
way round on purpose - it asserts on the generated JSON first and only then
honours `HP_UPDATE_SNAPSHOTS=1` - which `test_runtime_extraction.sh` cannot do,
because its assertions all run after the trace is built.
### What the LuCI form snapshots cover
A snapshot records, for every option the form builds: kind, name, title,
description, dependency set, the `ListValue` value list, and the non-function
properties that were set on it. Options are nested, so a `SectionValue`
option's nested form is included.
That last part was missing until it was fixed: `Option.toJSON()` skipped the
`subsection` key, which is where `SectionValue` keeps the nested form, so the
node form's entire body and client.js's rule sections were invisible. The
measured effect of the fix:
| Target | Options dumped | Snapshot size |
|---|---|---|
| `node.json` | 13 → **117** | 3.5 KB → 34.6 KB |
| `client.json` | 29 → **206** | 8.6 KB → 56.3 KB |
| `server.json` | 95 → 95 | 25.9 KB → 27.8 KB |
Before that fix, a change to the protocol picker - exactly the kind of change
PHASE 8 makes - was visible in `server.json` only. That is why
`tests/frontend-protocol-inventory.js` also exists: it asserts the *meaning* of
the protocol table against the backend, which a structural snapshot cannot.
What the snapshots still cannot tell you: whether the form is *usable*. They
prove a change to the option tree is deliberate and reviewable; they say nothing
about whether the resulting page works in a browser.
`tests/ucode/mocks/homeproxy.uc` is a test double for the real module: only
`validation()` is stubbed (the real one runs `/sbin/validate_data`, which does
not exist outside OpenWrt), everything else is a copy of the production code.
Three further mocks exist because a test needs a narrower seam than the full
module:
* `tests/ucode/mocks/homeproxy_fetcher.uc` — replaces `wGETVerbose` with a
stub that reads the canned response from a global, so the fetcher test never
shells out to `wget`.
* `tests/ucode/mocks/homeproxy_firewall.uc` — `isEmpty` / `validation` plus a
`RUN_DIR` read from a global, so `firewall_pre.uc` writes its nft fragments
into a scratch dir.
* `tests/ucode/test_migrate_config.sh` rewrites the staged copy of
`migrate_config.uc` so its cursor points at the sandbox. Whenever a test
rewrites a staged copy, it must abort when the rewrite anchor stops
matching — otherwise the sed no-ops and the script under test operates on
the live `/etc/config`. Both `test_migrate_config.sh` and
`test_firewall_pre.sh` do this.
### Files that still have no direct test
Deliberately, because a meaningful test would need more than the off-target
harness provides:
| Path | Why |
| --- | --- |
| `scripts/update_resources.sh` | The version query and the download are a GitHub API round-trip plus a jsdelivr fetch, so the verification path is driven by `tests/runtime/test_resource_update.sh` with `wget` / `jsonfilter` / `ucode` / `uci` / `flock` stubbed and the script's absolute paths rewritten into a sandbox: matching digest installs, a mismatch or a missing digest refuses and leaves the installed list and its `.ver` alone. The `*)` usage branch is covered by the shell syntax check. |
| `scripts/update_crond.sh` | A fixed list of invocations against hard-coded `/etc/homeproxy/scripts` paths. Covered by the shell syntax check. |
| `scripts/clean_log.sh` | `while true; do sleep 180; …` — testing the rotation needs the loop made injectable first. Covered by the shell syntax check. |
| `htdocs/.../view/homeproxy/status.js` | A LuCI view; its behaviour is only observable in a browser. |
| `init.d/homeproxy` | procd semantics need a real procd, and `service_started` only behaves correctly when procd really starts the instances — the offline harness models that, but it is a model. PR-05 shrank the file from 517 to 253 lines by moving the dnsmasq / fw4 / tproxy-TUN / service-plumbing into `scripts/runtime/{service,dns,firewall,net}.sh`; the trace test proves the move kept the command-and-file behaviour identical, and the shell syntax check covers every file. The health gate itself was verified on the ImmortalWrt test machine (see the plan's 2.14.8). |
## Architecture regression coverage
The old `test_demo_architecture.sh` compared the production `OutboundFactory`
against itself (via `generate_outbound()`, which delegates to it) and could
therefore never fail; it is gone, along with the `/* HP_TEST_HOOK */` marker it
relied on. Two tests replaced it:
* `tests/ucode/test_golden_outbounds.sh` pins the actual emitted JSON per
protocol in `tests/snapshots/generator/outbounds.json`; PR-04 added the
matching `tests/ucode/test_golden_inbounds.sh` for the server inbounds
(`tests/snapshots/generator/inbounds.json`).
* `tests/ucode/test_protocol_inventory.sh` asserts that the parser, the model,
the loader option table, the adapter tables and the golden snapshot all name
the same set of protocols.
`root/etc/init.d/homeproxy` is exercised for what can be checked off-target:
`tests/ucode/run.sh` syntax-checks it and the `runtime/` helpers,
`tests/runtime/test_config_transaction.sh` covers the transaction semantics, and
`tests/runtime/test_runtime_extraction.sh` pins the orchestration order
(generate before teardown, known-good before firewall, cron before
`config_load`, early return before `mkdir`) against the pre-PR-05 trace.
procd itself is still only exercised on a target. **The paragraph below is a
historical record, not a target you can use**: `root@192.168.1.102` is the
author's former test machine, an address that is no longer part of the test
setup and that nothing in this tree defaults to. `tests/run.sh` reads
`$HP_TEST_HOST`, skips the layer when it is unset, and arch-guard 13 keeps it
that way - so treat what follows as "what was observed on 2026-09", not as
reproduction instructions.
PR-05 was verified on the ImmortalWrt test machine (`root@192.168.1.102`):
`start` brought up both
`sing-box-c` and `log-cleaner` instances (proving that a procd instance
registered from a *sourced module* works), `reload` passed the health gate and
refreshed known-good, and `stop` removed the instances, the TUN device, the ip
rules, the dnsmasq snippets and the live configuration while keeping the
known-good copy. That run is a manual step, not a CI job — see the plan's
PHASE 9 row for the on-target CI gap.
## Adding cases
* New share link → add an `expect_fields(...)` case to `test_parse_uri.uc`.
* New protocol option that reaches the generator → extend the fixtures in
`tests/fixtures/generators/`, keeping values schema-valid so `sing-box check`
still passes.
* Intentional LuCI form change → regenerate the snapshots:
```sh
node tests/luci-form-snapshot.js . node > tests/snapshots/node.json
node tests/luci-form-snapshot.js . client > tests/snapshots/client.json
node tests/luci-form-snapshot.js . server > tests/snapshots/server.json
```
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,170 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Regression fixture for generate_client.uc: a node of every supported
# protocol reachable through a single urltest main node, so one run exercises
# all outbound builders (TLS, transport, reality, mux, obfuscation).
config homeproxy 'config'
option routing_mode 'bypass_mainland_china'
option proxy_mode 'tun'
option ipv6_support '0'
option log_level 'error'
option main_node 'urltest'
option main_udp_node 'nil'
option dns_server 'https://cloudflare-dns.com/dns-query'
list main_urltest_nodes 'n_anytls'
list main_urltest_nodes 'n_vless_reality_ws'
list main_urltest_nodes 'n_vmess_http'
list main_urltest_nodes 'n_trojan_grpc'
list main_urltest_nodes 'n_ss'
list main_urltest_nodes 'n_hy2'
list main_urltest_nodes 'n_snell'
list main_urltest_nodes 'n_http'
list main_urltest_nodes 'n_socks'
list main_urltest_nodes 'n_shadowtls'
config homeproxy 'infra'
option mixed_port '5330'
option dns_port '5333'
option tun_name 'singtun0'
option tun_addr4 '172.19.0.1/30'
option tun_addr6 'fdfe:dcba:9876::1/126'
option tun_mtu '9000'
option self_mark '100'
option tun_mark '102'
option table_mark '100'
option ntp_server 'nil'
config node 'n_anytls'
option label 'anytls'
option type 'anytls'
option address 'a.example.com'
option port '443'
option password 'secret'
option tls '1'
option tls_sni 'a.example.com'
option tls_insecure '0'
option anytls_idle_session_check_interval '30'
option anytls_idle_session_timeout '30'
option anytls_min_idle_session '0'
config node 'n_vless_reality_ws'
option label 'vless-reality-ws'
option type 'vless'
option address 'b.example.com'
option port '443'
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
option transport 'ws'
option ws_host 'b.example.com'
option ws_path '/ws?ed=2048'
option tls '1'
option tls_sni 'b.example.com'
option tls_reality '1'
option tls_reality_public_key '4UAg690QziXeelpUJoAmXiim0gfQESSiJkNfvF-o8Uw'
option tls_reality_short_id 'abcd'
option tls_utls 'chrome'
option vless_flow 'xtls-rprx-vision'
option packet_encoding 'xudp'
config node 'n_vmess_http'
option label 'vmess-http'
option type 'vmess'
option address 'c.example.com'
option port '80'
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
option transport 'http'
option http_host 'c.example.com'
option http_path '/path'
option http_method 'GET'
option vmess_alterid '0'
option vmess_encrypt 'auto'
option vmess_global_padding '1'
option tls '0'
option packet_encoding 'packetaddr'
config node 'n_trojan_grpc'
option label 'trojan-grpc'
option type 'trojan'
option address 'd.example.com'
option port '443'
option password 'secret'
option transport 'grpc'
option grpc_servicename 'gs'
option grpc_permit_without_stream '1'
option tls '1'
option tls_sni 'd.example.com'
option tls_handshake_timeout '15'
option tls_min_version '1.2'
option tls_max_version '1.3'
option tls_cipher_suites 'TLS_AES_128_GCM_SHA256'
config node 'n_ss'
option label 'ss'
option type 'shadowsocks'
option address 'e.example.com'
option port '8388'
option shadowsocks_encrypt_method 'aes-256-gcm'
option password 'secret'
option multiplex '1'
option multiplex_protocol 'h2mux'
option multiplex_padding '1'
option multiplex_max_connections '4'
option udp_over_tcp '1'
option udp_over_tcp_version '2'
config node 'n_hy2'
option label 'hy2'
option type 'hysteria2'
option address 'f.example.com'
option port '443'
option password 'secret'
option tls '1'
option tls_sni 'f.example.com'
option hysteria_obfs_type 'salamander'
option hysteria_obfs_password 'obfspass'
option hysteria_up_mbps '100'
option hysteria_down_mbps '500'
option hysteria_hopping_port '20000:30000'
option hysteria_hop_interval '30'
option hysteria_bbr_profile 'conservative'
config node 'n_snell'
option label 'snell'
option type 'snell'
option address 'g.example.com'
option port '443'
option password 'psk123456789012'
option snell_version '4'
option snell_obfs_mode 'http'
option snell_obfs_host 'bing.com'
option snell_reuse '1'
option snell_userkey 'ukey'
config node 'n_http'
option label 'http'
option type 'http'
option address 'h.example.com'
option port '8080'
option username 'u'
option password 'p'
option tls '0'
config node 'n_socks'
option label 'socks'
option type 'socks'
option address 'i.example.com'
option port '1080'
option username 'u'
option password 'p'
option socks_version '5'
config node 'n_shadowtls'
option label 'shadowtls'
option type 'shadowtls'
option address 'j.example.com'
option port '443'
option password 'secret'
option shadowtls_version '3'
option tls '1'
option tls_sni 'j.example.com'
@@ -0,0 +1,63 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Regression fixture for routing_mode='custom'. It reproduces two crashes
# that only the custom path could hit:
#
# * a direct node with an override forces generate_outbound() to write into
# direct_overrides, and the routing rule below forces get_direct_override()
# to read it -- so the table has to be declared before both helpers;
# * a local rule-set must not carry the legacy download_detour field, which
# sing-box 1.14 rejects on anything but a remote rule-set.
#
# __RULESET_DIR__ is substituted by tests/ucode/test_generators.sh with a
# directory holding a real compiled .srs file.
config homeproxy 'config'
option routing_mode 'custom'
option proxy_mode 'redirect_tproxy'
option ipv6_support '0'
option log_level 'error'
config homeproxy 'routing'
option default_outbound 'direct-out'
option default_outbound_dns 'default-dns'
config homeproxy 'infra'
option mixed_port '5330'
option dns_port '5333'
option redirect_port '5331'
option self_mark '100'
# direct node: exercises the direct_overrides write path
config node 'n_direct'
option label 'direct-node'
option type 'direct'
option override_address '1.2.3.4'
option override_port '8443'
# routing node + rule: exercises get_direct_override() reading direct_overrides
config routing_node 'rn_direct'
option enabled '1'
option label 'rn-direct'
option node 'n_direct'
# domain_resolver is what makes the generator call get_resolver(). No
# fixture exercised it, so generator/outbound.uc using the function without
# importing it (from ./common.uc) went unnoticed: ucode raises "access to
# undeclared variable" and the whole generation dies, but only for a custom
# routing_node that has this option set - which the UI offers.
option domain_resolver 'default-dns'
option domain_strategy 'prefer_ipv4'
config routing_rule 'rr_direct'
option enabled '1'
option label 'rr-direct'
option outbound 'rn_direct'
option action 'route'
# local rule-set: must never carry download_detour
config ruleset 'rs_local'
option enabled '1'
option label 'local-rs'
option type 'local'
option format 'binary'
option path '__RULESET_DIR__/test.srs'
@@ -0,0 +1,45 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Regression fixture: a direct node as the main node.
#
# generate_outbound() turns a direct node's override_address / override_port
# into a route action rather than outbound fields, so main-out ends up as a
# bare {"type":"direct","tag":"main-out"}. main-dns used to hard-code
# `detour: 'main-out'`, and the rule-set http_client inherited it; sing-box
# refuses to detour into an empty direct outbound:
#
# FATAL start service: start dns/tcp[main-dns]: detour to an empty direct
# outbound makes no sense
# FATAL start service: initialize rule-set[0]: ... detour to an empty direct
# outbound makes no sense
#
# and the service never started. `sing-box check` accepts such a config, so
# test_generators.sh inspects the generated JSON instead of trusting check.
config homeproxy 'config'
option routing_mode 'bypass_mainland_china'
option proxy_mode 'tun'
option ipv6_support '0'
option log_level 'error'
option main_node 'n_direct'
option main_udp_node 'nil'
option dns_server '223.5.5.5'
config homeproxy 'infra'
option mixed_port '5330'
option dns_port '5333'
option tun_name 'singtun0'
option tun_addr4 '172.19.0.1/30'
option tun_addr6 'fdfe:dcba:9876::1/126'
option tun_mtu '9000'
option self_mark '100'
option tun_mark '102'
option table_mark '100'
option ntp_server 'nil'
config node 'n_direct'
option label 'direct-main'
option type 'direct'
option override_address '127.0.0.1'
option override_port '8080'
option enabled '1'
@@ -0,0 +1,167 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Fixture for the domain model skeleton test (Stage A1.2+). It exercises
# every UCI section the Config Loader is supposed to read, and is *not*
# used by the generator regression suite (tests/fixtures/generators/
# client.uci and custom.uci are). Sections in this file:
#
# config general routing_mode, main_node, etc.
# infra ports and marks
# dns DNS settings (default_strategy, cache knobs)
# dns_server one UDP server
# dns_rule one hijack rule
# routing default_outbound, default_outbound_dns
# routing_node one urltest-style selector
# routing_rule one sniffer route
# ruleset one remote rule-set
# control bind_interface, lan_proxy_mode, wan_proxy_*_ips
# subscription auto_update, filter_keywords, etc.
# server one inbound (for the self-host side; client ignores it)
# node two nodes (anytls + snell) so the existing node-shape
# assertions keep working
#
# A domain-model test that loads this fixture can assert every Loader
# sub-object without depending on the generator's particular routing
# mode or proxy mode.
config homeproxy 'config'
option routing_mode 'bypass_mainland_china'
option proxy_mode 'redirect_tproxy'
option ipv6_support '0'
option log_level 'error'
option main_node 'urltest'
option main_udp_node 'nil'
option main_urltest_nodes 'n_anytls'
option main_urltest_nodes 'n_snell'
option dns_server '8.8.8.8'
option china_dns_server '223.5.5.5'
option routing_port 'common'
option github_token ''
option cn_ip_fallback '0'
config homeproxy 'infra'
option mixed_port '5330'
option dns_port '5333'
option redirect_port '5331'
option tproxy_port '5332'
option tun_name 'singtun0'
option tun_addr4 '172.19.0.1/30'
option tun_addr6 'fdfe:dcba:9876::1/126'
option tun_mtu '9000'
option self_mark '100'
option tun_mark '102'
option table_mark '100'
option tproxy_mark '101'
option ntp_server 'time.apple.com'
option common_port '22,53,80,443'
option dns_redirect '1'
option udp_timeout ''
config homeproxy 'dns'
option default_strategy 'prefer_ipv4'
option default_server 'default-dns'
option disable_cache '0'
option disable_cache_expire '0'
option optimistic_cache '0'
option cache_file_store_dns '0'
option dns_timeout '5s'
option optimistic_timeout '3s'
config dns_server 'ds_main'
option label 'main-dns'
option type 'udp'
option server '8.8.8.8'
option server_port '53'
option detour 'direct-out'
config dns_rule 'dr_hijack'
option enabled '1'
option label 'hijack-private'
option mode 'default'
option action 'hijack-dns'
config homeproxy 'routing'
option default_outbound 'direct-out'
option default_outbound_dns 'default-dns'
option domain_strategy ''
option find_neighbor '0'
option udp_timeout '5m'
option tcpip_stack 'system'
option endpoint_independent_nat ''
config routing_node 'rn_main'
option enabled '1'
option label 'main'
option node 'urltest'
option urltest_nodes 'n_anytls'
option urltest_nodes 'n_snell'
option urltest_interval '3m'
option urltest_tolerance '50'
config routing_rule 'rr_sniff'
option enabled '1'
option label 'sniff-route'
option outbound 'main-out'
option action 'route'
config ruleset 'rs_cn'
option enabled '1'
option label 'china-list'
option type 'remote'
option format 'source'
option url 'https://example.com/cn.list'
option download_detour 'main-out'
option update_interval '24h'
config homeproxy 'control'
option bind_interface ''
option lan_proxy_mode 'disabled'
list wan_proxy_ipv4_ips '91.108.4.0/22'
list wan_proxy_ipv6_ips '2001:67c:4e8::/48'
config homeproxy 'subscription'
option auto_update '0'
option allow_insecure '0'
option packet_encoding 'xudp'
option update_via_proxy '0'
option filter_nodes 'blacklist'
list filter_keywords '重置|到期'
list filter_keywords 'Expiration'
option user_agent 'HomeProxy/1.0'
list subscription_url 'https://example.com/sub'
config homeproxy 'server'
option enabled '1'
option log_level 'warn'
config server 's_vless'
option enabled '1'
option label 'server-vless'
option type 'vless'
option address '::'
option port '443'
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
option tls '1'
option tls_sni 'server.example.com'
config homeproxy 'migration'
option crontab '1'
config node 'n_anytls'
option label 'anytls'
option type 'anytls'
option address 'a.example.com'
option port '443'
option password 'secret'
option tls '1'
option tls_sni 'a.example.com'
option tls_insecure '0'
config node 'n_snell'
option label 'snell'
option type 'snell'
option address 'g.example.com'
option port '443'
option password 'psk123456789012'
option snell_version '4'
option snell_userkey 'ukey'
@@ -0,0 +1,248 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Golden-snapshot fixture for the protocol adapter: one node per protocol the
# adapter can build an outbound for, with a value on each option that has a
# mapping. tests/ucode/test_golden_outbounds.sh turns this into
# tests/snapshots/generator/outbounds.json and diffs it on every run.
#
# It replaces test_demo_architecture.sh, which compared the production
# OutboundFactory against itself and therefore could never fail. The
# snapshot pins the actual emitted JSON, so adding, removing or re-mapping a
# field for any protocol shows up as a reviewable diff.
#
# WireGuard is absent on purpose: it is emitted as a sing-box endpoint, not an
# outbound, and is covered by wireguard.uci.
config homeproxy 'config'
option routing_mode 'bypass_mainland_china'
option proxy_mode 'tun'
option ipv6_support '0'
option main_node 'n_vless_reality_ws'
option main_udp_node 'nil'
config homeproxy 'infra'
option mixed_port '5330'
option dns_port '5333'
option tun_name 'singtun0'
option tun_addr4 '172.19.0.1/30'
option tun_addr6 'fdfe:dcba:9876::1/126'
option tun_mtu '9000'
option self_mark '100'
option tun_mark '102'
option table_mark '100'
option ntp_server 'nil'
config node 'n_vless_reality_ws'
option label 'vless-reality-ws'
option type 'vless'
option address 'b.example.com'
option port '443'
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
option transport 'ws'
option ws_host 'b.example.com'
option ws_path '/ws?ed=2048'
option websocket_early_data_header 'Sec-WebSocket-Protocol'
option websocket_early_data '2048'
option tls '1'
option tls_sni 'b.example.com'
option tls_alpn 'h2,http/1.1'
option tls_min_version '1.2'
option tls_max_version '1.3'
option tls_handshake_timeout '10'
option tls_cipher_suites 'TLS_AES_128_GCM_SHA256'
option tls_reality '1'
option tls_reality_public_key '4UAg690QziXeelpUJoAmXiim0gfQESSiJkNfvF-o8Uw'
option tls_reality_short_id 'abcd'
option tls_utls 'chrome'
option vless_flow 'xtls-rprx-vision'
option packet_encoding 'xudp'
# Stale option left behind by switching this node's type from
# shadowsocks: sing-box rejects udp_over_tcp on a vless outbound, so the
# adapter must ignore it. See OPTION_FIELDS.vless.
option udp_over_tcp '1'
option udp_over_tcp_version '2'
option multiplex '1'
option multiplex_protocol 'h2mux'
option multiplex_max_connections '4'
option multiplex_min_streams '2'
option multiplex_max_streams '8'
option multiplex_padding '1'
option tcp_fast_open '1'
option tcp_multi_path '0'
option udp_fragment '1'
config node 'n_vmess_http'
option label 'vmess-http'
option type 'vmess'
option address 'l.example.com'
option port '443'
option uuid '66666666-7777-8888-9999-aaaaaaaaaaaa'
option transport 'http'
option http_host 'l.example.com'
option http_path '/path'
option http_method 'GET'
option http_idle_timeout '30'
option http_ping_timeout '15'
option vmess_alterid '0'
option vmess_encrypt 'auto'
option vmess_global_padding '1'
option packet_encoding 'packetaddr'
option tls '1'
option tls_sni 'l.example.com'
option tls_insecure '1'
option tls_utls 'firefox'
config node 'n_trojan_grpc'
option label 'trojan-grpc'
option type 'trojan'
option address 'h.example.com'
option port '443'
option password 'trojan-pwd'
option transport 'grpc'
option grpc_servicename 'gs'
option grpc_permit_without_stream '1'
option tls '1'
option tls_sni 'h.example.com'
config node 'n_hysteria'
option label 'hysteria'
option type 'hysteria'
option address 'm.example.com'
option port '443'
option hysteria_auth_type 'string'
option hysteria_auth_payload 'hy1-auth'
option hysteria_up_mbps '100'
option hysteria_down_mbps '500'
option hysteria_obfs_type 'xplus'
option hysteria_obfs_password 'hy1-obfs'
option hysteria_hop_interval '30'
option tls '1'
option tls_sni 'm.example.com'
config node 'n_hy2'
option label 'hy2'
option type 'hysteria2'
option address 'k.example.com'
option port '443'
option password 'hy2-pwd'
option hysteria_obfs_type 'salamander'
option hysteria_obfs_password 'hy2-obfs'
option hysteria_up_mbps '100'
option hysteria_down_mbps '500'
option hysteria_bbr_profile 'conservative'
option hysteria_hopping_port '20000:30000'
option hysteria_hop_interval '30'
option hysteria_hop_interval_max '60'
option hysteria_disable_chrome_parrot '1'
option tls '1'
option tls_sni 'k.example.com'
config node 'n_tuic'
option label 'tuic'
option type 'tuic'
option address 'j.example.com'
option port '443'
option uuid '11111111-2222-3333-4444-555555555555'
option password 'tuic-pwd'
option tuic_congestion_control 'cubic'
option tuic_udp_relay_mode 'native'
option tuic_enable_zero_rtt '1'
option tuic_udp_over_stream '0'
option tuic_heartbeat '3s'
option tls '1'
option tls_sni 'j.example.com'
option tls_alpn 'h3'
config node 'n_shadowsocks'
option label 'ss'
option type 'shadowsocks'
option address 'd.example.com'
option port '8389'
option shadowsocks_encrypt_method 'aes-256-gcm'
option password 'ss-password'
option shadowsocks_plugin 'obfs-local'
option shadowsocks_plugin_opts 'obfs=http;obfs-host=d.example.com'
option udp_over_tcp '1'
option udp_over_tcp_version '2'
config node 'n_snell'
option label 'snell'
option type 'snell'
option address 'c.example.com'
option port '8388'
option password 'snell-psk'
option snell_version '4'
option snell_userkey 'snell-userkey'
option snell_reuse '1'
option snell_obfs_mode 'http'
option snell_obfs_host 'c.example.com'
config node 'n_socks'
option label 'socks'
option type 'socks'
option address 'g.example.com'
option port '1080'
option username 'bob'
option password 'socks-pwd'
option socks_version '5'
option udp_over_tcp '1'
option udp_over_tcp_version '2'
config node 'n_http'
option label 'http'
option type 'http'
option address 'f.example.com'
option port '8080'
option username 'alice'
option password 'http-pwd'
option tls '1'
option tls_sni 'f.example.com'
config node 'n_shadowtls'
option label 'shadowtls'
option type 'shadowtls'
option address 'i.example.com'
option port '443'
option password 'shadowtls-pwd'
option shadowtls_version '3'
option tls '1'
option tls_sni 'i.example.com'
config node 'n_anytls'
option label 'anytls'
option type 'anytls'
option address 'e.example.com'
option port '443'
option password 'anytls-pwd'
option anytls_idle_session_check_interval '30'
option anytls_idle_session_timeout '90'
option anytls_min_idle_session '2'
option tls '1'
option tls_sni 'e.example.com'
config node 'n_ssh'
option label 'ssh'
option type 'ssh'
option address 'n.example.com'
option port '22'
option username 'root'
# The node form stores the private key as a DynamicList, so UCI holds one
# list entry per line and the adapter has to re-join them into the single
# string sing-box expects. Throwaway test key.
list ssh_priv_key '-----BEGIN OPENSSH PRIVATE KEY-----'
list ssh_priv_key 'b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW'
list ssh_priv_key 'QyNTUxOQAAACAY0ADv5F4CV2TlGkNg2c3z3zBfeCDjLEusnrtYyxjNpAAAAJgit4/AIreP'
list ssh_priv_key 'wAAAAAtzc2gtZWQyNTUxOQAAACAY0ADv5F4CV2TlGkNg2c3z3zBfeCDjLEusnrtYyxjNpA'
list ssh_priv_key 'AAAEC4rRAsuFAQRHkpIDid/Cj0n1PS6o88D3xsDoYkDUVx0xjQAO/kXgJXZOUaQ2DZzfPf'
list ssh_priv_key 'MF94IOMsS6yeu1jLGM2kAAAADmhvbWVwcm94eS10ZXN0AQIDBAUGBw=='
list ssh_priv_key '-----END OPENSSH PRIVATE KEY-----'
option ssh_client_version 'SSH-2.0-OpenSSH_8.9p1'
list ssh_host_key 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBjQAO/kXgJXZOUaQ2DZzfPfMF94IOMsS6yeu1jLGM2k homeproxy-test'
list ssh_host_key_algo 'ssh-ed25519'
config node 'n_direct'
option label 'direct'
option type 'direct'
option override_address '1.1.1.1'
option override_port '443'
option tcp_fast_open '1'
@@ -0,0 +1,54 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Regression fixture for P0-5: a broken node inside an urltest group must not
# abort the whole configuration.
#
# The Adapter's create() calls die() on validation problems, and the generator
# built every urltest member through it, so ONE node missing its uuid made
# generate_client.uc exit non-zero. init.d then had no configuration at all
# and the service could not start - even though the rest of the group was
# fine. urltest candidates are now pruned (with a warning) instead, while
# nodes that routing rules actually resolve through stay fatal.
#
# n_broken is a vless node with no uuid on purpose; the test asserts that
# generation succeeds and that n_broken is absent from the emitted group.
config homeproxy 'config'
option routing_mode 'bypass_mainland_china'
option proxy_mode 'tun'
option ipv6_support '0'
option log_level 'error'
option main_node 'urltest'
option main_udp_node 'nil'
list main_urltest_nodes 'n_ok'
list main_urltest_nodes 'n_broken'
option dns_server 'https://cloudflare-dns.com/dns-query'
config homeproxy 'infra'
option mixed_port '5330'
option dns_port '5333'
option tun_name 'singtun0'
option tun_addr4 '172.19.0.1/30'
option tun_addr6 'fdfe:dcba:9876::1/126'
option tun_mtu '9000'
option self_mark '100'
option tun_mark '102'
option table_mark '100'
option ntp_server 'nil'
config node 'n_ok'
option label 'ok'
option type 'vless'
option address 'ok.example.com'
option port '443'
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
option tls '1'
option tls_sni 'ok.example.com'
config node 'n_broken'
option label 'broken'
option type 'vless'
option address 'broken.example.com'
option port '443'
option tls '1'
option tls_sni 'broken.example.com'
@@ -0,0 +1,177 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# The same node set as client.uci (a node of every supported protocol behind
# one urltest main node) in the *product default* proxy mode.
#
# client.uci uses `proxy_mode 'tun'`, which is the mode that needs no
# routing_mark - so for as long as that was the only end-to-end fixture, the
# mark path was never generated and a regression that deleted it (the
# adapter's COMMON_FIELDS listed it as null and overwrote the literal) could
# not be seen by any test. This fixture is the one that exercises
# redirect-in/tproxy-in, the dns detours that are gated on self_mark, and the
# per-outbound routing_mark itself.
config homeproxy 'config'
option routing_mode 'bypass_mainland_china'
option proxy_mode 'redirect_tproxy'
option ipv6_support '0'
option log_level 'error'
option main_node 'urltest'
option main_udp_node 'nil'
option dns_server 'https://cloudflare-dns.com/dns-query'
list main_urltest_nodes 'n_anytls'
list main_urltest_nodes 'n_vless_reality_ws'
list main_urltest_nodes 'n_vmess_http'
list main_urltest_nodes 'n_trojan_grpc'
list main_urltest_nodes 'n_ss'
list main_urltest_nodes 'n_hy2'
list main_urltest_nodes 'n_snell'
list main_urltest_nodes 'n_http'
list main_urltest_nodes 'n_socks'
list main_urltest_nodes 'n_shadowtls'
config homeproxy 'infra'
option mixed_port '5330'
option dns_port '5333'
option tun_name 'singtun0'
option tun_addr4 '172.19.0.1/30'
option tun_addr6 'fdfe:dcba:9876::1/126'
option tun_mtu '9000'
option self_mark '100'
option tun_mark '102'
option table_mark '100'
option ntp_server 'nil'
config node 'n_anytls'
option label 'anytls'
option type 'anytls'
option address 'a.example.com'
option port '443'
option password 'secret'
option tls '1'
option tls_sni 'a.example.com'
option tls_insecure '0'
option anytls_idle_session_check_interval '30'
option anytls_idle_session_timeout '30'
option anytls_min_idle_session '0'
config node 'n_vless_reality_ws'
option label 'vless-reality-ws'
option type 'vless'
option address 'b.example.com'
option port '443'
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
option transport 'ws'
option ws_host 'b.example.com'
option ws_path '/ws?ed=2048'
option tls '1'
option tls_sni 'b.example.com'
option tls_reality '1'
option tls_reality_public_key '4UAg690QziXeelpUJoAmXiim0gfQESSiJkNfvF-o8Uw'
option tls_reality_short_id 'abcd'
option tls_utls 'chrome'
option vless_flow 'xtls-rprx-vision'
option packet_encoding 'xudp'
config node 'n_vmess_http'
option label 'vmess-http'
option type 'vmess'
option address 'c.example.com'
option port '80'
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
option transport 'http'
option http_host 'c.example.com'
option http_path '/path'
option http_method 'GET'
option vmess_alterid '0'
option vmess_encrypt 'auto'
option vmess_global_padding '1'
option tls '0'
option packet_encoding 'packetaddr'
config node 'n_trojan_grpc'
option label 'trojan-grpc'
option type 'trojan'
option address 'd.example.com'
option port '443'
option password 'secret'
option transport 'grpc'
option grpc_servicename 'gs'
option grpc_permit_without_stream '1'
option tls '1'
option tls_sni 'd.example.com'
option tls_handshake_timeout '15'
option tls_min_version '1.2'
option tls_max_version '1.3'
option tls_cipher_suites 'TLS_AES_128_GCM_SHA256'
config node 'n_ss'
option label 'ss'
option type 'shadowsocks'
option address 'e.example.com'
option port '8388'
option shadowsocks_encrypt_method 'aes-256-gcm'
option password 'secret'
option multiplex '1'
option multiplex_protocol 'h2mux'
option multiplex_padding '1'
option multiplex_max_connections '4'
option udp_over_tcp '1'
option udp_over_tcp_version '2'
config node 'n_hy2'
option label 'hy2'
option type 'hysteria2'
option address 'f.example.com'
option port '443'
option password 'secret'
option tls '1'
option tls_sni 'f.example.com'
option hysteria_obfs_type 'salamander'
option hysteria_obfs_password 'obfspass'
option hysteria_up_mbps '100'
option hysteria_down_mbps '500'
option hysteria_hopping_port '20000:30000'
option hysteria_hop_interval '30'
option hysteria_bbr_profile 'conservative'
config node 'n_snell'
option label 'snell'
option type 'snell'
option address 'g.example.com'
option port '443'
option password 'psk123456789012'
option snell_version '4'
option snell_obfs_mode 'http'
option snell_obfs_host 'bing.com'
option snell_reuse '1'
option snell_userkey 'ukey'
config node 'n_http'
option label 'http'
option type 'http'
option address 'h.example.com'
option port '8080'
option username 'u'
option password 'p'
option tls '0'
config node 'n_socks'
option label 'socks'
option type 'socks'
option address 'i.example.com'
option port '1080'
option username 'u'
option password 'p'
option socks_version '5'
config node 'n_shadowtls'
option label 'shadowtls'
option type 'shadowtls'
option address 'j.example.com'
option port '443'
option password 'secret'
option shadowtls_version '3'
option tls '1'
option tls_sni 'j.example.com'
@@ -0,0 +1,118 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Regression fixture for generate_server.uc: one inbound per protocol family,
# covering the shared TLS/transport builders (ACME provider, ECH key, REALITY,
# WebSocket/gRPC/HTTP transports) on the server side.
config homeproxy 'server'
option enabled '1'
option log_level 'warn'
config server 's_vless_ws'
option enabled '1'
option type 'vless'
option address '10.0.0.1'
option port '8443'
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
option transport 'ws'
option ws_host 'a.example.com'
option ws_path '/ws'
option websocket_early_data '2048'
option tls '1'
option tls_sni 'a.example.com'
option tls_acme '1'
option tls_acme_domain 'a.example.com'
option tls_acme_provider 'letsencrypt'
option tls_acme_email 'admin@example.com'
option tls_acme_dsn 'a.example.com'
option tls_acme_key_type 'p256'
option tls_alpn 'h2'
option tls_min_version '1.2'
config server 's_anytls'
option enabled '1'
option type 'anytls'
option address '10.0.0.1'
option port '8444'
option password 'secret'
option anytls_padding_scheme 'stop=8'
option tls '1'
option tls_sni 'b.example.com'
option tls_acme '1'
option tls_acme_domain 'b.example.com'
option tls_acme_provider 'zerossl'
option tls_acme_email 'admin@example.com'
option tls_acme_dsn 'b.example.com'
config server 's_hy2'
option enabled '1'
option type 'hysteria2'
option address '10.0.0.1'
option port '8445'
option password 'secret'
option hysteria_up_mbps '100'
option hysteria_down_mbps '500'
option hysteria_obfs_type 'salamander'
option hysteria_obfs_password 'obfspass'
option hysteria_masquerade 'https://example.com'
option tls '1'
option tls_sni 'c.example.com'
option tls_acme '1'
option tls_acme_domain 'c.example.com'
option tls_acme_provider 'letsencrypt'
option tls_acme_email 'admin@example.com'
option tls_acme_dsn 'c.example.com'
config server 's_trojan_grpc'
option enabled '1'
option type 'trojan'
option address '10.0.0.1'
option port '8446'
option password 'secret'
option transport 'grpc'
option grpc_servicename 'gs'
option multiplex '1'
option multiplex_padding '1'
option tls '1'
option tls_sni 'd.example.com'
option tls_acme '1'
option tls_acme_domain 'd.example.com'
option tls_acme_provider 'letsencrypt'
option tls_acme_email 'admin@example.com'
option tls_acme_dsn 'd.example.com'
config server 's_ss'
option enabled '1'
option type 'shadowsocks'
option address '10.0.0.1'
option port '8447'
option network 'tcp'
option shadowsocks_encrypt_method 'aes-256-gcm'
option password 'secret'
option udp_timeout '300'
config server 's_snell'
option enabled '1'
option type 'snell'
option port '8448'
option password 'psk123456789012'
option snell_version '5'
option snell_obfs_mode 'http'
config server 's_tuic'
option enabled '1'
option type 'tuic'
option address '10.0.0.1'
option port '8449'
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
option password 'secret'
option tuic_congestion_control 'bbr'
option tuic_auth_timeout '3'
option tuic_heartbeat '10'
option tls '1'
option tls_sni 'e.example.com'
option tls_acme '1'
option tls_acme_domain 'e.example.com'
option tls_acme_provider 'letsencrypt'
option tls_acme_email 'admin@example.com'
option tls_acme_dsn 'e.example.com'
@@ -0,0 +1,50 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Regression fixture for the WireGuard endpoint path.
#
# WireGuard is the one protocol generate_client.uc emits as a sing-box
# *endpoint* instead of an outbound, through generate_endpoint(). A2/A3
# converted every call site to pass a Node but left that function reading the
# flat UCI option names (node.wireguard_*, node.tcp_fast_open, ...), which no
# Node carries - so the endpoint came out with a null private key and a peer
# with no public key, and `sing-box check` rejected the whole configuration.
# The unit test asserts the key material survives, not just that sing-box
# accepts the file.
config homeproxy 'config'
option routing_mode 'bypass_mainland_china'
option proxy_mode 'tun'
option ipv6_support '0'
option log_level 'error'
option main_node 'n_wg'
option main_udp_node 'nil'
option dns_server 'https://cloudflare-dns.com/dns-query'
config homeproxy 'infra'
option mixed_port '5330'
option dns_port '5333'
option tun_name 'singtun0'
option tun_addr4 '172.19.0.1/30'
option tun_addr6 'fdfe:dcba:9876::1/126'
option tun_mtu '9000'
option self_mark '100'
option tun_mark '102'
option table_mark '100'
option ntp_server 'nil'
config node 'n_wg'
option label 'wireguard'
option type 'wireguard'
option address 'wg.example.com'
option port '51820'
option wireguard_private_key 'iKaNuoWRQTFPD5V3OoMNdMshsMgU9t7rolJNpgNx+UM='
option wireguard_peer_public_key 'DDcdTHUv0Q6XYDf9l93jzwwuoY/G1TC+g74QH0A9HmM='
list wireguard_local_address '172.16.0.2/32'
list wireguard_local_address 'fd00::2/128'
option wireguard_mtu '1420'
option wireguard_persistent_keepalive_interval '25'
list wireguard_reserved '1'
list wireguard_reserved '2'
list wireguard_reserved '3'
option tcp_fast_open '1'
option udp_fragment '1'
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,578 @@
===== scenario: A-tun-bypass-client-only (start) =====
uci -q show dhcp.@dnsmasq[0]
config_load homeproxy
sing-box version -n
uname -r
ip route show default
ip -6 route show default
ifstatus wan
ucode -S etc/homeproxy/scripts/generate_client.uc
log Warning: failed to restart dnsmasq, DNS-based routing may be stale.
ip tuntap add mode tun user root name singtun0
sleep 1s
ip link set singtun0 up
ip route replace default dev singtun0 table 100
ip rule add fwmark 102 lookup 100
procd_open_instance sing-box-c
procd_set_param command /usr/bin/sing-box
procd_append_param command run --config var/run/homeproxy/sing-box-c.json
procd_add_jail sing-box-c log procfs
procd_add_jail_mount var/run/homeproxy/sing-box-c.json
procd_add_jail_mount_rw var/run/homeproxy/sing-box-c.log
procd_add_jail_mount_rw etc/homeproxy/cache.db
procd_add_jail_mount etc/homeproxy/certs/
procd_add_jail_mount /etc/ssl/
procd_add_jail_mount /etc/localtime
procd_add_jail_mount /etc/TZ
procd_set_param capabilities /etc/capabilities/homeproxy.json
procd_set_param no_new_privs 1
procd_set_param user sing-box
procd_set_param group sing-box
procd_set_param limits core=unlimited
procd_set_param limits nofile=1000000 1000000
procd_set_param stderr 1
procd_set_param respawn
procd_close_instance
procd_open_instance log-cleaner
procd_set_param command etc/homeproxy/scripts/clean_log.sh
procd_set_param respawn
procd_close_instance
chown sing-box:sing-box var/run/homeproxy/sing-box-c.json var/run/homeproxy/sing-box-c.log etc/homeproxy/cache.db
ucode etc/homeproxy/scripts/firewall_pre.uc
utpl -S etc/homeproxy/scripts/firewall_post.ut
fw4 reload
sing-box version -n
log sing-box 1.14.0 started.
start rc=127
log Warning: failed to drop the auto-update cron entry.
log Warning: failed to restart cron.
config_load homeproxy
ip rule del fwmark 101 table 100
ip route del local 0.0.0.0/0 dev lo table 100
ip -6 rule del fwmark 101 table 100
ip -6 route del local ::/0 dev lo table 100
ip route del default dev singtun0 table 100
ip rule del fwmark 102 table 100
ip -6 route del default dev singtun0 table 100
ip -6 rule del fwmark 102 table 100
nft flush chain inet fw4 homeproxy_output_redir
nft delete chain inet fw4 homeproxy_output_redir
nft flush chain inet fw4 homeproxy_redirect
nft delete chain inet fw4 homeproxy_redirect
nft flush chain inet fw4 homeproxy_redirect_proxy
nft delete chain inet fw4 homeproxy_redirect_proxy
nft flush chain inet fw4 homeproxy_redirect_proxy_port
nft delete chain inet fw4 homeproxy_redirect_proxy_port
nft flush chain inet fw4 homeproxy_redirect_lanac
nft delete chain inet fw4 homeproxy_redirect_lanac
nft flush chain inet fw4 homeproxy_mangle_prerouting
nft delete chain inet fw4 homeproxy_mangle_prerouting
nft flush chain inet fw4 homeproxy_mangle_output
nft delete chain inet fw4 homeproxy_mangle_output
nft flush chain inet fw4 homeproxy_mangle_tproxy
nft delete chain inet fw4 homeproxy_mangle_tproxy
nft flush chain inet fw4 homeproxy_mangle_tproxy_port
nft delete chain inet fw4 homeproxy_mangle_tproxy_port
nft flush chain inet fw4 homeproxy_mangle_lanac
nft delete chain inet fw4 homeproxy_mangle_lanac
nft flush chain inet fw4 homeproxy_mangle_mark
nft delete chain inet fw4 homeproxy_mangle_mark
nft flush chain inet fw4 homeproxy_mangle_tun
nft delete chain inet fw4 homeproxy_mangle_tun
nft flush chain inet fw4 homeproxy_mangle_tun_mark
nft delete chain inet fw4 homeproxy_mangle_tun_mark
nft flush set inet fw4 homeproxy_local_addr_v4
nft delete set inet fw4 homeproxy_local_addr_v4
nft flush set inet fw4 homeproxy_local_addr_v6
nft delete set inet fw4 homeproxy_local_addr_v6
nft flush set inet fw4 homeproxy_gfw_list_v4
nft delete set inet fw4 homeproxy_gfw_list_v4
nft flush set inet fw4 homeproxy_gfw_list_v6
nft delete set inet fw4 homeproxy_gfw_list_v6
nft flush set inet fw4 homeproxy_mainland_addr_v4
nft delete set inet fw4 homeproxy_mainland_addr_v4
nft flush set inet fw4 homeproxy_mainland_addr_v6
nft delete set inet fw4 homeproxy_mainland_addr_v6
nft flush set inet fw4 homeproxy_wan_proxy_addr_v4
nft delete set inet fw4 homeproxy_wan_proxy_addr_v4
nft flush set inet fw4 homeproxy_wan_proxy_addr_v6
nft delete set inet fw4 homeproxy_wan_proxy_addr_v6
nft flush set inet fw4 homeproxy_wan_direct_addr_v4
nft delete set inet fw4 homeproxy_wan_direct_addr_v4
nft flush set inet fw4 homeproxy_wan_direct_addr_v6
nft delete set inet fw4 homeproxy_wan_direct_addr_v6
nft flush set inet fw4 homeproxy_routing_port
nft delete set inet fw4 homeproxy_routing_port
fw4 reload
log Warning: failed to restart dnsmasq after removing the homeproxy snippets.
log Service stopped.
config_load homeproxy
ip link set singtun0 down
ip tuntap del mode tun name singtun0
stop rc=0
file var/run/homeproxy/fw4_forward.nft:
file var/run/homeproxy/fw4_input.nft:
file var/run/homeproxy/fw4_post.nft:
file var/run/homeproxy/homeproxy.log:
file var/run/homeproxy/known-good/sing-box-c.json: {"log":{},"inbounds":[{"tag":"dns-in","listen_port":5333},{"tag":"mixed-in","listen_port":5330}],"outbounds":[]}|
file etc/homeproxy/cache.db:
===== scenario: B-tproxy-gfwlist-ipv6-with-server (start) =====
uci -q show dhcp.@dnsmasq[0]
config_load homeproxy
sing-box version -n
uname -r
ip route show default
ip -6 route show default
ifstatus wan
ucode -S etc/homeproxy/scripts/generate_client.uc
log Warning: failed to drop the previous auto-update cron entry.
log Warning: failed to install the auto-update cron entry.
log Warning: failed to restart cron.
log Warning: failed to restart dnsmasq, DNS-based routing may be stale.
ip rule add fwmark 101 table 100
ip route add local 0.0.0.0/0 dev lo table 100
ip -6 rule add fwmark 101 table 100
ip -6 route add local ::/0 dev lo table 100
procd_open_instance sing-box-c
procd_set_param command /usr/bin/sing-box
procd_append_param command run --config var/run/homeproxy/sing-box-c.json
procd_add_jail sing-box-c log procfs
procd_add_jail_mount var/run/homeproxy/sing-box-c.json
procd_add_jail_mount_rw var/run/homeproxy/sing-box-c.log
procd_add_jail_mount etc/homeproxy/certs/
procd_add_jail_mount /etc/ssl/
procd_add_jail_mount /etc/localtime
procd_add_jail_mount /etc/TZ
procd_set_param capabilities /etc/capabilities/homeproxy.json
procd_set_param no_new_privs 1
procd_set_param user sing-box
procd_set_param group sing-box
procd_set_param limits core=unlimited
procd_set_param limits nofile=1000000 1000000
procd_set_param stderr 1
procd_set_param respawn
procd_close_instance
procd_open_instance log-cleaner
procd_set_param command etc/homeproxy/scripts/clean_log.sh
procd_set_param respawn
procd_close_instance
chown sing-box:sing-box var/run/homeproxy/sing-box-c.json var/run/homeproxy/sing-box-c.log etc/homeproxy/cache.db
ucode etc/homeproxy/scripts/firewall_pre.uc
utpl -S etc/homeproxy/scripts/firewall_post.ut
fw4 reload
sing-box version -n
log sing-box 1.14.0 started.
start rc=127
log Warning: failed to drop the auto-update cron entry.
log Warning: failed to restart cron.
config_load homeproxy
ip rule del fwmark 101 table 100
ip route del local 0.0.0.0/0 dev lo table 100
ip -6 rule del fwmark 101 table 100
ip -6 route del local ::/0 dev lo table 100
ip route del default dev singtun0 table 100
ip rule del fwmark 102 table 100
ip -6 route del default dev singtun0 table 100
ip -6 rule del fwmark 102 table 100
nft flush chain inet fw4 homeproxy_output_redir
nft delete chain inet fw4 homeproxy_output_redir
nft flush chain inet fw4 homeproxy_redirect
nft delete chain inet fw4 homeproxy_redirect
nft flush chain inet fw4 homeproxy_redirect_proxy
nft delete chain inet fw4 homeproxy_redirect_proxy
nft flush chain inet fw4 homeproxy_redirect_proxy_port
nft delete chain inet fw4 homeproxy_redirect_proxy_port
nft flush chain inet fw4 homeproxy_redirect_lanac
nft delete chain inet fw4 homeproxy_redirect_lanac
nft flush chain inet fw4 homeproxy_mangle_prerouting
nft delete chain inet fw4 homeproxy_mangle_prerouting
nft flush chain inet fw4 homeproxy_mangle_output
nft delete chain inet fw4 homeproxy_mangle_output
nft flush chain inet fw4 homeproxy_mangle_tproxy
nft delete chain inet fw4 homeproxy_mangle_tproxy
nft flush chain inet fw4 homeproxy_mangle_tproxy_port
nft delete chain inet fw4 homeproxy_mangle_tproxy_port
nft flush chain inet fw4 homeproxy_mangle_lanac
nft delete chain inet fw4 homeproxy_mangle_lanac
nft flush chain inet fw4 homeproxy_mangle_mark
nft delete chain inet fw4 homeproxy_mangle_mark
nft flush chain inet fw4 homeproxy_mangle_tun
nft delete chain inet fw4 homeproxy_mangle_tun
nft flush chain inet fw4 homeproxy_mangle_tun_mark
nft delete chain inet fw4 homeproxy_mangle_tun_mark
nft flush set inet fw4 homeproxy_local_addr_v4
nft delete set inet fw4 homeproxy_local_addr_v4
nft flush set inet fw4 homeproxy_local_addr_v6
nft delete set inet fw4 homeproxy_local_addr_v6
nft flush set inet fw4 homeproxy_gfw_list_v4
nft delete set inet fw4 homeproxy_gfw_list_v4
nft flush set inet fw4 homeproxy_gfw_list_v6
nft delete set inet fw4 homeproxy_gfw_list_v6
nft flush set inet fw4 homeproxy_mainland_addr_v4
nft delete set inet fw4 homeproxy_mainland_addr_v4
nft flush set inet fw4 homeproxy_mainland_addr_v6
nft delete set inet fw4 homeproxy_mainland_addr_v6
nft flush set inet fw4 homeproxy_wan_proxy_addr_v4
nft delete set inet fw4 homeproxy_wan_proxy_addr_v4
nft flush set inet fw4 homeproxy_wan_proxy_addr_v6
nft delete set inet fw4 homeproxy_wan_proxy_addr_v6
nft flush set inet fw4 homeproxy_wan_direct_addr_v4
nft delete set inet fw4 homeproxy_wan_direct_addr_v4
nft flush set inet fw4 homeproxy_wan_direct_addr_v6
nft delete set inet fw4 homeproxy_wan_direct_addr_v6
nft flush set inet fw4 homeproxy_routing_port
nft delete set inet fw4 homeproxy_routing_port
fw4 reload
log Warning: failed to restart dnsmasq after removing the homeproxy snippets.
log Service stopped.
config_load homeproxy
ip link set singtun0 down
ip tuntap del mode tun name singtun0
stop rc=0
file var/run/homeproxy/fw4_forward.nft:
file var/run/homeproxy/fw4_input.nft:
file var/run/homeproxy/fw4_post.nft:
file var/run/homeproxy/homeproxy.log:
file var/run/homeproxy/known-good/sing-box-c.json: {"log":{},"inbounds":[{"tag":"dns-in","listen_port":5333},{"tag":"mixed-in","listen_port":5330}],"outbounds":[]}|
===== scenario: C-nothing-configured (start) =====
uci -q show dhcp.@dnsmasq[0]
config_load homeproxy
sing-box version -n
log Neither client nor server is configured. Service not started.
start rc=127
log Warning: failed to drop the auto-update cron entry.
log Warning: failed to restart cron.
config_load homeproxy
ip rule del fwmark 101 table 100
ip route del local 0.0.0.0/0 dev lo table 100
ip -6 rule del fwmark 101 table 100
ip -6 route del local ::/0 dev lo table 100
ip route del default dev singtun0 table 100
ip rule del fwmark 102 table 100
ip -6 route del default dev singtun0 table 100
ip -6 rule del fwmark 102 table 100
nft flush chain inet fw4 homeproxy_output_redir
nft delete chain inet fw4 homeproxy_output_redir
nft flush chain inet fw4 homeproxy_redirect
nft delete chain inet fw4 homeproxy_redirect
nft flush chain inet fw4 homeproxy_redirect_proxy
nft delete chain inet fw4 homeproxy_redirect_proxy
nft flush chain inet fw4 homeproxy_redirect_proxy_port
nft delete chain inet fw4 homeproxy_redirect_proxy_port
nft flush chain inet fw4 homeproxy_redirect_lanac
nft delete chain inet fw4 homeproxy_redirect_lanac
nft flush chain inet fw4 homeproxy_mangle_prerouting
nft delete chain inet fw4 homeproxy_mangle_prerouting
nft flush chain inet fw4 homeproxy_mangle_output
nft delete chain inet fw4 homeproxy_mangle_output
nft flush chain inet fw4 homeproxy_mangle_tproxy
nft delete chain inet fw4 homeproxy_mangle_tproxy
nft flush chain inet fw4 homeproxy_mangle_tproxy_port
nft delete chain inet fw4 homeproxy_mangle_tproxy_port
nft flush chain inet fw4 homeproxy_mangle_lanac
nft delete chain inet fw4 homeproxy_mangle_lanac
nft flush chain inet fw4 homeproxy_mangle_mark
nft delete chain inet fw4 homeproxy_mangle_mark
nft flush chain inet fw4 homeproxy_mangle_tun
nft delete chain inet fw4 homeproxy_mangle_tun
nft flush chain inet fw4 homeproxy_mangle_tun_mark
nft delete chain inet fw4 homeproxy_mangle_tun_mark
nft flush set inet fw4 homeproxy_local_addr_v4
nft delete set inet fw4 homeproxy_local_addr_v4
nft flush set inet fw4 homeproxy_local_addr_v6
nft delete set inet fw4 homeproxy_local_addr_v6
nft flush set inet fw4 homeproxy_gfw_list_v4
nft delete set inet fw4 homeproxy_gfw_list_v4
nft flush set inet fw4 homeproxy_gfw_list_v6
nft delete set inet fw4 homeproxy_gfw_list_v6
nft flush set inet fw4 homeproxy_mainland_addr_v4
nft delete set inet fw4 homeproxy_mainland_addr_v4
nft flush set inet fw4 homeproxy_mainland_addr_v6
nft delete set inet fw4 homeproxy_mainland_addr_v6
nft flush set inet fw4 homeproxy_wan_proxy_addr_v4
nft delete set inet fw4 homeproxy_wan_proxy_addr_v4
nft flush set inet fw4 homeproxy_wan_proxy_addr_v6
nft delete set inet fw4 homeproxy_wan_proxy_addr_v6
nft flush set inet fw4 homeproxy_wan_direct_addr_v4
nft delete set inet fw4 homeproxy_wan_direct_addr_v4
nft flush set inet fw4 homeproxy_wan_direct_addr_v6
nft delete set inet fw4 homeproxy_wan_direct_addr_v6
nft flush set inet fw4 homeproxy_routing_port
nft delete set inet fw4 homeproxy_routing_port
fw4 reload
log Warning: failed to restart dnsmasq after removing the homeproxy snippets.
log Service stopped.
config_load homeproxy
ip link set singtun0 down
ip tuntap del mode tun name singtun0
stop rc=0
file var/run/homeproxy/fw4_forward.nft:
file var/run/homeproxy/fw4_input.nft:
file var/run/homeproxy/fw4_post.nft:
===== scenario: D-health-gate-rollback (reload) =====
uci -q show dhcp.@dnsmasq[0]
config_load homeproxy
sing-box version -n
uname -r
ip route show default
ip -6 route show default
ifstatus wan
ucode -S etc/homeproxy/scripts/generate_client.uc
log Warning: failed to restart dnsmasq, DNS-based routing may be stale.
ip tuntap add mode tun user root name singtun0
sleep 1s
ip link set singtun0 up
ip route replace default dev singtun0 table 100
ip rule add fwmark 102 lookup 100
procd_open_instance sing-box-c
procd_set_param command /usr/bin/sing-box
procd_append_param command run --config var/run/homeproxy/sing-box-c.json
procd_add_jail sing-box-c log procfs
procd_add_jail_mount var/run/homeproxy/sing-box-c.json
procd_add_jail_mount_rw var/run/homeproxy/sing-box-c.log
procd_add_jail_mount_rw etc/homeproxy/cache.db
procd_add_jail_mount etc/homeproxy/certs/
procd_add_jail_mount /etc/ssl/
procd_add_jail_mount /etc/localtime
procd_add_jail_mount /etc/TZ
procd_set_param capabilities /etc/capabilities/homeproxy.json
procd_set_param no_new_privs 1
procd_set_param user sing-box
procd_set_param group sing-box
procd_set_param limits core=unlimited
procd_set_param limits nofile=1000000 1000000
procd_set_param stderr 1
procd_set_param respawn
procd_close_instance
procd_open_instance log-cleaner
procd_set_param command etc/homeproxy/scripts/clean_log.sh
procd_set_param respawn
procd_close_instance
chown sing-box:sing-box var/run/homeproxy/sing-box-c.json var/run/homeproxy/sing-box-c.log etc/homeproxy/cache.db
ucode etc/homeproxy/scripts/firewall_pre.uc
utpl -S etc/homeproxy/scripts/firewall_post.ut
fw4 reload
sing-box version -n
log sing-box 1.14.0 started.
prime start rc=127
log Reloading service...
config_load homeproxy
ucode -S etc/homeproxy/scripts/generate_client.uc
log Warning: failed to drop the auto-update cron entry.
log Warning: failed to restart cron.
config_load homeproxy
ip rule del fwmark 101 table 100
ip route del local 0.0.0.0/0 dev lo table 100
ip -6 rule del fwmark 101 table 100
ip -6 route del local ::/0 dev lo table 100
ip route del default dev singtun0 table 100
ip rule del fwmark 102 table 100
ip -6 route del default dev singtun0 table 100
ip -6 rule del fwmark 102 table 100
nft flush chain inet fw4 homeproxy_output_redir
nft delete chain inet fw4 homeproxy_output_redir
nft flush chain inet fw4 homeproxy_redirect
nft delete chain inet fw4 homeproxy_redirect
nft flush chain inet fw4 homeproxy_redirect_proxy
nft delete chain inet fw4 homeproxy_redirect_proxy
nft flush chain inet fw4 homeproxy_redirect_proxy_port
nft delete chain inet fw4 homeproxy_redirect_proxy_port
nft flush chain inet fw4 homeproxy_redirect_lanac
nft delete chain inet fw4 homeproxy_redirect_lanac
nft flush chain inet fw4 homeproxy_mangle_prerouting
nft delete chain inet fw4 homeproxy_mangle_prerouting
nft flush chain inet fw4 homeproxy_mangle_output
nft delete chain inet fw4 homeproxy_mangle_output
nft flush chain inet fw4 homeproxy_mangle_tproxy
nft delete chain inet fw4 homeproxy_mangle_tproxy
nft flush chain inet fw4 homeproxy_mangle_tproxy_port
nft delete chain inet fw4 homeproxy_mangle_tproxy_port
nft flush chain inet fw4 homeproxy_mangle_lanac
nft delete chain inet fw4 homeproxy_mangle_lanac
nft flush chain inet fw4 homeproxy_mangle_mark
nft delete chain inet fw4 homeproxy_mangle_mark
nft flush chain inet fw4 homeproxy_mangle_tun
nft delete chain inet fw4 homeproxy_mangle_tun
nft flush chain inet fw4 homeproxy_mangle_tun_mark
nft delete chain inet fw4 homeproxy_mangle_tun_mark
nft flush set inet fw4 homeproxy_local_addr_v4
nft delete set inet fw4 homeproxy_local_addr_v4
nft flush set inet fw4 homeproxy_local_addr_v6
nft delete set inet fw4 homeproxy_local_addr_v6
nft flush set inet fw4 homeproxy_gfw_list_v4
nft delete set inet fw4 homeproxy_gfw_list_v4
nft flush set inet fw4 homeproxy_gfw_list_v6
nft delete set inet fw4 homeproxy_gfw_list_v6
nft flush set inet fw4 homeproxy_mainland_addr_v4
nft delete set inet fw4 homeproxy_mainland_addr_v4
nft flush set inet fw4 homeproxy_mainland_addr_v6
nft delete set inet fw4 homeproxy_mainland_addr_v6
nft flush set inet fw4 homeproxy_wan_proxy_addr_v4
nft delete set inet fw4 homeproxy_wan_proxy_addr_v4
nft flush set inet fw4 homeproxy_wan_proxy_addr_v6
nft delete set inet fw4 homeproxy_wan_proxy_addr_v6
nft flush set inet fw4 homeproxy_wan_direct_addr_v4
nft delete set inet fw4 homeproxy_wan_direct_addr_v4
nft flush set inet fw4 homeproxy_wan_direct_addr_v6
nft delete set inet fw4 homeproxy_wan_direct_addr_v6
nft flush set inet fw4 homeproxy_routing_port
nft delete set inet fw4 homeproxy_routing_port
fw4 reload
log Warning: failed to restart dnsmasq after removing the homeproxy snippets.
log Service stopped.
config_load homeproxy
ip link set singtun0 down
ip tuntap del mode tun name singtun0
config_load homeproxy
sing-box version -n
uname -r
ip route show default
ip -6 route show default
ifstatus wan
ucode -S etc/homeproxy/scripts/generate_client.uc
log Warning: failed to restart dnsmasq, DNS-based routing may be stale.
ip tuntap add mode tun user root name singtun0
sleep 1s
ip link set singtun0 up
ip route replace default dev singtun0 table 100
ip rule add fwmark 102 lookup 100
procd_open_instance sing-box-c
procd_set_param command /usr/bin/sing-box
procd_append_param command run --config var/run/homeproxy/sing-box-c.json
procd_add_jail sing-box-c log procfs
procd_add_jail_mount var/run/homeproxy/sing-box-c.json
procd_add_jail_mount_rw var/run/homeproxy/sing-box-c.log
procd_add_jail_mount_rw etc/homeproxy/cache.db
procd_add_jail_mount etc/homeproxy/certs/
procd_add_jail_mount /etc/ssl/
procd_add_jail_mount /etc/localtime
procd_add_jail_mount /etc/TZ
procd_set_param capabilities /etc/capabilities/homeproxy.json
procd_set_param no_new_privs 1
procd_set_param user sing-box
procd_set_param group sing-box
procd_set_param limits core=unlimited
procd_set_param limits nofile=1000000 1000000
procd_set_param stderr 1
procd_set_param respawn
procd_close_instance
procd_open_instance log-cleaner
procd_set_param command etc/homeproxy/scripts/clean_log.sh
procd_set_param respawn
procd_close_instance
chown sing-box:sing-box var/run/homeproxy/sing-box-c.json var/run/homeproxy/sing-box-c.log etc/homeproxy/cache.db
ucode etc/homeproxy/scripts/firewall_pre.uc
utpl -S etc/homeproxy/scripts/firewall_post.ut
fw4 reload
sing-box version -n
log sing-box 1.14.0 started.
jsonfilter -e @['homeproxy'].instances['sing-box-c']
jsonfilter -e @.running
sleep 1
jsonfilter -e @['homeproxy'].instances['sing-box-c']
jsonfilter -e @.running
sleep 1
jsonfilter -e @['homeproxy'].instances['sing-box-c']
jsonfilter -e @.running
sleep 1
jsonfilter -e @['homeproxy'].instances['sing-box-c']
jsonfilter -e @.running
sleep 1
jsonfilter -e @['homeproxy'].instances['sing-box-c']
jsonfilter -e @.running
sleep 1
jsonfilter -e @['homeproxy'].instances['sing-box-c']
jsonfilter -e @.running
sleep 1
jsonfilter -e @['homeproxy'].instances['sing-box-c']
jsonfilter -e @.running
sleep 1
jsonfilter -e @['homeproxy'].instances['sing-box-c']
jsonfilter -e @.running
sleep 1
jsonfilter -e @['homeproxy'].instances['sing-box-c']
jsonfilter -e @.running
sleep 1
jsonfilter -e @['homeproxy'].instances['sing-box-c']
jsonfilter -e @.running
sleep 1
log Error: sing-box-c did not come up with the new configuration, rolling back.
log Error: the failing configuration is already the known-good one; nothing to roll back to.
log Error: rollback was not possible; the service may be down. Check var/run/homeproxy/homeproxy.log.
reload_service rc=1
log Warning: failed to drop the auto-update cron entry.
log Warning: failed to restart cron.
config_load homeproxy
ip rule del fwmark 101 table 100
ip route del local 0.0.0.0/0 dev lo table 100
ip -6 rule del fwmark 101 table 100
ip -6 route del local ::/0 dev lo table 100
ip route del default dev singtun0 table 100
ip rule del fwmark 102 table 100
ip -6 route del default dev singtun0 table 100
ip -6 rule del fwmark 102 table 100
nft flush chain inet fw4 homeproxy_output_redir
nft delete chain inet fw4 homeproxy_output_redir
nft flush chain inet fw4 homeproxy_redirect
nft delete chain inet fw4 homeproxy_redirect
nft flush chain inet fw4 homeproxy_redirect_proxy
nft delete chain inet fw4 homeproxy_redirect_proxy
nft flush chain inet fw4 homeproxy_redirect_proxy_port
nft delete chain inet fw4 homeproxy_redirect_proxy_port
nft flush chain inet fw4 homeproxy_redirect_lanac
nft delete chain inet fw4 homeproxy_redirect_lanac
nft flush chain inet fw4 homeproxy_mangle_prerouting
nft delete chain inet fw4 homeproxy_mangle_prerouting
nft flush chain inet fw4 homeproxy_mangle_output
nft delete chain inet fw4 homeproxy_mangle_output
nft flush chain inet fw4 homeproxy_mangle_tproxy
nft delete chain inet fw4 homeproxy_mangle_tproxy
nft flush chain inet fw4 homeproxy_mangle_tproxy_port
nft delete chain inet fw4 homeproxy_mangle_tproxy_port
nft flush chain inet fw4 homeproxy_mangle_lanac
nft delete chain inet fw4 homeproxy_mangle_lanac
nft flush chain inet fw4 homeproxy_mangle_mark
nft delete chain inet fw4 homeproxy_mangle_mark
nft flush chain inet fw4 homeproxy_mangle_tun
nft delete chain inet fw4 homeproxy_mangle_tun
nft flush chain inet fw4 homeproxy_mangle_tun_mark
nft delete chain inet fw4 homeproxy_mangle_tun_mark
nft flush set inet fw4 homeproxy_local_addr_v4
nft delete set inet fw4 homeproxy_local_addr_v4
nft flush set inet fw4 homeproxy_local_addr_v6
nft delete set inet fw4 homeproxy_local_addr_v6
nft flush set inet fw4 homeproxy_gfw_list_v4
nft delete set inet fw4 homeproxy_gfw_list_v4
nft flush set inet fw4 homeproxy_gfw_list_v6
nft delete set inet fw4 homeproxy_gfw_list_v6
nft flush set inet fw4 homeproxy_mainland_addr_v4
nft delete set inet fw4 homeproxy_mainland_addr_v4
nft flush set inet fw4 homeproxy_mainland_addr_v6
nft delete set inet fw4 homeproxy_mainland_addr_v6
nft flush set inet fw4 homeproxy_wan_proxy_addr_v4
nft delete set inet fw4 homeproxy_wan_proxy_addr_v4
nft flush set inet fw4 homeproxy_wan_proxy_addr_v6
nft delete set inet fw4 homeproxy_wan_proxy_addr_v6
nft flush set inet fw4 homeproxy_wan_direct_addr_v4
nft delete set inet fw4 homeproxy_wan_direct_addr_v4
nft flush set inet fw4 homeproxy_wan_direct_addr_v6
nft delete set inet fw4 homeproxy_wan_direct_addr_v6
nft flush set inet fw4 homeproxy_routing_port
nft delete set inet fw4 homeproxy_routing_port
fw4 reload
log Warning: failed to restart dnsmasq after removing the homeproxy snippets.
log Service stopped.
config_load homeproxy
ip link set singtun0 down
ip tuntap del mode tun name singtun0
stop rc=0
file var/run/homeproxy/fw4_forward.nft:
file var/run/homeproxy/fw4_input.nft:
file var/run/homeproxy/fw4_post.nft:
file var/run/homeproxy/homeproxy.log:
file var/run/homeproxy/known-good/sing-box-c.json: {"log":{},"inbounds":[{"tag":"dns-in","listen_port":5333},{"tag":"mixed-in","listen_port":5399}],"outbounds":[]}|
file etc/homeproxy/cache.db:
@@ -0,0 +1,156 @@
#!/usr/bin/env node
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* The certificate upload buttons: which staging path do they actually write?
*
* `hp.uploadCertificate` derives its staging path from the *second* argument
* the call site binds - the UCI option name:
*
* const tmpPath = '/tmp/homeproxy_cert_' + filename + '.tmp';
*
* L.bind() prepends the bound arguments, and LuCI's form.Button calls
* `onclick(ev, section_id)` (form.js renderWidget), so the event lands on the
* handler's *first* parameter. A handler that declares a leading parameter no
* caller passes therefore shifts everything by one: filename becomes the event
* object, the path becomes /tmp/homeproxy_cert_[object Event].tmp, and both the
* ACL whitelist (acl.d/luci-app-homeproxy.json) and certificate_write
* (luci.homeproxy, which reads /tmp/homeproxy_cert_${filename}.tmp) stop
* matching. Every upload fails and the UI only shows a generic error.
*
* The unit under test is the *argument alignment*, not the handler body, so
* this test wires each real call site's arguments exactly as the view does and
* checks the path and the RPC filename that come out.
*
* Usage: node tests/frontend-certificate-upload.js <repo-root>
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { loadLuciModule } = require('./lib/luci-module.js');
const root = path.resolve(process.argv[2] || '.');
let checks = 0, failures = 0;
function check(what, ok, detail) {
checks++;
if (ok) return;
failures++;
console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`);
}
/* --- the call sites, read from the views --------------------------------- *
* Parsed rather than hardcoded so a renamed button, a changed UCI option name
* or a different binding shape is exercised as it is today. */
const VIEWS = [ 'node.js', 'server.js' ];
const calls = [];
for (const view of VIEWS) {
const file = path.join(root, 'htdocs/luci-static/resources/view/homeproxy', view);
const src = fs.readFileSync(file, 'utf8');
const re = /L\.bind\(hp\.uploadCertificate[^;]*\)/g;
let m;
while ((m = re.exec(src)) !== null)
calls.push({ view, text: m[0] });
}
check('the certificate upload buttons were found', calls.length === 4,
`expected 4 L.bind(hp.uploadCertificate ...) call sites, found ${calls.length}`);
/* Turn `L.bind(hp.uploadCertificate, hp, _('certificate'), 'client_ca')` into
* ['certificate', 'client_ca']: the arguments the view binds, in order. */
function boundArgs(text) {
const inner = text.replace(/^L\.bind\(hp\.uploadCertificate\s*,\s*hp\s*,?/, '').replace(/\)$/, '');
const args = [];
const re = /_\(\s*'([^']*)'\s*\)|'([^']*)'|"([^"]*)"|([A-Za-z_$][\w$]*)|(\d+)/g;
let m;
while ((m = re.exec(inner)) !== null) {
if (m[1] !== undefined || m[2] !== undefined || m[3] !== undefined)
args.push(m[1] ?? m[2] ?? m[3]);
else
args.push(m[4] !== undefined ? { identifier: m[4] } : Number(m[5]));
}
return args;
}
const parsed = calls.map((c) => ({ ...c, args: boundArgs(c.text) }));
/* --- load homeproxy.js the way LuCI does ---------------------------------- */
const uploads = [];
const rpcCalls = [];
const L = {
bind(fn, self, ...bound) {
return fn.bind(self, ...bound);
}
};
/* The recording ui stub goes in the deps record: that is the object the
* module's own `'require ui'` resolves to (the loader's runtime argument only
* supplies the _ / E / L globals). */
const uiStub = {
addNotification: () => {},
showModal: () => {},
hideModal: () => {},
uploadFile: (target, fileEl) => {
uploads.push({ target, fileEl });
return Promise.resolve({ size: 1 });
},
createHandlerFn: (self, fn) => fn
};
const hp = loadLuciModule(
path.join(root, 'htdocs/luci-static/resources/homeproxy.js'),
{
baseclass: { extend: (o) => o },
form: { DynamicList: { extend: (o) => o } },
fs: {}, rpc: {}, uci: {}, ui: uiStub
},
{ L, E: () => null }
);
check('homeproxy.js exposes uploadCertificate', typeof hp.uploadCertificate === 'function',
typeof hp.uploadCertificate);
/* --- drive each call site the way form.js does ---------------------------- */
async function run() {
for (const call of parsed) {
const label = `${call.view}: ${call.text}`;
const handler = L.bind(hp.uploadCertificate, Object.assign({}, hp, {
rpcCall: (method, params) => {
rpcCalls.push({ method, params });
return Promise.resolve({ result: true });
}
}), ...call.args);
uploads.length = 0;
rpcCalls.length = 0;
/* form.js: ui.createHandlerFn(this, (section_id, ev) => this.onclick(ev, section_id), section_id)
* L.bind prepends, so the handler is invoked with (event, section_id). */
const fakeFile = { name: 'cert.pem' };
const ev = { target: { files: [ fakeFile ], value: 'C:\\fakepath\\cert.pem' } };
await handler(ev, 'cfg01');
const want = call.args[1];
const got = uploads.length === 1 ? uploads[0].target : null;
check(`${label}: uploads to /tmp/homeproxy_cert_${want}.tmp`,
got === `/tmp/homeproxy_cert_${want}.tmp`, String(got));
const rpc = rpcCalls.length === 1 ? rpcCalls[0] : null;
check(`${label}: certificate_write receives the same name`,
rpc && rpc.method === 'certificate_write' && rpc.params && rpc.params[0] === want,
rpc ? JSON.stringify(rpc) : 'no rpcCall');
}
}
run().then(() => {
console.log(`${checks - failures}/${checks} checks passed`);
if (failures) {
console.error('FRONTEND CERTIFICATE UPLOAD TEST FAILED');
process.exit(1);
}
console.log('FRONTEND CERTIFICATE UPLOAD TEST PASSED');
process.exit(0);
});
@@ -0,0 +1,183 @@
#!/usr/bin/env node
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* homeproxy.js calcStringMD5() must be *the* MD5 of RFC 1321, because its
* output is compared against a value another language computed.
*
* The contract, end to end:
*
* update_subscriptions.uc url = replace(url, /#.*$/, ''); md5(url)
* -> written to UCI as the node's `grouphash`
* repository.uc md5(node.grouphash + label)
* -> the UCI section name of a subscription node
* view/homeproxy/node.js hp.calcStringMD5(suburl.replace(/#.*$/, ''))
* -> matched against `grouphash` to decide which
* subscription tab a section belongs to, and
* whether it is a subscription node at all
*
* So the browser's digest and ucode's `digest.md5()` have to agree byte for
* byte. Nothing verified that before this file: the suite had no MD5 vector
* anywhere, and the implementation it was checking was a minified snippet that
* did two non-standard things - it normalised CRLF to LF before hashing, and
* it encoded UTF-8 one UTF-16 code unit at a time, so any astral character
* (emoji) produced invalid UTF-8. Either one silently mismatches ucode, and
* the failure mode is quiet: the subscription's nodes stop matching
* `grouphash`, so they fall out of their tab and reappear as user nodes
* instead of erroring.
*
* Why this is hand-rolled at all is explained at the method. Briefly:
* crypto.subtle.digest() has no MD5 (the Web Crypto spec lists only SHA-1,
* SHA-256, SHA-384, SHA-512) and the algorithm cannot be changed without
* invalidating every existing install's `grouphash`.
*
* Two independent references are used, so a shared mistake is unlikely to pass
* both:
*
* 1. the seven test vectors of RFC 1321 appendix A.5, hardcoded here;
* 2. Node's crypto.createHash('md5').update(s, 'utf8') over a wider corpus,
* including the padding boundaries of section 3.1 (55/56, 63/64, 119/120
* bytes) and the UTF-8 cases above.
*
* tests/ucode/test_subscription_repository.uc pins the ucode half to the same
* RFC 1321 vectors, which is what turns "each side is standard" into "the two
* sides are equal".
*
* Usage: node tests/frontend-md5.js <repo-root>
*/
'use strict';
const path = require('path');
const crypto = require('crypto');
const { loadLuciModule } = require('./lib/luci-module.js');
const root = path.resolve(process.argv[2] || '.');
let checks = 0, failures = 0;
function check(what, ok, detail) {
checks++;
if (ok) return;
failures++;
console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`);
}
/* calcStringMD5() only needs the module's own class; the deps are the same
* inert set tests/frontend-validators.js uses, since the method touches none
* of them. */
const hp = loadLuciModule(
path.join(root, 'htdocs/luci-static/resources/homeproxy.js'),
{
baseclass: { extend: (o) => o },
form: { DynamicList: { extend: (o) => o } },
fs: {}, rpc: {}, uci: {}, ui: {}
});
/* An independent MD5, from the platform, as the second opinion. */
const reference = (s) => crypto.createHash('md5').update(s, 'utf8').digest('hex');
/* --- 1. RFC 1321 appendix A.5, verbatim -------------------------------- */
const RFC1321 = [
[ '', 'd41d8cd98f00b204e9800998ecf8427e' ],
[ 'a', '0cc175b9c0f1b6a831c399e269772661' ],
[ 'abc', '900150983cd24fb0d6963f7d28e17f72' ],
[ 'message digest', 'f96b697d7cb7938d525a2f31aaf161d0' ],
[ 'abcdefghijklmnopqrstuvwxyz', 'c3fcd3d76192e4007dfb496cca67e13b' ],
[ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
'd174ab98d277d9f5a5611c2c9f419d9f' ],
[ '12345678901234567890123456789012345678901234567890123456789012345678901234567890',
'57edf4a22be3c955ac49da2e2107b67a' ]
];
for (const [ input, expected ] of RFC1321) {
const got = hp.calcStringMD5(input);
check(`RFC 1321 A.5 ${JSON.stringify(input.slice(0, 24))}`, got === expected,
`got ${got}, expected ${expected}`);
}
/* --- 2. shape ---------------------------------------------------------- */
check('output is lowercase hex', /^[0-9a-f]{32}$/.test(hp.calcStringMD5('anything')));
check('output is 32 characters', hp.calcStringMD5('anything').length === 32,
String(hp.calcStringMD5('anything').length));
/* --- 3. the two divergences the old body had --------------------------- */
/* It called `e.replace(/\r\n/g, '\n')` first, so these two inputs hashed
* alike. RFC 1321 hashes the bytes it is given, and so does ucode. */
check('CRLF is not normalised to LF',
hp.calcStringMD5('\r\n') === reference('\r\n') &&
hp.calcStringMD5('\r\n') !== hp.calcStringMD5('\n'),
`CRLF=${hp.calcStringMD5('\r\n')} LF=${hp.calcStringMD5('\n')}`);
/* It encoded one UTF-16 code unit at a time, so a surrogate pair became two
* orphaned three-byte sequences instead of one four-byte sequence. */
check('an astral character uses real UTF-8',
hp.calcStringMD5('\u{1F600}') === reference('\u{1F600}'),
`got ${hp.calcStringMD5('\u{1F600}')}, expected ${reference('\u{1F600}')}`);
check('CJK uses real UTF-8',
hp.calcStringMD5('\u4e2d\u6587') === reference('\u4e2d\u6587'),
`got ${hp.calcStringMD5('\u4e2d\u6587')}, expected ${reference('\u4e2d\u6587')}`);
/* --- 4. cross-check against the platform MD5 --------------------------- */
const corpus = [
'',
' ',
'https://example.com/sub?token=abc',
'https://example.com/sub?token=abc#Node%20Name',
'https://sub.example.com/api/v1/client/subscribe?token=deadbeef',
'\u4e2d\u6587',
'\u{1F600}',
'\u{1F1E8}\u{1F1F3} flag pair',
'mixed \u4e2d\u6587 and \u{1F600} emoji',
'\t\n\r\n\u0000',
'y'.repeat(54), 'y'.repeat(55), 'y'.repeat(56), 'y'.repeat(57),
'y'.repeat(62), 'y'.repeat(63), 'y'.repeat(64), 'y'.repeat(65),
'y'.repeat(118), 'y'.repeat(119), 'y'.repeat(120), 'y'.repeat(121),
'y'.repeat(1000),
'\u00e9'.repeat(40),
'\u{1F600}'.repeat(20)
];
for (const input of corpus) {
const got = hp.calcStringMD5(input);
const expected = reference(input);
check(`cross-check ${input.length} bytes ${JSON.stringify(input.slice(0, 16))}`,
got === expected, `got ${got}, expected ${expected}`);
}
/* --- 5. the grouphash shape, exactly as both sides build it ------------ */
/* node.js hashes the subscription URL with its fragment stripped; the
* fragment carries the label a user appended and is deliberately excluded, so
* appending one must not move the digest. */
const base = 'https://example.com/sub?token=abc';
check('stripping the fragment is what makes the two forms agree',
hp.calcStringMD5(base) === hp.calcStringMD5(base + '#Some%20Label'.replace(/#.*$/, '')),
`${hp.calcStringMD5(base)} vs ${hp.calcStringMD5(base + '#Some%20Label'.replace(/#.*$/, ''))}`);
/* --- 6. non-strings fail loudly ---------------------------------------- */
/* The old body threw here only by accident (`null.replace` is a TypeError).
* TextEncoder would encode the text "null" and return a digest, which would
* silently name a UCI section after a string nobody asked for - so the check
* is explicit and this test keeps it that way. */
for (const bad of [ null, undefined, 42, {}, [], true ]) {
const label = bad === null ? 'null' : (Array.isArray(bad) ? 'array' : typeof bad);
let threw = null;
try {
hp.calcStringMD5(bad);
}
catch (e) {
threw = e;
}
check(`calcStringMD5 rejects ${label}`, threw instanceof TypeError,
threw ? String(threw) : 'returned a digest instead of throwing');
}
console.log(`frontend md5: ${checks} checks, ${failures} failures`);
process.exit(failures ? 1 : 0);
@@ -0,0 +1,192 @@
#!/usr/bin/env node
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Frontend protocol inventory.
*
* homeproxy.js carries one ordered `protocols` table and both forms render
* their type picker from it. Before that table existed each form had its own
* hand-written value list, and they had drifted in ways nothing could catch:
* `snell` had a complete form block, a CREDENTIALS row and a golden outbound
* but was not in the node form's list, so it could not be selected at all; the
* node form offered `chacha20`, which sing-box rejects outright.
*
* The form snapshots cannot catch this class of bug - they record an option's
* dependency and default, and the node form's picker lives in a subsection the
* dump does not reach - so this test reads the table directly and compares it
* with the backend tables that decide what the generators can actually build.
*
* Checks:
* 1. every offered type is modelled by the backend (parser/mapping.uc
* PROTOCOL_TO_UCI) - the table that makes a protocol reachable from UCI;
* 2. every protocol the outbound adapter can build (OPTION_FIELDS,
* REQUIRED_CREDENTIALS) is offered by the CLIENT form - "the backend
* supports it and the UI cannot select it" is the snell bug;
* 3. every protocol offered by the SERVER form has inbound credentials
* (model.uc INBOUND_CREDENTIALS) - the reverse direction;
* 4. the client and server lists match their expected order, because the
* order is part of the UI and filtering one table has to reproduce it;
* 5. protocols the backend models but the UI deliberately does not offer are
* listed explicitly, so an accidental omission has to be added here (and
* this file is reviewed) rather than passing silently.
*
* Usage: node tests/frontend-protocol-inventory.js <repo-root>
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { loadLuciModule } = require('./lib/luci-module.js');
const root = path.resolve(process.argv[2] || '.');
const scripts = path.join(root, 'root/etc/homeproxy/scripts');
/* The registry is data plus the renderer; loading it through the whole LuCI
* mock would test the mock. `protocols` only needs the module to evaluate its
* top-level statements, so the imports are stubbed just far enough for that. */
function loadHomeproxy() {
return loadLuciModule(
path.join(root, 'htdocs/luci-static/resources/homeproxy.js'),
{
baseclass: { extend: (o) => o },
form: { DynamicList: { extend: (o) => o } },
fs: {}, rpc: {}, uci: {}, ui: {}
});
}
/* Read the keys of one `export const NAME = { ... }` table. Only depth-1 keys
* count, so nested per-option maps and the `};` terminator are ignored. */
function tableKeys(file, name) {
const lines = fs.readFileSync(file, 'utf8').split('\n');
const head = new RegExp('^export const ' + name + '\\s*=\\s*\\{');
let start = -1;
for (let i = 0; i < lines.length; i++) {
if (head.test(lines[i])) { start = i; break; }
}
if (start === -1)
throw new Error(`table ${name} not found in ${file}`);
const keys = [];
let depth = 1;
for (let i = start + 1; i < lines.length; i++) {
const line = lines[i];
if (depth === 1) {
const m = /^\t([A-Za-z0-9_]+)\s*:/.exec(line);
if (m) keys.push(m[1]);
}
for (const ch of line) {
if (ch === '{') depth++;
else if (ch === '}') depth--;
}
if (depth <= 0) break;
}
return keys;
}
let checks = 0, failures = 0;
function check(what, ok, detail) {
checks++;
if (ok) return;
failures++;
console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`);
}
const hp = loadHomeproxy();
const protocols = hp.protocols;
check('homeproxy.js exports a protocols table', Array.isArray(protocols) && protocols.length > 0);
if (!Array.isArray(protocols)) {
console.error(`\n${checks} checks, ${failures} failures`);
process.exit(1);
}
const mapped = tableKeys(path.join(scripts, 'parser/mapping.uc'), 'PROTOCOL_TO_UCI');
const optionFields = tableKeys(path.join(scripts, 'config/adapter.uc'), 'OPTION_FIELDS');
const required = tableKeys(path.join(scripts, 'config/adapter.uc'), 'REQUIRED_CREDENTIALS');
const inboundCredentials = tableKeys(path.join(scripts, 'config/model.uc'), 'INBOUND_CREDENTIALS');
const credentials = tableKeys(path.join(scripts, 'config/model.uc'), 'CREDENTIALS');
const clientList = protocols.filter((p) => p.sides.includes('client')).map((p) => p.type);
const serverList = protocols.filter((p) => p.sides.includes('server')).map((p) => p.type);
check('mapping.uc PROTOCOL_TO_UCI parsed', mapped.length > 0, `${mapped.length} keys`);
check('adapter.uc OPTION_FIELDS parsed', optionFields.length > 0, `${optionFields.length} keys`);
check('adapter.uc REQUIRED_CREDENTIALS parsed', required.length > 0, `${required.length} keys`);
check('model.uc INBOUND_CREDENTIALS parsed', inboundCredentials.length > 0, `${inboundCredentials.length} keys`);
/* 1. Each side is checked against the table that makes it reachable there.
* The client side is the outbound half: PROTOCOL_TO_UCI is what the Loader
* reads options through. The server side is the inbound half, which lives
* in model.uc's INBOUND_CREDENTIALS - server-only protocols such as naive
* and mixed legitimately have no outbound mapping at all. */
for (const p of protocols) {
check(`'${p.type}' declares at least one side`, p.sides.length > 0);
/* A label is now wrapped in _() at the table, so it is a translated
* String object rather than a primitive when a translation exists. The
* property under test is that it is present and non-empty. */
check(`'${p.type}' has a label`, p.label != null && String(p.label).length > 0);
if (p.sides.includes('client'))
check(`client '${p.type}' is modelled by the backend (PROTOCOL_TO_UCI)`, mapped.includes(p.type));
if (p.sides.includes('server'))
check(`server '${p.type}' has inbound credentials (INBOUND_CREDENTIALS)`, inboundCredentials.includes(p.type));
}
/* 2. Outbound-capable in the backend => selectable in the client form. This
* is the direction that catches "the backend supports it and the UI cannot
* select it", which is how snell was lost. */
const outboundCapable = [...new Set([...optionFields, ...required])].sort();
for (const type of outboundCapable) {
check(`outbound-capable '${type}' can be selected in the node form`, clientList.includes(type));
}
/* 4. The rendered order is part of the UI. Pinned here because the form
* snapshots do not reach the node form's picker. */
const expectedClient = [
'direct', 'anytls', 'http', 'hysteria', 'hysteria2',
'shadowsocks', 'shadowtls', 'snell', 'socks', 'ssh',
'trojan', 'tuic', 'wireguard', 'vless', 'vmess'
];
const expectedServer = [
'anytls', 'http', 'hysteria', 'hysteria2', 'naive', 'mixed',
'shadowsocks', 'snell', 'socks', 'trojan', 'tuic', 'vless', 'vmess'
];
check('client protocol order', JSON.stringify(clientList) === JSON.stringify(expectedClient),
`got ${JSON.stringify(clientList)}`);
check('server protocol order', JSON.stringify(serverList) === JSON.stringify(expectedServer),
`got ${JSON.stringify(serverList)}`);
/* 5. Deliberate omissions, spelled out. A protocol the backend models but no
* form offers has to appear here; adding one to this list is a reviewable
* decision instead of a silent gap. */
const DELIBERATE = {
/* The backend models the inbound side (INBOUND_CREDENTIALS,
* REQUIRED_INBOUND_CREDENTIALS, INBOUND_CLAIM_FIELDS) but the inbound
* generation path has no fixture/golden coverage, so the server form must
* not offer it until that exists. The client form does offer it. */
shadowtls: 'server side has no inbound fixture/golden coverage yet',
/* Endpoint protocols: modelled and buildable, but the client form gates
* them behind runtime features (wireguard on with_wireguard+gvisor,
* direct is always offered). Listed here because they are not part of
* the adapter's per-protocol OPTION_FIELDS tables. */
wireguard: 'endpoint, gated on with_wireguard + with_gvisor',
direct: 'endpoint, always offered by the client form'
};
const unmapped = [...new Set([...mapped, ...credentials, ...inboundCredentials])]
.filter((t) => !clientList.includes(t) && !serverList.includes(t))
.sort();
for (const type of unmapped) {
check(`unoffered protocol '${type}' is a documented decision`, type in DELIBERATE,
'models it in the backend but no form offers it');
}
console.log(`frontend protocol inventory: ${checks} checks, ${failures} failures`);
console.log(` client: ${clientList.join(' ')}`);
console.log(` server: ${serverList.join(' ')}`);
if (unmapped.length)
console.log(` deliberately unoffered: ${unmapped.map((t) => `${t} (${DELIBERATE[t] || 'UNDOCUMENTED'})`).join(', ')}`);
process.exit(failures ? 1 : 0);
@@ -0,0 +1,180 @@
#!/usr/bin/env node
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* What the UI does when an RPC does not answer. Two paths, both of which
* used to state something false rather than admit ignorance.
*
* getBuiltinFeatures() goes through rpcCall(), which resolves to `{}` when the
* call does not come back. The protocol list used to be filtered against that
* map unconditionally, so an empty map hid every feature-gated protocol
* (hysteria, hysteria2, naive, tuic, wireguard). A node already saved as one
* of them then rendered with no matching Select option, and LuCI's
* AbstractValue.parse() writes whenever formvalue != cfgvalue - so the next
* Save & Apply silently rewrote the node's `type` to whichever protocol came
* first. Silent protocol change, from a failed status RPC.
*
* No test could see it: luci-form-snapshot.js always passes the fully
* populated feature map (and its rpc stub always resolves), so the empty-map
* path was never taken.
*
* Usage: node tests/frontend-feature-fallback.js <repo-root>
*/
'use strict';
const path = require('path');
const { loadLuciModule } = require('./lib/luci-module.js');
const root = path.resolve(process.argv[2] || '.');
let checks = 0, failures = 0;
function check(what, ok, detail) {
checks++;
if (ok) return;
failures++;
console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`);
}
const hp = loadLuciModule(
path.join(root, 'htdocs/luci-static/resources/homeproxy.js'),
{ baseclass: { extend: (o) => o }, form: { DynamicList: { extend: (o) => o } },
fs: {}, rpc: {}, uci: {}, ui: {} });
function types(side, features) {
return hp.protocolChoices(side, features).map((p) => p.type);
}
const FULL = { with_quic: true, with_wireguard: true, with_gvisor: true };
const GATED_CLIENT = ['hysteria', 'hysteria2', 'tuic', 'wireguard'];
const GATED_SERVER = ['hysteria', 'hysteria2', 'naive', 'tuic'];
/* --- the failure path: an empty map means "unknown", not "none" ---------- */
for (const [side, gated] of [['client', GATED_CLIENT], ['server', GATED_SERVER]]) {
const unknown = types(side, {});
const full = types(side, FULL);
for (const g of gated) {
check(`${side}: ${g} is still offered when the feature call failed`,
unknown.indexOf(g) !== -1,
`offered: ${unknown.join(',')}`);
}
/* The property that actually matters: an unanswered call must not offer
* FEWER protocols than a successful one. */
check(`${side}: an unknown feature map offers at least as many as a full one`,
unknown.length >= full.length,
`unknown=${unknown.length} full=${full.length}`);
check(`${side}: the unknown list contains everything the full list does`,
full.every((t) => unknown.indexOf(t) !== -1),
`missing: ${full.filter((t) => unknown.indexOf(t) === -1).join(',')}`);
}
/* --- and the filter still works when the features ARE known -------------- */
const noQuic = types('client', { with_quic: false, with_wireguard: true, with_gvisor: true });
for (const g of ['hysteria', 'hysteria2', 'tuic'])
check(`client: ${g} is filtered out when with_quic is false`, noQuic.indexOf(g) === -1,
`offered: ${noQuic.join(',')}`);
check('client: wireguard survives with_quic=false', noQuic.indexOf('wireguard') !== -1);
/* wireguard needs BOTH of its features; one missing must be enough to hide it. */
const partialWg = types('client', { with_quic: true, with_wireguard: true, with_gvisor: false });
check('client: wireguard is hidden when only one of its features is present',
partialWg.indexOf('wireguard') === -1, `offered: ${partialWg.join(',')}`);
/* --- renderProtocolOptions must use the same rule ------------------------ */
function driveRenderProtocolOptions(features, side) {
const offered = [];
const fakeOption = {
value: (type) => { offered.push(type); },
rmempty: true
};
const fakeSection = { option: () => fakeOption };
hp.renderProtocolOptions(fakeSection, { features: features, side: side });
return offered;
}
for (const [side, gated] of [['client', GATED_CLIENT], ['server', GATED_SERVER]]) {
const offered = driveRenderProtocolOptions({}, side);
for (const g of gated)
check(`renderProtocolOptions(${side}, {}) still offers ${g}`,
offered.indexOf(g) !== -1, `offered: ${offered.join(',')}`);
}
const renderedFull = driveRenderProtocolOptions(FULL, 'client');
check('renderProtocolOptions offers every client protocol with full features',
renderedFull.length === types('client', FULL).length,
`${renderedFull.length} vs ${types('client', FULL).length}`);
/* --- the status bar: "the query failed" is not "the service is down" ----- */
/* getServiceStatus() asks rpcCall() for a `null` fallback so it can tell the
* two apart; the default `{}` made the try/catch report a failed query as
* NOT RUNNING, which reads as "your proxy is down". */
const running = hp.statusLabel(true);
const stopped = hp.statusLabel(false);
const unknown = hp.statusLabel(null);
check('a running service is green', String(running.color) === 'green', String(running.color));
check('a stopped service is red', String(stopped.color) === 'red', String(stopped.color));
check('an unanswered query is neither green nor red',
String(unknown.color) !== 'green' && String(unknown.color) !== 'red',
String(unknown.color));
check('an unanswered query does not say NOT RUNNING',
String(unknown.text) !== String(stopped.text),
`unknown=${String(unknown.text)} stopped=${String(stopped.text)}`);
check('an unanswered query does not say RUNNING either',
String(unknown.text) !== String(running.text),
`unknown=${String(unknown.text)} running=${String(running.text)}`);
check('a missing status argument is treated as unknown',
String(hp.statusLabel(undefined).text) === String(unknown.text));
check('the three states are three distinct texts',
new Set([String(running.text), String(stopped.text), String(unknown.text)]).size === 3);
/* --- the status poll registers exactly once ----------------------------- */
/* The flag that makes this true used to live in client.js and server.js as two
* separate booleans. Every re-render that got past it added another poll
* handler, and LuCI keeps them all. The form snapshots never reach this code,
* so nothing could fail until the state moved somewhere testable. */
function makePoll() {
const added = [];
return {
added: added,
poll: { add: (fn) => { added.push(fn); return added.length; } }
};
}
{
const h = makePoll();
let reads = 0;
const register = hp.statusPoller({
poll: h.poll,
read: () => { reads++; return Promise.resolve('v'); },
paint: () => {}
});
check('the first registration reports that it registered', register() === true);
check('a second registration reports that it did not', register() === false);
check('a third registration reports that it did not', register() === false);
check('exactly one poll handler was added', h.added.length === 1, `${h.added.length} added`);
/* The handler has to actually do something - a registrar that registered an
* empty function would satisfy the count above. read() is called
* synchronously inside the handler, so this needs no awaiting (and a
* top-level `return` is not allowed in a CommonJS module anyway - the first
* version of this used one and the whole file failed to load, printing
* nothing at all). */
h.added[0]();
check('the registered handler calls read() when invoked', reads === 1, `${reads} reads`);
}
console.log(`frontend rpc fallbacks: ${checks} checks, ${failures} failures`);
process.exit(failures ? 1 : 0);
@@ -0,0 +1,119 @@
#!/usr/bin/env node
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Frontend RPC boundary.
*
* Every backend call goes through `homeproxy.js`'s `rpcCall()`, which is the
* one place that knows how to declare an RPC and what to do when the call does
* not come back. Before that, nine call sites used
* `L.resolveDefault(call(), {})` - which does NOT catch a rejection, it only
* substitutes for a null/undefined *result* - so an unreachable rpcd left the
* caller's `.then()` never running and said nothing: a status bar that silently
* stopped updating, a capability list that quietly became empty, and an
* unhandled rejection in the console.
*
* This is a source-level invariant rather than a behavioural test because the
* behaviour needs a browser; what it can pin down is that nobody reintroduces a
* second declaration site or the swallowing pattern.
*
* Usage: node tests/frontend-rpc-inventory.js <repo-root>
*/
'use strict';
const fs = require('fs');
const path = require('path');
const root = path.resolve(process.argv[2] || '.');
const res = path.join(root, 'htdocs/luci-static/resources');
const files = [
'homeproxy.js',
'view/homeproxy/node.js',
'view/homeproxy/client.js',
'view/homeproxy/server.js',
'view/homeproxy/status.js'
];
let checks = 0, failures = 0;
function check(what, ok, detail) {
checks++;
if (ok) return;
failures++;
console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`);
}
/* Comments have to go before the pattern checks. A naive grep matched the doc
* comment in homeproxy.js that quotes the old `L.resolveDefault(call(), {})`
* idiom - which is exactly the trap the plan records for the Architecture
* Guard: the check has to talk about code, not about prose describing code.
* Only block comments are stripped; a `//` stripper would cut real code after a
* URL in a string literal, and hiding a violation is worse than a false alarm. */
function stripComments(src) {
return src.replace(/\/\*[\s\S]*?\*\//g, '');
}
const sources = {};
for (const f of files)
sources[f] = stripComments(fs.readFileSync(path.join(res, f), 'utf8'));
/* 1. One declaration site. */
for (const f of files) {
const n = (sources[f].match(/\brpc\.declare\s*\(/g) || []).length;
if (f === 'homeproxy.js')
check('homeproxy.js declares the RPCs', n === 1, `${n} rpc.declare calls, expected 1 (inside rpcCall)`);
else
check(`${f} declares no RPC of its own`, n === 0, `${n} rpc.declare call(s) outside the shared helper`);
}
/* 2. The swallowing pattern: a call wrapped in resolveDefault, which reads like
* error handling and is not.
*
* The pattern used to be `L\.resolveDefault\(\s*call[A-Za-z0-9_]*\s*\(`, which only
* matched a callee whose name begins with `call` - the spelling the old code
* happened to use. `L.resolveDefault(hp.rpcCall(...))` passed it, so the check
* was almost vacuous, and my own reverse verification missed that because it
* restored the very spelling the regex hardcodes (a circular proof: the
* pattern was tested against the only form it recognises).
*
* Any resolveDefault wrapping is now flagged, and the pattern is self-tested
* below so it cannot quietly stop matching. */
const SWALLOW = /L\.resolveDefault\s*\(/g;
check('the resolveDefault pattern matches the real-world spelling',
'L.resolveDefault(hp.rpcCall("list")).then()'.match(SWALLOW)?.length === 1);
check('the resolveDefault pattern matches the historical spelling',
'L.resolveDefault(call()).then()'.match(SWALLOW)?.length === 1);
for (const f of files) {
const m = sources[f].match(SWALLOW) || [];
check(`${f} does not wrap an RPC call in L.resolveDefault`, m.length === 0, m.join(', '));
}
/* 3. rpcCall itself has to catch, and has to have a fallback. */
const hp = sources['homeproxy.js'];
const helper = /rpcCall\s*\(method,\s*args,\s*options\)\s*\{([\s\S]*?)\n\t\}/.exec(hp);
check('homeproxy.js defines rpcCall', !!helper);
if (helper) {
check('rpcCall catches a failed call', /\.catch\(/.test(helper[1]));
check('rpcCall has a fallback for a failed call', /fallback/.test(helper[1]));
check('rpcCall reports the failure to the user', /addNotification/.test(helper[1]));
}
/* 4. A view that calls rpcCall must import the module that provides it. */
for (const f of files) {
if (f === 'homeproxy.js') continue;
if (!/\bhp\.rpcCall\s*\(/.test(sources[f])) continue;
check(`${f} imports homeproxy for hp.rpcCall`, /'require homeproxy as hp';/.test(sources[f]));
}
/* 5. No file keeps an unused `require rpc` after the conversion. */
for (const f of files) {
if (f === 'homeproxy.js') continue;
const requiresRpc = /'require rpc';/.test(sources[f]);
const usesRpc = /\brpc\./.test(sources[f]);
check(`${f} has no unused 'require rpc'`, !requiresRpc || usesRpc);
}
console.log(`frontend rpc boundary: ${checks} checks, ${failures} failures`);
process.exit(failures ? 1 : 0);
@@ -0,0 +1,227 @@
#!/usr/bin/env node
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Title escaping and subscription-URL parsing.
*
* Two sinks with different properties, which is the whole point of this test:
*
* - A form *tab* title is a bare string child of E('a', ...), and LuCI's
* dom.append() assigns a bare string to innerHTML. One decode, so one
* level of escaping is both necessary and sufficient.
*
* - A GridSection *modal* title goes through form.titleFn(), which runs the
* value through stripTags(). stripTags' documented contract is "HTML tags
* removed, and HTML entities DECODED" - it parses `<div>${s}</div>` and
* returns textContent - and the decoded result is then handed to the same
* innerHTML sink. Escaping is therefore not enough there, and is in fact
* worse than nothing: `&lt;img&gt;` would be decoded back into markup.
*
* The test drives the same two decodes the framework performs, so a value that
* would become markup in the browser fails here instead.
*
* Usage: node tests/frontend-title-escaping.js <repo-root>
*/
'use strict';
const path = require('path');
const { loadLuciModule } = require('./lib/luci-module.js');
const root = path.resolve(process.argv[2] || '.');
let checks = 0, failures = 0;
function check(what, ok, detail) {
checks++;
if (ok) return;
failures++;
console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`);
}
/* uci.get is what loadModalTitle reads the label through; keep it mutable so
* the test can present a hostile label. */
let label = null;
const hp = loadLuciModule(
path.join(root, 'htdocs/luci-static/resources/homeproxy.js'),
{ baseclass: { extend: (o) => o }, form: { DynamicList: { extend: (o) => o } },
fs: {}, rpc: {}, ui: {},
uci: { get: (_c, _s, o) => (o === 'label' ? label : null) } });
/* Models what form.stripTags() does to a string: parse it as the innerHTML of
* a <div>, then read the text back. For the entities that matter here that is
* exactly one level of entity decoding - which is why it turns '&lt;' into the
* character '<'. Only the five named entities and numeric character
* references are modelled; that is enough, because those are the only ways to
* spell '<' without writing it. */
function stripTags(s) {
if (typeof s == 'string' && !/[<>&]/.test(s))
return s;
/* A raw tag is dropped by the parse, which is what makes a *raw* '<...>'
* label safe today; only the text outside tags survives. */
const withoutTags = s.replace(/<[^>]*>/g, '');
return decodeEntitiesOnce(withoutTags);
}
function decodeEntitiesOnce(s) {
const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0' };
return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, body) => {
if (body[0] === '#') {
const code = body[1] === 'x' || body[1] === 'X'
? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
return Number.isFinite(code) ? String.fromCodePoint(code) : m;
}
return Object.prototype.hasOwnProperty.call(named, body) ? named[body] : m;
});
}
/* The property that decides whether assigning a string to innerHTML can start
* a tag: no literal '<' may survive stripTags' single decode.
*
* Two callers, deliberately kept apart, because conflating them hides bugs:
* rendersAsMarkupAfterEscape() takes a RAW value and applies the sanitizer
* first, so it tests the sanitizer; titleBecomesMarkup() takes a value that
* has already been sanitized (the output of loadModalTitle) and must NOT
* sanitize it again - doing so would mask a call site that forgot to. */
function titleBecomesMarkup(alreadyEscaped) {
return stripTags(alreadyEscaped).includes('<');
}
function rendersAsMarkupAfterEscape(raw) {
return titleBecomesMarkup(hp.escapeTitleText(raw));
}
const PAYLOADS = [
'&lt;img src=x onerror=alert(1)&gt;', // the reported vector
'<img src=x onerror=alert(1)>', // raw tag: safe before, must stay safe
'&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;',
'&#60;script&#62;alert(1)&#60;/script&#62;',
'&#x3c;script&#x3e;',
'"><script>alert(1)</script>',
"'><svg onload=alert(1)>",
'&',
'&&lt;&lt;',
'a < b > c'
];
for (const p of PAYLOADS) {
check(`escapeTitleText leaves no live markup for ${JSON.stringify(p)}`,
!rendersAsMarkupAfterEscape(p),
`stripTags(escapeTitleText(x)) = ${JSON.stringify(stripTags(hp.escapeTitleText(p)))}`);
}
/* And it must not mangle ordinary names. */
for (const name of ['Tokyo-01', '香港 01', 'a & b', 'node (backup)', 'im?possible', '100%']) {
check(`escapeTitleText keeps ${JSON.stringify(name)} readable`,
stripTags(hp.escapeTitleText(name)) === name,
`got ${JSON.stringify(stripTags(hp.escapeTitleText(name)))}`);
}
/* escapeHtml is the one-decode sink: it must not emit a literal '<' at all,
* and exactly one decode must give the original back - no more (the value is
* fully escaped) and no less (it is escaped deeply enough that a single decode
* cannot re-introduce markup).
*
* This check used to read `!decodeEntitiesOnce(escaped).includes('<') || true`,
* which is unconditionally true, so it never checked anything: for the raw-tag
* payloads one decode *does* contain '<' (that is what a one-level escape
* means), and the `|| true` was papering over the wrong property. */
for (const p of PAYLOADS) {
const escaped = hp.escapeHtml(p);
check(`escapeHtml emits no literal '<' for ${JSON.stringify(p)}`,
!escaped.includes('<') && !escaped.includes('>'),
JSON.stringify(escaped));
const decoded = decodeEntitiesOnce(escaped);
check(`escapeHtml survives exactly one decode for ${JSON.stringify(p)}`,
decoded === p,
`one decode gave ${JSON.stringify(decoded)}, expected ${JSON.stringify(p)}`);
}
/* The reason escapeTitleText exists at all: on the *raw* tag case, plain
* escaping hands stripTags something it decodes straight back into markup.
* This is not a defect in escapeHtml - it is the wrong tool for this sink. */
check('escapeHtml would be unsafe for the modal title (raw tag decoded back)',
stripTags(hp.escapeHtml('<img src=x onerror=alert(1)>')).includes('<'),
'if this ever stops being true the comment above is stale');
/* loadModalTitle must use the stripTags-aware escape on the label. */
for (const p of ['&lt;img src=x onerror=alert(1)&gt;', '<img src=x onerror=alert(1)>']) {
label = p;
const title = hp.loadModalTitle('Node', 'Add', 'homeproxy', 'n1');
check(`loadModalTitle escapes the label ${JSON.stringify(p)}`,
!titleBecomesMarkup(title),
`title = ${JSON.stringify(title)} -> ${JSON.stringify(stripTags(title))}`);
}
label = null;
check('loadModalTitle falls back to addtitle without a label',
hp.loadModalTitle('Node', 'Add', 'homeproxy', 'n1') === 'Add');
/* --- subscriptionInfo: the crash and the tab-title sink ------------------ */
/* A fragment of '#%' or '#100%' is accepted by the form validator (it only
* needs new URL() to succeed and a hostname to exist) but makes
* decodeURIComponent throw. That threw out of render() before m.render(),
* so the whole Node Settings page failed to render. */
for (const u of ['https://h/p#%', 'https://h/p#100%', 'https://h/p#%zz', 'https://h/p#a%']) {
let info = null, threw = null;
try { info = hp.subscriptionInfo(u); } catch (e) { threw = e; }
check(`subscriptionInfo does not throw on ${u}`, threw === null, threw && threw.message);
check(`subscriptionInfo still returns a hash for ${u}`,
info && typeof info.hash === 'string' && info.hash.length === 32,
info && JSON.stringify(info.hash));
/* The fallback has to be the raw fragment, not the whole URL: a bare
* decodeURIComponent() would be caught by the outer guard and display
* 'https://h/p#100%' in the tab, which reads as a bug to the user. */
check(`subscriptionInfo shows the raw fragment for ${u}`,
info && info.title === u.slice(u.indexOf('#') + 1),
info && JSON.stringify(info.title));
}
/* A value UCI may hold whatever another package wrote. */
for (const junk of ['not a url', '', null, undefined, 42, {}]) {
let threw = null;
try { hp.subscriptionInfo(junk); } catch (e) { threw = e; }
check(`subscriptionInfo tolerates ${JSON.stringify(junk)}`, threw === null, threw && threw.message);
}
check('subscriptionInfo returns null for an empty string', hp.subscriptionInfo('') === null);
check('subscriptionInfo returns null for a non-string', hp.subscriptionInfo(42) === null);
/* The tab-title path renders the fragment through innerHTML. Wrapped so a
* regression fails with a named check instead of a stack trace. */
function info(label, url) {
try {
return hp.subscriptionInfo(url);
}
catch (e) {
check(`subscriptionInfo must not throw for ${label}`, false, `${e.name}: ${e.message}`);
return { hash: '', title: '' };
}
}
const hostile = info('a raw-tag fragment', 'https://e.com/#<img src=x onerror=alert(1)>');
check('subscriptionInfo escapes a raw-tag fragment',
!hostile.title.includes('<') && !hostile.title.includes('>'),
JSON.stringify(hostile.title));
const encoded = info('an entity-encoded fragment', 'https://e.com/#&lt;img src=x onerror=alert(1)&gt;');
check('subscriptionInfo escapes an entity-encoded fragment',
!encoded.title.includes('<') && !encoded.title.includes('>'),
JSON.stringify(encoded.title));
const named = info('a percent-encoded fragment', 'https://e.com/#Tokyo%20%26%20Osaka');
check('subscriptionInfo keeps %26 as & (one decode, then escape)',
named.title === 'Tokyo &amp; Osaka', JSON.stringify(named.title));
const plain = info('a plain URL', 'https://sub.example.com/path');
check('subscriptionInfo falls back to the hostname without a fragment',
plain.title === 'sub.example.com', JSON.stringify(plain.title));
/* The hash must ignore the fragment, so two URLs that differ only by fragment
* map to the same subscription. */
check('subscriptionInfo hashes the URL without its fragment',
info('a fragment x', 'https://e.com/a#x').hash === info('a fragment y', 'https://e.com/a#y').hash);
console.log(`frontend title escaping: ${checks} checks, ${failures} failures`);
process.exit(failures ? 1 : 0);
@@ -0,0 +1,137 @@
#!/usr/bin/env node
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Frontend validators.
*
* The form snapshots record an option's kind, name, title, dependencies and
* value list - a `validate` callback is a function, so it is not in the dump at
* all. Sharing the password validator between the two forms silently added the
* 2022-blake3 key check to the node form (the server form had it, the node form
* did not), and nothing in the suite would have noticed either way.
*
* So this drives the validators directly with a fake form context: a
* behavioural test without a browser, since the callbacks only need
* `this.section.formvalue`.
*
* A validator answers `true` or a non-empty message, and that message is a LuCI
* String object (it carries `.format`), so the predicate is `!== true` with a
* non-empty rendering rather than a typeof check.
*
* Usage: node tests/frontend-validators.js <repo-root>
*/
'use strict';
const path = require('path');
const { loadLuciModule } = require('./lib/luci-module.js');
const root = path.resolve(process.argv[2] || '.');
let checks = 0, failures = 0;
function check(what, ok, detail) {
checks++;
if (ok) return;
failures++;
console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`);
}
function isError(result) {
return result !== true && result != null && String(result).length > 0;
}
const hp = loadLuciModule(
path.join(root, 'htdocs/luci-static/resources/homeproxy.js'),
{
baseclass: { extend: (o) => o },
form: { DynamicList: { extend: (o) => o } },
fs: {}, rpc: {}, uci: {}, ui: {}
});
/* A 16 byte key encodes to 24 base64 characters; 32 bytes to 44. These are the
* lengths sing-box insists on for the 2022-blake3 ciphers. */
const KEY_128 = 'AAAAAAAAAAAAAAAAAAAAAA==';
const KEY_256 = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
check('the 16-byte fixture is 24 characters', KEY_128.length === 24, String(KEY_128.length));
check('the 32-byte fixture is 44 characters', KEY_256.length === 44, String(KEY_256.length));
function run(validator, values, section_id) {
const ctx = { section: { formvalue: (_id, name) => values[name] } };
return validator.call(ctx, section_id === undefined ? 'sec1' : section_id, values.password);
}
function ss(method, password) {
return { type: 'shadowsocks', shadowsocks_encrypt_method: method, password: password };
}
/* The two forms pass different protocol sets; use the values from the call
* sites rather than inventing a third list. */
const CLIENT_TYPES = [ 'anytls', 'shadowsocks', 'shadowtls', 'snell', 'trojan' ];
const SERVER_TYPES = [ 'anytls', 'http', 'mixed', 'naive', 'shadowsocks', 'snell', 'socks', 'trojan' ];
for (const [label, types] of [['client', CLIENT_TYPES], ['server', SERVER_TYPES]]) {
const validate = hp.validatePassword(types);
check(`${label}: rejects an empty value for a protocol this side requires`,
isError(run(validate, { type: 'trojan', password: '' })));
check(`${label}: accepts an empty value for a protocol this side does not require`,
run(validate, { type: 'vless', password: '' }) === true);
check(`${label}: no section means no validation`,
run(validate, { type: 'trojan', password: '' }, null) === true);
/* The shadowsocks 'none' cipher legitimately has no password. */
check(`${label}: shadowsocks with the 'none' cipher accepts an empty value`,
run(validate, ss('none', '')) === true);
check(`${label}: shadowsocks with a real cipher rejects an empty value`,
isError(run(validate, ss('aes-128-gcm', ''))));
/* The check the node form was missing until the validator was shared. */
check(`${label}: 2022-blake3-aes-128-gcm rejects the 44 character key`,
isError(run(validate, ss('2022-blake3-aes-128-gcm', KEY_256))));
check(`${label}: 2022-blake3-aes-128-gcm accepts the 24 character key`,
run(validate, ss('2022-blake3-aes-128-gcm', KEY_128)) === true);
check(`${label}: 2022-blake3-aes-256-gcm rejects the 24 character key`,
isError(run(validate, ss('2022-blake3-aes-256-gcm', KEY_128))));
check(`${label}: 2022-blake3-aes-256-gcm accepts the 44 character key`,
run(validate, ss('2022-blake3-aes-256-gcm', KEY_256)) === true);
check(`${label}: 2022-blake3-chacha20-poly1305 uses the 44 character rule`,
run(validate, ss('2022-blake3-chacha20-poly1305', KEY_256)) === true);
check(`${label}: a non-base64 value is rejected for a 2022 cipher`,
isError(run(validate, ss('2022-blake3-aes-128-gcm', 'not base64 at all'))));
}
/* validateBase64Key on its own, since the shared validator delegates to it. */
check('validateBase64Key accepts the correct length', hp.validateBase64Key(24, 'sec1', KEY_128) === true);
check('validateBase64Key rejects the wrong length', isError(hp.validateBase64Key(24, 'sec1', KEY_256)));
check('validateBase64Key ignores an empty value', hp.validateBase64Key(24, 'sec1', '') === true);
check('validateBase64Key ignores a missing section', hp.validateBase64Key(24, null, 'x') === true);
check('validateBase64Key rejects a value with no padding', isError(hp.validateBase64Key(24, 'sec1', 'A'.repeat(24))));
/* validateCertificatePath: the certificate policy has to be the same list the
* backend enforces (CERT_PATH_ROOTS in homeproxy.uc; guard 29 compares them
* textually). A path the UI accepts and the backend drops used to make
* certificate_path null silently, so the TLS listener failed with nothing
* pointing at the path. The `..` rejection matters on its own: a prefix match
* alone accepts /etc/ssl/../shadow, and sing-box opens these files as root. */
check('cert path accepts /etc/homeproxy/certs/',
hp.validateCertificatePath('sec1', '/etc/homeproxy/certs/srv.pem') === true);
check('cert path accepts /etc/acme/',
hp.validateCertificatePath('sec1', '/etc/acme/example.com/cert.pem') === true);
check('cert path accepts /etc/ssl/',
hp.validateCertificatePath('sec1', '/etc/ssl/certs/srv.pem') === true);
check('cert path rejects /etc/passwd',
isError(hp.validateCertificatePath('sec1', '/etc/passwd')));
check('cert path rejects a traversal segment',
isError(hp.validateCertificatePath('sec1', '/etc/ssl/../shadow')));
check('cert path rejects a bare root',
isError(hp.validateCertificatePath('sec1', '/etc/ssl/')));
check('cert path rejects a relative path',
isError(hp.validateCertificatePath('sec1', 'etc/ssl/srv.pem')));
check('cert path ignores an empty value',
hp.validateCertificatePath('sec1', '') === true);
check('cert path ignores a missing section',
hp.validateCertificatePath(null, '/etc/passwd') === true);
console.log(`frontend validators: ${checks} checks, ${failures} failures`);
process.exit(failures ? 1 : 0);
@@ -0,0 +1,409 @@
#!/usr/bin/env node
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Three frontend call sites whose *wiring* - not their body - decides whether
* they work, driven the way LuCI drives them:
*
* 1. client/nodes.js `urltest_nodes.validate` must not dereference a null
* value. It used to re-read the value through `this.section.formvalue()`
* (which answers null when the widget is not in the DOM) and then read
* `.length` on it, so the save aborted with a TypeError instead of the
* "non-empty value" message. The framework already hands the value in:
* getValidator() is L.bind(this.validate, this, section_id), so the widget
* calls validate(value).
*
* 2. client/access.js `_proxy_domain_list.remove` / `_direct_domain_list.remove`
* must read routing_mode from the 'config' section. `this.section` is the
* nested 'control' NamedSection, whose children do not include
* routing_mode, so `formvalue()` returned null and the test was always
* true: a reset wiped the domain list even in custom mode.
*
* 3. view/homeproxy/status.js `getRuntimeLog` must receive each log's
* basename. It used to derive it with `o.option.split('_')[1]`; it is now
* the third bound argument, ahead of the framework's
* (option_index, section_id, in_table). Shift that by one and the switch
* stops matching, so the log-level select disappears and the wrong
* basename reaches fs.read_direct and log_clean.
*
* The mocks cover only the surface each module touches; the plumbing under
* test is the value/argument wiring.
*
* NOTE: tests/run.sh enumerates its node suites explicitly and is owned by
* another change, so this file is not in that list yet. Run it directly:
*
* node tests/frontend-view-wiring.js <repo-root>
*/
'use strict';
const path = require('path');
const { loadLuciModule } = require('./lib/luci-module.js');
const root = path.resolve(process.argv[2] || '.');
/* LuCI extends String with format() (luci.js); status.js builds the log path
* with it. The shared loader does not inject it, so provide it here exactly as
* tests/luci-form-snapshot.js does. */
if (typeof String.prototype.format !== 'function') {
String.prototype.format = function (...args) {
const values = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
let i = 0;
return this.replace(/%[sdj%]/g, (m) => (m === '%%' ? '%' : (i < values.length ? String(values[i++]) : m)));
};
}
/* cbi.js defines the static form as `''.format.apply(fmt, args)`; status.js
* builds the log path with it. */
if (typeof String.format !== 'function')
String.format = function (fmt, ...args) { return String(fmt).format(...args); };
let checks = 0, failures = 0;
function check(what, ok, detail) {
checks++;
if (ok) return;
failures++;
console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`);
}
/* --- a form mock that records what the views build --------------------- */
class MockOption {
constructor(kind, name, title, description) {
this.__kind = kind && kind.__name__ ? kind.__name__ : String(kind);
this.__name = name;
this.option = name;
this.title = title;
this.description = description;
if (this.__kind === 'SectionValue')
this.subsection = new MockSection('NamedSection', name);
}
depends() {}
value() {}
cbid(section_id) { return `cbid.homeproxy.${section_id}.${this.__name}`; }
}
class MockSection {
constructor(kind, name) {
this.__kind = kind;
this.__name = name;
this.options = [];
}
tab() {}
taboption(_tab, ...args) { return this.option(...args); }
option(kind, name, title, description) {
const o = new MockOption(kind, name, title, description);
o.section = this;
this.options.push(o);
return o;
}
/* form.js AbstractSection.formvalue() walks the *children* of this
* section and answers null for anything else - which is why
* `this.section.formvalue('config', 'routing_mode')` from inside the
* nested 'control' section never found routing_mode. */
formvalue(_section_id, option) {
for (const child of this.options)
if (child.__name === option)
return child.value;
return null;
}
}
function makeForm() {
const form = {};
for (const name of [ 'Value', 'ListValue', 'Flag', 'DynamicList', 'MultiValue', 'TextValue',
'DummyValue', 'Button', 'TypedSection', 'NamedSection', 'GridSection', 'SectionValue' ]) {
form[name] = class {};
/* form.js names each CBI class on `__name__`; the views use it to
* tell a SectionValue (which owns a sub-section) from the rest. */
form[name].__name__ = name;
}
form.Map = class {
constructor(title) { this.title = title; this.sections = []; }
section(kind, name) {
const section = new MockSection(kind && kind.__name__ ? kind.__name__ : String(kind), name);
this.sections.push(section);
return section;
}
render() { return this; }
};
return form;
}
function findOption(section, name) {
for (const o of section.options)
if (o.__name === name)
return o;
return null;
}
const L = { bind: (fn, self, ...bound) => fn.bind(self, ...bound) };
/* ====================================================================== *
* 1. nodes.js: the urltest_nodes validator is null-safe
* ====================================================================== */
function testNodesValidator() {
const form = makeForm();
const nodes = loadLuciModule(
path.join(root, 'htdocs/luci-static/resources/view/homeproxy/client/nodes.js'),
{
baseclass: { extend: (o) => o },
form,
uci: { sections: () => {} },
homeproxy: {
CBIStaticList: class {},
loadModalTitle: () => '',
loadDefaultLabel: () => '',
validateUniqueValue: () => true,
renderSectionAdd: () => null
},
'tools.widgets': { DeviceSelect: class {} }
},
{ L, E: () => null });
const ctx = { s: new MockSection('TypedSection', undefined), proxy_nodes: {} };
nodes.renderRoutingNodes(ctx);
const section = ctx.s.options[0].subsection;
const validate = findOption(section, 'urltest_nodes')?.validate;
check('nodes.js: urltest_nodes has a validate callback', typeof validate === 'function', typeof validate);
if (typeof validate !== 'function')
return;
/* Before the fix this re-read `this.section.formvalue()` - null here, since
* the widget is not in the DOM - and threw on `.length`. */
let threw = null;
try {
check('nodes.js: a missing value passes without throwing',
validate.call({ section: { formvalue: () => null } }, 'sec1', null) === true);
}
catch (e) {
threw = e;
}
check('nodes.js: a null value does not throw', threw === null, threw && threw.message);
check('nodes.js: an empty list is rejected',
validate.call({}, 'sec1', []) !== true && String(validate.call({}, 'sec1', [])).length > 0);
check('nodes.js: a non-empty list passes',
validate.call({}, 'sec1', [ 'node1' ]) === true);
check('nodes.js: no section means no validation',
validate.call({}, null, []) === true);
}
/* ====================================================================== *
* 2. access.js: the domain-list resets read routing_mode from 'config'
* ====================================================================== */
function testAccessDomainLists() {
const form = makeForm();
const writes = [];
/* The module closes over its own `uci` dependency, so the per-case values
* go into a mutable stub rather than a captured snapshot. */
const uciStub = { get: () => undefined };
const access = loadLuciModule(
path.join(root, 'htdocs/luci-static/resources/view/homeproxy/client/access.js'),
{
baseclass: { extend: (o) => o },
form,
uci: uciStub,
homeproxy: {
rpcCall: (method, args) => {
writes.push({ method, args });
return Promise.resolve({ result: true });
}
},
'tools.widgets': { DeviceSelect: class {} },
'tools.firewall': {
addIPOption: (ss, _tab, name) => ss.option(form.Value, name),
addMACOption: (ss, _tab, name) => ss.option(form.Value, name)
}
},
{ L, E: () => null });
const run = (routing_mode, option_name) => {
writes.length = 0;
uciStub.get = (_cfg, _section, option) => (option === 'routing_mode' ? routing_mode : undefined);
const ctx = {
s: new MockSection('NamedSection', 'config'),
hosts: {},
stubValidator: { apply: () => true }
};
access.render(ctx);
const control = ctx.s.options.find((o) => o.__name === '_control').subsection;
const so = findOption(control, option_name);
check(`access.js: ${option_name} exists`, so !== null, String(so));
if (!so)
return Promise.resolve([]);
/* rpcCall() runs synchronously inside remove(), so the snapshot has
* to be taken here: the four cases are started in the same tick and a
* shared array read from .then() would see all of them. */
const ret = so.remove.call(so);
const snapshot = writes.slice();
return Promise.resolve(ret).then(() => snapshot);
};
const cleared = (w, list) =>
w.length === 1 && w[0].method === 'acllist_write' && w[0].args[0] === list && w[0].args[1] === '';
return Promise.all([
run('custom', '_proxy_domain_list').then((w) =>
check('access.js: custom mode keeps the proxy list', w.length === 0, JSON.stringify(w))),
run('global', '_proxy_domain_list').then((w) =>
check('access.js: non-custom mode clears the proxy list', cleared(w, 'proxy_list'), JSON.stringify(w))),
run('custom', '_direct_domain_list').then((w) =>
check('access.js: custom mode keeps the direct list', w.length === 0, JSON.stringify(w))),
run('global', '_direct_domain_list').then((w) =>
check('access.js: non-custom mode clears the direct list', cleared(w, 'direct_list'), JSON.stringify(w)))
]);
}
/* ====================================================================== *
* 3. status.js: each log view keeps its own basename
* ====================================================================== */
function E(tag, attrs, children) {
if (Array.isArray(tag) && attrs === undefined)
return { tag: null, attrs: {}, children: tag, appendChild() {} };
return {
tag,
attrs: attrs || {},
children: children === undefined ? [] : (Array.isArray(children) ? children : [ children ]),
appendChild() {}
};
}
function findClickable(node) {
if (!node || typeof node !== 'object')
return null;
if (node.attrs && typeof node.attrs.click === 'function')
return node;
for (const child of node.children || []) {
const hit = findClickable(child);
if (hit) return hit;
}
return null;
}
function findTag(node, tag) {
if (!node || typeof node !== 'object')
return null;
if (node.tag === tag)
return node;
for (const child of node.children || []) {
const hit = findTag(child, tag);
if (hit) return hit;
}
return null;
}
function testLogViewWiring() {
const form = makeForm();
const reads = [];
const rpcCalls = [];
let pollHandlers = null;
const status = loadLuciModule(
path.join(root, 'htdocs/luci-static/resources/view/homeproxy/status.js'),
{
baseclass: {},
dom: { content: () => {} },
form,
fs: { read_direct: (p) => { reads.push(p); return Promise.resolve('log line'); } },
poll: { add: () => {} },
uci: { get: () => 'warn' },
ui: {
createHandlerFn: (_self, fn) => fn,
addNotification: () => {},
changes: { apply: () => {} },
showModal: () => {},
hideModal: () => {}
},
view: { extend: (o) => o },
homeproxy: {
statusPoller: (handlers) => { pollHandlers = handlers; return () => {}; },
rpcCall: (method, args) => { rpcCalls.push({ method, args }); return Promise.resolve({}); }
}
},
{ L: Object.assign({}, L, { resource: () => 'icons/loading.svg', env: { pollinterval: 5 } }), E });
const map = status.render();
const section = map.sections[map.sections.length - 1];
const wanted = [
[ '_homeproxy_logview', 'homeproxy' ],
[ '_sing-box-c_logview', 'sing-box-c' ],
[ '_sing-box-s_logview', 'sing-box-s' ]
];
let rendered = 0;
for (const [ option_name, basename ] of wanted) {
const o = findOption(section, option_name);
check(`status.js: ${option_name} exists`, o !== null, String(o));
if (!o)
continue;
rpcCalls.length = 0;
/* form.js calls render(option_index, section_id, in_table). */
const tree = o.render(0, 'cfg01', false);
rendered++;
/* log_clean must target the same basename this view reads. */
const button = findClickable(tree);
check(`status.js: ${option_name} has a clean button`, button !== null, 'no click handler in the tree');
if (button) {
Promise.resolve(button.attrs.click());
check(`status.js: ${option_name} cleans '${basename}'`,
rpcCalls.length === 1 && rpcCalls[0].method === 'log_clean' && rpcCalls[0].args[0] === basename,
JSON.stringify(rpcCalls));
}
/* The log-level select is built inside a switch on the basename: a
* shifted argument turns it into a label, no case matches, and the
* select disappears. */
const select = findTag(tree, 'select');
const want_select = basename !== 'homeproxy';
check(`status.js: ${option_name} log-level select ${want_select ? 'present' : 'absent'}`,
(select !== null) === want_select, String(select));
if (select)
check(`status.js: ${option_name} select carries section_id`,
String(select.attrs.id || '').includes('cfg01'), String(select.attrs.id));
}
check('status.js: all three log views rendered', rendered === 3, String(rendered));
check('status.js: the log poll was registered', pollHandlers !== null && typeof pollHandlers.read === 'function');
if (!pollHandlers)
return Promise.resolve();
/* One tick reads one file per registered target, keyed by basename. */
return Promise.resolve(pollHandlers.read()).then(() => {
check('status.js: the poll reads each log by basename',
JSON.stringify(reads.slice().sort()) === JSON.stringify([
'/var/run/homeproxy/homeproxy.log',
'/var/run/homeproxy/sing-box-c.log',
'/var/run/homeproxy/sing-box-s.log'
].sort()),
JSON.stringify(reads));
});
}
testNodesValidator();
testAccessDomainLists()
.then(testLogViewWiring)
.then(() => {
console.log(`frontend view wiring: ${checks} checks, ${failures} failures`);
process.exit(failures ? 1 : 0);
})
.catch((e) => {
console.error('FAIL frontend view wiring threw:', (e && e.stack) || e);
process.exit(1);
});
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2025 ImmortalWrt.org
#
# Report how much of the gettext template (po/templates/homeproxy.pot) is
# translated in po/zh_Hans/homeproxy.po. Missing, empty and fuzzy entries all
# count as untranslated. The check only warns by default so it does not block
# a build; pass --fail-below to turn the warning into a failure.
#
# A template that parses to fewer than MIN_TOTAL non-ignored entries is a hard
# failure regardless of flags: an empty or truncated .pot makes every
# percentage meaningless, and a coverage number must not be allowed to report
# 100% for a translation set that covers nothing.
#
# Two defects are hard failures whatever the flags say, because neither shows
# up in the percentage: a msgstr whose placeholders do not match its msgid, and
# a msgstr that repeats a 20+ character run of itself.
#
# Usage:
# tests/i18n-coverage.py # warn below 95%
# tests/i18n-coverage.py --warn-below 90
# tests/i18n-coverage.py --fail-below 95
# tests/i18n-coverage.py --list 20
import argparse
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEMPLATE = os.path.join(ROOT, "po/templates/homeproxy.pot")
TRANSLATION = os.path.join(ROOT, "po/zh_Hans/homeproxy.po")
IGNORE = os.path.join(ROOT, "tests/i18n-ignore.txt")
# The .pot is generated from the shipped sources and currently parses to 737
# non-empty msgid entries, of which tests/i18n-ignore.txt exempts enough to
# leave 729 checked. 400 is a deliberately low floor - a bit over half the
# current corpus - chosen to catch an empty or truncated template without
# failing on a legitimate future removal of some strings. 0 must never be
# accepted: `translated / total` used to fall back to 100% when total was 0,
# so a wiped .pot passed both --warn-below 100 and --fail-below 100.
MIN_TOTAL = 400
# A translation that says the same thing twice (the TUIC entry shipped with the
# whole sentence in two half-translations) is invisible to the coverage
# percentage - the msgstr is neither empty nor fuzzy - and to the placeholder
# check - the conversions still match. Any run of DUPLICATE_MIN characters
# that occurs twice inside one msgstr fails the check, whatever --fail-below
# says.
#
# Exclusion rule (why the markup-heavy entries in this catalogue do not trip
# it): LuCI strings mix prose with HTML, so a *correct* translation can repeat
# a structural run many times, e.g. the default-rule entry carries
# "</code> &&<br/><code>(port || port_range)" three times over. Those tags are
# template scaffolding, not translated wording, so every <...> tag is stripped
# before the scan. A duplicated clause keeps far more than 20 characters of
# real text after stripping, while the longest structural run above collapses
# to ") &&(" - below the threshold. Without the strip that entry would be a
# false positive.
DUPLICATE_MIN = 20
TAG_RE = re.compile(r"<[^>]*>")
def load_ignore(path):
"""msgids that are deliberately not translated (technical tokens)."""
if not os.path.exists(path):
return set()
ignored = set()
with open(path, encoding="utf-8") as handle:
for raw in handle:
line = raw.strip()
if line and not line.startswith("#"):
ignored.add(line)
return ignored
def unquote(value):
value = value.strip()
if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
return value[1:-1]
return value
def parse_po(path):
"""Return {msgid: {'msgstr': str, 'fuzzy': bool}} for non-empty msgids."""
entries = {}
msgid = None
msgstr = ""
state = None
pending_fuzzy = False
entry_fuzzy = False
def flush():
if msgid:
entries[msgid] = {"msgstr": msgstr, "fuzzy": entry_fuzzy}
with open(path, encoding="utf-8") as handle:
for raw in handle:
line = raw.rstrip("\n")
if line.startswith("#,"):
if "fuzzy" in line:
pending_fuzzy = True
continue
if line.startswith("#"):
continue
if line.startswith("msgid "):
flush()
msgid = unquote(line[len("msgid "):])
msgstr = ""
entry_fuzzy = pending_fuzzy
pending_fuzzy = False
state = "msgid"
elif line.startswith("msgstr "):
msgstr = unquote(line[len("msgstr "):])
state = "msgstr"
elif line.startswith('"'):
if state == "msgid":
msgid += unquote(line)
elif state == "msgstr":
msgstr += unquote(line)
flush()
return entries
def placeholder_counts(text):
"""How many %s/%d/%j conversions a string carries.
LuCI's String.format() silently substitutes an empty string for a missing
argument, so a translation with extra placeholders renders an empty
<code></code> and one with too few drops whatever the source interpolated.
Neither shows up in the coverage percentage - only the presence and the
completeness of the msgstr are checked - which is how two zh_Hans entries
shipped with four placeholders against a two-placeholder msgid."""
return len(re.findall(r"%(?:[sdj])", text))
def find_repeats(text, minimum=DUPLICATE_MIN):
"""Repeated runs of >= minimum characters inside one translated string.
Returns a list of (start, length, fragment) triplets, longest run first,
with overlapping reports of the same run collapsed into one. HTML tags are
stripped first (see DUPLICATE_MIN above for why)."""
text = TAG_RE.sub("", text)
length = len(text)
if length < minimum * 2:
return []
hits = []
seen = {}
for index in range(length - minimum + 1):
gram = text[index:index + minimum]
first = seen.setdefault(gram, index)
if first == index:
continue
# Grow the match in both directions so the reported fragment is the
# maximal repeated run rather than the first 20-character window.
end = minimum
while (index + end < length and first + end < length
and text[first + end] == text[index + end]):
end += 1
back = 0
while (index - back > 0 and first - back > 0
and text[first - back - 1] == text[index - back - 1]):
back += 1
hits.append((first - back, index - back, end + back,
text[first - back:first - back + end + back]))
hits.sort(key=lambda hit: (-hit[2], hit[0], hit[1]))
kept = []
for hit in hits:
if any(hit[0] >= other[0] and hit[0] < other[0] + other[2] for other in kept):
continue
kept.append(hit)
return kept
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--warn-below", type=float, default=100.0,
help="warn when coverage is below this percentage (default: 100)")
parser.add_argument("--fail-below", type=float, default=None,
help="exit non-zero when coverage is below this percentage")
parser.add_argument("--list", type=int, default=10,
help="list at most this many untranslated entries (default: 10)")
args = parser.parse_args()
template = parse_po(TEMPLATE)
translation = parse_po(TRANSLATION)
ignored = load_ignore(IGNORE)
total = 0
untranslated = []
for msgid in template:
if msgid in ignored:
continue
total += 1
entry = translation.get(msgid)
if not entry or entry["fuzzy"] or not entry["msgstr"].strip():
untranslated.append(msgid)
# A translated string has to keep the msgid's placeholders: the count, not
# the letters around them. Checked on its own so a bad translation cannot
# hide behind a 100% coverage number.
mismatched = []
for msgid, entry in translation.items():
if msgid not in template or entry["fuzzy"] or not entry["msgstr"].strip():
continue
want, got = placeholder_counts(msgid), placeholder_counts(entry["msgstr"])
if want != got:
mismatched.append((msgid, want, got))
translated = total - len(untranslated)
coverage = (translated / total * 100) if total else 100.0
print(f"zh_Hans translation coverage: {translated}/{total} ({coverage:.1f}%)"
f", {len(ignored)} msgid(s) ignored by tests/i18n-ignore.txt")
if mismatched:
print(f"::error title=Translation placeholders::{len(mismatched)} translated"
f" string(s) do not carry the same number of %s/%d/%j conversions as"
f" their msgid - LuCI's format() renders an empty fragment for a"
f" missing argument and drops the extra ones")
for msgid, want, got in mismatched[:args.list]:
print(f" - msgid has {want}, msgstr has {got}: {msgid}")
return 1
# Same idea as the placeholder check: a msgstr that repeats itself counts
# as translated, so only this scan can catch it. Hard failure, not tied to
# --fail-below.
duplicated = []
for msgid, entry in translation.items():
if entry["fuzzy"] or not entry["msgstr"].strip():
continue
for _start, length, _second, fragment in find_repeats(entry["msgstr"]):
duplicated.append((msgid, length, fragment))
if duplicated:
print(f"::error title=Duplicate translation::{len(duplicated)} msgstr(s)"
f" repeat a run of {DUPLICATE_MIN}+ characters inside themselves -"
f" the entry looks translated but ships the same wording twice")
for msgid, length, fragment in duplicated[:args.list]:
print(f" - {length} chars repeated: {fragment!r} in: {msgid}")
return 1
# A denominator below the floor means the template is empty or truncated,
# not that the translation is perfect. Fail hard whatever the flags say;
# this is a template-integrity check, not a coverage threshold.
if total < MIN_TOTAL:
print(f"::error title=Translation coverage::the gettext template parsed"
f" to only {total} non-ignored msgid(s), below the {MIN_TOTAL}"
f" floor - the .pot is empty or truncated, so {coverage:.1f}%"
f" coverage is meaningless")
return 1
if untranslated and args.list:
print(f"{len(untranslated)} untranslated entries, first {min(args.list, len(untranslated))}:")
for msgid in untranslated[:args.list]:
print(f" - {msgid}")
summary = os.environ.get("GITHUB_STEP_SUMMARY")
if summary:
with open(summary, "a", encoding="utf-8") as handle:
handle.write("## zh_Hans translation coverage\n\n")
handle.write(f"**{translated}/{total} ({coverage:.1f}%)** — "
f"{len(untranslated)} untranslated entry/entries.\n\n")
if untranslated:
handle.write("<details><summary>Untranslated</summary>\n\n")
for msgid in untranslated:
handle.write(f"- `{msgid}`\n")
handle.write("\n</details>\n")
if args.fail_below is not None and coverage < args.fail_below:
print(f"::error title=Translation coverage::zh_Hans coverage {coverage:.1f}% "
f"is below {args.fail_below}%")
return 1
if coverage < args.warn_below:
# --warn-below is advisory on purpose (build.yml records the number
# instead of gating a release on it), so the message must say out loud
# that this did not fail anything; otherwise a red annotation reads
# like a gate that the exit status contradicts.
print(f"::warning title=Translation coverage::zh_Hans coverage {coverage:.1f}% "
f"is below {args.warn_below}% ({len(untranslated)} untranslated entry/entries)"
f" - advisory only, this did NOT fail the build; use --fail-below"
f" {args.warn_below:g} to make it a gate")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,13 @@
# msgids that are intentionally left untranslated (protocol/technical tokens
# whose zh_Hans form is the same as the source). One msgid per line, matching
# the unquoted template string; '#' starts a comment.
http
unsafe-raw
unshaped
v4
v5
v6
wrong-ack
wrong-checksum
wrong-md5
wrong-timestamp
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env node
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* The package's JSON and UCI assets.
*
* Nothing validated any of these. A malformed acl.d file makes rpcd refuse the
* whole ACL, so every RPC the app makes is denied - and the browser shows only
* a failed request. A menu.d action pointing at a view that was renamed drops
* the page out of the menu. Neither is visible to any other test, because the
* form tests render the views directly and never go through the menu or the
* ACL at all.
*
* Checked here:
* - every .json under root/ parses;
* - menu.d entries carry title/order/action, and a `view` action names a
* view file that exists;
* - the ACL declares the same name menu.d depends on, has read and write
* sections, lists only string method names, and contains no wildcard;
* - capabilities/homeproxy.json has all five sets, with identical contents
* (they describe one privilege set, not five);
* - etc/config/homeproxy is valid UCI: every significant line is a config,
* option or list statement with a balanced quoted value.
*
* Usage: node tests/json-assets.js <repo-root>
*/
'use strict';
const fs = require('fs');
const path = require('path');
const root = path.resolve(process.argv[2] || '.');
let checks = 0, failures = 0;
function check(what, ok, detail) {
checks++;
if (ok) return;
failures++;
console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`);
}
function walk(dir, out) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p, out);
else out.push(p);
}
return out;
}
/* --- every JSON asset parses -------------------------------------------- */
const jsonFiles = walk(path.join(root, 'root'), []).filter((f) => f.endsWith('.json'));
check('found the JSON assets', jsonFiles.length >= 3, `${jsonFiles.length} found`);
const parsed = {};
for (const f of jsonFiles) {
const rel = path.relative(root, f);
try {
parsed[rel] = JSON.parse(fs.readFileSync(f, 'utf8'));
check(`${rel} parses`, true);
}
catch (e) {
check(`${rel} parses`, false, e.message);
}
}
/* --- menu.d ------------------------------------------------------------- */
const MENU = 'root/usr/share/luci/menu.d/luci-app-homeproxy.json';
const menu = parsed[MENU];
if (!menu) {
check(`${MENU} is usable`, false, 'missing or unparseable');
}
else {
const entries = Object.entries(menu);
check('menu.d has entries', entries.length > 0, `${entries.length}`);
for (const [key, e] of entries) {
check(`menu '${key}' has a title`, typeof e.title === 'string' && e.title.length > 0);
check(`menu '${key}' has a numeric order`, typeof e.order === 'number');
check(`menu '${key}' has an action`, e.action && typeof e.action === 'object');
if (e.action && e.action.type === 'view') {
const view = path.join(root, 'htdocs/luci-static/resources/view', e.action.path + '.js');
check(`menu '${key}' points at an existing view`, fs.existsSync(view),
path.relative(root, view));
}
}
}
/* --- the ACL ------------------------------------------------------------ */
const ACL = 'root/usr/share/rpcd/acl.d/luci-app-homeproxy.json';
const acl = parsed[ACL];
if (!acl) {
check(`${ACL} is usable`, false, 'missing or unparseable');
}
else {
const names = Object.keys(acl);
check('the ACL declares exactly one grant', names.length === 1, names.join(','));
/* menu.d gates the page on an ACL name; if they disagree the pages vanish
* for a non-root user even though the ACL itself is fine. */
const dependsAcl = menu && menu['admin/services/homeproxy']?.depends?.acl;
if (Array.isArray(dependsAcl)) {
for (const d of dependsAcl)
check(`menu.d's depends.acl '${d}' exists in acl.d`, names.indexOf(d) !== -1,
names.join(','));
}
for (const name of names) {
const g = acl[name];
check(`acl '${name}' has a description`, typeof g.description === 'string');
check(`acl '${name}' has a read section`, g.read && typeof g.read === 'object');
check(`acl '${name}' has a write section`, g.write && typeof g.write === 'object');
for (const side of ['read', 'write']) {
const ubus = g[side]?.ubus;
if (!ubus) continue;
for (const [obj, methods] of Object.entries(ubus)) {
check(`acl ${side}.ubus.${obj} is an array`, Array.isArray(methods));
if (!Array.isArray(methods)) continue;
for (const m of methods) {
check(`acl ${side}.ubus.${obj} lists string methods`,
typeof m === 'string' && m.length > 0, JSON.stringify(m));
check(`acl ${side}.ubus.${obj} has no wildcard method`, m !== '*', m);
}
check(`acl ${side}.ubus.${obj} has no duplicate methods`,
new Set(methods).size === methods.length, methods.join(','));
}
}
}
}
/* --- capabilities ------------------------------------------------------- */
const CAPS = 'root/etc/capabilities/homeproxy.json';
const caps = parsed[CAPS];
if (!caps) {
check(`${CAPS} is usable`, false, 'missing or unparseable');
}
else {
const sets = ['bounding', 'effective', 'ambient', 'permitted', 'inheritable'];
for (const s of sets)
check(`capabilities has '${s}'`, Array.isArray(caps[s]), typeof caps[s]);
/* The four non-inheritable sets (bounding / effective / ambient / permitted)
* describe one privilege set; a capability in `bounding` but not in
* `effective` is silently dropped by procd, which is the kind of thing
* nobody notices until the service cannot bind. `inheritable` is
* allowed to be empty (and indeed should be): the review H2 hardened it
* to `[]` because nothing the orchestrator spawns needs to inherit caps,
* and an empty inheritable prevents accidental propagation to children. */
const present = sets.filter((s) => Array.isArray(caps[s]));
if (present.length > 1) {
const nonInheritable = present.filter((s) => s !== 'inheritable');
if (nonInheritable.length > 1) {
const first = JSON.stringify([...caps[nonInheritable[0]]].sort());
for (const s of nonInheritable.slice(1))
check(`capabilities '${s}' matches '${nonInheritable[0]}'`,
JSON.stringify([...caps[s]].sort()) === first,
`${JSON.stringify(caps[s])} vs ${JSON.stringify(caps[nonInheritable[0]])}`);
}
/* inheritable stays independent of the other four - assert only that
* it is well-formed (already done by the has-set check above). */
}
}
/* --- the factory UCI config --------------------------------------------- */
const CFG = 'root/etc/config/homeproxy';
const cfgLines = fs.readFileSync(path.join(root, CFG), 'utf8').split('\n');
let cfgStatements = 0;
cfgLines.forEach((line, i) => {
const t = line.replace(/\s+$/, '');
if (/^\s*$/.test(t) || /^\s*#/.test(t)) return;
cfgStatements++;
/* config <type> [<name>] | option <key> <value> | list <key> <value> */
const m = /^\s*(config|option|list)\s+(\S+)(?:\s+(.*))?$/.exec(t);
if (!m) {
check(`${CFG}:${i + 1} is a valid UCI statement`, false, JSON.stringify(t));
return;
}
if (m[1] === 'config') {
if (m[3] && !/^'[^']*'$/.test(m[3]))
check(`${CFG}:${i + 1} section name is single-quoted`, false, m[3]);
return;
}
if (!m[3] || !/^'[^']*'$/.test(m[3]))
check(`${CFG}:${i + 1} value is single-quoted`, false, JSON.stringify(m[3]));
});
check(`${CFG} has statements`, cfgStatements > 0, `${cfgStatements}`);
check(`${CFG} declares the main section`,
/^\s*config\s+\S+\s+'config'/m.test(cfgLines.join('\n')));
console.log(`json assets: ${checks} checks, ${failures} failures`);
process.exit(failures ? 1 : 0);
@@ -0,0 +1,51 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Load a LuCI frontend module outside LuCI.
*
* `'require x as y';` is LuCI's own directive, not JavaScript, so it is
* rewritten into a dependency lookup before the file is evaluated. The
* snapshot renderer and the frontend invariant tests all need this, and there
* is no reason for them to each carry their own copy - the snapshot script had
* one, and the first invariant test copy-pasted it.
*
* `runtime` supplies the `_` / `E` / `L` globals the module expects; the
* defaults are inert, which is enough for the tests that only read the
* module's data (a table, a validator) rather than render it.
*/
'use strict';
const fs = require('fs');
function loadLuciModule(file, deps, runtime) {
let src = fs.readFileSync(file, 'utf8');
src = src.replace(/^'require ([^']+) as (\w+)';$/gm,
(_m, mod, alias) => `const ${alias} = __deps[${JSON.stringify(mod)}];`);
src = src.replace(/^'require ([^']+)';$/gm,
(_m, mod) => `const ${mod.split('.').pop().replace(/[^\w]/g, '_')} = __deps[${JSON.stringify(mod)}];`);
runtime = runtime || {};
const E = runtime.E || (() => null);
const L = runtime.L || {};
/* LuCI's `_()` returns a String object carrying a `format()` method, and
* the form code uses both halves (`_('Expecting: %s').format(x)`). A
* plain-string stand-in throws on the second half, so the default has to
* be a String object too. */
const _ = runtime._ || ((s) => {
const out = new String(s);
out.format = function(...args) {
let i = 0;
return String(this).replace(/%[sdj%]/g, (m) =>
m === '%%' ? '%' : (i < args.length ? String(args[i++]) : m));
};
return out;
});
/* eslint-disable-next-line no-new-func */
const factory = new Function('__deps', '_', 'E', 'L', src);
return factory(deps, _, E, L);
}
module.exports = { loadLuciModule };
@@ -0,0 +1,352 @@
#!/usr/bin/env node
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*
* Dump the LuCI form definition of the homeproxy node/client/server views as
* JSON, using a minimal mock of the LuCI client runtime. It is meant to be run
* against two checkouts (e.g. HEAD and the working tree) and the resulting
* snapshots diffed, so that a pure refactor can be proven not to change any
* option name, dependency, default or label.
*
* Usage: node tests/luci-form-snapshot.js <repo-root> [node|client|server]
*/
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
/* --- Function fingerprints --------------------------------------------- *
* `props` used to drop every function-valued property, which made the 36
* `o.validate = ...` assignments in the views invisible: deleting one, or
* pointing it at a different function, produced a byte-identical snapshot.
*
* Record a digest of the function body instead of its source text, so a
* comment or an indentation change is not noise. The digest is
* `<length>:<sha1-12>` of the comment- and whitespace-normalised source.
*
* L.bind() returns a native-code bound function whose toString() is
* `function () { [native code] }` for every bound function, so six of the
* validators would be indistinguishable. L.bind() below therefore remembers
* the target and the stable bound arguments, and the fingerprint folds them
* in: `bind(<target>;arg,arg)`.
*/
const bindSources = new WeakMap();
function describeArg(value) {
if (value === null)
return 'null';
if (value === undefined)
return 'undefined';
const kind = typeof value;
if (kind === 'string')
return JSON.stringify(value);
if (kind === 'number' || kind === 'boolean')
return String(value);
return `<${kind}>`;
}
/* Remove // and /* *\/ comments without touching string/template literals.
* Regex literals are left alone: an unescaped '/' cannot appear in one. */
function stripComments(src) {
let out = '';
let quote = null;
for (let i = 0; i < src.length; i++) {
const c = src[i], d = src[i + 1];
if (quote) {
out += c;
if (c === '\\') { out += d === undefined ? '' : d; i++; }
else if (c === quote) quote = null;
continue;
}
if (c === '"' || c === "'" || c === '`') { quote = c; out += c; continue; }
if (c === '/' && d === '/') {
while (i < src.length && src[i] !== '\n') i++;
out += '\n';
continue;
}
if (c === '/' && d === '*') {
i += 2;
while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++;
i++;
out += ' ';
continue;
}
out += c;
}
return out;
}
function fnFingerprint(fn) {
const bound = bindSources.get(fn);
const src = stripComments(String(bound ? bound.target : fn)).replace(/\s+/g, ' ').trim();
const digest = crypto.createHash('sha1').update(src).digest('hex').slice(0, 12);
if (bound)
return `bind(${src.length}:${digest};${bound.args.join(',')})`;
return `fn(${src.length}:${digest})`;
}
/* --- LuCI runtime mock start ------------------------------------------- */
String.prototype.format = function (...args) {
const values = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
let i = 0;
return this.replace(/%[sdj%]/g, (m) => m === '%%' ? '%' : (i < values.length ? String(values[i++]) : m));
};
function E(tag, attrs, children) {
return { tag: tag, attrs: attrs, children: children };
}
function _(text) { return text; }
const L = {
bind: (fn, self, ...args) => {
const bound = fn.bind(self, ...args);
if (typeof fn === 'function')
bindSources.set(bound, { target: fn, args: args.map(describeArg) });
return bound;
},
resolveDefault: (promise, fallback) => promise.catch(() => fallback),
ui: { hideModal: () => {}, addNotification: () => {}, changes: { apply: () => {} } },
env: { paths: {} }
};
let sectionSeq = 0;
class Option {
constructor(kind, name, title, description) {
this.__kind = kind;
this.__name = name;
this.__title = title;
this.__description = description;
this.__depends = [];
this.__values = [];
}
depends(...args) { this.__depends.push(args.length === 1 ? args[0] : args); }
value(value, label) { this.__values.push([ value, label === undefined ? null : label ]); }
toJSON() {
const props = {};
for (const key of Object.keys(this).sort()) {
if (key.startsWith('__') || key === 'subsection')
continue;
const value = this[key];
if (value === undefined)
continue;
/* A function-valued prop (validate, load, onclick, ...) gets a
stable digest rather than being dropped. */
if (typeof value === 'function') {
props[key] = fnFingerprint(value);
continue;
}
props[key] = value;
}
return {
kind: this.__kind,
name: this.__name,
title: this.__title,
description: this.__description === undefined ? null : this.__description,
depends: this.__depends,
values: this.__values,
/* A SectionValue option owns the nested form (`o.subsection`).
Skipping it dropped the node form's entire body - every
per-protocol option - and client.js's rule sections, which is
why node.json held 14 options while server.json held 97. */
subsection: this.subsection ? this.subsection.toJSON() : null,
props: props
};
}
}
class Section {
constructor(kind, name) {
this.__kind = kind;
this.__name = name;
this.__id = `${kind}:${name || ''}#${sectionSeq++}`;
this.__options = [];
this.__tabs = [];
if (kind === 'SectionValue')
this.subsection = new Section('GridSection', name);
}
option(kind, name, title, description) {
const kindName = kind && kind.__name__ ? kind.__name__ : String(kind);
const option = new Option(kindName, name, title, description);
if (kindName === 'SectionValue')
option.subsection = new Section('GridSection', name);
this.__options.push(option);
return option;
}
tab(id, title) { this.__tabs.push([ id, title ]); }
taboption(_tab, ...args) { return this.option(...args); }
toJSON() {
return {
id: this.__id,
kind: this.__kind,
name: this.__name,
tabs: this.__tabs,
options: this.__options.map((o) => o.toJSON()),
subsection: this.subsection ? this.subsection.toJSON() : null
};
}
}
const classes = [ 'Value', 'ListValue', 'Flag', 'DynamicList', 'MultiValue', 'TextValue', 'Button', 'TypedSection',
'NamedSection', 'GridSection', 'SectionValue' ];
const form = {};
for (const name of classes) {
form[name] = class {};
form[name].__name__ = name;
}
form.DynamicList.extend = function (obj) {
const cls = class extends form.DynamicList {};
Object.assign(cls.prototype, obj);
cls.__name__ = obj.__name__ || 'DynamicList';
return cls;
};
form.Value.extend = function (obj) {
const cls = class extends form.Value {};
Object.assign(cls.prototype, obj);
cls.__name__ = obj.__name__ || 'Value';
return cls;
};
form.Map = class {
constructor(title) { this.title = title; this.sections = []; }
section(kind, name) {
const section = new Section(kind && kind.__name__ ? kind.__name__ : String(kind), name);
this.sections.push(section);
return section;
}
render() { return { sections: this.sections.map((s) => s.toJSON()) }; }
};
const uci = {
load: () => Promise.resolve(),
get: () => undefined,
get_first: () => null,
get_all: () => ({}),
sections: () => {},
foreach: () => {},
set: () => {}, add: () => 'section', remove: () => {}, save: () => Promise.resolve()
};
const ui = {
addNotification: () => {},
showModal: () => {},
hideModal: () => {},
uploadFile: () => Promise.resolve({}),
addValidator: () => {},
Textarea: class { render() { return E('textarea'); } getValue() { return ''; } },
createHandlerFn: (self, fn) => fn
};
const baseclass = { extend: (obj) => obj };
const view = { extend: (obj) => obj };
const poll = { add: () => {} };
const rpc = { declare: () => () => Promise.resolve({}) };
const fsMock = { exec_direct: () => Promise.resolve(''), read: () => Promise.resolve(''), write: () => Promise.resolve() };
const widgets = { DeviceSelect: class {} };
const deps = {
baseclass, form, fs: fsMock, rpc, uci, ui, view, poll,
'luci.http': { urldecode: (s) => s, urlencode: (s) => s, urldecode_params: () => ({}) },
'luci.sys': { init_action: () => {} },
'tools.widgets': widgets,
/* tools.firewall exports two helpers - addIPOption / addMACOption -
* that wire a TextValue into the firewall zone matcher UI. The
* real implementation reads UCI host sections and renders a
* multi-select with validation. The snapshot only needs the
* shape of the returned option object so we stub them out:
* each one returns a TaggedValue-like object with the same
* `.value`/`.datatype`/`.depends`/`.validate` interface that
* the form framework traverses during rendering. The snapshots
* are pure structural diffs; this mock only has to keep the
* form render from throwing. */
'tools.firewall': {
addIPOption(section, _optName, _title, _description, _family, _hosts, _noHostCheck) {
return section.option(form.Value, _optName, _title, _description);
},
addMACOption(section, _optName, _title, _description, _hosts) {
return section.option(form.Value, _optName, _title, _description);
}
}
};
/* --- LuCI runtime mock end --------------------------------------------- */
/* The loader itself lives in tests/lib/luci-module.js, shared with the
* frontend invariant tests; this just binds it to the mock runtime above. */
const { loadLuciModule: loadModule } = require('./lib/luci-module.js');
function loadLuciModule(file, extraDeps) {
return loadModule(file, Object.assign({}, deps, extraDeps), { _: _, E: E, L: L });
}
function main() {
const root = process.argv[2];
const target = process.argv[3] || 'node';
if (!root)
throw new Error('usage: luci-form-snapshot.js <repo-root> [node|client|server]');
if (!['node', 'client', 'server'].includes(target))
throw new Error(`unknown snapshot target: ${target} (expected node|client|server)`);
const viewDir = path.join(root, 'htdocs/luci-static/resources');
const homeproxy = loadLuciModule(path.join(viewDir, 'homeproxy.js'), {});
/* Per-tab modules of the client view. Each one is loaded with the deps
* it actually `require`s - the loader rewrites the 'require ... as X;'
* directive into a __deps lookup, so a missing key here becomes a
* runtime error inside the first tab render. The dep set is the same
* per-tab shape the modules had at the top of client.js before the
* split, plus `common` for the modules that pull in renderRuleSection.
*/
const tabsBase = { baseclass, homeproxy, form, uci };
const tabs = {};
if (target === 'client') {
const tabDir = path.join(viewDir, 'view/homeproxy/client');
tabs['view.homeproxy.client.common'] = loadLuciModule(path.join(tabDir, 'common.js'),
{ ...tabsBase });
tabs['view.homeproxy.client.routing'] = loadLuciModule(path.join(tabDir, 'routing.js'),
{ ...tabsBase, 'view.homeproxy.client.common': tabs['view.homeproxy.client.common'] });
tabs['view.homeproxy.client.nodes'] = loadLuciModule(path.join(tabDir, 'nodes.js'),
{ ...tabsBase, 'tools.widgets': widgets });
tabs['view.homeproxy.client.dns'] = loadLuciModule(path.join(tabDir, 'dns.js'),
{ ...tabsBase, 'view.homeproxy.client.common': tabs['view.homeproxy.client.common'] });
tabs['view.homeproxy.client.subscription'] = loadLuciModule(path.join(tabDir, 'subscription.js'),
{ ...tabsBase });
tabs['view.homeproxy.client.access'] = loadLuciModule(path.join(tabDir, 'access.js'),
{ ...tabsBase, 'tools.widgets': widgets, 'tools.firewall': deps['tools.firewall'] });
tabs['view.homeproxy.client.udp_nat'] = loadLuciModule(path.join(tabDir, 'udp_nat.js'),
{ ...tabsBase });
tabs['view.homeproxy.client.tun_dns'] = loadLuciModule(path.join(tabDir, 'tun_dns.js'),
{ ...tabsBase });
}
const mod = loadLuciModule(path.join(viewDir, 'view/homeproxy', target + '.js'), { homeproxy, ...tabs });
const features = {
version: '1.14.0',
with_acme: true, with_grpc: true, with_gvisor: true, with_quic: true,
with_utls: true, with_wireguard: true, hp_has_tcp_brutal: true,
hp_has_tproxy: true, hp_has_tun: true, hp_has_ip_full: true
};
const rendered = mod.render([ undefined, features ]);
process.stdout.write(JSON.stringify(rendered, null, '\t') + '\n');
}
main();
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Single source for the test statistics README.md quotes.
#
# sh tests/print-stats.sh
#
# The README used to restate the numbers by hand, so they drifted the moment a
# guard, a check or a test file was added: the architecture row still said
# "34 guards / 125 checks / 74 files" while the tree had 35 / 126 / 75, and the
# frontend-validator count was one short after a validator was added. This
# script measures them instead, and the README cites it.
#
# guards guard sections in tests/arch-guard.sh
# checks the total arch-guard.sh counts and prints at the end of its run
# test_files files under tests/, excluding the gitignored .DS_Store
#
# `checks` costs one guard run: it is computed while the guards execute, so
# there is no cheaper honest way to report it. The run is pure shell/python
# (no ucode, no node) and takes well under a second. The guard's exit status is
# propagated, so a failing guard is never reported as a green statistic.
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
GUARDS="$(grep -c '^echo "== guard ' "$ROOT/tests/arch-guard.sh")"
GUARD_OUT="$(sh "$ROOT/tests/arch-guard.sh" "$ROOT" 2>&1)"
GUARD_STATUS=$?
CHECKS="$(printf '%s\n' "$GUARD_OUT" | sed -n 's/^\([0-9][0-9]*\) checks, .*/\1/p' | tail -1)"
# find, not `git ls-files tests`: the count must be the same before and after
# the commit that adds a test file, and it must survive a tarball checkout.
# Excluding .DS_Store keeps it equal to `git ls-files tests | wc -l` in this
# tree (the file is gitignored but every macOS listing has one).
TEST_FILES="$(find "$ROOT/tests" -type f -not -name '.DS_Store' | grep -c .)"
printf 'guards=%s\n' "$GUARDS"
printf 'checks=%s\n' "$CHECKS"
printf 'test_files=%s\n' "$TEST_FILES"
if [ "$GUARD_STATUS" -ne 0 ]; then
echo "warning: arch-guard.sh exited $GUARD_STATUS - the counts above come from a failing run" >&2
fi
exit "$GUARD_STATUS"
+314
View File
@@ -0,0 +1,314 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# One-shot entry point for the test suite:
#
# tests/run.sh
#
# Translation coverage and the LuCI form snapshots run locally (they only need
# python3 / node). The ucode tests run locally when ucode and sing-box are
# available, otherwise the checkout is copied to $HP_TEST_HOST and run there.
#
# The default is the dedicated test machine, NOT the production router: the
# fallback untars the whole checkout into $HP_TEST_DIR on the target, which
# must never land on the box the house actually routes through.
#
# HP_TEST_HOST=root@<test-machine> tests/run.sh
# HP_TEST_DIR=/tmp/hp-tests tests/run.sh
#
# If HP_TEST_HOST is unset, the device-side suite is SKIPPED: nobody's
# checkout should silently ssh into a guessed LAN address, and a hardcoded
# IP would also leak the author's network topology.
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# One work root per invocation. The sub-suites default to fixed paths like
# /tmp/hp-ucode-tests, so two concurrent runs of this script (a local one and a
# CI one over ssh, say) deleted each other's staging mid-test. mktemp -d gives
# each run its own, and the trap removes it.
WORK_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/hp-run.XXXXXX")" || exit 1
HOST="${HP_TEST_HOST:-}"
# The remote staging dir is per-run too, derived from the work root. With a
# shared /tmp/hp-tests two concurrent runs deleted each other's checkout
# mid-suite: "could not stage the tests" was the reproducible symptom.
# HP_TEST_DIR still overrides it.
REMOTE_DIR="${HP_TEST_DIR:-/tmp/hp-tests-$(basename "$WORK_ROOT")}"
# Both roots are removed on every exit path. The remote one needs ssh, so if the
# host is unreachable this is a no-op rather than an error.
cleanup() {
rm -rf "$WORK_ROOT"
case "${STAGED_REMOTELY:-0}" in
1) ssh -o BatchMode=yes -o ConnectTimeout=10 "$HOST" "rm -rf '$REMOTE_DIR'" 2>/dev/null || true ;;
esac
}
trap cleanup EXIT INT TERM
FAILED=0
# Set when the ucode layer could neither run locally nor be staged on a target.
# Distinct from FAILED on purpose: the subset did pass, and exit 3 says so.
UCODE_SKIPPED=0
echo "== zh_Hans translation coverage =="
if ! python3 "$ROOT/tests/i18n-coverage.py" --fail-below 100; then
echo "FAIL: zh_Hans translation coverage is below 100%"
FAILED=1
fi
echo "== LuCI form snapshots =="
for target in node client server; do
# $WORK_ROOT, not a fixed /tmp path: two concurrent runs (a local one and a
# CI one over ssh, say) would otherwise overwrite each other's snapshot and
# each compare against the other run's output.
if ! node "$ROOT/tests/luci-form-snapshot.js" "$ROOT" "$target" > "$WORK_ROOT/snapshot-$target.json" 2> "$WORK_ROOT/snapshot-$target.err"; then
echo "FAIL: could not render the $target form"
cat "$WORK_ROOT/snapshot-$target.err"
FAILED=1
continue
fi
if diff -q "$ROOT/tests/snapshots/$target.json" "$WORK_ROOT/snapshot-$target.json" > "/dev/null"; then
echo "PASS: $target form snapshot"
else
echo "FAIL: $target form snapshot changed:"
diff "$ROOT/tests/snapshots/$target.json" "$WORK_ROOT/snapshot-$target.json" | head -40
FAILED=1
fi
done
echo "== frontend protocol inventory =="
# Node-only, no ucode/sing-box needed: the frontend's protocol table must agree
# with the backend tables that decide what the generators can build.
node "$ROOT/tests/frontend-protocol-inventory.js" "$ROOT" || FAILED=1
echo "== frontend rpc boundary =="
# One declaration site, and no call site that looks like error handling but is
# not. Source-level because the behaviour needs a browser.
node "$ROOT/tests/frontend-rpc-inventory.js" "$ROOT" || FAILED=1
echo "== frontend validators =="
# A `validate` callback is a function, so no snapshot records it; this drives it
# with a fake form context instead.
node "$ROOT/tests/frontend-validators.js" "$ROOT" || FAILED=1
echo "== package JSON and UCI assets =="
# A malformed acl.d makes rpcd refuse the whole ACL, so every RPC is denied;
# a menu.d action pointing at a renamed view drops the page from the menu.
# Nothing else looks at these files.
node "$ROOT/tests/json-assets.js" "$ROOT" || FAILED=1
echo "== frontend RPC fallbacks =="
# What the UI claims when an RPC does not answer: the protocol list must not
# shrink (a saved node's type would be silently rewritten on the next save),
# and the status bar must not report a failed query as NOT RUNNING.
node "$ROOT/tests/frontend-rpc-fallbacks.js" "$ROOT" || FAILED=1
echo "== frontend title escaping =="
# Two sinks, two different escapes: a tab title decodes once, a section modal
# title goes through form.stripTags() which *decodes entities*, so escaping
# alone would hand it live markup. This models both decodes.
node "$ROOT/tests/frontend-title-escaping.js" "$ROOT" || FAILED=1
echo "== certificate upload argument alignment =="
# L.bind prepends the bound arguments and the Button hands the event in first,
# so a handler with a leading parameter no caller passes writes its staging file
# to /tmp/homeproxy_cert_[object Event].tmp - a path neither the ACL nor
# certificate_write knows. Drives each real call site with the framework's
# argument order and checks the path that comes out.
node "$ROOT/tests/frontend-certificate-upload.js" "$ROOT" || FAILED=1
echo "== frontend view wiring =="
# Loads the real view modules and drives the form.js calling conventions the
# snapshots cannot see: the log view's explicit log filename, the validator that
# used to dereference a null formvalue, and the routing_mode lookup that used to
# read the wrong UCI section (and therefore never actually guarded anything).
node "$ROOT/tests/frontend-view-wiring.js" "$ROOT" || FAILED=1
echo "== frontend MD5 (the grouphash contract) =="
# calcStringMD5() has to be RFC 1321 MD5 and nothing else: its output is
# compared against the md5() ucode writes into a node's `grouphash`, so a
# non-standard variant does not error - it silently drops a subscription's
# nodes out of their tab. crypto.subtle has no MD5, so the implementation is
# hand-rolled; this is what keeps it honest. The ucode half is pinned to the
# same vectors in tests/ucode/test_subscription_repository.uc.
node "$ROOT/tests/frontend-md5.js" "$ROOT" || FAILED=1
echo "== runtime extraction equivalence (PR-05) =="
# Pure shell: no ucode/sing-box needed, so it runs before the local-or-SSH
# branch below. A host without the toolchain can still prove that the init
# script refactor did not change behaviour.
sh "$ROOT/tests/runtime/test_runtime_extraction.sh" "$ROOT" "$WORK_ROOT/runtime-extraction" || FAILED=1
echo "== dnsmasq snippet writer (review L6) =="
# Pure shell too. Counts dnsmasq restarts through a stub to pin the
# incremental behaviour: an unchanged snippet set must not restart dnsmasq
# (a restart flushes every client's DNS cache), while a changed resource
# list, routing mode or ipv6 setting must.
sh "$ROOT/tests/runtime/test_dns_snippets.sh" "$ROOT" "$WORK_ROOT/dns-snippets" || FAILED=1
echo "== functional health probes (C2) =="
# Also pure shell: nslookup/nc are stubbed, so the probe results are
# deterministic. What it pins is the property a rollback depends on - the probes
# report by default and can only fail the gate when health_probe_strict is on,
# and "cannot probe here" never fails it.
sh "$ROOT/tests/runtime/test_health_probe.sh" "$ROOT" || FAILED=1
echo "== crontab permissions =="
# Also pure shell: hp_crontab_drop() is driven against a sandboxed crontab, so
# the host's own is never touched. It pins the mode of /etc/crontabs/root
# across a rewrite - the temporary file mv replaced it with was 0644 while
# procd creates it 0600.
sh "$ROOT/tests/runtime/test_crontab_perms.sh" "$ROOT" "$WORK_ROOT/crontab-perms" || FAILED=1
echo "== resource update verification (B2) =="
# Pure shell: wget / jsonfilter / ucode / uci / flock are stubbed and the
# script's absolute paths are rewritten into a sandbox, so this drives the
# verification path - match, mismatch, no API digest, no local digest - without
# a network or a router. What the *shipped* digest helper computes is
# tests/ucode/test_resource_blob_sha.sh's job.
sh "$ROOT/tests/runtime/test_resource_update.sh" "$ROOT" || FAILED=1
echo "== architecture guard =="
# Cross-file invariants that no single-layer test can see: the generators must
# read the production UCI directory, and the subscription updater must read the
# fields where the Loader actually puts them. Both regressed silently once.
# Pure shell, no ucode/node, so it also runs before the toolchain branch.
sh "$ROOT/tests/arch-guard.sh" "$ROOT" || FAILED=1
echo "== ucode tests =="
# Order matters here: local toolchain first, then the ssh fallback, then an
# honest skip. With `HP_TEST_HOST` tested first, a developer who had built the
# toolchain still got this whole layer skipped - and a final "ALL TESTS PASSED"
# - for as long as the variable was unset. That is the same shape as the skips
# that once let an unparseable
# update_subscriptions.uc reach a device. The testbed is the documented way to
# run this layer off-target, so an unset variable must not shadow it.
if command -v ucode > "/dev/null" 2>&1 && command -v sing-box > "/dev/null" 2>&1; then
# The generator cases feed the emitted config to `sing-box check` and the
# package targets sing-box >= 1.14; an older binary rejects 1.14-only
# fields. Fail loudly here instead of letting each fixture look like a
# generator regression.
SB_VER="$(sing-box version 2>/dev/null | sed -n 's/^sing-box version \([0-9][0-9.]*\).*/\1/p' | head -1)"
case "$SB_VER" in
1.1[4-9]*|1.[2-9][0-9]*|[2-9].*) ;;
*) echo "FAIL: sing-box >= 1.14 required, found '${SB_VER:-unknown}' ($(command -v sing-box))"
echo " tests/toolchain/build-ucode-linux.sh installs a matching one"
FAILED=1
;;
esac
# A "NOT RUN" (exit 2) from the layer is a skip, not a failure: the layer
# declines to run where it cannot judge the result (no ucode, or a
# non-Linux host, where sing-box rejects the Linux-only routing_mark).
# tests/run.sh still refuses to call the run a pass - it ends in the
# PARTIAL branch below.
sh "$ROOT/tests/ucode/run.sh" "$ROOT" "$WORK_ROOT/ucode"
case "$?" in
0) ;;
2) UCODE_SKIPPED=1 ;;
*) FAILED=1 ;;
esac
elif [ -n "$HOST" ]; then
echo "(no local ucode/sing-box, executing on $HOST)"
# BatchMode: a host-key or password prompt would otherwise hang the suite
# forever with no output. ConnectTimeout bounds an unreachable host.
#
# $REMOTE_DIR is expanded here and quoted inside the remote command: it was
# interpolated bare into `rm -rf`, so a value with a space would have
# removed two paths instead of one.
SSH="ssh -o BatchMode=yes -o ConnectTimeout=10"
# The staging policy, stated where it is carried out: this suite copies the
# checkout to the target and runs it from there. It never installs the
# package and never runs `apk`/`opkg` on the target.
#
# That is not a style preference. Running the package manager on a live
# device rewrote /etc/config/homeproxy with the feed package's default and
# the user's node configuration was lost; there was no backup and no
# snapshot. Staging cannot do that - nothing it writes outlives the run.
#
# The prices of staging are a version skew and a two-version problem, so
# both are printed rather than left to be discovered:
# - the target may have a *different* build of the app installed (or none);
# what is tested is the checkout, not what the device runs;
# - `sing-box` on the target is whatever the target has, which is why the
# local branch gates on >= 1.14 and this one records the version.
echo "== target and source versions (staging; the package is not installed) =="
SRC_VERSION="$(sed -n 's/^PKG_VERSION:=//p' "$ROOT/Makefile")-r$(sed -n 's/^PKG_RELEASE:=//p' "$ROOT/Makefile")"
echo " source : $SRC_VERSION"
# Single-quoted on purpose: the inner $(...) must be evaluated by the
# target's shell, not by this one. Double quotes here need three levels of
# escaping and the first attempt produced "unterminated quoted string".
# `apk info -v` prints the description, not the version; `apk list -I` gives
# "name-version arch {repo} [installed]".
$SSH "$HOST" 'pkg=$(apk list -I luci-app-homeproxy 2>/dev/null | head -1 | cut -d" " -f1); [ -n "$pkg" ] || pkg=$(opkg status luci-app-homeproxy 2>/dev/null | sed -n "s/^Version: //p" | head -1); [ -n "$pkg" ] || pkg="(not installed)"; sb=$(sing-box version 2>/dev/null | sed -n "s/^sing-box version \([0-9][0-9.]*\).*/\1/p" | head -1); echo " target : $pkg"; echo " sing-box: ${sb:-unknown}"' || true
if tar czf - -C "$ROOT" --exclude .git --exclude node_modules . \
| $SSH "$HOST" "rm -rf '$REMOTE_DIR' && mkdir -p '$REMOTE_DIR' && tar xzf - -C '$REMOTE_DIR'"; then
STAGED_REMOTELY=1
# HP_REQUIRE_FW4: a target always has firewall4, so the fw4 render
# layer in test_firewall_template.sh must actually run there. Without
# this, a target missing it would report NOT RUN and the suite would
# still pass, which is how a device-only layer quietly stops being
# exercised at all.
#
# It defaults to 1 here and is overridable for a target that
# legitimately has no firewall4 (the on-target workflow's `require_fw4`
# input). That input used to be parsed and then ignored, because this
# line hardcoded 1 - a switch that could not switch. The value is
# restricted to 0/1 because it is interpolated into the remote command
# below; anything else falls back to 1 rather than reaching the shell.
case "${HP_REQUIRE_FW4:-1}" in
0) HP_REQUIRE_FW4=0 ;;
1|'') HP_REQUIRE_FW4=1 ;;
*) echo "WARN: HP_REQUIRE_FW4='${HP_REQUIRE_FW4}' is not 0 or 1; using 1"
HP_REQUIRE_FW4=1 ;;
esac
# The second argument is the work dir. Without it the remote run fell
# back to ucode/run.sh's default /tmp/hp-ucode-tests, so two concurrent
# suite runs on the target shared it and deleted each other's staging -
# "could not sandbox ... missing anchor".
$SSH "$HOST" "HP_REQUIRE_FW4='$HP_REQUIRE_FW4' sh '$REMOTE_DIR/tests/ucode/run.sh' '$REMOTE_DIR' '$REMOTE_DIR/work'"
case "$?" in
0) ;;
2) UCODE_SKIPPED=1 ;;
*) FAILED=1 ;;
esac
else
echo "FAIL: could not stage the tests on $HOST"
FAILED=1
fi
else
# A skipped layer is not a pass. The ucode layer is where the generator,
# the parser, the subscription pipeline and every golden snapshot are
# validated - the layer that has twice shipped something no router could
# parse while the suite printed ALL TESTS PASSED. Reporting the subset as
# a pass is exactly how that happened, so the run ends non-zero (3, which
# is distinct from a test failure) and says which layer is missing.
echo "SKIP: no local ucode/sing-box and HP_TEST_HOST is not set"
echo " the ucode layer (generator / parser / subscription / golden"
echo " snapshots) was NOT run by this invocation."
echo " build it: sh tests/toolchain/build-ucode-linux.sh # Linux"
echo " or stage: HP_TEST_HOST=root@<test-machine> sh tests/run.sh"
echo " a Linux container is the supported way to run it off-target;"
echo " tests/README.md has the one-liner. HP_ALLOW_PARTIAL=1 turns"
echo " this run back into 'the subset passed' (exit 0)."
if [ "${HP_ALLOW_PARTIAL:-0}" = "1" ]; then
echo " HP_ALLOW_PARTIAL=1: reporting the subset as a pass"
else
UCODE_SKIPPED=1
fi
fi
if [ "$FAILED" -ne 0 ]; then
echo "SOME TESTS FAILED"
exit 1
fi
if [ "$UCODE_SKIPPED" = "1" ]; then
echo "PARTIAL: every suite that ran passed, but the ucode layer did not run"
exit 3
fi
echo "ALL TESTS PASSED"
exit $FAILED
@@ -0,0 +1,260 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Runtime configuration-transaction tests.
#
# These cover the pieces init.d/homeproxy relies on so that a bad
# configuration cannot leave the router without a working service:
#
# * the known-good copy recorded after a configuration passed check
# * the fallback to that copy when a regeneration produces nothing
# * the rollback after a reload that comes up unhealthy
# * the health probe that decides whether a rollback is needed
#
# The helpers are pure shell on purpose (see scripts/runtime/), so this runs
# anywhere, without ucode, procd or a router.
#
# Usage: sh tests/runtime/test_config_transaction.sh <repo-root>
ROOT="${1:-.}"
ROOT="$(cd "$ROOT" && pwd)"
RUNTIME="$ROOT/root/etc/homeproxy/scripts/runtime"
WORK="$(mktemp -d "${TMPDIR:-/tmp}/hp-runtime.XXXXXX")" || exit 1
trap 'rm -rf "$WORK"' EXIT INT TERM
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
}
if [ ! -f "$RUNTIME/config.sh" ] || [ ! -f "$RUNTIME/health.sh" ]; then
echo "FAIL: runtime helpers are missing under $RUNTIME"
exit 1
fi
# shellcheck source=/dev/null
. "$RUNTIME/config.sh"
# shellcheck source=/dev/null
. "$RUNTIME/health.sh"
LIVE="$WORK/run/sing-box-c.json"
GOOD="$WORK/run/known-good/sing-box-c.json"
echo "== config transaction =="
# Nothing generated yet and no fallback: unusable.
hp_ensure_live "$LIVE" "$GOOD"
expect "ensure_live: nothing usable" "$?" "2"
# A configuration that passed check becomes the known-good copy.
mkdir -p "$WORK/run"
printf 'v1\n' > "$LIVE"
hp_known_good "$LIVE" "$GOOD"
expect "known_good: recorded" "$?" "0"
expect "known_good: content" "$(cat "$GOOD")" "v1"
# Live file present -> ensure_live leaves it alone.
hp_ensure_live "$LIVE" "$GOOD"
expect "ensure_live: keeps the live file" "$?" "0"
# Generation failed and removed the live file -> restore the fallback. This
# is the start-up path: without it a failed generation left the router with
# no configuration at all.
rm -f "$LIVE"
hp_ensure_live "$LIVE" "$GOOD"
expect "ensure_live: restores the fallback" "$?" "1"
expect "ensure_live: restored content" "$(cat "$LIVE")" "v1"
# A new configuration that passes check but is not yet proven: the fallback
# still holds the previous one.
printf 'v2\n' > "$LIVE"
hp_same_file "$LIVE" "$GOOD"
expect "same_file: different configs" "$?" "1"
hp_rollback "$LIVE" "$GOOD"
expect "rollback: applied" "$?" "0"
expect "rollback: previous content restored" "$(cat "$LIVE")" "v1"
hp_same_file "$LIVE" "$GOOD"
expect "same_file: identical configs" "$?" "0"
# Nothing to roll back to must be reported, not silently ignored.
rm -f "$GOOD"
hp_rollback "$LIVE" "$GOOD"
expect "rollback: refuses without a fallback" "$?" "1"
hp_same_file "$LIVE" "$GOOD"
expect "same_file: missing file is not equal" "$?" "1"
echo "== health probe =="
# Deterministic stubs so the probe's control flow is tested rather than the
# host's process table or a real service.
#
# The gate asks procd first (ubus + jsonfilter) and only falls back to a
# process scan when procd cannot be asked at all. That order is the fix for
# the P0: with the process scan
# first, a STALE sing-box process left over from an earlier run kept answering
# "alive" while procd restarted a crashing instance, so a dead service was
# accepted and recorded as the new known-good. The first case below pins
# exactly that.
BIN="$WORK/bin"
mkdir -p "$BIN"
# One sample == one ubus call, so the counter indexes the health pattern.
cat > "$BIN/ubus" <<'STUB'
#!/bin/sh
n=$(cat "$HP_FAKE_COUNTER" 2>/dev/null || echo 0)
n=$((n + 1))
echo "$n" > "$HP_FAKE_COUNTER"
echo 'procd-state'
STUB
cat > "$BIN/pgrep" <<'STUB'
#!/bin/sh
[ "${HP_FAKE_STALE_PROC:-0}" = "1" ] && exit 0
exit 1
STUB
cat > "$BIN/jsonfilter" <<'STUB'
#!/bin/sh
# HP_FAKE_PATTERN is a string of 1/0, one character per sample.
n=$(cat "$HP_FAKE_COUNTER" 2>/dev/null || echo 1)
[ "$n" -ge 1 ] || n=1
ch=$(printf '%s' "${HP_FAKE_PATTERN:-1}" | cut -c "$n")
[ -n "$ch" ] || ch=$(printf '%s' "${HP_FAKE_PATTERN:-1}" | cut -c 1)
up=no
[ "$ch" = "1" ] && up=yes
case "$*" in
*".instances["*)
if [ "$up" = "yes" ]; then
printf '{"running":true}\n'
else
printf '{"running":false,"exit_code":1}\n'
fi
;;
*"@.running"*) [ "$up" = "yes" ] && echo true || echo false ;;
*"@.exit_code"*) [ "$up" = "yes" ] || echo 1 ;;
esac
exit 0
STUB
cat > "$BIN/netstat" <<'STUB'
#!/bin/sh
echo "Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name"
if [ "${HP_FAKE_NO_OWNER:-0}" = "1" ]; then
printf 'tcp 0 0 :::%s :::* LISTEN\n' "${HP_FAKE_PORT:-5330}"
elif [ -n "${HP_FAKE_PORT:-}" ]; then
printf 'tcp 0 0 :::%s :::* LISTEN 42/%s\n' "$HP_FAKE_PORT" "${HP_FAKE_PORT_OWNER:-sing-box}"
fi
exit 0
STUB
# The health budget is measured in samples, not wall-clock seconds; sleeping
# for real would make each case take the whole budget in seconds.
cat > "$BIN/sleep" <<'STUB'
#!/bin/sh
exit 0
STUB
chmod +x "$BIN/pgrep" "$BIN/ubus" "$BIN/jsonfilter" "$BIN/netstat" "$BIN/sleep"
PATH="$BIN:$PATH"
export PATH
CFG="$WORK/run/sing-box-c.json"
: > "$CFG"
COUNTER="$WORK/sample-count"
HP_FAKE_COUNTER="$COUNTER"
export HP_FAKE_COUNTER
reset_samples() {
echo 0 > "$COUNTER"
}
# 1. procd says down while a stale process still matches the scan.
HP_FAKE_PATTERN=0 HP_FAKE_STALE_PROC=1
export HP_FAKE_PATTERN HP_FAKE_STALE_PROC
reset_samples
hp_instance_running "sing-box-c" "$CFG"
expect "instance_running: a stale process does not override procd" "$?" "1"
# 2. procd says running and reports no failed exit code.
HP_FAKE_PATTERN=1 HP_FAKE_STALE_PROC=0
export HP_FAKE_PATTERN HP_FAKE_STALE_PROC
reset_samples
hp_instance_running "sing-box-c" "$CFG"
expect "instance_running: reports up" "$?" "0"
# 3. hp_wait_service needs the instance healthy for several CONSECUTIVE
# samples: one lucky poll must not be enough (that is what let a
# crash-restart loop pass the old gate).
HP_FAKE_PATTERN=101010
export HP_FAKE_PATTERN
reset_samples
hp_wait_service "sing-box-c" "$CFG" 6 3
expect "wait_service: alternating samples never reach the stability window" "$?" "1"
# 4. A bad sample early in the window resets the count, and a later healthy run
# still succeeds.
HP_FAKE_PATTERN=110111
export HP_FAKE_PATTERN
reset_samples
hp_wait_service "sing-box-c" "$CFG" 6 3
expect "wait_service: recovers after a bad sample" "$?" "0"
# 5. Listener attribution. "The port is in the listen table" is not enough: in
# the failure this gate exists for, the port was listening but owned by the
# process that had taken it.
HP_FAKE_PORT=5330 HP_FAKE_PORT_OWNER=sing-box
export HP_FAKE_PORT HP_FAKE_PORT_OWNER
hp_listener_owned "sing-box" 5330
expect "listener: owned by sing-box" "$?" "0"
HP_FAKE_PORT_OWNER=socat
export HP_FAKE_PORT_OWNER
hp_listener_owned "sing-box" 5330
expect "listener: listening but owned by another process fails" "$?" "1"
HP_FAKE_NO_OWNER=1
export HP_FAKE_NO_OWNER
hp_listener_owned "sing-box" 5330
expect "listener: no owner column is reported as unavailable" "$?" "2"
unset HP_FAKE_NO_OWNER
HP_FAKE_PORT=5330 HP_FAKE_PORT_OWNER=sing-box
export HP_FAKE_PORT HP_FAKE_PORT_OWNER
hp_listener_owned "sing-box" 5330 5399
expect "listener: a missing port fails" "$?" "1"
# 6. The whole sample: procd up and the listeners owned.
HP_FAKE_PATTERN=1
export HP_FAKE_PATTERN
reset_samples
hp_service_healthy "sing-box-c" "$CFG" 5330
expect "service_healthy: procd up and listeners owned" "$?" "0"
HP_FAKE_PORT_OWNER=socat
export HP_FAKE_PORT_OWNER
reset_samples
hp_service_healthy "sing-box-c" "$CFG" 5330
expect "service_healthy: procd up but the listener was taken by another process" "$?" "1"
# 7. hp_wait_instance is kept for compatibility.
HP_FAKE_PORT_OWNER=sing-box HP_FAKE_PATTERN=1
export HP_FAKE_PORT_OWNER HP_FAKE_PATTERN
reset_samples
hp_wait_instance "sing-box-c" "$CFG" 1
expect "wait_instance (compat): succeeds while running" "$?" "0"
HP_FAKE_PATTERN=0
export HP_FAKE_PATTERN
reset_samples
hp_wait_instance "sing-box-c" "$CFG" 1
expect "wait_instance (compat): times out when down" "$?" "1"
printf '%d checks, %d failures\n' "$CHECKS" "$FAILURES"
exit $FAILED
@@ -0,0 +1,107 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# hp_crontab_drop() rewrites /etc/crontabs/root through a temporary file, and
# `mv` replaces the *file*, not its contents: the temporary carries the shell's
# umask (0644) while procd creates the root crontab as 0600. Every stop/start
# therefore left it world-readable - confirmed on the device (0600 after a
# boot, 0644 after one reload) - and a root crontab can hold other jobs'
# secrets.
#
# The function takes the crontab path as an argument and the module is
# device-side, so this drives it against a sandboxed file and never touches the
# host's own crontab.
#
# Usage: sh tests/runtime/test_crontab_perms.sh <repo-root> [workdir]
set -u
ROOT="${1:-.}"
WORK="${2:-$(mktemp -d)}"
FAILED=0
fail() {
echo "FAIL: $*"
FAILED=1
}
# BSD stat takes -f, GNU takes -c; try both rather than assuming a host.
file_mode() {
stat -c '%a' "$1" 2>"/dev/null" || stat -f '%Lp' "$1" 2>"/dev/null"
}
rm -rf "$WORK"
mkdir -p "$WORK"
# The module is sourced by the init script, which provides log() and CONF.
CONF="homeproxy"
log() { printf '%s\n' "$*" >> "$WORK/log"; }
# shellcheck disable=SC1091
. "$ROOT/root/etc/homeproxy/scripts/runtime/service.sh"
CRONTAB="$WORK/crontab"
write_crontab() {
cat > "$CRONTAB" <<-'EOF'
0 3 * * * root /usr/bin/keep-me
30 4 * * * root /etc/homeproxy/scripts/update_subscriptions.uc #homeproxy_autosetup
EOF
}
# --- the entry is dropped and the rest of the file survives ---------------
write_crontab
chmod 600 "$CRONTAB"
hp_crontab_drop "$CRONTAB"
drop_rc=$?
if [ "$drop_rc" = 0 ]; then
echo "PASS: hp_crontab_drop reports success"
else
fail "hp_crontab_drop returned $drop_rc"
fi
if grep -q "keep-me" "$CRONTAB" && ! grep -q "autosetup" "$CRONTAB"; then
echo "PASS: the auto-update entry is gone and other jobs survive"
else
fail "the crontab was not edited as expected:"
sed 's/^/ /' "$CRONTAB"
fi
if [ "$(file_mode "$CRONTAB")" = "600" ]; then
echo "PASS: the root crontab is left 0600"
else
fail "the root crontab is $(file_mode "$CRONTAB") after the rewrite; it was 0600"
fi
# --- a file an older version already loosened is repaired -----------------
write_crontab
chmod 644 "$CRONTAB"
hp_crontab_drop "$CRONTAB"
if [ "$(file_mode "$CRONTAB")" = "600" ]; then
echo "PASS: a world-readable crontab is tightened to 0600"
else
fail "a crontab that was already 0644 stayed $(file_mode "$CRONTAB")"
fi
# --- a missing crontab is reported, not invented --------------------------
rm -f "$CRONTAB"
if hp_crontab_drop "$CRONTAB"; then
fail "hp_crontab_drop claimed success for a crontab that does not exist"
else
echo "PASS: a missing crontab is reported as a failure"
fi
if [ -e "$CRONTAB" ]; then
fail "hp_crontab_drop created a crontab that did not exist"
else
echo "PASS: no crontab is created out of nothing"
fi
rm -rf "$WORK"
if [ "$FAILED" != 0 ]; then
exit 1
fi
exit 0
+253
View File
@@ -0,0 +1,253 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# dnsmasq snippet-writer tests (review L6).
#
# hp_dnsmasq_write_snippets used to rewrite the snippet set and restart
# dnsmasq unconditionally. A restart drops every client's DNS cache, and the
# list files it feeds on change once a day at most while the routing mode and
# the DNS port stay put - so an ordinary `reload` flushed the whole LAN's DNS
# cache for no reason.
#
# The writer now renders into a staging directory and compares with what is
# installed; the restart happens only when something actually changed. These
# tests pin that contract from the outside, by counting restarts through a
# stub:
#
# * an identical second call does not restart
# * a changed resource list does restart
# * a changed routing mode does restart, and drops the snippet the old
# mode produced (a stale gfw_list.conf next to redirect-dns.conf would
# have dnsmasq serve both)
# * toggling ipv6_support does restart (the nftset suffix changes)
# * removing the resource list does restart, and removes the snippet, so
# the next call is stable again
#
# The stub is a stand-in for /etc/init.d/dnsmasq, wired in the same way
# tests/runtime/test_runtime_extraction.sh wires its init scripts: the copy of
# dns.sh under test is sed-rewritten so the absolute path points into the
# sandbox.
#
# Usage: sh tests/runtime/test_dns_snippets.sh <repo-root> [work-dir]
ROOT="${1:-.}"
ROOT="$(cd "$ROOT" && pwd)"
RUNTIME="$ROOT/root/etc/homeproxy/scripts/runtime"
WORK="${2:-$(mktemp -d "${TMPDIR:-/tmp}/hp-dns-snippets.XXXXXX")}"
OWN_WORK=0
[ -n "${2:-}" ] || OWN_WORK=1
[ "$OWN_WORK" -eq 1 ] && trap 'rm -rf "$WORK"' EXIT INT TERM
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
}
exists() {
# exists <path> -> yes|no, so an assertion can compare it as a value.
[ -e "$1" ] && echo yes || echo no
}
has_line() {
# has_line <pattern> <file> -> yes|no. A count would be brittle here:
# each domain in a gfw/proxy snippet expands to a server= line *and* an
# nftset= line, so the interesting question is presence, not multiplicity.
grep -q -- "$1" "$2" 2>"/dev/null" && echo yes || echo no
}
if [ ! -f "$RUNTIME/dns.sh" ]; then
echo "FAIL: $RUNTIME/dns.sh is missing"
exit 1
fi
rm -rf "$WORK"
mkdir -p "$WORK/init.d" \
"$WORK/hp/resources" \
"$WORK/dnsmasq.d"
DNS_DIR="$WORK/dnsmasq.d/dnsmasq-homeproxy.d"
INCLUDE="$WORK/dnsmasq.d/dnsmasq-homeproxy.conf"
RESTARTS="$WORK/restarts"
LOG="$WORK/log"
# --- stubs --------------------------------------------------------------
# runtime/dns.sh probes `dnsmasq --version` for nftset support before emitting
# nftset= directives. This suite stubs the init script, not the binary, so
# state the answer instead: nftset snippets are what a full dnsmasq emits, and
# that is what the cases below are about.
HP_DNSMASQ_NFTSET=1
export HP_DNSMASQ_NFTSET
# Stand-in for /etc/init.d/dnsmasq: counts restarts instead of running one.
cat > "$WORK/init.d/dnsmasq" <<EOF
#!/bin/sh
echo "\$*" >> "$RESTARTS"
exit 0
EOF
chmod +x "$WORK/init.d/dnsmasq"
# dnsmasq.sh is sourced, so the two ini.d paths inside it have to point at
# the stub. Only the absolute path is rewritten - the same trick the runtime
# extraction test uses.
sed "s#/etc/init.d/dnsmasq#$WORK/init.d/dnsmasq#g" \
"$RUNTIME/dns.sh" > "$WORK/dns.sh"
if ! grep -qF "$WORK/init.d/dnsmasq" "$WORK/dns.sh"; then
echo "FAIL: could not rewrite the dnsmasq init path in the dns.sh copy"
exit 1
fi
# The module calls log() and the two config readers; a device provides them
# via /lib/functions.sh and the init script. Both are stubbed to read from
# this test's environment so a scenario is one variable assignment.
log() { echo "$*" >> "$LOG"; }
config_get_bool() { eval "$1=\"\${HP_IPV6:-0}\""; }
config_get() { eval "$1=\"\${HP_DNS_PORT:-5333}\""; }
# shellcheck source=/dev/null
. "$WORK/dns.sh"
restart_count() { wc -l < "$RESTARTS" 2>/dev/null | tr -d ' '; }
# --- fixtures -----------------------------------------------------------
printf 'example.com\nfoo.example.org\n' > "$WORK/hp/resources/gfw_list.txt"
printf 'example.cn\n' > "$WORK/hp/resources/china_list.txt"
printf 'proxy.example.net\n' > "$WORK/hp/resources/proxy_list.txt"
run() {
# run <routing-mode>
hp_dnsmasq_write_snippets "$DNS_DIR" "$WORK/hp" "$1"
}
: > "$RESTARTS"
: > "$LOG"
echo "== gfwlist: first write, then an identical re-run =="
run "gfwlist"
expect "first write restarts dnsmasq" "$(restart_count)" "1"
expect "gfw_list.conf installed" "$(exists "$DNS_DIR/gfw_list.conf")" "yes"
expect "proxy_list.conf installed" "$(exists "$DNS_DIR/proxy_list.conf")" "yes"
expect "include file points at the dir" "$(cat "$INCLUDE")" "conf-dir=$DNS_DIR"
# The whole point of L6: same inputs -> no restart.
run "gfwlist"
expect "identical re-run does not restart" "$(restart_count)" "1"
expect "identical re-run logs the skip" \
"$(grep -c 'unchanged' "$LOG")" "1"
echo "== gfwlist: a changed resource list restarts =="
printf 'example.com\nfoo.example.org\nnew.example.net\n' > "$WORK/hp/resources/gfw_list.txt"
run "gfwlist"
expect "changed gfw_list.txt restarts dnsmasq" "$(restart_count)" "2"
expect "the new domain reached the snippet" \
"$(has_line 'new.example.net' "$DNS_DIR/gfw_list.conf")" "yes"
# ...and is stable again straight after.
run "gfwlist"
expect "re-run after the change does not restart" "$(restart_count)" "2"
echo "== toggling ipv6_support restarts (the nftset suffix changes) =="
HP_IPV6=1 run "gfwlist"
expect "ipv6_support on restarts dnsmasq" "$(restart_count)" "3"
expect "the v6 nftset suffix reached the snippet" \
"$(has_line 'homeproxy_gfw_list_v6' "$DNS_DIR/gfw_list.conf")" "yes"
HP_IPV6=1 run "gfwlist"
expect "re-run with ipv6 on does not restart" "$(restart_count)" "3"
HP_IPV6=0 run "gfwlist"
expect "ipv6_support off restarts again" "$(restart_count)" "4"
echo "== a changed routing mode restarts and drops the stale snippet =="
run "bypass_mainland_china"
expect "mode change restarts dnsmasq" "$(restart_count)" "5"
expect "redirect-dns.conf installed" "$(exists "$DNS_DIR/redirect-dns.conf")" "yes"
expect "the old gfw_list.conf is gone" "$(exists "$DNS_DIR/gfw_list.conf")" "no"
expect "redirect-dns.conf names the DNS port" \
"$(has_line 'server=127.0.0.1#5333' "$DNS_DIR/redirect-dns.conf")" "yes"
run "bypass_mainland_china"
expect "same mode re-run does not restart" "$(restart_count)" "5"
echo "== a removed resource list restarts and removes its snippet =="
rm -f "$WORK/hp/resources/proxy_list.txt"
run "bypass_mainland_china"
expect "removing proxy_list.txt restarts" "$(restart_count)" "6"
expect "the stale proxy_list.conf is gone" "$(exists "$DNS_DIR/proxy_list.conf")" "no"
run "bypass_mainland_china"
expect "re-run after the removal does not restart" "$(restart_count)" "6"
echo "== removal leaves nothing to restart for =="
hp_dnsmasq_remove_snippets "$DNS_DIR"
expect "remove restarts dnsmasq once" "$(restart_count)" "7"
expect "the snippet dir is gone" "$(exists "$DNS_DIR")" "no"
expect "the include file is gone" "$(exists "$INCLUDE")" "no"
hp_dnsmasq_remove_snippets "$DNS_DIR"
expect "a second remove does not restart" "$(restart_count)" "7"
echo "== an unusable infra.dns_port cannot reach the snippet =="
# `infra` has no UI and no validator, and the value is interpolated into sed's
# replacement. '5333\nlog-queries' is accepted by the generator - int() is
# strtoll-based, so it reads 5333 - and sed turns the `\n` into a real newline,
# which used to append a dnsmasq directive of the writer's choosing. A value
# with a `/` made sed fail instead, and the `>`-truncated file was installed as
# a valid (empty) snippet. Both have to be neutralised, and a value that is
# not a port at all has to leave the installed set alone.
run "bypass_mainland_china"
expect "the good snippet set is installed" "$(exists "$DNS_DIR/redirect-dns.conf")" "yes"
GOOD_CONF="$(cat "$DNS_DIR/redirect-dns.conf")"
GOOD_RESTARTS="$(restart_count)"
HP_DNS_PORT="$(printf '5333\nlog-queries')" run "bypass_mainland_china"
expect "a newline in dns_port cannot inject a directive" \
"$(has_line 'log-queries' "$DNS_DIR/redirect-dns.conf")" "no"
expect "a newline in dns_port keeps the installed snippet" \
"$(cat "$DNS_DIR/redirect-dns.conf")" "$GOOD_CONF"
expect "a newline in dns_port does not restart dnsmasq" "$(restart_count)" "$GOOD_RESTARTS"
HP_DNS_PORT='5333/evil' run "bypass_mainland_china"
expect "a slash in dns_port cannot break the snippet" \
"$(has_line 'evil' "$DNS_DIR/redirect-dns.conf")" "no"
expect "a slash in dns_port keeps the installed snippet" \
"$(cat "$DNS_DIR/redirect-dns.conf")" "$GOOD_CONF"
expect "a slash in dns_port does not restart dnsmasq" "$(restart_count)" "$GOOD_RESTARTS"
HP_DNS_PORT='not-a-port' run "bypass_mainland_china"
expect "an unusable dns_port leaves the snippet in place" \
"$(cat "$DNS_DIR/redirect-dns.conf")" "$GOOD_CONF"
expect "an unusable dns_port does not restart dnsmasq" "$(restart_count)" "$GOOD_RESTARTS"
expect "an unusable dns_port is reported in the log" \
"$(grep -c 'dns_port' "$LOG")" "1"
echo
printf '%s checks, %s failures\n' "$CHECKS" "$FAILURES"
if [ "$FAILED" != 0 ]; then
echo "DNS SNIPPET TESTS FAILED"
exit 1
fi
echo "DNS SNIPPET TESTS PASSED"
exit 0
+221
View File
@@ -0,0 +1,221 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Functional health probes (runtime/health.sh).
#
# The process/listener gate answers "is the instance up and does it own its
# ports?". It cannot answer "does it actually serve?", and the probes that do
# are on the same code path a rollback is decided on - so the one property that
# must not regress is that they only *report* by default. A probe that fails
# because the uplink is down, or because this target has no nslookup/nc, must
# never be able to fail the gate: a rollback loop is worse than the condition
# being probed for.
#
# `health_probe_strict` (UCI) is the opt-in that turns a probe failure into a
# gate failure. "Cannot probe here" stays non-fatal even then - there is
# nothing to conclude from a missing tool.
#
# The probes take explicit ports and shell out to nslookup/nc, so this runs
# anywhere without ucode, procd or a router.
#
# Usage: sh tests/runtime/test_health_probe.sh <repo-root>
ROOT="${1:-.}"
ROOT="$(cd "$ROOT" && pwd)"
RUNTIME="$ROOT/root/etc/homeproxy/scripts/runtime"
WORK="$(mktemp -d "${TMPDIR:-/tmp}/hp-probes.XXXXXX")" || exit 1
trap 'rm -rf "$WORK"' EXIT INT TERM
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
}
if [ ! -f "$RUNTIME/health.sh" ]; then
echo "FAIL: runtime/health.sh is missing under $RUNTIME"
exit 1
fi
# log() comes from init.d on a router; the probes only call it to report, so
# the test records the lines and asserts on them.
PROBE_LOG="$WORK/probe.log"
: > "$PROBE_LOG"
log() { printf '%s\n' "$*" >> "$PROBE_LOG"; }
# shellcheck source=/dev/null
. "$RUNTIME/health.sh"
# --- stub PATH -------------------------------------------------------------
# The probes are written against `command -v nslookup` / `command -v nc`, so
# the sandbox decides what exists. Each stub's behaviour follows a marker file,
# which keeps the call sites readable.
BIN_OK="$WORK/bin-ok"
BIN_NONE="$WORK/bin-none"
mkdir -p "$BIN_OK" "$BIN_NONE"
cat > "$BIN_OK/nslookup" <<'EOF'
#!/bin/sh
[ -f "$HP_PROBE_STUB_DIR/dns_ok" ] || exit 1
exit 0
EOF
cat > "$BIN_OK/nc" <<'EOF'
#!/bin/sh
[ -f "$HP_PROBE_STUB_DIR/tcp_ok" ] || exit 1
exit 0
EOF
chmod +x "$BIN_OK/nslookup" "$BIN_OK/nc"
HP_PROBE_STUB_DIR="$WORK"
export HP_PROBE_STUB_DIR
PATH="$BIN_OK:$PATH"
export PATH
echo "== hp_probe_dns =="
: > "$WORK/dns_ok"
hp_probe_dns 5333 example.com
expect "dns: an answering inbound is 0" "$?" "0"
rm -f "$WORK/dns_ok"
hp_probe_dns 5333 example.com
expect "dns: a silent inbound is 1" "$?" "1"
hp_probe_dns "" example.com
expect "dns: no port means cannot-probe (2), not failure" "$?" "2"
echo "== hp_probe_tcp =="
: > "$WORK/tcp_ok"
hp_probe_tcp 5330
expect "tcp: an accepting listener is 0" "$?" "0"
rm -f "$WORK/tcp_ok"
hp_probe_tcp 5330
expect "tcp: a refusing listener is 1" "$?" "1"
hp_probe_tcp ""
expect "tcp: no port means cannot-probe (2), not failure" "$?" "2"
echo "== missing tooling is 'cannot probe', not 'failed' =="
# PATH is replaced, not prepended to: the host this runs on may well have its
# own nslookup/nc (macOS ships both), and a prepended empty directory would
# still find them behind `command -v`.
SAVED_PATH="$PATH"
PATH="$BIN_NONE"
hp_probe_dns 5333 example.com
rc_dns_missing=$?
hp_probe_tcp 5330
rc_tcp_missing=$?
PATH="$SAVED_PATH"
expect "dns: no nslookup is 2" "$rc_dns_missing" "2"
expect "tcp: no nc is 2" "$rc_tcp_missing" "2"
export PATH
echo "== hp_run_probes: reporting is the default =="
: > "$PROBE_LOG"
rm -f "$WORK/dns_ok" "$WORK/tcp_ok"
HP_PROBE_STRICT=0
export HP_PROBE_STRICT
hp_run_probes "client" 5333 5330
expect "reporting: both probes failing still returns 0" "$?" "0"
expect "reporting: the DNS failure is logged" \
"$(grep -c 'the client DNS inbound (127.0.0.1:5333) did not answer' "$PROBE_LOG")" "1"
expect "reporting: the TCP failure is logged" \
"$(grep -c 'nothing accepted a connection on the client mixed inbound (127.0.0.1:5330)' "$PROBE_LOG")" "1"
echo "== hp_run_probes: strict turns a failure into a gate failure =="
: > "$PROBE_LOG"
HP_PROBE_STRICT=1
export HP_PROBE_STRICT
hp_run_probes "client" 5333 5330
expect "strict: a failed probe returns 1" "$?" "1"
expect "strict: the strict path is named in the log" \
"$(grep -c 'health_probe_strict is on' "$PROBE_LOG")" "1"
echo "== hp_run_probes: strict does not fire on a healthy service =="
: > "$WORK/dns_ok"
: > "$WORK/tcp_ok"
: > "$PROBE_LOG"
hp_run_probes "client" 5333 5330
expect "strict: a passing probe returns 0" "$?" "0"
expect "strict: a passing probe logs nothing" "$(wc -l < "$PROBE_LOG" | tr -d ' ')" "0"
echo "== hp_run_probes: 'cannot probe' never fails the gate =="
rm -f "$WORK/dns_ok" "$WORK/tcp_ok"
: > "$PROBE_LOG"
HP_PROBE_STRICT=1
export HP_PROBE_STRICT
PATH="$BIN_NONE"
hp_run_probes "client" 5333 5330
rc_no_tools=$?
PATH="$SAVED_PATH"
export PATH
expect "strict: a probe that cannot run still returns 0" "$rc_no_tools" "0"
expect "strict: the missing tooling is reported" \
"$(grep -c 'cannot probe the client' "$PROBE_LOG")" "2"
echo "== hp_config_port_for_tag =="
# The generator writes one field per line (%.J); the sed in the helper also has
# to accept the compact shape, because a config that came from anywhere else
# (or a fixture) must not silently yield "no port" - that is how the probes
# would end up probing nothing at all.
cat > "$WORK/pretty.json" <<'EOF'
{
"inbounds": [
{
"tag": "dns-in",
"listen": "127.0.0.1",
"listen_port": 5333
},
{
"tag": "mixed-in",
"listen": "::",
"listen_port": 5330
},
{
"tag": "tproxy-in",
"listen_port": 5332
}
],
"outbounds": [
{
"tag": "direct-out",
"type": "direct"
}
]
}
EOF
cat > "$WORK/compact.json" <<'EOF'
{"inbounds":[{"tag":"dns-in","listen_port":5333},{"tag":"mixed-in","listen_port":5330}],"outbounds":[{"tag":"direct-out","type":"direct"}]}
EOF
expect "port_for_tag: dns-in from a pretty config" \
"$(hp_config_port_for_tag "$WORK/pretty.json" 'dns-in')" "5333"
expect "port_for_tag: mixed-in from a pretty config" \
"$(hp_config_port_for_tag "$WORK/pretty.json" 'mixed-in')" "5330"
expect "port_for_tag: a third inbound is not confused with the first" \
"$(hp_config_port_for_tag "$WORK/pretty.json" 'tproxy-in')" "5332"
expect "port_for_tag: an outbound tag is not read as a listener" \
"$(hp_config_port_for_tag "$WORK/pretty.json" 'direct-out')" ""
expect "port_for_tag: an absent tag yields nothing" \
"$(hp_config_port_for_tag "$WORK/pretty.json" 'nope-in')" ""
expect "port_for_tag: compact JSON yields the same port" \
"$(hp_config_port_for_tag "$WORK/compact.json" 'dns-in')" "5333"
expect "config_ports: both listeners are reported" \
"$(hp_config_ports "$WORK/pretty.json" | tr '\n' ' ' | sed 's/ $//')" "5333 5330 5332"
printf '%d checks, %d failures\n' "$CHECKS" "$FAILURES"
[ "$FAILED" = 0 ] && echo "HEALTH PROBE TESTS PASSED"
exit "$FAILED"
@@ -0,0 +1,255 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# update_resources.sh: the content-verification path.
#
# The download URL is pinned to a commit, and the script now refuses to install
# a file whose git blob id does not match the one GitHub reports for that path
# and commit. The whole point of the check is that it *refuses*, so a driver
# that only walks the happy path would keep passing with the check deleted -
# this one drives the four outcomes and asserts what each leaves behind:
#
# 1. digest matches -> installed, .ver advanced
# 2. digest differs -> NOT installed, previous file and .ver intact
# 3. no digest from the API-> NOT installed (fail closed)
# 4. no local digest -> NOT installed (fail closed)
# plus: already up to date -> no download at all
#
# Everything external is stubbed (wget, jsonfilter, ucode, uci, flock) and the
# script's absolute paths are rewritten into a sandbox, so this is pure shell
# and runs on a laptop, in CI and on a target. The ucode stub answers with a
# fixed digest: what the *shipped* helper computes is
# tests/ucode/test_resource_blob_sha.sh's job, which checks it against git.
#
# Usage: sh tests/runtime/test_resource_update.sh <repo-root> [work-dir]
set -u
ROOT="$(cd "${1:-.}" && pwd)"
WORK="${2:-/tmp/hp-resource-update-test}"
rm -rf "$WORK"
mkdir -p "$WORK/scripts" "$WORK/resources" "$WORK/run" "$WORK/bin"
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
}
# --- stage the script and its helper ----------------------------------------
cp "$ROOT/root/etc/homeproxy/scripts/update_resources.sh" "$WORK/scripts/"
cp "$ROOT/root/etc/homeproxy/scripts/resource_blob_sha.uc" "$WORK/scripts/"
# Rewrite the two absolute runtime paths. The anchors are asserted below: a sed
# that quietly stopped matching would leave the test writing to /etc.
sed -e "s|^RESOURCES_DIR=\"/etc/\$NAME/resources\"|RESOURCES_DIR=\"$WORK/resources\"|" \
-e "s|^RUN_DIR=\"/var/run/\$NAME\"|RUN_DIR=\"$WORK/run\"|" \
"$WORK/scripts/update_resources.sh" > "$WORK/scripts/update_resources.sh.new"
mv -f "$WORK/scripts/update_resources.sh.new" "$WORK/scripts/update_resources.sh"
for anchor in "RESOURCES_DIR=\"$WORK/resources\"" "RUN_DIR=\"$WORK/run\""; do
grep -qF "$anchor" "$WORK/scripts/update_resources.sh" || {
echo "FAIL: could not sandbox update_resources.sh - missing anchor: $anchor"
exit 1
}
done
# --- stubs ------------------------------------------------------------------
# wget serves three shapes, chosen by the URL: the commit list, the contents
# metadata, and the file itself. What the contents API reports and what the
# file contains are control files, so each case can make them agree or not.
cat > "$WORK/bin/wget" <<'EOF'
#!/bin/sh
url=""
out=""
while [ $# -gt 0 ]; do
case "$1" in
-O) out="$2"; shift 2 ;;
-O-|--spider) shift ;;
--timeout=*|--header) shift 2 ;;
*) url="$1"; shift ;;
esac
done
emit() { [ -n "$out" ] && printf '%s' "$1" > "$out" || printf '%s' "$1"; }
case "$url" in
*api.github.com/*/commits*) emit "$(cat "$HP_T_API_COMMITS")" ;;
*api.github.com/*/contents*) emit "$(cat "$HP_T_API_CONTENTS")" ;;
*)
if [ -n "${HP_T_WGET_FAIL:-}" ]; then exit 1; fi
emit "$(cat "$HP_T_BODY")"
;;
esac
exit 0
EOF
# jsonfilter: answer the two expressions the script actually asks for. A stub
# that simply returned "the first sha" also answered the commit-date query with
# the commit sha, which silently made the expected .ver string wrong - the real
# jsonfilter reads the expression it is handed, so this one does too.
cat > "$WORK/bin/jsonfilter" <<'EOF'
#!/bin/sh
expr=""
for a in "$@"; do expr="$a"; done
payload="$(cat)"
case "$expr" in
*commit.committer.date*)
printf '%s' "$payload" | sed -n 's/.*"date"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1
;;
*)
printf '%s' "$payload" | sed -n 's/.*"sha"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1
;;
esac
EOF
# ucode: the shipped helper's contract, answered from a control file. An empty
# control file means "cannot compute" (the helper exits 1 and prints nothing).
cat > "$WORK/bin/ucode" <<'EOF'
#!/bin/sh
[ -s "$HP_T_LOCAL_BLOB" ] || exit 1
cat "$HP_T_LOCAL_BLOB"
EOF
# uci/flock are only reached for the token and the lock; both are no-ops here.
cat > "$WORK/bin/uci" <<'EOF'
#!/bin/sh
exit 1
EOF
cat > "$WORK/bin/flock" <<'EOF'
#!/bin/sh
exit 0
EOF
chmod +x "$WORK/bin/"*
PATH="$WORK/bin:$PATH"
export PATH
# The script's shebang is /bin/sh, so run it under the strictest /bin/sh this
# host has: dash rejects the multi-digit file descriptor and the `&>` that
# busybox ash and bash accept greedily, and "exec: 200: not found" was how that
# reached CI. On a target there is no dash and busybox ash is the real thing.
RUN_SH="sh"
if command -v dash > "/dev/null" 2>&1; then
RUN_SH="dash"
fi
echo "running update_resources.sh under: $RUN_SH"
# --- fixtures ---------------------------------------------------------------
API_SHA="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
LOCAL_SHA="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
OUTPUT_SHA="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
cat > "$WORK/api-commits.json" <<EOF
[{"sha":"$API_SHA","commit":{"committer":{"date":"2026-01-02T03:04:05Z"}}}]
EOF
write_contents_json() {
# write_contents_json <blob-sha-or-empty>
if [ -n "$1" ]; then
printf '{"name":"ipv4.txt","size":6,"sha":"%s"}' "$1" > "$WORK/api-contents.json"
else
printf '{"name":"ipv4.txt","size":6}' > "$WORK/api-contents.json"
fi
}
printf 'fresh\n' > "$WORK/body.txt"
printf 'stale-installed-copy\n' > "$WORK/resources/china_ip4.txt"
printf 'OLDVERSION' > "$WORK/resources/china_ip4.ver"
export HP_T_API_COMMITS="$WORK/api-commits.json"
export HP_T_API_CONTENTS="$WORK/api-contents.json"
export HP_T_BODY="$WORK/body.txt"
run_case() {
# run_case <local-blob-file-content> <contents-api-sha>
printf '%s' "$1" > "$WORK/local-blob"
write_contents_json "$2"
export HP_T_LOCAL_BLOB="$WORK/local-blob"
"$RUN_SH" "$WORK/scripts/update_resources.sh" china_ip4 > "$WORK/stdout" 2>&1
echo $?
}
reset_state() {
printf 'stale-installed-copy\n' > "$WORK/resources/china_ip4.txt"
printf 'OLDVERSION' > "$WORK/resources/china_ip4.ver"
rm -f "$WORK/run/homeproxy.log" "$WORK/resources/china_ip4.updated_at"
}
echo "== case 1: the digest matches -> installed =="
reset_state
rc="$(run_case "$LOCAL_SHA" "$LOCAL_SHA")"
expect "exit status 0" "$rc" "0"
expect "the new list was installed" "$(cat "$WORK/resources/china_ip4.txt")" "fresh"
expect "the .ver was advanced to the resolved commit" \
"$(cat "$WORK/resources/china_ip4.ver")" "2026-01-02 $API_SHA"
expect "the success is logged" \
"$(grep -c 'Successfully updated via' "$WORK/run/homeproxy.log")" "1"
echo "== case 2: the digest differs -> refused, previous state kept =="
reset_state
rc="$(run_case "$OUTPUT_SHA" "$LOCAL_SHA")"
expect "exit status non-zero" "$rc" "1"
expect "the installed list is untouched" \
"$(cat "$WORK/resources/china_ip4.txt")" "stale-installed-copy"
expect "the .ver is untouched" "$(cat "$WORK/resources/china_ip4.ver")" "OLDVERSION"
expect "no .updated_at was written" "$([ -e "$WORK/resources/china_ip4.updated_at" ] && echo yes || echo no)" "no"
expect "the refusal is logged with both ids" \
"$(grep -c "does not match the content of commit $API_SHA" "$WORK/run/homeproxy.log")" "1"
expect "the downloaded copy was discarded" "$([ -e "$WORK/run/ipv4.txt" ] && echo yes || echo no)" "no"
echo "== case 3: no blob id from the API -> refused =="
reset_state
rc="$(run_case "$LOCAL_SHA" "")"
expect "exit status non-zero" "$rc" "1"
expect "the installed list is untouched" \
"$(cat "$WORK/resources/china_ip4.txt")" "stale-installed-copy"
expect "the .ver is untouched" "$(cat "$WORK/resources/china_ip4.ver")" "OLDVERSION"
expect "the reason is logged" \
"$(grep -c 'GitHub reports no blob id' "$WORK/run/homeproxy.log")" "1"
# Not merely "it refused": with the API check skipped, the comparison still
# refuses (an empty API id never equals a real one), so without this the check
# could be deleted and the case would still pass on the wrong reason.
expect "the API-missing path is the one taken" \
"$(grep -c 'does not match the content of commit' "$WORK/run/homeproxy.log")" "0"
echo "== case 4: no local digest -> refused =="
reset_state
rc="$(run_case "" "$LOCAL_SHA")"
expect "exit status non-zero" "$rc" "1"
expect "the installed list is untouched" \
"$(cat "$WORK/resources/china_ip4.txt")" "stale-installed-copy"
expect "the reason is logged" \
"$(grep -c 'cannot compute the blob id' "$WORK/run/homeproxy.log")" "1"
expect "the local-digest path is the one taken" \
"$(grep -c 'does not match the content of commit' "$WORK/run/homeproxy.log")" "0"
echo "== case 5: already at the resolved commit -> nothing downloaded =="
reset_state
printf '2026-01-02 %s' "$API_SHA" > "$WORK/resources/china_ip4.ver"
: > "$WORK/run/homeproxy.log"
rc="$(run_case "$LOCAL_SHA" "$LOCAL_SHA")"
expect "exit status 3 (up to date)" "$rc" "3"
expect "the installed list is untouched" \
"$(cat "$WORK/resources/china_ip4.txt")" "stale-installed-copy"
expect "it says so in the log" \
"$(grep -c 'already at the latest version' "$WORK/run/homeproxy.log")" "1"
printf '%d checks, %d failures\n' "$CHECKS" "$FAILURES"
if [ "$FAILED" != "0" ]; then
echo "RESOURCE UPDATE TESTS FAILED"
echo "--- last run output ---"
cat "$WORK/stdout"
exit 1
fi
echo "RESOURCE UPDATE TESTS PASSED"
exit 0
@@ -0,0 +1,882 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Runtime orchestration trace test.
#
# Drives root/etc/init.d/homeproxy through a stubbed environment and records
# exactly what the orchestration does: which commands run, in what order, with
# which arguments, and which files end up where. The trace is compared against
# tests/fixtures/runtime/trace.golden.txt.
#
# Two baselines live in tests/fixtures/runtime/:
#
# trace.pre-pr05.txt captured from the 517-line init script before PHASE 7
# moved the dnsmasq/fw4/net/service plumbing out of it.
# It is the record that the extraction itself was
# behaviour-preserving (360 identical lines across three
# scenarios, commit c2aeac5).
# trace.golden.txt the baseline after the health-gate fix. The
# difference between the two files IS the intentional
# change: start_service now waits for hp_wait_service
# before logging "started", records known-good only
# after that gate passed, and reload_service rolls back
# through start instead of duplicating the gate.
# `diff trace.pre-pr05.txt trace.golden.txt` is the
# review artifact.
#
# Scenario D is the regression test for the P0: a candidate configuration whose
# mixed_port is already taken must NOT be recorded as known-good, and a reload
# must roll back to the previous configuration.
#
# Usage: sh tests/runtime/test_runtime_extraction.sh <repo-root> [work-dir]
#
# HP_INITD=<path> drive another init script
# HP_GOLDEN=<path> compare against another baseline
# HP_UPDATE_GOLDEN=1 rewrite the baseline instead of comparing
ROOT="${1:-.}"
# Per-run by default rather than a fixed /tmp path. This script has two
# callers (tests/run.sh and tests/ucode/run.sh) and one of them passed no work
# dir, so two concurrent suite runs shared /tmp/hp-runtime-trace and deleted
# each other's sandbox mid-test - the reproducible symptom was "could not
# sandbox ... missing anchor". tests/runtime/test_config_transaction.sh already
# defaults to mktemp -d.
WORK="${2:-$(mktemp -d "${TMPDIR:-/tmp}/hp-runtime-trace.XXXXXX")}"
OWN_WORK=0
[ -n "${2:-}" ] || OWN_WORK=1
# The dnsmasq conf path is the one shared resource left: it must stay at
# /tmp/etc/dnsmasq.conf.hp_test because the payload is what derives the section
# name, so it cannot move into $WORK. Concurrent runs therefore take turns on
# it, via mkdir (atomic everywhere, no flock dependency), with a bounded wait.
#
# The lock records the owner PID and its start time. A fixed mkdir lock with
# no owner information left the directory behind whenever a run was SIGKILLed,
# and the next run then burned the full two-minute timeout and failed - a
# "flaky" test whose real cause was a stale artifact. Now a lock whose holder
# is gone, or that is older than the stale threshold, is reclaimed instead of
# waited out. The pid is written immediately after mkdir; a crash in that
# microsecond-wide window is covered by the short no-pid grace below.
DNSMASQ_LOCK="/tmp/etc/.hp-runtime-trace.lock"
LOCK_TIMEOUT=120
# Reclaim a lock held longer than this before the full timeout: a live but
# wedged holder should not cost the next run two minutes. Well above the
# few seconds this test needs, so a healthy run is never stolen.
LOCK_STALE=90
mkdir -p "$(dirname "$DNSMASQ_LOCK")"
_lock_tries=0
while ! mkdir "$DNSMASQ_LOCK" 2>/dev/null; do
owner="$(cat "$DNSMASQ_LOCK/pid" 2>/dev/null || true)"
started="$(cat "$DNSMASQ_LOCK/started" 2>/dev/null || true)"
now="$(date +%s)"
case "$owner" in ''|*[!0-9]*) owner="" ;; esac
case "$started" in ''|*[!0-9]*) started="" ;; esac
if [ -n "$owner" ] && ! kill -0 "$owner" 2>/dev/null; then
echo "WARN: reclaiming stale lock $DNSMASQ_LOCK (holder pid $owner is gone)" >&2
rm -rf "$DNSMASQ_LOCK"
continue
fi
if [ -n "$started" ] && [ "$((now - started))" -gt "$LOCK_STALE" ]; then
echo "WARN: reclaiming lock $DNSMASQ_LOCK (held for $((now - started))s)" >&2
rm -rf "$DNSMASQ_LOCK"
continue
fi
if [ -z "$owner" ] && [ "$_lock_tries" -ge 5 ]; then
echo "WARN: reclaiming lock $DNSMASQ_LOCK (no owner pid was ever written)" >&2
rm -rf "$DNSMASQ_LOCK"
continue
fi
_lock_tries=$((_lock_tries + 1))
if [ "$_lock_tries" -gt "$LOCK_TIMEOUT" ]; then
echo "FAIL: another run has held $DNSMASQ_LOCK for over $LOCK_TIMEOUT seconds"
exit 1
fi
sleep 1
done
printf '%s\n' "$$" > "$DNSMASQ_LOCK/pid"
date +%s > "$DNSMASQ_LOCK/started"
# A trap rather than a line at the end: this script exits early on several
# failure paths, and the first version of this cleanup was inserted into the
# middle of one of them. Release only if this run still owns the lock (the
# pid/started files make that check possible), so a reclaimed lock is never
# removed out from under its new owner.
cleanup() {
if [ "$(cat "$DNSMASQ_LOCK/pid" 2>/dev/null || true)" = "$$" ]; then
rm -rf "$DNSMASQ_LOCK"
fi
[ "$OWN_WORK" = 1 ] && rm -rf "$WORK"
}
trap cleanup EXIT INT TERM
ROOT="$(cd "$ROOT" && pwd)"
SCRIPTS="$ROOT/root/etc/homeproxy/scripts"
GOLDEN="${HP_GOLDEN:-$ROOT/tests/fixtures/runtime/trace.golden.txt}"
INITD="${HP_INITD:-$ROOT/root/etc/init.d/homeproxy}"
TRACE="$WORK/trace.txt"
SANDBOX="$WORK/box"
BIN="$WORK/bin"
# The dnsmasq conf-dir path is absolute in the payload; the section-name
# lookup is keyed off this file name.
DNSMASQ_CONF="/tmp/etc/dnsmasq.conf.hp_test"
rm -rf "$WORK"
mkdir -p "$SANDBOX/etc/homeproxy/scripts/runtime" \
"$SANDBOX/etc/homeproxy/resources" \
"$SANDBOX/var/run/homeproxy" \
"$SANDBOX/dnsmasq" \
"$SANDBOX/sbin" \
"$SANDBOX/init.d" \
"$SANDBOX/etc/crontabs" \
"$BIN"
# The auto-update entry is installed by editing the crontab in place, so the
# file has to exist in the sandbox: on a laptop /etc/crontabs/root does not, the
# `sed -i` fails and the run logs a warning the target never produces. Seeded
# with a stale entry so the delete path is actually exercised.
printf '# existing entry\n0 2 * * * /etc/init.d/acme renew\n0 2 * * * /x #homeproxy_autosetup\n' \
> "$SANDBOX/etc/crontabs/root"
# Stand-ins for the absolute init scripts the runtime calls. They record the
# call and succeed, so the trace is the same everywhere and the "Warning: failed
# to restart ..." lines a laptop produced cannot leak into the golden.
for initd in dnsmasq cron miniupnpd; do
cat > "$SANDBOX/init.d/$initd" <<'EOF'
#!/bin/sh
printf 'init.d/%s %s\n' "$(basename "$0")" "$*" >> "$TRACE"
exit 0
EOF
chmod +x "$SANDBOX/init.d/$initd"
done
# --- the payload the init script expects ---------------------------------
cp "$SCRIPTS/runtime/"*.sh "$SANDBOX/etc/homeproxy/scripts/runtime/"
cp "$SCRIPTS/fw4_names.sh" "$SANDBOX/etc/homeproxy/scripts/fw4_names.sh"
# One line each is enough: the snippet generator rewrites these lists, it
# does not parse them.
printf 'example.com\nfoo.example.org\n' > "$SANDBOX/etc/homeproxy/resources/gfw_list.txt"
printf 'example.cn\n' > "$SANDBOX/etc/homeproxy/resources/china_list.txt"
printf 'proxy.example.net\n' > "$SANDBOX/etc/homeproxy/resources/proxy_list.txt"
# Makes the conf-dir resolver take its success path.
mkdir -p "$(dirname "$DNSMASQ_CONF")"
printf 'conf-dir=%s\n' "$SANDBOX/dnsmasq" > "$DNSMASQ_CONF"
# Makes the ujail branches reachable off-target.
: > "$SANDBOX/sbin/ujail"
chmod +x "$SANDBOX/sbin/ujail"
# --- stubs: every external command the orchestration can reach -----------
stub() {
cat > "$BIN/$1" <<'EOF'
#!/bin/sh
printf '%s %s\n' "$(basename "$0")" "$*" >> "$TRACE"
exit 0
EOF
chmod +x "$BIN/$1"
}
# C2: the functional health probes run `nslookup` and `nc` after the
# process/listener gate, so both have to exist in the sandbox PATH - otherwise
# the host's own binaries would answer and the trace would depend on the host
# (which is the one thing this test exists to prevent). The generic stub
# reports success, so a healthy scenario stays quiet: the probes only log when
# they fail or cannot run.
for cmd in nft fw4 utpl pgrep chown chmod nslookup nc; do
stub "$cmd"
done
# `ip` is the one command whose exit status drives control flow: teardown
# drains duplicate rules with `while ip rule del ...; do :; done`, which only
# terminates because deleting a non-existent rule fails. A stub that always
# succeeds would spin forever.
cat > "$BIN/ip" <<'EOF'
#!/bin/sh
printf 'ip %s\n' "$*" >> "$TRACE"
case "$*" in
*"rule del"*) exit 1 ;;
esac
exit 0
EOF
chmod +x "$BIN/ip"
# The conf-dir resolver derives the dnsmasq section name from `uci show`.
cat > "$BIN/uci" <<'EOF'
#!/bin/sh
printf '%s %s\n' uci "$*" >> "$TRACE"
case "$*" in
*"show dhcp.@dnsmasq[0]"*) echo "dhcp.hp_test= dnsmasq" ;;
esac
exit 0
EOF
chmod +x "$BIN/uci"
# `uname -r` decides the GSO workaround; pin it so traces agree.
cat > "$BIN/uname" <<'EOF'
#!/bin/sh
printf '%s %s\n' uname "$*" >> "$TRACE"
[ "$1" = "-r" ] && echo "6.12.94" || echo "Linux"
EOF
chmod +x "$BIN/uname"
# `sing-box version -n` feeds the version gate and the "started" log line.
cat > "$BIN/sing-box" <<'EOF'
#!/bin/sh
printf '%s %s\n' sing-box "$*" >> "$TRACE"
[ "$1" = "version" ] && echo "1.14.0"
exit 0
EOF
chmod +x "$BIN/sing-box"
# dnsmasq --version decides whether the DNS snippets may carry nftset=. The
# stand-in reports the dnsmasq-full feature set, which is what a device able to
# run this package has; runtime/dns.sh's hp_dnsmasq_has_nftset() reads it. Not
# traced: it is a feature probe, not part of the orchestration under test.
cat > "$BIN/dnsmasq" <<'EOF'
#!/bin/sh
[ "$1" = "--version" ] && {
echo "Dnsmasq version 2.93 Copyright (c) 2000-2024 Simon Kelley"
echo "Compile time options: IPv6 GNU-getopt no-DBus UBus no-i18n no-IDN DHCP DHCPv6 no-Lua TFTP conntrack no-ipset nftset auth DNSSEC no-ID loop-detect inotify dumpfile"
}
exit 0
EOF
chmod +x "$BIN/dnsmasq"
# `ucode -S generate_*.uc` is where the live configuration comes from. The
# stub writes whichever mixed_port the fixture currently declares; whether that
# configuration can actually run is decided from its content by
# `hp_test_broken` below, so the answer follows the live file rather than a
# sticky marker (the rollback copies the old file over the live one without
# regenerating, and the gate has to see that as healthy).
cat > "$BIN/ucode" <<'EOF'
#!/bin/sh
printf 'ucode %s\n' "$*" >> "$TRACE"
for arg in "$@"; do
case "$arg" in
*generate_client.uc)
# One field per line, like the generator's own `%.J` output: the
# runtime reads this file (hp_config_ports, hp_config_port_for_tag), so
# a compact fixture would test a shape production never writes.
printf '{\n\t"log": {},\n\t"inbounds": [\n\t\t{\n\t\t\t"tag": "dns-in",\n\t\t\t"listen_port": %s\n\t\t},\n\t\t{\n\t\t\t"tag": "mixed-in",\n\t\t\t"listen_port": %s\n\t\t}\n\t],\n\t"outbounds": []\n}\n' \
"${HP_CFG_dns_port:-5333}" "${HP_CFG_mixed_port:-5330}" > "$HP_TEST_RUN_DIR/sing-box-c.json"
;;
*generate_server.uc)
printf '{\n\t"log": {},\n\t"inbounds": [],\n\t"outbounds": []\n}\n' > "$HP_TEST_RUN_DIR/sing-box-s.json"
;;
esac
done
exit 0
EOF
chmod +x "$BIN/ucode"
# The fault model: a configuration that declares the port the fixture pretends
# is already taken cannot come up. Keyed on the live file's content, so it
# stays correct when the rollback replaces that file without regenerating.
cat > "$BIN/hp_test_broken" <<'EOF'
#!/bin/sh
side="$1"
[ -n "${HP_TEST_OCCUPIED_PORT:-}" ] || exit 1
[ -f "$HP_TEST_RUN_DIR/sing-box-$side.json" ] || exit 1
# The pattern has to accept the generator's own `%.J` shape, which is
# `"listen_port": 5399` - with a space. The first version hardcoded the compact
# form, so it only injected the fault because the fixture happened to be
# compact; the moment the fixture was made faithful, the gate stopped seeing a
# broken configuration. The trailing (`[^0-9]`|end) keeps 5399 from matching
# 53990.
grep -qE "\"listen_port\"[[:space:]]*:[[:space:]]*$HP_TEST_OCCUPIED_PORT([^0-9]|$)" "$HP_TEST_RUN_DIR/sing-box-$side.json" || exit 1
exit 0
EOF
chmod +x "$BIN/hp_test_broken"
# `ubus call service list` output is not parsed; `jsonfilter` answers from the
# fixture state instead, which keeps the stub independent of jsonfilter's real
# expression language. ubus does not write to the trace itself: its output
# always goes through a pipe, so a second writer would race the trace order.
cat > "$BIN/ubus" <<'EOF'
#!/bin/sh
exit 0
EOF
chmod +x "$BIN/ubus"
# procd's view of the instance. Down + exit_code 1 whenever the live
# configuration for that side cannot bind - which is what procd reports while
# it restarts a crashing instance.
cat > "$BIN/jsonfilter" <<'EOF'
#!/bin/sh
printf 'jsonfilter %s\n' "$*" >> "$TRACE"
side="c"
case "$*" in
*"sing-box-s"*) side="s" ;;
esac
down=no
hp_test_broken "$side" && down=yes
case "$*" in
*".instances["*)
if [ "$down" = "yes" ]; then
printf '{"running":false,"exit_code":1}\n'
else
printf '{"running":true}\n'
fi
;;
*"@.running"*)
[ "$down" = "yes" ] && echo "false" || echo "true"
;;
*"@.exit_code"*)
[ "$down" = "yes" ] && echo "1"
;;
esac
exit 0
EOF
chmod +x "$BIN/jsonfilter"
# The listen table. The ports are taken from the LIVE configuration, the way
# the real gate derives them, and the occupied port is held by the process that
# took it - the case that makes a naive "is the port in the table?" check
# useless.
cat > "$BIN/netstat" <<'EOF'
#!/bin/sh
printf 'netstat %s\n' "$*" >> "$TRACE"
echo "Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name"
live="$HP_TEST_RUN_DIR/sing-box-c.json"
ports=$(grep -o '"listen_port"[^0-9]*[0-9]*' "$live" 2>/dev/null | grep -o '[0-9]*$' | tr '\n' ' ')
[ -n "$ports" ] || ports="${HP_CFG_mixed_port:-5330} ${HP_CFG_dns_port:-5333}"
for p in $ports; do
if [ -n "${HP_TEST_OCCUPIED_PORT:-}" ] && [ "$p" = "$HP_TEST_OCCUPIED_PORT" ]; then
printf 'tcp 0 0 :::%s :::* LISTEN 77/socat\n' "$p"
else
printf 'tcp 0 0 :::%s :::* LISTEN 4242/sing-box\n' "$p"
fi
done
exit 0
EOF
chmod +x "$BIN/netstat"
# The WAN wait polls `ip route show default`, `ip -6 route show default` and
# `ifstatus wan`; the first two produce no output through the generic stub, so
# ifstatus has to report the interface up or every scenario would spin for a
# minute. `sleep` is neutralised for the same reason: the 15-sample health
# budget must not cost 15 real seconds.
cat > "$BIN/ifstatus" <<'EOF'
#!/bin/sh
printf 'ifstatus %s\n' "$*" >> "$TRACE"
echo '{ "up": true }'
exit 0
EOF
chmod +x "$BIN/ifstatus"
cat > "$BIN/sleep" <<'EOF'
#!/bin/sh
printf 'sleep %s\n' "$*" >> "$TRACE"
exit 0
EOF
chmod +x "$BIN/sleep"
# --- harness: UCI + procd + the log sink ---------------------------------
# Sourced *into* the same shell as the init script, exactly like rc.common
# does, so the init script's globals and functions resolve as on a router.
cat > "$WORK/harness.sh" <<'EOF'
# Fixture UCI values. config_get assigns through its first argument - an
# OpenWrt config_get is a variable-setting function, not a getter.
HP_CFG_routing_mode="bypass_mainland_china"
HP_CFG_proxy_mode="tun"
HP_CFG_main_node="n1"
HP_CFG_main_udp_node="nil"
HP_CFG_default_outbound="direct-out"
HP_CFG_ipv6_support="0"
HP_CFG_auto_update="0"
HP_CFG_auto_update_time="2"
HP_CFG_server_enabled="0"
HP_CFG_table_mark="100"
HP_CFG_tproxy_mark="101"
HP_CFG_tun_mark="102"
HP_CFG_tun_name="singtun0"
HP_CFG_dns_port="5333"
HP_CFG_mixed_port="5330"
config_load() { printf 'config_load %s\n' "$*" >> "$TRACE"; }
# The fixture is keyed by "<section>_<option>" (server_enabled, mixed_port, ...)
# while config_get/config_get_bool receive section and option separately. The
# lookup has to try the section-qualified name first: reading only the bare
# option name meant `server_enabled=1` on a scenario was silently ignored -
# scenario B's "with server" registered no server instance at all - which is a
# coverage hole, not a fixture convenience.
config_get() {
__cg_var="$1"; __cg_sec="$2"; __cg_opt="$3"; __cg_def="$4"
eval "__cg_val=\"\${HP_CFG_${__cg_sec}_${__cg_opt}:-}\""
[ -n "$__cg_val" ] || eval "__cg_val=\"\${HP_CFG_${__cg_opt}:-}\""
[ -n "$__cg_val" ] || __cg_val="$__cg_def"
eval "$__cg_var=\"\$__cg_val\""
}
config_get_bool() {
__cg_var="$1"; __cg_sec="$2"; __cg_opt="$3"; __cg_def="$4"
eval "__cg_val=\"\${HP_CFG_${__cg_sec}_${__cg_opt}:-}\""
[ -n "$__cg_val" ] || eval "__cg_val=\"\${HP_CFG_${__cg_opt}:-}\""
[ -n "$__cg_val" ] || __cg_val="$__cg_def"
eval "$__cg_var=\"\$__cg_val\""
}
# procd is not available off-target; record the calls instead, so a lost or
# reordered instance parameter shows up as a trace diff.
procd_open_instance() { printf 'procd_open_instance %s\n' "$*" >> "$TRACE"; }
procd_close_instance() { printf 'procd_close_instance\n' >> "$TRACE"; }
procd_set_param() { printf 'procd_set_param %s\n' "$*" >> "$TRACE"; }
procd_append_param() { printf 'procd_append_param %s\n' "$*" >> "$TRACE"; }
procd_add_jail() { printf 'procd_add_jail %s\n' "$*" >> "$TRACE"; }
procd_add_jail_mount() { printf 'procd_add_jail_mount %s\n' "$*" >> "$TRACE"; }
procd_add_jail_mount_rw() { printf 'procd_add_jail_mount_rw %s\n' "$*" >> "$TRACE"; }
procd_add_reload_trigger() { printf 'procd_add_reload_trigger %s\n' "$*" >> "$TRACE"; }
procd_add_interface_trigger() { printf 'procd_add_interface_trigger %s\n' "$*" >> "$TRACE"; }
# rc.common's lifecycle, modelled faithfully:
#
# start() { rc_procd start_service "$@"; service_started; }
# stop() { stop_service "$@"; procd_kill ...; service_stopped; }
#
# start_service only *registers* the procd instances; procd runs them when the
# service is closed. That is why the health gate lives in service_started()
# and why this harness has to call it - a harness that reported the instances
# healthy as soon as they were registered hid exactly that bug once.
start() { start_service; service_started; }
stop() { stop_service; service_stopped; }
# The real log() stamps a timestamp and appends to a file, which would make
# the trace nondeterministic.
hp_trace_log() { printf 'log %s\n' "$*" >> "$TRACE"; }
EOF
# --- run one scenario ----------------------------------------------------
# $1 scenario name, $2 the action (start|reload), then the HP_CFG_* overrides.
run_scenario() {
scenario="$1"; shift
action="$1"; shift
rm -rf "$SANDBOX/var/run/homeproxy" "$SANDBOX/dnsmasq/dnsmasq-homeproxy.d" \
"$SANDBOX/dnsmasq/dnsmasq-homeproxy.conf" "$SANDBOX/etc/homeproxy/cache.db" \
"$SANDBOX/etc/homeproxy/ruleset" "$SANDBOX/etc/homeproxy/certs"
mkdir -p "$SANDBOX/var/run/homeproxy" "$SANDBOX/dnsmasq"
printf '\n===== scenario: %s (%s) =====\n' "$scenario" "$action" >> "$TRACE"
(
PATH="$BIN:$PATH"; export PATH
TRACE="$TRACE"; export TRACE
HP_TEST_RUN_DIR="$SANDBOX/var/run/homeproxy"; export HP_TEST_RUN_DIR
# harness.sh assigns the fixture defaults, so it has to be sourced
# BEFORE the per-scenario overrides - the other order silently
# resets every override and makes all scenarios run the same
# configuration.
. "$WORK/harness.sh"
for kv in "$@"; do
eval "HP_CFG_${kv%%=*}=\"${kv#*=}\""
export "HP_CFG_${kv%%=*}"
done
export HP_CFG_routing_mode HP_CFG_proxy_mode HP_CFG_main_node \
HP_CFG_main_udp_node HP_CFG_default_outbound HP_CFG_ipv6_support \
HP_CFG_auto_update HP_CFG_auto_update_time HP_CFG_server_enabled \
HP_CFG_table_mark HP_CFG_tproxy_mark HP_CFG_tun_mark \
HP_CFG_tun_name HP_CFG_dns_port HP_CFG_mixed_port \
HP_TEST_OCCUPIED_PORT HP_TEST_DOWN
# A certificate on disk makes the per-file chown/chmod in
# hp_prepare_runtime_files() visible in the trace: an uploaded key
# lands as root:0600 and the jailed server needs it handed to the
# sing-box user, or it cannot start.
if [ "$HP_CFG_server_enabled" = "1" ]; then
mkdir -p "$SANDBOX/etc/homeproxy/certs"
printf '%s\n' '-----BEGIN PRIVATE KEY-----' \
> "$SANDBOX/etc/homeproxy/certs/server_privatekey.pem"
fi
. "$WORK/initd.sh"
# Override the log sink the init script just defined.
log() { hp_trace_log "$*"; }
# Scenario D needs a good configuration in place first, so that the
# rollback has a real target to restore. The candidate is switched
# to the already-taken port only for the reload.
if [ "$action" = "reload" ]; then
unset HP_TEST_OCCUPIED_PORT
start
printf 'prime start rc=%d\n' "$?" >> "$TRACE"
if [ "${HP_TEST_DISABLE_CLIENT_AFTER:-0}" = "1" ]; then
# The second phase turns the client off instead of breaking
# its port (scenario H).
HP_CFG_main_node=nil
export HP_CFG_main_node
else
HP_CFG_mixed_port="${HP_TEST_OCCUPIED_AFTER:-5399}"
HP_TEST_OCCUPIED_PORT="${HP_TEST_OCCUPIED_AFTER:-5399}"
export HP_CFG_mixed_port HP_TEST_OCCUPIED_PORT
fi
reload_service
printf 'reload_service rc=%d\n' "$?" >> "$TRACE"
elif [ "$action" = "start-twice" ]; then
# Scenario I: two plain starts in a row, with the client switched
# off in between. This is LuCI's Start button on a router whose
# node was just removed - a *start*, not a reload, which is the
# path that has no rollback.
start
printf 'prime start rc=%d\n' "$?" >> "$TRACE"
HP_CFG_main_node=nil
export HP_CFG_main_node
start
printf 'start rc=%d\n' "$?" >> "$TRACE"
else
start
printf 'start rc=%d\n' "$?" >> "$TRACE"
fi
stop
printf 'stop rc=%d\n' "$?" >> "$TRACE"
) >/dev/null 2>&1
# Record the files that ended up in the output directories, with
# contents, so a changed snippet or a lost known-good copy is visible.
for f in "$SANDBOX/var/run/homeproxy"/* \
"$SANDBOX/var/run/homeproxy/known-good"/* \
"$SANDBOX/etc/homeproxy/cache.db" \
"$SANDBOX/dnsmasq"/* \
"$SANDBOX/dnsmasq/dnsmasq-homeproxy.d"/*; do
[ -f "$f" ] || continue
printf 'file %s: ' "${f#"$SANDBOX"/}" >> "$TRACE"
tr '\n' '|' < "$f" >> "$TRACE"
printf '\n' >> "$TRACE"
done
}
# --- stage the init script under test ------------------------------------
# Rewrite the absolute paths to the sandbox. Anchor guards: a silent no-op
# rewrite would make this test compare two unsandboxed runs and pass for the
# wrong reason.
#
# `/sbin/ujail` lives in init.d before PR-05 and in runtime/service.sh after
# it, so the rewrite has to cover both.
# The runtime modules talk to a few ABSOLUTE paths, and those are the test's
# only host dependence: `/etc/init.d/dnsmasq` exists on a router (so the real
# script runs, with its own side effects) and not on a laptop, and `/sbin/ujail`
# likewise. Rewriting them into the sandbox is what makes the trace identical
# on macOS, on the CI runner and on the target - the first on-target run differed
# from line 11 purely because dnsmasq's init script was there to run.
sandbox_paths() {
sed -e "s#-x \"/sbin/ujail\"#-x \"$SANDBOX/sbin/ujail\"#g" \
-e "s#/etc/init.d/dnsmasq#$SANDBOX/init.d/dnsmasq#g" \
-e "s#/etc/init.d/cron#$SANDBOX/init.d/cron#g" \
-e "s#/etc/init.d/miniupnpd#$SANDBOX/init.d/miniupnpd#g" \
-e "s#/etc/crontabs/root#$SANDBOX/etc/crontabs/root#g" \
"$1" > "$1.tmp" && mv "$1.tmp" "$1"
}
sed -e "s#^HP_DIR=\"/etc/homeproxy\"#HP_DIR=\"$SANDBOX/etc/homeproxy\"#" \
-e "s#^RUN_DIR=\"/var/run/homeproxy\"#RUN_DIR=\"$SANDBOX/var/run/homeproxy\"#" \
"$INITD" > "$WORK/initd.sh"
sandbox_paths "$WORK/initd.sh"
for module in "$SANDBOX/etc/homeproxy/scripts/runtime"/*.sh; do
sandbox_paths "$module"
done
for anchor in "HP_DIR=\"$SANDBOX/etc/homeproxy\"" \
"RUN_DIR=\"$SANDBOX/var/run/homeproxy\""; do
if ! grep -qF "$anchor" "$WORK/initd.sh"; then
echo "FAIL: could not sandbox $INITD - missing anchor: $anchor"
rm -f "$DNSMASQ_CONF"
exit 1
fi
done
for anchor in "$SANDBOX/sbin/ujail" "$SANDBOX/init.d/dnsmasq" "$SANDBOX/init.d/cron" \
"$SANDBOX/etc/crontabs/root"; do
if ! grep -qF "$anchor" "$WORK/initd.sh" "$SANDBOX/etc/homeproxy/scripts/runtime"/*.sh; then
echo "FAIL: could not sandbox $anchor - the trace would depend on the host"
rm -f "$DNSMASQ_CONF"
exit 1
fi
done
# Scenario A: TUN client, bypass_mainland_china, no server, no ipv6.
run_scenario "A-tun-bypass-client-only" start \
proxy_mode=tun routing_mode=bypass_mainland_china \
main_node=n1 main_udp_node=nil server_enabled=0 ipv6_support=0
# Scenario B: tproxy client with a UDP node + ipv6, gfwlist, server enabled,
# auto-update cron on.
run_scenario "B-tproxy-gfwlist-ipv6-with-server" start \
proxy_mode=redirect_tproxy routing_mode=gfwlist \
main_node=n1 main_udp_node=u1 server_enabled=1 ipv6_support=1 \
auto_update=1 auto_update_time=3
# Scenario C: neither side configured -> the early return must stay early.
run_scenario "C-nothing-configured" start \
proxy_mode=tun routing_mode=bypass_mainland_china \
main_node=nil main_udp_node=nil server_enabled=0 ipv6_support=0
# Scenario D: the P0 regression. A healthy configuration is started and
# recorded, then a candidate whose mixed_port is already taken is reloaded.
# The gate must reject it, the previous known-good must survive, and the
# rollback must bring the service back on the old configuration.
run_scenario "D-health-gate-rollback" reload \
proxy_mode=tun routing_mode=bypass_mainland_china \
main_node=n1 main_udp_node=nil server_enabled=0 ipv6_support=0 \
HP_TEST_OCCUPIED_AFTER=5399
# Scenario E: custom routing mode. It is the mode whose start path has to
# prepare both runtime files - the ruleset directory and the shared cache.db -
# and whose client jail has to carry HP_DIR, because a local rule-set may point
# anywhere under it. The trace records all three, so this scenario fails if the
# custom client stops being jailed or stops getting its runtime files.
run_scenario "E-custom-routing-runtime-files" start \
proxy_mode=tun routing_mode=custom \
main_node=n1 main_udp_node=nil default_outbound=direct-out \
server_enabled=0 ipv6_support=0
# Scenario F: custom routing with no default outbound. The client's "main node"
# reference is mode-dependent (config.main_node vs routing.default_outbound), and
# a regression that reads the wrong UCI key in this mode makes an unconfigured
# router look configured: it starts a client whose generated route block has no
# `final`, so every intercepted connection leaves through sing-box's built-in
# direct outbound instead of the proxy. The service must refuse to start.
run_scenario "F-custom-routing-nothing-configured" start \
proxy_mode=tun routing_mode=custom \
main_node=n1 main_udp_node=nil default_outbound=nil \
server_enabled=0 ipv6_support=0
# Scenario G: a plain `start` whose configuration cannot come up. The
# intercept rules are installed *before* the health gate runs, and the start
# path has no rollback (that is reload-only) - so the gate failure has to
# release them itself. Otherwise the router's own clients keep being
# redirected to a listener that is not there: the LAN loses DNS and TCP, not
# just the proxy. HP_TEST_OCCUPIED_PORT makes the generated configuration
# unbindable, and the env-prefix assignment is how it reaches the scenario
# (run_scenario's key=value arguments are HP_CFG_* overrides).
HP_TEST_OCCUPIED_PORT=5399 run_scenario "G-start-health-gate-failure" start \
proxy_mode=redirect_tproxy routing_mode=bypass_mainland_china \
main_node=n1 main_udp_node=nil server_enabled=0 ipv6_support=0 \
mixed_port=5399
# Scenario H: two plain starts, the second with the client switched off but the
# server still enabled (the node was removed, the server side stayed). LuCI's
# Start button takes this path, not reload, and it has no rollback - so the
# intercept layer the first start installed has to be released by the start
# itself. The "neither side is configured" branch does not cover this case:
# with the server still on, the run gets past it and would leave the LAN
# pointed at the client's dead redirect port. This is also what makes the
# reload path's "keep the DNS layer across the stop" safe: without it,
# disabling the client would leave the snippets installed forever.
# Scenario I: the DNS snippet writer fails (an unusable infra.dns_port). The
# snippets are half of the intercept layer - without them the LAN keeps
# resolving through the ISP while the nft rules still send its DNS to sing-box's
# dns-in - and the writer returns non-zero for exactly this case rather than
# installing an empty snippet. A start must say so and must not promote the
# configuration to known-good, which is the same rule a failed firewall apply
# already follows.
run_scenario "I-dns-snippet-write-failure" start \
proxy_mode=tun routing_mode=bypass_mainland_china \
main_node=n1 main_udp_node=nil server_enabled=0 ipv6_support=0 \
mixed_port=5398 dns_port="70000"
run_scenario "H-start-disables-client" start-twice \
proxy_mode=redirect_tproxy routing_mode=bypass_mainland_china \
main_node=n1 main_udp_node=nil server_enabled=1 ipv6_support=0
rm -f "$DNSMASQ_CONF"
# Strip the sandbox prefix so the trace is portable.
sed "s#$SANDBOX/##g" "$TRACE" > "$TRACE.norm"
if [ "${HP_UPDATE_GOLDEN:-0}" = "1" ]; then
mkdir -p "$(dirname "$GOLDEN")"
cp "$TRACE.norm" "$GOLDEN"
echo "PASS: golden trace written to $GOLDEN ($(wc -l < "$GOLDEN") lines)"
exit 0
fi
if [ ! -f "$GOLDEN" ]; then
echo "FAIL: golden trace $GOLDEN is missing"
exit 1
fi
# The decision must not depend on `diff`. This test also runs on the target
# (tests/ucode/run.sh stages it there), and busybox has `cmp` but often no
# `diff` - the first on-target run failed with "diff: not found" and looked
# exactly like an orchestration change. The golden snapshot tests already do
# it this way: `cmp` decides, `diff` only formats the diagnosis.
# Scenario E specifically: the golden comparison above would report a difference
# without saying which scenario caused it, and this is the one whose whole point
# is the absence of a warning. It has to run after TRACE.norm is built - the
# first version of this check read the file before it existed and therefore
# always passed.
if awk '/^===== scenario: E-custom-routing-runtime-files/,0' "$TRACE.norm" \
| grep -q "failed to change the ownership of the runtime files"; then
echo "FAIL: custom mode reports a chown failure for a runtime file it should have created"
awk '/^===== scenario: E-custom-routing-runtime-files/,0' "$TRACE.norm" \
| grep -n "failed to change the ownership" | head -2
exit 1
fi
# Scenario F pins the mode-dependent read itself. It has to assert on the
# "not configured" log rather than on the generated file, because the failure
# mode is a client that starts and produces a config with no route.final - it
# looks healthy from every other angle (process up, ports bound).
if awk '/^===== scenario: F-custom-routing-nothing-configured/,0' "$TRACE.norm" \
| grep -q "^log Neither client nor server is configured\. Service not started\.$"; then
echo "PASS: custom mode with no default outbound does not start a client"
else
echo "FAIL: custom mode with default_outbound=nil still started a client"
awk '/^===== scenario: F-custom-routing-nothing-configured/,0' "$TRACE.norm" | head -25
exit 1
fi
# A3: a reload generates each enabled side exactly once, and activates that
# artifact. Before the candidate staging this scenario ran the client generator
# twice per reload - once as the preflight, once again from start_service after
# stop_service() had deleted the validated file - so the bytes that ran were not
# the bytes that were checked. Counted inside the reload rather than over the
# whole scenario, because the priming start legitimately generates once itself.
reload_generates=$(awk '/^log Reloading service\.\.\.$/,/^reload_service rc=/' "$TRACE.norm" \
| grep -c '^ucode -S etc/homeproxy/scripts/generate_client\.uc$')
if [ "$reload_generates" = "1" ]; then
echo "PASS: a reload generates the client configuration exactly once"
else
echo "FAIL: a reload ran the client generator $reload_generates time(s), expected 1"
awk '/^log Reloading service\.\.\.$/,/^reload_service rc=/' "$TRACE.norm" \
| grep -n 'ucode -S\|Activating the' | head -5
exit 1
fi
if awk '/^log Reloading service\.\.\.$/,/^reload_service rc=/' "$TRACE.norm" \
| grep -q '^log Activating the client configuration validated by the reload preflight\.$'; then
echo "PASS: the reload activates the configuration it validated"
else
echo "FAIL: the reload did not activate its validated candidate"
exit 1
fi
# Scenario G asserts the behaviour, not just its trace: a start whose client
# cannot come up must release the intercept layer (otherwise the LAN keeps
# being redirected into it), and a healthy start must not pay for that.
if awk '/^===== scenario: G-start-health-gate-failure/,/^===== scenario: H-/' "$TRACE.norm" \
| grep -q "^log Reverting the intercept layer"; then
echo "PASS: a failed start releases the intercept layer"
else
echo "FAIL: a failed start left the redirect rules and the DNS snippets installed"
awk '/^===== scenario: G-start-health-gate-failure/,/^===== scenario: H-/' "$TRACE.norm" | head -40
exit 1
fi
if awk '/^===== scenario: G-start-health-gate-failure/,/^===== scenario: H-/' "$TRACE.norm" \
| grep -q "^file var/run/homeproxy/known-good/"; then
echo "FAIL: a start that failed the health gate recorded a known-good configuration"
exit 1
else
echo "PASS: a failed start records no known-good configuration"
fi
# Scenario I: a failed snippet install must be reported and must not promote the
# configuration. The DNS layer is half the intercept, and the writer returns
# non-zero instead of installing an empty snippet - so the start has to notice.
SCEN_I="/^===== scenario: I-dns-snippet-write-failure/"
if awk "$SCEN_I,0" "$TRACE.norm" | grep -q "^log Error: the dnsmasq snippets were not installed"; then
echo "PASS: a failed DNS snippet install is reported by the start"
else
echo "FAIL: a failed DNS snippet install was silently ignored"
awk "$SCEN_I,0" "$TRACE.norm" | head -30
exit 1
fi
# The known-good pair persists across scenarios, so the dump shows whatever the
# last successful start recorded. Scenario I generates a configuration nothing
# else does (mixed_port 5398), so the copy is identifiable by content: if the
# dump holds 5398 the failed start promoted over it, if it holds anything else
# the previous known-good survived - which is the required behaviour.
if awk "$SCEN_I,0" "$TRACE.norm" \
| grep '^file var/run/homeproxy/known-good/sing-box-c.json:' \
| grep -q '5398'; then
echo "FAIL: a start whose DNS snippets did not install recorded a known-good configuration"
exit 1
else
echo "PASS: a start whose DNS snippets did not install records no known-good"
fi
if awk '/^===== scenario: A-tun-bypass-client-only/,/^===== scenario: B-/' "$TRACE.norm" \
| grep -q "^log Reverting the intercept layer"; then
echo "FAIL: a healthy start released the intercept layer it had just installed"
exit 1
else
echo "PASS: a healthy start does not release the intercept layer"
fi
# P1-8: a reload must leave the DNS layer alone. Its stop used to remove the
# snippets and restart dnsmasq, then start put them back and restarted it again
# - a plaintext DNS window and a full cache flush for a configuration that did
# not change. The writer's content comparison is what makes keeping them safe:
# the re-render below has to report that nothing changed.
if awk '/^===== scenario: D-health-gate-rollback/,/^===== scenario: E-/' "$TRACE.norm" \
| grep -q "^log dnsmasq snippets unchanged, skipping the restart\.$"; then
echo "PASS: a reload keeps the DNS snippets and does not restart dnsmasq for them"
else
echo "FAIL: a reload re-rendered the DNS layer instead of keeping it"
awk '/^===== scenario: D-health-gate-rollback/,/^===== scenario: E-/' "$TRACE.norm" \
| grep -n "dnsmasq" | head -8
exit 1
fi
# Scenario H: the client was switched off and the start has to take the
# intercept layer down with it (LuCI's Start button, no rollback involved).
if awk '/^===== scenario: H-start-disables-client/,0' "$TRACE.norm" \
| grep -q "^log Reverting the intercept layer"; then
echo "PASS: a start that finds the client switched off releases the intercept layer"
else
echo "FAIL: switching the client off left the redirect rules and DNS snippets in place"
awk '/^===== scenario: H-start-disables-client/,0' "$TRACE.norm" | tail -40
exit 1
fi
if awk '/^===== scenario: H-start-disables-client/,0' "$TRACE.norm" \
| grep -q "^file dnsmasq/dnsmasq-homeproxy"; then
echo "FAIL: the dnsmasq snippets survived the client being switched off"
awk '/^===== scenario: H-start-disables-client/,0' "$TRACE.norm" | grep "^file dnsmasq"
exit 1
else
echo "PASS: the dnsmasq snippets are gone after the client is switched off"
fi
if cmp -s "$GOLDEN" "$TRACE.norm"; then
echo "PASS: runtime orchestration matches $(basename "$GOLDEN")"
# Counted from the golden rather than hardcoded: a scenario added without
# this line would quietly claim the old number.
echo " ($(wc -l < "$GOLDEN") trace lines across $(grep -c '^===== scenario: ' "$GOLDEN") scenarios)"
exit 0
fi
echo "FAIL: the orchestration changed:"
if command -v diff > "/dev/null" 2>&1; then
diff -u "$GOLDEN" "$TRACE.norm" | head -80
else
# No diff here: name the first differing lines and show both sides, which
# is enough to diagnose without the tool.
awk '
NR == FNR { golden[FNR] = $0; n = FNR; next }
{
if ($0 != golden[FNR] && shown < 20) {
printf " line %d\n golden: %s\n actual: %s\n", FNR, golden[FNR], $0;
shown++;
}
}
END {
if (n > FNR)
printf " (golden has %d more lines than the trace)\n", n - FNR;
}
' "$GOLDEN" "$TRACE.norm"
fi
exit 1
exit 0
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,184 @@
{
"s_vless_ws": {
"type": "vless",
"tag": "cfg-s_vless_ws-in",
"listen": "10.0.0.1",
"listen_port": 8443,
"users": [
{
"name": "cfg-s_vless_ws-server",
"uuid": "3af88561-9c69-4b19-8f7e-f08d580bc339"
}
],
"tls": {
"enabled": true,
"server_name": "a.example.com",
"alpn": "h2",
"min_version": "1.2",
"certificate_provider": {
"type": "acme",
"domain": [
"a.example.com"
],
"data_directory": "<HP_DIR>/certs",
"default_server_name": "a.example.com",
"email": "admin@example.com",
"provider": "letsencrypt",
"key_type": "p256"
}
},
"transport": {
"type": "ws",
"path": "/ws",
"headers": {
"Host": "a.example.com"
},
"max_early_data": 2048
}
},
"s_anytls": {
"type": "anytls",
"tag": "cfg-s_anytls-in",
"listen": "10.0.0.1",
"listen_port": 8444,
"padding_scheme": "stop=8",
"users": [
{
"name": "cfg-s_anytls-server",
"password": "secret"
}
],
"tls": {
"enabled": true,
"server_name": "b.example.com",
"certificate_provider": {
"type": "acme",
"domain": [
"b.example.com"
],
"data_directory": "<HP_DIR>/certs",
"default_server_name": "b.example.com",
"email": "admin@example.com",
"provider": "zerossl"
}
}
},
"s_hy2": {
"type": "hysteria2",
"tag": "cfg-s_hy2-in",
"listen": "10.0.0.1",
"listen_port": 8445,
"up_mbps": 100,
"down_mbps": 500,
"obfs": {
"type": "salamander",
"password": "obfspass"
},
"masquerade": "https://example.com",
"users": [
{
"name": "cfg-s_hy2-server",
"password": "secret"
}
],
"tls": {
"enabled": true,
"server_name": "c.example.com",
"certificate_provider": {
"type": "acme",
"domain": [
"c.example.com"
],
"data_directory": "<HP_DIR>/certs",
"default_server_name": "c.example.com",
"email": "admin@example.com",
"provider": "letsencrypt"
}
}
},
"s_trojan_grpc": {
"type": "trojan",
"tag": "cfg-s_trojan_grpc-in",
"listen": "10.0.0.1",
"listen_port": 8446,
"users": [
{
"name": "cfg-s_trojan_grpc-server",
"password": "secret"
}
],
"multiplex": {
"enabled": true,
"padding": true
},
"tls": {
"enabled": true,
"server_name": "d.example.com",
"certificate_provider": {
"type": "acme",
"domain": [
"d.example.com"
],
"data_directory": "<HP_DIR>/certs",
"default_server_name": "d.example.com",
"email": "admin@example.com",
"provider": "letsencrypt"
}
},
"transport": {
"type": "grpc",
"service_name": "gs"
}
},
"s_ss": {
"type": "shadowsocks",
"tag": "cfg-s_ss-in",
"listen": "10.0.0.1",
"listen_port": 8447,
"udp_timeout": "300s",
"network": "tcp",
"method": "aes-256-gcm",
"password": "secret",
"tls": null
},
"s_snell": {
"type": "snell",
"tag": "cfg-s_snell-in",
"listen": "::",
"listen_port": 8448,
"psk": "psk123456789012",
"version": 5,
"obfs_mode": "http",
"tls": null
},
"s_tuic": {
"type": "tuic",
"tag": "cfg-s_tuic-in",
"listen": "10.0.0.1",
"listen_port": 8449,
"congestion_control": "bbr",
"auth_timeout": "3s",
"heartbeat": "10s",
"users": [
{
"name": "cfg-s_tuic-server",
"password": "secret",
"uuid": "3af88561-9c69-4b19-8f7e-f08d580bc339"
}
],
"tls": {
"enabled": true,
"server_name": "e.example.com",
"certificate_provider": {
"type": "acme",
"domain": [
"e.example.com"
],
"data_directory": "<HP_DIR>/certs",
"default_server_name": "e.example.com",
"email": "admin@example.com",
"provider": "letsencrypt"
}
}
}
}
@@ -0,0 +1,258 @@
{
"n_vless_reality_ws": {
"type": "vless",
"tag": "cfg-n_vless_reality_ws-out",
"routing_mark": 100,
"server": "b.example.com",
"server_port": 443,
"tcp_fast_open": true,
"tcp_multi_path": false,
"udp_fragment": true,
"packet_encoding": "xudp",
"uuid": "3af88561-9c69-4b19-8f7e-f08d580bc339",
"flow": "xtls-rprx-vision",
"multiplex": {
"enabled": true,
"protocol": "h2mux",
"max_connections": 4,
"min_streams": 2,
"max_streams": 8,
"padding": true
},
"tls": {
"enabled": true,
"server_name": "b.example.com",
"alpn": "h2,http/1.1",
"min_version": "1.2",
"max_version": "1.3",
"handshake_timeout": "10s",
"cipher_suites": "TLS_AES_128_GCM_SHA256",
"utls": {
"enabled": true,
"fingerprint": "chrome"
},
"reality": {
"enabled": true,
"public_key": "4UAg690QziXeelpUJoAmXiim0gfQESSiJkNfvF-o8Uw",
"short_id": "abcd"
}
},
"transport": {
"type": "ws",
"path": "/ws?ed=2048",
"headers": {
"Host": "b.example.com"
},
"max_early_data": 2048,
"early_data_header_name": "Sec-WebSocket-Protocol"
}
},
"n_vmess_http": {
"type": "vmess",
"tag": "cfg-n_vmess_http-out",
"routing_mark": 100,
"server": "l.example.com",
"server_port": 443,
"packet_encoding": "packetaddr",
"uuid": "66666666-7777-8888-9999-aaaaaaaaaaaa",
"security": "auto",
"global_padding": true,
"tls": {
"enabled": true,
"server_name": "l.example.com",
"insecure": true,
"utls": {
"enabled": true,
"fingerprint": "firefox"
}
},
"transport": {
"type": "http",
"host": "l.example.com",
"path": "/path",
"method": "GET",
"idle_timeout": "30s",
"ping_timeout": "15s"
}
},
"n_trojan_grpc": {
"type": "trojan",
"tag": "cfg-n_trojan_grpc-out",
"routing_mark": 100,
"server": "h.example.com",
"server_port": 443,
"password": "trojan-pwd",
"tls": {
"enabled": true,
"server_name": "h.example.com"
},
"transport": {
"type": "grpc",
"service_name": "gs",
"permit_without_stream": true
}
},
"n_hysteria": {
"type": "hysteria",
"tag": "cfg-n_hysteria-out",
"routing_mark": 100,
"server": "m.example.com",
"server_port": 443,
"auth_str": "hy1-auth",
"up_mbps": 100,
"down_mbps": 500,
"hop_interval": "30s",
"obfs": "hy1-obfs",
"tls": {
"enabled": true,
"server_name": "m.example.com"
}
},
"n_hy2": {
"type": "hysteria2",
"tag": "cfg-n_hy2-out",
"routing_mark": 100,
"server": "k.example.com",
"server_port": 443,
"password": "hy2-pwd",
"up_mbps": 100,
"down_mbps": 500,
"hop_interval": "30s",
"hop_interval_max": "60s",
"server_ports": "20000:30000",
"obfs": {
"type": "salamander",
"password": "hy2-obfs"
},
"bbr_profile": "conservative",
"disable_chrome_parrot": true,
"tls": {
"enabled": true,
"server_name": "k.example.com"
}
},
"n_tuic": {
"type": "tuic",
"tag": "cfg-n_tuic-out",
"routing_mark": 100,
"server": "j.example.com",
"server_port": 443,
"uuid": "11111111-2222-3333-4444-555555555555",
"password": "tuic-pwd",
"congestion_control": "cubic",
"udp_relay_mode": "native",
"udp_over_stream": false,
"zero_rtt_handshake": true,
"heartbeat": "3s",
"tls": {
"enabled": true,
"server_name": "j.example.com",
"alpn": "h3"
}
},
"n_shadowsocks": {
"type": "shadowsocks",
"tag": "cfg-n_shadowsocks-out",
"routing_mark": 100,
"server": "d.example.com",
"server_port": 8389,
"password": "ss-password",
"method": "aes-256-gcm",
"plugin": "obfs-local",
"plugin_opts": "obfs=http;obfs-host=d.example.com",
"udp_over_tcp": {
"enabled": true,
"version": 2
}
},
"n_snell": {
"type": "snell",
"tag": "cfg-n_snell-out",
"routing_mark": 100,
"server": "c.example.com",
"server_port": 8388,
"psk": "snell-psk",
"userkey": "snell-userkey",
"reuse": true,
"version": 4,
"obfs_mode": "http",
"obfs_host": "c.example.com"
},
"n_socks": {
"type": "socks",
"tag": "cfg-n_socks-out",
"routing_mark": 100,
"server": "g.example.com",
"server_port": 1080,
"username": "bob",
"password": "socks-pwd",
"version": "5",
"udp_over_tcp": {
"enabled": true,
"version": 2
}
},
"n_http": {
"type": "http",
"tag": "cfg-n_http-out",
"routing_mark": 100,
"server": "f.example.com",
"server_port": 8080,
"username": "alice",
"password": "http-pwd",
"tls": {
"enabled": true,
"server_name": "f.example.com"
}
},
"n_shadowtls": {
"type": "shadowtls",
"tag": "cfg-n_shadowtls-out",
"routing_mark": 100,
"server": "i.example.com",
"server_port": 443,
"password": "shadowtls-pwd",
"version": 3,
"tls": {
"enabled": true,
"server_name": "i.example.com"
}
},
"n_anytls": {
"type": "anytls",
"tag": "cfg-n_anytls-out",
"routing_mark": 100,
"server": "e.example.com",
"server_port": 443,
"password": "anytls-pwd",
"idle_session_check_interval": "30s",
"idle_session_timeout": "90s",
"min_idle_session": 2,
"tls": {
"enabled": true,
"server_name": "e.example.com"
}
},
"n_ssh": {
"type": "ssh",
"tag": "cfg-n_ssh-out",
"routing_mark": 100,
"server": "n.example.com",
"server_port": 22,
"user": "root",
"private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACAY0ADv5F4CV2TlGkNg2c3z3zBfeCDjLEusnrtYyxjNpAAAAJgit4/AIreP\nwAAAAAtzc2gtZWQyNTUxOQAAACAY0ADv5F4CV2TlGkNg2c3z3zBfeCDjLEusnrtYyxjNpA\nAAAEC4rRAsuFAQRHkpIDid/Cj0n1PS6o88D3xsDoYkDUVx0xjQAO/kXgJXZOUaQ2DZzfPf\nMF94IOMsS6yeu1jLGM2kAAAADmhvbWVwcm94eS10ZXN0AQIDBAUGBw==\n-----END OPENSSH PRIVATE KEY-----",
"client_version": "SSH-2.0-OpenSSH_8.9p1",
"host_key": [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBjQAO/kXgJXZOUaQ2DZzfPfMF94IOMsS6yeu1jLGM2k homeproxy-test"
],
"host_key_algorithms": [
"ssh-ed25519"
]
},
"n_direct": {
"type": "direct",
"tag": "cfg-n_direct-out",
"routing_mark": 100,
"tcp_fast_open": true
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+259
View File
@@ -0,0 +1,259 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Build a self-contained ucode toolchain on Linux so tests/run.sh can execute
# the ucode part locally (and in CI) without copying the checkout to an
# OpenWrt device. This is the only toolchain script on purpose: the target is
# Linux, the CI runner is Linux, and `sing-box check` accepts the Linux-only
# routing_mark only there - an off-target layer anywhere else would have to
# weaken the very configuration it exists to validate.
#
# Usage:
# sh tests/toolchain/build-ucode-linux.sh [prefix] [build-dir]
#
# Defaults: prefix=~/.local/ucode-testbed build-dir=/tmp/ucode-build
#
# After a successful build the script prints the PATH/shim setup that
# tests/run.sh expects. See tests/README.md for the runner contract.
set -eu
PREFIX="${1:-$HOME/.local/ucode-testbed}"
BUILD="${2:-/tmp/ucode-build}"
# The package Makefile targets sing-box 1.14; tests/ucode/test_generators.sh
# feeds the generated configs to `sing-box check`, so the version has to match.
SINGBOX_VERSION="1.14.0"
# ucode is pinned to the revision ImmortalWrt/OpenWrt snapshots shipped as
# package version 2026.01.16~85922056. That ucode requires a terminating ';'
# after `export function ... }` and rejects object destructuring; upstream
# relaxed the trailing-';' rule afterwards (openwrt/openwrt@main and
# immortalwrt/immortalwrt@master now pin b885dd0f, whose own test suite uses
# the semicolon-free form). Building ucode from the default branch therefore
# lets target-incompatible modules pass CI, which is exactly how the A/B
# refactor shipped five modules that no router could parse.
# tests/ucode/test_ucode_grammar.sh fails loudly if this pin is ever moved to
# a permissive revision, so the mismatch cannot come back unnoticed.
UCODE_REV="${UCODE_REV:-85922056ef7abeace3cca3ab28bc1ac2d88e31b1}"
# json-c (libubox) and libmd (digest module) come from apt. Their pkg-config
# files live under /usr/lib/<arch>-linux-gnu/pkgconfig, which pkg-config
# already searches; we just need to make sure the .pc files are installed.
if ! pkg-config --exists json-c; then
echo "ERROR: json-c is required (apt install libjson-c-dev)" >&2
exit 1
fi
if ! pkg-config --exists libmd; then
echo "ERROR: libmd is required (apt install libmd-dev)" >&2
exit 1
fi
mkdir -p "$PREFIX" "$PREFIX/bin" "$BUILD"
PREFIX="$(cd "$PREFIX" && pwd)"
BUILD="$(cd "$BUILD" && pwd)"
# BUILD_WITH_INSTALL_RPATH links against the final install location from the
# start, so `cmake --install` never has to rewrite the load commands. Linux
# uses RUNPATH and standard .so naming, so no install_name_tool step is needed
# here at all.
#
# CMAKE_INSTALL_RPATH is what BUILD_WITH_INSTALL_RPATH actually bakes in:
# without it the installed binaries get an empty RUNPATH, and the first
# `$PREFIX/bin/ucode` invocation in this script (and every later one, plus
# the test suite) dies with
# libucode.so.0: cannot open shared object file
# $PREFIX/lib is not a default loader search path, so this has to be set.
CMAKE_COMMON="-DCMAKE_INSTALL_PREFIX=$PREFIX
-DCMAKE_INSTALL_LIBDIR=lib
-DCMAKE_BUILD_TYPE=Release
-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON
-DCMAKE_INSTALL_RPATH=$PREFIX/lib
-DCMAKE_PREFIX_PATH=$PREFIX"
# Optional second argument pins the checkout to a revision (branch, tag or
# full commit id). Repositories without one keep tracking their default
# branch; only ucode is pinned, because upstream relaxed grammar rules the
# package still has to stay compatible with.
clone() {
repo="$1"
rev="${2:-}"
dir="$BUILD/$(basename "$repo")"
if [ -d "$dir/.git" ]; then
git -C "$dir" fetch --depth 1 origin >/dev/null 2>&1 || true
else
git clone --depth 1 "https://github.com/$repo.git" "$dir"
fi
if [ -n "$rev" ]; then
if ! git -C "$dir" fetch --depth 1 origin "$rev" >/dev/null 2>&1; then
echo "ERROR: could not fetch $repo at $rev" >&2
exit 1
fi
git -C "$dir" checkout -q --detach FETCH_HEAD
# A build tree left over from another revision must not be reused.
rm -rf "$dir/build"
fi
echo "$dir"
}
# The full luci tree is large and only one directory is needed; fetch it in
# partial/sparse mode so the toolchain build stays quick.
clone_sparse() {
repo="$1"
path="$2"
dir="$BUILD/$(basename "$repo")"
if [ ! -d "$dir/.git" ]; then
git clone --depth 1 --filter=blob:none --sparse \
"https://github.com/$repo.git" "$dir" >/dev/null
fi
git -C "$dir" sparse-checkout set "$path" >/dev/null
echo "$dir"
}
build() {
dir="$1"
shift
echo "==> building $(basename "$dir")"
# shellcheck disable=SC2086
cmake -S "$dir" -B "$dir/build" $CMAKE_COMMON "$@" >/dev/null
cmake --build "$dir/build" -j"$(nproc)" >/dev/null
cmake --install "$dir/build" >/dev/null
}
# libubox first: it provides ubox, blobmsg_json and the headers that both the
# uci and ubus modules (and ucode's own ubus/uci plugins) link against.
build "$(clone openwrt/libubox)" -DBUILD_LUA=OFF -DBUILD_EXAMPLES=OFF
# libuci: the cursor() implementation used by every generator and the RPC daemon.
build "$(clone openwrt/uci)" -DBUILD_LUA=OFF
# libubus: ubus.connect() is used by generate_client.uc and the RPC daemon.
build "$(clone openwrt/ubus)" -DBUILD_LUA=OFF
# ucode last, so its CMake detects all three libraries and enables the
# uci/ubus/uloop plugins. ffi / nl80211 / rtnl are off: not needed by the
# tests, and their headers are not in the OpenWrt tree on a default clone.
build "$(clone jow-/ucode "$UCODE_REV")" \
-DUBUS_SUPPORT=ON -DUCI_SUPPORT=ON -DULOOP_SUPPORT=ON \
-DFFI_SUPPORT=OFF -DNL80211_SUPPORT=OFF -DRTNL_SUPPORT=OFF \
-DDEBUG_SUPPORT=ON -DZLIB_SUPPORT=ON
# liblucihttp: homeproxy.uc, parse_uri.uc and the RPC daemon import
# luci.http for urldecode_params()/urlencode()/urldecode(). luci.http is a
# thin ucode wrapper around this C module, so it has to exist for the
# generators to even load. Built after ucode, whose headers it needs.
#
# Two flags are load-bearing, and omitting them is what left this step
# failing on CI (ucode/module.h not found, then a bare -lucode):
# -I$PREFIX/include upstream adds no include path for ucode, whose
# module.h in turn pulls in json-c (from apt, so that part resolves).
# -L$PREFIX/lib upstream links `-lucode` by bare name without a
# find_library() call, relying on libucode being in a default search
# path. $PREFIX is not one.
LUCIHTTP_DIR="$(clone jow-/lucihttp)"
echo "==> building lucihttp"
cmake -S "$LUCIHTTP_DIR" -B "$LUCIHTTP_DIR/build" $CMAKE_COMMON \
-DBUILD_LUA=OFF -DBUILD_TESTS=OFF \
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
-DCMAKE_SHARED_LINKER_FLAGS="-L$PREFIX/lib" \
-DCMAKE_C_FLAGS="-Wno-error -I$PREFIX/include" >/dev/null
cmake --build "$LUCIHTTP_DIR/build" -j"$(nproc)" >/dev/null
cmake --install "$LUCIHTTP_DIR/build" >/dev/null
# The luci.* ucode modules themselves (http.uc, sys.uc, ...) are pure sources
# shipped by the luci-base package; OpenWrt installs them to
# /usr/share/ucode/luci. Stage the same layout inside the prefix so
# `import { urldecode_params } from 'luci.http'` resolves locally.
LUCI_DIR="$(clone_sparse openwrt/luci modules/luci-base/ucode)"
mkdir -p "$PREFIX/share/ucode/luci"
cp "$LUCI_DIR"/modules/luci-base/ucode/*.uc "$PREFIX/share/ucode/luci/"
cp -R "$LUCI_DIR"/modules/luci-base/ucode/controller "$PREFIX/share/ucode/luci/" 2>/dev/null || true
cp -R "$LUCI_DIR"/modules/luci-base/ucode/template "$PREFIX/share/ucode/luci/" 2>/dev/null || true
# sing-box is not built here, but the generator cases run `sing-box check` and
# the package targets sing-box >= 1.14, so an older binary reports unknown
# fields ("handshake_timeout", "certificate_provider") and fails the fixtures.
# Take the official release binary unless a matching one already exists.
sb_version() {
if [ -x "$PREFIX/bin/sing-box" ]; then
"$PREFIX/bin/sing-box" version 2>/dev/null |
awk '/^sing-box version /{sub(/^sing-box version /,""); sub(/-.*/,""); print; exit}'
fi
}
if [ "$(sb_version)" != "$SINGBOX_VERSION" ]; then
echo "==> fetching sing-box $SINGBOX_VERSION"
sb_tar="sing-box-$SINGBOX_VERSION-linux-amd64.tar.gz"
sb_url="https://github.com/SagerNet/sing-box/releases/download/v$SINGBOX_VERSION/$sb_tar"
if ! curl -fsSL "$sb_url" -o "$BUILD/$sb_tar" >/dev/null 2>&1; then
echo "ERROR: could not download $sb_url" >&2
exit 1
fi
tar xzf "$BUILD/$sb_tar" -C "$BUILD"
cp "$BUILD/sing-box-$SINGBOX_VERSION-linux-amd64/sing-box" "$PREFIX/bin/sing-box"
chmod +x "$PREFIX/bin/sing-box"
fi
echo
echo "==> verifying"
"$PREFIX/bin/ucode" -e 'printf("ucode %s\n", ARGV[0] ?? "ok");'
printf ' ucode rev %s (pinned)\n' "$(git -C "$BUILD/ucode" rev-parse --short HEAD 2>/dev/null || echo unknown)"
printf ' sing-box %s\n' "$("$PREFIX/bin/sing-box" version 2>/dev/null | head -1)"
FAILED=0
# utpl is what tests/ucode/test_firewall_template.sh renders firewall_post.ut
# with. ucode installs it as a symlink to itself, so a toolchain without it
# would silently drop the firewall template check.
if [ -x "$PREFIX/bin/utpl" ]; then
printf ' utpl OK\n'
else
printf ' utpl MISSING\n'
FAILED=1
fi
# The pinned ucode must keep enforcing the strict grammar the package targets;
# the canary turns a permissive upstream revision into a build failure instead
# of a silently green test run. Requires utpl/ucode on PATH.
if PATH="$PREFIX/bin:$PATH" sh "$(dirname "$0")/../ucode/test_ucode_grammar.sh"; then
printf ' grammar strict (matches ImmortalWrt ucode)\n'
else
printf ' grammar MISMATCH - do not ship this toolchain\n'
FAILED=1
fi
for mod in fs math uci ubus digest zlib struct resolv socket lucihttp luci.http luci.sys; do
if "$PREFIX/bin/ucode" -e "import * as m from '$mod';" 2>/dev/null; then
printf ' module %-10s OK\n' "$mod"
else
printf ' module %-10s MISSING\n' "$mod"
FAILED=1
fi
done
# Exercise the exact parser helper the share-link code depends on, so a
# broken lucihttp binding is detected here rather than mid-refactor. The
# expected values mirror upstream semantics: '+' decodes to a space inside a
# value and a repeated key keeps the last occurrence.
if ! "$PREFIX/bin/ucode" -e '
import { urldecode_params } from "luci.http";
let p = urldecode_params("a=1&b=x+y&b=2");
(p.a == "1" && p.b == "2") || exit(1);
' 2>/dev/null; then
echo " luci.http urldecode_params MISMATCH"
FAILED=1
fi
echo
if [ "$FAILED" -eq 0 ]; then
echo "==> done"
else
echo "==> done WITH PROBLEMS (see MISSING/MISMATCH above)" >&2
exit 1
fi
echo "prefix: $PREFIX"
echo
echo "Add the toolchain to PATH (needed by tests/run.sh):"
echo " export PATH=\"$PREFIX/bin:\$PATH\""
echo "The prefix also carries the matching sing-box, so it must come before any"
echo "other sing-box in PATH (e.g. /usr/local/bin)."
exit 0
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Local stand-in for OpenWrt's /sbin/validate_data.
#
# homeproxy.uc validates hostnames, addresses and ports by shelling out to
# validate_data, which only exists on an OpenWrt target; without it the
# generators cannot run on a development host at all. This wrapper reproduces
# the contract the caller relies on -- exit 0 means valid, non-zero means
# invalid -- and delegates the checks themselves to the test double that the
# parser unit tests already use, so the validation logic stays in one place
# instead of growing a third copy.
#
# PINNED FIXTURE, NOT AN EQUIVALENT.
# Its `validation()` is an approximation of the real /sbin/validate_data and is
# meant to stay that way; do not "fix" a difference by making it match. What it
# covers: port, ip4addr, ip6addr, hostname, host. What the real one covers and
# this does not: macaddr, network, ip4prefix/ip6prefix, ipset, uinteger, range,
# and the rest of OpenWrt's list. Anything that needs those must run on a
# target - tests/ucode/run.sh exports HP_VALIDATE_DATA only off-target, and a
# device keeps the production binary. The differences are deliberate, which is
# why tests/ucode/test_mock_sync.sh compares the copied *string helpers* with
# production but explicitly excludes validation().
#
# Not shipped in the package: it only exists for the local testbed, see
# tests/README.md.
set -eu
HERE="$(cd "$(dirname "$0")" && pwd)"
REPO="$(cd "$HERE/../.." && pwd)"
datatype="${1:-}"
data="${2:-}"
[ -n "$datatype" ] && [ -n "$data" ] || exit 1
# The two values travel in the environment rather than being interpolated into
# the ucode source: a hostname containing a single quote used to close the
# string literal and turn the rest of the value into code, so this wrapper
# would report "invalid" for a valid name (and could in principle be made to
# run something).
HP_VD_TYPE="$datatype" HP_VD_DATA="$data" exec ucode -L "$HERE" -e '
import { validation } from "'"$REPO"'/tests/ucode/mocks/homeproxy.uc";
exit(validation(getenv("HP_VD_TYPE"), getenv("HP_VD_DATA")) === true ? 0 : 1);
'
@@ -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