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:
1 parent
6653de3b2c
commit
3f1a8cb90f
2 files changed
+29
-1
No files matched your search
@@ -60,6 +60,15 @@ UNREACHABLE_RECONNECT_S = 120.0
|
|||||||
# session.
|
# session.
|
||||||
OBSERVE_REFRESH_INTERVAL_S = 6 * 3600.0
|
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:
|
class PushBridge:
|
||||||
|
|
||||||
@@ -239,6 +248,7 @@ class PushBridge:
|
|||||||
cert_path=self.shared.CERT_PATH,
|
cert_path=self.shared.CERT_PATH,
|
||||||
key_path=self.shared.KEY_PATH,
|
key_path=self.shared.KEY_PATH,
|
||||||
on_notification=self._on_notification,
|
on_notification=self._on_notification,
|
||||||
|
local_port=DTLS_LOCAL_PORT_BASE + self.app.index,
|
||||||
)
|
)
|
||||||
sess.connect()
|
sess.connect()
|
||||||
self.session = sess
|
self.session = sess
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ class DtlsCoapSession:
|
|||||||
def __init__(self, host, port, cert_path=None, key_path=None, *,
|
def __init__(self, host, port, cert_path=None, key_path=None, *,
|
||||||
cert_pem=None, key_pem=None,
|
cert_pem=None, key_pem=None,
|
||||||
on_notification=None, mtu=1200,
|
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 \
|
if (cert_path is not None or key_path is not None) and \
|
||||||
(cert_pem is not None or key_pem is not None):
|
(cert_pem is not None or key_pem is not None):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -134,6 +135,17 @@ class DtlsCoapSession:
|
|||||||
self.on_notification = on_notification # fn(href, payload_bytes)
|
self.on_notification = on_notification # fn(href, payload_bytes)
|
||||||
self.mtu = mtu
|
self.mtu = mtu
|
||||||
self._min_req_interval = 1.0 / rate_limit_rps
|
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.sock = None
|
||||||
self.conn = None
|
self.conn = None
|
||||||
@@ -193,6 +205,12 @@ class DtlsCoapSession:
|
|||||||
conn.set_ciphertext_mtu(self.mtu)
|
conn.set_ciphertext_mtu(self.mtu)
|
||||||
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
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)
|
sock.settimeout(2.0)
|
||||||
dest = (self.host, self.port)
|
dest = (self.host, self.port)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user