messagefoundry 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- messagefoundry/__init__.py +108 -0
- messagefoundry/__main__.py +1155 -0
- messagefoundry/api/__init__.py +27 -0
- messagefoundry/api/app.py +1581 -0
- messagefoundry/api/approvals.py +184 -0
- messagefoundry/api/auth_models.py +211 -0
- messagefoundry/api/auth_routes.py +655 -0
- messagefoundry/api/field_authz.py +96 -0
- messagefoundry/api/models.py +374 -0
- messagefoundry/api/security.py +247 -0
- messagefoundry/api/tls.py +47 -0
- messagefoundry/auth/__init__.py +39 -0
- messagefoundry/auth/data/common_passwords.NOTICE +13 -0
- messagefoundry/auth/data/common_passwords.txt +10000 -0
- messagefoundry/auth/identity.py +71 -0
- messagefoundry/auth/ldap.py +264 -0
- messagefoundry/auth/notifications.py +68 -0
- messagefoundry/auth/passwords.py +53 -0
- messagefoundry/auth/permissions.py +120 -0
- messagefoundry/auth/policy.py +153 -0
- messagefoundry/auth/ratelimit.py +55 -0
- messagefoundry/auth/service.py +1323 -0
- messagefoundry/auth/tokens.py +26 -0
- messagefoundry/auth/totp.py +174 -0
- messagefoundry/checks.py +174 -0
- messagefoundry/config/__init__.py +30 -0
- messagefoundry/config/active_environment.py +80 -0
- messagefoundry/config/ai_policy.py +140 -0
- messagefoundry/config/code_sets.py +260 -0
- messagefoundry/config/connections_edit.py +200 -0
- messagefoundry/config/connections_file.py +287 -0
- messagefoundry/config/db_lookup.py +117 -0
- messagefoundry/config/environments.py +116 -0
- messagefoundry/config/ingest_time.py +83 -0
- messagefoundry/config/models.py +240 -0
- messagefoundry/config/reference.py +158 -0
- messagefoundry/config/response.py +83 -0
- messagefoundry/config/run_context.py +153 -0
- messagefoundry/config/settings.py +1311 -0
- messagefoundry/config/state.py +99 -0
- messagefoundry/config/tls_policy.py +110 -0
- messagefoundry/config/wiring.py +1918 -0
- messagefoundry/console/__init__.py +20 -0
- messagefoundry/console/__main__.py +274 -0
- messagefoundry/console/_async.py +107 -0
- messagefoundry/console/change_password.py +111 -0
- messagefoundry/console/client.py +552 -0
- messagefoundry/console/connections.py +324 -0
- messagefoundry/console/login.py +107 -0
- messagefoundry/console/mfa.py +205 -0
- messagefoundry/console/reauth.py +94 -0
- messagefoundry/console/search.py +57 -0
- messagefoundry/console/service_control.py +137 -0
- messagefoundry/console/sessions.py +122 -0
- messagefoundry/console/shell.py +410 -0
- messagefoundry/console/status.py +377 -0
- messagefoundry/console/users_page.py +282 -0
- messagefoundry/console/widgets.py +553 -0
- messagefoundry/generators/README.md +27 -0
- messagefoundry/generators/__init__.py +15 -0
- messagefoundry/generators/_core.py +589 -0
- messagefoundry/generators/_hl7data.py +428 -0
- messagefoundry/generators/adt.py +286 -0
- messagefoundry/generators/all_types.py +24 -0
- messagefoundry/generators/bar.py +28 -0
- messagefoundry/generators/dft.py +20 -0
- messagefoundry/generators/mdm.py +39 -0
- messagefoundry/generators/mfn.py +46 -0
- messagefoundry/generators/oml.py +32 -0
- messagefoundry/generators/orl.py +30 -0
- messagefoundry/generators/orm.py +23 -0
- messagefoundry/generators/oru.py +21 -0
- messagefoundry/generators/ras.py +20 -0
- messagefoundry/generators/rde.py +54 -0
- messagefoundry/generators/siu.py +64 -0
- messagefoundry/generators/vxu.py +20 -0
- messagefoundry/hl7schema.py +75 -0
- messagefoundry/last_resort.py +55 -0
- messagefoundry/logging_setup.py +332 -0
- messagefoundry/parsing/__init__.py +64 -0
- messagefoundry/parsing/consistency.py +166 -0
- messagefoundry/parsing/groups.py +228 -0
- messagefoundry/parsing/message.py +453 -0
- messagefoundry/parsing/peek.py +237 -0
- messagefoundry/parsing/split.py +120 -0
- messagefoundry/parsing/summary.py +46 -0
- messagefoundry/parsing/tree.py +128 -0
- messagefoundry/parsing/validate.py +95 -0
- messagefoundry/parsing/x12/__init__.py +46 -0
- messagefoundry/parsing/x12/delimiters.py +140 -0
- messagefoundry/parsing/x12/errors.py +30 -0
- messagefoundry/parsing/x12/interchange.py +232 -0
- messagefoundry/parsing/x12/message.py +200 -0
- messagefoundry/parsing/x12/peek.py +207 -0
- messagefoundry/pipeline/__init__.py +21 -0
- messagefoundry/pipeline/alert_sinks.py +486 -0
- messagefoundry/pipeline/alerts.py +100 -0
- messagefoundry/pipeline/cert_expiry.py +219 -0
- messagefoundry/pipeline/cluster.py +955 -0
- messagefoundry/pipeline/cluster_sqlserver.py +444 -0
- messagefoundry/pipeline/config_convergence.py +137 -0
- messagefoundry/pipeline/dryrun.py +450 -0
- messagefoundry/pipeline/engine.py +756 -0
- messagefoundry/pipeline/leader_tasks.py +158 -0
- messagefoundry/pipeline/reference_sync.py +369 -0
- messagefoundry/pipeline/retention.py +289 -0
- messagefoundry/pipeline/security_notify.py +168 -0
- messagefoundry/pipeline/state_convergence.py +143 -0
- messagefoundry/pipeline/wiring_runner.py +1722 -0
- messagefoundry/py.typed +0 -0
- messagefoundry/redaction.py +71 -0
- messagefoundry/scaffold.py +321 -0
- messagefoundry/secrets_dpapi.py +129 -0
- messagefoundry/store/__init__.py +46 -0
- messagefoundry/store/audit_tee.py +67 -0
- messagefoundry/store/base.py +758 -0
- messagefoundry/store/crypto.py +166 -0
- messagefoundry/store/keyprovider.py +192 -0
- messagefoundry/store/postgres.py +3447 -0
- messagefoundry/store/sqlserver.py +3014 -0
- messagefoundry/store/store.py +3790 -0
- messagefoundry/timezone.py +207 -0
- messagefoundry/transports/__init__.py +50 -0
- messagefoundry/transports/base.py +269 -0
- messagefoundry/transports/database.py +693 -0
- messagefoundry/transports/file.py +551 -0
- messagefoundry/transports/framing.py +164 -0
- messagefoundry/transports/loopback.py +53 -0
- messagefoundry/transports/mllp.py +644 -0
- messagefoundry/transports/remotefile.py +664 -0
- messagefoundry/transports/rest.py +281 -0
- messagefoundry/transports/signing.py +321 -0
- messagefoundry/transports/soap.py +507 -0
- messagefoundry/transports/tcp.py +307 -0
- messagefoundry/transports/timer.py +146 -0
- messagefoundry/transports/x12.py +323 -0
- messagefoundry-0.1.0.dist-info/METADATA +212 -0
- messagefoundry-0.1.0.dist-info/RECORD +142 -0
- messagefoundry-0.1.0.dist-info/WHEEL +4 -0
- messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
- messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
- messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""MLLP (Minimal Lower Layer Protocol) transport + HL7 ACK building.
|
|
4
|
+
|
|
5
|
+
MLLP wraps each message in a *block*::
|
|
6
|
+
|
|
7
|
+
<0x0B> message-bytes <0x1C><0x0D>
|
|
8
|
+
SB EB CR
|
|
9
|
+
|
|
10
|
+
The single most common place toy engines break is framing: forgetting the trailing CR,
|
|
11
|
+
treating the SB/EB bytes as message content, or assuming one message per TCP read. A
|
|
12
|
+
real peer may split a message across reads or pack several into one. :class:`MLLPDecoder`
|
|
13
|
+
is a stateful, byte-accurate reassembler that handles both.
|
|
14
|
+
|
|
15
|
+
ACKs are built from the inbound MSH (echoing its encoding characters, swapping
|
|
16
|
+
sender/receiver, copying the original control id into MSA-2). ``ack_mode`` selects the
|
|
17
|
+
MSA-1 code family: ``original`` → AA/AE/AR, ``enhanced`` → CA/CE/CR.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import logging
|
|
24
|
+
import ssl
|
|
25
|
+
from collections.abc import Callable, Mapping
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import hl7
|
|
30
|
+
from hl7.containers import Component, Field, Repetition
|
|
31
|
+
|
|
32
|
+
from messagefoundry.config.models import AckMode, ConnectorType, Destination, Source
|
|
33
|
+
from messagefoundry.config.settings import INSECURE_TLS_ESCAPE_ENV, insecure_tls_allowed
|
|
34
|
+
from messagefoundry.config.tls_policy import harden_kex_groups, harden_verify_flags
|
|
35
|
+
from messagefoundry.parsing.peek import HL7PeekError, Peek, normalize
|
|
36
|
+
from messagefoundry.redaction import safe_exc
|
|
37
|
+
from messagefoundry.transports.framing import MLLP_CODEC, FrameDecoder, FrameError
|
|
38
|
+
from messagefoundry.transports.base import (
|
|
39
|
+
DeliveryError,
|
|
40
|
+
DeliveryResponse,
|
|
41
|
+
DestinationConnector,
|
|
42
|
+
InboundHandler,
|
|
43
|
+
NegativeAckError,
|
|
44
|
+
SourceConnector,
|
|
45
|
+
peer_ip_allowed,
|
|
46
|
+
probe_tcp_reachable,
|
|
47
|
+
register_destination,
|
|
48
|
+
register_source,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"SB",
|
|
53
|
+
"EB",
|
|
54
|
+
"CR",
|
|
55
|
+
"DEFAULT_MAX_FRAME_BYTES",
|
|
56
|
+
"DEFAULT_MAX_CONNECTIONS",
|
|
57
|
+
"DEFAULT_RECEIVE_TIMEOUT",
|
|
58
|
+
"frame",
|
|
59
|
+
"MLLPDecoder",
|
|
60
|
+
"MLLPFrameError",
|
|
61
|
+
"build_ack",
|
|
62
|
+
"EncodingCharacters",
|
|
63
|
+
"parse_encoding_characters",
|
|
64
|
+
"reencode_delimiters",
|
|
65
|
+
"MLLPDestination",
|
|
66
|
+
"MLLPSource",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
logger = logging.getLogger(__name__)
|
|
70
|
+
|
|
71
|
+
# MLLP framing is the VT/FS+CR preset of the shared, configurable codec (transports.framing); these
|
|
72
|
+
# names + frame()/MLLPDecoder are kept as the MLLP-specific surface so existing imports + tests hold.
|
|
73
|
+
SB = 0x0B # start block (VT)
|
|
74
|
+
EB = 0x1C # end block (FS)
|
|
75
|
+
CR = 0x0D # carriage return
|
|
76
|
+
|
|
77
|
+
# Resource caps (DoS guards). All are overridable per connection via MLLP() settings; see
|
|
78
|
+
# docs/CONNECTIONS.md. A falsy value (None/0) in settings disables the cap explicitly.
|
|
79
|
+
DEFAULT_MAX_FRAME_BYTES = 16 * 1024 * 1024 # 16 MiB — fits embedded base64 docs, bounds OOM
|
|
80
|
+
DEFAULT_MAX_CONNECTIONS = 256 # bound concurrent inbound clients (connection-flood guard)
|
|
81
|
+
DEFAULT_RECEIVE_TIMEOUT = 60.0 # seconds — close inbound sockets idle this long (slowloris guard)
|
|
82
|
+
# On stop()/reload, established clients are closed and their handlers given this long to finish an
|
|
83
|
+
# in-flight commit before the connection tasks are cancelled — bounds shutdown so a peer holding a
|
|
84
|
+
# connection open can't hang it (review H-2).
|
|
85
|
+
_CLIENT_SHUTDOWN_GRACE = 5.0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# MLLP's frame-too-large error is the shared codec error under its historical name (subclassing keeps
|
|
89
|
+
# `except MLLPFrameError` working while the codec raises the generic FrameError internally).
|
|
90
|
+
class MLLPFrameError(FrameError):
|
|
91
|
+
"""Raised when an MLLP frame exceeds its configured byte cap before end-of-block.
|
|
92
|
+
|
|
93
|
+
Signals the caller to drop the connection rather than buffer an unbounded frame.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def frame(payload: str | bytes, encoding: str = "utf-8") -> bytes:
|
|
98
|
+
"""Wrap a message in an MLLP block: ``SB payload EB CR`` (the VT/FS+CR codec preset)."""
|
|
99
|
+
return MLLP_CODEC.frame(payload, encoding)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class MLLPDecoder(FrameDecoder):
|
|
103
|
+
"""Stateful MLLP frame reassembler — the :class:`~messagefoundry.transports.framing.FrameDecoder`
|
|
104
|
+
bound to the MLLP (VT/FS+CR) codec.
|
|
105
|
+
|
|
106
|
+
Feed it whatever bytes arrive; it yields complete message payloads (framing bytes
|
|
107
|
+
stripped) as they complete. Bytes outside a frame — including a stray CR after EB or
|
|
108
|
+
junk before the next SB — are discarded, matching tolerant real-world receivers. A frame
|
|
109
|
+
over ``max_frame_bytes`` raises :class:`MLLPFrameError`.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
error_class = MLLPFrameError
|
|
113
|
+
|
|
114
|
+
def __init__(self, max_frame_bytes: int | None = None) -> None:
|
|
115
|
+
super().__init__(MLLP_CODEC, max_frame_bytes=max_frame_bytes)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --- ACK building ------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
# MSH-1 default field separator and MSH-2 default encoding characters.
|
|
121
|
+
_DEFAULT_FIELD_SEP = "|"
|
|
122
|
+
_DEFAULT_ENC = "^~\\&"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _no_seg_sep(value: str) -> str:
|
|
126
|
+
"""Strip CR/LF from an echoed ACK value so an attacker-controlled inbound field can't inject a
|
|
127
|
+
new segment into the ACK we send back (HL7-3)."""
|
|
128
|
+
return value.replace("\r", " ").replace("\n", " ")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _escape_ack_text(text: str, *, field_sep: str, enc: str) -> str:
|
|
132
|
+
"""Sanitize free-text MSA-3: drop CR/LF and escape the escape char + field separator so the
|
|
133
|
+
text can't introduce extra fields/segments (the inbound-derived NACK reason is untrusted)."""
|
|
134
|
+
esc = enc[2] if len(enc) > 2 else "\\"
|
|
135
|
+
text = _no_seg_sep(text)
|
|
136
|
+
# Escape the escape char first (so the substitution below stays reversible), then the field sep.
|
|
137
|
+
return text.replace(esc, f"{esc}E{esc}").replace(field_sep, f"{esc}F{esc}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
_CODES = {
|
|
141
|
+
AckMode.ORIGINAL: {"AA": "AA", "AE": "AE", "AR": "AR"},
|
|
142
|
+
AckMode.ENHANCED: {"AA": "CA", "AE": "CE", "AR": "CR"},
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def build_ack(
|
|
147
|
+
inbound: str | bytes | Peek,
|
|
148
|
+
*,
|
|
149
|
+
code: str = "AA",
|
|
150
|
+
text: str | None = None,
|
|
151
|
+
ack_mode: AckMode = AckMode.ORIGINAL,
|
|
152
|
+
control_id: str | None = None,
|
|
153
|
+
timestamp: str = "",
|
|
154
|
+
) -> str:
|
|
155
|
+
"""Build an HL7 acknowledgement for ``inbound``.
|
|
156
|
+
|
|
157
|
+
``code`` is the logical outcome — ``"AA"`` (accept), ``"AE"`` (error) or ``"AR"``
|
|
158
|
+
(reject) — mapped to the MSA-1 value appropriate for ``ack_mode``. ``text`` becomes
|
|
159
|
+
MSA-3 (e.g. a NACK reason). ``control_id`` is the ACK's own MSH-10 (defaults to
|
|
160
|
+
echoing the inbound control id). ``timestamp`` is MSH-7; pass one to pin it (tests),
|
|
161
|
+
otherwise it defaults to the current HL7 DTM so strict senders that reject an empty
|
|
162
|
+
MSH-7 don't NAK-loop and re-send (review low-6).
|
|
163
|
+
"""
|
|
164
|
+
if code not in _CODES[AckMode.ORIGINAL]:
|
|
165
|
+
raise ValueError(f"unknown ack code {code!r} (expected AA, AE or AR)")
|
|
166
|
+
timestamp = timestamp or datetime.now().strftime("%Y%m%d%H%M%S")
|
|
167
|
+
msa1 = _CODES[ack_mode if ack_mode is not AckMode.NONE else AckMode.ORIGINAL][code]
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
peek = inbound if isinstance(inbound, Peek) else Peek.parse(inbound)
|
|
171
|
+
except HL7PeekError:
|
|
172
|
+
peek = None
|
|
173
|
+
|
|
174
|
+
field_sep = (peek.field("MSH-1") if peek else None) or _DEFAULT_FIELD_SEP
|
|
175
|
+
enc = (peek.field("MSH-2") if peek else None) or _DEFAULT_ENC
|
|
176
|
+
# Every value below is echoed from the (untrusted) inbound message, so strip CR/LF to prevent
|
|
177
|
+
# segment injection into the ACK; MSA-3 free text is additionally escaped (HL7-3).
|
|
178
|
+
sending_app = _no_seg_sep((peek.sending_app if peek else None) or "")
|
|
179
|
+
sending_fac = _no_seg_sep((peek.sending_facility if peek else None) or "")
|
|
180
|
+
receiving_app = _no_seg_sep((peek.receiving_app if peek else None) or "")
|
|
181
|
+
receiving_fac = _no_seg_sep((peek.receiving_facility if peek else None) or "")
|
|
182
|
+
version = _no_seg_sep((peek.version if peek else None) or "2.5.1")
|
|
183
|
+
original_control = _no_seg_sep((peek.control_id if peek else None) or "")
|
|
184
|
+
ack_control = _no_seg_sep(control_id if control_id is not None else original_control)
|
|
185
|
+
|
|
186
|
+
# Swap sender/receiver: the ACK goes back the way it came.
|
|
187
|
+
msh_fields = [
|
|
188
|
+
"MSH",
|
|
189
|
+
_no_seg_sep(enc),
|
|
190
|
+
receiving_app,
|
|
191
|
+
receiving_fac,
|
|
192
|
+
sending_app,
|
|
193
|
+
sending_fac,
|
|
194
|
+
timestamp,
|
|
195
|
+
"",
|
|
196
|
+
"ACK",
|
|
197
|
+
ack_control,
|
|
198
|
+
"P",
|
|
199
|
+
version,
|
|
200
|
+
]
|
|
201
|
+
msh = field_sep.join(msh_fields)
|
|
202
|
+
msa_fields = ["MSA", msa1, original_control]
|
|
203
|
+
if text:
|
|
204
|
+
msa_fields.append(_escape_ack_text(text, field_sep=field_sep, enc=enc))
|
|
205
|
+
msa = field_sep.join(msa_fields)
|
|
206
|
+
return msh + "\r" + msa + "\r"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# --- per-outbound encoding-character override (Corepoint -override parity) ----
|
|
210
|
+
|
|
211
|
+
#: The five MSH delimiter characters, in MSH order: MSH-1 (field separator) then the four MSH-2
|
|
212
|
+
#: characters (component, repetition, escape, subcomponent). A target set for an outbound re-encode.
|
|
213
|
+
EncodingCharacters = tuple[str, str, str, str, str]
|
|
214
|
+
|
|
215
|
+
#: The number of characters an ``encoding_characters`` override must carry (MSH-1 + 4 MSH-2 chars).
|
|
216
|
+
_ENCODING_CHARS_LEN = 5
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def parse_encoding_characters(value: str) -> EncodingCharacters:
|
|
220
|
+
"""Validate an ``encoding_characters`` override and split it into its five MSH delimiters.
|
|
221
|
+
|
|
222
|
+
``value`` is the MSH-1 field separator followed by the four MSH-2 characters
|
|
223
|
+
(component, repetition, escape, subcomponent) — e.g. the HL7 default ``"|^~\\&"``. Fails **loud**
|
|
224
|
+
(``ValueError``) on a bad value rather than silently shipping a malformed header: it must be exactly
|
|
225
|
+
five characters and all five must be distinct (HL7 forbids reusing a delimiter for two roles — a
|
|
226
|
+
collision would make the message ambiguous to the receiver). Called once at connector build so a bad
|
|
227
|
+
config is caught at dry-run / ``check`` time, not per delivery."""
|
|
228
|
+
if not isinstance(value, str) or len(value) != _ENCODING_CHARS_LEN:
|
|
229
|
+
raise ValueError(
|
|
230
|
+
f"encoding_characters must be exactly {_ENCODING_CHARS_LEN} characters "
|
|
231
|
+
"(MSH-1 field separator + the 4 MSH-2 chars: component, repetition, escape, subcomponent), "
|
|
232
|
+
f"got {value!r}"
|
|
233
|
+
)
|
|
234
|
+
if len(set(value)) != _ENCODING_CHARS_LEN:
|
|
235
|
+
raise ValueError(
|
|
236
|
+
f"encoding_characters {value!r} reuses a delimiter — all five (field, component, "
|
|
237
|
+
"repetition, escape, subcomponent) must be distinct"
|
|
238
|
+
)
|
|
239
|
+
# Index explicitly rather than unpack the str (mypy disallows str-unpacking) — the five characters
|
|
240
|
+
# are MSH-1 then the four MSH-2 chars, in order.
|
|
241
|
+
return value[0], value[1], value[2], value[3], value[4]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def reencode_delimiters(payload: str, target: EncodingCharacters) -> str:
|
|
245
|
+
"""Re-serialize ``payload`` (an HL7 v2 message) with the ``target`` MSH delimiters.
|
|
246
|
+
|
|
247
|
+
The message is parsed with its **own** current delimiters (read from its MSH-1/MSH-2, never assumed
|
|
248
|
+
to be ``|^~\\&``), then re-joined with the target field/component/repetition/subcomponent separators
|
|
249
|
+
and a rewritten MSH-1/MSH-2 — so a downstream re-parse sees the same logical fields under the new
|
|
250
|
+
delimiters. This is the "parse → set new MSH-1/MSH-2 → re-encode" contract, done by re-joining the
|
|
251
|
+
parse tree rather than by string-slicing the raw bytes.
|
|
252
|
+
|
|
253
|
+
Leaf values are carried through **verbatim except for the escape character**: structural delimiters
|
|
254
|
+
never appear literally inside a leaf (they are escaped), and HL7's named escapes (``\\F\\``,
|
|
255
|
+
``\\S\\`` …) are delimiter-agnostic — only their surrounding escape character changes when the
|
|
256
|
+
escape character does. Crucially we do **not** round-trip leaves through python-hl7's
|
|
257
|
+
``unescape``/``escape`` (which corrupt code points above U+007F — accented/CJK names — and would
|
|
258
|
+
silently mangle PHI; the same quirk :class:`~messagefoundry.parsing.message.Message` avoids). When
|
|
259
|
+
the source already uses the target escape character, leaves are byte-identical.
|
|
260
|
+
|
|
261
|
+
Raises :class:`ValueError` if ``payload`` is not parseable HL7 (no MSH / malformed header), so the
|
|
262
|
+
caller can fail the delivery loud instead of framing a corrupted message."""
|
|
263
|
+
field_sep, comp, rep, esc, sub = target
|
|
264
|
+
try:
|
|
265
|
+
message = hl7.parse(normalize(payload))
|
|
266
|
+
seg_sep: str = message.separator # segment separator (CR) is not part of the override
|
|
267
|
+
src_esc: str = message.esc # the source message's own escape character
|
|
268
|
+
except (hl7.HL7Exception, IndexError, ValueError) as exc:
|
|
269
|
+
# IndexError covers a header so truncated python-hl7 can't read MSH-2 (e.g. "MSH|"); ValueError
|
|
270
|
+
# is defensive. A non-HL7 body simply cannot be delimiter-rewritten — surface it, don't corrupt.
|
|
271
|
+
raise ValueError(
|
|
272
|
+
f"cannot re-encode delimiters: payload is not parseable HL7 ({exc})"
|
|
273
|
+
) from exc
|
|
274
|
+
|
|
275
|
+
def leaf_text(node: object) -> str:
|
|
276
|
+
# Only the escape character can legitimately change inside a leaf; every other byte (incl.
|
|
277
|
+
# non-ASCII) is preserved exactly. If the escape char is unchanged this is a no-op copy.
|
|
278
|
+
text = str(node)
|
|
279
|
+
return text if src_esc == esc else text.replace(src_esc, esc)
|
|
280
|
+
|
|
281
|
+
def join_component(node: object) -> str:
|
|
282
|
+
if isinstance(node, Component):
|
|
283
|
+
return sub.join(leaf_text(child) for child in node)
|
|
284
|
+
return leaf_text(node)
|
|
285
|
+
|
|
286
|
+
def join_repetition(node: object) -> str:
|
|
287
|
+
if isinstance(node, Repetition):
|
|
288
|
+
return comp.join(join_component(child) for child in node)
|
|
289
|
+
return join_component(node)
|
|
290
|
+
|
|
291
|
+
def join_field(node: object) -> str:
|
|
292
|
+
if isinstance(node, Field):
|
|
293
|
+
return rep.join(join_repetition(child) for child in node)
|
|
294
|
+
return join_repetition(node)
|
|
295
|
+
|
|
296
|
+
out_segments: list[str] = []
|
|
297
|
+
for segment in message:
|
|
298
|
+
seg_id = str(segment[0])
|
|
299
|
+
if seg_id == "MSH":
|
|
300
|
+
# python-hl7 indexes MSH as: [0]="MSH", [1]=MSH-1 (the field sep itself), [2]=MSH-2; MSH-1
|
|
301
|
+
# is implied by the field join and MSH-2 is rewritten to advertise the new delimiters, so
|
|
302
|
+
# the real fields start at index 3.
|
|
303
|
+
parts = ["MSH", comp + rep + esc + sub]
|
|
304
|
+
tail = list(segment)[3:]
|
|
305
|
+
else:
|
|
306
|
+
parts = [seg_id]
|
|
307
|
+
tail = list(segment)[1:]
|
|
308
|
+
parts.extend(join_field(node) for node in tail)
|
|
309
|
+
out_segments.append(field_sep.join(parts))
|
|
310
|
+
return seg_sep.join(out_segments) + seg_sep
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# --- destination -------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _mllp_ssl_context(s: Mapping[str, Any], *, server: bool) -> ssl.SSLContext | None:
|
|
317
|
+
"""Build the per-connection MLLP ``SSLContext`` (WP-13b, ADR 0002), or ``None`` when ``tls`` is off.
|
|
318
|
+
|
|
319
|
+
Built once in the connector ``__init__`` (a bad cert/key fails at build, like LDAPS). TLS 1.2+ floor.
|
|
320
|
+
**Inbound** (``server=True``): present ``tls_cert_file``/``tls_key_file`` as the server identity;
|
|
321
|
+
``tls_ca_file`` opts into mTLS (require + verify a client cert). **Outbound** (``server=False``):
|
|
322
|
+
verify the peer's cert against ``tls_ca_file`` (or the system trust store) with hostname checking,
|
|
323
|
+
and optionally present ``tls_cert_file`` for mTLS. ``tls_verify=False`` (outbound) is MITM-able and
|
|
324
|
+
refused unless ``insecure_tls_allowed()``, with a loud warning — exactly as LDAPS / SQL Server."""
|
|
325
|
+
if not s.get("tls"):
|
|
326
|
+
return None
|
|
327
|
+
cert, key, ca = s.get("tls_cert_file"), s.get("tls_key_file"), s.get("tls_ca_file")
|
|
328
|
+
if server:
|
|
329
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
330
|
+
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
331
|
+
if not cert:
|
|
332
|
+
raise ValueError("MLLP inbound tls=true requires tls_cert_file (the server identity)")
|
|
333
|
+
ctx.load_cert_chain(certfile=cert, keyfile=key)
|
|
334
|
+
if ca: # opt-in mTLS: require + verify a client cert against this trust anchor
|
|
335
|
+
ctx.load_verify_locations(cafile=ca)
|
|
336
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
337
|
+
harden_kex_groups(ctx) # pin approved ECDHE groups where supported (ASVS 11.6.2)
|
|
338
|
+
harden_verify_flags(ctx) # strict RFC 5280 validation of any mTLS client cert (ASVS 12.1.4)
|
|
339
|
+
return ctx
|
|
340
|
+
# Outbound (client): verify the server cert unless explicitly — and loudly — disabled.
|
|
341
|
+
verify = bool(s.get("tls_verify", True))
|
|
342
|
+
if not verify and not insecure_tls_allowed():
|
|
343
|
+
raise ValueError(
|
|
344
|
+
"MLLP tls_verify=false disables server-certificate verification (MITM risk). Use a trusted "
|
|
345
|
+
f"CA (tls_ca_file), or set {INSECURE_TLS_ESCAPE_ENV}=1 to allow it on a trusted-network bind."
|
|
346
|
+
)
|
|
347
|
+
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca)
|
|
348
|
+
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
349
|
+
if verify:
|
|
350
|
+
ctx.check_hostname = bool(s.get("tls_check_hostname", True))
|
|
351
|
+
else:
|
|
352
|
+
logger.warning(
|
|
353
|
+
"MLLP TLS certificate verification is DISABLED (tls_verify=false, permitted by %s).",
|
|
354
|
+
INSECURE_TLS_ESCAPE_ENV,
|
|
355
|
+
)
|
|
356
|
+
ctx.check_hostname = False
|
|
357
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
358
|
+
if cert: # optional client identity for mTLS
|
|
359
|
+
ctx.load_cert_chain(certfile=cert, keyfile=key)
|
|
360
|
+
harden_kex_groups(ctx) # pin approved ECDHE groups where supported (ASVS 11.6.2)
|
|
361
|
+
if verify: # skip the tls_verify=false / CERT_NONE path — nothing to validate (ASVS 12.1.4)
|
|
362
|
+
harden_verify_flags(ctx) # strict RFC 5280 validation of the server cert
|
|
363
|
+
return ctx
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class MLLPDestination(DestinationConnector):
|
|
367
|
+
"""Send a payload to an MLLP receiver and require a positive ACK.
|
|
368
|
+
|
|
369
|
+
Phase 1 opens a fresh connection per delivery — simple and robust to flaky peers; a
|
|
370
|
+
persistent/pooled connection can come later. A negative ACK (MSA-1 not in the accept
|
|
371
|
+
family) or any I/O/timeout raises :class:`DeliveryError`, so the pipeline retries.
|
|
372
|
+
|
|
373
|
+
Note (at-least-once): if the payload is sent but the ACK is lost (peer closes / times
|
|
374
|
+
out after receiving), the retry re-delivers — the receiver may see a duplicate. This is
|
|
375
|
+
the documented at-least-once trade-off; outbound receivers must be idempotent.
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
def __init__(self, config: Destination) -> None:
|
|
379
|
+
s = config.settings
|
|
380
|
+
self.host: str = s.get("host", "127.0.0.1")
|
|
381
|
+
self.port: int = int(s["port"])
|
|
382
|
+
self.timeout: float = float(s.get("timeout_seconds", 30.0))
|
|
383
|
+
self.connect_timeout: float = float(s.get("connect_timeout", 10.0))
|
|
384
|
+
self.encoding: str = s.get("encoding", "utf-8")
|
|
385
|
+
mf = s.get("max_frame_bytes", DEFAULT_MAX_FRAME_BYTES)
|
|
386
|
+
self.max_frame_bytes: int | None = int(mf) if mf else None
|
|
387
|
+
# Per-outbound delimiter override (Corepoint -override parity): None = ship the payload as-is
|
|
388
|
+
# (byte-identical, the default). A set value is validated NOW (at build) so a malformed override
|
|
389
|
+
# fails at dry-run / `check`, not per delivery; it is applied in send() before framing.
|
|
390
|
+
chars = s.get("encoding_characters")
|
|
391
|
+
self.encoding_characters: EncodingCharacters | None = (
|
|
392
|
+
parse_encoding_characters(chars) if chars is not None else None
|
|
393
|
+
)
|
|
394
|
+
# ADR 0013: when True, send() returns a DeliveryResponse carrying the application ACK (the
|
|
395
|
+
# MSA/ERR the partner returned) for the delivery worker to capture. Default False → returns None,
|
|
396
|
+
# byte-identical. A *read* failure (peer-close, frame-size) is never captured — it stays a
|
|
397
|
+
# retryable DeliveryError; only a read-but-unparseable ACK becomes outcome='unparseable'.
|
|
398
|
+
self.capture_response: bool = bool(s.get("capture_response", False))
|
|
399
|
+
# WP-13b: per-connection outbound TLS (verify the peer). Built once here so a bad cert/CA fails
|
|
400
|
+
# at build (dry-run/check), not per delivery. None when tls is off → plaintext, byte-identical.
|
|
401
|
+
self._ssl: ssl.SSLContext | None = _mllp_ssl_context(s, server=False)
|
|
402
|
+
|
|
403
|
+
async def send(self, payload: str) -> DeliveryResponse | None:
|
|
404
|
+
if self.encoding_characters is not None:
|
|
405
|
+
# Re-encode the body with this destination's delimiters before framing. A non-HL7/garbled
|
|
406
|
+
# payload can't be rewritten — surface it as a DeliveryError (the message reached neither the
|
|
407
|
+
# wire nor the peer) rather than framing a corrupted message; the pipeline records the ERROR.
|
|
408
|
+
try:
|
|
409
|
+
payload = reencode_delimiters(payload, self.encoding_characters)
|
|
410
|
+
except ValueError as exc:
|
|
411
|
+
raise DeliveryError(f"MLLP encoding-character override failed: {exc}") from exc
|
|
412
|
+
try:
|
|
413
|
+
reader, writer = await asyncio.wait_for(
|
|
414
|
+
asyncio.open_connection(
|
|
415
|
+
self.host,
|
|
416
|
+
self.port,
|
|
417
|
+
ssl=self._ssl,
|
|
418
|
+
# SNI + (when verifying) hostname check against the configured peer host.
|
|
419
|
+
server_hostname=self.host if self._ssl else None,
|
|
420
|
+
),
|
|
421
|
+
self.connect_timeout,
|
|
422
|
+
)
|
|
423
|
+
except (OSError, asyncio.TimeoutError) as exc:
|
|
424
|
+
raise DeliveryError(f"MLLP connect to {self.host}:{self.port} failed: {exc}") from exc
|
|
425
|
+
try:
|
|
426
|
+
writer.write(frame(payload, self.encoding))
|
|
427
|
+
await asyncio.wait_for(writer.drain(), self.timeout)
|
|
428
|
+
ack_bytes = await asyncio.wait_for(self._read_ack(reader), self.timeout)
|
|
429
|
+
except asyncio.TimeoutError as exc:
|
|
430
|
+
raise DeliveryError("MLLP timed out waiting for ACK") from exc
|
|
431
|
+
except OSError as exc:
|
|
432
|
+
raise DeliveryError(f"MLLP I/O error: {exc}") from exc
|
|
433
|
+
finally:
|
|
434
|
+
writer.close()
|
|
435
|
+
try:
|
|
436
|
+
await writer.wait_closed()
|
|
437
|
+
except OSError:
|
|
438
|
+
pass
|
|
439
|
+
return self._check_ack(ack_bytes)
|
|
440
|
+
|
|
441
|
+
async def test_connection(self) -> None:
|
|
442
|
+
# Reachability only: open + close a connection (no frame, no ACK) so a test never delivers.
|
|
443
|
+
await probe_tcp_reachable(self.host, self.port, self.connect_timeout, "MLLP")
|
|
444
|
+
|
|
445
|
+
async def _read_ack(self, reader: asyncio.StreamReader) -> bytes:
|
|
446
|
+
decoder = MLLPDecoder(max_frame_bytes=self.max_frame_bytes)
|
|
447
|
+
while True:
|
|
448
|
+
chunk = await reader.read(4096)
|
|
449
|
+
if not chunk:
|
|
450
|
+
raise DeliveryError("MLLP peer closed before sending an ACK")
|
|
451
|
+
try:
|
|
452
|
+
for message in decoder.feed(chunk):
|
|
453
|
+
return message
|
|
454
|
+
except MLLPFrameError as exc:
|
|
455
|
+
raise DeliveryError(f"ACK exceeded max frame size: {exc}") from exc
|
|
456
|
+
|
|
457
|
+
def _check_ack(self, ack_bytes: bytes) -> DeliveryResponse | None:
|
|
458
|
+
try:
|
|
459
|
+
ack = Peek.parse(ack_bytes)
|
|
460
|
+
except HL7PeekError as exc:
|
|
461
|
+
# A reply frame WAS received (the read above succeeded) but its MSA won't parse. For a
|
|
462
|
+
# capturing outbound this is a captured outcome='unparseable' — a reply arrived; we just
|
|
463
|
+
# can't read it — NOT "no reply". For a non-capturing outbound it stays byte-identical:
|
|
464
|
+
# a transport-level problem retried like any I/O failure (plain DeliveryError).
|
|
465
|
+
if self.capture_response:
|
|
466
|
+
return DeliveryResponse(
|
|
467
|
+
body=ack_bytes.decode(self.encoding, errors="replace"),
|
|
468
|
+
outcome="unparseable",
|
|
469
|
+
detail=f"unparseable ACK: {safe_exc(exc)}", # scrub: a bad ACK can embed a reply fragment (#120)
|
|
470
|
+
)
|
|
471
|
+
raise DeliveryError(f"unparseable ACK: {exc}") from exc
|
|
472
|
+
msa1 = ack.field("MSA-1")
|
|
473
|
+
if msa1 in ("AA", "CA"):
|
|
474
|
+
if self.capture_response:
|
|
475
|
+
return DeliveryResponse(
|
|
476
|
+
body=ack_bytes.decode(self.encoding, errors="replace"),
|
|
477
|
+
outcome="accepted",
|
|
478
|
+
detail=f"MSA-1={msa1}",
|
|
479
|
+
)
|
|
480
|
+
return None
|
|
481
|
+
detail = ack.field("MSA-3") or ""
|
|
482
|
+
# A negative ACK is a *partner rejection*, not a transport failure: the message reached the
|
|
483
|
+
# peer, which said no. It is NOT captured — it routes through the existing NegativeAckError
|
|
484
|
+
# failure policy (dead-letter on a permanent reject / retry on a transient error), unchanged by
|
|
485
|
+
# capture. AR/CR (reject) is permanent (fail-fast); AE/CE (error) and any unrecognized negative
|
|
486
|
+
# code are treated as transient (retry), the conservative choice when the intent is unclear.
|
|
487
|
+
code, permanent = ("AR", True) if msa1 in ("AR", "CR") else ("AE", False)
|
|
488
|
+
raise NegativeAckError(
|
|
489
|
+
f"negative ACK (MSA-1={msa1}): {detail}".rstrip(": "), code=code, permanent=permanent
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# --- source ------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
class MLLPSource(SourceConnector):
|
|
497
|
+
"""Listen for inbound MLLP connections, hand each message to the pipeline handler,
|
|
498
|
+
and frame whatever the handler returns back to the sender as the ACK."""
|
|
499
|
+
|
|
500
|
+
def __init__(self, config: Source) -> None:
|
|
501
|
+
s = config.settings
|
|
502
|
+
# The bind interface is injected from the service's [inbound].bind_host (authors never set a
|
|
503
|
+
# host on an inbound). Fall back to loopback for a missing/None value — never bind all
|
|
504
|
+
# interfaces (0.0.0.0) by accident, since MLLP has no transport auth. See docs/CONNECTIONS.md.
|
|
505
|
+
self.host: str = s.get("host") or "127.0.0.1"
|
|
506
|
+
self.port: int = int(s["port"])
|
|
507
|
+
self.encoding: str = s.get("encoding", "utf-8")
|
|
508
|
+
# Caps below: key absent → secure default; present-but-falsy (None/0) → disabled.
|
|
509
|
+
mc = s.get("max_connections", DEFAULT_MAX_CONNECTIONS)
|
|
510
|
+
self.max_connections: int | None = int(mc) if mc else None
|
|
511
|
+
rt = s.get("receive_timeout", DEFAULT_RECEIVE_TIMEOUT)
|
|
512
|
+
self.receive_timeout: float | None = float(rt) if rt else None
|
|
513
|
+
mf = s.get("max_frame_bytes", DEFAULT_MAX_FRAME_BYTES)
|
|
514
|
+
self.max_frame_bytes: int | None = int(mf) if mf else None
|
|
515
|
+
# Per-connection peer-IP allowlist (Tier 4 operability): when set, a connecting peer whose IP
|
|
516
|
+
# is not listed is refused at accept time. Absent/empty = no restriction.
|
|
517
|
+
sa = s.get("source_ip_allowlist")
|
|
518
|
+
self.source_ip_allowlist: list[str] | None = [str(x) for x in sa] if sa else None
|
|
519
|
+
# WP-13b: per-connection inbound TLS (present a server cert; opt-in mTLS via tls_ca_file). Built
|
|
520
|
+
# once here so a bad cert/key fails at build. None when tls is off → plaintext, byte-identical.
|
|
521
|
+
self._ssl: ssl.SSLContext | None = _mllp_ssl_context(s, server=True)
|
|
522
|
+
self._server: asyncio.Server | None = None
|
|
523
|
+
self._handler: InboundHandler | None = None
|
|
524
|
+
self._active = 0
|
|
525
|
+
# Live client writers + their handler tasks, so stop()/reload can actively close established
|
|
526
|
+
# connections (a peer may hold one open for weeks) and bound the wait — server.wait_closed()
|
|
527
|
+
# alone hangs on a still-connected sender on py3.12.1+ and is a no-op quiesce on 3.11 (H-2).
|
|
528
|
+
self._clients: set[asyncio.StreamWriter] = set()
|
|
529
|
+
self._client_tasks: set[asyncio.Task[None]] = set()
|
|
530
|
+
|
|
531
|
+
async def start(
|
|
532
|
+
self, handler: InboundHandler, *, leader_gate: Callable[[], bool] | None = None
|
|
533
|
+
) -> None:
|
|
534
|
+
# leader_gate is ignored: a listen source runs on every node (each binds its own endpoint;
|
|
535
|
+
# a load balancer / per-node ports distribute inbound connections), so there is no
|
|
536
|
+
# shared-resource double-read to gate. Accepted only so the runner's call is uniform.
|
|
537
|
+
self._handler = handler
|
|
538
|
+
self._server = await asyncio.start_server(
|
|
539
|
+
self._on_client, self.host, self.port, ssl=self._ssl
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
@property
|
|
543
|
+
def sockport(self) -> int:
|
|
544
|
+
"""The actual bound port (useful when configured with port 0 in tests)."""
|
|
545
|
+
assert self._server is not None
|
|
546
|
+
port: int = self._server.sockets[0].getsockname()[1]
|
|
547
|
+
return port
|
|
548
|
+
|
|
549
|
+
async def stop(self) -> None:
|
|
550
|
+
# Stop accepting NEW connections (this alone does not close established ones).
|
|
551
|
+
if self._server is not None:
|
|
552
|
+
self._server.close()
|
|
553
|
+
# Close established client connections BEFORE awaiting the server — otherwise
|
|
554
|
+
# server.wait_closed() hangs on py3.12.1+ waiting for in-flight handlers of a peer that
|
|
555
|
+
# holds its connection open. Closing the writer makes each read loop return EOF; a message
|
|
556
|
+
# mid-handler still finishes its commit (the body is durably stored before any ACK, so
|
|
557
|
+
# at-least-once holds — only a not-yet-sent ACK is lost, which the sender retries). Then
|
|
558
|
+
# await the connection tasks with a bounded grace and cancel any stragglers (review H-2).
|
|
559
|
+
for writer in list(self._clients):
|
|
560
|
+
writer.close()
|
|
561
|
+
pending = [task for task in self._client_tasks if not task.done()]
|
|
562
|
+
if pending:
|
|
563
|
+
_done, still_running = await asyncio.wait(pending, timeout=_CLIENT_SHUTDOWN_GRACE)
|
|
564
|
+
for task in still_running:
|
|
565
|
+
task.cancel()
|
|
566
|
+
if still_running:
|
|
567
|
+
await asyncio.gather(*still_running, return_exceptions=True)
|
|
568
|
+
self._clients.clear()
|
|
569
|
+
self._client_tasks.clear()
|
|
570
|
+
# Now that no client handlers are in flight, this completes promptly instead of hanging.
|
|
571
|
+
if self._server is not None:
|
|
572
|
+
await self._server.wait_closed()
|
|
573
|
+
self._server = None
|
|
574
|
+
|
|
575
|
+
async def _on_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
576
|
+
assert self._handler is not None
|
|
577
|
+
# Register before anything else so stop() can always find + close this connection — no race
|
|
578
|
+
# with a client that connects just as we're stopping (review H-2).
|
|
579
|
+
task = asyncio.current_task()
|
|
580
|
+
self._clients.add(writer)
|
|
581
|
+
if task is not None:
|
|
582
|
+
self._client_tasks.add(task)
|
|
583
|
+
try:
|
|
584
|
+
if self.source_ip_allowlist is not None:
|
|
585
|
+
peer = writer.get_extra_info("peername")
|
|
586
|
+
if not peer_ip_allowed(peer, self.source_ip_allowlist):
|
|
587
|
+
logger.warning(
|
|
588
|
+
"MLLP connection from %s refused: not in source_ip_allowlist", peer
|
|
589
|
+
)
|
|
590
|
+
return # not allowlisted — refuse (closed in the outer finally; _active untouched)
|
|
591
|
+
if self.max_connections is not None and self._active >= self.max_connections:
|
|
592
|
+
return # at capacity — refuse the new client (closed in the outer finally)
|
|
593
|
+
self._active += 1
|
|
594
|
+
try:
|
|
595
|
+
decoder = MLLPDecoder(max_frame_bytes=self.max_frame_bytes)
|
|
596
|
+
while True:
|
|
597
|
+
if self.receive_timeout:
|
|
598
|
+
try:
|
|
599
|
+
chunk = await asyncio.wait_for(reader.read(4096), self.receive_timeout)
|
|
600
|
+
except asyncio.TimeoutError:
|
|
601
|
+
break # idle past receive_timeout — close the connection
|
|
602
|
+
else:
|
|
603
|
+
chunk = await reader.read(4096)
|
|
604
|
+
if not chunk:
|
|
605
|
+
break
|
|
606
|
+
try:
|
|
607
|
+
for message in decoder.feed(chunk):
|
|
608
|
+
reply = await self._handler(message)
|
|
609
|
+
if reply is not None:
|
|
610
|
+
writer.write(frame(reply, self.encoding))
|
|
611
|
+
await writer.drain()
|
|
612
|
+
except MLLPFrameError as exc:
|
|
613
|
+
peer = writer.get_extra_info("peername")
|
|
614
|
+
logger.warning(
|
|
615
|
+
"MLLP frame from %s over cap; closing connection: %s", peer, exc
|
|
616
|
+
)
|
|
617
|
+
break # drop the connection rather than buffer without bound
|
|
618
|
+
except OSError:
|
|
619
|
+
raise # peer reset / write failure → handled by the outer OSError catch (quiet)
|
|
620
|
+
except Exception as exc:
|
|
621
|
+
# Last-resort (ASVS 16.5.4): an unexpected handler/codec error must not let the
|
|
622
|
+
# per-connection task die silently or leak detail. Log redacted; drop the conn.
|
|
623
|
+
peer = writer.get_extra_info("peername")
|
|
624
|
+
logger.error(
|
|
625
|
+
"MLLP connection from %s failed unexpectedly: %s", peer, safe_exc(exc)
|
|
626
|
+
)
|
|
627
|
+
break
|
|
628
|
+
except OSError:
|
|
629
|
+
pass # peer reset; nothing to do but drop the connection
|
|
630
|
+
finally:
|
|
631
|
+
self._active -= 1
|
|
632
|
+
finally:
|
|
633
|
+
self._clients.discard(writer)
|
|
634
|
+
if task is not None:
|
|
635
|
+
self._client_tasks.discard(task)
|
|
636
|
+
writer.close()
|
|
637
|
+
try:
|
|
638
|
+
await writer.wait_closed()
|
|
639
|
+
except OSError:
|
|
640
|
+
pass
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
register_destination(ConnectorType.MLLP, MLLPDestination)
|
|
644
|
+
register_source(ConnectorType.MLLP, MLLPSource)
|