Skip to content

Instantly share code, notes, and snippets.

@saihtaM
Created February 21, 2026 20:41
Show Gist options
  • Select an option

  • Save saihtaM/2dc3989b511deda67251cf9c2aba01fd to your computer and use it in GitHub Desktop.

Select an option

Save saihtaM/2dc3989b511deda67251cf9c2aba01fd to your computer and use it in GitHub Desktop.
dns-prefer-ipv6 - Don't give A record, if AAAA exist, to help some services being more IPv6 friendly instead of just using IPv4 in dual stack servers.

dns-prefer-ipv6

WARNING: 98% of this was written by an LLM

A tiny Python DNS proxy that makes IPv6-only servers work correctly with software that queries A records first and never falls back to AAAA.

The problem

Some DNS resolvers (e.g. Rust's hickory, older getaddrinfo configs) query A records first. On a server with only outbound IPv6 (or NAT64 that blocks non-web ports), connecting to an IPv4 address simply fails — and the badly coded client never retries with AAAA.

The fix

Run this proxy on 127.0.0.1:53 as the first nameserver. For every A query, it fires a parallel AAAA probe:

  • Host has both A and AAAA → return NODATA for the A query (caller is forced onto IPv6)
  • Host has only A records → pass the A record through (IPv4-only hosts still work, e.g. via NAT64)
  • Any other query type (AAAA, SRV, MX, …) → forwarded unchanged

Requirements

  • Python 3.10+
  • python3-dnslib (or pip install dnslib)
  • A Linux system running systemd
  • Port 53 on localhost must be free (see step 3)

Install

1. Install the dependency

apt install python3-dnslib        # Debian/Ubuntu
# or: pip install dnslib

2. Install the script

mv dns-prefer-ipv6.py /usr/local/bin/dns-prefer-ipv6.py
chmod +x /usr/local/bin/dns-prefer-ipv6.py

Edit the UPSTREAMS list near the top of the script to point at your actual resolver IPs. Use cat /etc/resolv.conf to find them, then replace the placeholder addresses.

3. Free port 53 on localhost

Something else is probably listening on 127.0.0.1:53. Common culprits:

systemd-resolved stub listener (most common):

# /etc/systemd/resolved.conf
[Resolve]
DNSStubListener=no

systemctl restart systemd-resolved

Another DNS server (e.g. PowerDNS, bind) bound to 0.0.0.0 or 127.0.0.1: Restrict it to your public IP only. For PowerDNS:

# /etc/powerdns/pdns.conf – add:
local-address=YOUR.PUBLIC.IP.HERE   # your public IP

systemctl restart pdns

Verify the port is free:

ss -ulnp sport = 53
# Should show nothing on 127.0.0.1 or ::1

4. Install and start the service

cp dns-prefer-ipv6.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now dns-prefer-ipv6.service
systemctl status dns-prefer-ipv6.service

5. Make it your first nameserver

Debian/Ubuntu with resolvconf:

echo "nameserver 127.0.0.1" > /etc/resolvconf/resolv.conf.d/head
resolvconf -u

Or edit /etc/resolv.conf directly (will be overwritten on reboot unless managed):

nameserver 127.0.0.1
nameserver <your-upstream-1>
nameserver <your-upstream-2>

6. (Podman/Docker) Containers need a bind-mount

Containers get their own mount namespace. Even with --network=host, the container's /etc/resolv.conf is a snapshot copied at start time — it does not track live changes to the host file.

Add this flag to your container run command:

-v /etc/resolv.conf:/etc/resolv.conf:ro

This bind-mounts the live host file into the container so it always sees 127.0.0.1 as the first nameserver, regardless of when the container was started.

Tests

# A record suppressed (matrix.org has both A and AAAA)
dig A matrix.org @127.0.0.1
# Expected: NOERROR, zero answers (NODATA)

# AAAA record returned normally
dig AAAA matrix.org @127.0.0.1
# Expected: NOERROR, one or more AAAA answers

# IPv4-only host passes A through unchanged
dig A example.com @127.0.0.1
# Expected: NOERROR, one or more A answers

# Watch suppression live
journalctl -u dns-prefer-ipv6.service -f
# Lines like: suppress A  matrix.org (AAAA exists)

Revert

systemctl disable --now dns-prefer-ipv6.service
rm /usr/local/bin/dns-prefer-ipv6.py /etc/systemd/system/dns-prefer-ipv6.service
# Remove "nameserver 127.0.0.1" from /etc/resolv.conf (or resolvconf head)
# Re-bind your previous DNS server to 127.0.0.1 if needed
#!/usr/bin/env python3
"""
dns-prefer-ipv6: DNS proxy that suppresses A records when AAAA records exist.
For any A query: if the same name also has AAAA records, return NODATA (empty
NOERROR) so the caller only sees IPv6. If there are no AAAA records, the A
records are returned unchanged. All other query types are forwarded as-is.
Listens on 127.0.0.1:53 and [::1]:53 (UDP + TCP).
"""
import concurrent.futures
import logging
import signal
import socket
import struct
import threading
from dnslib import DNSRecord, QTYPE
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger(__name__)
# Upstream resolvers – queried directly (bypassing this proxy)
# Edit these to match your actual resolvers (IPv6 preferred; IPv4 as fallback)
UPSTREAMS = [
("2620:fe::11", 53), # hoster IPv6 resolver 1
("2620:fe::fe:11", 53), # hoster IPv6 resolver 2
("9.9.9.11", 53), # hoster IPv4 resolver 1
]
TIMEOUT = 3 # seconds per upstream attempt
# Shared thread pool for parallel A + AAAA upstream queries
POOL = concurrent.futures.ThreadPoolExecutor(max_workers=64, thread_name_prefix="dns")
# ---------------------------------------------------------------------------
# Upstream helpers
# ---------------------------------------------------------------------------
def _raw_query(data: bytes) -> bytes | None:
"""Send raw DNS packet to the first responsive upstream; return raw reply."""
for addr, port in UPSTREAMS:
family = socket.AF_INET6 if ":" in addr else socket.AF_INET
try:
with socket.socket(family, socket.SOCK_DGRAM) as s:
s.settimeout(TIMEOUT)
s.sendto(data, (addr, port))
return s.recvfrom(4096)[0]
except OSError:
continue
return None
def _has_aaaa(qname: str) -> bool:
"""Return True if upstream returns at least one AAAA record for qname."""
try:
req = DNSRecord.question(qname.rstrip("."), "AAAA")
raw = _raw_query(req.pack())
if raw:
resp = DNSRecord.parse(raw)
return any(r.rtype == QTYPE.AAAA for r in resp.rr)
except Exception:
pass
return False
# ---------------------------------------------------------------------------
# Core query processor
# ---------------------------------------------------------------------------
def process(data: bytes) -> bytes | None:
"""
Process a DNS query.
- Non-A queries → forwarded unchanged.
- A query with AAAA available → empty NOERROR (NODATA).
- A query without AAAA → forwarded unchanged.
"""
try:
req = DNSRecord.parse(data)
except Exception:
return None
if req.q.qtype != QTYPE.A:
return _raw_query(data)
# Fire both the real A query and the AAAA probe in parallel
qname = str(req.q.qname)
a_fut = POOL.submit(_raw_query, data)
aaaa_fut = POOL.submit(_has_aaaa, qname)
try:
a_raw = a_fut.result(timeout=TIMEOUT + 1)
has_aaaa = aaaa_fut.result(timeout=TIMEOUT + 1)
except concurrent.futures.TimeoutError:
a_raw = a_fut.result() if a_fut.done() else None
has_aaaa = False
if has_aaaa:
log.info("suppress A %s (AAAA exists)", qname.rstrip("."))
return req.reply().pack() # empty NOERROR
return a_raw
# ---------------------------------------------------------------------------
# UDP server
# ---------------------------------------------------------------------------
def _udp_server(bind_addr: str) -> None:
family = socket.AF_INET6 if ":" in bind_addr else socket.AF_INET
s = socket.socket(family, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((bind_addr, 53))
log.info("UDP listening on %s:53", bind_addr)
while True:
data, addr = s.recvfrom(4096)
def _handle(d=data, a=addr, sock=s) -> None:
resp = process(d)
if resp:
try:
sock.sendto(resp, a)
except OSError:
pass
POOL.submit(_handle)
# ---------------------------------------------------------------------------
# TCP server
# ---------------------------------------------------------------------------
def _tcp_client(conn: socket.socket) -> None:
try:
buf = b""
while len(buf) < 2:
chunk = conn.recv(2 - len(buf))
if not chunk:
return
buf += chunk
(length,) = struct.unpack("!H", buf)
data = b""
while len(data) < length:
chunk = conn.recv(length - len(data))
if not chunk:
return
data += chunk
resp = process(data)
if resp:
conn.sendall(struct.pack("!H", len(resp)) + resp)
except OSError:
pass
finally:
conn.close()
def _tcp_server(bind_addr: str) -> None:
family = socket.AF_INET6 if ":" in bind_addr else socket.AF_INET
s = socket.socket(family, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((bind_addr, 53))
s.listen(64)
log.info("TCP listening on %s:53", bind_addr)
while True:
conn, _ = s.accept()
POOL.submit(_tcp_client, conn)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
for bind in ("127.0.0.1", "::1"):
for fn in (_udp_server, _tcp_server):
threading.Thread(target=fn, args=(bind,), daemon=True).start()
log.info("dns-prefer-ipv6 running – AAAA-wins filter active")
signal.pause()
[Unit]
Description=DNS proxy – suppress A records when AAAA exists
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/bin/dns-prefer-ipv6.py
Restart=always
RestartSec=3
# CAP_NET_BIND_SERVICE lets it bind port 53 without running as root
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
DynamicUser=yes
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment