Refactored rnsh to use argparse

This commit is contained in:
Mark Qvist 2026-04-27 00:06:33 +02:00
commit f924086198
8 changed files with 119 additions and 47 deletions

View file

@ -0,0 +1,59 @@
import argparse
import sys
from RNS.Utilities.rnsh._version import __version__ as __rnsh_version__
from RNS._version import __version__
DEFAULT_SERVICE_NAME = "default"
def setup_argument_parser():
parser = argparse.ArgumentParser(description="Reticulum Remote Shell Utility", epilog="When specifying a command to execute, separate rnsh\noptions from the command and its arguments with --\n\nFor example:\n rnsh -l -- /bin/bash --login\n rnsh <destination> -- ls -la /tmp", formatter_class=argparse.RawDescriptionHelpFormatter)
# Common options
parser.add_argument("--config", "-c", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
parser.add_argument("--identity", "-i", action="store", default=None, help="path to identity file to use", 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("--version", action="version", version="rnsh {rv} (protocol {pv})".format(rv=__version__, pv=__rnsh_version__))
# Listener options
parser.add_argument("-l", "--listen", action="store_true", default=False, help="listen (server) mode; any command specified after -- will be used as the default command when the initiator does not provide one or when remote command execution is disabled; if no command is specified, the default shell of the user running rnsh will be used")
parser.add_argument("-s", "--service", action="store", default=None, help="service name for identity file if not the default", type=str)
parser.add_argument("-b", "--announce",action="store", default=None,help="announce on startup and every PERIOD seconds; specify 0 to announce on startup only",metavar="PERIOD", type=int)
parser.add_argument("-a", "--allowed", action="append", default=None, metavar="HASH", type=str, help="allow this identity to connect (may be specified multiple times); allowed identities can also be specified in ~/.rnsh/allowed_identities or ~/.config/rnsh/allowed_identities, one hash per line")
parser.add_argument("-n", "--no-auth", action="store_true", default=False, help="disable authentication (allow any identity to connect)")
parser.add_argument("-A", "--remote-command-as-args", action="store_true", default=False, help="concatenate remote command to the argument list of the default program or shell")
parser.add_argument("-C", "--no-remote-command", action="store_true", default=False, help="disable executing command lines received from the remote initiator")
# Initiator options
parser.add_argument("-N", "--no-id", action="store_true", default=False, help="disable identity announcement on connect")
parser.add_argument("-m", "--mirror", action="store_true", default=False, help="return with the exit code of the remote process")
parser.add_argument("-w", "--timeout", action="store", default=None, help="connect and request timeout in seconds", metavar="SECONDS", type=float)
parser.add_argument("destination", nargs="?", default=None, help="hexadecimal hash of the destination to connect to", type=str)
return parser
def parse_arguments(argv=None):
if argv is None: argv = sys.argv[1:]
# Split at -- to separate rnsh options from the command to execute.
# Everything before -- (or the entire argv if no --) goes to argparse.
# Everything after -- becomes the command list.
try:
split_idx = argv.index("--")
rnsh_argv = argv[:split_idx]
command = argv[split_idx + 1:]
except ValueError:
rnsh_argv = argv
command = []
parser = setup_argument_parser()
args = parser.parse_args(rnsh_argv)
args.command = command
if args.listen and not args.service: args.service = DEFAULT_SERVICE_NAME
return args, parser

View file

@ -39,18 +39,18 @@ import time
import tty
from typing import Callable, TypeVar
import RNS
import rnsh.exception as exception
import rnsh.process as process
import rnsh.retry as retry
import rnsh.session as session
import RNS.Utilities.rnsh.exception as exception
import RNS.Utilities.rnsh.process as process
import RNS.Utilities.rnsh.retry as retry
import RNS.Utilities.rnsh.session as session
import re
import contextlib
import rnsh.args
import pwd
import bz2
import rnsh.protocol as protocol
import rnsh.helpers as helpers
import rnsh.rnsh
import RNS.Utilities.rnsh.protocol as protocol
import RNS.Utilities.rnsh.helpers as helpers
import RNS.Utilities.rnsh.rnsh as rnsh
_identity = None
_reticulum = None
@ -154,7 +154,7 @@ async def _initiate_link(configdir, rnsconfigdir, identitypath=None, verbosity=0
_reticulum = RNS.Reticulum(configdir=rnsconfigdir, loglevel=targetloglevel, logdest=RNS.LOG_FILE)
if _identity is None:
_identity = rnsh.rnsh.prepare_identity(identitypath)
_identity = rnsh.prepare_identity(identitypath)
if not RNS.Transport.has_path(destination_hash):
RNS.Transport.request_path(destination_hash)
@ -169,7 +169,7 @@ async def _initiate_link(configdir, rnsconfigdir, identitypath=None, verbosity=0
listener_identity,
RNS.Destination.OUT,
RNS.Destination.SINGLE,
rnsh.rnsh.APP_NAME
rnsh.APP_NAME
)
if _link is None or _link.status == RNS.Link.PENDING:

View file

@ -36,17 +36,17 @@ import time
import tty
from typing import Callable, TypeVar
import RNS
import rnsh.exception as exception
import rnsh.process as process
import rnsh.retry as retry
import rnsh.session as session
import RNS.Utilities.rnsh.exception as exception
import RNS.Utilities.rnsh.process as process
import RNS.Utilities.rnsh.retry as retry
import RNS.Utilities.rnsh.session as session
import re
import contextlib
import rnsh.args
import pwd
import rnsh.protocol as protocol
import rnsh.helpers as helpers
import rnsh.rnsh
import RNS.Utilities.rnsh.protocol as protocol
import RNS.Utilities.rnsh.helpers as helpers
import RNS.Utilities.rnsh.rnsh as rnsh
_identity = None
@ -123,8 +123,8 @@ async def listen(configdir, rnsconfigdir, command, identitypath=None, service_na
# More -v should increase verbosity (higher RNS.loglevel); -q should decrease it
targetloglevel = compute_target_rns_loglevel(verbosity, quietness, RNS.LOG_INFO)
_reticulum = RNS.Reticulum(configdir=rnsconfigdir, loglevel=targetloglevel)
_identity = rnsh.rnsh.prepare_identity(identitypath, service_name)
_destination = RNS.Destination(_identity, RNS.Destination.IN, RNS.Destination.SINGLE, rnsh.rnsh.APP_NAME)
_identity = rnsh.prepare_identity(identitypath, service_name)
_destination = RNS.Destination(_identity, RNS.Destination.IN, RNS.Destination.SINGLE, rnsh.APP_NAME)
RNS.log(f"rnsh listening for commands on {RNS.prettyhexrep(_destination.hash)}", RNS.LOG_NOTICE)

View file

@ -40,7 +40,7 @@ import types
import typing
import RNS
import rnsh.exception as exception
import RNS.Utilities.rnsh.exception as exception
CTRL_C = "\x03".encode("utf-8")
CTRL_D = "\x04".encode("utf-8")

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import RNS
from RNS.vendor import umsgpack
from RNS.Buffer import StreamDataMessage as RNSStreamDataMessage
import rnsh.retry
import RNS.Utilities.rnsh.retry
import abc
import contextlib
import struct
@ -77,7 +77,7 @@ class VersionInfoMessage(RNS.MessageBase):
def __init__(self, sw_version: str = None):
super().__init__()
self.sw_version = sw_version or rnsh.__version__
self.sw_version = sw_version or RNS.Utilities.rnsh.__version__
self.protocol_version = PROTOCOL_VERSION
def pack(self) -> bytes: return umsgpack.packb((self.sw_version, self.protocol_version))

View file

@ -1,6 +1,10 @@
# MIT License
# 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
#
# Copyright (c) 2023 Aaron Heise
# Reticulum License
#
# Copyright (c) 2016-2025 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
@ -9,8 +13,16 @@
# 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 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,
@ -23,7 +35,7 @@
import asyncio
import threading
import time
import rnsh.exception as exception
import RNS.Utilities.rnsh.exception as exception
from typing import Callable
from contextlib import AbstractContextManager
import types

View file

@ -32,12 +32,13 @@ import os
import sys
import RNS
import rnsh.process as process
import rnsh.session as session
import rnsh.args
import rnsh.loop
import rnsh.listener as listener
import rnsh.initiator as initiator
import RNS.Utilities.rnsh.process as process
import RNS.Utilities.rnsh.session as session
import RNS.Utilities.rnsh.args
import RNS.Utilities.rnsh.loop
import RNS.Utilities.rnsh.listener as listener
import RNS.Utilities.rnsh.initiator as initiator
from RNS.Utilities.rnsh.args import parse_arguments
APP_NAME = "rnsh"
loop: asyncio.AbstractEventLoop | None = None
@ -91,13 +92,13 @@ def ensure_config_directory():
async def _rnsh_cli_main():
global verbose_set
args = rnsh.args.Args(sys.argv)
args, parser = parse_arguments()
verbose_set = args.verbose > 0
configdir = ensure_config_directory()
if args.print_identity:
print_identity(args.config, args.identity, args.service_name, args.listen)
print_identity(args.config, args.identity, args.service, args.listen)
return 0
if args.listen:
@ -110,17 +111,17 @@ async def _rnsh_cli_main():
await listener.listen(configdir=configdir,
rnsconfigdir=args.config,
command=args.command_line,
command=args.command,
identitypath=args.identity,
service_name=args.service_name,
service_name=args.service,
verbosity=args.verbose,
quietness=args.quiet,
allowed=args.allowed,
allowed=args.allowed or [],
allowed_file=allowed_file,
disable_auth=args.no_auth,
announce_period=args.announce,
no_remote_command=args.no_remote_cmd,
remote_cmd_as_args=args.remote_cmd_as_args)
no_remote_command=args.no_remote_command,
remote_cmd_as_args=args.remote_command_as_args)
return 0
if args.destination is not None:
@ -132,12 +133,12 @@ async def _rnsh_cli_main():
noid=args.no_id,
destination=args.destination,
timeout=args.timeout,
command=args.command_line
command=args.command
)
return return_code if args.mirror else 0
else:
print("")
print(rnsh.args.usage)
parser.print_help()
print("")
return 1

View file

@ -1,11 +1,11 @@
from __future__ import annotations
import contextlib
import functools
import rnsh.exception as exception
import asyncio
import rnsh.process as process
import rnsh.helpers as helpers
import rnsh.protocol as protocol
import RNS.Utilities.rnsh.exception as exception
import RNS.Utilities.rnsh.process as process
import RNS.Utilities.rnsh.helpers as helpers
import RNS.Utilities.rnsh.protocol as protocol
import enum
from typing import TypeVar, Generic, Callable, List
from abc import abstractmethod, ABC