You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ADN-DMR-Peer-Server/tests/domain/test_mesh_admission.py

394 lines
14 KiB

# 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

Powered by TurnKey Linux.