diff --git a/smartthings_local/protocol/dtls_probe.py b/smartthings_local/protocol/dtls_probe.py index bc0216a..b950383 100644 --- a/smartthings_local/protocol/dtls_probe.py +++ b/smartthings_local/protocol/dtls_probe.py @@ -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 @@ -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 @@ -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. diff --git a/tests/test_dtls_probe.py b/tests/test_dtls_probe.py index 927d378..9028a5c 100644 --- a/tests/test_dtls_probe.py +++ b/tests/test_dtls_probe.py @@ -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)