asyncssh icon indicating copy to clipboard operation
asyncssh copied to clipboard

Override `listen_host` and `listen_port` in `server_requested()`

Open pcorbel opened this issue 1 year ago • 4 comments

Hello there 👋, First, I wanted to thanks all the contributors to this project, especially @ronf for this amazing work. I have a use-case that I couldn't crack yet without your help. I want the ssh client to simply execute

ssh -R localhost:3000 [email protected]

and be able in the server_requested() method to override the listen_host to 127.0.0.1 and listen_port to a dynamic port generated by myself.

Here is my code

class MySSHServer(SSHServer):
    async def start(self) -> None:
        await create_server(
            server_factory=MySSHServer,
            host="0.0.0.0",
            port=2222,
            server_host_keys=["my.key"],
            process_factory=self.handle_client,
        )

    async def handle_client(self, process: SSHServerProcess) -> None:
        process.stdout.write("Hello world")
        process.exit(0)

    def server_requested(self, listen_host: str, listen_port: int) -> bool:
        # I want the ssh client to simply execute: ssh -R localhost:3000 [email protected]
        # and then always force listen_host to 127.0.0.1 and listen_port to a dynamic port generated by myself
        return True

    def password_auth_supported(self) -> bool:
        return True

    def validate_password(self, username: str, password: str) -> bool:
        return True

Is it doable? After digging trough the source code, I know I have to override the server_requested() method and to return a SSHListener (or better a SSHForwardListener) instance, by calling the create_tcp_forward_listener() method to generate it, but I do not know how to do it myself.

Thanks a lot

pcorbel avatar Jul 09 '22 13:07 pcorbel

This should be pretty simple. If I understand your use case, you can use the existing forward_local_port() function on the SSHServerConnection to do the bulk of the work for you. It should look something like:

import asyncio, import asyncio, asyncssh, sys

class MySSHServer(asyncssh.SSHServer):
    def connection_made(self, conn):
        self._conn = conn

    async def server_requested(self, listen_host: str, listen_port: int) -> bool:
        listener = await self._conn.forward_local_port(listen_host, 0,
                                                       listen_host, listen_port)

        print(f'Listening on {listener.get_port()}')
        return listener

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

loop = asyncio.new_event_loop()

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

loop.run_forever()

Basically, instead of having server_requested return True when you want to allow the connection, you tell it you want to create a listener on a dynamic port (listen_port of 0). However, you still pass in the original listen_port requested by the SSH client as the dest_port, so that it will think the SSH server is listening on the originally requested port (3000 in your example). The end result will be that the SSH client will still forward connections to port 3000 whenever a new connection comes in on your dynamic port.

If you like, you can put in checks in server_requested() which only return this listener when the requested local_port is 3000, returning False in cases where you don't want to allow the request. As shown in the example here, you can get the dynamic port from the listener object you create, so you can tell remote clients what port number to connect to.

I've got this working, but I did see a problem in OpenSSH acting as a client for this. When I ran it with your example of "-R localhost:3000", the listener got set up correctly, but OpenSSH seemed to silently fail when trying to make the outbound connection to localhost:3000 after a connection came in on the dynamic port. This caused the remote client to get an immediate connection closed. I was able to work around this by running OpenSSH with "-R localhost:3000:localhost:3000", explicitly telling it what local destination IP and port to connect to when new remote connections come in.

When things work correctly (with "-R localhost:3000:localhost:3000"), the OpenSSSH log looks something like:

debug1: Remote connections from localhost:3000 forwarded to local address localhost:3000
debug3: send packet: type 80
debug2: fd 5 setting TCP_NODELAY
debug3: set_sock_tos: set socket 5 IPV6_TCLASS 0x48
debug1: Entering interactive session.
debug1: pledge: network
debug3: receive packet: type 2
debug3: Received SSH2_MSG_IGNORE
debug3: receive packet: type 81
debug1: remote forward success for: listen localhost:3000, connect localhost:3000
debug2: forwarding_success: -1 expected forwarding replies remaining

