Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

asyncssh server with a cmd2 application #648

Closed
xuoguoto opened this issue Apr 9, 2024 · 8 comments
Closed

asyncssh server with a cmd2 application #648

xuoguoto opened this issue Apr 9, 2024 · 8 comments

Comments

@xuoguoto
Copy link

xuoguoto commented Apr 9, 2024

Hello,

I have a cmd2 application which I want to serve via ssh. As I understand, cmd2 application expects that its running in a pty, and I can wrap the application in a pty and run, which works perfectly. Now how to embed this in asycssh event loop is what I am looking for:

Here is a simple cmd2 application which i am using as example:

#!/usr/bin/env python
"""A simple cmd2 application."""
import cmd2

class FirstApp(cmd2.Cmd):
    """A simple cmd2 application."""

if __name__ == '__main__':
    import sys
    c = FirstApp()
    sys.exit(c.cmdloop())

If the above script is named cmd01.py, you can use this script to run the above in a pty:

# script.py
import os
import pty

shell = "./cmd01.py"

def read(fd):
    data = os.read(fd, 1024)
    return data

pty.spawn(shell, read)

Running the program will give an output like:

$ python3 script.py 
(Cmd) help

Documented commands (use 'help -v' for verbose/'help <topic>' for details):
===========================================================================
alias  help     macro  run_pyscript  set    shortcuts
edit   history  quit   run_script    shell

(Cmd) quit
$

Normal command line expectations like tab completion and bash like cli navigation (ctrl-a, ctrl-e) etc work fine in this.

From the documentation of pty:

A loop copies STDIN of the current process to the child and data received from the child to STDOUT of the current process.
The functions master_read and stdin_read are passed a file descriptor which they should read from, and they should always return a byte string.

In the sample program above, read is the master_read and sees all input from and to the shell

Now, how could such a program be invoked by asyncssh? I have search far and wide and could not get a way to start. I am sure some one must have done this because cmd2 is a good way to build an interactive command line application and next obvious step is to distribute it over ssh.

Thanks!

@xuoguoto
Copy link
Author

xuoguoto commented Apr 9, 2024

Thinking more about it, I guess if the stdin and stdout of pty.spawn(shell, read) can be redirected to the asyncssh input/output pty.spawn(shell, read) will be able to feed it to the program and get it working.

@xuoguoto
Copy link
Author

Making some more progress:

Adapting the IO redirection example:

import asyncio, asyncssh, subprocess, sys

async def handle_client(process: asyncssh.SSHServerProcess) -> None:
    bc_proc = subprocess.Popen('./script.py', shell=True, stdin=subprocess.PIPE,
                               stdout=subprocess.PIPE, stderr=subprocess.PIPE)

    await process.redirect(stdin=bc_proc.stdin, stdout=bc_proc.stdout,
                           stderr=bc_proc.stderr)
    await process.stdout.drain()
    process.exit(0)

async def start_server() -> None:
    await asyncssh.listen('', 8022, server_host_keys=['ssh_host_key'],
                          authorized_client_keys='ssh_user_ca',
                          process_factory=handle_client)

loop = asyncio.get_event_loop()

try:
    loop.run_until_complete(start_server())
except (OSError, asyncssh.Error) as exc:
    sys.exit('Error starting server: ' + str(exc))

loop.run_forever()

where script.py is the pty program given above. When running this I am almost there, except tab completion is not working over ssh, also Ctrl+W (Cut the word before the cursor) is not working.

Also, I am not sure if there are better ways to do this than calling two python programs as binaries in a chain to get this working.

@ronf
Copy link
Owner

ronf commented Apr 10, 2024

I don't think this will be straightforward. The cmd2 module is not async-friendly. There is some discussion about this at https://cmd2.readthedocs.io/en/latest/examples/alternate_event_loops.html, but it doesn't really go into detail about how you'd change all of its synchronous calls which are doing I/O to be able to be fed from async functions. I saw one suggestion online about running each async operation using loop.run_until_complete(), but that definitely won't work with AsyncSSH.

