Migrating from BIND9 to Knot DNS + Knot Resolver

Introduction

This guide covers migrating a complex DNS infrastructure from BIND9 to Knot DNS (authoritative) and Knot Resolver (recursive). The setup includes split-horizon DNS, DNSSEC signing, dynamic updates from DHCP and ACME clients, and privacy-focused upstream forwarding.

Why Migrate?

Limitations of BIND9

  • Monolithic design: BIND9 combines authoritative and recursive functions in a single process, which can be a security concern
  • Complex configuration: Views and zones mixed together make configuration harder to reason about
  • Modern features: At the time, BIND9 did not support DNS-over-TLS and required an external resolver
  • Performance: Because BIND9 didn't support DNS-over-TLS at the time, using stubby caused a significant performance problem

Benefits of Knot

  • Separation of concerns: Knot DNS (authoritative) and Knot Resolver (recursive) are separate daemons
  • Better DNSSEC tooling: Automatic key management and signing
  • Performance: Knot can spawn several instances from systemd and share the load of a single listening socket
  • Modern configuration: YAML for Knot DNS, Lua for Knot Resolver means editor support

Architecture Comparison

BIND9 Architecture

bind9 cluster_0 BIND9 sandview Sandbox View - Recursive only - Filtering internet Internet sandview->internet intview Internal View - Recursive + Forward external External View - Authoritative only intview->external internal Internal IPs intview->internal intview->internet internet->external sandboxed Sandboxed IPs sandboxed->sandview
  • Single process handles all DNS functions
  • Views separate traffic based on source IP
  • Internal view forwards to local resolver for recursion
  • External view serves authoritative data only

Knot Architecture

Kea cluster_0 Knot Resolver (kresd) cluster_1 Knot DNS (knotd) kresd Recursive Resolver - DNS-over-TLS -Policy Routing -Stub zones knotd Authoritative Server - DNSSEC auto - Dynamic zones kresd->knotd internal Internal IPs kresd->internal internet Internet kresd->internet knotd->internet
  • Separate processes for authoritative and recursive
  • Knot Resolver listens on internal IPs for recursive queries
  • Knot DNS listens on both internal and external IPs for authoritative data
  • Stub zones in Knot Resolver forward local domain queries to Knot DNS

Migration Steps

1. Install Knot Packages

# Install both Knot DNS and Knot Resolver
dnf install knot knot-resolver

2. Convert TSIG Keys

Both systems use the same TSIG key format. Copy key definitions from BIND to Knot:

BIND format:

key "dhcp-updater" {
algorithm hmac-sha256;
secret "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
};

Knot format:

key:
- id: dhcp-updater
algorithm: hmac-sha256
secret: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

3. Configure Knot DNS (Authoritative)

Create /etc/knot/knot.conf:

server:
rundir: "/run/knot"
user: knot:knot
automatic-acl: on
# This hides the server version
identity: ""
version: ""
# Loopback
listen: 127.0.0.1@53
listen: ::1@53
# Internal interface for stub queries from resolver
listen: 10.250.0.2@53
# External interfaces for public queries
listen: 203.0.113.1@53
listen: 2001:db8::1@53
database:
storage: "/var/lib/knot"
# Define TSIG keys to be used in ACLs
key:
- id: dhcp-updater
algorithm: hmac-sha256
secret: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
- id: certbot
algorithm: hmac-sha256
secret: BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
# Define ACLs for use in templates and zones
acl:
- id: allow_dhcp_update
key: dhcp-updater
action: update
acl:
- id: acme_txt_update
key: certbot
action: update
update-type: [TXT]
# Define templates for common configurations
template:
- id: default
storage: "/var/lib/knot/global"
file: "%s.zone"
semantic-checks: on
dnssec-signing: on
acl: allow_dhcp_update
- id: internal
storage: "/var/lib/knot/internal"
file: "%s.zone"
semantic-checks: on
acl: allow_dhcp_update
# Define zones
zone:
- domain: internal.example.com
acl: acme_txt_update
acl: allow_dhcp_update
- domain: dmz.example.com
acl: acme_txt_update
- domain: guest.example.com
- domain: internal
template: internal
- domain: 10.100.10.in-addr.arpa
template: internal

4. Configure Knot Resolver

Create /etc/knot-resolver/kresd.conf:

-- Network interface configuration
net.listen('10.250.0.1', 53, { kind = 'dns' })
net.listen('10.250.0.1', 853, { kind = 'tls' })
net.listen('fd00:1234:5678::1', 53, { kind = 'dns' })
-- TLS certificate for DNS-over-TLS
net.tls(
'/etc/letsencrypt/live/resolver.example.com/fullchain.pem',
'/etc/letsencrypt/live/resolver.example.com/privkey.pem'
)
-- Load modules
modules = {
'hints > iterate',
'stats',
'predict',
}
-- Cache configuration
cache.open(100 * MB, 'lmdb:///var/cache/knot-resolver')
-- Local domains - forward to Knot DNS
localDomains = policy.todnames({
'internal.example.com.',
'dmz.example.com.',
'guest.example.com.',
'10.in-addr.arpa.',
})
policy.add(policy.suffix(policy.FLAGS({'NO_EDNS', 'NO_CACHE'}), localDomains))
policy.add(policy.suffix(policy.STUB({'10.250.0.2'}), localDomains))
-- Upstream: forward to DNS provider via DNS-over-TLS
policy.add(policy.all(policy.TLS_FORWARD({
{'2001:db8::53', hostname='dns.example.net'},
{'203.0.113.53', hostname='dns.example.net'}
})))

