feat(obp): anchor a bridge's peer to DNS when TARGET_IP is a hostname

A peer on a dynamic IP forces RELAX_CHECKS on, and RELAX_CHECKS meant
"accept from any address on earth". On a shared-passphrase mesh that is
enough for a second host to be taken for the peer: production showed
OBP-USA with two live instances (74.132.44.239, the configured peer, and
129.80.176.29, a stale clone), both authenticating, both sending voice,
keepalives and quenches. The session address flapped between them every
few seconds, so half of what we transmitted went to the wrong host, loss
climbed to 30%, and hop counts escalated until MAX HOPS dropped frames.

The network already had the answer: TARGET_IP was written as a name,
3103.adn.systems, which tracks the dynamic IP by DNS. normalize_obp_config
resolved it once and then overwrote TARGET_IP with the address, losing the
name, so nothing could ever ask again — while PEER systems have kept
_MASTER_IP and re-resolved through reactor.resolve() all along.

OPENBRIDGE now gets the same treatment:

- normalize_obp_config keeps the name in _TARGET_IP, as PEER keeps
  _MASTER_IP.
- A session whose TARGET_IP was a name is DNS-anchored: learn_peer()
  refuses to move it, and only adopt_resolved() can.
- accepts_source() stops widening to "anywhere" for an anchored bridge.
  RELAX_CHECKS keeps its meaning for bridges configured with an address.
- Control frames get that same check. They had none at all, which is how a
  foreign BCSQ could quench a live stream and a foreign BCST could STUN the
  bridge outright.
- A frame from elsewhere is refused and schedules a re-resolution, rate
  limited so unknown traffic cannot drive a lookup per packet. If the name
  now answers with that address, the peer migrates and the next frame is
  accepted; a periodic loop keeps it fresh while the link is idle. A
  resolver failure keeps the address we have.

Verified on the 213 master: the clone's frames are refused and logged once,
the flapping is gone, and a real call bridged to eight systems at 0.37%
loss and 6 hops.
pull/87/head
Rodrigo Pérez 1 week ago
parent 35f5c01b3f
commit d5d44b1fde

