diff --git a/src/adn_server/application/reporting_use_cases.py b/src/adn_server/application/reporting_use_cases.py index 6c5c758..11c0482 100644 --- a/src/adn_server/application/reporting_use_cases.py +++ b/src/adn_server/application/reporting_use_cases.py @@ -71,3 +71,11 @@ class ReportingUseCases: "(ROUTER) not sending to system %s as last KeepAlive was %s seconds ago", system_name, int(session.keepalive_age(now) or 0), ) + if session.drops: + # Why this bridge refused frames, by reason: the answer to + # "my call does not cross" without reading the whole log. + logger.debug( + "(ROUTER) system %s refused frames: %s", + system_name, + ", ".join(f"{reason}={count}" for reason, count in sorted(session.drops.items())), + ) diff --git a/src/adn_server/domain/mesh_engine.py b/src/adn_server/domain/mesh_engine.py new file mode 100644 index 0000000..0273932 --- /dev/null +++ b/src/adn_server/domain/mesh_engine.py @@ -0,0 +1,376 @@ +# ADN DMR Peer Server - domain mesh engine +# +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# +############################################################################### +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +############################################################################### + +"""What an OpenBridge leg does with a verified frame, as effects. + +The engine takes a decoded frame, the link's session and the policy that +applies to it, and answers with a list of things to do: deliver this to +routing, quench that stream, log this line. It reads no configuration, touches +no socket and calls no logger; the adapter that owns those executes what comes +back, in order. + +Two consequences worth the move. A datagram can be replayed through the engine +outside the server — from a capture, in a test, on a laptop — and the answer is +the same list. And every drop carries a ``reason``, so the bridge can count and +trace what it refuses instead of leaving it in the log text. + +The engine updates the session it is handed (that is the link's state, and a +frame is what moves it); everything that leaves the process is an effect. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any + +from .mesh_admission import ( + AdmissionContext, + ObpFrame, + Rejection, + admit_dmrd_v1, + admit_dmre_v5, + call_attributes, + check_network_id, +) +from .mesh_admission import MeshEnvelope as AdmissionEnvelope +from .hbp_protocol import HBPF_DATA_SYNC, HBPF_SLT_VHEAD +from .mesh_routing import MeshIngress +from .mesh_session import ObpBridgeSession +from .value_objects import bytes_4, int_id + +TALKER_ALIAS_VSEQ = (1, 2, 3, 4) + + +# --- effects ----------------------------------------------------------------- + + +@dataclass(frozen=True) +class Reject: + """Drop this frame: log it (once per stream), quench the peer if asked.""" + + rejection: Rejection + dst_id: bytes + stream_id: bytes + + @property + def reason(self) -> str: + return self.rejection.reason + + +@dataclass(frozen=True) +class Log: + """One log line, already carrying its arguments.""" + + level: int + message: str + args: tuple[Any, ...] = () + + +@dataclass(frozen=True) +class RequestVersion: + """Tell the peer which protocol version we speak (BCVE).""" + + +@dataclass(frozen=True) +class NoteStream: + """Record that this peer is carrying this stream.""" + + peer_id: bytes + rf_src: bytes + stream_id: bytes + + +@dataclass(frozen=True) +class StoreTalkerAlias: + """Keep the talker-alias burst embedded in a voice frame.""" + + peer_id: bytes + rf_src: bytes + stream_id: bytes + dtype_vseq: int + burst: bytes + + +@dataclass(frozen=True) +class Deliver: + """Hand the frame to routing, with the mesh fields it needs.""" + + peer_id: bytes + rf_src: bytes + dst_id: bytes + seq: int + slot: int + call_type: str + frame_type: int + dtype_vseq: int + stream_id: bytes + frame: bytes + hops: bytes = b"" + source_server: bytes = b"\x00\x00\x00\x00" + ber: bytes = b"\x00" + rssi: bytes = b"\x00" + source_rptr: bytes = b"\x00\x00\x00\x00" + + +Effect = Reject | Log | RequestVersion | NoteStream | StoreTalkerAlias | Deliver + + +@dataclass(frozen=True) +class BridgePolicy: + """Everything about this bridge the engine needs, read once per frame.""" + + system: str + network_id: bytes = b"" + proto_ver: Any = 5 + relax_checks: bool = True + server_id: bytes = b"\x00\x00\x00\x00" + admission: AdmissionContext = field(default_factory=AdmissionContext) + + @property + def rejects_v1(self) -> bool: + """True when this link is configured above protocol version 1.""" + ver = 5 if self.proto_ver is None else self.proto_ver + return ver > 1 + + +def server_id_bytes(value: Any) -> bytes: + """GLOBAL SERVER_ID as the four bytes the mesh puts on the wire.""" + if isinstance(value, bytes) and len(value) >= 4: + return value + if isinstance(value, int): + return bytes_4(value & 0xFFFFFFFF) + return b"\x00\x00\x00\x00" + + +# --- ingress ----------------------------------------------------------------- + + +def reject_v1_protocol(stream_id: bytes, *, policy: BridgePolicy) -> list[Effect]: + """A v1 frame on a link configured for a later protocol version.""" + return [ + Reject( + Rejection( + reason="proto-version", + message="(%s) *ProtoControl* Version 1 protocol prohibited by PROTO_VER, Ver: %s", + args=(policy.system, policy.proto_ver), + level=logging.WARNING, + quench=False, + ), + dst_id=b"", + stream_id=stream_id, + ), + RequestVersion(), + ] + + +def accepts_source( + addr: tuple[str, int] | None, *, policy: BridgePolicy, session: ObpBridgeSession +) -> bool: + """A frame counts as ours when it comes from the peer, or RELAX_CHECKS is on.""" + return bool(policy.relax_checks) or addr == session.peer + + +def _delivery_effects( + frame: ObpFrame, + data: bytes, + *, + peer_id: bytes, + frame_type: int, + dtype_vseq: int, + deliver: Deliver, +) -> list[Effect]: + effects: list[Effect] = [] + if frame.call_type == "group" and frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VHEAD: + effects.append( + Log( + logging.INFO, + "(%s) CALL RX (OBP) src %s -> TG %s slot %s", + (frame.system, int_id(frame.rf_src), int_id(frame.dst_id), frame.slot), + ) + ) + effects.append(NoteStream(peer_id=peer_id, rf_src=frame.rf_src, stream_id=frame.stream_id)) + if ( + frame.call_type in ("group", "vcsbk") + and frame_type != HBPF_DATA_SYNC + and dtype_vseq in TALKER_ALIAS_VSEQ + and len(data) >= 53 + ): + effects.append( + StoreTalkerAlias( + peer_id=peer_id, + rf_src=frame.rf_src, + stream_id=frame.stream_id, + dtype_vseq=dtype_vseq, + burst=data[20:53], + ) + ) + effects.append(deliver) + return effects + + +def ingest_dmrd_v1( + ingress: MeshIngress, + addr: tuple[str, int] | None, + *, + policy: BridgePolicy, + session: ObpBridgeSession, + now: float, +) -> list[Effect] | None: + """A verified DMRD v1 frame. ``None`` means the source was not accepted.""" + if not accepts_source(addr, policy=policy, session=session): + return None + data = ingress.voice_frame + stream_id = data[16:20] + dst_id = data[8:11] + peer_id = data[11:15] + + rejection = check_network_id( + policy.system, stream_id, expected=policy.network_id, received=peer_id + ) + if rejection is not None: + return [Reject(rejection, dst_id, stream_id)] + + attrs = call_attributes(data[15]) + frame = ObpFrame( + system=policy.system, + stream_id=stream_id, + rf_src=data[5:8], + dst_id=dst_id, + slot=attrs.slot, + call_type=attrs.call_type, + ) + rejection = admit_dmrd_v1(frame, policy.admission) + if rejection is not None: + return [Reject(rejection, dst_id, stream_id)] + + effects = _delivery_effects( + frame, + data, + peer_id=peer_id, + frame_type=attrs.frame_type, + dtype_vseq=attrs.dtype_vseq, + deliver=Deliver( + peer_id=peer_id, + rf_src=frame.rf_src, + dst_id=dst_id, + seq=data[4], + slot=attrs.slot, + call_type=attrs.call_type, + frame_type=attrs.frame_type, + dtype_vseq=attrs.dtype_vseq, + stream_id=stream_id, + frame=data, + hops=b"", + source_server=policy.server_id, + ), + ) + session.note_keepalive(now) + return effects + + +def ingest_dmre_v5( + ingress: MeshIngress, + addr: tuple[str, int] | None, + *, + policy: BridgePolicy, + session: ObpBridgeSession, + timestamp_ns: int, + now: float, +) -> list[Effect] | None: + """A verified DMRE v5 frame. ``None`` means the source was not accepted.""" + if not accepts_source(addr, policy=policy, session=session): + return None + data = ingress.voice_frame + stream_id = data[16:20] + dst_id = data[8:11] + peer_id = data[11:15] + + rejection = check_network_id( + policy.system, stream_id, expected=policy.network_id, received=peer_id, dmre=True + ) + if rejection is not None: + return [Reject(rejection, dst_id, stream_id)] + + attrs = call_attributes(data[15]) + frame = ObpFrame( + system=policy.system, + stream_id=stream_id, + rf_src=data[5:8], + dst_id=dst_id, + # OpenBridge streams are TS1: DMRD v1 rejects anything else and DMRE + # can still carry TS2 in its bits, so normalize before routing sees it. + slot=1, + call_type=attrs.call_type, + ) + hops = ingress.hops if isinstance(ingress.hops, int) else int.from_bytes(ingress.hops, "big") + envelope = AdmissionEnvelope( + source_server=int.from_bytes(ingress.source_server, "big"), + hops=hops, + timestamp_ns=timestamp_ns, + source_server_id=ingress.source_server, + ) + rejection = admit_dmre_v5(frame, envelope, policy.admission, now=now) + if rejection is not None: + return [Reject(rejection, dst_id, stream_id)] + + effects = _delivery_effects( + frame, + data, + peer_id=peer_id, + frame_type=attrs.frame_type, + dtype_vseq=attrs.dtype_vseq, + deliver=Deliver( + peer_id=peer_id, + rf_src=frame.rf_src, + dst_id=dst_id, + seq=data[4], + slot=1, + call_type=attrs.call_type, + frame_type=attrs.frame_type, + dtype_vseq=attrs.dtype_vseq, + stream_id=stream_id, + frame=b"DMRD" + data[4:], + hops=(hops + 1).to_bytes(1, "big"), + source_server=ingress.source_server, + ber=ingress.ber, + rssi=ingress.rssi, + source_rptr=ingress.source_rptr, + ), + ) + session.note_keepalive(now) + return effects + + +__all__ = [ + "BridgePolicy", + "accepts_source", + "Deliver", + "Effect", + "Log", + "NoteStream", + "Reject", + "RequestVersion", + "StoreTalkerAlias", + "ingest_dmrd_v1", + "ingest_dmre_v5", + "reject_v1_protocol", + "server_id_bytes", +] diff --git a/src/adn_server/domain/mesh_session.py b/src/adn_server/domain/mesh_session.py index 1dffb80..272e24b 100644 --- a/src/adn_server/domain/mesh_session.py +++ b/src/adn_server/domain/mesh_session.py @@ -70,6 +70,7 @@ class ObpBridgeSession: last_keepalive: float | None = None quenched: dict[bytes, bytes] = field(default_factory=dict) stunned: bool = False + drops: dict[str, int] = field(default_factory=dict) # --- peer address -------------------------------------------------------- @@ -149,6 +150,12 @@ class ObpBridgeSession: continue return False + # --- what this link refuses ---------------------------------------------- + + def count_drop(self, reason: str) -> None: + """Tally a refused frame by reason, for counters and traces.""" + self.drops[reason] = self.drops.get(reason, 0) + 1 + # --- stun ---------------------------------------------------------------- def stun(self) -> None: diff --git a/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py b/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py index 1003890..6605eb2 100644 --- a/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py +++ b/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py @@ -79,19 +79,23 @@ from ...domain import bytes_3, bytes_4, int_id from ...domain.dmr import decode from ...domain.dmr.const import LC_OPT from ...domain.hbp_protocol import normalize_fixed_width_ascii, normalize_fixed_width_bytes -from ...domain.mesh_admission import ( - AclRules, - AdmissionContext, - MeshEnvelope, - ObpFrame, - Rejection, - admit_dmrd_v1, - admit_dmre_v5, - call_attributes, - check_network_id, - server_prefix, -) +from ...domain.mesh_admission import AclRules, AdmissionContext, Rejection, server_prefix from ...domain.mesh_routing import MeshEgress, MeshIngress, PeerMeshConfig +from ...domain.mesh_engine import ( + BridgePolicy, + Deliver, + Effect, + Log, + NoteStream, + Reject, + RequestVersion, + StoreTalkerAlias, + accepts_source, + ingest_dmrd_v1, + ingest_dmre_v5, + reject_v1_protocol, + server_id_bytes, +) from ...domain.mesh_session import ObpBridgeSession, obp_session from ...domain.talker_alias import ( DMRA_PACKET_LEN, @@ -2182,6 +2186,61 @@ class HBPProtocol(DatagramProtocol): resolve_server_id=self.validate_obp_source_server_id, ) + def _obp_policy(self) -> BridgePolicy: + """This bridge's rules, read once per frame and handed to the engine.""" + return BridgePolicy( + system=self._system, + network_id=self._config.get("NETWORK_ID", b""), + proto_ver=self._config.get("VER"), + relax_checks=bool(self._config.get("RELAX_CHECKS")), + server_id=server_id_bytes(self._CONFIG.get("GLOBAL", {}).get("SERVER_ID", 0)), + admission=self._obp_admission_context(), + ) + + def _obp_apply(self, effects: list[Effect] | None) -> None: + """Carry out what the engine decided, in order.""" + if not effects: + return + for effect in effects: + if isinstance(effect, Reject): + self._obp_reject(effect.rejection, effect.dst_id, effect.stream_id) + self._session.count_drop(effect.reason) + elif isinstance(effect, Log): + logger.log(effect.level, effect.message, *effect.args) + elif isinstance(effect, NoteStream): + self.note_dmrd_stream(effect.peer_id, effect.rf_src, effect.stream_id) + elif isinstance(effect, StoreTalkerAlias): + self.store_ta_from_voice_burst( + effect.peer_id, + effect.rf_src, + effect.stream_id, + effect.dtype_vseq, + effect.burst, + ) + elif isinstance(effect, Deliver): + if self._dmrd_received: + self._dmrd_received( + self._system, + effect.peer_id, + effect.rf_src, + effect.dst_id, + effect.seq, + effect.slot, + effect.call_type, + effect.frame_type, + effect.dtype_vseq, + effect.stream_id, + effect.frame, + obp_use_parsed=True, + obp_hops=effect.hops, + obp_source_server=effect.source_server, + obp_ber=effect.ber, + obp_rssi=effect.rssi, + obp_source_rptr=effect.source_rptr, + ) + elif isinstance(effect, RequestVersion): + self._obp_send_bcve() + def _obp_reject(self, rejection: Rejection, _dst_id: bytes, _stream_id: bytes) -> None: """Apply one admission decision: log it (once per stream) and quench the peer.""" if rejection.log_once: @@ -2208,197 +2267,52 @@ class HBPProtocol(DatagramProtocol): ) def _obp_datagram_received(self, _packet: bytes, _sockaddr: tuple[str, int]) -> None: - """Port of hblink.py OPENBRIDGE.datagramReceived: DMRD v1 (53+HMAC), BCKA, BCVE.""" + """OpenBridge ingress: verify, hand to the engine, apply what it answers.""" if _packet[:3] == DMR and _packet[:4] == DMRD and len(_packet) >= 73: - _data = _packet[:53] - _stream_id = _data[16:20] - if self._config.get("VER", 5) > 1: - if _stream_id not in self._laststrid: - logger.warning("(%s) *ProtoControl* Version 1 protocol prohibited by PROTO_VER, Ver: %s", self._system, self._config.get("VER")) - self._laststrid.append(_stream_id) - self._obp_send_bcve() + _policy = self._obp_policy() + _stream_id = _packet[16:20] + if _policy.rejects_v1: + self._obp_apply(reject_v1_protocol(_stream_id, policy=_policy)) return _ingress = self._try_decode_mesh_ingress(_packet) - if _ingress is not None and _ingress.codec == "obp_v1" and (_sockaddr == self._session.peer or self._config.get("RELAX_CHECKS")): - _data = _ingress.voice_frame + if ( + _ingress is not None + and _ingress.codec == "obp_v1" + and accepts_source(_sockaddr, policy=_policy, session=self._session) + ): self._obp_sync_target_sock_from_peer(_sockaddr, _stream_id) - _peer_id = _data[11:15] - _dst_id = _data[8:11] - _rejection = check_network_id( - self._system, - _stream_id, - expected=self._config.get("NETWORK_ID", b""), - received=_peer_id, - ) - if _rejection is not None: - self._obp_reject(_rejection, _dst_id, _stream_id) - return - _seq = _data[4] - _rf_src = _data[5:8] - _attrs = call_attributes(_data[15]) - _slot = _attrs.slot - _call_type = _attrs.call_type - _frame_type = _attrs.frame_type - _dtype_vseq = _attrs.dtype_vseq - _frame = ObpFrame( - system=self._system, - stream_id=_stream_id, - rf_src=_rf_src, - dst_id=_dst_id, - slot=_slot, - call_type=_call_type, - ) - _rejection = admit_dmrd_v1(_frame, self._obp_admission_context()) - if _rejection is not None: - self._obp_reject(_rejection, _dst_id, _stream_id) - return - if _call_type == "group" and _frame_type == HBPF_DATA_SYNC and _dtype_vseq == HBPF_SLT_VHEAD: - logger.info( - "(%s) CALL RX (OBP) src %s -> TG %s slot %s", - self._system, int_id(_rf_src), int_id(_dst_id), _slot, - ) - self.note_dmrd_stream(_peer_id, _rf_src, _stream_id) - if ( - _call_type in ("group", "vcsbk") - and _frame_type != HBPF_DATA_SYNC - and _dtype_vseq in (1, 2, 3, 4) - and len(_data) >= 53 - ): - self.store_ta_from_voice_burst( - _peer_id, _rf_src, _stream_id, _dtype_vseq, _data[20:53], - ) - # Group/vcsbk stream state, LC, duplicates: routing_use_cases._obp_group_voice_router_obp (legacy routerOBP.dmrd_received) - if self._dmrd_received: - # Legacy hblink DMRD v1: SERVER_ID + default rptr/hops/ber/rssi (`hblink.py` ~338–345, ~416) - _global = self._CONFIG.get("GLOBAL", {}) - _sid = _global.get("SERVER_ID", b"\x00\x00\x00\x00") - _obp_ss = ( - _sid - if isinstance(_sid, bytes) and len(_sid) >= 4 - else bytes_4(int(_sid) & 0xFFFFFFFF if isinstance(_sid, int) else 0) - ) - self._dmrd_received( - self._system, - _peer_id, - _rf_src, - _dst_id, - _seq, - _slot, - _call_type, - _frame_type, - _dtype_vseq, - _stream_id, - _data, - obp_use_parsed=True, - obp_hops=b"", - obp_source_server=_obp_ss, - obp_ber=b"\x00", - obp_rssi=b"\x00", - obp_source_rptr=b"\x00\x00\x00\x00", + self._obp_apply( + ingest_dmrd_v1( + _ingress, + _sockaddr, + policy=_policy, + session=self._session, + now=time.time(), ) - self._session.note_keepalive(time.time()) + ) else: logger.warning("(%s) OpenBridge HMAC failed, packet discarded - OPCODE: %s SRC: %s", self._system, _packet[:4], _sockaddr) elif _packet[:4] == DMRE: - # Legacy hblink.py OPENBRIDGE: DMRE (v5) incoming – 89-byte or 85-byte format, BLAKE2b _ingress = self._try_decode_mesh_ingress(_packet) if _ingress is None or _ingress.codec != "dmre_v5": return - if not (_sockaddr == self._session.peer or self._config.get("RELAX_CHECKS")): + _policy = self._obp_policy() + if not accepts_source(_sockaddr, policy=_policy, session=self._session): logger.warning("(%s) OpenBridge DMRE BLAKE2b failed, packet discarded - SRC: %s", self._system, _sockaddr) return - _data = _ingress.voice_frame - _ber = _ingress.ber - _rssi = _ingress.rssi - _embedded_version = _ingress.embedded_ver if _ingress.embedded_ver is not None else self._config.get("VER", 5) - _source_server = _ingress.source_server - _source_rptr = _ingress.source_rptr - _hops = _ingress.hops _trailer = parse_dmre_trailer(_packet) _timestamp = _trailer.timestamp if _trailer is not None else b"\x00" * 8 - _stream_id = _data[16:20] - self._obp_sync_target_sock_from_peer(_sockaddr, _stream_id) - _peer_id = _data[11:15] - _dst_id = _data[8:11] - _rejection = check_network_id( - self._system, - _stream_id, - expected=self._config.get("NETWORK_ID", b""), - received=_peer_id, - dmre=True, - ) - if _rejection is not None: - self._obp_reject(_rejection, _dst_id, _stream_id) - return - _seq = _data[4] - _rf_src = _data[5:8] - _attrs = call_attributes(_data[15]) - _slot = _attrs.slot - if self._config.get("MODE") == "OPENBRIDGE": - # Legacy bridge_master: OpenBridge streams are effectively TS1 (DMRD v1 rejects slot != 1). - # DMRE can still carry TS2 in bits; BRIDGES use TS:1 for OBP — normalize before STATUS/dmrd. - _slot = 1 - _call_type = _attrs.call_type - _frame_type = _attrs.frame_type - _dtype_vseq = _attrs.dtype_vseq - _frame = ObpFrame( - system=self._system, - stream_id=_stream_id, - rf_src=_rf_src, - dst_id=_dst_id, - slot=_slot, - call_type=_call_type, - ) - _envelope = MeshEnvelope( - source_server=int.from_bytes(_source_server, "big"), - hops=_hops if isinstance(_hops, int) else int.from_bytes(_hops, "big"), - timestamp_ns=int.from_bytes(_timestamp, "big"), - source_server_id=_source_server, - ) - _rejection = admit_dmre_v5( - _frame, - _envelope, - self._obp_admission_context(), - now=time.time(), - ) - if _rejection is not None: - self._obp_reject(_rejection, _dst_id, _stream_id) - return - _inthops = _envelope.hops + 1 - self.note_dmrd_stream(_peer_id, _rf_src, _stream_id) - if ( - _call_type in ("group", "vcsbk") - and _frame_type != HBPF_DATA_SYNC - and _dtype_vseq in (1, 2, 3, 4) - and len(_data) >= 53 - ): - self.store_ta_from_voice_burst( - _peer_id, _rf_src, _stream_id, _dtype_vseq, _data[20:53], + self._obp_sync_target_sock_from_peer(_sockaddr, _ingress.voice_frame[16:20]) + self._obp_apply( + ingest_dmre_v5( + _ingress, + _sockaddr, + policy=_policy, + session=self._session, + timestamp_ns=int.from_bytes(_timestamp, "big"), + now=time.time(), ) - _data_dmrd = DMRD + _data[4:] - _hops_out = _inthops.to_bytes(1, "big") - if self._dmrd_received: - # Legacy hblink DMRE: same fields passed to dmrd_received as after increment (`hblink.py` ~592–596) - self._dmrd_received( - self._system, - _peer_id, - _rf_src, - _dst_id, - _seq, - _slot, - _call_type, - _frame_type, - _dtype_vseq, - _stream_id, - _data_dmrd, - obp_use_parsed=True, - obp_hops=_hops_out, - obp_source_server=_source_server, - obp_ber=_ber, - obp_rssi=_rssi, - obp_source_rptr=_source_rptr, - ) - self._session.note_keepalive(time.time()) + ) elif _packet[:4] == EOBP: logger.warning("(%s) *ProtoControl* KF7EEL EOBP protocol not supported", self._system) elif self._config.get("ENHANCED_OBP") and _packet[:2] == BC: diff --git a/tests/domain/test_mesh_engine.py b/tests/domain/test_mesh_engine.py new file mode 100644 index 0000000..5224ca3 --- /dev/null +++ b/tests/domain/test_mesh_engine.py @@ -0,0 +1,277 @@ +# ADN DMR Peer Server - tests domain mesh engine +# +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# +############################################################################### +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +############################################################################### + +"""The OBP ingress engine: a frame in, a list of things to do out.""" + +from __future__ import annotations + +import pytest + +from adn_server.domain import bytes_3, bytes_4 +from adn_server.domain.mesh_admission import AclRules, AdmissionContext +from adn_server.domain.mesh_engine import ( + BridgePolicy, + Deliver, + Log, + NoteStream, + Reject, + RequestVersion, + StoreTalkerAlias, + accepts_source, + ingest_dmrd_v1, + ingest_dmre_v5, + reject_v1_protocol, + server_id_bytes, +) +from adn_server.domain.mesh_routing import MeshIngress +from adn_server.domain.mesh_session import ObpBridgeSession + +_SYSTEM = "OBP-FR" +_NETWORK = bytes_4(20840) +_SERVER = bytes_4(21310) +_PEER = ("82.65.127.86", 62201) +_STREAM = bytes_4(0xAABBCCDD) +_NOW = 1_800_000_000.0 + + +def _voice(*, src: int = 2130003, dst: int = 214, bits: int = 0x00, peer: bytes = _NETWORK) -> bytes: + return b"".join( + [ + b"DMRD", + bytes([7]), + bytes_3(src), + bytes_3(dst), + peer, + bytes([bits]), + _STREAM, + bytes(range(33)), + ] + ) + + +def _ingress(frame: bytes, *, codec: str = "obp_v1", hops: bytes = b"\x00", source_server: bytes = _SERVER) -> MeshIngress: + return MeshIngress( + codec=codec, + voice_frame=frame, + hops=hops, + ber=b"\x02", + rssi=b"\x03", + source_server=source_server, + source_rptr=bytes_4(4321), + embedded_ver=5, + ) + + +def _policy(**overrides) -> BridgePolicy: + base = { + "system": _SYSTEM, + "network_id": _NETWORK, + "proto_ver": 1, + "relax_checks": True, + "server_id": _SERVER, + "admission": AdmissionContext(), + } + base.update(overrides) + return BridgePolicy(**base) + + +def _session() -> ObpBridgeSession: + return ObpBridgeSession(system_name=_SYSTEM, configured_peer=_PEER) + + +def _of(effects, kind): + return [e for e in effects if isinstance(e, kind)] + + +# --- policy ------------------------------------------------------------------ + + +@pytest.mark.parametrize(("ver", "rejects"), [(1, False), (5, True), (4, True), (None, True)]) +def test_a_link_above_version_1_refuses_v1_frames(ver, rejects) -> None: + assert _policy(proto_ver=ver).rejects_v1 is rejects + + +def test_the_version_complaint_names_the_configured_version_and_asks_for_bcve() -> None: + effects = reject_v1_protocol(_STREAM, policy=_policy(proto_ver=5)) + reject, version = effects + assert isinstance(reject, Reject) + assert reject.reason == "proto-version" + assert reject.rejection.quench is False + assert reject.rejection.args[1] == 5 + assert isinstance(version, RequestVersion) + + +@pytest.mark.parametrize( + ("value", "expected"), + [(bytes_4(21310), bytes_4(21310)), (21310, bytes_4(21310)), ("21310", b"\x00\x00\x00\x00")], +) +def test_the_server_id_reaches_the_wire_as_four_bytes(value, expected) -> None: + assert server_id_bytes(value) == expected + + +def test_a_frame_from_anywhere_needs_relax_checks() -> None: + session = _session() + strict = _policy(relax_checks=False) + assert accepts_source(_PEER, policy=strict, session=session) is True + assert accepts_source(("9.9.9.9", 1), policy=strict, session=session) is False + assert accepts_source(("9.9.9.9", 1), policy=_policy(), session=session) is True + + +# --- DMRD v1 ----------------------------------------------------------------- + + +def test_a_group_call_is_announced_noted_and_delivered() -> None: + session = _session() + effects = ingest_dmrd_v1(_ingress(_voice(bits=0x21)), _PEER, policy=_policy(), session=session, now=_NOW) + assert [type(e) for e in effects] == [Log, NoteStream, Deliver] + announcement = _of(effects, Log)[0] + assert "CALL RX (OBP)" in announcement.message + assert announcement.args == (_SYSTEM, 2130003, 214, 1) + + +def test_delivery_carries_the_v1_defaults() -> None: + session = _session() + effects = ingest_dmrd_v1(_ingress(_voice()), _PEER, policy=_policy(), session=session, now=_NOW) + deliver = _of(effects, Deliver)[0] + assert (deliver.rf_src, deliver.dst_id, deliver.stream_id) == (bytes_3(2130003), bytes_3(214), _STREAM) + assert (deliver.seq, deliver.slot, deliver.call_type) == (7, 1, "group") + # v1 carries no mesh envelope: our own server id, no hops, no ber/rssi + assert deliver.hops == b"" + assert deliver.source_server == _SERVER + assert (deliver.ber, deliver.rssi, deliver.source_rptr) == (b"\x00", b"\x00", b"\x00\x00\x00\x00") + + +def test_a_voice_burst_keeps_its_talker_alias() -> None: + session = _session() + effects = ingest_dmrd_v1(_ingress(_voice(bits=0x01)), _PEER, policy=_policy(), session=session, now=_NOW) + alias = _of(effects, StoreTalkerAlias)[0] + assert alias.dtype_vseq == 1 + assert alias.burst == bytes(range(33)) + + +def test_a_frame_from_another_network_is_refused() -> None: + session = _session() + effects = ingest_dmrd_v1( + _ingress(_voice(peer=bytes_4(26811))), _PEER, policy=_policy(), session=session, now=_NOW + ) + assert [type(e) for e in effects] == [Reject] + assert effects[0].reason == "network-id-mismatch" + + +def test_a_talkgroup_that_must_stay_home_is_refused_and_quenched() -> None: + session = _session() + effects = ingest_dmrd_v1(_ingress(_voice(dst=9)), _PEER, policy=_policy(), session=session, now=_NOW) + assert effects[0].reason == "tg-filter" + assert effects[0].rejection.quench is True + assert effects[0].dst_id == bytes_3(9) + + +def test_an_unaccepted_source_is_not_the_engine_s_business() -> None: + session = _session() + effects = ingest_dmrd_v1( + _ingress(_voice()), ("9.9.9.9", 40000), policy=_policy(relax_checks=False), session=session, now=_NOW + ) + assert effects is None + + +def test_a_delivered_frame_counts_as_a_keepalive() -> None: + session = _session() + ingest_dmrd_v1(_ingress(_voice()), _PEER, policy=_policy(), session=session, now=_NOW) + assert session.last_keepalive == _NOW + + +def test_a_refused_frame_is_not_a_keepalive() -> None: + session = _session() + ingest_dmrd_v1(_ingress(_voice(dst=9)), _PEER, policy=_policy(), session=session, now=_NOW) + assert session.keepalive_seen is False + + +def test_the_acls_reach_the_engine_through_the_policy() -> None: + session = _session() + admission = AdmissionContext( + acl_check=lambda target, _acl: target != bytes_3(2130003), + system_rules=AclRules(enabled=True), + ) + effects = ingest_dmrd_v1( + _ingress(_voice()), _PEER, policy=_policy(admission=admission), session=session, now=_NOW + ) + assert effects[0].reason == "system-sub-acl" + + +# --- DMRE v5 ----------------------------------------------------------------- + + +def _v5(session, *, frame: bytes | None = None, hops: bytes = b"\x02", age: float = 0.0, **policy_kw): + return ingest_dmre_v5( + _ingress(frame or _voice(), codec="dmre_v5", hops=hops, source_server=bytes_4(2084)), + _PEER, + policy=_policy(proto_ver=5, **policy_kw), + session=session, + timestamp_ns=int((_NOW - age) * 1_000_000_000), + now=_NOW, + ) + + +def test_a_v5_frame_is_delivered_with_its_envelope() -> None: + session = _session() + deliver = _of(_v5(session), Deliver)[0] + assert deliver.frame[:4] == b"DMRD" # routing speaks DMRD, the mesh header is unwrapped + assert deliver.hops == b"\x03" # one more hop than it arrived with + assert deliver.source_server == bytes_4(2084) + assert (deliver.ber, deliver.rssi, deliver.source_rptr) == (b"\x02", b"\x03", bytes_4(4321)) + + +def test_a_v5_frame_is_always_routed_as_slot_1() -> None: + """OpenBridge streams are TS1; DMRE can still carry TS2 in its bits byte.""" + session = _session() + deliver = _of(_v5(session, frame=_voice(bits=0x80)), Deliver)[0] + assert deliver.slot == 1 + + +def test_a_late_v5_frame_is_refused() -> None: + session = _session() + effects = _v5(session, age=9.0) + assert effects[0].reason == "stale-packet" + + +def test_a_looping_v5_frame_is_refused() -> None: + session = _session() + effects = _v5(session, hops=b"\x0a") + assert effects[0].reason == "max-hops" + + +def test_an_unaccepted_v5_source_is_not_the_engine_s_business() -> None: + session = _session() + effects = ingest_dmre_v5( + _ingress(_voice(), codec="dmre_v5"), + ("9.9.9.9", 40000), + policy=_policy(proto_ver=5, relax_checks=False), + session=session, + timestamp_ns=int(_NOW * 1_000_000_000), + now=_NOW, + ) + assert effects is None + + +def test_a_v5_frame_from_another_network_is_refused_by_name() -> None: + session = _session() + effects = _v5(session, frame=_voice(peer=bytes_4(26811))) + assert effects[0].reason == "network-id-mismatch" + assert "DMRE" in effects[0].rejection.message diff --git a/tests/domain/test_mesh_session.py b/tests/domain/test_mesh_session.py index d3aa444..3c02a18 100644 --- a/tests/domain/test_mesh_session.py +++ b/tests/domain/test_mesh_session.py @@ -265,3 +265,11 @@ def test_asking_for_an_unknown_system_creates_an_empty_session() -> None: session = obp_session(_config(), "NOPE") assert session.peer_known is False assert session.keepalive_seen is False + + +def test_refused_frames_are_tallied_by_reason() -> None: + session = _session() + session.count_drop("tg-filter") + session.count_drop("tg-filter") + session.count_drop("max-hops") + assert session.drops == {"tg-filter": 2, "max-hops": 1} diff --git a/tests/fixtures/obp_ingress_effects.jsonl b/tests/fixtures/obp_ingress_effects.jsonl new file mode 100644 index 0000000..700aa99 --- /dev/null +++ b/tests/fixtures/obp_ingress_effects.jsonl @@ -0,0 +1,96 @@ +{"case":{"desc":"tg 1","dst":1,"kind":"v1","name":"v1 tg 1","stream":1358954497},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954497 FROM SUBSCRIBER 1 BY GLOBAL TG FILTER"]],"quenched":[[1,1358954497]]}} +{"case":{"desc":"tg 9","dst":9,"kind":"v1","name":"v1 tg 9","stream":1358954498},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954498 FROM SUBSCRIBER 9 BY GLOBAL TG FILTER"]],"quenched":[[9,1358954498]]}} +{"case":{"desc":"tg 79","dst":79,"kind":"v1","name":"v1 tg 79","stream":1358954499},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954499 FROM SUBSCRIBER 79 BY GLOBAL TG FILTER"]],"quenched":[[79,1358954499]]}} +{"case":{"desc":"tg 80","dst":80,"kind":"v1","name":"v1 tg 80","stream":1358954500},"effects":{"delivered":[[2130003,80]],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"tg 92","dst":92,"kind":"v1","name":"v1 tg 92","stream":1358954501},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954501 FROM SUBSCRIBER 92 BY GLOBAL TG FILTER"]],"quenched":[[92,1358954501]]}} +{"case":{"desc":"tg 199","dst":199,"kind":"v1","name":"v1 tg 199","stream":1358954502},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954502 FROM SUBSCRIBER 199 BY GLOBAL TG FILTER"]],"quenched":[[199,1358954502]]}} +{"case":{"desc":"tg 200","dst":200,"kind":"v1","name":"v1 tg 200","stream":1358954503},"effects":{"delivered":[[2130003,200]],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"tg 214","dst":214,"kind":"v1","name":"v1 tg 214","stream":1358954504},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"tg 777","dst":777,"kind":"v1","name":"v1 tg 777","stream":1358954505},"effects":{"delivered":[[2130003,777]],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"tg 778","dst":778,"kind":"v1","name":"v1 tg 778","stream":1358954506},"effects":{"delivered":[[2130003,778]],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"tg 9989","dst":9989,"kind":"v1","name":"v1 tg 9989","stream":1358954507},"effects":{"delivered":[[2130003,9989]],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"tg 9990","dst":9990,"kind":"v1","name":"v1 tg 9990","stream":1358954508},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954508 FROM SUBSCRIBER 9990 BY GLOBAL TG FILTER"]],"quenched":[[9990,1358954508]]}} +{"case":{"desc":"tg 9999","dst":9999,"kind":"v1","name":"v1 tg 9999","stream":1358954509},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954509 FROM SUBSCRIBER 9999 BY GLOBAL TG FILTER"]],"quenched":[[9999,1358954509]]}} +{"case":{"desc":"tg 900999","dst":900999,"kind":"v1","name":"v1 tg 900999","stream":1358954510},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954510 FROM SUBSCRIBER 900999 BY GLOBAL TG FILTER"]],"quenched":[[900999,1358954510]]}} +{"case":{"bits":0,"desc":"bits 0x00","kind":"v1","name":"v1 bits 0x00","stream":1358954511},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"bits":0,"desc":"bits 0x00 on a local tg","dst":9,"kind":"v1","name":"v1 bits 0x00 on a local tg","stream":1358954512},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954512 FROM SUBSCRIBER 9 BY GLOBAL TG FILTER"]],"quenched":[[9,1358954512]]}} +{"case":{"bits":64,"desc":"bits 0x40","kind":"v1","name":"v1 bits 0x40","stream":1358954513},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"bits":64,"desc":"bits 0x40 on a local tg","dst":9,"kind":"v1","name":"v1 bits 0x40 on a local tg","stream":1358954514},"effects":{"delivered":[[2130003,9]],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"bits":35,"desc":"bits 0x23","kind":"v1","name":"v1 bits 0x23","stream":1358954515},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"bits":35,"desc":"bits 0x23 on a local tg","dst":9,"kind":"v1","name":"v1 bits 0x23 on a local tg","stream":1358954516},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954516 FROM SUBSCRIBER 9 BY GLOBAL TG FILTER"]],"quenched":[[9,1358954516]]}} +{"case":{"bits":128,"desc":"bits 0x80","kind":"v1","name":"v1 bits 0x80","stream":1358954517},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[40,"(OBP-FR) OpenBridge packet discarded because it was not received on slot 1. SID: 2130003, TGID 214"]],"quenched":[]}} +{"case":{"bits":128,"desc":"bits 0x80 on a local tg","dst":9,"kind":"v1","name":"v1 bits 0x80 on a local tg","stream":1358954518},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[40,"(OBP-FR) OpenBridge packet discarded because it was not received on slot 1. SID: 2130003, TGID 9"]],"quenched":[]}} +{"case":{"bits":227,"desc":"bits 0xe3","kind":"v1","name":"v1 bits 0xe3","stream":1358954519},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[40,"(OBP-FR) OpenBridge packet discarded because it was not received on slot 1. SID: 2130003, TGID 214"]],"quenched":[]}} +{"case":{"bits":227,"desc":"bits 0xe3 on a local tg","dst":9,"kind":"v1","name":"v1 bits 0xe3 on a local tg","stream":1358954520},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[40,"(OBP-FR) OpenBridge packet discarded because it was not received on slot 1. SID: 2130003, TGID 9"]],"quenched":[]}} +{"case":{"bits":22,"desc":"bits 0x16","kind":"v1","name":"v1 bits 0x16","stream":1358954521},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"bits":22,"desc":"bits 0x16 on a local tg","dst":9,"kind":"v1","name":"v1 bits 0x16 on a local tg","stream":1358954522},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954522 FROM SUBSCRIBER 9 BY GLOBAL TG FILTER"]],"quenched":[[9,1358954522]]}} +{"case":{"desc":"global subscriber, acl g=True s=False","global_acl":true,"kind":"v1","name":"v1 global subscriber, acl g=True s=False","src":2130002,"stream":1358954523,"system_acl":false},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954523 ON TGID 2130002 BY GLOBAL TS1 ACL"]],"quenched":[[214,1358954523]]}} +{"case":{"desc":"global subscriber, acl g=False s=True","global_acl":false,"kind":"v1","name":"v1 global subscriber, acl g=False s=True","src":2130002,"stream":1358954524,"system_acl":true},"effects":{"delivered":[[2130002,214]],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"global subscriber, acl g=True s=True","global_acl":true,"kind":"v1","name":"v1 global subscriber, acl g=True s=True","src":2130002,"stream":1358954525,"system_acl":true},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954525 ON TGID 2130002 BY GLOBAL TS1 ACL"]],"quenched":[[214,1358954525]]}} +{"case":{"desc":"system subscriber, acl g=True s=False","global_acl":true,"kind":"v1","name":"v1 system subscriber, acl g=True s=False","src":2130001,"stream":1358954526,"system_acl":false},"effects":{"delivered":[[2130001,214]],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"system subscriber, acl g=False s=True","global_acl":false,"kind":"v1","name":"v1 system subscriber, acl g=False s=True","src":2130001,"stream":1358954527,"system_acl":true},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954527 FROM SUBSCRIBER 2130001 BY SYSTEM ACL"]],"quenched":[[214,1358954527]]}} +{"case":{"desc":"system subscriber, acl g=True s=True","global_acl":true,"kind":"v1","name":"v1 system subscriber, acl g=True s=True","src":2130001,"stream":1358954528,"system_acl":true},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954528 FROM SUBSCRIBER 2130001 BY SYSTEM ACL"]],"quenched":[[214,1358954528]]}} +{"case":{"desc":"allowed subscriber, acl g=True s=False","global_acl":true,"kind":"v1","name":"v1 allowed subscriber, acl g=True s=False","src":2130003,"stream":1358954529,"system_acl":false},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"allowed subscriber, acl g=False s=True","global_acl":false,"kind":"v1","name":"v1 allowed subscriber, acl g=False s=True","src":2130003,"stream":1358954530,"system_acl":true},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"allowed subscriber, acl g=True s=True","global_acl":true,"kind":"v1","name":"v1 allowed subscriber, acl g=True s=True","src":2130003,"stream":1358954531,"system_acl":true},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"denied tg 777","dst":777,"global_acl":true,"kind":"v1","name":"v1 denied tg 777","stream":1358954532,"system_acl":true},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954532 ON TGID 777 BY SYSTEM ACL"]],"quenched":[[777,1358954532]]}} +{"case":{"desc":"denied tg 778","dst":778,"global_acl":true,"kind":"v1","name":"v1 denied tg 778","stream":1358954533,"system_acl":true},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954533 ON TGID 778 BY GLOBAL TS1 ACL"]],"quenched":[[778,1358954533]]}} +{"case":{"desc":"stunned by the operator","kind":"v1","name":"v1 stunned by the operator","stream":1358954534,"stun":true},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[30,"(OBP-FR) Bridge STUNned, discarding"],[20,"(OBP-FR) Bridge STUNned, discarding"]],"quenched":[]}} +{"case":{"desc":"from an unexpected address","from":["9.9.9.9",40000],"kind":"v1","name":"v1 from an unexpected address","stream":1358954535},"effects":{"delivered":[[2130003,214]],"egress":[[24,["9.9.9.9",40000]],[73,["9.9.9.9",40000]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[10,"(OBP-FR) *BridgeControl* OBP peer address sync to 9.9.9.9:40000 (RELAX_CHECKS; was 82.65.127.86:62201)"]],"quenched":[]}} +{"case":{"desc":"from an unexpected address, no relax","from":["9.9.9.9",40000],"kind":"v1","name":"v1 from an unexpected address, no relax","relax":false,"stream":1358954536},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[30,"(OBP-FR) OpenBridge HMAC failed, packet discarded - OPCODE: b'DMRD' SRC: ('9.9.9.9', 40000)"]],"quenched":[]}} +{"case":{"desc":"tg 1","dst":1,"kind":"v5","name":"v5 tg 1","stream":1358954537},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954537 ON TG 1 BY GLOBAL TG FILTER (local to repeater)"]],"quenched":[[1,1358954537]]}} +{"case":{"desc":"tg 9","dst":9,"kind":"v5","name":"v5 tg 9","stream":1358954538},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954538 ON TG 9 BY GLOBAL TG FILTER (local to repeater)"]],"quenched":[[9,1358954538]]}} +{"case":{"desc":"tg 79","dst":79,"kind":"v5","name":"v5 tg 79","stream":1358954539},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954539 ON TG 79 BY GLOBAL TG FILTER (local to repeater)"]],"quenched":[[79,1358954539]]}} +{"case":{"desc":"tg 85","dst":85,"kind":"v5","name":"v5 tg 85","stream":1358954540},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954540 ON TG 85 BY GLOBAL TG FILTER (local to MCC)"]],"quenched":[[85,1358954540]]}} +{"case":{"desc":"tg 92","dst":92,"kind":"v5","name":"v5 tg 92","stream":1358954541},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954541 ON TG 92 BY GLOBAL TG FILTER (local to server main ID)"]],"quenched":[[92,1358954541]]}} +{"case":{"desc":"tg 100","dst":100,"kind":"v5","name":"v5 tg 100","stream":1358954542},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954542 ON TG 100 BY GLOBAL TG FILTER (local to server main ID)"]],"quenched":[[100,1358954542]]}} +{"case":{"desc":"tg 199","dst":199,"kind":"v5","name":"v5 tg 199","stream":1358954543},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954543 ON TG 199 BY GLOBAL TG FILTER (local to server main ID)"]],"quenched":[[199,1358954543]]}} +{"case":{"desc":"tg 214","dst":214,"kind":"v5","name":"v5 tg 214","stream":1358954544},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"tg 850","dst":850,"kind":"v5","name":"v5 tg 850","stream":1358954545},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954545 ON TG 850 BY GLOBAL TG FILTER (local to MCC)"]],"quenched":[[850,1358954545]]}} +{"case":{"desc":"tg 9990","dst":9990,"kind":"v5","name":"v5 tg 9990","stream":1358954546},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954546 ON TG 9990 BY GLOBAL TG FILTER (local to server)"]],"quenched":[[9990,1358954546]]}} +{"case":{"desc":"tg 900999","dst":900999,"kind":"v5","name":"v5 tg 900999","stream":1358954547},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954547 ON TG 900999 BY GLOBAL TG FILTER (local to server)"]],"quenched":[[900999,1358954547]]}} +{"case":{"desc":"source server 123","kind":"v5","name":"v5 source server 123","source_server":123,"stream":1358954548},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[30,"(OBP-FR) Source Server should be between 4 and 7 digits, discarding Src: 123"]],"quenched":[[214,1358954548]]}} +{"case":{"desc":"source server 123, validated","kind":"v5","name":"v5 source server 123, validated","source_server":123,"stream":1358954549,"validate_server_ids":true},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[30,"(OBP-FR) Source Server should be between 4 and 7 digits, discarding Src: 123"]],"quenched":[[214,1358954549]]}} +{"case":{"desc":"source server 2084","kind":"v5","name":"v5 source server 2084","source_server":2084,"stream":1358954550},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"source server 2084, validated","kind":"v5","name":"v5 source server 2084, validated","source_server":2084,"stream":1358954551,"validate_server_ids":true},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"source server 2131","kind":"v5","name":"v5 source server 2131","source_server":2131,"stream":1358954552},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"source server 2131, validated","kind":"v5","name":"v5 source server 2131, validated","source_server":2131,"stream":1358954553,"validate_server_ids":true},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[30,"(OBP-FR) Source Server ID is 4 or 5 digits but not in list: 2131"]],"quenched":[[214,1358954553]]}} +{"case":{"desc":"source server 21310","kind":"v5","name":"v5 source server 21310","source_server":21310,"stream":1358954554},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"source server 21310, validated","kind":"v5","name":"v5 source server 21310, validated","source_server":21310,"stream":1358954555,"validate_server_ids":true},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[30,"(OBP-FR) Source Server ID is 4 or 5 digits but not in list: 21310"]],"quenched":[[214,1358954555]]}} +{"case":{"desc":"source server 2130001","kind":"v5","name":"v5 source server 2130001","source_server":2130001,"stream":1358954556},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"source server 2130001, validated","kind":"v5","name":"v5 source server 2130001, validated","source_server":2130001,"stream":1358954557,"validate_server_ids":true},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"tg 100 from server 20840","dst":100,"kind":"v5","name":"v5 tg 100 from server 20840","source_server":20840,"stream":1358954558},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954558 ON TG 100 BY GLOBAL TG FILTER (local to server main ID)"]],"quenched":[[100,1358954558]]}} +{"case":{"desc":"tg 100 from server 21310","dst":100,"kind":"v5","name":"v5 tg 100 from server 21310","source_server":21310,"stream":1358954559},"effects":{"delivered":[[2130003,100]],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"tg 85 from server 20851","dst":85,"kind":"v5","name":"v5 tg 85 from server 20851","source_server":20851,"stream":1358954560},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954560 ON TG 85 BY GLOBAL TG FILTER (local to MCC)"]],"quenched":[[85,1358954560]]}} +{"case":{"desc":"tg 850 from server 21310","dst":850,"kind":"v5","name":"v5 tg 850 from server 21310","source_server":21310,"stream":1358954561},"effects":{"delivered":[[2130003,850]],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"0 hops","hops":0,"kind":"v5","name":"v5 0 hops","stream":1358954562},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"8 hops","hops":8,"kind":"v5","name":"v5 8 hops","stream":1358954563},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"9 hops","hops":9,"kind":"v5","name":"v5 9 hops","stream":1358954564},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"10 hops","hops":10,"kind":"v5","name":"v5 10 hops","stream":1358954565},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[10,"(OBP-FR) MAX HOPS exceed, dropping. Hops: 11, DST: 214, SRC: 2084"]],"quenched":[[214,1358954565]]}} +{"case":{"desc":"20 hops","hops":20,"kind":"v5","name":"v5 20 hops","stream":1358954566},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[10,"(OBP-FR) MAX HOPS exceed, dropping. Hops: 21, DST: 214, SRC: 2084"]],"quenched":[[214,1358954566]]}} +{"case":{"age":0.0,"desc":"0.0s old","kind":"v5","name":"v5 0.0s old","stream":1358954567},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"age":4.0,"desc":"4.0s old","kind":"v5","name":"v5 4.0s old","stream":1358954568},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"age":6.0,"desc":"6.0s old","kind":"v5","name":"v5 6.0s old","stream":1358954569},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[30,"(OBP-FR) Packet from server 2084 more than 5s old!, discarding"]],"quenched":[[214,1358954569]]}} +{"case":{"age":60.0,"desc":"60.0s old","kind":"v5","name":"v5 60.0s old","stream":1358954570},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[30,"(OBP-FR) Packet from server 2084 more than 5s old!, discarding"]],"quenched":[[214,1358954570]]}} +{"case":{"desc":"global subscriber","global_acl":true,"kind":"v5","name":"v5 global subscriber","src":2130002,"stream":1358954571,"system_acl":true},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954571 ON TGID 2130002 BY GLOBAL TS1 ACL"]],"quenched":[[214,1358954571]]}} +{"case":{"desc":"system subscriber","global_acl":true,"kind":"v5","name":"v5 system subscriber","src":2130001,"stream":1358954572,"system_acl":true},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954572 FROM SUBSCRIBER 2130001 BY SYSTEM ACL"]],"quenched":[[214,1358954572]]}} +{"case":{"desc":"stunned by the operator","kind":"v5","name":"v5 stunned by the operator","stream":1358954573,"stun":true},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[30,"(OBP-FR) Bridge STUNned, discarding"],[20,"(OBP-FR) Bridge STUNned, discarding"]],"quenched":[]}} +{"case":{"desc":"from an unexpected address","from":["9.9.9.9",40000],"kind":"v5","name":"v5 from an unexpected address","stream":1358954574},"effects":{"delivered":[[2130003,214]],"egress":[[24,["9.9.9.9",40000]],[89,["9.9.9.9",40000]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[10,"(OBP-FR) *BridgeControl* OBP peer address sync to 9.9.9.9:40000 (RELAX_CHECKS; was 82.65.127.86:62201)"]],"quenched":[]}} +{"case":{"desc":"from 82.65.127.86:62201 relax=False","from":["82.65.127.86",62201],"kind":"bcka","name":"bcka from 82.65.127.86:62201 relax=False","relax":false,"stream":1358954575},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"from 82.65.127.86:62201 relax=True","from":["82.65.127.86",62201],"kind":"bcka","name":"bcka from 82.65.127.86:62201 relax=True","relax":true,"stream":1358954576},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"]],"quenched":[]}} +{"case":{"desc":"from 82.65.127.86:40000 relax=False","from":["82.65.127.86",40000],"kind":"bcka","name":"bcka from 82.65.127.86:40000 relax=False","relax":false,"stream":1358954577},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",40000]],[73,["82.65.127.86",40000]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) *BridgeControl* Source IP and Port has changed for OBP from 82.65.127.86:62201 to 82.65.127.86:40000, updating"]],"quenched":[]}} +{"case":{"desc":"from 82.65.127.86:40000 relax=True","from":["82.65.127.86",40000],"kind":"bcka","name":"bcka from 82.65.127.86:40000 relax=True","relax":true,"stream":1358954578},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",40000]],[73,["82.65.127.86",40000]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) *BridgeControl* Source IP and Port has changed for OBP from 82.65.127.86:62201 to 82.65.127.86:40000, updating"]],"quenched":[]}} +{"case":{"desc":"from 9.9.9.9:62201 relax=False","from":["9.9.9.9",62201],"kind":"bcka","name":"bcka from 9.9.9.9:62201 relax=False","relax":false,"stream":1358954579},"effects":{"delivered":[],"egress":[[24,["9.9.9.9",62201]],[73,["9.9.9.9",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) *BridgeControl* Source IP and Port has changed for OBP from 82.65.127.86:62201 to 9.9.9.9:62201, updating"]],"quenched":[]}} +{"case":{"desc":"from 9.9.9.9:62201 relax=True","from":["9.9.9.9",62201],"kind":"bcka","name":"bcka from 9.9.9.9:62201 relax=True","relax":true,"stream":1358954580},"effects":{"delivered":[],"egress":[[24,["9.9.9.9",62201]],[73,["9.9.9.9",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) *BridgeControl* Source IP and Port has changed for OBP from 82.65.127.86:62201 to 9.9.9.9:62201, updating"]],"quenched":[]}} +{"case":{"desc":"from 82.65.127.86:62201 relax=False","from":["82.65.127.86",62201],"kind":"bcsq","name":"bcsq from 82.65.127.86:62201 relax=False","relax":false,"stream":1358954581},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) *BridgeControl* BCSQ accepted: stream_id=1358954581 TGID=214 (peer quenched; forwarding on this OBP stops for this stream/TG)"]],"quenched":[]}} +{"case":{"desc":"from 82.65.127.86:62201 relax=True","from":["82.65.127.86",62201],"kind":"bcsq","name":"bcsq from 82.65.127.86:62201 relax=True","relax":true,"stream":1358954582},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) *BridgeControl* BCSQ accepted: stream_id=1358954582 TGID=214 (peer quenched; forwarding on this OBP stops for this stream/TG)"]],"quenched":[]}} +{"case":{"desc":"from 82.65.127.86:40000 relax=False","from":["82.65.127.86",40000],"kind":"bcsq","name":"bcsq from 82.65.127.86:40000 relax=False","relax":false,"stream":1358954583},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) *BridgeControl* BCSQ accepted: stream_id=1358954583 TGID=214 (peer quenched; forwarding on this OBP stops for this stream/TG)"]],"quenched":[]}} +{"case":{"desc":"from 82.65.127.86:40000 relax=True","from":["82.65.127.86",40000],"kind":"bcsq","name":"bcsq from 82.65.127.86:40000 relax=True","relax":true,"stream":1358954584},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) *BridgeControl* BCSQ accepted: stream_id=1358954584 TGID=214 (peer quenched; forwarding on this OBP stops for this stream/TG)"]],"quenched":[]}} +{"case":{"desc":"from 9.9.9.9:62201 relax=False","from":["9.9.9.9",62201],"kind":"bcsq","name":"bcsq from 9.9.9.9:62201 relax=False","relax":false,"stream":1358954585},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) *BridgeControl* BCSQ accepted: stream_id=1358954585 TGID=214 (peer quenched; forwarding on this OBP stops for this stream/TG)"]],"quenched":[]}} +{"case":{"desc":"from 9.9.9.9:62201 relax=True","from":["9.9.9.9",62201],"kind":"bcsq","name":"bcsq from 9.9.9.9:62201 relax=True","relax":true,"stream":1358954586},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) *BridgeControl* BCSQ accepted: stream_id=1358954586 TGID=214 (peer quenched; forwarding on this OBP stops for this stream/TG)"]],"quenched":[]}} +{"case":{"desc":"from 82.65.127.86:62201 relax=False","from":["82.65.127.86",62201],"kind":"bcst","name":"bcst from 82.65.127.86:62201 relax=False","relax":false,"stream":1358954587},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[5,"(OBP-FR) *BridgeControl* BCST STUN request received"],[20,"(OBP-FR) Bridge STUNned, discarding"]],"quenched":[]}} +{"case":{"desc":"from 82.65.127.86:62201 relax=True","from":["82.65.127.86",62201],"kind":"bcst","name":"bcst from 82.65.127.86:62201 relax=True","relax":true,"stream":1358954588},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[5,"(OBP-FR) *BridgeControl* BCST STUN request received"],[20,"(OBP-FR) Bridge STUNned, discarding"]],"quenched":[]}} +{"case":{"desc":"from 82.65.127.86:40000 relax=False","from":["82.65.127.86",40000],"kind":"bcst","name":"bcst from 82.65.127.86:40000 relax=False","relax":false,"stream":1358954589},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[5,"(OBP-FR) *BridgeControl* BCST STUN request received"],[20,"(OBP-FR) Bridge STUNned, discarding"]],"quenched":[]}} +{"case":{"desc":"from 82.65.127.86:40000 relax=True","from":["82.65.127.86",40000],"kind":"bcst","name":"bcst from 82.65.127.86:40000 relax=True","relax":true,"stream":1358954590},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[5,"(OBP-FR) *BridgeControl* BCST STUN request received"],[20,"(OBP-FR) Bridge STUNned, discarding"]],"quenched":[]}} +{"case":{"desc":"from 9.9.9.9:62201 relax=False","from":["9.9.9.9",62201],"kind":"bcst","name":"bcst from 9.9.9.9:62201 relax=False","relax":false,"stream":1358954591},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[5,"(OBP-FR) *BridgeControl* BCST STUN request received"],[20,"(OBP-FR) Bridge STUNned, discarding"]],"quenched":[]}} +{"case":{"desc":"from 9.9.9.9:62201 relax=True","from":["9.9.9.9",62201],"kind":"bcst","name":"bcst from 9.9.9.9:62201 relax=True","relax":true,"stream":1358954592},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[5,"(OBP-FR) *BridgeControl* BCST STUN request received"],[20,"(OBP-FR) Bridge STUNned, discarding"]],"quenched":[]}} diff --git a/tests/harness/obp_ingress.py b/tests/harness/obp_ingress.py new file mode 100644 index 0000000..4100e29 --- /dev/null +++ b/tests/harness/obp_ingress.py @@ -0,0 +1,317 @@ +# ADN DMR Peer Server - tests harness obp ingress corpus +# +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# +############################################################################### +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +############################################################################### + +"""What an OpenBridge leg does with a datagram, recorded frame by frame. + +Each case is a recipe (not raw bytes): the frame to build, the configuration to +build it against, and the address it arrives from. Running one feeds a real +``HBPProtocol`` and records everything observable from outside — what reached +routing, what was quenched, where egress went afterwards and what was logged. + +The recorded answers live in ``fixtures/obp_ingress_effects.jsonl``; they were +taken from the pre-refactor handler and verified frame by frame against +upstream ``develop``, so they are the contract the OBP ingress must keep +whatever it is rebuilt on. Regenerate with ``CAPTURE=1 pytest +tests/infrastructure/test_obp_ingress_effects.py`` and read the diff carefully: +every line that moves is a behaviour change. +""" + +from __future__ import annotations + +import copy +import functools +import itertools +import json +import logging +import os +import time +from pathlib import Path +from typing import Any + +from adn_server.domain import bytes_3, bytes_4 +from adn_server.infrastructure.acl_router import InMemoryAclRouter +from adn_server.infrastructure.hbp_constants import BCST, DMRD +from adn_server.infrastructure.mesh.dmre_v5 import build_dmre +from adn_server.infrastructure.mesh.obp_v1 import build_bcka, build_bcsq, build_dmrd_v1, obp_hmac_sha1 +from adn_server.infrastructure.twisted_adapters.udp_hbp import HBPProtocol + +FIXTURE = Path(__file__).resolve().parent.parent / "fixtures" / "obp_ingress_effects.jsonl" + +PASSPHRASE = (b"test-passphrase" + b"\x00" * 20)[:20] +NETWORK_ID = bytes_4(20840) +PEER = ("82.65.127.86", 62201) +SERVER_ID = 21310 + +# Subscribers the ACLs deny, so both scopes can be told apart in the recording. +DENIED_BY_GLOBAL = 2130002 +DENIED_BY_SYSTEM = 2130001 +ALLOWED = 2130003 + +if not hasattr(logging.Logger, "trace"): # the server installs TRACE with the log config + logging.addLevelName(5, "TRACE") + logging.Logger.trace = functools.partialmethod(logging.Logger.log, 5) # type: ignore[attr-defined] + + +class _Recorder(logging.Handler): + def __init__(self) -> None: + super().__init__(level=1) + self.lines: list[tuple[int, str]] = [] + + def emit(self, record: logging.LogRecord) -> None: + try: + text = record.getMessage() + except TypeError as exc: # a message and its arguments that do not match + text = f"" + self.lines.append((record.levelno, text)) + + +class _Transport: + def __init__(self) -> None: + self.sent: list[tuple[int, tuple[str, int]]] = [] + + def write(self, data: bytes, addr: tuple[str, int]) -> None: + self.sent.append((len(data), addr)) + + +def build_config(case: dict[str, Any]) -> dict[str, Any]: + """The server config one case runs against.""" + system = { + "MODE": "OPENBRIDGE", + "ENABLED": True, + "NETWORK_ID": NETWORK_ID, + "PASSPHRASE": PASSPHRASE, + "TARGET_IP": PEER[0], + "TARGET_PORT": PEER[1], + "TARGET_SOCK": PEER, + "RELAX_CHECKS": case.get("relax", True), + "VER": 5 if case["kind"] == "v5" else 1, + "ENHANCED_OBP": True, + "USE_ACL": case.get("system_acl", False), + "SUB_ACL": (False, [(DENIED_BY_SYSTEM, DENIED_BY_SYSTEM)]), + "TG1_ACL": (False, [(777, 777)]), + } + config: dict[str, Any] = { + "GLOBAL": { + "SERVER_ID": bytes_4(SERVER_ID), + "USE_ACL": case.get("global_acl", False), + "SUB_ACL": (False, [(DENIED_BY_GLOBAL, DENIED_BY_GLOBAL)]), + "TG1_ACL": (False, [(778, 778)]), + "VALIDATE_SERVER_IDS": case.get("validate_server_ids", False), + "PING_TIME": 10, + }, + "SYSTEMS": {"OBP-FR": system}, + "_SERVER_IDS": {"2084"}, + "_SUB_IDS": {DENIED_BY_SYSTEM: "C31AG", DENIED_BY_GLOBAL: "C31AG"}, + "_PEER_IDS": {}, + "_LOCAL_SUBSCRIBER_IDS": {}, + } + if case.get("stun"): + config["STUN"] = True + return config + + +def _voice_body(case: dict[str, Any]) -> bytes: + return b"".join( + [ + DMRD, + bytes([1]), + bytes_3(case.get("src", ALLOWED)), + bytes_3(case.get("dst", 214)), + NETWORK_ID, + bytes([case.get("bits", 0x00)]), + bytes_4(case.get("stream", 0xAABBCCDD)), + b"\x00" * 33, + ] + ) + + +def build_packet(case: dict[str, Any], *, now: float | None = None) -> bytes: + """The datagram a case puts on the wire, valid MAC included.""" + kind = case["kind"] + if kind == "v1": + return build_dmrd_v1(_voice_body(case), NETWORK_ID, PASSPHRASE) + if kind == "v5": + now = time.time() if now is None else now + packet = build_dmre( + _voice_body(case), + server_id=NETWORK_ID, + ber=b"\x00", + rssi=b"\x00", + embedded_ver=5, + timestamp_ns=int((now - case.get("age", 0.0)) * 1_000_000_000), + source_server=bytes_4(case.get("source_server", 2084)), + source_rptr=bytes_4(0), + hops=case.get("hops", 0).to_bytes(1, "big"), + passphrase=PASSPHRASE, + extended_layout=True, + ) + assert packet is not None + return packet + if kind == "bcka": + return build_bcka(PASSPHRASE) + if kind == "bcsq": + return build_bcsq(bytes_3(case.get("dst", 214)), bytes_4(case.get("stream", 1)), PASSPHRASE) + if kind == "bcst": + return BCST + obp_hmac_sha1(PASSPHRASE, BCST) + raise ValueError(f"unknown case kind: {kind}") + + +def observe(case: dict[str, Any], protocol_cls: type = HBPProtocol) -> dict[str, Any]: + """Run one case and record everything observable from outside the bridge.""" + delivered: list[tuple[int, int]] = [] + quenched: list[tuple[int, int]] = [] + recorder = _Recorder() + root = logging.getLogger("adn_server") + root.addHandler(recorder) + previous_level = root.level + root.setLevel(1) + transport = _Transport() + try: + packet = build_packet(case) + protocol = protocol_cls( + "OBP-FR", + build_config(case), + router=InMemoryAclRouter(), + dmrd_received=lambda *a, **k: delivered.append((int.from_bytes(a[2], "big"), int.from_bytes(a[3], "big"))), + ) + protocol._obp_send_bcsq = lambda tgid, stream: quenched.append( # type: ignore[assignment] + (int.from_bytes(tgid, "big"), int.from_bytes(stream, "big")) + ) + protocol._obp_send_bcve = lambda: None # type: ignore[assignment] + protocol.transport = transport # type: ignore[assignment] + protocol.startProtocol() + transport.sent.clear() + protocol._obp_datagram_received(packet, tuple(case.get("from", PEER))) + # Where egress goes now is what the peer address is for, and a stunned + # bridge must stop sending: probe both after every case. + protocol_cls._obp_send_bcka(protocol) + protocol.send_system(_voice_body({"src": ALLOWED, "dst": 214})[:53]) + finally: + root.removeHandler(recorder) + root.setLevel(previous_level) + return { + "delivered": delivered, + "quenched": quenched, + "egress": [[size, list(addr)] for size, addr in transport.sent], + "log": [[level, text] for level, text in recorder.lines], + } + + +def _cases() -> list[dict[str, Any]]: + cases: list[dict[str, Any]] = [] + stream = 0x51000000 + + def add(**case: Any) -> None: + nonlocal stream + stream += 1 + case.setdefault("stream", stream) + case["name"] = "{kind} {desc}".format(**case) + cases.append(case) + + # DMRD v1: the talkgroup filter, the bits byte and both ACL scopes. + for dst in (1, 9, 79, 80, 92, 199, 200, 214, 777, 778, 9989, 9990, 9999, 900999): + add(kind="v1", desc=f"tg {dst}", dst=dst) + for bits in (0x00, 0x40, 0x23, 0x80, 0xE3, 0x16): + add(kind="v1", desc=f"bits {bits:#04x}", bits=bits) + add(kind="v1", desc=f"bits {bits:#04x} on a local tg", bits=bits, dst=9) + for src, scope in ((DENIED_BY_GLOBAL, "global"), (DENIED_BY_SYSTEM, "system"), (ALLOWED, "allowed")): + for global_acl, system_acl in ((True, False), (False, True), (True, True)): + add( + kind="v1", + desc=f"{scope} subscriber, acl g={global_acl} s={system_acl}", + src=src, + global_acl=global_acl, + system_acl=system_acl, + ) + for dst in (777, 778): + add(kind="v1", desc=f"denied tg {dst}", dst=dst, global_acl=True, system_acl=True) + add(kind="v1", desc="stunned by the operator", stun=True) + add(kind="v1", desc="from an unexpected address", **{"from": ["9.9.9.9", 40000]}) + add(kind="v1", desc="from an unexpected address, no relax", relax=False, **{"from": ["9.9.9.9", 40000]}) + + # DMRE v5: the envelope (age, hops, source server) on top of the same filters. + for dst in (1, 9, 79, 85, 92, 100, 199, 214, 850, 9990, 900999): + add(kind="v5", desc=f"tg {dst}", dst=dst) + for source_server in (123, 2084, 2131, 21310, 2130001): + add(kind="v5", desc=f"source server {source_server}", source_server=source_server) + add( + kind="v5", + desc=f"source server {source_server}, validated", + source_server=source_server, + validate_server_ids=True, + ) + for dst, source_server in ((100, 20840), (100, 21310), (85, 20851), (850, 21310)): + add(kind="v5", desc=f"tg {dst} from server {source_server}", dst=dst, source_server=source_server) + for hops in (0, 8, 9, 10, 20): + add(kind="v5", desc=f"{hops} hops", hops=hops) + for age in (0.0, 4.0, 6.0, 60.0): + add(kind="v5", desc=f"{age}s old", age=age) + for src, scope in ((DENIED_BY_GLOBAL, "global"), (DENIED_BY_SYSTEM, "system")): + add(kind="v5", desc=f"{scope} subscriber", src=src, global_acl=True, system_acl=True) + add(kind="v5", desc="stunned by the operator", stun=True) + add(kind="v5", desc="from an unexpected address", **{"from": ["9.9.9.9", 40000]}) + + # Control frames: where do they leave the egress afterwards? + for kind, addr in itertools.product( + ("bcka", "bcsq", "bcst"), + (PEER, ("82.65.127.86", 40000), ("9.9.9.9", 62201)), + ): + for relax in (False, True): + add(kind=kind, desc=f"from {addr[0]}:{addr[1]} relax={relax}", relax=relax, **{"from": list(addr)}) + return cases + + +CASES: list[dict[str, Any]] = _cases() + + +def capture_enabled() -> bool: + return os.environ.get("CAPTURE", "").strip().lower() in ("1", "true", "yes") + + +def record(path: Path = FIXTURE) -> None: + """Rewrite the fixture from what this tree does right now.""" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as fh: + for case in CASES: + row = {"case": case, "effects": observe(copy.deepcopy(case))} + fh.write(json.dumps(row, separators=(",", ":"), sort_keys=True) + "\n") + + +def load(path: Path = FIXTURE) -> list[dict[str, Any]]: + with path.open(encoding="utf-8") as fh: + return [json.loads(line) for line in fh if line.strip()] + + +def as_json(effects: dict[str, Any]) -> dict[str, Any]: + """Round-trip through JSON so recorded and observed compare as equals.""" + return json.loads(json.dumps(effects)) + + +__all__ = [ + "CASES", + "FIXTURE", + "as_json", + "build_config", + "build_packet", + "capture_enabled", + "load", + "observe", + "record", +] diff --git a/tests/infrastructure/test_obp_ingress_effects.py b/tests/infrastructure/test_obp_ingress_effects.py new file mode 100644 index 0000000..d3946c7 --- /dev/null +++ b/tests/infrastructure/test_obp_ingress_effects.py @@ -0,0 +1,52 @@ +# ADN DMR Peer Server - tests infrastructure obp ingress effects +# +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# +############################################################################### +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +############################################################################### + +"""The OBP ingress contract, frame by frame, against a recorded corpus. + +Every case is a real datagram with a valid MAC. What it produces — delivery to +routing, source quench, the address egress leaves from afterwards and the log +lines — was recorded from the handler as it behaved before the refactor and +checked against upstream ``develop``. A diff here is a behaviour change, so +read it before regenerating with ``CAPTURE=1``. +""" + +from __future__ import annotations + +import pytest +from tests.harness.obp_ingress import CASES, FIXTURE, as_json, capture_enabled, load, observe, record + + +@pytest.fixture(scope="module") +def recorded() -> dict[str, dict]: + if capture_enabled(): + record() + if not FIXTURE.exists(): + pytest.skip(f"no corpus at {FIXTURE}; regenerate with CAPTURE=1") + return {row["case"]["name"]: row for row in load()} + + +def test_corpus_covers_every_case(recorded: dict[str, dict]) -> None: + assert set(recorded) == {case["name"] for case in CASES} + + +@pytest.mark.parametrize("case", CASES, ids=lambda c: c["name"]) +def test_ingress_effects_match_the_recording(case: dict, recorded: dict[str, dict]) -> None: + expected = recorded[case["name"]]["effects"] + assert as_json(observe(case)) == expected