111 lines
5.3 KiB
Markdown
111 lines
5.3 KiB
Markdown
# CoreDNS resolver
|
|
|
|
## Goal
|
|
|
|
Replace the RouterOS built-in DNS forwarder with a CoreDNS container for
|
|
configurability, and suppress IPv6 (AAAA) resolution by default to keep traffic
|
|
on IPv4.
|
|
|
|
## Background
|
|
|
|
The ISP provides no native IPv6 — only a Hurricane Electric (HE) tunnel
|
|
(`2001:470:61a3::/48`). HE addresses fall in ranges some sites flag as
|
|
datacenter/bot traffic, producing endless CAPTCHAs. The goal is to prefer IPv4
|
|
egress while keeping IPv6 available for our own services and any domain
|
|
explicitly trusted over IPv6.
|
|
|
|
## What this is NOT (and why)
|
|
|
|
An earlier iteration used **DNS64 + NAT64 (Tayga)** to force traffic through
|
|
IPv4. It was removed:
|
|
|
|
- **Performance**: Tayga is a userspace translator with no hardware offload.
|
|
Every translated packet crossed RouterOS twice (v6 in, v4 out) plus a
|
|
userspace hop, capping throughput at ~250 Mbps against a 1 Gbps line.
|
|
- **SPOF**: two containers (CoreDNS + Tayga) in the datapath of nearly all
|
|
traffic on a router whose native forwarder had been rock-solid.
|
|
- **Architectural inversion**: NAT64 exists to let IPv6-only clients reach IPv4.
|
|
We don't want IPv6 egress at all — using NAT64 to avoid IPv6 was solving the
|
|
problem backwards.
|
|
|
|
Plain AAAA suppression in CoreDNS achieves the same IPv4-preferred outcome with
|
|
zero datapath overhead — DNS is the only thing touched, packet forwarding stays
|
|
on the RouterOS fastpath at line rate.
|
|
|
|
The full account of the NAT64/IPv6-mostly attempt and why it was abandoned is in
|
|
[nat64-dns64-postmortem.md](./nat64-dns64-postmortem.md).
|
|
|
|
## How it works
|
|
|
|
CoreDNS runs as a single container (`172.20.0.3`), reachable from RouterOS DNS
|
|
which forwards client queries to it. The [Corefile](../mikrotik/coredns/Corefile)
|
|
has three server blocks:
|
|
|
|
1. **`lumpiasty.xyz`** — our own zone. Forwards normally, keeps AAAA, so internal
|
|
services reachable over the HE prefix resolve to their real IPv6 addresses.
|
|
2. **`.` (default)** — forwards everything else, but a `template IN AAAA` block
|
|
returns empty NOERROR for all AAAA queries, so clients fall back to IPv4 and
|
|
avoid the HE tunnel's flagged egress. A records and all other types pass
|
|
through untouched.
|
|
|
|
The whitelist is implemented as a reusable `(aaaa_allowed)` snippet imported by
|
|
zones that should keep AAAA. To trust another domain over IPv6, add a server
|
|
block for it that imports `aaaa_allowed`.
|
|
|
|
### Why suppression, not NXDOMAIN
|
|
|
|
The AAAA template returns NOERROR with an empty answer (NODATA), not NXDOMAIN.
|
|
This is correct: the name exists, it just has no (advertised) AAAA. Clients
|
|
treat it as "no IPv6 address" and use the A record. Returning NXDOMAIN would
|
|
wrongly imply the name doesn't exist and break the A lookup.
|
|
|
|
## Future improvement
|
|
|
|
The current global-suppress-plus-whitelist is coarse: a domain that is genuinely
|
|
IPv6-only (no A record) and not whitelisted becomes unreachable. The intended
|
|
end state is a plugin that suppresses AAAA only when the domain also has an A
|
|
record, so IPv6-only destinations keep working without manual whitelisting. No
|
|
in-tree CoreDNS plugin does this today.
|
|
|
|
## Custom image
|
|
|
|
Built from source with a minimal plugin set (`errors`, `log`, `health`,
|
|
`template`, `cache`, `forward`, `reload`) instead of the default ~40, producing
|
|
a ~6-8 MB image. The `dns64` plugin is no longer compiled in.
|
|
|
|
Source: [`mikrotik/coredns/`](../mikrotik/coredns/). Built by Woodpecker
|
|
([`.woodpecker/coredns-build.yaml`](../.woodpecker/coredns-build.yaml)) on pushes
|
|
touching `mikrotik/coredns/**`, pushed to `gitea.lumpiasty.xyz/lumpiasty/coredns-mikrotik`.
|
|
|
|
## RouterOS integration
|
|
|
|
- `/ip/dns servers=172.20.0.3` — RouterOS forwards client queries to CoreDNS
|
|
- RDNSS in RA (`/ipv6/nd dns=...` on vlan2/vlan5) advertises an IPv6 resolver
|
|
(the router's per-VLAN address) to dual-stack clients; RouterOS DNS relays to
|
|
CoreDNS
|
|
- No DHCP option 108, no PREF64 — those belonged to the removed IPv6-mostly setup
|
|
|
|
## Pitfalls learned (kept for reference)
|
|
|
|
These were hit during the NAT64 era and the migration; some still apply:
|
|
|
|
1. **RouterOS static FWD entries corrupt NXDOMAIN.** A `type=FWD match-subdomain=yes`
|
|
entry returns NOERROR/empty instead of relaying NXDOMAIN. Combined with
|
|
`ndots:5` and kubernetes pod search domains, `getaddrinfo` stops at the first
|
|
search-suffixed NODATA candidate and never tries the absolute name — apps fail
|
|
with `ENOTFOUND` for valid hostnames while `nslookup` (absolute query) works.
|
|
Our own zone is therefore handled in the Corefile, not via a RouterOS FWD
|
|
entry. RouterOS DNS does plain forwarding only (plus the Tailscale `ts.net`
|
|
FWD, which is acceptable as its subdomains genuinely don't exist publicly).
|
|
2. **`advertise-dns=no` on new ND entries.** RouterOS creates per-interface
|
|
`ipv6 nd` entries with `advertise-dns=no`, suppressing the RDNSS option even
|
|
when a static `dns=` list is set. Must be enabled explicitly.
|
|
3. **Per-interface ND entries must be created, not modified.** Only the
|
|
`interface=all` default ships out of the box; `api_find_and_modify` matching a
|
|
specific interface silently matches nothing. Use `api_modify`.
|
|
|
|
Verification: `rdisc6` (NixOS package `ndisc6`) dumps RA contents. The CoreDNS
|
|
`log` plugin output is visible via `/log print` on the router (container
|
|
`logging=yes`) and shows the rcode CoreDNS returned — comparing it to what the
|
|
client received isolates which hop corrupts a response.
|