Label-driven split-horizon DNS: per-host advertiser + receiver + dnsmasq with self-reload
 
 
 
Go to file
Claude Opus 5 (1M context) 5235e9856b Add edge-node advertiser compose for the Pi
The Pi is arm64 and the NAS amd64 with no shared registry yet, so this
builds from source on the target instead of pulling. SET_NAME=pi keeps its
records in a separate file from the NAS set, which is what makes two hosts
advertising into one dnsmasq safe without any merge logic.
2026-08-05 22:21:46 -07:00
advertiser dnsmasq-sync: per-host advertiser, receiver, self-reloading dnsmasq 2026-08-05 19:49:30 -07:00
receiver dnsmasq-sync: per-host advertiser, receiver, self-reloading dnsmasq 2026-08-05 19:49:30 -07:00
.gitignore dnsmasq-sync: per-host advertiser, receiver, self-reloading dnsmasq 2026-08-05 19:49:30 -07:00
Dockerfile.dnsmasq dnsmasq-sync: per-host advertiser, receiver, self-reloading dnsmasq 2026-08-05 19:49:30 -07:00
README.md Add edge-node advertiser compose for the Pi 2026-08-05 22:21:46 -07:00
claude-avatar.png Add avatar image for the claude Gitea account 2026-08-05 21:22:23 -07:00
compose.pi.yaml Add edge-node advertiser compose for the Pi 2026-08-05 22:21:46 -07:00
compose.yaml dnsmasq-sync: per-host advertiser, receiver, self-reloading dnsmasq 2026-08-05 19:49:30 -07:00
dnsmasq.conf dnsmasq-sync: per-host advertiser, receiver, self-reloading dnsmasq 2026-08-05 19:49:30 -07:00
entrypoint.sh dnsmasq-sync: per-host advertiser, receiver, self-reloading dnsmasq 2026-08-05 19:49:30 -07:00
reload-agent.sh dnsmasq-sync: per-host advertiser, receiver, self-reloading dnsmasq 2026-08-05 19:49:30 -07:00

README.md

dnsmasq-sync

Split-horizon DNS for a multi-host Docker setup, driven entirely by container labels. Adding an internal hostname means adding a label to a compose file - nothing central is edited, and no host is SSHed into.

How it fits together

each Docker host                    the DNS host
┌──────────────────┐               ┌────────────────────────────┐
│ advertiser       │  POST /sets/  │ receiver ──► writes file   │
│  reads local     │──────────────►│                     │      │
│  docker socket   │               │              inotify (masked)
└──────────────────┘               │                     ▼      │
                                   │        dnsmasq SIGHUPs itself
                                   └────────────────────────────┘

advertiser runs on every Docker host. It reads the local Docker socket - which returns labels inline and pushes an event stream - renders the hostnames that host serves, and POSTs them as one set. One host, one set.

receiver validates and writes sets into the directory dnsmasq reads. It does not reload anything.

dnsmasq watches that directory with inotify and SIGHUPs itself on change. That indirection is deliberate: the writer needs no Docker access and no knowledge of dnsmasq, so anything that can make an HTTP request can publish records.

Labels

labels:
  dnsmasq: foo.example.com          # opt-in; comma-separated for several names
  dnsmasq.target: 100.64.0.1        # optional IPv4 override
  dnsmasq.target6: "fd7a::1"        # optional IPv6 override

Opt-in by design: unlabelled containers get no internal name.

Design notes

  • Convergence beats events. Every publish is a full render of that host's records, compared byte-for-byte. Events only reduce latency. A "what changed" signal cannot express removals, so it can never be the only path.
  • Fail toward stale, never empty. An empty set is refused rather than written, so an upstream failure cannot delete internal DNS.
  • One set per host. dnsmasq reads every file in its addn-hosts directory, so sets never merge and hosts cannot clobber each other's records.
  • Writes are atomic. Temp file plus rename; dnsmasq never reads a partial file. Temp files use a .tmp suffix that the inotify agent ignores.
  • The inotify mask is mandatory. Watching without one reports IN_ACCESS and IN_CLOSE_NOWRITE - which dnsmasq generates by reading these files - and the result is a reload feedback loop. Measured at ~46,000 reloads/minute.
  • An AAAA accompanies every A. dnsmasq answers A from these files but forwards AAAA upstream for the same name. Without the AAAA, an IPv6-preferring client (RFC 6724) takes the public path while IPv4 takes the private one - split-horizon breaking only for hosts that are also public.
  • A dead host's records are left alone. They point at a host that is down, which is the honest answer. Removal is deliberate (DELETE /sets/<host>), not a timeout, so a network blip cannot silently delete a host's names.

Receiver API

POST   /sets/{name}         replace a host's whole set (hosts format)
DELETE /sets/{name}         remove it (decommissioning)
PUT    /entries/{hostname}  add/replace one ad-hoc record, {"v4":..,"v6":..}
DELETE /entries/{hostname}
GET    /healthz

All except /healthz require Authorization: Bearer $AUTH_TOKEN.

Adding a host

Run the advertiser there, changing only SET_NAME and TARGET_V4/TARGET_V6. Nothing on the DNS host needs to change.

compose.pi.yaml is that file for the Pi, and the template for any host after it. It is separate from compose.yaml because that one is the DNS host's stack - dnsmasq and the receiver - while an edge node runs only the advertiser.

It uses build: rather than image: on purpose: the Pi is arm64 and the NAS is amd64, and there is no registry both can reach yet, so building on the target is what lets one repo serve both. Switch to image: once that is configured.

SET_NAME must be unique per host. It is the filename dnsmasq reads, so two hosts sharing one would silently overwrite each other's records.