Skip to content

Hosted DNS, Cluster Domain and wildcard certificates - #1061

Merged
nick-potts merged 21 commits into
mainfrom
feat/1015-cluster-domain
Sep 25, 2026
Merged

nick-potts merged 21 commits into
mainfrom
feat/1015-cluster-domain

Conversation

@nick-potts

@nick-potts nick-potts commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Managed hostnames move out of the daemon and become a Cloud feature, served by the Ployz-run Hosted DNS service (getployz/hosted-dns#1). Each Organization gets one Cluster Domain and one wildcard certificate that covers every generated hostname.

founder enrolls ──▶ Cloud reserves the Cluster Domain (Organization slug as the label)
hourly + events ──▶ sync-cluster-domain, one run at a time per Organization with a Cluster Domain (paired or not)
                      reserve       only if the row is missing; a reaped (404) or retired (410) name is replaced
                      probe         GET http://<ip>/.ployz-verify on each ingress Machine
                      records       full apex A/AAAA PUT, only when a frame exists and at least one probe succeeded
                      lease         7-day lease, renewed even with no Cluster
                      certificate   new key + CSR for name and *.name when missing or under 30 days → Hosted DNS → GTS
                      publish       PublishCertificateMaterial(*.name) ──▶ daemon
deploy          ──▶ Cloud expands prefix → prefix.<name>; the daemon only sees explicit hostnames
Organization teardown ──▶ release the name (tried once, failure ignored)

Core loses all Hosted DNS and gains one frozen RPC:

 rpc catalog
-  ReserveDomain / GetDomain / ReleaseDomain / CreateDomainRecords
+  PublishCertificateMaterial { hostname | *.wildcard, set { chain, key } | clear }
 runtime watch frame
-  hosted_dns_hostname
 IngressHostname
-  enum { Explicit, ClusterDomain { label } }
+  IngressHost (plain)
 CLI
-  ployz dns …, --no-dns, --dns-endpoint

managedHostnames stays in the authored environment document, but core lowering refuses it. Cloud expands it to explicit hostnames before lowering (spec amended). The daemon never lets ACME touch published Certificate Material. A published *.x covers matching hostnames, so ACME orders nothing for them. Custom domains keep Let's Encrypt HTTP-01 exactly as before.

dashboard/src/modules/cluster-domain/
├── hosted-dns.server.ts      # the only Hosted DNS client (mint key, per-call timeout)
├── cluster-domain.server.ts  # reserve, release, Publish now
├── sync.server.ts            # probe, records, lease, certificate, publish
├── sync.inngest.ts           # sync-cluster-domain + hourly fan-out
└── wildcard-csr.server.ts    # CSR for name + *.name with no CN (Node has no CSR builder)

Evidence

  • Before: generated domains were reserved by the daemon, held in cluster state, and exposed through four DNS RPCs. Every generated hostname ordered its own Let's Encrypt certificate under one shared apex.
    After: the daemon holds no generated-domain state. rg -i 'hosted_dns|hostedDns|ReserveDomain|no-dns|dns-endpoint' core/ returns nothing.

Final run, at eaeb4ca4, after the four-axis review:

core       cargo test -p ployz-core -p ployzd -p ployz-sdk -p ployz   1442 passed
           cargo fmt --check · cargo clippy --workspace --all-targets  clean
dashboard  tsc --noEmit · oxlint --type-aware .                        clean
           vitest cluster-domain + deployments + environment-design + inngest + collections (incl. postgres)   338 passed
layer 3    certificates suite (testkit image built from this branch, at 1322fc8d)
             published_material_is_served_never_renewed_and_clear_returns_it_to_acme   ✔
             published_wildcard_covers_hostnames_so_acme_orders_nothing                ✔
             custom_https_hostname_obtains_a_certificate_from_a_fake_ca                ✘ fails the same way on main (sed on a bind-mounted /etc/hosts)

Four-axis review: Standards, Spec, Maintainability and Simplicity all pass after 3 rounds.

New tests that pin the behaviour:

sync-cluster-domain (InngestTestEngine + Postgres + fake Hosted DNS)
  full sync writes reachable IPv4/IPv6, leaves out unreachable Servers
  no frame / nothing reachable → no records PUT, lease still renewed
  reaped (404) or retired (410) name → fresh reservation, old *.name cleared on the Cluster
  Organization with no Cluster → lease still renewed
  certificate missing or < 30 days → key + CSR → chain stored, *.name published
  Hosted DNS failure → current certificate kept, retried next sync
deploy
  managed hostname → explicit prefix.<name> route with a stable id
  no row + reserve fails → refused with cluster_domain_unreserved

Merge Danger

Door: one-way

PublishCertificateMaterial becomes a frozen RPC under the Stable promise once 0.2.0 ships. Deleting the four DNS RPCs and the hosted_dns row is fine only because nothing has been released yet. The rest is two-way: one additive table and Cloud-side code.

Blast Radius: ingress

  • Every deploy with a managed hostname now depends on the Organization's Cluster Domain row. With no row and Hosted DNS down, the deploy is refused by name instead of shipping a bare prefix.
  • HTTPS for generated hostnames now comes from the published wildcard, not per-hostname ACME. If publishing breaks, those hostnames lose valid certificates. Custom domains are unaffected.
  • The Runtime Watch frame changed shape: hosted_dns_hostname is gone, and the certificate keys are now CertificateHost. The SDK payloads are regenerated.
  • Hosted DNS is live at https://dns.ployz.app (Deploy Hosted DNS to production and set Cloud's default endpoint #1018, smoke run passed). The mint key is already set on Ployz Dashboard web and worker, so hosted Cloud mints without hitting the per-IP limit.

Closes #1015
Closes #1018
Closes #1019
Closes #1020
Closes #1021
Closes #1022
Closes #1023
Closes #1024

Codex added 13 commits September 25, 2026 04:39
Adds organization_cluster_domain (token encrypted under APP_ENCRYPTION_SECRET,
optional wildcard certificate columns), PLOYZ_HOSTED_DNS_URL and optional
PLOYZ_HOSTED_DNS_MINT_KEY config, and a small Hosted DNS client. Founder
enrollment completion reserves the name with the Organization slug and never
fails on a Hosted DNS error. Server Settings shows the name or Not reserved yet,
with Publish now reserving a missing name. Organization teardown releases the
name once after the Organization row is deleted, ignoring failure.
Deploy compile reads the row (one inline reserve when missing, else a
named cluster_domain_unreserved failure pointing to Server Settings) and
sends managed hostnames to the daemon as explicit prefix.name routes.
PLOYZ_PUBLIC_DOMAIN, the Service drawer, variables tab and reference
targets read the name from the Org Store, so they render with no Server
connected. Cloud no longer reads hosted_dns_hostname from the runtime
frame, and the up.ployz.dev placeholder is gone.
…main

One Inngest function, sync-cluster-domain, runs per Organization on cluster-domain/sync.requested. An hourly cron fans that event out to every founded pairing. The sync reserves a missing name, probes ingress Servers with a public IP on /.ployz-verify, PUTs the reachable set and renews the lease. A missing frame or an empty set writes no records. A reaped name (404) is reserved again; 410 and 401 fail as ClusterDomainUnusable without retries. Publish now, founder enrollment completion and machine removal request the sync. Server Settings shows when records were published and each ingress Server's place in the set, which needs the new unreachable column.
Derive the route id as a v5-shaped UUID over the hostname's SHA-1, so
two compiles of the same config send identical routes. Core validation
requires route ids to be UUIDs; the test lowers the expanded config to
prove it.
Add the frozen PublishCertificateMaterial RPC (Cluster capability
ployz.certificates.publish.v1): set validates chain, key match and
leaf coverage; clear removes published material and returns the
hostname to ACME. Certificate rows gain an optional published flag
that ACME never orders, renews or overwrites. Rows key by
CertificateHost (hostname or *.x); a published wildcard suppresses
ACME for covered hostnames and lends them its material in Caddy.
SDK: Client.publishCertificateMaterial.
Rebuild the SDK for the CertificateHost wire type, and let managed hostnames take certificate evidence from a covering published wildcard.
sync-cluster-domain gains ensure-certificate and publish-certificate after
renew-lease. With no certificate or under 30 days left, Cloud generates a
P-256 key and a CSR for exactly name and *.name (empty subject), obtains
the chain from Hosted DNS through withClusterDomain, and stores the key
encrypted under APP_ENCRYPTION_SECRET with the chain and not-after. A
Hosted DNS failure, 429 included, keeps the current certificate. Every
sync republishes *.name through PublishCertificateMaterial, which is
idempotent, so a re-paired Cluster gets the material back.

The Hosted DNS client timeout is now per call; the certificate call
allows 15 minutes. Server Settings shows the wildcard's expiry.
Cloud now owns the Cluster Domain end to end, so core only knows explicit
Ingress Hostnames. Deletes the Hosted DNS client and cluster row, the four
DNS RPCs, hosted_dns_hostname on the Runtime Watch frame,
IngressHostname::ClusterDomain and its label type, managedHostnames
lowering (now refused unexpanded), the CLI --no-dns/--dns-endpoint flags,
machine add/remove DNS refresh, the ingress probe and the Hosted DNS
layer-3 suite. Keeps /.ployz-verify, ClusterDnsVerdict and the .internal
DNS server.

Closes #1024
…enum

PortPublication::Ingress holds IngressHost directly; SDK payloads
regenerated. Published Certificate Material is its own row variant, so
ACME writes go through one acme_row guard, and SAN coverage, the wanted
set and the ingress projection share CertificateHost::covers via
published_cover. Store row helpers take CertificateHost and
publish_certificate_material takes material by value.
Session RPC wrappers share one unary helper; bind collects the apply-set
with map; the warned-failure test uses the message the CLI emits.
Codex added 4 commits September 25, 2026 06:36
The hourly sync fans out over every organization_cluster_domain row, so
an Organization with no Cluster still renews its lease. The sync reserves
once up front; withClusterDomain only loads the row, and a name Hosted
DNS reaped (404) or retired (410) is replaced by a fresh reservation with
its old wildcard cleared from the Cluster. The lease route is called only
when no records were PUT. The published column becomes record_addresses,
the two unreleased migrations are one, and the settings copy says
Cluster Domain.
…spense

managedHostname(prefix, domain) builds every generated hostname;
expandManagedHostnames moves to environment-design and fails with a typed
ClusterDomainRequired. useClusterDomainName reads the Org Store with
Suspense, and the settings tab test covers the covering-wildcard
certificate status.
acme_row hands back the row's CertificateHost key so ACME writers stop
rebuilding it, and a row marked published without valid material is
refused as corrupt.
requireClusterDomain already refuses a deploy without a Cluster Domain,
so expansion takes the name directly. The sync's records flag is
recordsPut.
@nick-potts
nick-potts marked this pull request as ready for review September 25, 2026 06:48
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

Codex added 3 commits September 25, 2026 07:04
Conflicts: ingress.rs, caddy_tests.rs and contract.rs imports (main
deleted Ingress Proxy Fragments; this branch collapsed IngressHostname),
caddy_tests keeps main's caddyfile_for with CertificateHost keys; config,
.env.example and vitest.setup take main's single Polar product and drop
installer config, keeping the Hosted DNS URL and mint key; tests use
main's testConfigEnvironment. SDK payloads, config-wasm and the
organization_cluster_domain migration are regenerated after main's
migrations.
Takes the Build Method rename in ServiceSettingsTab.test.tsx, regenerates
config-wasm, and regenerates the organization_cluster_domain migration
after main's build_method migration.
@nick-potts
nick-potts merged commit 041acf7 into main Sep 25, 2026
12 checks passed
@nick-potts
nick-potts deleted the feat/1015-cluster-domain branch September 30, 2026 15:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment