feat(protocol): bound DTLS endpoint probing

This commit is contained in:
Jason Morcos
2026-08-03 12:45:14 -07:00
parent d677c72f89
commit dd453ebdfb
7 changed files with 1166 additions and 201 deletions
+49 -39
View File
@@ -1,9 +1,8 @@
"""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.
gate and standard/dynamic OCF port discovery in PushBridge. The DTLS probe is
faked so these run without hardware; only routing and selection are exercised.
"""
import logging
import time
import types
import pytest
@@ -15,31 +14,52 @@ 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.app = types.SimpleNamespace(ip='192.0.2.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_port_probe(live_ports):
"""Return a one-port probe stand-in for the selected 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')
port=port,
is_dtls_server=alive,
)
return fake
def _fake_port_set(live_ports):
"""Return an aggregate probe stand-in with explicit ambiguity."""
def fake(ip, ports, **kw):
live = tuple(port for port in ports if port in live_ports)
if len(live) == 1:
outcome = 'selected'
selected_port = live[0]
elif live:
outcome = 'ambiguous'
selected_port = None
else:
outcome = 'unreachable'
selected_port = None
return types.SimpleNamespace(
outcome=outcome,
selected_port=selected_port,
)
return fake
def test_pinned_live_port_is_gated_and_returned(monkeypatch):
monkeypatch.setattr(bridge, 'probe', _fake_probe({49155}))
monkeypatch.setattr(bridge, 'probe_dtls_port', _fake_port_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()))
monkeypatch.setattr(bridge, 'probe_dtls_port', _fake_port_probe(set()))
b = _mk_bridge(ocf_port=49155)
with pytest.raises(ConnectionError):
b._resolve_port()
@@ -48,50 +68,39 @@ def test_pinned_dead_port_raises_for_backoff(monkeypatch):
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}))
monkeypatch.setattr(bridge, 'probe_dtls_ports', _fake_port_set({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}))
def test_autodiscovery_refuses_ambiguous_live_ports(monkeypatch):
monkeypatch.setattr(
bridge,
'probe_dtls_ports',
_fake_port_set({5684, 49154}),
)
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
with pytest.raises(ConnectionError, match='multiple DTLS listeners'):
b._resolve_port()
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}))
# path must return the previously proven port without an ambiguous
# full-set probe.
monkeypatch.setattr(
bridge,
'probe_dtls_port',
_fake_port_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()))
monkeypatch.setattr(bridge, 'probe_dtls_port', _fake_port_probe(set()))
monkeypatch.setattr(bridge, 'probe_dtls_ports', _fake_port_set(set()))
b = _mk_bridge(ocf_port=None, discovered=49154)
with pytest.raises(ConnectionError):
b._resolve_port()
@@ -102,5 +111,6 @@ 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 bridge.OCF_STANDARD_SECURE_PORT in cands
assert 49200 in cands
assert cands == sorted(cands)
+317 -25
View File
@@ -1,36 +1,60 @@
import socket
import threading
import time
from smartthings_local.errors import ProbeError
import pytest
from smartthings_local.protocol import dtls_probe as p
def _rec(content_type, frag):
def _rec(content_type, frag, *, epoch=0):
"""Build one DTLS record: 13-byte header + fragment."""
return (bytes([content_type])
+ b'\xfe\xfd' # DTLS 1.2
+ b'\x00\x00' # epoch
+ epoch.to_bytes(2, 'big') # 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)
header = (
bytes([msg_type])
+ len(body).to_bytes(3, 'big')
+ b'\x00\x00' # message sequence
+ b'\x00\x00\x00' # fragment offset
+ len(body).to_bytes(3, 'big')
)
return _rec(p._CT_HANDSHAKE, header + body)
def _alert(level, desc):
return _rec(p._CT_ALERT, bytes([level, desc]))
def _hvr(cookie=b'cookie'):
return _hs(3, b'\xfe\xfd' + bytes([len(cookie)]) + cookie)
def _server_hello():
body = (
b'\xfe\xfd'
+ b'\x00' * 32
+ b'\x00' # session ID length
+ b'\xc0\x2b' # ECDHE-ECDSA-AES128-GCM-SHA256
+ b'\x00' # null compression
)
return _hs(2, body)
def _alert(level, desc, *, epoch=0):
return _rec(p._CT_ALERT, bytes([level, desc]), epoch=epoch)
def test_classify_hello_verify_request():
assert p.classify_datagram(_hs(3, b'\x00' * 20)) == [
assert p.classify_datagram(_hvr()) == [
(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)
dgram = _server_hello() + _hs(11, b'\x00' * 40)
assert p.classify_datagram(dgram) == [
(p._CT_HANDSHAKE, 'ServerHello'),
(p._CT_HANDSHAKE, 'Certificate')]
@@ -49,7 +73,7 @@ def test_classify_unknown_handshake_type_is_not_lost():
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)
r = p.probe('127.0.0.1', 5684, timeout=0.1)
assert r.outcome == p.DEAD
assert not r.is_dtls_server
assert r.datagrams == []
@@ -80,6 +104,7 @@ class _FakeSock:
self.sends = []
self.recv_calls = 0
self.closed = False
self.destination = None
def settimeout(self, t):
self._timeout = t
@@ -90,16 +115,31 @@ class _FakeSock:
def bind(self, *a):
pass
def connect(self, destination):
self.destination = destination
def send(self, data):
self.sends.append(data)
return len(data)
def sendto(self, data, dest):
self.sends.append(data)
return len(data)
def recv(self, n):
self.recv_calls += 1
resp = self._responder(self)
if resp is None:
time.sleep(self._timeout)
raise TimeoutError()
return resp
def recvfrom(self, n):
self.recv_calls += 1
resp = self._responder(self)
if resp is None:
time.sleep(self._timeout)
raise socket.timeout()
raise TimeoutError()
return resp, ('127.0.0.1', 5684)
def close(self):
@@ -114,25 +154,49 @@ 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))
fake = _FakeSock(lambda _fake: _hvr())
_patch_sock(monkeypatch, fake)
r = p.probe('127.0.0.1', 5684, stateless=True, timeout=2.0)
r = p.probe('127.0.0.1', 5684, stateless=True, timeout=0.2)
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_stateless_probe_preserves_first_flight_alert(monkeypatch):
fake = _FakeSock(lambda _fake: _alert(2, 48))
_patch_sock(monkeypatch, fake)
result = p.probe('127.0.0.1', 5684, stateless=True, timeout=0.2)
assert result.outcome == p.REJECTED
assert result.alert == (2, 'unknown_ca')
assert len(fake.sends) == 1
def test_stateless_warning_alert_proves_liveness_without_fatal_rejection(
monkeypatch):
fake = _FakeSock(lambda _fake: _alert(1, 90))
_patch_sock(monkeypatch, fake)
result = p.probe('127.0.0.1', 5684, stateless=True, timeout=0.2)
assert result.outcome == p.LIVE
assert result.is_dtls_server
assert result.alert == (1, 'user_canceled')
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
fake = _FakeSock(lambda f: _hvr() 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)
r = p.probe('127.0.0.1', 5684, stateless=True, retries=2, timeout=0.3)
assert r.outcome == p.LIVE
assert len(fake.sends) == 2 # initial + one retransmit
assert fake.sends[0] == fake.sends[1]
def test_silent_port_is_dead_only_after_flight_budget(monkeypatch):
@@ -140,21 +204,249 @@ def test_silent_port_is_dead_only_after_flight_budget(monkeypatch):
# `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)
r = p.probe('127.0.0.1', 5684, stateless=True, retries=1, timeout=0.2)
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))
def test_explicit_diagnostic_feeds_server_flight_back(monkeypatch):
# The explicitly named diagnostic 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 makes OpenSSL emit a cookie-bearing second ClientHello,
# which is precisely what proves the diagnostic did not short-circuit.
fake = _FakeSock(
lambda f: _hvr() if f.recv_calls == 1 else None)
_patch_sock(monkeypatch, fake)
r = p.probe('127.0.0.1', 5684, stateless=False, timeout=3.0)
r = p.diagnose_dtls_handshake('127.0.0.1', 5684, timeout=0.3)
assert r.outcome == p.LIVE # HVR still proved liveness
assert isinstance(r.error, ProbeError) # OpenSSL processed the flight
assert len(fake.sends) >= 2 # OpenSSL processed the flight
def test_stateless_probe_ignores_unrelated_datagram_without_retransmit(
monkeypatch):
responses = iter((
_rec(p._CT_APP_DATA, b'unrelated'),
_hvr(),
))
fake = _FakeSock(lambda _fake: next(responses))
_patch_sock(monkeypatch, fake)
result = p.probe_dtls_port(
'127.0.0.1', 5684, retries=1, timeout=0.2)
assert result.response_kind == p.HELLO_VERIFY_REQUEST
assert result.attempts == 1
assert len(fake.sends) == 1
assert fake.recv_calls == 2
def test_stateless_probe_forwards_explicit_address_family(monkeypatch):
fake = _FakeSock(lambda _fake: _hvr())
calls = []
def open_socket(host, port, *, family, timeout):
calls.append((host, port, family, timeout))
fake.settimeout(timeout)
return fake, object()
monkeypatch.setattr(p, 'open_connected_udp_socket', open_socket)
result = p.probe_dtls_port(
'appliance.invalid', 5684, family=socket.AF_INET6, timeout=0.2)
assert result.is_dtls_server
assert calls == [('appliance.invalid', 5684, socket.AF_INET6, 0.2 / 3)]
def test_client_hello_flight_is_complete_epoch_zero_dtls():
flight = p._client_hello_flight(mtu=1200)
assert flight
assert all(len(record) <= 1200 for record in flight)
assert all(record[1:3] in p._DTLS_VERSIONS for record in flight)
assert all(record[3:5] == b'\x00\x00' for record in flight)
assert any(
record[0] == p._CT_HANDSHAKE and record[13] == 1
for record in flight
)
def test_liveness_classifier_accepts_first_flight_response_classes():
assert p._classify_liveness_response(_hvr()) == \
p.HELLO_VERIFY_REQUEST
assert p._classify_liveness_response(_server_hello()) == \
p.SERVER_HELLO
assert p._classify_liveness_response(_alert(2, 48)) == p.ALERT
def test_liveness_classifier_rejects_truncated_or_nonzero_epoch():
assert p._classify_liveness_response(_hvr()[:-1]) is None
assert p._classify_liveness_response(_hs(3)) is None
assert p._classify_liveness_response(_hs(2, b'\x00' * 20)) is None
nonzero_epoch = bytearray(_hvr())
nonzero_epoch[4] = 1
assert p._classify_liveness_response(bytes(nonzero_epoch)) is None
def test_liveness_alert_detail_comes_from_valid_epoch_zero_record(monkeypatch):
datagram = _alert(2, 40, epoch=1) + _alert(2, 48)
fake = _FakeSock(lambda _fake: datagram)
_patch_sock(monkeypatch, fake)
result = p.probe_dtls_port('127.0.0.1', 5684, timeout=0.2)
assert result.response_kind == p.ALERT
assert result.alert == (2, 'unknown_ca')
def _liveness(port, *, live=True, error_code=None):
return p.DtlsLivenessResult(
port=port,
response_kind=p.HELLO_VERIFY_REQUEST if live else None,
attempts=1,
error_code=error_code,
)
def test_multi_port_probe_runs_concurrently_and_preserves_order(monkeypatch):
ports = (5684, 49154, 49155)
barrier = threading.Barrier(len(ports))
def fake_probe(_host, port, **_kwargs):
barrier.wait(timeout=2.0)
return _liveness(port, live=port == 5684)
monkeypatch.setattr(p, '_client_hello_flight', lambda **_kwargs: (b'hello',))
monkeypatch.setattr(p, '_probe_dtls_port_with_flight', fake_probe)
result = p.probe_dtls_ports('appliance.invalid', ports)
assert result.outcome == p.SELECTED
assert result.selected_port == 5684
assert tuple(item.port for item in result.results) == ports
assert not any(
thread.name.startswith('smartthings-dtls-probe')
for thread in threading.enumerate()
)
def test_multi_port_probe_reports_ambiguity_without_guessing(monkeypatch):
monkeypatch.setattr(p, '_client_hello_flight', lambda **_kwargs: (b'hello',))
monkeypatch.setattr(
p,
'_probe_dtls_port_with_flight',
lambda _host, port, **_kwargs: _liveness(port),
)
result = p.probe_dtls_ports('appliance.invalid', (5684, 49154))
assert result.outcome == p.AMBIGUOUS
assert result.selected_port is None
assert result.live_ports == (5684, 49154)
def test_multi_port_probe_prefers_previously_proven_listener(monkeypatch):
monkeypatch.setattr(p, '_client_hello_flight', lambda **_kwargs: (b'hello',))
monkeypatch.setattr(
p,
'_probe_dtls_port_with_flight',
lambda _host, port, **_kwargs: _liveness(port),
)
result = p.probe_dtls_ports(
'appliance.invalid',
(5684, 49154),
preferred_port=49154,
)
assert result.outcome == p.SELECTED
assert result.selected_port == 49154
def test_multi_port_probe_folds_worker_failure_into_redacted_result(monkeypatch):
monkeypatch.setattr(p, '_client_hello_flight', lambda **_kwargs: (b'hello',))
monkeypatch.setattr(
p,
'_probe_dtls_port_with_flight',
lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError('private')),
)
result = p.probe_dtls_ports('private-host.invalid', (5684,))
assert result.outcome == p.UNREACHABLE
assert result.results[0].error_code == 'probe_worker_failed'
assert 'private-host' not in repr(result)
assert 'private' not in repr(result)
def test_multi_port_probe_bounds_candidate_count():
with pytest.raises(ValueError, match='at most 32'):
p.probe_dtls_ports('appliance.invalid', tuple(range(1, 34)))
def test_multi_port_probe_rejects_invalid_family_before_starting_workers():
with pytest.raises(ValueError, match='family'):
p.probe_dtls_ports(
'appliance.invalid',
(5684, 49154),
family=9999,
)
def test_diagnostic_honors_timeout_below_half_second(monkeypatch):
now = [10.0]
class BudgetSocket:
def __init__(self):
self.timeout = None
self.timeouts = []
def settimeout(self, timeout):
self.timeout = timeout
self.timeouts.append(timeout)
def send(self, data):
return len(data)
def recv(self, _size):
now[0] += self.timeout
raise TimeoutError()
def close(self):
pass
sock = BudgetSocket()
open_timeouts = []
def open_socket(_host, _port, *, family, timeout):
assert family == socket.AF_UNSPEC
open_timeouts.append(timeout)
sock.settimeout(timeout)
return sock, object()
monkeypatch.setattr(p, 'open_connected_udp_socket', open_socket)
monkeypatch.setattr(p.time, 'monotonic', lambda: now[0])
result = p.diagnose_dtls_handshake(
'appliance.invalid',
5684,
timeout=0.1,
retries=0,
)
assert result.outcome == p.DEAD
assert open_timeouts == [0.1]
assert sock.timeouts and max(sock.timeouts) <= 0.1
assert now[0] <= 10.1
def test_cli_bounds_port_fanout(capsys):
result = p._main([
'appliance.invalid',
*(str(port) for port in range(1, 34)),
])
assert result == 2
assert 'at most 32 PORT values' in capsys.readouterr().out