/ Examples / Filetransfer.py
Filetransfer.py
  1  ##########################################################
  2  # This RNS example demonstrates a simple filetransfer    #
  3  # server and client program. The server will serve a     #
  4  # directory of files, and the clients can list and       #
  5  # download files from the server.                        #
  6  #                                                        #
  7  # Please note that using RNS Resources for large file    #
  8  # transfers is not recommended, since compression,       #
  9  # encryption and hashmap sequencing can take a long time #
 10  # on systems with slow CPUs, which will probably result  #
 11  # in the client timing out before the resource sender    #
 12  # can complete preparing the resource.                   #
 13  #                                                        #
 14  # If you need to transfer large files, use the Bundle    #
 15  # class instead, which will automatically slice the data #
 16  # into chunks suitable for packing as a Resource.        #
 17  ##########################################################
 18  
 19  import os
 20  import sys
 21  import time
 22  import threading
 23  import argparse
 24  import RNS
 25  import RNS.vendor.umsgpack as umsgpack
 26  
 27  # Let's define an app name. We'll use this for all
 28  # destinations we create. Since this echo example
 29  # is part of a range of example utilities, we'll put
 30  # them all within the app namespace "example_utilities"
 31  APP_NAME = "example_utilities"
 32  
 33  # We'll also define a default timeout, in seconds
 34  APP_TIMEOUT = 45.0
 35  
 36  ##########################################################
 37  #### Server Part #########################################
 38  ##########################################################
 39  
 40  serve_path = None
 41  
 42  # This initialisation is executed when the users chooses
 43  # to run as a server
 44  def server(configpath, path):
 45      # We must first initialise Reticulum
 46      reticulum = RNS.Reticulum(configpath)
 47      
 48      # Randomly create a new identity for our file server
 49      server_identity = RNS.Identity()
 50  
 51      global serve_path
 52      serve_path = path
 53  
 54      # We create a destination that clients can connect to. We
 55      # want clients to create links to this destination, so we
 56      # need to create a "single" destination type.
 57      server_destination = RNS.Destination(
 58          server_identity,
 59          RNS.Destination.IN,
 60          RNS.Destination.SINGLE,
 61          APP_NAME,
 62          "filetransfer",
 63          "server"
 64      )
 65  
 66      # We configure a function that will get called every time
 67      # a new client creates a link to this destination.
 68      server_destination.set_link_established_callback(client_connected)
 69  
 70      # Everything's ready!
 71      # Let's Wait for client requests or user input
 72      announceLoop(server_destination)
 73  
 74  def announceLoop(destination):
 75      # Let the user know that everything is ready
 76      RNS.log("File server "+RNS.prettyhexrep(destination.hash)+" running")
 77      RNS.log("Hit enter to manually send an announce (Ctrl-C to quit)")
 78  
 79      # We enter a loop that runs until the users exits.
 80      # If the user hits enter, we will announce our server
 81      # destination on the network, which will let clients
 82      # know how to create messages directed towards it.
 83      while True:
 84          entered = input()
 85          destination.announce()
 86          RNS.log("Sent announce from "+RNS.prettyhexrep(destination.hash))
 87  
 88  # Here's a convenience function for listing all files
 89  # in our served directory
 90  def list_files():
 91      # We add all entries from the directory that are
 92      # actual files, and does not start with "."
 93      global serve_path
 94      return [file for file in os.listdir(serve_path) if os.path.isfile(os.path.join(serve_path, file)) and file[:1] != "."]
 95  
 96  # When a client establishes a link to our server
 97  # destination, this function will be called with
 98  # a reference to the link. We then send the client
 99  # a list of files hosted on the server.
