Merge pull request #35 from ce5rpy/develop

release: 2.2.4
pull/37/head
ce5rpy 3 months ago committed by GitHub
commit d2287d21f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -23,6 +23,8 @@ GLOBAL:
TALKER_ALIAS_FORMAT: "{callsign} {fname}" TALKER_ALIAS_FORMAT: "{callsign} {fname}"
# utf8 (Motorola), iso8 (Hytera), 7bit; both vendors by default # utf8 (Motorola), iso8 (Hytera), 7bit; both vendors by default
TALKER_ALIAS_TEXT_FORMAT: "utf8,iso8" TALKER_ALIAS_TEXT_FORMAT: "utf8,iso8"
# UDP SO_RCVBUF for voice listeners (bytes); default 4 MB avoids kernel RcvbufErrors under load
# UDP_RCVBUF: 4194304
REPORTS: REPORTS:
REPORT: true REPORT: true

@ -75,6 +75,24 @@ Important: the proxy sends **RPTO to the master only**, not to the hotspot direc
--- ---
## `logged_in` reconciliation
The **`logged_in`** flag gates both **password** and **IP-based** login: only
rows with `logged_in = 1` can authenticate on the dashboard. The **peer
server** keeps this flag accurate by reconciling it against actually connected
peers every **120 s** (the `lst_seen` loop):
- Peers currently connected to the inject-only MASTER get `logged_in = 1`.
- All other rows get `logged_in = 0`.
- The loop starts with `now=True`, so the **first tick at boot clears every
stale flag immediately** — after a server restart, hotspots that did not
reconnect cannot authenticate via login-by-IP.
This replaced the legacy hourly `clean_tbl` (24 h idle sweep), which left
`logged_in = 1` for peers no longer connected after a restart.
---
## Password hashing ## Password hashing
**`AuthenticateUser`** uses: **`AuthenticateUser`** uses:

@ -21,6 +21,7 @@ The following intervals are part of the current runtime behavior:
| `bridge_reset` | **6s** | Bridge reset flag cleanup and pending reset completion. | | `bridge_reset` | **6s** | Bridge reset flag cleanup and pending reset completion. |
| OPTIONS refresh | **event-driven** | Static TG / reflector from **RPTO**, **startup/reload** (`apply_startup_bridges`), **dmrd** no-source fallback. No periodic 26s loop (**D-28**). | | OPTIONS refresh | **event-driven** | Static TG / reflector from **RPTO**, **startup/reload** (`apply_startup_bridges`), **dmrd** no-source fallback. No periodic 26s loop (**D-28**). |
| `dynamic_tg_purge_loop` | **60s** | Purge expired **SINGLE=1** rows from `peer_dynamic_tgs` and in-memory `_PEER_UA_SESSIONS`. | | `dynamic_tg_purge_loop` | **60s** | Purge expired **SINGLE=1** rows from `peer_dynamic_tgs` and in-memory `_PEER_UA_SESSIONS`. |
| `lst_seen` (self-service reconcile) | **120s** | Reconcile `Clients.logged_in` against currently connected peers: connected peers get `logged_in=1`, the rest `0`. Runs with `now=True` on first tick so stale flags clear immediately after a server restart. Prevents the monitor from authenticating disconnected hotspots via login-by-IP. |
| `statTrimmer` | **303s** | Trim stale STAT bridges and transient status entries. | | `statTrimmer` | **303s** | Trim stale STAT bridges and transient status entries. |
If you change one of these intervals, document the operational impact for monitoring, loop behavior, and troubleshooting. If you change one of these intervals, document the operational impact for monitoring, loop behavior, and troubleshooting.

