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)