6.7 KiB
EVIDENCE.md — phase gate outputs (recorded 2026-07-10)
Test zone shown as example.com — a redaction of the real delegated sub-zone
used during the runs (publicly delegated to 1.2.3.4 → CoreDNS :5053).
To reproduce, that name will need changing: ACME needs a real zone to
validate against even on the staging CA (validation follows the public DNS
tree), and Let's Encrypt refuses to issue for example.com itself by policy
(rejectedIdentifier). API key redacted as $PDNS_API_KEY, server IP as
1.2.3.4 throughout.
Phase 1 — Go tests
$ go test ./... -count=1
ok git.bjphoster.com/source/coredns-powerdns-api-wrapper 1.440s
27 top-level tests (auth, zone list/detail, rrset aggregation + field order,
PATCH REPLACE/DELETE, TXT quoting both directions, FQDN normalization, notify,
trailing-dot equivalence, 404/422 bodies, config fail-fast, privdrop non-root
path). Isolated table test_pdnsapi_records; live coredns_records row count
identical before/after (4/4).
Phase 2 — curl on localhost (2026-07-10T21:43 CEST)
$ curl -H "X-API-Key: $PDNS_API_KEY" http://127.0.0.1:3000/api/v1/servers/localhost/zones
[{"id":"example.com.","name":"example.com.","url":"/api/v1/servers/localhost/zones/example.com.","kind":"Native"}]
$ curl .../zones/example.com. # zone detail (trimmed)
{"id":"example.com.","name":"example.com.","kind":"Native","url":"...","rrsets":[
{"name":"example.com.","type":"A","ttl":300,"records":[{"content":"1.2.3.4","disabled":false}]},
{"name":"example.com.","type":"NS","ttl":300,"records":[{"content":"ns1.example.com.","disabled":false}]},
{"name":"example.com.","type":"SOA","ttl":300,"records":[{"content":"ns1.example.com. hostmaster.example.com. 0 44 55 66 300","disabled":false}]},
{"name":"www.example.com.","type":"CNAME","ttl":300,"records":[{"content":"example.com.","disabled":false}]}]}
$ PATCH REPLACE single token "test-token-123" → HTTP 204
$ wrong API key → {"error":"Unauthorized"} HTTP 401
$ missing zone (nosuchzone.org.) → {"error":"Not Found"} HTTP 404
$ malformed rrset (REPLACE, records: []) → {"error":"REPLACE requires at least one record"} HTTP 422
$ PATCH REPLACE two records on _acme-challenge → HTTP 204
$ SELECT name,ttl,content,record_type FROM coredns_records WHERE name LIKE '_acme%';
_acme-challenge 120 {"text":"token-for-example-com"} TXT
_acme-challenge 120 {"text":"token-for-www-example-com"} TXT
Phase 3 — dig propagation
dig @1.2.3.4 is not reachable from inside this host (hairpin NAT +
ISP-level UDP/53 interception, proven by getting an answer from an IP with no
DNS server). Verification ran against the same CoreDNS instance directly and,
externally, via public resolvers (see Phase 5 prep).
$ dig @127.0.0.1 -p 5053 TXT _acme-challenge.example.com +norecurse +noall +answer
_acme-challenge.example.com. 120 IN TXT "token-for-example-com"
_acme-challenge.example.com. 120 IN TXT "token-for-www-example-com" ← both values of the 2-record REPLACE
$ PATCH changetype:DELETE → HTTP 204, then:
$ dig ... → status: NXDOMAIN; SELECT COUNT(*) ... LIKE '_acme%' → 0
Record writes on an existing zone were visible immediately (no zone_update_interval wait; the interval only gates new-zone visibility).
Phase 4 — ngrok public endpoint (https://claude-test-endpoint.ngrok-free.dev)
$ zones via ngrok → identical body to Phase 2, HTTP 200
$ zone detail via ngrok → identical body to Phase 2, HTTP 200
$ PATCH REPLACE via ngrok → HTTP 204; dig @127.0.0.1 -p 5053 served "ngrok-test-token"
$ wrong key via ngrok → {"error":"Unauthorized"} HTTP 401
$ missing zone via ngrok → {"error":"Not Found"} HTTP 404
$ changetype BOGUS → {"error":"unknown or missing changetype"} HTTP 422
$ PATCH DELETE (cleanup) → HTTP 204
Phase 5 — acme.sh staging E2E (zone example.com)
Zone setup: SOA/NS/A(apex)/A(ns1)/CNAME(www) rows inserted; CoreDNS served the new zone after ~10s; external chain verified before the run:
$ curl 'https://dns.google/resolve?name=example.com&type=SOA'
{"Status":0,...,"Comment":"Response from 1.2.3.4."} ← public chain hits our CoreDNS
check-host.net: 10/10 nodes (DE HK IL×2 IN IR MD UA US×2) resolved A=1.2.3.4
First attempt failed with During secondary validation: DNS problem: query timed out (transient reachability of one LE vantage point); retry succeeded
with zero acme.sh-side workarounds:
docker run --rm -v ~/acme.sh:/acme.sh -e PDNS_Url=... -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
[...] Adding TXT value: GzM5RMGKqZ... for domain: _acme-challenge.example.com
[...] The TXT record has been successfully added.
[...] Success for domain example.com / www.example.com
[...] Cert success.
Your cert is in: /acme.sh/example.com_ecc/example.com.cer
Server log (request sequence per acme.sh call): GET /zones 200 (_get_root) →
GET /zones/{zone} 200 (existing challenges) → PATCH 204 → PUT notify 200;
cleanup DELETE PATCHes 204. After cleanup: DB _acme% rows = 0, dig NXDOMAIN.
$ openssl x509 -noout -issuer -ext subjectAltName -dates
issuer=C=US, O=Let's Encrypt, CN=(STAGING) Artificial Amaranth YE1
DNS:example.com, DNS:www.example.com
Phase 6 — container image E2E + privilege drop
$ APP_VERSION=0.1.0 make docker → git.bjphoster.com/source/coredns-powerdns-api-wrapper:0.1.0 (7.94MB, scratch)
$ docker run -d --name pdns-api-e2e --network host --env PDNS_API_KEY \
--env MYSQL_DSN=... --env PUID=4321 --env PGID=4321 <image>
$ docker logs pdns-api-e2e
level=INFO msg="privileges dropped" uid=4321 gid=4321
level=INFO msg="mysql pool ready"
level=INFO msg=listening addr=:3000
$ docker top pdns-api-e2e -eo pid,uid,gid,comm ← host view
PID UID GID COMMAND
446573 4321 4321 app ← NOT root
$ curl http://127.0.0.1:3000/healthz → {"status":"ok"}
Full Phase 5 re-run against the container (cached LE authorizations deactivated first so dns-01 actually re-ran):
[...] Verifying: example.com → Success
[...] Verifying: www.example.com → Success
[...] Cert success.
Container request log shows the same GET/PATCH/notify sequence; post-run DB
_acme% rows = 0, dig NXDOMAIN, cert dated Jul 10 19:19:58 2026 GMT with both
SANs, staging issuer.
Definition-of-done greps
$ grep -ri yaml --exclude-dir=.git . → only CLAUDE.md/PLAN.md prose;
nothing config-related in Go sources, makefile, or dockerfile
(update_child_repos.sh, the last stray reference, has since been deleted)
$ gofmt -l . → clean; go vet ./... → clean