You've already forked coredns-powerdns-api-wrapper
first commit
This commit is contained in:
151
docs/EVIDENCE.md
Normal file
151
docs/EVIDENCE.md
Normal 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
146
docs/SEMANTICS.md
Normal 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.
|
||||
Reference in New Issue
Block a user