fix(obp): keep keepalives from relocating a bridge's peer

BCKA carries no NETWORK_ID, so on a shared-passphrase mesh anyone's
keepalive verifies against any bridge. It was moving session.peer with no
gate at all — not even RELAX_CHECKS, which the recorded corpus shows:
"bcka from 9.9.9.9:62201 relax=False" moved egress to 9.9.9.9. Since
session.peer is where voice and control are sent, a second instance of a
peer (seen in production on OBP-USA: two hosts, two 10s keepalive timers)
took the traffic over every few seconds.

A keepalive now only confirms liveness. It may still bootstrap a bridge
that has no address yet (inbound-only, no TARGET_IP), since there is
nothing to steal there and it is the only way such a bridge learns where
to answer. Relocation is left to DMRD/DMRE, which identify themselves.

Also: the fan-in demux ranked bridges by sys_cfg's TARGET_SOCK, frozen
since #81 moved runtime state into the session, so the "live" ranks were
dead code and a peer that really moved was no longer recognised. It now
reads learned_peer from the session store, closing the integration #81
left pending.
pull/87/head
Rodrigo Pérez 1 week ago
parent 901e7af657
commit 35f5c01b3f

@ -28,6 +28,7 @@ from typing import Any, Protocol
from twisted.internet.protocol import DatagramProtocol from twisted.internet.protocol import DatagramProtocol
from adn_server.domain.mesh_session import MeshSessionStore
from adn_server.infrastructure.hbp_constants import BCKA, BCSQ, BCST, BCVE, DMRD, DMRE from adn_server.infrastructure.hbp_constants import BCKA, BCSQ, BCST, BCVE, DMRD, DMRE
from adn_server.infrastructure.mesh.obp_v1 import verify_bcka, verify_bcsq, verify_bcst, verify_bcve from adn_server.infrastructure.mesh.obp_v1 import verify_bcka, verify_bcsq, verify_bcst, verify_bcve
from adn_server.infrastructure.udp_rcvbuf import apply_udp_rcvbuf, udp_rcvbuf_bytes from adn_server.infrastructure.udp_rcvbuf import apply_udp_rcvbuf, udp_rcvbuf_bytes
@ -106,10 +107,7 @@ class ObpBridgeEntry:
sink: InProcessObpSink sink: InProcessObpSink
reply_transport: ObpIngressReplyTransport reply_transport: ObpIngressReplyTransport
legacy_port: int | None = None legacy_port: int | None = None
# Live SYSTEMS.<name> dict; used to tell bridges apart by peer address. # Peer as configured, snapshotted when the registry is built.
sys_cfg: dict[str, Any] | None = None
# Peer as configured, snapshotted when the registry is built. RELAX_CHECKS
# rewrites TARGET_SOCK in sys_cfg at runtime; this one never moves.
peer_hint: tuple[str, int] | None = None peer_hint: tuple[str, int] | None = None
@ -120,6 +118,8 @@ class ObpBridgeRegistry:
by_network_id: dict[bytes, str] = field(default_factory=dict) by_network_id: dict[bytes, str] = field(default_factory=dict)
by_legacy_port: dict[int, str] = field(default_factory=dict) by_legacy_port: dict[int, str] = field(default_factory=dict)
bridges: dict[str, ObpBridgeEntry] = field(default_factory=dict) bridges: dict[str, ObpBridgeEntry] = field(default_factory=dict)
# Where each bridge's peer actually is, as DMRD/DMRE taught it.
sessions: MeshSessionStore | None = None
def register(self, entry: ObpBridgeEntry) -> None: def register(self, entry: ObpBridgeEntry) -> None:
self.bridges[entry.system_name] = entry self.bridges[entry.system_name] = entry
@ -127,35 +127,38 @@ class ObpBridgeRegistry:
if entry.legacy_port is not None: if entry.legacy_port is not None:
self.by_legacy_port[entry.legacy_port] = entry.system_name self.by_legacy_port[entry.legacy_port] = entry.system_name
@staticmethod def _learned_peer(self, system_name: str) -> tuple[str, int] | None:
def _peer_rank(entry: ObpBridgeEntry, addr: tuple[str, int]) -> int: if self.sessions is None:
return None
session = self.sessions.get(system_name)
return session.learned_peer if session is not None else None
def _peer_rank(self, entry: ObpBridgeEntry, addr: tuple[str, int]) -> int:
host, port = addr host, port = addr
hint = entry.peer_hint hint = entry.peer_hint
live = peer_sock_from_config(entry.sys_cfg) learned = self._learned_peer(entry.system_name)
if hint is not None and hint == (host, port): if hint is not None and hint == (host, port):
return 0 return 0
if live is not None and live == (host, port): if learned is not None and learned == (host, port):
return 1 return 1
if hint is not None and hint[0] == host: if hint is not None and hint[0] == host:
return 2 return 2
if live is not None and live[0] == host: if learned is not None and learned[0] == host:
return 3 return 3
return 4 return 4
def bridges_by_peer(self, addr: tuple[str, int] | None) -> list[tuple[str, ObpBridgeEntry]]: def bridges_by_peer(self, addr: tuple[str, int] | None) -> list[tuple[str, ObpBridgeEntry]]:
"""Bridges ordered by how well their peer matches ``addr``. """Bridges ordered by how well their peer matches ``addr``.
The configured peer (``peer_hint``, taken from the YAML when the registry is The configured peer (``peer_hint``, from the YAML) is tried before the learned
built) is tried before the live ``TARGET_SOCK``, which RELAX_CHECKS rewrites one so a wrong match can never entrench itself; the learned one is what lets a
in place whenever a bridge accepts traffic from an unexpected source: ranking peer that really moved still be recognised. It comes from the session store,
the live value first would let a single misattributed control frame move a where only DMRD/DMRE put it — they carry NETWORK_ID, so they cannot be
bridge's target and then keep matching that same wrong address. Matching the misattributed the way a control frame can.
live value after it still lets a peer that legitimately moved (dynamic IP,
learned from a DMRD, which carries NETWORK_ID) be recognised. Order: configured IP:port, learned IP:port, configured IP, learned IP (a peer
answering from another local port, e.g. its own fan-in), then registration
Order: configured IP:port, live IP:port, configured IP, live IP (a peer that order — where a shared passphrase leaves nothing else to go on.
answers from another local port, e.g. its own fan-in), then registration
order.
""" """
items = list(self.bridges.items()) items = list(self.bridges.items())
if addr is None: if addr is None:

@ -29,6 +29,7 @@ from typing import Any
from twisted.internet import reactor from twisted.internet import reactor
from adn_server.application.proxy.deployment import obp_bridge_legacy_listen_port, obp_proxy_enabled from adn_server.application.proxy.deployment import obp_bridge_legacy_listen_port, obp_proxy_enabled
from adn_server.domain.mesh_session import mesh_sessions
from adn_server.infrastructure.proxy.obp_config import obp_proxy_settings from adn_server.infrastructure.proxy.obp_config import obp_proxy_settings
from adn_server.infrastructure.proxy.obp_fanin import ( from adn_server.infrastructure.proxy.obp_fanin import (
InProcessObpSink, InProcessObpSink,
@ -50,7 +51,7 @@ def build_obp_bridge_registry(
primary_transport: Any, primary_transport: Any,
) -> ObpBridgeRegistry: ) -> ObpBridgeRegistry:
"""Register enabled OPENBRIDGE systems for fan-in demux.""" """Register enabled OPENBRIDGE systems for fan-in demux."""
registry = ObpBridgeRegistry() registry = ObpBridgeRegistry(sessions=mesh_sessions(config))
systems = config.get("SYSTEMS", {}) systems = config.get("SYSTEMS", {})
if not isinstance(systems, dict): if not isinstance(systems, dict):
return registry return registry
@ -83,7 +84,6 @@ def build_obp_bridge_registry(
sink=InProcessObpSink(proto), sink=InProcessObpSink(proto),
reply_transport=reply, reply_transport=reply,
legacy_port=legacy_port if legacy_port and legacy_port > 0 else None, legacy_port=legacy_port if legacy_port and legacy_port > 0 else None,
sys_cfg=sys_cfg,
peer_hint=peer_sock_from_config(sys_cfg), peer_hint=peer_sock_from_config(sys_cfg),
) )
registry.register(entry) registry.register(entry)

