14 KiB
PLAN.md — PowerDNS API Simulator for CoreDNS (MySQL backend)
Status: DELIVERED 2026-07-10. This document was updated after completion to reflect the system as actually built and verified. Deviations from the original plan are marked [as-built] inline. Phase-gate evidence lives in
docs/EVIDENCE.md; the empirical data-shape contract indocs/SEMANTICS.md.
Goal
Implement a Go web service that exposes a 1:1 replica of the PowerDNS Authoritative HTTP API (the subset used by ACME clients) and translates those calls into MySQL queries against the database that CoreDNS reads (mysql plugin). The sole consumer is acme.sh's dns_pdns provider for Let's Encrypt DNS-01 validation. The program must be production-ready and fully tested end-to-end before the work is considered done.
The translation contract in one sentence: speak PowerDNS at the HTTP edge, read/write CoreDNS rows at the DB edge — in both directions. A GET must reassemble CoreDNS rows into pdns rrsets; a PATCH must decompose pdns rrsets into CoreDNS rows.
The repository was already initialized with a minimal Go web server; it was extended, not rewritten (WebServer type, gorilla/mux Routes, /version, graceful shutdown all kept).
Non-negotiable constraints
- No config.yaml. All YAML config code, struct tags, and the YAML dependency are removed. All configuration comes exclusively from environment variables (Docker-friendly). [as-built] Confirmed:
grep -ri yamlover the repo finds nothing config-related (update_child_repos.sh, the last stray reference, has been deleted). - Exact PowerDNS API compatibility for the endpoints acme.sh touches: same paths, same JSON shapes, same status codes, same headers. The PowerDNS API docs and the
dns_pdns.shsource are authoritative. [as-built]dns_pdns.shwas read from theneilpang/acme.shimage (/acmebin/dnsapi/dns_pdns.sh); its scraping regexes require rrset JSON field ordername,type,ttl,records(ordered Go structs, never maps) andchangetype: DELETEbodies withoutttl/records— both honored. - API key auth via
X-API-Keyheader, value fromPDNS_API_KEY. Missing/wrong key returns401with body{"error":"Unauthorized"}. - Never hit the Let's Encrypt production CA. Staging only, in every phase.
- Done means: full acme.sh workflow (staging CA) succeeds and a certificate is emitted. [as-built] Achieved twice: once against
go-built binary, once against the container image.
Configuration (environment variables)
Implemented in config.go exactly as specified; fail-fast errors name the missing variable.
| Variable | Purpose | Default |
|---|---|---|
PDNS_API_KEY |
Shared secret for X-API-Key |
(required, no default) |
LISTEN_ADDR |
HTTP listen address | :3000 |
MYSQL_DSN |
Go MySQL DSN (user:pass@tcp(host:3306)/dbname?parseTime=true) |
(required) |
TABLE_PREFIX |
Table name prefix, mirroring the Corefile table_prefix directive (e.g. coredns_) |
coredns_ |
PDNS_SERVER_ID |
Server id in API paths | localhost |
DEFAULT_TTL |
TTL when acme.sh doesn't send one | 120 |
LOG_LEVEL |
debug / info / warn / error (validated) |
info |
PUID |
UID to drop privileges to after startup | (required) |
PGID |
GID to drop privileges to after startup | (required) |
[as-built] PUID=0 or PGID=0 is rejected at config load ("refusing to serve as root"). DEFAULT_TTL must be a positive integer.
Privilege drop (startup sequence)
Implemented in privdrop.go, called first thing in main() before any listener, goroutine, or DB connection.
- Order:
setgroups([])→setgid(PGID)→setuid(PUID), exactly. - Go ≥ 1.16 applies the raw syscalls process-wide; module targets
go 1.22, builder is go 1.22.2. - After dropping, verification:
os.Getuid()/os.Getgid()must equalPUID/PGID, andsyscall.Setuid(0)must fail. Effective uid/gid logged atinfo. Verification failure →os.Exit(1); never serve as root. - The MySQL pool is opened after the drop.
LISTEN_ADDRis:3000(>1024), so no capability retention; everything is dropped.- [as-built] If the process starts as non-root (local dev runs), the drop is skipped with a
warnlog ("already running unprivileged");PUID/PGIDremain required config. This path is unit-tested; the root path is verified at container level (docker topshows uid/gid 4321/4321 with test values).
Phase 0 — Empirical semantics discovery ✅ (findings in docs/SEMANTICS.md)
Ground truth was established against the live MariaDB and the running CoreDNS (never restarted or reconfigured — its behavior is part of the ground truth):
- CoreDNS side (verified empirically, not from plugin docs):
- Table
coredns_records(id, zone, name, ttl, content TEXT/JSON, record_type);zoneFQDN with trailing dot;namerelative to zone, no trailing dot,''at apex; multi-label relative names work; FQDN-in-name is NOT served. - Multiple TXT values on one name = multiple rows, one
{"text":"..."}each. A JSON array inside one row is NOT served. - TXT quoting: store the raw unquoted token; CoreDNS adds wire quoting. Storing pdns-style
\"tok\"yields literal quotes on the wire (wrong for ACME). - Queries are case-insensitive; store names lowercase.
- Visibility [key as-built finding]: record lookups hit MySQL live — writes to an existing zone are visible immediately.
zone_update_interval(120s in the Corefile) only gates the cached zone list, i.e. newly created zones. It was NOT lowered for dev (CoreDNS is hands-off); it didn't need to be.
- Table
- PowerDNS side: rrset model
{name, type, ttl, changetype, records:[{content, disabled}]};REPLACEreplaces the entire(name, type)set;DELETEremoves it (and arrives withoutttl/records). - The full mapping table (pdns operation → exact SQL, TXT quote handling, FQDN normalization) is in
docs/SEMANTICS.md§4; every handler implements it.
API surface implemented (what acme.sh dns_pdns uses)
All under /api/v1, all requiring X-API-Key; unknown {server_id} → 404 on every route; zone_id accepted with/without trailing dot and URL-encoded dots; Content-Type: application/json on all responses.
GET /zones— zone list fromSELECT DISTINCT zone;id/namecanonical with trailing dot (acme.sh_get_rootsubstring-matches"name":"zone."), plusurland [as-built]kind:"Native"for shape parity.GET /zones/{zone_id}— zone detail withrrsetsaggregated from rows grouped by(name, type). TXT contents re-quoted ("\"tok\""); A/AAAA→ip, CNAME/NS→host, SOA assembled asns mbox 0 refresh retry expire minttl(serial not stored → 0); unparsable content passed through raw. 0 rows → 404.PATCH /zones/{zone_id}—REPLACE: transactional delete-all + one INSERT per record (the two-TXT-values-on-one-_acme-challengecase is the critical path and is covered by tests, curl, dig, and the E2E).DELETE: delete-all for(zone, name, type).204on success,422+{"error":"<specific>"}on malformed rrsets,404on unknown zone. [as-built] Write support: TXT/A/AAAA/CNAME/NS; names lowercased, must be within the zone (label-boundary checked,notexample.com.vsexample.com.→ 422); TTL absent/0 →DEFAULT_TTL.PUT /zones/{zone_id}/notify— no-op (CoreDNS reads MySQL live);200+{"result":"Notification queued"}; unknown zone → 404.
MySQL layer
- Exact live schema, table name from
TABLE_PREFIX(store.go). - Prepared statements; REPLACE (delete+insert) wrapped in a transaction, rollback on any error.
- [as-built] Pool: MaxOpenConns 10, MaxIdleConns 5, ConnMaxLifetime 60s (mirrors the Corefile limits); ping at startup with 5s timeout, fail fast.
- [as-built] Driver pinned to
go-sql-driver/mysql v1.9.3(v1.10 forcesgo 1.24, above the module'sgo 1.22).
Development / test environment (as actually used)
- Dev server on
:3000, exposed via the running ngrok tunnel:https://claude-test-endpoint.ngrok-free.dev→http://127.0.0.1:3000 - CoreDNS running on port 5053, public forward
1.2.3.4:53→ localhost:5053. - Test zone:
example.com(SAN:www.example.com) — placeholder; this will need changing before any real run. ACME validation resolves through the public DNS tree even against the staging CA, so the zone must be genuinely delegated from its public parent to the CoreDNS IP (1.2.3.4) — and Let's Encrypt additionally refuses to issue forexample.comitself by policy (rejectedIdentifier). The delivery E2E therefore ran against a real delegated sub-zone, redacted toexample.comthroughout these docs. The zone rows (SOA, apex NS/A,ns1A →1.2.3.4,wwwCNAME) live incoredns_records. - dig caveat [as-built]: this dev host's outbound UDP/53 is transparently intercepted by a middlebox (proven: an IP with no DNS server "answers").
dig @1.2.3.4was unreachable during part of development and later worked (verified genuine via TCP + answer-shape/serial match with the local instance). When in doubt, verify againstdig @127.0.0.1 -p 5053(same instance) and externally via DoH (https://dns.google/resolve?...reports"Response from 1.2.3.4").
Test plan (all executed; evidence in docs/EVIDENCE.md)
Phase 1 — Unit/handler tests ✅
27 top-level tests: auth (missing/wrong/correct key), zone listing, zone detail rrset aggregation + JSON field order, PATCH REPLACE/DELETE semantics, TXT quote handling both directions, FQDN normalization + boundary cases, notify, trailing-dot equivalence, 404/422 bodies, config fail-fast + defaults, privdrop non-root path. [as-built] DB-backed tests run against an isolated test_pdnsapi_records table on the live MariaDB (created/seeded/dropped per run; skip cleanly if DB unreachable); the real coredns_records is never touched.
Phase 2 — Manual API tests with curl (localhost) ✅
curl -s -H "X-API-Key: $PDNS_API_KEY" http://127.0.0.1:3000/api/v1/servers/localhost/zones
curl -s -H "X-API-Key: $PDNS_API_KEY" http://127.0.0.1:3000/api/v1/servers/localhost/zones/example.com.
curl -s -X PATCH -H "X-API-Key: $PDNS_API_KEY" -H "Content-Type: application/json" \
-d '{"rrsets":[{"name":"_acme-challenge.example.com.","type":"TXT","ttl":120,"changetype":"REPLACE","records":[{"content":"\"test-token-123\"","disabled":false}]}]}' \
http://127.0.0.1:3000/api/v1/servers/localhost/zones/example.com.
Verified: wrong key → 401, missing zone → 404, malformed rrset → 422, two-record REPLACE on one _acme-challenge name → 204 with exactly two {"text":...} rows.
Phase 3 — DNS propagation check with dig ✅
dig @1.2.3.4 TXT _acme-challenge.example.com +norecurse +noall +answer
# fallback if the dev host's UDP/53 interception bites: dig @127.0.0.1 -p 5053 ...
Both values of the two-record REPLACE served; after changetype: DELETE → NXDOMAIN. Writes were visible immediately (see Phase 0 visibility finding — no zone_update_interval wait for records on existing zones).
Phase 4 — Public endpoint test (ngrok) ✅
Phase 2 calls repeated against https://claude-test-endpoint.ngrok-free.dev: identical bodies and status codes (200/204/401/404/422), PATCH-through-tunnel served by CoreDNS.
Phase 5 — Full E2E with acme.sh (Let's Encrypt STAGING only) ✅
docker run --rm -v ~/acme.sh:/acme.sh \
-e PDNS_Url="https://claude-test-endpoint.ngrok-free.dev" \
-e PDNS_ServerId="localhost" \
-e PDNS_Token="$PDNS_API_KEY" \
-e PDNS_Ttl="120" \
neilpang/acme.sh --issue --staging --dns dns_pdns \
-d example.com -d www.example.com
All five checks passed: root-zone detection via GET /zones (server log), TXT PATCHes for both names (MySQL rows + dig), staging CA validated both identifiers, staging cert (issuer "(STAGING) Artificial Amaranth YE1", both SANs) saved under ~/acme.sh, cleanup DELETEs confirmed (DB empty, NXDOMAIN).
[as-built] flakiness note: no --dnssleep is needed (propagation is immediate; acme.sh's default 20s check passes). One attempt failed with a transient LE "secondary validation" timeout — that is remote reachability of 1.2.3.4 from LE's vantage points, not propagation; the retry succeeded unchanged. If issuance flakes, retry before touching anything.
Phase 6 — Production readiness ✅
- Dockerfile untouched (already compliant: 2-stage build →
scratch, noUSERdirective — the binary itself drops toPUID/PGID). - [as-built] makefile amendments (style preserved):
run/dockerrunpublish container port 3000 (was 80), pass through the nine app env vars with--env;dockerrunno longer mountsconfig.yamland uses the:$${APP_VERSION}tag..vars(gitignored) created locally for the build (bryanpedini/gobuilder:1.22.2-alpine3.19, imagegit.bjphoster.com/source/coredns-powerdns-api-wrapper). - Privilege-drop checks: unit test (non-root path) + container-level — image run with test
PUID=4321/PGID=4321; startup logprivileges dropped uid=4321 gid=4321and host-sidedocker topshows the serving process as 4321/4321, not root. - Graceful shutdown (SIGTERM), slog request logging at
info, structured errors,/healthz(MySQL ping, unauthenticated, no data) — all in place. - README: env var table (incl.
TABLE_PREFIX↔ Corefiletable_prefix), docker run example, acme.sh staging/production usage, propagation/--dnssleepguidance, known limitations. - Final Phase 5 re-run against the built image (
:0.1.0,--network hostsince MariaDB binds to 127.0.0.1 only): cached staging authorizations were deactivated first so DNS-01 genuinely re-ran through the container; fresh cert issued.
Definition of done — all satisfied 2026-07-10
docs/SEMANTICS.mdexists; every handler implements its mapping table. ✅- All YAML config code and files removed; env-vars-only confirmed by grep. ✅
- All Go tests pass (
go test ./... -count=1). ✅ - Phases 2–5 pass, evidence recorded in
docs/EVIDENCE.md. ✅ - acme.sh issues a staging certificate for the test zone +
wwwSAN with zero acme.sh-side workarounds. ✅ (zone isexample.com, see environment section) - Container image builds and runs the same E2E, serving process verified as
PUID/PGID, not root. ✅
Explicit non-goals (unchanged, binding)
- Full PowerDNS API coverage (zone CRUD beyond GET, cryptokeys, metadata, search, TSIG).
- DNSSEC.
- Any DNS serving by this service itself — CoreDNS remains the only resolver.