← Notes

Zone-o-Matic: how a DynDNS endpoint grew into the write side of my DNS

· dns, acme, cert-manager, go, homelab

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:

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:

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.