/ RNS / Utilities / rncp.py
rncp.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 argparse
 35  import threading
 36  import shutil
 37  import time
 38  import sys
 39  import os
 40  
 41  from RNS._version import __version__
 42  
 43  APP_NAME = "rncp"
 44  allow_all = False
 45  allow_fetch = False
 46  allow_overwrite_on_receive = False
 47  fetch_auto_compress = True
 48  fetch_jail = None
 49  save_path = None
 50  show_phy_rates = False
 51  allowed_identity_hashes = []
 52  identity = None
 53  
 54  def prepare_identity(identity_path):
 55      global identity
 56      if identity_path == None:
 57          identity_path = RNS.Reticulum.identitypath+"/"+APP_NAME
 58  
 59      if os.path.isfile(identity_path):
 60          identity = RNS.Identity.from_file(identity_path)
 61          if identity == None:
 62              RNS.log(f"Could not load identity for rncp. The identity file at \"{identity_path}\" may be corrupt or unreadable.", RNS.LOG_ERROR)
 63              RNS.exit(2)
 64  
 65      if identity == None:
 66          RNS.log("No valid saved identity found, creating new...", RNS.LOG_INFO)
 67          identity = RNS.Identity()
 68          identity.to_file(identity_path)
 69  
 70  REQ_FETCH_NOT_ALLOWED = 0xF0
 71  
 72  es = " "
 73  erase_str = "\33[2K\r"
 74  
 75  def listen(configdir, identitypath = None, verbosity = 0, quietness = 0, allowed = [], display_identity = False,
 76             limit = None, disable_auth = None, fetch_allowed = False, no_compress=False,
 77             jail = None, save = None, announce = False, allow_overwrite=False):
 78  
 79      global allow_all, allow_fetch, allowed_identity_hashes, fetch_jail, save_path, identity
 80      global fetch_auto_compress, allow_overwrite_on_receive
 81  
 82      allow_fetch = fetch_allowed
 83      fetch_auto_compress = not no_compress
 84      allow_overwrite_on_receive = allow_overwrite
 85      identity = None
 86      if announce < 0:
 87          announce = False
 88  
 89      targetloglevel = 3+verbosity-quietness
 90      reticulum = RNS.Reticulum(configdir=configdir, loglevel=targetloglevel)
 91  
 92      if jail != None:
 93          fetch_jail = os.path.abspath(os.path.expanduser(jail))
 94          RNS.log("Restricting fetch requests to paths under \""+fetch_jail+"\"", RNS.LOG_VERBOSE)
 95  
 96      if save != None:
 97          sp = os.path.abspath(os.path.expanduser(save))
 98          if os.path.isdir(sp):
 99              if os.access(sp, os.W_OK):
