# 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 $ 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 ```