prompt-toolkit / python-prompt-toolkit

Library for building powerful interactive command line applications in Python
https://python-prompt-toolkit.readthedocs.io/
BSD 3-Clause "New" or "Revised" License
9.21k stars 715 forks source link

Connect prompt_toolkit to stdout / stdin of an asyncssh server #902

Open mpenning opened 5 years ago

mpenning commented 5 years ago

I'm experimenting with asyncssh to build a custom SSH server; I want to connect each client to a pseudo-shell based on prompt_toolkit. Clients will enter commands into that pseudo-shell to control the server.

In this case, I'm just building a chat app; the experiment largely follows the example under asyncssh's "Serving multiple clients" in the documentation.

My problem is I can't figure out how to reassign stdin and stdout for each client. prompt_toolkit seems to assume that the server's stdout is where prompts are sent; however, I need the prompts to use each individual client's stdin and stdout.

QUESTION

This is my first attempt to build anything with either asyncssh and prompt_toolkit, so perhaps I'm missing something. Is there a way to make prompt_toolkit use the client's stdin and stdout?

My (obviously broken) attempt at this is shown below; the problem is in the interact() method.

import asyncio
import crypt
import sys
import os

from prompt_toolkit.eventloop.defaults import use_asyncio_event_loop
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit import prompt

import asyncssh

# Tell prompt_toolkit to use the asyncio event loop.
use_asyncio_event_loop()

class ChatClient:
    _clients = []

    def __init__(self, process):
        self._process = process

    @classmethod
    async def handle_client(cls, process):
        await cls(process).interact()

    def write(self, msg):
        self._process.stdout.write(msg)

    def broadcast(self, msg):
        for client in self._clients:
            if client != self:
                client.write(msg)

    async def interact(self):
        self.write('Welcome to chat!\n\n')

        self.write('Enter your name: ')
        name = (await self._process.stdin.readline()).rstrip('\n')

        self.write('\n%d other users are connected.\n\n' % len(self._clients))

        self._clients.append(self)
        self.broadcast('*** %s has entered chat ***\n' % name)

        try:
            ### This works, but it isn't using prompt toolkit...
            #async for line in self._process.stdin:
            #    self.broadcast('%s: %s' % (name, line))
            #    self._process.stdout.write('chat# ')
            while True:
                line = await prompt('chat# ', async_=True) # <--- Should use client's stdout
                self.broadcast('{0}: {1}'.format(name, line))
        except asyncssh.BreakReceived:
            pass

        self.broadcast('*** %s has left chat ***\n' % name)
        self._clients.remove(self)

passwords = {'guest': '',                 # guest account with no password
             'user123': 'qV2iEadIGV2rw'   # password of 'secretpw'
            }

class CustomSSHServer(asyncssh.SSHServer):
    def connection_made(self, conn):
        print('SSH connection received from %s.' %
                  conn.get_extra_info('peername')[0])

    def connection_lost(self, exc):
        if exc:
            print('SSH connection error: ' + str(exc), file=sys.stderr)
        else:
            print('SSH connection closed.')

    def begin_auth(self, username):
        # If the user's password is the empty string, no auth is required
        return passwords.get(username) != ''

    def password_auth_supported(self):
        return True

    def validate_password(self, username, password):
        pw = passwords.get(username, '*')
        return crypt.crypt(password, pw) == pw

async def start_server():
    await asyncssh.create_server(CustomSSHServer, '', 8022,
                        server_host_keys=['ssh_host_key'],
                        process_factory=ChatClient.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()
jonathanslenders commented 5 years ago

Hi @mpenning,

This is possible, yes. I don't have much time right now to look into it and provide an out-of-the-box solution.

But you can look at this code: https://github.com/prompt-toolkit/ptpython/blob/master/ptpython/contrib/asyncssh_repl.py That's very old code, which is broken right now, because it still expects prompt_toolkit 1.0 (I forgot to upgrade this piece.) But it demonstrates how to create the input and output objects required by the prompt_toolkit Application (CommandLineInterface back then).

For this, I would suggest to use prompt_toolkit 3.0 (the master branch), which is currently not yet released but pretty stable. Then, for every connection you have to create an AppSession, see: https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/prompt_toolkit/application/current.py#L131 This is required in order to run multiple prompt_toolkit applications alongside each other. (It requires Python 3.7, because it requires contextvars). The input and output objects have to be passed to the AppSession.

You can also look at how prompt_toolkit.contrib.telnet is implemented, and the chat-server in the examples: https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/examples/telnet/chat-app.py

I hope that helps.

jonathanslenders commented 5 years ago

I merged some asyncssh integration code.

Please have a look at this example, try running it with the latest prompt_toolkit and see whether that's what you are looking for.

https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/examples/ssh/asyncssh-server.py

mpenning commented 5 years ago

Please have a look at this example

Wow, this is so much more than I expected.

A couple of points... in contrib/ssh/server.py, you probably want to include password authentication or a comment explaining that you need to customize authentication yourself just so it's easier for people unfamiliar with asyncssh to use the example. I made some local modifications to contrib/ssh/server.py so it used passwd auth, but others may be confused by an sshd that doesn't authenticate.

Also, maybe at the beginning of contrib/ssh/server.py include a line like assert sys.version_info >= (3, 7, 0) so it's more obvious that this requires python 3.7.

Overall, I'm very grateful for this example. Thank you!