Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions smartthings_local/protocol/dtls_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@
from .dtls_handshake import _drive_dtls_handshake, _validate_mtu
from .endpoint import open_host_filtered_udp_socket

# A close_notify is a two-byte alert inside one record, so this only has to
# clear OpenSSL's queued shutdown rather than a whole flight.
_SHUTDOWN_READ_SIZE = 4096

# DTLS record content types (RFC 6347 §4.1)
_CT_CHANGE_CIPHER_SPEC = 20
_CT_ALERT = 21
Expand Down Expand Up @@ -710,6 +714,37 @@ def _accept_any_peer_chain(*_args):
return True


def _release_peer(connection, sock):
"""Send close_notify so the appliance frees the peer this run allocated.

The appliance firmware frees a peer entry when it receives close_notify,
and has no idle timeout that would reclaim one otherwise, so a diagnostic
that closed its socket without a shutdown left association state behind
for the life of the appliance. Measured on 2026-10-04: six completed
diagnostics inside six minutes took a healthy dryer's DTLS endpoint down
for about seven minutes, while its ICMP and plaintext CoAP kept answering
normally.

The connection is a memory BIO, so ``shutdown()`` only queues the alert;
it has to be read back out and sent here. Best effort by design -- the
caller already has its result, and a peer this fails to release is the
behaviour that shipped before, so nothing here may raise.
"""
try:
connection.shutdown()
except SSL.Error:
return
try:
pending = connection.bio_read(_SHUTDOWN_READ_SIZE)
except SSL.Error:
return
for record in split_dtls(pending):
try:
sock.send(record)
except OSError:
return


def _validate_diagnostic_auth(auth, cert_pem, key_pem, cert_path, key_path):
if auth is None:
return
Expand Down Expand Up @@ -881,6 +916,7 @@ def record_datagram(datagram):
result.outcome = COMPLETED
if result.rtt_s is None:
result.rtt_s = time.monotonic() - started
_release_peer(conn, sock)
except SSL.Error:
# A fatal Alert lands here; record_datagram() has already classified
# the alert record before it is fed back into OpenSSL.
Expand Down
47 changes: 47 additions & 0 deletions tests/test_dtls_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -977,3 +977,50 @@ def test_iter_handshake_fragments_falls_back_on_a_truncated_header():

assert [(name, seq, off) for name, seq, off, _ in got] == [
('HelloVerifyRequest', None, 0)]


def test_completed_diagnostic_releases_the_peer_with_close_notify(monkeypatch):
# The appliance firmware frees a peer entry when it receives close_notify
# and has no idle timeout that reclaims one, so a diagnostic that
# completed and then closed its socket left association state behind.
# Measured on 2026-10-04: six completed diagnostics inside six minutes
# took a healthy dryer's DTLS endpoint down for about seven minutes while
# its ICMP and plaintext CoAP kept answering normally.
cert_pem, key_pem = _synthetic_ec_server_credentials()
server_cert, server_key = _synthetic_ec_server_credentials()
fake = _FakeSock(
_LoopbackDtlsServer(
_server_context(
cipher=b'ECDHE-ECDSA-AES128-GCM-SHA256:@SECLEVEL=0',
cert_pem=server_cert,
key_pem=server_key,
)
)
)
_patch_sock(monkeypatch, fake)

result = p.diagnose_dtls_handshake(
'127.0.0.1', 5684, cert_pem=cert_pem, key_pem=key_pem, timeout=2.0)

assert result.outcome == p.COMPLETED
assert fake.sends, 'the diagnostic sent nothing'
last = fake.sends[-1]
assert last[0] == p._CT_ALERT
# Sent under the negotiated epoch, so the two alert bytes are ciphertext
# here; the record type and a non-zero epoch are what identify it.
assert last[3:5] == b'\x00\x01'


def test_diagnostic_that_never_completed_sends_no_alert(monkeypatch):
# Scope line for the release above. Before the handshake finishes there is
# no session for OpenSSL to shut down, and an appliance that answers with
# a fatal alert has already dropped the peer itself, so this path stays as
# it shipped.
fake = _FakeSock(lambda sock: None)
_patch_sock(monkeypatch, fake)

result = p.diagnose_dtls_handshake(
'127.0.0.1', 5684, timeout=0.3, retries=0)

assert result.outcome != p.COMPLETED
assert all(datagram[0] != p._CT_ALERT for datagram in fake.sends)
Loading