@ -184,8 +184,16 @@ def reject_v1_protocol(stream_id: bytes, *, policy: BridgePolicy) -> list[Effect
def accepts_source(
addr: tuple[str, int] | None, *, policy: BridgePolicy, session: ObpBridgeSession
) -> bool:
"""A frame counts as ours when it comes from the peer, or RELAX_CHECKS is on."""
return bool(policy.relax_checks) or addr == session.peer
"""A frame counts as ours when it comes from the peer.
RELAX_CHECKS widens that to any address, which is how a peer on a dynamic IP
keeps working. It does not widen it when DNS owns the peer: there the name is
the identity and only a re-resolution may move it, so a second host holding
the same passphrase is not mistaken for the peer.
"""
if addr == session.peer:
return True
return bool(policy.relax_checks) and not session.dns_anchored
def _delivery_effects(

@ -36,6 +36,7 @@ question it actually means — "has a keepalive ever arrived?", "is it stale?",
from __future__ import annotations
import ipaddress
from dataclasses import dataclass, field
from typing import Any
@ -59,6 +60,24 @@ def _peer_from_config(sys_cfg: dict[str, Any] | None) -> tuple[str | None, int]:
return (str(host) if host else None, port)
def dns_host_from_config(sys_cfg: dict[str, Any] | None) -> str | None:
"""``TARGET_IP`` as written, when it was a hostname rather than a literal address.
``normalize_obp_config`` keeps the original under ``_TARGET_IP`` before it
overwrites ``TARGET_IP`` with what the name resolved to, the same way PEER
systems keep ``_MASTER_IP``.
"""
original = (sys_cfg or {}).get("_TARGET_IP")
if not original:
return None
text = str(original).strip()
try:
ipaddress.ip_address(text)
except ValueError:
return text or None
return None
@dataclass
class ObpBridgeSession:
"""What one OpenBridge link knows about its peer right now."""
@ -71,12 +90,23 @@ class ObpBridgeSession:
quenched: dict[bytes, bytes] = field(default_factory=dict)
stunned: bool = False
drops: dict[str, int] = field(default_factory=dict)
# TARGET_IP as the operator wrote it, when that was a hostname. Set means DNS
# owns this peer's address: nothing the wire says can move it.
dns_host: str | None = None
resolved_peer: tuple[str, int] | None = None
dns_checked_at: float = 0.0
# --- peer address --------------------------------------------------------
@property
def dns_anchored(self) -> bool:
return bool(self.dns_host)
@property
def peer(self) -> tuple[str | None, int]:
"""Where to send: what the wire taught us, else what the YAML says."""
"""Where to send: DNS when it owns this peer, else what the wire taught us."""
if self.dns_anchored:
return self.resolved_peer or self.configured_peer
return self.learned_peer or self.configured_peer
@property
@ -87,6 +117,8 @@ class ObpBridgeSession:
"""Remember the address a datagram really came from. True when it moved."""
if not addr or not addr[0]:
return False
if self.dns_anchored: # only a re-resolution may move a DNS-anchored peer
return False
host, port = str(addr[0]), int(addr[1])
if self.peer == (host, port):
return False
@ -94,6 +126,15 @@ class ObpBridgeSession:
self.learned_at = at
return True
def adopt_resolved(self, addr: tuple[str, int], *, at: float) -> bool:
"""Move to where DNS now says the peer is. True when it moved."""
host, port = str(addr[0]), int(addr[1])
self.dns_checked_at = at
if self.peer == (host, port):
return False
self.resolved_peer = (host, port)
return True
def forget_learned_peer(self) -> None:
"""Drop what the wire taught us and fall back to the configured peer."""
self.learned_peer = None
@ -191,10 +232,12 @@ class MeshSessionStore:
session = ObpBridgeSession(
system_name=system_name,
configured_peer=_peer_from_config(sys_cfg),
dns_host=dns_host_from_config(sys_cfg),
)
self._sessions[system_name] = session
elif sys_cfg is not None:
session.configured_peer = _peer_from_config(sys_cfg)
session.dns_host = dns_host_from_config(sys_cfg)
return session
def drop(self, system_name: str) -> None:

@ -151,6 +151,9 @@ def normalize_obp_config(config: dict) -> None:
sys_cfg["NETWORK_ID"] = (net_id & 0xFFFFFFFF).to_bytes(4, "big")
target_ip = str(sys_cfg.get("TARGET_IP", ""))
target_port = int(sys_cfg.get("TARGET_PORT", 62044))
# Keep the name the operator wrote, like PEER keeps _MASTER_IP: resolving
# here overwrites TARGET_IP, and a hostname has to stay re-resolvable.
sys_cfg["_TARGET_IP"] = target_ip
if target_ip:
try:
resolved = socket.gethostbyname(target_ip)

@ -155,6 +155,12 @@ logger = logging.getLogger(__name__)
_DEFAULT_MESH_REGISTRY = MeshCodecRegistry()
# A DNS-anchored OBP peer is re-resolved on this cadence, and on demand when a
# frame arrives from somewhere else — never more often than the shorter one, so
# unknown traffic cannot drive a lookup per packet.
OBP_DNS_REFRESH_S = 300.0
OBP_DNS_MIN_INTERVAL_S = 15.0
def get_user_password(radio_id: int):
"""Legacy get_user_password (hblink.py). Stub: returns None (no individual passwords)."""
@ -266,6 +272,7 @@ class HBPProtocol(DatagramProtocol):
self._bcsq_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)
self._obp_foreign_source_log_once: deque = deque(maxlen=1024)
else:
self._laststrid = {1: b"", 2: b""}
self.STATUS = {1: _make_slot_status(), 2: _make_slot_status()}
@ -322,6 +329,10 @@ class HBPProtocol(DatagramProtocol):
self._bcve_loop = task.LoopingCall(self._obp_send_bcve)
_bcve_d = self._bcve_loop.start(60)
_bcve_d.addErrback(self._looping_err_handle)
if self._session.dns_anchored:
self._dns_loop = task.LoopingCall(self._obp_resolve_target)
_dns_d = self._dns_loop.start(OBP_DNS_REFRESH_S, now=False)
_dns_d.addErrback(self._looping_err_handle)
elif self._config.get("MODE") == "MASTER":
ping_time = self._CONFIG.get("GLOBAL", {}).get("PING_TIME", 10)
self._maintenance_loop = task.LoopingCall(self._master_maintenance_loop)
@ -2160,6 +2171,77 @@ class HBPProtocol(DatagramProtocol):
if _stream_id is not None and isinstance(_once, deque):
_once.append(_stream_id)
def _obp_reject_source(self, _opcode: bytes, _sockaddr: tuple[str, int]) -> None:
"""Refuse a frame from an address DNS does not give for this peer, and ask again.
A peer really moving is exactly what this looks like, so the refusal
schedules a re-resolution: if the name now answers with this address, the
next frame is accepted.
"""
self._obp_resolve_target()
_once = getattr(self, "_obp_foreign_source_log_once", None)
if isinstance(_once, deque) and _sockaddr in _once:
return
if isinstance(_once, deque):
_once.append(_sockaddr)
_peer = self._session.peer
_why = (
f"{self._session.dns_host} resolves to {_peer[0]}:{_peer[1]}"
if self._session.dns_anchored
else f"this bridge's peer is {_peer[0]}:{_peer[1]}"
)
logger.info(
"(%s) *BridgeControl* %s from %s:%s discarded: %s",
self._system,
_opcode.decode("ascii", errors="replace"),
_sockaddr[0],
int(_sockaddr[1]),
_why,
)
def _obp_resolve_target(self) -> None:
"""Ask DNS where this peer is now: non-blocking, and rate limited.
Never resolve on the datagram thread — an unknown source must not be able
to drive a lookup per packet.
"""
_session = self._session
if not _session.dns_anchored:
return
_now = time.time()
if _now - _session.dns_checked_at < OBP_DNS_MIN_INTERVAL_S:
return
_session.dns_checked_at = _now
_d = reactor.resolve(_session.dns_host)
_d.addCallback(self._obp_target_resolved)
_d.addErrback(self._obp_target_resolve_failed)
def _obp_target_resolved(self, _host: str) -> None:
_session = self._session
_was = _session.peer
if _session.adopt_resolved((_host, int(_session.configured_peer[1])), at=time.time()):
logger.info(
"(%s) *BridgeControl* OBP peer moved: %s now resolves to %s (was %s:%s)",
self._system,
_session.dns_host,
_host,
_was[0],
_was[1],
)
_once = getattr(self, "_obp_foreign_source_log_once", None)
if isinstance(_once, deque):
_once.clear()
def _obp_target_resolve_failed(self, _failure: Any) -> None:
"""Keep the address we have: a name server hiccup must not drop the link."""
logger.debug(
"(%s) *BridgeControl* could not resolve %s, keeping %s:%s",
self._system,
self._session.dns_host,
self._session.peer[0],
self._session.peer[1],
)
@property
def _session(self) -> ObpBridgeSession:
"""Live state of this OpenBridge link (peer, keepalive, quench)."""
@ -2276,11 +2358,12 @@ class HBPProtocol(DatagramProtocol):
self._obp_apply(reject_v1_protocol(_stream_id, policy=_policy))
return
_ingress = self._try_decode_mesh_ingress(_packet)
if (
_ingress is not None
and _ingress.codec == "obp_v1"
and accepts_source(_sockaddr, policy=_policy, session=self._session)
if _ingress is not None and _ingress.codec == "obp_v1" and not accepts_source(
_sockaddr, policy=_policy, session=self._session
):
self._obp_reject_source(_packet[:4], _sockaddr)
return
if _ingress is not None and _ingress.codec == "obp_v1":
self._obp_sync_target_sock_from_peer(_sockaddr, _stream_id)
self._obp_apply(
ingest_dmrd_v1(
@ -2299,7 +2382,7 @@ class HBPProtocol(DatagramProtocol):
return
_policy = self._obp_policy()
if not accepts_source(_sockaddr, policy=_policy, session=self._session):
logger.warning("(%s) OpenBridge DMRE BLAKE2b failed, packet discarded - SRC: %s", self._system, _sockaddr)
self._obp_reject_source(_packet[:4], _sockaddr)
return
_trailer = parse_dmre_trailer(_packet)
_timestamp = _trailer.timestamp if _trailer is not None else b"\x00" * 8
@ -2317,6 +2400,11 @@ class HBPProtocol(DatagramProtocol):
elif _packet[:4] == EOBP:
logger.warning("(%s) *ProtoControl* KF7EEL EOBP protocol not supported", self._system)
elif self._config.get("ENHANCED_OBP") and _packet[:2] == BC:
# Control frames carry no NETWORK_ID, so the source address is the only
# thing telling one sender from another on a shared-passphrase mesh.
if not accepts_source(_sockaddr, policy=self._obp_policy(), session=self._session):
self._obp_reject_source(_packet[:4], _sockaddr)
return
_passphrase = _get_passphrase_bytes(self._config)
if _packet[:4] == BCKA and len(_packet) >= 24:
if verify_bcka(_packet, _passphrase):

@ -51,6 +51,55 @@ def _config(**overrides) -> dict:
return {"SYSTEMS": {"OBP-FR": sys_cfg}}
# --- peer anchored to DNS -----------------------------------------------------
def _dns_session() -> ObpBridgeSession:
return ObpBridgeSession(
system_name="OBP-FR", configured_peer=_CONFIGURED, dns_host="peer.example.net"
)
def test_a_dns_anchored_peer_ignores_what_the_wire_says() -> None:
"""The name is the identity: a host that holds the passphrase but is not what
the name resolves to cannot take the link over."""
session = _dns_session()
assert session.dns_anchored
assert session.learn_peer(_ELSEWHERE, at=_NOW) is False
assert session.peer == _CONFIGURED
def test_a_dns_anchored_peer_moves_when_the_name_resolves_elsewhere() -> None:
session = _dns_session()
assert session.adopt_resolved(_ELSEWHERE, at=_NOW) is True
assert session.peer == _ELSEWHERE
assert session.dns_checked_at == _NOW
def test_resolving_to_the_same_address_is_not_a_move() -> None:
session = _dns_session()
assert session.adopt_resolved(_CONFIGURED, at=_NOW) is False
assert session.peer == _CONFIGURED
assert session.dns_checked_at == _NOW
def test_a_target_written_as_an_address_is_not_dns_anchored() -> None:
"""Only a name can be re-resolved; a literal address keeps the old behaviour."""
store = MeshSessionStore()
session = store.session("OBP-FR", {"_TARGET_IP": _CONFIGURED[0], "TARGET_SOCK": _CONFIGURED})
assert session.dns_anchored is False
assert session.learn_peer(_ELSEWHERE, at=_NOW) is True
def test_a_target_written_as_a_name_is_dns_anchored() -> None:
store = MeshSessionStore()
session = store.session(
"OBP-FR", {"_TARGET_IP": "peer.example.net", "TARGET_SOCK": _CONFIGURED}
)
assert session.dns_host == "peer.example.net"
assert session.peer == _CONFIGURED
# --- peer address ------------------------------------------------------------

@ -37,7 +37,7 @@
{"case":{"desc":"denied tg 778","dst":778,"global_acl":true,"kind":"v1","name":"v1 denied tg 778","stream":1358954533,"system_acl":true},"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) CALL DROPPED WITH STREAM ID 1358954533 ON TGID 778 BY GLOBAL TS1 ACL"]],"quenched":[[778,1358954533]]}}
{"case":{"desc":"stunned by the operator","kind":"v1","name":"v1 stunned by the operator","stream":1358954534,"stun":true},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[30,"(OBP-FR) Bridge STUNned, discarding"],[20,"(OBP-FR) Bridge STUNned, discarding"]],"quenched":[]}}
{"case":{"desc":"from an unexpected address","from":["9.9.9.9",40000],"kind":"v1","name":"v1 from an unexpected address","stream":1358954535},"effects":{"delivered":[[2130003,214]],"egress":[[24,["9.9.9.9",40000]],[73,["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, no relax","from":["9.9.9.9",40000],"kind":"v1","name":"v1 from an unexpected address, no relax","relax":false,"stream":1358954536},"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"],[30,"(OBP-FR) OpenBridge HMAC failed, packet discarded - OPCODE: b'DMRD' SRC: ('9.9.9.9', 40000)"]],"quenched":[]}}
{"case":{"desc":"from an unexpected address, no relax","from":["9.9.9.9",40000],"kind":"v1","name":"v1 from an unexpected address, no relax","relax":false,"stream":1358954536},"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* DMRD from 9.9.9.9:40000 discarded: this bridge's peer is 82.65.127.86:62201"]],"quenched":[]}}
{"case":{"desc":"tg 1","dst":1,"kind":"v5","name":"v5 tg 1","stream":1358954537},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954537 ON TG 1 BY GLOBAL TG FILTER (local to repeater)"]],"quenched":[[1,1358954537]]}}
{"case":{"desc":"tg 9","dst":9,"kind":"v5","name":"v5 tg 9","stream":1358954538},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954538 ON TG 9 BY GLOBAL TG FILTER (local to repeater)"]],"quenched":[[9,1358954538]]}}
{"case":{"desc":"tg 79","dst":79,"kind":"v5","name":"v5 tg 79","stream":1358954539},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",62201]],[89,["82.65.127.86",62201]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[20,"(OBP-FR) CALL DROPPED WITH STREAM ID 1358954539 ON TG 79 BY GLOBAL TG FILTER (local to repeater)"]],"quenched":[[79,1358954539]]}}
@ -78,20 +78,26 @@
{"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=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",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=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"],[20,"(OBP-FR) *BridgeControl* BCKA from 82.65.127.86:40000 discarded: this bridge's peer is 82.65.127.86:62201"]],"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,["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=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"],[20,"(OBP-FR) *BridgeControl* BCKA from 9.9.9.9:62201 discarded: this bridge's peer is 82.65.127.86:62201"]],"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=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 from 82.65.127.86:40000 discarded: this bridge's peer is 82.65.127.86:62201"]],"quenched":[]}}
{"case":{"desc":"from 82.65.127.86:40000 relax=True","from":["82.65.127.86",40000],"kind":"bcsq","name":"bcsq from 82.65.127.86:40000 relax=True","relax":true,"stream":1358954584},"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=1358954584 TGID=214 (peer quenched; forwarding on this OBP stops for this stream/TG)"]],"quenched":[]}}
{"case":{"desc":"from 9.9.9.9:62201 relax=False","from":["9.9.9.9",62201],"kind":"bcsq","name":"bcsq from 9.9.9.9:62201 relax=False","relax":false,"stream":1358954585},"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=1358954585 TGID=214 (peer quenched; forwarding on this OBP stops for this stream/TG)"]],"quenched":[]}}
{"case":{"desc":"from 9.9.9.9:62201 relax=False","from":["9.9.9.9",62201],"kind":"bcsq","name":"bcsq from 9.9.9.9:62201 relax=False","relax":false,"stream":1358954585},"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 from 9.9.9.9:62201 discarded: this bridge's peer is 82.65.127.86:62201"]],"quenched":[]}}
{"case":{"desc":"from 9.9.9.9:62201 relax=True","from":["9.9.9.9",62201],"kind":"bcsq","name":"bcsq from 9.9.9.9:62201 relax=True","relax":true,"stream":1358954586},"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=1358954586 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":"bcst","name":"bcst from 82.65.127.86:62201 relax=False","relax":false,"stream":1358954587},"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:62201 relax=True","from":["82.65.127.86",62201],"kind":"bcst","name":"bcst from 82.65.127.86:62201 relax=True","relax":true,"stream":1358954588},"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=False","from":["82.65.127.86",40000],"kind":"bcst","name":"bcst from 82.65.127.86:40000 relax=False","relax":false,"stream":1358954589},"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=False","from":["82.65.127.86",40000],"kind":"bcst","name":"bcst from 82.65.127.86:40000 relax=False","relax":false,"stream":1358954589},"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* BCST from 82.65.127.86:40000 discarded: this bridge's peer is 82.65.127.86:62201"]],"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]],[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* BCST from 9.9.9.9:62201 discarded: this bridge's peer is 82.65.127.86:62201"]],"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":[]}}
{"case":{"desc":"dns-anchored, from the resolved address","dns_host":"peer.example.net","from":["82.65.127.86",62201],"kind":"v1","name":"v1 dns-anchored, from the resolved address","relax":true,"stream":1358954594},"effects":{"delivered":[[2130003,214]],"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":"dns-anchored, from elsewhere","dns_host":"peer.example.net","from":["9.9.9.9",62201],"kind":"v1","name":"v1 dns-anchored, from elsewhere","relax":true,"stream":1358954595},"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* DMRD from 9.9.9.9:62201 discarded: peer.example.net resolves to 82.65.127.86:62201"]],"quenched":[]}}
{"case":{"desc":"dns-anchored, from the resolved address","dns_host":"peer.example.net","from":["82.65.127.86",62201],"kind":"bcka","name":"bcka dns-anchored, from the resolved address","relax":true,"stream":1358954596},"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":"dns-anchored, from elsewhere","dns_host":"peer.example.net","from":["9.9.9.9",62201],"kind":"bcka","name":"bcka dns-anchored, from elsewhere","relax":true,"stream":1358954597},"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* BCKA from 9.9.9.9:62201 discarded: peer.example.net resolves to 82.65.127.86:62201"]],"quenched":[]}}
{"case":{"desc":"dns-anchored, from the resolved address","dns_host":"peer.example.net","from":["82.65.127.86",62201],"kind":"bcsq","name":"bcsq dns-anchored, from the resolved address","relax":true,"stream":1358954598},"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=1358954598 TGID=214 (peer quenched; forwarding on this OBP stops for this stream/TG)"]],"quenched":[]}}
{"case":{"desc":"dns-anchored, from elsewhere","dns_host":"peer.example.net","from":["9.9.9.9",62201],"kind":"bcsq","name":"bcsq dns-anchored, from elsewhere","relax":true,"stream":1358954599},"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 from 9.9.9.9:62201 discarded: peer.example.net resolves to 82.65.127.86:62201"]],"quenched":[]}}

