rnpath.py
1 #!/usr/bin/env python3 2 3 # Reticulum License 4 # 5 # Copyright (c) 2016-2025 Mark Qvist 6 # 7 # Permission is hereby granted, free of charge, to any person obtaining a copy 8 # of this software and associated documentation files (the "Software"), to deal 9 # in the Software without restriction, including without limitation the rights 10 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 # copies of the Software, and to permit persons to whom the Software is 12 # furnished to do so, subject to the following conditions: 13 # 14 # - The Software shall not be used in any kind of system which includes amongst 15 # its functions the ability to purposefully do harm to human beings. 16 # 17 # - The Software shall not be used, directly or indirectly, in the creation of 18 # an artificial intelligence, machine learning or language model training 19 # dataset, including but not limited to any use that contributes to the 20 # training or development of such a model or algorithm. 21 # 22 # - The above copyright notice and this permission notice shall be included in 23 # all copies or substantial portions of the Software. 24 # 25 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 # SOFTWARE. 32 33 import RNS 34 import os 35 import sys 36 import time 37 import argparse 38 39 from RNS._version import __version__ 40 41 remote_link = None 42 def connect_remote(destination_hash, auth_identity, timeout, no_output = False): 43 global remote_link, reticulum 44 if not RNS.Transport.has_path(destination_hash): 45 if not no_output: 46 print("Path to "+RNS.prettyhexrep(destination_hash)+" requested", end=" ") 47 sys.stdout.flush() 48 RNS.Transport.request_path(destination_hash) 49 pr_time = time.time() 50 while not RNS.Transport.has_path(destination_hash): 51 time.sleep(0.1) 52 if time.time() - pr_time > timeout: 53 if not no_output: 54 print("\r \r", end="") 55 print("Path request timed out") 56 exit(12) 57 58 remote_identity = RNS.Identity.recall(destination_hash) 59 60 def remote_link_closed(link): 61 if link.teardown_reason == RNS.Link.TIMEOUT: 62 if not no_output: 63 print("\r \r", end="") 64 print("The link timed out, exiting now") 65 elif link.teardown_reason == RNS.Link.DESTINATION_CLOSED: 66 if not no_output: 67 print("\r \r", end="") 68 print("The link was closed by the server, exiting now") 69 else: 70 if not no_output: 71 print("\r \r", end="") 72 print("Link closed unexpectedly, exiting now") 73 exit(10) 74 75 def remote_link_established(link): 76 global remote_link 77 link.identify(auth_identity) 78 remote_link = link 79 80 if not no_output: 81 print("\r \r", end="") 82 print("Establishing link with remote transport instance...", end=" ") 83 sys.stdout.flush() 84 85 remote_destination = RNS.Destination(remote_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "rnstransport", "remote", "management") 86 link = RNS.Link(remote_destination) 87 link.set_link_established_callback(remote_link_established) 88 link.set_link_closed_callback(remote_link_closed) 89 90 def program_setup(configdir, table, rates, drop, destination_hexhash, verbosity, timeout, drop_queues, 91 drop_via, max_hops, remote=None, management_identity=None, remote_timeout=RNS.Transport.PATH_REQUEST_TIMEOUT, 92 no_output=False, json=False): 93 global remote_link, reticulum 94 reticulum = RNS.Reticulum(configdir = configdir, loglevel = 3+verbosity) 95 if remote: 96 try: 97 dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2 98 if len(remote) != dest_len: 99 raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2)) 100 try: 101 identity_hash = bytes.fromhex(remote) 102 remote_hash = RNS.Destination.hash_from_name_and_identity("rnstransport.remote.management", identity_hash) 103 except Exception as e: 104 raise ValueError("Invalid destination entered. Check your input.") 105 106 identity = RNS.Identity.from_file(os.path.expanduser(management_identity)) 107 if identity == None: 108 raise ValueError("Could not load management identity from "+str(management_identity)) 109 110 try: 111 connect_remote(remote_hash, identity, remote_timeout, no_output) 112 except Exception as e: 113 raise e 114 115 except Exception as e: 116 print(str(e)) 117 exit(20) 118 119 while remote_link == None: 120 time.sleep(0.1) 121 122 123 if table: 124 destination_hash = None 125 if destination_hexhash != None: 126 try: 127 dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2 128 if len(destination_hexhash) != dest_len: 129 raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2)) 130 try: 131 destination_hash = bytes.fromhex(destination_hexhash) 132 except Exception as e: 133 raise ValueError("Invalid destination entered. Check your input.") 134 except Exception as e: 135 print(str(e)) 136 sys.exit(1) 137 138 if not remote_link: 139 table = sorted(reticulum.get_path_table(max_hops=max_hops), key=lambda e: (e["interface"], e["hops"]) ) 140 else: 141 if not no_output: 142 print("\r \r", end="") 143 print("Sending request...", end=" ") 144 sys.stdout.flush() 145 receipt = remote_link.request("/path", data = ["table", destination_hash, max_hops]) 146 while not receipt.concluded(): 147 time.sleep(0.1) 148 response = receipt.get_response() 149 if response: 150 table = response 151 print("\r \r", end="") 152 else: 153 if not no_output: 154 print("\r \r", end="") 155 print("The remote request failed. Likely authentication failure.") 156 exit(10) 157 158 displayed = 0 159 if json: 160 import json 161 for p in table: 162 for k in p: 163 if isinstance(p[k], bytes): 164 p[k] = RNS.hexrep(p[k], delimit=False) 165 166 print(json.dumps(table)) 167 exit() 168 else: 169 for path in table: 170 if destination_hash == None or destination_hash == path["hash"]: 171 displayed += 1 172 exp_str = RNS.timestamp_str(path["expires"]) 173 if path["hops"] == 1: 174 m_str = " " 175 else: 176 m_str = "s" 177 print(RNS.prettyhexrep(path["hash"])+" is "+str(path["hops"])+" hop"+m_str+" away via "+RNS.prettyhexrep(path["via"])+" on "+path["interface"]+" expires "+RNS.timestamp_str(path["expires"])) 178 179 if destination_hash != None and displayed == 0: 180 print("No path known") 181 sys.exit(1) 182 183 elif rates: 184 destination_hash = None 185 if destination_hexhash != None: 186 try: 187 dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2 188 if len(destination_hexhash) != dest_len: 189 raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2)) 190 try: 191 destination_hash = bytes.fromhex(destination_hexhash) 192 except Exception as e: 193 raise ValueError("Invalid destination entered. Check your input.") 194 except Exception as e: 195 print(str(e)) 196 sys.exit(1) 197 198 if not remote_link: 199 table = reticulum.get_rate_table() 200 else: 201 if not no_output: 202 print("\r \r", end="") 203 print("Sending request...", end=" ") 204 sys.stdout.flush() 205 receipt = remote_link.request("/path", data = ["rates", destination_hash]) 206 while not receipt.concluded(): 207 time.sleep(0.1) 208 response = receipt.get_response() 209 if response: 210 table = response 211 print("\r \r", end="") 212 else: 213 if not no_output: 214 print("\r \r", end="") 215 print("The remote request failed. Likely authentication failure.") 216 exit(10) 217 218 table = sorted(table, key=lambda e: e["last"]) 219 if json: 220 import json 221 for p in table: 222 for k in p: 223 if isinstance(p[k], bytes): 224 p[k] = RNS.hexrep(p[k], delimit=False) 225 226 print(json.dumps(table)) 227 exit() 228 else: 229 if len(table) == 0: 230 print("No information available") 231 232 else: 233 displayed = 0 234 for entry in table: 235 if destination_hash == None or destination_hash == entry["hash"]: 236 displayed += 1 237 try: 238 last_str = pretty_date(int(entry["last"])) 239 start_ts = entry["timestamps"][0] 240 span = max(time.time() - start_ts, 3600.0) 241 span_hours = span/3600.0 242 span_str = pretty_date(int(entry["timestamps"][0])) 243 hour_rate = round(len(entry["timestamps"])/span_hours, 3) 244 if hour_rate-int(hour_rate) == 0: 245 hour_rate = int(hour_rate) 246 247 if entry["rate_violations"] > 0: 248 if entry["rate_violations"] == 1: 249 s_str = "" 250 else: 251 s_str = "s" 252 253 rv_str = ", "+str(entry["rate_violations"])+" active rate violation"+s_str 254 else: 255 rv_str = "" 256 257 if entry["blocked_until"] > time.time(): 258 bli = time.time()-(int(entry["blocked_until"])-time.time()) 259 bl_str = ", new announces allowed in "+pretty_date(int(bli)) 260 else: 261 bl_str = "" 262 263 264 print(RNS.prettyhexrep(entry["hash"])+" last heard "+last_str+" ago, "+str(hour_rate)+" announces/hour in the last "+span_str+rv_str+bl_str) 265 266 except Exception as e: 267 print("Error while processing entry for "+RNS.prettyhexrep(entry["hash"])) 268 print(str(e)) 269 270 if destination_hash != None and displayed == 0: 271 print("No information available") 272 sys.exit(1) 273 274 elif drop_queues: 275 if remote_link: 276 if not no_output: 277 print("\r \r", end="") 278 print("Dropping announce queues on remote instances not yet implemented") 279 exit(255) 280 281 print("Dropping announce queues on all interfaces...") 282 reticulum.drop_announce_queues() 283 284 elif drop: 285 if remote_link: 286 if not no_output: 287 print("\r \r", end="") 288 print("Dropping path on remote instances not yet implemented") 289 exit(255) 290 291 try: 292 dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2 293 if len(destination_hexhash) != dest_len: 294 raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2)) 295 try: 296 destination_hash = bytes.fromhex(destination_hexhash) 297 except Exception as e: 298 raise ValueError("Invalid destination entered. Check your input.") 299 except Exception as e: 300 print(str(e)) 301 sys.exit(1) 302 303 if reticulum.drop_path(destination_hash): 304 print("Dropped path to "+RNS.prettyhexrep(destination_hash)) 305 else: 306 print("Unable to drop path to "+RNS.prettyhexrep(destination_hash)+". Does it exist?") 307 sys.exit(1) 308 309 elif drop_via: 310 if remote_link: 311 if not no_output: 312 print("\r \r", end="") 313 print("Dropping all paths via specific transport instance on remote instances yet not implemented") 314 exit(255) 315 316 try: 317 dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2 318 if len(destination_hexhash) != dest_len: 319 raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2)) 320 try: 321 destination_hash = bytes.fromhex(destination_hexhash) 322 except Exception as e: 323 raise ValueError("Invalid destination entered. Check your input.") 324 except Exception as e: 325 print(str(e)) 326 sys.exit(1) 327 328 if reticulum.drop_all_via(destination_hash): 329 print("Dropped all paths via "+RNS.prettyhexrep(destination_hash)) 330 else: 331 print("Unable to drop paths via "+RNS.prettyhexrep(destination_hash)+". Does the transport instance exist?") 332 sys.exit(1) 333 334 else: 335 if remote_link: 336 if not no_output: 337 print("\r \r", end="") 338 print("Requesting paths on remote instances not implemented") 339 exit(255) 340 341 try: 342 dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2 343 if len(destination_hexhash) != dest_len: 344 raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2)) 345 try: 346 destination_hash = bytes.fromhex(destination_hexhash) 347 except Exception as e: 348 raise ValueError("Invalid destination entered. Check your input.") 349 except Exception as e: 350 print(str(e)) 351 sys.exit(1) 352 353 if not RNS.Transport.has_path(destination_hash): 354 RNS.Transport.request_path(destination_hash) 355 print("Path to "+RNS.prettyhexrep(destination_hash)+" requested ", end=" ") 356 sys.stdout.flush() 357 358 i = 0 359 syms = "⢄⢂⢁⡁⡈⡐⡠" 360 limit = time.time()+timeout 361 while not RNS.Transport.has_path(destination_hash) and time.time()<limit: 362 time.sleep(0.1) 363 print(("\b\b"+syms[i]+" "), end="") 364 sys.stdout.flush() 365 i = (i+1)%len(syms) 366 367 if RNS.Transport.has_path(destination_hash): 368 hops = RNS.Transport.hops_to(destination_hash) 369 next_hop_bytes = reticulum.get_next_hop(destination_hash) 370 if next_hop_bytes == None: 371 print("\r \rError: Invalid path data returned") 372 sys.exit(1) 373 else: 374 next_hop = RNS.prettyhexrep(next_hop_bytes) 375 next_hop_interface = reticulum.get_next_hop_if_name(destination_hash) 376 377 if hops != 1: 378 ms = "s" 379 else: 380 ms = "" 381 382 print("\rPath found, destination "+RNS.prettyhexrep(destination_hash)+" is "+str(hops)+" hop"+ms+" away via "+next_hop+" on "+next_hop_interface) 383 else: 384 print("\r \rPath not found") 385 sys.exit(1) 386 387 388 389 def main(): 390 try: 391 parser = argparse.ArgumentParser(description="Reticulum Path Discovery Utility") 392 393 parser.add_argument("--config", 394 action="store", 395 default=None, 396 help="path to alternative Reticulum config directory", 397 type=str 398 ) 399 400 parser.add_argument( 401 "--version", 402 action="version", 403 version="rnpath {version}".format(version=__version__) 404 ) 405 406 parser.add_argument( 407 "-t", 408 "--table", 409 action="store_true", 410 help="show all known paths", 411 default=False 412 ) 413 414 parser.add_argument( 415 "-m", 416 "--max", 417 action="store", 418 metavar="hops", 419 type=int, 420 help="maximum hops to filter path table by", 421 default=None 422 ) 423 424 parser.add_argument( 425 "-r", 426 "--rates", 427 action="store_true", 428 help="show announce rate info", 429 default=False 430 ) 431 432 parser.add_argument( 433 "-d", 434 "--drop", 435 action="store_true", 436 help="remove the path to a destination", 437 default=False 438 ) 439 440 parser.add_argument( 441 "-D", 442 "--drop-announces", 443 action="store_true", 444 help="drop all queued announces", 445 default=False 446 ) 447 448 parser.add_argument( 449 "-x", "--drop-via", 450 action="store_true", 451 help="drop all paths via specified transport instance", 452 default=False 453 ) 454 455 parser.add_argument( 456 "-w", 457 action="store", 458 metavar="seconds", 459 type=float, 460 help="timeout before giving up", 461 default=RNS.Transport.PATH_REQUEST_TIMEOUT 462 ) 463 464 parser.add_argument( 465 "-R", 466 action="store", 467 metavar="hash", 468 help="transport identity hash of remote instance to manage", 469 default=None, 470 type=str 471 ) 472 473 parser.add_argument( 474 "-i", 475 action="store", 476 metavar="path", 477 help="path to identity used for remote management", 478 default=None, 479 type=str 480 ) 481 482 parser.add_argument( 483 "-W", 484 action="store", 485 metavar="seconds", 486 type=float, 487 help="timeout before giving up on remote queries", 488 default=RNS.Transport.PATH_REQUEST_TIMEOUT 489 ) 490 491 parser.add_argument( 492 "-j", 493 "--json", 494 action="store_true", 495 help="output in JSON format", 496 default=False 497 ) 498 499 parser.add_argument( 500 "destination", 501 nargs="?", 502 default=None, 503 help="hexadecimal hash of the destination", 504 type=str 505 ) 506 507 parser.add_argument('-v', '--verbose', action='count', default=0) 508 509 args = parser.parse_args() 510 511 if args.config: 512 configarg = args.config 513 else: 514 configarg = None 515 516 if not args.drop_announces and not args.table and not args.rates and not args.destination and not args.drop_via: 517 print("") 518 parser.print_help() 519 print("") 520 else: 521 program_setup( 522 configdir = configarg, 523 table = args.table, 524 rates = args.rates, 525 drop = args.drop, 526 destination_hexhash = args.destination, 527 verbosity = args.verbose, 528 timeout = args.w, 529 drop_queues = args.drop_announces, 530 drop_via = args.drop_via, 531 max_hops = args.max, 532 remote=args.R, 533 management_identity=args.i, 534 remote_timeout=args.W, 535 json=args.json, 536 ) 537 sys.exit(0) 538 539 except KeyboardInterrupt: 540 print("") 541 exit() 542 543 def pretty_date(time=False): 544 from datetime import datetime 545 now = datetime.now() 546 if type(time) is int: 547 diff = now - datetime.fromtimestamp(time) 548 elif isinstance(time,datetime): 549 diff = now - time 550 elif not time: 551 diff = now - now 552 second_diff = diff.seconds 553 day_diff = diff.days 554 if day_diff < 0: 555 return '' 556 if day_diff == 0: 557 if second_diff < 10: 558 return str(second_diff) + " seconds" 559 if second_diff < 60: 560 return str(second_diff) + " seconds" 561 if second_diff < 120: 562 return "1 minute" 563 if second_diff < 3600: 564 return str(int(second_diff / 60)) + " minutes" 565 if second_diff < 7200: 566 return "an hour" 567 if second_diff < 86400: 568 return str(int(second_diff / 3600)) + " hours" 569 if day_diff == 1: 570 return "1 day" 571 if day_diff < 7: 572 return str(day_diff) + " days" 573 if day_diff < 31: 574 return str(int(day_diff / 7)) + " weeks" 575 if day_diff < 365: 576 return str(int(day_diff / 30)) + " months" 577 return str(int(day_diff / 365)) + " years" 578 579 if __name__ == "__main__": 580 main()