5. Migrate Zone Files

Zone files are compatible between BIND and Knot. Copy them to the Knot storage directory. My old BIND zones were in /var/named/dynamic and each subdirectory was named after the zone it is reference.

# Copy zone files
cp /var/named/dynamic/example.com/zone.db /var/lib/knot/global/example.com.zone
# Fix ownership
chown -R knot:knot /var/lib/knot

6. Migrate DNSSEC Keys

For BIND zones with inline-signing, export and import DNSSEC keys:

# BIND stores keys as K<zone>+<alg>+<keyid>.key/private files
# Knot can import these or generate new keys automatically
# Option 1: Let Knot generate new keys (easier)
knotc zone-key-rollover example.com ksk
knotc zone-key-rollover example.com zsk
# Option 2: Import existing BIND keys
keymgr example.com import-bind /var/named/dynamic/example.com

Advanced Configuration & Edge Cases

1. Split-Horizon DNS: BIND Views vs. Knot Services

The Challenge:

BIND9 uses views to implement split-horizon DNS - serving different answers based on the client's source IP. Knot DNS doesn't have views, requiring a different approach.

BIND9 Approach:

view "internal" {
match-clients { 10.0.0.0/8; 192.168.0.0/16; };
recursion yes;
zone "example.com" {
type master;
file "internal/example.com.zone";
};
};
view "external" {
match-clients { any; };
recursion no;
zone "example.com" {
type master;
file "external/example.com.zone";
};
};

Single BIND process listens on all IPs and routes queries to views.

Knot Approach:

Different architecture entirely:

  1. Knot DNS (authoritative) listens on:

    • Internal IP (10.250.0.2) - for stub queries from Knot Resolver
    • External IP (203.0.113.1) - for public authoritative queries
  2. Knot Resolver (recursive) listens on:

    • Internal IP (10.250.0.1) - for internal clients
  3. Query flow:

    • Internal clients → Knot Resolver (10.250.0.1) → Stub to Knot DNS (10.250.0.2) for local zones
    • External clients → Knot DNS (203.0.113.1) directly

Key Differences:

AspectBIND9Knot
Process modelSingle processTwo separate processes
Traffic separationViews (source IP matching)Network interface binding
RecursionPer-view settingSeparate daemon (Knot Resolver)
ConfigurationMatch-clients in viewsListen directives on different IPs

Migration Gotcha:

BIND's listen-on with negation doesn't work the same way:

# BIND: Listen on all interfaces EXCEPT 10.250.0.1
listen-on { !10.250.0.1; any; };

With Knot, you must explicitly list all interfaces:

# Knot: Explicitly list each interface
server:
listen: 10.250.0.2@53
listen: 203.0.113.1@53
listen: 2001:db8::1@53

2. DNS-over-TLS to Upstream Resolvers

The Challenge:

ISPs can snoop on DNS queries. Use DNS-over-TLS (DoT) to encrypt queries to upstream resolvers.

BIND9 Approach:

BIND9 does support DNS-over-TLS in version 9.18+, but configuration is complex:

options {
forwarders port 853 tls ephemeral {
2001:db8::53;
203.0.113.53;
};
};

Knot Resolver Approach:

Much cleaner with policy.TLS_FORWARD:

-- Forward all queries via DNS-over-TLS
policy.add(policy.all(policy.TLS_FORWARD({
{'2001:db8::53', hostname='dns.provider.com'},
{'203.0.113.53', hostname='dns.provider.com'}
})))

The hostname parameter is critical - it's used for TLS certificate verification.

Real-World Example: NextDNS

NextDNS provides DNS filtering with unique profile IDs:

-- NextDNS with profile ID abc123
policy.add(policy.all(policy.TLS_FORWARD({
{'2a07:a8c0::', hostname='abc123.dns.nextdns.io'},
{'2a07:a8c1::', hostname='abc123.dns.nextdns.io'},
{'45.90.28.0', hostname='abc123.dns.nextdns.io'},
{'45.90.30.0', hostname='abc123.dns.nextdns.io'}
})))

Local Zones Exception:

Local zones should NOT go over TLS to upstream - they should stub to your authoritative server:

-- First: Define local domains
localDomains = policy.todnames({
'internal.example.com.',
})
-- Second: Disable EDNS and caching for local domains
policy.add(policy.suffix(policy.FLAGS({'NO_EDNS', 'NO_CACHE'}), localDomains))
-- Third: Stub local domains to Knot DNS
policy.add(policy.suffix(policy.STUB({'10.250.0.2'}), localDomains))
-- Fourth: Everything else goes to upstream via TLS
policy.add(policy.all(policy.TLS_FORWARD({...})))

