External DNS with OpenWRT
If you run a Kubernetes homelab behind an OpenWrt router, every new
Ingress means another DNS record to add by hand. It works, but it’s
one more manual step to remember, and it doesn’t scale well once
you’re deploying more than the occasional service.
This post shows how to automate that with
external-dns,
using the OpenWrt router already sitting at the edge of the lab.
The lab:
- Kubernetes cluster — nothing special, any cluster with a working Ingress controller will do
- OpenWrt router at
192.168.5.1— dnsmasq handles LAN DNS on port 53, Knot DNS added on port 5300 - Zone:
hq.c1.itknecht.de(adapt to your own domain)
Why Knot on port 5300?
The obvious question: why not just use dnsmasq, which is already there?
The short answer is: dnsmasq cannot do what external-dns needs.
dnsmasq is a lightweight forwarding resolver and DHCP server — it is not
an authoritative DNS server. It has no support for RFC2136 dynamic DNS
updates, no TSIG authentication, no SOA/serial management, and no zone
transfer (AXFR). external-dns requires all of these to operate with the
rfc2136 provider.
The next thought might be: replace dnsmasq with a full authoritative
server. That would mean losing DHCP, LAN hostname resolution, and the
deep UCI integration that makes OpenWrt work the way it does. Not a small
change — it would fundamentally alter the router’s behavior.
Knot DNS solves this without touching any of that. It runs alongside
dnsmasq on port 5300, owns exactly one internal zone, and accepts
TSIG-authenticated dynamic updates from external-dns. dnsmasq keeps
doing everything it did before, and just forwards queries for
hq.c1.itknecht.de to Knot. The router behaves identically to before
from every other perspective.
It is a small, focused addition — a good fit for a router that should
stay boring.
1. Install Knot DNS on OpenWrt
opkg update
opkg install knot knot-keymgr knot-libs knot-libzscanner knot-nsupdate knot-zonecheck2. Generate a TSIG Key
Knot ships keymgr for key management. Generate a TSIG key that
external-dns will use to authenticate dynamic DNS updates:
keymgr tsig generate externaldns-key algorithm=hmac-sha256The output is a complete key block — copy the secret value, you will
need it in two places: the Knot config and the Kubernetes Secret.
3. Configure Knot
Create /etc/knot/knot.conf:
server:
rundir: "/var/run/knot"
user: knot:knot
automatic-acl: on
listen: [ 192.168.5.1@5300 ]
log:
- target: syslog
any: info
database:
storage: "/var/lib/knot"
journal-db-max-size: 20M
timer-db-max-size: 5M
remote:
template:
- id: default
storage: "/var/lib/knot"
file: "%s.zone"
key:
- id: externaldns-key
algorithm: hmac-sha256
secret: <your-generated-secret>=
acl:
- id: externaldns-update
key: externaldns-key
action: [update, transfer]
address: 0.0.0.0/0
zone:
- domain: hq.c1.itknecht.de
file: "/etc/knot/zones/hq.c1.itknecht.de.zone"
acl: externaldns-updateThe ACL allows TSIG-authenticated dynamic updates and zone transfers
from any source address — external-dns needs both (--rfc2136-tsig-axfr
does a full zone transfer to enumerate existing records before writing).
4. Create the initial zone file
Knot needs a valid zone on disk before it will accept dynamic updates.
Create /etc/knot/zones/hq.c1.itknecht.de.zone:
$ORIGIN hq.c1.itknecht.de.
@ 3600 IN SOA ns1.hq.c1.itknecht.de. admin.itknecht.de. 1 3600 900 604800 86400
3600 IN NS ns1.hq.c1.itknecht.de.
ns1 3600 IN A 192.168.5.1ns1 at the internal IP of the Knot server (192.168.5.1) Start and enable Knot:
service knot start
service knot enable
knotc zone-status hq.c1.itknecht.de5. Forward the zone in dnsmasq
Add one line to /etc/dnsmasq.conf so LAN clients resolve the zone via Knot:
server=/hq.c1.itknecht.de/192.168.5.1#5300Restart dnsmasq:
service dnsmasq restart6. Open port 5300 in the OpenWrt firewall
In /etc/config/firewall (or via UCI):
uci add firewall rule
uci set firewall.@rule[-1].src='lan'
uci set firewall.@rule[-1].name='knot@5300'
uci add_list firewall.@rule[-1].proto='udp'
uci set firewall.@rule[-1].dest_port='5300'
uci set firewall.@rule[-1].target='ACCEPT'
uci set firewall.@rule[-1].family='ipv4'
uci commit firewall
service firewall restart7. Test dynamic updates manually
Before wiring up external-dns, it’s worth confirming a dynamic update
actually works — same mechanism external-dns will use later, just
without the client.
Set a test record with knsupdate (the TSIG-aware update tool that
ships with Knot):
cat <<EOF | knsupdate -y hmac-sha256:externaldns-key:<your-generated-secret>=
server 192.168.5.1 5300
zone hq.c1.itknecht.de
update add test.hq.c1.itknecht.de. 300 A 192.168.5.99
send
EOFCheck that it landed:
dig @192.168.5.1 -p 5300 test.hq.c1.itknecht.deRemove it again:
cat <<EOF | knsupdate -y hmac-sha256:externaldns-key:<your-generated-secret>=
server 192.168.5.1 5300
zone hq.c1.itknecht.de
update delete test.hq.c1.itknecht.de. A
send
EOFConfirm it’s gone:
dig @192.168.5.1 -p 5300 test.hq.c1.itknecht.deIf all three steps behave as expected, the ACL and TSIG key are set up
correctly and external-dns can use the same path.
8. Deploy external-dns in Kubernetes
First, store the TSIG secret as a Kubernetes Secret:
kubectl create namespace external-dns
kubectl create secret generic externaldns-tsig \
--namespace external-dns \
--from-literal=tsig-secret='<your-generated-secret>='Then apply the full deployment:
apiVersion: v1
kind: ServiceAccount
metadata:
name: external-dns
namespace: external-dns
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: external-dns
rules:
- apiGroups: [""]
resources: ["services", "endpoints", "pods", "nodes"]
verbs: ["get", "watch", "list"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "watch", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: external-dns
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: external-dns
subjects:
- kind: ServiceAccount
name: external-dns
namespace: external-dns
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: external-dns
namespace: external-dns
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: external-dns
template:
metadata:
labels:
app: external-dns
spec:
serviceAccountName: external-dns
containers:
- name: external-dns
image: registry.k8s.io/external-dns/external-dns:v0.15.1
args:
- --source=ingress
- --domain-filter=hq.c1.itknecht.de
- --provider=rfc2136
- --rfc2136-host=192.168.5.1
- --rfc2136-port=5300
- --rfc2136-zone=hq.c1.itknecht.de
- --rfc2136-tsig-keyname=externaldns-key
- --rfc2136-tsig-secret=$(TSIG_SECRET)
- --rfc2136-tsig-secret-alg=hmac-sha256
- --rfc2136-tsig-axfr
- --registry=txt
- --txt-owner-id=hq-c1
- --policy=upsert-only
- --interval=1m
env:
- name: TSIG_SECRET
valueFrom:
secretKeyRef:
name: externaldns-tsig
key: tsig-secretA few things worth noting:
--rfc2136-tsig-axfrenables zone transfer to enumerate existing records — required for the TXT registry to work correctly--policy=upsert-onlymeans external-dns only creates/updates records, never deletes them; safe for a homelab--registry=txtwith--txt-owner-idlets external-dns track which records it owns via TXT records
9. Verify
Check that the external-dns pod is running and writing records:
kubectl -n external-dns logs -f deploy/external-dnsAfter the first sync interval (1 minute), query the zone directly:
dig @192.168.5.1 -p 5300 myapp.hq.c1.itknecht.deOr via dnsmasq (standard port, as LAN clients would):
dig @192.168.5.1 myapp.hq.c1.itknecht.deCheck the serial increment on the router — each external-dns write bumps it:
knotc zone-status hq.c1.itknecht.de