204 lines
6.9 KiB
Python

#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
import contextlib
import logging
import socket
import asyncssh
log = logging.getLogger(__name__)
READ_SIZE = 1024
BROADCAST_DRAIN_TIMEOUT = 10
class _NoAuthSSHServer(asyncssh.SSHServer):
"""Allow console transport without interactive SSH authentication prompts."""
def begin_auth(self, username):
return False
class _ManagedSSHListener:
"""Compatibility wrapper that owns AsyncioSSHServer shutdown."""
def __init__(self, ssh_server, listener):
self._ssh_server = ssh_server
self._listener = listener
self._close_task = None
def close(self):
if self._close_task is None:
self._close_task = asyncio.create_task(self._ssh_server.close())
async def wait_closed(self):
self.close()
with contextlib.suppress(asyncio.CancelledError):
await self._close_task
def __getattr__(self, attribute):
return getattr(self._listener, attribute)
class AsyncioSSHServer:
def __init__(self, reader=None, writer=None):
self._reader = reader
self._writer = writer
self._sessions = {}
self._sessions_lock = asyncio.Lock()
self._writer_lock = asyncio.Lock()
self._close_lock = asyncio.Lock()
self._broadcast_task = None
self._server = None
self._server_handle = None
self._host_key = asyncssh.generate_private_key("ssh-ed25519")
async def start(self, host, port):
if self._server is not None:
raise RuntimeError("AsyncioSSHServer is already started")
self._server = await asyncssh.listen(
host=host,
port=port,
server_factory=_NoAuthSSHServer,
server_host_keys=[self._host_key],
process_factory=self._run_client_session,
encoding=None,
reuse_address=True,
)
self._server_handle = _ManagedSSHListener(self, self._server)
if self._reader is not None and self._broadcast_task is None:
self._broadcast_task = asyncio.create_task(self._broadcast_from_upstream())
return self._server_handle
async def close(self):
async with self._close_lock:
if self._broadcast_task is not None:
broadcast_task = self._broadcast_task
self._broadcast_task = None
broadcast_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await broadcast_task
# Always disconnect all active SSH client sessions so that
# server.wait_closed() does not block indefinitely waiting for
# them to finish on their own.
await self._disconnect_all_clients()
if self._server is not None:
self._server.close()
await self._server.wait_closed()
self._server = None
self._server_handle = None
async def _run_client_session(self, process):
self._set_socket_options(process)
async with self._sessions_lock:
self._sessions[process] = process
try:
while True:
data = await process.stdin.read(READ_SIZE)
if not data:
break
if self._writer is not None:
async with self._writer_lock:
self._writer.write(data)
await self._writer.drain()
except asyncio.CancelledError:
raise
except (ConnectionError, OSError, asyncssh.Error):
pass
finally:
await self._disconnect_client(process)
async def _broadcast_from_upstream(self):
try:
while True:
data = await self._reader.read(READ_SIZE)
if not data:
break
for process in await self._get_sessions_snapshot():
try:
process.stdout.write(data)
await asyncio.wait_for(process.stdout.drain(), timeout=BROADCAST_DRAIN_TIMEOUT)
except (OSError, ConnectionError, asyncio.TimeoutError, asyncssh.Error):
await self._disconnect_client(process)
except asyncio.CancelledError:
raise
except (ConnectionError, OSError):
pass
finally:
await self._disconnect_all_clients()
async def _get_sessions_snapshot(self):
async with self._sessions_lock:
return list(self._sessions.keys())
async def _disconnect_all_clients(self):
async with self._sessions_lock:
sessions = list(self._sessions.keys())
for process in sessions:
await self._disconnect_client(process)
async def _disconnect_client(self, process):
async with self._sessions_lock:
self._sessions.pop(process, None)
with contextlib.suppress(Exception):
process.exit(0)
channel = process.get_extra_info("channel")
if channel is not None:
with contextlib.suppress(Exception):
channel.close()
wait_closed = getattr(channel, "wait_closed", None)
if callable(wait_closed):
with contextlib.suppress(Exception):
await wait_closed()
@staticmethod
def _set_socket_options(process):
sock = process.get_extra_info("socket")
if sock is None:
return
with contextlib.suppress(OSError):
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
try:
if hasattr(socket, "TCP_KEEPIDLE"):
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60)
elif hasattr(socket, "TCP_KEEPALIVE"):
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPALIVE, 60)
else:
raise AttributeError("No TCP keepalive idle socket option is available")
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 4)
except (AttributeError, OSError):
log.debug("Failed to tune TCP keepalive for SSH client; using OS defaults", exc_info=True)