feat(protocol): add DTLS ClientHello liveness probe + wire it into the bridge

A stateless-by-default DTLS ClientHello probe that classifies a host:port
as DEAD/LIVE/COMPLETED/REJECTED in ~1 RTT off the server's first flight,
sitting in front of the full handshake.

Probe (smartthings_local/protocol/dtls_probe.py):
- Stateless liveness mode (default): stops at HelloVerifyRequest and never
  sends the cookie'd second ClientHello, so by RFC 6347 §4.2.1 it leaves
  no association on the device — safe to run before a real connect.
- Diagnostic mode (stateless=False): drives the handshake further to
  capture cipher/cert-chain/CertificateRequest or a fatal Alert, for
  OCF-PKI-wall characterization (#16). Kept out of hot reconnect paths.
- Retransmit + retries: services OpenSSL's DTLS retransmit timer so a
  single dropped ClientHello no longer reads as a false DEAD.

MQTT bridge (mqtt_demo):
- Stateless pre-flight gate in session_once() rejects a silent/rebooting
  device or wrong port in ~3s (retries=1) instead of eating the 12s
  HANDSHAKE_TIMEOUT_S per reconnect.
- OCF-band port autodiscovery when OCF_PORT is unset: races the band in
  parallel and returns on the first port to answer LIVE (~1 RTT, abandoning
  the dead-port probes), cached across reconnects; the stateless gate
  leaves no orphan, preserving the fixed-source-port §4.2.8 invariant.

Validated on real hardware (dryer 49155 / oven 49154): parallel discovery
resolves both ports in <1s, connect with no orphan cooldown, and a wrong
pinned port rejected in ~3s.

Tests: probe behaviour (retransmit recovery, stateless single-flight
guard, silent-port flight budget, diagnostic continuation) and bridge
port-resolution (pinned gate, parallel discovery early-exit, cache).
This commit is contained in:
Jack Nagy
2026-08-01 10:57:54 +01:00
parent 8c2108a510
commit 1a35cd59a1
7 changed files with 713 additions and 15 deletions
+106
View File
@@ -0,0 +1,106 @@
"""Port-resolution logic for the MQTT bridge: the stateless pre-flight
gate and OCF-band autodiscovery in PushBridge. The DTLS probe is faked so
these run without hardware — only the routing/gating/caching is exercised.
"""
import logging
import time
import types
import pytest
import mqtt_demo.bridge as bridge
def _mk_bridge(ocf_port, default=49155, discovered=None):
"""A PushBridge shell with only the attributes _resolve_port touches,
bypassing the heavyweight __init__ (MQTT client, cert paths, …)."""
b = bridge.PushBridge.__new__(bridge.PushBridge)
b.app = types.SimpleNamespace(ip='10.0.0.9', ocf_port=ocf_port, index=0)
b.descriptor = types.SimpleNamespace(default_observe_port=default)
b._discovered_port = discovered
b.log = logging.getLogger('test-bridge')
return b
def _fake_probe(live_ports):
"""Return a probe() stand-in reporting is_dtls_server for live_ports."""
def fake(ip, port, **kw):
alive = port in live_ports
return types.SimpleNamespace(
port=port, is_dtls_server=alive,
outcome='live' if alive else 'dead')
return fake
def test_pinned_live_port_is_gated_and_returned(monkeypatch):
monkeypatch.setattr(bridge, 'probe', _fake_probe({49155}))
b = _mk_bridge(ocf_port=49155)
assert b._resolve_port() == 49155
def test_pinned_dead_port_raises_for_backoff(monkeypatch):
monkeypatch.setattr(bridge, 'probe', _fake_probe(set()))
b = _mk_bridge(ocf_port=49155)
with pytest.raises(ConnectionError):
b._resolve_port()
def test_autodiscovery_finds_and_caches_live_port(monkeypatch):
# Only 49154 answers; it isn't the descriptor default, so discovery is
# what finds it — and it must be cached for the next reconnect.
monkeypatch.setattr(bridge, 'probe', _fake_probe({49154}))
b = _mk_bridge(ocf_port=None, default=49155)
assert b._resolve_port() == 49154
assert b._discovered_port == 49154
def test_autodiscovery_returns_a_live_port(monkeypatch):
# Early-exit: the first candidate to answer LIVE wins. Real devices
# expose exactly one DTLS port; if several answer, any live one is a
# correct result.
monkeypatch.setattr(bridge, 'probe', _fake_probe({49153, 49155}))
b = _mk_bridge(ocf_port=None, default=49155)
assert b._resolve_port() in {49153, 49155}
def test_autodiscovery_early_exits_before_dead_ports_finish(monkeypatch):
# The live port answers immediately; the dead ports "hang" on their
# retry budget. Discovery must return at the live port's speed, not
# block on the slow dead probes.
def slow_probe(ip, port, **kw):
if port == 49154:
return types.SimpleNamespace(
port=port, is_dtls_server=True, outcome='live')
time.sleep(0.5) # a dead port burning its retry budget
return types.SimpleNamespace(
port=port, is_dtls_server=False, outcome='dead')
monkeypatch.setattr(bridge, 'probe', slow_probe)
b = _mk_bridge(ocf_port=None, default=49155)
t0 = time.time()
assert b._resolve_port() == 49154
assert time.time() - t0 < 0.25 # did not wait out the 0.5s dead probes
def test_cached_live_port_is_reused_without_rediscovery(monkeypatch):
# Cached 49156 and the default 49155 are both live; the cache-first
# path must return the cached port, not re-race the band (which would
# tie-break to the default).
monkeypatch.setattr(bridge, 'probe', _fake_probe({49155, 49156}))
b = _mk_bridge(ocf_port=None, default=49155, discovered=49156)
assert b._resolve_port() == 49156
def test_autodiscovery_all_dead_raises_and_clears_cache(monkeypatch):
monkeypatch.setattr(bridge, 'probe', _fake_probe(set()))
b = _mk_bridge(ocf_port=None, discovered=49154)
with pytest.raises(ConnectionError):
b._resolve_port()
assert b._discovered_port is None
def test_candidate_ports_cover_band_plus_default(monkeypatch):
b = _mk_bridge(ocf_port=None, default=49200)
cands = b._candidate_ports()
assert set(bridge.OCF_PORT_BAND) <= set(cands)
assert 49200 in cands
assert cands == sorted(cands)
+159
View File
@@ -0,0 +1,159 @@
import socket
import time
from smartthings_local.protocol import dtls_probe as p
def _rec(content_type, frag):
"""Build one DTLS record: 13-byte header + fragment."""
return (bytes([content_type])
+ b'\xfe\xfd' # DTLS 1.2
+ b'\x00\x00' # epoch
+ b'\x00\x00\x00\x00\x00\x00' # sequence number
+ len(frag).to_bytes(2, 'big')
+ frag)
def _hs(msg_type, body=b''):
return _rec(p._CT_HANDSHAKE, bytes([msg_type]) + body)
def _alert(level, desc):
return _rec(p._CT_ALERT, bytes([level, desc]))
def test_classify_hello_verify_request():
assert p.classify_datagram(_hs(3, b'\x00' * 20)) == [
(p._CT_HANDSHAKE, 'HelloVerifyRequest')]
def test_classify_coalesced_server_flight():
# OpenSSL commonly hands back ServerHello+Certificate back-to-back.
dgram = _hs(2, b'\x00' * 30) + _hs(11, b'\x00' * 40)
assert p.classify_datagram(dgram) == [
(p._CT_HANDSHAKE, 'ServerHello'),
(p._CT_HANDSHAKE, 'Certificate')]
def test_classify_fatal_alert_names_description():
# The OCF-PKI-wall signature: fatal unsupported_certificate (43).
assert p.classify_datagram(_alert(2, 43)) == [
(p._CT_ALERT, (2, 'unsupported_certificate'))]
def test_classify_unknown_handshake_type_is_not_lost():
assert p.classify_datagram(_hs(99)) == [(p._CT_HANDSHAKE, 'hs99')]
def test_dead_port_probe_is_dead_and_never_raises():
# Nothing listens here; the probe must fold the silence into a DEAD
# result within the timeout rather than raise.
r = p.probe('127.0.0.1', 5684, timeout=1.0)
assert r.outcome == p.DEAD
assert not r.is_dtls_server
assert r.datagrams == []
def test_is_dtls_server_reflects_outcome():
r = p.ProbeResult('h', 1)
r.outcome = p.LIVE
assert r.is_dtls_server
r.outcome = p.REJECTED
assert r.is_dtls_server
r.outcome = p.DEAD
assert not r.is_dtls_server
# --- probe() behavioural tests over a scripted fake UDP socket ----------
#
# OpenSSL runs for real against a memory BIO, so the ClientHello on the
# wire is genuine; only the datagram transport is faked. `responder(fake)`
# is called on every recvfrom and returns the bytes to deliver, or None to
# simulate a lost/silent flight (which sleeps the socket timeout so
# OpenSSL's DTLS retransmit clock advances in real time).
class _FakeSock:
def __init__(self, responder):
self._responder = responder
self._timeout = 0.5
self.sends = []
self.recv_calls = 0
self.closed = False
def settimeout(self, t):
self._timeout = t
def setsockopt(self, *a):
pass
def bind(self, *a):
pass
def sendto(self, data, dest):
self.sends.append(data)
return len(data)
def recvfrom(self, n):
self.recv_calls += 1
resp = self._responder(self)
if resp is None:
time.sleep(self._timeout)
raise socket.timeout()
return resp, ('127.0.0.1', 5684)
def close(self):
self.closed = True
def _patch_sock(monkeypatch, fake):
monkeypatch.setattr(p.socket, 'socket', lambda *a, **k: fake)
def test_stateless_probe_sends_exactly_one_clienthello(monkeypatch):
# The §4.2.8 regression guard: a HelloVerifyRequest proves liveness,
# and the stateless gate must stop there — never emitting the cookie'd
# second ClientHello that would commit association state on the device.
fake = _FakeSock(lambda f: _hs(3, b'\x00' * 20))
_patch_sock(monkeypatch, fake)
r = p.probe('127.0.0.1', 5684, stateless=True, timeout=2.0)
assert r.outcome == p.LIVE
assert len(fake.sends) == 1 # only the initial ClientHello
assert fake.recv_calls == 1 # stopped on the first flight
assert fake.closed
def test_retransmit_recovers_from_dropped_first_flight(monkeypatch):
# The first ClientHello is "lost" (recvfrom times out) until OpenSSL's
# retransmit timer fires a second flight; only then does the server
# answer. A single dropped datagram must NOT read as DEAD.
fake = _FakeSock(lambda f: _hs(3, b'\x00' * 20) if len(f.sends) >= 2
else None)
_patch_sock(monkeypatch, fake)
r = p.probe('127.0.0.1', 5684, stateless=True, retries=2, timeout=5.0)
assert r.outcome == p.LIVE
assert len(fake.sends) == 2 # initial + one retransmit
def test_silent_port_is_dead_only_after_flight_budget(monkeypatch):
# A truly silent port: DEAD, but only after the initial flight plus
# `retries` retransmits — not on the first unanswered datagram.
fake = _FakeSock(lambda f: None)
_patch_sock(monkeypatch, fake)
r = p.probe('127.0.0.1', 5684, stateless=True, retries=1, timeout=6.0)
assert r.outcome == p.DEAD
assert not r.is_dtls_server
assert len(fake.sends) == 2 # initial + retries(1) retransmit
def test_diagnostic_mode_feeds_server_flight_back(monkeypatch):
# The inverse of the stateless guard: stateless=False must NOT stop at
# the HelloVerifyRequest — it feeds the flight back into OpenSSL to
# drive the handshake onward (the #16 characterization path). The
# fed-back record here is a stub, so OpenSSL surfaces an error the
# moment it processes it, which is precisely what proves the probe did
# not short-circuit before the write.
fake = _FakeSock(lambda f: _hs(3, b'\x00' * 20))
_patch_sock(monkeypatch, fake)
r = p.probe('127.0.0.1', 5684, stateless=False, timeout=3.0)
assert r.outcome == p.LIVE # HVR still proved liveness
assert r.error is not None # OpenSSL processed the fed-back flight