Concepts

DNS

Authoritative records, what the proxied flag actually changes, and the TTL you do not control.

A record here is an authored record, not a wire record. hickory-proto owns the wire format and this project does not duplicate it. What guardyn_core::dns::DnsRecord holds is what a person typed, plus the two things a wire record cannot express: whether the record is proxied, and how it should steer.

That distinction is load-bearing, because DNS in a CDN has a second job. Records decide what a resolver is told, and — for proxied records — they also decide which hostnames the proxy is willing to answer for at all. Both jobs read the same list out of the same signed configuration snapshot, which is why ZoneConfig.records is carried to the data plane even though the data plane is not a nameserver.

The record types, and the single line you type

Fourteen types are supported, and each one is parsed into its own variant of RecordData at authoring time rather than stored as an opaque string. The reason is blunt: half the bugs in DNS management UIs are a malformed MX or a CAA with the flags in the wrong field, and a content: text column guarantees the authoritative server will eventually have to reject its own configuration while answering a query. Parsing once, in the control plane, means that never happens.

TypeWhat you typeStored fields
A203.0.113.7address
AAAA2001:db8::1address
CNAMEtarget.example.nettarget — not at the apex
ALIASorigin.example.nettarget — apex only
MX10 mx1.example.netpriority, exchange
TXTv=spf1 include:_spf.example.com ~alltext, held unsplit
NSns1.example.nettarget — not at the apex
SRV10 5 443 sip.example.netpriority, weight, port, target
CAA0 issue "letsencrypt.org"flags, tag, value
PTRname.example.nettarget
HTTPS1 . alpn=h3,h2priority, target, params
SVCB1 . alpn=h2priority, target, params
TLSA3 1 1 <hex>usage, selector, matching_type, certificate
SSHFP4 2 <hex>algorithm, fingerprint_type, fingerprint

Anything else is refused by name, with the list of what does work, so nobody has to guess: asking for an SPF record answers that it is not a type this platform supports and points at TXT. The parser is also deliberately forgiving about the forms people actually paste:

  • MX takes 10 mx1.example.net in the content field, or a bare hostname with a separate priority. Both are how UIs do it, so both work; a hostname with no priority anywhere is the error, and the message shows the shape.
  • TXT strips surrounding double quotes, because a person copying an SPF record out of a zone file brings them along. The 255-byte chunking is a wire concern and is not done here.
  • CAA lowercases the tag and strips quotes from the value. Only issue, issuewild, iodef and issuemail pass validation.
  • HTTPS and SVCB split the remainder into key=value parameters. A parameter with no = is dropped rather than rejected, so a valueless key such as no-default-alpn does not survive the round trip.
  • Every target goes through one hostname check: trimmed, lowercased, trailing dot removed, and it must contain a dot. localhost is therefore rejected as a CNAME target.
adding a proxied record
POST /v1/zones/{zone_id}/dns

{
  "name": "www",
  "type": "A",
  "content": "203.0.113.7",
  "proxied": true,
  "comment": "the marketing site"
}

name may be relative (www), absolute (www.example.com), or @ or empty for the apex; all three resolve through one function, records::absolute_name, shared by the API, the CLI and anything else that writes a record — three implementations of “does this name already end in the zone” would be three chances to produce www.example.com.example.com. Stored names are always fully qualified, lowercase and without a trailing dot, and the apex is the zone name itself rather than @: @ is a zone-file convention, and storing it would force every consumer to know the zone in order to read one row. comment is truncated to 500 characters, because it is free text from a form.

The proxied flag, and exactly what it changes

This is the whole trick of a CDN’s DNS. An A record marked proxied does not answer with the customer’s address at all. It answers with ours, and the address the customer typed becomes origin configuration. Because that is a substitution rather than a setting, the authoritative server and the proxy must agree exactly on which records are proxied — which is why the flag lives on the record and travels in the same snapshot both of them read.

Only four types can carry HTTP, so only four are proxiable: A, AAAA, CNAME and ALIAS. The flag on anything else is refused at authoring time by validate, and — belt and braces — DnsRecord::is_proxied() is the conjunction of the flag and RecordData::is_proxiable(), so a stray true on a TXT row is ignored rather than honoured. Honouring it would mean answering a TXT query with an IP address.

Which hostnames the edge will serve

ConfigSnapshot::reindex builds the host index, and it admits three things per zone: every pattern in tls_hosts (wildcards going to a separate list, sorted longest-suffix-first so *.api.example.com beats *.example.com), the name of every record where proxied && is_proxiable(), and the zone apex unconditionally. The apex is unconditional because a zone in pending_ns has no delegation and could otherwise not be tested at all — it is reachable by sending its Host header at a node, which is the only way to check a configuration before cutting the nameservers over.

The same index answers the TLS resolver, through ConfigSnapshot::served_hosts(), and that function exists because the divergence was a live bug: the edge rebuilt its own list from tls_hosts plus the apex, left every proxied record out, and https://lab.example.com failed the handshake with access denied while http://lab.example.com served fine. A hostname the edge will not issue a certificate for is a hostname that cannot be reached over https.

Auto, and why a proxied record’s TTL is not yours

Ttl has two shapes on the wire: the string "auto", or a number of seconds. Auto resolves to 300 seconds for an unproxied record and 30 seconds for a proxied one. An authored value is clamped to between 30 seconds and one day: below 30 the query load stops being worth it, and above a day a mistake outlives the person who made it. The column enforces the same range as a CHECK, and auto is stored as NULL rather than a sentinel so a query summing TTLs cannot accidentally include it.

