cl-proxy terminates HTTP and HTTPS for every route declared under a
service's x-clouduler.proxy (see docs/stacks.md), forwards to the
container backend (directly when this node runs it, through the owning
node's relay otherwise, see "Reaching a container on another node from a
host" in docs/networking.md), and issues/renews the certificates those
routes need via ACME DNS-01 through cl-dns.
proxy:
enabled: true
http_port: 80
https_port: 443
dns_publish: all-nodes # or owning-node
acme:
enabled: false
directory_url: https://acme-staging-v02.api.letsencrypt.org/directory
contact_email: ops@example.comBoth listeners are bound on every node, on all addresses, because a route's
hostname resolves to whichever node publishes it and that changes as
containers move. Neither may collide with web.port, which the api holds on
the same addresses; the config validator refuses that outright rather than
leaving a listener that silently failed to bind. This is why web.port
defaults to 8443 and not 443: the standard ports belong to the services the
cluster publishes, and the admin ui takes the odd one.
The dashboard is published as a route in the same table as everything else,
controlled by web.through_proxy (default on):
web:
address: cloud
network: tailscale
port: 8443 # direct https listener, always up
through_proxy: true # also publish through the reverse proxy
backend_port: 8444 # loopback port the proxy forwards toThe point is that it then gets the Let's Encrypt certificate and the
Cloudflare record from the machinery that already runs for every service,
rather than needing its own path for each. On the standard HTTPS port it
serves a real certificate; the direct web.port listener falls back to a
self-signed one until ACME has issued.
Three things about this are deliberate:
- The direct listener on
web.portstays up regardless. It is the way back in when the proxy cannot bind its ports (they are below 1024 and need root) or when a config edit breaks routing. Locking the operator out of the ui that fixes the proxy would be the worst available failure mode. - The backend is this node's own api over loopback, not a load balanced pool across the cluster. The proxy speaks plain HTTP to its backends, and that hop carries session cookies while the overlay is not encrypted, so it must not leave the machine. A non-leader api already redirects to the leader, so spreading the load would only add a redirect anyway.
- The route sets
preserve_hostand no timeouts. The api derives its OIDC redirect URL from the request host, and/api/eventsplus the log and stats streams are server-sent events that must not be capped or timed out.
backend_port may not collide with web.port or either proxy port; the
validator refuses all three.
Routes and certificates are rebuilt from replicated state every five seconds. A route with no running backend is still published and answers 502, since a redeploy briefly having nothing behind it is not the same as the hostname not being configured, and a 404 is what a client would cache.
A route is built from one ProxyRoute entry (host, network, port,
path_prefix, tls, middlewares, sticky) plus the overlay address of
every running replica of the service instance. Per-route behaviour that is
about neither matching nor backend choice — max_body_size, websockets,
preserve_host, response_timeout — is carried separately in
RouteOptions, so adding a knob does not touch every Route::new call
site. The routing table
(RouteTable) is rebuilt wholesale on every placement change and swapped in
atomically (arc_swap), so request handling never blocks on a reload and
never sees a half-updated table.
A bare host (blog) is qualified with the network's domain; a dotted one
is taken as a full name. host: "@" publishes the apex, the network's domain
itself. Quote it: a bare @ is reserved in YAML. DNS needs an A/AAAA record
at the apex, or a CNAME that the DNS provider flattens (Cloudflare does).
Given a request's Host header (port suffix ignored) and path:
- Exact host beats wildcard host. If any route is registered for the exact host, only routes for that host are considered, even if a wildcard route would also match.
- Wildcard hosts are written
*.example.comand match exactly one extra label:foo.example.commatches,example.comanda.b.example.comdo not. When multiple wildcard bases match a host (e.g.*.example.comand*.internal.example.comboth matchingx.internal.example.com), the longest base wins. - Longest
path_prefixwins among the routes that matched the host. A route with nopath_prefixmatches every path under its host, and counts as prefix length zero, so it only wins when nothing more specific matches.
No match returns 404.
A route carries one backend address per running replica. Requests round-robin
across them. When sticky is set, the proxy remembers the chosen backend in
a cl_sticky cookie (the backend's socket address, so pinning survives a
table reload as long as that backend is still in the pool); a request
carrying a valid cl_sticky cookie goes straight to that backend without
consuming a round-robin slot. If the cookied backend is no longer in the
pool, the proxy falls back to round robin and issues a new cookie.
Most routes come from a stack's x-clouduler.proxy and follow its containers
automatically. A route for something clouduler does not run at all — another
box on the network, a third party endpoint — is managed entirely from the web
ui instead, not cluster.yaml: like a redirect, one of these can be added or
changed without a daemon restart, since it lives in replicated state under a
reserved config name (see cl_api::static_routes) rather than the config
file every daemon has to be restarted to pick up. It is exposed at
/api/static-routes (GET/POST/PUT /{id}/DELETE /{id}, admin only to
write).
A rule is a host (optionally with a path_prefix) on a given network, and
a backend (host:port); tls, middlewares, max_body_size,
websockets, preserve_host and response_timeout all work the same as on
a stack's proxy route. An optional node pins the host's DNS records to that
one node's address, so requests are routed through it (useful when only that
node can reach the backend); left unset, every node is published.
backend's host is resolved fresh on every reconcile pass (every 5s), so a
DNS change on the backend takes effect without editing anything, and every
address it resolves to becomes a backend, round robined the same way multiple
replicas of a service are.
Both listeners bind 0.0.0.0 on every node, since a route's hostname can
resolve to any node. That means a route published on a private (or
internal) network would otherwise be reachable from any interface a node
has, including a public one. To prevent that, every route built from a
network with a subnet (internal or private) only accepts a connection
whose real TCP peer address falls inside that subnet; anything else gets
403. A route on a public network has no subnet and so no restriction —
it is meant to be reachable from anywhere.
This is a check on the actual socket peer, not a header, so it cannot be spoofed by a client claiming a different address. It applies uniformly to stack routes, static backends, redirects and the dashboard. There is no per-route opt-out and no forward-auth layer yet; both are deliberately out of scope for now.
Simple host/URL redirects are managed entirely from the web ui, not
cluster.yaml — they are expected to change often enough that editing the
config file for each one would be annoying. A redirect rule is a source
host (optionally with a path_prefix) on a given network, a destination
target URL, whether to append the original path and query
(preserve_path), and a status code (301/302/307/308). A matching
request gets a Location response built from the rule and never reaches a
backend at all. Rules are stored in replicated state under a reserved config
name (see cl_api::redirects) and exposed at /api/redirects
(GET/POST/PUT /{id}/DELETE /{id}, admin only to write).
Applied in this fixed order, regardless of the order they're listed in the stack file:
redirect_https— not actually part of the per-request middleware chain. Driven directly byroute.tls: the plain HTTP listener 308-redirects any request for atls: trueroute to the same host and path over https, before matching againstmiddlewaresat all. 308 preserves the method and body, unlike 301/302.strip_prefix— removes the route's ownpath_prefixfrom the request path before it reaches the backend./api/thingson a route withpath_prefix: /apibecomes/things.headers— sets or removes headers on the proxied request and on the response.cors— see below. Answers a preflight directly; addsAccess-Control-Allow-*to a real request's response when itsOriginis allowed.
There is no HTTP basic auth middleware. To put a login in front of an app
that has none, use authenticated (below).
Protects an app that has no login of its own, optionally for some groups only.
# cluster.yaml
proxy:
auth:
host: login # portal label, one hostname per network below
networks: [wan, lan] # networks that offer the login (login.<wan domain>, login.<lan domain>)
issuer: https://sso.example.com/application/o/apps/
client_id: clouduler-apps
groups_claim: groups # default
session_ttl: 7d # how long a login lasts (default 12h)
title: Sign in to continue
palette: grape # login page colours: grape, sunset, ocean, forest
accent: "#e5603c" # optional: overrides the palette's main colour
remember_ip: [public] # networks whose login page offers "remember this IP address" (default: none)
remember_ip_ttl: 30d # how long a saved address skips the login (default 30d)
# stack file
proxy:
- host: wiki
network: wan
port: 80
middlewares:
- authenticated: # bare `authenticated` = any user of the provider
groups: [family, admins] # at least one of these
allow_internal: true # default: containers skip the loginThis is a separate identity provider client and group namespace from
auth.oidc (which logs operators into the dashboard). Create the client with
one redirect URI per listed network, https://<portal host>/callback, then
store its secret (also under Settings, Service login in the dashboard):
PUT /api/proxy-auth/client-secret with {"client_secret": "…"} (admin; write
only, like /api/oidc/client-secret). The portal hostnames get DNS and a
certificate like any proxied host.
Per network. A login is once per network: each listed network has its own
portal and its own sessions, so signing in on lan does not sign anyone in on
wan. A route may only use authenticated on a network listed in
proxy.auth.networks; a stack asking for a login on any other network (or with
no proxy.auth at all) fails validation.
Flow. A request with no valid cl_proxy_auth cookie gets a 302 to
https://<portal>/login?callback=<url>. If the browser already has a portal
session, the portal immediately 302s back to
https://<app>/.well-known/clouduler-cookie?token=…&redirect=…, which spends
the token, sets the app's own cookie and 302s to the page originally asked
for: three redirects, no javascript, no click. With no portal session the
portal shows a sign-in page, runs the OIDC login, then does the same.
Non-navigation requests (non-GET, or an Accept without text/html) get a
401 instead of a redirect. A signed-in user outside the route's groups gets
a 403 page with a "use another account" link (portal logout).
State. Sessions and tokens are in raft, so any node accepts any login and
logins survive restarts; only a hash of each cookie/token is stored. A token
is valid for 60 seconds and can be spent once, cluster-wide. Cookies are
host-only and bound to their hostname; an app cookie never outlives the portal
session that produced it (session_ttl, fixed at login).
Remembering an IP address. On the networks listed in remember_ip, the
login page shows a "Remember this IP address" checkbox, and ticking it asks for
confirmation before logging in. The checkbox only appears when the visitor's
TCP peer address belongs to that network: inside its subnet for an internal or
private network, a globally routable address for a public one (never a header,
so it cannot be spoofed). A network not listed offers nothing, and a saved
address stops working if its network is removed from the list. The saved
address skips the login on that network for remember_ip_ttl, independent of
session_ttl, with the groups the user had at that login. Signing out at the
portal forgets the address. Anyone behind the same address, for example a
router doing NAT, is let in too, so turn it on only for networks where that is
acceptable. Groups are read at
login: a change at the provider applies from the next login. Signing out
(the portal's /logout, or /.well-known/clouduler-logout on any protected
host) removes the portal session and every app session handed out from it, so
all those cookies stop working at once.
The proxy strips its cookies before forwarding, so backends never see them.
Callbacks. The portal only redirects to a hostname whose route has
authenticated, so it is not an open redirect. The reserved paths
/.well-known/clouduler-* are answered by the proxy on protected hosts.
Containers skip the login by default (allow_internal): the check is on
the real TCP peer, which must be in a container overlay or nat subnet, or be a
node's internal address. Set allow_internal: false to require a login from
them too. Caveat: a host process on a node counts as internal.
cors carries its settings and is fully
enforced, since cross-origin access control is a property of the stack's own
application. Declared on a service's proxy route in the stack file:
x-clouduler:
proxy:
- host: api
network: wan
port: 80
middlewares:
- cors:
origins: ["https://app.example.com"]
methods: [GET, POST] # default: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS
headers: [] # empty echoes the browser's Access-Control-Request-Headers
allow_credentials: false
max_age: 10mAn OPTIONS preflight (one carrying Access-Control-Request-Method) is
answered directly with the allow headers and never reaches the backend. A
real request gets the same headers added to whatever the backend returned,
when its Origin matches (origins: ["*"] matches any). allow_credentials
cannot be combined with a wildcard origin — browsers reject that pairing, so
it is rejected at stack validation time instead of failing confusingly at
request time.
Every proxied request gets:
X-Forwarded-For— the connecting peer's IP, appended to any existing value already on the request (so a chain of proxies accumulates correctly).X-Forwarded-Proto—httporhttps, whichever listener accepted the connection.X-Forwarded-Host— the originalHostheader the client sent, before it gets overwritten with the backend's address for the outbound request.
Hop-by-hop headers (Connection, Keep-Alive, Proxy-Authenticate,
Proxy-Authorization, TE, Trailer, Transfer-Encoding, Upgrade, plus
anything named in an incoming Connection header) are stripped in both
directions, except on a websocket (or other) upgrade request, where
Connection/Upgrade are preserved so the handshake can complete.
A request with Connection: Upgrade and an Upgrade header is proxied
differently: the proxy opens its own HTTP/1.1 connection to the backend,
sends the request with the upgrade headers intact, and if the backend
responds 101 Switching Protocols, takes both the client and backend
connections out of HTTP handling (hyper::upgrade::on) and splices the two
raw byte streams together with tokio::io::copy_bidirectional. If the
backend does not upgrade, its normal response is relayed as-is.
Every hostname the reverse proxy answers for — stack routes, static
backends, redirects — plus the web ui's own address needs an actual DNS
record pointing somewhere, or nothing but a certificate exists for it.
clouduler::proxy::run_dns_reconcile publishes these automatically: every 60
seconds, on the leader only, it computes the desired record set from
ClusterConfig and replicated state (cl_dns::records_for/plan/apply),
diffs it against each Cloudflare zone's live records, and converges. Gated
only on a cloudflare section with a readable token being present — unlike
certificate issuance, it does not additionally require proxy.acme.enabled,
since a plain A record has nothing to do with TLS.
proxy.dns_publish decides what a stack route's record actually says:
all-nodes(default): since every node runs the reverse proxy and forwards internally, a hostname's records point at every node's address on the declared network, not just the node a stack's containers happen to be placed on —n1failing does not needblog.example.com's DNS to change forn2to keep answering it. This is Docker Swarm's routing-mesh model: reaching any node reaches the service.owning-node: DNS does the routing instead of the proxy. Only the node(s) actually running a service's containers (placement::service_nodes) are published, and a stale record left on any other node is deleted through the Cloudflare API the next timeplan/applyruns. Static backends and redirects aren't backed by a container, so they still publish every node's address in this mode. Because a hostname can only point at one place, the scheduler enforces an implicit placement constraint in this mode: every instance whosex-clouduler.proxyroutes publish the same hostname (say, one service serving/and another serving/adminon the same host) is co-located onto one node (cl_sched::place, viaDesired::proxy_hosts) — a placement request that cannot be satisfied is reported unplaceable rather than silently split across nodes.
A network can opt out of this with publish_dns: false on the network
(internal and private networks only). Its hostnames are then never sent to
Cloudflare, and records already published for it are deleted on the next
pass. They are answered by the client DNS server below, or by whatever DNS
the operator runs. Certificates for those hostnames are unaffected: DNS-01
only writes a challenge TXT record.
proxy.dns_server makes every node answer DNS itself, split-horizon style:
proxy:
dns_server:
enabled: true # default false; port 53 on node addresses is left alone otherwise
networks: [lan] # empty = every internal/private network the node has an address on
port: 53Each node listens on its own address on those networks
(cl_daemon::client_dns). The network a query comes from is decided by the
client's source address (ClusterConfig::client_network): the subnetted
network whose subnet holds it, else the public network for a globally
routable address. Answers follow:
- A hostname on a public network is answered to everyone. A client inside
a private network gets each serving node's address on that network (a LAN
client gets the node's LAN address, so it skips the router and the proxy
sees its real address, which
proxy.auth.remember_iprelies on). A node with no address on the client's network falls back to its address on the route's network. - A hostname on a subnetted network is answered only to clients inside
that subnet, exactly the proxy's network gate. Anyone else gets
REFUSED. - Which nodes are named follows
proxy.dns_publish, and the web ui hostname names the leader alone, as in the published records. - Names the cluster does not own are forwarded to the host's resolvers for
clients inside a subnetted network, and
REFUSEDfor everyone else, so listing a public network never makes the node an open resolver. A public address is only bound if this node actually holds it (often it sits on the router), otherwise the bind is retried quietly.
Config changes take effect without a restart. This is separate from the
container-facing resolver on the overlay gateways (internal_dns).
Records are tagged with an ownership TXT record the same way
cl_dns::reconcile always has, so a zone can be shared with records
clouduler doesn't manage. A record apply failure (e.g. a name colliding with
something else in the zone) is attributed to the specific hostname(s) it
names, not blanket-applied to every hostname sharing that Cloudflare zone —
one stack's DNS problem no longer shows up on an unrelated stack's records.
Applied changes are also written to replicated state
(Request::UpsertDnsRecord/RemoveDnsRecord), which is what the ui's
Certificates & DNS page reads (GET /api/dns) — that page was reading real
state from day one, but nothing was ever writing to it until this loop
existed to do so.
CertStore holds one parsed rustls::sign::CertifiedKey per fqdn (exact or
*.domain wildcard), loaded from cl_raft::CertificateState records and
swapped in atomically on every raft state change. Certificates are
distributed through raft, not issued per node, so any node can terminate TLS
for any route regardless of which node holds the raft leadership at a given
moment.
The HTTPS listener uses one shared rustls::ServerConfig whose
ResolvesServerCert implementation (CertResolver) picks the certificate by
SNI: exact fqdn first, then the longest matching wildcard base. Before
completing a handshake, the listener peeks the ClientHello (via
tokio_rustls::LazyConfigAcceptor) to read the SNI name and calls
CertStore::resolve for it. If nothing has been issued for that name yet,
resolve generates and caches a self-signed certificate on the spot (logged
at warn when generated, so a self-signed cert in front of real traffic is
never silent) and the handshake proceeds; ResolvesServerCert::resolve
itself only ever needs to do a synchronous lookup after that, since the
fallback is already cached.
acme::issue_certificate drives one ACME order to completion over DNS-01:
- Submit a
NewOrderfor the given names (which may include a*.domainwildcard entry; instant-acme submits it to the CA as*.domainand the CA authorizes the base domain with a wildcard flag). - For each pending authorization, fetch the
dns-01challenge, compute its key authorization digest, and callcl_dns::set_challengeto create the_acme-challenge.<domain>TXT record. cl_dns::await_propagationconfirms the record is visible two ways before the challenge is marked ready: first a quick poll of Cloudflare's own API (fails fast if the record was never created), then a poll of the zone's actual authoritative nameservers, resolved fresh and queried directly rather than through a possibly cache-poisoned recursive resolver. The first check alone used to be treated as "propagated," but a Cloudflare API write can land in their control plane several seconds before it's visible on the edge network the CA actually queries, which read as a real, repeatable "order not ready: Invalid" failure — Let's Encrypt had already attempted and failed the DNS-01 lookup by the time we asked it to.- Once every authorization is satisfied, poll the order to
Ready, finalize it (which generates the certificate key and CSR internally viainstant-acme), and poll for the issued chain. - Every TXT record created during the order is cleared via
cl_dns::clear_challenge, whether issuance succeeded or failed, so a failed order never leaves stray challenge records behind.
The leaf certificate's notAfter is parsed out of the returned chain
(x509-parser) and stored alongside the PEM chain and key in a
CertificateState, written to raft via Request::StoreCertificate.
The ACME directory URL is configurable (AcmeConfig::directory_url).
AcmeConfig::default() points at the Let's Encrypt production directory;
LETS_ENCRYPT_STAGING_URL is exposed as a named constant for tests and the
qemu harness, which should point at a local Pebble server via a custom
directory_url instead of either Let's Encrypt endpoint.
Issuance and renewal run only on the leader, and only when proxy.acme.enabled
is set and a Cloudflare token is readable. Several nodes ordering the same
certificate at once would burn the CA's rate limit and race each other writing
the result back. Every five minutes the leader:
- orders a certificate for each
tls: trueroute hostname, and for the web ui's own fqdn, that has none stored yet; - runs
renewal_tickover everything that does have one.
Cloudflare zone ids are resolved from cloudflare.zones at the start of every
issuance/renewal pass, not cached across ticks: the token is a replicated
cluster secret rather than a file read once at startup, so it can be set or
rotated from the web ui while the daemon is already running. The longest
matching zone wins, so a delegated subdomain zone is preferred over its parent.
The api serves the ui with the stored certificate for its own fqdn when there is one, and generates a self-signed one when there is not, which is what makes a brand new cluster reachable before anything has been issued.
acme::load_or_create_account persists ACME account credentials
(instant_acme::AccountCredentials, which embeds the account's private key
and directory URL) as JSON at a configured path, creating a new account only
when that file doesn't exist yet.
acme::renewal_tick runs one pass over the current raft leader's certificate
store: any certificate within RENEWAL_WINDOW (30 days) of not_after is
reissued and written back with Request::StoreCertificate. It is a no-op
on a follower, so it's safe to call unconditionally from every node's timer;
acme::run_renewal_loop does exactly that on a fixed interval. Zone
selection (which Cloudflare zone a given fqdn's challenge record belongs in)
is supplied by the caller through the ZoneResolver trait, since that
mapping lives in the cluster config, which this crate does not depend on.
Every hostname above assumes it sits under a zone listed in cloudflare.zones,
so its ACME challenge can be answered with a _acme-challenge TXT record.
That doesn't hold for a domain bought and pointed at the cluster without ever
being added to Cloudflare — zones.zone_id_for finds nothing for it, and by
default issue_missing/renewal_tick record
ReconcileStatus::Error("no cloudflare zone covers this hostname") and give
up rather than guess.
Writing the hostname in a stack's x-clouduler.proxy (or a static route)
with a trailing dot, e.g. host: gitlab.example.net., opts that one name out
of that error and into ACME's other standard challenge, HTTP-01, instead:
cl_config::qualifystrips the trailing dot from the fqdn actually used for routing/certificates, so the dot itself never reaches DNS. The raw, still-dottedhoststring is whatclouduler::proxy::tls_hostschecks (host.ends_with('.')) to mark the resulting fqdn as HTTP-01-eligible — this happens in the daemon, notcl-proxy, since it's the caller that knows the raw stack configqualifyalready discarded the dot from.acme::issue_certificate_http01orders the certificate the same way asissue_certificate, except each challenge's key authorization is written into an in-memoryHttp01Store(token -> key_authorization) instead of a DNS TXT record, and the challenge type requested isHttp01notDns01.- The plain-http listener (
server::handle_http) answersGET /.well-known/acme-challenge/<token>straight out of that store, ahead of any routing decision and regardless ofHost— the CA's validator has no reason to send aHostclouduler would recognize, and the hostname being validated may have no route yet on a first issuance. This means the domain's own DNS (however it's managed) must point at the cluster and its plain HTTP port must be reachable from the internet; nothing here publishes that A/AAAA record, since it isn't in a zone clouduler can write to. - The
Http01Storeis created once per proxy supervisor run and shared between thelistenandacmetask groups regardless of which one restarts on a config change — a token the issuance task hands out must still be answered by whichever listener instance is live when the validator asks for it.
Renewal recomputes the same tls_hosts allow-list from current config on
every tick rather than remembering how a certificate was originally issued,
so a certificate keeps renewing through HTTP-01 for as long as its stack
still declares the host with a trailing dot, and switches back to DNS-01
automatically if the domain is later added to a Cloudflare zone.
A stack can limit which hosts its containers ask the proxy for with
allowed on a network (docs/stacks.md, "Restricting what a stack can
reach"). The proxy identifies the caller by the TCP peer address, never a
header, against the overlay subnets of the stacks that declared a list
(cl_proxy::PeerPolicies, rebuilt from replicated state every reconcile pass).
When the route found for the request is published on a network the caller's
stack listed, and the request's Host is not on that list, the answer is a
403 naming the stack, host and network. A route on a network the stack did
not list is unaffected, and a host with no route is still a 404.
The check runs after the route lookup and the network gate. With no restricted
stack the table is empty and a request pays one atomic load. Otherwise it scans
the restricted subnets and does a hash lookup only for a caller inside one.
host:port entries never reach the proxy, the firewall enforces those.
A stack's own routes are exempt: a container that requests a hostname its own
stack publishes on that network passes without a list entry. Only hostnames
declared in the stack's own x-clouduler.proxy count, and only on the network
each is published on. Calls to sibling services by service name never reach the
proxy and need no entry.