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
ce5rpy 1 week ago committed by GitHub
commit 076c200d5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -46,6 +46,51 @@ Example: migrate `OBP-CL2` to the shared fan-in while `OBP-EU` keeps `PORT: 6299
- `NETWORK_ID` must be unique among enabled OPENBRIDGE systems.
- `LISTEN_PORT` must not collide with any OPENBRIDGE `PORT` when `BIND_LEGACY_PORTS` is true.
- `RELAX_CHECKS: true` is recommended so `TARGET_SOCK` is learned from the first valid packet.
- `RELAX_CHECKS: true` is recommended so the peer address is learned from the first valid packet. What is learned lives in the bridge's session, not in the config: `TARGET_IP` / `TARGET_PORT` stay as written, and a reload puts the link back on them.
## Why did that call not cross?
Replay a capture through the same ingress the server runs, offline and against
your own `adn-server.yaml`. Nothing is sent and no port is bound, so the server
can keep running:
```bash
tcpdump -i any -n -s 0 -w /tmp/obp.pcap 'udp and portrange 62000-63000' # a minute is plenty
adn-server -c adn-server.yaml --replay /tmp/obp.pcap
```
Capture a port range rather than a list of ports: a peer that answers from
somewhere unexpected is exactly the case worth looking at, and a narrow filter
hides it.
Each frame comes back with the bridge it belongs to and a verdict:
```
12:04:31 82.65.127.86:62201 OBP-FR DMRD v1 2130001 -> 214 delivered
12:04:31 85.241.222.7:62268 OBP-PT DMRE v5 2680015 -> 9 dropped (tg-filter-server) +BCSQ
12:04:32 203.0.113.9:50000 - DMRD v1 unmatched
3 datagram(s)
OBP-FR
1 delivered
OBP-PT
1 dropped: tg-filter-server
(no bridge)
1 unmatched
```
`unmatched` means no enabled bridge could verify the frame with its passphrase,
and `outbound` is what this server sent — an unfiltered capture holds both
directions and only what arrived is judged (`--replay-both-directions` judges
the rest too). Add `--system OBP-FR` to look at one link, `--replay-limit N` to
stop early and `--replay-summary` for the tally alone. Classic pcap only;
convert a pcapng with `editcap -F pcap in.pcapng out.pcap`.
Two things the report is not. It stops where routing begins: on a mesh the same
call legitimately arrives on several bridges at once, and it is loop control —
later, in routing — that keeps one and drops the rest, so a call the server
logged once may show as delivered on more than one link here. And the alias
tables and the server-id list are loaded at runtime, not from the YAML, so
offline those two checks are skipped rather than guessed.
See also: [OpenBridge protocol](../protocols/openbridge.md).

@ -44,6 +44,52 @@ Ejemplo: migrar `OBP-CL2` al fan-in compartido mientras `OBP-EU` conserva `PORT:
- `NETWORK_ID` único entre OPENBRIDGE habilitados.
- `LISTEN_PORT` sin colisión con ningún `PORT` de sección si `BIND_LEGACY_PORTS` es true.
- `RELAX_CHECKS: true` recomendado para aprender `TARGET_SOCK` del primer paquete válido.
- `RELAX_CHECKS: true` recomendado para aprender la dirección del peer del primer paquete válido. Lo aprendido vive en la sesión del bridge, no en la config: `TARGET_IP` / `TARGET_PORT` se quedan como están escritos, y una recarga devuelve el enlace a ellos.
## ¿Por qué no cruzó esa llamada?
Pasa una captura por el mismo ingress que corre el servidor, sin conexión y
contra tu propio `adn-server.yaml`. No se envía nada ni se abre ningún puerto,
así que el servidor puede seguir funcionando:
```bash
tcpdump -i any -n -s 0 -w /tmp/obp.pcap 'udp and portrange 62000-63000' # con un minuto sobra
adn-server -c adn-server.yaml --replay /tmp/obp.pcap
```
Captura un rango de puertos, no una lista: un peer que contesta desde donde no
se espera es justo el caso que interesa mirar, y un filtro estrecho lo esconde.
Cada trama vuelve con el bridge al que pertenece y un veredicto:
```
12:04:31 82.65.127.86:62201 OBP-FR DMRD v1 2130001 -> 214 delivered
12:04:31 85.241.222.7:62268 OBP-PT DMRE v5 2680015 -> 9 dropped (tg-filter-server) +BCSQ
12:04:32 203.0.113.9:50000 - DMRD v1 unmatched
3 datagram(s)
OBP-FR
1 delivered
OBP-PT
1 dropped: tg-filter-server
(no bridge)
1 unmatched
```
`unmatched` significa que ningún bridge habilitado pudo verificar la trama con
su passphrase, y `outbound` es lo que ha enviado este servidor — una captura sin
filtrar lleva las dos direcciones y solo se juzga lo que llegó
(`--replay-both-directions` juzga también el resto). `--system OBP-FR` mira un
solo enlace, `--replay-limit N` corta antes y `--replay-summary` deja solo el
recuento. Solo pcap clásico; un pcapng se convierte con
`editcap -F pcap in.pcapng out.pcap`.
Dos cosas que el informe no es. Termina donde empieza el enrutado: en una malla
la misma llamada llega legítimamente por varios bridges a la vez, y es el
control de bucles —después, en routing— quien se queda con una y descarta el
resto, así que una llamada que el servidor registró una vez puede aparecer aquí
entregada en más de un enlace. Y las tablas de alias y la lista de server-ids se
cargan en ejecución, no del YAML, así que sin conexión esas dos comprobaciones
se saltan en vez de adivinarse.
Ver también: [protocolo OpenBridge](../protocols/openbridge.md).

