Concepts
Configuration
Snapshots, plan, apply, rollback — and what "there is no partial apply" buys you.
Editing configuration and publishing it are separate actions. Adding a DNS record writes a row in Postgres and changes nothing about what visitors get. Serving happens from a snapshot: one immutable value holding every zone on the platform — records, rulesets by phase, cache policy, pools, TLS hosts, functions, lists and resolved plan ceilings. A node holds exactly one and swaps the whole thing atomically, so a request is answered by version 41 or by version 42, never by a mixture. Hence apply is one insert plus one activation, rollback is activating an older row, and there is no partial apply because there is no state in between.
The lifecycle
| Stage | What happens |
|---|---|
| build | Reads the database into one ConfigSnapshot using a fixed number of bulk queries regardless of zone count, then groups in memory. A per-zone builder would be N+1 per table, and an apply would become an outage of its own making. |
| validate | Part of the build, not a later pass. Rulesets are type-checked, each phase is held to a complexity budget, pools are checked for having somewhere to send traffic. |
| sign | JSON, zstd at level 3, then Ed25519 over the compressed bytes. The result carries payload, signature, key id, version and content hash. |
| apply | Deactivate whatever is live and insert the new row with activated_at = now(), inside one transaction. |
| rollback | Verify a stored snapshot, then activate it. No table but the pointer moves. |
| prune | Delete inactive rows outside a retention window. The live row is never deleted. |
Validation belongs to the build, not the edge: a configuration that cannot work should be a message from gdn apply, not a node that starts refusing requests. A rule referencing a list nobody defined, a pool whose every origin is disabled, a load balancer pointing at another zone's pool — each stops the build, and all of them are reported together, because fixing them one attempt at a time is how somebody gives up. The answer is 422, not 400: the request was well-formed and what it described cannot be built.
$ gdn plan
+ lab.amthalgroup.com
~ amthalgroup.com
dns + TXT _dmarc.amthalgroup.com → v=DMARC1; p=none ttl 300
! rules 4 → 5 rules in the custom phase
settings trace sampling 1.00% → 100.00%
4 changes. Lines marked ! can interrupt traffic.
Pass --accept-disruption to apply this.The diff is computed over meaning, not over JSON, which would bury the line that matters under a reordered map and a re-serialised float. Rulesets are compared by rendered text, so two rules differing only in a timestamp are the same rule; records are keyed by name, type, content, proxied flag and effective TTL, so one recreated with a new id is unchanged. The ! marker is applied narrowly — a DMARC record is not marked, because marking it would train people to ignore the marker.
Content addressing, and what the hash leaves out
Every snapshot carries a content_hash: blake3 over a canonical serialisation of the zones, hex. Canonical means they are collected into a BTreeMap keyed by the zone id's string form first, so hash-map iteration order cannot change the result. The database enforces the shape with a check constraint, ^[0-9a-f]{64}$.
Signing over the bytes that arrived
A node accepts its entire configuration over a network, so a forged snapshot is not a bug — it is compromise of every zone on that node at once, a redirect of anybody's traffic to anybody's origin. The control plane signs with Ed25519 and the node verifies against a public key baked into its own configuration. Ed25519 rather than RSA or ECDSA-P256: 64-byte signatures, 32-byte keys, deterministic, so there is no nonce to get wrong and leak a key.
There is no allow_unsigned flag. An empty signature is refused in words — “there is no mode in which an unsigned snapshot is accepted” — because a flag like that exists to be set during an incident and never unset. Verification is ordered so a malformed or hostile artefact costs as little as possible:
- an empty signature is refused outright;
- a payload over 512MB is refused before decompression — the threat is not a bomb from something holding the signing key, it is a bug producing a snapshot the size of the disk;
- the key id must be one this node trusts, and the error names it and the trusted set;
- the signature must decode as 64 bytes of hex and verify against the payload;
- only then is the payload decompressed, parsed, and put through its own
verify(); - finally the envelope's
versionandcontent_hashmust equal the payload's.
That last step exists for one attack: re-labelling a genuinely signed old snapshot as a newer version. The signature still verifies, since the payload was not touched, but the node would believe it was current and stop pulling — pinned to last week's origins. Both fields sit outside the payload so a node can decide whether to decompress at all, which is why both must be checked against the inside.
What verify() checks before a node serves
- The wire format is exactly the one the node understands.
SNAPSHOT_FORMATis2. A higher one is refused with “upgrade the node before the control plane”; a lower one is refused too, because a format this build hashes differently is one whose integrity it cannot check. - The recomputed content hash matches the declared one, when a hash is present.
- No zone is filed under the wrong key —
zones[id].zone.idmust equalid, or a hostname lookup could return another tenant's configuration.
Keys, ids and rotation
| Variable | What it holds |
|---|---|
| GUARDYN_CONFIG_SIGNING_KEY | The control plane's 32-byte Ed25519 seed, hex — 64 characters. What production uses, because it comes from a secret manager and never touches a backed-up disk. |
| GUARDYN_CONFIG_PUBLIC_KEYS | A node's trust set: comma-separated hex public keys. Empty is an error, not an empty trust set that rejects everything. |
| GUARDYN_DATA_DIR | Where a development key file lives. Defaults to .data; the file is config-signing.key, written 0600. |
A key id is derived rather than chosen: blake3 of the public key, first 16 hex characters. Two keys can therefore never share an id, and it is short enough to read in a log line. It rides in the envelope and in the x-guardyn-config-key-id header, which is what lets a node hold several trusted keys and still know which to check against.
GUARDYN_CONFIG_PUBLIC_KEYS is a list because a rotation needs a window in which both keys are accepted; otherwise rotating means updating every node in the same instant as the control plane. Add the new public key everywhere, switch the signing key, apply once, then drop the old one. gdn keys show prints the local public half and never generates, because a read that silently creates a key means the answer changes the first time you ask.
$ gdn keys generate A new configuration signing keypair. key id 8f2c1ab30d4e57f9 public 3f1c… (64 hex characters) secret 9a04… (64 hex characters) The secret is shown once and is not stored. Control plane: GUARDYN_CONFIG_SIGNING_KEY=9a04… Every edge node: GUARDYN_CONFIG_PUBLIC_KEYS=3f1c…
A snapshot is global, which is why applying is operator-scoped
One snapshot is active for the whole platform, enforced by the database rather than by application code: snapshot_one_active is a partial unique index on (activated_at IS NOT NULL). Two concurrent activations would leave “what is serving traffic” with no answer. Versions come from edge.config_version_seqrather than max(version) + 1, because two applies reading the same maximum would pick the same number and one insert would fail on the unique index after all its work was done.
The consequence is the point: since a snapshot covers every zone, applying one publishes every tenant's pending changes, not only yours. A customer editing their own DNS must not be able to do that, so editing is per-customer and publishing is not — separate permissions rather than one permission with a comment. Non-operators get the same 404 route from every operator endpoint as from a path that does not exist, so probing does not confirm the console's existence.
| Endpoint | Who may call it |
|---|---|
| GET /v1/config | Any signed-in customer. What is live, and whether their saved configuration differs from it. |
| POST /v1/config/plan | Operator. Diffs live against what the database would build. |
| POST /v1/config/apply | Operator. Publishes a new version. |
| POST /v1/config/rollback | Operator. Activates an earlier version. |
| GET /v1/config/history | Operator. limit defaults to 25 and is clamped to 200. |
| GET /v1/node/config | A node, by bearer token — not a session, because a node has no user. |
| POST /v1/node/ack | A node, by bearer token. Reports what it is serving. |
So a customer cannot see the diff; what they get is GET /v1/config, which answers the question the endpoint exists for — “why is my DNS change not live yet” — by building a fresh snapshot and comparing its content hash to the live row's.
{
"live": {
"version": 42,
"content_hash": "18e29888782667dc0d3a9b41f5c7e6b2a4d80f19c3e5b7a1d2f4068ac9b1e37d",
"created_by": "you@example.com",
"message": "block wordpress scanners",
"zones": 3,
"bytes": 20416,
"key_id": "8f2c1ab30d4e57f9",
"activated_at": "2026-08-27T09:14:02.881Z",
"created_at": "2026-08-27T09:14:02.774Z"
},
"has_unpublished_changes": true,
"message": "Your saved configuration differs from what is being served. It goes live on the next apply.",
"workspace": "Amthal Group"
}Apply decides on the content hash, not on the rendered plan
An apply builds a preview against the live version number, diffs it, and only then decides. Building against the live number means a no-op consumes nothing: taking a version from the sequence and then finding there was nothing to do leaves a hole in a monotonic sequence, which is the kind of thing somebody spends an afternoon investigating. If the preview's content hash equals the live row's, the answer is { "applied": false, "reason": "no_changes" }.
A disruptive plan without accept_disruption answers 409 with the plan in the message, so an interruption is never a surprise. Once a version is taken the snapshot is rebuilt with it rather than having the preview's metadata mutated, so version and hash are consistent by construction; the sealed artefact is then verified against the control plane's own key before storage, because a signing key that has silently gone wrong would otherwise be discovered by a node refusing every config, which points at the network. gdn apply prints the plan first and --dry-run stops there; both apply and rollback write an audit entry.
$ gdn apply -m "raise trace sampling while we debug"
~ amthalgroup.com
settings trace sampling 1.00% → 100.00%
1 change
✓ applied v43 — 3 zone(s)
edge nodes pick it up on their next pollRollback
gdn rollback with no argument walks the live snapshot's recorded parent_version rather than the numerically previous version, because versions are global across every kind of change and the number below yours may belong to a snapshot that was never activated. The target is verified before activation: a stored row could have been written by a build with a different signing key, and activating one a node will then refuse means the platform keeps serving the old configuration while the control plane believes it has rolled back.
The node protocol
Nodes poll; the control plane does not push. A push would need the control plane to hold a connection to every node and retry the unreachable ones, which makes its availability the platform's availability. A poll makes each node responsible for its own currency, so a control plane that is down means configuration stops changing rather than traffic stopping.
- The node calls
GET /v1/node/config?have=Nwith the version it serves;have=0means none. 204means current — the common case by an enormous margin, since a node polls every five seconds by default and configuration changes rarely, so the answer carries no body, onlyx-guardyn-config-version. Nothing published yet is also204, without the header: a404would suggest the node was wrong to ask.200carries the payload asapplication/octet-stream, with signature, key id, version and hash in headers. The node verifies before installing.- The node then calls
POST /v1/node/ackwith what it now serves — or, on failure, with what it is still serving plus the error, which is logged aterrorand audited asconfig.node_rejected. A node that cannot apply a configuration means the platform is serving two, and nobody would otherwise know.
| Header | Carries |
|---|---|
| x-guardyn-config-version | The live version. Present on 200 and on a current 204. |
| x-guardyn-config-signature | The Ed25519 signature over the body, hex. |
| x-guardyn-config-key-id | Which key signed it. |
| x-guardyn-config-hash | The payload's content hash, to cross-check the envelope. |
A failed poll is never fatal: the node warns, keeps serving, and backs off by doubling from the poll interval up to 60 seconds — capped, because a node backed off to ten minutes takes ten minutes to notice the control plane is back. It also refuses anything older than what it holds, since installing a stale configuration merely because it arrived most recently is how a node ends up serving last week's origins.
versions_behind
GET /v1/nodes reports versions_behind: the newest snapshot version minus the version that node last confirmed. Zero means current. null means it has never acknowledged anything — a different state from being up to date, and where a node sits before its first pull, so reporting zero would make it look healthy. It measures against the newest row rather than the active one, so it also reads non-zero after a rollback.
What is not finished
Command detail for plan, apply, rollback, history and keys is in the gdn CLI; every endpoint the CLI drives, and what authorises it, is in the HTTP API.