Order matters! Policies are evaluated in order, so local domain policies must come before the catch-all TLS forward.

3. Selective Routing with EDNS Client Subnet (ECS)

The Challenge:

Some services like YouTube, Netflix, and CDNs benefit from knowing your approximate location to route you to the nearest edge server. EDNS Client Subnet (ECS) sends part of your IP address to authoritative nameservers for better locality. However, this reduces privacy.

You might want:

  • Default: ECS disabled for privacy
  • Selective: ECS enabled only for specific domains that benefit from it (video streaming, CDNs)

Privacy vs Performance Trade-off:

ApproachPrivacyPerformanceUse Case
No ECSHigh - Your IP subnet isn't sharedLower - May get farther CDN nodesPrivacy-conscious users
ECS everywhereLow - Your subnet shared with all domainsHigher - Optimal CDN routingPerformance-first users
Selective ECSBalanced - Only specific domains get subnetBalanced - Fast for streaming, private otherwiseBest of both worlds

BIND9 Approach:

BIND9 doesn't have built-in selective ECS routing. You would need to:

  1. Run two BIND instances with different forwarding configs
  2. Use views to route different clients to different instances

This is complex and not practical.

Knot Resolver Approach:

Knot Resolver supports per-policy forwarding, making selective ECS routing straightforward.

Example: Route video streaming domains to ECS-enabled resolver

-- Define domains that benefit from ECS (CDNs, video streaming)
ecsDomains = policy.todnames({
'youtube.com.',
'googlevideo.com.',
'ytimg.com.',
'netflix.com.',
'nflxvideo.net.',
'twitch.tv.',
'cloudflare.com.',
'cloudfront.net.',
})
-- Forward ECS domains to Quad9 with ECS enabled
policy.add(policy.suffix(policy.TLS_FORWARD({
{'2620:fe::11', hostname='dns.quad9.net'}, -- IPv6 ECS
{'2620:fe::fe:11', hostname='dns.quad9.net'}, -- IPv6 ECS
{'9.9.9.11', hostname='dns.quad9.net'}, -- IPv4 ECS
{'149.112.112.11', hostname='dns.quad9.net'} -- IPv4 ECS
}), ecsDomains))
-- Everything else goes to privacy-focused resolver (no ECS)
policy.add(policy.all(policy.TLS_FORWARD({
{'2620:fe::fe', hostname='dns.quad9.net'}, -- IPv6 no ECS
{'2620:fe::9', hostname='dns.quad9.net'}, -- IPv6 no ECS
{'9.9.9.9', hostname='dns.quad9.net'}, -- IPv4 no ECS
{'149.112.112.112', hostname='dns.quad9.net'} -- IPv4 no ECS
})))

How it works:

  1. Client queries video.youtube.com

  2. Knot Resolver matches youtube.com. in ecsDomains

  3. Query forwarded to Quad9 ECS endpoint (9.9.9.11)

  4. Quad9 sends ECS with client's subnet to YouTube's DNS

  5. YouTube returns IP of geographically closest edge server

  6. Client queries example.com

  7. No match in ecsDomains

  8. Query forwarded to Quad9 privacy endpoint (9.9.9.9)

  9. Quad9 does NOT send ECS

  10. Privacy preserved for non-CDN domains

Alternative: Per-Client ECS Routing

You can also route based on source IP - give some clients ECS, others not:

-- Define networks that should use ECS
ecsClients = kres.view.addr('10.100.10.0/24', '10.100.20.0/24')
-- ECS-enabled forwarding for specific clients
policy.add(policy.suffix(policy.TLS_FORWARD({
{'9.9.9.11', hostname='dns.quad9.net'}
}), ecsClients))
-- Privacy forwarding for everyone else
policy.add(policy.all(policy.TLS_FORWARD({
{'9.9.9.9', hostname='dns.quad9.net'}
})))

DNS-over-QUIC for Lower Latency:

Some providers support DNS-over-QUIC (DoQ) which is faster than DNS-over-TLS. Currently Knot DNS supports DNS-over-QUIC, but Knot Resolver does not support it yet. See Support for DoQ on cz.nic GitLab for status.

As of now, as far as providers, NextDNS supports DNS-over-QUIC and no others do. This is slightly ironic since Google invented QUIC.

Provider Comparison for ECS:

ProviderECS EndpointNo-ECS EndpointNotes
Quad99.9.9.11, 149.112.112.119.9.9.9, 149.112.112.112Malware filtering on both
Cloudflare1.1.1.11.1.1.1ECS disabled by default, can't enable (exception is whoami.ds.akahelp.net for debugging)
Google8.8.8.88.8.8.8ECS enabled by default, can't disable
NextDNSCustom profileCustom profile"Anonymized" ECS configurable per profile

NextDNS with Selective ECS:

NextDNS allows ECS configuration per profile. Create two profiles on your NextDNS dashboard under Settings. It's called "Anonymized EDNS Client Subnet" and when it's off, it won't send ECS. When it's on, it will send ECS with some sort of anonymization. From the "Setup" page, note the ID of each profile.