@ -265,6 +265,7 @@ class HBPProtocol(DatagramProtocol):
self.STATUS: dict[Any, Any] = {} self.STATUS: dict[Any, Any] = {}
self._bcsq_log_once: deque = deque(maxlen=1024) self._bcsq_log_once: deque = deque(maxlen=1024)
self._obp_target_sync_log_once: deque = deque(maxlen=1024) self._obp_target_sync_log_once: deque = deque(maxlen=1024)
self._obp_foreign_bcka_log_once: deque = deque(maxlen=1024)
else: else:
self._laststrid = {1: b"", 2: b""} self._laststrid = {1: b"", 2: b""}
self.STATUS = {1: _make_slot_status(), 2: _make_slot_status()} self.STATUS = {1: _make_slot_status(), 2: _make_slot_status()}
@ -2320,11 +2321,34 @@ class HBPProtocol(DatagramProtocol):
if _packet[:4] == BCKA and len(_packet) >= 24: if _packet[:4] == BCKA and len(_packet) >= 24:
if verify_bcka(_packet, _passphrase): if verify_bcka(_packet, _passphrase):
_session = self._session _session = self._session
_was = _session.peer
_now = time.time() _now = time.time()
_session.note_keepalive(_now) _session.note_keepalive(_now)
# BCKA carries no NETWORK_ID, so with a shared passphrase anyone's
# keepalive verifies here: it may bootstrap a peer we have no address
# for, never move one we already have. DMRD/DMRE identify themselves
# and do that instead (_obp_sync_target_sock_from_peer).
if not _session.peer_known:
if _session.learn_peer(_sockaddr, at=_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]) logger.info(
"(%s) *BridgeControl* OBP peer address learned from keepalive: %s:%s",
self._system,
_sockaddr[0],
_sockaddr[1],
)
elif _sockaddr != _session.peer:
_once = getattr(self, "_obp_foreign_bcka_log_once", None)
if not isinstance(_once, deque) or _sockaddr not in _once:
if isinstance(_once, deque):
_once.append(_sockaddr)
logger.debug(
"(%s) *BridgeControl* BCKA from %s:%s is not this bridge's peer %s:%s "
"(keepalive only; a second instance of the peer, or another bridge sharing the passphrase)",
self._system,
_sockaddr[0],
_sockaddr[1],
_session.peer[0],
_session.peer[1],
)
else: else:
logger.info("(%s) *BridgeControl* BCKA invalid KeepAlive, packet discarded", self._system) 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) # Source quench — legacy hblink.py OPENBRIDGE ~629-639 (sets CONFIG['_bcsq'][tgid]=stream_id)