@ -125,6 +125,8 @@ def build_config(case: dict[str, Any]) -> dict[str, Any]:
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("dns_host"): # TARGET_IP written as a name: DNS owns the address
system["_TARGET_IP"] = case["dns_host"]
if case.get("stun"):
config["STUN"] = True
return config
@ -198,6 +200,7 @@ def observe(case: dict[str, Any], protocol_cls: type = HBPProtocol) -> dict[str,
(int.from_bytes(tgid, "big"), int.from_bytes(stream, "big"))
)
protocol._obp_send_bcve = lambda: None # type: ignore[assignment]
protocol._obp_resolve_target = lambda: None # type: ignore[assignment] # no DNS from a recording
protocol.transport = transport # type: ignore[assignment]
protocol.startProtocol()
transport.sent.clear()
@ -279,6 +282,18 @@ def _cases() -> list[dict[str, Any]]:
for relax in (False, True):
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)})
# TARGET_IP written as a name: only what it resolves to is this peer, however
# RELAX_CHECKS is set, because a name is an identity and an address is not.
for kind in ("v1", "bcka", "bcsq"):
for addr, where in ((PEER, "the resolved address"), (("9.9.9.9", 62201), "elsewhere")):
add(
kind=kind,
desc=f"dns-anchored, from {where}",
dns_host="peer.example.net",
relax=True,
**{"from": list(addr)},
)
return cases

