Actions
37 actions, callable from the panel, the command palette and the API as
POST /api/v1/a/<id>. Internal actions used between modules are not listed.
| Action | What it does | Risk | Preview |
|---|---|---|---|
cdn.zone.create | Create a pull or storage zone | medium | |
cdn.zone.update | Change a zone (origin, cache, security, TLS, suspend) | medium | |
cdn.zone.delete | Delete a zone: stop serving, drop its cache and certificate | high | |
cdn.zone.list | List zones (read) | low | No dry run |
cdn.zone.get | Get a zone with its hostnames, rules, settings and origin lockdown header (read) | low | No dry run |
cdn.hostname.add | Add a custom hostname (needs verification before it serves) | medium | |
cdn.hostname.verify | Check the CNAME or TXT record of a custom hostname and activate it | medium | No dry run |
cdn.hostname.remove | Remove a custom hostname | medium | |
cdn.rule.set | Replace the ordered cache rules of a zone | medium | |
cdn.rule.preview | Show which rule and settings a URL would get (read) | low | No dry run |
cdn.purge | Purge cached objects by URL, prefix, cache tag or everything | low | |
cdn.purge.history | Recent purges of a zone (read) | low | No dry run |
cdn.token.rotate | Add a new signing key; the old one keeps validating for the overlap | medium | |
cdn.token.sign | Generate a signed URL (read) | low | No dry run |
cdn.token.snippet | Code that signs URLs in your language (node, python, php, go, ruby, java, dotnet, curl) (read) | low | No dry run |
cdn.stats.get | Requests, bandwidth, hit ratio, status codes, top URLs of a zone (read) | low | No dry run |
cdn.usage.get | Bandwidth used this month against the account limit (read) | low | No dry run |
cdn.api.describe | How to call the CDN from your own code: endpoints, token, examples (read) | low | No dry run |
cdn.edge.list | Edge nodes with health, cache size and what GeoDNS answers with (read) | low | No dry run |
cdn.edge.enable | Make a node an edge: role, nginx stack, config, health, then GeoDNS | high | |
cdn.edge.drain | Stop answering with an edge (existing DNS answers expire by TTL) | high | |
cdn.edge.undrain | Answer with a drained edge again | medium | |
cdn.edge.remove | Remove the edge stack from a node | critical | |
cdn.edge.health | Probe edges now (read) | low | No dry run |
cdn.region.get | The region map (pops, countries, client networks) (read) | low | No dry run |
cdn.region.set | Replace the region map and re-steer GeoDNS | medium | |
cdn.settings.get | Global CDN settings (read) | low | No dry run |
cdn.settings.set | Change one global CDN setting | medium | |
cdn.reconcile | Re-apply edge configuration and GeoDNS from the stored state | low | |
cdn.integration.create | Connect Cloudflare or Bunny with an API token (vault-stored, masked) | medium | No dry run |
cdn.integration.list | Connected external CDNs (read) | low | No dry run |
cdn.integration.test | Validate the token and report its scope | low | No dry run |
cdn.integration.delete | Disconnect an external CDN | medium | |
cdn.integration.zones | Zones or pull zones at the provider, with the capability matrix (read) | low | No dry run |
cdn.integration.purge | Purge at the provider (URL, tag, prefix or everything where supported) | low | |
cdn.integration.setting | Change a cache or development-mode setting at the provider | medium | |
cdn.integration.wire_dns | Point a hostname at the provider (CNAME through dns, or the provider DNS for Cloudflare) | medium |
Permissions and limits
Permissions
cdn.zone.viewView zones, hostnames and snippetscdn.zone.manageCreate and change zones, hostnames and signing keyscdn.rule.manageChange cache rulescdn.purgePurge the cachecdn.stats.viewView CDN statistics and usagecdn.integration.manageConnect and change external CDN integrationscdn.integration.viewView external CDN integrationscdn.edge.adminManage the edge network, region map and settingscdn.edge.viewView the edge network and settings
Plan limits
cdn.zonesCDN zonescdn.hostnames_per_zoneCustom hostnames per zonecdn.bandwidth_gbCDN bandwidth per monthcdn.requests_mCDN requests per month (millions)cdn.cache_gbEdge cache per zonecdn.purges_per_hourPurges per hourcdn.rules_per_zoneCache rules per zonecdn.integrationsExternal CDN integrations
Engineering notes
Generated from modules/cdn/docs.md at build d90e9e2. These are the notes the engineers keep
next to the code: precise, technical, and honest about what is not done yet.
Spec docs/specs/cdn.md; handover docs/tasks/w8/w8-02-cdn.handover.md. Manifest edition: cdn (paid add-on key, see licensing below).
Desired state lives in schema m_cdn (settings, edges, zones, purges, stats_hourly, integrations). Edges are only ever given the
complete rendered configuration (cdngen.Render); apply is idempotent and cdn.reconcile re-applies everything from the database.
How a request flows
client -> GeoDNS (PowerDNS LUA record *.<base domain>) -> nearest healthy edge (nginx proxy_cache) -> origin (site URL or S3 bucket)
- Edge node = a node with the cluster
edgerole and a row inm_cdn.edges.cdn.edge.enableadds the role throughcluster.node.roles, installs nginx.org mainlinenginx+nginx-module-njson a node without nginx (the same build thewebmodule installs); on a node that already has nginx it adds the njs package that matches it (nginx-module-njsfor nginx.org's nginx,libnginx-mod-http-jsfor the distribution's nginx - the wrong one conflicts with the installed nginx in apt) (allow-listed,cdn.edge.install), refuses by name ahttp_port/https_port/status port another program already holds (before installing anything) and never leaves a restarting unit behind when a first start fails, writes a separate nginx instance (rc-cdn-edge.service,/etc/rc-cdn, cache/var/cache/rc-cdn/<zone>, logs/var/log/rc-cdn) so it never touches thewebmodule's nginx (it can share a web node: pick otherhttp_port/https_port), applies the config, requires/healthto answer, and only then adds the node to DNS. A freshly installed distro nginx (default site on :80) is stopped and disabled. - Zone hostname
<zone>.<base_domain>always serves; custom hostnames serve aftercdn.hostname.verify(CNAME to the zone hostname or TXT_rc-cdn.<host>= token). A self-signed certificate is made per zone (stable across applies, re-made when the hostname set changes);cert_iduses the PEM thesslmodule deployed on that edge node (/var/lib/respirecloud/ssl/certs/<id>), falling back to self-signed with a warning. - GeoDNS: one LUA
Arecord*.<base domain>(TTLgeodns_ttl, default 30 s) in the zone of the same name on the node that runs PowerDNS (dns_node, default the control node).cdn.geodns.applyswitchesenable-lua-records=yesandenable-lua-record-updates=yeson in its own drop-inrc-cdn.conf(without the second one pdns refuses every REPLACE of the record) and PATCHes the pdns API; a refused update puts the previous record back. A client in a pop's CIDRs (or countries, withgeoipon and a GeoIP database configured in pdns) gets that pop's edges; all other clients get any healthy edge (pickclosestwith GeoIP, random without).ifportupprobes each edge on its own port and drops dead edges (edges on different ports are probed in one call per port and the answer is picked from the union; in that mixed-port form a port group whose edges are all down is still answered, because pdns cannot tell "all down" from "all up" for a selectorall, so keep one port across edges in production); the per-pop answer is a list of lists (pop first, everybody else second) so a pop with all edges down falls back to the other pops. Draining edges are left out. Names are validated and the Lua is built from a closed grammar (no customer text reaches it). - Cache: key =
<zone>|<decoded path>[?args]; request collapsing (proxy_cache_lock), background update, stale on error/timeout/5xx, per-zone cache directory (purge-all = delete the directory),X-Cache(HIT/MISS/EXPIRED/BYPASS/STALE) andX-Edge(node id) on every response. Rules are ordered (first match wins; path glob*within a segment,**across, or extension list) and set TTL, browser TTL, bypass, query handling (ignore/all), ignoring originCache-Control, response headers; zone-level cookie bypass, gzip, CORS/security headers, allow/deny CIDRs, hotlink protection, HTTP->HTTPS redirect, origin failover (extra URLs arebackupservers), origin host header, custom origin headers, origin timeout.cdn.rule.previewshows which rule a URL hits. - Purge (
cdn.purge): url (exact path, also drops?queryvariants), prefix, tag (originCache-Tag/Surrogate-Key), all. Done by the agent deleting the cache file (nginx treats a vanished file as a miss; exact URLs are found by the md5 of the key, prefix/tag by reading the key and headers stored in each cache file). Runs on every edge in parallel, returns per-edge counts andpartial/failed_edges; rate limit = package limitcdn.purges_per_hour(default 600); each purge is logged with the actor (cdn.purge.history). - Signed URLs:
?expires=<unix>&token=<hex>[&ip=<ip>], token = HMAC-SHA256(secret,path\nexpires\nip), checked at the edge by an njs function (constant-time compare, keys from the zone's config) before the cache is consulted, so HITs are protected too and the token does not enter the cache key. Keys rotate with an overlap (cdn.token.rotate): the old key keeps validating untilnow + overlap_hours.cdn.token.signsigns a URL server-side;cdn.token.snippetreturns signer code in node/python/php/go/ruby/java/dotnet/curl (verified against the Go implementation in tests and, for python, against a real edge in the lab). Signing secrets are shown once (create, rotate) and never in plans or zone reads. - Storage zones: origin = a bucket of the built-in S3 (
storage). The edge reads anonymously, so the bucket must bepublic-read(a private bucket is refused with the reason); only GET/HEAD pass the edge; the cache key stays the public path. Writes go to the bucket with the storage module's keys, reads through the CDN; overwrite an object, then purge it. Private buckets with SigV4 origin requests are not built. - Origin lockdown: every zone has a random secret sent to the origin as
X-RC-CDN-Secret;cdn.zone.get(zone managers only) returns it with a ready nginx snippet. - SSRF guard: customers cannot point a zone at loopback, link-local or private addresses (literal or resolved at create/update). Admins can (their own sites).
- Stats and metering:
cdn.stats.collect(1 min) reads each edge's access log from a saved offset into hourly rows (requests, bytes, hits/misses, status classes, origin bytes, TTFB, top URLs).cdn.stats.getranges 1 h-90 d, CSV export;cdn.usage.getshows the month againstcdn.bandwidth_gb. Events at 80/95/100 % (cdn.bandwidth.warning|exceeded), with settingoverage_policy=blockzones of that account return 403 until the next month.cdn.origin.failing|recoveredfrom the 5xx share of origin fetches. Rows of a deleted zone are kept 30 days, others 90. - External CDNs (
cdn.integration.*): Cloudflare and Bunny. Token in the vault (cdn/integration/<id>), row keeps••••last4,testvalidates scope;zoneslists provider zones and the capability matrix;purge(Cloudflare: url/prefix/tag/all; Bunny: url/prefix(wildcard url)/all),setting(allow-listed keys per provider),wire_dns(Cloudflare: provider DNS record; Bunny: CNAME throughdns.record.set). An operation the provider lacks is refused withnot_supportedbefore any call; the same matrix comes back inlist/zonesso the UI can grey it out. Only an admin can set anendpointoverride (the lab mock). - Customer API: every action is a REST call
POST /api/v1/a/<action>with an API token that holds the permission (cdn.zone.view,cdn.purge,cdn.stats.view,cdn.zone.manage,cdn.rule.manage; subusers can be scoped to one zone);cdn.api.describereturns the base URL, auth, examples (curl/node/python/php), a deploy-script purge, and for storage zones how to read through the CDN and write to the bucket.?dry_run=1previews every mutation as a Plan with the real per-edge config diff (secrets redacted). Actions are also scriptable withrespirecloudctl.
Licensing
Manifest edition: cdn (module-wide entitlement key, docs/tasks/w8/w8-04-store-licensing.handover.md). Without the entitlement the host answers not_entitled for every
action and the module is "not included" in the Store. Degrades cleanly: the data plane does not depend on the control plane. Edges keep serving the last applied config
(nginx), GeoDNS keeps answering, stats lines keep accumulating in the access log until the module is back. Spec says external-CDN integrations ship in the base: a module-wide key cannot split
the module, see the handover (per-action edition or a second module id).
Verified in the lab (PROFILE=cluster, FAKENET=full, Ubuntu 24.04, nginx 1.24 + njs 0.8.2, pdns 4.8.3, SeaweedFS 4.48)
See the handover for commands and outputs: pull zone through two edges (MISS/HIT, per-edge cache), purge url/tag/prefix/all, signed URL (valid, tampered, expired, ip-bound, key rotation overlap, python-signed), storage zone (put via presigned URL, serve, overwrite + purge, private bucket refused, PUT refused), stale on origin failure, bypass rule, hotlink, gzip, HTTP->HTTPS redirect, origin lockdown header, GeoDNS per resolver view and failover, edge down/up detection by the scheduler, external CDN integrations against a mock API, stats and usage, dry-run plans.
Not done
imgproxy image optimisation, origin shield, soft purge, private-bucket (SigV4) origins, per-zone WAF/rate-limit toggles, country allow/deny, Brotli/zstd, HTTP/3,
Fastly/CloudFront/KeyCDN/Gcore, bandwidth throttle mode, edge log download/stream to logs, auto-purge on web deploy, DB-IP lite download, certificate issuance for custom
hostnames through ssl (self-signed until the contract below exists), p50/p95 TTFB (average only), UI.