Files
coredns-powerdns-api-wrapper/docs/SEMANTICS.md
2026-07-10 23:53:43 +02:00

7.0 KiB

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)

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

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:

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 namerel. All statements for one PATCH run in one transaction; any error rolls back everything.

changetype: REPLACE (requires type, ttl ≥ 0, ≥1 record):

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):

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.