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:
@@ -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