We'll assume ECS profile is ecs and non-ECS is nonecs

-- Profile with ECS enabled for CDN domains
ecsDomains = policy.todnames({
'youtube.com.',
'netflix.com.',
})
policy.add(policy.suffix(policy.TLS_FORWARD({
{'45.90.28.0', hostname='ecs.dns.nextdns.io'} -- ECS profile
}), ecsDomains))
-- Default profile without ECS
policy.add(policy.all(policy.TLS_FORWARD({
{'45.90.28.0', hostname='nonecs.dns.nextdns.io'} -- Privacy profile
})))

Testing ECS:

Verify ECS is being sent by comparing against other providers:

# Query with dig with ECS
dig @9.9.9.11 youtube.com
# Query with dig without ECS
dig @9.9.9.9 youtube.com
# Check response header for ECS in EDNS
dig @10.250.0.1 youtube.com +subnet=203.0.113.0/24

Recommended Domains for ECS:

ecsDomains = policy.todnames({
-- Google/YouTube
'youtube.com.',
'googlevideo.com.',
'ytimg.com.',
'ggpht.com.',
-- Netflix
'netflix.com.',
'nflxvideo.net.',
'nflximg.net.',
-- Streaming services
'twitch.tv.',
'hulu.com.',
'disneyplus.com.',
-- CDNs
'cloudflare.com.',
'cloudfront.net.',
'akamaihd.net.',
'fastly.net.',
-- Gaming
'steampowered.com.',
'valvesoftware.com.',
'riotgames.com.',
})

Security Considerations:

  1. ECS leaks subnet information - Authoritative servers learn your approximate location
  2. Cache poisoning - ECS responses are client-specific, complicating cache validation
  3. Fingerprinting - Combination of queries + ECS can identify users

Best Practice:

  • Enable ECS only for trusted, performance-critical domains
  • Use privacy-first (no ECS) as default
  • Regularly review ECS domain list
  • Consider geographic privacy requirements (GDPR, etc.)

4. Systemd Multi-Instance for Multiple Resolver Profiles

The Challenge:

You want different resolver instances with different filtering policies (e.g., filtered for kids, unfiltered for admins). Multiple instances can even listen to the same TCP socket and have the same cache to increase performance.

Solution: Systemd Templates

Knot Resolver supports systemd instances via [email protected]:

# Start filtered instances
systemctl start kresd@filtered1
systemctl start kresd@filtered2
# Start bypass instances
systemctl start kresd@bypass1
systemctl start kresd@bypass2
# Start web management instance
systemctl start kresd@webmgmt

Configuration Detection:

The Lua config can detect which instance is running:

local systemd_instance = os.getenv("SYSTEMD_INSTANCE")
local is_bypass = string.match(systemd_instance, '^bypass')
if is_bypass then
-- Bypass instance: listen on different IP
net.listen('10.250.0.10', 53, { kind = 'dns' })
-- Use unfiltered upstream
policy.add(policy.all(policy.TLS_FORWARD({
{'2001:db8::53', hostname='unfiltered.dns.example.net'}
})))
else
-- Filtered instance: normal IP
net.listen('10.250.0.1', 53, { kind = 'dns' })
-- Use filtered upstream
policy.add(policy.all(policy.TLS_FORWARD({
{'2001:db8::53', hostname='filtered.dns.example.net'}
})))
end

Separate Caches:

Each instance should have its own cache:

if is_bypass then
cache.open(100 * MB, 'lmdb:///var/cache/knot-resolver/bypass')
else
cache.open(100 * MB, 'lmdb:///var/cache/knot-resolver/filtered')
end

Web Management Instance:

You can run a dedicated instance just for the web UI:

if string.match(systemd_instance, '^webmgmt') then
modules.load('http')
http.config({
tls = true,
cert = '/etc/letsencrypt/live/resolver.example.com/fullchain.pem',
key = '/etc/letsencrypt/live/resolver.example.com/privkey.pem',
})
net.listen('10.250.0.1', 8888, { kind = 'webmgmt'})
end

Access at: https://10.250.0.1:8888

5. Dynamic DNS Updates for DHCP

The Challenge:

DHCP server (ISC KEA) needs to update DNS records when it assigns leases.

BIND9 Configuration:

key "dhcp-updater" {
algorithm hmac-sha256;
secret "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
};
zone "internal.example.com" {
type master;
file "dynamic/internal.example.com.zone";
update-policy {
grant dhcp-updater zonesub ANY;
};
};
zone "10.100.10.in-addr.arpa" {
type master;
file "dynamic/10.100.10.in-addr.arpa.zone";
update-policy {
grant dhcp-updater zonesub ANY;
};
};

Knot DNS Configuration:

key:
- id: dhcp-updater
algorithm: hmac-sha256
secret: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
acl:
- id: allow_dhcp_update
key: dhcp-updater
action: update
zone:
- domain: internal.example.com
acl: allow_dhcp_update
- domain: 10.100.10.in-addr.arpa
acl: allow_dhcp_update

KEA DHCP Configuration:

No changes needed! KEA uses nsupdate with TSIG, which works identically:

{
"DhcpDdns": {
"enable-updates": true,
"server-ip": "10.250.0.2",
"server-port": 53,
"ncr-protocol": "UDP",
"ncr-format": "JSON",
"tsig-keys": [{
"name": "dhcp-updater",
"algorithm": "HMAC-SHA256",
"secret": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
}]
}
}

Important: Point KEA to Knot DNS's IP (10.250.0.2), not Knot Resolver.

6. ACME DNS-01 Challenge Updates

The Challenge:

Let's Encrypt DNS-01 challenges require updating TXT records. Multiple services need updates, but should only update specific records.

BIND9 Granular Permissions:

zone "internal.example.com" {
update-policy {
grant dhcp-updater zonesub ANY;
grant web-certbot name _acme-challenge.web.internal.example.com. TXT;
grant mail-certbot name _acme-challenge.mail.internal.example.com. TXT;
grant wildcard-certbot name _acme-challenge.internal.example.com. TXT;
};
};

Knot DNS Granular Permissions:

key:
- id: web-certbot
algorithm: hmac-sha512
secret: BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB==
- id: mail-certbot
algorithm: hmac-sha512
secret: CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC==
acl:
- id: web_acme_update
key: web-certbot
action: update
update-type: [TXT]
update-owner: name
update-owner-match: equal
update-owner-name: [_acme-challenge.web]
- id: mail_acme_update
key: mail-certbot
action: update
update-type: [TXT]
update-owner: name
update-owner-match: equal
update-owner-name: [_acme-challenge.mail]
zone:
- domain: internal.example.com
acl: web_acme_update
acl: mail_acme_update
acl: allow_dhcp_update

Certbot Hook Script:

#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/update-dns.sh
SERVER="10.250.0.2"
ZONE="internal.example.com"
KEY_NAME="web-certbot"
KEY_FILE="/etc/letsencrypt/dns-update.key"
nsupdate -k "$KEY_FILE" <<EOF
server $SERVER
zone $ZONE
update delete _acme-challenge.web.$ZONE TXT
update add _acme-challenge.web.$ZONE 60 TXT "$CERTBOT_VALIDATION"
send
EOF

TSIG Key File Format:

key "web-certbot" {
algorithm hmac-sha512;
secret "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB==";
};

7. DNSSEC Automatic Signing

The Challenge:

BIND9 requires manual key management and zone signing configuration. Knot automates this.

BIND9 Configuration:

zone "example.com" {
type master;
file "dynamic/example.com/zone.db";
inline-signing yes;
dnssec-policy default;
key-directory "dynamic/example.com";
};

Requires:

  • Generating keys manually with dnssec-keygen
  • Setting key timings
  • BIND handles inline signing (separates signed/unsigned zones)

Knot DNS Configuration:

template:
- id: default
dnssec-signing: on
dnssec-policy: default
zone:
- domain: example.com

That's it! Knot automatically:

  • Generates keys if they don't exist
  • Signs the zone
  • Rotates keys based on policy
  • Publishes DNSKEY records
  • Manages NSEC/NSEC3 records

Custom DNSSEC Policy:

policy:
- id: custom
algorithm: ecdsap256sha256
ksk-lifetime: 365d
zsk-lifetime: 90d
nsec3: on
nsec3-iterations: 10
nsec3-salt-length: 8
zone:
- domain: example.com
dnssec-policy: custom

Key Management:

# List keys for a zone
keymgr example.com list
# Generate new key
keymgr example.com generate
# Import BIND keys
keymgr example.com import-bind /path/to/bind/keys
# Trigger key rollover
knotc zone-key-rollover example.com ksk

DS Records:

After signing, publish DS records at your registrar:

# Get DS records
knotc zone-read example.com | grep '^example.com.*DS'

8. Catalog Zones for View Synchronization

The Challenge:

In BIND9, you might use catalog zones to automatically provision zones from internal view to external view.

BIND9 Configuration:

# Internal view - master catalog
view "internal" {
zone "catalog.example.org" {
type master;
file "dynamic/catalog.example.org/zone.db";
also-notify { 127.0.0.1 key internal-to-external; };
};
};
# External view - slave catalog
view "external" {
zone "catalog.example.org" {
type slave;
masters { 127.0.0.1 key internal-to-external; };
};
catalog-zones {
zone "catalog.example.org"
default-masters { 127.0.0.1 key internal-to-external; }
zone-directory "catzones";
};
};

Zones added to the catalog in the internal view are automatically provisioned in the external view.

Knot DNS Alternative:

Knot DNS doesn't support catalog zones (as of 3.x). Instead:

  1. Option 1: Single Knot instance

    • Don't separate internal/external zones
    • Use different IPs for internal vs. external access
    • Knot Resolver handles the split-horizon by stubbing local domains
  2. Option 2: Zone synchronization script

    • Use knotc to manage zones programmatically
    • Script to add/remove zones on both instances
  3. Option 3: Configuration management

    • Use Ansible/Puppet/Salt to generate Knot configs
    • Deploy synchronized configs to both instances

Example: Adding zones via script

#!/bin/bash
# add-zone.sh
ZONE="$1"
ZONE_FILE="/var/lib/knot/global/$ZONE.zone"
# Add zone to configuration
knotc conf-begin
knotc conf-set "zone[$ZONE]"
knotc conf-commit
# Create empty zone file if needed
if [ ! -f "$ZONE_FILE" ]; then
cat > "$ZONE_FILE" <<EOF
\$ORIGIN $ZONE.
\$TTL 3600
@ SOA ns1.example.com. admin.example.com. (
$(date +%Y%m%d01) ; serial
3600 ; refresh
1800 ; retry
604800 ; expire
86400 ) ; minimum
NS ns1.example.com.
EOF
chown knot:knot "$ZONE_FILE"
fi
# Load the zone
knotc zone-reload "$ZONE"

9. DNS64 for IPv6-Only Clients

The Challenge:

Kubernetes clusters with IPv6-only pods need to reach IPv4-only services. DNS64 synthesizes AAAA records from A records.

BIND9 Configuration:

view "internal" {
dns64 64:ff9b::/96 {
clients { 10.200.0.0/16; 2001:db8::/64; };
};
};

Knot Resolver Configuration:

-- Enable DNS64 module
modules.load('dns64')
-- Configure DNS64 for specific clients
dns64.config({
prefix = '64:ff9b::/96',
-- Optional: Restrict to specific clients
})
-- Alternative: Use policy for more control
policy.add(policy.suffix(policy.FLAGS('DNS64_DISABLE'), policy.todnames({
-- Disable DNS64 for domains that have native AAAA
'ipv6-capable.example.com.'
})))

How it works:

  1. IPv6-only client queries ipv4-only.example.com for AAAA record
  2. Knot Resolver finds no AAAA record
  3. Knot Resolver looks up A record: 192.0.2.1
  4. Knot Resolver synthesizes AAAA: 64:ff9b::192.0.2.1 (or 64:ff9b::c000:201)
  5. Client connects to NAT64 gateway with that IPv6 address
  6. NAT64 gateway translates to IPv4 192.0.2.1

NAT64 Configuration (Linux):

# Enable IPv6 forwarding
sysctl -w net.ipv6.conf.all.forwarding=1
# Set up Jool NAT64
modprobe jool
jool instance add "default" --netfilter --pool6 64:ff9b::/96
# Add IPv4 pool
jool -i "default" pool4 add 203.0.113.100 --tcp --udp --icmp

10. Forwarding Specific Zones to Kubernetes DNS

The Challenge:

Kubernetes has its own internal DNS (CoreDNS). You want *.kube.example.com to resolve via Kubernetes DNS.

BIND9 Configuration:

zone "kube.example.com" {
type forward;
forward only;
forwarders { 10.43.0.10; };
};

Knot Resolver Configuration:

-- Forward Kubernetes domains to CoreDNS
kubeDomains = policy.todnames({
'kube.example.com.',
})
-- Disable EDNS and caching
policy.add(policy.suffix(policy.FLAGS({'NO_EDNS', 'NO_CACHE'}), kubeDomains))
-- Forward to CoreDNS
policy.add(policy.suffix(policy.STUB({'10.43.0.10'}), kubeDomains))

Why NO_EDNS?

Some Kubernetes DNS implementations don't handle EDNS properly. Disabling it ensures compatibility.

Why NO_CACHE?

Kubernetes services can change IPs rapidly. Disabling cache ensures you always get fresh data.

Multiple Kubernetes Clusters:

-- Production cluster
prodKube = policy.todnames({'prod.kube.example.com.'})
policy.add(policy.suffix(policy.FLAGS({'NO_EDNS', 'NO_CACHE'}), prodKube))
policy.add(policy.suffix(policy.STUB({'10.43.0.10'}), prodKube))
-- Staging cluster
stagingKube = policy.todnames({'staging.kube.example.com.'})
policy.add(policy.suffix(policy.FLAGS({'NO_EDNS', 'NO_CACHE'}), stagingKube))
policy.add(policy.suffix(policy.STUB({'10.44.0.10'}), stagingKube))

11. Hiding Server Version and Identity

The Challenge:

You don't want to leak DNS server information to attackers.

BIND9 Configuration:

options {
version "unknown";
hostname none;
server-id none;
};

Knot DNS Configuration:

server:
identity: ""
version: ""
nsid: ""

Knot Resolver Configuration:

Knot Resolver doesn't expose version information by default. You can set custom NSID if you have a large number of resolvers and you are trying to debug which one is sending the reply back.

-- Set NSID (useful for debugging from client side)
local systemd_instance = os.getenv("SYSTEMD_INSTANCE")
modules.load('nsid')
nsid.name(systemd_instance)

Test:

# Query for version (should return nothing or custom value)
dig @10.250.0.2 version.bind chaos txt
dig @10.250.0.2 id.server chaos txt

12. Statistics and Monitoring

BIND9 Statistics:

options {
statistics-file "data/named_stats.txt";
};
# Generate statistics
rndc stats
# View statistics
cat /var/named/data/named_stats.txt

Knot DNS Statistics:

# Real-time statistics
knotc stats
# Specific zone statistics
knotc zone-stats example.com

Knot Resolver Statistics:

-- Enable statistics module
modules.load('stats')
-- Enable Prometheus metrics
modules.load('prometheus')
net.listen('127.0.0.1', 9145, { kind = 'webmgmt' })

Access Prometheus metrics: http://127.0.0.1:9145/metrics

Grafana Dashboard:

Knot Resolver provides a Grafana dashboard template:

  • Import dashboard ID: 13314
  • Configure Prometheus data source
  • Monitor queries, cache hit rate, latency, etc.

Testing the Migration

1. Test DNSSEC Validation

# Query signed zone
dig @10.250.0.2 example.com SOA +dnssec
# Should show RRSIG records
dig @10.250.0.2 example.com DNSKEY +short

2. Test Dynamic Updates

# Create update file
cat > /tmp/update.txt <<EOF
server 10.250.0.2
zone internal.example.com
update add test.internal.example.com 300 A 10.100.10.99
send
EOF
# Send update with TSIG key
nsupdate -k /etc/dhcp-updater.key /tmp/update.txt
# Verify
dig @10.250.0.2 test.internal.example.com A +short

3. Test DNS-over-TLS

# Query via DoT
kdig +tls @10.250.0.1 example.com
# Alternative: Use openssl
openssl s_client -connect 10.250.0.1:853 -servername resolver.example.com

4. Test Split-Horizon

# Query from internal network (should recurse)
dig @10.250.0.1 google.com
# Query Knot DNS directly (should only answer authoritative)
dig @10.250.0.2 google.com # Should get REFUSED
# Query local zone from both
dig @10.250.0.1 internal.example.com # Works (via stub)
dig @10.250.0.2 internal.example.com # Works (authoritative)

5. Test Cache

# First query (cache miss)
time dig @10.250.0.1 example.com
# Second query (cache hit - should be faster)
time dig @10.250.0.1 example.com
# Check cache statistics
knotc stats | grep cache

Performance Comparison

If you want to compare these two head-to-head, you can use the dnsperf program to do so.

Benchmarking with dnsperf

# Create query file
cat > queries.txt <<EOF
google.com A
example.com A
internal.example.com A
EOF
# Test BIND9
dnsperf -s 10.250.0.1 -d queries.txt -l 30
# Test Knot Resolver
dnsperf -s 10.250.0.1 -d queries.txt -l 30

Common Pitfalls

1. Forgetting to Update Stub Zones

After adding a new zone to Knot DNS, update Knot Resolver:

localDomains = policy.todnames({
'internal.example.com.',
'dmz.example.com.',
'new-zone.example.com.', -- Don't forget this!
})

Restart Knot Resolver:

systemctl restart kresd@filtered

2. TSIG Key Algorithm Mismatches

BIND and Knot support different algorithms. Stick to commonly supported ones:

  • hmac-sha256
  • hmac-sha512
  • hmac-md5 (deprecated, replace ASAP)

3. SELinux Issues

Knot DNS and Knot Resolver have different SELinux contexts:

# Fix file contexts
restorecon -Rv /var/lib/knot
restorecon -Rv /var/cache/knot-resolver
# Check denials
ausearch -m avc -ts recent | grep knot

4. Systemd Socket Activation

Knot Resolver uses systemd socket activation by default. Disable if you configure net.listen() in Lua:

# Disable socket activation
systemctl disable kresd.socket
systemctl stop kresd.socket
# Start service directly
systemctl enable kresd@filtered
systemctl start kresd@filtered

5. IPv6 Freebind

If Knot Resolver fails to bind IPv6 addresses:

-- Use freebind option
net.listen('2001:db8::1', 53, { kind = 'dns', freebind = true })

Requires:

# Enable freebind in kernel
sysctl -w net.ipv6.ip_nonlocal_bind=1

6. ACME DNS Validation Timing

Certbot deletes TXT records immediately after validation. If you have multiple ACME clients, you might have conflicts.

Solution: Use --dns-txt-propagation-seconds:

certbot certonly \
--dns-rfc2136 \
--dns-rfc2136-credentials /etc/letsencrypt/rfc2136.ini \
--dns-rfc2136-propagation-seconds 10 \
-d example.com

DNS Application Firewall for Sandboxed Clients

The Challenge:

You want to restrict certain clients (guest networks, IoT devices, sandbox environments) from accessing specific domains or redirect their queries to filtered upstreams without creating separate resolver instances.

BIND9 Approach:

BIND9 requires separate views with response-policy zones (RPZ):

view "sandbox" {
match-clients { 10.200.0.0/24; };
recursion yes;
response-policy {
zone "rpz.blacklist";
};
zone "rpz.blacklist" {
type master;
file "rpz.blacklist.zone";
};
};

This requires maintaining zone files with blocklists, and you need separate views for each policy.

Knot Resolver DAF Approach:

The DNS Application Firewall (DAF) module provides a high-level declarative interface for filtering queries:

