Files

334 lines
14 KiB
Bash

#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Two properties of the proxy-mode DNS block that the generator regression
# suite cannot see, because run_case() truncates the resource lists and uses a
# fixture without the bootstrap option:
#
# 1. The proxy-domain resource list has to reach the DNS block in *every*
# proxy routing mode, not only in bypass_mainland_china.
#
# proxy_list.txt is the user's escape hatch for a domain the preset lists
# mis-classify - the canonical case is Google Play, whose
# connect.googleapis.cn resolves through the domestic resolver and then
# stays direct, so the store cannot update. The routing half
# (route.uc pushes the proxy-domain rule_set to main-out) has always been
# emitted in all the proxy modes, while the DNS half used to be nested
# inside the bypass_mainland_china branch. In global / gfwlist /
# proxy_mainland_china a listed domain therefore still resolved through
# the domestic resolver; only the bypass mode did the right thing.
#
# 2. The bootstrap resolver: the DoH/DoT endpoint (main-dns) is the only
# server whose address is a hostname, and it used to borrow default-dns
# (the WAN/ISP resolver) for that lookup, which makes the ISP the
# answerer of the one query that decides where every proxied name goes.
# The resolver is derived from the China DNS server now, so both halves
# of that rule are asserted: a bare IP there yields a bootstrap-dns that
# main-dns points at and which carries no domain_resolver of its own,
# and a hostname there yields no bootstrap server at all (a hostname
# cannot resolve the hostname it would be needed to resolve).
#
# Usage: sh tests/ucode/test_dns_proxy_list.sh <repo-root> [work-dir]
ROOT="${1:-.}"
WORK="${2:-/tmp/hp-dns-proxy-list-test}"
ROOT="$(cd "$ROOT" && pwd)"
FIXTURE="$ROOT/tests/fixtures/generators/client.uci"
DIRECT_DOMAIN="direct.example.cn"
PROXY_DOMAIN="play.example.cn"
CHINA_DNS_IP="223.5.5.5"
CHINA_DNS_DOH="https://dns.alidns.com/dns-query"
FAILED=0
rm -rf "$WORK"
mkdir -p "$WORK"
# Stage the checkout the way test_generators.sh does: HP_DIR/RUN_DIR/UCICONFIG_DIR
# are rewritten into the work dir and the fixture is copied in as the UCI
# config. The fixture carries no china_dns_server (the generator then falls
# back to the Aliyun public resolver), so the line is inserted into the
# `config` section here - the section the Loader reads it from. It has to be
# inserted rather than substituted for that reason.
stage_case() {
dir="$1"
label="$2"
china_dns="$3"
fixture="$4"
rm -rf "$dir"
mkdir -p "$dir/config" "$dir/run" "$dir/scripts/config" "$dir/scripts/generator" \
"$dir/scripts/parser" "$dir/resources"
cp "$fixture" "$dir/config/homeproxy-pro"
if [ -n "$china_dns" ]; then
sed -i "/^\toption routing_mode /i\\
\toption china_dns_server '$china_dns'
" "$dir/config/homeproxy-pro"
fi
# Both lists are populated on purpose. direct_list.txt is what makes the
# direct-domain DNS rule appear at all, and the generator documents that
# proxy-domain sits after it; with only one of the two the relative order
# is untestable.
printf '%s\n' "$DIRECT_DOMAIN" > "$dir/resources/direct_list.txt"
printf '%s\n' "$PROXY_DOMAIN" > "$dir/resources/proxy_list.txt"
# The client config carries a `type: local` rule-set generated from
# china_ip4.txt; sing-box opens that path during `check`, and on a router
# hp_prepare_runtime_files generates it before the config is used.
if ! ucode -S "$ROOT/root/etc/homeproxy-pro/scripts/runtime/china_ip_ruleset.uc" \
"$ROOT/root/etc/homeproxy-pro/resources/china_ip4.txt" "$dir/resources/china_ip4.json" \
>>"$dir/resources/china_ip4.log" 2>&1; then
printf '{"version":3,"rules":[{"ip_cidr":["192.0.2.0/24"]}]}\n' > "$dir/resources/china_ip4.json"
fi
# The DNS half of the China split. generate_client.uc reports it as
# ctx.china_domain_ready, an lstat on this file; absent, the domestic
# lookup rule is not emitted and the ordering this file asserts cannot be
# observed at all. On a router hp_prepare_runtime_files generates it from
# china_list.txt by the same route as china_ip4.json above.
if ! ucode -S "$ROOT/root/etc/homeproxy-pro/scripts/runtime/domain_ruleset.uc" \
"$ROOT/root/etc/homeproxy-pro/resources/china_list.txt" "$dir/resources/china-domain.json" \
>>"$dir/resources/china-domain.log" 2>&1; then
printf '{"version":3,"rules":[{"domain_suffix":["example.invalid"]}]}\n' > "$dir/resources/china-domain.json"
fi
VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
sed -e "s#^export const HP_DIR = '/etc/homeproxy-pro';#export const HP_DIR = '$dir';#" \
-e "s#^export const RUN_DIR = '/var/run/homeproxy-pro';#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-pro/scripts/homeproxy-pro.uc" > "$dir/scripts/homeproxy-pro.uc"
cp "$ROOT/root/etc/homeproxy-pro/scripts/config/loader.uc" "$dir/scripts/config/"
cp "$ROOT/root/etc/homeproxy-pro/scripts/config/model.uc" "$dir/scripts/config/"
cp "$ROOT/root/etc/homeproxy-pro/scripts/config/adapter.uc" "$dir/scripts/config/"
cp "$ROOT/root/etc/homeproxy-pro/scripts/parser/"*.uc "$dir/scripts/parser/"
cp "$ROOT/root/etc/homeproxy-pro/scripts/generator/"*.uc "$dir/scripts/generator/"
cp "$ROOT/root/etc/homeproxy-pro/scripts/generate_client.uc" "$dir/scripts/generate_client.uc"
# macOS: `sing-box check` rejects the SO_MARK-based routing_mark on a
# direct outbound (Linux-only), so the generator copy emits null there.
# Same patch as test_generators.sh, applied to the same module.
case "$(uname -s)" in
Darwin)
sed -i '' "s#routing_mark: strToInt(.*self_mark)#routing_mark: null#" \
"$dir/scripts/generator/client.uc"
;;
esac
if ! ( cd "$dir/scripts" && ucode -L "$dir/scripts" generate_client.uc > "$dir/generate.out" 2> "$dir/generate.err" ); then
echo "FAIL: $label: generate_client.uc exited non-zero"
head -5 "$dir/generate.err"
return 1
fi
json="$dir/run/sing-box-c.json"
if [ ! -s "$json" ]; then
echo "FAIL: $label: no client configuration was generated"
head -5 "$dir/generate.err"
return 1
fi
if ! sing-box check --config "$json"; then
echo "FAIL: $label: sing-box rejected the generated configuration"
return 1
fi
return 0
}
# The mode's default policy, asserted as a *pair*. The DNS chain and the
# route chain each end in a fallback (`dns.final`, `route.final`), and they
# have to name the same side: a domain that ends up proxied must be resolved
# by the proxy-path resolver, and one that ends up direct must not be
# resolved through the proxy. The two were literals in different modules with
# nothing tying them together, and the route side applied "unknown goes to the
# proxy" to all four modes - so "Only proxy mainland China" proxied everything
# it was not told to proxy.
check_fallback_pair() {
label="$1"
json="$2"
case "$label" in
proxy_mainland_china) want_route="direct-out"; want_dns="default-dns" ;;
*) want_route="main-out"; want_dns="main-dns" ;;
esac
# The two finals sit in different blocks and their relative order is not
# fixed (route.final is emitted before dns.final in the modes whose dns
# block grows, after it in the others), so each is taken from its own
# block: dns.final is the only '"final"' indented by two tabs inside the
# top-level dns object, route.final the only one inside route.
have_dns="$(awk '/^\t"dns": \{/{d=1} d && /^\t\t"final":/{gsub(/.*: "|".*/,""); print; exit}' "$json")"
have_route="$(awk '/^\t"route": \{/{r=1} r && /^\t\t"final":/{gsub(/.*: "|".*/,""); print; exit}' "$json")"
if [ "$have_route" != "$want_route" ]; then
echo "FAIL: $label: route.final is '$have_route', the mode's policy is '$want_route'"
return 1
fi
if [ "$have_dns" != "$want_dns" ]; then
echo "FAIL: $label: dns.final is '$have_dns', the mode's policy is '$want_dns'"
return 1
fi
# Stated as its own property, because the failure it catches is silent:
# if one of the two ever gains a new value, the pair is what breaks.
proxy_route=no; proxy_dns=no
[ "$have_route" = "main-out" ] && proxy_route=yes
[ "$have_dns" = "main-dns" ] && proxy_dns=yes
if [ "$proxy_route" != "$proxy_dns" ]; then
echo "FAIL: $label: route.final ($have_route) and dns.final ($have_dns) disagree on the mode's default"
return 1
fi
echo "PASS: $label: route.final=$have_route and dns.final=$have_dns agree"
# Both mainland modes match destinations against the local china-ip
# rule-set, so both have to declare it. proxy_mainland_china reached this
# point without the declaration once and sing-box refused the whole config
# with "initialize rule[3]: rule-set not found"; the health gate then
# rolled the intercept layer back and the network went unproxied.
case "$label" in
bypass_mainland_china|proxy_mainland_china)
if ! grep -qF '"rule_set": "china-ip"' "$json" || ! grep -qF '"tag": "china-ip"' "$json"; then
echo "FAIL: $label: the route rule matches china-ip but the rule-set is not declared"
return 1
fi
;;
esac
return 0
}
# The rule_set assertions, shared by every case: the list reaches the config,
# the DNS half resolves through main-dns ahead of the SVCB/HTTPS reject, and
# direct-domain keeps its precedence.
check_proxy_list() {
label="$1"
json="$2"
if ! grep -qF "$PROXY_DOMAIN" "$json"; then
echo "FAIL: $label: proxy_list.txt never reached the generated config"
return 1
fi
dns_rule="$(grep -nF '"rule_set": "proxy-domain"' "$json" | head -1 | cut -d: -f1)"
if [ -z "$dns_rule" ]; then
echo "FAIL: $label: no proxy-domain rule_set in the generated config"
return 1
fi
if ! sed -n "$((dns_rule + 1)),$((dns_rule + 4))p" "$json" | grep -qF '"main-dns"'; then
echo "FAIL: $label: the proxy-domain DNS rule does not resolve through main-dns:"
sed -n "$((dns_rule - 3)),$((dns_rule + 4))p" "$json"
return 1
fi
reject_line="$(grep -nF '"query_type": [' "$json" | head -1 | cut -d: -f1)"
if [ -n "$reject_line" ] && [ "$dns_rule" -gt "$reject_line" ]; then
echo "FAIL: $label: the proxy-domain DNS rule is emitted after the SVCB/HTTPS reject"
return 1
fi
# A domain present in both lists keeps direct-domain first: the
# relocation into the shared prefix must not have reversed the
# precedence the bypass block used to have.
direct_rule="$(grep -nF '"rule_set": "direct-domain"' "$json" | head -1 | cut -d: -f1)"
if [ -z "$direct_rule" ] || [ "$direct_rule" -gt "$dns_rule" ]; then
echo "FAIL: $label: direct-domain must be the first DNS rule (direct=${direct_rule:-none} proxy=$dns_rule)"
return 1
fi
# In the one mode that has both, the domestic lookup still comes last.
case "$label" in
bypass_mainland_china*)
china_domain_rule="$(grep -nF '"rule_set": "china-domain"' "$json" | head -1 | cut -d: -f1)"
if [ -z "$china_domain_rule" ] || [ "$china_domain_rule" -lt "$dns_rule" ]; then
echo "FAIL: $label: china-domain must come after proxy-domain (proxy=$dns_rule china-domain=${china_domain_rule:-none})"
return 1
fi
;;
esac
return 0
}
# --- 1. bootstrap resolver, derived from a bare-IP China DNS server ---------
dir="$WORK/bootstrap"
if stage_case "$dir" "bootstrap" "$CHINA_DNS_IP" "$FIXTURE"; then
json="$dir/run/sing-box-c.json"
bootstrap_line="$(grep -nF '"tag": "bootstrap-dns"' "$json" | head -1 | cut -d: -f1)"
if [ -z "$bootstrap_line" ]; then
echo "FAIL: bootstrap: a bare-IP China DNS server produced no bootstrap-dns server"
FAILED=1
elif ! sed -n "$bootstrap_line,$((bootstrap_line + 4))p" "$json" | grep -qF "\"$CHINA_DNS_IP\""; then
echo "FAIL: bootstrap: bootstrap-dns does not carry the China DNS server ($CHINA_DNS_IP)"
FAILED=1
elif sed -n "$bootstrap_line,$((bootstrap_line + 4))p" "$json" | grep -qF '"domain_resolver"'; then
echo "FAIL: bootstrap: the bootstrap resolver has a domain_resolver of its own"
FAILED=1
else
# main-dns is the only hostname-addressed server, so it is the only
# one allowed to point at the bootstrap resolver.
main_line="$(grep -nF '"tag": "main-dns"' "$json" | head -1 | cut -d: -f1)"
if [ -z "$main_line" ]; then
echo "FAIL: bootstrap: no main-dns server was emitted"
FAILED=1
elif ! sed -n "$main_line,$((main_line + 6))p" "$json" | grep -qF '"server": "bootstrap-dns"'; then
echo "FAIL: bootstrap: main-dns still resolves through the WAN resolver"
FAILED=1
else
echo "PASS: bootstrap: main-dns resolves its own hostname through bootstrap-dns ($CHINA_DNS_IP)"
fi
fi
else
FAILED=1
fi
# --- 2. a hostname China DNS server is not a usable bootstrap --------------
# The option also accepts a DoH/DoT URL, and a URL is a hostname: using one to
# resolve the hostname it would be needed to resolve is the cycle this whole
# mechanism exists to avoid. It must fall back to the WAN resolver, loudly
# enough to be visible here, rather than to a broken bootstrap.
dir="$WORK/hostname"
if stage_case "$dir" "hostname" "$CHINA_DNS_DOH" "$FIXTURE"; then
json="$dir/run/sing-box-c.json"
if grep -qF '"tag": "bootstrap-dns"' "$json"; then
echo "FAIL: hostname: a DoH China DNS server was used as the bootstrap resolver"
FAILED=1
elif ! grep -qF '"server": "default-dns"' "$json"; then
echo "FAIL: hostname: no bootstrap server and main-dns does not use default-dns either"
FAILED=1
else
echo "PASS: hostname: a DoH China DNS server yields no bootstrap server, main-dns uses default-dns"
fi
else
FAILED=1
fi
# --- 3. proxy-domain in every proxy routing mode ---------------------------
# One case per proxy routing mode. Custom mode is deliberately absent: it
# ignores both resource lists (context.uc gates their inputs on
# `routing_mode !== 'custom'`) and has its own user-defined dns_rule sections.
for mode in bypass_mainland_china global gfwlist proxy_mainland_china; do
dir="$WORK/$mode"
sed "s#^\(\s*option routing_mode \).*#\1'$mode'#" "$FIXTURE" > "$WORK/fixture-$mode.uci"
if stage_case "$dir" "$mode" "" "$WORK/fixture-$mode.uci"; then
if check_proxy_list "$mode" "$dir/run/sing-box-c.json"; then
echo "PASS: $mode: direct-domain < proxy-domain via main-dns < SVCB/HTTPS reject"
else
FAILED=1
fi
check_fallback_pair "$mode" "$dir/run/sing-box-c.json"
else
FAILED=1
fi
done
exit $FAILED