The Genesis Of This Procedure:
I put a CR1220 battery in the router so the RTC (Real Time Clock) was kept up-to-date when the router’s power was off, or when rebooting, so the router doesn’t have to reach out to the NTP servers upon startup. I got to thinking, “Why can’t we do something similar for the DoH DNS servers and their IP addresses, so the router doesn’t have to reach out to the bootstrapping DNS servers?”
If you use https-dns-proxy to do DNS queries via DoH (DNS-over-HTTPS), you should know that the router itself is still doing plaintext DNS lookups, to enumerate the DoH servers. It does this curiously frequently.
If you try to completely block Port 53 for device (the router) and lan, the router won’t be able to get the DoH DNS server IP addresses, so https-dns-proxy won’t be able to resolve domains to IP addresses. A router dead-lock. Web pages won’t load. It does this especially on router reboot, but it’ll happen any time you block Port 53.
But, there is a way to get the best of all worlds: Port 53 completely locked down for device (the router) and lan; DNS resolution via DoH still working, no router dead-lock on reboot or any other time.
To do so, we have to trick https-dns-proxy, we have to create several files and populate them with data, we have to create a script, and we have to create 4 firewall Traffic Rules.
I crashed my router three times trying to get it working. The procedure below ensures you don’t have to go through the same issues.
First, start a Terminal window on your computer, and log into the router via ssh.
ssh [email protected] {enter root password when prompted}
Type:
cat /etc/config/https-dns-proxy
Using the mouse, highlight the entirety of that file, then right-click, select ‘Copy’. Ctrl-C will not work here to copy.
In a text editor, paste the contents of that file you just copied. At the top, press Enter to create a blank line, and in that top, blank line, type:
cat << 'EOF' > /etc/config/https-dns-proxy
… and at the very end of the file, press Enter to create a blank line, and in that bottom, blank line, type:
EOF
If something goes wrong, you can just copy the entirety of that text from the text editor, paste it all-at-once into the Terminal window, press Enter, then type:
/etc/init.d/https-dns-proxy restart
… and https-dns-proxy will restart with the original settings. I tend to make typos, and this safeguard procedure has allowed me to roll back to a working configuration and try again more than a few times.
With that safeguard in place, copy the entirety of that text into another text editor window (so you’ve got two copies… the original, and the one you’ll be working on).
In the working copy, look for the following headers:
config https-dns-proxy
… under each of those, you’ll see:
option bootstrap_dns
… each line under each header will have the bootstrap DNS servers for that DoH server. Those bootstrap DNS servers are doing plaintext Port 53 DNS lookups. So we’ll just get rid of them.
For each option bootstrap_dns line, clear all of the servers between the ’ ’ marks. Then in each one, put:
127.0.0.1
… for IPv4 DoH DNS servers, or
:1
… for IPv6 DoH DNS servers.
Each one will look like this:
option bootstrap_dns '127.0.0.1'
… for IPv4 DoH DNS servers, and:
option bootstrap_dns '::1'
… for IPv6 DoH DNS servers.
Don’t do anything with that file yet. We’ve got a bit of configuring to do first.
We’re going to create some ‘hosts’ files, specifically formatted to the DoH DNS servers.
In your working copy of /etc/config/https-dns-proxy, look for the following lines:
option resolver_url
… there’ll be a URL after that, for instance:
option resolver_url 'https://dns.mullvad.net/dns-query'
… you want to find each of those option resolver_url lines, copy the URLs into another text window, then strip them down to their FQDN (Fully Qualified Domain Name).
For the example above, it would be:
dns.mullvad.net
For each of those FQDNs, you’ll do an nslookup:
nslookup dns.mullvad.net Server: 127.0.0.1 Address: 127.0.0.1:53 Name: dns.mullvad.net Address: 194.242.2.2 Name: dns.mullvad.net Address: 2a07:e340::2
Record the Address: lines in a text file, thusly:
194.242.2.4 dns.mullvad.net 2a07:e340::4 dns.mullvad.net
And create a bash scriplet to inject those entries into their own hosts file:
echo "194.242.2.4 dns.mullvad.net" >> /etc/dnsmasq.d/hosts/dns.mullvad.net echo "2a07:e340::4 dns.mullvad.net" >> /etc/dnsmasq.d/hosts/dns.mullvad.net
NOTE THE FORMAT: echo “[IP ADDRESS] [FQDN]” >> /etc/dnsmasq.d/hosts/[FQDN]
Enter each bash scriplet line into Terminal one-at-a-time, for all of the URLs in the /etc/config/https-dns-proxy file.
Now you’ve got several hosts files, all stored at /etc/dnsmasq.d/hosts.
Here’s an example of one of those files:
cat /etc/dnsmasq.d/hosts/dns.adguard-dns.com 94.140.14.14 dns.adguard-dns.com 94.140.15.15 dns.adguard-dns.com 2a10:50c0::ad1:ff dns.adguard-dns.com 2a10:50c0::ad2:ff dns.adguard-dns.com
You can see all of the files by issuing:
ls /etc/dnsmasq.d/hosts
For instance:
root@OpenWrt:~# ls /etc/dnsmasq.d/hosts dns.adguard-dns.com dns.mullvad.net dnsforge.de doh.libredns.gr doh.opendns.com
If you mess up a file and want to remove it and try again, you can do, for instance:
rm -f /etc/dnsmasq.d/hosts/dns.adguard-dns.com
… then go back up to the bash scriptlet above to redo it.
So I’ve got:
/etc/dnsmasq.d/hosts/dns.adguard-dns.com /etc/dnsmasq.d/hosts/dns.mullvad.net /etc/dnsmasq.d/hosts/dnsforge.de /etc/dnsmasq.d/hosts/doh.libredns.gr /etc/dnsmasq.d/hosts/doh.opendns.com
In the LuCI interface, under Network >> DNS >> Resolv & Hosts Files, enter each of those paths to the Additional hosts files setting, then click the + button next to that entry.

Note the grayed-out last entry:
/etc/dnsmasq.hosts
… this is an example. That file doesn’t actually exist, unless you create it.
Restart the services to pick up those hosts files:
/etc/init.d/dnsmasq restart /etc/init.d/https-dns-proxy restart
NOW… remember that working copy of /etc/config/https-dns-proxy we worked on above? The one where we changed each option bootstrap_dns line to:
option bootstrap_dns '127.0.0.1'
– or –
option bootstrap_dns '::1'
Copy the entirety of that working copy, from cat to EOF, paste it into the Terminal window all-in-one-go, then press `Enter’.
Trigger a restart of https-dns-proxy so it loads the new settings:
/etc/init.d/https-dns-proxy restart
Now you can lock down your firewall. Create the following Network >> Firewall >> Traffic Rules:
Drop-Router-To-Wan (Port 53, 853, 5353) Outgoing IPv4 and IPv6, protocol TCP, UDP From this device To wan, port 53, 853, 5353 Drop output
Reject-LAN-To-Wan (Port 53, 853) Forwarded IPv4 and IPv6, protocol TCP, UDP From lan To wan, port 53, 853 Reject forward
Drop-LAN-To-Wan (Port 5353) Forwarded IPv4 and IPv6, protocol UDP From lan To wan, port 5353 Drop forward
Drop-WAN-To-Router (Port 53, 853) Incoming IPv4 and IPv6, protocol TCP, UDP From wan To this device, port 53, 853 Drop input
Ensure each new Traffic Rule has the Enable checkbox checked, then click Save & Apply at the bottom of the page.
Congratulations, your router and your lan are now locked down for Port 53 plaintext DNS resolution. The only DNS resolution which can take place must take place via DoH.
“But DNS servers sometimes change their IP addresses!”, you exclaim, “My DoH DNS server hosts files will become stale over time!”
Remember my stating that we’d need to create a script?
Copy the entirety of this script, from cat to EOF, paste it into the Terminal window all-in-one-go, then hit Enter.
cat << 'EOF' > /etc/update-doh-bootstrap.sh #!/bin/sh HOSTS_DIR="/etc/dnsmasq.d/hosts" UPDATED=0 TEST_DOMAIN="one.one.one.one" echo "=== Executing Round-Robin Bootstrap Update ===" # Ensure the required tracking directory exists mkdir -p "$HOSTS_DIR" # 1. DYNAMICALLY DISCOVER ACTIVE DOMAINS FROM CONFIG VIA SYSTEM NUMERIC INDEXING ACTIVE_DOMAINS="" # Find out how many blocks exist by looking at the configuration length NUM_CONFIG_BLOCKS=$(uci show https-dns-proxy 2>/dev/null | grep -c "=https-dns-proxy") if [ "$NUM_CONFIG_BLOCKS" -eq 0 ] || [ -z "$NUM_CONFIG_BLOCKS" ]; then echo "[ERROR] No configuration blocks found in /etc/config/https-dns-proxy. Aborting." exit 1 fi # Loop through every config block sequentially using its raw index idx=0 while [ "$idx" -le "$((NUM_CONFIG_BLOCKS - 1))" ]; do url=$(uci -q get https-dns-proxy.@https-dns-proxy[$idx].resolver_url) if [ -n "$url" ]; then # Extract FQDN domain=$(echo "$url" | sed -E 's|^[^:]+://||; s|/.*||') if [ -n "$domain" ]; then # Append if it's unique case " $ACTIVE_DOMAINS " in *" $domain "*) ;; *) ACTIVE_DOMAINS="$ACTIVE_DOMAINS $domain" ;; esac fi fi idx=$((idx + 1)) done # Trim trailing whitespace ACTIVE_DOMAINS=$(echo $ACTIVE_DOMAINS) # HOUSEKEEPING: PRUNE DELETED PROXIES FROM THE HOSTS FOLDER for file_path in "$HOSTS_DIR"/*; do [ -f "$file_path" ] || continue file_domain=$(basename "$file_path") case " $ACTIVE_DOMAINS " in *" $file_domain "*) ;; *) echo " Pruning unused proxy domain: $file_domain" rm -f "$file_path" UPDATED=1 ;; esac done # Count how many unique target domains are active NUM_DOMAINS=0 for d in $ACTIVE_DOMAINS; do NUM_DOMAINS=$((NUM_DOMAINS + 1)) done if [ "$NUM_DOMAINS" -eq 0 ]; then echo "[ERROR] Found configuration blocks, but failed to extract active FQDN urls. Aborting." exit 1 fi # Function to get an element by index from the list (1-based index) get_domain_at_index() { target_idx=$1 count=1 for d in $ACTIVE_DOMAINS; do if [ "$count" -eq "$target_idx" ]; then echo "$d" return fi count=$((count + 1)) done } # Helper to find which local port a specific DoH domain is running on get_proxy_port() { target_domain=$1 local port="" local block_idx=0 while [ "$block_idx" -le "$((NUM_CONFIG_BLOCKS - 1))" ]; do local check_url=$(uci -q get https-dns-proxy.@https-dns-proxy[$block_idx].resolver_url) if echo "$check_url" | grep -q "$target_domain"; then port=$(uci -q get https-dns-proxy.@https-dns-proxy[$block_idx].listen_port) break fi block_idx=$((block_idx + 1)) done echo "${port:-5053}" } # EXECUTE ROLLING CHAIN UPDATES WITH HEALTH VALIDATION i=1 while [ "$i" -le "$NUM_DOMAINS" ]; do domain=$(get_domain_at_index "$i") # Calculate the previous index for the chain. Wrap around if at index 1. if [ "$i" -eq 1 ]; then prev_idx="$NUM_DOMAINS" else prev_idx=$((i - 1)) fi upstream_domain=$(get_domain_at_index "$prev_idx") # Locate the local secure port for the upstream proxy domain secure_port=$(get_proxy_port "$upstream_domain") echo "Checking upstream proxy health: $upstream_domain on Local Port $secure_port..." # Run a test query through the upstream proxy port to ensure it's alive and answering PROXY_HEALTH=$(nslookup -port="$secure_port" "$TEST_DOMAIN" 127.0.0.1 2>/dev/null | grep -c "Address") file_path="$HOSTS_DIR/$domain" TMP_FILE="/tmp/bootstrap.$domain.tmp" rm -f "$TMP_FILE" if [ "$PROXY_HEALTH" -gt 0 ]; then echo " [HEALTHY] Querying $domain via active proxy port $secure_port..." RAW_IPS=$(nslookup -port="$secure_port" "$domain" 127.0.0.1 2>/dev/null | awk '/Address/ {print $2}' | sed 's/Address://g' | grep -v '127.0.0.1' | grep -v '^$') else echo " [UNHEALTHY/OFFLINE] Upstream port $secure_port did not respond. Bypassing..." RAW_IPS="" fi # Fail-safe bootstrap fallback: if the proxy test failed OR the returned list is empty if [ -z "$RAW_IPS" ]; then echo " [Fallback] Attempting direct public bootstrap for $domain..." RAW_IPS=$(nslookup "$domain" 1.1.1.1 2>/dev/null | awk '/Address/ {print $2}' | sed 's/Address://g' | grep -v '1.1.1.1' | grep -v '^$') # Double check backup public endpoint if Cloudflare dropped it if [ -z "$RAW_IPS" ]; then RAW_IPS=$(nslookup "$domain" 9.9.9.9 2>/dev/null | awk '/Address/ {print $2}' | sed 's/Address://g' | grep -v '9.9.9.9' | grep -v '^$') fi fi if [ -n "$RAW_IPS" ]; then for ip in $RAW_IPS; do echo "$ip $domain" >> "$TMP_FILE" done # Sort the file in place so round-robin ordering doesn't trick the comparison sort -o "$TMP_FILE" "$TMP_FILE" if ! cmp -s "$file_path" "$TMP_FILE"; then echo " [Update] Network shift or new configuration detected. Updating $domain." mv "$TMP_FILE" "$file_path" UPDATED=1 else echo " [OK] Local cached IPs match live configurations." rm -f "$TMP_FILE" fi else echo " [ERROR] Could not safely resolve $domain via proxy or fallback. Preserving existing records." rm -f "$TMP_FILE" fi i=$((i + 1)) done # If updates occurred, signal DNSMASQ to instantly pick up the modifications if [ "$UPDATED" -eq 1 ]; then killall -HUP dnsmasq echo "=== Network stack synchronized successfully ===" else echo "=== All definitions match live configurations. No reloads needed. ===" fi EOF
Make the file executable:
chmod +x /etc/update-doh-bootstrap.sh
Run the file manually the first time:
sh /etc/update-doh-bootstrap.sh
If you run it a second time immediately, it’ll state that no changes need be made.
You’ll note that the script is using the DoH DNS servers to update the IP addresses in the hosts files, so the router always knows the IP addresses for the DoH DNS server domains.
Now set it up to run automatically. In the LuCI interface, under System >> Scheduled Tasks, enter:
# Update DoH bootstrap servers, so Port 53 plaintext DNS queries can be completely blocked (DAILY @ 0330) 30 3 * * * /etc/update-doh-bootstrap.sh
You can set it to run whenever you like, and however often you like:
# ┌───────────── minute (0–59) # │ ┌───────────── hour (0–23) # │ │ ┌───────────── day of month (1–31) # │ │ │ ┌───────────── month (1–12 or JAN–DEC) # │ │ │ │ ┌───────────── day of week (0–7 or SUN–SAT, 0 and 7 = Sunday) # │ │ │ │ │ # * * * * *
You can test each DoH DNS server by doing an nslookup on domains you’ve not visited (so they’re not in the dnsmasq cache):
nslookup -port=5053 redstate.com 127.0.0.1 nslookup -port=5054 wmbriggs.com 127.0.0.1 nslookup -port=5055 wattsupwiththat.com 127.0.0.1
… etc., etc., for however many ports you’re using in https-dns-proxy.
Pro-Tip: To speed up DNS resolution (in the LuCI interface):
Network >> DNS >> General >> All Servers {checked} Network >> DNS >> Resolv & Hosts Files >> Strict order {unchecked}
Pro-Tip: If things go wrong and you quickly need DNS resolution, create this Network >> Firewall >> Traffic Rule, but leave it Disabled (uncheck 'Enable`). Enable it to allow regular DNS resolution:
Allow-Router-Bootstrap (Port 53) Outgoing IPv4 and IPv6, protocol UDP From this device To wan, IP 94.140.14.14, 94.140.15.15, 2a10:50c0::1:ff, 2a10:50c0::2:ff, port 53 Accept output
… use whichever IP addresses from your list of DoH DNS servers you want. Click-and-hold the ‘three-horizontal-lines’ button for that new rule, then drag it above the Drop-Router-To-Wan (Port 53, 853, 5353) rule, then click Save & Apply.
For reference, here’s my /etc/config/https-dns-proxy file. I’ve put Canary Domains into a separate file and set it up to pull them in via an include (Https-dns-proxy Canary Domain glitch), because https-dns-proxy messes up the Network >> DNS >> Forwards list if it has to handle the Canary Domains:
config main 'config' option canary_domains_icloud '0' option canary_domains_mozilla '0' option force_dns '1' option notrack_dns '1' list force_dns_port '53' list force_dns_port '853' list force_dns_src_interface 'lan' option procd_trigger_wan6 '0' option heartbeat_domain 'heartbeat.mossdef.org' option heartbeat_sleep_timeout '30' option heartbeat_wait_timeout '10' option user 'nobody' option group 'nogroup' option force_ip_family 'auto' option update_dnsmasq '0' option force_dnsmasq '0' config https-dns-proxy option resolver_url 'https://dns.adguard-dns.com/dns-query' option bootstrap_dns '127.0.0.1' option listen_port '5053' option listen_addr '127.0.0.1' option force_ip_family 'ipv4' config https-dns-proxy option resolver_url 'https://dns.mullvad.net/dns-query' option bootstrap_dns '127.0.0.1' option listen_port '5054' option listen_addr '127.0.0.1' option force_ip_family 'ipv4' config https-dns-proxy option resolver_url 'https://doh.libredns.gr/ads' option bootstrap_dns '127.0.0.1' option listen_port '5055' option listen_addr '127.0.0.1' option force_ip_family 'ipv4' config https-dns-proxy option resolver_url 'https://doh.opendns.com/dns-query' option bootstrap_dns '127.0.0.1' option listen_port '5056' option listen_addr '127.0.0.1' option force_ip_family 'ipv4' config https-dns-proxy option resolver_url 'https://dnsforge.de/dns-query' option bootstrap_dns '127.0.0.1' option listen_port '5057' option listen_addr '127.0.0.1' option force_ip_family 'ipv4' config https-dns-proxy option resolver_url 'https://dns.adguard-dns.com/dns-query' option bootstrap_dns '::1' option listen_port '5053' option listen_addr '::1' option force_ip_family 'ipv6' config https-dns-proxy option resolver_url 'https://dns.mullvad.net/dns-query' option bootstrap_dns '::1' option listen_port '5054' option listen_addr '::1' option force_ip_family 'ipv6' config https-dns-proxy option resolver_url 'https://doh.libredns.gr/dns-query' option bootstrap_dns '::1' option listen_port '5055' option listen_addr '::1' option force_ip_family 'ipv6' config https-dns-proxy option resolver_url 'https://doh.opendns.com/dns-query' option bootstrap_dns '::1' option listen_port '5056' option listen_addr '::1' option force_ip_family 'ipv6' config https-dns-proxy option resolver_url 'https://dnsforge.de/dns-query' option bootstrap_dns '::1' option listen_port '5057' option listen_addr '::1' option force_ip_family 'ipv6'