fix: bind a fixed DTLS source port per device to evict stale sessions

When HA restarts without a clean DTLS close_notify (crash, host reboot),
the appliance keeps an orphaned DTLS association keyed to the client's
(IP, source port). Reconnecting from a fresh ephemeral port looks like a
new peer, so the device holds the orphan until its own timer reaps it,
which is 5 to 15 min on always-on appliances (fridges), during which the
new session's first reads hang. This is the root cause behind the repeated
"DTLS handshake timeout" reconnect storms on always-on devices (#119).

Bind a deterministic source port per device so every reconnect re-handshakes
over the same 5-tuple, which the device must treat as a rebooted peer and
evict the old association for (RFC 6347 §4.2.8). Recovery drops from a
device-timer wait to a single handshake.

The port must be stable across restarts and unique per device on the HA host
(the library socket is unconnected, so a shared source port would cross-
deliver datagrams). _local_source_port() uses the host's last IPv4 octet as
the offset from DTLS_LOCAL_PORT_BASE (unique on a /24), with a CRC32 fallback
for non-IPv4 hosts.

Requires smartthings-local >= 0.1.1, which adds DtlsCoapSession(local_port=).
The fix is backwards compatible upstream: local_port defaults to None
(previous ephemeral-port behaviour).

Root-caused and verified upstream in QuiteYellow/SmartThings-Local#14
(bench-verified on oven + dryer, field-verified on an always-on fridge
across repeated restarts).
This commit is contained in:
Jack Nagy committed 2026-07-27 19:17:39 +01:00
1 parent 7c68d478af
commit 0ebcfb00ff
4 files changed
+71 -5

No files matched your search

+7
View File
@@ -42,6 +42,13 @@ LIVENESS_PROBE_TIMEOUT_S = 1.5
# without stalling setup; it matches the per-resource read timeout elsewhere. # without stalling setup; it matches the per-resource read timeout elsewhere.
PROBE_GET_TIMEOUT_S = 10.0 PROBE_GET_TIMEOUT_S = 10.0
# Base for the local (client-side) DTLS source port, distinct from the
# destination probe ports above. See coordinator._local_source_port for why a
# fixed per-device source port matters and how the per-device offset is
# derived. Base mirrors the upstream smartthings-local reference bridge.
# Requires smartthings-local >= 0.1.1.
DTLS_LOCAL_PORT_BASE = 49700
SUMMARY_INTERVAL_S = 30.0 SUMMARY_INTERVAL_S = 30.0
DEVICE_SUPPORT_ISSUE_URL = ( DEVICE_SUPPORT_ISSUE_URL = (
+28 -1
View File
@@ -2,9 +2,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import ipaddress
import logging import logging
import threading import threading
import time import time
import zlib
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import Any
@@ -37,6 +39,7 @@ from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S
from .const import ( from .const import (
DOMAIN, CONF_HOST, CONF_PORT, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, DOMAIN, CONF_HOST, CONF_PORT, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
CONF_BYPASS_REMOTE_CONTROL, DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S, CONF_BYPASS_REMOTE_CONTROL, DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
DTLS_LOCAL_PORT_BASE,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -54,6 +57,29 @@ class _NoOpDescriptor:
_RECOVERY_RETRY_S = 600.0 # re-attempt observe mode this often while polling _RECOVERY_RETRY_S = 600.0 # re-attempt observe mode this often while polling
def _local_source_port(host: str) -> int:
"""Deterministic UDP source port for this device's DTLS socket.
Binding the same source port on every (re)connect keeps the client on one
5-tuple, so the appliance evicts an orphaned session left by a previous run
(unclean shutdown -> no DTLS close_notify) at handshake time per RFC 6347
§4.2.8, instead of holding it for 5-15 min while the new session's reads
hang. See DTLS_LOCAL_PORT_BASE in const.py. Requires smartthings-local
>= 0.1.1 (the version that added DtlsCoapSession(local_port=...)).
The port must be stable across restarts and unique per device on this HA
host: the library's socket is unconnected (recvfrom), so two devices
sharing a source port would mis-demux each other's datagrams. For the usual
dotted-IPv4 host we use the last octet as the offset (unique on a /24);
anything else folds a stable CRC32 into the same 256-wide window.
"""
try:
offset = int(ipaddress.IPv4Address(host)) & 0xFF
except (ipaddress.AddressValueError, ValueError):
offset = zlib.crc32(host.encode()) & 0xFF
return DTLS_LOCAL_PORT_BASE + offset
def _is_placeholder_serial(serial: str) -> bool: def _is_placeholder_serial(serial: str) -> bool:
"""True for a non-empty serialNum that isn't actually a real identity. """True for a non-empty serialNum that isn't actually a real identity.
@@ -182,7 +208,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
key_pem = self._entry.data[CONF_LEAF_KEY_PEM] key_pem = self._entry.data[CONF_LEAF_KEY_PEM]
sess = DtlsCoapSession(host, port, cert_pem=cert_pem, key_pem=key_pem, sess = DtlsCoapSession(host, port, cert_pem=cert_pem, key_pem=key_pem,
on_notification=self._observe.on_notification) on_notification=self._observe.on_notification,
local_port=_local_source_port(host))
sess.connect() sess.connect()
sess.start_reader() sess.start_reader()
self._session = sess self._session = sess
+2 -2
View File
@@ -10,7 +10,7 @@
"requirements": [ "requirements": [
"cbor2>=5.4.6", "cbor2>=5.4.6",
"pyOpenSSL>=23.0", "pyOpenSSL>=23.0",
"smartthings-local>=0.1.0" "smartthings-local>=0.1.1"
], ],
"version": "0.13.0" "version": "0.14.0"
} }
+34 -2
View File
@@ -11,9 +11,12 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import issue_registry as ir from homeassistant.helpers import issue_registry as ir
from custom_components.localthings.const import ( from custom_components.localthings.const import (
CONF_BYPASS_REMOTE_CONTROL, CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S, CONF_BYPASS_REMOTE_CONTROL, CONF_HOST, DOMAIN, DTLS_LOCAL_PORT_BASE,
SUMMARY_INTERVAL_S,
)
from custom_components.localthings.coordinator import (
LocalThingsCoordinator, _local_source_port,
) )
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.capabilities.common import ( from custom_components.localthings.registry.capabilities.common import (
remote_control_enabled, remote_control_enabled,
remote_control_required_for_write, remote_control_required_for_write,
@@ -1174,3 +1177,32 @@ async def test_send_command_operational_still_blocked_when_without_sc(
assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == 'remote_control_disabled' assert exc_info.value.translation_key == 'remote_control_disabled'
assert posted is False assert posted is False
def test_local_source_port_stable_and_in_range() -> None:
"""Same host always maps to the same port (stability is the whole point:
a reconnect must reuse the 5-tuple), and it stays in the documented window."""
for host in ('192.168.1.217', '10.0.0.42', 'fridge.local'):
port = _local_source_port(host)
assert port == _local_source_port(host)
assert DTLS_LOCAL_PORT_BASE <= port <= DTLS_LOCAL_PORT_BASE + 0xFF
def test_local_source_port_unique_per_host_on_a_24() -> None:
"""Distinct last octets -> distinct ports, so two devices on the same HA
host never share a source port (the lib's socket is unconnected, so a
shared port would cross-deliver datagrams)."""
ports = {_local_source_port(f'192.168.1.{n}') for n in range(1, 255)}
assert len(ports) == 254
def test_local_source_port_ipv4_uses_last_octet() -> None:
"""The IPv4 fast path is the last octet, not a hash."""
assert _local_source_port('192.168.1.217') == DTLS_LOCAL_PORT_BASE + 217
def test_local_source_port_non_ipv4_falls_back_to_hash() -> None:
"""A non-IPv4 host still yields a deterministic in-range port."""
port = _local_source_port('some-hostname')
assert port == _local_source_port('some-hostname')
assert DTLS_LOCAL_PORT_BASE <= port <= DTLS_LOCAL_PORT_BASE + 0xFF