#!/bin/sh # SPDX-License-Identifier: GPL-2.0-only # # Behaviour tests for the rpcd module (root/usr/share/rpcd/ucode/luci.homeproxy-pro). # # 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 [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-pro/scripts/." "$WORK/scripts/" # stage_rewrite : point a file's absolute /etc/homeproxy-pro 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-pro/certs. # The certificate staging prefix is moved too (it is a literal /tmp path inside # luci.homeproxy-pro): 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-pro/scripts/#$WORK/scripts/#g" \ -e "s#^const HP_DIR = '/etc/homeproxy-pro';#const HP_DIR = '$WORK';#" \ -e "s#^export const HP_DIR = '/etc/homeproxy-pro';#export const HP_DIR = '$WORK';#" \ -e "s#^const RUN_DIR = '/var/run/homeproxy-pro';#const RUN_DIR = '$WORK/run';#" \ -e "s#^export const RUN_DIR = '/var/run/homeproxy-pro';#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-pro.uc" "$WORK/scripts/homeproxy-pro.uc.new" mv -f "$WORK/scripts/homeproxy-pro.uc.new" "$WORK/scripts/homeproxy-pro.uc" stage_rewrite "$ROOT/root/usr/share/rpcd/ucode/luci.homeproxy-pro" "$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', 'debug_report', '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); /* --- debug_report ------------------------------------------------------- * * This one is worth more than a shape check, because the property that * makes it usable is the opposite of "works on my router": it has to * produce a complete, readable report on a machine that has none of the * runtime - no nft, no sing-box, no apk, no /etc/config/homeproxy-pro. That is * exactly this test host, so a regression that turns a failed probe into an * exception, or drops a section when its command is missing, fails here * rather than on a user's router where the report is the thing they are * relying on. */ import { isSecretKey } from '@@SCRIPTS@@/runtime/debug_report.uc'; const dr = rpc.debug_report.call({ args: {} }); check('debug_report succeeds where no runtime is installed', dr.result === true, dr.error); check('debug_report names the file it wrote', type(dr.path) === 'string' && length(dr.path) > 0); check('debug_report reports a non-zero size', dr.size > 0); const dr_text = readfile(sprintf('%s/run/debug.log', WORK)); check('the report was written where the ACL grants read', dr_text != null && length(dr_text) > 0); if (dr_text != null && length(dr_text) > 0) { check('the report carries a title', index(dr_text, '# HomeProxy ') != -1); check('the report carries a privacy notice', index(dr_text, '脱敏') != -1); /* Every section header, checked as a literal substring rather than a * regex: the headers carry CJK and full-width brackets, and a probe that * throws would take the whole report down rather than leave one section * missing - so "the section is still there" is the property worth pinning. */ for (let h in ['## 1. ', '## 2. ', '## 3. ', '## 4. ', '## 5. ', '## 6. ', '## 7. ', '## 8. ', '## 9. ', '## 10. ']) check(sprintf('the report still has section %s', h), index(dr_text, h) != -1); /* A missing command must be said out loud. A report that silently omits * the firewall dump reads exactly like "the firewall is empty", which is * the opposite of what happened. */ check('an unavailable probe is labelled, not omitted', index(dr_text, 'unavailable') != -1 || index(dr_text, '(no output)') != -1); } /* The redaction rule has no I/O to observe, so it is asserted directly: a * key that quietly stops being recognised is a credential in an issue * thread, and nothing else in this suite would notice. */ for (let k in ['password', 'uuid', 'secret', 'token', 'private_key', 'url', 'urls', 'subscription_urls', 'address', 'tls_sni', 'username', 'hysteria_obfs_password', 'tls_reality_public_key', 'tls_reality_short_id', 'ws_path', 'hysteria_obfs_method', 'auth']) check(sprintf('the key %s is redacted', k), isSecretKey(k)); /* ...and the other direction matters just as much: over-broad matching * would blank out the very switches the report exists to show. */ for (let k in ['proxy_mode', 'routing_mode', 'mixed_port', 'redirect_port', 'self_mark', 'tproxy_mark', 'log_level', 'ipv6_support', 'enabled', 'grouphash']) check(sprintf('the key %s is left readable', k), !isSecretKey(k)); /* The urltest lists are the reason `url` is anchored rather than a bare * substring: `main_urltest_nodes` contains the letters u-r-l, so an * unanchored pattern blanked the pool out of the report - and "is this node * still in the test pool" is exactly what the report is read for. These * three were found by a canary run, not by inspection. */ for (let k in ['main_urltest_nodes', 'main_urltest_tolerance', 'main_udp_urltest_nodes']) check(sprintf('the key %s is left readable (urltest, not url)', k), !isSecretKey(k)); /* grpc_servicename names the gRPC service, it is not a credential: masking * it would hide which transport a node uses, which is worth reporting. * Asserted on the readable side because the obvious guess is that anything * shaped like a service identifier should be hidden. */ check('the key grpc_servicename is left readable', !isSecretKey('grpc_servicename')); printf('rpc methods: %d checks, %d failures\n', checks, failures); exit(failures ? 1 : 0); DRIVER # The module ends with `return { 'luci.homeproxy-pro': 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#" -e "s#@@SCRIPTS@@#$WORK/scripts#g" \ "$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