4.7 KiB
DNS Solution Research
Decision: PowerDNS Authoritative + ExternalDNS
Why PowerDNS
| Feature | PowerDNS | CoreDNS | BIND9 | Technitium |
|---|---|---|---|---|
| REST API | Full | No (needs etcd) | No (nsupdate) | Yes |
| Database backend | PostgreSQL/MySQL/SQLite | etcd | Zone files | Custom |
| Health-aware DNS | Lua records (ifportup, ifurlup) | No | No | No |
| ExternalDNS provider | Yes | Yes (via etcd) | Yes (RFC 2136) | No |
| DNSSEC | Yes | Limited | Best | Yes |
| Split DNS | dnsdist routing | Corefile blocks | Views (best) | APP records |
| Maturity | ISP-grade | K8s-focused | Oldest | Newer |
PowerDNS wins on: REST API (critical for Lab), health-check-aware Lua records, database backend for HA, and ExternalDNS integration.
Architecture
Lab Server
(control plane)
│
│ PowerDNS REST API
▼
┌───────────────┐
│ PowerDNS │
│ Authoritative│──── PostgreSQL/SQLite backend
│ Server │
└───────┬───────┘
│
┌───────────┼───────────┐
│ │ │
▼ ▼ ▼
Internal DNS ExternalDNS dnsdist
.lab.internal (k8s syncs (split DNS
Services/ routing)
Ingress)
How Lab Uses DNS
Auto-registration on onboard
When lab onboard completes, Lab calls PowerDNS API:
- A record:
<server>.lab.internal → <ip> - PTR record:
<reverse-ip>.in-addr.arpa → <server>.lab.internal - Both created/updated atomically
Domain claims via labels
Labels can claim shared domain names:
labels:
mailserver:
dns:
records:
- type: A
name: "{{server.name}}.lab.internal"
claims:
- name: mail.example.com
type: A
health_check: { port: 25 }
All servers with label mailserver contribute to mail.example.com round-robin.
PowerDNS Lua records remove unhealthy servers automatically.
IP mobility
Lab agent on machine reports IP change → Lab server updates PowerDNS API → A record, PTR, and all claimed domains updated.
K8s integration
ExternalDNS runs in k8s, syncs Service/Ingress records to same PowerDNS instance. Same DNS server serves both bare metal and k8s records.
Groups claiming domains
Groups can claim domains for all member servers:
groups:
production-web:
match:
labels: [web-frontend]
environment: prod
dns:
claims:
- name: www.example.com
type: A
health_check: { url: "https://{{server.ip}}/healthz" }
DNS Plugin Interface
type DNSPlugin interface {
Name() string
// Record management
CreateRecord(zone, name, recordType string, targets []string, ttl int) error
UpdateRecord(zone, name, recordType string, targets []string, ttl int) error
DeleteRecord(zone, name, recordType string) error
ListRecords(zone string) ([]Record, error)
// Health-checked records
CreateHealthCheckedRecord(zone, name string, targets []string, check HealthCheck) error
// Zone management
CreateZone(name string, kind string) error
DeleteZone(name string) error
}
Built-in:
dns-powerdns— PowerDNS REST API (primary)dns-route53— AWS Route53 (for cloud deployments)dns-rfc2136— RFC 2136 dynamic updates (BIND/Knot fallback)
Split DNS Setup
Internal zones (.lab.internal) served by PowerDNS authoritatively.
External queries forwarded upstream (8.8.8.8, ISP DNS).
Options:
- dnsdist (PowerDNS ecosystem) routes by source subnet
- CoreDNS as resolver — serves internal from PowerDNS, forwards external
- BIND views — if we need view-based split on same zone (unlikely)
Evaluated and Not Chosen
| Tool | Why Not |
|---|---|
| CoreDNS | No REST API, needs etcd intermediary, k8s-focused |
| BIND9 | No REST API, nsupdate is cumbersome for automation |
| Technitium | No ExternalDNS provider, newer/smaller community |
| dnsmasq | Not suitable — caching forwarder, no API, ~1000 client limit |
| Knot DNS | No REST API, better as secondary/downstream |
DNS-as-Code (Optional Layer)
For static DNS infrastructure (SOA, NS, MX, base zone config):
- octoDNS (GitHub) or DNSControl (Stack Exchange)
- GitOps workflow: PR → review → merge → sync to PowerDNS
- Dynamic records (server A records, claims) managed by Lab directly via API
- Static records managed via DNS-as-code in Git