diff --git a/README.md b/README.md index 3542b10..d535bcf 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ For a full worked integration, the higher-level `smartthings_local.ocf` layer ### Under the hood -Each appliance runs an independent bridge built around three coordinated pieces over one persistent DTLS session: a `StateCache` (single source of truth for all reps), a `PollScheduler` (tiered adaptive polling — hot/warm/cold + a periodic `/device/0` sweep), and a `KeepaliveTask` (CoAP empty-CON ping for DTLS-layer liveness, with consecutive-failure detection for MQTT availability). Tier cadences are descriptor-declared and were calibrated against the empirically-measured per-firmware ceilings: dryer ~14 req/s, oven ~8 req/s. OBSERVE registrations (RFC 7641) are kept as an opportunistic freshness accelerator — when the appliance has internet and emits notifications, the cache absorbs them and the next-poll timer is reset for that resource; when it's air-gapped, polling alone carries the UX with no other code change. Token-stable Block2 (RFC 7959) handles multi-block reads. Writes are optimistically merged into the cache the moment the device 2.04-confirms, with the scheduler deferring that resource's next poll past the fetchback-revert window. Reconnect with exponential backoff on session errors. +Each appliance runs an independent bridge built around three coordinated pieces over one persistent DTLS session: a `StateCache` (single source of truth for all reps), a `PollScheduler` (tiered adaptive polling — hot/warm/cold + a periodic `/device/0` sweep), and a `KeepaliveTask` (CoAP empty-CON ping for DTLS-layer liveness, with consecutive-failure detection for MQTT availability). Tier cadences are descriptor-declared and were calibrated against the empirically-measured per-firmware ceilings: dryer ~14 req/s, oven ~8 req/s. OBSERVE registrations (RFC 7641) are kept as an opportunistic freshness accelerator — when the appliance has internet and emits notifications, the cache absorbs them and the next-poll timer is reset for that resource; when it's air-gapped, polling alone carries the UX with no other code change. Token-stable Block2 (RFC 7959) handles multi-block reads. Writes are optimistically merged into the cache the moment the device 2.04-confirms, with the scheduler deferring that resource's next poll past the fetchback-revert window. Reconnect with exponential backoff on session errors, gated by a stateless DTLS ClientHello pre-flight (`smartthings_local/protocol/dtls_probe.py`) so a silent/rebooting device or wrong port drops into backoff in ~1 RTT instead of eating the full handshake timeout; when `OCF_PORT` is unset the same probe auto-discovers the live port across the OCF band. Authentication uses a client cert keyed to the UUID published in Samsung's own wildcard cloud TLS cert. Every Samsung Tizen/RT-OCF appliance's factory ACL grants that UUID `perm=31` (full CRUDN) on `href=*`, so a single cert chain works across the whole fleet. Setup is one Python script. @@ -84,12 +84,21 @@ Read the result: - **`49154/udp` (or similar 4915x) open|filtered with a DTLS handshake responding** → newer firmware (Tizen RT 3.x with DAWIT 3.0). This is what the bridge talks to. - **Only `8888/tcp` open (token-based HTTPS)** → older firmware (~2018–2022). **Not supported here.** +nmap's `open|filtered` can't tell a real DTLS server from a silent UDP port. Confirm which of the candidate ports actually speaks DTLS with the ClientHello probe — it sends one ClientHello and reports back per port: + +```sh +# Stateless liveness check — one ClientHello round trip; leaves no state on the device +.venv/bin/python -m smartthings_local.protocol.dtls_probe "$APPLIANCE_IP" 49153 49154 49155 49156 --stateless +``` + +`live` means a DTLS server answered its `HelloVerifyRequest` (that's your control port); `dead` means silent / not DTLS. Once you have the client cert (Part 2), drop `--stateless` to run the default *diagnostic* drive, which reports `completed` (cert accepted) or `rejected` with the server's fatal alert — an `unsupported_certificate` / `unknown_ca` alert is the signature of a newer OCF-PKI device that won't accept the AC14K_M cert. The same probe gates the bridge's own reconnect loop and auto-discovers the port when `OCF_PORT` is unset. + ### Tested combinations | Appliance class | Model family | Confirmed | |---|---|---| -| Washer | WW11DG (`DA_WM_TP2_20_COMMON`) | All entities. Contributed by [@indykoning](https://github.com/indykoning) (PR #13); tested via [`mbillow/localthings`](https://github.com/mbillow/localthings) | -| Dryer | DV5000T (`DA_WM_TP2_20_COMMON`, `mnid=0AJT`); DV90T reported same family | All entities, ≤1s hot-tier poll (OBSERVE accelerates when online) | +| Washer | WW11DG (`DA_WM_TP2_20_COMMON`, `mnid=0AJT`) | All entities. Contributed by [@indykoning](https://github.com/indykoning) (PR #13); tested via [`mbillow/localthings`](https://github.com/mbillow/localthings) | +| Dryer | DV5000T (`DA_WM_TP2_20_COMMON`, `mnid=0AJT`); DV90T (same `mnid=0AJT`) | All entities, ≤1s hot-tier poll (OBSERVE accelerates when online) | | Oven | NV7000BS-class (`TP1X_DA-KS-OVEN-0107X`, `mnid=0AJT`) | All entities; hot-tier poll covers door + operational state regardless of cloud reachability | | Fridge | ARTIK051_REF_17K (`DA-REF-ART-COMMON-1_20201124`) | Contributed by [@aminorjourney](https://github.com/aminorjourney) (PR #1). Older firmware family; port 49155, minimal `/oic/res` with full tree under `/device/0` | @@ -101,7 +110,7 @@ Descriptors are firmware-family-specific. Each descriptor hardcodes the resource **What this means in practice:** if you set `APPLIANCE__CLASS=fridge` on a fridge that speaks a different firmware family than the one this descriptor was built for, the bridge will start and connect fine, but many sensors will publish as unknown and some controls won't work. Nothing catastrophic — you just get a half-broken HA device card. -If your appliance model doesn't match a row in the tested table above, it may still work if it's on the same firmware family; otherwise you'd write a new descriptor (see "Adding a new appliance class" below). The ARTIK051 fridge and the newer RF9000B-class fridge, for example, expose genuinely different resource models (collection-resource vs per-instance-resource) and can't share a descriptor even though they're both "fridges". +If your appliance model doesn't match a row in the tested table above, it may still work if it's on the same firmware family; otherwise you'd write a new descriptor (see "Adding a new appliance class" below). The ARTIK051 fridge and the newer RF9000B-class fridge, for example, expose different resource models (collection-resource vs per-instance-resource) and can't share a descriptor even though they're both "fridges". --- @@ -189,14 +198,14 @@ APPLIANCE_COUNT=2 # Appliance 1 — dryer APPLIANCE_1_CLASS=dryer APPLIANCE_1_IP=192.168.1.100 -APPLIANCE_1_OCF_PORT= # blank → descriptor default (49155 for dryer) +APPLIANCE_1_OCF_PORT= # blank → auto-discover across the OCF band (dryer=49155) APPLIANCE_1_TOPIC=samsung_dryer APPLIANCE_1_NAME=Samsung Dryer # Appliance 2 — oven APPLIANCE_2_CLASS=oven APPLIANCE_2_IP=192.168.1.101 -APPLIANCE_2_OCF_PORT= # blank → descriptor default (49154 for oven) +APPLIANCE_2_OCF_PORT= # blank → auto-discover across the OCF band (oven=49154) APPLIANCE_2_TOPIC=samsung_oven APPLIANCE_2_NAME=Samsung Oven ``` @@ -242,9 +251,11 @@ python3 -m venv .venv ``` 14:08:42 INFO mqtt_demo SmartThings-Local Bridge starting (2 appliances) 14:08:42 INFO mqtt_demo broker = :1883 (user=) -14:08:42 INFO mqtt_demo [1] dryer @ :49155 (DTLS) → topic samsung_dryer/* -14:08:42 INFO mqtt_demo [2] oven @ :49154 (DTLS) → topic samsung_oven/* +14:08:42 INFO mqtt_demo [1] dryer @ :49155? (DTLS, auto-discover) → topic samsung_dryer/* +14:08:42 INFO mqtt_demo [2] oven @ :49154? (DTLS, auto-discover) → topic samsung_oven/* 14:08:42 INFO mqtt_demo MQTT connected → :1883 +14:08:43 INFO dryer discovered DTLS port 49155 +14:08:43 INFO oven discovered DTLS port 49154 14:08:43 INFO dryer DTLS connected — subscribing 11 paths 14:08:44 INFO dryer. identified — serial=… 14:08:44 INFO dryer. seeded → 25 links; sensors live @@ -321,7 +332,7 @@ Notes specific to this firmware family: | `APPLIANCE_COUNT` | Number of `APPLIANCE__*` blocks to read (1-indexed) | | `APPLIANCE__CLASS` | Descriptor name: `dryer`, `oven`, `fridge` | | `APPLIANCE__IP` | LAN IP of the appliance | -| `APPLIANCE__OCF_PORT` | Optional override (blank → descriptor default: dryer=49155, oven=49154, fridge=49155) | +| `APPLIANCE__OCF_PORT` | Optional. Blank → auto-discover the DTLS port across the OCF band 49153–49156 (via a stateless ClientHello probe); set it to pin a specific port and skip discovery (dryer=49155, oven=49154, fridge=49155) | | `APPLIANCE__TOPIC` | MQTT topic prefix (also the HA device identifier — changing it re-keys the device) | | `APPLIANCE__NAME` | Friendly name on the HA device card | | `MQTT_BROKER` / `MQTT_PORT` / `MQTT_USER` / `MQTT_PASS` | Broker config | diff --git a/mqtt_demo/.env.example b/mqtt_demo/.env.example index de774f6..9895d1b 100644 --- a/mqtt_demo/.env.example +++ b/mqtt_demo/.env.example @@ -11,8 +11,9 @@ APPLIANCE_COUNT=1 # Appliance 1 — Samsung dryer APPLIANCE_1_CLASS=dryer APPLIANCE_1_IP=192.168.1.100 -# Leave OCF_PORT blank to inherit the descriptor's default -# (dryer=49155, oven=49154). +# OCF_PORT is optional. Leave it blank to auto-discover the live DTLS +# port each connect (a stateless ClientHello races the OCF band +# 49153-49156); set it to pin a specific port and skip discovery. APPLIANCE_1_OCF_PORT= APPLIANCE_1_TOPIC=samsung_dryer APPLIANCE_1_NAME=Samsung Dryer diff --git a/mqtt_demo/__main__.py b/mqtt_demo/__main__.py index 570a10f..b1a0ee5 100644 --- a/mqtt_demo/__main__.py +++ b/mqtt_demo/__main__.py @@ -63,9 +63,12 @@ def main(): shared.MQTT_BROKER, shared.MQTT_PORT, shared.MQTT_USER or '') for app, desc in pairs: - port = app.ocf_port or desc.default_observe_port - logger.info(" [%d] %s @ %s:%d (DTLS) → topic %s/*", - app.index, app.klass, app.ip, port, app.topic_prefix) + if app.ocf_port is not None: + port_note = f"{app.ocf_port} (DTLS)" + else: + port_note = f"{desc.default_observe_port}? (DTLS, auto-discover)" + logger.info(" [%d] %s @ %s:%s → topic %s/*", + app.index, app.klass, app.ip, port_note, app.topic_prefix) # --- MQTT client (shared) --- cli = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, diff --git a/mqtt_demo/bridge.py b/mqtt_demo/bridge.py index 28f77e4..cb67db0 100644 --- a/mqtt_demo/bridge.py +++ b/mqtt_demo/bridge.py @@ -24,6 +24,7 @@ import time import cbor2 from smartthings_local.protocol.dtls_session import DtlsCoapSession, fmt_code +from smartthings_local.protocol.dtls_probe import probe from smartthings_local.ocf.keepalive import KeepaliveTask from smartthings_local.ocf.observe_refresh import ObserveRefreshTask @@ -69,6 +70,21 @@ OBSERVE_REFRESH_INTERVAL_S = 6 * 3600.0 # orphan otherwise lingers 5-15 min. DTLS_LOCAL_PORT_BASE = 49700 +# SmartThings appliances bind their OCF CoAP-DTLS control port in this +# dynamic band (dryer/fridge 49155, oven 49154). When OCF_PORT is unset we +# race a stateless ClientHello across the band to find the live one instead +# of trusting a single hardcoded default. +OCF_PORT_BAND = range(49153, 49157) + +# The pre-flight liveness gate tolerates one dropped ClientHello (retries=1 +# → ~1 RTT when the device answers, ~2.6 s to call a silent port DEAD), +# which is far cheaper than eating the 12 s HANDSHAKE_TIMEOUT_S on a +# rebooting device or a wrong port. It is stateless (stops at +# HelloVerifyRequest), so it leaves no association on the device and the +# fixed-source-port reconnect invariant is untouched (see session_once). +_GATE_RETRIES = 1 +_GATE_TIMEOUT_S = 4.0 + class PushBridge: @@ -85,7 +101,11 @@ class PushBridge: self.log = bridge_logger(app.klass) self._serial: str | None = None + # Best-effort port for the startup log; the real port is resolved + # per-connect by _resolve_port (a pinned OCF_PORT is used as-is, an + # unset one is auto-discovered and cached in _discovered_port). self.port = app.ocf_port or descriptor.default_observe_port + self._discovered_port: int | None = None self.session: DtlsCoapSession | None = None self.scheduler: PollScheduler | None = None @@ -242,15 +262,87 @@ class PushBridge: # ---- session lifecycle ------------------------------------------ + def _candidate_ports(self) -> list[int]: + """The OCF band plus the descriptor's documented default, deduped + and ordered — the search space when OCF_PORT is unset.""" + return sorted(set(OCF_PORT_BAND) | {self.descriptor.default_observe_port}) + + def _race_probe(self, candidates: list[int]) -> int | None: + """Race a stateless ClientHello across all candidates in parallel + and return the first port that answers LIVE — without waiting for + the dead ones to burn their full retry budget. Returns None if none + answer. + + The winner comes back in ~1 RTT; the losing probes are abandoned + (shutdown(wait=False)) and each just runs out its own ~timeout loop + and closes its own socket in finally. This is a latency win, not a + correctness need — unlike #212's full-handshake race the losers are + bounded at a few seconds, not 12 s. Real appliances expose exactly + one DTLS port, so first-to-answer is unambiguous.""" + import concurrent.futures as cf + ex = cf.ThreadPoolExecutor(max_workers=len(candidates)) + try: + futs = [ex.submit(probe, self.app.ip, p, + retries=_GATE_RETRIES, timeout=_GATE_TIMEOUT_S) + for p in candidates] + for fut in cf.as_completed(futs): + r = fut.result() + if r.is_dtls_server: + return r.port + return None + finally: + ex.shutdown(wait=False) + + def _resolve_port(self) -> int: + """Return a port that just answered a stateless DTLS ClientHello, + or raise ConnectionError so run_forever backs off — instead of + committing a 12 s handshake against a silent/rebooting device or a + wrong port. The probe is stateless (RFC 6347 §4.2.1: the device + allocates nothing for a first ClientHello), so it leaves no + orphaned association to collide with the fixed-source-port + reconnect. + + A pinned OCF_PORT is gated but never overridden. An unset port is + auto-discovered across the band and cached; the cache is tried + first on the next reconnect and rediscovered only if it goes DEAD.""" + pinned = self.app.ocf_port + if pinned is not None: + r = probe(self.app.ip, pinned, + retries=_GATE_RETRIES, timeout=_GATE_TIMEOUT_S) + if not r.is_dtls_server: + raise ConnectionError( + f"port {pinned} not a live DTLS server ({r.outcome})") + return pinned + + # A previously discovered port is almost certainly still the one — + # try it alone first and only fall back to a full band re-race if + # it has gone silent (firmware moved it, or it was never right). + if self._discovered_port is not None: + r = probe(self.app.ip, self._discovered_port, + retries=_GATE_RETRIES, timeout=_GATE_TIMEOUT_S) + if r.is_dtls_server: + return self._discovered_port + self._discovered_port = None + + candidates = self._candidate_ports() + live = self._race_probe(candidates) + if live is None: + raise ConnectionError(f"no live DTLS server across {candidates}") + self.log.info("discovered DTLS port %d", live) + self._discovered_port = live + return live + def session_once(self): + port = self._resolve_port() sess = DtlsCoapSession( - self.app.ip, self.port, + self.app.ip, port, cert_path=self.shared.CERT_PATH, key_path=self.shared.KEY_PATH, on_notification=self._on_notification, local_port=DTLS_LOCAL_PORT_BASE + self.app.index, ) sess.connect() + self.port = port self.session = sess self.session_started_ts = time.time() self.connect_count += 1 diff --git a/smartthings_local/protocol/dtls_probe.py b/smartthings_local/protocol/dtls_probe.py new file mode 100644 index 0000000..8c669dc --- /dev/null +++ b/smartthings_local/protocol/dtls_probe.py @@ -0,0 +1,326 @@ +"""DTLS ClientHello probe — a cheap, deterministic liveness + diagnostic +primitive that sits in front of a full handshake. + +Two problems this solves: + + 1. Liveness. A 1-byte UDP probe cannot tell a silent port from a real + DTLS server: anything that doesn't return ICMP-unreachable looks + "live", so a discovery loop pays the full HANDSHAKE_TIMEOUT_S + (12 s) on every false-positive port. A real DTLS server, by + contrast, answers a ClientHello with a HelloVerifyRequest (RFC 6347 + §4.2.1 stateless cookie exchange) in ~1 RTT, *before* any + certificate work. So one ClientHello round-trip distinguishes the + real port from dead ones deterministically and cheaply — then the + expensive cert handshake is committed to exactly one port. + + 2. Diagnosis. The full handshake collapses "no DTLS server here", + "server up but rejected my cert", and "server up but no shared + cipher/version" into one opaque timeout/error. Everything the + server volunteers about itself — chosen cipher, its cert chain, its + CertificateRequest, or a fatal Alert — arrives in its first flight, + *before* we send our own certificate. Driving the handshake only + that far (no client cert required) characterizes a device. This is + how you tell an OCF-PKI-wall device (rejects at cert-verify) from a + cipher/version mismatch without a cert it would ever accept. + +Reuses split_dtls() (the record framer) and the same memory-BIO pump as +DtlsCoapSession.connect(), so the ClientHello on the wire is byte-for-byte +what our real client emits (same cipher list, same @SECLEVEL=0). +""" + +import socket +import time + +from OpenSSL import SSL + +from .coap import split_dtls +from .dtls_session import _OCF_ROOT_CA, _load_pem_chain + +# DTLS record content types (RFC 6347 §4.1) +_CT_CHANGE_CIPHER_SPEC = 20 +_CT_ALERT = 21 +_CT_HANDSHAKE = 22 +_CT_APP_DATA = 23 + +# Handshake message types (RFC 5246 §7.4 / RFC 6347) +_HS_NAMES = { + 0: 'HelloRequest', + 1: 'ClientHello', + 2: 'ServerHello', + 3: 'HelloVerifyRequest', + 11: 'Certificate', + 12: 'ServerKeyExchange', + 13: 'CertificateRequest', + 14: 'ServerHelloDone', + 15: 'CertificateVerify', + 16: 'ClientKeyExchange', + 20: 'Finished', +} + +# TLS alert descriptions (RFC 5246 §7.2) — the ones a picky OCF stack +# actually sends are called out; the rest are here so a probe never +# reports a bare number. +_ALERT_NAMES = { + 0: 'close_notify', + 10: 'unexpected_message', + 20: 'bad_record_mac', + 40: 'handshake_failure', + 42: 'bad_certificate', + 43: 'unsupported_certificate', + 44: 'certificate_revoked', + 45: 'certificate_expired', + 46: 'certificate_unknown', + 47: 'illegal_parameter', + 48: 'unknown_ca', + 49: 'access_denied', + 50: 'decode_error', + 51: 'decrypt_error', + 70: 'protocol_version', + 71: 'insufficient_security', + 80: 'internal_error', + 86: 'inappropriate_fallback', + 90: 'user_canceled', + 112: 'unrecognized_name', + 116: 'certificate_required', +} + +# Outcome classes, coarsest first. +DEAD = 'dead' # no DTLS response at all — silent/non-DTLS port +LIVE = 'live' # DTLS server confirmed (HelloVerifyRequest/ServerHello) +COMPLETED = 'completed' # full handshake succeeded (cert accepted) +REJECTED = 'rejected' # server sent a fatal Alert + + +class ProbeResult: + """What a single ClientHello probe learned about one host:port.""" + + def __init__(self, host, port): + self.host = host + self.port = port + self.outcome = DEAD + self.rtt_s = None + # Ordered, de-duplicated handshake message names the server sent. + self.handshake_msgs = [] + # (level, description_name) if a fatal/warning Alert was seen. + self.alert = None + # Raw inbound datagrams, for callers that want to dig deeper. + self.datagrams = [] + self.error = None + + @property + def is_dtls_server(self): + """True when a DTLS server was proven present, regardless of + whether it liked our credentials.""" + return self.outcome in (LIVE, COMPLETED, REJECTED) + + def __repr__(self): + bits = [f'{self.host}:{self.port}', self.outcome] + if self.rtt_s is not None: + bits.append(f'{self.rtt_s * 1000:.0f}ms') + if self.handshake_msgs: + bits.append('+'.join(self.handshake_msgs)) + if self.alert: + bits.append(f'alert={self.alert[1]}') + if self.error: + bits.append(f'err={self.error}') + return f'' + + +def classify_datagram(dgram): + """Parse one inbound UDP datagram into a list of + (content_type, detail) tuples — detail is the handshake message name + for handshake records, an (level, description_name) tuple for alerts, + or None otherwise. Pure; safe to unit-test on captured bytes.""" + out = [] + for rec in split_dtls(dgram): + ct = rec[0] + frag = rec[13:] + if ct == _CT_HANDSHAKE and frag: + out.append((ct, _HS_NAMES.get(frag[0], f'hs{frag[0]}'))) + elif ct == _CT_ALERT and len(frag) >= 2: + out.append((ct, (frag[0], _ALERT_NAMES.get(frag[1], str(frag[1]))))) + else: + out.append((ct, None)) + return out + + +def probe(host, port, *, cert_pem=None, key_pem=None, + cert_path=None, key_path=None, + stateless=True, retries=2, timeout=3.0, mtu=1280): + """Send a DTLS ClientHello to host:port and classify the server's + first flight. + + Two modes: + + stateless=True (default) — a *liveness* gate. Stop the instant the + server proves itself with a HelloVerifyRequest (or ServerHello), + and never send the cookie'd second ClientHello. By RFC 6347 + §4.2.1 the server answers the first ClientHello WITHOUT allocating + association state, so a stateless probe leaves the device + completely untouched — no orphaned association, no ~8 s §4.2.8 + cooldown for a later real connect from a different source port. + Outcome is DEAD or LIVE. This is the mode a discovery/reconnect + loop should use in front of a real handshake. + + stateless=False — a *diagnostic* drive. Continue the handshake as + far as the server's own flight goes (up to ServerHelloDone, or to + COMPLETED with a client cert), capturing its cipher, cert chain, + CertificateRequest, or a fatal Alert. This deliberately commits + association state on the device, so keep it out of hot reconnect + paths; it is the tool for characterizing an OCF-PKI-wall device + (#16) — trust rejection vs cipher/version mismatch. + + A single dropped ClientHello would otherwise read as a false DEAD, so + the silent path services OpenSSL's DTLS retransmit timer and re-sends + up to `retries` times before giving up. A live server still answers + on the first RTT — retransmit only lengthens the silent path. + + Never raises on a network/handshake failure — those are folded into + the ProbeResult so a discovery loop can race many ports safely. + """ + result = ProbeResult(host, port) + + ctx = SSL.Context(SSL.DTLS_METHOD) + ctx.load_verify_locations(_OCF_ROOT_CA) + # Accept the chain unconditionally: a probe classifies what the server + # sends, it does not gate on our trust decision. + ctx.set_verify(SSL.VERIFY_PEER, lambda *a: True) + ctx.set_cipher_list(b'ECDHE-ECDSA-AES128-GCM-SHA256:@SECLEVEL=0') + if cert_pem is not None: + _load_pem_chain(ctx, cert_pem, key_pem) + elif cert_path is not None: + ctx.use_certificate_chain_file(cert_path) + ctx.use_privatekey_file(key_path) + ctx.check_privatekey() + + conn = SSL.Connection(ctx, None) + conn.set_connect_state() + conn.set_ciphertext_mtu(mtu) + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(0.5) + dest = (host, port) + + t0 = time.time() + seen = set() + retransmits = 0 + try: + while time.time() - t0 < timeout: + try: + conn.do_handshake() + result.outcome = COMPLETED + if result.rtt_s is None: + result.rtt_s = time.time() - t0 + break + except SSL.WantReadError: + pass + except SSL.Error as e: + # A fatal Alert lands here; the alert record was already + # captured below, so classification still works. + result.error = str(e) + break + + try: + o = conn.bio_read(65535) + if o: + for r in split_dtls(o): + sock.sendto(r, dest) + except SSL.WantReadError: + pass + + try: + d, _ = sock.recvfrom(65535) + except socket.timeout: + # No answer to the last flight. Service OpenSSL's DTLS + # retransmit timer: once it has counted down to 0, + # handle_timeout() re-queues the previous flight into the + # write BIO for the next iteration to flush. A live server + # answers within a flight or two; a silent/non-DTLS port + # never does, so we give up only after `retries` + # retransmits — one dropped ClientHello no longer reads as + # a false DEAD. + to = conn.DTLSv1_get_timeout() + if to is not None and to <= 0: + if retransmits >= retries: + break + conn.DTLSv1_handle_timeout() + retransmits += 1 + continue + if not d: + continue + + if result.rtt_s is None: + result.rtt_s = time.time() - t0 + result.datagrams.append(d) + server_flight = False + for ct, detail in classify_datagram(d): + if ct == _CT_HANDSHAKE: + if detail not in seen: + seen.add(detail) + result.handshake_msgs.append(detail) + if result.outcome == DEAD: + result.outcome = LIVE + if detail in ('HelloVerifyRequest', 'ServerHello'): + server_flight = True + elif ct == _CT_ALERT and detail is not None: + level, name = detail + result.alert = (level, name) + if level == 2: # fatal + result.outcome = REJECTED + # Stateless liveness: the server proved itself with a + # HelloVerifyRequest/ServerHello, which it answered without + # allocating state. Stop before feeding this flight back to + # OpenSSL — doing so would make it emit the cookie'd second + # ClientHello, the message that actually commits association + # state on the device. Not writing it keeps the probe + # zero-footprint. + if stateless and server_flight: + break + conn.bio_write(d) + finally: + sock.close() + + return result + + +def _main(argv): + import concurrent.futures as cf + + if len(argv) < 2: + print('usage: python -m smartthings_local.protocol.dtls_probe ' + 'HOST PORT [PORT...] [--cert FILE --key FILE] [--stateless]') + return 2 + host = argv[0] + cert_path = key_path = None + # CLI defaults to the diagnostic drive so `HOST PORT` characterizes a + # device (cipher/cert/Alert). Pass --stateless for the zero-footprint + # liveness gate a reconnect loop would use. + stateless = False + ports = [] + it = iter(argv[1:]) + for a in it: + if a == '--cert': + cert_path = next(it) + elif a == '--key': + key_path = next(it) + elif a == '--stateless': + stateless = True + else: + ports.append(int(a)) + + # Race the ports: a ClientHello probe is cheap, so fan out and let the + # live one answer in ~1 RTT instead of serializing 12 s timeouts. + with cf.ThreadPoolExecutor(max_workers=max(1, len(ports))) as ex: + futs = {ex.submit(probe, host, p, cert_path=cert_path, + key_path=key_path, stateless=stateless): p + for p in ports} + results = [f.result() for f in cf.as_completed(futs)] + + for r in sorted(results, key=lambda r: r.port): + print(r) + return 0 + + +if __name__ == '__main__': + import sys + raise SystemExit(_main(sys.argv[1:])) diff --git a/tests/test_bridge_port_resolution.py b/tests/test_bridge_port_resolution.py new file mode 100644 index 0000000..6d65dbe --- /dev/null +++ b/tests/test_bridge_port_resolution.py @@ -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) diff --git a/tests/test_dtls_probe.py b/tests/test_dtls_probe.py new file mode 100644 index 0000000..74aee77 --- /dev/null +++ b/tests/test_dtls_probe.py @@ -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