refactor(obp): drive OpenBridge ingress from an engine that answers with effects

Phase 3, and the end of the hblink shape in this path. Phase 1 lifted the
admission rules out of the adapter, phase 2 took the state they read; what was
left in ``_obp_datagram_received`` was the plumbing between them — two long
branches that decoded, decided, logged, quenched and called into routing, all
inside a Twisted ``DatagramProtocol`` where none of it could be run on its own.

``domain/mesh_engine.py`` now takes a decoded frame, the link's session and a
``BridgePolicy``, and answers with a list of effects: ``Reject``, ``Log``,
``NoteStream``, ``StoreTalkerAlias``, ``Deliver``, ``RequestVersion``. It reads
no configuration, opens no socket and calls no logger. The adapter keeps the
three things that are genuinely I/O — verify the MAC, build the policy, carry
out the effects in order — and the handler goes from ~180 lines of nested
branches to ~45 of dispatch.

Two things this buys beyond the shape. A datagram can be replayed through the
engine anywhere: a test, a laptop, a capture from a sysop, with no reactor in
sight. And every refusal carries a ``reason``, now tallied per bridge in the
session and printed by the keepalive loop at debug level — "my call does not
cross" is answered by ``tg-filter-mcc=12, system-sub-acl=3`` instead of by
grepping the log.

No behaviour change intended, and this time checked two ways. The differential
harness ran 9594 frames through this tree and through upstream ``develop``:
identical delivery, quench, egress address and log lines. And the corpus in
``tests/fixtures/obp_ingress_effects.jsonl`` — 96 recorded cases covering the
talkgroup filters, both ACL scopes, the bits byte, the DMRE envelope (age,
hops, source server) and BCKA/BCSQ/BCST from three addresses — was recorded
from the pre-engine handler, verified frame by frame against develop, and still
passes untouched. It stays as the contract for whatever comes next; regenerate
with CAPTURE=1 and read the diff.

Tests: 24 new unit tests for the engine (100% of the module), the recorded
corpus as a regression net, 998 passed, 2 skipped (the 2 failures are this
machine's and fail on develop too).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pull/81/head
yo 1 week ago
parent 05915a8207
commit e105d493f0

@ -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())),
)

@ -0,0 +1,376 @@
# ADN DMR Peer Server - domain mesh engine
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# 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",
]

@ -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:

@ -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:

@ -0,0 +1,277 @@
# ADN DMR Peer Server - tests domain mesh engine
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# 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

@ -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}

@ -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":[]}}

@ -0,0 +1,317 @@
# ADN DMR Peer Server - tests harness obp ingress corpus
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# 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"<BROKEN LOG CALL: {exc}>"
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",
]

@ -0,0 +1,52 @@
# ADN DMR Peer Server - tests infrastructure obp ingress effects
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# 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
Loading…
Cancel
Save

Powered by TurnKey Linux.