🌈 Sync 2026-10-01 13:09:37

This commit is contained in:
github-actions[bot] committed 2026-10-01 13:09:37 +08:00
1 parent dceb6de0e4
commit 889951b6d0
116 files changed
+6376 -1093

No files matched your search

+1 -1
View File
@@ -293,7 +293,7 @@ Two further host differences are bridged so the remaining checks still run:
| `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-pro.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-pro.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/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. It also renders the client DNS tab to reach the inline `dns_server` validator, which pins the legacy `'wan'` literal: the dropdown stopped offering "WAN DNS (read from interface)" while the generator still maps `wan` to `wan_dns`, so a configuration that chose it while it was offered must stay saveable - every validator runs on save, and `wan` is neither a hostname nor an address. Reverse-proved: dropping the `'wan'` exception fails one check. |
| `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-pro` 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. |
+26 -3
View File
@@ -2077,6 +2077,22 @@ echo "== guard 45: the bootstrap resolver is derived, not configured =="
# - a second DNS address next to the China one made no sense to read;
# (b) a bare IP is the only usable value, because the option also accepts
# 'wan' and a DoH/DoT URL, and a hostname cannot resolve a hostname.
#
# (b) is asserted on the *validator*, not on a hand-rolled regex. The guard
# used to require the two shape tests this function used to carry
# (/^[0-9]+(\.[0-9]+){3}$/ and /^[0-9a-fA-F:]+$/), and that is exactly what let
# a real bug through: the second one matches any hostname made only of hex
# digits and colons - cafe, face, abc, add, dead:beef - and the first one never
# range-checked the octets, so 999.999.999.999 passed too (measured on the
# device, 2026-10-01). The bootstrap server is emitted with no
# domain_resolver by design, so any of those is unsatisfiable and sing-box
# refuses to start at all:
# FATAL create service: initialize DNS server[0]: missing domain resolver
# for domain server address
# isValidCIDR() - the same validator the firewall path runs every address
# through - is the shared definition of "is this an address", and the ucode
# test tests/ucode/test_build_guards.uc pins the behaviour (bare v4/v6 in,
# 'wan' / a DoH URL / a hostname / an out-of-range address out).
if grep -q "const addr = ctx.china_dns_server;" "$SCRIPTS/generator/dns.uc"; then
pass "the bootstrap address is taken from the China DNS server"
else
@@ -2084,12 +2100,19 @@ else
fail "separate option again, main-dns may fall back to the WAN resolver"
fi
if grep -qE "match\(addr, /\^\[0-9\]\+" "$SCRIPTS/generator/dns.uc" &&
grep -qE "match\(addr, /\^\[0-9a-fA-F:\]\+" "$SCRIPTS/generator/dns.uc"; then
pass "only a bare IP is accepted as the bootstrap address"
if grep -q "isValidCIDR(addr, 4) || isValidCIDR(addr, 6)" "$SCRIPTS/generator/dns.uc"; then
pass "only a bare IP is accepted as the bootstrap address (via isValidCIDR)"
else
fail "bootstrap_addr() no longer rejects a non-IP value - a DoH URL or the"
fail "literal 'wan' is a hostname, and using one to resolve a hostname is a cycle"
fail "(it must go through isValidCIDR; a hand-rolled shape test accepted"
fail "'cafe' and 999.999.999.999, which made sing-box refuse to start)"
fi
if grep -qE "match\(addr, /\^\[0-9" "$SCRIPTS/generator/dns.uc"; then
fail "a hand-rolled IP shape test is back in bootstrap_addr() - use isValidCIDR"
else
pass "no hand-rolled IP shape test in bootstrap_addr()"
fi
# The option is gone from the form and from the shipped config; leaving either
@@ -2,6 +2,7 @@
===== scenario: A-tun-bypass-client-only (start) =====
uci -q show dhcp.@dnsmasq[0]
config_load homeproxy-pro
ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1)
sing-box version -n
uname -r
ip route show default
@@ -152,6 +153,7 @@ file etc/homeproxy-pro/cache.db:
===== scenario: B-tproxy-gfwlist-ipv6-with-server (start) =====
uci -q show dhcp.@dnsmasq[0]
config_load homeproxy-pro
ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1)
sing-box version -n
uname -r
ip route show default
@@ -344,6 +346,7 @@ file var/run/homeproxy-pro/known-good/uci-snapshot.txt: homeproxy-pro.config.mai
===== scenario: C-nothing-configured (start) =====
uci -q show dhcp.@dnsmasq[0]
config_load homeproxy-pro
ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1)
sing-box version -n
log Neither client nor server is configured. Service not started.
config_load homeproxy-pro
@@ -423,6 +426,7 @@ file var/run/homeproxy-pro/fw4_post.nft:
===== scenario: D-health-gate-rollback (reload) =====
uci -q show dhcp.@dnsmasq[0]
config_load homeproxy-pro
ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1)
sing-box version -n
uname -r
ip route show default
@@ -555,6 +559,7 @@ init.d/miniupnpd restart
log Service stopped.
config_load homeproxy-pro
config_load homeproxy-pro
ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1)
sing-box version -n
uname -r
ip route show default
@@ -856,6 +861,7 @@ init.d/miniupnpd restart
log Service stopped.
config_load homeproxy-pro
config_load homeproxy-pro
ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1)
sing-box version -n
uname -r
ip route show default
@@ -1012,6 +1018,7 @@ file dnsmasq/dnsmasq-homeproxy-pro.d/redirect-dns.conf: no-poll|no-resolv|server
===== scenario: E-custom-routing-runtime-files (start) =====
uci -q show dhcp.@dnsmasq[0]
config_load homeproxy-pro
ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1)
sing-box version -n
uname -r
ip route show default
@@ -1163,6 +1170,7 @@ file etc/homeproxy-pro/cache.db:
===== scenario: F-custom-routing-nothing-configured (start) =====
uci -q show dhcp.@dnsmasq[0]
config_load homeproxy-pro
ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1)
sing-box version -n
log Neither client nor server is configured. Service not started.
config_load homeproxy-pro
@@ -1242,6 +1250,7 @@ file var/run/homeproxy-pro/fw4_post.nft:
===== scenario: G-start-health-gate-failure (start) =====
uci -q show dhcp.@dnsmasq[0]
config_load homeproxy-pro
ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1)
sing-box version -n
uname -r
ip route show default
@@ -1516,6 +1525,7 @@ file etc/homeproxy-pro/cache.db:
===== scenario: I-dns-snippet-write-failure (start) =====
uci -q show dhcp.@dnsmasq[0]
config_load homeproxy-pro
ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1)
sing-box version -n
uname -r
ip route show default
@@ -1663,6 +1673,7 @@ file etc/homeproxy-pro/cache.db:
===== scenario: H-start-disables-client (start-twice) =====
uci -q show dhcp.@dnsmasq[0]
config_load homeproxy-pro
ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1)
sing-box version -n
uname -r
ip route show default
@@ -1769,6 +1780,7 @@ sing-box version -n
log sing-box 1.14.0 started.
prime start rc=0
config_load homeproxy-pro
ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1)
sing-box version -n
log Reverting the intercept layer; traffic will go out unproxied until the client is up.
ip rule del fwmark 101 table 100
@@ -133,5 +133,100 @@ check('cert path ignores an empty value',
check('cert path ignores a missing section',
hp.validateCertificatePath(null, '/etc/passwd') === true);
/* --- dns_server: the legacy 'wan' value has to stay acceptable ---------- *
* The preset-mode "Overseas DNS server" field used to offer a 'wan' entry
* ("WAN DNS (read from interface)"). The entry is gone from the dropdown -
* the ISP resolver is the one thing that must not be picked there - but the
* literal is what a configuration saved back then still carries, and the
* generator keeps mapping it to wan_dns
* (root/etc/homeproxy-pro/scripts/generator/context.uc). Dropping the entry
* without keeping the validator in step made those configurations unsaveable:
* every field's validator runs on save, and 'wan' is neither a hostname nor
* an address, so the page refused to save a configuration that was working.
*
* The callback is inline in renderDnsCache(), so the form is rendered against
* a minimal Section mock and the stored validate is called the way a widget
* calls it - with the config section id, not null.
*/
function dnsServerValidate() {
const classes = [ 'Value', 'ListValue', 'Flag', 'DynamicList', 'MultiValue', 'TextValue',
'Button', 'TypedSection', 'NamedSection', 'GridSection', 'SectionValue' ];
const formMock = {};
for (const name of classes) {
formMock[name] = class {};
formMock[name].__name__ = name;
}
let captured = null;
const sectionMock = {
tab() {},
taboption(_tab, kind, name, title, description) { return this.option(kind, name, title, description); },
option(kind, name, title, description) {
const option = { kind, name, title, description, value() {}, depends() {} };
if (kind && kind.__name__ === 'SectionValue')
option.subsection = sectionMock;
if (name === 'dns_server')
captured = option;
return option;
}
};
const dns = loadLuciModule(
path.join(root, 'htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js'),
{
baseclass: { extend: (o) => o },
form: formMock,
uci: { load: () => {}, get: () => undefined },
homeproxy-pro: {},
'view.homeproxy-pro.client.common': {}
});
/* Only the datatypes this validator asks for are implemented. A stub
* that accepted everything would also pass the 'wan' check below, which
* is why the empty-value and not-an-address checks are here too. */
const stubValidator = {
value: null,
apply(type, value) {
if (value != null)
this.value = value;
switch (type) {
case 'ip4addr':
return /^\d{1,3}(\.\d{1,3}){3}$/.test(this.value) &&
this.value.split('.').every((octet) => parseInt(octet, 10) <= 255);
case 'ip6addr':
return typeof this.value === 'string' && this.value.includes(':');
case 'ipaddr':
return this.apply('ip4addr', this.value) || this.apply('ip6addr', this.value);
case 'hostname':
return /^[A-Za-z0-9.-]+$/.test(this.value) &&
!/^\d{1,3}(\.\d{1,3}){3}$/.test(this.value);
default:
return true;
}
},
assert: (condition) => !!condition
};
dns.renderDnsCache({ s: sectionMock, stubValidator });
const validator = captured && captured.validate;
if (typeof validator !== 'function')
throw new Error('dns.js no longer registers a dns_server validator');
const ctx = { section: { formvalue: () => '0' } };
return (value) => validator.call(ctx, 'config', value);
}
const validateDnsServer = dnsServerValidate();
check("dns_server accepts the legacy 'wan' literal the generator still maps",
validateDnsServer('wan') === true);
check('dns_server still accepts a bare IP', validateDnsServer('1.1.1.1') === true);
check('dns_server still accepts a DoH endpoint',
validateDnsServer('https://cloudflare-dns.com/dns-query') === true);
check('dns_server still rejects an empty value', isError(validateDnsServer('')));
check('dns_server still rejects a value that is neither a hostname nor an address',
isError(validateDnsServer('not a dns server')));
console.log(`frontend validators: ${checks} checks, ${failures} failures`);
process.exit(failures ? 1 : 0);
@@ -3307,14 +3307,14 @@
"props": {
"default": "8.8.8.8",
"rmempty": false,
"validate": "fn(730:48f7f360fbd2)"
"validate": "fn(758:77dbe8107e5c)"
}
},
{
"kind": "Value",
"name": "china_dns_server",
"title": "China DNS server",
"description": "Resolves the domains on the China path, so it has to be a domestic service to get the correct local CDN answers. Keep the default; the upstream may block UDP/53, in which case a DoH address (https://dns.alidns.com/dns-query, https://doh.pub/dns-query) also works. Support UDP, TCP, DoH, DoQ, DoT.",
"description": "Resolves the domains on the China path, so it has to be a domestic service to get the correct local CDN answers. Keep the default; the upstream may block UDP/53, in which case a DoH address (https://dns.alidns.com/dns-query, https://doh.pub/dns-query) also works. Support UDP, TCP, DoH, DoQ, DoT. This address is also the resolver the proxy DNS server above uses to look up its own hostname when that one is a domain - and a URL here is a domain, which cannot do that lookup itself, so with a URL here the proxy DNS server falls back to the WAN resolver. Enter the bare IP form of the same service to keep that lookup on this router.",
"depends": [
[
"routing_mode",
@@ -514,4 +514,12 @@ sh "$ROOT/tests/ucode/test_protocol_inventory.sh" "$ROOT" "$WORK/inventory" || F
echo "== domain model skeleton =="
sh "$ROOT/tests/ucode/test_domain_model_skeleton.sh" "$ROOT" "$WORK/domain_model" || FAILED=1
echo "== build guards (TLS server_name, bootstrap address) =="
# The two rules that decide whether a configuration can be built at all. Both
# used to be fatal rejections of configurations sing-box accepts, so a node
# without an SNI (every share link with no `sni=`) took the main outbound -
# and with it the whole client configuration - down. Pure functions, so this
# one needs no fixture and no confdir.
sh "$ROOT/tests/ucode/test_build_guards.sh" "$ROOT" "$WORK/build_guards" || FAILED=1
exit $FAILED
@@ -0,0 +1,56 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# The two "can this configuration be built at all" guards: Node.validate's
# TLS/server_name rule and bootstrap_addr's address check. See
# tests/ucode/test_build_guards.uc for why both used to be fatal rejections
# of configurations sing-box accepts.
#
# Neither rule reads UCI, so this stages the modules the two of them import
# and nothing else - no confdir, no fixture. That is what makes the test
# trustworthy on a target: `cursor(<dir>)` returns null there, so a UCI-based
# test would read an empty configuration and pass vacuously.
#
# Staging layout (mirrors production relative imports):
# $WORK/scripts/homeproxy-pro.uc - the real module, validate_data path rewritten
# $WORK/scripts/config/model.uc - Node / CREDENTIALS tables
# $WORK/scripts/config/adapter.uc - OutboundFactory
# $WORK/scripts/generator/dns.uc - build_dns
# $WORK/scripts/generator/common.uc - get_outbound / get_resolver
# $WORK/scripts/parser/*.uc - model.uc -> '../homeproxy-pro.uc', loader's mapping
# $WORK/scripts/test_build_guards.uc - the test
#
# Usage: sh tests/ucode/test_build_guards.sh <repo-root> [work-dir]
ROOT="${1:-.}"
WORK="${2:-/tmp/hp-build-guards-test}"
ROOT="$(cd "$ROOT" && pwd)"
FAILED=0
rm -rf "$WORK"
mkdir -p "$WORK/scripts/config" "$WORK/scripts/generator" "$WORK/scripts/parser"
# HP_VALIDATE_DATA lets a development host replace /sbin/validate_data (see
# tests/README.md); on a target the production path is kept. The test itself
# never calls it - build_dns() parses the DNS address through parseURL() on
# its way to the block - but homeproxy-pro.uc has to load.
VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
sed -e "s#/sbin/validate_data#${VALIDATE_DATA}#" \
"$ROOT/root/etc/homeproxy-pro/scripts/homeproxy-pro.uc" > "$WORK/scripts/homeproxy-pro.uc"
cp "$ROOT/root/etc/homeproxy-pro/scripts/config/model.uc" "$WORK/scripts/config/"
cp "$ROOT/root/etc/homeproxy-pro/scripts/config/adapter.uc" "$WORK/scripts/config/"
cp "$ROOT/root/etc/homeproxy-pro/scripts/generator/dns.uc" "$WORK/scripts/generator/"
cp "$ROOT/root/etc/homeproxy-pro/scripts/generator/common.uc" "$WORK/scripts/generator/"
cp "$ROOT/root/etc/homeproxy-pro/scripts/parser/"*.uc "$WORK/scripts/parser/"
cp "$ROOT/tests/ucode/test_build_guards.uc" "$WORK/scripts/"
if ( cd "$WORK/scripts" && ucode -L "$WORK/scripts" test_build_guards.uc ); then
echo "PASS: build guards (TLS server_name, bootstrap address)"
else
echo "FAIL: build guards (TLS server_name, bootstrap address)"
FAILED=1
fi
exit $FAILED
@@ -0,0 +1,269 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Guards that decide whether a configuration can be BUILT at all, as opposed
* to guards that decide what it contains. Both of the rules pinned here used
* to reject configurations sing-box accepts, and both rejections were fatal
* rather than noisy, so each one could take the proxy down over a value the
* user never thought was load-bearing.
*
* 1. config/model.uc Node.validate - "TLS enabled without server_name"
* fired for every TLS node that had no explicit SNI, including the
* ordinary case of a share link with no `sni=` parameter and a node
* form that lets you save one. sing-box defaults an outbound's
* tls.server_name to the outbound's server address, so a hostname
* address needs no SNI at all. As a main node the node failed
* OutboundFactory.create() -> die(), i.e. `node '<id>': TLS enabled
* without server_name`, the reload rolled back to the previous
* configuration and a boot start refused to come up.
* Reproduced on a router (2026-10-01, sing-box 1.14.2).
* The rule is now scoped to IP addresses, where the implicit SNI is
* the literal IP and the connection genuinely cannot succeed.
*
* 2. generator/dns.uc bootstrap_addr - the two regexes it used accepted
* any hostname made only of hex digits and colons (cafe, face, abc,
* add, dead:beef) and an out-of-range IPv4 (999.999.999.999). The
* bootstrap server is emitted with no domain_resolver on purpose, so a
* hostname there is unsatisfiable and sing-box refuses to start:
* FATAL create service: initialize DNS server[0]: missing domain
* resolver for domain server address
* It now goes through isValidCIDR(), like every other address that
* reaches a root context.
*
* Everything here is a pure function of its arguments - no UCI cursor, no
* confdir, no files. That is deliberate: `cursor(<dir>)` returns null on the
* target (measured 2026-10-01), and a test that loads UCI through a sandbox
* confdir therefore reads an empty configuration there and passes
* vacuously. Asserting the guards directly needs neither.
*
* Run from tests/ucode/test_build_guards.sh.
*/
'use strict';
import { Node } from './config/model.uc';
import { OutboundFactory } from './config/adapter.uc';
import { build_dns } from './generator/dns.uc';
let failures = 0;
let checks = 0;
function ok(name, condition, detail) {
checks++;
if (!condition) {
printf('FAIL %s%s\n', name, detail === null ? '' : sprintf(': %J', detail));
failures++;
}
}
function expect_problems(name, node, expected) {
checks++;
const got = OutboundFactory.problems(node);
if (sprintf('%J', got) !== sprintf('%J', expected)) {
printf('FAIL %s: expected problems %J, got %J\n', name, expected, got);
failures++;
}
}
/* A node as the Loader builds it: the nested sub-objects, not a flat dict. */
function node(address, tls_enabled, server_name, reality) {
return Node.create({
id: 'n1',
name: 'test',
type: 'trojan',
address: address,
port: '443',
credentials: { password: 'secret' },
protocol_options: {},
common: {},
multiplex: {},
transport: { type: null },
tls: {
enabled: tls_enabled,
server_name: server_name,
insecure: '0',
reality: { enabled: reality || '0' }
}
});
}
/* --- 1. the TLS / server_name rule -------------------------------------- */
/* The regression: a share link with no `sni=` parameter, on a hostname. This
* is the shape the trojan/anytls/hysteria2/tuic parsers produce. */
expect_problems('hostname address, TLS, no server_name',
node('bwg.example.com', '1', null, null), []);
ok('... is buildable',
OutboundFactory.buildable(node('bwg.example.com', '1', null, null)) === true);
/* ... and the whole point: the outbound is actually produced, with a TLS
* block that lets sing-box fall back to the address as the SNI. */
{
const built = OutboundFactory.tryCreate(node('bwg.example.com', '1', null, null), '100');
checks++;
if (length(built.problems) || built.outbound === null) {
printf('FAIL outbound build for a sni-less hostname node: %J\n', built.problems);
failures++;
} else if (built.outbound.tls?.enabled !== true || 'server_name' in built.outbound.tls) {
printf('FAIL outbound tls shape: expected { enabled: true } with no server_name, got %J\n',
built.outbound.tls);
failures++;
}
}
/* The rule still fires where it is right to: an IP address with no SNI gets
* SNI=IP, which no real certificate answers to. */
expect_problems('IPv4 address, TLS, no server_name',
node('1.2.3.4', '1', null, null),
['TLS enabled on IP address 1.2.3.4 without server_name']);
expect_problems('IPv6 address, TLS, no server_name',
node('2001:67c:4e8::1', '1', null, null),
['TLS enabled on IP address 2001:67c:4e8::1 without server_name']);
/* reality runs its own handshake, so it never needed server_name. */
expect_problems('IP address, TLS, reality, no server_name',
node('1.2.3.4', '1', null, '1'), []);
/* An explicit SNI is and always was fine, on either address shape. */
expect_problems('hostname address, TLS, explicit server_name',
node('bwg.example.com', '1', 'cdn.example.net', null), []);
expect_problems('IP address, TLS, explicit server_name',
node('1.2.3.4', '1', 'cdn.example.net', null), []);
/* No TLS at all: the rule never applied, and still does not. */
expect_problems('hostname address, no TLS, no server_name',
node('bwg.example.com', '0', null, null), []);
/* The rule must not have shadowed the cross-protocol checks. */
expect_problems('still reports a missing credential',
Node.create({
id: 'n2', name: 't', type: 'trojan', address: 'bwg.example.com', port: '443',
credentials: {}, protocol_options: {}, common: {}, multiplex: {},
transport: { type: null },
tls: { enabled: '1', server_name: null, insecure: '0', reality: { enabled: '0' } }
}),
['trojan requires password']);
expect_problems('still reports an out-of-range port',
Node.create({
id: 'n3', name: 't', type: 'trojan', address: 'bwg.example.com', port: '70000',
credentials: { password: 'x' }, protocol_options: {}, common: {}, multiplex: {},
transport: { type: null },
tls: { enabled: '1', server_name: null, insecure: '0', reality: { enabled: '0' } }
}),
["invalid port '70000'"]);
/* --- 2. the bootstrap resolver address ---------------------------------- */
/* append_bootstrap_dns() and append_proxy_dns() are private, so the guards are
* asserted through the public entry point: what ends up in the dns block, and
* what build_dns() does with a China DNS value it cannot use.
*
* `mode` is 'built' (the block was produced) or 'died' (build_dns refused,
* naming the field). The "died" case is the point of the third rule below: a
* value the option cannot express used to reach the generator as `...null`
* and throw "Value (null) is not iterable", which names no field at all. */
function dns_for(china_dns_server) {
const config = { };
const ctx = {
routing_mode: 'bypass_mainland_china',
proxy_mode: 'redirect_tproxy',
main_node: 'n1',
main_udp_node: 'nil',
ipv6_support: '0',
log_level: 'warn',
self_mark: '100',
wan_dns: '223.5.5.5',
dns_server: 'https://dns.google/dns-query',
china_dns_server: china_dns_server,
cn_ip_fallback: '0',
sniffer_advanced_mode: '0',
direct_domain_list: [],
proxy_domain_list: []
};
try {
build_dns(config, { dns: { servers: [], rules: [] } }, ctx);
} catch (e) {
return { died: sprintf('%s', e.message) };
}
return { died: null, config: config };
}
function bootstrap_server(addr) {
const r = dns_for(addr);
if (r.died !== null)
return null;
for (let s in (r.config.dns?.servers || []))
if (s.tag === 'bootstrap-dns')
return s;
return null;
}
/* The values the option documents, plus the other family. A bare IP is the
* one thing a bootstrap server can be: it has no domain_resolver of its own,
* so it must be able to answer without one. */
ok('bare IPv4 is used as the bootstrap address',
bootstrap_server('223.5.5.5')?.server === '223.5.5.5',
bootstrap_server('223.5.5.5'));
ok('bare IPv6 is used as the bootstrap address',
bootstrap_server('fdfe::1')?.server === 'fdfe::1',
bootstrap_server('fdfe::1'));
/* A real hostname was never a candidate: main-dns keeps borrowing the WAN
* resolver, which is the documented behaviour for 'wan' and for a host name. */
ok('a dotted hostname yields no bootstrap server',
bootstrap_server('dns.alidns.com') === null,
bootstrap_server('dns.alidns.com'));
ok("the 'wan' sentinel yields no bootstrap server",
bootstrap_server('wan') === null,
bootstrap_server('wan'));
ok('an empty value yields no bootstrap server',
bootstrap_server('') === null,
bootstrap_server(''));
/* Every one of these used to be accepted as a bare address by the regex pair
* bootstrap_addr() carried - a hostname made only of hex digits and colons,
* or an IPv4 whose octets were never range-checked. Measured on the target,
* all seven were emitted as a `bootstrap-dns` server address, and that
* server has no domain_resolver by design, so sing-box refused to start:
*
* FATAL create service: initialize DNS server[0]: missing domain resolver
* for domain server address
*
* The invariant pinned here is that no China DNS value can produce a
* bootstrap server unless it is a real address. */
for (let bad in ['cafe', 'face', 'abc', 'add', 'dead:beef', '999.999.999.999', '1.2.3']) {
checks++;
if (bootstrap_server(bad) !== null) {
printf('FAIL china_dns_server %s: emitted a bootstrap-dns server (%J)\n',
bad, bootstrap_server(bad));
failures++;
}
}
/* And the failure mode for a value the option cannot express at all is a
* refusal that names the field. Spreading the null parse_dnsserver() returns
* used to throw "Value (null) is not iterable" instead - an exception naming
* no field, exit 254, rollback.
*
* Which values land here is a property of /sbin/validate_data, not of this
* generator: a dotless name is a valid *hostname* to it, so 'cafe' is carried
* through to china-dns unchanged, while 'dead:beef' (a colon with no port)
* is not parseable at all. Both are accepted here; only the second may die,
* and it has to die by name. */
for (let bad in ['cafe', 'face', 'abc', 'add', 'dead:beef', '999.999.999.999', '1.2.3']) {
checks++;
const died = dns_for(bad).died;
if (died !== null && (!match(died, /China DNS server/) || match(died, /not iterable/))) {
printf('FAIL china_dns_server %s: died with %J (want null, or a message naming the field)\n',
bad, died);
failures++;
}
}
printf('%d checks, %d failures\n', checks, failures);
exit(failures === 0 ? 0 : 1);