Files
2026-07-10 23:53:43 +02:00

6.7 KiB
Raw Permalink Blame History

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