########################################################## # This RNS example demonstrates a simple filetransfer # # server and client program. The server will serve a # # directory of files, and the clients can list and # # download files from the server. # # # # Please note that using RNS Resources for large file # # transfers is not recommended, since compression, # # encryption and hashmap sequencing can take a long time # # on systems with slow CPUs, which will probably result # # in the client timing out before the resource sender # # can complete preparing the resource. # # # # If you need to transfer large files, use the Bundle # # class instead, which will automatically slice the data # # into chunks suitable for packing as a Resource. # ########################################################## import os import sys import time import threading import argparse import RNS import RNS.vendor.umsgpack as umsgpack # Let's define an app name. We'll use this for all # destinations we create. Since this echo example # is part of a range of example utilities, we'll put # them all within the app namespace "example_utilities" APP_NAME = "example_utilities" # We'll also define a default timeout, in seconds APP_TIMEOUT = 45.0 ########################################################## #### Server Part ######################################### ########################################################## serve_path = None # This initialisation is executed when the users chooses # to run as a server def server(configpath, path): # We must first initialise Reticulum reticulum = RNS.Reticulum(configpath) # Randomly create a new identity for our file server server_identity = RNS.Identity() global serve_path serve_path = path # We create a destination that clients can connect to. We # want clients to create links to this destination, so we # need to create a "single" destination type. server_destination = RNS.Destination( server_identity, RNS.Destination.IN, RNS.Destination.SINGLE, APP_NAME, "filetransfer", "server" ) # We configure a function that will get called every time # a new client creates a link to this destination. server_destination.set_link_established_callback(client_connected) # Everything's ready! # Let's Wait for client requests or user input announceLoop(server_destination) def announceLoop(destination): # Let the user know that everything is ready RNS.log(f"File server {RNS.prettyhexrep(destination.hash)} running") RNS.log("Hit enter to manually send an announce (Ctrl-C to quit)") # We enter a loop that runs until the users exits. # If the user hits enter, we will announce our server # destination on the network, which will let clients # know how to create messages directed towards it. while True: entered = input() destination.announce() RNS.log(f"Sent announce from {RNS.prettyhexrep(destination.hash)}") # Here's a convenience function for listing all files # in our served directory def list_files(): # We add all entries from the directory that are # actual files, and does not start with "." global serve_path return [file for file in os.listdir(serve_path) if os.path.isfile(os.path.join(serve_path, file)) and file[:1] != "."] # When a client establishes a link to our server # destination, this function will be called with # a reference to the link. We then send the client # a list of files hosted on the server. def client_connected(link): # Check if the served directory still exists if os.path.isdir(serve_path): RNS.log("Client connected, sending file list...") link.set_link_closed_callback(client_disconnected) # We pack a list of files for sending in a packet data = umsgpack.packb(list_files()) # Check the size of the packed data if len(data) <= RNS.Link.MDU: # If it fits in one packet, we will just # send it as a single packet over the link. list_packet = RNS.Packet(link, data) list_receipt = list_packet.send() list_receipt.set_timeout(APP_TIMEOUT) list_receipt.set_delivery_callback(list_delivered) list_receipt.set_timeout_callback(list_timeout) else: RNS.log("Too many files in served directory!", RNS.LOG_ERROR) RNS.log("You should implement a function to split the filelist over multiple packets.", RNS.LOG_ERROR) RNS.log("Hint: The client already supports it :)", RNS.LOG_ERROR) # After this, we're just going to keep the link # open until the client requests a file. We'll # configure a function that get's called when # the client sends a packet with a file request. link.set_packet_callback(client_request) else: RNS.log("Client connected, but served path no longer exists!", RNS.LOG_ERROR) link.teardown() def client_disconnected(link): RNS.log("Client disconnected") def client_request(message, packet): global serve_path try: filename = message.decode("utf-8") except Exception as e: filename = None if filename in list_files(): try: # If we have the requested file, we'll # read it and pack it as a resource RNS.log(f"Client requested \"{filename}\"") file = open(os.path.join(serve_path, filename), "rb") file_resource = RNS.Resource( file, packet.link, callback=resource_sending_concluded ) file_resource.filename = filename except Exception as e: # If somethign went wrong, we close # the link RNS.log(f"Error while reading file \"{filename}\"", RNS.LOG_ERROR) packet.link.teardown() raise e else: # If we don't have it, we close the link RNS.log("Client requested an unknown file") packet.link.teardown() # This function is called on the server when a # resource transfer concludes. def resource_sending_concluded(resource): if hasattr(resource, "filename"): name = resource.filename else: name = "resource" if resource.status == RNS.Resource.COMPLETE: RNS.log(f"Done sending \"{name}\" to client") elif resource.status == RNS.Resource.FAILED: RNS.log(f"Sending \"{name}\" to client failed") def list_delivered(receipt): RNS.log("The file list was received by the client") def list_timeout(receipt): RNS.log("Sending list to client timed out, closing this link") link = receipt.destination link.teardown() ########################################################## #### Client Part ######################################### ########################################################## # We store a global list of files available on the server server_files = [] # A reference to the server link server_link = None # And a reference to the current download current_download = None current_filename = None # Variables to store download statistics download_started = 0 download_finished = 0 download_time = 0 transfer_size = 0 file_size = 0 # This initialisation is executed when the users chooses # to run as a client def client(destination_hexhash, configpath): # We need a binary representation of the destination # hash that was entered on the command line try: dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2 if len(destination_hexhash) != dest_len: raise ValueError( f"Destination length is invalid, must be {dest_len} hexadecimal characters ({dest_len // 2} bytes)." ) destination_hash = bytes.fromhex(destination_hexhash) except: RNS.log("Invalid destination entered. Check your input!\n") exit() # We must first initialise Reticulum reticulum = RNS.Reticulum(configpath) # Check if we know a path to the destination if not RNS.Transport.has_path(destination_hash): RNS.log("Destination is not yet known. Requesting path and waiting for announce to arrive...") RNS.Transport.request_path(destination_hash) while not RNS.Transport.has_path(destination_hash): time.sleep(0.1) # Recall the server identity server_identity = RNS.Identity.recall(destination_hash) # Inform the user that we'll begin connecting RNS.log("Establishing link with server...") # When the server identity is known, we set # up a destination server_destination = RNS.Destination( server_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, APP_NAME, "filetransfer", "server" ) # We also want to automatically prove incoming packets server_destination.set_proof_strategy(RNS.Destination.PROVE_ALL) # And create a link link = RNS.Link(server_destination) # We expect any normal data packets on the link # to contain a list of served files, so we set # a callback accordingly link.set_packet_callback(filelist_received) # We'll also set up functions to inform the # user when the link is established or closed link.set_link_established_callback(link_established) link.set_link_closed_callback(link_closed) # And set the link to automatically begin # downloading advertised resources link.set_resource_strategy(RNS.Link.ACCEPT_ALL) link.set_resource_started_callback(download_began) link.set_resource_concluded_callback(download_concluded) menu() # Requests the specified file from the server def download(filename): global server_link, menu_mode, current_filename, transfer_size, download_started current_filename = filename download_started = 0 transfer_size = 0 # We just create a packet containing the # requested filename, and send it down the # link. We also specify we don't need a # packet receipt. request_packet = RNS.Packet(server_link, filename.encode("utf-8"), create_receipt=False) request_packet.send() print("") print(f"Requested \"{filename}\" from server, waiting for download to begin...") menu_mode = "download_started" # This function runs a simple menu for the user # to select which files to download, or quit menu_mode = None def menu(): global server_files, server_link # Wait until we have a filelist while len(server_files) == 0: time.sleep(0.1) RNS.log("Ready!") time.sleep(0.5) global menu_mode menu_mode = "main" should_quit = False while (not should_quit): print_menu() while not menu_mode == "main": # Wait time.sleep(0.25) user_input = input() if user_input == "q" or user_input == "quit" or user_input == "exit": should_quit = True print("") else: if user_input in server_files: download(user_input) else: try: if 0 <= int(user_input) < len(server_files): download(server_files[int(user_input)]) except: pass if should_quit: server_link.teardown() # Prints out menus or screens for the # various states of the client program. # It's simple and quite uninteresting. # I won't go into detail here. Just # strings basically. def print_menu(): global menu_mode, download_time, download_started, download_finished, transfer_size, file_size if menu_mode == "main": clear_screen() print_filelist() print("") print("Select a file to download by entering name or number, or q to quit") print(("> "), end=' ') elif menu_mode == "download_started": download_began = time.time() while menu_mode == "download_started": time.sleep(0.1) if time.time() > download_began+APP_TIMEOUT: print("The download timed out") time.sleep(1) server_link.teardown() if menu_mode == "downloading": print("Download started") print("") while menu_mode == "downloading": global current_download percent = round(current_download.get_progress() * 100.0, 1) print(f'\rProgress: {percent} % ', end=' ') sys.stdout.flush() time.sleep(0.1) if menu_mode == "save_error": print(("\rProgress: 100.0 %"), end=' ') sys.stdout.flush() print("") print("Could not write downloaded file to disk") current_download.status = RNS.Resource.FAILED menu_mode = "download_concluded" if menu_mode == "download_concluded": if current_download.status == RNS.Resource.COMPLETE: print(("\rProgress: 100.0 %"), end=' ') sys.stdout.flush() # Print statistics hours, rem = divmod(download_time, 3600) minutes, seconds = divmod(rem, 60) timestring = f"{int(hours):0>2}:{int(minutes):0>2}:{seconds:05.2f}" print("") print("") print("--- Statistics -----") print(f"\tTime taken : {timestring}") print(f"\tFile size : {size_str(file_size)}") print(f"\tData transferred : {size_str(transfer_size)}") print(f"\tEffective rate : {size_str(file_size / download_time, suffix='b')}/s") print(f"\tTransfer rate : {size_str(transfer_size / download_time, suffix='b')}/s") print("") print("The download completed! Press enter to return to the menu.") print("") input() else: print("") print("The download failed! Press enter to return to the menu.") input() current_download = None menu_mode = "main" print_menu() # This function prints out a list of files # on the connected server. def print_filelist(): global server_files print("Files on server:") for index,file in enumerate(server_files): print(f"\t({index})\t{file}") def filelist_received(filelist_data, packet): global server_files, menu_mode try: # Unpack the list and extend our # local list of available files filelist = umsgpack.unpackb(filelist_data) for file in filelist: if not file in server_files: server_files.append(file) # If the menu is already visible, # we'll update it with what was # just received if menu_mode == "main": print_menu() except: RNS.log("Invalid file list data received, closing link") packet.link.teardown() # This function is called when a link # has been established with the server def link_established(link): # We store a reference to the link # instance for later use global server_link server_link = link # Inform the user that the server is # connected RNS.log("Link established with server") RNS.log("Waiting for filelist...") # And set up a small job to check for # a potential timeout in receiving the # file list thread = threading.Thread(target=filelist_timeout_job, daemon=True) thread.start() # This job just sleeps for the specified # time, and then checks if the file list # was received. If not, the program will # exit. def filelist_timeout_job(): time.sleep(APP_TIMEOUT) global server_files if len(server_files) == 0: RNS.log("Timed out waiting for filelist, exiting") os._exit(0) # When a link is closed, we'll inform the # user, and exit the program def link_closed(link): if link.teardown_reason == RNS.Link.TIMEOUT: RNS.log("The link timed out, exiting now") elif link.teardown_reason == RNS.Link.DESTINATION_CLOSED: RNS.log("The link was closed by the server, exiting now") else: RNS.log("Link closed, exiting now") RNS.Reticulum.exit_handler() time.sleep(1.5) os._exit(0) # When RNS detects that the download has # started, we'll update our menu state # so the user can be shown a progress of # the download. def download_began(resource): global menu_mode, current_download, download_started, transfer_size, file_size current_download = resource if download_started == 0: download_started = time.time() transfer_size += resource.size file_size = resource.total_size menu_mode = "downloading" # When the download concludes, successfully # or not, we'll update our menu state and # inform the user about how it all went. def download_concluded(resource): global menu_mode, current_filename, download_started, download_finished, download_time download_finished = time.time() download_time = download_finished - download_started saved_filename = current_filename if resource.status == RNS.Resource.COMPLETE: counter = 0 while os.path.isfile(saved_filename): counter += 1 saved_filename = f"{current_filename}.{counter}" try: file = open(saved_filename, "wb") file.write(resource.data.read()) file.close() menu_mode = "download_concluded" except: menu_mode = "save_error" else: menu_mode = "download_concluded" # A convenience function for printing a human- # readable file size def size_str(num, suffix='B'): units = ['','Ki','Mi','Gi','Ti','Pi','Ei','Zi'] last_unit = 'Yi' if suffix == 'b': num *= 8 units = ['','K','M','G','T','P','E','Z'] last_unit = 'Y' for unit in units: if abs(num) < 1024.0: return f"{num:3.2f} {unit}{suffix}" num /= 1024.0 return f"{num:.2f} {last_unit}{suffix}" # A convenience function for clearing the screen def clear_screen(): os.system('cls' if os.name=='nt' else 'clear') ########################################################## #### Program Startup ##################################### ########################################################## # This part of the program runs at startup, # and parses input of from the user, and then # starts up the desired program mode. if __name__ == "__main__": try: parser = argparse.ArgumentParser( description="Simple file transfer server and client utility" ) parser.add_argument( "-s", "--serve", action="store", metavar="dir", help="serve a directory of files to clients" ) parser.add_argument( "--config", action="store", default=None, help="path to alternative Reticulum config directory", type=str ) parser.add_argument( "destination", nargs="?", default=None, help="hexadecimal hash of the server destination", type=str ) args = parser.parse_args() if args.config: configarg = args.config else: configarg = None if args.serve: if os.path.isdir(args.serve): server(configarg, args.serve) else: RNS.log("The specified directory does not exist") else: if (args.destination == None): print("") parser.print_help() print("") else: client(args.destination, configarg) except KeyboardInterrupt: print("") exit()