Skip to content

Command line

The cdn binary has two halves. The client commands talk to a running service over HTTP and need nothing but a URL and a token. The server commands talk to the database and the object store directly, and are run where DATABASE_URL and the S3 settings are set.

cdn --help

Commands that act on a resource read verb noun: cdn create zone, cdn list org, cdn set quota. The verbs stay a closed set however many resources arrive, and cdn create --help lists what there is to create. Everything else is an action with no object to name: cdn migrate, cdn sweep, cdn login, plus the object commands ls, put and rm, which mean what they already mean to anyone holding a terminal.

Client commands

cdn login stores the address and the token in ~/.config/cdn/config.json, so the rest take neither. CDN_URL and CDN_TOKEN override it, which is what a CI job should use.

A server with a private certificate

A dev deployment usually has a self-signed certificate, and the client refuses it the way any HTTP client would:

httpx.ConnectError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate

Point the client at the certificate. For one command, the standard variable works:

SSL_CERT_FILE=/path/to/cert.pem cdn ls assets

To set it once, store it with the credential:

cdn login https://cdn.example.com "$CDN_TOKEN" --ca-bundle /path/to/cert.pem

CDN_CA_BUNDLE overrides the stored path, the way CDN_URL and CDN_TOKEN do. Fetch the certificate the server is actually presenting with:

openssl s_client -connect cdn.example.com:443 </dev/null 2>/dev/null \
  | openssl x509 > cert.pem

Either way this replaces the trust store: a client pointed at a dev certificate stops trusting public CAs. Use a separate config file, or the environment variable, if the same machine talks to both.

cdn login <url> <token> store a credential for this machine
cdn ls <zone> [prefix] list objects
cdn put <zone> <file> [path] upload one file
cdn rm <zone> <path> delete one object
cdn sync <zone> <dir> upload a directory, skipping what already matches
cdn url <zone> <path> print an object's URL
cdn purge <zone> [paths…] drop cached copies
cdn sync assets ./dist --delete      # remove objects the directory lacks
cdn sync assets ./dist --dry-run     # say what would happen, do nothing
cdn url assets reports/q3.pdf --expires 2h
cdn purge assets --prefix img/
cdn purge assets --all

sync compares size and MD5 against what is already stored, so a second run over an unchanged tree transfers nothing. Durations are 90, 30m, 2h, 7d.

Server commands

Schema and workers

cdn migrate bring the database up to the latest schema
cdn sweep --interval 300 evict least-recently-read files past the high-water mark
cdn rollup --interval 3600 fold hourly statistics into daily, prune the audit log
cdn reconcile [zone] [--repair] compare the index against the object store

The two workers loop on an interval, because the platform they run on supervises processes and has no scheduler. reconcile is one-shot and exits non-zero when something disagrees, which makes it a reasonable nightly job.

Provisioning

cdn provision --org <slug> --email <address> an organisation, an owner and a first zone; idempotent
cdn create org <slug> <name> create an organisation
cdn create zone <slug> create a storage zone
cdn create zone <slug> --kind pull --origin <url> create a zone mirroring an origin
cdn create user <email> <name> create a web UI account
cdn create token <name> mint a token
cdn list org\|zone\|user\|token show what exists
cdn show token <name\|key id> one token in full, --s3 for its S3 credential
cdn delete zone\|org\|user <slug> remove it, and say what went too
cdn set quota org\|zone <slug> <size> cap how much may be stored
cdn create user you@example.com "Your Name" --role owner
cdn create token "release uploads" \
    --zone acme-assets --prefix releases/ --scopes write
cdn create token "rclone" --scopes read,write,delete --s3
cdn set quota org acme 100G
cdn set quota zone acme-video none      # lift it again

Which organisation

Every command above takes --org <slug>, and none of them needs it while there is only one organisation, which is every deployment until a second tenant arrives. With two or more, the choice has to be made:

$ cdn create zone assets
more than one organisation: name it with --org (`cdn list org`)

Organisations are the operator's, not the tenant's, so they appear on no web page. cdn list org is where a slug comes from. Any command given a slug that is not there answers with the ones that are:

$ cdn create zone libera-web --org libra
no organisation with slug 'libra'; there is: acme, libera

A zone, by contrast, needs no slug looked up: the web UI creates zones inside whichever organisation the signed-in account belongs to, which is the ordinary way to make one.

Getting a credential back

A token is printed once and stored as a hash, so the bearer credential cannot be recovered: a token nobody wrote down has to be replaced.

The S3 credential can be, because it was never stored either. It is derived from CDN_SECRET_KEY and the token's key id, which is what lets SigV4 be verified without this service holding a reversible copy of anything, and recomputing it is the same arithmetic as minting it:

$ cdn show token ci --s3
ci
  key id        znsnzc3zcrdf
  organisation  abilian
  zone          libera
  ...
  S3 access key id   znsnzc3zcrdf
  S3 secret key      9123452c75c5189a38d3b8fe63705beee5b2cd425c691ef426bd76ce7df5887f
  S3 endpoint        https://cdn.abilian.com/_/s3

Without --s3 no credential is printed, so a casual show cannot splash a working secret into a terminal. Names are not unique (two organisations may each have a token called ci), so a shared name is refused with both key ids.

Because the S3 secret is derived from CDN_SECRET_KEY, rotating that key invalidates every S3 credential at once, exactly as it invalidates every session cookie. That is why hop3.toml mints it once and never regenerates it.

Deleting

Nothing is deleted by surprise. delete zone refuses a zone that still holds objects, delete org refuses one that still holds zones or accounts, and delete user refuses the last owner of an organisation, because an organisation with no owners has nobody who can invite one, and the only way back in is this command line. --force overrides each, and says what it took:

$ cdn delete zone assets
zone assets still holds 412 objects; delete them first, or pass --force to delete them with it

$ cdn delete zone assets --force
zone assets deleted
  and 412 objects
  and 2 tokens
  and 1899 statistics rows

Two things happen without being announced. A token restricted to the deleted zone is deleted with it: a null zone_id means "every zone of this organisation", so clearing it would turn a key scoped to one zone into a key over all of them. The audit log keeps its entries, with the zone reference cleared; it is append-only and outlives what it describes, which is why an entry records a name as well as an id.

provision is the one command that is safe to repeat: it creates what is absent, leaves what is there, and never rewrites an existing account's password. --check reports what is missing and exits non-zero without changing anything, which is what a platform's verify step runs. The create commands are the opposite on purpose: they refuse, so a mistyped slug is an error you see immediately.

create user generates and prints the password unless one is given, because a password typed on a command line is in the shell history of whoever ran it. create token prints the secret once; only a hash is stored. --s3 additionally prints what to configure an S3 client with.

Scopes are read, write, delete, purge and stats, comma-separated; the default is read,write. --prefix restricts the token to paths under it, for reads and listings as well as writes.