From c13412369a5365d25b385b870229c2cb1e986c09 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 1 Jan 2026 17:35:41 +0100 Subject: [PATCH] Implemented blackhole management --- RNS/Identity.py | 5 ++ RNS/Reticulum.py | 180 ++++++++++++++++++++++++++-------------- RNS/Transport.py | 119 +++++++++++++++++++++++++- RNS/Utilities/rnpath.py | 69 ++++++++++++++- RNS/Utilities/rnsd.py | 12 +++ 5 files changed, 319 insertions(+), 66 deletions(-) diff --git a/RNS/Identity.py b/RNS/Identity.py index 7fb2be5..aaed5e7 100644 --- a/RNS/Identity.py +++ b/RNS/Identity.py @@ -430,6 +430,11 @@ class Identity: announced_identity = Identity(create_keys=False) announced_identity.load_public_key(public_key) + if len(RNS.Transport.blackholed_identities) > 0: + if announced_identity.hash in RNS.Transport.blackholed_identities: + RNS.log(f"Invalidated and dropped announce from blackholed identity {RNS.prettyhexrep(announced_identity.hash)}", RNS.LOG_EXTREME) + return False + if announced_identity.pub != None and announced_identity.validate(signature, signed_data): if only_validate_signature: del announced_identity diff --git a/RNS/Reticulum.py b/RNS/Reticulum.py index 4e4ceae..0cc41c7 100755 --- a/RNS/Reticulum.py +++ b/RNS/Reticulum.py @@ -248,6 +248,7 @@ class Reticulum: Reticulum.cachepath = Reticulum.configdir+"/storage/cache" Reticulum.resourcepath = Reticulum.configdir+"/storage/resources" Reticulum.identitypath = Reticulum.configdir+"/storage/identities" + Reticulum.blackholepath = Reticulum.configdir+"/storage/blackhole" Reticulum.interfacepath = Reticulum.configdir+"/interfaces" Reticulum.__transport_enabled = False @@ -258,6 +259,8 @@ class Reticulum: Reticulum.__discovery_enabled = False Reticulum.__discover_interfaces = False Reticulum.__required_discovery_value = None + Reticulum.__publish_blackhole = False + Reticulum.__blackhole_sources = [] Reticulum.panic_on_interface_error = False @@ -276,10 +279,8 @@ class Reticulum: self.requested_loglevel = loglevel self.requested_verbosity = verbosity if self.requested_loglevel != None: - if self.requested_loglevel > RNS.LOG_EXTREME: - self.requested_loglevel = RNS.LOG_EXTREME - if self.requested_loglevel < RNS.LOG_CRITICAL: - self.requested_loglevel = RNS.LOG_CRITICAL + if self.requested_loglevel > RNS.LOG_EXTREME: self.requested_loglevel = RNS.LOG_EXTREME + if self.requested_loglevel < RNS.LOG_CRITICAL: self.requested_loglevel = RNS.LOG_CRITICAL RNS.loglevel = self.requested_loglevel @@ -292,27 +293,16 @@ class Reticulum: self.last_data_persist = time.time() self.last_cache_clean = 0 - if not os.path.isdir(Reticulum.storagepath): - os.makedirs(Reticulum.storagepath) - - if not os.path.isdir(Reticulum.cachepath): - os.makedirs(Reticulum.cachepath) - - if not os.path.isdir(os.path.join(Reticulum.cachepath, "announces")): - os.makedirs(os.path.join(Reticulum.cachepath, "announces")) - - if not os.path.isdir(Reticulum.resourcepath): - os.makedirs(Reticulum.resourcepath) - - if not os.path.isdir(Reticulum.identitypath): - os.makedirs(Reticulum.identitypath) - - if not os.path.isdir(Reticulum.interfacepath): - os.makedirs(Reticulum.interfacepath) + if not os.path.isdir(Reticulum.storagepath): os.makedirs(Reticulum.storagepath) + if not os.path.isdir(Reticulum.cachepath): os.makedirs(Reticulum.cachepath) + if not os.path.isdir(Reticulum.resourcepath): os.makedirs(Reticulum.resourcepath) + if not os.path.isdir(Reticulum.identitypath): os.makedirs(Reticulum.identitypath) + if not os.path.isdir(Reticulum.blackholepath): os.makedirs(Reticulum.blackholepath) + if not os.path.isdir(Reticulum.interfacepath): os.makedirs(Reticulum.interfacepath) + if not os.path.isdir(os.path.join(Reticulum.cachepath, "announces")): os.makedirs(os.path.join(Reticulum.cachepath, "announces")) if os.path.isfile(self.configpath): - try: - self.config = ConfigObj(self.configpath) + try: self.config = ConfigObj(self.configpath) except Exception as e: RNS.log("Could not parse the configuration at "+self.configpath, RNS.LOG_ERROR) RNS.log("Check your configuration file for errors!", RNS.LOG_ERROR) @@ -455,24 +445,30 @@ class Reticulum: if "reticulum" in self.config: for option in self.config["reticulum"]: value = self.config["reticulum"][option] + if option == "share_instance": value = self.config["reticulum"].as_bool(option) self.share_instance = value + if RNS.vendor.platformutils.use_af_unix(): if option == "instance_name": value = self.config["reticulum"][option] self.local_socket_path = value + if option == "shared_instance_type": if self.shared_instance_type == None: value = self.config["reticulum"][option].lower() if value in ["tcp", "unix"]: self.shared_instance_type = value + if option == "shared_instance_port": value = int(self.config["reticulum"][option]) self.local_interface_port = value + if option == "instance_control_port": value = int(self.config["reticulum"][option]) self.local_control_port = value + if option == "rpc_key": try: value = bytes.fromhex(self.config["reticulum"][option]) @@ -480,49 +476,70 @@ class Reticulum: except Exception as e: RNS.log("Invalid shared instance RPC key specified, falling back to default key", RNS.LOG_ERROR) self.rpc_key = None + if option == "enable_transport": v = self.config["reticulum"].as_bool(option) if v == True: Reticulum.__transport_enabled = True + if option == "link_mtu_discovery": v = self.config["reticulum"].as_bool(option) if v == True: Reticulum.__link_mtu_discovery = True + if option == "enable_remote_management": v = self.config["reticulum"].as_bool(option) if v == True: Reticulum.__remote_management_enabled = True + if option == "remote_management_allowed": v = self.config["reticulum"].as_list(option) for hexhash in v: dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2 - if len(hexhash) != dest_len: - raise ValueError("Identity hash length for remote management ACL "+str(hexhash)+" is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2)) - try: - allowed_hash = bytes.fromhex(hexhash) - except Exception as e: - raise ValueError("Invalid identity hash for remote management ACL: "+str(hexhash)) + if len(hexhash) != dest_len: raise ValueError("Identity hash length for remote management ACL "+str(hexhash)+" is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2)) + try: allowed_hash = bytes.fromhex(hexhash) + except Exception as e: raise ValueError("Invalid identity hash for remote management ACL: "+str(hexhash)) if not allowed_hash in RNS.Transport.remote_management_allowed: RNS.Transport.remote_management_allowed.append(allowed_hash) + if option == "respond_to_probes": v = self.config["reticulum"].as_bool(option) if v == True: Reticulum.__allow_probes = True + if option == "force_shared_instance_bitrate": v = self.config["reticulum"].as_int(option) Reticulum._force_shared_instance_bitrate = v + if option == "panic_on_interface_error": v = self.config["reticulum"].as_bool(option) if v == True: Reticulum.panic_on_interface_error = True + if option == "use_implicit_proof": v = self.config["reticulum"].as_bool(option) if v == True: Reticulum.__use_implicit_proof = True if v == False: Reticulum.__use_implicit_proof = False + if option == "discover_interfaces": v = self.config["reticulum"].as_bool(option) if v == True: Reticulum.__discover_interfaces = True if v == False: Reticulum.__discover_interfaces = False + if option == "required_discovery_value": v = self.config["reticulum"].as_int(option) if v > 0: Reticulum.__required_discovery_value = v else: Reticulum.__required_discovery_value = None + + if option == "publish_blackhole": + v = self.config["reticulum"].as_bool(option) + if v == True: Reticulum.__publish_blackhole = True + if v == False: Reticulum.__publish_blackhole = False + + if option == "blackhole_sources": + v = self.config["reticulum"].as_list(option) + for hexhash in v: + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2 + if len(hexhash) != dest_len: raise ValueError(f"Identity hash length for blackhole source {hexhash} is invalid, must be {dest_len} hexadecimal characters ({dest_len//2} bytes).") + try: source_identity_hash = bytes.fromhex(hexhash) + except Exception as e: raise ValueError(f"Invalid identity hash for remote blackhole source: {hexhash}") + if not source_identity_hash in Reticulum.__blackhole_sources: Reticulum.__blackhole_sources.append(source_identity_hash) if RNS.compiled: RNS.log("Reticulum running in compiled mode", RNS.LOG_DEBUG) else: RNS.log("Reticulum running in interpreted mode", RNS.LOG_DEBUG) @@ -968,48 +985,34 @@ class Reticulum: if "get" in call: path = call["get"] - if path == "interface_stats": - rpc_connection.send(self.get_interface_stats()) - if path == "path_table": mh = call["max_hops"] rpc_connection.send(self.get_path_table(max_hops=mh)) - if path == "rate_table": - rpc_connection.send(self.get_rate_table()) - - if path == "next_hop_if_name": - rpc_connection.send(self.get_next_hop_if_name(call["destination_hash"])) - - if path == "next_hop": - rpc_connection.send(self.get_next_hop(call["destination_hash"])) - - if path == "first_hop_timeout": - rpc_connection.send(self.get_first_hop_timeout(call["destination_hash"])) - - if path == "link_count": - rpc_connection.send(self.get_link_count()) - - if path == "packet_rssi": - rpc_connection.send(self.get_packet_rssi(call["packet_hash"])) - - if path == "packet_snr": - rpc_connection.send(self.get_packet_snr(call["packet_hash"])) - - if path == "packet_q": - rpc_connection.send(self.get_packet_q(call["packet_hash"])) + if path == "interface_stats": rpc_connection.send(self.get_interface_stats()) + if path == "rate_table": rpc_connection.send(self.get_rate_table()) + if path == "next_hop_if_name": rpc_connection.send(self.get_next_hop_if_name(call["destination_hash"])) + if path == "next_hop": rpc_connection.send(self.get_next_hop(call["destination_hash"])) + if path == "first_hop_timeout": rpc_connection.send(self.get_first_hop_timeout(call["destination_hash"])) + if path == "link_count": rpc_connection.send(self.get_link_count()) + if path == "packet_rssi": rpc_connection.send(self.get_packet_rssi(call["packet_hash"])) + if path == "packet_snr": rpc_connection.send(self.get_packet_snr(call["packet_hash"])) + if path == "packet_q": rpc_connection.send(self.get_packet_q(call["packet_hash"])) + if path == "blackholed_identities": rpc_connection.send(self.get_blackholed_identities()) if "drop" in call: path = call["drop"] + if path == "path": rpc_connection.send(self.drop_path(call["destination_hash"])) + if path == "all_via": rpc_connection.send(self.drop_all_via(call["destination_hash"])) + if path == "announce_queues": rpc_connection.send(self.drop_announce_queues()) - if path == "path": - rpc_connection.send(self.drop_path(call["destination_hash"])) + if "blackhole_identity" in call: + identity_hash = call["blackhole_identity"] + rpc_connection.send(self.blackhole_identity(identity_hash)) - if path == "all_via": - rpc_connection.send(self.drop_all_via(call["destination_hash"])) - - if path == "announce_queues": - rpc_connection.send(self.drop_announce_queues()) + if "unblackhole_identity" in call: + identity_hash = call["unblackhole_identity"] + rpc_connection.send(self.unblackhole_identity(identity_hash)) rpc_connection.close() @@ -1376,6 +1379,37 @@ class Reticulum: def reload_interface(self, interface): pass + def get_blackholed_identities(self): + if self.is_connected_to_shared_instance: + rpc_connection = self.get_rpc_client() + rpc_connection.send({"get": "blackholed_identities"}) + response = rpc_connection.recv() + return response + + else: return RNS.Transport.blackholed_identities + + def blackhole_identity(self, identity_hash): + if len(identity_hash) != RNS.Reticulum.TRUNCATED_HASHLENGTH//8: return False + else: + if self.is_connected_to_shared_instance: + rpc_connection = self.get_rpc_client() + rpc_connection.send({"blackhole_identity": identity_hash}) + response = rpc_connection.recv() + return response + + else: return RNS.Transport.blackhole_identity(identity_hash) + + def unblackhole_identity(self, identity_hash): + if len(identity_hash) != RNS.Reticulum.TRUNCATED_HASHLENGTH//8: return False + else: + if self.is_connected_to_shared_instance: + rpc_connection = self.get_rpc_client() + rpc_connection.send({"unblackhole_identity": identity_hash}) + response = rpc_connection.recv() + return response + + else: return RNS.Transport.unblackhole_identity(identity_hash) + @staticmethod def should_use_implicit_proof(): """ @@ -1433,13 +1467,33 @@ class Reticulum: @staticmethod def required_discovery_value(): """ - Returns the required stam value for a discovered interface + Returns the required stamp value for a discovered interface to be considered valid and remembered. :returns: The required stamp value as an integer. """ return Reticulum.__required_discovery_value + @staticmethod + def publish_blackhole_enabled(): + """ + Returns whether blackhole list publishing is enabled for the + running instance. + + :returns: True if blackhole list publishing is enabled, False if not. + """ + return Reticulum.__publish_blackhole + + @staticmethod + def blackhole_sources(): + """ + Returns the list of transport identity hashes from which + blackhole lists are sourced. + + :returns: A list of identity hashes. + """ + return Reticulum.__blackhole_sources + # 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/Transport.py b/RNS/Transport.py index c8bba3f..9646e33 100755 --- a/RNS/Transport.py +++ b/RNS/Transport.py @@ -113,6 +113,7 @@ class Transport: announce_rate_table = {} # A table for keeping track of announce rates path_requests = {} # A table for storing path request timestamps path_states = {} # A table for keeping track of path states + blackholed_identities = {} # A table for keeping track of blackholed identities discovery_path_requests = {} # A table for keeping track of path requests on behalf of other nodes discovery_pr_tags = [] # A table for keeping track of tagged path requests @@ -195,6 +196,8 @@ class Transport: except Exception as e: RNS.log("Could not load packet hashlist from storage, the contained exception was: "+str(e), RNS.LOG_ERROR) + Transport.reload_blackhole() + # Create transport-specific destinations Transport.path_request_destination = RNS.Destination(None, RNS.Destination.IN, RNS.Destination.PLAIN, Transport.APP_NAME, "path", "request") Transport.path_request_destination.set_packet_callback(Transport.path_request_handler) @@ -245,8 +248,14 @@ class Transport: random_blobs = serialised_entry[5] receiving_interface = Transport.find_interface_from_hash(serialised_entry[6]) announce_packet = Transport.get_cached_packet(serialised_entry[7], packet_type="announce") + blackholed = False - if announce_packet != None and receiving_interface != None: + if len(Transport.blackholed_identities) > 0: + path_identity = RNS.Identity.recall(destination_hash) + if path_identity in Transport.blackholed_identities: blackholed = True + del path_identity + + if announce_packet != None and receiving_interface != None and blackholed == False: announce_packet.unpack() # We increase the hops, since reading a packet # from cache is equivalent to receiving it again @@ -261,6 +270,8 @@ class Transport: RNS.log("The announce packet could not be loaded from cache", RNS.LOG_DEBUG) if receiving_interface == None: RNS.log("The interface is no longer available", RNS.LOG_DEBUG) + if blackholed: + RNS.log("The associated identity is blackholed", RNS.LOG_DEBUG) if len(Transport.path_table) == 1: specifier = "entry" @@ -3040,6 +3051,112 @@ class Transport: if not Transport.owner.is_connected_to_shared_instance: Transport.persist_data() + @staticmethod + def blackhole_identity(identity_hash): + try: + if not identity_hash in Transport.blackholed_identities: + entry = {"source": Transport.identity.hash, "until": None} + Transport.blackholed_identities[identity_hash] = entry + Transport.persist_blackhole() + Transport.remove_blackholed_paths() + RNS.log(f"Blackholed identity {RNS.prettyhexrep(identity_hash)}", RNS.LOG_INFO) + return True + + else: return None + + except Exception as e: + RNS.log(f"Error while blackholing identity: {e}", RNS.LOG_ERROR) + return False + + @staticmethod + def unblackhole_identity(identity_hash): + try: + if identity_hash in Transport.blackholed_identities: + Transport.blackholed_identities.pop(identity_hash) + Transport.persist_blackhole() + RNS.log(f"Lifted blackhole for identity {RNS.prettyhexrep(identity_hash)}", RNS.LOG_INFO) + return True + + else: return None + + except Exception as e: + RNS.log(f"Error while unblackholing identity: {e}", RNS.LOG_ERROR) + return False + + @staticmethod + def reload_blackhole(): + now = time.time() + source_count = 0 + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2 + for filename in os.listdir(RNS.Reticulum.blackholepath): + try: + if filename == "local": source_identity_hash = Transport.identity.hash + else: + if len(filename) != dest_len: raise ValueError(f"Identity hash length for blackhole source {filename} is invalid") + source_identity_hash = bytes.fromhex(filename) + + sourcepath = os.path.join(RNS.Reticulum.blackholepath, filename) + with open(sourcepath, "rb") as f: + packed = f.read() + source_list = umsgpack.unpackb(packed) + for identity_hash in source_list: + if len(identity_hash) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8: + if identity_hash in Transport.blackholed_identities: + if Transport.blackholed_identities[identity_hash]["source"] == Transport.identity.hash: + continue + + until = source_list[identity_hash]["until"] + entry = {"source": source_identity_hash, "until": until} + if until == None or now < until: + Transport.blackholed_identities[identity_hash] = entry + + source_count += 1 + + except Exception as e: + RNS.log(f"Could not load blackholed identities from source file {filename}: {e}", RNS.LOG_ERROR) + RNS.trace_exception(e) + + Transport.remove_blackholed_paths() + + def remove_blackholed_paths(): + drop_destinations = [] + for destination_hash in Transport.path_table.copy(): + try: + associated_identity = RNS.Identity.recall(destination_hash) + if associated_identity and associated_identity.hash in Transport.blackholed_identities: + if not destination_hash in drop_destinations: drop_destinations.append(destination_hash) + except Exception as e: + RNS.log(f"Error while enumerating blackhole-associated destinations: {e}", RNS.LOG_ERROR) + + for destination_hash in drop_destinations: + try: + if destination_hash in Transport.path_table: Transport.path_table.pop(destination_hash) + except Exception as e: + RNS.log(f"Error while dropping blackhole-associated destination from path table: {e}", RNS.LOG_ERROR) + + if len(drop_destinations) > 0: + ms = "" if len(drop_destinations) == 1 else "s" + RNS.log(f"Removed {len(drop_destinations)} destination{ms} associated with blackholed identities from path table", RNS.LOG_INFO) + + @staticmethod + def persist_blackhole(): + try: + local_blackhole = {} + for identity_hash in Transport.blackholed_identities: + if Transport.blackholed_identities[identity_hash]["source"] == Transport.identity.hash: + local_blackhole[identity_hash] = Transport.blackholed_identities[identity_hash] + + packed = umsgpack.packb(local_blackhole) + localpath = os.path.join(RNS.Reticulum.blackholepath, "local") + tmppath = f"{localpath}.tmp" + with open(tmppath, "wb") as f: f.write(packed) + if os.path.isfile(localpath): os.unlink(localpath) + os.rename(tmppath, localpath) + + except Exception as e: + RNS.log(f"Error while persisting blackhole list: {e}", RNS.LOG_ERROR) + RNS.trace_exception(e) + # Table entry indices diff --git a/RNS/Utilities/rnpath.py b/RNS/Utilities/rnpath.py index 95117e7..24b8d4c 100644 --- a/RNS/Utilities/rnpath.py +++ b/RNS/Utilities/rnpath.py @@ -87,6 +87,14 @@ def connect_remote(destination_hash, auth_identity, timeout, no_output = False): link.set_link_established_callback(remote_link_established) link.set_link_closed_callback(remote_link_closed) +def parse_hash(input_str): + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2 + if len(input_str) != dest_len: raise ValueError("Hash length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2)) + try: + hash_bytes = bytes.fromhex(input_str) + return hash_bytes + except Exception as e: raise ValueError("Invalid hash entered. Check your input.") + def program_setup(configdir, table, rates, drop, destination_hexhash, verbosity, timeout, drop_queues, drop_via, max_hops, remote=None, management_identity=None, remote_timeout=RNS.Transport.PATH_REQUEST_TIMEOUT, blackholed=False, blackhole=False, unblackhole=False, no_output=False, json=False): @@ -114,7 +122,64 @@ def program_setup(configdir, table, rates, drop, destination_hexhash, verbosity, while remote_link == None: time.sleep(0.1) - if table: + if blackholed: + if remote_link: + if not no_output: + print("\r \r", end="") + print("Listing blackholed identities on remote instances not yet implemented") + exit(255) + + try: + blackholed = reticulum.get_blackholed_identities() + now = time.time() + for identity_hash in blackholed: + until = blackholed[identity_hash]["until"] + if until == None: until_str = "indefinitely" + else: until_str = f" for {RNS.prettytime(until-now)}" + print(f"{RNS.prettyhexrep(identity_hash)} blackholed {until_str}") + + + except Exception as e: + print(f"Could not get blackholed identities from RNS instance: {e}") + exit(20) + + elif blackhole: + if remote_link: + if not no_output: + print("\r \r", end="") + print("Blackholing identity on remote instances not yet implemented") + exit(255) + + try: + identity_hash = parse_hash(destination_hexhash) + result = reticulum.blackhole_identity(identity_hash) + if result == True: print(f"Blackholed identity {destination_hexhash}") + elif result == None: print(f"Identity {destination_hexhash} already blackholed") + else: print(f"Could not blackhole identity {destination_hexhash}") + + except Exception as e: + print(f"Could not blackhole identity: {e}") + exit(20) + + elif unblackhole: + if remote_link: + if not no_output: + print("\r \r", end="") + print("Blackholing identity on remote instances not yet implemented") + exit(255) + + try: + identity_hash = parse_hash(destination_hexhash) + result = reticulum.unblackhole_identity(identity_hash) + if result == True: print(f"Lifted blackhole for identity {destination_hexhash}") + elif result == None: print(f"Identity {destination_hexhash} not blackholed") + else: print(f"Could not unblackhole identity {destination_hexhash}") + + except Exception as e: + print(f"Could not unblackhole identity: {e}") + exit(20) + + elif table: destination_hash = None if destination_hexhash != None: try: @@ -379,7 +444,7 @@ def main(): if args.config: configarg = args.config else: configarg = None - if not args.drop_announces and not args.table and not args.rates and not args.destination and not args.drop_via: + if not args.drop_announces and not args.table and not args.rates and not args.destination and not args.drop_via and not args.blackholed: print("") parser.print_help() print("") diff --git a/RNS/Utilities/rnsd.py b/RNS/Utilities/rnsd.py index 09f1af2..2b5686d 100755 --- a/RNS/Utilities/rnsd.py +++ b/RNS/Utilities/rnsd.py @@ -196,6 +196,18 @@ instance_name = default # respond_to_probes = No +# You can publish your local list of blackholed identities +# for other transport instances to use for automatic, +# network-wide blackhole management. + +# publish_blackhole = No + +# List of remote transport identities from which to auto- +# matically source lists of blackholed identities. + +# blackhole_sources = 521c87a83afb8f29e4455e77930b973b + + [logging] # Valid log levels are 0 through 7: # 0: Log only critical information