Zone-o-Matic: how a DynDNS endpoint grew into the write side of my DNS
My authoritative DNS is about as simple as it gets: CoreDNS on the hubs
serving plain zone files with the file plugin, which reloads them when the
SOA serial changes. Simple to read, simple to back up, simple to diff in git.
Zone-o-Matic is the small Go service that writes to those files. It did not start out as a plan — every API in it arrived with a new client that needed to change a record.
The initial problem: routers and acme.sh
August 2025. Some of my routers sit behind dynamic addresses, and I wanted
their names in my own zones rather than at a third-party DynDNS provider. The
same routers get TLS certificates from acme.sh, which needs to publish
dns-01 challenge records.
So the first version spoke two existing protocols:
- the no-ip style
GET /nic/update, which OpenWrt’s ddns-scripts and pretty much every router already know; - the acme-dns API, which
acme.shsupports out of the box.
Both just translate into “set this record in that zone file”.
Follow-up: lego
November 2025. Hosts outside OpenWrt — the S3 gateway, for example — renew
their certificates with lego. lego has a
generic httpreq provider:
two calls, /present and /cleanup, that add and remove a single challenge
value. Adding them meant a certificate for a name and its wildcard could be
validated at the same time.
Follow-up: PowerDNS for Proxmox
April 2026. Proxmox VE’s SDN can register VMs in DNS, but only through a PowerDNS server. Instead of running PowerDNS next to CoreDNS, zoneomatic gained the subset of the PowerDNS API that Proxmox actually calls.
Follow-up: RFC2136, because of cert-manager
September 2026. The Kubernetes clusters issue certificates with
cert-manager. cert-manager has no built-in lego httpreq solver — using it
would mean deploying an extra webhook plugin into every cluster. What
cert-manager does have built in is the
rfc2136
solver: plain DNS UPDATE, authenticated with TSIG.
So zoneomatic learned to speak DNS UPDATE. There are two listeners: one for
ACME that accepts nothing but _acme-challenge TXT records, and one for full
updates that nsupdate and knsupdate can use. Each cluster now needs only a
ClusterIssuer and a TSIG key — no webhook. Around the same time, DynDNS
updates learned to keep the matching PTR records in reverse zones in sync.
Treating zone files with respect
With this many writers, the zone files still have to look like a careful human edited them:
- Comments are kept in every position — whole lines, end of a record, inside SOA or multi-line TXT parentheses. A replaced record keeps its comment; a deleted one takes its comment with it.
- The SOA serial is bumped — Unix-time serials become the current time
(still increasing for several changes within a second),
YYYYMMDDnnserials are incremented. - Every write is verified before it lands. The new file is parsed again with an independent parser (miekg/dns) and must contain exactly the intended records and all the comments; otherwise the update fails and the old file stays. The replacement itself is atomic.
- ACME records never disappear.
_acme-challengenames hold a placeholder when no challenge is active.
The formatter, pkg/dnsfmt, is a separate Go module, and the zone writer is
fuzz-tested.
Safety and testing
HTTP users come from an htpasswd file with bcrypt hashes; RFC2136 uses TSIG keys pinned to an algorithm. TSIG authenticates a request but does not encrypt it, so the RFC2136 listeners belong on a private interface — in my case the VPN mesh — with an address allowlist on top. Only the configured zone files are writable.
Besides unit and end-to-end tests with real nsupdate/knsupdate, make k3d
spins up a throwaway k3d cluster with cert-manager,
Pebble as the ACME server and CoreDNS,
and issues real certificates through zoneomatic. CI runs the same thing.
There is a Dockerfile for containers and an OpenWrt package in my feed — that is how it runs next to CoreDNS on the routers.