After a remote connection is opened, it logs:

debug3: receive packet: type 2
debug3: Received SSH2_MSG_IGNORE
debug3: receive packet: type 90
debug1: client_input_channel_open: ctype forwarded-tcpip rchan 0 win 2097152 max 32768
debug1: client_request_forwarded_tcpip: listen localhost port 3000, originator 127.0.0.1 port 49702
debug2: fd 6 setting O_NONBLOCK
debug2: fd 6 setting TCP_NODELAY
debug1: connect_next: host localhost ([::1]:3000) in progress, fd=6
debug3: fd 6 is O_NONBLOCK
debug3: fd 6 is O_NONBLOCK
debug1: channel 0: new [127.0.0.1]
debug1: confirm forwarded-tcpip
debug3: channel 0: waiting for connection
debug1: channel 0: connected to localhost port 3000
debug3: send packet: type 91

When trying this with "-R localhost:3000", it logs the following:

debug1: Remote connections from localhost:3000 forwarded to local address socks:0
debug3: send packet: type 80
debug2: fd 5 setting TCP_NODELAY
debug3: set_sock_tos: set socket 5 IPV6_TCLASS 0x48
debug1: Entering interactive session.
debug1: pledge: network
debug3: receive packet: type 2
debug3: Received SSH2_MSG_IGNORE
debug3: receive packet: type 81
debug1: remote forward success for: listen localhost:3000, connect socks:0
debug2: forwarding_success: -1 expected forwarding replies remaining

Note the "socks:0" part here, which looks suspicious. When it gets a new remote connection, it then logs:

debug3: receive packet: type 2
debug3: Received SSH2_MSG_IGNORE
debug3: receive packet: type 90
debug1: client_input_channel_open: ctype forwarded-tcpip rchan 1 win 2097152 max 32768
debug1: client_request_forwarded_tcpip: listen localhost port 3000, originator 127.0.0.1 port 49734
debug1: channel 0: new [127.0.0.1]
debug1: confirm forwarded-tcpip
debug3: send packet: type 91
debug2: channel 0: pre_rdynamic: have 0
debug2: channel 0: pre_rdynamic: have 0

After this, as soon as any input arrives, the connection is closed:

debug3: receive packet: type 2
debug3: Received SSH2_MSG_IGNORE
debug2: channel 0: pre_rdynamic: have 0
debug2: channel 0: pre_rdynamic: have 5
debug2: channel 0: read failed
debug2: chan_shutdown_read: channel 0: (i0 o0 sock -1 wfd -1 efd -1 [closed])
debug2: channel 0: input open -> drain
debug2: channel 0: ibuf empty
debug2: channel 0: send eof
debug3: send packet: type 96
debug2: channel 0: input drain -> closed
debug2: channel 0: write failed
debug2: chan_shutdown_write: channel 0: (i3 o0 sock -1 wfd -1 efd -1 [closed])
debug2: channel 0: output open -> closed
debug2: channel 0: send close
debug3: send packet: type 97
debug3: channel 0: will not send data after close
debug3: receive packet: type 2
debug3: Received SSH2_MSG_IGNORE
debug3: channel 0: will not send data after close
debug3: receive packet: type 97
debug2: channel 0: rcvd close
debug3: channel 0: will not send data after close
debug2: channel 0: is dead
debug2: channel 0: garbage collecting
debug1: channel 0: free: 127.0.0.1, nchannels 1
debug3: channel 0: status: The following connections are open:
  #0 127.0.0.1 (t4 r1 i3/0 o3/0 e[closed]/0 fd -1/-1/-1 sock -1 cc -1 io 0x00/0x00)

In the working case, only the first two lines of output show up here for each new block of input, without the "pre_rdynamic" lines and without the "read failed".

I think this may be a bug in OpenSSH. I was able to reproduce exactly this same error when connecting an OpenSSH client to a standard OpenSSH server and requesting remote port forwarding using the same two versions of "-R", without any dynamic listening ports and without AysncSSH being involved at all. Repeating the host/port twice in the "-R" seems like a good workaround for this, though.

