You've already forked coredns-powerdns-api-wrapper
147 lines
7.0 KiB
Markdown
147 lines
7.0 KiB
Markdown
# 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.
|