Domains and TLS
What the Domains page manages: one zone, one list of public hostnames, the DNS records that
follow the panel's public address, the certificate in front of the panel, the SSH gateway's own
name, and the front door two joined clusters can serve for each other. Read from
lib/hostnames.ts, lib/domain-check.ts, lib/domains-dns-sync.ts, lib/domains-sync.ts,
lib/cloudflare.ts, lib/acme.ts, lib/front-door.ts, lib/front-door-sync.ts,
lib/ssh-gateway.ts and app/api/domains/route.ts.
The page needs network.domains, which the built-in roles give to admins only. Every change
asks for your password again (sudo mode, good for five minutes). Reading the page does not.
One zone, one hostname list#
Conduit manages names in one Cloudflare zone (network.domains.zone). Every public name the
cluster answers to is a row in one list, and each row has a target that says what the name is
for. Three settings used to hold hostnames on three pages, each with its own mode switch and its
own way of writing a DNS record; the gateway's "mirror the panel" mode once rewrote the panel's
record as DNS-only. Now there is one table, and a consumer asks hostnameFor(kind) for the name
it needs. A kind with no row of its own falls back to the activated panel hostname, which is what
"mirror" used to mean.
The live pair is the worked example. Both clusters are in example.com, and each keeps its own rows:
| Cluster | Hostname | Kind | Notes |
|---|---|---|---|
| edge-01 | play.example.com | Panel | the primary; failover on, so edge-02 may serve it while edge-01 is silent |
| edge-01 | play.example.com | Cluster address | what edge-01 advertises to joined clusters; never moves |
| edge-01 | play.example.com | SSH gateway | DNS only |
| edge-01 | play.example.com | Resource packs | proxied |
| edge-02 | play.example.com | Cluster address | what edge-02 advertises to joined clusters |
| edge-02 | play.example.com | SSH gateway | DNS only |
| edge-02 | play.example.com | Resource packs | proxied |
Both clusters share the zone credentials with each other. Only edge-01 has a panel hostname marked
failover, so play.example.com is the one door that can move today: edge-02 holds a certificate for it
and points its record at itself while edge-01 does not answer.
Connecting the zone#
The zone card takes three values. Saving verifies what it can and asks for your password.
| Field | What it is | Required |
|---|---|---|
| Zone | the root domain, example.com | yes |
| API token | a Cloudflare token scoped to Zone, DNS, Edit on that zone | for DNS records, DNS-01 certificates and the front door |
| Origin CA key | the Origin CA Key from dash.cloudflare.com, My Profile, API Tokens | no; see Certificates |
Saving with a token calls the Cloudflare API once, lists the zone by name and keeps its id
(cfResolveZone). A token that cannot see the zone fails in the form with Cloudflare's own
message, so a wrong scope is found before a record is written. Both credentials are write-only:
the page shows "stored" and never the value, and /secrets lists them by path with a fingerprint.
Cloudflare never exposes the Origin CA key through its API, so it has to be pasted from a person's
profile; skip it when the token is a user token that also has Zone, SSL and Certificates, Edit,
because newer accounts accept such a token on the Origin CA endpoint too.
Share these credentials with joined clusters appears once the zone is verified. On, the token and the Origin CA key travel in the reach bundle to every cluster this one works together with, which is what lets that cluster serve a hostname marked failover (below). Off withdraws them on the next tick. It needs a connected zone; there is nothing to share otherwise.
Hostname kinds#
Every hostname must be under the zone. The kind decides the DNS record, how the row is proven, and whether more than one row may exist.
| Kind | What it is for | DNS record | Rows | Proven by |
|---|---|---|---|---|
| Panel | the dashboard, through the TLS front on the panel containers | A, proxied by default | many; the activated one is the primary | the challenge echo, 15-minute window |
| Cluster address | this cluster's own https address for joined clusters (play.example.com). Advertised on GET /api/fleet/peer once proven; every joined cluster moves its link there after the name answered the same call, and never back down to http. It never fails over | A, proxied | one | the challenge echo, kept durably: an offer that flickered every 15 minutes would make the far side switch back and forth |
| SSH gateway | the console gateway's own name | A, DNS only, whatever the switch says: raw SSH cannot cross the Cloudflare proxy | one | an external prober connecting to the port |
| Resource packs | pack downloads, served by the panel | A, proxied: a game client trusts the Origin CA certificate only through Cloudflare | one | serving a pack end to end |
| Custom target | any ip:port behind the front, 10.0.0.5:8100 | A, proxied by default | many | the challenge echo |
Adding a second row of a single-row kind replaces the existing one, DNS record included, rather than refusing; the form says which row it replaces before you press the button. Changing a row's target or proxied flag keeps its identity but drops its proof, because a row that moved from proxied to DNS-only takes a different path to the panel and the old echo says nothing about the new one. A gateway or packs row cannot become the primary.
Verification#
A panel or custom hostname has to prove it reaches this panel before it can be activated, and the proof is a challenge echo that the server records itself:
- Check calls
POST /api/domains/check {hostname}. The panel resolves the name (A and CNAME), says whether the addresses are Cloudflare's (orange cloud) or its own public address, and mints a token bound to that hostname, good for ten minutes. - Your browser fetches
https://<hostname>/api/domains/challenge?token=...over the public internet. For that fetch to succeed, DNS must resolve, the edge or your router must forward:443to the panel front, the certificate must be trusted, and theHostheader must arrive intact. - The public route compares the token and the
Hostthe request carried. Only when both match does it stamp the rowverifiedAtand burn the token. A request that reached a panel with the wrongHostanswers "reached the panel with Host ..., expected the domain", which is what a proxy rewritingHostor a probe of a bare IP looks like.
Set primary refuses a row whose stamp is older than 15 minutes; the badge reads "stale" after that, so a check has to be run again just before activating. The wizard's own success message is not trusted; the stamp is written by the request that landed.
The other kinds are proven server-side. A gateway row is probed from the internet by an external
prober (/api/ssh-gateway?check=1): reachable, not reachable ("the edge router needs a forward to
the gateway port"), or inconclusive. A packs row is proven by a pack request through the hostname;
the uptime sweep keeps that stamp current and clears it on the opposite finding, and pack URLs keep
using the panel's name until the row has been seen serving. A cluster address is proven by the
same echo as a panel row, but the stamp is durable: the joined clusters probe the name themselves
before moving to it.
DNS follows the public address#
Every A record points at the panel's public IPv4, and the leader rewrites them when it changes.
The address comes from a ladder, in this order: the override on the Public address card, then
CONDUIT_PUBLIC_IP from the environment, then live detection through an echo service
(api.ipify.org, ifconfig.me, icanhazip.com, cached five minutes), then the last address that
was detected. Nothing ever falls back to a literal: an early version wrote the author's own WAN
address into a fresh install's records. The page says which rung answered.
A record is written when a row is added, if a token and a zone are stored. After that the leader
reconciles on every tick (domainsDnsKeepInSync):
- The desired records are a pure function of the address and the list. A signature of the pair is
kept in
dnsApplied; a tick with the same signature does nothing. - When it writes,
cfUpsertRecordskips a record Cloudflare already has right, so adding one hostname touches one record. Records are written with TTL 1 (Cloudflare's automatic). - No token or zone, and no hostnames, are ordinary idle states. No address is a skip with a log line: an A record with no content is an outage, not a fallback.
- A failed write (a revoked token, Cloudflare down) backs off for five minutes rather than retrying every ten seconds; a panel restart retries at once.
- A hostname a joined cluster is serving under a fresh claim is left alone (see the front door), and the held set is part of the signature so the record is rewritten when the claim ends.
Removing a row deletes its record. A gateway row is always written DNS-only.
Certificates#
The certificate card covers every hostname in the list. Which authority issues it depends on what credentials are stored.
Cloudflare Origin CA#
With an Origin CA key (or a user token that has SSL and Certificates, Edit), Issue certificate
generates an RSA-2048 key and CSR in the panel and asks Cloudflare for an origin certificate
covering every hostname, valid 5475 days. The private key never leaves the store
(network.domains.originCert). An origin certificate is trusted by Cloudflare's edge, which is
what lets the proxy run SSL mode Full (strict); a browser that reaches the origin directly does not
trust it, so a DNS-only panel hostname behind this certificate fails TLS in the browser.
The card shows what the certificate covers, when it was issued and expires, and its rollout as "on N of M panel nodes". A hostname added after issuance shows as "not covered" until Renew. The leader renews on its own inside the last 30 days when credentials are present; when they are not, it raises "Origin certificate expires in Nd and cannot be renewed" rather than letting the date pass unnoticed.
Let's Encrypt through DNS-01#
Shipping in 2026.8.319. Without an Origin CA key the panel issues a publicly trusted certificate
from Let's Encrypt (lib/acme.ts, the acme-client library). The zone token can edit DNS and
nothing else, and that is exactly what the DNS-01 challenge needs:
- For each name the panel creates a TXT record
_acme-challenge.<name>(TTL 120) holding the key authorization, Let's Encrypt reads it, and the panel deletes the record. A TXT left behind by a failed removal is clutter, not a fault. - The account key is created on the first issuance and kept in the store
(
network.domains.acme.accountKeyPem), like every other credential the panel minted. Losing it costs a new registration and nothing else. - Certificates are 90 days and renewed at 30 remaining, the same window the origin certificate uses. Every issuance makes a fresh key pair, which is why the register calls the certificate key self-service.
- The result is stored as
network.domains.leCertand preferred over the origin certificate when both exist. It is browser-trusted at the origin, so a DNS-only panel hostname works with it. - No port has to be open. The same path therefore works for a hostname behind the Cloudflare proxy, where an HTTP challenge would land on the edge and not on the origin, and for a node whose only reachable service is the agent.
Where the certificate goes#
The reconcile leader rolls the TLS front onto every panel container (domainsKeepInSync): nginx,
installed once from the Debian repositories, the certificate and key at
/etc/conduit/tls/cert.pem and key.pem (mode 600), and one server block per HTTP hostname.
A panel or packs row proxies to the panel; a custom row to its ip:port; a gateway row gets no
server block at all. Each container's upstream is its own panel first and the sibling replicas
as backup, so a replica whose panel is restarting falls through rather than answering 502; when
nothing is left the front returns a small "Conduit is restarting" page that reloads itself every
two seconds. Long-poll and SSE traffic passes with buffering off and one-hour timeouts.
The rollout is hash-gated per container: a container that already has this exact config is not
touched, and the record is written per container, so one dead node cannot keep the healthy
replicas re-applying forever. A stand-in server block for another cluster's front door carries
that cluster's certificate under its own file name (door-<tag>.pem), so an owner's renewal never
touches this cluster's own certificate.
The SSH gateway hostname and SSH over 443#
The console gateway is a dedicated sshd on every panel container, default port 2202
(network.sshGateway.port), separate from the system sshd on :22. Its hostname is a row in the
table with target SSH gateway, always DNS-only; without one the panel's primary is advertised:
ssh -p 2202 conduit@play.example.comPublish makes every node forward the gateway port to the VIP, so the gateway answers on any node address; off, it answers on the VIP only. The forward needs the firewall's forwarding on, and the card offers to switch it on when it is not.
SSH over 443 lets an SSH client ride :443 looking like HTTPS, for networks that reset
non-standard SSH ports. It needs a gateway hostname of its own, because the split keys on it: with
the switch on, nginx's stream module owns :443 on the panel container and reads the first bytes
of every connection. Bytes that are not a TLS ClientHello are a raw SSH client and go straight to
the sshd. A TLS handshake whose SNI is the gateway hostname is terminated on 127.0.0.1:8444 with
the panel's certificate and handed to the sshd as plain SSH; any other SNI goes to the panel web,
which moves off :443 onto 127.0.0.1:8443 while the split is on. Handing the split the panel's
own name would route every HTTPS handshake for the panel to the sshd, which is why the switch
stays disabled until a dedicated row exists.
# raw SSH on :443
ssh -p 443 conduit@play.example.com
# if the port itself is inspected, wrap SSH in real TLS
ssh -o ProxyCommand='openssl s_client -quiet -verify_quiet -connect %h:443 -servername %h 2>/dev/null' conduit@play.example.comThe reachability block on the gateway card probes three rows: the VIP from inside the cluster, the public address and the hostname from the internet through an external prober. "Not reachable" on an edge row means the port is not open from outside, usually a missing WAN-to-VIP forward on the edge router; Cloudflare cannot proxy raw SSH.
The shared front door#
One panel hostname, served by the cluster that owns it, taken over by a joined cluster when the owner falls silent, and handed back when the owner answers again. It needs both clusters joined and working together (federation.md), the owner's zone connected with a token, and the owner sharing its credentials.
The owner's side. The Failover switch on a panel row (it needs a connected zone, since a
door nobody could point anywhere is not a door) and the Share switch on the zone card. The
hostnames marked failover are published in the owner's replicated partition
(frontDoor.hostnames) together with the address the owner answers at, the addresses of its panel
containers that its own node can reach, and the clusters it shares with. The raw rows never cross;
the projection does.
The stand-in's side. A joined cluster holding the owner's credentials issues an origin
certificate for the owner's doors ahead of time, with the owner's key, and renews it 30 days before
expiry; its TLS front carries a server block for the door from then on, so a takeover moves only a
DNS record. Every tick it probes the owner's panel on two paths: from itself, GET /api/fleet/peer
at the owner's public address with its own credential, where any answer the panel composed counts
as alive, a 401 included, and only no answer or a gateway 5xx is silence; and when that fails,
through the owner's own node over the mesh, which curls the owner's panel containers on their LAN.
A panel that its own node cannot reach is down.
The decision (lib/front-door.ts, pure and tested without a network):
- After three minutes of silence on both paths, the stand-in points the A record at itself,
keeping the row's proxied flag, and publishes a claim (
frontDoor.claims, a record it owns, so it replicates like any other). Both panels raise an alert: "play.example.comnow points at this cluster" on the stand-in, "play.example.comis being served by edge-02" on the owner. - After two minutes of the owner answering without a gap, it points the record back at the owner's published address and withdraws the claim. One lucky probe is not enough: a panel that answers once and dies again would bounce its hostname every few minutes. If the owner published no address, the record is left as it is rather than pointed at nothing.
- A claim not refreshed for ten minutes was made by a claimant that is itself gone; the owner takes its door back, once per stale claim.
- Only the clusters the owner shares with may claim, the lowest store id first; two live claims on one door settle on the lower id, and the other points the record at the winner and withdraws.
- Three minutes and two, because a panel restarting for an update is gone for fifteen seconds and must not move its hostname, and a provider outage is not over in one lucky probe.
sequenceDiagram
participant B as Browser
participant DNS as Cloudflare DNS
participant O as Owner (edge-01)
participant S as Stand-in (edge-02)
S->>O: probe every tick: GET /api/fleet/peer, then via edge-01's node over the mesh
O--xS: no answer on both paths, three minutes
S->>DNS: A play.example.com -> edge-02 (owner's shared token)
S->>S: publish claim, raise alert
B->>DNS: play.example.com (TTL 1)
DNS->>B: edge-02's address
B->>S: play.example.com (edge-02's stand-in certificate)
S->>B: sign in again, edge-01's servers driven over the mesh
O->>S: answering again, two minutes without a gap
S->>DNS: A play.example.com -> edge-01's address
S->>S: withdraw claim, resolve alert
B->>O: play.example.comWhat a person sees. The record has a one-minute TTL at Cloudflare, so browsers follow a
takeover or a hand-back about a minute after the decision. Sessions are per cluster, so you sign in
again on the stand-in; your account is there because identity replicates. The stand-in drives the
owner's servers over the mesh with the reach it holds; a setting that lives in the owner's store
waits until the owner is back. The front door card and GET /api/fleet/front-door show every door
across the fleet: who owns it, who serves it now and since when, and whether this cluster could
stand in.
Limits. A partition between the two clouds that also cuts the mesh reads as an outage: the stand-in takes the door, and hands it back two minutes after the partition heals. The owner's DNS reconcile leaves a hostname alone while a fresh claim on it stands. A cluster address never fails over; that is what the panel hostname marked failover is for. A door with a hostname that is not a valid hostname is dropped by the reader before it can reach a config or a certificate request.
Node hostnames#
Shipping in 2026.8.319: a hostname per node, skyd-<node>.<zone>, with a Let's Encrypt certificate
issued through the same DNS-01 path (lib/node-tls.ts), and the node agent (0.3.4) serving TLS on
port 8443, so a node's agent can be reached by a name a client trusts. This section will grow when
that release lands; it is named here so the DNS-01 path above is read as shared rather than as the
panel's alone.
What the page does not do#
- One zone per cluster, on Cloudflare only. There is no other DNS provider.
- A records only. No AAAA, no CNAME rows, and no wildcard issuance from the page.
- The certificate covers the panel front and the hostnames in the list. Game servers are reached by address and port and carry no certificate from here.
- An origin certificate is trusted through Cloudflare only. Use the Let's Encrypt path for a name that browsers reach directly.
- Failover moves a panel hostname. It does not move the cluster address, the gateway or the packs hostname, and it does not copy settings: the owner's store is the owner's.