Merge pull request #97 from pyopower/perf/hbp-peer-options-once
perf(hbp): parse a peer's OPTIONS and RF mode on change, and one ACL gate for both ingress pathspull/99/head
commit
595f4764d0
@ -0,0 +1,134 @@
|
||||
# ADN DMR Peer Server - tests hbp ACL gate
|
||||
#
|
||||
# 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
|
||||
###############################################################################
|
||||
|
||||
"""One ACL gate, shared by the MASTER and PEER DMRD ingress paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.harness.deterministic import PacketSpec
|
||||
from tests.support.hbp_repeat_stack import RecordingTransport, build_hbp_repeat_stack
|
||||
|
||||
from adn_server.domain import bytes_3, bytes_4
|
||||
from adn_server.infrastructure.acl_router import InMemoryAclRouter
|
||||
from adn_server.infrastructure.twisted_adapters.udp_hbp import HBPProtocol
|
||||
|
||||
_PEER = (1234567).to_bytes(4, "big")
|
||||
_ADDR = ("10.0.0.9", 54321)
|
||||
_RF_SRC = 3120001
|
||||
_DENY_SRC = (False, [(_RF_SRC, _RF_SRC)])
|
||||
_PERMIT_ALL = (True, [(1, 4294967295)])
|
||||
_NO_STREAM = b"\x00" # _make_slot_status seed: no stream seen on this slot yet
|
||||
|
||||
|
||||
def _master_stack(**global_acl: Any):
|
||||
stack = build_hbp_repeat_stack()
|
||||
# the shared stack ships a permit-everything router; these tests need the real one
|
||||
stack.hbp._router = InMemoryAclRouter()
|
||||
# the first thing ingress does after the gate: a frame that got through is in SUB_MAP
|
||||
stack.config["_SUB_MAP"] = {}
|
||||
stack.config["GLOBAL"].update(global_acl)
|
||||
stack.register_peer(_PEER, _ADDR)
|
||||
return stack
|
||||
|
||||
|
||||
def _voice_head(tg: int, slot: int, stream_id: int, *, call_type: str = "group") -> bytes:
|
||||
return replace(
|
||||
PacketSpec(
|
||||
dst_id=tg, slot=slot, stream_id=stream_id, call_type=call_type,
|
||||
peer_id=int.from_bytes(_PEER, "big"), rf_src=_RF_SRC,
|
||||
),
|
||||
frame_type=2, dtype_vseq=1,
|
||||
).data()
|
||||
|
||||
|
||||
def test_master_global_subscriber_acl_drops_and_logs_once_per_stream(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
stack = _master_stack(USE_ACL=True, SUB_ACL=_DENY_SRC, TG1_ACL=_PERMIT_ALL, TG2_ACL=_PERMIT_ALL)
|
||||
with caplog.at_level(logging.INFO):
|
||||
for _ in range(3):
|
||||
stack.hbp._master_datagram_received(_voice_head(214, 2, 0x44440001), _ADDR)
|
||||
|
||||
assert stack.config["_SUB_MAP"] == {}
|
||||
dropped = [r for r in caplog.records if "BY GLOBAL ACL" in r.getMessage()]
|
||||
assert len(dropped) == 1
|
||||
assert "FROM SUBSCRIBER 3120001" in dropped[0].getMessage()
|
||||
|
||||
|
||||
def test_master_system_tg_acl_applies_to_the_frame_slot_only() -> None:
|
||||
stack = _master_stack()
|
||||
sys_cfg = stack.config["SYSTEMS"][stack.system_name]
|
||||
sys_cfg["USE_ACL"] = True
|
||||
sys_cfg["SUB_ACL"] = _PERMIT_ALL
|
||||
sys_cfg["TG1_ACL"] = (False, [(214, 214)])
|
||||
sys_cfg["TG2_ACL"] = _PERMIT_ALL
|
||||
|
||||
stack.hbp._master_datagram_received(_voice_head(214, 1, 0x44440002), _ADDR)
|
||||
assert stack.config["_SUB_MAP"] == {}
|
||||
|
||||
stack.hbp._master_datagram_received(_voice_head(214, 2, 0x44440003), _ADDR)
|
||||
assert bytes_3(_RF_SRC) in stack.config["_SUB_MAP"]
|
||||
|
||||
|
||||
def test_on_demand_unit_service_skips_tg_acl_but_not_subscriber_acl() -> None:
|
||||
"""Legacy exemption: service destinations bypass the TG lists, never SUB_ACL."""
|
||||
stack = _master_stack(USE_ACL=True, SUB_ACL=_PERMIT_ALL, TG1_ACL=(False, [(1, 4294967295)]),
|
||||
TG2_ACL=(False, [(1, 4294967295)]))
|
||||
assert not stack.hbp._acl_rejects_dmrd(
|
||||
bytes_4(_RF_SRC)[1:], bytes_4(9990)[1:], 2, b"\x44\x44\x00\x04", True,
|
||||
)
|
||||
stack.config["GLOBAL"]["SUB_ACL"] = _DENY_SRC
|
||||
assert stack.hbp._acl_rejects_dmrd(
|
||||
bytes_4(_RF_SRC)[1:], bytes_4(9990)[1:], 2, b"\x44\x44\x00\x05", True,
|
||||
)
|
||||
|
||||
|
||||
def test_peer_mode_ingress_uses_the_same_gate() -> None:
|
||||
"""PEER mode (client of another master) rejects on the very same rules."""
|
||||
master_sockaddr = ("203.0.113.7", 62031)
|
||||
config: dict[str, Any] = {
|
||||
"GLOBAL": {"USE_ACL": True, "SUB_ACL": _DENY_SRC, "TG1_ACL": _PERMIT_ALL,
|
||||
"TG2_ACL": _PERMIT_ALL},
|
||||
"SYSTEMS": {
|
||||
"PEER-1": {
|
||||
"MODE": "PEER",
|
||||
"ENABLED": True,
|
||||
"LOOSE": True,
|
||||
"RADIO_ID": _PEER,
|
||||
"MASTER_SOCKADDR": master_sockaddr,
|
||||
"USE_ACL": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
hbp = HBPProtocol("PEER-1", config, router=InMemoryAclRouter())
|
||||
hbp.transport = RecordingTransport() # type: ignore[assignment]
|
||||
|
||||
hbp._peer_datagram_received(_voice_head(214, 2, 0x44440006), master_sockaddr)
|
||||
assert hbp.STATUS[2].get("RX_STREAM_ID") == _NO_STREAM
|
||||
|
||||
config["GLOBAL"]["SUB_ACL"] = _PERMIT_ALL
|
||||
hbp._peer_datagram_received(_voice_head(214, 2, 0x44440007), master_sockaddr)
|
||||
assert hbp.STATUS[2].get("RX_STREAM_ID") == (0x44440007).to_bytes(4, "big")
|
||||
@ -0,0 +1,134 @@
|
||||
# ADN DMR Peer Server - tests peer OPTIONS / RF mode caching
|
||||
#
|
||||
# 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
|
||||
###############################################################################
|
||||
|
||||
"""Per-peer OPTIONS fields and RF mode are parsed on change, not per frame."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from tests.support.hbp_repeat_stack import build_hbp_repeat_stack
|
||||
|
||||
from adn_server.application.routing.helpers import (
|
||||
RF_MODE_DUPLEX,
|
||||
RF_MODE_SIMPLEX,
|
||||
peer_options_fields,
|
||||
peer_rf_mode,
|
||||
peer_single_mode,
|
||||
)
|
||||
from adn_server.application.routing.peer_downlink_index import cached_peer_static_tgs
|
||||
|
||||
_PEER = (1234567).to_bytes(4, "big")
|
||||
_ADDR = ("10.0.0.9", 54321)
|
||||
|
||||
|
||||
def _rptc(peer_id: bytes, *, slots: bytes, rx: bytes, tx: bytes) -> bytes:
|
||||
"""MMDVM RPTC frame (field widths per hblink.py / MMDVMHost RPTC layout)."""
|
||||
return b"".join(
|
||||
[
|
||||
b"RPTC",
|
||||
peer_id,
|
||||
b"CE5RPY ",
|
||||
rx.ljust(9, b"0"),
|
||||
tx.ljust(9, b"0"),
|
||||
b"01",
|
||||
b"01",
|
||||
b"00.00000",
|
||||
b"000.00000",
|
||||
b"000",
|
||||
b"Andorra".ljust(20),
|
||||
b"hotspot".ljust(19),
|
||||
slots,
|
||||
b"".ljust(124),
|
||||
b"MMDVM".ljust(40),
|
||||
b"".ljust(40),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _waiting_peer(stack: Any, peer_id: bytes, addr: tuple[str, int]) -> dict[str, Any]:
|
||||
peer: dict[str, Any] = {
|
||||
"CONNECTION": "WAITING_CONFIG",
|
||||
"SOCKADDR": addr,
|
||||
"RADIO_ID": str(int.from_bytes(peer_id, "big")),
|
||||
"CALLSIGN": b"CE5RPY ",
|
||||
}
|
||||
stack.hbp._peers[peer_id] = peer
|
||||
return peer
|
||||
|
||||
|
||||
def test_options_fields_reused_while_the_blob_is_unchanged() -> None:
|
||||
"""Same OPTIONS must not be re-parsed: ingress asks several times per frame."""
|
||||
stack = build_hbp_repeat_stack()
|
||||
stack.register_peer(_PEER, _ADDR)
|
||||
peer = stack.hbp._peers[_PEER]
|
||||
peer["OPTIONS"] = b"TS2_1=214;SINGLE=1;TIMER=15;"
|
||||
|
||||
first = peer_options_fields(peer)
|
||||
assert peer_options_fields(peer) is first
|
||||
|
||||
|
||||
def test_rpto_makes_the_new_options_take_effect() -> None:
|
||||
"""RPTO drops the cached parse, so SINGLE/TIMER and static TGs follow the new blob."""
|
||||
stack = build_hbp_repeat_stack()
|
||||
sys_cfg = stack.config["SYSTEMS"][stack.system_name]
|
||||
stack.register_peer(_PEER, _ADDR)
|
||||
peer = stack.hbp._peers[_PEER]
|
||||
|
||||
stack.hbp._master_datagram_received(b"RPTO" + _PEER + b"TS2_1=214;SINGLE=1;", _ADDR)
|
||||
assert peer_single_mode(peer, sys_cfg) is True
|
||||
assert cached_peer_static_tgs(peer) == ((), ("214",))
|
||||
|
||||
stack.hbp._master_datagram_received(b"RPTO" + _PEER + b"TS1_1=91;SINGLE=0;", _ADDR)
|
||||
assert peer_single_mode(peer, sys_cfg) is False
|
||||
assert cached_peer_static_tgs(peer) == (("91",), ())
|
||||
assert peer_options_fields(peer).get("SINGLE") == "0"
|
||||
|
||||
|
||||
def test_rptc_classifies_rf_mode_at_login() -> None:
|
||||
"""Simplex/duplex is decided from the RPTC fields, not on every frame."""
|
||||
stack = build_hbp_repeat_stack()
|
||||
stack.config["SYSTEMS"][stack.system_name]["ALLOW_UNREG_ID"] = True
|
||||
peer = _waiting_peer(stack, _PEER, _ADDR)
|
||||
|
||||
stack.hbp._master_datagram_received(
|
||||
_rptc(_PEER, slots=b"3", rx=b"438500000", tx=b"431100000"), _ADDR,
|
||||
)
|
||||
assert peer["CONNECTION"] == "YES"
|
||||
assert peer["RF_MODE"] == RF_MODE_DUPLEX
|
||||
assert peer_rf_mode(peer) == RF_MODE_DUPLEX
|
||||
|
||||
|
||||
def test_relogin_with_new_slots_reclassifies_rf_mode() -> None:
|
||||
"""A hotspot that comes back as simplex must not keep the duplex verdict."""
|
||||
stack = build_hbp_repeat_stack()
|
||||
stack.config["SYSTEMS"][stack.system_name]["ALLOW_UNREG_ID"] = True
|
||||
peer = _waiting_peer(stack, _PEER, _ADDR)
|
||||
stack.hbp._master_datagram_received(
|
||||
_rptc(_PEER, slots=b"3", rx=b"438500000", tx=b"431100000"), _ADDR,
|
||||
)
|
||||
assert peer["RF_MODE"] == RF_MODE_DUPLEX
|
||||
|
||||
peer["CONNECTION"] = "WAITING_CONFIG"
|
||||
stack.hbp._master_datagram_received(
|
||||
_rptc(_PEER, slots=b"4", rx=b"145500000", tx=b"145500000"), _ADDR,
|
||||
)
|
||||
assert peer["RF_MODE"] == RF_MODE_SIMPLEX
|
||||
assert peer_rf_mode(peer) == RF_MODE_SIMPLEX
|
||||
Loading…
Reference in new issue