diff --git a/src/adn_server/domain/mesh_engine.py b/src/adn_server/domain/mesh_engine.py index 76460cd..ee798ad 100644 --- a/src/adn_server/domain/mesh_engine.py +++ b/src/adn_server/domain/mesh_engine.py @@ -186,12 +186,15 @@ def accepts_source( ) -> bool: """A frame counts as ours when it comes from the peer. - RELAX_CHECKS widens that to any address, for a peer on a dynamic IP — but not - when DNS owns the peer, or a second host with the passphrase would pass as it. + A name pins the host, never the port: a peer answers from whatever socket it + bound, which NAT may rewrite and which needs not be the port we send to. With + no name to go by, RELAX_CHECKS decides as it always has. """ if addr == session.peer: return True - return bool(policy.relax_checks) and not session.dns_anchored + if session.dns_anchored: + return bool(addr) and addr[0] == session.peer[0] + return bool(policy.relax_checks) def _delivery_effects( diff --git a/src/adn_server/domain/mesh_session.py b/src/adn_server/domain/mesh_session.py index 5697606..0a5acd0 100644 --- a/src/adn_server/domain/mesh_session.py +++ b/src/adn_server/domain/mesh_session.py @@ -93,16 +93,32 @@ class ObpBridgeSession: # --- peer address -------------------------------------------------------- + @property + def _anchor(self) -> tuple[str, int] | None: + """Where the name puts this peer, once it has resolved to an address at all. + + A name that has never resolved anchors nothing: the link has to keep working + on whatever RELAX_CHECKS allows, or a name server that was down at startup + would take it off the air. + """ + if not self.dns_host: + return None + host, port = self.resolved_peer or self.configured_peer + return (host, port) if host else None + @property def dns_anchored(self) -> bool: - return bool(self.dns_host) + return self._anchor is not None @property def peer(self) -> tuple[str | None, int]: - """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 + """Where to send: DNS owns the host, the wire may still refine the port.""" + anchor = self._anchor + if anchor is None: + return self.learned_peer or self.configured_peer + if self.learned_peer and self.learned_peer[0] == anchor[0]: + return self.learned_peer + return anchor @property def peer_known(self) -> bool: @@ -112,9 +128,10 @@ 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: - return False host, port = str(addr[0]), int(addr[1]) + anchor = self._anchor + if anchor is not None and host != anchor[0]: + return False # only a re-resolution moves an anchored peer to another host if self.peer == (host, port): return False self.learned_peer = (host, port) @@ -125,10 +142,11 @@ class ObpBridgeSession: """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 + was = self.peer + if self.learned_peer and self.learned_peer[0] != host: + self.learned_peer = None # a port learned for the host it just left self.resolved_peer = (host, port) - return True + return self.peer != was def forget_learned_peer(self) -> None: """Drop what the wire taught us and fall back to the configured peer.""" diff --git a/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py b/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py index a9e6f8e..bc1380b 100644 --- a/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py +++ b/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py @@ -2413,7 +2413,10 @@ class HBPProtocol(DatagramProtocol): _session.note_keepalive(_now) # Anyone with the passphrase can send one, so it may bootstrap a peer # we have no address for, never move one we have. DMRD/DMRE do that. - if not _session.peer_known: + if _session.dns_anchored: + # accepts_source vetted the host above, so this only refines the port. + _session.learn_peer(_sockaddr, at=_now) + elif not _session.peer_known: if _session.learn_peer(_sockaddr, at=_now): logger.info( "(%s) *BridgeControl* OBP peer address learned from keepalive: %s:%s", diff --git a/tests/domain/test_mesh_session.py b/tests/domain/test_mesh_session.py index 2ce6a6d..8949c4b 100644 --- a/tests/domain/test_mesh_session.py +++ b/tests/domain/test_mesh_session.py @@ -69,6 +69,33 @@ def test_a_dns_anchored_peer_ignores_what_the_wire_says() -> None: assert session.peer == _CONFIGURED +def test_a_name_that_never_resolved_anchors_nothing() -> None: + """normalize_obp_config leaves TARGET_SOCK as (None, port) when the name does not + resolve at startup. Anchoring on that would take the link off the air for good.""" + session = ObpBridgeSession( + system_name="OBP-FR", configured_peer=(None, 62201), dns_host="peer.example.net" + ) + assert session.dns_anchored is False + assert session.learn_peer(_ELSEWHERE, at=_NOW) is True + + +def test_a_dns_anchored_peer_takes_a_new_port_on_its_own_host() -> None: + """The name pins the host. A peer answers from whatever socket it bound, which + NAT may rewrite and which needs not be the port we send to.""" + session = _dns_session() + other_port = (_CONFIGURED[0], 57933) + assert session.learn_peer(other_port, at=_NOW) is True + assert session.peer == other_port + + +def test_resolving_elsewhere_drops_a_port_learned_on_the_old_host() -> None: + session = _dns_session() + session.learn_peer((_CONFIGURED[0], 57933), at=_NOW) + assert session.adopt_resolved(_ELSEWHERE, at=_NOW) is True + assert session.peer == _ELSEWHERE + assert session.learned_peer is None + + 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 diff --git a/tests/fixtures/obp_ingress_effects.jsonl b/tests/fixtures/obp_ingress_effects.jsonl index 37be71a..3bfcb25 100644 --- a/tests/fixtures/obp_ingress_effects.jsonl +++ b/tests/fixtures/obp_ingress_effects.jsonl @@ -96,8 +96,11 @@ {"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":[]}} +{"case":{"desc":"dns-anchored, from the resolved host on another port","dns_host":"peer.example.net","from":["82.65.127.86",57933],"kind":"v1","name":"v1 dns-anchored, from the resolved host on another port","relax":true,"stream":1358954595},"effects":{"delivered":[[2130003,214]],"egress":[[24,["82.65.127.86",57933]],[73,["82.65.127.86",57933]]],"log":[[20,"(OBP-FR) Starting OBP. TARGET_IP: 82.65.127.86, TARGET_PORT: 62201"],[10,"(OBP-FR) *BridgeControl* OBP peer address sync to 82.65.127.86:57933 (RELAX_CHECKS; was 82.65.127.86: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":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"],[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":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"]],"quenched":[]}} +{"case":{"desc":"dns-anchored, from the resolved host on another port","dns_host":"peer.example.net","from":["82.65.127.86",57933],"kind":"bcka","name":"bcka dns-anchored, from the resolved host on another port","relax":true,"stream":1358954598},"effects":{"delivered":[],"egress":[[24,["82.65.127.86",57933]],[73,["82.65.127.86",57933]]],"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":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* 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":1358954600},"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=1358954600 TGID=214 (peer quenched; forwarding on this OBP stops for this stream/TG)"]],"quenched":[]}} +{"case":{"desc":"dns-anchored, from the resolved host on another port","dns_host":"peer.example.net","from":["82.65.127.86",57933],"kind":"bcsq","name":"bcsq dns-anchored, from the resolved host on another port","relax":true,"stream":1358954601},"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=1358954601 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":1358954602},"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":[]}} diff --git a/tests/harness/obp_ingress.py b/tests/harness/obp_ingress.py index 4adfe0d..8b34007 100644 --- a/tests/harness/obp_ingress.py +++ b/tests/harness/obp_ingress.py @@ -286,7 +286,11 @@ def _cases() -> list[dict[str, Any]]: # 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")): + for addr, where in ( + (PEER, "the resolved address"), + ((PEER[0], 57933), "the resolved host on another port"), + (("9.9.9.9", 62201), "elsewhere"), + ): add( kind=kind, desc=f"dns-anchored, from {where}", diff --git a/tests/infrastructure/test_obp_ingress_effects.py b/tests/infrastructure/test_obp_ingress_effects.py index d60743b..137cf23 100644 --- a/tests/infrastructure/test_obp_ingress_effects.py +++ b/tests/infrastructure/test_obp_ingress_effects.py @@ -66,14 +66,15 @@ def test_ingress_effects_match_the_recording(case: dict, recorded: dict[str, dic [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: +def test_a_keepalive_never_moves_egress_to_another_host(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. + mesh. It may refine the port on the host we already talk to, never move the + bridge to a different host. 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 + assert tuple(addr)[0] == PEER[0] def test_a_keepalive_bootstraps_a_bridge_with_no_configured_peer() -> None: