Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Nuances: TLS on what the engine listens on

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

The API on the network needs tls (level 1)

  • What: api: { listen: 0.0.0.0:7700 } without tls: serves the API and the web UI over plain HTTP: passwords and tokens cross the network readable, and browsers won't keep the session cookie (it is Secure), so the UI can't sign in. The engine and ductus validate warn about it.
  • What to do: name a certificate (api: { listen: 0.0.0.0:7700, tls: { certificate: ductus-api } }, with ductus-api under certificates: in resources.yaml). Browsers that have seen it over HTTPS won't try plain HTTP to it again for a year (HSTS).

A certificate the engine can't use stops the start, with the reason (level 2)

  • What: every certificates: entry is loaded at start, before the database: a missing file, a file that isn't PEM, a key that isn't the certificate's, or an expired certificate stops the engine and says which entry and why. ductus validate checks the same, and shows each certificate's names and days left.
  • Why: a typo should show at deploy, not as the first sender's refused handshake at 2 a.m.
  • What to do: run ductus validate in the environment you deploy to (certificates from secrets differ per environment).

Renewing: replace both files, no restart (level 2)

  • What: a certificate given as cert_file and key_file is read again every minute (one task for the whole engine, comparing the files' contents; nothing touches the database). A changed pair that loads is used for new connections; connections already open keep theirs until they close. A changed pair that doesn't load (half copied, a new certificate with the old key, expired) is logged once as an error, and the engine keeps presenting the pair it had: a bad renewal never takes a listener down.
  • Why: that's how certbot, win-acme, step and most PKI tooling deliver certificates: they replace files. Any ACME client works this way, with no engine feature needed.
  • What to do: write the new files, then check the log for certificate renewed within a minute (or the "Ours" group on the Connections page; its "Check now" reads the files at once). Either order works: while only one of the two has changed, the old pair stays in use (a check that lands in between logs one error, which the next check clears). Certificates from secrets (cert:/key:) and CAs change with a deploy and a restart.

Give the whole chain in cert_file (level 3)

  • What: the engine presents exactly what cert_file (or cert) holds: the certificate first, then its intermediates. Browsers often fetch a missing intermediate themselves; Java and .NET senders and most integration engines don't, and refuse the handshake.
  • What to do: put the CA's intermediate certificates after yours in the same file (the CA usually sends a "full chain" or "bundle" file). Doctor's full mode (the Connections page's "Run the checks", ductus doctor while the engine runs) connects to each TLS listener and warns when the chain stops short of a root (see Doctor reads the chain our listeners serve).

Senders on TLS 1.0 or 1.1 can't connect (level 3)

  • What: listeners take TLS 1.2 and 1.3 only. Old engines and appliances that only speak 1.0/1.1 fail the handshake; the log says "no TLS version or cipher in common".
  • What to do: upgrade the sender, or put a TLS-terminating proxy in front for it alone (and keep that hop on a trusted network).

A client certificate says which system sent, not who (level 3)

  • What: with clients: { ca: hospital-ca, subjects: [...] }, a listener takes only senders whose certificate the CA issued and whose subject matches one pattern (CN=bridges*.hospital.org; all parts of a pattern must match; DNS= matches the certificate's DNS names). It identifies the sending system, not a person: nothing about it reaches the audit log as an actor.
  • What to do: keep subjects narrow: without it, every certificate that CA ever issued (often every machine in the hospital) is let in.

A refused handshake is logged once a minute per sender (level 3)

  • What: a sender that isn't speaking TLS, has no or the wrong client certificate, doesn't trust ours, or doesn't finish within 10 s is dropped without an answer (an MLLP sender sees the connection close, no ACK). The log says why, at most once a minute per listener and sender address: a misconfigured sender retries every few seconds, and the log isn't for counting them.
  • What to do: after changing a sender, wait a minute for the next line, or watch the sender's own log. A check the running engine makes of its own listeners (the Connections page's full checks) isn't logged as a refusal; ductus doctor run on the same server is another program, so a listener that wants client certificates logs its check once, from this server's own address.

A certificate from the hospital's CA: request, then show (level 2)

  • What: ductus cert request tls/mllp --dns mllp.hospital.org [--ip 10.1.2.3] [--subject "O=General Hospital, OU=Interfaces"] writes a new private key (tls/mllp.key, readable only by its owner, never overwritten) and a certificate signing request (tls/mllp.csr) asking for a certificate a server and a client can both use. The key is RSA 2048 unless you ask for --key ecdsa: every CA takes RSA (Microsoft AD CS templates often still require it). What comes back goes in tls/mllp.pem, ours first, then the CA's intermediates. ductus cert show tls/mllp.pem (or the entry's name) says what it holds and judges it the way the engine and doctor do: names, issuer, days left (the chain's earliest), the chain's order and whether it reaches a root or a CA named in resources.yaml, and whether the key is the certificate's. It exits 1 when the engine would refuse the pair (wrong key, expired, not a certificate), and says when a file is a CSR, a key, or DER (binary) instead of PEM.
  • Why: a key generated elsewhere and mailed around is a key someone else has; a chain that's incomplete or in the wrong order passes a browser test and fails Java and .NET senders.
  • What to do: run cert request on the engine's server, send only the .csr, and run cert show on what comes back before naming it in resources.yaml. To renew: a new request under another name, then copy its .pem and .key over the two in use (see Renewing).

A CA's certificate as a server's (level 3)

  • What: openssl req -x509 makes a certificate marked as a CA's (CA:TRUE) by default, which is how many lab receivers get theirs. The engine's connections (MLLP and HTTP destinations, apis:, webhooks, the heartbeat, directories) accept a receiver presenting one only when that exact certificate is itself trusted: named in ca:, or installed as trusted on the engine's server. Its dates and the host name are still checked. That's the same on Linux and Windows, and what Windows, Java (Mirth) and .NET do. Trusting one exact certificate is at least as strict as trusting a CA. A CA:TRUE certificate that is only issued by a trusted CA is refused ("the receiver presents a CA's certificate (CA:TRUE) as its own"), on Windows too, though Windows alone would take it. A client certificate like that is refused on our listeners. Exceptions: three connections use a TLS stack of their own. Alert mail (SMTP) follows the OS: Linux refuses any CA:TRUE certificate, Windows takes both kinds. Postgres and SQL Server refuse any CA:TRUE certificate on both. Doctor's mail check says what the mail connection would. ductus cert show warns about any CA:TRUE pair.
  • What to do: for a lab, naming the receiver's own certificate in ca: works. Anywhere else, ask for a server certificate (ductus cert request for the hospital's CA, or ductus cert self-signed for a lab of your own).

What TLS costs (level 4)

  • What: measured on one engine, TLS against plain (scripts/bench-tls.sh, WSL laptop, local Postgres with ~5 ms durable commits): strict MLLP ingest is the same within noise, 234–239 vs 238–245 ACKs/s on one connection and 2,690–2,780 vs 2,720–2,930 on 16 (the commit flush bounds both; the handshake is once per connection). A sender's connect to the end of its handshake takes ~0.56 ms p50 with an ECDSA certificate, ~0.96 ms with RSA 2048, ~0.66 ms when we also check its client certificate (plain TCP: 0.08 ms); TLS 1.3 resumption saves little (~0.54 ms: it still agrees a new key). An idle TLS connection holds ~23 kB of the engine's memory against ~15 kB plain (1,000 held open: +23 MB vs +15 MB). Respond mode over HTTPS: 0.29 ms p50 / 0.39 ms p95 on a kept-alive connection (HTTP: 0.27 / 0.36); with a new connection per request 1.2 ms / 3.0 ms (HTTP: 0.51 / 0.73).
  • What to do: turn TLS on wherever the network isn't yours: for MLLP feeds it costs nothing you'll see at hospital volumes (1M ADT a day is ~12 msg/s on average). Senders that open a connection per message (some do) pay one handshake each, ~1 ms with RSA, on top of the ~4 ms a strict commit takes here: unnoticeable at a few messages a second. Many such senders at once, handshaking hundreds of times a second, weren't measured: ask a busy one to keep its connection open, as MLLP senders usually do. Callers of respond-mode APIs should keep connections alive (most HTTP clients do by default): a new HTTPS connection per call roughly quadruples the time on the wire. Memory is no reason to avoid TLS: 1,000 connections add ~23 MB.

Post-quantum key exchange: on by default, never in the way (level 3)

  • What: every TLS connection the engine makes or accepts (listeners, the API, MLLP and HTTP destinations, api() and fetch(), mail, webhooks, the directory) offers and prefers the hybrid key exchange X25519MLKEM768 (classic X25519 plus ML-KEM-768, the one browsers use). A peer that knows it gets it; one that doesn't gets plain X25519 with no extra round trip (both key shares go in the first message). It protects the session keys, so traffic recorded today can't be decrypted later by a quantum computer ("record now, decrypt later": health records stay sensitive for decades). Certificates and signatures are unchanged (no post-quantum certificates yet: not issued by CAs). Measured (scripts/bench-tls.sh): +0.15 ms per full handshake (ECDSA p50 0.69–0.73 ms vs 0.55–0.58 ms with X25519 alone), ~1.2 kB more in the first message; nothing on a connection kept open. Checked against OpenSSL 3.5 both ways: hybrid when both offer it, X25519 when one doesn't.
  • What to do: nothing to turn on. Doctor's TLS step says what each server agreed ("TLS 1.3, post-quantum key exchange (X25519MLKEM768)" or "key exchange X25519"): the second kind are partners to ask about it (OpenSSL 3.5, Java 24+, recent Windows and Go have it).

Testing TLS without the real systems (level 1)

  • What: ductus gen --tls sends over TLS, trusting this server's roots plus --ca FILE and presenting --cert FILE --key FILE, verified the way the engine's destinations verify; it says why a handshake fails ("the receiver wants a client certificate (name one in --cert)"). ductus sink --tls --cert FILE --key FILE [--clients-ca FILE --subject PATTERN] takes MLLP over TLS only, as a listener with tls: does, and logs refusals the same way. Both read PEM files, the same as certificates: entries.
  • What to do: try a feed's TLS settings in a lab with ductus cert self-signed pairs before the receiver's team is involved (scripts/demo-tls.sh does the whole path, with a renewal mid-stream).

HTTP interfaces on one port share its tls (level 2)

  • What: HTTP interfaces on the same port are one server, so they're all HTTPS with the same certificate, or all plain. ductus validate and the engine refuse a port whose interfaces disagree.
  • What to do: give them the same tls:, or different ports.