@ -78,10 +78,10 @@
{"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 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=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: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=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",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[10,"(OBP-FR) *BridgeControl* BCKA from 82.65.127.86:40000 is not this bridge's peer 82.65.127.86:62201 (keepalive only; a second instance of the peer, or another bridge sharing the passphrase)"]],"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 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",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[10,"(OBP-FR) *BridgeControl* BCKA from 82.65.127.86:40000 is not this bridge's peer 82.65.127.86:62201 (keepalive only; a second instance of the peer, or another bridge sharing the passphrase)"]],"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=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,["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"],[10,"(OBP-FR) *BridgeControl* BCKA from 9.9.9.9:62201 is not this bridge's peer 82.65.127.86:62201 (keepalive only; a second instance of the peer, or another bridge sharing the passphrase)"]],"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 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,["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"],[10,"(OBP-FR) *BridgeControl* BCKA from 9.9.9.9:62201 is not this bridge's peer 82.65.127.86:62201 (keepalive only; a second instance of the peer, or another bridge sharing the passphrase)"]],"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=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: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=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":[]}}
@ -94,3 +94,4 @@
{"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 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=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":[]}} {"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":[]}}
{"case":{"desc":"with no TARGET_IP configured","from":["82.65.127.86",62201],"kind":"bcka","name":"bcka with no TARGET_IP configured","no_peer":true,"stream":1358954593},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[73,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: , TARGET_PORT: 62201"],[10,"(OBP-FR) *BridgeControl* not sending KeepAlive, TARGET not currently known"],[20,"(OBP-FR) *BridgeControl* OBP peer address learned from keepalive: 82.65.127.86:62201"]],"quenched":[]}}

@ -122,6 +122,9 @@ def build_config(case: dict[str, Any]) -> dict[str, Any]:
"_PEER_IDS": {}, "_PEER_IDS": {},
"_LOCAL_SUBSCRIBER_IDS": {}, "_LOCAL_SUBSCRIBER_IDS": {},
} }
if case.get("no_peer"): # inbound-only bridge: the operator set no TARGET_IP
system["TARGET_IP"] = None
system["TARGET_SOCK"] = (None, PEER[1])
if case.get("stun"): if case.get("stun"):
config["STUN"] = True config["STUN"] = True
return config return config
@ -275,6 +278,7 @@ def _cases() -> list[dict[str, Any]]:
): ):
for relax in (False, True): for relax in (False, True):
add(kind=kind, desc=f"from {addr[0]}:{addr[1]} relax={relax}", relax=relax, **{"from": list(addr)}) add(kind=kind, desc=f"from {addr[0]}:{addr[1]} relax={relax}", relax=relax, **{"from": list(addr)})
add(kind="bcka", desc="with no TARGET_IP configured", no_peer=True, **{"from": list(PEER)})
return cases return cases

@ -30,7 +30,16 @@ read it before regenerating with ``CAPTURE=1``.
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from tests.harness.obp_ingress import CASES, FIXTURE, as_json, capture_enabled, load, observe, record from tests.harness.obp_ingress import (
CASES,
FIXTURE,
PEER,
as_json,
capture_enabled,
load,
observe,
record,
)
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
@ -50,3 +59,28 @@ def test_corpus_covers_every_case(recorded: dict[str, dict]) -> None:
def test_ingress_effects_match_the_recording(case: dict, recorded: dict[str, dict]) -> None: def test_ingress_effects_match_the_recording(case: dict, recorded: dict[str, dict]) -> None:
expected = recorded[case["name"]]["effects"] expected = recorded[case["name"]]["effects"]
assert as_json(observe(case)) == expected assert as_json(observe(case)) == expected
@pytest.mark.parametrize(
"case",
[c for c in CASES if c["kind"] == "bcka" and not c.get("no_peer")],
ids=lambda c: c["name"],
)
def test_a_keepalive_never_moves_egress_off_the_peer(case: dict) -> None:
"""A keepalive carries no NETWORK_ID, so anyone holding the passphrase can send
one: a second instance of the peer, or another bridge on a shared-passphrase
mesh. It must not decide where this bridge transmits. Recorded above as well,
but asserted here so regenerating the corpus cannot drop it.
"""
for _size, addr in observe(case)["egress"]:
assert tuple(addr) == PEER
def test_a_keepalive_bootstraps_a_bridge_with_no_configured_peer() -> None:
"""The other half: with no TARGET_IP there is nothing to protect and nothing to
steal, and the keepalive is the only way an inbound-only bridge learns where to
answer. Bootstrapping an unknown peer stays allowed.
"""
case = next(c for c in CASES if c.get("no_peer"))
for _size, addr in observe(case)["egress"]:
assert tuple(addr) == PEER

