Merge pull request #81 from pyopower/refactor/obp-admission-policy
refactor(obp): separate the OpenBridge ingress from its hblink port (admission, session, engine, replay)pull/82/head
commit
076c200d5c
@ -0,0 +1,418 @@
|
||||
# ADN DMR Peer Server - domain mesh admission
|
||||
#
|
||||
# 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
|
||||
###############################################################################
|
||||
|
||||
"""Admission rules for OpenBridge ingress, as pure decisions.
|
||||
|
||||
Every rule answers one question — may this frame continue? — and returns a
|
||||
``Rejection`` describing what to log and whether to quench the peer, or ``None``
|
||||
to let the frame through. Nothing here touches sockets, the reactor, the clock
|
||||
or the live SYSTEMS config: the caller passes what the rule needs and applies
|
||||
the outcome. That is what makes the gauntlet testable with plain values, and
|
||||
what lets a later engine reuse the same decisions without the Twisted adapter.
|
||||
|
||||
The wire formats live in ``infrastructure.mesh`` (obp_v1, dmre_v5); this module
|
||||
only decides what to do with a frame once it has been decoded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .value_objects import int_id
|
||||
|
||||
# Talkgroups a mesh peer must never hand us: hblink's hard-coded table, kept as
|
||||
# named constants so the ranges can be read (and one day configured) on sight.
|
||||
TG_LOCAL_TO_REPEATER_MAX = 79
|
||||
TG_LOCAL_TO_SERVER = (9990, 9999)
|
||||
TG_LOCAL_TO_SERVER_MAIN = (92, 199)
|
||||
TG_LOCAL_TO_MCC = ((80, 89), (800, 899))
|
||||
TG_DATA_GATEWAY = 900999
|
||||
|
||||
MAX_HOPS = 10
|
||||
MAX_PACKET_AGE_S = 5.0
|
||||
|
||||
_AclCheck = Callable[[bytes, Any], bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CallAttributes:
|
||||
"""Slot and call classification carried by the DMRD bits byte."""
|
||||
|
||||
slot: int
|
||||
call_type: str
|
||||
frame_type: int
|
||||
dtype_vseq: int
|
||||
|
||||
|
||||
def call_attributes(bits: int) -> CallAttributes:
|
||||
"""Decode the bits byte of a DMRD/DMRE frame.
|
||||
|
||||
``vcsbk`` (a CSBK preamble) shares the data-sync pattern with voice, so it is
|
||||
recognised before the group fallback — mis-classifying it is what turns a
|
||||
private data preamble into a dynamic talkgroup on the far side.
|
||||
"""
|
||||
if bits & 0x40:
|
||||
call_type = "unit"
|
||||
elif (bits & 0x23) == 0x23:
|
||||
call_type = "vcsbk"
|
||||
else:
|
||||
call_type = "group"
|
||||
return CallAttributes(
|
||||
slot=2 if (bits & 0x80) else 1,
|
||||
call_type=call_type,
|
||||
frame_type=(bits & 0x30) >> 4,
|
||||
dtype_vseq=bits & 0xF,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ObpFrame:
|
||||
"""The identity of one ingress frame, as the admission rules see it."""
|
||||
|
||||
system: str
|
||||
stream_id: bytes
|
||||
rf_src: bytes
|
||||
dst_id: bytes
|
||||
slot: int
|
||||
call_type: str
|
||||
|
||||
@property
|
||||
def dst(self) -> int:
|
||||
return int(int_id(self.dst_id))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Rejection:
|
||||
"""Why a frame was dropped, and what the caller should do about it.
|
||||
|
||||
``message``/``args`` are kept apart so the caller can hand them straight to
|
||||
``logger.log`` and keep lazy formatting. ``reason`` is the stable handle: log
|
||||
text may be reworded, ``reason`` is what metrics and tests match on.
|
||||
"""
|
||||
|
||||
reason: str
|
||||
message: str
|
||||
args: tuple[Any, ...] = ()
|
||||
level: int = logging.INFO
|
||||
quench: bool = True
|
||||
log_once: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AclRules:
|
||||
"""One ACL scope (GLOBAL or the system's own)."""
|
||||
|
||||
enabled: bool = False
|
||||
sub_acl: Any = (True, [])
|
||||
tg1_acl: Any = (True, [])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MeshEnvelope:
|
||||
"""DMRE v5 envelope fields the admission rules look at.
|
||||
|
||||
``source_server_id`` is the same value as ``source_server``, kept in its wire
|
||||
form because the alias lookup that validates it takes bytes.
|
||||
"""
|
||||
|
||||
source_server: int
|
||||
hops: int
|
||||
timestamp_ns: int = 0
|
||||
source_server_id: bytes = b""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdmissionContext:
|
||||
"""Everything outside the frame that the rules depend on."""
|
||||
|
||||
stunned: bool = False
|
||||
acl_check: _AclCheck | None = None
|
||||
global_rules: AclRules = field(default_factory=AclRules)
|
||||
system_rules: AclRules = field(default_factory=AclRules)
|
||||
server_id: int = 0
|
||||
validate_server_ids: bool = False
|
||||
known_server_prefixes: Iterable[str] = ()
|
||||
resolve_server_id: Callable[[bytes], Any] | None = None # alias lookup, bytes in
|
||||
|
||||
|
||||
def server_prefix(server_id: Any) -> int:
|
||||
"""First four digits of our SERVER_ID, whether it is stored as int or bytes."""
|
||||
if isinstance(server_id, bytes):
|
||||
server_id = int.from_bytes(server_id, "big")
|
||||
try:
|
||||
return int(str(int(server_id))[:4])
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def check_network_id(
|
||||
system: str,
|
||||
stream_id: bytes,
|
||||
*,
|
||||
expected: bytes,
|
||||
received: bytes,
|
||||
dmre: bool = False,
|
||||
) -> Rejection | None:
|
||||
"""The peer must send the NETWORK_ID we have configured for this bridge."""
|
||||
if expected == received:
|
||||
return None
|
||||
label = "OpenBridge DMRE discarded" if dmre else "OpenBridge packet discarded"
|
||||
return Rejection(
|
||||
reason="network-id-mismatch",
|
||||
message="(%s) " + label + " because NETWORK_ID: %s Does not match sent Peer ID: %s",
|
||||
args=(system, int_id(expected or b""), int_id(received)),
|
||||
level=logging.ERROR,
|
||||
quench=False,
|
||||
)
|
||||
|
||||
|
||||
def check_slot(frame: ObpFrame) -> Rejection | None:
|
||||
"""DMRD v1 over OpenBridge is TS1 only."""
|
||||
if frame.slot == 1:
|
||||
return None
|
||||
return Rejection(
|
||||
reason="not-slot-1",
|
||||
message="(%s) OpenBridge packet discarded because it was not received on slot 1. SID: %s, TGID %s",
|
||||
args=(frame.system, int_id(frame.rf_src), int_id(frame.dst_id)),
|
||||
level=logging.ERROR,
|
||||
quench=False,
|
||||
log_once=False,
|
||||
)
|
||||
|
||||
|
||||
def check_stun(frame: ObpFrame, *, stunned: bool) -> Rejection | None:
|
||||
"""A STUNned bridge accepts nothing until the operator lifts it."""
|
||||
if not stunned:
|
||||
return None
|
||||
return Rejection(
|
||||
reason="stunned",
|
||||
message="(%s) Bridge STUNned, discarding",
|
||||
args=(frame.system,),
|
||||
level=logging.WARNING,
|
||||
quench=False,
|
||||
)
|
||||
|
||||
|
||||
def check_packet_age(frame: ObpFrame, envelope: MeshEnvelope, *, now: float) -> Rejection | None:
|
||||
"""DMRE carries a timestamp; a late frame is a replay or a stalled path."""
|
||||
if envelope.timestamp_ns / 1_000_000_000 >= (now - MAX_PACKET_AGE_S):
|
||||
return None
|
||||
return Rejection(
|
||||
reason="stale-packet",
|
||||
message="(%s) Packet from server %s more than 5s old!, discarding",
|
||||
args=(frame.system, envelope.source_server),
|
||||
level=logging.WARNING,
|
||||
)
|
||||
|
||||
|
||||
def check_source_server(
|
||||
frame: ObpFrame,
|
||||
envelope: MeshEnvelope,
|
||||
ctx: AdmissionContext,
|
||||
) -> Rejection | None:
|
||||
"""A DMRE source server is a 4-7 digit ID, known to us or a valid DMR ID."""
|
||||
digits = str(envelope.source_server)
|
||||
if len(digits) < 4 or len(digits) > 7:
|
||||
return Rejection(
|
||||
reason="source-server-length",
|
||||
message="(%s) Source Server should be between 4 and 7 digits, discarding Src: %s",
|
||||
args=(frame.system, envelope.source_server),
|
||||
level=logging.WARNING,
|
||||
)
|
||||
if ctx.validate_server_ids and len(digits) in (4, 5) and digits[:4] not in ctx.known_server_prefixes:
|
||||
return Rejection(
|
||||
reason="source-server-unknown",
|
||||
message="(%s) Source Server ID is 4 or 5 digits but not in list: %s",
|
||||
args=(frame.system, envelope.source_server),
|
||||
level=logging.WARNING,
|
||||
)
|
||||
if len(digits) > 5 and ctx.resolve_server_id is not None:
|
||||
if not ctx.resolve_server_id(envelope.source_server_id):
|
||||
return Rejection(
|
||||
reason="source-server-invalid",
|
||||
message="(%s) Source Server 6 or 7 digits but not a valid DMR ID, discarding Src: %s",
|
||||
args=(frame.system, envelope.source_server),
|
||||
level=logging.WARNING,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def check_hops(frame: ObpFrame, envelope: MeshEnvelope) -> Rejection | None:
|
||||
"""Every mesh hop bumps the counter; past MAX_HOPS the frame is looping."""
|
||||
hops = envelope.hops + 1
|
||||
if hops <= MAX_HOPS:
|
||||
return None
|
||||
return Rejection(
|
||||
reason="max-hops",
|
||||
message="(%s) MAX HOPS exceed, dropping. Hops: %s, DST: %s, SRC: %s",
|
||||
args=(frame.system, hops, frame.dst, envelope.source_server),
|
||||
level=logging.DEBUG,
|
||||
log_once=False,
|
||||
)
|
||||
|
||||
|
||||
def _tg_filter(frame: ObpFrame, reason: str, scope: str) -> Rejection:
|
||||
return Rejection(
|
||||
reason=reason,
|
||||
message="(%s) CALL DROPPED WITH STREAM ID %s ON TG %s BY GLOBAL TG FILTER (%s)",
|
||||
args=(frame.system, int_id(frame.stream_id), frame.dst, scope),
|
||||
)
|
||||
|
||||
|
||||
def check_tg_filter_v1(frame: ObpFrame) -> Rejection | None:
|
||||
"""DMRD v1 filter: one rule for every talkgroup that must stay local."""
|
||||
if frame.call_type == "unit":
|
||||
return None
|
||||
dst = frame.dst
|
||||
if (
|
||||
dst <= TG_LOCAL_TO_REPEATER_MAX
|
||||
or TG_LOCAL_TO_SERVER[0] <= dst <= TG_LOCAL_TO_SERVER[1]
|
||||
or TG_LOCAL_TO_SERVER_MAIN[0] <= dst <= TG_LOCAL_TO_SERVER_MAIN[1]
|
||||
or dst == TG_DATA_GATEWAY
|
||||
):
|
||||
return Rejection(
|
||||
reason="tg-filter",
|
||||
message="(%s) CALL DROPPED WITH STREAM ID %s FROM SUBSCRIBER %s BY GLOBAL TG FILTER",
|
||||
args=(frame.system, int_id(frame.stream_id), dst),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def check_tg_filter_v5(
|
||||
frame: ObpFrame,
|
||||
envelope: MeshEnvelope,
|
||||
ctx: AdmissionContext,
|
||||
) -> Rejection | None:
|
||||
"""DMRE v5 filter: same idea, but the last two rules depend on who sent it.
|
||||
|
||||
A talkgroup local to a server, or to an MCC, is only refused when the source
|
||||
server is *not* part of that server or that MCC.
|
||||
"""
|
||||
if frame.call_type == "unit":
|
||||
return None
|
||||
dst = frame.dst
|
||||
if dst <= TG_LOCAL_TO_REPEATER_MAX:
|
||||
return _tg_filter(frame, "tg-filter-repeater", "local to repeater")
|
||||
if TG_LOCAL_TO_SERVER[0] <= dst <= TG_LOCAL_TO_SERVER[1] or dst == TG_DATA_GATEWAY:
|
||||
return _tg_filter(frame, "tg-filter-server", "local to server")
|
||||
source = str(envelope.source_server)
|
||||
if TG_LOCAL_TO_SERVER_MAIN[0] <= dst <= TG_LOCAL_TO_SERVER_MAIN[1]:
|
||||
if int(source[:4]) != ctx.server_id:
|
||||
return _tg_filter(frame, "tg-filter-server-main", "local to server main ID")
|
||||
return None
|
||||
for low, high in TG_LOCAL_TO_MCC:
|
||||
if low <= dst <= high and int(source[:3]) != int(str(ctx.server_id)[:3]):
|
||||
return _tg_filter(frame, "tg-filter-mcc", "local to MCC")
|
||||
return None
|
||||
|
||||
|
||||
def check_acl_chain(frame: ObpFrame, ctx: AdmissionContext) -> Rejection | None:
|
||||
"""Subscriber and talkgroup ACLs, GLOBAL first and then the system's own."""
|
||||
acl_check = ctx.acl_check
|
||||
if acl_check is None:
|
||||
return None
|
||||
if ctx.global_rules.enabled:
|
||||
if not acl_check(frame.rf_src, ctx.global_rules.sub_acl):
|
||||
return Rejection(
|
||||
reason="global-sub-acl",
|
||||
message="(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY GLOBAL TS1 ACL",
|
||||
args=(frame.system, int_id(frame.stream_id), int_id(frame.rf_src)),
|
||||
)
|
||||
if frame.slot == 1 and not acl_check(frame.dst_id, ctx.global_rules.tg1_acl):
|
||||
return Rejection(
|
||||
reason="global-tg1-acl",
|
||||
message="(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY GLOBAL TS1 ACL",
|
||||
args=(frame.system, int_id(frame.stream_id), int_id(frame.dst_id)),
|
||||
)
|
||||
if ctx.system_rules.enabled:
|
||||
if not acl_check(frame.rf_src, ctx.system_rules.sub_acl):
|
||||
return Rejection(
|
||||
reason="system-sub-acl",
|
||||
message="(%s) CALL DROPPED WITH STREAM ID %s FROM SUBSCRIBER %s BY SYSTEM ACL",
|
||||
args=(frame.system, int_id(frame.stream_id), int_id(frame.rf_src)),
|
||||
)
|
||||
if not acl_check(frame.dst_id, ctx.system_rules.tg1_acl):
|
||||
return Rejection(
|
||||
reason="system-tg1-acl",
|
||||
message="(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY SYSTEM ACL",
|
||||
args=(frame.system, int_id(frame.stream_id), int_id(frame.dst_id)),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def admit_dmrd_v1(frame: ObpFrame, ctx: AdmissionContext) -> Rejection | None:
|
||||
"""Full DMRD v1 gauntlet, in the order the legacy handler applied it."""
|
||||
return (
|
||||
check_slot(frame)
|
||||
or check_stun(frame, stunned=ctx.stunned)
|
||||
or check_tg_filter_v1(frame)
|
||||
or check_acl_chain(frame, ctx)
|
||||
)
|
||||
|
||||
|
||||
def admit_dmre_v5(
|
||||
frame: ObpFrame,
|
||||
envelope: MeshEnvelope,
|
||||
ctx: AdmissionContext,
|
||||
*,
|
||||
now: float,
|
||||
) -> Rejection | None:
|
||||
"""Full DMRE v5 gauntlet, in the order the legacy handler applied it."""
|
||||
return (
|
||||
check_stun(frame, stunned=ctx.stunned)
|
||||
or check_packet_age(frame, envelope, now=now)
|
||||
or check_source_server(frame, envelope, ctx)
|
||||
or check_hops(frame, envelope)
|
||||
or check_tg_filter_v5(frame, envelope, ctx)
|
||||
or check_acl_chain(frame, ctx)
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AclRules",
|
||||
"AdmissionContext",
|
||||
"CallAttributes",
|
||||
"MAX_HOPS",
|
||||
"MAX_PACKET_AGE_S",
|
||||
"MeshEnvelope",
|
||||
"ObpFrame",
|
||||
"Rejection",
|
||||
"TG_DATA_GATEWAY",
|
||||
"TG_LOCAL_TO_MCC",
|
||||
"TG_LOCAL_TO_REPEATER_MAX",
|
||||
"TG_LOCAL_TO_SERVER",
|
||||
"TG_LOCAL_TO_SERVER_MAIN",
|
||||
"admit_dmrd_v1",
|
||||
"admit_dmre_v5",
|
||||
"call_attributes",
|
||||
"check_acl_chain",
|
||||
"check_hops",
|
||||
"check_network_id",
|
||||
"check_packet_age",
|
||||
"check_slot",
|
||||
"check_source_server",
|
||||
"check_stun",
|
||||
"check_tg_filter_v1",
|
||||
"check_tg_filter_v5",
|
||||
"server_prefix",
|
||||
]
|
||||
@ -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,259 @@
|
||||
# ADN DMR Peer Server - domain mesh session
|
||||
#
|
||||
# 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
|
||||
###############################################################################
|
||||
|
||||
"""Live state of an OpenBridge link, kept out of the configuration.
|
||||
|
||||
Legacy hblink wrote what a bridge learns at runtime straight into its ``SYSTEMS``
|
||||
block: ``_bcka`` for the last keepalive, ``_bcsq`` for the peer's quench table,
|
||||
and ``TARGET_IP``/``TARGET_PORT``/``TARGET_SOCK`` rewritten in place whenever
|
||||
``RELAX_CHECKS`` accepted a datagram from an unexpected address. Configuration
|
||||
and session state shared one mutable dict, so what the operator wrote in the
|
||||
YAML could be overwritten by whatever arrived on the wire, and no reader could
|
||||
tell the two apart.
|
||||
|
||||
Here they are separate: ``configured_peer`` is what the YAML says and never
|
||||
moves, ``learned_peer`` is what the wire says, and every reader asks the
|
||||
question it actually means — "has a keepalive ever arrived?", "is it stale?",
|
||||
"is this stream quenched?" — instead of poking at dict keys.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .value_objects import bytes_3, int_id
|
||||
|
||||
KEEPALIVE_TIMEOUT_S = 60.0
|
||||
|
||||
|
||||
def _peer_from_config(sys_cfg: dict[str, Any] | None) -> tuple[str | None, int]:
|
||||
"""The peer as configured: TARGET_SOCK when normalized, else TARGET_IP/PORT."""
|
||||
cfg = sys_cfg or {}
|
||||
sock = cfg.get("TARGET_SOCK")
|
||||
if isinstance(sock, tuple) and len(sock) == 2:
|
||||
host, port = sock
|
||||
else:
|
||||
host, port = cfg.get("TARGET_IP"), cfg.get("TARGET_PORT", 62044)
|
||||
try:
|
||||
port = int(port)
|
||||
except (TypeError, ValueError):
|
||||
port = 62044
|
||||
return (str(host) if host else None, port)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ObpBridgeSession:
|
||||
"""What one OpenBridge link knows about its peer right now."""
|
||||
|
||||
system_name: str
|
||||
configured_peer: tuple[str | None, int] = (None, 62044)
|
||||
learned_peer: tuple[str, int] | None = None
|
||||
learned_at: float = 0.0
|
||||
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 --------------------------------------------------------
|
||||
|
||||
@property
|
||||
def peer(self) -> tuple[str | None, int]:
|
||||
"""Where to send: what the wire taught us, else what the YAML says."""
|
||||
return self.learned_peer or self.configured_peer
|
||||
|
||||
@property
|
||||
def peer_known(self) -> bool:
|
||||
return bool(self.peer[0])
|
||||
|
||||
def learn_peer(self, addr: tuple[str, int], *, at: float) -> bool:
|
||||
"""Remember the address a datagram really came from. True when it moved."""
|
||||
if not addr or not addr[0]:
|
||||
return False
|
||||
host, port = str(addr[0]), int(addr[1])
|
||||
if self.peer == (host, port):
|
||||
return False
|
||||
self.learned_peer = (host, port)
|
||||
self.learned_at = at
|
||||
return True
|
||||
|
||||
def forget_learned_peer(self) -> None:
|
||||
"""Drop what the wire taught us and fall back to the configured peer."""
|
||||
self.learned_peer = None
|
||||
self.learned_at = 0.0
|
||||
|
||||
# --- keepalive -----------------------------------------------------------
|
||||
|
||||
def note_keepalive(self, at: float) -> None:
|
||||
self.last_keepalive = at
|
||||
|
||||
@property
|
||||
def keepalive_seen(self) -> bool:
|
||||
"""False until the first keepalive (or the seed at startup)."""
|
||||
return self.last_keepalive is not None
|
||||
|
||||
def keepalive_age(self, now: float) -> float | None:
|
||||
if self.last_keepalive is None:
|
||||
return None
|
||||
return now - self.last_keepalive
|
||||
|
||||
def keepalive_stale(self, now: float, *, timeout: float = KEEPALIVE_TIMEOUT_S) -> bool:
|
||||
"""True when a keepalive was expected by now and has not arrived."""
|
||||
if self.last_keepalive is None:
|
||||
return False
|
||||
return self.last_keepalive < now - timeout
|
||||
|
||||
def keepalive_ok(self, now: float, *, timeout: float = KEEPALIVE_TIMEOUT_S) -> bool:
|
||||
return self.keepalive_seen and not self.keepalive_stale(now, timeout=timeout)
|
||||
|
||||
# --- source quench -------------------------------------------------------
|
||||
|
||||
def quench(self, tgid: bytes, stream_id: bytes) -> None:
|
||||
"""The peer asked us to stop sending this stream on this talkgroup."""
|
||||
self.quenched[tgid] = stream_id
|
||||
|
||||
def quenches(self, dst_id: Any, stream_id: bytes) -> bool:
|
||||
"""True when the peer quenched this stream for this talkgroup.
|
||||
|
||||
Talkgroups arrive as 3-byte ids here and as 4-byte ids elsewhere, so a
|
||||
direct hit is tried first and the rest are compared numerically.
|
||||
"""
|
||||
if not self.quenched:
|
||||
return False
|
||||
tid = dst_id[:3] if isinstance(dst_id, bytes) and len(dst_id) >= 3 else bytes_3(int_id(dst_id))
|
||||
if self.quenched.get(tid) == stream_id:
|
||||
return True
|
||||
for key, value in self.quenched.items():
|
||||
if value != stream_id:
|
||||
continue
|
||||
try:
|
||||
if isinstance(key, bytes) and len(key) >= 3 and int_id(key) == int_id(tid):
|
||||
return True
|
||||
except Exception:
|
||||
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:
|
||||
"""The peer asked this bridge to stop sending (BCST)."""
|
||||
self.stunned = True
|
||||
|
||||
def release_stream(self, stream_id: bytes) -> None:
|
||||
"""Forget the quench entries of a stream that is over."""
|
||||
for tgid, value in list(self.quenched.items()):
|
||||
if value == stream_id:
|
||||
self.quenched.pop(tgid, None)
|
||||
|
||||
|
||||
class MeshSessionStore:
|
||||
"""The live sessions, one per OPENBRIDGE system, by system name."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._sessions: dict[str, ObpBridgeSession] = {}
|
||||
|
||||
def __contains__(self, system_name: str) -> bool:
|
||||
return system_name in self._sessions
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._sessions)
|
||||
|
||||
def get(self, system_name: str) -> ObpBridgeSession | None:
|
||||
return self._sessions.get(system_name)
|
||||
|
||||
def session(self, system_name: str, sys_cfg: dict[str, Any] | None = None) -> ObpBridgeSession:
|
||||
"""The session for this system, created from its config on first use."""
|
||||
session = self._sessions.get(system_name)
|
||||
if session is None:
|
||||
session = ObpBridgeSession(
|
||||
system_name=system_name,
|
||||
configured_peer=_peer_from_config(sys_cfg),
|
||||
)
|
||||
self._sessions[system_name] = session
|
||||
elif sys_cfg is not None:
|
||||
session.configured_peer = _peer_from_config(sys_cfg)
|
||||
return session
|
||||
|
||||
def drop(self, system_name: str) -> None:
|
||||
self._sessions.pop(system_name, None)
|
||||
|
||||
def sync(self, config: dict[str, Any]) -> None:
|
||||
"""Follow a config (re)load: refresh configured peers, forget dead links.
|
||||
|
||||
A bridge that is still there keeps what it has learned; the address the
|
||||
operator edited in the YAML wins again only where it is now different,
|
||||
which is what makes a reload a way out of a bad learned address.
|
||||
"""
|
||||
systems = config.get("SYSTEMS", {})
|
||||
live: set[str] = set()
|
||||
for name, sys_cfg in systems.items():
|
||||
if not isinstance(sys_cfg, dict) or sys_cfg.get("MODE") != "OPENBRIDGE":
|
||||
continue
|
||||
if not sys_cfg.get("ENABLED", True):
|
||||
continue
|
||||
live.add(name)
|
||||
configured = _peer_from_config(sys_cfg)
|
||||
session = self._sessions.get(name)
|
||||
if session is None:
|
||||
self._sessions[name] = ObpBridgeSession(system_name=name, configured_peer=configured)
|
||||
continue
|
||||
if session.configured_peer != configured:
|
||||
session.configured_peer = configured
|
||||
session.forget_learned_peer()
|
||||
for name in list(self._sessions):
|
||||
if name not in live:
|
||||
self._sessions.pop(name, None)
|
||||
|
||||
|
||||
def mesh_sessions(config: dict[str, Any]) -> MeshSessionStore:
|
||||
"""The store for this server, kept beside the other runtime tables.
|
||||
|
||||
It lives under a private top-level key, like ``_SUB_MAP`` and ``_PEER_IDS``,
|
||||
so every layer that already receives the config can reach the same instance
|
||||
and ``config_reload`` preserves it across a SIGHUP. What it holds is no
|
||||
longer inside the SYSTEMS blocks, which is what makes those read-only.
|
||||
"""
|
||||
store = config.get("_MESH_SESSIONS")
|
||||
if not isinstance(store, MeshSessionStore):
|
||||
store = MeshSessionStore()
|
||||
store.sync(config)
|
||||
config["_MESH_SESSIONS"] = store
|
||||
return store
|
||||
|
||||
|
||||
def obp_session(config: dict[str, Any], system_name: str) -> ObpBridgeSession:
|
||||
"""Session of one OPENBRIDGE system, from the server-wide store."""
|
||||
sys_cfg = config.get("SYSTEMS", {}).get(system_name)
|
||||
return mesh_sessions(config).session(system_name, sys_cfg)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"KEEPALIVE_TIMEOUT_S",
|
||||
"MeshSessionStore",
|
||||
"ObpBridgeSession",
|
||||
"mesh_sessions",
|
||||
"obp_session",
|
||||
]
|
||||
@ -0,0 +1,404 @@
|
||||
# ADN DMR Peer Server - infrastructure obp replay
|
||||
#
|
||||
# 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
|
||||
###############################################################################
|
||||
|
||||
"""Replay a capture through the OpenBridge ingress and say what it would do.
|
||||
|
||||
Offline answer to "why did that call not cross": the frames from a ``tcpdump``
|
||||
capture go through the same engine the server runs, against the operator's own
|
||||
adn-server.yaml, and each one comes back with the bridge it belongs to and
|
||||
either a delivery or the reason it was refused. Nothing is sent, nothing is
|
||||
bound; the server can keep running while this reads the capture.
|
||||
|
||||
This is what the engine being pure buys — the decisions can be taken anywhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, TextIO
|
||||
|
||||
from adn_server.application.proxy.deployment import obp_bridge_legacy_listen_port
|
||||
from adn_server.domain import int_id
|
||||
from adn_server.domain.mesh_admission import AclRules, AdmissionContext, server_prefix
|
||||
from adn_server.domain.mesh_engine import (
|
||||
BridgePolicy,
|
||||
Deliver,
|
||||
Reject,
|
||||
accepts_source,
|
||||
ingest_dmrd_v1,
|
||||
ingest_dmre_v5,
|
||||
reject_v1_protocol,
|
||||
server_id_bytes,
|
||||
)
|
||||
from adn_server.domain.mesh_routing import PeerMeshConfig
|
||||
from adn_server.domain.mesh_session import MeshSessionStore, ObpBridgeSession
|
||||
from adn_server.infrastructure.acl_router import InMemoryAclRouter
|
||||
from adn_server.infrastructure.hbp_constants import BC, BCKA, BCSQ, BCST, BCVE, DMRD, DMRE
|
||||
from adn_server.infrastructure.mesh.dmre_v5 import parse_dmre_trailer
|
||||
from adn_server.infrastructure.mesh.registry import MeshCodecRegistry
|
||||
from adn_server.infrastructure.pcap import CaptureError, CapturedDatagram, read_udp
|
||||
|
||||
_CONTROL_NAMES = {BCKA: "BCKA", BCSQ: "BCSQ", BCST: "BCST", BCVE: "BCVE"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Verdict:
|
||||
"""What the ingress would do with one captured datagram."""
|
||||
|
||||
datagram: CapturedDatagram
|
||||
system: str | None = None
|
||||
kind: str = "?"
|
||||
rf_src: int = 0
|
||||
dst_id: int = 0
|
||||
outcome: str = "unmatched"
|
||||
reason: str = ""
|
||||
quench: bool = False
|
||||
|
||||
def line(self) -> str:
|
||||
when = time.strftime("%H:%M:%S", time.localtime(self.datagram.timestamp))
|
||||
source = f"{self.datagram.source[0]}:{self.datagram.source[1]}"
|
||||
system = self.system or "-"
|
||||
call = f"{self.rf_src} -> {self.dst_id}" if self.rf_src or self.dst_id else ""
|
||||
verdict = self.outcome if not self.reason else f"{self.outcome} ({self.reason})"
|
||||
if self.quench:
|
||||
verdict += " +BCSQ"
|
||||
return f"{when} {source:<24} {system:<12} {self.kind:<9} {call:<24} {verdict}"
|
||||
|
||||
|
||||
def _peer_hosts(systems: dict[str, dict[str, Any]]) -> set[str]:
|
||||
"""The addresses the configured peers live at."""
|
||||
hosts: set[str] = set()
|
||||
for sys_cfg in systems.values():
|
||||
sock = sys_cfg.get("TARGET_SOCK")
|
||||
host = sock[0] if isinstance(sock, tuple) else sys_cfg.get("TARGET_IP")
|
||||
if host:
|
||||
hosts.add(str(host))
|
||||
return hosts
|
||||
|
||||
|
||||
def _listen_ports(systems: dict[str, dict[str, Any]], *, listen_port: int) -> set[int]:
|
||||
"""Ports this server receives OpenBridge on: the fan-in and each legacy bind."""
|
||||
ports = {listen_port}
|
||||
for sys_cfg in systems.values():
|
||||
bridge_port = obp_bridge_legacy_listen_port(
|
||||
sys_cfg, listen_port=listen_port, bind_legacy_ports=True
|
||||
)
|
||||
if bridge_port:
|
||||
ports.add(int(bridge_port))
|
||||
port = sys_cfg.get("_REPORT_PORT") or sys_cfg.get("PORT")
|
||||
if port:
|
||||
ports.add(int(port))
|
||||
return ports
|
||||
|
||||
|
||||
def _openbridge_systems(config: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
return {
|
||||
name: sys_cfg
|
||||
for name, sys_cfg in config.get("SYSTEMS", {}).items()
|
||||
if isinstance(sys_cfg, dict)
|
||||
and sys_cfg.get("MODE") == "OPENBRIDGE"
|
||||
and sys_cfg.get("ENABLED", True)
|
||||
}
|
||||
|
||||
|
||||
def _policy(config: dict[str, Any], name: str, sys_cfg: dict[str, Any], router: Any) -> BridgePolicy:
|
||||
global_cfg = config.get("GLOBAL", {})
|
||||
admission = AdmissionContext(
|
||||
stunned="STUN" in config,
|
||||
acl_check=router.acl_check,
|
||||
global_rules=AclRules(
|
||||
enabled=bool(global_cfg.get("USE_ACL")),
|
||||
sub_acl=global_cfg.get("SUB_ACL", (True, [])),
|
||||
tg1_acl=global_cfg.get("TG1_ACL", (True, [])),
|
||||
),
|
||||
system_rules=AclRules(
|
||||
enabled=bool(sys_cfg.get("USE_ACL")),
|
||||
sub_acl=sys_cfg.get("SUB_ACL", (True, [])),
|
||||
tg1_acl=sys_cfg.get("TG1_ACL", (True, [])),
|
||||
),
|
||||
server_id=server_prefix(global_cfg.get("SERVER_ID", 0)),
|
||||
# The server-id list and the alias tables are loaded at runtime, not from
|
||||
# the YAML, so offline we cannot answer "is this server known?" and saying
|
||||
# "no" would blame every frame on a table we do not have.
|
||||
validate_server_ids=bool(global_cfg.get("VALIDATE_SERVER_IDS")) and bool(config.get("_SERVER_IDS")),
|
||||
known_server_prefixes=config.get("_SERVER_IDS", set()),
|
||||
resolve_server_id=lambda _sid: True
|
||||
)
|
||||
return BridgePolicy(
|
||||
system=name,
|
||||
network_id=sys_cfg.get("NETWORK_ID", b""),
|
||||
proto_ver=sys_cfg.get("VER"),
|
||||
relax_checks=bool(sys_cfg.get("RELAX_CHECKS")),
|
||||
server_id=server_id_bytes(global_cfg.get("SERVER_ID", 0)),
|
||||
admission=admission,
|
||||
)
|
||||
|
||||
|
||||
def _mesh_config(sys_cfg: dict[str, Any], server_id: bytes) -> PeerMeshConfig:
|
||||
passphrase = sys_cfg.get("PASSPHRASE") or b""
|
||||
if isinstance(passphrase, str):
|
||||
passphrase = (passphrase.strip().encode("utf-8") + b"\x00" * 20)[:20]
|
||||
ver = sys_cfg.get("VER")
|
||||
return PeerMeshConfig(
|
||||
passphrase=passphrase,
|
||||
server_id=server_id,
|
||||
wire_ver=int(ver) if ver is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _candidates(
|
||||
systems: dict[str, dict[str, Any]],
|
||||
datagram: CapturedDatagram,
|
||||
*,
|
||||
listen_port: int,
|
||||
) -> list[str]:
|
||||
"""Bridges to try for this datagram, the likeliest first.
|
||||
|
||||
Same evidence the server has: the port it arrived on, then the configured
|
||||
peer address, then everyone else — a shared passphrase makes the rest
|
||||
ambiguous, which is exactly why the order matters.
|
||||
"""
|
||||
host, port = datagram.source
|
||||
local_port = datagram.destination[1]
|
||||
exact: list[str] = []
|
||||
same_host: list[str] = []
|
||||
rest: list[str] = []
|
||||
for name, sys_cfg in systems.items():
|
||||
bridge_port = obp_bridge_legacy_listen_port(
|
||||
sys_cfg, listen_port=listen_port, bind_legacy_ports=True
|
||||
)
|
||||
if bridge_port == local_port:
|
||||
exact.insert(0, name)
|
||||
continue
|
||||
sock = sys_cfg.get("TARGET_SOCK")
|
||||
target_host = sock[0] if isinstance(sock, tuple) else sys_cfg.get("TARGET_IP")
|
||||
target_port = sock[1] if isinstance(sock, tuple) else sys_cfg.get("TARGET_PORT")
|
||||
if target_host == host and target_port == port:
|
||||
exact.append(name)
|
||||
elif target_host == host:
|
||||
same_host.append(name)
|
||||
else:
|
||||
rest.append(name)
|
||||
return exact + same_host + rest
|
||||
|
||||
|
||||
def _is_inbound(
|
||||
datagram: CapturedDatagram, peer_hosts: set[str], inbound_ports: set[int]
|
||||
) -> bool:
|
||||
"""Did this datagram arrive, or did we send it?
|
||||
|
||||
Both ends of an OpenBridge link usually sit on the same port number, so the
|
||||
port alone cannot tell: the address does. A frame from a configured peer is
|
||||
ingress, one addressed to a configured peer is ours. When neither matches
|
||||
(a peer behind NAT, say), fall back to the port it was sent to.
|
||||
"""
|
||||
if datagram.source[0] in peer_hosts:
|
||||
return True
|
||||
if datagram.destination[0] in peer_hosts:
|
||||
return False
|
||||
return datagram.destination[1] in inbound_ports
|
||||
|
||||
|
||||
def _control_verdict(datagram: CapturedDatagram, payload: bytes, system: str | None) -> Verdict:
|
||||
name = _CONTROL_NAMES.get(payload[:4], "BC?")
|
||||
return Verdict(
|
||||
datagram=datagram,
|
||||
system=system,
|
||||
kind=name,
|
||||
outcome="control",
|
||||
)
|
||||
|
||||
|
||||
def replay(
|
||||
config: dict[str, Any],
|
||||
datagrams: list[CapturedDatagram],
|
||||
*,
|
||||
system: str | None = None,
|
||||
now: float | None = None,
|
||||
only_inbound: bool = True,
|
||||
) -> list[Verdict]:
|
||||
"""Run captured datagrams through the ingress engine. Sends nothing."""
|
||||
systems = _openbridge_systems(config)
|
||||
listen_port = int(config.get("OBP_PROXY", {}).get("LISTEN_PORT", 62032) or 62032)
|
||||
# Which way a datagram was going is a property of the server, not of the
|
||||
# bridge being looked at, so every enabled link counts here.
|
||||
inbound_ports = _listen_ports(systems, listen_port=listen_port)
|
||||
peer_hosts = _peer_hosts(systems)
|
||||
if system is not None:
|
||||
systems = {name: cfg for name, cfg in systems.items() if name == system}
|
||||
if not systems:
|
||||
raise KeyError(f"no enabled OPENBRIDGE system named {system!r}")
|
||||
router = InMemoryAclRouter()
|
||||
registry = MeshCodecRegistry()
|
||||
store = MeshSessionStore()
|
||||
store.sync(config)
|
||||
server_id = server_id_bytes(config.get("GLOBAL", {}).get("SERVER_ID", 0))
|
||||
|
||||
verdicts: list[Verdict] = []
|
||||
for datagram in datagrams:
|
||||
payload = datagram.payload
|
||||
if len(payload) < 4:
|
||||
continue
|
||||
opcode = payload[:4]
|
||||
if only_inbound and not _is_inbound(datagram, peer_hosts, inbound_ports):
|
||||
# An unfiltered capture carries both directions; what left this server
|
||||
# is not ingress, and judging it would blame our own frames.
|
||||
verdicts.append(
|
||||
Verdict(
|
||||
datagram=datagram,
|
||||
kind=_CONTROL_NAMES.get(opcode, "DMRD v1" if opcode == DMRD else "DMRE v5")
|
||||
if opcode in _CONTROL_NAMES or opcode in (DMRD, DMRE)
|
||||
else "?",
|
||||
outcome="outbound",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if opcode[:2] == BC and opcode in _CONTROL_NAMES:
|
||||
names = _candidates(systems, datagram, listen_port=listen_port)
|
||||
verdicts.append(_control_verdict(datagram, payload, names[0] if names else None))
|
||||
continue
|
||||
if opcode not in (DMRD, DMRE):
|
||||
continue
|
||||
verdict = Verdict(datagram=datagram, kind="DMRD v1" if opcode == DMRD else "DMRE v5")
|
||||
for name in _candidates(systems, datagram, listen_port=listen_port):
|
||||
sys_cfg = systems[name]
|
||||
ingress = registry.decode_auto(payload, _mesh_config(sys_cfg, server_id))
|
||||
if ingress is None:
|
||||
continue
|
||||
session = store.session(name, sys_cfg)
|
||||
policy = _policy(config, name, sys_cfg, router)
|
||||
verdict.system = name
|
||||
frame = ingress.voice_frame
|
||||
verdict.rf_src = int(int_id(frame[5:8]))
|
||||
verdict.dst_id = int(int_id(frame[8:11]))
|
||||
effects = _effects_for(
|
||||
opcode, ingress, payload, datagram, policy=policy, session=session, now=now
|
||||
)
|
||||
_fill(verdict, effects)
|
||||
break
|
||||
verdicts.append(verdict)
|
||||
return verdicts
|
||||
|
||||
|
||||
def _effects_for(
|
||||
opcode: bytes,
|
||||
ingress: Any,
|
||||
payload: bytes,
|
||||
datagram: CapturedDatagram,
|
||||
*,
|
||||
policy: BridgePolicy,
|
||||
session: ObpBridgeSession,
|
||||
now: float | None,
|
||||
) -> list[Any] | None:
|
||||
moment = datagram.timestamp if now is None else now
|
||||
if opcode == DMRD:
|
||||
if policy.rejects_v1:
|
||||
return reject_v1_protocol(ingress.voice_frame[16:20], policy=policy)
|
||||
if not accepts_source(datagram.source, policy=policy, session=session):
|
||||
return None
|
||||
return ingest_dmrd_v1(ingress, datagram.source, policy=policy, session=session, now=moment)
|
||||
trailer = parse_dmre_trailer(payload)
|
||||
timestamp = trailer.timestamp if trailer is not None else b"\x00" * 8
|
||||
if not accepts_source(datagram.source, policy=policy, session=session):
|
||||
return None
|
||||
return ingest_dmre_v5(
|
||||
ingress,
|
||||
datagram.source,
|
||||
policy=policy,
|
||||
session=session,
|
||||
timestamp_ns=int.from_bytes(timestamp, "big"),
|
||||
now=moment,
|
||||
)
|
||||
|
||||
|
||||
def _fill(verdict: Verdict, effects: list[Any] | None) -> None:
|
||||
if effects is None:
|
||||
verdict.outcome = "refused"
|
||||
verdict.reason = "source not accepted"
|
||||
return
|
||||
for effect in effects:
|
||||
if isinstance(effect, Reject):
|
||||
verdict.outcome = "dropped"
|
||||
verdict.reason = effect.reason
|
||||
verdict.quench = effect.rejection.quench
|
||||
return
|
||||
if isinstance(effect, Deliver):
|
||||
verdict.outcome = "delivered"
|
||||
return
|
||||
verdict.outcome = "ignored"
|
||||
|
||||
|
||||
def format_report(verdicts: list[Verdict], *, capture: str, verbose: bool = True) -> str:
|
||||
"""The per-frame lines and the tally the sysop actually reads."""
|
||||
lines = [f"OBP replay of {capture}", ""]
|
||||
if verbose:
|
||||
lines.extend(verdict.line() for verdict in verdicts)
|
||||
lines.append("")
|
||||
by_system: dict[str, Counter] = {}
|
||||
for verdict in verdicts:
|
||||
label = verdict.system or "(no bridge)"
|
||||
key = verdict.outcome if not verdict.reason else f"{verdict.outcome}: {verdict.reason}"
|
||||
by_system.setdefault(label, Counter())[key] += 1
|
||||
lines.append(f"{len(verdicts)} datagram(s)")
|
||||
for label in sorted(by_system):
|
||||
lines.append(f" {label}")
|
||||
for key, count in sorted(by_system[label].items(), key=lambda item: (-item[1], item[0])):
|
||||
lines.append(f" {count:>6} {key}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def run_replay(
|
||||
config: dict[str, Any],
|
||||
capture_path: str,
|
||||
*,
|
||||
system: str | None = None,
|
||||
limit: int | None = None,
|
||||
summary_only: bool = False,
|
||||
both_directions: bool = False,
|
||||
out: TextIO | None = None,
|
||||
) -> int:
|
||||
"""Read a capture, replay it, print the report. Returns 0 when it ran."""
|
||||
stream = out or sys.stdout
|
||||
try:
|
||||
datagrams = list(read_udp(Path(capture_path)))
|
||||
except (CaptureError, OSError) as exc:
|
||||
print(f"ERROR capture: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
if limit is not None:
|
||||
datagrams = datagrams[:limit]
|
||||
try:
|
||||
verdicts = replay(config, datagrams, system=system, only_inbound=not both_directions)
|
||||
except KeyError as exc:
|
||||
print(f"ERROR system: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(format_report(verdicts, capture=capture_path, verbose=not summary_only), file=stream)
|
||||
return 0
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Verdict",
|
||||
"format_report",
|
||||
"replay",
|
||||
"run_replay",
|
||||
]
|
||||
@ -0,0 +1,180 @@
|
||||
# ADN DMR Peer Server - infrastructure pcap reader
|
||||
#
|
||||
# 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
|
||||
###############################################################################
|
||||
|
||||
"""Read UDP datagrams out of a classic pcap file, without dependencies.
|
||||
|
||||
Enough of the format to walk a ``tcpdump -w`` capture and hand back what a
|
||||
socket would have received: the payload, who sent it, which port it arrived on
|
||||
and when. Anything that is not IPv4/IPv6 UDP is skipped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
PCAP_MAGIC_US = 0xA1B2C3D4 # timestamps in microseconds
|
||||
PCAP_MAGIC_NS = 0xA1B23C4D # timestamps in nanoseconds
|
||||
PCAPNG_MAGIC = 0x0A0D0D0A
|
||||
|
||||
LINKTYPE_NULL = 0
|
||||
LINKTYPE_ETHERNET = 1
|
||||
LINKTYPE_RAW = 101
|
||||
LINKTYPE_LINUX_SLL = 113
|
||||
LINKTYPE_LINUX_SLL2 = 276
|
||||
LINKTYPE_IPV4 = 228
|
||||
LINKTYPE_IPV6 = 229
|
||||
|
||||
|
||||
class CaptureError(Exception):
|
||||
"""The file is not a capture this reader can walk."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapturedDatagram:
|
||||
"""One UDP datagram as it appeared on the wire."""
|
||||
|
||||
timestamp: float
|
||||
source: tuple[str, int]
|
||||
destination: tuple[str, int]
|
||||
payload: bytes
|
||||
|
||||
|
||||
def _ipv4(raw: bytes) -> str:
|
||||
return ".".join(str(b) for b in raw)
|
||||
|
||||
|
||||
def _ipv6(raw: bytes) -> str:
|
||||
parts = [f"{raw[i] << 8 | raw[i + 1]:x}" for i in range(0, 16, 2)]
|
||||
return ":".join(parts)
|
||||
|
||||
|
||||
def _strip_link_layer(frame: bytes, linktype: int) -> tuple[bytes, int] | None:
|
||||
"""Return the network-layer payload and its ethertype-ish family."""
|
||||
if linktype == LINKTYPE_ETHERNET:
|
||||
if len(frame) < 14:
|
||||
return None
|
||||
ethertype = int.from_bytes(frame[12:14], "big")
|
||||
offset = 14
|
||||
while ethertype in (0x8100, 0x88A8): # VLAN tags
|
||||
if len(frame) < offset + 4:
|
||||
return None
|
||||
ethertype = int.from_bytes(frame[offset + 2 : offset + 4], "big")
|
||||
offset += 4
|
||||
return frame[offset:], ethertype
|
||||
if linktype == LINKTYPE_LINUX_SLL:
|
||||
if len(frame) < 16:
|
||||
return None
|
||||
return frame[16:], int.from_bytes(frame[14:16], "big")
|
||||
if linktype == LINKTYPE_LINUX_SLL2:
|
||||
# `tcpdump -i any` on a recent libpcap: protocol first, 20-byte header
|
||||
if len(frame) < 20:
|
||||
return None
|
||||
return frame[20:], int.from_bytes(frame[:2], "big")
|
||||
if linktype == LINKTYPE_NULL:
|
||||
if len(frame) < 4:
|
||||
return None
|
||||
family = int.from_bytes(frame[:4], "little")
|
||||
return frame[4:], 0x0800 if family == 2 else 0x86DD
|
||||
if linktype in (LINKTYPE_RAW, LINKTYPE_IPV4, LINKTYPE_IPV6):
|
||||
if not frame:
|
||||
return None
|
||||
version = frame[0] >> 4
|
||||
return frame, 0x0800 if version == 4 else 0x86DD
|
||||
return None
|
||||
|
||||
|
||||
def _udp_from_ip(packet: bytes, ethertype: int) -> tuple[str, str, bytes] | None:
|
||||
"""Return ``(src_ip, dst_ip, udp_segment)`` for an IPv4/IPv6 UDP packet."""
|
||||
if ethertype == 0x0800:
|
||||
if len(packet) < 20 or packet[0] >> 4 != 4:
|
||||
return None
|
||||
header_len = (packet[0] & 0x0F) * 4
|
||||
if packet[9] != 17 or len(packet) < header_len + 8: # 17 = UDP
|
||||
return None
|
||||
return _ipv4(packet[12:16]), _ipv4(packet[16:20]), packet[header_len:]
|
||||
if ethertype == 0x86DD:
|
||||
if len(packet) < 40 or packet[0] >> 4 != 6:
|
||||
return None
|
||||
if packet[6] != 17 or len(packet) < 48: # no extension-header walking
|
||||
return None
|
||||
return _ipv6(packet[8:24]), _ipv6(packet[24:40]), packet[40:]
|
||||
return None
|
||||
|
||||
|
||||
def read_udp(path: str | Path) -> Iterator[CapturedDatagram]:
|
||||
"""Walk a capture and yield its UDP datagrams in order."""
|
||||
path = Path(path)
|
||||
with path.open("rb") as fh:
|
||||
header = fh.read(24)
|
||||
if len(header) < 24:
|
||||
raise CaptureError(f"{path}: too short to be a capture")
|
||||
magic = int.from_bytes(header[:4], "big")
|
||||
if magic == PCAPNG_MAGIC:
|
||||
raise CaptureError(
|
||||
f"{path}: pcapng is not supported; convert it first "
|
||||
"(editcap -F pcap in.pcapng out.pcap)"
|
||||
)
|
||||
if magic in (PCAP_MAGIC_US, PCAP_MAGIC_NS):
|
||||
endian = ">"
|
||||
else:
|
||||
magic = int.from_bytes(header[:4], "little")
|
||||
if magic not in (PCAP_MAGIC_US, PCAP_MAGIC_NS):
|
||||
raise CaptureError(f"{path}: not a pcap file")
|
||||
endian = "<"
|
||||
divisor = 1_000_000_000 if magic == PCAP_MAGIC_NS else 1_000_000
|
||||
linktype = struct.unpack(endian + "I", header[20:24])[0]
|
||||
|
||||
record = struct.Struct(endian + "IIII")
|
||||
while True:
|
||||
raw = fh.read(record.size)
|
||||
if len(raw) < record.size:
|
||||
return
|
||||
seconds, fraction, captured_len, _original_len = record.unpack(raw)
|
||||
frame = fh.read(captured_len)
|
||||
if len(frame) < captured_len:
|
||||
return
|
||||
stripped = _strip_link_layer(frame, linktype)
|
||||
if stripped is None:
|
||||
continue
|
||||
packet, ethertype = stripped
|
||||
addresses = _udp_from_ip(packet, ethertype)
|
||||
if addresses is None:
|
||||
continue
|
||||
source_ip, destination_ip, segment = addresses
|
||||
if len(segment) < 8:
|
||||
continue
|
||||
source_port, destination_port, length = struct.unpack(">HHH", segment[:6])
|
||||
payload = segment[8 : max(8, length)] if length >= 8 else segment[8:]
|
||||
yield CapturedDatagram(
|
||||
timestamp=seconds + fraction / divisor,
|
||||
source=(source_ip, source_port),
|
||||
destination=(destination_ip, destination_port),
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CaptureError",
|
||||
"CapturedDatagram",
|
||||
"read_udp",
|
||||
]
|
||||
@ -0,0 +1,393 @@
|
||||
# ADN DMR Peer Server - tests domain mesh admission
|
||||
#
|
||||
# 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
|
||||
###############################################################################
|
||||
|
||||
"""OpenBridge admission rules: one frame in, one decision out."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from adn_server.domain import bytes_3, bytes_4
|
||||
from adn_server.domain.mesh_admission import (
|
||||
AclRules,
|
||||
AdmissionContext,
|
||||
MeshEnvelope,
|
||||
ObpFrame,
|
||||
admit_dmrd_v1,
|
||||
admit_dmre_v5,
|
||||
call_attributes,
|
||||
check_acl_chain,
|
||||
check_hops,
|
||||
check_network_id,
|
||||
check_packet_age,
|
||||
check_slot,
|
||||
check_source_server,
|
||||
check_stun,
|
||||
check_tg_filter_v1,
|
||||
check_tg_filter_v5,
|
||||
server_prefix,
|
||||
)
|
||||
|
||||
_SYSTEM = "OBP-FR"
|
||||
_STREAM = bytes_4(0xAABBCCDD)
|
||||
_SRC = bytes_3(2130001)
|
||||
_NOW = 1_800_000_000.0
|
||||
|
||||
|
||||
def _frame(dst: int = 214, *, slot: int = 1, call_type: str = "group") -> ObpFrame:
|
||||
return ObpFrame(
|
||||
system=_SYSTEM,
|
||||
stream_id=_STREAM,
|
||||
rf_src=_SRC,
|
||||
dst_id=bytes_3(dst),
|
||||
slot=slot,
|
||||
call_type=call_type,
|
||||
)
|
||||
|
||||
|
||||
def _envelope(*, source_server: int = 20840, hops: int = 0, fresh: bool = True) -> MeshEnvelope:
|
||||
return MeshEnvelope(
|
||||
source_server=source_server,
|
||||
hops=hops,
|
||||
timestamp_ns=int(_NOW * 1_000_000_000) if fresh else 0,
|
||||
source_server_id=bytes_4(source_server),
|
||||
)
|
||||
|
||||
|
||||
def _ctx(**kwargs) -> AdmissionContext:
|
||||
return AdmissionContext(**kwargs)
|
||||
|
||||
|
||||
# --- bits byte ---------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("bits", "slot", "call_type"),
|
||||
[
|
||||
(0x00, 1, "group"),
|
||||
(0x80, 2, "group"),
|
||||
(0x40, 1, "unit"),
|
||||
(0xC0, 2, "unit"),
|
||||
(0x23, 1, "vcsbk"),
|
||||
(0xE3, 2, "unit"), # the private bit wins over the CSBK pattern
|
||||
],
|
||||
)
|
||||
def test_call_attributes_classifies_the_bits_byte(bits: int, slot: int, call_type: str) -> None:
|
||||
attrs = call_attributes(bits)
|
||||
assert (attrs.slot, attrs.call_type) == (slot, call_type)
|
||||
|
||||
|
||||
def test_call_attributes_splits_frame_type_and_sequence() -> None:
|
||||
attrs = call_attributes(0x16)
|
||||
assert attrs.frame_type == 1
|
||||
assert attrs.dtype_vseq == 6
|
||||
|
||||
|
||||
# --- identity ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_network_id_match_is_admitted() -> None:
|
||||
assert check_network_id(_SYSTEM, _STREAM, expected=bytes_4(20840), received=bytes_4(20840)) is None
|
||||
|
||||
|
||||
def test_network_id_mismatch_is_logged_but_not_quenched() -> None:
|
||||
rejection = check_network_id(_SYSTEM, _STREAM, expected=bytes_4(20840), received=bytes_4(26811))
|
||||
assert rejection is not None
|
||||
assert rejection.reason == "network-id-mismatch"
|
||||
assert rejection.level == logging.ERROR
|
||||
assert rejection.quench is False
|
||||
assert "OpenBridge packet discarded" in rejection.message
|
||||
|
||||
|
||||
def test_network_id_mismatch_names_dmre_when_asked() -> None:
|
||||
rejection = check_network_id(
|
||||
_SYSTEM, _STREAM, expected=bytes_4(20840), received=bytes_4(26811), dmre=True
|
||||
)
|
||||
assert rejection is not None
|
||||
assert "OpenBridge DMRE discarded" in rejection.message
|
||||
|
||||
|
||||
# --- slot, stun --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_slot_1_is_admitted_and_slot_2_is_not() -> None:
|
||||
assert check_slot(_frame()) is None
|
||||
rejection = check_slot(_frame(slot=2))
|
||||
assert rejection is not None
|
||||
assert rejection.reason == "not-slot-1"
|
||||
assert rejection.quench is False
|
||||
assert rejection.log_once is False # legacy logs this one on every frame
|
||||
|
||||
|
||||
def test_stunned_bridge_drops_without_quenching() -> None:
|
||||
assert check_stun(_frame(), stunned=False) is None
|
||||
rejection = check_stun(_frame(), stunned=True)
|
||||
assert rejection is not None
|
||||
assert rejection.reason == "stunned"
|
||||
assert rejection.quench is False
|
||||
|
||||
|
||||
# --- DMRE envelope -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_fresh_packet_passes_and_old_one_is_dropped() -> None:
|
||||
assert check_packet_age(_frame(), _envelope(), now=_NOW) is None
|
||||
rejection = check_packet_age(_frame(), _envelope(), now=_NOW + 6)
|
||||
assert rejection is not None
|
||||
assert rejection.reason == "stale-packet"
|
||||
|
||||
|
||||
def test_packet_without_timestamp_counts_as_stale() -> None:
|
||||
"""A DMRE frame with no trailer has no timestamp, and legacy drops it."""
|
||||
rejection = check_packet_age(_frame(), _envelope(fresh=False), now=_NOW)
|
||||
assert rejection is not None
|
||||
assert rejection.reason == "stale-packet"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source_server", [123, 12345678])
|
||||
def test_source_server_must_be_4_to_7_digits(source_server: int) -> None:
|
||||
rejection = check_source_server(_frame(), _envelope(source_server=source_server), _ctx())
|
||||
assert rejection is not None
|
||||
assert rejection.reason == "source-server-length"
|
||||
|
||||
|
||||
def test_short_source_server_must_be_a_known_server() -> None:
|
||||
envelope = _envelope(source_server=2084)
|
||||
ctx = _ctx(validate_server_ids=True, known_server_prefixes={"2131"})
|
||||
rejection = check_source_server(_frame(), envelope, ctx)
|
||||
assert rejection is not None
|
||||
assert rejection.reason == "source-server-unknown"
|
||||
ctx_known = _ctx(validate_server_ids=True, known_server_prefixes={"2084"})
|
||||
assert check_source_server(_frame(), envelope, ctx_known) is None
|
||||
|
||||
|
||||
def test_unknown_short_source_server_passes_when_validation_is_off() -> None:
|
||||
envelope = _envelope(source_server=2084)
|
||||
assert check_source_server(_frame(), envelope, _ctx(known_server_prefixes={"2131"})) is None
|
||||
|
||||
|
||||
def test_long_source_server_must_resolve_to_a_dmr_id() -> None:
|
||||
envelope = _envelope(source_server=2130001)
|
||||
rejection = check_source_server(_frame(), envelope, _ctx(resolve_server_id=lambda _id: False))
|
||||
assert rejection is not None
|
||||
assert rejection.reason == "source-server-invalid"
|
||||
assert check_source_server(_frame(), envelope, _ctx(resolve_server_id=lambda _id: "C31AG")) is None
|
||||
|
||||
|
||||
def test_long_source_server_is_looked_up_by_its_wire_bytes() -> None:
|
||||
seen: list[bytes] = []
|
||||
envelope = _envelope(source_server=2130001)
|
||||
check_source_server(_frame(), envelope, _ctx(resolve_server_id=lambda sid: seen.append(sid) or True))
|
||||
assert seen == [bytes_4(2130001)]
|
||||
|
||||
|
||||
def test_hops_are_counted_and_capped() -> None:
|
||||
assert check_hops(_frame(), _envelope(hops=8)) is None
|
||||
rejection = check_hops(_frame(), _envelope(hops=10))
|
||||
assert rejection is not None
|
||||
assert rejection.reason == "max-hops"
|
||||
assert rejection.level == logging.DEBUG
|
||||
assert rejection.log_once is False # legacy quenches every looping frame
|
||||
|
||||
|
||||
# --- talkgroup filters -------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dst", [9, 79, 92, 199, 9990, 9999, 900999])
|
||||
def test_tg_filter_v1_keeps_local_talkgroups_off_the_mesh(dst: int) -> None:
|
||||
rejection = check_tg_filter_v1(_frame(dst))
|
||||
assert rejection is not None
|
||||
assert rejection.reason == "tg-filter"
|
||||
assert rejection.quench is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dst", [80, 91, 200, 214, 9989, 10000])
|
||||
def test_tg_filter_v1_lets_ordinary_talkgroups_through(dst: int) -> None:
|
||||
assert check_tg_filter_v1(_frame(dst)) is None
|
||||
|
||||
|
||||
def test_tg_filter_v1_ignores_private_calls() -> None:
|
||||
assert check_tg_filter_v1(_frame(9, call_type="unit")) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("dst", "reason"),
|
||||
[
|
||||
(9, "tg-filter-repeater"),
|
||||
(79, "tg-filter-repeater"),
|
||||
(9990, "tg-filter-server"),
|
||||
(900999, "tg-filter-server"),
|
||||
],
|
||||
)
|
||||
def test_tg_filter_v5_drops_talkgroups_that_never_leave_home(dst: int, reason: str) -> None:
|
||||
rejection = check_tg_filter_v5(_frame(dst), _envelope(), _ctx(server_id=2131))
|
||||
assert rejection is not None
|
||||
assert rejection.reason == reason
|
||||
|
||||
|
||||
def test_tg_filter_v5_allows_a_server_local_tg_from_that_server() -> None:
|
||||
"""92-199 belong to one server: only that server may bridge them."""
|
||||
ctx = _ctx(server_id=2084)
|
||||
assert check_tg_filter_v5(_frame(100), _envelope(source_server=20840), ctx) is None
|
||||
rejection = check_tg_filter_v5(_frame(100), _envelope(source_server=21310), ctx)
|
||||
assert rejection is not None
|
||||
assert rejection.reason == "tg-filter-server-main"
|
||||
|
||||
|
||||
def test_tg_filter_v5_allows_an_mcc_tg_from_the_same_mcc() -> None:
|
||||
ctx = _ctx(server_id=2084)
|
||||
assert check_tg_filter_v5(_frame(85), _envelope(source_server=20851), ctx) is None
|
||||
rejection = check_tg_filter_v5(_frame(850), _envelope(source_server=21310), ctx)
|
||||
assert rejection is not None
|
||||
assert rejection.reason == "tg-filter-mcc"
|
||||
|
||||
|
||||
def test_tg_filter_v5_lets_ordinary_talkgroups_through() -> None:
|
||||
assert check_tg_filter_v5(_frame(214), _envelope(), _ctx(server_id=2131)) is None
|
||||
|
||||
|
||||
def test_tg_filter_v5_ignores_private_calls() -> None:
|
||||
assert check_tg_filter_v5(_frame(9, call_type="unit"), _envelope(), _ctx(server_id=2131)) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("value", "prefix"), [(21310, 2131), (bytes_4(21310), 2131), (0, 0), (None, 0)])
|
||||
def test_server_prefix_reads_int_or_bytes(value: object, prefix: int) -> None:
|
||||
assert server_prefix(value) == prefix
|
||||
|
||||
|
||||
# --- ACLs --------------------------------------------------------------------
|
||||
|
||||
|
||||
def _deny(*denied: bytes):
|
||||
def _check(target: bytes, _acl: object) -> bool:
|
||||
return target not in denied
|
||||
|
||||
return _check
|
||||
|
||||
|
||||
def test_acl_chain_is_skipped_without_a_router() -> None:
|
||||
ctx = _ctx(global_rules=AclRules(enabled=True), system_rules=AclRules(enabled=True))
|
||||
assert check_acl_chain(_frame(), ctx) is None
|
||||
|
||||
|
||||
def test_acl_chain_admits_when_every_rule_passes() -> None:
|
||||
ctx = _ctx(
|
||||
acl_check=_deny(),
|
||||
global_rules=AclRules(enabled=True),
|
||||
system_rules=AclRules(enabled=True),
|
||||
)
|
||||
assert check_acl_chain(_frame(), ctx) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("denied", "scope", "reason"),
|
||||
[
|
||||
(_SRC, "global", "global-sub-acl"),
|
||||
(bytes_3(214), "global", "global-tg1-acl"),
|
||||
(_SRC, "system", "system-sub-acl"),
|
||||
(bytes_3(214), "system", "system-tg1-acl"),
|
||||
],
|
||||
)
|
||||
def test_acl_chain_reports_which_rule_dropped_the_call(denied: bytes, scope: str, reason: str) -> None:
|
||||
rules = AclRules(enabled=True)
|
||||
ctx = _ctx(
|
||||
acl_check=_deny(denied),
|
||||
global_rules=rules if scope == "global" else AclRules(),
|
||||
system_rules=rules if scope == "system" else AclRules(),
|
||||
)
|
||||
rejection = check_acl_chain(_frame(), ctx)
|
||||
assert rejection is not None
|
||||
assert rejection.reason == reason
|
||||
assert rejection.quench is True
|
||||
|
||||
|
||||
def test_global_talkgroup_acl_only_applies_to_slot_1() -> None:
|
||||
ctx = _ctx(acl_check=_deny(bytes_3(214)), global_rules=AclRules(enabled=True))
|
||||
assert check_acl_chain(_frame(slot=2), ctx) is None
|
||||
|
||||
|
||||
# --- full gauntlets ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_dmrd_v1_admits_an_ordinary_group_call() -> None:
|
||||
ctx = _ctx(acl_check=_deny(), global_rules=AclRules(enabled=True))
|
||||
assert admit_dmrd_v1(_frame(214), ctx) is None
|
||||
|
||||
|
||||
def test_dmrd_v1_reports_the_first_rule_that_fails() -> None:
|
||||
"""Order matters: a STUNned bridge is reported as such, not as a TG drop."""
|
||||
ctx = _ctx(stunned=True, acl_check=_deny(_SRC), global_rules=AclRules(enabled=True))
|
||||
rejection = admit_dmrd_v1(_frame(9), ctx)
|
||||
assert rejection is not None
|
||||
assert rejection.reason == "stunned"
|
||||
|
||||
|
||||
def test_dmre_v5_admits_an_ordinary_group_call() -> None:
|
||||
ctx = _ctx(server_id=2131, acl_check=_deny(), system_rules=AclRules(enabled=True))
|
||||
assert admit_dmre_v5(_frame(214), _envelope(), ctx, now=_NOW) is None
|
||||
|
||||
|
||||
def test_dmre_v5_checks_the_envelope_before_the_talkgroup() -> None:
|
||||
ctx = _ctx(server_id=2131)
|
||||
rejection = admit_dmre_v5(_frame(9), _envelope(fresh=False), ctx, now=_NOW)
|
||||
assert rejection is not None
|
||||
assert rejection.reason == "stale-packet"
|
||||
|
||||
|
||||
def test_every_rejection_can_be_formatted() -> None:
|
||||
"""A log line with the wrong number of placeholders logs an error instead of
|
||||
the drop, so each rule's message and args are checked against each other."""
|
||||
ctx_deny = _ctx(
|
||||
stunned=False,
|
||||
acl_check=_deny(_SRC, bytes_3(214)),
|
||||
global_rules=AclRules(enabled=True),
|
||||
system_rules=AclRules(enabled=True),
|
||||
server_id=2084,
|
||||
validate_server_ids=True,
|
||||
known_server_prefixes={"2131"},
|
||||
resolve_server_id=lambda _id: False,
|
||||
)
|
||||
rejections = [
|
||||
check_network_id(_SYSTEM, _STREAM, expected=bytes_4(1), received=bytes_4(2)),
|
||||
check_network_id(_SYSTEM, _STREAM, expected=bytes_4(1), received=bytes_4(2), dmre=True),
|
||||
check_slot(_frame(slot=2)),
|
||||
check_stun(_frame(), stunned=True),
|
||||
check_packet_age(_frame(), _envelope(fresh=False), now=_NOW),
|
||||
check_source_server(_frame(), _envelope(source_server=123), _ctx()),
|
||||
check_source_server(_frame(), _envelope(source_server=2084), ctx_deny),
|
||||
check_source_server(_frame(), _envelope(source_server=2130001), ctx_deny),
|
||||
check_hops(_frame(), _envelope(hops=10)),
|
||||
check_tg_filter_v1(_frame(9)),
|
||||
check_tg_filter_v5(_frame(9), _envelope(), ctx_deny),
|
||||
check_tg_filter_v5(_frame(9990), _envelope(), ctx_deny),
|
||||
check_tg_filter_v5(_frame(100), _envelope(source_server=21310), ctx_deny),
|
||||
check_tg_filter_v5(_frame(85), _envelope(source_server=21310), ctx_deny),
|
||||
check_acl_chain(_frame(), ctx_deny),
|
||||
check_acl_chain(_frame(), _ctx(acl_check=_deny(_SRC), system_rules=AclRules(enabled=True))),
|
||||
]
|
||||
assert all(r is not None for r in rejections)
|
||||
seen = set()
|
||||
for rejection in rejections:
|
||||
assert rejection is not None
|
||||
rejection.message % rejection.args # raises if the arity is wrong
|
||||
seen.add(rejection.reason)
|
||||
assert len(seen) == len(rejections) - 1 # the two network-id variants share a reason
|
||||
@ -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,275 @@
|
||||
# ADN DMR Peer Server - tests domain mesh session
|
||||
#
|
||||
# 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
|
||||
###############################################################################
|
||||
|
||||
"""Live OpenBridge session state: peer address, keepalive and source quench."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from adn_server.domain import bytes_3, bytes_4
|
||||
from adn_server.domain.mesh_session import (
|
||||
MeshSessionStore,
|
||||
ObpBridgeSession,
|
||||
mesh_sessions,
|
||||
obp_session,
|
||||
)
|
||||
|
||||
_CONFIGURED = ("82.65.127.86", 62201)
|
||||
_ELSEWHERE = ("85.241.222.7", 62268)
|
||||
_NOW = 1_800_000_000.0
|
||||
|
||||
|
||||
def _session(configured: tuple = _CONFIGURED) -> ObpBridgeSession:
|
||||
return ObpBridgeSession(system_name="OBP-FR", configured_peer=configured)
|
||||
|
||||
|
||||
def _config(**overrides) -> dict:
|
||||
sys_cfg = {
|
||||
"MODE": "OPENBRIDGE",
|
||||
"ENABLED": True,
|
||||
"TARGET_IP": _CONFIGURED[0],
|
||||
"TARGET_PORT": _CONFIGURED[1],
|
||||
"TARGET_SOCK": _CONFIGURED,
|
||||
}
|
||||
sys_cfg.update(overrides)
|
||||
return {"SYSTEMS": {"OBP-FR": sys_cfg}}
|
||||
|
||||
|
||||
# --- peer address ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_fresh_session_sends_to_the_configured_peer() -> None:
|
||||
session = _session()
|
||||
assert session.peer == _CONFIGURED
|
||||
assert session.peer_known
|
||||
|
||||
|
||||
def test_learning_a_peer_moves_egress_but_not_the_configuration() -> None:
|
||||
session = _session()
|
||||
assert session.learn_peer(_ELSEWHERE, at=_NOW) is True
|
||||
assert session.peer == _ELSEWHERE
|
||||
assert session.configured_peer == _CONFIGURED
|
||||
assert session.learned_at == _NOW
|
||||
|
||||
|
||||
def test_learning_the_same_address_twice_reports_no_move() -> None:
|
||||
session = _session()
|
||||
assert session.learn_peer(_CONFIGURED, at=_NOW) is False
|
||||
session.learn_peer(_ELSEWHERE, at=_NOW)
|
||||
assert session.learn_peer(_ELSEWHERE, at=_NOW + 1) is False
|
||||
assert session.learned_at == _NOW
|
||||
|
||||
|
||||
def test_an_empty_address_is_not_learned() -> None:
|
||||
session = _session()
|
||||
assert session.learn_peer(("", 0), at=_NOW) is False
|
||||
assert session.learned_peer is None
|
||||
|
||||
|
||||
def test_forgetting_falls_back_to_the_configured_peer() -> None:
|
||||
session = _session()
|
||||
session.learn_peer(_ELSEWHERE, at=_NOW)
|
||||
session.forget_learned_peer()
|
||||
assert session.peer == _CONFIGURED
|
||||
|
||||
|
||||
def test_a_peer_that_never_resolved_is_not_known() -> None:
|
||||
assert _session((None, 62201)).peer_known is False
|
||||
|
||||
|
||||
# --- keepalive ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_keepalive_starts_unseen() -> None:
|
||||
session = _session()
|
||||
assert session.keepalive_seen is False
|
||||
assert session.keepalive_age(_NOW) is None
|
||||
assert session.keepalive_stale(_NOW) is False # never seen is not the same as stale
|
||||
assert session.keepalive_ok(_NOW) is False
|
||||
|
||||
|
||||
def test_a_fresh_keepalive_is_ok_and_an_old_one_is_stale() -> None:
|
||||
session = _session()
|
||||
session.note_keepalive(_NOW - 10)
|
||||
assert session.keepalive_ok(_NOW) is True
|
||||
assert session.keepalive_stale(_NOW) is False
|
||||
assert session.keepalive_age(_NOW) == 10
|
||||
session.note_keepalive(_NOW - 61)
|
||||
assert session.keepalive_stale(_NOW) is True
|
||||
assert session.keepalive_ok(_NOW) is False
|
||||
|
||||
|
||||
def test_the_keepalive_timeout_can_be_narrowed() -> None:
|
||||
session = _session()
|
||||
session.note_keepalive(_NOW - 30)
|
||||
assert session.keepalive_stale(_NOW, timeout=10) is True
|
||||
|
||||
|
||||
# --- source quench -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_quenched_stream_is_reported_for_its_talkgroup() -> None:
|
||||
session = _session()
|
||||
session.quench(bytes_3(214), b"strm")
|
||||
assert session.quenches(bytes_3(214), b"strm") is True
|
||||
assert session.quenches(bytes_3(214), b"other") is False
|
||||
assert session.quenches(bytes_3(215), b"strm") is False
|
||||
|
||||
|
||||
def test_a_talkgroup_matches_whatever_width_it_arrives_in() -> None:
|
||||
"""TGs travel as 3 bytes here and 4 bytes elsewhere; the number is the same."""
|
||||
session = _session()
|
||||
session.quench(bytes_4(214), b"strm")
|
||||
assert session.quenches(bytes_3(214), b"strm") is True
|
||||
|
||||
|
||||
def test_nothing_is_quenched_on_a_fresh_session() -> None:
|
||||
assert _session().quenches(bytes_3(214), b"strm") is False
|
||||
|
||||
|
||||
def test_a_finished_stream_releases_its_quench() -> None:
|
||||
session = _session()
|
||||
session.quench(bytes_3(214), b"strm")
|
||||
session.quench(bytes_3(215), b"other")
|
||||
session.release_stream(b"strm")
|
||||
assert session.quenches(bytes_3(214), b"strm") is False
|
||||
assert session.quenches(bytes_3(215), b"other") is True
|
||||
|
||||
|
||||
def test_a_bridge_stunned_by_its_peer_stays_stunned() -> None:
|
||||
session = _session()
|
||||
assert session.stunned is False
|
||||
session.stun()
|
||||
assert session.stunned is True
|
||||
|
||||
|
||||
# --- the store ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_session_is_created_from_the_system_configuration() -> None:
|
||||
store = MeshSessionStore()
|
||||
session = store.session("OBP-FR", _config()["SYSTEMS"]["OBP-FR"])
|
||||
assert session.configured_peer == _CONFIGURED
|
||||
assert store.session("OBP-FR") is session
|
||||
|
||||
|
||||
def test_a_session_falls_back_to_target_ip_and_port() -> None:
|
||||
store = MeshSessionStore()
|
||||
cfg = {"TARGET_IP": "10.0.0.1", "TARGET_PORT": "62044"}
|
||||
assert store.session("OBP-XX", cfg).configured_peer == ("10.0.0.1", 62044)
|
||||
|
||||
|
||||
def test_an_unusable_target_port_falls_back_to_the_default() -> None:
|
||||
store = MeshSessionStore()
|
||||
assert store.session("OBP-XX", {"TARGET_IP": "10.0.0.1", "TARGET_PORT": "?"}).configured_peer == (
|
||||
"10.0.0.1",
|
||||
62044,
|
||||
)
|
||||
|
||||
|
||||
def test_a_quench_key_that_is_not_a_talkgroup_is_ignored() -> None:
|
||||
session = _session()
|
||||
session.quenched[b"xx"] = b"strm" # short key, as a bad peer could send
|
||||
assert session.quenches(bytes_3(214), b"strm") is False
|
||||
|
||||
|
||||
def test_sync_registers_enabled_openbridges_only() -> None:
|
||||
config = _config()
|
||||
config["SYSTEMS"]["HOTSPOT"] = {"MODE": "MASTER", "ENABLED": True}
|
||||
config["SYSTEMS"]["OBP-OFF"] = {"MODE": "OPENBRIDGE", "ENABLED": False}
|
||||
store = MeshSessionStore()
|
||||
store.sync(config)
|
||||
assert "OBP-FR" in store
|
||||
assert "HOTSPOT" not in store
|
||||
assert "OBP-OFF" not in store
|
||||
assert len(store) == 1
|
||||
|
||||
|
||||
def test_a_reload_that_moves_the_peer_wins_over_what_was_learned() -> None:
|
||||
"""Editing TARGET_IP in the YAML is the way out of a bad learned address."""
|
||||
config = _config()
|
||||
store = MeshSessionStore()
|
||||
store.sync(config)
|
||||
store.session("OBP-FR").learn_peer(_ELSEWHERE, at=_NOW)
|
||||
|
||||
config["SYSTEMS"]["OBP-FR"]["TARGET_SOCK"] = ("44.31.61.66", 62032)
|
||||
store.sync(config)
|
||||
session = store.session("OBP-FR")
|
||||
assert session.configured_peer == ("44.31.61.66", 62032)
|
||||
assert session.learned_peer is None
|
||||
assert session.peer == ("44.31.61.66", 62032)
|
||||
|
||||
|
||||
def test_a_reload_that_changes_nothing_keeps_what_was_learned() -> None:
|
||||
config = _config()
|
||||
store = MeshSessionStore()
|
||||
store.sync(config)
|
||||
store.session("OBP-FR").learn_peer(_ELSEWHERE, at=_NOW)
|
||||
store.session("OBP-FR").note_keepalive(_NOW)
|
||||
|
||||
store.sync(config)
|
||||
session = store.session("OBP-FR")
|
||||
assert session.peer == _ELSEWHERE
|
||||
assert session.last_keepalive == _NOW
|
||||
|
||||
|
||||
def test_a_bridge_removed_from_the_config_loses_its_session() -> None:
|
||||
config = _config()
|
||||
store = MeshSessionStore()
|
||||
store.sync(config)
|
||||
config["SYSTEMS"].pop("OBP-FR")
|
||||
store.sync(config)
|
||||
assert "OBP-FR" not in store
|
||||
|
||||
|
||||
def test_dropping_a_session_by_hand() -> None:
|
||||
store = MeshSessionStore()
|
||||
store.session("OBP-FR")
|
||||
store.drop("OBP-FR")
|
||||
assert store.get("OBP-FR") is None
|
||||
|
||||
|
||||
def test_the_store_lives_beside_the_other_runtime_tables() -> None:
|
||||
config = _config()
|
||||
store = mesh_sessions(config)
|
||||
assert config["_MESH_SESSIONS"] is store
|
||||
assert mesh_sessions(config) is store
|
||||
assert store.get("OBP-FR") is not None
|
||||
|
||||
|
||||
def test_one_session_per_system_from_the_server_config() -> None:
|
||||
config = _config()
|
||||
session = obp_session(config, "OBP-FR")
|
||||
assert session.configured_peer == _CONFIGURED
|
||||
assert obp_session(config, "OBP-FR") is session
|
||||
|
||||
|
||||
def test_asking_for_an_unknown_system_creates_an_empty_session() -> None:
|
||||
"""Callers ask by name; a system with no OBP config simply has no peer."""
|
||||
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
|
||||
@ -0,0 +1,424 @@
|
||||
# ADN DMR Peer Server - tests infrastructure obp replay
|
||||
#
|
||||
# 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
|
||||
###############################################################################
|
||||
|
||||
"""Replaying a capture: read the pcap, ask the engine, report the verdict."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from adn_server.domain import bytes_3, bytes_4
|
||||
from adn_server.infrastructure.hbp_constants import BCKA, DMRD
|
||||
from adn_server.infrastructure.mesh.dmre_v5 import build_dmre
|
||||
from adn_server.infrastructure.mesh.obp_v1 import build_bcka, build_dmrd_v1
|
||||
from adn_server.infrastructure.obp_replay import format_report, replay, run_replay
|
||||
from adn_server.infrastructure.pcap import CaptureError, read_udp
|
||||
|
||||
_PASSPHRASE = (b"test-passphrase" + b"\x00" * 20)[:20]
|
||||
_FR = ("82.65.127.86", 62201)
|
||||
_PT = ("85.241.222.7", 62268)
|
||||
_NOW = 1_800_000_000.0
|
||||
|
||||
|
||||
# --- building a capture on the fly -------------------------------------------
|
||||
|
||||
|
||||
def _udp_packet(source: tuple[str, int], destination: tuple[str, int], payload: bytes) -> bytes:
|
||||
src_ip = bytes(int(part) for part in source[0].split("."))
|
||||
dst_ip = bytes(int(part) for part in destination[0].split("."))
|
||||
udp = struct.pack(">HHHH", source[1], destination[1], 8 + len(payload), 0) + payload
|
||||
total = 20 + len(udp)
|
||||
ip = struct.pack(">BBHHHBBH", 0x45, 0, total, 0, 0, 64, 17, 0) + src_ip + dst_ip
|
||||
ethernet = b"\x02" * 6 + b"\x03" * 6 + b"\x08\x00"
|
||||
return ethernet + ip + udp
|
||||
|
||||
|
||||
def write_pcap(path, frames: list[tuple[float, tuple, tuple, bytes]]) -> None:
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(struct.pack("<IHHiIII", 0xA1B2C3D4, 2, 4, 0, 0, 262144, 1))
|
||||
for when, source, destination, payload in frames:
|
||||
packet = _udp_packet(source, destination, payload)
|
||||
fh.write(struct.pack("<IIII", int(when), int(when % 1 * 1_000_000), len(packet), len(packet)))
|
||||
fh.write(packet)
|
||||
|
||||
|
||||
def _voice(*, src: int = 2130001, dst: int = 214, bits: int = 0x21, stream: int = 0x1234) -> bytes:
|
||||
body = b"".join(
|
||||
[
|
||||
DMRD,
|
||||
bytes([1]),
|
||||
bytes_3(src),
|
||||
bytes_3(dst),
|
||||
bytes_4(20840),
|
||||
bytes([bits]),
|
||||
bytes_4(stream),
|
||||
b"\x00" * 33,
|
||||
]
|
||||
)
|
||||
return build_dmrd_v1(body, bytes_4(20840), _PASSPHRASE)
|
||||
|
||||
|
||||
def _voice_v5(*, dst: int = 214, age: float = 0.0, now: float = _NOW) -> bytes:
|
||||
body = b"".join(
|
||||
[
|
||||
DMRD,
|
||||
bytes([1]),
|
||||
bytes_3(2130001),
|
||||
bytes_3(dst),
|
||||
bytes_4(20840),
|
||||
bytes([0x21]),
|
||||
bytes_4(0x4321),
|
||||
b"\x00" * 33,
|
||||
]
|
||||
)
|
||||
packet = build_dmre(
|
||||
body,
|
||||
server_id=bytes_4(20840),
|
||||
ber=b"\x00",
|
||||
rssi=b"\x00",
|
||||
embedded_ver=5,
|
||||
timestamp_ns=int((now - age) * 1_000_000_000),
|
||||
source_server=bytes_4(2084),
|
||||
source_rptr=bytes_4(0),
|
||||
hops=b"\x00",
|
||||
passphrase=_PASSPHRASE,
|
||||
extended_layout=True,
|
||||
)
|
||||
assert packet is not None
|
||||
return packet
|
||||
|
||||
|
||||
def _config() -> dict:
|
||||
return {
|
||||
"GLOBAL": {
|
||||
"SERVER_ID": bytes_4(21310),
|
||||
"USE_ACL": False,
|
||||
"SUB_ACL": (True, []),
|
||||
"TG1_ACL": (True, []),
|
||||
},
|
||||
"SYSTEMS": {
|
||||
"OBP-FR": {
|
||||
"MODE": "OPENBRIDGE",
|
||||
"ENABLED": True,
|
||||
"NETWORK_ID": bytes_4(20840),
|
||||
"PASSPHRASE": _PASSPHRASE,
|
||||
"TARGET_IP": _FR[0],
|
||||
"TARGET_PORT": _FR[1],
|
||||
"TARGET_SOCK": _FR,
|
||||
"RELAX_CHECKS": False,
|
||||
"VER": 1,
|
||||
"ENHANCED_OBP": True,
|
||||
"_REPORT_PORT": 62201,
|
||||
},
|
||||
"HOTSPOT": {"MODE": "MASTER", "ENABLED": True},
|
||||
},
|
||||
"_SERVER_IDS": set(),
|
||||
}
|
||||
|
||||
|
||||
# --- the pcap reader ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_reading_udp_out_of_a_capture(tmp_path) -> None:
|
||||
capture = tmp_path / "obp.pcap"
|
||||
write_pcap(capture, [(_NOW, _FR, ("10.0.0.1", 62201), b"hello")])
|
||||
datagrams = list(read_udp(capture))
|
||||
assert len(datagrams) == 1
|
||||
assert datagrams[0].source == _FR
|
||||
assert datagrams[0].destination == ("10.0.0.1", 62201)
|
||||
assert datagrams[0].payload == b"hello"
|
||||
assert int(datagrams[0].timestamp) == int(_NOW)
|
||||
|
||||
|
||||
def test_a_file_that_is_not_a_capture(tmp_path) -> None:
|
||||
path = tmp_path / "nope.pcap"
|
||||
path.write_bytes(b"not a capture at all, really" * 2)
|
||||
with pytest.raises(CaptureError):
|
||||
list(read_udp(path))
|
||||
|
||||
|
||||
def test_pcapng_says_how_to_convert_it(tmp_path) -> None:
|
||||
path = tmp_path / "new.pcapng"
|
||||
path.write_bytes(b"\x0a\x0d\x0d\x0a" + b"\x00" * 40)
|
||||
with pytest.raises(CaptureError, match="editcap"):
|
||||
list(read_udp(path))
|
||||
|
||||
|
||||
def test_reading_a_linux_cooked_v2_capture(tmp_path) -> None:
|
||||
"""``tcpdump -i any`` on a recent libpcap writes SLL2, not Ethernet."""
|
||||
capture = tmp_path / "any.pcap"
|
||||
packet = _udp_packet(_FR, ("10.0.0.1", 62201), b"hello")[14:] # drop the ethernet header
|
||||
sll2 = struct.pack(">HHIHBB", 0x0800, 0, 2, 1, 0, 6) + b"\x02" * 8
|
||||
with open(capture, "wb") as fh:
|
||||
fh.write(struct.pack("<IHHiIII", 0xA1B2C3D4, 2, 4, 0, 0, 262144, 276))
|
||||
frame = sll2 + packet
|
||||
fh.write(struct.pack("<IIII", int(_NOW), 0, len(frame), len(frame)))
|
||||
fh.write(frame)
|
||||
datagrams = list(read_udp(capture))
|
||||
assert [d.payload for d in datagrams] == [b"hello"]
|
||||
assert datagrams[0].source == _FR
|
||||
|
||||
|
||||
def _write_raw(path, linktype: int, frame: bytes) -> None:
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(struct.pack("<IHHiIII", 0xA1B2C3D4, 2, 4, 0, 0, 262144, linktype))
|
||||
fh.write(struct.pack("<IIII", int(_NOW), 0, len(frame), len(frame)))
|
||||
fh.write(frame)
|
||||
|
||||
|
||||
def _ip_udp() -> bytes:
|
||||
return _udp_packet(_FR, ("10.0.0.1", 62201), b"hello")[14:]
|
||||
|
||||
|
||||
def test_reading_a_raw_ip_capture(tmp_path) -> None:
|
||||
capture = tmp_path / "raw.pcap"
|
||||
_write_raw(capture, 101, _ip_udp())
|
||||
assert [d.payload for d in read_udp(capture)] == [b"hello"]
|
||||
|
||||
|
||||
def test_reading_a_loopback_capture(tmp_path) -> None:
|
||||
capture = tmp_path / "null.pcap"
|
||||
_write_raw(capture, 0, struct.pack("<I", 2) + _ip_udp())
|
||||
assert [d.payload for d in read_udp(capture)] == [b"hello"]
|
||||
|
||||
|
||||
def test_reading_a_vlan_tagged_frame(tmp_path) -> None:
|
||||
capture = tmp_path / "vlan.pcap"
|
||||
tagged = b"\x02" * 6 + b"\x03" * 6 + b"\x81\x00" + b"\x00\x64" + b"\x08\x00" + _ip_udp()
|
||||
_write_raw(capture, 1, tagged)
|
||||
assert [d.payload for d in read_udp(capture)] == [b"hello"]
|
||||
|
||||
|
||||
def test_reading_an_ipv6_datagram(tmp_path) -> None:
|
||||
capture = tmp_path / "v6.pcap"
|
||||
payload = b"hello"
|
||||
udp = struct.pack(">HHHH", _FR[1], 62201, 8 + len(payload), 0) + payload
|
||||
header = struct.pack(">IHBB", 0x60000000, len(udp), 17, 64)
|
||||
source = bytes.fromhex("20010db8000000000000000000000001")
|
||||
destination = bytes.fromhex("20010db8000000000000000000000002")
|
||||
_write_raw(capture, 101, header + source + destination + udp)
|
||||
datagrams = list(read_udp(capture))
|
||||
assert datagrams[0].payload == payload
|
||||
assert datagrams[0].source == ("2001:db8:0:0:0:0:0:1", _FR[1])
|
||||
|
||||
|
||||
def test_frames_that_are_not_udp_are_skipped(tmp_path) -> None:
|
||||
capture = tmp_path / "tcp.pcap"
|
||||
packet = bytearray(_ip_udp())
|
||||
packet[9] = 6 # TCP
|
||||
_write_raw(capture, 101, bytes(packet))
|
||||
assert list(read_udp(capture)) == []
|
||||
|
||||
|
||||
def test_an_unknown_link_layer_is_skipped(tmp_path) -> None:
|
||||
capture = tmp_path / "weird.pcap"
|
||||
_write_raw(capture, 999, _ip_udp())
|
||||
assert list(read_udp(capture)) == []
|
||||
|
||||
|
||||
def test_a_truncated_record_ends_the_walk(tmp_path) -> None:
|
||||
capture = tmp_path / "cut.pcap"
|
||||
_write_raw(capture, 101, _ip_udp())
|
||||
with open(capture, "ab") as fh:
|
||||
fh.write(struct.pack("<IIII", int(_NOW), 0, 200, 200) + b"\x45" * 10)
|
||||
assert len(list(read_udp(capture))) == 1
|
||||
|
||||
|
||||
# --- the replay ---------------------------------------------------------------
|
||||
|
||||
|
||||
def _replay(frames: list[tuple], config: dict | None = None, **kwargs):
|
||||
from adn_server.infrastructure.pcap import CapturedDatagram
|
||||
|
||||
datagrams = [
|
||||
CapturedDatagram(timestamp=when, source=src, destination=dst, payload=payload)
|
||||
for when, src, dst, payload in frames
|
||||
]
|
||||
return replay(config or _config(), datagrams, **kwargs)
|
||||
|
||||
|
||||
def test_a_good_frame_from_the_configured_peer_is_delivered() -> None:
|
||||
verdicts = _replay([(_NOW, _FR, ("10.0.0.1", 62201), _voice())])
|
||||
assert [v.outcome for v in verdicts] == ["delivered"]
|
||||
assert verdicts[0].system == "OBP-FR"
|
||||
assert (verdicts[0].rf_src, verdicts[0].dst_id) == (2130001, 214)
|
||||
assert verdicts[0].kind == "DMRD v1"
|
||||
|
||||
|
||||
def test_a_local_talkgroup_is_reported_with_its_reason_and_quench() -> None:
|
||||
verdicts = _replay([(_NOW, _FR, ("10.0.0.1", 62201), _voice(dst=9))])
|
||||
assert verdicts[0].outcome == "dropped"
|
||||
assert verdicts[0].reason == "tg-filter"
|
||||
assert verdicts[0].quench is True
|
||||
|
||||
|
||||
def test_a_frame_from_an_unexpected_address_is_refused_when_checks_are_strict() -> None:
|
||||
verdicts = _replay([(_NOW, ("9.9.9.9", 40000), ("10.0.0.1", 62201), _voice())])
|
||||
assert verdicts[0].outcome == "refused"
|
||||
assert "source" in verdicts[0].reason
|
||||
|
||||
|
||||
def test_a_frame_no_bridge_can_verify_is_left_unmatched() -> None:
|
||||
config = _config()
|
||||
config["SYSTEMS"]["OBP-FR"]["PASSPHRASE"] = (b"another" + b"\x00" * 20)[:20]
|
||||
verdicts = _replay([(_NOW, _FR, ("10.0.0.1", 62201), _voice())], config)
|
||||
assert verdicts[0].outcome == "unmatched"
|
||||
assert verdicts[0].system is None
|
||||
|
||||
|
||||
def test_a_v5_frame_is_replayed_with_the_time_it_was_captured() -> None:
|
||||
config = _config()
|
||||
config["SYSTEMS"]["OBP-FR"]["VER"] = 5
|
||||
verdicts = _replay([(_NOW, _FR, ("10.0.0.1", 62201), _voice_v5())], config)
|
||||
assert verdicts[0].kind == "DMRE v5"
|
||||
assert verdicts[0].outcome == "delivered"
|
||||
|
||||
|
||||
def test_a_v5_frame_that_was_already_late_when_captured_is_dropped() -> None:
|
||||
config = _config()
|
||||
config["SYSTEMS"]["OBP-FR"]["VER"] = 5
|
||||
verdicts = _replay([(_NOW, _FR, ("10.0.0.1", 62201), _voice_v5(age=9.0))], config)
|
||||
assert verdicts[0].reason == "stale-packet"
|
||||
|
||||
|
||||
def test_a_v1_frame_on_a_v5_link_asks_the_peer_for_its_version() -> None:
|
||||
config = _config()
|
||||
config["SYSTEMS"]["OBP-FR"]["VER"] = 5
|
||||
verdicts = _replay([(_NOW, _FR, ("10.0.0.1", 62201), _voice())], config)
|
||||
assert verdicts[0].reason == "proto-version"
|
||||
|
||||
|
||||
def test_control_frames_are_named_not_judged() -> None:
|
||||
verdicts = _replay([(_NOW, _FR, ("10.0.0.1", 62201), build_bcka(_PASSPHRASE))])
|
||||
assert verdicts[0].kind == "BCKA"
|
||||
assert verdicts[0].outcome == "control"
|
||||
assert verdicts[0].datagram.payload[:4] == BCKA
|
||||
|
||||
|
||||
def test_the_bridge_is_chosen_by_the_port_the_frame_arrived_on() -> None:
|
||||
"""Two bridges, one passphrase: the local port is the evidence that tells them apart."""
|
||||
config = _config()
|
||||
config["SYSTEMS"]["OBP-PT"] = {
|
||||
**config["SYSTEMS"]["OBP-FR"],
|
||||
"TARGET_IP": _PT[0],
|
||||
"TARGET_PORT": _PT[1],
|
||||
"TARGET_SOCK": _PT,
|
||||
"RELAX_CHECKS": True,
|
||||
"_REPORT_PORT": 62268,
|
||||
}
|
||||
verdicts = _replay([(_NOW, ("203.0.113.9", 50000), ("10.0.0.1", 62268), _voice())], config)
|
||||
assert verdicts[0].system == "OBP-PT"
|
||||
|
||||
|
||||
def test_only_the_system_that_was_asked_for() -> None:
|
||||
config = _config()
|
||||
config["SYSTEMS"]["OBP-PT"] = {**config["SYSTEMS"]["OBP-FR"], "_REPORT_PORT": 62268}
|
||||
verdicts = _replay([(_NOW, _FR, ("10.0.0.1", 62201), _voice())], config, system="OBP-PT")
|
||||
assert verdicts[0].system == "OBP-PT"
|
||||
with pytest.raises(KeyError):
|
||||
_replay([], config, system="OBP-NOPE")
|
||||
|
||||
|
||||
def test_what_this_server_sent_is_not_judged_as_ingress() -> None:
|
||||
"""An unfiltered capture carries both directions; egress is not ours to admit."""
|
||||
verdicts = _replay([(_NOW, ("10.0.0.1", 62201), _FR, _voice())])
|
||||
assert verdicts[0].outcome == "outbound"
|
||||
assert verdicts[0].system is None
|
||||
|
||||
|
||||
def test_both_directions_can_be_judged_on_purpose() -> None:
|
||||
verdicts = _replay([(_NOW, ("10.0.0.1", 62201), _FR, _voice())], only_inbound=False)
|
||||
assert verdicts[0].outcome != "outbound"
|
||||
|
||||
|
||||
def test_server_ids_are_not_validated_without_the_table() -> None:
|
||||
"""The server-id list is loaded at runtime: offline it cannot be the reason."""
|
||||
config = _config()
|
||||
config["SYSTEMS"]["OBP-FR"]["VER"] = 5
|
||||
config["GLOBAL"]["VALIDATE_SERVER_IDS"] = True
|
||||
verdicts = _replay([(_NOW, _FR, ("10.0.0.1", 62201), _voice_v5())], config)
|
||||
assert verdicts[0].outcome == "delivered"
|
||||
config["_SERVER_IDS"] = {"9999"}
|
||||
verdicts = _replay([(_NOW, _FR, ("10.0.0.1", 62201), _voice_v5())], config)
|
||||
assert verdicts[0].reason == "source-server-unknown"
|
||||
|
||||
|
||||
def test_the_report_tallies_what_happened() -> None:
|
||||
verdicts = _replay(
|
||||
[
|
||||
(_NOW, _FR, ("10.0.0.1", 62201), _voice(stream=1)),
|
||||
(_NOW, _FR, ("10.0.0.1", 62201), _voice(dst=9, stream=2)),
|
||||
(_NOW, _FR, ("10.0.0.1", 62201), _voice(dst=9990, stream=3)),
|
||||
]
|
||||
)
|
||||
report = format_report(verdicts, capture="obp.pcap")
|
||||
assert "3 datagram(s)" in report
|
||||
assert "OBP-FR" in report
|
||||
assert "delivered" in report
|
||||
assert "dropped: tg-filter" in report
|
||||
|
||||
|
||||
def test_the_report_can_skip_the_per_frame_lines() -> None:
|
||||
verdicts = _replay([(_NOW, _FR, ("10.0.0.1", 62201), _voice())])
|
||||
summary = format_report(verdicts, capture="obp.pcap", verbose=False)
|
||||
assert "delivered" in summary
|
||||
assert time.strftime("%H:%M:%S", time.localtime(_NOW)) not in summary
|
||||
|
||||
|
||||
# --- the command ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_run_replay_prints_a_report(tmp_path, capsys) -> None:
|
||||
capture = tmp_path / "obp.pcap"
|
||||
write_pcap(
|
||||
capture,
|
||||
[
|
||||
(_NOW, _FR, ("10.0.0.1", 62201), _voice(stream=1)),
|
||||
(_NOW + 1, _FR, ("10.0.0.1", 62201), _voice(dst=9, stream=2)),
|
||||
],
|
||||
)
|
||||
assert run_replay(_config(), str(capture)) == 0
|
||||
printed = capsys.readouterr().out
|
||||
assert "2 datagram(s)" in printed
|
||||
assert "tg-filter" in printed
|
||||
|
||||
|
||||
def test_run_replay_stops_at_the_limit(tmp_path, capsys) -> None:
|
||||
capture = tmp_path / "obp.pcap"
|
||||
write_pcap(capture, [(_NOW + i, _FR, ("10.0.0.1", 62201), _voice(stream=i)) for i in range(5)])
|
||||
assert run_replay(_config(), str(capture), limit=2) == 0
|
||||
assert "2 datagram(s)" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_run_replay_reports_a_bad_capture(tmp_path, capsys) -> None:
|
||||
path = tmp_path / "broken.pcap"
|
||||
path.write_bytes(b"x" * 64)
|
||||
assert run_replay(_config(), str(path)) == 1
|
||||
assert "ERROR capture" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_run_replay_reports_an_unknown_system(tmp_path, capsys) -> None:
|
||||
capture = tmp_path / "obp.pcap"
|
||||
write_pcap(capture, [(_NOW, _FR, ("10.0.0.1", 62201), _voice())])
|
||||
assert run_replay(_config(), str(capture), system="NOPE") == 1
|
||||
assert "ERROR system" in capsys.readouterr().err
|
||||
Loading…
Reference in new issue