From 2b3d6a09896e525364bc695876294217c42f42c2 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 3 Jan 2026 00:36:50 +0100 Subject: [PATCH] Added auto-connect option for discovered interfaces --- RNS/Discovery.py | 131 ++++++++++++++++++++++++++-- RNS/Interfaces/BackboneInterface.py | 12 ++- RNS/Reticulum.py | 19 ++++ RNS/Utilities/rnstatus.py | 5 ++ 4 files changed, 158 insertions(+), 9 deletions(-) diff --git a/RNS/Discovery.py b/RNS/Discovery.py index 2ecbebe..2e278dc 100644 --- a/RNS/Discovery.py +++ b/RNS/Discovery.py @@ -285,7 +285,6 @@ class InterfaceAnnounceHandler: discovery_hash_material = info["transport_id"]+info["name"] info["discovery_hash"] = RNS.Identity.full_hash(discovery_hash_material.encode("utf-8")) - RNS.log(f"Discovered interface with stamp value {value}: {info}", RNS.LOG_DEBUG) if self.callback and callable(self.callback): self.callback(info) except Exception as e: @@ -296,17 +295,25 @@ class InterfaceDiscovery(): THRESHOLD_STALE = 3*24*60*60 THRESHOLD_REMOVE = 7*24*60*60 + MONITOR_INTERVAL = 5 + DETACH_THRESHOLD = 12 + STATUS_STALE = 0 STATUS_UNKNOWN = 100 STATUS_AVAILABLE = 1000 STATUS_CODE_MAP = {"available": STATUS_AVAILABLE, "unknown": STATUS_UNKNOWN, "stale": STATUS_STALE} + AUTOCONNECT_TYPES = ["BackboneInterface", "TCPServerInterface"] def __init__(self, required_value=InterfaceAnnouncer.DEFAULT_STAMP_VALUE, callback=None, discover_interfaces=True): if not required_value: required_value = InterfaceAnnouncer.DEFAULT_STAMP_VALUE - self.required_value = required_value - self.discovery_callback = callback - self.rns_instance = RNS.Reticulum.get_instance() + self.required_value = required_value + self.discovery_callback = callback + self.rns_instance = RNS.Reticulum.get_instance() + self.monitored_interfaces = [] + self.monitoring_autoconnects = False + self.monitor_interval = self.MONITOR_INTERVAL + self.detach_threshold = self.DETACH_THRESHOLD if not self.rns_instance: raise SystemError("Attempt to start interface discovery listener without an active RNS instance") self.storagepath = os.path.join(RNS.Reticulum.storagepath, "discovery", "interfaces") @@ -354,11 +361,13 @@ class InterfaceDiscovery(): def interface_discovered(self, info): try: name = info["name"] + value = info["value"] + interface_type = info["type"] discovery_hash = info["discovery_hash"] hops = info["hops"]; ms = "" if hops == 1 else "s" filename = RNS.hexrep(discovery_hash, delimit=False) filepath = os.path.join(self.storagepath, filename) - RNS.log(f"Discovered interface {RNS.prettyhexrep(discovery_hash)} {hops} hop{ms} away: {name}", RNS.LOG_DEBUG) + RNS.log(f"Discovered {interface_type} {hops} hop{ms} away with stamp value {value}: {name}", RNS.LOG_DEBUG) if not os.path.isfile(filepath): try: with open(filepath, "wb") as f: @@ -400,7 +409,117 @@ class InterfaceDiscovery(): RNS.trace_exception(e) return - if self.discovery_callback and callable(self.discovery_callback): self.discovery_callback(info) + self.autoconnect(info) + + try: + if self.discovery_callback and callable(self.discovery_callback): self.discovery_callback(info) + except Exception as e: RNS.log(f"Error while processing external interface discovery callback: {e}", RNS.LOG_ERROR) + + def monitor_interface(self, interface): + if not interface in self.monitored_interfaces: + self.monitored_interfaces.append(interface) + + if not self.monitoring_autoconnects: + self.monitoring_autoconnects = True + threading.Thread(target=self.__monitor_job, daemon=True).start() + + def __monitor_job(self): + while self.monitoring_autoconnects: + time.sleep(self.monitor_interval) + detached_interfaces = [] + for interface in self.monitored_interfaces: + try: + if interface.online: + if hasattr(interface, "autoconnect_down") and interface.autoconnect_down != None: + RNS.log(f"Auto-discovered interface {interface} reconnected") + interface.autoconnect_down = None + + else: + if not hasattr(interface, "autoconnect_down") or interface.autoconnect_down == None: + RNS.log(f"Auto-discovered interface {interface} disconnected", RNS.LOG_DEBUG) + interface.autoconnect_down = time.time() + + else: + down_for = time.time()-interface.autoconnect_down + if down_for >= self.detach_threshold: + RNS.log(f"Auto-discovered interface {interface} has been down for {RNS.prettytime(down_for)}, detaching", RNS.LOG_DEBUG) + interface.detach() + detached_interfaces.append(interface) + + except Exception as e: + RNS.log(f"Error while checking auto-connected interface state for {interface}: {e}", RNS.LOG_ERROR) + + for interface in detached_interfaces: + try: + self.monitored_interfaces.remove(interface) + RNS.Transport.interfaces.remove(interface) + except Exception as e: + RNS.log(f"Error while de-registering auto-connected interface from transport: {e}", RNS.LOG_ERROR) + + + def autoconnect(self, info): + try: + if RNS.Reticulum.should_autoconnect_discovered_interfaces(): + autoconnected_count = len([i for i in RNS.Transport.interfaces if hasattr(i, "autoconnect_hash")]) + if autoconnected_count < RNS.Reticulum.max_autoconnected_interfaces(): + interface_type = info["type"] + if interface_type in self.AUTOCONNECT_TYPES: + endpoint_specifier = "" + if "reachable_on" in info: endpoint_specifier += str(info["reachable_on"]) + if "port" in info: endpoint_specifier += str(info["port"]) + endpoint_hash = RNS.Identity.full_hash(endpoint_specifier.encode("utf-8")) + exists = False + for interface in RNS.Transport.interfaces: + if hasattr(interface, "autoconnect_hash") and interface.autoconnect_hash: + exists = True + break + + else: + dest_match = "reachable_on" in info and hasattr(interface, "target_ip") and interface.target_ip == info["reachable_on"] + port_match = not "port" in info or (hasattr(interface, "target_port") and "port" in info and interface.target_port == info["port"]) + b32d_match = "reachable_on" in info and hasattr(interface, "b32") and interface.b32 == info["reachable_on"] + + if (dest_match and port_match) or b32d_match: + exists = True + break + + if exists: RNS.log(f"Discovered {interface_type} already exists, not auto-connecting", RNS.LOG_DEBUG) + else: + if interface_type == "TCPClientInterface": + RNS.log(f"Your operating system does not support the Backbone interface type, and must degrade to using TCPClientInterface instead", RNS.LOG_WARNING) + RNS.log(f"Auto-connecting discovered TCPClient interfaces is not yet implemented, aborting auto-connect", RNS.LOG_WARNING) + RNS.log(f"You can obtain the configuration entry and add this interface manually instead using rnstatus -D", RNS.LOG_WARNING) + return + + if interface_type == "I2PInterface": + RNS.log(f"Auto-connecting discovered I2P interfaces is not yet implemented, aborting auto-connect", RNS.LOG_WARNING) + RNS.log(f"You can obtain the configuration entry and add this interface manually instead using rnstatus -D", RNS.LOG_WARNING) + return + + RNS.log(f"Auto-connecting discovered {interface_type}") + config_entry = info["config_entry"] + interface_config = {} + interface_name = info["name"] + interface_config["name"] = f"{interface_name}" + ifac_netname = info["ifac_netname"] if "ifac_netname" in info else None + ifac_netkey = info["ifac_netkey"] if "ifac_netkey" in info else None + interface = None + + if interface_type == "BackboneInterface": + from RNS.Interfaces import BackboneInterface + interface_config["target_host"] = info["reachable_on"] + interface_config["target_port"] = info["port"] + interface = BackboneInterface.BackboneClientInterface(RNS.Transport, interface_config) + + if interface: + interface.autoconnect_hash = endpoint_hash + interface.autoconnect_source = info["network_id"] + RNS.Reticulum.get_instance()._add_interface(interface, ifac_netname=ifac_netname, ifac_netkey=ifac_netkey, configured_bitrate=5E6) + self.monitor_interface(interface) + + except Exception as e: + RNS.log(f"Error while auto-connecting discovered interface: {e}", RNS.LOG_ERROR) + RNS.trace_exception(e) class BlackholeUpdater(): INITIAL_WAIT = 20 diff --git a/RNS/Interfaces/BackboneInterface.py b/RNS/Interfaces/BackboneInterface.py index 3a0f28c..d40ae7c 100644 --- a/RNS/Interfaces/BackboneInterface.py +++ b/RNS/Interfaces/BackboneInterface.py @@ -408,7 +408,9 @@ class BackboneInterface(Interface): if hasattr(listener_socket, "shutdown"): if callable(listener_socket.shutdown): try: listener_socket.shutdown(socket.SHUT_RDWR) - except Exception as e: RNS.log("Error while shutting down socket for "+str(self)+": "+str(e), RNS.LOG_ERROR) + except Exception as e: + if str(e).endswith("Transport endpoint is not connected"): pass + else: RNS.log("Error while shutting down socket for "+str(self)+": "+str(e), RNS.LOG_ERROR) def __str__(self): if ":" in self.bind_ip: @@ -523,7 +525,9 @@ class BackboneClientInterface(Interface): try: if self.socket != None: self.socket.shutdown(socket.SHUT_RDWR) - except Exception as e: RNS.log("Error while shutting down socket for "+str(self)+": "+str(e), RNS.LOG_ERROR) + except Exception as e: + if str(e).endswith("Transport endpoint is not connected"): pass + else: RNS.log("Error while shutting down socket for "+str(self)+": "+str(e), RNS.LOG_ERROR) try: if self.socket != None: self.socket.close() @@ -580,7 +584,7 @@ class BackboneClientInterface(Interface): if not self.reconnecting: self.reconnecting = True attempts = 0 - while not self.online: + while not self.online and not self.detached: time.sleep(BackboneClientInterface.RECONNECT_WAIT) attempts += 1 @@ -593,6 +597,8 @@ class BackboneClientInterface(Interface): except Exception as e: RNS.log("Connection attempt for "+str(self)+" failed: "+str(e), RNS.LOG_DEBUG) + if not self.online: return + if not self.never_connected: RNS.log("Reconnected socket for "+str(self)+".", RNS.LOG_INFO) diff --git a/RNS/Reticulum.py b/RNS/Reticulum.py index c79d324..9e76fa8 100755 --- a/RNS/Reticulum.py +++ b/RNS/Reticulum.py @@ -259,6 +259,7 @@ class Reticulum: Reticulum.__allow_probes = False Reticulum.__discovery_enabled = False Reticulum.__discover_interfaces = False + Reticulum.__autoconnect_discovered_interfaces = False Reticulum.__required_discovery_value = None Reticulum.__publish_blackhole = False Reticulum.__blackhole_sources = [] @@ -575,6 +576,10 @@ class Reticulum: try: source_identity_hash = bytes.fromhex(hexhash) except Exception as e: raise ValueError(f"Invalid identity hash for interface discovery source: {hexhash}") if not source_identity_hash in Reticulum.__interface_sources: Reticulum.__interface_sources.append(source_identity_hash) + + if option == "autoconnect_discovered_interfaces": + v = self.config["reticulum"].as_int(option) + if v > 0: Reticulum.__autoconnect_discovered_interfaces = v if RNS.compiled: RNS.log("Reticulum running in compiled mode", RNS.LOG_DEBUG) else: RNS.log("Reticulum running in interpreted mode", RNS.LOG_DEBUG) @@ -1193,6 +1198,11 @@ class Reticulum: ifstats["ifac_size"] = None ifstats["ifac_netname"] = None + if hasattr(interface, "autoconnect_source"): + ifstats["autoconnect_source"] = interface.autoconnect_source + else: + ifstats["autoconnect_source"] = None + if hasattr(interface, "announce_queue"): if interface.announce_queue != None: ifstats["announce_queue"] = len(interface.announce_queue) @@ -1221,6 +1231,7 @@ class Reticulum: stats["txs"] = RNS.Transport.speed_tx if Reticulum.transport_enabled(): stats["transport_id"] = RNS.Transport.identity.hash + stats["network_id"] = RNS.Transport.network_identity.hash if RNS.Transport.network_identity else None stats["transport_uptime"] = time.time()-RNS.Transport.start_time if Reticulum.probe_destination_enabled(): stats["probe_responder"] = RNS.Transport.probe_destination.hash @@ -1544,6 +1555,14 @@ class Reticulum: """ return Reticulum.__interface_sources + @staticmethod + def should_autoconnect_discovered_interfaces(): + return Reticulum.__autoconnect_discovered_interfaces > 0 + + @staticmethod + def max_autoconnected_interfaces(): + return Reticulum.__autoconnect_discovered_interfaces + # Default configuration file: __default_rns_config__ = '''# This is the default Reticulum config file. # You should probably edit it to include any additional, diff --git a/RNS/Utilities/rnstatus.py b/RNS/Utilities/rnstatus.py index bcee142..241f1a6 100644 --- a/RNS/Utilities/rnstatus.py +++ b/RNS/Utilities/rnstatus.py @@ -435,6 +435,9 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json= print(" {n}".format(n=ifstat["name"])) + if "autoconnect_source" in ifstat and ifstat["autoconnect_source"] != None: + print(" Source : Auto-connect via <{ns}>".format(ns=ifstat["autoconnect_source"])) + if "ifac_netname" in ifstat and ifstat["ifac_netname"] != None: print(" Network : {nn}".format(nn=ifstat["ifac_netname"])) @@ -573,6 +576,8 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json= if "transport_id" in stats and stats["transport_id"] != None: print("\n Transport Instance "+RNS.prettyhexrep(stats["transport_id"])+" running") + if "network_id" in stats and stats["network_id"] != None: + print(" Network Identity "+RNS.prettyhexrep(stats["network_id"])) if "probe_responder" in stats and stats["probe_responder"] != None: print(" Probe responder at "+RNS.prettyhexrep(stats["probe_responder"])+ " active") if "transport_uptime" in stats and stats["transport_uptime"] != None: