Split DNS on Turris with kresd: Override A Records Without Breaking AAAA or MX
The problem: your router sees the public zone, not your LAN
When a public hostname points to a reverse proxy, every client inside your home network hits that proxy — even when a direct LAN address would be faster and skip an unnecessary hop. Split DNS solves this by returning a private IP to local clients while the rest of the world still gets the public one. On Turris routers, which use kresd (Knot Resolver) as the default recursive resolver, the policy module makes this straightforward once you know which knob to reach for.
Why hints.config won’t do it
The first instinct is hints.config('/etc/hosts'). It looks like exactly the right tool: static overrides, readable syntax, built into kresd. The problem shows up the moment a client asks for something other than an A record.
When kresd finds a hostname in the hints file it owns that name entirely — all record types resolve from the local copy. Ask for AAAA? NXDOMAIN. Ask for MX? NXDOMAIN. Your mail client quietly breaks and your IPv6 stack goes dark for those hosts. That is not a split DNS setup; it’s a DNS blackhole for anything that isn’t an A record.
hints.add() is a marginal improvement: it injects a single A or AAAA record rather than shadowing the whole name from a file. But behaviour for non-hinted record types on the same hostname is inconsistent across kresd versions, and in practice it is not reliable enough if you depend on AAAA or MX records from the public zone for the same hostnames.
The right tool: policy.ANSWER
kresd’s policy module runs before resolution begins. You can intercept a query, return a synthetic answer for one specific record type, and let every other record type fall through to upstream DNS completely untouched. That is exactly the behaviour split DNS requires.
policy.ANSWER takes a table keyed by kresd record-type constants. Anything not in that table is ignored by the rule — the query continues on to the normal resolution path. Declare only kres.type.A and AAAA, MX, TXT, SOA, and everything else will resolve against your upstream as usual.
Full configuration walkthrough
Step 1: hook in a custom Lua file
Turris OS manages kresd through UCI, so you do not edit kresd’s generated config directly — it gets regenerated on restart. Instead, declare a custom include file in /etc/config/resolver:
config resolver 'kresd'
option include_config '/etc/kresd/custom.conf'
That path is arbitrary; /etc/kresd/custom.conf is conventional on Turris. The file survives reboots and TurrisOS updates because it lives outside the generated config tree.
Step 2: write the Lua policy
Put this in /etc/kresd/custom.conf:
local local_ipv4 = {
["arch.doma.example.com."] = "192.168.1.6",
["ha.doma.example.com."] = "192.168.1.10",
["node1.doma.example.com."] = "192.168.1.17",
["node2.doma.example.com."] = "192.168.1.18",
["node3.doma.example.com."] = "192.168.1.19",
}
for host, ip in pairs(local_ipv4) do
policy.add(
policy.domains(
policy.ANSWER(
{ [kres.type.A] = { rdata=kres.str2ip(ip), ttl=300 } }
), { todname(host) }
)
)
end
For each hostname, this registers a policy rule that fires when kresd receives an A query for that name. The answer comes back immediately with the private IP and a 300-second TTL. Any other record type for the same hostname bypasses the rule and resolves normally.
Step 3: restart the resolver
/etc/init.d/resolver restart
Changes in /etc/config/resolver and the included Lua file take effect after restart. No reboot needed.
Details that trip people up
- Trailing dot on every hostname. kresd works with fully-qualified names internally.
arch.doma.example.com.matches;arch.doma.example.comwithout the dot does not. The rule silently never fires if you omit it. - kres.str2ip() is mandatory.
rdataexpects binary wire format, not a dotted-decimal string. Pass the raw string and the rule will appear to load but return garbage or nothing. - TTL affects client-side caching. 300 seconds means a changed private IP takes up to five minutes to propagate to cached clients. Set it lower during testing — 10 or 30 seconds is fine — and raise it once things are stable.
- Policy rules are ordered.
policy.add()appends rules; the first matching rule wins. If you have overlapping rules elsewhere in your config, order matters.
Adding IPv6 overrides
The same pattern works for AAAA records. Add a second table for any hosts where you want to return a local IPv6 address:
local local_ipv6 = {
["arch.doma.example.com."] = "fd00::6",
}
for host, ip in pairs(local_ipv6) do
policy.add(
policy.domains(
policy.ANSWER(
{ [kres.type.AAAA] = { rdata=kres.str2ip(ip), ttl=300 } }
), { todname(host) }
)
)
end
You can also combine both types in a single policy.ANSWER call by putting kres.type.A and kres.type.AAAA in the same table. Do that only if you want both record types overridden for the same host; for a host where only the A record should be private and the AAAA should come from public DNS, keep them in separate rules.
Verifying the setup
From any client on your LAN, query the resolver directly:
dig @<turris-ip> arch.doma.example.com A
dig @<turris-ip> arch.doma.example.com AAAA
dig @<turris-ip> arch.doma.example.com MX
The A query should come back with the private IP and a 300s TTL in the ANSWER section. AAAA and MX should return whatever the public authoritative server has — or NXDOMAIN if those records genuinely do not exist, which is the correct behaviour. If the A record still shows the public IP, double-check the trailing dot and restart the resolver again.
Sources
- knot-resolver.readthedocs.io
- knot-resolver.cz
- wiki.turris.cz
- ctrl.blog
- blog.frehi.be
- dennogumi.org
- bbs.archlinux.org
