first commit

This commit is contained in:
2026-07-09 22:16:19 +02:00
commit 869749bb01
34 changed files with 3204 additions and 0 deletions

151
docs/EVIDENCE.md Normal file
View File

@@ -0,0 +1,151 @@
# 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
```

146
docs/SEMANTICS.md Normal file
View File

@@ -0,0 +1,146 @@
# SEMANTICS.md — CoreDNS (mysql plugin) ↔ PowerDNS API data-shape contract
Ground truth established empirically on 2026-07-10 against the live stack:
MariaDB 12.3.2 (`coredns` db, user `coredns`), CoreDNS with compiled-in `mysql`
plugin serving on `:5053`, Corefile `table_prefix coredns_`,
`zone_update_interval 120s`, plugin `ttl 300`.
## 1. Live schema (source of truth: `SHOW CREATE TABLE coredns_records`)
```sql
CREATE TABLE `coredns_records` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`zone` varchar(255) NOT NULL,
`name` varchar(255) NOT NULL,
`ttl` int(11) DEFAULT NULL,
`content` text DEFAULT NULL,
`record_type` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
```
- `zone`: FQDN **with trailing dot**, lowercase (`example.com.`).
- `name`: **relative to zone, no trailing dot**; empty string `''` at the apex;
multi-label relative names work (`_p0f.www` served at `_p0f.www.example.com.`
— verified). A name stored as FQDN is **not** served (verified).
- `content`: JSON object, one row per record value. Type-specific keys (observed
live + plugin conventions):
| record_type | content JSON |
|---|---|
| TXT | `{"text":"<raw value>"}` |
| A | `{"ip":"1.2.3.4"}` |
| AAAA | `{"ip":"::1"}` |
| CNAME / NS | `{"host":"target.example.com."}` |
| SOA | `{"ttl":300,"mbox":"hostmaster.example.com.","ns":"ns1.example.com.","refresh":44,"retry":55,"expire":66}` |
- `ttl`: integer seconds, honored as served TTL (verified: row ttl 120 served
as 120).
## 2. Empirical CoreDNS read semantics (all verified with dig against the live instance)
| Question | Verified answer |
|---|---|
| Multiple TXT values on one name | **Multiple rows**, same `(zone,name,record_type)`, one `{"text":...}` each. Both values served. |
| JSON array inside one row (`{"text":["a","b"]}`) | **Not served** (empty answer). Never write this shape. |
| TXT quoting in DB | Store the **raw, unquoted** token. CoreDNS adds wire quoting: `{"text":"tok"}``"tok"` on the wire. Storing `\"tok\"` yields literal quotes inside the value (`"\"tok\""` on the wire) — wrong for ACME. |
| Query case sensitivity | Case-insensitive (`_P0A.EXAMPLE.COM` matched row `_p0a`). Store names lowercase. |
| Write visibility | Record inserts/deletes on an **existing** zone are visible **immediately** (record lookups hit the DB per query). `zone_update_interval` (120s) only gates the cached **zone list**, i.e. only newly-created zones are delayed. No CoreDNS restart, no dnssleep needed for TXT changes on `example.com.`. |
## 3. PowerDNS API semantics (from `dns_pdns.sh` in the `neilpang/acme.sh` image + pdns API docs)
rrset model: `{"name": "<fqdn.>", "type": "TXT", "ttl": N, "changetype": "REPLACE"|"DELETE", "records": [{"content": "\"token\"", "disabled": false}]}`
- `REPLACE` replaces the **entire** record set for `(name, type)`.
- `DELETE` removes the entire set; acme.sh sends it **without `ttl` and without
`records`** — validation must accept that.
- TXT `content` on the API is **quoted**: `"\"token\""` in the JSON body.
- acme.sh specifics that the responses must satisfy:
- `_get_root` substring-matches `"name":"<candidate>."` against the
normalized zone-list JSON → every zone `name` must be FQDN with trailing
dot.
- Zone id in URLs arrives **with** trailing dot (`/zones/example.com.`);
accept it also without a dot and with URL-encoded dots, like PowerDNS.
- `_list_existingchallenges` regex-scrapes the zone-detail JSON: within each
rrset object, `"name"` must appear **before** `"records"`, names must be
FQDN with trailing dot, and TXT contents must be quoted
(`"content":"\"tok\""`). Use ordered Go structs, never maps.
- `PUT /zones/{id}/notify` is called after every PATCH and must succeed.
## 4. Mapping table (every handler implements exactly this)
Notation: `zone` = canonical zone FQDN with trailing dot; `rel(name)` =
lowercased rrset name with the zone suffix and trailing dot stripped (apex →
`''`); `unq(c)` = TXT content with one leading and one trailing `"` removed;
`q(t)` = `"` + t + `"`. `{p}` = `TABLE_PREFIX` (default `coredns_`).
### GET /api/v1/servers/{sid}/zones
```sql
SELECT DISTINCT zone FROM {p}records ORDER BY zone;
```
`[{"id":"<zone>","name":"<zone>","url":"/api/v1/servers/{sid}/zones/<zone>"}]`
(zone already carries the trailing dot; emit as-is, plus `kind: "Native"` for
shape parity).
### GET /api/v1/servers/{sid}/zones/{zone_id}
Canonicalize `zone_id` (URL-decode, lowercase, ensure trailing dot). Then:
```sql
SELECT name, ttl, content, record_type FROM {p}records WHERE zone = ?;
```
Zone unknown (0 rows) → 404. Group rows by `(name, record_type)` → one rrset
each: `name` = `rel==''` ? zone : `rel + "." + zone`; `ttl` = the rows' ttl
(first non-NULL, else plugin default 300); `records[i].content` per type:
| record_type | rrset content |
|---|---|
| TXT | `q(content.text)` |
| A / AAAA | `content.ip` |
| CNAME / NS | `content.host` |
| SOA | `content.ns + " " + content.mbox + " 0 " + refresh + " " + retry + " " + expire + " " + (content.ttl or 300)` (serial not stored; emit 0) |
| other/unparsable | raw `content` string (never omit the row) |
`records[i].disabled` = `false` always. Response: `{"id","name","kind":"Native","url","rrsets":[...]}` with struct field order `name`, `type`, `ttl`, `records`.
### PATCH /api/v1/servers/{sid}/zones/{zone_id} (TXT via acme.sh; generic for others)
For each rrset, canonicalize `name``rel`. All statements for one PATCH run
in **one transaction**; any error rolls back everything.
`changetype: REPLACE` (requires `type`, `ttl` ≥ 0, ≥1 record):
```sql
DELETE FROM {p}records WHERE zone = ? AND name = ? AND record_type = ?;
-- then, one INSERT PER RECORD (multi-value = multiple rows, §2):
INSERT INTO {p}records (zone, name, ttl, content, record_type)
VALUES (?, ?, ?, ?, ?);
```
TXT insert content = `{"text": unq(api content)}` (JSON-marshalled, so inner
quotes/backslashes stay valid JSON). A/AAAA → `{"ip": c}`; CNAME/NS →
`{"host": c}`. TTL: from rrset; if absent/0 use `DEFAULT_TTL` env (120).
`changetype: DELETE` (no `ttl`/`records` required):
```sql
DELETE FROM {p}records WHERE zone = ? AND name = ? AND record_type = ?;
```
Success → `204 No Content` (empty body). Unknown zone → 404. Malformed
(unknown changetype, empty/missing name or type, REPLACE without records) →
`422` with PowerDNS error body.
### PUT /api/v1/servers/{sid}/zones/{zone_id}/notify
No-op (CoreDNS reads the DB live). Zone must exist (else 404). Return
`200` + `{"result":"Notification queued"}`.
## 5. Cross-cutting
- Auth: every `/api/v1/*` route requires `X-API-Key` equal to `PDNS_API_KEY`;
otherwise `401` + error body. `/healthz` is unauthenticated.
- Error body, PowerDNS shape: `{"error":"<message>"}` (400/401/404/422/500).
- Unknown `{sid}` (≠ `PDNS_SERVER_ID`) → 404.
- All responses `Content-Type: application/json` (204 has no body).
- The apex TXT case (`name == zone`) maps to `rel = ''` — same SQL applies.
- ACME tokens are base64url (`A-Za-z0-9_-`), no escaping hazards; the JSON
marshaller handles anything else.