From 417ac9f8da9b3522ab0a077834ddd27c268cb401 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 24 May 2022 20:14:43 +0200 Subject: [PATCH] Added rnx remote command utility --- RNS/Utilities/rnx.py | 680 ++++++++++++++++++++++++++++++++++++++++++ docs/source/using.rst | 2 +- setup.py | 1 + 3 files changed, 682 insertions(+), 1 deletion(-) create mode 100644 RNS/Utilities/rnx.py diff --git a/RNS/Utilities/rnx.py b/RNS/Utilities/rnx.py new file mode 100644 index 0000000..b2f5997 --- /dev/null +++ b/RNS/Utilities/rnx.py @@ -0,0 +1,680 @@ +#!/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 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 = 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("Identity : "+str(identity)) + print("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("Allowed destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2)) + 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("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("Command link "+str(link)+" established") + +def command_link_closed(link): + RNS.log("Command link "+str(link)+" closed") + +def initiator_identified(link, identity): + global allow_all + RNS.log("Initiator of link "+str(link)+" identified as "+RNS.prettyhexrep(identity.hash)) + if not allow_all and not identity.hash in allowed_identity_hashes: + RNS.log("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("Executing command ["+command+"] for "+RNS.prettyhexrep(remote_identity.hash)) + else: + RNS.log("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("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("Delivering result of command ["+str(command)+"] to "+RNS.prettyhexrep(remote_identity.hash)) + else: + RNS.log("Delivering result of command ["+str(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(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("Allowed destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2)) + 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="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) + + if not spin(until=lambda: link.status == RNS.Link.ACTIVE, msg="Establishing link with "+RNS.prettyhexrep(destination_hash), timeout=timeout): + print("Could not establish link with "+RNS.prettyhexrep(destination_hash)) + exit(243) + + if not noid: + link.identify(identity) + + 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") + 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") + 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") + 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") + 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("Remote command execution took "+str(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 = " in "+str(transfer_duration)+" seconds" + else: + tdstr = " in "+pretty_time(transfer_duration) + + spdstr = ", effective rate "+size_str(total_size/transfer_duration, "b")+"ps" + + print("Transferred "+size_str(total_size)+tdstr+spdstr) + + if outlen != None and stdout != None: + if len(stdout) < outlen: + tstr = ", "+str(len(stdout))+" bytes displayed" + else: + tstr = "" + print("Remote wrote "+str(outlen)+" bytes to stdout"+tstr) + + if errlen != None and stderr != None: + if len(stderr) < errlen: + tstr = ", "+str(len(stderr))+" bytes displayed" + else: + tstr = "" + print("Remote wrote "+str(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(" stdout truncated to "+str(len(stdout))+" bytes") + if len(stderr) != 0 and len(stderr) < errlen: + print(" stderr truncated to "+str(len(stderr))+" bytes") + else: + print("Remote could not execute command") + if interactive: + return + else: + exit(248) + else: + print("No response") + 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 files 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="rncp {version}".format(version=__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: + code = None + while True: + try: + cstr = str(code) if code and code != 0 else "" + print(cstr+"> ",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='') + + 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: + print("") + if resource != None: + resource.cancel() + 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 "%.0f %s%s" % (num, unit, suffix) + else: + return "%.2f %s%s" % (num, unit, suffix) + num /= 1000.0 + + return "%.2f%s%s" % (num, 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(str(days)+" day"+sd if verbose else str(days)+"d") + + if hours > 0: + components.append(str(hours)+" hour"+sh if verbose else str(hours)+"h") + + if minutes > 0: + components.append(str(minutes)+" minute"+sm if verbose else str(minutes)+"m") + + if seconds > 0: + components.append(str(seconds)+" second"+ss if verbose else str(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() \ No newline at end of file diff --git a/docs/source/using.rst b/docs/source/using.rst index d5d0d96..2e916bd 100644 --- a/docs/source/using.rst +++ b/docs/source/using.rst @@ -340,7 +340,7 @@ You can specify as many allowed senders as needed, or complete disable authentic -i, --identity print identity and destination info and exit -r, --receive wait for incoming files -b, --no-announce don't announce at program start - -a ALLOW, --allow ALLOW accept from this identity + -a allowed_hash accept from this identity -n, --no-auth accept files from anyone -w seconds sender timeout before giving up --version show program's version number and exit diff --git a/setup.py b/setup.py index 23074fe..9383e41 100644 --- a/setup.py +++ b/setup.py @@ -27,6 +27,7 @@ setuptools.setup( 'rnprobe=RNS.Utilities.rnprobe:main', 'rnpath=RNS.Utilities.rnpath:main', 'rncp=RNS.Utilities.rncp:main', + 'rnx=RNS.Utilities.rnx:main', ] }, install_requires=['cryptography>=3.4.7', 'pyserial>=3.5', 'netifaces'],