fix: production ingress mitigations (UDP rcvbuf, rate limiter, TA embed) (#34)
* fix: raise UDP SO_RCVBUF on voice listeners (C-LOCAL) Apply a 4 MB receive buffer on system and proxy UDP sockets to reduce kernel RcvbufErrors under OBP load; size is configurable via GLOBAL.UDP_RCVBUF. * fix: exclude byte-identical duplicates from HBP rate counter (A.2) Check lastData before incrementing the ingress packet counter so compressed duplicate bursts do not trigger legitimate RATE DROP on call start. * fix: duplicate-safe TA embed phase and REPEAT VHEAD DMRA (B) Ignore byte-identical B-E embed bursts so duplicate uplinks do not desync the TA phase machine, and re-emit DMRA on every VHEAD on the REPEAT path. * chore: document echo point-to-point and logged_in reconciliation (EN/ES) Document multi-hotspot echo/service delivery via RX_PEER, the lst_seen logged_in reconcile loop, and cross-links between user and dev guides.pull/35/head
parent
2e4026f9d7
commit
156731447c
@ -0,0 +1,60 @@
|
||||
# ADN DMR Peer Server - UDP receive buffer sizing
|
||||
# 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
|
||||
###############################################################################
|
||||
|
||||
"""Raise SO_RCVBUF on voice UDP listeners to avoid kernel RcvbufErrors under load."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import socket
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_UDP_RCVBUF = 4 * 1024 * 1024
|
||||
|
||||
|
||||
def udp_rcvbuf_bytes(config: dict[str, Any] | None) -> int:
|
||||
if not config:
|
||||
return DEFAULT_UDP_RCVBUF
|
||||
raw = config.get("GLOBAL", {}).get("UDP_RCVBUF", DEFAULT_UDP_RCVBUF)
|
||||
if isinstance(raw, bool) or not isinstance(raw, int) or raw <= 0:
|
||||
return DEFAULT_UDP_RCVBUF
|
||||
return raw
|
||||
|
||||
|
||||
def apply_udp_rcvbuf(
|
||||
sock: socket.socket,
|
||||
requested: int,
|
||||
*,
|
||||
label: str,
|
||||
logger: logging.Logger,
|
||||
) -> None:
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, requested)
|
||||
except OSError as exc:
|
||||
logger.warning("(%s) UDP RX buffer not raised (requested %s): %s", label, requested, exc)
|
||||
return
|
||||
try:
|
||||
effective = sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF)
|
||||
except OSError as exc:
|
||||
logger.warning("(%s) UDP RX buffer set but getsockopt failed: %s", label, exc)
|
||||
return
|
||||
logger.info("(%s) UDP RX buffer raised to %s bytes (requested %s)", label, effective, requested)
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_UDP_RCVBUF", "apply_udp_rcvbuf", "udp_rcvbuf_bytes"]
|
||||
@ -0,0 +1,48 @@
|
||||
"""Tests for UDP receive buffer helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import socket
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from adn_server.infrastructure.udp_rcvbuf import (
|
||||
DEFAULT_UDP_RCVBUF,
|
||||
apply_udp_rcvbuf,
|
||||
udp_rcvbuf_bytes,
|
||||
)
|
||||
|
||||
|
||||
def test_udp_rcvbuf_bytes_default() -> None:
|
||||
assert udp_rcvbuf_bytes(None) == DEFAULT_UDP_RCVBUF
|
||||
assert udp_rcvbuf_bytes({}) == DEFAULT_UDP_RCVBUF
|
||||
assert udp_rcvbuf_bytes({"GLOBAL": {}}) == DEFAULT_UDP_RCVBUF
|
||||
|
||||
|
||||
def test_udp_rcvbuf_bytes_from_config() -> None:
|
||||
assert udp_rcvbuf_bytes({"GLOBAL": {"UDP_RCVBUF": 2097152}}) == 2097152
|
||||
|
||||
|
||||
def test_udp_rcvbuf_bytes_rejects_invalid() -> None:
|
||||
assert udp_rcvbuf_bytes({"GLOBAL": {"UDP_RCVBUF": 0}}) == DEFAULT_UDP_RCVBUF
|
||||
assert udp_rcvbuf_bytes({"GLOBAL": {"UDP_RCVBUF": -1}}) == DEFAULT_UDP_RCVBUF
|
||||
assert udp_rcvbuf_bytes({"GLOBAL": {"UDP_RCVBUF": "big"}}) == DEFAULT_UDP_RCVBUF
|
||||
assert udp_rcvbuf_bytes({"GLOBAL": {"UDP_RCVBUF": True}}) == DEFAULT_UDP_RCVBUF
|
||||
|
||||
|
||||
def test_apply_udp_rcvbuf_sets_socket_buffer() -> None:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
requested = 2 * 1024 * 1024
|
||||
log = MagicMock(spec=logging.Logger)
|
||||
apply_udp_rcvbuf(sock, requested, label="TEST", logger=log)
|
||||
effective = sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF)
|
||||
assert effective >= requested
|
||||
log.info.assert_called_once()
|
||||
args = log.info.call_args[0]
|
||||
assert args[0] == "(%s) UDP RX buffer raised to %s bytes (requested %s)"
|
||||
assert args[1] == "TEST"
|
||||
assert args[2] == effective
|
||||
assert args[3] == requested
|
||||
finally:
|
||||
sock.close()
|
||||
@ -0,0 +1,204 @@
|
||||
# ADN DMR Peer Server - tests talker alias embed phase duplicate
|
||||
#
|
||||
# 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
|
||||
###############################################################################
|
||||
|
||||
"""TA embed phase machine must ignore byte-identical duplicate voice bursts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bitarray import bitarray
|
||||
from tests.harness.deterministic import DeterministicScenario, PacketSpec
|
||||
from tests.harness.scenarios import talker_alias_config
|
||||
from tests.support.hbp_repeat_stack import build_hbp_repeat_stack
|
||||
|
||||
from adn_server.application.routing_use_cases import RoutingUseCases
|
||||
from adn_server.domain import bytes_3, bytes_4
|
||||
from adn_server.domain.dmr.bptc import encode_emblc
|
||||
from adn_server.domain.dmr.const import LC_OPT
|
||||
from adn_server.infrastructure.acl_router import InMemoryAclRouter
|
||||
from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore
|
||||
from adn_server.infrastructure.talker_alias_emblc import default_ta_emblc_encoder
|
||||
|
||||
_EMB_SLICE = slice(116, 148)
|
||||
_PEER_TX = bytes_4(730039210)
|
||||
_PEER_RX = bytes_4(730039101)
|
||||
_ADDR_TX = ("10.0.0.1", 62001)
|
||||
_ADDR_RX = ("10.0.0.2", 62002)
|
||||
|
||||
|
||||
def _embed_bits(dmrpkt: bytes) -> bitarray:
|
||||
bits = bitarray(endian="big")
|
||||
bits.frombytes(dmrpkt)
|
||||
return bits[_EMB_SLICE]
|
||||
|
||||
|
||||
def _init_repeat_slot(
|
||||
bridge: RoutingUseCases,
|
||||
*,
|
||||
system_name: str = "MASTER-A",
|
||||
slot: int = 2,
|
||||
stream_id: bytes,
|
||||
rf_src: bytes,
|
||||
dst_id: bytes,
|
||||
) -> dict:
|
||||
class _Proto:
|
||||
STATUS = {slot: {}}
|
||||
|
||||
proto = _Proto()
|
||||
protocols = {system_name: proto}
|
||||
bridge._get_protocols = lambda: protocols # type: ignore[method-assign]
|
||||
st = proto.STATUS[slot]
|
||||
st["REP_STREAM_ID"] = stream_id
|
||||
st["REP_EMB_LC"] = encode_emblc(LC_OPT + dst_id + rf_src)
|
||||
bridge._init_talker_alias_embed(st, system_name, system_name, rf_src, stream_id)
|
||||
return st
|
||||
|
||||
|
||||
def _run_superframe(
|
||||
bridge: RoutingUseCases,
|
||||
*,
|
||||
system_name: str,
|
||||
slot: int,
|
||||
stream_id: bytes,
|
||||
payload: bytes,
|
||||
duplicate_e: bool = False,
|
||||
) -> bytes:
|
||||
for dtype in (1, 2, 3, 4):
|
||||
bridge.rewrite_repeat_voice_burst(
|
||||
system_name, slot, stream_id, dtype, payload,
|
||||
)
|
||||
if duplicate_e:
|
||||
bridge.rewrite_repeat_voice_burst(
|
||||
system_name, slot, stream_id, 4, payload,
|
||||
)
|
||||
return bridge.rewrite_repeat_voice_burst(
|
||||
system_name, slot, stream_id, 1, payload,
|
||||
)
|
||||
|
||||
|
||||
def test_duplicate_burst_e_does_not_advance_ta_phase() -> None:
|
||||
"""Byte-identical burst E must not double-advance the embed phase machine."""
|
||||
config = talker_alias_config()
|
||||
bridge = RoutingUseCases(
|
||||
InMemoryAclRouter(),
|
||||
config,
|
||||
InMemorySubscriptionStore(),
|
||||
get_protocols=lambda: {},
|
||||
encode_emblc=encode_emblc,
|
||||
ta_emblc_encoder=default_ta_emblc_encoder,
|
||||
)
|
||||
stream_id = bytes_4(0xC0FFEE01)
|
||||
rf_src = bytes_3(3120001)
|
||||
dst_id = bytes_3(7304)
|
||||
payload = b"\x42" + b"\x00" * 32
|
||||
st = _init_repeat_slot(
|
||||
bridge,
|
||||
stream_id=stream_id,
|
||||
rf_src=rf_src,
|
||||
dst_id=dst_id,
|
||||
)
|
||||
|
||||
baseline_next_b = _run_superframe(
|
||||
bridge,
|
||||
system_name="MASTER-A",
|
||||
slot=2,
|
||||
stream_id=stream_id,
|
||||
payload=payload,
|
||||
duplicate_e=False,
|
||||
)
|
||||
baseline_phase = st.get("TX_TA_PHASE", 0)
|
||||
baseline_embed = _embed_bits(baseline_next_b)
|
||||
|
||||
st_dup = _init_repeat_slot(
|
||||
bridge,
|
||||
stream_id=stream_id,
|
||||
rf_src=rf_src,
|
||||
dst_id=dst_id,
|
||||
)
|
||||
dup_next_b = _run_superframe(
|
||||
bridge,
|
||||
system_name="MASTER-A",
|
||||
slot=2,
|
||||
stream_id=stream_id,
|
||||
payload=payload,
|
||||
duplicate_e=True,
|
||||
)
|
||||
|
||||
assert st_dup.get("TX_TA_PHASE", 0) == baseline_phase
|
||||
assert _embed_bits(dup_next_b) == baseline_embed
|
||||
|
||||
|
||||
def test_repeat_stack_duplicate_burst_matches_non_duplicate_embed() -> None:
|
||||
"""Integration: duplicated REPEAT burst E keeps the next superframe TA embed aligned."""
|
||||
|
||||
def _play_through(duplicate_e: bool) -> bitarray:
|
||||
stack = build_hbp_repeat_stack(talker_alias=True)
|
||||
stack.register_peer(_PEER_TX, _ADDR_TX, options="TS2=7304;")
|
||||
stack.register_peer(_PEER_RX, _ADDR_RX, options="TS2=7304;")
|
||||
base = PacketSpec(
|
||||
peer_id=730039210,
|
||||
rf_src=7300392,
|
||||
dst_id=7304,
|
||||
slot=2,
|
||||
stream_id=0xA1B2C3D4,
|
||||
payload=b"\x77" + b"\x00" * 32,
|
||||
)
|
||||
stack.inject_spec(DeterministicScenario.voice_head_spec(base), _ADDR_TX)
|
||||
for seq, dtype in enumerate((1, 2, 3, 4), start=1):
|
||||
stack.inject_spec(
|
||||
DeterministicScenario.voice_burst_spec(base, seq=seq, dtype_vseq=dtype),
|
||||
_ADDR_TX,
|
||||
)
|
||||
if duplicate_e:
|
||||
stack.inject_spec(
|
||||
DeterministicScenario.voice_burst_spec(base, seq=5, dtype_vseq=4),
|
||||
_ADDR_TX,
|
||||
)
|
||||
stack.transport.clear()
|
||||
next_seq = 6 if duplicate_e else 5
|
||||
stack.inject_spec(
|
||||
DeterministicScenario.voice_burst_spec(base, seq=next_seq, dtype_vseq=1),
|
||||
_ADDR_TX,
|
||||
)
|
||||
downlink = stack.transport.for_addr(_ADDR_RX)
|
||||
assert downlink, "expected downlink after superframe"
|
||||
return _embed_bits(downlink[0][20:53])
|
||||
|
||||
assert _play_through(duplicate_e=False) == _play_through(duplicate_e=True)
|
||||
|
||||
|
||||
def test_two_vheads_emit_dmra_on_repeat_path() -> None:
|
||||
"""Legacy hblink re-sends DMRA on every VHEAD; REPEAT must not dedupe the second."""
|
||||
stack = build_hbp_repeat_stack(talker_alias=True)
|
||||
stack.register_peer(_PEER_TX, _ADDR_TX, options="TS2=7304;")
|
||||
stack.register_peer(_PEER_RX, _ADDR_RX, options="TS2=7304;")
|
||||
base = PacketSpec(
|
||||
peer_id=730039210,
|
||||
rf_src=7300392,
|
||||
dst_id=7304,
|
||||
slot=2,
|
||||
stream_id=0xA1B2C3D4,
|
||||
)
|
||||
|
||||
stack.inject_spec(DeterministicScenario.voice_head_spec(base), _ADDR_TX)
|
||||
first_dmra = len(stack.dmra_capture)
|
||||
assert first_dmra == 1
|
||||
|
||||
stack.inject_spec(DeterministicScenario.voice_head_spec(base), _ADDR_TX)
|
||||
assert len(stack.dmra_capture) == first_dmra + 1
|
||||
Loading…
Reference in new issue