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:
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user