Split-Horizon DNS Misconfiguration

Intermediate DNS

A split-horizon (split-brain) DNS setup is intended to return private IP addresses to internal clients and public IPs to external clients for the same hostname. Due to misconfiguration, the zones are out of sync — internal clients receive the public IP (routing through the internet for internal services) or external clients receive RFC 1918 private addresses that are unreachable from the internet, causing connection failures on both sides.

Symptoms

  • Internal clients connect to services via the internet (hairpin NAT) instead of the direct internal path
  • VPN-connected clients cannot reach internal services by hostname, even though split-tunnel routes are configured correctly
  • External users get NXDOMAIN or RFC 1918 addresses for a public-facing hostname
  • The same hostname resolves to different IPs depending on which machine runs the query
  • Internal DNS resolver returns public IPs for an internal service (e.g., app.corp.com → 203.0.113.10 instead of 10.0.1.50)
  • After a DNS zone update, internal and external views diverge — one view was updated but the other was not

Possible Root Causes

  • The internal DNS view's match-clients ACL does not cover all internal subnets or VPN tunnel address ranges, so some internal clients fall through to the external view
  • Zone records were updated in the external view but not the internal view (or vice versa), causing the two views to diverge
  • VPN clients are not receiving or not honoring the VPN-pushed DNS server, so they continue using the public resolver and receive external-view responses
  • Internal clients are configured with a public DNS resolver (8.8.8.8) rather than the internal server, bypassing the split-horizon entirely
  • A new subnet was added (cloud environment, new VLAN, new VPN pool) without updating the internal-nets ACL on the DNS server

Diagnosis Steps

Step 1 — Identify which view a client is using

# Query from inside the network (internal view)
dig A app.corp.com @<internal-dns-server>

# Query from outside the network (external view / public resolver)
dig A app.corp.com @8.8.8.8
dig A app.corp.com @1.1.1.1

# Compare the two results — they should return different IPs
# Internal: 10.x.x.x / 172.16.x.x / 192.168.x.x
# External: a public routable IP

Step 2 — Verify which DNS server internal clients actually use

# Linux
resolvectl status | grep "DNS Servers"
cat /etc/resolv.conf

# macOS
scutil --dns | grep nameserver

# Windows
ipconfig /all | findstr "DNS Servers"

If internal clients are using a public resolver (8.8.8.8) instead of the internal one, split-horizon cannot work — all clients will see the public view.

Step 3 — Check zone configuration on the internal DNS server

For BIND 9:

# List all views and their match conditions
grep -A 5 'view "internal"' /etc/bind/named.conf
grep -A 5 'view "external"' /etc/bind/named.conf

# Verify zone file for the internal view
named-checkzone corp.com /etc/bind/zones/internal/db.corp.com

# Verify the match-clients ACL covers the internal subnets
grep -A 10 'acl "internal-nets"' /etc/bind/named.conf

Step 4 — Test from inside a VPN tunnel

VPN clients often fail split-horizon because the VPN push does not override the client's stub resolver configuration:

# On VPN-connected client — what resolver is active?
resolvectl status | grep -A 5 tun0    # Linux
scutil --dns | head -30               # macOS

# Does the VPN-assigned DNS server respond correctly?
dig A app.corp.com @<vpn-pushed-dns>

If the VPN-pushed DNS server is correct but the client is not using it, the issue is in VPN client DNS push configuration (push "dhcp-option DNS 10.0.0.53" in OpenVPN, or DNS setting in WireGuard Peer section).

Step 5 — Confirm zone serial numbers are in sync

Different serials between views means updates have been applied to one view only:

# Check SOA serial for internal view
dig SOA corp.com @<internal-dns>

# Check SOA serial for external view
dig SOA corp.com @<external-dns>

# Or directly on the nameserver
rndc zonestatus corp.com IN internal
rndc zonestatus corp.com IN external

Step 6 — Test hairpin NAT (the common symptom)

If internal clients are hitting the public IP of an internal service:

# traceroute from internal client to the service hostname
traceroute app.corp.com

# Does the path go out to the internet and back?
# Internal direct: 10.0.0.1 → 10.0.1.50 (1-2 hops)
# Hairpin (broken): 10.0.0.1 → 203.0.113.10 → NAT back in → 10.0.1.50 (many hops)

Solution

Fix 1 — Update match-clients ACL to include all internal ranges

# /etc/bind/named.conf or named.conf.local

acl "internal-nets" {
    10.0.0.0/8;
    172.16.0.0/12;
    192.168.0.0/16;
    10.8.0.0/24;      # Add VPN pool
    localhost;
    localnets;
};

view "internal" {
    match-clients { internal-nets; };
    # ...
};

view "external" {
    match-clients { any; };
    # ...
};
# Validate and reload
sudo named-checkconf
sudo rndc reload

Fix 2 — Synchronize zone records across views

After any DNS record change, update BOTH views and increment the SOA serial in each:

# Update internal zone file
sudo vim /etc/bind/zones/internal/db.corp.com

# Update external zone file
sudo vim /etc/bind/zones/external/db.corp.com

# Reload both zones
sudo rndc reload corp.com IN internal
sudo rndc reload corp.com IN external

# Verify serials match (or internal > external for recently updated)
dig SOA corp.com @<internal-ns>
dig SOA corp.com @<external-ns>

Fix 3 — Fix VPN DNS push

For OpenVPN:

# server.conf — push DNS to all clients
push "dhcp-option DNS 10.0.0.53"
push "dhcp-option DNS 10.0.0.54"

For WireGuard (client config):

[Interface]
DNS = 10.0.0.53

[Peer]
# ...

Fix 4 — Enforce internal DNS on internal clients (DHCP option 6)

# ISC DHCP — /etc/dhcp/dhcpd.conf
subnet 10.0.0.0 netmask 255.255.255.0 {
    option domain-name-servers 10.0.0.53, 10.0.0.54;
}

Prevention

  • Maintain a single source-of-truth zone file for each domain and generate internal/external views from it programmatically — manual dual-maintenance is error-prone
  • Add automated testing after every DNS change: verify that the same hostname resolves to the internal IP from a host inside each internal subnet and to the public IP from an external resolver
  • Document all internal subnets and VPN pools in the DNS server's ACL comments; update the ACL whenever a new network segment is provisioned
  • Monitor for hairpin NAT traffic at the perimeter firewall — internal-to-internal traffic that leaves and re-enters the network indicates split-horizon failure
  • Use a configuration management tool (Ansible, Terraform) to apply DNS changes to both views atomically, preventing one-view-only updates

Related Protocols

Related Terms

More in DNS