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
parent
05915a8207
commit
e105d493f0
@ -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",
|
||||
]
|
||||
@ -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
|
||||
@ -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…
Reference in new issue