@ -37,6 +37,7 @@ from adn_server.application.proxy.deployment import (
) )
from adn_server.domain import bytes_4 from adn_server.domain import bytes_4
from adn_server.domain.errors import ConfigError from adn_server.domain.errors import ConfigError
from adn_server.domain.mesh_session import obp_session
from adn_server.infrastructure.config_normalizer import normalize_obp_config from adn_server.infrastructure.config_normalizer import normalize_obp_config
from adn_server.infrastructure.config_validator import validate_config from adn_server.infrastructure.config_validator import validate_config
from adn_server.infrastructure.hbp_constants import DMRD from adn_server.infrastructure.hbp_constants import DMRD
@ -417,7 +418,7 @@ def _shared_passphrase_registry() -> tuple[ObpBridgeRegistry, dict[str, _Recordi
passphrase=_PASS, passphrase=_PASS,
sink=InProcessObpSink(proto), sink=InProcessObpSink(proto),
reply_transport=ObpIngressReplyTransport(_RecordingTransport()), reply_transport=ObpIngressReplyTransport(_RecordingTransport()),
sys_cfg={"TARGET_SOCK": peer, "TARGET_IP": peer[0], "TARGET_PORT": peer[1]}, peer_hint=peer,
) )
) )
return registry, protos return registry, protos
@ -548,9 +549,9 @@ def test_reload_keeps_egress_pinned_to_the_bridge_socket(monkeypatch) -> None:
assert fake.transports[62032].sent == [] assert fake.transports[62032].sent == []
def test_control_frame_prefers_configured_peer_over_relaxed_target() -> None: def test_control_frame_prefers_configured_peer_over_learned_one() -> None:
"""RELAX_CHECKS rewrites TARGET_SOCK in place; a bridge dragged onto another """A bridge that learned another bridge's address must not start stealing that
bridge's address must not start stealing that peer's control frames.""" peer's control frames: the configured peer outranks anything learned."""
config = _obp_mesh_config() config = _obp_mesh_config()
protocols = {name: _RecordingObp() for name in ("OBP-FR", "OBP-PT")} protocols = {name: _RecordingObp() for name in ("OBP-FR", "OBP-PT")}
registry = build_obp_bridge_registry( registry = build_obp_bridge_registry(
@ -561,10 +562,7 @@ def test_control_frame_prefers_configured_peer_over_relaxed_target() -> None:
primary_transport=_RecordingTransport(), primary_transport=_RecordingTransport(),
) )
peer_pt = ("85.241.222.7", 62268) peer_pt = ("85.241.222.7", 62268)
# What _obp_sync_target_sock_from_peer() does after a misattributed frame. obp_session(config, "OBP-FR").learn_peer(peer_pt, at=1.0)
poisoned = config["SYSTEMS"]["OBP-FR"]
poisoned["TARGET_IP"], poisoned["TARGET_PORT"] = peer_pt
poisoned["TARGET_SOCK"] = peer_pt
demux = ObpFanInDemux(registry) demux = ObpFanInDemux(registry)
demux.deliver(build_bcka(_PASS), peer_pt, local_port=62032, transport=_RecordingTransport()) demux.deliver(build_bcka(_PASS), peer_pt, local_port=62032, transport=_RecordingTransport())
@ -572,6 +570,28 @@ def test_control_frame_prefers_configured_peer_over_relaxed_target() -> None:
assert not protocols["OBP-FR"].packets assert not protocols["OBP-FR"].packets
def test_control_frame_follows_a_peer_that_moved() -> None:
"""Voice (DMRD/DMRE, which carries NETWORK_ID) teaches the session where a peer
really is; control frames from there must resolve to it, not to whichever bridge
happens to be registered first."""
config = _obp_mesh_config()
protocols = {name: _RecordingObp() for name in ("OBP-FR", "OBP-PT")}
registry = build_obp_bridge_registry(
config,
protocols,
bind_legacy_ports=True,
listen_port=62032,
primary_transport=_RecordingTransport(),
)
moved_to = ("129.80.176.29", 62268)
obp_session(config, "OBP-PT").learn_peer(moved_to, at=1.0)
demux = ObpFanInDemux(registry)
demux.deliver(build_bcka(_PASS), moved_to, local_port=62032, transport=_RecordingTransport())
assert protocols["OBP-PT"].packets, "the keepalive did not follow the peer that moved"
assert not protocols["OBP-FR"].packets
def test_debug_does_not_log_voice_packets(caplog) -> None: def test_debug_does_not_log_voice_packets(caplog) -> None:
"""DMRD/DMRE demux by NETWORK_ID, never ambiguous, and *CALL START*/*CALL END* """DMRD/DMRE demux by NETWORK_ID, never ambiguous, and *CALL START*/*CALL END*
(routing_use_cases.py) already give once-per-call visibility. Concurrent calls (routing_use_cases.py) already give once-per-call visibility. Concurrent calls

Loading…
Cancel
Save

Powered by TurnKey Linux.