100                  save_path = sp
101              else:
102                  RNS.log("Output directory not writable", RNS.LOG_ERROR)
103                  RNS.exit(4)
104          else:
105              RNS.log("Output directory not found", RNS.LOG_ERROR)
106              RNS.exit(3)
107  
108          RNS.log("Saving received files in \""+save_path+"\"", RNS.LOG_VERBOSE)
109  
110      prepare_identity(identitypath)
111  
112      destination = RNS.Destination(identity, RNS.Destination.IN, RNS.Destination.SINGLE, APP_NAME, "receive")
113  
114      if display_identity:
115          print("Identity     : "+str(identity))
116          print("Listening on : "+RNS.prettyhexrep(destination.hash))
117          RNS.exit(0)
118  
119      if disable_auth:
120          allow_all = True
121      else:
122          dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2
123          try:
124              allowed_file_name = "allowed_identities"
125              allowed_file = None
126              if os.path.isfile(os.path.expanduser("/etc/rncp/"+allowed_file_name)):
127                  allowed_file = os.path.expanduser("/etc/rncp/"+allowed_file_name)
128              elif os.path.isfile(os.path.expanduser("~/.config/rncp/"+allowed_file_name)):
129                  allowed_file = os.path.expanduser("~/.config/rncp/"+allowed_file_name)
130              elif os.path.isfile(os.path.expanduser("~/.rncp/"+allowed_file_name)):
131                  allowed_file = os.path.expanduser("~/.rncp/"+allowed_file_name)
132              if allowed_file != None:
133                  af = open(allowed_file, "r")
134                  al = af.read().replace("\r", "").split("\n")
135                  ali = []
136                  for a in al:
137                      if len(a) == dest_len:
138                          ali.append(a)
139  
140                  if len(ali) > 0:
141                      if not allowed:
142                          allowed = ali
143                      else:
144                          allowed.extend(ali)
145                  if len(ali) == 1:
146                      ms = "y"
147                  else:
148                      ms = "ies"
149                  
150                  RNS.log("Loaded "+str(len(ali))+" allowed identit"+ms+" from "+str(allowed_file), RNS.LOG_VERBOSE)
151  
152          except Exception as e:
153              RNS.log("Error while parsing allowed_identities file. The contained exception was: "+str(e), RNS.LOG_ERROR)
154  
155          if allowed != None:
156              for a in allowed:
157                  try:
158                      if len(a) != dest_len:
159                          raise ValueError("Allowed destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
160                      try:
161                          destination_hash = bytes.fromhex(a)
162                          allowed_identity_hashes.append(destination_hash)
163                      except Exception as e:
164                          raise ValueError("Invalid destination entered. Check your input.")
165                  except Exception as e:
166                      print(str(e))
167                      RNS.exit(1)
168  
169      if len(allowed_identity_hashes) < 1 and not disable_auth:
170          print("Warning: No allowed identities configured, rncp will not accept any files!")
171  
172      def fetch_request(path, data, request_id, link_id, remote_identity, requested_at):
173          global allow_fetch, fetch_jail, fetch_auto_compress
174          if not allow_fetch:
175              return REQ_FETCH_NOT_ALLOWED
176  
177          if fetch_jail:
178              if data.startswith(fetch_jail+"/"):
179                  data = data.replace(fetch_jail+"/", "")
180              file_path = os.path.abspath(os.path.expanduser(f"{fetch_jail}/{data}"))
181              if not file_path.startswith(fetch_jail+"/"):
182                  RNS.log(f"Disallowing fetch request for {file_path} outside of fetch jail {fetch_jail}", RNS.LOG_WARNING)
183                  return REQ_FETCH_NOT_ALLOWED
184          else:
185              file_path = os.path.abspath(os.path.expanduser(f"{data}"))
186  
187          target_link = None
188          for link in RNS.Transport.active_links:
189              if link.link_id == link_id:
190                  target_link = link
191  
192          if not os.path.isfile(file_path):
193              RNS.log("Client-requested file not found: "+str(file_path), RNS.LOG_VERBOSE)
194              return False
195          else:
196              if target_link != None:
197                  RNS.log("Sending file "+str(file_path)+" to client", RNS.LOG_VERBOSE)
198  
199                  try:
200                      metadata = {"name": os.path.basename(file_path).encode("utf-8") }
201                      fetch_resource = RNS.Resource(open(file_path, "rb"), target_link, metadata=metadata, auto_compress=fetch_auto_compress)
202                      return True
203  
204                  except Exception as e:
205                      RNS.log(f"Could not send file to client. The contained exception was: {e}", RNS.LOG_ERROR)
206                      return False
207  
208              else:
209                  return None
210  
211  
212      destination.set_link_established_callback(client_link_established)
213      if allow_fetch:
214          if allow_all:
215              RNS.log("Allowing unauthenticated fetch requests", RNS.LOG_WARNING)
216              destination.register_request_handler("fetch_file", response_generator=fetch_request, allow=RNS.Destination.ALLOW_ALL)
217          else:
218              destination.register_request_handler("fetch_file", response_generator=fetch_request, allow=RNS.Destination.ALLOW_LIST, allowed_list=allowed_identity_hashes)
219  
220      print("rncp listening on "+RNS.prettyhexrep(destination.hash))
221  
222      if announce >= 0:
223          def job():
224              destination.announce()
225              if announce > 0:
226                  while True:
227                      time.sleep(announce)
228                      destination.announce()
229  
230          threading.Thread(target=job, daemon=True).start()
231      
232      while True: time.sleep(1)
233  
234  def client_link_established(link):
235      RNS.log("Incoming link established", RNS.LOG_VERBOSE)
236      link.set_remote_identified_callback(receive_sender_identified)
237      link.set_resource_strategy(RNS.Link.ACCEPT_APP)
238      link.set_resource_callback(receive_resource_callback)
239      link.set_resource_started_callback(receive_resource_started)
240      link.set_resource_concluded_callback(receive_resource_concluded)
241  
242  def receive_sender_identified(link, identity):
243      global allow_all
244  
245      if identity.hash in allowed_identity_hashes:
246          RNS.log("Authenticated sender", RNS.LOG_VERBOSE)
247      else:
248          if not allow_all:
249              RNS.log("Sender not allowed, tearing down link", RNS.LOG_VERBOSE)
250              link.teardown()
251          else:
252              pass
253  
254  def receive_resource_callback(resource):
255      global allow_all
256      
257      sender_identity = resource.link.get_remote_identity()
258  
259      if sender_identity != None:
260          if sender_identity.hash in allowed_identity_hashes:
261              return True
262  
263      if allow_all:
264          return True
265  
266      return False
267  
268  def receive_resource_started(resource):
269      if resource.link.get_remote_identity():
270          id_str = " from "+RNS.prettyhexrep(resource.link.get_remote_identity().hash)
271      else:
272          id_str = ""
273  
274      print("Starting resource transfer "+RNS.prettyhexrep(resource.hash)+id_str)
275  
276  def receive_resource_concluded(resource):
277      global save_path, allow_overwrite_on_receive
278      if resource.status == RNS.Resource.COMPLETE:
279          print(str(resource)+" completed")
280  
281          if resource.metadata == None:
282              print("Invalid data received, ignoring resource")
283              return
284  
285          else:
286              try:
287                  filename = os.path.basename(resource.metadata["name"].decode("utf-8"))
288                  counter = 0
289                  if save_path:
290                      saved_filename = os.path.abspath(os.path.expanduser(save_path+"/"+filename))
291                      if not saved_filename.startswith(save_path+"/"):
292                          RNS.log(f"Invalid save path {saved_filename}, ignoring", RNS.LOG_ERROR)
293                          return
294                  else:
295                      saved_filename = filename
296  
297                  full_save_path = saved_filename
298                  if allow_overwrite_on_receive:
299                      if os.path.isfile(full_save_path):
300                          try: os.unlink(full_save_path)
301                          except Exception as e:
302                              RNS.log(f"Could not overwrite existing file {full_save_path}, renaming instead", RNS.LOG_ERROR)
303  
304                  while os.path.isfile(full_save_path):
305                      counter += 1
306                      full_save_path = saved_filename+"."+str(counter)
307  
308                  shutil.move(resource.data.name, full_save_path)
309  
310              except Exception as e:
311                  RNS.log(f"An error occurred while saving received resource: {e}", RNS.LOG_ERROR)
312                  return
313  
314      else:
315          print("Resource failed")
316  
317  resource_done = False
318  current_resource = None
319  stats = []
320  speed = 0.0
321  phy_speed = 0.0
322  phy_got_total = 0
323  def sender_progress(resource):
324      stats_max = 32
325      global current_resource, stats, speed, phy_speed, phy_got_total, resource_done
326      current_resource = resource
327      
328      now = time.time()
329      got = current_resource.get_progress()*current_resource.get_data_size()
330      phy_got = current_resource.get_segment_progress()*current_resource.get_transfer_size()
331      
332      entry = [now, got, phy_got]
333      stats.append(entry)
334  
335      while len(stats) > stats_max:
336          stats.pop(0)
337  
338      span = now - stats[0][0]
339      if span == 0:
340          speed = 0
341          phy_speed = 0
342      
343      else:
344          diff = got - stats[0][1]
345          speed = diff/span
346  
347          phy_diff = phy_got - stats[0][2]
348          if phy_diff > 0:
349              phy_speed = phy_diff/span
350              # phy_got_total += phy_diff
351  
352      if resource.status < RNS.Resource.COMPLETE:
353          resource_done = False
354      else:
355          resource_done = True
356  
357  link = None
358  def fetch(configdir, identitypath = None, verbosity = 0, quietness = 0, destination = None, file = None, timeout = RNS.Transport.PATH_REQUEST_TIMEOUT, silent=False, phy_rates=False, save=None, allow_overwrite=False):
359      global current_resource, resource_done, link, speed, show_phy_rates, save_path, allow_overwrite_on_receive, identity
360      targetloglevel = 3+verbosity-quietness
361      show_phy_rates = phy_rates
362      allow_overwrite_on_receive = allow_overwrite
363  
364      if save:
365          sp = os.path.abspath(os.path.expanduser(save))
366          if os.path.isdir(sp):
367              if os.access(sp, os.W_OK):
368                  save_path = sp
369              else:
370                  RNS.log("Output directory not writable", RNS.LOG_ERROR)
371                  RNS.exit(4)
372          else:
373              RNS.log("Output directory not found", RNS.LOG_ERROR)
374              RNS.exit(3)
375  
376      try:
377          dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2
378          if len(destination) != dest_len:
379              raise ValueError("Allowed destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
380          try:
381              destination_hash = bytes.fromhex(destination)
382          except Exception as e:
383              raise ValueError("Invalid destination entered. Check your input.")
384      except Exception as e:
385          print(str(e))
386          RNS.exit(1)
387  
388      reticulum = RNS.Reticulum(configdir=configdir, loglevel=targetloglevel)
389  
390      if identity == None:
391          prepare_identity(identitypath)
392  
393      if not RNS.Transport.has_path(destination_hash):
394          RNS.Transport.request_path(destination_hash)
395          if silent:
396              print("Path to "+RNS.prettyhexrep(destination_hash)+" requested")
397          else:
398              print("Path to "+RNS.prettyhexrep(destination_hash)+" requested  ", end=es)
399          sys.stdout.flush()
400  
401      i = 0
402      syms = "⢄⢂⢁⡁⡈⡐⡠"
403      estab_timeout = time.time()+timeout
404      while not RNS.Transport.has_path(destination_hash) and time.time() < estab_timeout:
405          if not silent:
406              time.sleep(0.1)
407              print(("\b\b"+syms[i]+" "), end="")
408              sys.stdout.flush()
409              i = (i+1)%len(syms)
410  
411      if not RNS.Transport.has_path(destination_hash):
412          if silent:
413              print("Path not found")
414          else:
415              print(f"{erase_str}Path not found")
416          RNS.exit(1)
417      else:
418          if silent:
419              print("Establishing link with "+RNS.prettyhexrep(destination_hash))
420          else:
421              print(f"{erase_str}Establishing link with "+RNS.prettyhexrep(destination_hash)+"  ", end=es)
422  
423      listener_identity = RNS.Identity.recall(destination_hash)
424      listener_destination = RNS.Destination(
425          listener_identity,
426          RNS.Destination.OUT,
427          RNS.Destination.SINGLE,
428          APP_NAME,
429          "receive"
430      )
431  
432      link = RNS.Link(listener_destination)
433      while link.status != RNS.Link.ACTIVE and time.time() < estab_timeout:
434          if not silent:
435              time.sleep(0.1)
436              print(("\b\b"+syms[i]+" "), end="")
437              sys.stdout.flush()
438              i = (i+1)%len(syms)
439  
440      if not RNS.Transport.has_path(destination_hash):
441          if silent:
442              print("Could not establish link with "+RNS.prettyhexrep(destination_hash))
443          else:
444              print(f"{erase_str}Could not establish link with "+RNS.prettyhexrep(destination_hash))
445          RNS.exit(1)
446      else:
447          if silent:
448              print("Requesting file from remote...")
449          else:
450              print(f"{erase_str}Requesting file from remote  ", end=es)
451  
452      link.identify(identity)
453  
454      request_resolved = False
455      request_status = "unknown"
456      resource_resolved = False
457      resource_status = "unrequested"
458      current_resource = None
459      current_transfer_started = None
460      def request_response(request_receipt):
461          nonlocal request_resolved, request_status
462          if request_receipt.response == False:
463              request_status = "not_found"
464          elif request_receipt.response == None:
465              request_status = "remote_error"
466          elif request_receipt.response == REQ_FETCH_NOT_ALLOWED:
467              request_status = "fetch_not_allowed"
468          else:
469              request_status = "found"
470  
471          request_resolved = True
472  
473      def request_failed(request_receipt):
474          nonlocal request_resolved, request_status
475          request_status = "unknown"
476          request_resolved = True
477  
478      def fetch_resource_started(resource):
479          nonlocal resource_status, current_transfer_started
480          current_resource = resource
481          current_resource.progress_callback(sender_progress)
482          resource_status = "started"
483          if not current_transfer_started: current_transfer_started = time.time()
484  
485      def fetch_resource_concluded(resource):
486          nonlocal resource_resolved, resource_status
487          global save_path, allow_overwrite_on_receive
488          if resource.status == RNS.Resource.COMPLETE:
489              if resource.metadata == None:
490                  print("Invalid data received, ignoring resource")
491                  return
492  
493              else:
494                  try:
495                      filename = os.path.basename(resource.metadata["name"].decode("utf-8"))
496                      counter = 0
497                      if save_path:
498                          saved_filename = os.path.abspath(os.path.expanduser(save_path+"/"+filename))
499                          if not saved_filename.startswith(save_path+"/"):
500                              print(f"Invalid save path {saved_filename}, ignoring")
501                              return
502                      else:
503                          saved_filename = filename
504  
505                      full_save_path = saved_filename
506                      if allow_overwrite_on_receive:
507                          if os.path.isfile(full_save_path):
508                              try: os.unlink(full_save_path)
509                              except Exception as e:
510                                  print(f"Could not overwrite existing file {full_save_path}, renaming instead")
511  
512                      while os.path.isfile(full_save_path):
513                          counter += 1
514                          full_save_path = saved_filename+"."+str(counter)
515  
516                      shutil.move(resource.data.name, full_save_path)
517  
518                  except Exception as e:
519                      print(f"An error occurred while saving received resource: {e}")
520                      return
521  
522          else:
523              print("Resource failed")
524              resource_status = "failed"
525  
526          resource_resolved = True
527  
528      link.set_resource_strategy(RNS.Link.ACCEPT_ALL)
529      link.set_resource_started_callback(fetch_resource_started)
530      link.set_resource_concluded_callback(fetch_resource_concluded)
531      link.request("fetch_file", data=file, response_callback=request_response, failed_callback=request_failed)
532  
533      syms = "⢄⢂⢁⡁⡈⡐⡠"
534      while not request_resolved:
535          if not silent:
536              time.sleep(0.1)
537              print(("\b\b"+syms[i]+" "), end="")
538              sys.stdout.flush()
539              i = (i+1)%len(syms)
540  
541      if request_status == "fetch_not_allowed":
542          if not silent: print(f"{erase_str}", end="")
543          print("Fetch request failed, fetching the file "+str(file)+" was not allowed by the remote")
544          link.teardown()
545          time.sleep(0.15)
546          RNS.exit(0)
547      elif request_status == "not_found":
548          if not silent: print(f"{erase_str}", end="")
549          print("Fetch request failed, the file "+str(file)+" was not found on the remote")
550          link.teardown()
551          time.sleep(0.15)
552          RNS.exit(0)
553      elif request_status == "remote_error":
554          if not silent: print(f"{erase_str}", end="")
555          print("Fetch request failed due to an error on the remote system")
556          link.teardown()
557          time.sleep(0.15)
558          RNS.exit(0)
559      elif request_status == "unknown":
560          if not silent: print(f"{erase_str}", end="")
561          print("Fetch request failed due to an unknown error (probably not authorised)")
562          link.teardown()
563          time.sleep(0.15)
564          RNS.exit(0)
565      elif request_status == "found":
566          if not silent: print(f"{erase_str}", end="")
567  
568      while not resource_resolved:
569          if not silent:
570              time.sleep(0.1)
571              if current_resource:
572                  prg = current_resource.get_progress()
573                  percent = round(prg * 100.0, 1)
574                  if show_phy_rates:
575                      pss = size_str(phy_speed, "b")
576                      phy_str = f" ({pss}ps at physical layer)"
577                  else:
578                      phy_str = ""
579                  ps = size_str(int(prg*current_resource.total_size))
580                  ts = size_str(current_resource.total_size)
581                  ss = size_str(speed, "b")
582                  stat_str = f"{percent}% - {ps} of {ts} - {ss}ps{phy_str}"
583                  if prg != 1.0:
584                      print(f"{erase_str}Transferring file {syms[i]} {stat_str}", end=es)
585                  else:
586                      end_time = time.time(); delta_time = end_time - current_transfer_started
587                      speed = current_resource.total_size/delta_time; dt_str = RNS.prettytime(delta_time)
588                      ss = size_str(speed, "b")
589                      stat_str = f"{percent}% - {ps} of {ts} in {dt_str} - {ss}ps{phy_str}"
590                      print(f"{erase_str}Transfer complete  {stat_str}", end=es)
591              else:
592                  print(f"{erase_str}Waiting for transfer to start {syms[i]} ", end=es)
593              sys.stdout.flush()
594              i = (i+1)%len(syms)
595  
596      if not current_resource or current_resource.status != RNS.Resource.COMPLETE:
597          if silent:
598              print("The transfer failed")
599          else:
600              print(f"{erase_str}The transfer failed")
601          RNS.exit(1)
602      else:
603          if silent:
604              print(str(file)+" fetched from "+RNS.prettyhexrep(destination_hash))
605          else:
606              print("\n"+str(file)+" fetched from "+RNS.prettyhexrep(destination_hash))
607          link.teardown()
608          time.sleep(0.1)
609          RNS.exit(0)
610  
611      link.teardown()
612      time.sleep(0.1)
613      RNS.exit(0)
614  
615  
616  def send(configdir, identitypath = None, verbosity = 0, quietness = 0, destination = None, file = None, timeout = RNS.Transport.PATH_REQUEST_TIMEOUT, silent=False, phy_rates=False, no_compress=False):
617      global current_resource, resource_done, link, speed, show_phy_rates, phy_got_total, phy_speed, identity
618      targetloglevel = 3+verbosity-quietness
619      show_phy_rates = phy_rates
620  
621      try:
622          dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2
623          if len(destination) != dest_len:
624              raise ValueError("Allowed destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
625          try:
626              destination_hash = bytes.fromhex(destination)
627          except Exception as e:
628              raise ValueError("Invalid destination entered. Check your input.")
629      except Exception as e:
630          print(str(e))
631          RNS.exit(1)
632  
633      
634      file_path = os.path.expanduser(file)
635      if not os.path.isfile(file_path):
636          print("File not found")
637          sys.exit(1)
638  
639      metadata = {"name": os.path.basename(file_path).encode("utf-8") }
640  
641      print(f"{erase_str}", end="")
642  
643      reticulum = RNS.Reticulum(configdir=configdir, loglevel=targetloglevel)
644  
645      if identity == None:
646          prepare_identity(identitypath)
647  
648      if not RNS.Transport.has_path(destination_hash):
649          RNS.Transport.request_path(destination_hash)
650          if silent:
651              print("Path to "+RNS.prettyhexrep(destination_hash)+" requested")
652          else:
653              print("Path to "+RNS.prettyhexrep(destination_hash)+" requested  ", end=es)
654          sys.stdout.flush()
655  
656      i = 0
657      syms = "⢄⢂⢁⡁⡈⡐⡠"
658      estab_timeout = time.time()+timeout
659      while not RNS.Transport.has_path(destination_hash) and time.time() < estab_timeout:
660          if not silent:
661              time.sleep(0.1)
662              print(("\b\b"+syms[i]+" "), end="")
663              sys.stdout.flush()
664              i = (i+1)%len(syms)
665  
666      if not RNS.Transport.has_path(destination_hash):
667          if silent:
668              print("Path not found")
669          else:
670              print(f"{erase_str}Path not found")
671          RNS.exit(1)
672      else:
673          if silent:
674              print("Establishing link with "+RNS.prettyhexrep(destination_hash))
675          else:
676              print(f"{erase_str}Establishing link with "+RNS.prettyhexrep(destination_hash)+" ", end=es)
677  
678      receiver_identity = RNS.Identity.recall(destination_hash)
679      receiver_destination = RNS.Destination(
680          receiver_identity,
681          RNS.Destination.OUT,
682          RNS.Destination.SINGLE,
683          APP_NAME,
684          "receive"
685      )
686  
687      link = RNS.Link(receiver_destination)
688      while link.status != RNS.Link.ACTIVE and time.time() < estab_timeout:
689          if not silent:
690              time.sleep(0.1)
691              print(("\b\b"+syms[i]+" "), end="")
692              sys.stdout.flush()
693              i = (i+1)%len(syms)
694  
695      if time.time() > estab_timeout:
696          if silent:
697              print("Link establishment with "+RNS.prettyhexrep(destination_hash)+" timed out")
698          else:
699              print(f"{erase_str}Link establishment with "+RNS.prettyhexrep(destination_hash)+" timed out")
700          RNS.exit(1)
701      elif not RNS.Transport.has_path(destination_hash):
702          if silent:
703              print("No path found to "+RNS.prettyhexrep(destination_hash))
704          else:
705              print(f"{erase_str}No path found to "+RNS.prettyhexrep(destination_hash))
706          RNS.exit(1)
707      else:
708          if silent:
709              print("Advertising file resource...")
710          else:
711              print(f"{erase_str}Advertising file resource  ", end=es)
712  
713      link.identify(identity)
714      auto_compress = True
715      if no_compress: auto_compress = False
716      try: resource = RNS.Resource(open(file_path, "rb"), link, metadata=metadata, callback = sender_progress, progress_callback = sender_progress, auto_compress = auto_compress)
717      except Exception as e:
718          print(f"Could not start transfer: {e}")
719          RNS.exit(1)
720  
721      current_resource = resource
722  
723      while resource.status < RNS.Resource.TRANSFERRING:
724          if not silent:
725              time.sleep(0.1)
726              print(("\b\b"+syms[i]+" "), end="")
727              sys.stdout.flush()
728              i = (i+1)%len(syms)
729  
730      resource_started_at = time.time()
731      
732      if resource.status > RNS.Resource.COMPLETE:
733          if silent:
734              print("File was not accepted by "+RNS.prettyhexrep(destination_hash))
735          else:
736              print(f"{erase_str}File was not accepted by "+RNS.prettyhexrep(destination_hash))
737          RNS.exit(1)
738      else:
739          if silent:
740              print("Transferring file...")
741          else:
742              print(f"{erase_str}Transferring file  ", end=es)
743  
744      def progress_update(i, done=False):
745          time.sleep(0.1)
746          prg = current_resource.get_progress()
747          percent = round(prg * 100.0, 1)
748          if show_phy_rates and not resource_done:
749              pss = size_str(phy_speed, "b")
750              phy_str = f" ({pss}ps at physical layer)"
751          else:
752              phy_str = ""
753          es = "  "
754          cs = size_str(int(prg*current_resource.total_size))
755          ts = size_str(current_resource.total_size)
756          ss = size_str(speed, "b")
757          stat_str = f"{percent}% - {cs} of {ts} - {ss}ps{phy_str}"
758          if not done:
759              print(f"{erase_str}Transferring file "+syms[i]+" "+stat_str, end=es)
760          else:
761              print(f"{erase_str}Transfer complete  "+stat_str, end=es)
762          sys.stdout.flush()
763          i = (i+1)%len(syms)
764          return i
765  
766      while not resource_done:
767          if not silent:
768              i = progress_update(i)
769  
770      resource_concluded_at = time.time()
771      transfer_time = resource_concluded_at - resource_started_at
772      speed = current_resource.total_size/transfer_time
773      # phy_speed = phy_got_total/transfer_time
774  
775      if not silent:
776          i = progress_update(i, done=True)
777  
778      if current_resource.status != RNS.Resource.COMPLETE:
779          if silent:
780              print("The transfer failed")
781          else:
782              print(f"{erase_str}The transfer failed")
783          RNS.exit(1)
784      else:
785          if silent:
786              print(str(file_path)+" copied to "+RNS.prettyhexrep(destination_hash))
787          else:
788              print("\n"+str(file_path)+" copied to "+RNS.prettyhexrep(destination_hash))
789          link.teardown()
790          time.sleep(0.25)
791          RNS.exit(0)
792  
793  def main():
794      try:
795          parser = argparse.ArgumentParser(description="Reticulum File Transfer Utility")
796          parser.add_argument("file", nargs="?", default=None, help="file to be transferred", type=str)
797          parser.add_argument("destination", nargs="?", default=None, help="hexadecimal hash of the receiver", type=str)
798          parser.add_argument("--config", metavar="path", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
799          parser.add_argument('-v', '--verbose', action='count', default=0, help="increase verbosity")
800          parser.add_argument('-q', '--quiet', action='count', default=0, help="decrease verbosity")
801          parser.add_argument("-S", '--silent', action='store_true', default=False, help="disable transfer progress output")
802          parser.add_argument("-l", '--listen', action='store_true', default=False, help="listen for incoming transfer requests")
803          parser.add_argument("-C", '--no-compress', action='store_true', default=False, help="disable automatic compression")
804          parser.add_argument("-F", '--allow-fetch', action='store_true', default=False, help="allow authenticated clients to fetch files")
805          parser.add_argument("-f", '--fetch', action='store_true', default=False, help="fetch file from remote listener instead of sending")
806          parser.add_argument("-j", "--jail", metavar="path", action="store", default=None, help="restrict fetch requests to specified path", type=str)
807          parser.add_argument("-s", "--save", metavar="path", action="store", default=None, help="save received files in specified path", type=str)
808          parser.add_argument('-O', '--overwrite', action='store_true', default=False, help="Allow overwriting received files, instead of adding postfix")
809          parser.add_argument("-b", action='store', metavar="seconds", default=-1, help="announce interval, 0 to only announce at startup", type=int)
810          parser.add_argument('-a', metavar="allowed_hash", dest="allowed", action='append', help="allow this identity (or add in ~/.rncp/allowed_identities)", type=str)
811          parser.add_argument('-n', '--no-auth', action='store_true', default=False, help="accept requests from anyone")
812          parser.add_argument('-p', '--print-identity', action='store_true', default=False, help="print identity and destination info and exit")
813          parser.add_argument('-i', metavar="identity", action='store', dest="identity", default=None, help="path to identity to use", type=str)
814          parser.add_argument("-w", action="store", metavar="seconds", type=float, help="sender timeout before giving up", default=RNS.Transport.PATH_REQUEST_TIMEOUT)
815          parser.add_argument('-P', '--phy-rates', action='store_true', default=False, help="display physical layer transfer rates")
816          # parser.add_argument("--limit", action="store", metavar="files", type=float, help="maximum number of files to accept", default=None)
817          parser.add_argument("--version", action="version", version="rncp {version}".format(version=__version__))
818          
819          args = parser.parse_args()
820  
821          if args.listen or args.print_identity:
822              listen(
823                  configdir = args.config,
824                  identitypath = args.identity,
825                  verbosity=args.verbose,
826                  quietness=args.quiet,
827                  allowed = args.allowed,
828                  fetch_allowed = args.allow_fetch,
829                  no_compress = args.no_compress,
830                  jail = args.jail,
831                  save = args.save,
832                  display_identity=args.print_identity,
833                  # limit=args.limit,
834                  disable_auth=args.no_auth,
835                  announce=args.b,
836                  allow_overwrite=args.overwrite,
837              )
838  
839          elif args.fetch:
840              if args.destination != None and args.file != None:
841                  fetch(
842                      configdir = args.config,
843                      identitypath = args.identity,
844                      verbosity = args.verbose,
845                      quietness = args.quiet,
846                      destination = args.destination,
847                      file = args.file,
848                      timeout = args.w,
849                      silent = args.silent,
850                      phy_rates = args.phy_rates,
851                      save = args.save,
852                      allow_overwrite=args.overwrite,
853                  )
854              else:
855                  print("")
856                  parser.print_help()
857                  print("")
858  
859          elif args.destination != None and args.file != None:
860              send(
861                  configdir = args.config,
862                  identitypath = args.identity,
863                  verbosity = args.verbose,
864                  quietness = args.quiet,
865                  destination = args.destination,
866                  file = args.file,
867                  timeout = args.w,
868                  silent = args.silent,
869                  phy_rates = args.phy_rates,
870                  no_compress = args.no_compress,
871              )
872  
873          else:
874              print("")
875              parser.print_help()
876              print("")
877  
878      except KeyboardInterrupt:
879          print("")
880          if resource != None:
881              resource.cancel()
882          if link != None:
883              link.teardown()
884          RNS.exit()
885  
886  def size_str(num, suffix='B'):
887      units = ['','K','M','G','T','P','E','Z']
888      last_unit = 'Y'
889  
890      if suffix == 'b':
891          num *= 8
892          units = ['','K','M','G','T','P','E','Z']
893          last_unit = 'Y'
894  
895      for unit in units:
896          if abs(num) < 1000.0:
897              if unit == "":
898                  return "%.0f %s%s" % (num, unit, suffix)
899              else:
900                  return "%.2f %s%s" % (num, unit, suffix)
901          num /= 1000.0
902  
903      return "%.2f%s%s" % (num, last_unit, suffix)
904  
905  if __name__ == "__main__":
906      main()