You've already forked coredns-powerdns-api-wrapper
152 lines
6.7 KiB
Markdown
152 lines
6.7 KiB
Markdown
# 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
|
||
```
|