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

The Problem: Serving Private IPs Internally Without Breaking Public Records

When services live behind a reverse proxy, the public DNS has to point those hostnames at your WAN IP. But querying that WAN IP from inside your own network means traffic hairpins through your router or simply doesn’t reach the right backend. The fix is split-horizon DNS — answering with the private IP for clients on your LAN while the rest of the world still gets the public address.

On a Turris router running kresd (Knot Resolver), doing this cleanly — without stomping on AAAA or MX records — takes a specific approach.

Why hints.config Falls Short

The first thing most people try is the hints module. You can load an /etc/hosts-style file or add entries inline:

hints['arch.doma.example.com'] = '192.168.1.6'

The problem is that the hints module only handles A, AAAA, and PTR records. Once you add a hint for a hostname, queries for any other record type on that name — MX, TXT, CAA, SRV — return NODATA or NXDOMAIN depending on the kresd version and the hints.use_nodata setting. If you rely on MX records for mail routing, or on TXT records for DKIM or domain verification, hints will silently break them. That’s not a bug; it’s a design limit. The hints module is built for host-to-address mappings, not for hosting full DNS zone slices.

policy.ANSWER: Surgical Record Rewriting

The right tool is policy.ANSWER from kresd’s policy module. It intercepts queries for specific names and specific record types, injects the answer you provide, and passes all other queries through to the upstream resolver untouched. AAAA, MX, TXT — anything you don’t explicitly override resolves normally from the public DNS.

The core pattern for a single host:

policy.add(
    policy.domains(
        policy.ANSWER(
            { [kres.type.A] = { rdata=kres.str2ip('192.168.1.6'), ttl=300 } }
        ), { todname('arch.doma.example.com.') }
    )
)

A few things about the syntax. Domain names must be fully qualified — the trailing dot is required. kres.str2ip() converts a dotted-quad string to the binary wire format kresd expects for rdata; you can’t pass a plain string. todname() converts the FQDN to the internal wire-format name the policy engine uses. And policy.domains matches exact names only — it won’t match subdomains. Use policy.suffix instead if you want a rule to cover an entire subtree, but be aware that would override the A record for every name under that suffix.

Driving Multiple Hosts from a Lua Table

Repeating policy.add for every host gets tedious. Since kresd’s configuration is Lua, you can loop over a table:

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

Adding a host is one line in the table. The loop registers a separate policy rule per entry. Because the rules cover non-overlapping exact names, the order within the loop doesn’t matter.

The nodata Parameter — Easy to Get Wrong

By default, policy.ANSWER only rewrites the record types you list. An AAAA query for a name in your table just passes through to the upstream resolver. That is exactly the behavior you want when using this for split DNS.

The behavior changes if you pass nodata=true as the second argument to policy.ANSWER:

policy.ANSWER(
    { [kres.type.A] = { rdata=kres.str2ip('192.168.1.6'), ttl=300 } },
    true   -- nodata=true
)

With nodata=true, any record type not listed in the table receives a synthetic NODATA response instead of being forwarded upstream. AAAA queries return empty. MX queries return empty. This is easy to pick up from a copy-pasted snippet that was written for a different use case — say, blocking a domain entirely. If you’re doing split DNS, leave nodata at its default of false.

Setting It Up on Turris

Turris regenerates the main kresd configuration from its reForis interface on changes, so edits to /etc/kresd/kresd.conf won’t survive. The supported approach is a separate include file.

Open /etc/config/resolver and add this line inside the config resolver 'kresd' section:

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

Then put your Lua policy rules in /etc/kresd/custom.conf. After any change to that file, restart the resolver:

/etc/init.d/resolver restart

The custom file is not managed by reForis and won’t be overwritten by Turris OS upgrades.

TTL and Roaming Clients

At 300 seconds, a device that leaves your home network may keep resolving those hostnames to private IPs for up to five minutes before the cache expires. For most setups this is fine. If you have laptops or phones that frequently move between home and mobile networks, drop the TTL to 60 seconds. The cost is a few extra resolver queries per hour; the benefit is much cleaner transitions.

DNSSEC Conflict

Split-horizon DNS and DNSSEC have a structural conflict. If your public zone is DNSSEC-signed, kresd may reject the synthetic A record because there’s no matching RRSIG from your authoritative nameserver. One escape hatch is trust_anchors.set_insecure for the affected names or zone, though that disables validation for those records. A cleaner solution — if DNSSEC matters — is to run a local authoritative nameserver for the entire zone and forward queries to it from kresd, so the zone is served consistently from one place.

Alternative: Full Local Zone

For more than a handful of overrides, or if you need to serve record types that policy.ANSWER can’t easily synthesize (SRV, NAPTR), running a local authoritative zone is worth considering. Set up NSD or a lightweight BIND instance to serve your internal version of the zone, then forward queries for that zone from kresd using policy.stub:

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

You get a real zone file with full control over all record types. The tradeoff is a second daemon to run and a zone file to keep manually in sync with the public zone whenever you add or change records externally.

For five to ten hosts with only A record overrides, the Lua table approach is simpler and easier to maintain. The full local zone pays off once your internal namespace grows or you need types that go beyond address records.

Sources


Related Articles

Similar Posts