Reference
The gdn CLI
Every command, and the two that exist because a dashboard is the wrong shape for the job.
The binary is gdn rather than guardyn-cli, for the unglamorous reason that it gets typed dozens of times a day. It speaks to the control plane over the same HTTP API the dashboard uses, and holds no state beyond a session cookie. Almost everything it does, the dashboard also does; the exceptions are why it exists — publishing a configuration, and generating the key that signs one.
The shape of every invocation
Two flags are global, which in clap terms means they are accepted before or after the subcommand and apply to all of them.
| Flag | Effect |
|---|---|
| --host <url> | The control plane to talk to. Falls back to GUARDYN_API_URL, then to http://127.0.0.1:8787. A trailing slash is trimmed, so the value can be pasted from a browser. |
| --json | Print the response as pretty JSON instead of rendering it as a table. |
| --version | The crate version, currently 0.1.0. |
| --help | Per command, as gdn apply --help. There is no gdn help apply: the help subcommand is disabled, because two ways to ask the same question is one too many. |
The human renderer aligns columns by display width, draws no boxes, and emits no colour when stdout is not a terminal or when NO_COLOR is set. Errors go to stderr and the process exits 1; anyhow's default rendering is bypassed, because it puts Error: and a backtrace hint in front of a message written for a person.
$ gdn plan error cannot reach the control plane at http://127.0.0.1:8787. Start it with `cargo run -p guardyn-api`, or point at another one with --host.
Signing in, and where the session goes
gdn login takes --email and --totp; omit the email and it is prompted for. The password is read from GUARDYN_PASSWORD, otherwise prompted for without echo. A --password flag exists but is hidden from --help, because an argument lands in your shell history and in the output of ps. Echo suppression shells out to stty — the alternative is a dependency whose whole job is two ioctl calls, and this workspace denies unsafe.
$ gdn login Email: ops@example.com Password: ✓ signed in to http://127.0.0.1:8787 as ops@example.com session stored in /Users/you/.config/guardyn/credentials.toml
That is a POST /v1/auth/login. The CLI reads the guardyn_session value out of the Set-Cookie header — before the body, because a 401 has no cookie and the body carries the reason — and writes it to credentials.toml under $XDG_CONFIG_HOME/guardyn, or ~/.config/guardyn, or wherever GUARDYN_CONFIG_DIR points, chmod-ed to 0600. Inside is a hosts table keyed by base URL, each entry carrying a session and an email.
A corrupt credentials.toml is an empty set rather than an error, because a CLI that refuses to run over a stray character is a CLI somebody deletes. With a second factor, sign-in fails with code totp_required. gdn logout posts to /v1/auth/logout then removes this host's entry; the revoke is best effort, because a network failure must not stop the local file being cleared.
gdn status
Both halves of “where am I”: who the session belongs to, and what is being served. It reads /v1/auth/me and /v1/config, tolerating the second failing — somebody who cannot see the live snapshot should still be told who they are.
$ gdn status
http://127.0.0.1:8787
ops@example.com — Amthal Group (owner, free), platform operator
live: v41 — 3 zone(s), applied by ops@example.com 4h ago
"block wordpress scanners"
! Your saved configuration differs from what is being served. It goes live on the next apply.The role is one of viewer, analyst, admin or owner. The platform operator suffix appears only when is_platform_admin is true, and it is worth noticing: it is what the next four commands require.
plan, apply, rollback, history
The commands the CLI was written for. A configuration you cannot diff before publishing is a configuration you publish nervously, and what gets published is one signed snapshot covering every zone — which makes “publish” a different act from “save”. The mechanics are in Configuration; what follows is the surface.
| Command | Flags, and what they mean |
|---|---|
| gdn plan | No flags. POST /v1/config/plan. Consumes nothing — no version number is taken — so looking is free, which is the only way people keep looking. |
| gdn apply | -m/--message <text> (defaults to empty, and worth writing), --accept-disruption, --dry-run. |
| gdn rollback | An optional positional version. Without one, the previous snapshot. |
| gdn history | --limit <n>, default 20. The endpoint's own default is 25, which you will only see if you call it directly. |
The plan text is rendered by the server, so the terminal and the dashboard cannot disagree about what a change is called. The CLI only colours it: + green, - red, a ! marker yellow — marks you read without being told, because version control and Terraform use them.
$ gdn plan
~ amthalgroup.com
dns + A www.amthalgroup.com → 203.0.113.10 ttl 300
! rules 3 → 4 rules in the custom phase
settings trace sampling 1.00% → 100.00%
3 changes. Lines marked ! can interrupt traffic.
Pass --accept-disruption to apply this.The second column is the area: status, plan, tls, settings, dns, rules, cache, origins or functions. The diff is computed over meaning rather than over JSON, because a JSON diff turns a reordered map into hundreds of changed lines and hides the one that matters. Rules are compared by rendered text, so two that differ only in a timestamp are the same rule.
gdn apply always fetches and prints the plan first, then applies; --dry-run stops after the plan. An apply whose diff nobody saw is an apply nobody checked.
$ gdn apply -m "block wordpress scanners" --accept-disruption
~ amthalgroup.com
! rules 3 → 4 rules in the custom phase
1 change. Lines marked ! can interrupt traffic.
✓ applied v42 — 3 zone(s)
edge nodes pick it up on their next pollWithout --accept-disruption, a plan carrying any disruptive change is refused with a 409 whose message repeats the diff. Disruptive is decided per change, not per plan, and today it means one of:
- a zone being removed, or its status changing to anything other than active;
- any change to the minimum TLS version; HSTS turned on or newly preloaded; 0-RTT early data enabled; origin TLS moving to a mode that verifies the certificate; a host the edge no longer terminates TLS for;
- caching turned on or off; the cache key changing;
Set-Cookieresponses becoming cacheable; - an origin pool change, or a function being deployed or removed;
- a DNS record added or removed whose name does not begin with an underscore — the underscore test exists so that flagging a
_dmarcchange as traffic-disrupting does not train people to ignore the marker; - a rule change in the
ip_access,managed,custom,rate_limitorbotsphases.
A TTL change, a zone plan change, an HTTP/3 toggle, turning always-HTTPS on or off, letting HSTS lapse, and a transform-phase rule change are all undisruptive: a plan made only of those applies without the flag.
$ gdn rollback ✓ rolled back to v41 — 3 zone(s) "www to the new pool" This changes what is being served, not what is saved. The next apply publishes whatever is in the database now.
That last line prints every time, because it is what people assume wrongly. A rollback activates an earlier snapshot; it does not restore your saved configuration. A site deleted since stays deleted, and the next apply removes it again. Rolling back with no argument when there is no previous version is a 409 telling you to name one.
$ gdn history --limit 3
version zones size by when message
v40 3 12.4kB ops@example.com 6d ago first apply
→ v41 3 12.5kB ops@example.com 2d ago www to the new pool
v42 3 12.9kB ops@example.com 4h ago block wordpress scanners
→ marks the configuration being servedThe arrow sits on v41 rather than the highest version, which is what a rollback looks like afterwards: v42 was built and signed, and is not being served.
zones and dns
Two read-only commands, for checking something without opening a browser. gdn zones lists the sites in the session's workspace. gdn dns <zone> takes a domain name or a zone id: an argument that parses as a UUID is used directly, otherwise the zone list is fetched and matched by name after trimming whitespace, a trailing dot and case.
$ gdn zones
domain status plan records added
amthalgroup.com active free 14 12d ago
example.test pending free 2 just now
Pending sites need their nameservers set to ns1.guardyn.net, ns2.guardyn.net
$ gdn dns amthalgroup.com
type name content ttl
proxied A @ 203.0.113.10 30
proxied A www 203.0.113.10 30
dns only MX @ 10 mx1.example.com 3600
— TXT @ v=spf1 -all 300The status column is the raw value with pending_ns shortened to pending; paused and deleting also occur. The nameserver reminder prints once beneath the table rather than per row, and only when something is genuinely pending. The default pool is ns1.guardyn.net and ns2.guardyn.net, set with GUARDYN_NAMESERVERS.
In the DNS table the apex renders as @ and a subdomain loses its zone suffix, because the zone is in the command you typed; a name that merely ends with the same text is left alone, so notexample.com does not become not. The first column is proxy state — proxied, dns only, or an em dash for a type that cannot carry HTTP. The TTL is the effective one: a proxied record answers with 30 seconds regardless, because the address being served is ours, and an auto TTL unproxied resolves to 300.
keys generate and keys show
The one subcommand group that does no networking. Snapshots are signed with Ed25519 — the control plane holds the secret, every edge node holds the public half — and this is where that keypair comes from.
gdn keys generate
Generates a keypair, prints it, and writes nothing anywhere. The seed comes straight from the operating system rather than a thread-local generator: this key signs the configuration of every zone and is generated once, so there is no reason to accept a reseeding schedule between it and the kernel. The key id is the first eight bytes of the public key's blake3 hash rather than something chosen, so two keys can never collide.
$ gdn keys generate A new configuration signing keypair. key id 4f2c1a9b7e3d5088 public 9d1f…(64 hex characters) secret 3ab0…(64 hex characters) The secret is shown once and is not stored. Control plane: GUARDYN_CONFIG_SIGNING_KEY=3ab0… Every edge node: GUARDYN_CONFIG_PUBLIC_KEYS=9d1f… Nodes accept a comma-separated list, so a rotation can have a window in which both the old and the new key are trusted.
gdn keys show
Prints the key id and the public half of the key this machine would sign with, plus the GUARDYN_CONFIG_PUBLIC_KEYS line to paste into an edge node's environment. It resolves the key exactly as the control plane does:
GUARDYN_CONFIG_SIGNING_KEY, a 32-byte hex seed. What production uses, because it comes from a secret manager and never touches a disk anybody backs up.config-signing.keyunder the data directory —$GUARDYN_DATA_DIR, defaulting to.datarelative to where you ran the command. What development uses.- Nothing.
gdn keys showstops and reports the error, with the instruction to set the variable or generate a key.
Because the environment variable is checked first, running keys show with GUARDYN_CONFIG_SIGNING_KEY set tells you about that key, not the file on disk. keys generate ignores both sources: it is not asking, it is minting.
Environment variables gdn reads
| Variable | Read by, and for what |
|---|---|
| GUARDYN_API_URL | The control plane, when --host is absent. |
| GUARDYN_SESSION | A session value that wins over the stored file. For CI, where there is no interactive login and no config directory worth keeping. |
| GUARDYN_CONFIG_DIR | Where credentials.toml lives, overriding the XDG lookup. |
| XDG_CONFIG_HOME, HOME | The fallback chain for that directory: $XDG_CONFIG_HOME/guardyn, else $HOME/.config/guardyn. |
| GUARDYN_PASSWORD | The password for gdn login, instead of prompting. Preferable to a flag, because it does not reach your shell history. |
| GUARDYN_CONFIG_SIGNING_KEY | The signing seed keys show prefers, ahead of any key file. |
| GUARDYN_DATA_DIR | Where keys show looks for config-signing.key. Defaults to .data. |
| NO_COLOR | Set to anything and no ANSI escapes are emitted, ever. |
Requests carry a 30-second timeout and a gdn/0.1.0 user agent. The point of a limit is that a hung request stops looking like a hung CLI.
Why any of this is a CLI
Most of the platform is better in a browser. A DNS table wants to be a table; a firewall rule wants an editor that tells you a field name is wrong before you save. Two things are not like that.
- Publishing a configuration. What makes one irreversible-feeling act on a global artefact safe is reading a diff first. A diff is text, text belongs in a terminal, and the command that shows it to you is the command a pipeline runs —
--dry-runon a pull request,--jsonfor whatever posts the result. A button cannot go in a script, and a web form cannot be reviewed next to the change that caused it. - Holding a signing key.
keys generateprints a secret once, on your terminal, having never written it down. In a dashboard that secret would travel through a web server, a template, a browser and probably a request log on its way to your eyes. The safest place to mint a key is the machine of the person about to paste it into a secret manager, and that machine has a shell on it.