100  def client_connected(link):
101      # Check if the served directory still exists
102      if os.path.isdir(serve_path):
103          RNS.log("Client connected, sending file list...")
104  
105          link.set_link_closed_callback(client_disconnected)
106  
107          # We pack a list of files for sending in a packet
108          data = umsgpack.packb(list_files())
109  
110          # Check the size of the packed data
111          if len(data) <= RNS.Link.MDU:
112              # If it fits in one packet, we will just
113              # send it as a single packet over the link.
114              list_packet = RNS.Packet(link, data)
115              list_receipt = list_packet.send()
116              list_receipt.set_timeout(APP_TIMEOUT)
117              list_receipt.set_delivery_callback(list_delivered)
118              list_receipt.set_timeout_callback(list_timeout)
119          else:
120              RNS.log("Too many files in served directory!", RNS.LOG_ERROR)
121              RNS.log("You should implement a function to split the filelist over multiple packets.", RNS.LOG_ERROR)
122              RNS.log("Hint: The client already supports it :)", RNS.LOG_ERROR)
123              
124          # After this, we're just going to keep the link
125          # open until the client requests a file. We'll
126          # configure a function that get's called when
127          # the client sends a packet with a file request.
128          link.set_packet_callback(client_request)
129      else:
130          RNS.log("Client connected, but served path no longer exists!", RNS.LOG_ERROR)
131          link.teardown()
132  
133  def client_disconnected(link):
134      RNS.log("Client disconnected")
135  
136  def client_request(message, packet):
137      global serve_path
138  
139      try:
140          filename = message.decode("utf-8")
141      except Exception as e:
142          filename = None
143  
144      if filename in list_files():
145          try:
146              # If we have the requested file, we'll
147              # read it and pack it as a resource
148              RNS.log("Client requested \""+filename+"\"")
149              file = open(os.path.join(serve_path, filename), "rb")
150              
151              file_resource = RNS.Resource(
152                  file,
153                  packet.link,
154                  callback=resource_sending_concluded
155              )
156  
157              file_resource.filename = filename
158          except Exception as e:
159              # If somethign went wrong, we close
160              # the link
161              RNS.log("Error while reading file \""+filename+"\"", RNS.LOG_ERROR)
162              packet.link.teardown()
163              raise e
164      else:
165          # If we don't have it, we close the link
166          RNS.log("Client requested an unknown file")
167          packet.link.teardown()
168  
169  # This function is called on the server when a
170  # resource transfer concludes.
171  def resource_sending_concluded(resource):
172      if hasattr(resource, "filename"):
173          name = resource.filename
174      else:
175          name = "resource"
176  
177      if resource.status == RNS.Resource.COMPLETE:
178          RNS.log("Done sending \""+name+"\" to client")
179      elif resource.status == RNS.Resource.FAILED:
180          RNS.log("Sending \""+name+"\" to client failed")
181  
182  def list_delivered(receipt):
183      RNS.log("The file list was received by the client")
184  
185  def list_timeout(receipt):
186      RNS.log("Sending list to client timed out, closing this link")
187      link = receipt.destination
188      link.teardown()
189  
190  ##########################################################
191  #### Client Part #########################################
192  ##########################################################
193  
194  # We store a global list of files available on the server
195  server_files      = []
196  
197  # A reference to the server link
198  server_link       = None
199  
200  # And a reference to the current download
201  current_download  = None
202  current_filename  = None
203  
204  # Variables to store download statistics
205  download_started  = 0
206  download_finished = 0
207  download_time     = 0
208  transfer_size     = 0
209  file_size         = 0
210  
211  
212  # This initialisation is executed when the users chooses
213  # to run as a client
214  def client(destination_hexhash, configpath):
215      # We need a binary representation of the destination
216      # hash that was entered on the command line
217      try:
218          dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2
219          if len(destination_hexhash) != dest_len:
220              raise ValueError(
221                  "Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2)
222              )
223              
224          destination_hash = bytes.fromhex(destination_hexhash)
225      except:
226          RNS.log("Invalid destination entered. Check your input!\n")
227          sys.exit(0)
228  
229      # We must first initialise Reticulum
230      reticulum = RNS.Reticulum(configpath)
231  
232  
233      # Check if we know a path to the destination
234      if not RNS.Transport.has_path(destination_hash):
235          RNS.log("Destination is not yet known. Requesting path and waiting for announce to arrive...")
236          RNS.Transport.request_path(destination_hash)
237          while not RNS.Transport.has_path(destination_hash):
238              time.sleep(0.1)
239  
240      # Recall the server identity
241      server_identity = RNS.Identity.recall(destination_hash)
242  
243      # Inform the user that we'll begin connecting
244      RNS.log("Establishing link with server...")
245  
246      # When the server identity is known, we set
247      # up a destination
248      server_destination = RNS.Destination(
249          server_identity,
250          RNS.Destination.OUT,
251          RNS.Destination.SINGLE,
252          APP_NAME,
253          "filetransfer",
254          "server"
255      )
256  
257      # We also want to automatically prove incoming packets
258      server_destination.set_proof_strategy(RNS.Destination.PROVE_ALL)
259  
260      # And create a link
261      link = RNS.Link(server_destination)
262  
263      # We expect any normal data packets on the link
264      # to contain a list of served files, so we set
265      # a callback accordingly
266      link.set_packet_callback(filelist_received)
267  
268      # We'll also set up functions to inform the
269      # user when the link is established or closed
270      link.set_link_established_callback(link_established)
271      link.set_link_closed_callback(link_closed)
272  
273      # And set the link to automatically begin
274      # downloading advertised resources
275      link.set_resource_strategy(RNS.Link.ACCEPT_ALL)
276      link.set_resource_started_callback(download_began)
277      link.set_resource_concluded_callback(download_concluded)
278  
279      menu()
280  
281  # Requests the specified file from the server
282  def download(filename):
283      global server_link, menu_mode, current_filename, transfer_size, download_started
284      current_filename = filename
285      download_started = 0
286      transfer_size    = 0
287  
288      # We just create a packet containing the
289      # requested filename, and send it down the
290      # link. We also specify we don't need a
291      # packet receipt.
292      request_packet = RNS.Packet(server_link, filename.encode("utf-8"), create_receipt=False)
293      request_packet.send()
294      
295      print("")
296      print(("Requested \""+filename+"\" from server, waiting for download to begin..."))
297      menu_mode = "download_started"
298  
299  # This function runs a simple menu for the user
300  # to select which files to download, or quit
301  menu_mode = None
302  def menu():
303      global server_files, server_link
304      # Wait until we have a filelist
305      while len(server_files) == 0:
306          time.sleep(0.1)
307      RNS.log("Ready!")
308      time.sleep(0.5)
309  
310      global menu_mode
311      menu_mode = "main"
312      should_quit = False
313      while (not should_quit):
314          print_menu()
315  
316          while not menu_mode == "main":
317              # Wait
318              time.sleep(0.25)
319  
320          user_input = input()
321          if user_input == "q" or user_input == "quit" or user_input == "exit":
322              should_quit = True
323              print("")
324          else:
325              if user_input in server_files:
326                  download(user_input)
327              else:
328                  try:
329                      if 0 <= int(user_input) < len(server_files):
330                          download(server_files[int(user_input)])
331                  except:
332                      pass
333  
334      if should_quit:
335          server_link.teardown()
336  
337  # Prints out menus or screens for the
338  # various states of the client program.
339  # It's simple and quite uninteresting.
340  # I won't go into detail here. Just
341  # strings basically.
342  def print_menu():
343      global menu_mode, download_time, download_started, download_finished, transfer_size, file_size
344  
345      if menu_mode == "main":
346          clear_screen()
347          print_filelist()
348          print("")
349          print("Select a file to download by entering name or number, or q to quit")
350          print(("> "), end=' ')
351      elif menu_mode == "download_started":
352          download_began = time.time()
353          while menu_mode == "download_started":
354              time.sleep(0.1)
355              if time.time() > download_began+APP_TIMEOUT:
356                  print("The download timed out")
357                  time.sleep(1)
358                  server_link.teardown()
359  
360      if menu_mode == "downloading":
361          print("Download started")
362          print("")
363          while menu_mode == "downloading":
364              global current_download
365              percent = round(current_download.get_progress() * 100.0, 1)
366              print(("\rProgress: "+str(percent)+" %   "), end=' ')
367              sys.stdout.flush()
368              time.sleep(0.1)
369  
370      if menu_mode == "save_error":
371          print(("\rProgress: 100.0 %"), end=' ')
372          sys.stdout.flush()
373          print("")
374          print("Could not write downloaded file to disk")
375          current_download.status = RNS.Resource.FAILED
376          menu_mode = "download_concluded"
377  
378      if menu_mode == "download_concluded":
379          if current_download.status == RNS.Resource.COMPLETE:
380              print(("\rProgress: 100.0 %"), end=' ')
381              sys.stdout.flush()
382  
383              # Print statistics
384              hours, rem = divmod(download_time, 3600)
385              minutes, seconds = divmod(rem, 60)
386              timestring = "{:0>2}:{:0>2}:{:05.2f}".format(int(hours),int(minutes),seconds)
387              print("")
388              print("")
389              print("--- Statistics -----")
390              print("\tTime taken       : "+timestring)
391              print("\tFile size        : "+size_str(file_size))
392              print("\tData transferred : "+size_str(transfer_size))
393              print("\tEffective rate   : "+size_str(file_size/download_time, suffix='b')+"/s")
394              print("\tTransfer rate    : "+size_str(transfer_size/download_time, suffix='b')+"/s")
395              print("")
396              print("The download completed! Press enter to return to the menu.")
397              print("")
398              input()
399  
400          else:
401              print("")
402              print("The download failed! Press enter to return to the menu.")
403              input()
404  
405          current_download = None
406          menu_mode = "main"
407          print_menu()
408  
409  # This function prints out a list of files
410  # on the connected server.
411  def print_filelist():
412      global server_files
413      print("Files on server:")
414      for index,file in enumerate(server_files):
415          print("\t("+str(index)+")\t"+file)
416  
417  def filelist_received(filelist_data, packet):
418      global server_files, menu_mode
419      try:
420          # Unpack the list and extend our
421          # local list of available files
422          filelist = umsgpack.unpackb(filelist_data)
423          for file in filelist:
424              if not file in server_files:
425                  server_files.append(file)
426  
427          # If the menu is already visible,
428          # we'll update it with what was
429          # just received
430          if menu_mode == "main":
431              print_menu()
432      except:
433          RNS.log("Invalid file list data received, closing link")
434          packet.link.teardown()
435  
436  # This function is called when a link
437  # has been established with the server
438  def link_established(link):
439      # We store a reference to the link
440      # instance for later use
441      global server_link
442      server_link = link
443  
444      # Inform the user that the server is
445      # connected
446      RNS.log("Link established with server")
447      RNS.log("Waiting for filelist...")
448  
449      # And set up a small job to check for
450      # a potential timeout in receiving the
451      # file list
452      thread = threading.Thread(target=filelist_timeout_job, daemon=True)
453      thread.start()
454  
455  # This job just sleeps for the specified
456  # time, and then checks if the file list
457  # was received. If not, the program will
458  # exit.
459  def filelist_timeout_job():
460      time.sleep(APP_TIMEOUT)
461  
462      global server_files
463      if len(server_files) == 0:
464          RNS.log("Timed out waiting for filelist, exiting")
465          sys.exit(0)
466  
467  
468  # When a link is closed, we'll inform the
469  # user, and exit the program
470  def link_closed(link):
471      if link.teardown_reason == RNS.Link.TIMEOUT:
472          RNS.log("The link timed out, exiting now")
473      elif link.teardown_reason == RNS.Link.DESTINATION_CLOSED:
474          RNS.log("The link was closed by the server, exiting now")
475      else:
476          RNS.log("Link closed, exiting now")
477      
478      time.sleep(1.5)
479      sys.exit(0)
480  
481  # When RNS detects that the download has
482  # started, we'll update our menu state
483  # so the user can be shown a progress of
484  # the download.
485  def download_began(resource):
486      global menu_mode, current_download, download_started, transfer_size, file_size
487      current_download = resource
488      
489      if download_started == 0:
490          download_started = time.time()
491      
492      transfer_size += resource.size
493      file_size = resource.total_size
494      
495      menu_mode = "downloading"
496  
497  # When the download concludes, successfully
498  # or not, we'll update our menu state and 
499  # inform the user about how it all went.
500  def download_concluded(resource):
501      global menu_mode, current_filename, download_started, download_finished, download_time
502      download_finished = time.time()
503      download_time = download_finished - download_started
504  
505      saved_filename = current_filename
506  
507      if resource.status == RNS.Resource.COMPLETE:
508          counter = 0
509          while os.path.isfile(saved_filename):
510              counter += 1
511              saved_filename = current_filename+"."+str(counter)
512  
513          try:
514              file = open(saved_filename, "wb")
515              file.write(resource.data.read())
516              file.close()
517              menu_mode = "download_concluded"
518          except:
519              menu_mode = "save_error"
520      else:
521          menu_mode = "download_concluded"
522  
523  # A convenience function for printing a human-
524  # readable file size
525  def size_str(num, suffix='B'):
526      units = ['','Ki','Mi','Gi','Ti','Pi','Ei','Zi']
527      last_unit = 'Yi'
528  
529      if suffix == 'b':
530          num *= 8
531          units = ['','K','M','G','T','P','E','Z']
532          last_unit = 'Y'
533  
534      for unit in units:
535          if abs(num) < 1024.0:
536              return "%3.2f %s%s" % (num, unit, suffix)
537          num /= 1024.0
538      return "%.2f %s%s" % (num, last_unit, suffix)
539  
540  # A convenience function for clearing the screen
541  def clear_screen():
542      os.system('cls' if os.name=='nt' else 'clear')
543  
544  ##########################################################
545  #### Program Startup #####################################
546  ##########################################################
547  
548  # This part of the program runs at startup,
549  # and parses input of from the user, and then
550  # starts up the desired program mode.
551  if __name__ == "__main__":
552      try:
553          parser = argparse.ArgumentParser(
554              description="Simple file transfer server and client utility"
555          )
556  
557          parser.add_argument(
558              "-s",
559              "--serve",
560              action="store",
561              metavar="dir",
562              help="serve a directory of files to clients"
563          )
564  
565          parser.add_argument(
566              "--config",
567              action="store",
568              default=None,
569              help="path to alternative Reticulum config directory",
570              type=str
571          )
572  
573          parser.add_argument(
574              "destination",
575              nargs="?",
576              default=None,
577              help="hexadecimal hash of the server destination",
578              type=str
579          )
580  
581          args = parser.parse_args()
582  
583          if args.config:
584              configarg = args.config
585          else:
586              configarg = None
587  
588          if args.serve:
589              if os.path.isdir(args.serve):
590                  server(configarg, args.serve)
591              else:
592                  RNS.log("The specified directory does not exist")
593          else:
594              if (args.destination == None):
595                  print("")
596                  parser.print_help()
597                  print("")
598              else:
599                  client(args.destination, configarg)
600  
601      except KeyboardInterrupt:
602          print("")
603          sys.exit(0)