The CA certificates an operator adds to the ones the node's platform trusts,
named by YMER_NODE_EXTRA_CA_CERTS and loaded at boot; every TLS connection
the node makes trusts them alongside the platform's own.
A node behind a proxy that re-signs TLS needs to trust a certificate its platform does not ship. Which one is the operator's alone to know, so the node carries none of its own: the operator names one PEM file, and this module puts its certificates into the VM-wide CA cache beside the platform's. Each should be a self-signed CA certificate, the one a chain ends at: Mint verifies a chain against the cache only up to a self-signed certificate, so an intermediate named here loads, and is logged, but anchors no chain.
One cache, loaded once
OTP keeps a single CA cache, :public_key.cacerts_get/0, and Mint reads it
for every connection that passes no CA option of its own — every script
request through YmerNode.Script.Context.request/2, the browser service
client's calls, and any other request the node makes through Req or Mint.
Loading the cache once, at boot, is what reaches every one of them with no
per-caller code. A script that passes its own :cacerts or :cacertfile
trusts only what it names: Mint uses the option in place of the cache, never
beside it.
A connection made outside Mint never reads the cache, so it does not trust
the extra CA certificates. Typst's package downloads are one: Typst fetches a
@preview package with its own TLS client, inside its native library, which
trusts only the CA certificates compiled into it.
:public_key.cacerts_load/1 replaces the cache, and OTP has no call that
adds to it, so the node combines. It loads the platform's CAs the
platform's way, :public_key.cacerts_load/0 — a bundle file on Linux, the
system keychains on macOS — and then loads those certificates and the extra
ones together, through one temporary PEM file it writes, loads and removes.
The platform's set is never read from a file path of the node's choosing,
so a host-run release on macOS trusts what its keychains hold, as it would
without this module.
The load is the application's first child, ahead of every child that can make a request: the cache fills itself lazily with the platform's CAs alone at the first connection that finds it empty, so a request made before the load would trust less than the operator named. With no file named the module does nothing, and the cache loads lazily as it always has.
The file, and when it refuses boot
The file must be a readable regular file of PEM blocks, every block a
certificate that decodes, and at least one. Text outside the blocks is
allowed — the subject= and Bag Attributes lines an export writes — and
so are CRLF line endings. Anything else refuses boot, with one message per
cause naming the variable, the path, what was found and its fix, each
ending with the way to boot now: unset the variable.
The node classifies the file itself rather than leaving it to OTP, whose
own load cannot word a refusal: it answers {:error, :enoent} for a
missing file, an empty one, a DER file and a key alike, and it loads the
good certificates of a file with a damaged block beside them without a
word. A node trusting part of what its operator named, silently, is worse
than one that says which block broke. A key among the certificates usually
means the wrong file was named, so it refuses too.
An expired certificate, or one that is not a CA, is not refused: the operator chose it, and the handshake judges it as it judges any anchor.
With the variable set, the platform's own CAs failing to load refuses boot
as well: trusting the extra certificates alone would leave every other host
untrusted, and each connection to one would fail far from this cause. A
successful load logs one line naming the file, how many certificates it
added and each one's subject — what an operator checks in
docker compose logs to see that the right file was read.
Summary
Functions
Returns a specification to start this module under a supervisor.
Loads the platform's CAs and the extra CA certificates in path into the
CA cache as one set, and logs one line naming what it added.
The PEM file YMER_NODE_EXTRA_CA_CERTS names, expanded at boot by
config/runtime.exs; nil when the variable is unset or blank.
Reads the extra CA certificates in path: a readable regular file of PEM
blocks, every block a certificate that decodes, and at least one.
The subject of the certificate der: each attribute as <name>=<value>, in
the certificate's own order, joined by , — the form
openssl x509 -noout -subject prints for a subject of single-valued
attributes. Values are written as they are, never escaped; the attributes
of a multi-valued RDN are joined by +, an attribute with no short name is
written by its dotted OID, and a value OTP does not decode is written as
# and the hex of its DER, RFC 4514's form.
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
Loads the platform's CAs and the extra CA certificates in path into the
CA cache as one set, and logs one line naming what it added.
Raises ArgumentError, carrying the message an operator reads in the boot
log, when the file breaks the rule read/1 checks — before the platform's
CAs are touched — or when the platform's own CAs do not load.
load_platform is that load, :public_key.cacerts_load/0; it is an
argument only so a test can stand in a platform that fails.
The PEM file YMER_NODE_EXTRA_CA_CERTS names, expanded at boot by
config/runtime.exs; nil when the variable is unset or blank.
Reads the extra CA certificates in path: a readable regular file of PEM
blocks, every block a certificate that decodes, and at least one.
Answers each certificate's DER in file order, or {:error, message} with
the refusal an operator reads — one message per cause, naming the variable,
the path, what was found and its fix.
The subject of the certificate der: each attribute as <name>=<value>, in
the certificate's own order, joined by , — the form
openssl x509 -noout -subject prints for a subject of single-valued
attributes. Values are written as they are, never escaped; the attributes
of a multi-valued RDN are joined by +, an attribute with no short name is
written by its dotted OID, and a value OTP does not decode is written as
# and the hex of its DER, RFC 4514's form.