# Based on the original rnsh program by Aaron Heise (@acehoss) # https://github.com/acehoss/rnsh - MIT License - Copyright (c) 2023 Aaron Heise # This version of rnsh is included in RNS under the Reticulum License # # Reticulum License # # Copyright (c) 2016-2026 Mark Qvist # # 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 Software shall not be used in any kind of system which includes amongst # its functions the ability to purposefully do harm to human beings. # # - The Software shall not be used, directly or indirectly, in the creation of # an artificial intelligence, machine learning or language model training # dataset, including but not limited to any use that contributes to the # training or development of such a model or algorithm. # # - 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. from __future__ import annotations import asyncio import contextlib import copy import errno import fcntl import functools import os import pty import select import signal import struct import sys import termios import threading import tty import types import typing import RNS import RNS.Utilities.rnsh.exception as exception CTRL_C = "\x03".encode("utf-8") CTRL_D = "\x04".encode("utf-8") def tty_add_reader_callback(fd: int, callback: callable, loop: asyncio.AbstractEventLoop = None): """ Add an async reader callback for a tty file descriptor. Example usage: def reader(): data = tty_read(fd) # do something with data tty_add_reader_callback(self._child_fd, reader, self._loop) :param fd: file descriptor :param callback: callback function :param loop: asyncio event loop to which the reader should be added. If None, use the currently-running loop. """ if loop is None: loop = asyncio.get_running_loop() loop.add_reader(fd, callback) def tty_read(fd: int) -> bytes: """ Read available bytes from a tty file descriptor. When used in a callback added to a file descriptor using tty_add_reader_callback(...), this function creates a solution for non-blocking reads from ttys. :param fd: tty file descriptor :return: bytes read """ if fd_is_closed(fd): raise EOFError try: run = True result = bytearray() while not fd_is_closed(fd): ready, _, _ = select.select([fd], [], [], 0) if len(ready) == 0: break for f in ready: try: data = os.read(f, 4096) except OSError as e: if e.errno != errno.EIO and e.errno != errno.EWOULDBLOCK: raise else: if not data: # EOF if data is not None and len(data) > 0: result.extend(data) return result elif len(result) > 0: return result else: raise EOFError if data is not None and len(data) > 0: result.extend(data) return result except EOFError: raise except Exception as e: RNS.log(f"TTY read error: {e}", RNS.LOG_ERROR) def tty_read_poll(fd: int) -> bytes: """ Read available bytes from a tty file descriptor. When used in a callback added to a file descriptor using tty_add_reader_callback(...), this function creates a solution for non-blocking reads from ttys. :param fd: tty file descriptor :return: bytes read """ if fd_is_closed(fd): raise EOFError result = bytearray() try: flags = fcntl.fcntl(fd, fcntl.F_GETFL) fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) while True: try: data = os.read(fd, 4096) if not data: # EOF if len(result) > 0: return result raise EOFError result.extend(data) # continue loop to drain except OSError as e: if e.errno in (errno.EWOULDBLOCK, errno.EAGAIN): break if e.errno == errno.EIO: if len(result) > 0: return result raise EOFError raise except EOFError: raise except Exception as e: RNS.log(f"TTY read error: {e}", RNS.LOG_ERROR) return result def fd_is_closed(fd: int) -> bool: """ Check if file descriptor is closed :param fd: file descriptor :return: True if file descriptor is closed """ try: fcntl.fcntl(fd, fcntl.F_GETFL) < 0 except OSError as ose: return ose.errno == errno.EBADF def tty_unset_reader_callbacks(fd: int, loop: asyncio.AbstractEventLoop = None): """ Remove async reader callbacks for file descriptor. :param fd: file descriptor :param loop: asyncio event loop from which to remove callbacks """ with exception.permit(SystemExit): if loop is None: loop = asyncio.get_running_loop() loop.remove_reader(fd) def tty_get_winsize(fd: int) -> [int, int, int, int]: """ Ge the window size of a tty. :param fd: file descriptor of tty :return: (rows, cols, h_pixels, v_pixels) """ packed = fcntl.ioctl(fd, termios.TIOCGWINSZ, struct.pack('HHHH', 0, 0, 0, 0)) rows, cols, h_pixels, v_pixels = struct.unpack('HHHH', packed) return rows, cols, h_pixels, v_pixels def tty_set_winsize(fd: int, rows: int, cols: int, h_pixels: int, v_pixels: int): """ Set the window size on a tty. :param fd: file descriptor of tty :param rows: number of visible rows :param cols: number of visible columns :param h_pixels: number of visible horizontal pixels :param v_pixels: number of visible vertical pixels """ if fd < 0: return packed = struct.pack('HHHH', rows, cols, h_pixels, v_pixels) fcntl.ioctl(fd, termios.TIOCSWINSZ, packed) def process_exists(pid) -> bool: """ Check For the existence of a unix pid. :param pid: process id to check :return: True if process exists """ try: os.kill(pid, 0) except OSError: return False else: return True class TTYRestorer(contextlib.AbstractContextManager): # Indexes of flags within the attrs array ATTR_IDX_IFLAG = 0 ATTR_IDX_OFLAG = 1 ATTR_IDX_CFLAG = 2 ATTR_IDX_LFLAG = 4 ATTR_IDX_CC = 5 def __init__(self, fd: int, suppress_logs=False): """ Saves termios attributes for a tty for later restoration. The attributes are an array of values with the following meanings. tcflag_t c_iflag; /* input modes */ tcflag_t c_oflag; /* output modes */ tcflag_t c_cflag; /* control modes */ tcflag_t c_lflag; /* local modes */ cc_t c_cc[NCCS]; /* special characters */ :param fd: file descriptor of tty """ self._fd = fd self._tattr = None self._suppress_logs = suppress_logs self._tattr = self.current_attr() if not self._tattr and not self._suppress_logs: RNS.log(f"Could not get attrs for fd {fd}", RNS.LOG_DEBUG) def raw(self): """ Set raw mode on tty """ if self._fd is None: return with contextlib.suppress(termios.error): tty.setraw(self._fd, termios.TCSANOW) def original_attr(self) -> [any]: return copy.deepcopy(self._tattr) def current_attr(self) -> [any]: """ Get the current termios attributes for the wrapped fd. :return: attribute array """ if self._fd is None: return None with contextlib.suppress(termios.error): return copy.deepcopy(termios.tcgetattr(self._fd)) return None def set_attr(self, attr: [any], when: int = termios.TCSADRAIN): """ Set termios attributes :param attr: attribute list to set :param when: when attributes should be applied (termios.TCSANOW, termios.TCSADRAIN, termios.TCSAFLUSH) """ if not attr or self._fd is None: return with contextlib.suppress(termios.error): termios.tcsetattr(self._fd, when, attr) def isatty(self): return os.isatty(self._fd) if self._fd is not None else None def restore(self): """ Restore termios settings to state captured in constructor. """ self.set_attr(self._tattr, termios.TCSADRAIN) def __exit__(self, __exc_type: typing.Type[BaseException], __exc_value: BaseException, __traceback: types.TracebackType) -> bool: self.restore() return False #__exc_type is not None and issubclass(__exc_type, termios.error) def _task_from_event(evt: asyncio.Event, loop: asyncio.AbstractEventLoop = None): if not loop: loop = asyncio.get_running_loop() #TODO: this is hacky async def wait(): while not evt.is_set(): await asyncio.sleep(0.1) return True return loop.create_task(wait()) class AggregateException(Exception): def __init__(self, inner_exceptions: [Exception]): super().__init__() self.inner_exceptions = inner_exceptions def __str__(self): return "Multiple exceptions encountered: \n\n" + "\n\n".join(map(lambda e: str(e), self.inner_exceptions)) async def event_wait_any(evts: [asyncio.Event], timeout: float = None) -> (any, any): tasks = list(map(lambda evt: (evt, _task_from_event(evt)), evts)) try: finished, unfinished = await asyncio.wait(map(lambda t: t[1], tasks), timeout=timeout, return_when=asyncio.FIRST_COMPLETED) if len(unfinished) > 0: for task in unfinished: task.cancel() await asyncio.wait(unfinished) exceptions = [] for f in finished: ex = f.exception() if ex and not isinstance(ex, asyncio.CancelledError) and not isinstance(ex, TimeoutError): exceptions.append(ex) if len(exceptions) > 0: raise AggregateException(exceptions) return next(map(lambda t: next(map(lambda tt: tt[0], tasks)), finished), None) finally: unfinished = [] for task in map(lambda t: t[1], tasks): if task.done(): if not task.cancelled(): task.exception() else: task.cancel() unfinished.append(task) if len(unfinished) > 0: await asyncio.wait(unfinished) async def event_wait(evt: asyncio.Event, timeout: float) -> bool: """ Wait for event to be set, or timeout to expire. :param evt: asyncio.Event to wait on :param timeout: maximum number of seconds to wait. :return: True if event was set, False if timeout expired """ await event_wait_any([evt], timeout=timeout) return evt.is_set() def _launch_child(cmd_line: list[str], env: dict[str, str], stdin_is_pipe: bool, stdout_is_pipe: bool, stderr_is_pipe: bool) -> tuple[int, int, int, int]: # Set up PTY and/or pipes child_fd = parent_fd = None if not (stdin_is_pipe and stdout_is_pipe and stderr_is_pipe): parent_fd, child_fd = pty.openpty() child_stdin, parent_stdin = (os.pipe() if stdin_is_pipe else (child_fd, parent_fd)) parent_stdout, child_stdout = (os.pipe() if stdout_is_pipe else (parent_fd, child_fd)) parent_stderr, child_stderr = (os.pipe() if stderr_is_pipe else (parent_fd, child_fd)) # Fork pid = os.fork() if pid == 0: try: # We are in the child process, so close all open sockets and pipes except for the PTY and/or pipes max_fd = os.sysconf("SC_OPEN_MAX") for fd in range(3, max_fd): if fd not in (child_stdin, child_stdout, child_stderr): try: os.close(fd) except OSError: pass # Set up PTY and/or pipes os.dup2(child_stdin, 0) os.dup2(child_stdout, 1) os.dup2(child_stderr, 2) # Make PTY controlling if necessary so that CTRL_C/CTRL_D behave as expected if child_fd is not None: os.setsid() try: tty_fd = 0 if not stdin_is_pipe else (1 if not stdout_is_pipe else 2) # Set controlling TTY for this session fcntl.ioctl(tty_fd, termios.TIOCSCTTY, 0) except Exception: pass # Ensure the child is the foreground process group for the TTY try: os.setpgid(0, 0) pgid = os.getpgrp() import struct as _struct fcntl.ioctl(tty_fd, termios.TIOCSPGRP, _struct.pack('i', pgid)) except Exception: pass # Ensure canonical input with signals and local echo enabled try: tty_fd = 0 if not stdin_is_pipe else (1 if not stdout_is_pipe else 2) attrs = termios.tcgetattr(tty_fd) lflag = attrs[3] lflag |= termios.ICANON | termios.ISIG | termios.ECHO attrs[3] = lflag termios.tcsetattr(tty_fd, termios.TCSANOW, attrs) except Exception: pass # Execute the command os.execvpe(cmd_line[0], cmd_line, env) except Exception as err: exc_type, exc_obj, exc_tb = sys.exc_info() fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] print(f"Unable to start {cmd_line[0]}: {err} ({fname}:{exc_tb.tb_lineno})") sys.stdout.flush() # don't let any other modules get in our way, do an immediate silent exit. os._exit(255) else: # We are in the parent process, so close the child-side of the PTY and/or pipes if child_fd is not None: os.close(child_fd) if child_stdin != child_fd: os.close(child_stdin) if child_stdout != child_fd: os.close(child_stdout) if child_stderr != child_fd: os.close(child_stderr) # # Close the write end of the pipe if a pipe is used for standard input # if not stdin_is_pipe: # os.close(parent_stdin) # Return the child PID and the file descriptors for the PTY and/or pipes return pid, parent_stdin, parent_stdout, parent_stderr class CallbackSubprocess: # time between checks of child process PROCESS_POLL_TIME: float = 0.1 # Close pipes soon after process exit to avoid scheduling on closed event loops PROCESS_PIPE_TIME: int = 1 def __init__(self, argv: [str], env: dict, loop: asyncio.AbstractEventLoop, stdout_callback: callable, stderr_callback: callable, terminated_callback: callable, stdin_is_pipe: bool, stdout_is_pipe: bool, stderr_is_pipe: bool): """ Fork a child process and generate callbacks with output from the process. :param argv: the command line, tokenized. The first element must be the absolute path to an executable file. :param env: environment variables to override :param loop: the asyncio event loop to use :param stdout_callback: callback for data, e.g. def callback(data:bytes) -> None :param terminated_callback: callback for termination/return code, e.g. def callback(return_code:int) -> None """ assert loop is not None, "loop should not be None" assert stdout_callback is not None, "stdout_callback should not be None" assert terminated_callback is not None, "terminated_callback should not be None" self._command: [str] = argv self._env = env or {} self._loop = loop self._stdout_cb = stdout_callback self._stderr_cb = stderr_callback self._terminated_cb = terminated_callback self._pid: int = None self._child_stdin: int = None self._child_stdout: int = None self._child_stderr: int = None self._return_code: int = None self._stdout_eof: bool = False self._stderr_eof: bool = False self._stdin_is_pipe = stdin_is_pipe self._stdout_is_pipe = stdout_is_pipe self._stderr_is_pipe = stderr_is_pipe self._at_line_start: bool = True self._tty_line_buffer: bytearray = bytearray() def _ensure_pipes_closed(self): stdin = self._child_stdin stdout = self._child_stdout stderr = self._child_stderr fds = set(filter(lambda x: x is not None, list({stdin, stdout, stderr}))) RNS.log(f"Queuing close of pipes for ended process (fds: {fds})", RNS.LOG_DEBUG) def ensure_pipes_closed_inner(): RNS.log(f"Ensuring pipes are closed (fds: {fds})", RNS.LOG_DEBUG) for fd in fds: RNS.log(f"Closing fd {fd}", RNS.LOG_DEBUG) with contextlib.suppress(OSError): tty_unset_reader_callbacks(fd) with contextlib.suppress(OSError): os.close(fd) self._child_stdin = None self._child_stdout = None self._child_stderr = None # Avoid scheduling on a closed loop if self._loop.is_closed(): ensure_pipes_closed_inner() else: self._loop.call_later(CallbackSubprocess.PROCESS_PIPE_TIME, ensure_pipes_closed_inner) def terminate(self, kill_delay: float = 1.0): """ Terminate child process if running :param kill_delay: if after kill_delay seconds the child process has not exited, escalate to SIGHUP and SIGKILL """ RNS.log("terminate()", RNS.LOG_EXTREME) if not self.running: return with exception.permit(SystemExit): os.kill(self._pid, signal.SIGTERM) def kill(): if process_exists(self._pid): RNS.log("kill()", RNS.LOG_EXTREME) with exception.permit(SystemExit): os.kill(self._pid, signal.SIGHUP) os.kill(self._pid, signal.SIGKILL) self._loop.call_later(kill_delay, kill) def wait(): RNS.log("wait()", RNS.LOG_EXTREME) with contextlib.suppress(OSError): os.waitpid(self._pid, 0) self._ensure_pipes_closed() RNS.log("wait() finish", RNS.LOG_EXTREME) threading.Thread(target=wait, daemon=True).start() def close_stdin(self): with contextlib.suppress(Exception): os.close(self._child_stdin) # Encourage prompt shutdown if child lingers after stdin close def _ensure_terminate(): if self.running: self.terminate(kill_delay=0.2) if not self._loop.is_closed(): self._loop.call_later(0.05, _ensure_terminate) @property def started(self) -> bool: """ :return: True if child process has been started """ return self._pid is not None @property def running(self) -> bool: """ :return: True if child process is still running """ return self._pid is not None and process_exists(self._pid) def write(self, data: bytes): """ Write bytes to the stdin of the child process. :param data: bytes to write """ os.write(self._child_stdin, data) # TODO: Check what this is actually supposed to solve. # # For pipe-in + TTY-out, echo should be visible immediately if self._stdin_is_pipe and not self._stdout_is_pipe and self._stdout_cb is not None and data not in (CTRL_C, CTRL_D): try: self._stdout_cb(data) except Exception: pass def set_winsize(self, r: int, c: int, h: int, v: int): """ Set the window size on the tty of the child process. :param r: rows visible :param c: columns visible :param h: horizontal pixels visible :param v: vertical pixels visible :return: """ RNS.log(f"set_winsize({r},{c},{h},{v}", RNS.LOG_DEBUG) tty_set_winsize(self._child_stdout, r, c, h, v) def copy_winsize(self, fromfd: int): """ Copy window size from one tty to another. :param fromfd: source tty file descriptor """ r, c, h, v = tty_get_winsize(fromfd) self.set_winsize(r, c, h, v) def tcsetattr(self, when: int, attr: list[any]): # actual type is list[int | list[int | bytes]] """ Set tty attributes. :param when: when to apply change: termios.TCSANOW or termios.TCSADRAIN or termios.TCSAFLUSH :param attr: attributes to set """ termios.tcsetattr(self._child_stdin, when, attr) def tcgetattr(self) -> list[any]: # actual type is list[int | list[int | bytes]] """ Get tty attributes. :return: tty attributes value """ return termios.tcgetattr(self._child_stdout) def ttysetraw(self): tty.setraw(self._child_stdout, termios.TCSADRAIN) def start(self): """ Start the child process. """ RNS.log("start()", RNS.LOG_EXTREME) # # Using the parent environment seems to do some weird stuff, at least on macOS # parentenv = os.environ.copy() # env = {"HOME": parentenv["HOME"], # "PATH": parentenv["PATH"], # "TERM": self._term if self._term is not None else parentenv.get("TERM", "xterm"), # "LANG": parentenv.get("LANG"), # "SHELL": self._command[0]} env = os.environ.copy() for key in self._env: env[key] = self._env[key] program = self._command[0] assert isinstance(program, str) # match = re.search("^/bin/(.*sh)$", program) # if match: # self._command[0] = "-" + match.group(1) # env["SHELL"] = program # self._log.debug(f"set login shell {self._command}") self._pid, \ self._child_stdin, \ self._child_stdout, \ self._child_stderr = _launch_child(self._command, env, self._stdin_is_pipe, self._stdout_is_pipe, self._stderr_is_pipe) RNS.log(f"Started pid {self.pid}, fds: {self._child_stdin}, {self._child_stdout}, {self._child_stderr}", RNS.LOG_DEBUG) def poll(): try: pid, self._return_code = os.waitpid(self._pid, os.WNOHANG) if self._return_code is not None: self._return_code = self._return_code & 0xff if self._return_code is not None and not process_exists(self._pid): RNS.log(f"polled return code {self._return_code}", RNS.LOG_DEBUG) self._terminated_cb(self._return_code) if self.running: self._loop.call_later(CallbackSubprocess.PROCESS_POLL_TIME, poll) else: self._ensure_pipes_closed() except Exception as e: if not hasattr(e, "errno") or e.errno != errno.ECHILD: RNS.log(f"Error in process poll: {e}", RNS.LOG_DEBUG) self._loop.call_later(CallbackSubprocess.PROCESS_POLL_TIME, poll) def stdout(): try: with exception.permit(SystemExit): data = tty_read_poll(self._child_stdout) if data is not None and len(data) > 0: self._stdout_cb(data) # Opportunistically drain shortly after to coalesce immediate follow-up output if not self._loop.is_closed(): self._loop.call_later(0.01, stdout) except EOFError: self._stdout_eof = True tty_unset_reader_callbacks(self._child_stdout) self._stdout_cb(bytearray()) def stderr(): try: with exception.permit(SystemExit): data = tty_read_poll(self._child_stderr) if data is not None and len(data) > 0: self._stderr_cb(data) if not self._loop.is_closed(): self._loop.call_later(0.01, stderr) except EOFError: self._stderr_eof = True tty_unset_reader_callbacks(self._child_stderr) self._stderr_cb(bytearray()) tty_add_reader_callback(self._child_stdout, stdout, self._loop) if self._child_stderr != self._child_stdout: tty_add_reader_callback(self._child_stderr, stderr, self._loop) @property def stdout_eof(self): return self._stdout_eof or not self.running @property def stderr_eof(self): return self._stderr_eof or not self.running @property def return_code(self) -> int: return self._return_code @property def pid(self) -> int: return self._pid async def main(): """ A test driver for the CallbackProcess class. python ./process.py /bin/zsh --login """ if len(sys.argv) <= 1: print(f"Usage: {sys.argv} [child_arg ...]") exit(1) loop = asyncio.get_event_loop() # asyncio.set_event_loop(loop) retcode = loop.create_future() def stdout(data: bytes): os.write(sys.stdout.fileno(), data) def terminated(rc: int): retcode.set_result(rc) process = CallbackSubprocess(argv=sys.argv[1:], env={"TERM": os.environ.get("TERM", "xterm")}, loop=loop, stdout_callback=stdout, terminated_callback=terminated) def sigint_handler(sig, frame): if process is None or process.started and not process.running: raise KeyboardInterrupt elif process.running: process.write("\x03".encode("utf-8")) def sigwinch_handler(sig, frame): process.copy_winsize(sys.stdin.fileno()) signal.signal(signal.SIGINT, sigint_handler) signal.signal(signal.SIGWINCH, sigwinch_handler) def stdin(): try: data = tty_read(sys.stdin.fileno()) if data is not None: process.write(data) except EOFError: tty_unset_reader_callbacks(sys.stdin.fileno()) process.write(CTRL_D) tty_add_reader_callback(sys.stdin.fileno(), stdin) process.start() # call_soon called it too soon, not sure why. loop.call_later(0.001, functools.partial(process.copy_winsize, sys.stdin.fileno())) val = await retcode RNS.log(f"Got return code {val}", RNS.LOG_DEBUG) return val if __name__ == "__main__": tr = TTYRestorer(sys.stdin.fileno()) try: tr.raw() asyncio.run(main()) finally: tty_unset_reader_callbacks(sys.stdin.fileno()) tr.restore()