From 8c2108a510bf1942fda10e8e5c6057df83795e76 Mon Sep 17 00:00:00 2001 From: Jack Nagy Date: Sun, 26 Jul 2026 20:48:29 +0100 Subject: [PATCH] feat(protocol): fixed DTLS source port so reconnects evict orphaned sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-cause fix for the stale-session stall on always-on appliances (#14). When the client dies without close_notify (crash, SIGKILL), the device keeps an orphaned DTLS association keyed to the old 5-tuple; a reconnect from a fresh ephemeral port presents as a brand-new peer, so the orphan lingers until the device's own timer reaps it (observed 5-15 min). RFC 6347 §4.2.8 covers exactly this: a ClientHello arriving on an existing association's 5-tuple means the peer rebooted, and the server must complete the new handshake and discard the old association. Add an optional local_port to DtlsCoapSession that binds the UDP source port, and have the bridge bind base+appliance-index, so every reconnect re-handshakes over the same 5-tuple and the orphan is evicted instead of waited out. Bench-verified on live hardware (2026-07-26): RT-OCF accepts the same-5-tuple rehandshake (oven, dryer: handshake completes over a crash-orphaned association, reads work immediately). The oven does not reproduce the fridge stall even with 11 crash-orphaned OBSERVE registrations, so fridge-side confirmation of the eviction is still needed. Co-authored-by: vmvarga --- mqtt_demo/bridge.py | 10 ++++++++++ smartthings_local/protocol/dtls_session.py | 20 +++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/mqtt_demo/bridge.py b/mqtt_demo/bridge.py index b5f6039..28f77e4 100644 --- a/mqtt_demo/bridge.py +++ b/mqtt_demo/bridge.py @@ -60,6 +60,15 @@ UNREACHABLE_RECONNECT_S = 120.0 # session. OBSERVE_REFRESH_INTERVAL_S = 6 * 3600.0 +# Base for the fixed DTLS source port; each appliance binds base+index so +# every reconnect uses the same 5-tuple. If the bridge dies without +# close_notify (crash, SIGKILL), the device holds an orphaned association +# keyed to the old 5-tuple; re-handshaking from the SAME port makes the +# device evict the orphan (RFC 6347 §4.2.8) instead of wedging on it — +# the root cause behind stale sessions on always-on appliances, where the +# orphan otherwise lingers 5-15 min. +DTLS_LOCAL_PORT_BASE = 49700 + class PushBridge: @@ -239,6 +248,7 @@ class PushBridge: cert_path=self.shared.CERT_PATH, key_path=self.shared.KEY_PATH, on_notification=self._on_notification, + local_port=DTLS_LOCAL_PORT_BASE + self.app.index, ) sess.connect() self.session = sess diff --git a/smartthings_local/protocol/dtls_session.py b/smartthings_local/protocol/dtls_session.py index 6adb31d..3c0dc11 100644 --- a/smartthings_local/protocol/dtls_session.py +++ b/smartthings_local/protocol/dtls_session.py @@ -113,7 +113,8 @@ class DtlsCoapSession: def __init__(self, host, port, cert_path=None, key_path=None, *, cert_pem=None, key_pem=None, on_notification=None, mtu=1200, - rate_limit_rps: float = _DEFAULT_RATE_LIMIT_RPS): + rate_limit_rps: float = _DEFAULT_RATE_LIMIT_RPS, + local_port=None): if (cert_path is not None or key_path is not None) and \ (cert_pem is not None or key_pem is not None): raise ValueError( @@ -134,6 +135,17 @@ class DtlsCoapSession: self.on_notification = on_notification # fn(href, payload_bytes) self.mtu = mtu self._min_req_interval = 1.0 / rate_limit_rps + # Optional fixed UDP source port. A client that dies without + # close_notify leaves an orphaned DTLS association on the device, + # keyed to the old 5-tuple; reconnecting from a fresh ephemeral + # port presents as a *new* peer and the orphan lingers until the + # device's own timer reaps it (observed 5-15 min on always-on + # appliances). Binding the same source port on every connect makes + # a restart re-handshake over the SAME 5-tuple, which RFC 6347 + # §4.2.8 requires the server to treat as a rebooted peer: complete + # the new handshake and discard the old association. Verified + # accepted by RT-OCF (oven, 2026-07-26). + self.local_port = local_port self.sock = None self.conn = None @@ -193,6 +205,12 @@ class DtlsCoapSession: conn.set_ciphertext_mtu(self.mtu) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + if self.local_port is not None: + # Fixed source port → same 5-tuple on reconnect, so the device + # evicts any orphaned association per RFC 6347 §4.2.8 instead + # of serving a second one alongside it. See __init__. + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(('', self.local_port)) sock.settimeout(2.0) dest = (self.host, self.port)