Split DNS with kresd on Turris: Override A Records Without Losing AAAA or MX

The problem: one domain, two networks

If you run a Turris or any OpenWrt-based router with kresd (Knot Resolver) and you have a domain like doma.example.com with some hosts sitting behind a public reverse proxy, you’ve probably hit this: from inside the house you want those names to resolve to real LAN IPs, not the proxy’s public address. That’s split DNS.

The tricky part isn’t the concept. It’s doing it without breaking AAAA or MX records in the process.

Why hints.config() doesn’t cut it

The first thing most people reach for is the hints module. It ships with kresd and you can aim it at a hosts file:

hints.config('/etc/hosts')

The problem is a default behavior called NODATA synthesis. When hints has an A record for a hostname, it returns NODATA for any other record type — AAAA, MX, whatever — rather than letting the query fall through to the upstream zone. The flag controlling this is hints.use_nodata(true) and it is on by default.

You can disable it with hints.use_nodata(false), but that causes a different mess: kresd may resolve from the upstream zone anyway, returning conflicting data, or the hint gets bypassed entirely. Not clean.

policy.ANSWER: inject exactly what you want

The right tool is policy.ANSWER. It only intercepts the record type you specify. An AAAA or MX query for the same host passes straight through to the real upstream zone and returns whatever is actually published there. Nothing else gets touched.

The pattern uses a Lua table mapping hostnames to private IPs, with a loop that registers each rule:

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",
}

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

Two details matter. First, the trailing dot on every hostname is not optional — kresd expects fully-qualified domain names in the table. Drop it and the rule silently does nothing. Second, todname(host) converts the Lua string into kresd’s internal wire-format name used for matching; it is not just cosmetic.

The TTL of 300 seconds controls how long clients cache the injected A record. AAAA and MX records keep their own TTL from the real zone, unchanged.

Wiring it into Turris OS

On Turris OS, kresd config goes through UCI. Open /etc/config/resolver and inside the config resolver 'kresd' block, add:

option include_config '/etc/kresd/custom.conf'

Write the Lua snippet into /etc/kresd/custom.conf. That file is loaded as plain Lua at kresd startup. A full restart picks up the new include_config path the first time; after that, changes to custom.conf take effect with a reload.

Scoping to LAN with the view module

policy.ANSWER fires for every client that hits kresd — there is no built-in LAN filter. On a typical home Turris setup the resolver is not reachable from WAN anyway, but if you want to be explicit about it, the view module lets you scope rules by client subnet:

modules.load('view')

for host, ip in pairs(local_ipv4) do
    view:addr('192.168.1.0/24',
        policy.domains(
            policy.ANSWER(
                { [kres.type.A] = { rdata=kres.str2ip(ip), ttl=300 } }
            ), { todname(host) }
        )
    )
end

Clients in 192.168.1.0/24 get the private IP. Everyone else resolves normally from the public zone. The kresd documentation is upfront that true split-horizon DNS with isolated per-client caches is not supported — the shared cache can surface edge cases — but subnet scoping is more than adequate for a home network.

When the list grows: delegate to a local authoritative server

The Lua table works well for a handful of hosts. Past ten or fifteen entries, maintaining it becomes tedious. A cleaner path is to run a lightweight authoritative DNS server alongside kresd — Knot DNS and nsd are both available in Turris package feeds — serving just the internal subdomain, then point kresd at it with policy.STUB:

policy.add(
    policy.suffix(
        policy.STUB('127.0.0.1@5300'),
        policy.todnames({'doma.example.com.'})
    )
)

You edit a standard zone file instead of Lua. AAAA and MX records live in the local zone. There is no fallthrough to the public zone at all — which is actually what you want if the internal and external contents of the subdomain have diverged enough that you are managing them independently anyway.

Knot DNS and kresd coexist on the same Turris without trouble as long as they listen on different ports. Bind the authoritative server to 127.0.0.1:5300 and point the STUB rule there.

kresd version notes

Everything above targets kresd 5.x, which is what current Turris OS ships. Knot Resolver 6.x introduced a new declarative YAML configuration format that replaced large parts of the Lua API. Run kresd --version to check. If you’re on 6.x the syntax above won’t match; the official Knot Resolver documentation at knot-resolver.cz covers the version-specific config format in detail.

Sources


Related Articles

Similar Posts