@ -0,0 +1,114 @@
# ADN DMR Peer Server - OBP peer anchored to DNS: only a re-resolution may move it
from __future__ import annotations
import logging
from collections import deque
from types import SimpleNamespace
from twisted.internet import defer
from adn_server.domain.mesh_session import ObpBridgeSession
from adn_server.infrastructure.twisted_adapters import udp_hbp
from adn_server.infrastructure.twisted_adapters.udp_hbp import HBPProtocol
_RESOLVE = HBPProtocol._obp_resolve_target
_RESOLVED = HBPProtocol._obp_target_resolved
_REJECT = HBPProtocol._obp_reject_source
_HOST = "peer.example.net"
_CONFIGURED = ("74.132.44.239", 62059)
_MOVED = ("203.0.113.9", 62059)
_ZOMBIE = ("129.80.176.29", 62059)
_NOW = 1_800_000_000.0
def _fake_obp(*, dns_host: str | None = _HOST) -> SimpleNamespace:
fake = SimpleNamespace(
_system="OBP-USA",
_config={"MODE": "OPENBRIDGE", "RELAX_CHECKS": True},
_session=ObpBridgeSession(
system_name="OBP-USA", configured_peer=_CONFIGURED, dns_host=dns_host
),
_obp_foreign_source_log_once=deque(maxlen=1024),
)
fake._obp_resolve_target = lambda: _RESOLVE(fake)
fake._obp_target_resolved = lambda host: _RESOLVED(fake, host)
fake._obp_target_resolve_failed = lambda failure: None
return fake
class _FakeResolver:
"""Stands in for the reactor: records the names asked for, answers on demand."""
def __init__(self, answer: str) -> None:
self.answer = answer
self.asked: list[str] = []
def resolve(self, name: str):
self.asked.append(name)
return defer.succeed(self.answer)
def test_a_bridge_configured_with_an_address_never_asks_dns(monkeypatch) -> None:
resolver = _FakeResolver(_MOVED[0])
monkeypatch.setattr(udp_hbp, "reactor", resolver)
fake = _fake_obp(dns_host=None)
_RESOLVE(fake)
assert resolver.asked == []
def test_resolving_is_rate_limited(monkeypatch) -> None:
"""An unknown source must not be able to drive one lookup per packet."""
resolver = _FakeResolver(_CONFIGURED[0])
monkeypatch.setattr(udp_hbp, "reactor", resolver)
fake = _fake_obp()
for _ in range(50):
_RESOLVE(fake)
assert len(resolver.asked) == 1
def test_the_peer_follows_the_name_when_it_resolves_elsewhere() -> None:
fake = _fake_obp()
_RESOLVED(fake, _MOVED[0])
assert fake._session.peer == _MOVED
def test_the_peer_stays_put_when_the_name_still_resolves_to_it() -> None:
fake = _fake_obp()
_RESOLVED(fake, _CONFIGURED[0])
assert fake._session.peer == _CONFIGURED
def test_a_frame_from_elsewhere_is_refused_and_asks_dns_again(monkeypatch, caplog) -> None:
resolver = _FakeResolver(_CONFIGURED[0])
monkeypatch.setattr(udp_hbp, "reactor", resolver)
fake = _fake_obp()
with caplog.at_level(logging.INFO, logger="adn_server.infrastructure.twisted_adapters.udp_hbp"):
_REJECT(fake, b"BCKA", _ZOMBIE)
assert resolver.asked == [_HOST]
assert fake._session.peer == _CONFIGURED, "the zombie took the link over"
assert "discarded" in caplog.text and _HOST in caplog.text
def test_a_refused_source_is_logged_once(monkeypatch, caplog) -> None:
resolver = _FakeResolver(_CONFIGURED[0])
monkeypatch.setattr(udp_hbp, "reactor", resolver)
fake = _fake_obp()
with caplog.at_level(logging.INFO, logger="adn_server.infrastructure.twisted_adapters.udp_hbp"):
for _ in range(30):
_REJECT(fake, b"BCKA", _ZOMBIE)
assert sum("discarded" in line for line in caplog.text.splitlines()) == 1
def test_a_peer_that_really_moved_is_adopted_on_the_next_resolution(monkeypatch) -> None:
"""The whole point: a frame from an unknown address is refused, but it makes us
ask the name again — and when the name answers with that address, we migrate."""
resolver = _FakeResolver(_MOVED[0])
monkeypatch.setattr(udp_hbp, "reactor", resolver)
fake = _fake_obp()
_REJECT(fake, b"DMRD", _MOVED) # refused, and the lookup runs inline here
assert resolver.asked == [_HOST]
assert fake._session.peer == _MOVED, "the peer did not follow the name"
Loading…
Cancel
Save

Powered by TurnKey Linux.