Edge-CA trust: making server-side OIDC survive a non-public ACME directory
STATUS: LANDED, all runtimes. The CE core change is
ce@8bc38bc(modules/app/edge_ca.go,modules/app/inject_ca_trust.go,modules/app/system_ca_root.go), the probe-backoff follow-upce@2c62b54, and the JVM legce@6e5d9aa(modules/app/jvm_ca_trust.go).go.modis pinned at the last of those. This file records the design, the per-app consumption survey, and what remains.
The bug
Section titled “The bug”InjectDerivedKeycloakIssuer hands every SSO consumer a public https
issuer — the pinned Keycloak origin on SHC’s own edge — and roughly two
dozen of those apps run OIDC discovery server-side at boot.
When the estate sets acme.providers.<name>.directory to anything other
than Let’s Encrypt production (LE staging, an in-house Pebble or
step-ca), the edge leaf chains to a root that no container image ships. The
server-side dial then fails with UNABLE_TO_GET_ISSUER_CERT_LOCALLY, and
because discovery typically runs once at boot with no retry, the app’s
SSO is broken permanently, from first boot, with no self-heal.
Live-hit on huly’s account service against a certResolver: acme-staging-dns-hetzner estate. Nothing in SHC knew the estate was on a
staging CA, and nothing gave any app the anchor for it.
The fix, in two halves
Section titled “The fix, in two halves”1. The trust bundle learns the edge chain
Section titled “1. The trust bundle learns the edge chain”MaterializeTLSTrustAnchors writes the node’s trust bundle: the host’s
public CA bundle ∪ the SHC global root. It now also carries the CA chain
the estate’s own edge actually presents.
Where the CA comes from, and why.
| candidate source | verdict |
|---|---|
| a downloaded LE-staging root, pinned as a constant | rejected — staging roots rotate and expire; a baked constant is a time bomb |
| the ACME directory URL’s own TLS chain | rejected — Boulder’s staging directory is itself served by a public cert, so the chain is the wrong one |
what the ACME client stored (traefik acme.json) | rejected — lives in a docker volume, format-coupled, and still stops at the intermediates |
| a TLS handshake against the edge | chosen — it is the ground truth, byte-identical to what the app will be shown, and reuses the probe/host/port seams the stalled-ACME watcher already runs (AcmeRouterHosts, DeployedTraefikHTTPSPort) |
The served chain is not sufficient on its own. Measured against a live LE-staging edge, traefik serves
leaf → (STAGING) Ersatz Emmer YR2 → (STAGING) Yonder Yam Root YRand that last certificate is cross-signed by (STAGING) Pretend Pear X1 — a CA, but not self-signed. OpenSSL (and therefore Node, PHP, Ruby,
Python, .NET) will not stop at a non-self-signed anchor unless
X509_V_FLAG_PARTIAL_CHAIN is set, which none of them set. Verified: a
trust store holding the complete served chain still fails
openssl verify with error 2 ... unable to get issuer certificate.
So the collector continues through the topmost certificate’s AIA caIssuers pointer until it reaches a self-signed root — precisely the hop the verifier cannot make offline. Go’s own verifier would have accepted the cross-signed anchor, which is exactly why this had to be measured against OpenSSL rather than reasoned about.
AIA is plain HTTP by protocol. That is made harmless by a
cryptographic gate rather than by transport security: a fetched candidate
is accepted only when child.CheckSignatureFrom(candidate) succeeds. An
attacker can substitute bytes on the wire but cannot produce a certificate
whose key verifies the real intermediate’s signature.
2. The union is injected where it is needed
Section titled “2. The union is injected where it is needed”Making the union richer is only half a fix — before this, exactly two of the ~32 SSO-consuming recipes read it. Requiring the other thirty to each opt in is how a systemic bug stays systemic: every future SSO recipe would have to remember, and none of them can reproduce the failure without a staging-CA estate.
So the platform injects it. Any service whose rendered spec references an
https:// URL on one of the estate’s own ACME-covered hostnames gets
the bundle bind-mounted at /etc/shc/tls.ca plus the trust-file
environment variables its runtime reads:
| variable | runtimes |
|---|---|
SSL_CERT_FILE | Go, Ruby, PHP, Rust, .NET on Linux, curl (OpenSSL default store) |
NODE_EXTRA_CA_CERTS | Node — adds to the baked-in Mozilla roots |
REQUESTS_CA_BUNDLE | Python requests |
CURL_CA_BUNDLE | libcurl consumers that ignore SSL_CERT_FILE |
No single variable covers that set, which is why four are set; a runtime that does not know a variable simply ignores it.
The JVM reads none of those four. It reads exactly one keystore, named
by javax.net.ssl.trustStore, in JKS or PKCS12 form — so it gets its own
leg (below).
2b. JVM services get a truststore built from their own image
Section titled “2b. JVM services get a truststore built from their own image”InjectJVMEdgeCATrust runs immediately after the PEM leg, over the same
compose, and covers the same predicate. For every matching service whose
image is JVM-based it stamps a read-only bind of
/etc/shc/tls.p12 plus
JAVA_TOOL_OPTIONS=-Djavax.net.ssl.trustStore=/etc/shc/tls.p12 -Djavax.net.ssl.trustStorePassword=changeit -Djavax.net.ssl.trustStoreType=<detected>JAVA_TOOL_OPTIONS is the one variable the JVM itself reads, before
main() and therefore before the first SSLContext — the app-specific
knobs recipes set (JAVA_OPTS, JAVA_OPTIONS, EXTRA_JAVA_OPTIONS,
JAVA_OPTS_APPEND) are consumed only by that image’s launcher script. It
is prepended to any value a recipe already set, so a recipe that names
its own trustStore still wins (within one JAVA_TOOL_OPTIONS the last
-D takes effect).
The store is built inside the app’s own image, not converted on the host:
cp $JAVA_HOME/lib/security/cacerts → copykeytool -importcert (one per anchor) → copyone throwaway docker run --network none --entrypoint= per
(image, anchors), content-addressed and cached under CacheHome, result
written to <deployDir>/files/ and bound read-only exactly like the PEM
union. The build runs before injection: a javax.net.ssl.trustStore
pointing at a path that does not exist makes the JVM trust nothing, and
docker would helpfully materialise the missing bind as an empty directory.
Every failure mode (no docker, no keytool, unpullable image,
unrecognised keystore) fails soft to exactly the pre-change behaviour.
Why not simply convert the PEM union to PKCS12 host-side. Two independent measured reasons, either one disqualifying:
| measurement | |
|---|---|
It de-trusts. javax.net.ssl.trustStore replaces the JDK’s cacerts; it does not extend it. A JVM image’s cacerts is not a subset of the daemon host’s bundle. | SHA-256 diff against the real images: 5 roots in Temurin 25 (dependency-track) absent from Debian trixie’s ca-certificates — TrustAsia RSA + ECC, SwissSign RSA TLS 2022-1, OISTE Server Root RSA + ECC G1, all unexpired, all anyEKU. 29 for Zulu 25 (penpot), 24 of them unexpired. |
It does not work. Go’s stdlib cannot write PKCS12, so the host route is openssl pkcs12 -export -nokeys. OpenSSL omits the Oracle trusted-certificate attribute (2.16.840.1.113894.746875.1.1) Java needs to surface an entry as a trustedCertEntry. | Against Temurin 25: keytool -list reports “0 entries”; a real handshake dies with InvalidAlgorithmParameterException: the trustAnchors parameter must be non-empty. The JVM would trust nothing at all — strictly worse than the bug. |
Copying the image’s own cacerts is instead strictly additive:
144 → 147 entries for dependency-track, 109 → 112 for penpot. Nothing is
ever removed.
The JVM-ness test is a cheap local docker image inspect over
Config.Env/Entrypoint/Cmd, deliberately generous — an image that is
not pulled yet answers “maybe”, and the builder decides. A false positive
costs one short offline container; a false negative would silently re-open
the bug.
Why automatic injection is safe
Section titled “Why automatic injection is safe”Both halves are gated on an ACME provider declaring an explicit
directory, read off the TRAEFIK_ACME_PROVIDERS carrier the daemon
already produces for the system traefik render (no new daemon wiring, no
config import in modules/app, and an empty directory is already SHC’s own
encoding of “Let’s Encrypt production”).
Consequences:
- Stock estates are untouched. No probe, no fetch, byte-identical composes and a byte-identical CA union. Unit-pinned.
- Where it fires, SSO is already broken. The change can only move those estates from “cannot validate the edge” to “can”.
- Trust is only ever added. The union is a superset of the host’s public
roots, so pointing
SSL_CERT_FILEat it never removes trust a container had. Nothing is replaced and nothing else is broadened — the anchors are the estate’s own edge chain, each link cryptographically verified. - Recipes win. A service that already mounts the union is skipped whole; an environment variable the recipe set is left alone.
- The system ingress stack is excluded. traefik and forward-auth get their material through the dynamic-dir bundle (which gained the same anchors), and injecting into the stack that terminates the edge would churn cluster ingress for no benefit.
- Idempotent. A second pass over an already-injected compose is a no-op,
so
shc updatedoes not recreate containers forever.
Everything fails soft. A wedged edge, a firewalled AIA URL, or a chain that stops early yields the last known anchor set (or none) — never an error, and never a failed render.
3. One canonical name, in a reserved namespace
Section titled “3. One canonical name, in a reserved namespace”/etc/shc is a reserved, platform-owned mount namespace. Inside a
compose recipe an absolute source under it is not a host path — it is a
name the platform rewrites to whichever host file backs it on this node —
so a recipe writes the same string on both sides of the bind and never
learns the host layout:
volumes: - /etc/shc/tls.ca:/etc/shc/tls.ca:ro| container path | what it is |
|---|---|
/etc/shc/tls.ca | the PEM trust anchors (host roots ∪ SHC global root ∪ edge chain) |
/etc/shc/tls.p12 | the JVM truststore (the app image’s own cacerts ∪ the edge anchors) |
Named for what the file is to the container, not for how the platform
builds it. Three spellings of one file (/etc/shc/ca-union.pem,
/etc/shc/ssl-union.pem, /etc/dex/shc-ca-union.pem) collapse into
tls.ca; /etc/shc/ca-truststore.p12 becomes tls.p12.
core/volume resolves the namespace and fails closed on an unknown
leaf. That matters: on a root install the host’s real /etc/shc is SHC’s
own config home, so a typo like /etc/shc/tls-ca would otherwise bind
daemon configuration into a tenant container. Both mounts also require
:ro — the host files are node-scoped and shared by every tenant, so a
writable bind would let one tenant rewrite the anchors every other tenant’s
containers load.
The “should I materialize the file” trigger keys on the container path, the one token that survives volume resolution (the source half is rewritten, the target half never is). The old trigger keyed on the host file name, which would have stopped firing the moment the host location moved — leaving the mount present and the file absent, i.e. a directory where a trust store should be.
4. Out of backups, by construction
Section titled “4. Out of backups, by construction”Both artefacts moved from <deployDir>/files/ to <CacheHome>/mounts/.
A stack backup archives the deployment dir wholesale — the deployment
tar carves out only mounts/ and app/, and the default restic snapshot
path is the deployment dir — and modules/backup has no per-file
exclusion mechanism at all (backup.yaml’s exclude.mounts reaches mount
names only). So every archive carried a derived, node-specific trust bundle
and truststore, and restore copied the stale ones back over the fresh ones.
Restoring one estate’s edge anchors onto another estate is actively wrong.
Living outside deployments/ is the only thing that makes that impossible,
and it needs no exclusion list anyone could forget to maintain.
CacheHome is also the layout’s documented home for regenerable material,
survives reboots (unlike RuntimeDir, a tmpfs wiped on unit stop) and the
tmpfiles age sweep (unlike <StateHome>/tmp), and is untouched by
removeRenderedArtifacts, which only ever walks a deployment dir.
Writes are skip-if-identical then atomic-rename, so a repeat render does not touch the inode — docker binds a file by inode, and a live container must not have its trust store swapped or torn mid-read.
5. Any node can obtain the anchors
Section titled “5. Any node can obtain the anchors”The chain was derived from a 127.0.0.1 handshake against traefik’s
websecure listener, using hostnames read from the traefik dynamic dir —
both of which exist only on the Traefik host. The ingress sweep writes
the dynamic dir behind AmITraefikHost, and nothing listens on the
websecure port anywhere else. On every other node the hosts came back
empty, the dial was refused, the anchors were nil, and the app rendered
with no edge trust and no error.
That is not a corner case: stack.install is dispatched to the node a
stack targets, so a worker-hosted app is rendered on the worker.
Derivation does not move. The Traefik host still probes its own listener
and walks AIA; it publishes the result — anchors and the ACME hostnames
— on /internal/rpc/edge_ca.anchors, and every other node fetches it.
| decision | choice |
|---|---|
| who answers | the node hosting Traefik (app.TraefikHostNode, the same election the keycloak backchannel dialer uses) — not the raft leader; only the node terminating TLS knows which certificate the edge presents |
| transport | the internal mTLS listener; every /internal/rpc handler requires CapabilityCluster, so the caller has already proven a cluster-CA client cert. The payload is public certificate material either way |
| caching | the existing in-process anchor cache fronts both the local probe and the peer call, so a worker makes at most one round trip per retry window (2 min while unresolved) or TTL (30 min once resolved) |
| no certificate yet | the handler answers empty; the caller keeps its last known set. Same bootstrap semantics as before, now uniform across nodes |
| self-call | the daemon closure refuses to RPC the local node, so the Traefik host always uses its own probe |
Known limitation: the bootstrap window
Section titled “Known limitation: the bootstrap window”Anchors only exist once the edge is serving a real ACME certificate. An app
installed before the estate’s first successful issuance renders without
them; the next update/recreate picks them up, because the bundle is
re-materialized on every render. Anchors are cached in-process for 30
minutes, so a CA rotation self-heals within one TTL.
Known limitation: swarm host binds
Section titled “Known limitation: swarm host binds”Under the swarm runtime a task may schedule on a node other than the
one that rendered, and the platform mounts are host binds materialized on
the rendering node. This is a pre-existing, systemic property of every
FILE/SYSTEM mount in the catalogue (only DATA mounts are kept as
docker-managed named volumes for swarm), not something this change
introduces — but it is worth naming, because for the JVM leg the
consequence is harsher: an absent truststore becomes an empty directory and
the JVM then trusts nothing. Under compose, the supported
multi-node path, the renderer and the runner are the same node, so the
artefacts are always local. Closing the swarm case properly means teaching
swarm about platform mounts (a config/secret, or a per-node materialize
pass), and is deliberately out of scope here.
Per-app consumption survey
Section titled “Per-app consumption survey”32 apps ship apps/<name>/sso.plug.yaml. Before this change, two mounted
the CA union.
Recipe opt-in, kept — now on the canonical path:
| app | mount | trust mechanism |
|---|---|---|
| huly | /etc/shc/tls.ca on account | NODE_EXTRA_CA_CERTS |
| openobserve | /etc/shc/tls.ca on openobserve | SSL_CERT_FILE (distroless — no entrypoint wrapper possible) |
| openobserve | /etc/shc/tls.ca on dex | connector rootCAs |
| affine | /etc/shc/tls.ca on affine | NODE_EXTRA_CA_CERTS (added 2026-07-27 — the injection cannot see it, below) |
Why openobserve’s opt-in cannot simply be deleted in favour of the
injection. serviceDialsEdge does match both services — openobserve
via O2_DEX_BASE_URL, dex via the issuer inside its command heredoc —
so on a staging-CA estate the injection would cover them. But the injection
is gated on non-empty anchors, i.e. it fires only on an estate whose
ACME provider pins a non-production directory. openobserve needs the bundle
on every estate: the enterprise image is distroless, so it cannot build
one at entrypoint, and dex’s connector rootCAs replaces the system pool
— on a stock public-CA estate, dropping the mount would leave dex unable to
validate Keycloak’s ordinary public leaf. The recipe gate (shc.ca_root_b64)
is a strict superset of the injection’s, which is exactly why it stays.
huly’s account is in the same position for the weaker reason that its
NODE_EXTRA_CA_CERTS is additive; it is kept for symmetry and because the
recipe documents which of huly’s services actually dials the edge.
Why affine had to be moved OUT of the injected set (live, 2026-07-27). The
survey below originally listed affine as covered. It is not, and the reason
generalises: serviceDialsEdge searches the service subtree, and AFFiNE is
the one app in the catalogue whose OIDC issuer is not in its service at all —
it lives in a top-level configs: entry (the rendered config.json, since
AFFiNE dropped OAUTH_OIDC_* env in its 0.2x config rewrite). The service’s
only own-edge https:// URL is AFFiNE’s own AFFINE_SERVER_EXTERNAL_URL,
and on a first install that host is not an ACME router host yet — its route is
created BY the install — so the predicate matches nothing and the app renders
with no trust. Observed on the promanager hel1 corp estate (LE staging):
ERROR [OIDCProvider] Failed to validate OIDC configurationTypeError: fetch failed / Error: unable to get local issuer certificateLOG [OAuthProviderFactory] OAuth provider [oidc] unregistered.→ graphql serverConfig.oauthProviders = [] (sign-in modal shows no SSO button)Keycloak had minted the client correctly and config.json carried the right
issuer/clientId/secret — the only missing piece was the anchor. The recipe
now opts in, which also removes the first-install ordering dependency. See
“Still owed” for the predicate fix that would cover the class.
Now covered by platform injection (no recipe change needed). These consume a public-https issuer, had no CA trust at all, and run on a runtime one of the four injected variables reaches:
blockscout, defectdojo, gitea, gitlab, highlight,
kutt, librechat, mailcow, matomo, matrix, minio, monica,
netbird, nextcloud, paperless, peertube, postiz, supabase,
vaultwarden, windmill
(affine was in this list and did not belong — it moved to the recipe opt-in
table above. Every app that stays here carries its issuer in the SERVICE, which
is the only place the predicate looks.)
Unaffected as wired — these resolve an internal http:// issuer, so no
CA is involved: immich, mautic, wordpress, frappe. If any is ever
repointed at the public issuer it falls into the injected set automatically.
JVM survey
Section titled “JVM survey”The catalogue was swept for services that are BOTH JVM-based AND match the
injection predicate (their rendered spec names an https:// URL on one of
our own ACME hostnames):
| app / service | image | dials our edge? |
|---|---|---|
dependency-track / dtrack-apiserver | Temurin 25 | yes — ALPINE_OIDC_ISSUER, discovery + JWKS at boot |
penpot / penpot-backend | Zulu 25 | yes — PENPOT_OIDC_BASE_URI discovery + token exchange |
keycloak / keycloak | Quarkus | matches on KC_HOSTNAME, but that is self-advertisement, not a dial |
metabase / metabase | Clojure uberjar | matches on MB_SITE_URL — link generation only |
matrix / jitsi-jicofo, jitsi-jvb | Java | match on PUBLIC_URL — templated into config, XMPP internally |
JVM but no own-edge https anywhere, so out of scope entirely: sonarqube,
wazuh-indexer, graphiti/neo4j, paperless/tika, paperless/gotenberg,
huly/elastic, highlight/kafka, highlight/zookeeper.
So dependency-track and penpot are the complete set that genuinely
needs this; the other three take a superset of their own trust and are
unaffected either way. No recipe changes — the platform covers them,
and a JVM app added to the catalogue tomorrow is covered for free.
Still owed (per-app work)
Section titled “Still owed (per-app work)”minio’ssso.plug.yamlclaims the derived issuer is internal, which contradictsInjectDerivedKeycloakIssuer’s public derivation. Worth reconciling — the injection covers it either way, but the comment is misleading.- Port the CE-tree unit coverage (
modules/app/edge_ca_test.go,edge_ca_peer_test.go,inject_ca_trust_test.go,jvm_ca_trust_test.go,platform_mount_test.go,system_ca_union_edge_test.go,core/volume/platform_mount_test.go) intotests/unit/if useful. - Live validation on a staging estate. The chain walk, the resulting
trust decision, and both consuming runtimes were proven read-only
against a live staging edge (below); an end-to-end
shc installon such an estate has not been run, and neither has a live multi-node fetch ofedge_ca.anchorsfrom a worker. serviceDialsEdgeis blind to a service’sconfigs:/secrets:. A compose top-levelconfigs:entry is app configuration in every sense that matters — affine’s OIDC issuer lives in one — but the predicate only walks the service subtree, so a service that references such an entry (configs: [{source: X}]) is judged on its env/command alone. Resolving the referenced top-level entries into the searched text would have covered affine for free and closes the class. Until then the affected recipe opts in by hand.- Inert predicate matches.
keycloak,metabaseand the two jitsi services matchserviceDialsEdgeon a self-advertised or link-generation URL rather than a real dial, so on a staging-CA estate they take a container recreate (and, for keycloak, a truststore) they do not need. Harmless — the truststore is a superset of their own — but tightening the predicate to ignore self-advertisement keys would save the churn.
Evidence
Section titled “Evidence”Against a live LE-staging edge, read-only, driving the production collector and AIA fetcher:
anchor: subject="CN=(STAGING) Ersatz Emmer YR2,O=Let's Encrypt,C=US" selfSigned=falseanchor: subject="CN=(STAGING) Yonder Yam Root YR,O=ISRG,C=US" selfSigned=falseanchor: subject="CN=(STAGING) Pretend Pear X1,O=(STAGING) ISRG,C=US" selfSigned=trueopenssl verify of the served leaf:
host bundle only → error 20 ... unable to get local issuer certificatehost bundle + edge anchors → OKNode v25 TLS against the same edge:
default trust → authorizationError = UNABLE_TO_GET_ISSUER_CERT_LOCALLYNODE_EXTRA_CA_CERTS=<the new union> → authorized = trueThe first line is verbatim the failure huly’s account service died on.
JVM leg
Section titled “JVM leg”Driving the production builder (buildJVMTrustStore) on the anchors the
production collector had just walked, then a real JVM in the real app image
against
https://auth.corp.example.com/realms/corp/.well-known/openid-configuration:
store name=shc-ca-truststore-72f46018958e.p12 type=PKCS12 bytes=192245second call → cache hit, 0ms, byte-identical
dependency-track (Temurin 25) before → javax.net.ssl.SSLHandshakeException: PKIX path building failed: SunCertPathBuilderException: unable to find valid certification path to requested target after → OK http=200 {"issuer":"https://auth.corp.example.com/realms/corp",…
penpot (Zulu 25) before → same SSLHandshakeException after → OK http=200Additivity, through the merged penpot store — public TLS is untouched:
https://letsencrypt.org/ OK http=200https://api.github.com/ OK http=200https://pypi.org/ OK http=200And the disqualifying counter-measurement for the host-side conversion,
same JVM, same anchors, store produced by openssl pkcs12 -export -nokeys:
keytool -list → "Your keystore contains 0 entries"handshake → SSLException: InvalidAlgorithmParameterException: the trustAnchors parameter must be non-empty