If you just want line input editing and history, AsyncSSH actually has the capability built into it, for precisely a case like this where you want to set up AsyncSSH as a server and accept multiple connections from clients, allowing each of them to enter input lines which get delivered to the application a line at a time (allowing line editing until the user hits ). History is supported by this as well. However, there's no "shell" here, so you'd still need to implement your own parser for the input which gets delivered.

More info on AsyncSSH's line editing can be found at https://asyncssh.readthedocs.io/en/latest/#line-editing.

While it might be possible to spin off a separate process and PTY for each connection, that would largely defeat the purpose of using AsyncSSH here. At that point, you'd probably be better off just creating an account on the target system which had a shell of the cmd2 script you want to provide access to, and just let people SSH into that account using the system's SSH server.

@ronf
Copy link
Owner

ronf commented Apr 10, 2024

Reading your redirection example, you probably need to disable the AsyncSSH line editing I mentioned if you want the data passed through a character at a time to the remote script, so it can do the line editing. Just add "line_editor=False" when starting up the SSH listener.

@xuoguoto
Copy link
Author

xuoguoto commented Apr 10, 2024

Thanks @ronf I have added line_editor=False and now I can get the cmd2 shell over ssh with tab completion.

As you have mentioned its true that spawning two separate processes for every connected client is not very optimal. But with asyncssh in picture, I have an advantage of python based user management and not to tinker with system users.

I also have one more additional requirement, is it possible to select which shell I need to launch when I connect?

It's a bit weird requirement, where I have two separate programs to manage two separate aspects of the system. While those two will eventually be combined into one, till that happens, I need to access both via ssh. So when I ssh into the system if there is a means to choose which program I want to launch, then I can choose between them at startup. And once I exit one of the shell, I should be able to choose the other.

@ronf
Copy link
Owner

ronf commented Apr 11, 2024

Are you thinking of having AsyncSSH prompt the user about which script to run on session start, and then ask again later when that script exits? That should be possible, but you'll probably want to leave the line_editor enabled in that case to do the initial interaction with the user. Then, right before you set up the redirects, you'd disable it, and later when the script exited you'd undo the redirects and turn the editor back on before looping back to prompting the user again.

@ronf
Copy link
Owner

ronf commented Apr 13, 2024

Here's an example of what I think you were asking about:

import asyncio, asyncssh, sys

async def handle_client(process: asyncssh.SSHServerProcess) -> None:
    process.stdout.write('Choose script to run: \n\n  1. foo\n  2. bar\n')

    while True:
        process.stdout.write('\nScript to run: ')
        line = await process.stdin.readline()

        if line == '1\n':
            script = 'echo foo'
        elif line == '2\n':
            script = 'echo bar'
        elif line in ('', 'q\n'):
            break
        else:
            process.stdout.write('Invalid option, try again.\n')
            continue

        process.channel.set_line_mode(False)

        bc_proc = await asyncio.create_subprocess_shell(
            script, shell=True, stdin=asyncio.subprocess.PIPE,
            stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)

        await process.redirect(stdin=bc_proc.stdin, stdout=bc_proc.stdout,
                               stderr=bc_proc.stderr, send_eof=False,
                               recv_eof=False)

        await process.stdout.drain()

        await process.redirect(stdin=asyncssh.PIPE, stdout=asyncssh.PIPE,
                               stderr=asyncssh.PIPE, send_eof=False,
                               recv_eof=False)

        process.channel.set_line_mode(True)

    process.stdout.write('\nGoodbye!\n')
    process.exit(0)

async def start_server() -> None:
    await asyncssh.listen('', 8022, server_host_keys=['ssh_host_key'],
                          authorized_client_keys='ssh_user_ca',
                          process_factory=handle_client)

loop = asyncio.get_event_loop()

try:
    loop.run_until_complete(start_server())
except (OSError, asyncssh.Error) as exc:
    sys.exit('Error starting server: ' + str(exc))

loop.run_forever()

@xuoguoto
Copy link
Author

I think this will be good! Thankyou!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants