#!/usr/bin/env python3 # MIT License # # Copyright (c) 2016-2022 Mark Qvist / unsigned.io # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import RNS import subprocess import argparse import shlex import time import sys import tty import os from RNS._version import __version__ APP_NAME = "rnx" identity = None reticulum = None allow_all = False allowed_identity_hashes = [] def prepare_identity(identity_path): global identity if identity_path == None: identity_path = f"{RNS.Reticulum.identitypath}/{APP_NAME}" if os.path.isfile(identity_path): identity = RNS.Identity.from_file(identity_path) if identity == None: RNS.log("No valid saved identity found, creating new...", RNS.LOG_INFO) identity = RNS.Identity() identity.to_file(identity_path) def listen(configdir, identitypath = None, verbosity = 0, quietness = 0, allowed = [], print_identity = False, disable_auth = None, disable_announce=False): global identity, allow_all, allowed_identity_hashes, reticulum targetloglevel = 3+verbosity-quietness reticulum = RNS.Reticulum(configdir=configdir, loglevel=targetloglevel) prepare_identity(identitypath) destination = RNS.Destination(identity, RNS.Destination.IN, RNS.Destination.SINGLE, APP_NAME, "execute") if print_identity: print(f"Identity : {identity}") print(f"Listening on : {RNS.prettyhexrep(destination.hash)}") exit(0) if disable_auth: allow_all = True else: if allowed != None: for a in allowed: try: dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2 if len(a) != dest_len: raise ValueError(f"Allowed destination length is invalid, must be {dest_len} hexadecimal characters ({dest_len // 2} bytes).") try: destination_hash = bytes.fromhex(a) allowed_identity_hashes.append(destination_hash) except Exception as e: raise ValueError("Invalid destination entered. Check your input.") except Exception as e: print(str(e)) exit(1) if len(allowed_identity_hashes) < 1 and not disable_auth: print("Warning: No allowed identities configured, rncx will not accept any commands!") destination.set_link_established_callback(command_link_established) if not allow_all: destination.register_request_handler( path = "command", response_generator = execute_received_command, allow = RNS.Destination.ALLOW_LIST, allowed_list = allowed_identity_hashes ) else: destination.register_request_handler( path = "command", response_generator = execute_received_command, allow = RNS.Destination.ALLOW_ALL, ) RNS.log(f"rnx listening for commands on {RNS.prettyhexrep(destination.hash)}") if not disable_announce: destination.announce() while True: time.sleep(1) def command_link_established(link): link.set_remote_identified_callback(initiator_identified) link.set_link_closed_callback(command_link_closed) RNS.log(f"Command link {link} established") def command_link_closed(link): RNS.log(f"Command link {link} closed") def initiator_identified(link, identity): global allow_all RNS.log(f"Initiator of link {link} identified as {RNS.prettyhexrep(identity.hash)}") if not allow_all and not identity.hash in allowed_identity_hashes: RNS.log(f"Identity {RNS.prettyhexrep(identity.hash)} not allowed, tearing down link") link.teardown() def execute_received_command(path, data, request_id, remote_identity, requested_at): command = data[0].decode("utf-8") # Command to execute timeout = data[1] # Timeout in seconds o_limit = data[2] # Size limit for stdout e_limit = data[3] # Size limit for stderr stdin = data[4] # Data passed to stdin if remote_identity != None: RNS.log(f"Executing command [{command}] for {RNS.prettyhexrep(remote_identity.hash)}") else: RNS.log(f"Executing command [{command}] for unknown requestor") result = [ False, # 0: Command was executed None, # 1: Return value None, # 2: Stdout None, # 3: Stderr None, # 4: Total stdout length None, # 5: Total stderr length time.time(), # 6: Started None, # 7: Concluded ] try: process = subprocess.Popen(shlex.split(command), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) result[0] = True except Exception as e: result[0] = False return result stdout = b"" stderr = b"" timed_out = False if stdin != None: process.stdin.write(stdin) while True: try: stdout, stderr = process.communicate(timeout=1) if process.poll() != None: break if len(stdout) > 0: print(str(stdout)) sys.stdout.flush() except subprocess.TimeoutExpired: pass if timeout != None and time.time() > result[6]+timeout: RNS.log(f"Command [{command}] timed out and is being killed...") process.terminate() process.wait() if process.poll() != None: stdout, stderr = process.communicate() else: stdout = None stderr = None break if timeout != None and time.time() < result[6]+timeout: result[7] = time.time() # Deliver result result[1] = process.returncode if o_limit != None and len(stdout) > o_limit: if o_limit == 0: result[2] = b"" else: result[2] = stdout[0:o_limit] else: result[2] = stdout if e_limit != None and len(stderr) > e_limit: if e_limit == 0: result[3] = b"" else: result[3] = stderr[0:e_limit] else: result[3] = stderr result[4] = len(stdout) result[5] = len(stderr) if timed_out: RNS.log("Command timed out") return result if remote_identity != None: RNS.log(f"Delivering result of command [{command}] to {RNS.prettyhexrep(remote_identity.hash)}") else: RNS.log(f"Delivering result of command [{command}] to unknown requestor") return result def spin(until=None, msg=None, timeout=None): i = 0 syms = "⢄⢂⢁⡁⡈⡐⡠" if timeout != None: timeout = time.time()+timeout print(f"{msg} ", end=" ") while (timeout == None or time.time() timeout: return False else: return True current_progress = 0.0 stats = [] speed = 0.0 def spin_stat(until=None, timeout=None): global current_progress, response_transfer_size, speed i = 0 syms = "⢄⢂⢁⡁⡈⡐⡠" if timeout != None: timeout = time.time()+timeout while (timeout == None or time.time() timeout: return False else: return True def remote_execution_done(request_receipt): pass def remote_execution_progress(request_receipt): stats_max = 32 global current_progress, response_transfer_size, speed current_progress = request_receipt.progress response_transfer_size = request_receipt.response_transfer_size now = time.time() got = current_progress*response_transfer_size entry = [now, got] stats.append(entry) while len(stats) > stats_max: stats.pop(0) span = now - stats[0][0] if span == 0: speed = 0 else: diff = got - stats[0][1] speed = diff/span link = None listener_destination = None remote_exec_grace = 2.0 def execute(configdir, identitypath = None, verbosity = 0, quietness = 0, detailed = False, mirror = False, noid = False, destination = None, command = None, stdin = None, stdoutl = None, stderrl = None, timeout = RNS.Transport.PATH_REQUEST_TIMEOUT, result_timeout = None, interactive = False): global identity, reticulum, link, listener_destination, remote_exec_grace try: dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2 if len(destination) != dest_len: raise ValueError(f"Allowed destination length is invalid, must be {dest_len} hexadecimal characters ({dest_len // 2} bytes).") try: destination_hash = bytes.fromhex(destination) except Exception as e: raise ValueError("Invalid destination entered. Check your input.") except Exception as e: print(str(e)) exit(241) if reticulum == None: targetloglevel = 3+verbosity-quietness reticulum = RNS.Reticulum(configdir=configdir, loglevel=targetloglevel) if identity == None: prepare_identity(identitypath) if not RNS.Transport.has_path(destination_hash): RNS.Transport.request_path(destination_hash) if not spin(until=lambda: RNS.Transport.has_path(destination_hash), msg=f"Path to {RNS.prettyhexrep(destination_hash)} requested", timeout=timeout): print("Path not found") exit(242) if listener_destination == None: listener_identity = RNS.Identity.recall(destination_hash) listener_destination = RNS.Destination( listener_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, APP_NAME, "execute" ) if link == None or link.status == RNS.Link.CLOSED or link.status == RNS.Link.PENDING: link = RNS.Link(listener_destination) link.did_identify = False if not spin(until=lambda: link.status == RNS.Link.ACTIVE, msg=f"Establishing link with {RNS.prettyhexrep(destination_hash)}", timeout=timeout): print(f"Could not establish link with {RNS.prettyhexrep(destination_hash)}") exit(243) if not noid and not link.did_identify: link.identify(identity) link.did_identify = True if stdin != None: stdin = stdin.encode("utf-8") request_data = [ command.encode("utf-8"), # Command to execute timeout, # Timeout in seconds stdoutl, # Size limit for stdout stderrl, # Size limit for stderr stdin, # Data passed to stdin ] # TODO: Tune rexec_timeout = timeout+link.rtt*4+remote_exec_grace request_receipt = link.request( path="command", data=request_data, response_callback=remote_execution_done, failed_callback=remote_execution_done, progress_callback=remote_execution_progress, timeout=rexec_timeout ) spin( until=lambda:link.status == RNS.Link.CLOSED or (request_receipt.status != RNS.RequestReceipt.FAILED and request_receipt.status != RNS.RequestReceipt.SENT), msg="Sending execution request", timeout=rexec_timeout+0.5 ) if link.status == RNS.Link.CLOSED: print("Could not request remote execution, link was closed") exit(244) if request_receipt.status == RNS.RequestReceipt.FAILED: print("Could not request remote execution") if interactive: return else: exit(244) spin( until=lambda:request_receipt.status != RNS.RequestReceipt.DELIVERED, msg="Command delivered, awaiting result", timeout=timeout ) if request_receipt.status == RNS.RequestReceipt.FAILED: print("No result was received") if interactive: return else: exit(245) spin_stat( until=lambda:request_receipt.status != RNS.RequestReceipt.RECEIVING, timeout=result_timeout ) if request_receipt.status == RNS.RequestReceipt.FAILED: print("Receiving result failed") if interactive: return else: exit(246) if request_receipt.response != None: try: executed = request_receipt.response[0] retval = request_receipt.response[1] stdout = request_receipt.response[2] stderr = request_receipt.response[3] outlen = request_receipt.response[4] errlen = request_receipt.response[5] started = request_receipt.response[6] concluded = request_receipt.response[7] except Exception as e: print("Received invalid result") if interactive: return else: exit(247) if executed: if detailed: if stdout != None and len(stdout) > 0: print(stdout.decode("utf-8"), end="") if stderr != None and len(stderr) > 0: print(stderr.decode("utf-8"), file=sys.stderr, end="") sys.stdout.flush() sys.stderr.flush() print("\n--- End of remote output, rnx done ---") if started != None and concluded != None: cmd_duration = round(concluded - started, 3) print(f"Remote command execution took {cmd_duration} seconds") total_size = request_receipt.response_size if request_receipt.request_size != None: total_size += request_receipt.request_size transfer_duration = round(request_receipt.response_concluded_at - request_receipt.sent_at - cmd_duration, 3) if transfer_duration == 1: tdstr = " in 1 second" elif transfer_duration < 10: tdstr = f" in {transfer_duration} seconds" else: tdstr = f" in {pretty_time(transfer_duration)}" spdstr = f", effective rate {size_str(total_size / transfer_duration, 'b')}ps" print(f"Transferred {size_str(total_size)}{tdstr}{spdstr}") if outlen != None and stdout != None: if len(stdout) < outlen: tstr = f", {len(stdout)} bytes displayed" else: tstr = "" print(f"Remote wrote {outlen} bytes to stdout{tstr}") if errlen != None and stderr != None: if len(stderr) < errlen: tstr = f", {len(stderr)} bytes displayed" else: tstr = "" print(f"Remote wrote {errlen} bytes to stderr{tstr}") else: if stdout != None and len(stdout) > 0: print(stdout.decode("utf-8"), end="") if stderr != None and len(stderr) > 0: print(stderr.decode("utf-8"), file=sys.stderr, end="") if (stdoutl != 0 and len(stdout) < outlen) or (stderrl != 0 and len(stderr) < errlen): sys.stdout.flush() sys.stderr.flush() print("\nOutput truncated before being returned:") if len(stdout) != 0 and len(stdout) < outlen: print(f" stdout truncated to {len(stdout)} bytes") if len(stderr) != 0 and len(stderr) < errlen: print(f" stderr truncated to {len(stderr)} bytes") else: print("Remote could not execute command") if interactive: return else: exit(248) else: print("No response") if interactive: return else: exit(249) try: if not interactive: link.teardown() except Exception as e: pass if not interactive and mirror: if request_receipt.response[1] != None: exit(request_receipt.response[1]) else: exit(240) else: if interactive: if mirror: return request_receipt.response[1] else: return None else: exit(0) def main(): try: parser = argparse.ArgumentParser(description="Reticulum Remote Execution Utility") parser.add_argument("destination", nargs="?", default=None, help="hexadecimal hash of the listener", type=str) parser.add_argument("command", nargs="?", default=None, help="command to be execute", type=str) parser.add_argument("--config", metavar="path", action="store", default=None, help="path to alternative Reticulum config directory", type=str) parser.add_argument('-v', '--verbose', action='count', default=0, help="increase verbosity") parser.add_argument('-q', '--quiet', action='count', default=0, help="decrease verbosity") parser.add_argument('-p', '--print-identity', action='store_true', default=False, help="print identity and destination info and exit") parser.add_argument("-l", '--listen', action='store_true', default=False, help="listen for incoming commands") parser.add_argument('-i', metavar="identity", action='store', dest="identity", default=None, help="path to identity to use", type=str) parser.add_argument("-x", '--interactive', action='store_true', default=False, help="enter interactive mode") parser.add_argument("-b", '--no-announce', action='store_true', default=False, help="don't announce at program start") parser.add_argument('-a', metavar="allowed_hash", dest="allowed", action='append', help="accept from this identity", type=str) parser.add_argument('-n', '--noauth', action='store_true', default=False, help="accept commands from anyone") parser.add_argument('-N', '--noid', action='store_true', default=False, help="don't identify to listener") parser.add_argument("-d", '--detailed', action='store_true', default=False, help="show detailed result output") parser.add_argument("-m", action='store_true', dest="mirror", default=False, help="mirror exit code of remote command") parser.add_argument("-w", action="store", metavar="seconds", type=float, help="connect and request timeout before giving up", default=RNS.Transport.PATH_REQUEST_TIMEOUT) parser.add_argument("-W", action="store", metavar="seconds", type=float, help="max result download time", default=None) parser.add_argument("--stdin", action='store', default=None, help="pass input to stdin", type=str) parser.add_argument("--stdout", action='store', default=None, help="max size in bytes of returned stdout", type=int) parser.add_argument("--stderr", action='store', default=None, help="max size in bytes of returned stderr", type=int) parser.add_argument("--version", action="version", version=f"rnx {__version__}") args = parser.parse_args() if args.listen or args.print_identity: listen( configdir = args.config, identitypath = args.identity, verbosity=args.verbose, quietness=args.quiet, allowed = args.allowed, print_identity=args.print_identity, disable_auth=args.noauth, disable_announce=args.no_announce, ) elif args.destination != None and args.command != None: execute( configdir = args.config, identitypath = args.identity, verbosity = args.verbose, quietness = args.quiet, detailed = args.detailed, mirror = args.mirror, noid = args.noid, destination = args.destination, command = args.command, stdin = args.stdin, stdoutl = args.stdout, stderrl = args.stderr, timeout = args.w, result_timeout = args.W, interactive = args.interactive, ) if args.destination != None and args.interactive: # command_history_max = 5000 # command_history = [] # command_current = "" # history_idx = 0 # tty.setcbreak(sys.stdin.fileno()) code = None while True: try: cstr = str(code) if code and code != 0 else "" prompt = f"{cstr}> " print(prompt,end="") # cmdbuf = b"" # while True: # ch = sys.stdin.read(1) # cmdbuf += ch.encode("utf-8") # print("\r"+prompt+cmdbuf.decode("utf-8"), end="") command = input() if command.lower() == "exit" or command.lower() == "quit": exit(0) except KeyboardInterrupt: exit(0) except EOFError: exit(0) if command.lower() == "clear": print('\033c', end='') # command_history.append(command) # while len(command_history) > command_history_max: # command_history.pop(0) else: code = execute( configdir = args.config, identitypath = args.identity, verbosity = args.verbose, quietness = args.quiet, detailed = args.detailed, mirror = args.mirror, noid = args.noid, destination = args.destination, command = command, stdin = None, stdoutl = args.stdout, stderrl = args.stderr, timeout = args.w, result_timeout = args.W, interactive = True, ) else: print("") parser.print_help() print("") except KeyboardInterrupt: # tty.setnocbreak(sys.stdin.fileno()) print("") if link != None: link.teardown() exit() def size_str(num, suffix='B'): units = ['','K','M','G','T','P','E','Z'] last_unit = 'Y' if suffix == 'b': num *= 8 units = ['','K','M','G','T','P','E','Z'] last_unit = 'Y' for unit in units: if abs(num) < 1000.0: if unit == "": return f"{num:.0f} {unit}{suffix}" else: return f"{num:.2f} {unit}{suffix}" num /= 1000.0 return f"{num:.2f}{last_unit}{suffix}" def pretty_time(time, verbose=False): days = int(time // (24 * 3600)) time = time % (24 * 3600) hours = int(time // 3600) time %= 3600 minutes = int(time // 60) time %= 60 seconds = round(time, 2) ss = "" if seconds == 1 else "s" sm = "" if minutes == 1 else "s" sh = "" if hours == 1 else "s" sd = "" if days == 1 else "s" components = [] if days > 0: components.append(f"{days} day{sd}" if verbose else f"{days}d") if hours > 0: components.append(f"{hours} hour{sh}" if verbose else f"{hours}h") if minutes > 0: components.append(f"{minutes} minute{sm}" if verbose else f"{minutes}m") if seconds > 0: components.append(f"{seconds} second{ss}" if verbose else f"{seconds}s") i = 0 tstr = "" for c in components: i += 1 if i == 1: pass elif i < len(components): tstr += ", " elif i == len(components): tstr += " and " tstr += c return tstr if __name__ == "__main__": main()