@ -26,6 +26,7 @@ import time
from typing import Any
from adn_server.domain import int_id
from adn_server.domain.mesh_session import MeshSessionStore, ObpBridgeSession
from .payloads import _peer_field_json, build_topology
@ -73,17 +74,15 @@ def _upstream_peer_block(name: str, cfg: dict[str, Any]) -> dict[str, Any]:
return block
def _obp_ka_connected(cfg: dict[str, Any], now: float) -> bool | None:
def _obp_ka_connected(
cfg: dict[str, Any], session: ObpBridgeSession | None, now: float
) -> bool | None:
"""BCKA keepalive status for ENHANCED OBP legs; ``None`` when KA gating does not apply."""
if not cfg.get("ENHANCED_OBP"):
return None
bcka = cfg.get("_bcka")
if bcka is None:
return False
try:
return float(bcka) >= now - 60
except (TypeError, ValueError):
if session is None:
return False
return session.keepalive_ok(now)
def _openbridge_block(
@ -92,6 +91,7 @@ def _openbridge_block(
topology_row: dict[str, Any] | None,
*,
now: float,
session: ObpBridgeSession | None = None,
) -> dict[str, Any]:
"""Enabled OPENBRIDGE legs (``CTABLE.OPENBRIDGES``); STREAMS stay empty here (live chips = monitor/voice)."""
del name
@ -106,7 +106,7 @@ def _openbridge_block(
block["port"] = int(row["port"])
if row.get("enhanced_obp") or cfg.get("ENHANCED_OBP"):
block["enhanced_obp"] = True
connected = _obp_ka_connected(cfg, now)
connected = _obp_ka_connected(cfg, session, now)
if connected is not None:
block["connected"] = connected
return block
@ -117,6 +117,7 @@ def build_dashboard_state(
*,
server_id: str | None = None,
ts: float | None = None,
sessions: MeshSessionStore | None = None,
) -> dict[str, Any]:
"""Slim linked-systems view (masters with peers, homebrew peers, openbridges).
@ -160,7 +161,13 @@ def build_dashboard_state(
block["port"] = int(topo["port"])
masters[name] = block
elif mode == "OPENBRIDGE":
openbridges[name] = _openbridge_block(name, cfg, topo, now=epoch)
openbridges[name] = _openbridge_block(
name,
cfg,
topo,
now=epoch,
session=sessions.get(name) if sessions is not None else None,
)
elif mode in ("PEER", "XLXPEER") and _upstream_peer_connected(cfg):
peers[name] = _upstream_peer_block(name, cfg)

@ -29,6 +29,7 @@ import logging
import time
from typing import Any
from ..domain.mesh_session import obp_session
from .ports import ReportSender
logger = logging.getLogger(__name__)
@ -62,10 +63,19 @@ class ReportingUseCases:
if not sys_cfg.get("ENABLED", True):
continue
if sys_cfg.get("MODE") == "OPENBRIDGE" and sys_cfg.get("ENHANCED_OBP"):
if "_bcka" not in sys_cfg:
session = obp_session(self._config, system_name)
if not session.keepalive_seen:
logger.warning("(ROUTER) not sending to system %s as KeepAlive never seen", system_name)
elif sys_cfg["_bcka"] < now - 60:
elif session.keepalive_stale(now):
logger.warning(
"(ROUTER) not sending to system %s as last KeepAlive was %s seconds ago",
system_name, int(now - sys_cfg["_bcka"]),
system_name, int(session.keepalive_age(now) or 0),
)
if session.drops:
# Why this bridge refused frames, by reason: the answer to
# "my call does not cross" without reading the whole log.
logger.debug(
"(ROUTER) system %s refused frames: %s",
system_name,
", ".join(f"{reason}={count}" for reason, count in sorted(session.drops.items())),
)

@ -48,6 +48,7 @@ from typing import Any
from ...domain import HBPF_DATA_SYNC, HBPF_SLT_VHEAD, bytes_3, bytes_4, int_id
from ...domain.hbp_protocol import HBPF_SLT_VTERM, STREAM_TO
from ...domain.mesh_session import obp_session
from ..server_voice import DEFAULT_SERVER_VOICE_ID
PeerVoiceSlotRow = dict[str, Any]
@ -1145,24 +1146,10 @@ def is_ua_session_tgid(tgid: int) -> bool:
def obp_target_bcsq_quenches_stream(
systems_cfg: dict[str, Any], target_name: str, dst_id_b: bytes, stream_id: bytes
config: dict[str, Any], target_name: str, dst_id_b: bytes, stream_id: bytes
) -> bool:
"""True if target OBP config has _bcsq[tgid]==stream_id (bytes key or same int TG)."""
m = systems_cfg.get(target_name, {}).get("_bcsq")
if not isinstance(m, dict) or not m:
return False
tid = dst_id_b[:3] if isinstance(dst_id_b, bytes) and len(dst_id_b) >= 3 else bytes_3(int_id(dst_id_b))
if m.get(tid) == stream_id:
return True
for k, v in m.items():
if v != stream_id:
continue
try:
if isinstance(k, bytes) and len(k) >= 3 and int_id(k) == int_id(tid):
return True
except Exception:
continue
return False
"""True when the target OBP has quenched this stream for this talkgroup."""
return obp_session(config, target_name).quenches(dst_id_b, stream_id)
def _peer_key_from_int(peer_key: Any) -> bytes:

@ -466,7 +466,7 @@ class ObpForwardMixin:
"""Legacy sendDataToOBP: forward a unit-data packet to an OPENBRIDGE target."""
systems_cfg = self._config.get("SYSTEMS", {})
_target_system = systems_cfg.get(target, {})
if _target_system.get("ENHANCED_OBP") and "_bcka" in _target_system and _target_system["_bcka"] < pkt_time - 60:
if _target_system.get("ENHANCED_OBP") and self._obp_session(target).keepalive_stale(pkt_time):
return
protocols = self._get_protocols() if self._get_protocols else {}
target_proto = protocols.get(target)

@ -314,12 +314,7 @@ class RoutingTimerMixin:
self._obp_emit_end_tx_for_forward_legs(stream_id, system_name, now)
continue
for stream_id in to_remove:
_syscfg = systems_cfg.get(system_name, {})
_bmap = _syscfg.get("_bcsq")
if isinstance(_bmap, dict):
for _tgid_k, _sid in list(_bmap.items()):
if _sid == stream_id:
_bmap.pop(_tgid_k, None)
self._obp_session(system_name).release_stream(stream_id)
st_rem = obp_status.get(stream_id)
if isinstance(st_rem, dict) and _obp_status_is_forward_leg(st_rem):
self._obp_emit_end_tx_forward_leg(system_name, stream_id, st_rem, now)

@ -48,6 +48,7 @@ from ..domain import (
int_id,
)
from ..domain.dmr import bptc
from ..domain.mesh_session import ObpBridgeSession, obp_session
from .ports import AclRouter, DmrEmbeddedLcEncoder, SubscriptionStore, TalkerAliasEmblcEncoder
from .reporting_use_cases import ReportingUseCases
from .routing.hbp_forward import HbpForwardMixin
@ -97,6 +98,10 @@ class RoutingUseCases(
):
"""Use cases for subscription-based voice routing."""
def _obp_session(self, system_name: str) -> ObpBridgeSession:
"""Live state of an OPENBRIDGE leg (peer, keepalive, quench)."""
return obp_session(self._config, system_name)
def __init__(
self,
acl_router: AclRouter,
@ -570,12 +575,11 @@ class RoutingUseCases(
if isinstance(target_tgid, int):
target_tgid = bytes_3(target_tgid)
# If target has quenched us, don't send (~1856-1859).
if obp_target_bcsq_quenches_stream(systems_cfg, entry["SYSTEM"], dst_id_b, stream_id):
if obp_target_bcsq_quenches_stream(self._config, entry["SYSTEM"], dst_id_b, stream_id):
continue
# If target has missed keepalives (ENHANCED_OBP), don't send (~1861-1863)
if _target_system.get("ENHANCED_OBP") and (
"_bcka" not in _target_system or _target_system["_bcka"] < pkt_time - 60
):
_target_session = self._obp_session(entry["SYSTEM"])
if _target_system.get("ENHANCED_OBP") and not _target_session.keepalive_ok(pkt_time):
continue
# Talkgroup ACL (global + per-system TG1) (~1865-1873)
_global_cfg = self._config.get("GLOBAL", {})
@ -1456,7 +1460,7 @@ class RoutingUseCases(
if _target_system.get("MODE") != "OPENBRIDGE":
_target_peer_id = getattr(self, "_pvt_target_peer_ids", {}).get(_target)
if _target_system.get("MODE") == "OPENBRIDGE":
if _target_system.get("ENHANCED_OBP") and "_bcka" in _target_system and _target_system["_bcka"] < pkt_time - 60:
if _target_system.get("ENHANCED_OBP") and self._obp_session(_target).keepalive_stale(pkt_time):
continue
if stream_id not in _target_status:
_target_status[stream_id] = {

@ -106,14 +106,17 @@ def prepare_reload_config(holder: RuntimeContextHolder) -> dict[str, Any]:
"""
Build a working copy for SIGHUP reload.
The live ``_SUB_MAP`` object is shared so subscriber state is not duplicated.
The live ``_SUB_MAP`` and ``_MESH_SESSIONS`` objects are shared, not copied:
subscriber state and OpenBridge sessions survive the reload, and the readers
that hold a reference to the store keep looking at the live one.
On failure the holder is unchanged; on success call ``swap`` with the merged dict.
"""
live = holder.get().config
sub_map = live.get("_SUB_MAP")
shared = {key: live.get(key) for key in ("_SUB_MAP", "_MESH_SESSIONS")}
new_config = copy.deepcopy(live)
if sub_map is not None:
new_config["_SUB_MAP"] = sub_map
for key, value in shared.items():
if value is not None:
new_config[key] = value
return new_config

@ -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",
]

@ -63,6 +63,7 @@ from adn_server.application.subscription.echo_seed import seed_echo_routing_tabl
from adn_server.application.subscription.store_sync import replace_store_from_routing_table
from adn_server.domain import bytes_4
from adn_server.domain.dmr.bptc import encode_emblc
from adn_server.domain.mesh_session import mesh_sessions
from adn_server.infrastructure.acl_router import InMemoryAclRouter
from adn_server.infrastructure.config_normalizer import (
ensure_system_runtime_config as _ensure_system_runtime_config,
@ -743,6 +744,8 @@ def run_peer_server(
swap_runtime_config(runtime_holder, new_config, config_path=config_path)
normalize_proxy_target(config)
normalize_obp_proxy_targets(config)
# Follow the new YAML: refresh configured peers, drop sessions of dead links.
mesh_sessions(config).sync(config)
report_factory.set_config(config)
mqtt_after = mqtt_settings_from_config(config)
report_mqtt = reconcile_mqtt_publisher(
@ -751,6 +754,7 @@ def run_peer_server(
mqtt_before,
mqtt_after,
report_enabled=config.get("REPORTS", {}).get("REPORT", True),
sessions=mesh_sessions(config),
)
if proxy_state is not None:
apply_proxy_config_reload(proxy_state, config, logger=logger)

@ -45,6 +45,7 @@ from .logging_config import reapply_log_level
logger = logging.getLogger(__name__)
_RUNTIME_TOP_KEYS = frozenset({
"_MESH_SESSIONS",
"_SUB_MAP",
"_SUB_IDS",
"_SUB_PROFILES",

@ -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",
]

@ -31,6 +31,7 @@ from __future__ import annotations
from typing import Any
from adn_server.application.ports import ReportWireEncoder
from adn_server.domain.mesh_session import mesh_sessions
from .opcodes import REPORT_OPCODES
from .wire import ReportWire
@ -46,6 +47,5 @@ __all__ = [
def create_report_wire(config: dict[str, Any]) -> ReportWireEncoder:
"""Return the report wire encoder (``config`` reserved for future options)."""
del config
return ReportWire()
"""Return the report wire encoder, reading OBP keepalives from the live sessions."""
return ReportWire(sessions=mesh_sessions(config))

@ -29,6 +29,7 @@ from typing import Any
from adn_server.application.ports import ReportMqttPublisher, ReportWireEncoder
from adn_server.application.report.dashboard_state import build_dashboard_state
from adn_server.domain.mesh_session import MeshSessionStore
from .mqtt_config import MQTT_PUBLISH_VOICE_EVENT, MqttSettings, mqtt_settings_from_config
from .mqtt_topics import frame_message_type, mqtt_shared_state_topic, topic_for_frame
@ -63,8 +64,9 @@ class NullReportMqttPublisher(ReportMqttPublisher):
class PahoReportMqttPublisher(ReportMqttPublisher):
"""Publish retained shared ``state`` and live ``voice_event`` only."""
def __init__(self, settings: MqttSettings) -> None:
def __init__(self, settings: MqttSettings, sessions: MeshSessionStore | None = None) -> None:
self._settings = settings
self._sessions = sessions
self._client: Any = None
self._connected = False
self._get_systems: Callable[[], dict[str, Any]] | None = None
@ -131,7 +133,11 @@ class PahoReportMqttPublisher(ReportMqttPublisher):
) -> None:
if not self._connected or self._client is None:
return
payload = build_dashboard_state(systems, server_id=_server_id_from_settings(self._settings))
payload = build_dashboard_state(
systems,
server_id=_server_id_from_settings(self._settings),
sessions=self._sessions,
)
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
content_key = json.dumps(
{"ctable": payload.get("ctable"), "server_id": payload.get("server_id")},
@ -232,6 +238,7 @@ def reconcile_mqtt_publisher(
after: MqttSettings | None,
*,
report_enabled: bool,
sessions: MeshSessionStore | None = None,
) -> ReportMqttPublisher | None:
"""Stop/start MQTT client after SIGHUP when REPORTS.MQTT settings change."""
if before == after and (current is not None) == (after is not None):
@ -246,7 +253,7 @@ def reconcile_mqtt_publisher(
if before is not None:
logger.info("(REPORT) MQTT disconnected (disabled in config reload)")
return None
publisher = create_report_mqtt_publisher_from_settings(after)
publisher = create_report_mqtt_publisher_from_settings(after, sessions=sessions)
factory.set_mqtt(publisher)
if publisher is None:
logger.warning("(REPORT) MQTT enabled in config but publisher could not start")
@ -258,14 +265,16 @@ def reconcile_mqtt_publisher(
return publisher
def create_report_mqtt_publisher_from_settings(settings: MqttSettings) -> ReportMqttPublisher | None:
def create_report_mqtt_publisher_from_settings(
settings: MqttSettings, *, sessions: MeshSessionStore | None = None
) -> ReportMqttPublisher | None:
if mqtt is None:
logger.error(
"(REPORT) MQTT enabled but paho-mqtt is missing; "
"install with: pip install 'adn-server[mqtt]'"
)
return None
return PahoReportMqttPublisher(settings)
return PahoReportMqttPublisher(settings, sessions)
def create_report_mqtt_publisher(config: dict[str, Any]) -> ReportMqttPublisher | None:

@ -28,6 +28,7 @@ import time
from typing import Any
from adn_server.application.ports import ReportWireEncoder
from adn_server.domain.mesh_session import MeshSessionStore
from adn_server.application.report import (
REPORT_FEATURES,
REPORT_PROTOCOL,
@ -58,7 +59,8 @@ def _state_dedup_key(payload: dict[str, Any]) -> bytes:
class ReportWire(ReportWireEncoder):
"""Slim monitor encoder — ``dashboard_state`` + ``routing_table`` + ``voice_event``."""
def __init__(self) -> None:
def __init__(self, sessions: MeshSessionStore | None = None) -> None:
self._sessions = sessions
self._last_state_key: bytes | None = None
self._routing_seq: int = 0
self._last_routing_snapshot: dict[str, Any] | None = None
@ -82,7 +84,7 @@ class ReportWire(ReportWireEncoder):
def state_frames(self, systems: dict[str, Any], *, force: bool = False) -> tuple[bytes, ...]:
ts = time.time()
payload = build_dashboard_state(systems, ts=ts)
payload = build_dashboard_state(systems, ts=ts, sessions=self._sessions)
key = _state_dedup_key(payload)
if not force and self._last_state_key == key:
logger.debug("(REPORT) STATE_SND unchanged, skip")

@ -79,7 +79,24 @@ from ...domain import bytes_3, bytes_4, int_id
from ...domain.dmr import decode
from ...domain.dmr.const import LC_OPT
from ...domain.hbp_protocol import normalize_fixed_width_ascii, normalize_fixed_width_bytes
from ...domain.mesh_admission import AclRules, AdmissionContext, Rejection, server_prefix
from ...domain.mesh_routing import MeshEgress, MeshIngress, PeerMeshConfig
from ...domain.mesh_engine import (
BridgePolicy,
Deliver,
Effect,
Log,
NoteStream,
Reject,
RequestVersion,
StoreTalkerAlias,
accepts_source,
ingest_dmrd_v1,
ingest_dmre_v5,
reject_v1_protocol,
server_id_bytes,
)
from ...domain.mesh_session import ObpBridgeSession, obp_session
from ...domain.talker_alias import (
DMRA_PACKET_LEN,
decode_ta_from_blocks,
@ -286,15 +303,17 @@ class HBPProtocol(DatagramProtocol):
if getattr(self, "_obp_protocol_started", False):
return
self._obp_protocol_started = True
_peer = self._session.peer
logger.info(
"(%s) Starting OBP. TARGET_IP: %s, TARGET_PORT: %s",
self._system,
self._config.get("TARGET_IP", ""),
self._config.get("TARGET_PORT", ""),
_peer[0] or "",
_peer[1],
)
# bridge_master.routerOBP.to_target skips ENHANCED targets when '_bcka' not in SYSTEMS[name].
# Seed so cross-OBP forwarding works before the first inbound BCKA/DMR on *this* leg.
self._config["_bcka"] = time.time()
# bridge_master.routerOBP.to_target skips ENHANCED targets when the keepalive
# was never seen. Seed it so cross-OBP forwarding works before the first
# inbound BCKA/DMR on *this* leg.
self._session.note_keepalive(time.time())
if self._config.get("ENHANCED_OBP"):
self._bcka_loop = task.LoopingCall(self._obp_send_bcka)
_bcka_d = self._bcka_loop.start(10)
@ -1027,14 +1046,15 @@ class HBPProtocol(DatagramProtocol):
if self._config.get("MODE") == "MASTER":
self.send_peers(_packet, _hops, _ber, _rssi, _source_server, _source_rptr)
elif self._config.get("MODE") == "OPENBRIDGE":
# Global STUN (config) or per-system BCST (hblink sets _config['_STUN'] on BCST RX)
if "STUN" in self._CONFIG or self._config.get("_STUN"):
# Global STUN (operator, in the config) or this bridge's own BCST
if "STUN" in self._CONFIG or self._session.stunned:
logger.info("(%s) Bridge STUNned, discarding", self._system)
return
if not _hops:
_hops = (1).to_bytes(1, "big")
if _packet[:3] == DMR and self._config.get("TARGET_IP"):
_target_addr = (self._config["TARGET_IP"], self._config["TARGET_PORT"])
_session = self._session
if _packet[:3] == DMR and _session.peer_known:
_target_addr = _session.peer
_ver_cfg = self._config.get("VER")
if "VER" in self._config and _ver_cfg in (2, 3):
logger.error("(%s) protocol version %s no longer supported", self._system, _ver_cfg)
@ -1050,7 +1070,7 @@ class HBPProtocol(DatagramProtocol):
if _wire is not None:
self.transport.write(_wire, _target_addr)
else:
if not self._config.get("TARGET_IP"):
if not _session.peer_known:
logger.debug("(%s) Not sent packet as TARGET_IP not currently known", self._system)
else:
logger.error("(%s) OpenBridge system was asked to send non DMR packet with send_system(): %s", self._system, _packet)
@ -2095,63 +2115,147 @@ class HBPProtocol(DatagramProtocol):
logger.error("(%s) Unhandled error in timed loop.\n %s", self._system, failure)
def _obp_send_bcka(self) -> None:
"""Legacy send_bcka: BCKA + HMAC-SHA1 to TARGET. Uses TARGET_SOCK (IP only; hostnames resolved at startup or on first peer packet)."""
_addr = self._config.get("TARGET_SOCK")
if _addr and _addr[0]:
"""Legacy send_bcka: BCKA + HMAC-SHA1 to the peer (hostnames resolved at startup)."""
_session = self._session
_addr = _session.peer
if _session.peer_known:
self.transport.write(build_bcka(_get_passphrase_bytes(self._config)), _addr)
else:
logger.debug("(%s) *BridgeControl* not sending KeepAlive, TARGET not currently known", self._system)
def _obp_send_bcve(self) -> None:
"""Legacy send_bcve: BCVE + VER byte + HMAC-SHA1. Uses TARGET_SOCK (IP only)."""
_addr = self._config.get("TARGET_SOCK")
if self._config.get("ENHANCED_OBP") and _addr and _addr[0]:
"""Legacy send_bcve: BCVE + VER byte + HMAC-SHA1 to the peer."""
_session = self._session
_addr = _session.peer
if self._config.get("ENHANCED_OBP") and _session.peer_known:
self.transport.write(build_bcve(VER, _get_passphrase_bytes(self._config)), _addr)
else:
logger.debug("(%s) *BridgeControl* not sending BCVE, TARGET not currently known", self._system)
def _obp_sync_target_sock_from_peer(self, _sockaddr: tuple[str, int], _stream_id: bytes | None = None) -> None:
"""If RELAX_CHECKS accepted traffic from a different IP:port than TARGET_SOCK, sync (same idea as BCKA).
Ensures BCSQ and outbound DMR go to the peer address we actually receive from.
"""Learn the address RELAX_CHECKS just accepted, so replies go back to it.
A peer behind per-packet load-balanced NAT can flip source address on every frame of the
same call, so the sync itself still runs every packet but the log is debug and capped to
once per stream_id (same _log_once deque idiom as _bcsq_log_once above).
The configured peer stays where the operator put it; only the session
moves. A peer behind per-packet load-balanced NAT can flip source address
on every frame of the same call, so the log is debug and capped to once
per stream_id (same _log_once deque idiom as _bcsq_log_once above).
"""
if self._config.get("MODE") != "OPENBRIDGE" or not self._config.get("RELAX_CHECKS"):
return
if not _sockaddr or not _sockaddr[0]:
return
cur = self._config.get("TARGET_SOCK")
if cur == _sockaddr:
_session = self._session
cur = _session.peer
if not _session.learn_peer(_sockaddr, at=time.time()):
return
h, p = _sockaddr[0], int(_sockaddr[1])
_once = getattr(self, "_obp_target_sync_log_once", None)
if _stream_id is None or not isinstance(_once, deque) or _stream_id not in _once:
logger.debug(
"(%s) *BridgeControl* OBP peer address sync to %s:%s (RELAX_CHECKS; was %s:%s)",
self._system,
h,
p,
_sockaddr[0],
int(_sockaddr[1]),
(cur[0] if cur and cur[0] else "?"),
(cur[1] if cur and len(cur) > 1 else "?"),
)
if _stream_id is not None and isinstance(_once, deque):
_once.append(_stream_id)
self._config["TARGET_IP"] = h
self._config["TARGET_PORT"] = p
self._config["TARGET_SOCK"] = (h, p)
@property
def _session(self) -> ObpBridgeSession:
"""Live state of this OpenBridge link (peer, keepalive, quench)."""
return obp_session(self._CONFIG, self._system)
def _obp_admission_context(self) -> AdmissionContext:
"""Snapshot of everything the admission rules need, read once per frame."""
_global = self._CONFIG.get("GLOBAL", {})
return AdmissionContext(
stunned="STUN" in self._CONFIG,
acl_check=self._router.acl_check if self._router else None,
global_rules=AclRules(
enabled=bool(_global.get("USE_ACL")),
sub_acl=_global.get("SUB_ACL", (True, [])),
tg1_acl=_global.get("TG1_ACL", (True, [])),
),
system_rules=AclRules(
enabled=bool(self._config.get("USE_ACL")),
sub_acl=self._config.get("SUB_ACL", (True, [])),
tg1_acl=self._config.get("TG1_ACL", (True, [])),
),
server_id=server_prefix(_global.get("SERVER_ID", 0)),
validate_server_ids=bool(_global.get("VALIDATE_SERVER_IDS")),
known_server_prefixes=self._CONFIG.get("_SERVER_IDS", set()),
resolve_server_id=self.validate_obp_source_server_id,
)
def _obp_policy(self) -> BridgePolicy:
"""This bridge's rules, read once per frame and handed to the engine."""
return BridgePolicy(
system=self._system,
network_id=self._config.get("NETWORK_ID", b""),
proto_ver=self._config.get("VER"),
relax_checks=bool(self._config.get("RELAX_CHECKS")),
server_id=server_id_bytes(self._CONFIG.get("GLOBAL", {}).get("SERVER_ID", 0)),
admission=self._obp_admission_context(),
)
def _obp_apply(self, effects: list[Effect] | None) -> None:
"""Carry out what the engine decided, in order."""
if not effects:
return
for effect in effects:
if isinstance(effect, Reject):
self._obp_reject(effect.rejection, effect.dst_id, effect.stream_id)
self._session.count_drop(effect.reason)
elif isinstance(effect, Log):
logger.log(effect.level, effect.message, *effect.args)
elif isinstance(effect, NoteStream):
self.note_dmrd_stream(effect.peer_id, effect.rf_src, effect.stream_id)
elif isinstance(effect, StoreTalkerAlias):
self.store_ta_from_voice_burst(
effect.peer_id,
effect.rf_src,
effect.stream_id,
effect.dtype_vseq,
effect.burst,
)
elif isinstance(effect, Deliver):
if self._dmrd_received:
self._dmrd_received(
self._system,
effect.peer_id,
effect.rf_src,
effect.dst_id,
effect.seq,
effect.slot,
effect.call_type,
effect.frame_type,
effect.dtype_vseq,
effect.stream_id,
effect.frame,
obp_use_parsed=True,
obp_hops=effect.hops,
obp_source_server=effect.source_server,
obp_ber=effect.ber,
obp_rssi=effect.rssi,
obp_source_rptr=effect.source_rptr,
)
elif isinstance(effect, RequestVersion):
self._obp_send_bcve()
def _obp_reject(self, rejection: Rejection, _dst_id: bytes, _stream_id: bytes) -> None:
"""Apply one admission decision: log it (once per stream) and quench the peer."""
if rejection.log_once:
if _stream_id in self._laststrid:
return
self._laststrid.append(_stream_id)
logger.log(rejection.level, rejection.message, *rejection.args)
if rejection.quench:
self._obp_send_bcsq(_dst_id, _stream_id)
def _obp_send_bcsq(self, _tgid: bytes, _stream_id: bytes) -> None:
"""Legacy send_bcsq: BCSQ + tgid + stream_id + HMAC-SHA1. Uses TARGET_SOCK (IP only)."""
_addr = self._config.get("TARGET_SOCK")
if not _addr or not _addr[0]:
tip = self._config.get("TARGET_IP")
tport = int(self._config.get("TARGET_PORT", 62044))
if tip:
_addr = (tip, tport)
self._config["TARGET_SOCK"] = _addr
if _addr and _addr[0]:
"""Legacy send_bcsq: BCSQ + tgid + stream_id + HMAC-SHA1 to the peer."""
_session = self._session
_addr = _session.peer
if _session.peer_known:
self.transport.write(
build_bcsq(_tgid, _stream_id, _get_passphrase_bytes(self._config)),
_addr,
@ -2163,318 +2267,64 @@ class HBPProtocol(DatagramProtocol):
)
def _obp_datagram_received(self, _packet: bytes, _sockaddr: tuple[str, int]) -> None:
"""Port of hblink.py OPENBRIDGE.datagramReceived: DMRD v1 (53+HMAC), BCKA, BCVE."""
"""OpenBridge ingress: verify, hand to the engine, apply what it answers."""
if _packet[:3] == DMR and _packet[:4] == DMRD and len(_packet) >= 73:
_data = _packet[:53]
_stream_id = _data[16:20]
if self._config.get("VER", 5) > 1:
if _stream_id not in self._laststrid:
logger.warning("(%s) *ProtoControl* Version 1 protocol prohibited by PROTO_VER, Ver: %s", self._system, self._config.get("VER"))
self._laststrid.append(_stream_id)
self._obp_send_bcve()
_policy = self._obp_policy()
_stream_id = _packet[16:20]
if _policy.rejects_v1:
self._obp_apply(reject_v1_protocol(_stream_id, policy=_policy))
return
_ingress = self._try_decode_mesh_ingress(_packet)
if _ingress is not None and _ingress.codec == "obp_v1" and (_sockaddr == self._config.get("TARGET_SOCK") or self._config.get("RELAX_CHECKS")):
_data = _ingress.voice_frame
if (
_ingress is not None
and _ingress.codec == "obp_v1"
and accepts_source(_sockaddr, policy=_policy, session=self._session)
):
self._obp_sync_target_sock_from_peer(_sockaddr, _stream_id)
_peer_id = _data[11:15]
if self._config.get("NETWORK_ID") != _peer_id:
if _stream_id not in self._laststrid:
logger.error("(%s) OpenBridge packet discarded because NETWORK_ID: %s Does not match sent Peer ID: %s", self._system, int_id(self._config.get("NETWORK_ID", b"")), int_id(_peer_id))
self._laststrid.append(_stream_id)
return
_seq = _data[4]
_rf_src = _data[5:8]
_dst_id = _data[8:11]
_bits = _data[15]
_slot = 2 if (_bits & 0x80) else 1
if _bits & 0x40:
_call_type = "unit"
elif (_bits & 0x23) == 0x23:
_call_type = "vcsbk"
else:
_call_type = "group"
_frame_type = (_bits & 0x30) >> 4
_dtype_vseq = _bits & 0xF
if _slot != 1:
logger.error("(%s) OpenBridge packet discarded because it was not received on slot 1. SID: %s, TGID %s", self._system, int_id(_rf_src), int_id(_dst_id))
return
if "STUN" in self._CONFIG:
if _stream_id not in self._laststrid:
logger.warning("(%s) Bridge STUNned, discarding", self._system)
self._laststrid.append(_stream_id)
return
_int_dst_id = int_id(_dst_id)
if _call_type != "unit":
if _int_dst_id <= 79 or (_int_dst_id >= 9990 and _int_dst_id <= 9999) or (_int_dst_id >= 92 and _int_dst_id <= 199) or _int_dst_id == 900999:
if _stream_id not in self._laststrid:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s FROM SUBSCRIBER %s BY GLOBAL TG FILTER", self._system, int_id(_stream_id), _int_dst_id)
self._obp_send_bcsq(_dst_id, _stream_id)
self._laststrid.append(_stream_id)
return
_global = self._CONFIG.get("GLOBAL", {})
if self._router and _global.get("USE_ACL"):
if not self._router.acl_check(_rf_src, _global.get("SUB_ACL", (True, []))):
if _stream_id not in self._laststrid:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY GLOBAL TS1 ACL", self._system, int_id(_stream_id), int_id(_rf_src))
self._obp_send_bcsq(_dst_id, _stream_id)
self._laststrid.append(_stream_id)
return
if _slot == 1 and not self._router.acl_check(_dst_id, _global.get("TG1_ACL", (True, []))):
if _stream_id not in self._laststrid:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY GLOBAL TS1 ACL", self._system, int_id(_stream_id), int_id(_dst_id))
self._obp_send_bcsq(_dst_id, _stream_id)
self._laststrid.append(_stream_id)
return
if self._router and self._config.get("USE_ACL"):
if not self._router.acl_check(_rf_src, self._config.get("SUB_ACL", (True, []))):
if _stream_id not in self._laststrid:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s FROM SUBSCRIBER %s BY SYSTEM ACL", self._system, int_id(_stream_id), int_id(_rf_src))
self._obp_send_bcsq(_dst_id, _stream_id)
self._laststrid.append(_stream_id)
return
if not self._router.acl_check(_dst_id, self._config.get("TG1_ACL", (True, []))):
if _stream_id not in self._laststrid:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY SYSTEM ACL", self._system, int_id(_stream_id), int_id(_dst_id))
self._obp_send_bcsq(_dst_id, _stream_id)
self._laststrid.append(_stream_id)
return
if _call_type == "group" and _frame_type == HBPF_DATA_SYNC and _dtype_vseq == HBPF_SLT_VHEAD:
logger.info(
"(%s) CALL RX (OBP) src %s -> TG %s slot %s",
self._system, int_id(_rf_src), int_id(_dst_id), _slot,
)
self.note_dmrd_stream(_peer_id, _rf_src, _stream_id)
if (
_call_type in ("group", "vcsbk")
and _frame_type != HBPF_DATA_SYNC
and _dtype_vseq in (1, 2, 3, 4)
and len(_data) >= 53
):
self.store_ta_from_voice_burst(
_peer_id, _rf_src, _stream_id, _dtype_vseq, _data[20:53],
)
# Group/vcsbk stream state, LC, duplicates: routing_use_cases._obp_group_voice_router_obp (legacy routerOBP.dmrd_received)
if self._dmrd_received:
# Legacy hblink DMRD v1: SERVER_ID + default rptr/hops/ber/rssi (`hblink.py` ~338–345, ~416)
_global = self._CONFIG.get("GLOBAL", {})
_sid = _global.get("SERVER_ID", b"\x00\x00\x00\x00")
_obp_ss = (
_sid
if isinstance(_sid, bytes) and len(_sid) >= 4
else bytes_4(int(_sid) & 0xFFFFFFFF if isinstance(_sid, int) else 0)
)
self._dmrd_received(
self._system,
_peer_id,
_rf_src,
_dst_id,
_seq,
_slot,
_call_type,
_frame_type,
_dtype_vseq,
_stream_id,
_data,
obp_use_parsed=True,
obp_hops=b"",
obp_source_server=_obp_ss,
obp_ber=b"\x00",
obp_rssi=b"\x00",
obp_source_rptr=b"\x00\x00\x00\x00",
self._obp_apply(
ingest_dmrd_v1(
_ingress,
_sockaddr,
policy=_policy,
session=self._session,
now=time.time(),
)
self._config["_bcka"] = time.time()
)
else:
logger.warning("(%s) OpenBridge HMAC failed, packet discarded - OPCODE: %s SRC: %s", self._system, _packet[:4], _sockaddr)
elif _packet[:4] == DMRE:
# Legacy hblink.py OPENBRIDGE: DMRE (v5) incoming – 89-byte or 85-byte format, BLAKE2b
_ingress = self._try_decode_mesh_ingress(_packet)
if _ingress is None or _ingress.codec != "dmre_v5":
return
if not (_sockaddr == self._config.get("TARGET_SOCK") or self._config.get("RELAX_CHECKS")):
_policy = self._obp_policy()
if not accepts_source(_sockaddr, policy=_policy, session=self._session):
logger.warning("(%s) OpenBridge DMRE BLAKE2b failed, packet discarded - SRC: %s", self._system, _sockaddr)
return
_data = _ingress.voice_frame
_ber = _ingress.ber
_rssi = _ingress.rssi
_embedded_version = _ingress.embedded_ver if _ingress.embedded_ver is not None else self._config.get("VER", 5)
_source_server = _ingress.source_server
_source_rptr = _ingress.source_rptr
_hops = _ingress.hops
_trailer = parse_dmre_trailer(_packet)
_timestamp = _trailer.timestamp if _trailer is not None else b"\x00" * 8
_stream_id = _data[16:20]
self._obp_sync_target_sock_from_peer(_sockaddr, _stream_id)
_peer_id = _data[11:15]
if self._config.get("NETWORK_ID") != _peer_id:
if _stream_id not in self._laststrid:
logger.error("(%s) OpenBridge DMRE discarded because NETWORK_ID: %s Does not match sent Peer ID: %s", self._system, int_id(self._config.get("NETWORK_ID", b"")), int_id(_peer_id))
self._laststrid.append(_stream_id)
return
_seq = _data[4]
_rf_src = _data[5:8]
_dst_id = _data[8:11]
_int_dst_id = int_id(_dst_id)
_bits = _data[15]
_slot = 2 if (_bits & 0x80) else 1
if self._config.get("MODE") == "OPENBRIDGE":
# Legacy bridge_master: OpenBridge streams are effectively TS1 (DMRD v1 rejects slot != 1).
# DMRE can still carry TS2 in bits; BRIDGES use TS:1 for OBP — normalize before STATUS/dmrd.
_slot = 1
if _bits & 0x40:
_call_type = "unit"
elif (_bits & 0x23) == 0x23:
_call_type = "vcsbk"
else:
_call_type = "group"
_frame_type = (_bits & 0x30) >> 4
_dtype_vseq = _bits & 0xF
if "STUN" in self._CONFIG:
if _stream_id not in self._laststrid:
logger.warning("(%s) Bridge STUNned, discarding", self._system)
self._laststrid.append(_stream_id)
return
_ts_sec = int.from_bytes(_timestamp, "big") / 1_000_000_000
if _ts_sec < (time.time() - 5):
if _stream_id not in self._laststrid:
logger.warning("(%s) Packet from server %s more than 5s old!, discarding", self._system, int.from_bytes(_source_server, "big"))
self._obp_send_bcsq(_dst_id, _stream_id)
self._laststrid.append(_stream_id)
return
_src_srv_int = int.from_bytes(_source_server, "big")
_src_srv_str = str(_src_srv_int)
_src_srv_len = len(_src_srv_str)
if _src_srv_len < 4 or _src_srv_len > 7:
if _stream_id not in self._laststrid:
logger.warning("(%s) Source Server should be between 4 and 7 digits, discarding Src: %s", self._system, _src_srv_int)
self._obp_send_bcsq(_dst_id, _stream_id)
self._laststrid.append(_stream_id)
return
_global = self._CONFIG.get("GLOBAL", {})
_server_ids = self._CONFIG.get("_SERVER_IDS", set())
if _global.get("VALIDATE_SERVER_IDS") and _src_srv_len in (4, 5) and (_src_srv_str[:4] not in _server_ids):
if _stream_id not in self._laststrid:
logger.warning("(%s) Source Server ID is 4 or 5 digits but not in list: %s", self._system, _src_srv_int)
self._obp_send_bcsq(_dst_id, _stream_id)
self._laststrid.append(_stream_id)
return
if _src_srv_len > 5 and not self.validate_obp_source_server_id(_source_server):
if _stream_id not in self._laststrid:
logger.warning("(%s) Source Server 6 or 7 digits but not a valid DMR ID, discarding Src: %s", self._system, _src_srv_int)
self._obp_send_bcsq(_dst_id, _stream_id)
self._laststrid.append(_stream_id)
return
_inthops = (_hops if isinstance(_hops, int) else int.from_bytes(_hops, "big")) + 1
if _inthops > 10:
logger.debug(
"(%s) MAX HOPS exceed, dropping. Hops: %s, DST: %s, SRC: %s",
self._system,
_inthops,
_int_dst_id,
_src_srv_int,
)
self._obp_send_bcsq(_dst_id, _stream_id)
return
if _call_type != "unit":
if _int_dst_id <= 79:
if _stream_id not in self._laststrid:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s BY GLOBAL TG FILTER (local to repeater)", self._system, int_id(_stream_id), _int_dst_id)
self._obp_send_bcsq(_dst_id, _stream_id)
self._laststrid.append(_stream_id)
return
if (_int_dst_id >= 9990 and _int_dst_id <= 9999) or _int_dst_id == 900999:
if _stream_id not in self._laststrid:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s BY GLOBAL TG FILTER (local to server)", self._system, int_id(_stream_id), _int_dst_id)
self._obp_send_bcsq(_dst_id, _stream_id)
self._laststrid.append(_stream_id)
return
_sid = _global.get("SERVER_ID", 0)
_our_srv = int(str(_sid)[:4]) if isinstance(_sid, int) else int(str(int.from_bytes(_sid, "big"))[:4])
if (_int_dst_id >= 92 and _int_dst_id <= 199) and int(_src_srv_str[:4]) != _our_srv:
if _stream_id not in self._laststrid:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s BY GLOBAL TG FILTER (local to server main ID)", self._system, int_id(_stream_id), _int_dst_id)
self._obp_send_bcsq(_dst_id, _stream_id)
self._laststrid.append(_stream_id)
return
if ((_int_dst_id >= 80 and _int_dst_id <= 89) or (_int_dst_id >= 800 and _int_dst_id <= 899)) and int(_src_srv_str[:3]) != int(str(_our_srv)[:3]):
if _stream_id not in self._laststrid:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s BY GLOBAL TG FILTER (local to MCC)", self._system, int_id(_stream_id), _int_dst_id)
self._obp_send_bcsq(_dst_id, _stream_id)
self._laststrid.append(_stream_id)
return
if _global.get("USE_ACL") and self._router:
if not self._router.acl_check(_rf_src, _global.get("SUB_ACL", (True, []))):
if _stream_id not in self._laststrid:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY GLOBAL TS1 ACL", self._system, int_id(_stream_id), int_id(_rf_src))
self._obp_send_bcsq(_dst_id, _stream_id)
self._laststrid.append(_stream_id)
return
if _slot == 1 and not self._router.acl_check(_dst_id, _global.get("TG1_ACL", (True, []))):
if _stream_id not in self._laststrid:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY GLOBAL TS1 ACL", self._system, int_id(_stream_id), int_id(_dst_id))
self._obp_send_bcsq(_dst_id, _stream_id)
self._laststrid.append(_stream_id)
return
if self._config.get("USE_ACL") and self._router:
if not self._router.acl_check(_rf_src, self._config.get("SUB_ACL", (True, []))):
if _stream_id not in self._laststrid:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s FROM SUBSCRIBER %s BY SYSTEM ACL", self._system, int_id(_stream_id), int_id(_rf_src))
self._obp_send_bcsq(_dst_id, _stream_id)
self._laststrid.append(_stream_id)
return
if not self._router.acl_check(_dst_id, self._config.get("TG1_ACL", (True, []))):
if _stream_id not in self._laststrid:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY SYSTEM ACL", self._system, int_id(_stream_id), int_id(_dst_id))
self._obp_send_bcsq(_dst_id, _stream_id)
self._laststrid.append(_stream_id)
return
self.note_dmrd_stream(_peer_id, _rf_src, _stream_id)
if (
_call_type in ("group", "vcsbk")
and _frame_type != HBPF_DATA_SYNC
and _dtype_vseq in (1, 2, 3, 4)
and len(_data) >= 53
):
self.store_ta_from_voice_burst(
_peer_id, _rf_src, _stream_id, _dtype_vseq, _data[20:53],
self._obp_sync_target_sock_from_peer(_sockaddr, _ingress.voice_frame[16:20])
self._obp_apply(
ingest_dmre_v5(
_ingress,
_sockaddr,
policy=_policy,
session=self._session,
timestamp_ns=int.from_bytes(_timestamp, "big"),
now=time.time(),
)
_data_dmrd = DMRD + _data[4:]
_hops_out = _inthops.to_bytes(1, "big")
if self._dmrd_received:
# Legacy hblink DMRE: same fields passed to dmrd_received as after increment (`hblink.py` ~592–596)
self._dmrd_received(
self._system,
_peer_id,
_rf_src,
_dst_id,
_seq,
_slot,
_call_type,
_frame_type,
_dtype_vseq,
_stream_id,
_data_dmrd,
obp_use_parsed=True,
obp_hops=_hops_out,
obp_source_server=_source_server,
obp_ber=_ber,
obp_rssi=_rssi,
obp_source_rptr=_source_rptr,
)
self._config["_bcka"] = time.time()
)
elif _packet[:4] == EOBP:
logger.warning("(%s) *ProtoControl* KF7EEL EOBP protocol not supported", self._system)
elif self._config.get("ENHANCED_OBP") and _packet[:2] == BC:
_passphrase = _get_passphrase_bytes(self._config)
if _packet[:4] == BCKA and len(_packet) >= 24:
if verify_bcka(_packet, _passphrase):
self._config["_bcka"] = time.time()
if _sockaddr != self._config.get("TARGET_SOCK"):
logger.info("(%s) *BridgeControl* Source IP and Port has changed for OBP from %s:%s to %s:%s, updating", self._system, self._config.get("TARGET_IP"), self._config.get("TARGET_PORT"), _sockaddr[0], _sockaddr[1])
self._config["TARGET_IP"] = _sockaddr[0]
self._config["TARGET_PORT"] = _sockaddr[1]
self._config["TARGET_SOCK"] = _sockaddr
self._config.pop("_no_target_log_time", None) # reset so next "no target" logs once
_session = self._session
_was = _session.peer
_now = time.time()
_session.note_keepalive(_now)
if _session.learn_peer(_sockaddr, at=_now):
logger.info("(%s) *BridgeControl* Source IP and Port has changed for OBP from %s:%s to %s:%s, updating", self._system, _was[0], _was[1], _sockaddr[0], _sockaddr[1])
else:
logger.info("(%s) *BridgeControl* BCKA invalid KeepAlive, packet discarded", self._system)
# Source quench — legacy hblink.py OPENBRIDGE ~629-639 (sets CONFIG['_bcsq'][tgid]=stream_id)
@ -2483,9 +2333,7 @@ class HBPProtocol(DatagramProtocol):
if _bcsq is not None:
_tgid_bcsq = _bcsq.tgid
_stream_bcsq = _bcsq.stream_id
if "_bcsq" not in self._config:
self._config["_bcsq"] = {}
self._config["_bcsq"][_tgid_bcsq] = _stream_bcsq
self._session.quench(_tgid_bcsq, _stream_bcsq)
if self._config.get("MODE") == "OPENBRIDGE":
_key = (_stream_bcsq, _tgid_bcsq)
_once = getattr(self, "_bcsq_log_once", None)
@ -2513,7 +2361,7 @@ class HBPProtocol(DatagramProtocol):
if _packet[:4] == BCST and len(_packet) >= 24:
if verify_bcst(_packet, _passphrase):
logger.trace("(%s) *BridgeControl* BCST STUN request received", self._system)
self._config["_STUN"] = True
self._session.stun()
else:
logger.warning(
"(%s) *BridgeControl* BCST invalid STUN, packet discarded - SRC: %s",

@ -28,6 +28,7 @@ ADN DMR Peer Server entrypoint.
Run: python -m adn_server.main [-c adn-server.yaml] [--logging LEVEL]
python -m adn_server.main --echo [-c adn-echo.yaml]
python -m adn_server.main --doctor [-c adn-server.yaml]
python -m adn_server.main --replay capture.pcap [--system OBP-FR]
Config default: adn-server.yaml (or adn-echo.yaml with --echo).
"""
@ -46,8 +47,9 @@ if str(_ROOT) not in sys.path:
from adn_server.domain.errors import ConfigError
from adn_server.infrastructure import YamlConfigLoader, setup_logging
from adn_server.infrastructure.bootstrap import run_peer_server
from adn_server.infrastructure.config_normalizer import apply_talker_alias_defaults
from adn_server.infrastructure.config_normalizer import apply_talker_alias_defaults, normalize_obp_config
from adn_server.infrastructure.doctor import run_doctor
from adn_server.infrastructure.obp_replay import run_replay
from adn_server.infrastructure.echo import run_echo
@ -79,6 +81,36 @@ def _parse_args() -> argparse.Namespace:
action="store_true",
help="Validate config, ports, and peers; exit non-zero on errors",
)
parser.add_argument(
"--replay",
dest="REPLAY_CAPTURE",
default=None,
metavar="CAPTURE.pcap",
help="Replay OpenBridge frames from a pcap through the ingress and report what it would do",
)
parser.add_argument(
"--system",
dest="REPLAY_SYSTEM",
default=None,
help="With --replay: only this OPENBRIDGE system",
)
parser.add_argument(
"--replay-limit",
dest="REPLAY_LIMIT",
type=int,
default=None,
help="With --replay: stop after this many datagrams",
)
parser.add_argument(
"--replay-both-directions",
action="store_true",
help="With --replay: also judge what this server sent (an unfiltered capture has both)",
)
parser.add_argument(
"--replay-summary",
action="store_true",
help="With --replay: print the tally only, not one line per frame",
)
parser.add_argument("--version", action="version", version=f"adn-server {__version__}")
return parser.parse_args()
@ -119,6 +151,19 @@ def main() -> None:
print(f"(CONFIG) {exc}", file=sys.stderr)
sys.exit(1)
if args.REPLAY_CAPTURE:
normalize_obp_config(config)
sys.exit(
run_replay(
config,
args.REPLAY_CAPTURE,
system=args.REPLAY_SYSTEM,
limit=args.REPLAY_LIMIT,
summary_only=args.replay_summary,
both_directions=args.replay_both_directions,
)
)
if not args.echo:
apply_talker_alias_defaults(config)

@ -29,6 +29,7 @@ import jsonschema
import pytest
from adn_server.application.report.dashboard_state import build_dashboard_state
from adn_server.domain.mesh_session import MeshSessionStore
from adn_server.domain.value_objects import bytes_4
_SCHEMA_PATH = Path(__file__).resolve().parents[2] / "schemas" / "report-v2.json"
@ -115,11 +116,12 @@ def test_openbridge_connected_true_when_bcka_fresh() -> None:
"ENABLED": True,
"NETWORK_ID": 73010,
"ENHANCED_OBP": True,
"_bcka": now - 10,
"PEERS": {},
},
}
state = build_dashboard_state(systems, ts=now)
sessions = MeshSessionStore()
sessions.session("OBP-CL").note_keepalive(now - 10)
state = build_dashboard_state(systems, ts=now, sessions=sessions)
assert state["ctable"]["OPENBRIDGES"]["OBP-CL"]["connected"] is True
@ -131,11 +133,12 @@ def test_openbridge_connected_false_when_bcka_stale() -> None:
"ENABLED": True,
"NETWORK_ID": 73010,
"ENHANCED_OBP": True,
"_bcka": now - 61,
"PEERS": {},
},
}
state = build_dashboard_state(systems, ts=now)
sessions = MeshSessionStore()
sessions.session("OBP-CL").note_keepalive(now - 61)
state = build_dashboard_state(systems, ts=now, sessions=sessions)
assert state["ctable"]["OPENBRIDGES"]["OBP-CL"]["connected"] is False

@ -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",
]

@ -102,7 +102,7 @@ def test_reload_enables_mqtt(monkeypatch):
monkeypatch.setattr(
"adn_server.infrastructure.twisted_adapters.report.mqtt_publisher.create_report_mqtt_publisher_from_settings",
lambda _s: _NewPub(),
lambda _s, **_kw: _NewPub(),
)
result = reconcile_mqtt_publisher(factory, None, None, _settings(), report_enabled=True)
assert isinstance(result, _NewPub)
@ -119,7 +119,7 @@ def test_reload_restarts_when_broker_changes(monkeypatch):
monkeypatch.setattr(
"adn_server.infrastructure.twisted_adapters.report.mqtt_publisher.create_report_mqtt_publisher_from_settings",
lambda _s: _NewPub(),
lambda _s, **_kw: _NewPub(),
)
result = reconcile_mqtt_publisher(
factory,

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

@ -1,4 +1,4 @@
# ADN DMR Peer Server - OBP RELAX_CHECKS target-address sync log is debug, once per stream
# ADN DMR Peer Server - OBP RELAX_CHECKS peer sync moves the session, logs once per stream
from __future__ import annotations
@ -6,29 +6,39 @@ import logging
from collections import deque
from types import SimpleNamespace
from adn_server.domain.mesh_session import ObpBridgeSession
from adn_server.infrastructure.twisted_adapters.udp_hbp import HBPProtocol
_SYNC = HBPProtocol._obp_sync_target_sock_from_peer
def _fake_obp(target_sock=None) -> SimpleNamespace:
def _fake_obp(configured=("1.1.1.1", 62044)) -> SimpleNamespace:
return SimpleNamespace(
_system="OBP-USA",
_config={"MODE": "OPENBRIDGE", "RELAX_CHECKS": True, "TARGET_SOCK": target_sock},
_config={"MODE": "OPENBRIDGE", "RELAX_CHECKS": True},
_session=ObpBridgeSession(system_name="OBP-USA", configured_peer=configured),
_obp_target_sync_log_once=deque(maxlen=1024),
)
def test_sync_updates_target_sock_every_packet() -> None:
fake = _fake_obp(target_sock=("1.1.1.1", 62044))
def test_sync_follows_the_peer_on_every_packet() -> None:
fake = _fake_obp()
_SYNC(fake, ("2.2.2.2", 62044), b"strm")
assert fake._config["TARGET_SOCK"] == ("2.2.2.2", 62044)
assert fake._session.peer == ("2.2.2.2", 62044)
_SYNC(fake, ("1.1.1.1", 62044), b"strm")
assert fake._config["TARGET_SOCK"] == ("1.1.1.1", 62044)
assert fake._session.peer == ("1.1.1.1", 62044)
def test_sync_never_moves_the_configured_peer() -> None:
"""What the operator wrote in the YAML is not what the wire says."""
fake = _fake_obp()
_SYNC(fake, ("2.2.2.2", 62044), b"strm")
assert fake._session.configured_peer == ("1.1.1.1", 62044)
assert fake._session.learned_peer == ("2.2.2.2", 62044)
def test_sync_logs_debug_once_per_stream_even_when_flapping(caplog) -> None:
fake = _fake_obp(target_sock=("1.1.1.1", 62044))
fake = _fake_obp()
with caplog.at_level(logging.DEBUG, logger="adn_server.infrastructure.twisted_adapters.udp_hbp"):
for _ in range(20):
_SYNC(fake, ("2.2.2.2", 62044), b"strm-1")
@ -37,11 +47,11 @@ def test_sync_logs_debug_once_per_stream_even_when_flapping(caplog) -> None:
assert len(sync_records) == 1
assert sync_records[0].levelno == logging.DEBUG
# still tracked the address on every flap despite logging once
assert fake._config["TARGET_SOCK"] == ("1.1.1.1", 62044)
assert fake._session.peer == ("1.1.1.1", 62044)
def test_sync_logs_again_for_a_new_stream(caplog) -> None:
fake = _fake_obp(target_sock=("1.1.1.1", 62044))
fake = _fake_obp()
with caplog.at_level(logging.DEBUG, logger="adn_server.infrastructure.twisted_adapters.udp_hbp"):
_SYNC(fake, ("2.2.2.2", 62044), b"strm-1")
_SYNC(fake, ("1.1.1.1", 62044), b"strm-1")
@ -51,7 +61,8 @@ def test_sync_logs_again_for_a_new_stream(caplog) -> None:
def test_no_relax_checks_no_sync() -> None:
fake = _fake_obp(target_sock=("1.1.1.1", 62044))
fake = _fake_obp()
fake._config["RELAX_CHECKS"] = False
_SYNC(fake, ("2.2.2.2", 62044), b"strm")
assert fake._config["TARGET_SOCK"] == ("1.1.1.1", 62044)
assert fake._session.peer == ("1.1.1.1", 62044)
assert fake._session.learned_peer is None

@ -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…
Cancel
Save

Powered by TurnKey Linux.