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
2026-07-27 19:17:39 +01:00
parent 7c68d478af
commit 0ebcfb00ff
4 changed files with 71 additions and 5 deletions
+7
View File
@@ -42,6 +42,13 @@ LIVENESS_PROBE_TIMEOUT_S = 1.5
# without stalling setup; it matches the per-resource read timeout elsewhere.
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
DEVICE_SUPPORT_ISSUE_URL = (
+28 -1
View File
@@ -2,9 +2,11 @@
from __future__ import annotations
import asyncio
import ipaddress
import logging
import threading
import time
import zlib
from datetime import timedelta
from typing import Any
@@ -37,6 +39,7 @@ from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S
from .const import (
DOMAIN, CONF_HOST, CONF_PORT, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
CONF_BYPASS_REMOTE_CONTROL, DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
DTLS_LOCAL_PORT_BASE,
)
_LOGGER = logging.getLogger(__name__)
@@ -54,6 +57,29 @@ class _NoOpDescriptor:
_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:
"""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]
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.start_reader()
self._session = sess
+2 -2
View File
@@ -10,7 +10,7 @@
"requirements": [
"cbor2>=5.4.6",
"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 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 (
remote_control_enabled,
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_key == 'remote_control_disabled'
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