mirror of
https://github.com/markqvist/rnsh.git
synced 2025-06-06 21:48:50 -04:00
Several test-driven fixes
- Improved test suite exposed several issues with the handling of command line arguments which are now fixed - Fixed a race condition that would cause remote characters to be lost intermittently when running remote commands that finish immediately. - Added automated testing that actually spins up a random listener and initiator in a private Reticulum network and passes data between them, uncovering more issues which are now fixed. - Fixed (hopefully) an issue where `rnsh` doesn't know what version it is.
This commit is contained in:
parent
d4cb31e220
commit
27664df0b3
12 changed files with 508 additions and 85 deletions
3
.github/workflows/python-package.yml
vendored
3
.github/workflows/python-package.yml
vendored
|
@ -54,3 +54,6 @@ jobs:
|
||||||
|
|
||||||
- name: Test with pytest
|
- name: Test with pytest
|
||||||
run: poetry run pytest -m "not skip_ci" tests
|
run: poetry run pytest -m "not skip_ci" tests
|
||||||
|
|
||||||
|
- name: Vulnerability check
|
||||||
|
run: poetry run safety check
|
||||||
|
|
48
README.md
48
README.md
|
@ -17,13 +17,35 @@ out.
|
||||||
|
|
||||||
Anyway, there's a lot of room for improvement.
|
Anyway, there's a lot of room for improvement.
|
||||||
|
|
||||||
## Alpha Software
|
## Contents
|
||||||
|
|
||||||
|
- [Alpha Disclaimer](#reminder--alpha-software)
|
||||||
|
- [Recent Changes](#recent-changes)
|
||||||
|
- [Quickstart](#quickstart)
|
||||||
|
- [Options](#options)
|
||||||
|
- [How it works](#how-it-works)
|
||||||
|
- [Roadmap](#roadmap)
|
||||||
|
- [Active TODO](#todo)
|
||||||
|
|
||||||
|
### Reminder: Alpha Software
|
||||||
These early versions will be buggy. There will sometimes be major
|
These early versions will be buggy. There will sometimes be major
|
||||||
breaking changes to the command line parameters between releases.
|
breaking changes to the command line parameters between releases.
|
||||||
There will sometimes be breaking changes in the protocol between
|
There will sometimes be breaking changes in the protocol between
|
||||||
releases. Use at your own peril!
|
releases. Use at your own peril!
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
### v0.0.8
|
||||||
|
- Improved test suite exposed several issues with the handling of
|
||||||
|
command line arguments which are now fixed
|
||||||
|
- Fixed a race condition that would cause remote characters to be
|
||||||
|
lost intermittently when running remote commands that finish
|
||||||
|
immediately.
|
||||||
|
- Added automated testing that actually spins up a random listener
|
||||||
|
and initiator in a private Reticulum network and passes data
|
||||||
|
between them, uncovering more issues which are now fixed.
|
||||||
|
- Fixed (hopefully) an issue where `rnsh` doesn't know what
|
||||||
|
version it is.
|
||||||
|
|
||||||
### v0.0.7
|
### v0.0.7
|
||||||
Added `-A` command line option. This listener option causes the
|
Added `-A` command line option. This listener option causes the
|
||||||
remote command line to be appended to the arguments list of the
|
remote command line to be appended to the arguments list of the
|
||||||
|
@ -200,7 +222,25 @@ The protocol is build on top of the Reticulum `Request` and
|
||||||
- [X] ~~Pip package with command-line utility support~~
|
- [X] ~~Pip package with command-line utility support~~
|
||||||
- [X] ~~Publish to PyPI~~
|
- [X] ~~Publish to PyPI~~
|
||||||
- [X] ~~Improve signal handling~~
|
- [X] ~~Improve signal handling~~
|
||||||
- [ ] Protocol improvements (throughput!)
|
|
||||||
- [ ] Test on several *nixes
|
|
||||||
- [X] ~~Make it scriptable (currently requires a tty)~~
|
- [X] ~~Make it scriptable (currently requires a tty)~~
|
||||||
- [ ] Documentation improvements
|
- [X] ~~Protocol improvements (throughput!)~~
|
||||||
|
- [X] ~~Documentation improvements~~
|
||||||
|
- [ ] Test on several platforms
|
||||||
|
- [ ] Fix issues that come up with testing
|
||||||
|
- [ ] Fix issues with running `rnsh` in a binary pipeline, i.e.
|
||||||
|
piping the output of `tar` over `rsh`.
|
||||||
|
- [ ] Beta release
|
||||||
|
- [ ] Test and fix more issues
|
||||||
|
- [ ] V1.0
|
||||||
|
- [ ] Enhancement Ideas
|
||||||
|
- [ ] `authorized_keys` mode similar to SSH
|
||||||
|
- [ ] Git over `rnsh` (git remote helper)
|
||||||
|
- [ ] Sliding window acknowledgements for improved throughput
|
||||||
|
|
||||||
|
## Miscellaneous
|
||||||
|
|
||||||
|
By piping into/out of `rnsh`, it should be possible to transfer
|
||||||
|
files using the same method discussed in
|
||||||
|
[this article](https://cromwell-intl.com/open-source/tar-and-ssh.html).
|
||||||
|
I tested it just now and it doesn't work right. There's probably some
|
||||||
|
subtle garbling of the data at one end of the stream or the other.
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "rnsh"
|
name = "rnsh"
|
||||||
version = "0.0.7"
|
version = "0.0.8"
|
||||||
description = "Shell over Reticulum"
|
description = "Shell over Reticulum"
|
||||||
authors = ["acehoss <acehoss@acehoss.net>"]
|
authors = ["acehoss <acehoss@acehoss.net>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
@ -11,6 +11,7 @@ python = "^3.9"
|
||||||
docopt = "^0.6.2"
|
docopt = "^0.6.2"
|
||||||
psutil = "^5.9.4"
|
psutil = "^5.9.4"
|
||||||
rns = "^0.4.8"
|
rns = "^0.4.8"
|
||||||
|
tomli = "^2.0.1"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
rnsh = 'rnsh.rnsh:rnsh_cli'
|
rnsh = 'rnsh.rnsh:rnsh_cli'
|
||||||
|
@ -18,8 +19,8 @@ rnsh = 'rnsh.rnsh:rnsh_cli'
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
pytest = "^7.2.1"
|
pytest = "^7.2.1"
|
||||||
setuptools = "^67.2.0"
|
setuptools = "^67.2.0"
|
||||||
black = "^23.1.0"
|
|
||||||
pytest-asyncio = "^0.20.3"
|
pytest-asyncio = "^0.20.3"
|
||||||
|
safety = "^2.3.5"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
markers = [
|
markers = [
|
||||||
|
|
|
@ -19,3 +19,21 @@
|
||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# 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
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
# SOFTWARE.
|
# SOFTWARE.
|
||||||
|
|
||||||
|
import os
|
||||||
|
module_abs_filename = os.path.abspath(__file__)
|
||||||
|
module_dir = os.path.dirname(module_abs_filename)
|
||||||
|
# print(os.path.dirname(module_dir))
|
||||||
|
|
||||||
|
def _get_version():
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
import tomli
|
||||||
|
return tomli.load(open(os.path.join(os.path.dirname(module_dir), "pyproject.toml"), "rb"))["tool"]["poetry"]["version"]
|
||||||
|
except:
|
||||||
|
from importlib.metadata import version
|
||||||
|
return version(__package__)
|
||||||
|
except:
|
||||||
|
return "0.0.0"
|
||||||
|
|
||||||
|
__version__ = _get_version()
|
27
rnsh/args.py
27
rnsh/args.py
|
@ -1,6 +1,6 @@
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
import RNS
|
import RNS
|
||||||
import importlib.metadata
|
import rnsh
|
||||||
import docopt
|
import docopt
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
@ -64,13 +64,16 @@ class Args:
|
||||||
def __init__(self, argv: [str]):
|
def __init__(self, argv: [str]):
|
||||||
global usage
|
global usage
|
||||||
try:
|
try:
|
||||||
argv, program_args = _split_array_at(argv, "--")
|
self.argv = argv
|
||||||
|
self.program_args = []
|
||||||
|
self.docopts_argv, self.program_args = _split_array_at(self.argv, "--")
|
||||||
# need to add first arg after -- back onto argv for docopts, but only for listener
|
# need to add first arg after -- back onto argv for docopts, but only for listener
|
||||||
if len(program_args) > 0 and next(filter(lambda a: a == "-l" or a == "--listen", argv), None) is not None:
|
if next(filter(lambda a: a == "-l" or a == "--listen", self.docopts_argv), None) is not None \
|
||||||
argv.append(program_args[0])
|
and len(self.program_args) > 0:
|
||||||
self.program_args = program_args[1:]
|
self.docopts_argv.append(self.program_args[0])
|
||||||
|
self.program_args = self.program_args[1:]
|
||||||
|
|
||||||
args = docopt.docopt(usage, argv=argv[1:], version=f"rnsh {importlib.metadata.version('rnsh')}")
|
args = docopt.docopt(usage, argv=self.docopts_argv[1:], version=f"rnsh {rnsh.__version__}")
|
||||||
# json.dump(args, sys.stdout)
|
# json.dump(args, sys.stdout)
|
||||||
|
|
||||||
self.service_name = args.get("--service", None) or "default"
|
self.service_name = args.get("--service", None) or "default"
|
||||||
|
@ -87,23 +90,23 @@ class Args:
|
||||||
self.announce = int(announce)
|
self.announce = int(announce)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print("Invalid value for --announce")
|
print("Invalid value for --announce")
|
||||||
self.valid = False
|
sys.exit(1)
|
||||||
self.no_auth = args.get("--no-auth", None) or False
|
self.no_auth = args.get("--no-auth", None) or False
|
||||||
self.allowed = args.get("--allowed", None) or []
|
self.allowed = args.get("--allowed", None) or []
|
||||||
self.remote_cmd_as_args = args.get("--remote-command-as-args", None) or False
|
self.remote_cmd_as_args = args.get("--remote-command-as-args", None) or False
|
||||||
self.no_remote_cmd = args.get("--no-remote-command", None) or False
|
self.no_remote_cmd = args.get("--no-remote-command", None) or False
|
||||||
self.program = args.get("<program>", None)
|
self.program = args.get("<program>", None)
|
||||||
self.program_args = args.get("<arg>", None) or []
|
if len(self.program_args) == 0:
|
||||||
if self.program is not None:
|
self.program_args = args.get("<arg>", None) or []
|
||||||
self.program_args.insert(0, self.program)
|
|
||||||
self.program_args.extend(program_args)
|
|
||||||
self.no_id = args.get("--no-id", None) or False
|
self.no_id = args.get("--no-id", None) or False
|
||||||
self.mirror = args.get("--mirror", None) or False
|
self.mirror = args.get("--mirror", None) or False
|
||||||
self.timeout = args.get("--timeout", None) or RNS.Transport.PATH_REQUEST_TIMEOUT
|
self.timeout = args.get("--timeout", None) or RNS.Transport.PATH_REQUEST_TIMEOUT
|
||||||
self.destination = args.get("<destination_hash>", None)
|
self.destination = args.get("<destination_hash>", None)
|
||||||
self.help = args.get("--help", None) or False
|
self.help = args.get("--help", None) or False
|
||||||
|
self.command_line = [self.program] if self.program else []
|
||||||
|
self.command_line.extend(self.program_args)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("Error parsing arguments: {e}")
|
print(f"Error parsing arguments: {e}")
|
||||||
print()
|
print()
|
||||||
print(usage)
|
print(usage)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
|
@ -96,6 +96,8 @@ def tty_read(fd: int) -> bytes:
|
||||||
if data is not None and len(data) > 0:
|
if data is not None and len(data) > 0:
|
||||||
result.extend(data)
|
result.extend(data)
|
||||||
return result
|
return result
|
||||||
|
elif len(result) > 0:
|
||||||
|
return result
|
||||||
else:
|
else:
|
||||||
raise EOFError
|
raise EOFError
|
||||||
if data is not None and len(data) > 0:
|
if data is not None and len(data) > 0:
|
||||||
|
@ -225,7 +227,7 @@ class TTYRestorer(contextlib.AbstractContextManager):
|
||||||
return copy.deepcopy(termios.tcgetattr(self._fd))
|
return copy.deepcopy(termios.tcgetattr(self._fd))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def set_attr(self, attr: [any], when: int = termios.TCSANOW):
|
def set_attr(self, attr: [any], when: int = termios.TCSADRAIN):
|
||||||
"""
|
"""
|
||||||
Set termios attributes
|
Set termios attributes
|
||||||
:param attr: attribute list to set
|
:param attr: attribute list to set
|
||||||
|
@ -349,6 +351,7 @@ class CallbackSubprocess:
|
||||||
self._pid: int = None
|
self._pid: int = None
|
||||||
self._child_fd: int = None
|
self._child_fd: int = None
|
||||||
self._return_code: int = None
|
self._return_code: int = None
|
||||||
|
self._eof: bool = False
|
||||||
|
|
||||||
def terminate(self, kill_delay: float = 1.0):
|
def terminate(self, kill_delay: float = 1.0):
|
||||||
"""
|
"""
|
||||||
|
@ -437,7 +440,7 @@ class CallbackSubprocess:
|
||||||
return termios.tcgetattr(self._child_fd)
|
return termios.tcgetattr(self._child_fd)
|
||||||
|
|
||||||
def ttysetraw(self):
|
def ttysetraw(self):
|
||||||
tty.setraw(self._child_fd, termios.TCSANOW)
|
tty.setraw(self._child_fd, termios.TCSADRAIN)
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""
|
"""
|
||||||
|
@ -458,6 +461,8 @@ class CallbackSubprocess:
|
||||||
env[key] = self._env[key]
|
env[key] = self._env[key]
|
||||||
|
|
||||||
program = self._command[0]
|
program = self._command[0]
|
||||||
|
assert isinstance(program, str)
|
||||||
|
|
||||||
# match = re.search("^/bin/(.*sh)$", program)
|
# match = re.search("^/bin/(.*sh)$", program)
|
||||||
# if match:
|
# if match:
|
||||||
# self._command[0] = "-" + match.group(1)
|
# self._command[0] = "-" + match.group(1)
|
||||||
|
@ -509,15 +514,24 @@ class CallbackSubprocess:
|
||||||
if data is not None and len(data) > 0:
|
if data is not None and len(data) > 0:
|
||||||
callback(data)
|
callback(data)
|
||||||
except EOFError:
|
except EOFError:
|
||||||
|
self._eof = True
|
||||||
tty_unset_reader_callbacks(self._child_fd)
|
tty_unset_reader_callbacks(self._child_fd)
|
||||||
callback(CTRL_D)
|
callback(bytearray())
|
||||||
|
|
||||||
tty_add_reader_callback(self._child_fd, functools.partial(reader, self._child_fd, self._stdout_cb), self._loop)
|
tty_add_reader_callback(self._child_fd, functools.partial(reader, self._child_fd, self._stdout_cb), self._loop)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def eof(self):
|
||||||
|
return self._eof or not self.running
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def return_code(self) -> int:
|
def return_code(self) -> int:
|
||||||
return self._return_code
|
return self._return_code
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pid(self) -> int:
|
||||||
|
return self._pid
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
"""
|
"""
|
||||||
|
|
19
rnsh/rnsh.py
19
rnsh/rnsh.py
|
@ -248,7 +248,7 @@ def _protocol_response_chars_take(link_mdu: int, version: int) -> int:
|
||||||
|
|
||||||
def _protocol_request_chars_take(link_mdu: int, version: int, term: str, cmd: str) -> int:
|
def _protocol_request_chars_take(link_mdu: int, version: int, term: str, cmd: str) -> int:
|
||||||
if version >= _PROTOCOL_VERSION_2:
|
if version >= _PROTOCOL_VERSION_2:
|
||||||
return link_mdu - 15 * 8 - len(term) - len(cmd) - 20 # TODO: tune
|
return link_mdu - 15 * 8 - len(term) - len(cmd) - 20 # TODO: tune
|
||||||
else:
|
else:
|
||||||
return link_mdu // 2
|
return link_mdu // 2
|
||||||
|
|
||||||
|
@ -417,7 +417,7 @@ class Session:
|
||||||
if first_term_state is not None:
|
if first_term_state is not None:
|
||||||
# TODO: use a more specific error
|
# TODO: use a more specific error
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
self.process.tcsetattr(termios.TCSANOW, term_state[0])
|
self.process.tcsetattr(termios.TCSADRAIN, term_state[0])
|
||||||
if stdin is not None and len(stdin) > 0:
|
if stdin is not None and len(stdin) > 0:
|
||||||
if data[Session.REQUEST_IDX_VERS] < _PROTOCOL_VERSION_2:
|
if data[Session.REQUEST_IDX_VERS] < _PROTOCOL_VERSION_2:
|
||||||
stdin = base64.b64decode(stdin)
|
stdin = base64.b64decode(stdin)
|
||||||
|
@ -542,7 +542,7 @@ def _subproc_terminated(link: RNS.Link, return_code: int):
|
||||||
_loop.call_soon_threadsafe(cleanup)
|
_loop.call_soon_threadsafe(cleanup)
|
||||||
|
|
||||||
|
|
||||||
def _listen_start_proc(link: RNS.Link, remote_identity: str | None, term: str, cmd: str | None,
|
def _listen_start_proc(link: RNS.Link, remote_identity: str | None, term: str, cmd: [str],
|
||||||
loop: asyncio.AbstractEventLoop) -> Session | None:
|
loop: asyncio.AbstractEventLoop) -> Session | None:
|
||||||
log = _get_logger("_listen_start_proc")
|
log = _get_logger("_listen_start_proc")
|
||||||
try:
|
try:
|
||||||
|
@ -839,9 +839,6 @@ async def _initiate(configdir: str, identitypath: str, verbosity: int, quietness
|
||||||
log = _get_logger("_initiate")
|
log = _get_logger("_initiate")
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
_new_data = asyncio.Event()
|
_new_data = asyncio.Event()
|
||||||
command = command
|
|
||||||
if command is not None and len(command) == 1:
|
|
||||||
command = shlex.split(command[0])
|
|
||||||
|
|
||||||
data_buffer = bytearray(sys.stdin.buffer.read()) if not os.isatty(sys.stdin.fileno()) else bytearray()
|
data_buffer = bytearray(sys.stdin.buffer.read()) if not os.isatty(sys.stdin.fileno()) else bytearray()
|
||||||
|
|
||||||
|
@ -870,6 +867,7 @@ async def _initiate(configdir: str, identitypath: str, verbosity: int, quietness
|
||||||
mdu = 64
|
mdu = 64
|
||||||
rtt = 5
|
rtt = 5
|
||||||
first_loop = True
|
first_loop = True
|
||||||
|
cmdline = " ".join(command or [])
|
||||||
while not await _check_finished():
|
while not await _check_finished():
|
||||||
try:
|
try:
|
||||||
log.debug("top of client loop")
|
log.debug("top of client loop")
|
||||||
|
@ -895,7 +893,7 @@ async def _initiate(configdir: str, identitypath: str, verbosity: int, quietness
|
||||||
mdu = _protocol_request_chars_take(_link.MDU,
|
mdu = _protocol_request_chars_take(_link.MDU,
|
||||||
_PROTOCOL_VERSION_DEFAULT,
|
_PROTOCOL_VERSION_DEFAULT,
|
||||||
os.environ.get("TERM", ""),
|
os.environ.get("TERM", ""),
|
||||||
" ".join(command))
|
cmdline)
|
||||||
_new_data.set()
|
_new_data.set()
|
||||||
|
|
||||||
if _link:
|
if _link:
|
||||||
|
@ -916,6 +914,7 @@ async def _initiate(configdir: str, identitypath: str, verbosity: int, quietness
|
||||||
return 255
|
return 255
|
||||||
|
|
||||||
await process.event_wait_any([_new_data, _finished], timeout=min(max(rtt * 50, 5), 120))
|
await process.event_wait_any([_new_data, _finished], timeout=min(max(rtt * 50, 5), 120))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _loop_set_signal(sig, loop):
|
def _loop_set_signal(sig, loop):
|
||||||
|
@ -942,7 +941,7 @@ async def _rnsh_cli_main():
|
||||||
if args.listen:
|
if args.listen:
|
||||||
# log.info("command " + args.command)
|
# log.info("command " + args.command)
|
||||||
await _listen(configdir=args.config,
|
await _listen(configdir=args.config,
|
||||||
command=args.program_args,
|
command=args.command_line,
|
||||||
identitypath=args.identity,
|
identitypath=args.identity,
|
||||||
service_name=args.service_name,
|
service_name=args.service_name,
|
||||||
verbosity=args.verbose,
|
verbosity=args.verbose,
|
||||||
|
@ -964,7 +963,7 @@ async def _rnsh_cli_main():
|
||||||
destination=args.destination,
|
destination=args.destination,
|
||||||
service_name=args.service_name,
|
service_name=args.service_name,
|
||||||
timeout=args.timeout,
|
timeout=args.timeout,
|
||||||
command=args.program_args
|
command=args.command_line
|
||||||
)
|
)
|
||||||
return return_code if args.mirror else 0
|
return return_code if args.mirror else 0
|
||||||
else:
|
else:
|
||||||
|
@ -979,7 +978,7 @@ def rnsh_cli():
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
if not os.isatty(sys.stdin.fileno()):
|
if not os.isatty(sys.stdin.fileno()):
|
||||||
time.sleep(0.1) # attempting to deal with an issue with missing input
|
time.sleep(0.1) # attempting to deal with an issue with missing input
|
||||||
tty.setraw(sys.stdin.fileno(), termios.TCSANOW)
|
tty.setraw(sys.stdin.fileno(), termios.TCSADRAIN)
|
||||||
|
|
||||||
with process.TTYRestorer(sys.stdin.fileno()) as _tr, retry.RetryThread() as _retry_timer:
|
with process.TTYRestorer(sys.stdin.fileno()) as _tr, retry.RetryThread() as _retry_timer:
|
||||||
return_code = asyncio.run(_rnsh_cli_main())
|
return_code = asyncio.run(_rnsh_cli_main())
|
||||||
|
|
131
tests/helpers.py
Normal file
131
tests/helpers.py
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
import logging
|
||||||
|
import types
|
||||||
|
import typing
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import rnsh.rnsh
|
||||||
|
import asyncio
|
||||||
|
import rnsh.process
|
||||||
|
import contextlib
|
||||||
|
import threading
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import tests
|
||||||
|
import shutil
|
||||||
|
import random
|
||||||
|
|
||||||
|
module_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
module_abs_filename = os.path.abspath(tests.__file__)
|
||||||
|
module_dir = os.path.dirname(module_abs_filename)
|
||||||
|
|
||||||
|
|
||||||
|
class SubprocessReader(contextlib.AbstractContextManager):
|
||||||
|
def __init__(self, argv: [str], env: dict = None, name: str = None):
|
||||||
|
self._log = module_logger.getChild(self.__class__.__name__ + ("" if name is None else f"({name})"))
|
||||||
|
self.name = name or "subproc"
|
||||||
|
self.process: rnsh.process.CallbackSubprocess
|
||||||
|
self.loop = asyncio.get_running_loop()
|
||||||
|
self.env = env or os.environ.copy()
|
||||||
|
self.argv = argv
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._stdout = bytearray()
|
||||||
|
self.return_code: int = None
|
||||||
|
self.process = rnsh.process.CallbackSubprocess(argv=self.argv,
|
||||||
|
env=self.env,
|
||||||
|
loop=self.loop,
|
||||||
|
stdout_callback=self._stdout_cb,
|
||||||
|
terminated_callback=self._terminated_cb)
|
||||||
|
|
||||||
|
def _stdout_cb(self, data):
|
||||||
|
self._log.debug(f"_stdout_cb({data})")
|
||||||
|
with self._lock:
|
||||||
|
self._stdout.extend(data)
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
self._log.debug(f"read()")
|
||||||
|
with self._lock:
|
||||||
|
data = self._stdout.copy()
|
||||||
|
self._stdout.clear()
|
||||||
|
self._log.debug(f"read() returns {data}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _terminated_cb(self, rc):
|
||||||
|
self._log.debug(f"_terminated_cb({rc})")
|
||||||
|
self.return_code = rc
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._log.debug(f"start()")
|
||||||
|
self.process.start()
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
self._log.debug(f"cleanup()")
|
||||||
|
if self.process and self.process.running:
|
||||||
|
self.process.terminate(kill_delay=0.1)
|
||||||
|
|
||||||
|
def __exit__(self, __exc_type: typing.Type[BaseException], __exc_value: BaseException,
|
||||||
|
__traceback: types.TracebackType) -> bool:
|
||||||
|
self._log.debug(f"__exit__({__exc_type}, {__exc_value}, {__traceback})")
|
||||||
|
self.cleanup()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def replace_text_in_file(filename: str, text: str, replacement: str):
|
||||||
|
# Read in the file
|
||||||
|
with open(filename, 'r') as file:
|
||||||
|
filedata = file.read()
|
||||||
|
|
||||||
|
# Replace the target string
|
||||||
|
filedata = filedata.replace(text, replacement)
|
||||||
|
|
||||||
|
# Write the file out again
|
||||||
|
with open(filename, 'w') as file:
|
||||||
|
file.write(filedata)
|
||||||
|
|
||||||
|
|
||||||
|
class tempdir(object):
|
||||||
|
"""Sets the cwd within the context
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path (Path): The path to the cwd
|
||||||
|
"""
|
||||||
|
def __init__(self, cd: bool = False):
|
||||||
|
self.cd = cd
|
||||||
|
self.tempdir = tempfile.TemporaryDirectory()
|
||||||
|
self.path = self.tempdir.name
|
||||||
|
self.origin = pathlib.Path().absolute()
|
||||||
|
self.configfile = os.path.join(self.path, "config")
|
||||||
|
|
||||||
|
def setup_files(self):
|
||||||
|
shutil.copy(os.path.join(module_dir, "reticulum_test_config"), self.configfile)
|
||||||
|
port1 = random.randint(30000, 65000)
|
||||||
|
port2 = port1 + 1
|
||||||
|
replace_text_in_file(self.configfile, "22222", str(port1))
|
||||||
|
replace_text_in_file(self.configfile, "22223", str(port2))
|
||||||
|
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.setup_files()
|
||||||
|
if self.cd:
|
||||||
|
os.chdir(self.path)
|
||||||
|
|
||||||
|
return self.path
|
||||||
|
|
||||||
|
def __exit__(self, exc, value, tb):
|
||||||
|
if self.cd:
|
||||||
|
os.chdir(self.origin)
|
||||||
|
self.tempdir.__exit__(exc, value, tb)
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_and_cleanup():
|
||||||
|
td = None
|
||||||
|
with tests.helpers.tempdir() as td:
|
||||||
|
assert os.path.isfile(os.path.join(td, "config"))
|
||||||
|
with open(os.path.join(td, "config"), 'r') as file:
|
||||||
|
filedata = file.read()
|
||||||
|
assert filedata.index("acehoss test config") > 0
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
filedata.index("22222")
|
||||||
|
assert not os.path.exists(os.path.join(td, "config"))
|
15
tests/reticulum_test_config
Normal file
15
tests/reticulum_test_config
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
# acehoss test config
|
||||||
|
[reticulum]
|
||||||
|
enable_transport = False
|
||||||
|
share_instance = Yes
|
||||||
|
shared_instance_port = 22222
|
||||||
|
instance_control_port = 22223
|
||||||
|
panic_on_interface_error = No
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
loglevel = 7
|
||||||
|
|
||||||
|
[interfaces]
|
||||||
|
[[Default Interface]]
|
||||||
|
type = AutoInterface
|
||||||
|
enabled = Yes
|
77
tests/test_args.py
Normal file
77
tests/test_args.py
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
import rnsh.args
|
||||||
|
import shlex
|
||||||
|
import docopt
|
||||||
|
|
||||||
|
def test_program_args():
|
||||||
|
docopt_threw = False
|
||||||
|
try:
|
||||||
|
args = rnsh.args.Args(shlex.split("rnsh -l -n one two three"))
|
||||||
|
assert args.listen
|
||||||
|
assert args.program == "one"
|
||||||
|
assert args.program_args == ["two", "three"]
|
||||||
|
assert args.command_line == ["one", "two", "three"]
|
||||||
|
except docopt.DocoptExit:
|
||||||
|
docopt_threw = True
|
||||||
|
assert not docopt_threw
|
||||||
|
|
||||||
|
|
||||||
|
def test_program_args_dash():
|
||||||
|
docopt_threw = False
|
||||||
|
try:
|
||||||
|
args = rnsh.args.Args(shlex.split("rnsh -l -n -- one -l -C"))
|
||||||
|
assert args.listen
|
||||||
|
assert args.program == "one"
|
||||||
|
assert args.program_args == ["-l", "-C"]
|
||||||
|
assert args.command_line == ["one", "-l", "-C"]
|
||||||
|
except docopt.DocoptExit:
|
||||||
|
docopt_threw = True
|
||||||
|
assert not docopt_threw
|
||||||
|
|
||||||
|
def test_program_initiate_no_args():
|
||||||
|
docopt_threw = False
|
||||||
|
try:
|
||||||
|
args = rnsh.args.Args(shlex.split("rnsh one"))
|
||||||
|
assert not args.listen
|
||||||
|
assert args.destination == "one"
|
||||||
|
assert args.command_line == []
|
||||||
|
except docopt.DocoptExit:
|
||||||
|
docopt_threw = True
|
||||||
|
assert not docopt_threw
|
||||||
|
|
||||||
|
def test_program_initiate_dash_args():
|
||||||
|
docopt_threw = False
|
||||||
|
try:
|
||||||
|
args = rnsh.args.Args(shlex.split("rnsh --config ~/Projects/rnsh/testconfig -s test -vvvvvvv a5f72aefc2cb3cdba648f73f77c4e887 -- -l"))
|
||||||
|
assert not args.listen
|
||||||
|
assert args.config == "~/Projects/rnsh/testconfig"
|
||||||
|
assert args.service_name == "test"
|
||||||
|
assert args.verbose == 7
|
||||||
|
assert args.destination == "a5f72aefc2cb3cdba648f73f77c4e887"
|
||||||
|
assert args.command_line == ["-l"]
|
||||||
|
except docopt.DocoptExit:
|
||||||
|
docopt_threw = True
|
||||||
|
assert not docopt_threw
|
||||||
|
|
||||||
|
|
||||||
|
def test_program_listen_config_print():
|
||||||
|
docopt_threw = False
|
||||||
|
try:
|
||||||
|
args = rnsh.args.Args(shlex.split("rnsh -l --config testconfig -p"))
|
||||||
|
assert args.listen
|
||||||
|
assert args.config == "testconfig"
|
||||||
|
assert args.print_identity
|
||||||
|
assert args.command_line == []
|
||||||
|
except docopt.DocoptExit:
|
||||||
|
docopt_threw = True
|
||||||
|
assert not docopt_threw
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_at():
|
||||||
|
a, b = rnsh.args._split_array_at(["one", "two", "three"], "two")
|
||||||
|
assert a == ["one"]
|
||||||
|
assert b == ["three"]
|
||||||
|
|
||||||
|
def test_split_at_not_found():
|
||||||
|
a, b = rnsh.args._split_array_at(["one", "two", "three"], "four")
|
||||||
|
assert a == ["one", "two", "three"]
|
||||||
|
assert b == []
|
|
@ -1,68 +1,20 @@
|
||||||
import uuid
|
import tests.helpers
|
||||||
import time
|
import time
|
||||||
import pytest
|
import pytest
|
||||||
import rnsh.process
|
import rnsh.process
|
||||||
import contextlib
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
import types
|
|
||||||
import typing
|
|
||||||
import multiprocessing.pool
|
import multiprocessing.pool
|
||||||
logging.getLogger().setLevel(logging.DEBUG)
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
class State(contextlib.AbstractContextManager):
|
|
||||||
def __init__(self, argv: [str], loop: asyncio.AbstractEventLoop, env: dict = None):
|
|
||||||
self.process: rnsh.process.CallbackSubprocess
|
|
||||||
self.loop = loop
|
|
||||||
self.env = env or os.environ.copy()
|
|
||||||
self.argv = argv
|
|
||||||
self._lock = threading.RLock()
|
|
||||||
self._stdout = bytearray()
|
|
||||||
self.return_code: int = None
|
|
||||||
self.process = rnsh.process.CallbackSubprocess(argv=self.argv,
|
|
||||||
env=self.env,
|
|
||||||
loop=self.loop,
|
|
||||||
stdout_callback=self._stdout_cb,
|
|
||||||
terminated_callback=self._terminated_cb)
|
|
||||||
|
|
||||||
def _stdout_cb(self, data):
|
|
||||||
with self._lock:
|
|
||||||
self._stdout.extend(data)
|
|
||||||
|
|
||||||
def read(self):
|
|
||||||
with self._lock:
|
|
||||||
data = self._stdout.copy()
|
|
||||||
self._stdout.clear()
|
|
||||||
return data
|
|
||||||
|
|
||||||
def _terminated_cb(self, rc):
|
|
||||||
self.return_code = rc
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
self.process.start()
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
if self.process and self.process.running:
|
|
||||||
self.process.terminate(kill_delay=0.1)
|
|
||||||
|
|
||||||
def __exit__(self, __exc_type: typing.Type[BaseException], __exc_value: BaseException,
|
|
||||||
__traceback: types.TracebackType) -> bool:
|
|
||||||
self.cleanup()
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip_ci
|
@pytest.mark.skip_ci
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_echo():
|
async def test_echo():
|
||||||
"""
|
"""
|
||||||
Echoing some text through cat.
|
Echoing some text through cat.
|
||||||
"""
|
"""
|
||||||
loop = asyncio.get_running_loop()
|
with tests.helpers.SubprocessReader(argv=["/bin/cat"]) as state:
|
||||||
with State(argv=["/bin/cat"],
|
|
||||||
loop=loop) as state:
|
|
||||||
state.start()
|
state.start()
|
||||||
assert state.process is not None
|
assert state.process is not None
|
||||||
assert state.process.running
|
assert state.process.running
|
||||||
|
@ -84,9 +36,7 @@ async def test_echo_live():
|
||||||
"""
|
"""
|
||||||
Check for immediate echo
|
Check for immediate echo
|
||||||
"""
|
"""
|
||||||
loop = asyncio.get_running_loop()
|
with tests.helpers.SubprocessReader(argv=["/bin/cat"]) as state:
|
||||||
with State(argv=["/bin/cat"],
|
|
||||||
loop=loop) as state:
|
|
||||||
state.start()
|
state.start()
|
||||||
assert state.process is not None
|
assert state.process is not None
|
||||||
assert state.process.running
|
assert state.process.running
|
||||||
|
@ -101,6 +51,38 @@ async def test_echo_live():
|
||||||
assert decoded == message
|
assert decoded == message
|
||||||
assert not state.process.running
|
assert not state.process.running
|
||||||
|
|
||||||
|
@pytest.mark.skip_ci
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_double_echo_live():
|
||||||
|
"""
|
||||||
|
Check for immediate echo
|
||||||
|
"""
|
||||||
|
with tests.helpers.SubprocessReader(name="state", argv=["/bin/cat"]) as state:
|
||||||
|
with tests.helpers.SubprocessReader(name="state2", argv=["/bin/cat"]) as state2:
|
||||||
|
state.start()
|
||||||
|
state2.start()
|
||||||
|
assert state.process is not None
|
||||||
|
assert state.process.running
|
||||||
|
assert state2.process is not None
|
||||||
|
assert state2.process.running
|
||||||
|
message = "t"
|
||||||
|
state.process.write(message.encode("utf-8"))
|
||||||
|
state2.process.write(message.encode("utf-8"))
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
data = state.read()
|
||||||
|
data2 = state2.read()
|
||||||
|
state.process.write(rnsh.process.CTRL_C)
|
||||||
|
state2.process.write(rnsh.process.CTRL_C)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
assert len(data) > 0
|
||||||
|
assert len(data2) > 0
|
||||||
|
decoded = data.decode("utf-8")
|
||||||
|
decoded2 = data.decode("utf-8")
|
||||||
|
assert decoded == message
|
||||||
|
assert decoded2 == message
|
||||||
|
assert not state.process.running
|
||||||
|
assert not state2.process.running
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_event_wait_any():
|
async def test_event_wait_any():
|
||||||
|
|
|
@ -1,7 +1,17 @@
|
||||||
import logging
|
import logging
|
||||||
import rnsh.rnsh
|
|
||||||
logging.getLogger().setLevel(logging.DEBUG)
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
import tests.helpers
|
||||||
|
import rnsh.rnsh
|
||||||
|
import rnsh.process
|
||||||
|
import shlex
|
||||||
|
import pytest
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_check_magic():
|
def test_check_magic():
|
||||||
magic = rnsh.rnsh._PROTOCOL_VERSION_0
|
magic = rnsh.rnsh._PROTOCOL_VERSION_0
|
||||||
|
@ -13,3 +23,133 @@ def test_check_magic():
|
||||||
magic = magic | 0x00ffff0000000000
|
magic = magic | 0x00ffff0000000000
|
||||||
# make sure it fails now
|
# make sure it fails now
|
||||||
assert not rnsh.rnsh._protocol_check_magic(magic)
|
assert not rnsh.rnsh._protocol_check_magic(magic)
|
||||||
|
|
||||||
|
|
||||||
|
def test_version():
|
||||||
|
# version = importlib.metadata.version(rnsh.__version__)
|
||||||
|
assert rnsh.__version__ != "0.0.0"
|
||||||
|
assert rnsh.__version__ != "0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip_ci
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wrapper():
|
||||||
|
with tests.helpers.tempdir() as td:
|
||||||
|
with tests.helpers.SubprocessReader(argv=shlex.split(f"date")) as wrapper:
|
||||||
|
wrapper.start()
|
||||||
|
assert wrapper.process is not None
|
||||||
|
assert wrapper.process.running
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
text = wrapper.read().decode("utf-8")
|
||||||
|
assert len(text) > 5
|
||||||
|
assert not wrapper.process.running
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip_ci
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rnsh_listen_start_stop():
|
||||||
|
with tests.helpers.tempdir() as td:
|
||||||
|
with tests.helpers.SubprocessReader(argv=shlex.split(f"poetry run rnsh -l --config \"{td}\" -n -C -vvvvvv -- /bin/ls")) as wrapper:
|
||||||
|
wrapper.start()
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
assert wrapper.process.running
|
||||||
|
# wait for process to start up
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
# read the output
|
||||||
|
text = wrapper.read().decode("utf-8")
|
||||||
|
# listener should have printed "listening
|
||||||
|
assert text.index("listening") is not None
|
||||||
|
# stop process with SIGINT
|
||||||
|
wrapper.process.write(rnsh.process.CTRL_C)
|
||||||
|
# wait for process to wind down
|
||||||
|
start_time = time.time()
|
||||||
|
while wrapper.process.running and time.time() - start_time < 5:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
assert not wrapper.process.running
|
||||||
|
|
||||||
|
|
||||||
|
async def get_id_and_dest(td: str) -> tuple[str, str]:
|
||||||
|
with tests.helpers.SubprocessReader(name="getid", argv=shlex.split(f"poetry run -- rnsh -l --config \"{td}\" -p")) as wrapper:
|
||||||
|
wrapper.start()
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
assert wrapper.process.running
|
||||||
|
# wait for process to start up
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
# read the output
|
||||||
|
text = wrapper.read().decode("utf-8").replace("\r", "").replace("\n", "")
|
||||||
|
assert text.index("Identity") is not None
|
||||||
|
match = re.search(r"<([a-f0-9]{32})>[^<]+<([a-f0-9]{32})>", text)
|
||||||
|
assert match is not None
|
||||||
|
ih = match.group(1)
|
||||||
|
assert len(ih) == 32
|
||||||
|
dh = match.group(2)
|
||||||
|
assert len(dh) == 32
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
assert not wrapper.process.running
|
||||||
|
return ih, dh
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip_ci
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rnsh_get_id_and_dest() -> [int]:
|
||||||
|
with tests.helpers.tempdir() as td:
|
||||||
|
ih, dh = await get_id_and_dest(td)
|
||||||
|
assert len(ih) == 32
|
||||||
|
assert len(dh) == 32
|
||||||
|
|
||||||
|
|
||||||
|
async def do_connected_test(listener_args: str, initiator_args: str, test: callable):
|
||||||
|
with tests.helpers.tempdir() as td:
|
||||||
|
ih, dh = await get_id_and_dest(td)
|
||||||
|
assert len(ih) == 32
|
||||||
|
assert len(dh) == 32
|
||||||
|
with tests.helpers.SubprocessReader(name="listener", argv=shlex.split(f"poetry run -- rnsh -l --config \"{td}\" {listener_args}")) as listener, \
|
||||||
|
tests.helpers.SubprocessReader(name="initiator", argv=shlex.split(f"poetry run -- rnsh --config \"{td}\" {dh} {initiator_args}")) as initiator:
|
||||||
|
# listener startup
|
||||||
|
listener.start()
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
assert listener.process.running
|
||||||
|
# wait for process to start up
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
# read the output
|
||||||
|
text = listener.read().decode("utf-8")
|
||||||
|
assert text.index(dh) is not None
|
||||||
|
|
||||||
|
# initiator run
|
||||||
|
initiator.start()
|
||||||
|
assert initiator.process.running
|
||||||
|
|
||||||
|
await test(td, ih, dh, listener, initiator)
|
||||||
|
|
||||||
|
# expect test to shut down initiator
|
||||||
|
assert not initiator.process.running
|
||||||
|
|
||||||
|
# stop process with SIGINT
|
||||||
|
listener.process.write(rnsh.process.CTRL_C)
|
||||||
|
# wait for process to wind down
|
||||||
|
start_time = time.time()
|
||||||
|
while listener.process.running and time.time() - start_time < 5:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
assert not listener.process.running
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip_ci
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rnsh_get_echo_through():
|
||||||
|
cwd = os.getcwd()
|
||||||
|
|
||||||
|
async def test(td: str, ih: str, dh: str, listener: tests.helpers.SubprocessReader,
|
||||||
|
initiator: tests.helpers.SubprocessReader):
|
||||||
|
start_time = time.time()
|
||||||
|
while initiator.return_code is None and time.time() - start_time < 3:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
text = initiator.read().decode("utf-8").replace("\r", "").replace("\n", "")
|
||||||
|
assert text == cwd
|
||||||
|
|
||||||
|
await do_connected_test("-n -C -- /bin/pwd", "", test)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue