Send and receive HL7 v2 over MLLP
MLLP carries HL7 v2, so a channel with an MLLP source or destination has format: hl7v2 (the
default). Both ends run over TLS unless the block says plaintext: true.
Listen for messages
source:
mllp:
port: 2575
host: 0.0.0.0
tls:
cert: ../../certs/server.pem
key: ../../certs/server.key
ca: ../../certs/senders-ca.pem
destinations:
- file: { dir: ../../data/adt-in/out }
hostis127.0.0.1by default: only this machine can connect. Set the address of an interface, or0.0.0.0, to take connections from other machines.tls.certandtls.keyare the server's certificate and key.tls.caturns on mutual TLS: every sender must present a certificate this CA signed, and one that does not fails the handshake. MLLP carries no credentials of its own, so mutual TLS is how the source checks who is sending.plaintext: truein place oftlslistens without TLS. The engine logsplaintextfor the channel at every start.
The TLS files are read when the engine starts. A file that cannot be read, or a certificate, key and CA that do not load together, stops the start with the file and key at fault.
Bound connections and messages
source:
mllp:
port: 2575
tls: { cert: ../../certs/server.pem, key: ../../certs/server.key }
maxConnections: 20
receiveTimeout: 10m
maxConnectionAge: 1h
maxMessageSize: 8MB
keepOpen: false
destinations:
- file: { dir: ../../data/adt-in/out }
maxConnections(100 by default) is the most connections the source holds at once. One that arrives over it is closed at once, before any byte of it is read or any TLS handshake starts. The engine logsmllp-connection-refusedwith the cap and the codeMLLP_CONNECTION_LIMIT_REACHED, never who connected, and the health report counts them in the channel'sconnections.refused. Over TLS, a sender that connects and then stalls its TLS handshake holds a place from the moment it connects until Node.js's handshake timeout closes it, 120 seconds; the engine then logsmllp-tls-refusedwith the codeERR_TLS_HANDSHAKE_TIMEOUT.receiveTimeoutdoes not cover that wait, since its clock starts once the handshake is done. Pathways has no key for the handshake timeout, as@cosyte/mllppasses none through. To shorten it, end TLS in front of the source, at a proxy whose own handshake timeout is shorter, and run the source on127.0.0.1withplaintext: truebehind it; to make stalled handshakes harmless without that, setmaxConnectionswell above the senders you expect, so a few of them cannot fill it.receiveTimeout(no limit by default) closes a connection that goes this long without a whole message: one left idle, and one that sent part of a message and stopped. The clock starts again at each message. A part-sent message is never stored, so the sender sends it again on a new connection. Set it longer than the sender's own quiet periods, since many senders keep one connection open all day, and longer than storing a message takes.maxConnectionAge(no limit by default) closes a connection once it is older than this, after the message it brings has been stored and answered. The sender connects again, and through a load balancer that can be another node, so long-lived connections spread out again after a node is added or restarted. Off by default, since how a sender takes a closed connection is its own: most connect again, and the message they were sending, if any, was answered first. The engine logsmllp-connection-agedwhen it closes one.maxMessageSize(4 MB by default, 1 MB being 1,024 KB) is the largest message taken. A frame that grows past it closes the connection, nothing of it is stored, and the engine logsmllp-source-errorwith the codeMLLP_FRAME_TOO_LARGE.keepOpen: falsecloses each connection once every message it brought has been answered, for a sender that opens one connection per message. Each ACK is written out before the connection closes, and two messages that arrive together are each stored and answered first. By default the connection stays open for the next message.
Dial out to a sender
Some senders do not connect to you: they listen, and expect the receiver to connect to them. Give
the source connect in place of port, and it dials out to the sender and reads messages from that
connection, storing each and answering it on the same connection, as it does on a connection a sender
opened.
source:
mllp:
connect: { host: ehr.example.test, port: 2575 }
connectTimeout: 10s
tls:
ca: ../../certs/ehr-ca.pem
cert: ../../certs/pathways-client.pem
key: ../../certs/pathways-client.key
destinations:
- file: { dir: ../../data/adt-in/out }
connectis the sender's address and port. The name is looked up again at each dial.tlsreads as a TLS client's here:tls: {}verifies the sender's certificate against Node.js's built-in roots, andtls.catrusts your own CA in their place. The certificate is always verified.tls.certandtls.key, ortls.pfx, present a client certificate, for a sender that requires mutual TLS.minVersion,profileandcipherswork as on a listening source.connectTimeout(10 seconds by default) bounds each dial, the TLS handshake included.
A dial that fails, or a connection that drops, is dialled again after 1 second, doubling with each
failure in a row to 5 minutes, and back to 1 second once a connection opens. Until it is back the
health report shows the channel's source down and readiness fails;
the engine logs source-lost with a code such as ECONNREFUSED, CONNECT_TIMEOUT or
MLLP_CONNECTION_CLOSED, then source-regained, and logs mllp-connected at each connection.
receiveTimeout and maxMessageSize apply to the dialled connection as to any other: past either,
the connection closes and is dialled again. A source that dials out listens on no port, so port,
host, maxConnections, keepOpen and replyTo cannot be set with connect. A stop closes the
connection once the message it is handling has been stored and answered, and cuts it off at
drain.timeout as it does a listening source's connections.
Send each ACK to another address
A sender that wants its ACKs on a connection of their own, not the one it sent on, names an address
for them. Give the source replyTo, and it opens a new connection there for each ACK, writes the ACK,
and closes it. No ACK goes back on the connection the message came on.
source:
mllp:
port: 2575
tls:
cert: ../../certs/server.pem
key: ../../certs/server.key
ca: ../../certs/senders-ca.pem
replyTo: { host: ehr.example.test, port: 2576 }
connectTimeout: 5s
destinations:
- file: { dir: ../../data/adt-in/out }
replyTocomes from the channel's file only. The message's own header (MSH-3, MSH-4 or any other field) never chooses where its ACK goes.- The reply connection runs over TLS when the source does: the reply host's certificate is verified
against
tls.ca, or Node.js's built-in roots when the source has none, and the source's own certificate is presented to it, so a reply host that requires a client certificate needs that certificate to allow client authentication. connectTimeout(10 seconds by default) bounds opening each reply connection, and then writing the ACK on it, so a reply host that takes the connection and never reads cannot hold the ACKs behind it for longer.
Each message is stored before its ACK is sent, as always. When the reply address refuses, or does
not answer in time, the message stays stored and the engine logs mllp-reply-failed with the
message id and a code such as ECONNREFUSED, CONNECT_TIMEOUT or SEND_TIMEOUT, never content.
The sender, with no ACK, sends the message again, and the copy is flagged as a possible duplicate.
Messages on one connection are still answered one at a time, in order, so a slow reply address
holds the next ACK behind it.
Choose the TLS version and cipher suites
Both ends take the same TLS keys beside cert, key and ca:
source:
mllp:
port: 2575
tls:
pfx: ../../certs/server.p12
passphrase: { env: ADT_IN_PFX_PASSPHRASE }
minVersion: TLSv1.3
profile: atna
destinations:
- file: { dir: ../../data/adt-in/out }
minVersionis the oldest TLS version accepted:TLSv1.2(the default) orTLSv1.3. Nothing older can be set, so a client that offers only TLS 1.1 or 1.0 fails the handshake.profilenames the cipher suites offered.bcp195, the default, offers TLS 1.3's suites and, for TLS 1.2, only suites with ECDHE key exchange and AES-GCM:ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256,ECDHE-ECDSA-AES256-GCM-SHA384andECDHE-RSA-AES256-GCM-SHA384.atnaoffers the IHE ATNA suites as@cosyte/mllplists them: TLS 1.3's suites, and for TLS 1.2ECDHE-RSA-AES128-GCM-SHA256,ECDHE-RSA-AES256-GCM-SHA384,DHE-RSA-AES128-GCM-SHA256andDHE-RSA-AES256-GCM-SHA384, which need an RSA certificate.ciphersis an OpenSSL cipher list in place of a profile, for a peer no profile reaches. It cannot be set together withprofile, and a list OpenSSL takes nothing from stops the start.pfxis a PKCS#12 file holding the certificate and key, in place ofcertandkey(set one or the other). Itspassphraseis read at start from a secret reference, never written in the YAML. PEM files incertandkeystay the usual choice. A pfx file that does not open with its passphrase stops the start, naming the key, never the file's path or the passphrase.
A TLS client that worked before may now fail the handshake: before, the source and destination
offered Node.js's default cipher list, which still holds TLS 1.2 suites without forward secrecy or
without AES-GCM. A peer that can use none of the bcp195 suites needs profile: atna or a ciphers
list.
What the source answers
Each message is stored before it is acknowledged, and messages on one connection are handled one at a time, in order.
| The message | Stored | Answer |
|---|---|---|
| has a usable MSH segment with an MSH-10 control ID | yes | AA |
| has no usable MSH, no MSH-10, or more than one message in the frame | yes, as failed | AR |
| was damaged in transit (the framing dropped bytes of it) | yes, as failed | AE, so the sender sends it again |
| could not be stored (the database is unreachable or full) | no | by default none: the connection closes |
When a message cannot be stored, onStoreFailure decides: close (the default) sends no ACK and
closes the connection, so the sender sends the message again; AE or AR answers with that code
instead. Set it on the source, the channel or defaults.
A channel that stores nothing (storage.content: none) answers AA only once every destination
accepted the message, closes without an ACK when one failed in a way that may pass, and answers AE
when the transform failed or a destination rejected it for good. See Pass messages through without
storing them.
A message whose sender (MSH-3 and MSH-4) and MSH-10 repeat an earlier one is stored and processed as
usual, and flagged as a possible duplicate of it: never dropped. The engine logs
possible-duplicate, and pathways messages shows the earlier message's id.
The ACK swaps sender and receiver, echoes MSH-10 in MSA-2, and on a negative ACK adds one ERR segment with an HL7 table 0357 code: 207 when storing failed or a channel that stores nothing could not pass the message on, 101 for a missing MSH-10, 100 otherwise. It never carries message content. To answer differently, write your own ACK builder.
Send messages
source:
file: { dir: ../../data/adt-to-lab/in }
transform: ./transform.ts
destinations:
- mllp:
host: lab.example.com
port: 2576
tls:
ca: ../../certs/lab-ca.pem
cert: ../../certs/pathways-client.pem
key: ../../certs/pathways-client.key
ackTimeout: 10s
tls: {}verifies the receiver's certificate against Node.js's built-in roots;tls.catrusts your own CA in their place. The certificate is always verified.tls.certandtls.keypresent a client certificate, for a receiver that requires mutual TLS. Set both or neither, ortls.pfxwith itspassphrasein their place.tls.minVersion,tls.profileandtls.cipherswork as on the source: see Choose the TLS version and cipher suites.ackTimeout(10 seconds by default) is how long to wait to connect, and then for each ACK.
The destination keeps one connection open and reuses it, and opens a new one after any failure so a late ACK can never answer the wrong message. The ACK decides what happens:
| The receiver answers | The delivery |
|---|---|
AA or CA | delivered |
AE or CE | failed for good; the destination moves on |
AR or CR, no ACK in time, an ACK that cannot be read or answers another MSH-10, or no connection | retried, with later messages waiting behind it |
An output that holds an MLLP framing byte can never be sent, so it fails at once.
A resend after an attempt was cut off by a stop is sent unchanged: the same bytes, with the same
MSH-10 control ID, so a receiver that checks control IDs can spot the repeat. Pathways adds no field
to the message. The engine logs it as a possible-duplicate; see
Delivery and errors.
Related
- Authenticate endpoints and use TLS.
- How delivery and errors work: retries and order.
- Configuration reference: MLLP.