-- Enable DAF module
modules.load('daf')
-- Block malware/phishing domains
daf.add('qname ~ (malware|phishing).example.com deny')
-- Block queries from guest network to internal domains
daf.add('qname ~ %.internal%.example%.com AND src = 10.200.0.0/24 deny')
-- Reroute sandbox network queries to filtered upstream
daf.add('src = 10.200.0.0/24 mirror 10.250.0.10')

DAF Syntax:

Rules follow a "field operator operand action" format:

FieldDescriptionExample
qnameQuery nameqname = example.com
srcSource IPsrc = 10.200.0.0/24
dstDestination IPdst = 10.250.0.1
OperatorDescriptionExample
=Exact matchqname = example.com
~Regex matchqname ~ %.ads%.
AND, ORChain filtersqname ~ ads AND src = 10.200.0.0/24
ActionDescriptionEffect
denyBlock the queryReturns NXDOMAIN
rerouteRewrite destination IPreroute 192.0.2.1-127.0.0.1
rewriteRewrite answerrewrite A 127.0.0.1
mirrorForward to another resolvermirror 10.250.0.10
forwardForward with fallbackforward 10.250.0.10
truncateForce TCP retryTruncates response

Real-World Examples:

1. Block ads and tracking for all clients:

-- Load blocklist domains
adDomains = {
'doubleclick.net',
'googlesyndication.com',
'googleadservices.com',
'facebook.com',
'fbcdn.net',
}
for _, domain in ipairs(adDomains) do
daf.add('qname ~ %.' .. domain .. ' deny')
end

2. Sandbox IoT devices (no internal access, filtered internet):

-- Block IoT devices from accessing internal domains
daf.add('qname ~ %.internal%.example%.com AND src = 10.200.0.0/24 deny')
daf.add('qname ~ %.dmz%.example%.com AND src = 10.200.0.0/24 deny')
-- Route IoT queries through NextDNS with strict filtering
daf.add('src = 10.200.0.0/24 forward 45.90.28.0 hostname=strict.dns.nextdns.io')

3. Prevent DNS rebinding attacks:

-- Block private IP responses
daf.add('answer ~ 10%.%d+%.%d+%.%d+ deny')
daf.add('answer ~ 192%.168%.%d+%.%d+ deny')
daf.add('answer ~ 172%.(1[6-9]|2[0-9]|3[01])%.%d+%.%d+ deny')

4. Redirect specific domains to local services:

-- Redirect queries for service.example.com to local server
daf.add('qname = service.example.com rewrite A 10.100.10.50')

Managing Rules Dynamically:

The DAF provides a control interface for runtime rule management:

-- Add rule with tracking ID
local rule_id = daf.add('qname = blocked.example.com deny')
-- List all rules
print(daf.rules())
-- Count how many times a rule matched
print(daf.count(rule_id))
-- Suspend a rule temporarily
daf.suspend(rule_id)
-- Delete a rule
daf.del(rule_id)

Web Interface

DAF includes a web UI and RESTful API:

-- Enable web management
modules.load('http')
http.config({
tls = true,
cert = '/etc/letsencrypt/live/resolver.example.com/fullchain.pem',
key = '/etc/letsencrypt/live/resolver.example.com/privkey.pem',
})

Access at: https://10.250.0.1:8453/daf

Performance Considerations

  • DAF evaluates rules in order (first match wins)
  • Place most frequently matched rules first
  • Use specific matches before regex when possible
  • Monitor rule performance with daf.count()

Combining with Policy Routing

DAF works alongside Knot Resolver's policy framework:

-- First: DAF blocks specific domains
daf.add('qname ~ ads%.example%.com deny')
-- Then: Policy handles routing for allowed queries
localDomains = policy.todnames({'internal.example.com.'})
policy.add(policy.suffix(policy.STUB({'10.250.0.2'}), localDomains))
policy.add(policy.all(policy.TLS_FORWARD({...})))

Migration from BIND RPZ

If you have BIND RPZ zones, convert them to DAF rules:

# BIND RPZ zone
blocked.example.com CNAME .
*.ads.example.com CNAME .

Becomes:

# Knot Resolver DAF
daf.add('qname = blocked.example.com deny')
daf.add('qname ~ %.ads%.example%.com deny')

Logging Blocked Queries

Monitor what DAF is blocking:

-- Custom action with logging
local function log_and_deny(req, qry)
log('[DAF] Blocked: ' .. kres.dname2str(qry:name()) ..
' from ' .. req.qsource.addr)
return policy.DENY
end
-- Apply to specific rule
daf.add('qname ~ ads%.example%.com', log_and_deny)

Best Practices

  1. Test rules before deployment - Use daf.count() to verify matches
  2. Document rule purposes - Add comments explaining why domains are blocked
  3. Regular blocklist updates - Automate updates from threat feeds
  4. Monitor false positives - Check logs for legitimate services blocked
  5. Layer with upstream filtering - Use DAF for local policies, upstream for global threats

The DNS Application Firewall gives you fine-grained control over DNS traffic without maintaining complex RPZ zone files or running multiple resolver instances. It's particularly powerful for protecting IoT networks, enforcing corporate policies, and implementing parental controls.

References