feat(protocol): discover known-host OCF responder ports
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
"""Bounded discovery of a known host's plaintext OCF response port.
|
||||
|
||||
Some OCF devices receive multicast discovery on UDP 5683 but reply from an
|
||||
ephemeral port. This module records only token-correlated response ports from
|
||||
the caller's expected IPv4 address. The results are candidates: callers still
|
||||
need directory parsing, a DTLS probe, and authenticated identity validation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import math
|
||||
import secrets
|
||||
import selectors
|
||||
import socket
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..errors import MalformedMessageError
|
||||
from .coap import (
|
||||
ACCEPT,
|
||||
CF_CBOR,
|
||||
METHOD_GET,
|
||||
TYPE_ACK,
|
||||
TYPE_CON,
|
||||
TYPE_NON,
|
||||
URI_PATH,
|
||||
URI_QUERY,
|
||||
build_coap,
|
||||
parse_coap,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"OcfResponderPortDiscoveryResult",
|
||||
"discover_ocf_responder_ports",
|
||||
]
|
||||
|
||||
_OCF_MULTICAST_GROUP = socket.inet_ntoa(bytes((224, 0, 1, 187)))
|
||||
_OCF_DISCOVERY_PORT = 5683
|
||||
_OCF_CBOR = (10_000).to_bytes(2, "big")
|
||||
_OCF_CONTENT_FORMAT_VERSION = 2049
|
||||
_OCF_VERSION_1_0 = (2048).to_bytes(2, "big")
|
||||
_CONTENT = 0x45
|
||||
_MAX_DATAGRAM_BYTES = 8192
|
||||
_MAX_DATAGRAMS_PER_ROUND = 64
|
||||
_MAX_PORTS = 8
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, repr=False)
|
||||
class OcfResponderPortDiscoveryResult:
|
||||
"""Redacted result of one known-host multicast discovery operation."""
|
||||
|
||||
ports: tuple[int, ...]
|
||||
attempts: int
|
||||
responses: int
|
||||
error_code: str | None = None
|
||||
|
||||
@property
|
||||
def found(self) -> bool:
|
||||
"""Return whether at least one response port was discovered."""
|
||||
return bool(self.ports)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
"OcfResponderPortDiscoveryResult("
|
||||
f"found={self.found!r}, port_count={len(self.ports)}, "
|
||||
f"attempts={self.attempts}, responses={self.responses}, "
|
||||
f"error_code={self.error_code!r})"
|
||||
)
|
||||
|
||||
|
||||
def _validate_address(value: object, name: str) -> tuple[str, bytes]:
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"{name} must be an IPv4 address string")
|
||||
try:
|
||||
address = ipaddress.IPv4Address(value)
|
||||
except ipaddress.AddressValueError as exc:
|
||||
raise ValueError(f"{name} must be a valid IPv4 address") from exc
|
||||
if address.is_multicast or address.is_unspecified or address.is_reserved:
|
||||
raise ValueError(f"{name} must be a unicast IPv4 address")
|
||||
return str(address), address.packed
|
||||
|
||||
|
||||
def _validate_options(
|
||||
*,
|
||||
discovery_port: object,
|
||||
timeout: object,
|
||||
rounds: object,
|
||||
) -> tuple[int, float, int]:
|
||||
if isinstance(discovery_port, bool) or not isinstance(discovery_port, int):
|
||||
raise TypeError("discovery_port must be an integer")
|
||||
if not 1 <= discovery_port <= 65535:
|
||||
raise ValueError("discovery_port must be between 1 and 65535")
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
|
||||
raise TypeError("timeout must be a number")
|
||||
timeout_value = float(timeout)
|
||||
if not math.isfinite(timeout_value) or not 0 < timeout_value <= 30:
|
||||
raise ValueError("timeout must be greater than zero and at most 30")
|
||||
if isinstance(rounds, bool) or not isinstance(rounds, int):
|
||||
raise TypeError("rounds must be an integer")
|
||||
if not 1 <= rounds <= 4:
|
||||
raise ValueError("rounds must be between one and four")
|
||||
return discovery_port, timeout_value, rounds
|
||||
|
||||
|
||||
def _request(
|
||||
token: bytes,
|
||||
message_id: int,
|
||||
*,
|
||||
versioned: bool,
|
||||
filtered: bool,
|
||||
) -> bytes:
|
||||
options = [
|
||||
(URI_PATH, b"oic"),
|
||||
(URI_PATH, b"res"),
|
||||
(ACCEPT, _OCF_CBOR if versioned else CF_CBOR),
|
||||
]
|
||||
if filtered:
|
||||
options.append((URI_QUERY, b"rt=oic.r.doxm"))
|
||||
if versioned:
|
||||
options.append((_OCF_CONTENT_FORMAT_VERSION, _OCF_VERSION_1_0))
|
||||
return build_coap(TYPE_NON, METHOD_GET, message_id, token, options)
|
||||
|
||||
|
||||
def _result(
|
||||
ports: tuple[int, ...],
|
||||
attempts: int,
|
||||
responses: int,
|
||||
error_code: str | None = None,
|
||||
) -> OcfResponderPortDiscoveryResult:
|
||||
return OcfResponderPortDiscoveryResult(
|
||||
ports=ports,
|
||||
attempts=attempts,
|
||||
responses=responses,
|
||||
error_code=error_code,
|
||||
)
|
||||
|
||||
|
||||
def discover_ocf_responder_ports(
|
||||
target_address: str,
|
||||
*,
|
||||
interface_address: str,
|
||||
discovery_port: int = _OCF_DISCOVERY_PORT,
|
||||
timeout: float = 3.0,
|
||||
rounds: int = 2,
|
||||
) -> OcfResponderPortDiscoveryResult:
|
||||
"""Find plaintext OCF response ports for one known IPv4 host.
|
||||
|
||||
Each round sends modern OCF and legacy IoTivity NON requests to the
|
||||
link-local multicast group. Only a 2.05 response with a request token and
|
||||
the exact target source address contributes a candidate. One monotonic
|
||||
deadline bounds all rounds, and every socket is closed before return.
|
||||
"""
|
||||
|
||||
_target_address, target_key = _validate_address(target_address, "target_address")
|
||||
interface_address, interface_key = _validate_address(
|
||||
interface_address, "interface_address"
|
||||
)
|
||||
discovery_port, timeout, rounds = _validate_options(
|
||||
discovery_port=discovery_port,
|
||||
timeout=timeout,
|
||||
rounds=rounds,
|
||||
)
|
||||
|
||||
try:
|
||||
selector = selectors.DefaultSelector()
|
||||
except (OSError, ValueError):
|
||||
return _result((), 0, 0, "interface_unavailable")
|
||||
active = None
|
||||
try:
|
||||
active = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
active.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, interface_key)
|
||||
active.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1)
|
||||
active.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 0)
|
||||
active.bind((interface_address, 0))
|
||||
active.setblocking(False)
|
||||
selector.register(active, selectors.EVENT_READ)
|
||||
except (OSError, ValueError):
|
||||
if active is not None:
|
||||
try:
|
||||
active.close()
|
||||
except OSError:
|
||||
pass
|
||||
selector.close()
|
||||
return _result((), 0, 0, "interface_unavailable")
|
||||
|
||||
started = time.monotonic()
|
||||
deadline = started + timeout
|
||||
accepted_tokens: set[bytes] = set()
|
||||
observations: set[tuple[bytes, int]] = set()
|
||||
ports: list[int] = []
|
||||
seen_ports: set[int] = set()
|
||||
attempts = 0
|
||||
responses = 0
|
||||
too_many_ports = False
|
||||
|
||||
try:
|
||||
for round_number in range(rounds):
|
||||
if time.monotonic() >= deadline:
|
||||
break
|
||||
# Preserve the unfiltered modern and legacy requests used by the
|
||||
# installed appliance generations. Older media firmware can omit
|
||||
# usable endpoint policy from its large unfiltered directory but
|
||||
# answer the smaller legacy DOXM-filtered lookup, so send that as
|
||||
# a third bounded fallback rather than narrowing every request.
|
||||
for versioned, filtered in (
|
||||
(True, False),
|
||||
(False, False),
|
||||
(False, True),
|
||||
):
|
||||
token = secrets.token_bytes(8)
|
||||
while token in accepted_tokens:
|
||||
token = secrets.token_bytes(8)
|
||||
accepted_tokens.add(token)
|
||||
request = _request(
|
||||
token,
|
||||
secrets.randbits(16),
|
||||
versioned=versioned,
|
||||
filtered=filtered,
|
||||
)
|
||||
try:
|
||||
sent = active.sendto(
|
||||
request,
|
||||
(_OCF_MULTICAST_GROUP, discovery_port),
|
||||
)
|
||||
except OSError:
|
||||
continue
|
||||
attempts += 1
|
||||
if sent != len(request):
|
||||
continue
|
||||
|
||||
round_deadline = started + timeout * (round_number + 1) / rounds
|
||||
datagrams = 0
|
||||
while datagrams < _MAX_DATAGRAMS_PER_ROUND:
|
||||
remaining = min(deadline, round_deadline) - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
events = selector.select(remaining)
|
||||
except (OSError, ValueError):
|
||||
break
|
||||
if not events:
|
||||
break
|
||||
try:
|
||||
datagram, source = active.recvfrom(_MAX_DATAGRAM_BYTES + 1)
|
||||
except (BlockingIOError, OSError):
|
||||
continue
|
||||
datagrams += 1
|
||||
if len(datagram) > _MAX_DATAGRAM_BYTES:
|
||||
continue
|
||||
if (
|
||||
len(datagram) < 4
|
||||
or datagram[0] >> 6 != 1
|
||||
or datagram[0] & 0x0F > 8
|
||||
or 4 + (datagram[0] & 0x0F) > len(datagram)
|
||||
):
|
||||
continue
|
||||
if not isinstance(source, tuple) or len(source) != 2:
|
||||
continue
|
||||
source_host, source_port = source
|
||||
if not isinstance(source_host, str):
|
||||
continue
|
||||
try:
|
||||
source_key = socket.inet_pton(socket.AF_INET, source_host)
|
||||
except OSError:
|
||||
continue
|
||||
if source_key != target_key:
|
||||
continue
|
||||
if (
|
||||
isinstance(source_port, bool)
|
||||
or not isinstance(source_port, int)
|
||||
or not 1 <= source_port <= 65535
|
||||
):
|
||||
continue
|
||||
try:
|
||||
message_type, code, mid, token, _options, payload = parse_coap(
|
||||
datagram
|
||||
)
|
||||
except (IndexError, ValueError, MalformedMessageError):
|
||||
continue
|
||||
if (
|
||||
token not in accepted_tokens
|
||||
or code != _CONTENT
|
||||
or message_type not in (TYPE_NON, TYPE_CON)
|
||||
or not payload
|
||||
):
|
||||
continue
|
||||
if message_type == TYPE_CON:
|
||||
try:
|
||||
active.sendto(build_coap(TYPE_ACK, 0, mid, b"", []), source)
|
||||
except OSError:
|
||||
pass
|
||||
observation = (token, source_port)
|
||||
if observation in observations:
|
||||
continue
|
||||
observations.add(observation)
|
||||
responses += 1
|
||||
if source_port in seen_ports:
|
||||
continue
|
||||
if len(ports) >= _MAX_PORTS:
|
||||
too_many_ports = True
|
||||
continue
|
||||
seen_ports.add(source_port)
|
||||
ports.append(source_port)
|
||||
|
||||
if too_many_ports:
|
||||
return _result((), attempts, responses, "ambiguous_response")
|
||||
if ports:
|
||||
return _result(tuple(ports), attempts, responses)
|
||||
if attempts == 0:
|
||||
return _result((), attempts, responses, "interface_unavailable")
|
||||
return _result((), attempts, responses, "no_response")
|
||||
finally:
|
||||
try:
|
||||
selector.unregister(active)
|
||||
except (KeyError, OSError, ValueError):
|
||||
pass
|
||||
try:
|
||||
active.close()
|
||||
except OSError:
|
||||
pass
|
||||
selector.close()
|
||||
Reference in New Issue
Block a user