Implemented blackhole management

This commit is contained in:
Mark Qvist 2026-01-01 17:35:41 +01:00
commit c13412369a
5 changed files with 319 additions and 66 deletions

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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("")

View file

@ -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