Deploying an aggressive network interception engine solves telemetry leakage, but it introduces two severe operational liabilities: single-resolver outages and blanket egress exposure. When your edge firewall enforces strict DNAT redirection for port 53 and blackholes upstream encrypted ports (853, direct DoH CIDRs), any maintenance reboot or kernel panic on your DNS host halts network resolution across all domestic and lab endpoints. Simultaneously, specific lab environments—such as torrent testing nodes, off-site scrape daemons, and guest clients—require selective WAN masking through commercial privacy VPNs without penalizing the gigabit throughput of your primary trusted workstations.
To address both constraints, I expanded my edge architecture with an automated AdGuard Home state-synchronization daemon (adguardhome-sync) operating across discrete bare-metal compute hosts, alongside Policy-Based Routing (PBR) via WireGuard client tunnels provisioned directly on the UniFi Gateway Max (UXG-Max).
1. High-Availability Resolver Architecture
Relying on a single AdGuard container for the entire network creates an unacceptably fragile failure domain. The solution is an active-active local deployment where both instances listen on tagged VLAN interfaces, synchronized continuously over mutual REST APIs.
[ UniFi Gateway Max (DHCP Option 6) ]
├── DNS Primary: 192.168.20.5 (Host Alpha)
└── DNS Secondary: 192.168.20.6 (Host Beta)
│
┌─────────────────────┴─────────────────────┐
▼ ▼
[ Node Alpha: Primary ] [ Node Beta: Replica ]
- IP: 192.168.20.5 - IP: 192.168.20.6
- AdGuard Core Engine - AdGuard Core Engine
- Local TLS Termination - Local TLS Termination
│ ▲
│ (Sync State via REST API / Cron) │
└──────────────► [ adguardhome-sync ] ──────┘
The DHCP Distribution Strategy
In standard consumer environments, specifying two DNS servers often leads to non-deterministic round-robin distribution by client resolver stubs (e.g., systemd-resolved or Apple mDNSResponder). In an active-active model, this is advantageous:
- Both nodes share identical blocklists, client tags, and rewrites.
- Both nodes run local DNS-over-QUIC / DNS-over-HTTPS/3 upstream connections.
- If either node undergoes updates or fails, clients seamlessly fail over with zero dropped queries.
Deploying adguardhome-sync via Docker
To eliminate manual drift between the primary node (192.168.20.5) and replica node (192.168.20.6), we deploy adguardhome-sync inside the infrastructure VLAN. It continuously reconciles filtering rules, custom DNS rewrites, DHCP static leases, and client settings.
docker-compose.sync.yml
version: '3.8'
services:
adguardhome-sync:
image: ghcr.io/bakito/adguardhome-sync:v0.6.14
container_name: adguard-sync-daemon
restart: unless-stopped
environment:
- LOG_LEVEL=info
- CRON=*/30 * * * * * # Bi-directional check every 30 seconds
- ORIGIN_URL=http://192.168.20.5:3000
- ORIGIN_USERNAME=api-sync-admin
- ORIGIN_PASSWORD_FILE=/run/secrets/origin_api_key
- REPLICA_URL=http://192.168.20.6:3000
- REPLICA_USERNAME=api-sync-admin
- REPLICA_PASSWORD_FILE=/run/secrets/replica_api_key
- FEATURES_GENERAL_SETTINGS=true
- FEATURES_REWRITES=true
- FEATURES_SERVICES=true
- FEATURES_FILTERS=true
- FEATURES_CLIENTS=true
secrets:
- origin_api_key
- replica_api_key
networks:
infra_bridge:
ipv4_address: 192.168.20.7
secrets:
origin_api_key:
file: /opt/secrets/agh_origin.key
replica_api_key:
file: /opt/secrets/agh_replica.key
networks:
infra_bridge:
external: true
Bi-Weekly Synchronized Certificate Distribution
Both resolvers terminate DoT and DoH internally using the wild-card certificate obtained via Cloudflare DNS-01 challenges on the primary host. Once the systemd renewal timer completes, an automated post-hook distributes the updated certificate pair to the secondary node using rsync over mutual SSH keys:
#!/usr/bin/env bash
# /opt/scripts/post-cert-distribute.sh
set -euo pipefail
PRIMARY_CERT_PATH="/opt/certs/live/homelab.internal"
REPLICA_HOST="[email protected]"
REPLICA_CERT_PATH="/opt/certs/live/homelab.internal"
echo "[$(date -Iseconds)] Mirroring active TLS credentials to secondary resolver..."
rsync -avz --delete \
-e "ssh -i /root/.ssh/id_ed25519_certsync -o StrictHostKeyChecking=accept-new" \
"$PRIMARY_CERT_PATH/" \
"$REPLICA_HOST:$REPLICA_CERT_PATH/"
echo "[$(date -Iseconds)] Triggering remote AdGuard reload..."
ssh -i /root/.ssh/id_ed25519_certsync "$REPLICA_HOST" \
"docker exec -i adguard-core /opt/adguardhome/AdGuardHome -s reload"
echo "[$(date -Iseconds)] Cluster certificate rotation completed."
Selective Policy-Based Routing (PBR) on UniFi Gateway Max
With robust internal resolution established, outbound egress steering is enforced at the gateway layer. The UXG-Max offloads WireGuard encryption in hardware, capable of sustaining line-rate gigabit speeds on standard MTU allocations.
[ Client Egress Request ]
│
▼
[ UniFi Gateway Max Policy Engine ]
│
┌─────────────────────────┴─────────────────────────┐
│ │
▼ ▼
[ Default Route: WAN 1 ] [ Policy Route: WG0 ]
- Fiber ONT Gateway - Mullvad / Proton WireGuard
- Trusted Workstations - Lab Scraper Subnet (VLAN 50)
- Low Latency (2ms) - Selected IoT Workloads
- Native ISP Public IP - Anonymized WAN Gateway
Step 1: Provisioning the WireGuard Client Interface
Inside the UniFi Network Controller (Settings > VPN > VPN Client):
- Protocol: WireGuard
- Interface Name: wg0-vpn-exit
- Interface Address: 10.64.120.45/32
- Endpoint: 185.213.154.68:51820 (Example secure transit peer)
- Private Key: [SECURE_CLIENT_PRIVATE_KEY]
- Public Key: [PEER_SERVER_PUBLIC_KEY]
- Preshared Key (Optional): Enabled for post-quantum resistance
- MTU: 1420 (Clamped to avoid outer UDP fragmentation across PPPoE/Fiber uplinks)
Step 2: Formulating Policy-Based Routing (PBR) Rules
Under Settings > Routing > Policy-Based Routing, create target routing rules to steer designated traffic flows down the encrypted WireGuard tunnel without altering DNS attribution:
Rule 1: Sandboxed Lab Isolation via WireGuard
- What to Route: VLAN 50: LAB (192.168.50.0/24)
- Interface: wg0-vpn-exit
- Fallback Behavior: Drop Traffic (Kill-Switch)
- Objective: Prevent telemetry or sandboxed test runs from ever escaping over the cleartext ISP WAN if the tunnel collapses.
Rule 2: Ephemeral Workstation Tunneling by Target Domain
- What to Route: Specific destination domains (e.g., Geo-restricted cloud APIs or streaming CDNs)
- Traffic Type: Domain Name List (*.targetservice.com)
- Source: VLAN 10: TRUSTED
- Interface: wg0-vpn-exit
- Fallback Behavior: Fallback to Default WAN
Preventing VPN Leakage: The DNS/Kill-Switch Nexus
A pervasive issue with commercial VPN routing is the “DNS Leak”: while payload TCP traffic routes through the WireGuard tunnel, system DNS lookups still query the local network resolver, associating public ISP identities with target queries.
Because our network enforces the DNAT Port 53 Catch-All and drops port 853/DoH endpoints, we maintain complete control over how DNS resolves for tunnel-bound endpoints.
[ WireGuard Client (VLAN 50) ]
│
▼ (Query for external target)
[ Gateway DNAT Rule ] ──────────► Forces query to AdGuard (192.168.20.5)
│
▼
[ AdGuard Home Resolution ]
│
┌─────────────────────────────────┴─────────────────────────────────┐
▼ ▼
[ Upstream: Cloudflare DoQ via WAN ] [ Internal: split-horizon homelab.internal ]
- Encrypted In-Flight - Resolves to Private RFC1918 IPs
- Stripped EDNS Geolocation - Returned directly to client
By decoupling DNS resolution from the egress gateway interface:
- Internal hostnames (*.homelab.internal, storage nodes, Proxmox hypervisors) remain 100% resolvable to devices routed through the VPN tunnel.
- Upstream requests never expose the client’s internal IP address because edns_client_subnet is globally disabled in AdGuard.
- If the WireGuard peer interface stalls, the UXG-Max firewall policy terminates egress instantly, upholding a strict kill-switch guarantee.
Verification & Audit Commands
Run the following diagnostics to verify transparent replication and egress encapsulation:
# 1. Audit DNS sync engine health
docker logs -f adguard-sync-daemon --tail 20
# 2. Verify replica sync parity
diff <(curl -s -u admin:$PASS http://192.168.20.5:3000/control/filtering/status | jq .filters) \
<(curl -s -u admin:$PASS http://192.168.20.6:3000/control/filtering/status | jq .filters)
# 3. Test WireGuard kill-switch on Lab host (192.168.50.15)
# When wg0 is up:
curl -s https://ipinfo.io/json | jq '{ip: .ip, org: .org}'
# Result: returns WireGuard endpoint exit IP
# When wg0 interface is disabled on UXG-Max:
curl --connect-timeout 3 https://ipinfo.io/json
# Result: curl: (28) Connection timed out (Kill-switch verified)
Architectural Lessons & Trade-Offs
1. Replicating Dynamic Leases
adguardhome-sync synchronizes static DHCP leases and rewrite records seamlessly. However, if you run DHCP directly inside AdGuard rather than at the UniFi gateway, dynamic leases will conflict if both instances issue IP pools simultaneously. For this reason, keep DHCP authoritative on the UniFi Gateway Max, reserving AdGuard exclusively for L7 DNS filtering and rewrite coordination.
2. MTU Clamping on Policy-Routed WireGuard
Setting your WireGuard interface MTU too high (e.g., standard 1500) causes upstream MSS blackholing when encapsulated traffic passes through fiber ONT connections. Clamping the WireGuard tunnel interface to 1420 (or 1360 over PPPoE) prevents silent TCP stall cycles on TLS client hellos.
3. State Engine Parity
Running bi-directional syncing every 30 seconds introduces minimal CPU overhead (~0.2% on an idle x86 core). However, always designate the primary node as the sole authoritative source of truth for automated configuration scripts, treating the secondary strictly as a hot replica to avoid split-brain race conditions.