@ -285,7 +285,7 @@ flowchart TD
PKT[DMRD toward peer] --> F1{OPTIONS has TG<br/>or dynamic active?} PKT[DMRD toward peer] --> F1{OPTIONS has TG<br/>or dynamic active?}
F1 -->|No| DROP[Do not deliver] F1 -->|No| DROP[Do not deliver]
F1 -->|Yes| F2{Special TG 9990-9999?} F1 -->|Yes| F2{Special TG 9990-9999?}
F2 -->|Yes| PASS1[Bypass slot contention] F2 -->|Yes| PASS1[Point-to-point:<br/>only RX_PEER (or single peer)]
F2 -->|No| F3{Target slot<br/>free?} F2 -->|No| F3{Target slot<br/>free?}
F3 -->|Peer TXing ingress| DROP F3 -->|Peer TXing ingress| DROP
F3 -->|Bridge hold active<br/>different TG| DROP F3 -->|Bridge hold active<br/>different TG| DROP
@ -309,6 +309,24 @@ flowchart TD
| **Incompatible SINGLE lock** | SINGLE=1 with a lock on another TG → block, unless it is the lock TG or the peer activated it. | | **Incompatible SINGLE lock** | SINGLE=1 with a lock on another TG → block, unless it is the lock TG or the peer activated it. |
| **Stale session** | If the per-peer session has had no frames for `_STALE_PEER_SESSION_TIMEOUT`, it is purged and the slot frees. | | **Stale session** | If the per-peer session has had no frames for `_STALE_PEER_SESSION_TIMEOUT`, it is purged and the slot frees. |
### Special TGs (9990–9999): point-to-point delivery
Echo (9990) and on-demand service TGs (9991–9999) **bypass** the per-peer slot
contention and OPTIONS filters described above, but they are **not** broadcast
to all peers. On a multi-peer inject-only MASTER they are delivered
**point-to-point**:
- The packet goes **only** to `RX_PEER` — the exact peer that originated the
call on that slot — when `RX_TGID` matches the special TG.
- If `RX_TGID` does not yet match (e.g. the very first VHEAD of a 9990
transmission arrives before `RX_TGID` is updated) and only one peer is
connected, it is delivered to that single peer (legacy fallback).
- There is **no fuzzy matching** on the source DMR ID: other hotspots of the
same user never receive echo or service playback.
This matches legacy `adn-dmr-server`, where each MASTER had a single peer so
echo naturally returned only to the caller. See [Echo](../user-guide/echo.md).
### Mid-call join ### Mid-call join
When a downlink stream ends (VTERM or timeout), the peer's slot becomes free. When a downlink stream ends (VTERM or timeout), the peer's slot becomes free.

