diff --git a/RNS/Reticulum.py b/RNS/Reticulum.py index 77b742f5..c767eb52 100755 --- a/RNS/Reticulum.py +++ b/RNS/Reticulum.py @@ -1,6 +1,6 @@ # Reticulum License # -# Copyright (c) 2016-2025 Mark Qvist +# Copyright (c) 2016-2026 Mark Qvist # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -257,6 +257,7 @@ class Reticulum: Reticulum.__local_hops_delta = False Reticulum.__link_mtu_discovery = Reticulum.LINK_MTU_DISCOVERY Reticulum.__remote_management_enabled = False + Reticulum.__interface_management_enabled = True Reticulum.__use_implicit_proof = True Reticulum.__allow_probes = False Reticulum.__discovery_enabled = False @@ -548,6 +549,11 @@ class Reticulum: v = self.config["reticulum"].as_bool(option) if v == True: Reticulum.__remote_management_enabled = True + if option == "enable_interface_management": + v = self.config["reticulum"].as_bool(option) + if v == False: Reticulum.__interface_management_enabled = False + else: Reticulum.__interface_management_enabled = True + if option == "remote_management_allowed": v = self.config["reticulum"].as_list(option) for hexhash in v: @@ -757,7 +763,79 @@ class Reticulum: RNS.log("System interfaces are ready", RNS.LOG_VERBOSE) - def _synthesize_interface(self, config, name, instance_init=False): + def _attach_interface(self, name): + if not Reticulum.__interface_management_enabled: return False + try: + interfaces = { iface.name: iface for iface in RNS.Transport.interfaces } + if name in interfaces: + RNS.log(f"Attempt to attach existing interface \"{name}\"", RNS.LOG_WARNING) + return False + + else: + try: config = ConfigObj(self.configpath) + except Exception as e: + RNS.log(f"Could not parse the configuration at {self.configpath} during interface attach", RNS.LOG_ERROR) + return None + + if "interfaces" in config: + if not name in config["interfaces"]: + RNS.log(f"Cannot attach interface \"{name}\", no configuration entry exists", RNS.LOG_ERROR) + return None + + else: + interface_config = config["interfaces"][name] + self._synthesize_interface(interface_config, name, force_attach=True) + RNS.log(f"Interface \"{name}\" was attached", RNS.LOG_NOTICE) + return True + + except Exception as e: RNS.log(f"Error while detaching interface \"{name}\": {e}", RNS.LOG_ERROR) + return False + + def _detach_interface(self, name): + if not Reticulum.__interface_management_enabled: return False + try: + interfaces = { iface.name: iface for iface in RNS.Transport.interfaces } + if not name in interfaces: + RNS.log(f"Attempt to detach non-existing interface \"{name}\"", RNS.LOG_WARNING) + return None + + else: + interface = interfaces[name] + if isinstance(interface, I2PInterface.I2PInterface): return False + if isinstance(interface, LocalInterface.LocalClientInterface): return False + if isinstance(interface, LocalInterface.LocalServerInterface): return False + if interface.spawned_interfaces: + if type(interface.spawned_interfaces) == dict: spawned = list(interface.spawned_interfaces.values()) + elif type(interface.spawned_interfaces) == list: spawned = interface.spawned_interfaces.copy() + for s in spawned: + s.detach() + s.teardown() + RNS.Transport.remove_interface(s) + + interface.detach() + RNS.Transport.remove_interface(interface) + RNS.log(f"Interface \"{name}\" was detached", RNS.LOG_NOTICE) + return True + + except Exception as e: RNS.log(f"Error while detaching interface \"{name}\": {e}", RNS.LOG_ERROR); RNS.trace_exception(e) + return False + + def _reload_interface(self, name): + if not Reticulum.__interface_management_enabled: return False + interfaces = { iface.name: iface for iface in RNS.Transport.interfaces } + if not name in interfaces: + RNS.log(f"Attempt to reload non-existing interface \"{name}\"", RNS.LOG_WARNING) + return None + + if self._detach_interface(name) and self._attach_interface(name): + RNS.log(f"Interface \"{name}\" was reloaded", RNS.LOG_NOTICE) + return True + + else: + RNS.log(f"Could not reload interface \"{name}\"", RNS.LOG_NOTICE) + return False + + def _synthesize_interface(self, config, name, instance_init=False, force_attach=False): c = config interface_mode = Interface.Interface.MODE_FULL @@ -1009,7 +1087,7 @@ class Reticulum: interface.final_init() interface = None - if (("interface_enabled" in c) and c.as_bool("interface_enabled") == True) or (("enabled" in c) and c.as_bool("enabled") == True): + if (("interface_enabled" in c) and c.as_bool("interface_enabled") == True) or (("enabled" in c) and c.as_bool("enabled") == True) or force_attach: interface_config = c interface_config["name"] = name interface_config["selected_interface_mode"] = interface_mode @@ -1113,7 +1191,6 @@ class Reticulum: except Exception as e: RNS.log(f"External interface initialisation failed for {interface_type} / {name}", RNS.LOG_ERROR) RNS.trace_exception(e) - else: RNS.log("Skipping disabled interface \""+name+"\"", RNS.LOG_DEBUG) @@ -1300,6 +1377,12 @@ class Reticulum: if path == "blackholed_identities": self.rpc_return(conn, self.get_blackholed_identities()) if path == "is_blackholed": self.rpc_return(conn, self.is_blackholed(call["identity_hash"])) + if "manage" in call: + path = call["manage"] + if path == "attach_interface": self.rpc_return(conn, self.attach_interface(call["name"])) + if path == "detach_interface": self.rpc_return(conn, self.detach_interface(call["name"])) + if path == "reload_interface": self.rpc_return(conn, self.reload_interface(call["name"])) + if "drop" in call: path = call["drop"] if path == "path": self.rpc_return(conn, self.drop_path(call["destination_hash"])) @@ -1867,21 +1950,39 @@ class Reticulum: if RNS.Profiler.ran(): return RNS.Profiler.results() else: return None - def halt_interface(self, interface): - pass + def attach_interface(self, interface_name): + if self.is_connected_to_shared_instance: + rpc_connection = self.get_rpc_client() + rpc_connection.send_bytes(mp.packb({"manage": "attach_interface", "name": interface_name})) + response = mp.unpackb(rpc_connection.recv_bytes()) + return response + + else: return self._attach_interface(interface_name) - def resume_interface(self, interface): - pass + def detach_interface(self, interface_name): + if self.is_connected_to_shared_instance: + rpc_connection = self.get_rpc_client() + rpc_connection.send_bytes(mp.packb({"manage": "detach_interface", "name": interface_name})) + response = mp.unpackb(rpc_connection.recv_bytes()) + return response + + else: return self._detach_interface(interface_name) - def reload_interface(self, interface): - pass + def reload_interface(self, interface_name): + if self.is_connected_to_shared_instance: + rpc_connection = self.get_rpc_client() + rpc_connection.send_bytes(mp.packb({"manage": "reload_interface", "name": interface_name})) + response = mp.unpackb(rpc_connection.recv_bytes()) + return response + + else: return self._reload_interface(interface_name) def get_blackholed_identities(self): if self.is_connected_to_shared_instance: - rpc_connection = self.get_rpc_client() - rpc_connection.send_bytes(mp.packb({"get": "blackholed_identities"})) - response = mp.unpackb(rpc_connection.recv_bytes()) - return response + rpc_connection = self.get_rpc_client() + rpc_connection.send_bytes(mp.packb({"get": "blackholed_identities"})) + response = mp.unpackb(rpc_connection.recv_bytes()) + return response else: return RNS.Transport.blackholed_identities @@ -1892,10 +1993,10 @@ class Reticulum: if len(identity_hash) != RNS.Reticulum.TRUNCATED_HASHLENGTH//8: raise ValueError("Invalid identity hash length for blackhole check") if self.is_connected_to_shared_instance: - rpc_connection = self.get_rpc_client() - rpc_connection.send_bytes(mp.packb({"get": "is_blackholed", "identity_hash": identity_hash})) - response = mp.unpackb(rpc_connection.recv_bytes()) - return response + rpc_connection = self.get_rpc_client() + rpc_connection.send_bytes(mp.packb({"get": "is_blackholed", "identity_hash": identity_hash})) + response = mp.unpackb(rpc_connection.recv_bytes()) + return response else: return identity_hash in RNS.Transport.blackholed_identities diff --git a/RNS/Utilities/rnsd.py b/RNS/Utilities/rnsd.py index 515fffba..4e4e5ea3 100755 --- a/RNS/Utilities/rnsd.py +++ b/RNS/Utilities/rnsd.py @@ -215,6 +215,14 @@ instance_name = default # required_discovery_value = 14 +# You can disable allowing interfaces to be attached, de- +# tached or reloaded via rnstatus, for example if running +# on a shared system where users with normal RPC access +# should not be allowed to control interface states. + +# enable_interface_management = no + + # You can configure Reticulum to panic and forcibly close # if an unrecoverable interface error occurs, such as the # hardware device for an interface disappearing. This is diff --git a/RNS/Utilities/rnstatus.py b/RNS/Utilities/rnstatus.py index 04754dfa..31d89de8 100644 --- a/RNS/Utilities/rnstatus.py +++ b/RNS/Utilities/rnstatus.py @@ -159,7 +159,8 @@ def get_remote_status(destination_hash, include_lstats, include_profiling, ident def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=False, astats=False, pstats=False, lstats=False, sorting=None, sort_reverse=False, remote=None, management_identity=None, must_exit=True, rns_instance=None, traffic_totals=False, discovered_interfaces=False, config_entries=False, burst_filter=False, blocked_ips=False, - queue_stats=False, pps=False, profiling=False, remote_timeout=RNS.Transport.PATH_REQUEST_TIMEOUT): + queue_stats=False, pps=False, profiling=False, remote_timeout=RNS.Transport.PATH_REQUEST_TIMEOUT, + attach=None, detach=None, reload=None): if remote: require_shared = False else: require_shared = True @@ -176,6 +177,37 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json= if must_exit: exit(1) else: return + if attach: + result = reticulum.attach_interface(attach) + if result == True: print(f"Interface {attach} was attached") + elif result == False: print(f"Could not attach interface {attach}") + elif result == None: print(f"The interface {attach} does not exist") + else: print(f"Unknown error while attaching interface {attach}") + + if result == True: exit(0) + else: exit(1) + + if detach: + result = reticulum.detach_interface(detach) + if result == True: print(f"Interface {detach} was detached") + elif result == False: print(f"Could not detach interface {detach}") + elif result == None: print(f"The interface {detach} does not exist") + else: print(f"Unknown error while detaching interface {detach}") + + if result == True: exit(0) + else: exit(1) + + if reload: + result = reticulum.reload_interface(reload) + if result == True: print(f"Interface {reload} was reloaded") + elif result == False: print(f"Could not reload interface {reload}") + elif result == None: print(f"The interface {reload} does not exist") + else: print(f"Unknown error while reloading interface {reload}") + + if result == True: exit(0) + else: exit(1) + + link_count = None active_link_count = None stats = None @@ -832,6 +864,10 @@ def main(must_exit=True, rns_instance=None): parser.add_argument("--config", action="store", default=None, help="path to alternative Reticulum config directory", type=str) parser.add_argument("--version", action="version", version="rnstatus {version}".format(version=__version__)) + parser.add_argument("--attach", action="store", metavar="name", help="Attach interface by name", default=None, type=str) + parser.add_argument("--detach", action="store", metavar="name", help="Detach interface by name", default=None, type=str) + parser.add_argument("--reload", action="store", metavar="name", help="Reload interface by name", default=None, type=str) + parser.add_argument("-a", "--all", action="store_true", help="show all interfaces", default=False) parser.add_argument("-A", "--announce-stats", action="store_true", help="show announce stats", default=False) parser.add_argument("-P", "--pr-stats", action="store_true", help="show path request stats", default=False) @@ -898,7 +934,8 @@ def main(must_exit=True, rns_instance=None): astats=args.announce_stats, pstats=args.pr_stats, lstats=args.link_stats, sorting=args.sort, sort_reverse=args.reverse, remote=args.R, management_identity=args.i, remote_timeout=args.w, must_exit=must_exit, rns_instance=rns_instance, traffic_totals=args.totals, discovered_interfaces=args.discovered, config_entries=args.D, burst_filter=args.burst, - blocked_ips=args.blocked_ips, queue_stats=args.queues, pps=args.pps, profiling=args.profiling) + blocked_ips=args.blocked_ips, queue_stats=args.queues, pps=args.pps, profiling=args.profiling, + attach=args.attach, detach=args.detach, reload=args.reload) except KeyboardInterrupt: print("") diff --git a/docs/source/using.rst b/docs/source/using.rst index 71e7620c..3abb4734 100644 --- a/docs/source/using.rst +++ b/docs/source/using.rst @@ -274,8 +274,7 @@ You can easily add ``rnsd`` as an always-on service by :ref:`configuring a servi The rnstatus Utility ==================== -Using the ``rnstatus`` utility, you can view the status of configured Reticulum -interfaces, similar to the ``ifconfig`` program. +Using the ``rnstatus`` utility, you can view the status of and control configured Reticulum interfaces, similar to the ``ifconfig`` program. **Usage Examples** @@ -350,6 +349,9 @@ Filter output to only show some interfaces: -h, --help show this help message and exit --config CONFIG path to alternative Reticulum config directory --version show program's version number and exit + --attach name Attach interface by name + --detach name Detach interface by name + --reload name Reload interface by name -a, --all show all interfaces -A, --announce-stats show announce stats -P, --pr-stats show path request stats