Actions
43 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.edge.caps | What the edges can do: Brotli, zstd, country database, firewall (read) | low | No dry run |
cdn.zone.waf_events | Requests the edge firewall blocked (or would have) for a zone (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.cert.ensure | Order the certificate for a zone's verified custom hostnames and deploy it to every edge | medium | |
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.images.get | Image optimisation: the limits, the parameters, and which edges run the service (read) | low | No dry run |
cdn.images.install | Install image optimisation (container runtime if needed, then imgproxy) on the edges | high | |
cdn.images.remove | Remove the image service from the edges | medium | |
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 e0cdf300. 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. Real certificates for custom hostnames (w11-26): once a hostname is verified,cdn.cert.ensure(also run by the one-minute edge poll, retried at most every ten minutes per zone) orders one certificate per zone for its verified custom hostnames throughssl.cert.issue(system call,node_id= the first healthy edge, which holds the key), deploys it to every other edge withssl.cert.deploy(so every renewal follows: the ssl module copies the renewed chain and key, the cdn re-apply every 15 minutes orcdn.reconcileswaps and reloads) and sets the zone'scert_id. HTTP-01 is answered by every zone server on port 80 (/.well-known/acme-challenge/from/var/lib/rc-acme); an edge without the token file asks the other edges once (X-RC-ACME-Hop), and the HTTPS redirect, hotlink check and signed-URL gate step aside for that path. The host name<zone>.<base domain>keeps a self-signed certificate (a wildcard needs DNS-01 on the base domain).cert_idcan no longer be chosen by a customer (it would serve another account's certificate). - 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. - Cache key and poisoning: the key is
<zone>|<decoded path>[?args]and nothing else - no request header is part of it, and the edge sends the origin a fixedHost, an emptyAccept-Encodingand the zone's own headers, so a visitor cannot make the origin vary a cached object. Cookies only bypass (never key). A private-bucket zone ignores the query string. - Soft purge (
cdn.purge soft:true): the edge rewrites the validity inside nginx's cache-file header to the past (the file stays); the next request isSTALEand nginx revalidates in the background, later requests areHIT. Purge on deploy:cdn.zone.update purge_on_deploy {app_id|site_id, soft}(the caller must be able to see that app or site) makesapps.deploy.finished(status ok) andweb.site.deployedpurge the whole zone (cdn.on_deploy). - 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 (w11-26): for a private bucket the panel makes a read-only key for that bucket alone (storage.key.create, mode read, namedcdn-origin-<zone>, as the caller), keeps its secret in the vault and the edges' config, and the edge signs every origin GET with SigV4 in njs (s3auth; the path is built and encoded by njs,$argscleared, Authorization replaced). The bucket stays private (direct anonymous read is 403), the key is revoked with the zone or when the bucket changes. Visitors' Range headers pass through unsigned. - 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: scopes of an API token are permission ids (
cdn.*, orcdn.zone.view+cdn.purge+cdn.stats.viewfor a deploy script); a token never exceeds its owner or sees another account. Limiting a token to ONE zone is not built (tokens have no resource scope and a Team member cannot create tokens). Every action is a REST callPOST /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
Open after w11-26: imgproxy / image optimisation (AC-cdn-20..22), origin shield, per-zone WAF / rate-limit / country rules at the edge (reusing the web module's protection engine and GeoIP), edge access-log streaming to logs, the GeoIP (DB-IP lite) download and refresh, Fastly/CloudFront/KeyCDN/Gcore adapters (external providers: Cloudflare and Bunny are mock-proven only, none real-API-proven), Brotli/zstd, HTTP/3, bandwidth throttle mode, p50/p95 TTFB, an emitter for web.site.deployed, tokens limited to one zone, the UI for certificates / soft purge / deploy link / private bucket, a neutral agent helper file for what cdn shares with cluster / dns.
Rules at the edge (w11-89)
All of it is typed zone settings (cdngen.Security.Countries|RateLimit|WAF, cdngen.Cache.Brotli|Zstd|CompressMin|CompressTypes), validated against closed
sets and rendered by cdngen.Render; no customer text reaches nginx. A zone without the settings renders exactly what it did before.
- Country allow / deny (AC-cdn-10): the zone says
{mode: allow|deny, codes};geo/<zone>.confships as the marker#@geo <mode> <codes>and the edge's agent replaces it with the address blocks of those countries (cdnExpand,geoip.NetworksOf), because the control plane's modules are separate processes and cannot read the panel's database. Source on the node: the panel's own DB-IP Lite file (<RC_DATA_DIR>/geo/country.mmdb, downloaded by an administrator on Security > Licences with the licence and attribution shown there: the local edge is the panel node), else the security module's database on that node (security.geo.update). With neither the update is refused with a plain message and the Security tab says why the control is off. Cap: 60 countries and 120000 address blocks, refused past it (cdngen.MaxCountries,MaxGeoLines). A visitor from a refused country gets 403; in allow mode an address with no known country is refused too. The expansion happens at every apply, so a changed zone reaches the edge within one apply (seconds); a database that is refreshed later is picked up by the next apply (cdn.reconcile). Nginxgeohas the ranges as CIDRs (cdnRangePrefixes). A second edge without the panel's database uses the security module's, so it needssecurity.geo.updaterun once. - Rate limit (AC-cdn-18):
limit_req_zone $binary_remote_addrper zone,limit_req ... burst=N nodelay, status 429 withRetry-After: 1. - Web firewall at the edge (AC-cdn-18): the web module's service (
internal/wafd, Coraza + OWASP CRS, detect or block, paranoia 1-4), run as a second instance,rc-cdn-waf.service(userrc-cdn, envRC_WAF_CURRENT|RUN|EVENTS|SECRET), with its own sockets in/run/rc-cdn-waf, generations in/var/lib/rc-cdn-wafand events in/var/log/rc-cdn/waf. A protected zone renders two servers: the public one (TLS, country, rate limit, hotlink, signed URL check, thenproxy_passto the service withX-RC-Site: cdn-<zone>) and an inner one onup-cdn-<zone>.sockthat holds the cache and the origin, so every request is inspected BEFORE the cache (a HIT is inspected too) and the service fails open to the inner socket if it is down. The agent compiles the rules (wafd.Validate) before anything goes live, installs the unit on first use, publisheswaf/cdn-<zone>.jsonlike the web module publishes its generations, and rolls it back with the nginx config. Blocked requests:cdn.zone.waf_events(the Security tab lists them); the zone's request log (Logs) shows the 403/429 with the cache result taken from the inner server'sX-Cache. A zone name over 36 characters gets a short stable id (cdngen.WAFID:cdn-<first 20>-<12 hex of its SHA-256>) so two long names with the same start never collide (w11-98). Since w11-92 the edge runs the same pair as the web module:rc-cdn-waf.service(waf.sock) andrc-cdn-waf-b.service(waf-b.sock),Type=notify,RC_WAF_COMPILE_FIRST=1, restarted one after the other (only when a unit file changed), each copy listed twice in the zone'supstreamwith the zone's inner server as the last-resort backup. Stand-in proof (two units from the real code, an nginx with the same upstream text, 20 restarts at 100 injections/s): 1479 of 12818 passed uninspected with oneType=simplecopy, 0 of 16388 with the pair. A full edge install was not run (see the handover). - Compression (AC-cdn-09): gzip as before, plus Brotli and zstd on the configured MIME types (default list, or
compress_types) abovecompress_min(default 1024 bytes). The render emits#@mod:brotli .../#@mod:zstd ...lines and#@LOAD_MODULES@; the node keeps them only when its nginx has the module (ngx_http_brotli_filter_module.so,ngx_http_zstd_filter_module.sounder the nginx module directories) and drops them otherwise, andcdn.edge.caps(also incdn.edge.health) says per edge what exists, so the Caching tab shows "not available on" instead of a dead switch. cdn.edge.installtries the distribution's package for each (libnginx-mod-http-brotli-filter,libnginx-mod-http-zstd; nginx.org:nginx-module-brotli|zstd) and ignores a missing package. Ubuntu 26.04 ships Brotli but no zstd. Range requests: the edge caches whole objects, so the first range request of an uncached large file is passed on as a range (MISS, not cached) and every range is a HIT once one full request has filled the cache (proved with 200 MB). Slices (w11-98): a zone (or a single rule) can turn oncache.slice: nginxslice 1mfetches and caches a large object one 1 MB slice at a time (proxy_set_header Range $slice_range, cache key<zone>|<uri>$slice_range), so the FIRST range request on an uncached large file is answered after one slice (MISS) and every later request for an overlapping range is a HIT; without it the edge fetches the whole file first. The origin must answer Range with 206 (an origin that answers 200 makes the slice request fail with a 502). Off by default. Lab: 200 MB object,Range: bytes=100000000-100000099MISS, then100000050-100000199HIT, the origin saw exactly one request, for99614720-100663295; with slicing off the origin saw one request for the whole file.
Image optimisation (AC-cdn-20..22, w11-98): imgproxy on each edge
Evaluation (w11-89): nginx image_filter cannot convert formats; imgproxy (MIT, Go + libvips, official container) can, so it is the chosen service (lead's decision,
w11-98). Built as follows.
- Install (
cdn.images.install, administrator,dry_runshows the plan first: runtime download, the pinned image size (about 81 MB, 250 MB on disk), start, fence, nginx reload, free-disk warning): installs the container runtime throughcontainers.runtime.install(a declared system call) when the node has none, then the agent jobcdn.images.applypullsghcr.io/imgproxy/imgproxy@sha256:...(the digest iscatalog/versions.jsoncomponentimgproxy, binaryimage; the agent reads it from the catalog, never from the panel's request), makes the node's key/salt/token, starts the container and waits for its health check.cdn.images.removerefuses while a zone uses it. Which edges run it: settingimages_edges(node -> pinned image);cdn.images.getshows it (live per edge for an administrator). - Where it listens: imgproxy runs as its own user (
rc-cdn-img), read-only, no capabilities, 1 GB memory, on the host network and the host's user namespace, bound to127.0.0.1:18090. A kernel rule (inet rc_cdn_img_fence, unitrc-cdn-img-fence.service) lets only the edge's nginx workers (rc-cdn) and root connect to 18090, and only those plusrc-cdn-imgto 18092 (the feeder). imgproxy also answers only signed URLs (HMAC key made on the node) and its allowed source is exactlyhttp://127.0.0.1:18092/. The workers' loopback fence (inet rc_egress) is opened for127.0.0.1by the control plane, and the same agent rule closes every other loopback port for them (name resolution excepted), so a customer origin that resolves to 127.0.0.1 gets nothing new. Key, salt and token never leave the node (/var/lib/respirecloud/cdn/img.json,/etc/rc-cdn/imgproxy.confand.env, mode 0600); a rendered edge configuration contains none of them. - Request path (
cdngen/images.go): on a zone withimages.enabled, a GET/HEAD for an image extension withw,h,fit(contain|cover|stretch),q(1-100),fmt(webp|avif|jpeg|png|auto) is validated bycdn.imgVariant(400 with the reason; width/height up to 8192), turned into one canonical variant (fmt=autoresolves fromAccept: avif, else webp, else the original format) and answered by@rc_imgfrom the zone's cache (key<zone>|<path>?img=<variant>, so aurlpurge of the original removes its variants and a second request is a HIT,X-Cache;Vary: Accept). A MISS asks imgproxy with a signed URL. imgproxy fetches the ORIGINAL from the edge's own feeder (<zone>.rcimgservers on 127.0.0.1:18092 behind a secret path), so the origin lock header, the Host header, private buckets and the original's own cache entry work as for any request; the original is never modified. "Auto WebP" (images.auto_webp) turns a JPEG or PNG without parameters into WebP whenAcceptlistsimage/webp. Token-protected zones check the signed URL before the image branch. - Caps (administrator settings
img_max_src_mb25,img_max_mpx40,img_timeout_sec10; saving writes them to the container's environment): a source over the size answers 413, over the pixels or not an image or too slow answers 422 (the script maps imgproxy's development-mode messages to its own sentences; a visitor never sees imgproxy's text), an original that does not exist answers 404. The time cap answers 422 because AC-cdn-21 names 413/422 (imgproxy itself says 504). - Not supported on this version: animated GIF frames beyond the first, SVG, a zone served through the firewall is supported (the branch sits in the inner server).