For a proxied record the authored TTL is discarded outright — resolve returns 30 whatever was asked for. That looks high-handed until you name the failure it prevents: the address being served is one of ours, and a 24-hour TTL on it would pin traffic to a node we need to drain. The dashboard disables the TTL selector while the proxy toggle is on and says so in the tooltip, rather than accepting a value and silently overriding it.

CNAME at the apex, and the two halves of RFC 1034 §3.6.2

A CNAME at the zone apex is illegal, and every CDN solves it the same way: resolve the target and answer with A/AAAA. Here that behaviour is a separate type, ALIAS, rather than “a CNAME we happen to treat differently at the apex”. Naming it makes the illegal state unrepresentable: CNAME at the apex is rejected with a message pointing at ALIAS, and ALIAS below the apex is rejected pointing back at CNAME.

The exclusivity rule has two halves and they are enforced in two different places, on purpose. “At most one CNAME or ALIAS per name” is a partial unique index, dns_one_cname_per_name on (zone_id, name) WHERE type IN ('CNAME', 'ALIAS'), so no code path can bypass it — including a bulk import written later by somebody who never read this page. “Nothing else may share a name with one” cannot be expressed as an index, so it is a counting query run inside the same transaction as the insert, which is what stops two concurrent creates from both passing it. Either way the error names the RFC and tells you what to remove.

Validation reports every problem at once

DnsRecord::validate returns a Vec<String>, not the first error, because a form that reports one problem per submit is six round trips. The store’s RecordInput::validate calls straight through to it rather than repeating the rules, because two implementations of what is legal would drift. It checks:

  • the name is inside the zone, by is_in_zone, which requires the dot: evilexample.com is not inside example.com even though it ends with it;
  • 253 characters for the whole name, 63 for any single label;
  • the proxied flag against is_proxiable;
  • CNAME not at the apex, ALIAS only at the apex;
  • TXT content under 4096 bytes, which is where it stops fitting a UDP answer;
  • the CAA tag against the four known ones;
  • NS at the apex refused outright — the apex NS records are the delegation, and they belong to the platform;
  • an explicit TTL of at least 30 seconds.

Platform-managed records

A record can carry managed_by, set when the platform wrote it rather than a person. Both the API and the store refuse to update or delete one, naming the subsystem that owns it: an ACME challenge record a customer can delete is a certificate that stops renewing for reasons nobody can find. upsert_managed is keyed by name and type, so a retried certificate order cannot leave two challenge records.

Zone statuses

A zone’s status decides whether the data plane serves it and whether proxying is in effect, and those are two different questions.

Statusserves_httpproxiesWhat it means
pending_nsyesnoCreated and fully editable; the registrar does not point here yet. Reachable by sending the Host header at a node, which is how a configuration gets tested before cutover.
activeyesyesDelegation verified, serving traffic.
pausedyesnoThe escape hatch for "your CDN is breaking my site": DNS answers with the origin’s real address so traffic bypasses us, without deleting the zone and losing its configuration.
suspendednonoStopped by an operator. Intended to answer SERVFAIL rather than NXDOMAIN, so a resolver keeps the delegation and the fix is instant.
deletingnonoScheduled for deletion, retained briefly so an accident is recoverable.

Only active and paused can be asked for through the API, and the store enforces a transition table on top of that: pending_ns may move to either, active and paused may swap, and any of the three may move to deleting, which is what removing a zone does (a soft delete, so an accident is recoverable). Nothing moves a zone into suspended; that is an operator action. And note that pausing is a DNS-level bypass, so it takes effect only once the authoritative server exists — see the note at the top.

Reading and publishing

EndpointWhat it does
GET /v1/zones/{zone_id}/dnsRecords, the zone name, and the plan ceiling with the count used
POST /v1/zones/{zone_id}/dnsCreate. Admin role; the ceiling is checked against the count that would result
PUT /v1/zones/{zone_id}/dns/{record_id}Replace. Refused on a platform-managed record
DELETE /v1/zones/{zone_id}/dns/{record_id}Delete. Also refused on a platform-managed record

The ceiling is dns_records_per_zone: 200 on free, 3,500 on pro, 10,000 on business, unlimited on enterprise. Every create and update is written to the audit log with the before and after content and the resolved proxied value, because “who changed that record” is the first question of a DNS incident.

gdn dns example.com
            type   name  content             ttl
  proxied   A      @     203.0.113.7         30
  proxied   CNAME  www   example.com         30
  dns only  A      lab   198.51.100.9        300
  —         MX     @     10 mx1.example.net  300
  —         TXT    @     v=spf1 -all         300

The first column is the state of the proxy, and it shows an em dash for a type that cannot be proxied rather than “dns only”, which would imply a choice was available. The TTL shown is the effective one, so a proxied record reads 30 whether it was authored as auto or as a day.

Records reach the data plane the same way everything else does: a snapshot. A change is a draft until gdn apply publishes it, and gdn plan lists record changes individually, keyed by name, type, content, the resolved proxied flag and the effective TTL — so a record recreated with a new id but the same meaning is not a change anybody has to read. The apex, www, and any name that does not begin with an underscore are marked as traffic-bearing; _dmarc and _acme-challenge are not, because marking a DMARC record as traffic-disrupting trains people to ignore the marker.

turning the proxy on for www
$ gdn plan
  ~ example.com
     ! dns        + A      www.example.com → 203.0.113.7  (proxied)  ttl 30
     ! dns        - A      www.example.com → 203.0.113.7  ttl 300

2 changes. Lines marked ! can interrupt traffic.

Steering

Steering sits on the record — round robin by default, with weighted, geo, failover and load-balancer modes defined — so a customer with two origins in two regions does not have to learn a second concept to get failover. It is stored in the steering column and carried in the snapshot.