@ -37,6 +37,26 @@ sudo systemctl enable --now adn-echo
The main server exposes an **ECHO** master on **TG 9990** for the echo bridge. The standalone **echo** service is a separate process with its own config that connects to that master. The main server exposes an **ECHO** master on **TG 9990** for the echo bridge. The standalone **echo** service is a separate process with its own config that connects to that master.
## Multi-hotspot behaviour (inject-only proxy)
When hotspots attach through the **integrated proxy** (`PROXY`), several radios
may share the same MASTER as peers. In legacy `adn-dmr-server` each MASTER had
a single peer, so echo naturally returned only to the caller. The multi-peer
inject-only proxy enforces the same explicitly:
- **Point-to-point delivery.** Echo playback (TG 9990) and on-demand service
TGs (**9991–9999**) are delivered **only** to the exact peer that originated
the call (`RX_PEER` on the active slot), **never** to other hotspots of the
same user. There is no fuzzy matching on the source DMR ID.
- **Single-peer fallback.** When only one peer is connected, the packet is
delivered to it (legacy single-peer behaviour).
- This applies to both the **data plane** (audio routing) and the **report
plane** (`BRDG_EVENT` sent to the monitor): the monitor shows the echo chip
on the originating hotspot, not on a sibling.
See [Voice routing and contention — Downlink gate](../development/routing-and-contention.md#downlink-gate-does-a-peer-receive-the-packet)
and [Hotspot proxy — Multi-hotspot behaviour](hotspot-proxy.md#multi-hotspot-behaviour).
## Documentation ## Documentation
This page is the summary shipped with the repository; extend your deployment notes locally as needed. This page is the summary shipped with the repository; extend your deployment notes locally as needed.

@ -92,7 +92,7 @@ Details of the dashboard flow: [Self-service](../../monitor/self-service.md).
## Multi-hotspot behaviour ## Multi-hotspot behaviour
- Each authenticated hotspot is a **peer** on the inject-only MASTER with its own **OPTIONS** (static TGs). **Repeat** and monitor fan-out respect **per-peer OPTIONS** — traffic for a TG is not sent to peers that did not select it. - Each authenticated hotspot is a **peer** on the inject-only MASTER with its own **OPTIONS** (static TGs). **Repeat** and monitor fan-out respect **per-peer OPTIONS** — traffic for a TG is not sent to peers that did not select it.
- **Parrot / echo** talkgroups **9990–9999** bypass the OPTIONS filter and return to the **calling** hotspot (see [Special numbers](special-numbers.md)). - **Parrot / echo** talkgroups **9990–9999** are **point-to-point**: they bypass the OPTIONS filter and return **only** to the exact peer that originated the call (`RX_PEER` on the slot), never to other hotspots of the same user. With a single connected peer, it is delivered to that peer (legacy behaviour). See [Echo — Multi-hotspot behaviour](echo.md#multi-hotspot-behaviour-inject-only-proxy) and [Special numbers](special-numbers.md).
--- ---

@ -75,6 +75,25 @@ Importante: el proxy envía **RPTO solo al master**, no al hotspot directamente.
--- ---
## Reconciliación de `logged_in`
El flag **`logged_in`** controla tanto el login por **contraseña** como por
**IP**: sólo las filas con `logged_in = 1` pueden autenticarse en el dashboard.
El **peer server** mantiene este flag preciso reconciliándolo contra los peers
realmente conectados cada **120 s** (el bucle `lst_seen`):
- Los peers actualmente conectados al MASTER inject-only quedan `logged_in = 1`.
- Las demás filas quedan `logged_in = 0`.
- El bucle arranca con `now=True`, de modo que el **primer tick al arrancar
limpia todos los flags obsoletos inmediatamente** — tras un reinicio del
servidor, los hotspots que no se reconectaron no pueden autenticarse vía
login-by-IP.
Esto reemplazó el `clean_tbl` horario del legado (barrido de 24 h de inactividad),
que dejaba `logged_in = 1` en peers ya desconectados tras un reinicio.
---
## Hash de contraseñas ## Hash de contraseñas
**`AuthenticateUser`** usa: **`AuthenticateUser`** usa:

@ -21,6 +21,7 @@ Los siguientes intervalos forman parte del comportamiento actual en ejecución:
| `bridge_reset` | **6s** | Limpieza de flags de reset y cierre de resets pendientes. | | `bridge_reset` | **6s** | Limpieza de flags de reset y cierre de resets pendientes. |
| OPTIONS refresh | **por evento** | TG estáticas / reflector vía **RPTO**, **startup/reload** (`apply_startup_bridges`), fallback **dmrd** sin source. Sin loop periódico de 26s (**D-28**). | | OPTIONS refresh | **por evento** | TG estáticas / reflector vía **RPTO**, **startup/reload** (`apply_startup_bridges`), fallback **dmrd** sin source. Sin loop periódico de 26s (**D-28**). |
| `dynamic_tg_purge_loop` | **60s** | Purga filas **SINGLE=1** expiradas de `peer_dynamic_tgs` y `_PEER_UA_SESSIONS` en memoria. | | `dynamic_tg_purge_loop` | **60s** | Purga filas **SINGLE=1** expiradas de `peer_dynamic_tgs` y `_PEER_UA_SESSIONS` en memoria. |
| `lst_seen` (reconcile self-service) | **120s** | Reconcilia `Clients.logged_in` contra los peers conectados actualmente: los conectados quedan `logged_in=1`, el resto `0`. Corre con `now=True` en el primer tick para limpiar flags obsoletos inmediatamente tras un reinicio del servidor. Evita que el monitor autentique hotspots desconectados vía login-by-IP. |
| `statTrimmer` | **303s** | Limpieza de bridges STAT obsoletos y estados transitorios. | | `statTrimmer` | **303s** | Limpieza de bridges STAT obsoletos y estados transitorios. |
Si cambias uno de estos intervalos, documenta el impacto operativo en monitorización, comportamiento de bucles y troubleshooting. Si cambias uno de estos intervalos, documenta el impacto operativo en monitorización, comportamiento de bucles y troubleshooting.

@ -285,7 +285,7 @@ flowchart TD
PKT[DMRD hacia peer] --> F1{OPTIONS tiene TG<br/>o dinámico activo?} PKT[DMRD hacia peer] --> F1{OPTIONS tiene TG<br/>o dinámico activo?}
F1 -->|No| DROP[No entrega] F1 -->|No| DROP[No entrega]
F1 -->|Sí| F2{TG especial 9990-9999?} F1 -->|Sí| F2{TG especial 9990-9999?}
F2 -->|Sí| PASS1[Bypass slot contention] F2 -->|Sí| PASS1[Punto-a-punto:<br/>sólo RX_PEER (o peer único)]
F2 -->|No| F3{¿Slot destino<br/>libre?} F2 -->|No| F3{¿Slot destino<br/>libre?}
F3 -->|Peer TXing ingress| DROP F3 -->|Peer TXing ingress| DROP
F3 -->|Bridge hold activo<br/>TG distinto| DROP F3 -->|Bridge hold activo<br/>TG distinto| DROP
@ -309,6 +309,24 @@ flowchart TD
| **SINGLE lock incompatible** | SINGLE=1 con lock en otro TG → bloquea salvo que sea el TG del lock o el peer lo activara. | | **SINGLE lock incompatible** | SINGLE=1 con lock en otro TG → bloquea salvo que sea el TG del lock o el peer lo activara. |
| **Sesión stale** | Si la sesión per-peer lleva `_STALE_PEER_SESSION_TIMEOUT` sin frames, se purga y el slot se libera. | | **Sesión stale** | Si la sesión per-peer lleva `_STALE_PEER_SESSION_TIMEOUT` sin frames, se purga y el slot se libera. |
### TG especiales (9990–9999): entrega punto-a-punto
El eco (9990) y los TG de servicio bajo demanda (9991–9999) **omiten** la
contención de slot per-peer y los filtros OPTIONS descritos arriba, pero **no**
se difunden a todos los peers. En un MASTER inject-only multi-peer se entregan
**punto-a-punto**:
- El paquete va **sólo** a `RX_PEER` — el peer exacto que originó la llamada
en ese slot — cuando `RX_TGID` coincide con el TG especial.
- Si `RX_TGID` aún no coincide (p. ej. el primer VHEAD de una transmisión a
9990 llega antes de que se actualice `RX_TGID`) y sólo hay un peer conectado,
se entrega a ese peer único (fallback legado).
- **No hay matching difuso** del ID DMR de origen: otros hotspots del mismo
usuario nunca reciben el eco ni la reproducción de servicio.
Esto coincide con el legado `adn-dmr-server`, donde cada MASTER tenía un solo
peer y el eco volvía naturalmente sólo al llamante. Ver [Echo](../user-guide/echo.md).
### Mid-call join ### Mid-call join
Cuando un stream downlink termina (VTERM o timeout), el slot del peer queda Cuando un stream downlink termina (VTERM o timeout), el slot del peer queda

@ -37,6 +37,27 @@ sudo systemctl enable --now adn-echo
El servidor principal expone un master **ECHO** en **TG 9990** para el bridge de eco. El servicio **echo** independiente es un proceso aparte con su propia config que se conecta a ese master. El servidor principal expone un master **ECHO** en **TG 9990** para el bridge de eco. El servicio **echo** independiente es un proceso aparte con su propia config que se conecta a ese master.
## Comportamiento con varios hotspots (proxy inject-only)
Cuando los hotspots se conectan a través del **proxy integrado** (`PROXY`),
varios radios pueden compartir el mismo MASTER como peers. En el legado
`adn-dmr-server` cada MASTER tenía un solo peer, así que el eco volvía
naturalmente sólo al llamante. El proxy inject-only multi-peer lo impone
explícitamente:
- **Entrega punto-a-punto.** La reproducción del eco (TG 9990) y los TG de
servicio bajo demanda (**9991–9999**) se entregan **sólo** al peer exacto
que originó la llamada (`RX_PEER` en el slot activo), **nunca** a otros
hotspots del mismo usuario. No hay matching difuso del ID DMR de origen.
- **Fallback a peer único.** Cuando sólo hay un peer conectado, el paquete se
le entrega (comportamiento legado de peer único).
- Esto aplica tanto al **plano de datos** (enrutado de audio) como al **plano
de reportes** (`BRDG_EVENT` enviado al monitor): el monitor muestra el chip
de eco en el hotspot originador, no en un hermano.
Ver [Enrutado de voz y contención — Gate de downlink](../development/routing-and-contention.md#gate-de-downlink-un-peer-recibe-el-paquete)
y [Proxy hotspot — Comportamiento con varios hotspots](hotspot-proxy.md#comportamiento-con-varios-hotspots).
## Documentación ## Documentación
Esta página es el resumen incluido en el repositorio; amplía las notas de despliegue localmente según necesites. Esta página es el resumen incluido en el repositorio; amplía las notas de despliegue localmente según necesites.

@ -92,7 +92,7 @@ Detalle del flujo en el panel: [Self-service](../../monitor/self-service.md).
## Comportamiento con varios hotspots ## Comportamiento con varios hotspots
- Cada hotspot autenticado es un **peer** en el MASTER de inyección con sus **OPTIONS** (TG estáticas). **Repeat** y el fan-out del monitor respetan **OPTIONS por peer** — el tráfico de un TG no se envía a peers que no lo tienen seleccionado. - Cada hotspot autenticado es un **peer** en el MASTER de inyección con sus **OPTIONS** (TG estáticas). **Repeat** y el fan-out del monitor respetan **OPTIONS por peer** — el tráfico de un TG no se envía a peers que no lo tienen seleccionado.
- Los talkgroups **eco 9990–9999** omiten el filtro OPTIONS y vuelven al hotspot **llamante** (ver [Números especiales](special-numbers.md)). - Los talkgroups **eco 9990–9999** son **punto-a-punto**: omiten el filtro OPTIONS y vuelven **sólo** al peer exacto que originó la llamada (`RX_PEER` en el slot), nunca a otros hotspots del mismo usuario. Con un único peer conectado, se le entrega a ése (comportamiento legado). Ver [Echo — Comportamiento con varios hotspots](echo.md#comportamiento-con-varios-hotspots-proxy-inject-only) y [Números especiales](special-numbers.md).
--- ---

@ -216,6 +216,13 @@ class HbpForwardMixin:
_slot_st["lastSeq"] = False _slot_st["lastSeq"] = False
_slot_st["lastData"] = False _slot_st["lastData"] = False
_slot_st["RX_START"] = pkt_time _slot_st["RX_START"] = pkt_time
if _slot_st.get("lastData") and _slot_st["lastData"] == data and seq > 1:
_slot_st["loss"] = _slot_st.get("loss", 0) + 1
logger.debug(
"(%s) *PacketControl* last packet is a complete duplicate, discarding. Stream ID: %s TGID: %s",
system_name, int_id(stream_id), int_id(dst_id),
)
return False
_slot_st["packets"] = _slot_st.get("packets", 0) + 1 _slot_st["packets"] = _slot_st.get("packets", 0) + 1
_pkts = _slot_st["packets"] _pkts = _slot_st["packets"]
_rx_start = _slot_st.get("RX_START", pkt_time) _rx_start = _slot_st.get("RX_START", pkt_time)
@ -279,13 +286,6 @@ class HbpForwardMixin:
src_proto._obp_send_bcsq(dst_id, stream_id) src_proto._obp_send_bcsq(dst_id, stream_id)
_slot_st["_bcsq"] = True _slot_st["_bcsq"] = True
return False return False
if _slot_st.get("lastData") and _slot_st["lastData"] == data and seq > 1:
_slot_st["loss"] = _slot_st.get("loss", 0) + 1
logger.debug(
"(%s) *PacketControl* last packet is a complete duplicate, discarding. Stream ID: %s TGID: %s",
system_name, int_id(stream_id), int_id(dst_id),
)
return False
if seq and seq == _slot_st.get("lastSeq"): if seq and seq == _slot_st.get("lastSeq"):
_slot_st["loss"] = _slot_st.get("loss", 0) + 1 _slot_st["loss"] = _slot_st.get("loss", 0) + 1
return False return False

@ -321,6 +321,7 @@ class LcTaMixin:
tgid: int | None = None, tgid: int | None = None,
force: bool = False, force: bool = False,
fallback_inject: bool = False, fallback_inject: bool = False,
repeat_vhead: bool = False,
) -> None: ) -> None:
"""Emit DMRA to an HBP target on VHEAD (once per target stream).""" """Emit DMRA to an HBP target on VHEAD (once per target stream)."""
if not self._send_dmra_to_system: if not self._send_dmra_to_system:
@ -334,9 +335,9 @@ class LcTaMixin:
if force: if force:
if not self._talker_alias.should_resend_passthrough_dmra(target_system, stream_id): if not self._talker_alias.should_resend_passthrough_dmra(target_system, stream_id):
return return
elif not self._talker_alias.should_send_on_vhead(target_system, stream_id): elif not repeat_vhead and not self._talker_alias.should_send_on_vhead(target_system, stream_id):
return return
elif not self._talker_alias.should_send_on_vhead(target_system, stream_id): elif not repeat_vhead and not self._talker_alias.should_send_on_vhead(target_system, stream_id):
return return
if not have_passthrough: if not have_passthrough:
# Legacy resolve_ta (both): inject at VHEAD when the buffer is still empty. # Legacy resolve_ta (both): inject at VHEAD when the buffer is still empty.
@ -400,6 +401,7 @@ class LcTaMixin:
self._send_talker_alias_to_target( self._send_talker_alias_to_target(
system_name, system_name, rf_src, stream_id, source_peer, system_name, system_name, rf_src, stream_id, source_peer,
wire_slot=wire_slot, tgid=tgid, wire_slot=wire_slot, tgid=tgid,
repeat_vhead=True,
) )
def prepare_talker_alias_local_repeat( def prepare_talker_alias_local_repeat(
@ -526,6 +528,8 @@ class LcTaMixin:
st.pop("TX_TA_EMB", None) st.pop("TX_TA_EMB", None)
st.pop("TX_TA_PHASE", None) st.pop("TX_TA_PHASE", None)
st.pop("TX_TA_ON", None) st.pop("TX_TA_ON", None)
st.pop("_ta_last_embed_burst", None)
st.pop("_ta_last_embed_frag", None)
emblcs = self._talker_alias.embedded_emblc_for_stream( emblcs = self._talker_alias.embedded_emblc_for_stream(
source_system, source_system,
rf_src, rf_src,
@ -562,6 +566,15 @@ class LcTaMixin:
""" """
if dtype_vseq not in (1, 2, 3, 4): if dtype_vseq not in (1, 2, 3, 4):
return return
dmrpkt = dmrbits.tobytes()
burst_key = (dtype_vseq, dmrpkt)
# Duplicated uplink bursts (same B–E payload) must not advance the TA phase
# machine; REPEAT runs before ingress duplicate drops (legacy lastData parity).
if burst_key == st.get("_ta_last_embed_burst"):
last_frag = st.get("_ta_last_embed_frag")
if last_frag is not None:
dmrbits[EMB_LC_SLICE] = last_frag
return
ta_emb = st.get("TX_TA_EMB") ta_emb = st.get("TX_TA_EMB")
if ta_emb is not None and st.get("TX_TA_ON"): if ta_emb is not None and st.get("TX_TA_ON"):
phase = st.get("TX_TA_PHASE", 0) phase = st.get("TX_TA_PHASE", 0)
@ -575,6 +588,8 @@ class LcTaMixin:
if dtype_vseq == 4 and ta_emb is not None: if dtype_vseq == 4 and ta_emb is not None:
st["TX_TA_ON"] = True st["TX_TA_ON"] = True
dmrbits[EMB_LC_SLICE] = frag dmrbits[EMB_LC_SLICE] = frag
st["_ta_last_embed_burst"] = burst_key
st["_ta_last_embed_frag"] = frag
def _clear_talker_alias_embed(self, st: dict[str, Any]) -> None: def _clear_talker_alias_embed(self, st: dict[str, Any]) -> None:
st.pop("TX_TA_EMB", None) st.pop("TX_TA_EMB", None)
@ -582,3 +597,5 @@ class LcTaMixin:
st.pop("TX_TA_BLOCK_COUNT", None) st.pop("TX_TA_BLOCK_COUNT", None)
st.pop("TX_TA_ON", None) st.pop("TX_TA_ON", None)
st.pop("_ta_embed_kind", None) st.pop("_ta_embed_kind", None)
st.pop("_ta_last_embed_burst", None)
st.pop("_ta_last_embed_frag", None)

@ -44,6 +44,7 @@ from adn_server.application.proxy.deployment import (
proxy_target_system, proxy_target_system,
) )
from adn_server.application.report.queue import BoundedReportQueue, QueuedReportSender from adn_server.application.report.queue import BoundedReportQueue, QueuedReportSender
from adn_server.infrastructure.udp_rcvbuf import apply_udp_rcvbuf, udp_rcvbuf_bytes
from adn_server.application.runtime_context import ( from adn_server.application.runtime_context import (
ConfigProxy, ConfigProxy,
RuntimeContext, RuntimeContext,
@ -583,6 +584,7 @@ def run_peer_server(
def _listen_system(_name: str, bind: BindSpec, protocol: Any) -> Any: def _listen_system(_name: str, bind: BindSpec, protocol: Any) -> Any:
port = reactor.listenUDP(bind.port, protocol, interface=bind.ip or "0.0.0.0") port = reactor.listenUDP(bind.port, protocol, interface=bind.ip or "0.0.0.0")
apply_udp_rcvbuf(port.socket, udp_rcvbuf_bytes(config), label=_name, logger=logger)
logger.info("(GLOBAL) UDP %s listening on %s:%s", _name, bind.ip or "*", bind.port) logger.info("(GLOBAL) UDP %s listening on %s:%s", _name, bind.ip or "*", bind.port)
return port return port

@ -131,7 +131,7 @@ def _section_string_keys(section_name: str, section: dict[str, Any], keys: froze
def _validate_global(global_cfg: dict[str, Any], errors: list[str]) -> None: def _validate_global(global_cfg: dict[str, Any], errors: list[str]) -> None:
_section_string_keys("GLOBAL", global_cfg, GLOBAL_STRING_KEYS, errors) _section_string_keys("GLOBAL", global_cfg, GLOBAL_STRING_KEYS, errors)
for key in ("PING_TIME", "MAX_MISSED", "SERVER_ID"): for key in ("PING_TIME", "MAX_MISSED", "SERVER_ID", "UDP_RCVBUF"):
if key in global_cfg: if key in global_cfg:
_expect_int(f"GLOBAL.{key}", global_cfg[key], errors) _expect_int(f"GLOBAL.{key}", global_cfg[key], errors)
for key in ( for key in (

@ -282,6 +282,7 @@ def start_proxy_service(
debug=runtime["debug"], debug=runtime["debug"],
logger=logger, logger=logger,
protocol=fanin, protocol=fanin,
config=config,
) )
state.udp_port = udp_port state.udp_port = udp_port
state.client_sender = FanInClientSender(fanin_proto.transport) state.client_sender = FanInClientSender(fanin_proto.transport)

@ -32,6 +32,7 @@ from adn_server.application.ports import ProxyMasterSink
from adn_server.application.proxy import ProxyUseCases, peer_id_from_packet from adn_server.application.proxy import ProxyUseCases, peer_id_from_packet
from adn_server.domain.result import is_fail from adn_server.domain.result import is_fail
from adn_server.infrastructure.hbp_constants import RPTC, RPTO from adn_server.infrastructure.hbp_constants import RPTC, RPTO
from adn_server.infrastructure.udp_rcvbuf import apply_udp_rcvbuf, udp_rcvbuf_bytes
if TYPE_CHECKING: if TYPE_CHECKING:
from .self_service_bridge import ProxySelfServiceBridge from .self_service_bridge import ProxySelfServiceBridge
@ -118,10 +119,15 @@ def listen_proxy_fanin(
debug: bool = False, debug: bool = False,
logger: logging.Logger | None = None, logger: logging.Logger | None = None,
protocol: ProxyFanInProtocol | None = None, protocol: ProxyFanInProtocol | None = None,
config: dict[str, Any] | None = None,
udp_rcvbuf: int | None = None,
) -> tuple[ProxyFanInProtocol, Any]: ) -> tuple[ProxyFanInProtocol, Any]:
"""Bind LISTEN_PORT and return ``(protocol, udp_port)``.""" """Bind LISTEN_PORT and return ``(protocol, udp_port)``."""
fanin = protocol or ProxyFanInProtocol(proxy, master_sink, debug=debug, logger=logger) log = logger or _logger
fanin = protocol or ProxyFanInProtocol(proxy, master_sink, debug=debug, logger=log)
udp_port = reactor.listenUDP(listen_port, fanin, interface=listen_ip or "0.0.0.0") udp_port = reactor.listenUDP(listen_port, fanin, interface=listen_ip or "0.0.0.0")
buf_size = udp_rcvbuf if udp_rcvbuf is not None else udp_rcvbuf_bytes(config)
apply_udp_rcvbuf(udp_port.socket, buf_size, label="PROXY", logger=log)
return fanin, udp_port return fanin, udp_port

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

@ -53,6 +53,29 @@ def test_hbp_ingress_sets_rx_start_on_new_stream() -> None:
assert slot_st.get("RX_STREAM_ID") == base.data()[16:20] assert slot_st.get("RX_STREAM_ID") == base.data()[16:20]
def test_hbp_rate_limit_ignores_byte_identical_duplicates() -> None:
"""Compressed duplicate bursts must not inflate the ingress rate counter."""
bridges = active_routing_table(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(routing_table=bridges)
base = PacketSpec(dst_id=91, stream_id=0x90909090)
t0 = scenario.clock.time()
scenario.inject_hbp(
"MASTER-A",
DeterministicScenario.voice_head_spec(base),
ingress_pkt_time=t0,
)
for seq in range(1, 25):
burst = DeterministicScenario.voice_burst_spec(base, seq=seq, dtype_vseq=min(seq, 4))
pkt_time = t0 + seq * 0.06
ok = scenario.inject_hbp("MASTER-A", burst, ingress_pkt_time=pkt_time)
assert ok is not False
scenario.inject_hbp("MASTER-A", burst, ingress_pkt_time=pkt_time + 0.003)
forwarded = len(scenario.capture.for_system("MASTER-B"))
assert forwarded >= 20
def test_hbp_rate_drop_prevents_bridge_forward() -> None: def test_hbp_rate_drop_prevents_bridge_forward() -> None:
"""After ingress RATE DROP, no further packets are bridged.""" """After ingress RATE DROP, no further packets are bridged."""
bridges = active_routing_table(91, (("MASTER-A", 2), ("MASTER-B", 2))) bridges = active_routing_table(91, (("MASTER-A", 2), ("MASTER-B", 2)))

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

Powered by TurnKey Linux.