ronf avatar Jul 09 '22 15:07 ronf

You can also use "-R 3000:localhost:3000" here, or "-R 3000::3000". The issue seems to be with leaving out the destination port in the remote forwarding request.

ronf avatar Jul 09 '22 15:07 ronf

Thanks a lot! It works great. I'll take it from there 👍

pcorbel avatar Jul 10 '22 14:07 pcorbel

Terrific! Let me know if you run into any other issues.

If I get time, I may also try to narrow down what is causing the OpenSSH issue I saw.

ronf avatar Jul 10 '22 14:07 ronf

Thanks for the discussion here. May I ask?

  1. How to use stdout.write() instead of print(), inside server_requested?
print(f'Listening on {listener.get_port()}')
  1. How to get also local host and port? For example: -R 8080:google.com:80, it will be google.com and 80

schoolabs avatar Dec 11 '22 14:12 schoolabs

Keep in mind that the server_requested method is running on the server side, so any output using print() or sys.stdout.write() would be going to the terminal that the AsyncSSH server was started from, or whatever stdout was redirected to if the AsyncSSH server was started as some kind of daemon.

Are you looking to try and send output to the client through an SSH channel? This is not easy to do, as a port forwarding request like this is completely independent of any shell or exec sessions. In fact, a client can require port forwarding without having ANY shell or exec sessions open. If you somehow knew that there would always be exactly ONE shell/exec session opened on a given connection and you knew that the port forwarding request after after that was open, you might be able to save away a pointer to the SSHServerProcess (or session) and then use stdout on that process object to output something. This won't work in all cases, though.

As for the local host and port, the client never provides that information to the SSH server, so there's no way to get at it from server_requested() or any other AsyncSSH method. Packets come in over the SSH connection marked as being destined to the listening host/port (e.g. 8080) and when the client sees that, it locally looks up which host and port to forward the traffic to and does so without informing the server. If you want to print out this information, you'd have to modify the client (or turn up debug logging on it).

ronf avatar Dec 11 '22 17:12 ronf

Yes, I'm looking to print out some text to client via process.stdout. From what i know, the option to disable it in the client side is "-N". And i don't use that. Right now I can save the process pointer in the handle_client and write through it at the bottom of server_requested. It apparently works but i don't know if it's correct. I don't really know the sequence yet. Very thanks for your information.

schoolabs avatar Dec 11 '22 23:12 schoolabs

Yes - that should work. The only possible issue is if the SSH client tried to set up the port forwarding before starting the session, or if it did these operations quickly enough that your handle_client() code didn't get a chance to run before server_requested() was called. To protect against that, you might need some kind of event object you could wait on in server_requested() that could be set from handle_client() when it ran.

Things could also get more complicated if multiple clients tried to open sessions over the same SSH connection, as you'd have trouble knowing which port forwarding requests go with which sessions. This may not be an issue for your use case, but if you had the OpenSSH "ControlMaster" feature enabled, something like this could happen.

ronf avatar Dec 12 '22 15:12 ronf

Exactly. I did some hacks to sync both of them.. May i know, Is there any possibility of traffic leaking on a tunnel to another connected client?

"ControlMaster" is a new term for me to learn. Thanks for the valuable information.

schoolabs avatar Dec 13 '22 07:12 schoolabs

If you only ever open one session on an SSH connection, there's no issue of leakage. In that case, the only potential issue is the order that the session is created relative to the port forwarding request.

If your clients are OpenSSH not using the "ControlMaster" capability to allow multiple sessions to share a common SSH connection, you probably don't need to worry about this. However, with ControlMaster, you could see this. Similarly, if your client was an application using AsyncSSH or another library like libssh or paramiko, it would depend on the application code whether it opened multiple sessions on a connection, potentially triggering this issue.

ronf avatar Dec 13 '22 14:12 ronf