/ RNS / Utilities / rnpath.py
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()