feat(protocol): fixed DTLS source port so reconnects evict orphaned sessions

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 <garrysuchiy@gmail.com>
This commit is contained in:
Jack Nagy
2026-07-26 20:53:56 +01:00
co-authored by vmvarga
parent 6653de3b2c
commit 8c2108a510
2 changed files with 29 additions and 1 deletions
+10
View File
@@ -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
+19 -1
View File
@@ -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)