# SPDX-License-Identifier: GPL-2.0-only # # Copyright (C) 2025 ImmortalWrt.org # # dnsmasq integration for homeproxy-pro. # # homeproxy-pro does not run its own DNS front end: it points dnsmasq at the # sing-box DNS listener and, in the list-based routing modes, feeds it the # per-domain `server=` / `nftset=` mappings. All of that used to live inline # in /etc/init.d/homeproxy-pro. # # Layering note: this is a device-side module. It calls `log` and, in # hp_dnsmasq_write_snippets, relies on `config_load` having run (see # runtime/service.sh's header for the rationale). # # Sourced by /etc/init.d/homeproxy-pro. # hp_dnsmasq_resolve_dir # Locate the directory dnsmasq reads the homeproxy-pro snippets from and store it # in the global DNSMASQ_DIR. # # The UCI section name of the dnsmasq config is part of # /tmp/etc/dnsmasq.conf., but `uci get` cannot return a section name # (there is no `__name__`), so it is derived from the first `uci show` line. # Either step can fail on an unusual setup; fall back to the generic # directory and say so in the log instead of silently writing dnsmasq # snippets nobody reads. # # Called ONCE, at source time. stop_service deliberately reuses the value # instead of recomputing it: the directory must be the one the snippets were # written to, and a second resolution could log the warning twice or pick a # different directory after a dnsmasq reconfiguration. hp_dnsmasq_resolve_dir() { local dnsmasq_uci_config dnsmasq_conf_dir dnsmasq_uci_config="$(uci -q show "dhcp.@dnsmasq[0]" 2>"/dev/null" | head -n1)" dnsmasq_uci_config="${dnsmasq_uci_config#*.}" dnsmasq_uci_config="${dnsmasq_uci_config%%=*}" DNSMASQ_DIR="" if [ -n "$dnsmasq_uci_config" ] && [ -f "/tmp/etc/dnsmasq.conf.$dnsmasq_uci_config" ]; then dnsmasq_conf_dir="$(awk -F '=' '/^conf-dir=/ {print $2; exit}' "/tmp/etc/dnsmasq.conf.$dnsmasq_uci_config")" [ -n "$dnsmasq_conf_dir" ] && DNSMASQ_DIR="$dnsmasq_conf_dir/dnsmasq-homeproxy-pro.d" fi if [ -z "$DNSMASQ_DIR" ]; then log "WARNING: unable to derive the dnsmasq conf-dir (section '${dnsmasq_uci_config:-unknown}'), using /tmp/dnsmasq.d/dnsmasq-homeproxy-pro.d." DNSMASQ_DIR="/tmp/dnsmasq.d/dnsmasq-homeproxy-pro.d" fi } # hp_dnsmasq_has_nftset # 0 when the running dnsmasq understands `nftset=`. The base dnsmasq variant # is built without HAVE_NFTSET, and it does not ignore the directive: it aborts # with "recompile with HAVE_NFTSET defined", and because the snippet is read # through conf-dir that abort takes dnsmasq itself down - the LAN loses DNS and # DHCP, not just the domain routing. Probe once and degrade instead. hp_dnsmasq_has_nftset() { if [ -z "${HP_DNSMASQ_NFTSET:-}" ]; then # Look it up through PATH so the off-target harness can stand in a # dnsmasq of either flavour; fall back to the absolute path for an # init environment with a bare PATH. local bin bin="$(command -v dnsmasq 2>"/dev/null" || echo /usr/sbin/dnsmasq)" if "$bin" --version 2>"/dev/null" | grep -qw nftset; then HP_DNSMASQ_NFTSET=1 else HP_DNSMASQ_NFTSET=0 fi fi [ "$HP_DNSMASQ_NFTSET" = "1" ] } # hp_dnsmasq_render_snippets [run-dir] # Render the desired snippet set into . Writes nothing outside it, # restarts nothing: the caller decides whether the result differs from what is # already installed. # # The input list gates are `-s` rather than the original unconditional sed: # when the resource file is missing the old code produced an empty snippet # (or a stale one never got removed), which made "is this the same as last # time" undecidable. An empty list now means "no snippet for this mode", # which is also what a fresh install means. hp_dnsmasq_render_snippets() { local stage="$1" local hp_dir="$2" local routing_mode="$3" local dns_port="$4" local ipv6="$5" local run_dir="${6:-/var/run/homeproxy-pro}" local gfw_nftset_v6="" wan_nftset_v6="" local nftset_ok=1 # dns_port comes from `infra`, a section with no UI and no validator, and # it is interpolated both into the snippet text and into sed's replacement # below. A value carrying a newline reaches sed as `\n` and becomes a real # dnsmasq directive (the generator accepts it too: int() is strtoll-based, # so '5333\nlog-queries' is simply 5333 there), and a value with a `/` # makes sed fail - while `> "$stage/x.conf"` has already truncated the # file, so an empty snippet used to be installed and then cached by the # "unchanged" comparison. Keep the numeric prefix, refuse the rest. dns_port="$(printf '%s' "$dns_port" | sed -n 's/^\([0-9]\{1,\}\).*/\1/p')" case "$dns_port" in ''|*[!0-9]*) log "Warning: infra.dns_port is not a port (${4:-}); skipping the dnsmasq snippets." return 1 ;; esac if [ "$dns_port" -lt 1 ] || [ "$dns_port" -gt 65535 ]; then log "Warning: infra.dns_port=$dns_port is out of range; skipping the dnsmasq snippets." return 1 fi hp_dnsmasq_has_nftset || nftset_ok=0 case "$routing_mode" in "bypass_mainland_china"|"custom"|"global") cat <<-EOF > "$stage/redirect-dns.conf" || return 1 no-poll no-resolv server=127.0.0.1#$dns_port EOF ;; "gfwlist") if [ -s "$hp_dir/resources/gfw_list.txt" ]; then if [ "$nftset_ok" = "1" ]; then [ "$ipv6" -eq "0" ] || gfw_nftset_v6=",6#inet#fw4#homeproxy_gfw_list_v6" sed -r -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port\nnftset=\/\1\\/4#inet#fw4#homeproxy_gfw_list_v4$gfw_nftset_v6/g" \ "$hp_dir/resources/gfw_list.txt" > "$stage/gfw_list.conf" || return 1 else log "Warning: this dnsmasq has no nftset support; gfwlist routing degrades to plain server= entries (no address sets)." sed -r -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port/g" \ "$hp_dir/resources/gfw_list.txt" > "$stage/gfw_list.conf" || return 1 fi fi ;; "proxy_mainland_china") if [ -s "$hp_dir/resources/china_list.txt" ]; then sed -r -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port/g" \ "$hp_dir/resources/china_list.txt" > "$stage/china_list.conf" || return 1 fi ;; esac if [ "$routing_mode" != "custom" ] && [ -s "$hp_dir/resources/proxy_list.txt" ]; then if [ "$nftset_ok" = "1" ]; then [ "$ipv6" -eq "0" ] || wan_nftset_v6=",6#inet#fw4#homeproxy_wan_proxy_addr_v6" sed -r -e '/^\s*$/d' -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port\nnftset=\/\1\\/4#inet#fw4#homeproxy_wan_proxy_addr_v4$wan_nftset_v6/g" \ "$hp_dir/resources/proxy_list.txt" > "$stage/proxy_list.conf" || return 1 else log "Warning: this dnsmasq has no nftset support; the proxy list degrades to plain server= entries." sed -r -e '/^\s*$/d' -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port/g" \ "$hp_dir/resources/proxy_list.txt" > "$stage/proxy_list.conf" || return 1 fi fi # Node server addresses, host-name form. # # This snippet carries no `server=`: the routing mode's own snippet above # already decides which upstream answers, and in every proxied mode that is # sing-box's dns-in. The generator resolves the node through the WAN # resolver either way (route.default_domain_resolver), so the address that # comes back is the same one sing-box will dial - which is the point. Only # the `nftset=` is added here, and dnsmasq back-fills it from the answer it # relays, forwarded or not. # # Without it, a LAN client opening a connection to the node's own address # was redirected into sing-box, which dialled that same address through the # tunnel to that same node. sing-box's own outbound is already exempt # (routing_mark / self_mark); this covers the LAN, which is not. # # The literal-address form cannot come from here - there is no name to # resolve - so firewall_post.ut renders it into the same set directly. if [ "$nftset_ok" = "1" ] && [ -s "$run_dir/node-addr-domains.txt" ]; then local node_nftset_v6="" [ "$ipv6" -eq "0" ] || node_nftset_v6=",6#inet#fw4#homeproxy_node_addr_v6" sed -r -e '/^\s*$/d' \ -e "s/(.*)/nftset=\/.\1\/4#inet#fw4#homeproxy_node_addr_v4$node_nftset_v6/g" \ "$run_dir/node-addr-domains.txt" > "$stage/node_addr.conf" || return 1 elif [ -s "$run_dir/node-addr-domains.txt" ]; then log "Warning: this dnsmasq has no nftset support; LAN connections to the node's address are not excluded from the proxy." fi } # hp_dnsmasq_dir_differs # 0 when the installed snippet set is not exactly the staged one, 1 when it is # identical. Compares file names as well as contents, so a snippet that the # current routing mode no longer produces (but a previous one did) counts as a # change and gets cleaned up. hp_dnsmasq_dir_differs() { local stage="$1" local live="$2" local staged installed f [ -d "$live" ] || return 0 staged="$(ls -1 "$stage" 2>"/dev/null" | sort)" installed="$(ls -1 "$live" 2>"/dev/null" | sort)" [ "$staged" = "$installed" ] || return 0 for f in $staged; do cmp -s "$stage/$f" "$live/$f" || return 0 done return 1 } # hp_dnsmasq_write_snippets [run-dir] # Write the include file plus the mode-specific snippet, then restart # dnsmasq. Requires config_load (reads ipv6_support and dns_port). # # The snippets are rendered into a staging directory first and compared with # what is installed. dnsmasq 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 port stay the same - so the previous unconditional rewrite-and-restart # flushed the LAN's DNS cache on every reload for no reason. hp_dnsmasq_write_snippets() { local dnsmasq_dir="$1" local hp_dir="$2" local routing_mode="$3" local run_dir="${4:-/var/run/homeproxy-pro}" local ipv6_support dns_port local include="$dnsmasq_dir/../dnsmasq-homeproxy-pro.conf" local stage changed=0 config_get_bool ipv6_support "config" "ipv6_support" "0" config_get dns_port "infra" "dns_port" "5333" stage="$(mktemp -d "${TMPDIR:-/tmp}/hp-dnsmasq.XXXXXX")" || { log "Warning: failed to create a staging directory for the dnsmasq snippets." return 1 } # A refused render (an unusable dns_port, or sed failing) must leave the # installed snippet set untouched: the staged directory is discarded, so # the caller keeps the snippets that are already in place instead of # installing a truncated one. if ! hp_dnsmasq_render_snippets "$stage" "$hp_dir" "$routing_mode" "$dns_port" "$ipv6_support" "$run_dir"; then rm -rf "$stage" return 1 fi if [ ! -f "$include" ] || [ "$(cat "$include" 2>"/dev/null")" != "conf-dir=$dnsmasq_dir" ]; then changed=1 fi if hp_dnsmasq_dir_differs "$stage" "$dnsmasq_dir"; then changed=1 fi if [ "$changed" -eq 0 ]; then rm -rf "$stage" log "dnsmasq snippets unchanged, skipping the restart." return 0 fi mkdir -p "$(dirname "$dnsmasq_dir")" 2>"/dev/null" \ || log "Warning: failed to create the dnsmasq conf-dir parent." rm -rf "$dnsmasq_dir" if ! mv "$stage" "$dnsmasq_dir"; then log "Warning: failed to install the dnsmasq snippets into ${dnsmasq_dir}." rm -rf "$stage" return 1 fi # printf, not `echo -e`: busybox ash honours -e but a POSIX sh does not, # and on one the file would start with a literal "-e " and dnsmasq would # refuse to parse it. The file is one line of conf-dir= either way. printf 'conf-dir=%s\n' "$dnsmasq_dir" > "$include" \ || log "Warning: failed to write the dnsmasq conf-dir file." /etc/init.d/dnsmasq restart >"/dev/null" 2>&1 \ || log "Warning: failed to restart dnsmasq, DNS-based routing may be stale." } # hp_dnsmasq_remove_snippets # Remove the include file and the snippet directory, then restart dnsmasq. # The snippets are removed rather than restored: nothing ever captures the # pre-existing state, so there is no original to put back. hp_dnsmasq_remove_snippets() { local dnsmasq_dir="$1" local include="$dnsmasq_dir/../dnsmasq-homeproxy-pro.conf" # Nothing to undo when nothing was written - which is the normal case on a # router whose outbound node is 'nil', or one that never started. Removing # paths that do not exist is not an error, and restarting dnsmasq for it is # pure noise (it drops every client's DNS cache for no reason). if [ ! -e "$include" ] && [ ! -d "$dnsmasq_dir" ]; then return 0 fi rm -rf "$include" "$dnsmasq_dir" /etc/init.d/dnsmasq restart >"/dev/null" 2>&1 \ || log "Warning: failed to restart dnsmasq after removing the homeproxy-pro snippets." }