From 0a670b90953b69ebeb05089a7b936a7edb439ac7 Mon Sep 17 00:00:00 2001 From: Alex MKX Date: Tue, 23 Jun 2026 11:12:07 +0300 Subject: [PATCH 1/5] feat(forward): SSHForwardTracker class hierarchy + tracker_factory Replaces the experimental ForwardTracker Protocol (v2.23.0+forward-tracker.1) with a class hierarchy and per-connection factory pattern, per upstream review in ronf/asyncssh#807. - SSHForwardTracker: parent class with no-op default hooks (connection_lost, forward_local_bytes, forward_remote_bytes). - SSHPortForwardTracker(SSHForwardTracker): adds connection_made(forwarder, orig_host, orig_port) for TCP local forwards. - SSHPathForwardTracker(SSHForwardTracker): adds connection_made(forwarder) for UNIX-domain local forwards. - New tracker_factory kw on forward_local_port, forward_local_port_to_path, forward_local_path, forward_local_path_to_port. Factory is invoked once per accepted connection. - Byte hooks fire once per SSH message (observer-only; return values ignored). Buggy hooks/factories are isolated by per-call try/except. - Docs: dedicated 'Forward Tracker Classes' section in api.rst with factory pattern, example, and tracker-aware method list. No changes.rst entry (per maintainer request). - 12 new tests in tests/test_forward.py covering both subclasses, byte hooks, factory exception swallow, and hook exception swallow. Scope: local forwarding only. Remote-forward methods and forward_socks left for a follow-up; the design accommodates them without redesign. --- asyncssh/__init__.py | 6 +- asyncssh/connection.py | 77 +++++++++++++----- asyncssh/forward.py | 177 ++++++++++++++++++++++++++++++++++++++++- asyncssh/listener.py | 18 +++-- docs/api.rst | 64 +++++++++++++++ tests/test_forward.py | 143 +++++++++++++++++++++++++++++++++ 6 files changed, 453 insertions(+), 32 deletions(-) diff --git a/asyncssh/__init__.py b/asyncssh/__init__.py index fb316e0e..b2b49fc7 100644 --- a/asyncssh/__init__.py +++ b/asyncssh/__init__.py @@ -40,7 +40,8 @@ from .config import ConfigParseError -from .forward import SSHForwarder +from .forward import SSHForwarder, SSHForwardTracker +from .forward import SSHPortForwardTracker, SSHPathForwardTracker from .connection import SSHAcceptor, SSHClientConnection, SSHServerConnection from .connection import SSHClientConnectionOptions, SSHServerConnectionOptions @@ -147,7 +148,8 @@ 'SSHAgentKeyPair', 'SSHAuthorizedKeys', 'SSHCertificate', 'SSHClient', 'SSHClientChannel', 'SSHClientConnection', 'SSHClientConnectionOptions', 'SSHClientProcess', 'SSHClientSession', 'SSHCompletedProcess', - 'SSHForwarder', 'SSHKey', 'SSHKeyPair', 'SSHKnownHosts', + 'SSHForwarder', 'SSHForwardTracker', 'SSHPortForwardTracker', + 'SSHPathForwardTracker', 'SSHKey', 'SSHKeyPair', 'SSHKnownHosts', 'SSHLineEditorChannel', 'SSHListener', 'SSHReader', 'SSHServer', 'SSHServerChannel', 'SSHServerConnection', 'SSHServerConnectionOptions', 'SSHServerProcess', diff --git a/asyncssh/connection.py b/asyncssh/connection.py index 89fdb165..64fb61f4 100644 --- a/asyncssh/connection.py +++ b/asyncssh/connection.py @@ -85,7 +85,7 @@ from .encryption import encryption_needs_mac from .encryption import get_encryption_params, get_encryption -from .forward import SSHForwarder +from .forward import SSHForwarder, SSHForwardTrackerFactory from .gss import GSSBase, GSSClient, GSSServer, GSSError @@ -3210,7 +3210,9 @@ async def forward_unix_connection(self, dest_path: str) -> SSHForwarder: async def forward_local_port( self, listen_host: str, listen_port: int, dest_host: str, dest_port: int, - accept_handler: Optional[SSHAcceptHandler] = None) -> SSHListener: + accept_handler: Optional[SSHAcceptHandler] = None, + tracker_factory: + Optional[SSHForwardTrackerFactory] = None) -> SSHListener: """Set up local port forwarding This method is a coroutine which attempts to set up port @@ -3233,11 +3235,18 @@ async def forward_local_port( or not to allow connection forwarding, returning `True` to accept the connection and begin forwarding or `False` to reject and close it. + :param tracker_factory: + An optional callable invoked once per accepted connection + which returns a new :class:`SSHPortForwardTracker` (or + :class:`SSHForwardTracker` subclass) for observing that + connection's lifecycle. `None` (default) disables tracking + with no overhead. :type listen_host: `str` :type listen_port: `int` :type dest_host: `str` :type dest_port: `int` :type accept_handler: `callable` or coroutine + :type tracker_factory: `callable` or `None` :returns: :class:`SSHListener` @@ -3278,10 +3287,9 @@ async def tunnel_connection( (dest_host, dest_port)) try: - listener = await create_tcp_forward_listener(self, self._loop, - tunnel_connection, - listen_host, - listen_port) + listener = await create_tcp_forward_listener( + self, self._loop, tunnel_connection, listen_host, listen_port, + tracker_factory=tracker_factory) except OSError as exc: self.logger.debug1('Failed to create local TCP listener: %s', exc) raise @@ -3297,8 +3305,10 @@ async def tunnel_connection( return listener @async_context_manager - async def forward_local_path(self, listen_path: str, - dest_path: str) -> SSHListener: + async def forward_local_path( + self, listen_path: str, dest_path: str, + tracker_factory: + Optional[SSHForwardTrackerFactory] = None) -> SSHListener: """Set up local UNIX domain socket forwarding This method is a coroutine which attempts to set up UNIX domain @@ -3311,8 +3321,15 @@ async def forward_local_path(self, listen_path: str, The path on the local host to listen on :param dest_path: The path on the remote host to forward the connections to + :param tracker_factory: + An optional callable invoked once per accepted connection + which returns a new :class:`SSHPathForwardTracker` (or + :class:`SSHForwardTracker` subclass) for observing that + connection's lifecycle. `None` (default) disables tracking + with no overhead. :type listen_path: `str` :type dest_path: `str` + :type tracker_factory: `callable` or `None` :returns: :class:`SSHListener` @@ -3332,9 +3349,9 @@ async def tunnel_connection( listen_path, dest_path) try: - listener = await create_unix_forward_listener(self, self._loop, - tunnel_connection, - listen_path) + listener = await create_unix_forward_listener( + self, self._loop, tunnel_connection, listen_path, + tracker_factory=tracker_factory) except OSError as exc: self.logger.debug1('Failed to create local UNIX listener: %s', exc) raise @@ -5304,7 +5321,9 @@ async def open_tap(self, *args: object, **kwargs: object) -> \ @async_context_manager async def forward_local_port_to_path( self, listen_host: str, listen_port: int, dest_path: str, - accept_handler: Optional[SSHAcceptHandler] = None) -> SSHListener: + accept_handler: Optional[SSHAcceptHandler] = None, + tracker_factory: + Optional[SSHForwardTrackerFactory] = None) -> SSHListener: """Set up local TCP port forwarding to a remote UNIX domain socket This method is a coroutine which attempts to set up port @@ -5325,10 +5344,17 @@ async def forward_local_port_to_path( or not to allow connection forwarding, returning `True` to accept the connection and begin forwarding or `False` to reject and close it. + :param tracker_factory: + An optional callable invoked once per accepted connection + which returns a new :class:`SSHPortForwardTracker` (or + :class:`SSHForwardTracker` subclass) for observing that + connection's lifecycle. `None` (default) disables tracking + with no overhead. :type listen_host: `str` :type listen_port: `int` :type dest_path: `str` :type accept_handler: `callable` or coroutine + :type tracker_factory: `callable` or `None` :returns: :class:`SSHListener` @@ -5362,10 +5388,9 @@ async def tunnel_connection( (listen_host, listen_port), dest_path) try: - listener = await create_tcp_forward_listener(self, self._loop, - tunnel_connection, - listen_host, - listen_port) + listener = await create_tcp_forward_listener( + self, self._loop, tunnel_connection, listen_host, listen_port, + tracker_factory=tracker_factory) except OSError as exc: self.logger.debug1('Failed to create local TCP listener: %s', exc) raise @@ -5378,9 +5403,10 @@ async def tunnel_connection( return listener @async_context_manager - async def forward_local_path_to_port(self, listen_path: str, - dest_host: str, - dest_port: int) -> SSHListener: + async def forward_local_path_to_port( + self, listen_path: str, dest_host: str, dest_port: int, + tracker_factory: + Optional[SSHForwardTrackerFactory] = None) -> SSHListener: """Set up local UNIX domain socket forwarding to a remote TCP port This method is a coroutine which attempts to set up UNIX domain @@ -5395,9 +5421,16 @@ async def forward_local_path_to_port(self, listen_path: str, The hostname or address to forward the connections to :param dest_port: The port number to forward the connections to + :param tracker_factory: + An optional callable invoked once per accepted connection + which returns a new :class:`SSHPathForwardTracker` (or + :class:`SSHForwardTracker` subclass) for observing that + connection's lifecycle. `None` (default) disables tracking + with no overhead. :type listen_path: `str` :type dest_host: `str` :type dest_port: `int` + :type tracker_factory: `callable` or `None` :returns: :class:`SSHListener` @@ -5417,9 +5450,9 @@ async def tunnel_connection( listen_path, (dest_host, dest_port)) try: - listener = await create_unix_forward_listener(self, self._loop, - tunnel_connection, - listen_path) + listener = await create_unix_forward_listener( + self, self._loop, tunnel_connection, listen_path, + tracker_factory=tracker_factory) except OSError as exc: self.logger.debug1('Failed to create local UNIX listener: %s', exc) raise diff --git a/asyncssh/forward.py b/asyncssh/forward.py index 8470c000..7428c658 100644 --- a/asyncssh/forward.py +++ b/asyncssh/forward.py @@ -38,6 +38,113 @@ SSHForwarderCoro = Callable[..., Awaitable] +class SSHForwardTracker: + """Base class for observing the lifecycle of a forwarded connection + + A tracker observes a single forwarded connection on a local + listener. A `tracker_factory` passed to one of the + :meth:`forward_local_port() ` + family of methods is called once per accepted connection and must + return a new tracker instance, on which asyncssh then calls the + hooks below for the life of that connection. + + All hooks run inside the asyncio event loop and **must not block** + (no I/O, no sleeps). They are pure observers: return values are + ignored and the forwarded data is never altered. Each hook has a + no-op default, so a subclass need only override the ones it cares + about. Exceptions raised by a hook are caught and discarded, so a + buggy tracker can never break forwarding. + + This base class defines the hooks shared by all forward types. + Use :class:`SSHPortForwardTracker` for TCP local forwards and + :class:`SSHPathForwardTracker` for UNIX domain socket local + forwards; they differ only in the signature of `connection_made`. + + """ + + def connection_lost(self, exc: Optional[Exception]) -> None: + """Called when the forwarded connection has closed + + :param exc: + The exception which caused the connection to close, or + `None` if the connection closed cleanly. + :type exc: :class:`Exception` or `None` + + """ + + def forward_local_bytes(self, data: bytes) -> None: + """Called for data forwarded from the local side into the tunnel + + :param data: + A block of bytes received on the local connection and + about to be sent over the SSH connection. This is called + once per received block, not once per byte. + :type data: `bytes` + + """ + + def forward_remote_bytes(self, data: bytes) -> None: + """Called for data forwarded from the tunnel to the local side + + :param data: + A block of bytes received over the SSH connection and + about to be written to the local connection. This is + called once per received block, not once per byte. + :type data: `bytes` + + """ + + +class SSHPortForwardTracker(SSHForwardTracker): + """Tracker for local TCP port forwards + + Used with + :meth:`forward_local_port() ` + and :meth:`forward_local_port_to_path() + `. + + """ + + def connection_made(self, forwarder: 'SSHForwarder', + orig_host: str, orig_port: int) -> None: + """Called when a new TCP connection is accepted on the listener + + :param forwarder: + The forwarder handling this connection. + :param orig_host: + The originating client host. + :param orig_port: + The originating client port. + :type forwarder: :class:`SSHForwarder` + :type orig_host: `str` + :type orig_port: `int` + + """ + + +class SSHPathForwardTracker(SSHForwardTracker): + """Tracker for local UNIX domain socket forwards + + Used with + :meth:`forward_local_path() ` + and :meth:`forward_local_path_to_port() + `. + + """ + + def connection_made(self, forwarder: 'SSHForwarder') -> None: + """Called when a new UNIX domain connection is accepted + + :param forwarder: + The forwarder handling this connection. + :type forwarder: :class:`SSHForwarder` + + """ + + +SSHForwardTrackerFactory = Callable[[], SSHForwardTracker] + + class SSHForwarder(asyncio.BaseProtocol): """SSH port forwarding connection handler""" @@ -192,10 +299,59 @@ def close(self) -> None: class SSHLocalForwarder(SSHForwarder): """Local forwarding connection handler""" - def __init__(self, conn: 'SSHConnection', coro: SSHForwarderCoro): + def __init__(self, conn: 'SSHConnection', coro: SSHForwarderCoro, + tracker_factory: Optional[SSHForwardTrackerFactory] = None): super().__init__() self._conn = conn self._coro = coro + self._tracker_factory = tracker_factory + self._tracker: Optional[SSHForwardTracker] = None + + def _create_tracker(self) -> None: + """Instantiate this connection's tracker from the factory, if any""" + + if self._tracker_factory is None: + return + + try: + self._tracker = self._tracker_factory() + except Exception: # pylint: disable=broad-exception-caught + # A buggy factory must not break forwarding. + self._tracker = None + + def data_received(self, data: bytes, + datatype: Optional[int] = None) -> None: + """Handle incoming data from the local transport""" + + if self._tracker is not None: + try: + self._tracker.forward_local_bytes(data) + except Exception: # pylint: disable=broad-exception-caught + pass + + super().data_received(data, datatype) + + def write(self, data: bytes) -> None: + """Write tunnel data out to the local transport""" + + if self._tracker is not None: + try: + self._tracker.forward_remote_bytes(data) + except Exception: # pylint: disable=broad-exception-caught + pass + + super().write(data) + + def connection_lost(self, exc: Optional[Exception]) -> None: + """Handle a closed local connection""" + + if self._tracker is not None: + try: + self._tracker.connection_lost(exc) + except Exception: # pylint: disable=broad-exception-caught + pass + + super().connection_lost(exc) async def _forward(self, *args: object) -> None: """Begin local forwarding""" @@ -234,11 +390,21 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None: super().connection_made(transport) + orig_host, orig_port = '', 0 peername = cast(SockAddr, transport.get_extra_info('peername')) if peername: # pragma: no branch orig_host, orig_port = peername[:2] + self._create_tracker() + + if self._tracker is not None: + try: + cast(SSHPortForwardTracker, self._tracker).connection_made( + self, orig_host, orig_port) + except Exception: # pylint: disable=broad-exception-caught + pass + self.forward(orig_host, orig_port) @@ -249,4 +415,13 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None: """Handle a newly opened connection""" super().connection_made(transport) + + self._create_tracker() + + if self._tracker is not None: + try: + cast(SSHPathForwardTracker, self._tracker).connection_made(self) + except Exception: # pylint: disable=broad-exception-caught + pass + self.forward() diff --git a/asyncssh/listener.py b/asyncssh/listener.py index e9cc475b..b77db809 100644 --- a/asyncssh/listener.py +++ b/asyncssh/listener.py @@ -28,7 +28,7 @@ from typing import Sequence, Set, Tuple, Type, Union from typing_extensions import Self -from .forward import SSHForwarderCoro +from .forward import SSHForwarderCoro, SSHForwardTrackerFactory from .forward import SSHLocalPortForwarder, SSHLocalPathForwarder from .misc import HostPort, MaybeAwait from .session import SSHTCPSession, SSHUNIXSession @@ -345,14 +345,16 @@ async def create_tcp_local_listener( async def create_tcp_forward_listener(conn: 'SSHConnection', loop: asyncio.AbstractEventLoop, coro: SSHForwarderCoro, listen_host: str, - listen_port: int) -> \ - 'SSHForwardListener': + listen_port: int, + tracker_factory: + Optional[SSHForwardTrackerFactory] = + None) -> 'SSHForwardListener': """Create a listener to forward traffic from a local TCP port over SSH""" def protocol_factory() -> asyncio.BaseProtocol: """Start a port forwarder for each new local connection""" - return SSHLocalPortForwarder(conn, coro) + return SSHLocalPortForwarder(conn, coro, tracker_factory) return await create_tcp_local_listener(conn, loop, protocol_factory, listen_host, listen_port) @@ -361,14 +363,16 @@ def protocol_factory() -> asyncio.BaseProtocol: async def create_unix_forward_listener(conn: 'SSHConnection', loop: asyncio.AbstractEventLoop, coro: SSHForwarderCoro, - listen_path: str) -> \ - 'SSHForwardListener': + listen_path: str, + tracker_factory: + Optional[SSHForwardTrackerFactory] = + None) -> 'SSHForwardListener': """Create a listener to forward a local UNIX domain socket over SSH""" def protocol_factory() -> asyncio.BaseProtocol: """Start a path forwarder for each new local connection""" - return SSHLocalPathForwarder(conn, coro) + return SSHLocalPathForwarder(conn, coro, tracker_factory) server = await loop.create_unix_server(protocol_factory, listen_path) diff --git a/docs/api.rst b/docs/api.rst index 046245b3..f3f08d51 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1009,6 +1009,70 @@ Forwarder Classes ============================== = +Forward Tracker Classes +======================= + +The ``forward_local_*`` methods on :class:`SSHClientConnection` accept an +optional ``tracker_factory`` argument: a zero-argument callable invoked +once per accepted connection which returns a new +:class:`SSHForwardTracker` instance. asyncssh then calls that instance's +hooks for the life of the connection, giving applications a passive view +of per-connection lifecycle and byte flow -- useful for idle-based +auto-shutdown, connection counting, or traffic metrics. + +The hooks are pure observers: they run inside the asyncio event loop, +must not block, and never alter the forwarded data (return values are +ignored). Every hook has a no-op default, so a subclass overrides only +what it needs, and exceptions raised by a hook or factory are caught and +discarded so a buggy tracker cannot break forwarding. + +Use :class:`SSHPortForwardTracker` with the TCP-listener methods +(:meth:`forward_local_port() ` and +:meth:`forward_local_port_to_path() +`) and +:class:`SSHPathForwardTracker` with the UNIX-domain-listener methods +(:meth:`forward_local_path() ` and +:meth:`forward_local_path_to_port() +`). The two differ only in +the signature of ``connection_made``. + + .. code-block:: python + + class ConnCounter(asyncssh.SSHPortForwardTracker): + def __init__(self, counter): + self._counter = counter + + def connection_made(self, forwarder, orig_host, orig_port): + self._counter.active += 1 + + def connection_lost(self, exc): + self._counter.active -= 1 + + listener = await conn.forward_local_port( + '', 0, 'remote-host', 80, + tracker_factory=lambda: ConnCounter(counter)) + +.. autoclass:: SSHForwardTracker() + + ============================== = + .. automethod:: connection_lost + .. automethod:: forward_local_bytes + .. automethod:: forward_remote_bytes + ============================== = + +.. autoclass:: SSHPortForwardTracker() + + ============================== = + .. automethod:: connection_made + ============================== = + +.. autoclass:: SSHPathForwardTracker() + + ============================== = + .. automethod:: connection_made + ============================== = + + Listener Classes ================ diff --git a/tests/test_forward.py b/tests/test_forward.py index dbfd792e..465cbd24 100644 --- a/tests/test_forward.py +++ b/tests/test_forward.py @@ -697,6 +697,120 @@ async def accept_handler(_orig_host: str, _orig_port: int) -> bool: writer.close() await maybe_wait_closed(writer) + @asynctest + async def test_forward_local_port_tracker_factory_fires_made_and_lost(self): + """A port tracker sees connection_made and connection_lost""" + + events = [] + + class _RecordingTracker(asyncssh.SSHPortForwardTracker): + def connection_made(self, forwarder, orig_host, orig_port): + events.append(('made', forwarder, orig_host, orig_port)) + + def connection_lost(self, exc): + events.append(('lost', exc)) + + async with self.connect() as conn: + async with conn.forward_local_port( + '', 0, '', 7, + tracker_factory=_RecordingTracker) as listener: + await self._check_local_connection(listener.get_port()) + await asyncio.sleep(0.1) + + kinds = [event[0] for event in events] + self.assertIn('made', kinds) + self.assertIn('lost', kinds) + + made = next(event for event in events if event[0] == 'made') + self.assertIsInstance(made[1], asyncssh.SSHForwarder) + self.assertEqual(made[2], '127.0.0.1') + self.assertIsInstance(made[3], int) + + @asynctest + async def test_forward_local_port_tracker_factory_per_connection(self): + """The factory is called once per accepted connection""" + + trackers = [] + + class _CountingTracker(asyncssh.SSHPortForwardTracker): + def __init__(self): + trackers.append(self) + + def factory(): + return _CountingTracker() + + async with self.connect() as conn: + async with conn.forward_local_port( + '', 0, '', 7, tracker_factory=factory) as listener: + listen_port = listener.get_port() + await self._check_local_connection(listen_port) + await self._check_local_connection(listen_port) + await asyncio.sleep(0.1) + + self.assertEqual(len(trackers), 2) + + @asynctest + async def test_forward_local_port_tracker_byte_hooks(self): + """Byte hooks observe both forwarding directions""" + + local_bytes = bytearray() + remote_bytes = bytearray() + + class _ByteTracker(asyncssh.SSHPortForwardTracker): + def forward_local_bytes(self, data): + local_bytes.extend(data) + + def forward_remote_bytes(self, data): + remote_bytes.extend(data) + + async with self.connect() as conn: + async with conn.forward_local_port( + '', 0, '', 7, + tracker_factory=_ByteTracker) as listener: + await self._check_local_connection(listener.get_port()) + await asyncio.sleep(0.1) + + line = (str(id(self)) + '\n').encode('utf-8') + self.assertEqual(bytes(local_bytes), line) + self.assertEqual(bytes(remote_bytes), line) + + @asynctest + async def test_forward_local_port_tracker_factory_exception_swallowed(self): + """A factory that raises does not break forwarding""" + + def factory(): + raise RuntimeError('factory boom') + + async with self.connect() as conn: + async with conn.forward_local_port( + '', 0, '', 7, tracker_factory=factory) as listener: + await self._check_local_connection(listener.get_port(), + delay=0.1) + + @asynctest + async def test_forward_local_port_tracker_hook_exception_swallowed(self): + """A tracker whose hooks raise does not break forwarding""" + + class _BuggyTracker(asyncssh.SSHPortForwardTracker): + def connection_made(self, forwarder, orig_host, orig_port): + raise RuntimeError('made boom') + + def connection_lost(self, exc): + raise RuntimeError('lost boom') + + def forward_local_bytes(self, data): + raise RuntimeError('local boom') + + def forward_remote_bytes(self, data): + raise RuntimeError('remote boom') + + async with self.connect() as conn: + async with conn.forward_local_port( + '', 0, '', 7, + tracker_factory=_BuggyTracker) as listener: + await self._check_local_connection(listener.get_port(), + delay=0.1) + @unittest.skipIf(sys.platform == 'win32', 'skip UNIX domain socket tests on Windows') @asynctest @@ -1149,6 +1263,35 @@ async def test_forward_local_path(self): try_remove('local') + @asynctest + async def test_forward_local_path_tracker_factory(self): + """A path tracker sees connection_made (no addr) and connection_lost""" + + events = [] + + class _RecordingTracker(asyncssh.SSHPathForwardTracker): + def connection_made(self, forwarder): + events.append(('made', forwarder)) + + def connection_lost(self, exc): + events.append(('lost', exc)) + + async with self.connect() as conn: + async with conn.forward_local_path( + 'local', '/echo', + tracker_factory=_RecordingTracker): + await self._check_local_unix_connection('local') + await asyncio.sleep(0.1) + + try_remove('local') + + kinds = [event[0] for event in events] + self.assertIn('made', kinds) + self.assertIn('lost', kinds) + + made = next(event for event in events if event[0] == 'made') + self.assertIsInstance(made[1], asyncssh.SSHForwarder) + @asynctest async def test_forward_local_port_to_path_accept_handler(self): """Test forwarding of port to UNIX path with accept handler""" From 938b88cbda588ff2a258d3b43082904259ee0279 Mon Sep 17 00:00:00 2001 From: Alex MKX Date: Tue, 23 Jun 2026 11:36:37 +0300 Subject: [PATCH 2/5] fix(forward): make tracker connection_lost fire exactly once The local-forward _forward() coroutine calls connection_lost(exc) manually on channel-open failure, and the local transport's later close fires a second connection_lost(None) on the protocol via asyncio. Clearing self._tracker on the first call makes the hook fire exactly once. Also tightens the patch: - Drop redundant self._tracker = None in _create_tracker's except (the __init__ default already covers a raising factory). - New regression test asserting the tracker's connection_lost fires exactly once on a denied-by-accept_handler forward. - Replace asyncio.sleep(0.1) await-for-lost in existing tests with a per-test asyncio.Event resolved by the hook itself. --- asyncssh/forward.py | 20 ++++++++++++---- tests/test_forward.py | 54 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/asyncssh/forward.py b/asyncssh/forward.py index 7428c658..653c1914 100644 --- a/asyncssh/forward.py +++ b/asyncssh/forward.py @@ -316,8 +316,9 @@ def _create_tracker(self) -> None: try: self._tracker = self._tracker_factory() except Exception: # pylint: disable=broad-exception-caught - # A buggy factory must not break forwarding. - self._tracker = None + # A buggy factory must not break forwarding; + # self._tracker remains the __init__ default of None. + pass def data_received(self, data: bytes, datatype: Optional[int] = None) -> None: @@ -343,11 +344,20 @@ def write(self, data: bytes) -> None: super().write(data) def connection_lost(self, exc: Optional[Exception]) -> None: - """Handle a closed local connection""" + """Handle a closed local connection - if self._tracker is not None: + This is also called manually from `_forward()` on a channel + open failure, so the local transport's eventual close fires + a second `connection_lost(None)` on the protocol. The tracker + reference is cleared on the first call so the hook fires + exactly once per connection. + """ + + tracker, self._tracker = self._tracker, None + + if tracker is not None: try: - self._tracker.connection_lost(exc) + tracker.connection_lost(exc) except Exception: # pylint: disable=broad-exception-caught pass diff --git a/tests/test_forward.py b/tests/test_forward.py index 465cbd24..6e21fb56 100644 --- a/tests/test_forward.py +++ b/tests/test_forward.py @@ -702,6 +702,7 @@ async def test_forward_local_port_tracker_factory_fires_made_and_lost(self): """A port tracker sees connection_made and connection_lost""" events = [] + lost = asyncio.Event() class _RecordingTracker(asyncssh.SSHPortForwardTracker): def connection_made(self, forwarder, orig_host, orig_port): @@ -709,13 +710,14 @@ def connection_made(self, forwarder, orig_host, orig_port): def connection_lost(self, exc): events.append(('lost', exc)) + lost.set() async with self.connect() as conn: async with conn.forward_local_port( '', 0, '', 7, tracker_factory=_RecordingTracker) as listener: await self._check_local_connection(listener.get_port()) - await asyncio.sleep(0.1) + await asyncio.wait_for(lost.wait(), timeout=1.0) kinds = [event[0] for event in events] self.assertIn('made', kinds) @@ -731,10 +733,15 @@ async def test_forward_local_port_tracker_factory_per_connection(self): """The factory is called once per accepted connection""" trackers = [] + lost_events = [] class _CountingTracker(asyncssh.SSHPortForwardTracker): def __init__(self): trackers.append(self) + lost_events.append(asyncio.Event()) + + def connection_lost(self, exc): + lost_events[trackers.index(self)].set() def factory(): return _CountingTracker() @@ -745,7 +752,9 @@ def factory(): listen_port = listener.get_port() await self._check_local_connection(listen_port) await self._check_local_connection(listen_port) - await asyncio.sleep(0.1) + await asyncio.wait_for( + asyncio.gather(*(e.wait() for e in lost_events)), + timeout=1.0) self.assertEqual(len(trackers), 2) @@ -755,6 +764,7 @@ async def test_forward_local_port_tracker_byte_hooks(self): local_bytes = bytearray() remote_bytes = bytearray() + lost = asyncio.Event() class _ByteTracker(asyncssh.SSHPortForwardTracker): def forward_local_bytes(self, data): @@ -763,12 +773,15 @@ def forward_local_bytes(self, data): def forward_remote_bytes(self, data): remote_bytes.extend(data) + def connection_lost(self, exc): + lost.set() + async with self.connect() as conn: async with conn.forward_local_port( '', 0, '', 7, tracker_factory=_ByteTracker) as listener: await self._check_local_connection(listener.get_port()) - await asyncio.sleep(0.1) + await asyncio.wait_for(lost.wait(), timeout=1.0) line = (str(id(self)) + '\n').encode('utf-8') self.assertEqual(bytes(local_bytes), line) @@ -809,7 +822,36 @@ def forward_remote_bytes(self, data): '', 0, '', 7, tracker_factory=_BuggyTracker) as listener: await self._check_local_connection(listener.get_port(), - delay=0.1) + delay=0.1) + + @asynctest + async def test_forward_local_port_tracker_lost_fires_exactly_once(self): + """connection_lost fires once even when ChannelOpenError triggers + a manual notify in _forward() followed by the asyncio close path.""" + + lost_count = 0 + + class _Counting(asyncssh.SSHPortForwardTracker): + def connection_lost(self, exc): + nonlocal lost_count + lost_count += 1 + + async def deny(_h, _p): + return False + + async with self.connect() as conn: + async with conn.forward_local_port( + '', 0, '', 7, + accept_handler=deny, + tracker_factory=_Counting) as listener: + reader, writer = await asyncio.open_connection( + '127.0.0.1', listener.get_port()) + self.assertEqual((await reader.read()), b'') + writer.close() + await maybe_wait_closed(writer) + await asyncio.sleep(0.1) # bounded: upper-bound for any spurious duplicate + + self.assertEqual(lost_count, 1) @unittest.skipIf(sys.platform == 'win32', 'skip UNIX domain socket tests on Windows') @@ -1268,6 +1310,7 @@ async def test_forward_local_path_tracker_factory(self): """A path tracker sees connection_made (no addr) and connection_lost""" events = [] + lost = asyncio.Event() class _RecordingTracker(asyncssh.SSHPathForwardTracker): def connection_made(self, forwarder): @@ -1275,13 +1318,14 @@ def connection_made(self, forwarder): def connection_lost(self, exc): events.append(('lost', exc)) + lost.set() async with self.connect() as conn: async with conn.forward_local_path( 'local', '/echo', tracker_factory=_RecordingTracker): await self._check_local_unix_connection('local') - await asyncio.sleep(0.1) + await asyncio.wait_for(lost.wait(), timeout=1.0) try_remove('local') From 5217feefae2c52c035d8834f71b0320324710a39 Mon Sep 17 00:00:00 2001 From: Alex MKX Date: Fri, 10 Jul 2026 21:02:00 +0300 Subject: [PATCH 3/5] review: address ronf feedback on PR #807 - Do not export SSHForwardTracker publicly (Port/Path only) - Split factory type into SSHPortForwardTrackerFactory / SSHPathForwardTrackerFactory - Drop None from tracker_factory :type: docstrings; name the factory type - Pass tracker_factory positionally into the listener helper - Rename per-connection factory test to describe behavior - broad-exception-caught -> broad-except (older pylint compat) - Add docstrings to test tracker classes; fix short var names - Cover connection_made exception swallow for the Path case --- asyncssh/__init__.py | 4 ++-- asyncssh/connection.py | 27 ++++++++++++++------------- asyncssh/forward.py | 21 ++++++++++++--------- asyncssh/listener.py | 7 ++++--- tests/test_forward.py | 39 ++++++++++++++++++++++++++++++++++++--- 5 files changed, 68 insertions(+), 30 deletions(-) diff --git a/asyncssh/__init__.py b/asyncssh/__init__.py index b2b49fc7..a7d999a2 100644 --- a/asyncssh/__init__.py +++ b/asyncssh/__init__.py @@ -40,7 +40,7 @@ from .config import ConfigParseError -from .forward import SSHForwarder, SSHForwardTracker +from .forward import SSHForwarder from .forward import SSHPortForwardTracker, SSHPathForwardTracker from .connection import SSHAcceptor, SSHClientConnection, SSHServerConnection @@ -148,7 +148,7 @@ 'SSHAgentKeyPair', 'SSHAuthorizedKeys', 'SSHCertificate', 'SSHClient', 'SSHClientChannel', 'SSHClientConnection', 'SSHClientConnectionOptions', 'SSHClientProcess', 'SSHClientSession', 'SSHCompletedProcess', - 'SSHForwarder', 'SSHForwardTracker', 'SSHPortForwardTracker', + 'SSHForwarder', 'SSHPortForwardTracker', 'SSHPathForwardTracker', 'SSHKey', 'SSHKeyPair', 'SSHKnownHosts', 'SSHLineEditorChannel', 'SSHListener', 'SSHReader', 'SSHServer', 'SSHServerChannel', 'SSHServerConnection', diff --git a/asyncssh/connection.py b/asyncssh/connection.py index 64fb61f4..96bd048e 100644 --- a/asyncssh/connection.py +++ b/asyncssh/connection.py @@ -85,7 +85,8 @@ from .encryption import encryption_needs_mac from .encryption import get_encryption_params, get_encryption -from .forward import SSHForwarder, SSHForwardTrackerFactory +from .forward import SSHForwarder +from .forward import SSHPortForwardTrackerFactory, SSHPathForwardTrackerFactory from .gss import GSSBase, GSSClient, GSSServer, GSSError @@ -3212,7 +3213,7 @@ async def forward_local_port( dest_host: str, dest_port: int, accept_handler: Optional[SSHAcceptHandler] = None, tracker_factory: - Optional[SSHForwardTrackerFactory] = None) -> SSHListener: + Optional[SSHPortForwardTrackerFactory] = None) -> SSHListener: """Set up local port forwarding This method is a coroutine which attempts to set up port @@ -3246,7 +3247,7 @@ async def forward_local_port( :type dest_host: `str` :type dest_port: `int` :type accept_handler: `callable` or coroutine - :type tracker_factory: `callable` or `None` + :type tracker_factory: :class:`SSHPortForwardTrackerFactory` :returns: :class:`SSHListener` @@ -3289,7 +3290,7 @@ async def tunnel_connection( try: listener = await create_tcp_forward_listener( self, self._loop, tunnel_connection, listen_host, listen_port, - tracker_factory=tracker_factory) + tracker_factory) except OSError as exc: self.logger.debug1('Failed to create local TCP listener: %s', exc) raise @@ -3308,7 +3309,7 @@ async def tunnel_connection( async def forward_local_path( self, listen_path: str, dest_path: str, tracker_factory: - Optional[SSHForwardTrackerFactory] = None) -> SSHListener: + Optional[SSHPathForwardTrackerFactory] = None) -> SSHListener: """Set up local UNIX domain socket forwarding This method is a coroutine which attempts to set up UNIX domain @@ -3329,7 +3330,7 @@ async def forward_local_path( with no overhead. :type listen_path: `str` :type dest_path: `str` - :type tracker_factory: `callable` or `None` + :type tracker_factory: :class:`SSHPathForwardTrackerFactory` :returns: :class:`SSHListener` @@ -3351,7 +3352,7 @@ async def tunnel_connection( try: listener = await create_unix_forward_listener( self, self._loop, tunnel_connection, listen_path, - tracker_factory=tracker_factory) + tracker_factory) except OSError as exc: self.logger.debug1('Failed to create local UNIX listener: %s', exc) raise @@ -5323,7 +5324,7 @@ async def forward_local_port_to_path( self, listen_host: str, listen_port: int, dest_path: str, accept_handler: Optional[SSHAcceptHandler] = None, tracker_factory: - Optional[SSHForwardTrackerFactory] = None) -> SSHListener: + Optional[SSHPortForwardTrackerFactory] = None) -> SSHListener: """Set up local TCP port forwarding to a remote UNIX domain socket This method is a coroutine which attempts to set up port @@ -5354,7 +5355,7 @@ async def forward_local_port_to_path( :type listen_port: `int` :type dest_path: `str` :type accept_handler: `callable` or coroutine - :type tracker_factory: `callable` or `None` + :type tracker_factory: :class:`SSHPortForwardTrackerFactory` :returns: :class:`SSHListener` @@ -5390,7 +5391,7 @@ async def tunnel_connection( try: listener = await create_tcp_forward_listener( self, self._loop, tunnel_connection, listen_host, listen_port, - tracker_factory=tracker_factory) + tracker_factory) except OSError as exc: self.logger.debug1('Failed to create local TCP listener: %s', exc) raise @@ -5406,7 +5407,7 @@ async def tunnel_connection( async def forward_local_path_to_port( self, listen_path: str, dest_host: str, dest_port: int, tracker_factory: - Optional[SSHForwardTrackerFactory] = None) -> SSHListener: + Optional[SSHPathForwardTrackerFactory] = None) -> SSHListener: """Set up local UNIX domain socket forwarding to a remote TCP port This method is a coroutine which attempts to set up UNIX domain @@ -5430,7 +5431,7 @@ async def forward_local_path_to_port( :type listen_path: `str` :type dest_host: `str` :type dest_port: `int` - :type tracker_factory: `callable` or `None` + :type tracker_factory: :class:`SSHPathForwardTrackerFactory` :returns: :class:`SSHListener` @@ -5452,7 +5453,7 @@ async def tunnel_connection( try: listener = await create_unix_forward_listener( self, self._loop, tunnel_connection, listen_path, - tracker_factory=tracker_factory) + tracker_factory) except OSError as exc: self.logger.debug1('Failed to create local UNIX listener: %s', exc) raise diff --git a/asyncssh/forward.py b/asyncssh/forward.py index 653c1914..e3955976 100644 --- a/asyncssh/forward.py +++ b/asyncssh/forward.py @@ -24,7 +24,7 @@ import socket from types import TracebackType from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Optional -from typing import Type, cast +from typing import Type, Union, cast from typing_extensions import Self from .misc import ChannelOpenError, SockAddr @@ -142,7 +142,8 @@ def connection_made(self, forwarder: 'SSHForwarder') -> None: """ -SSHForwardTrackerFactory = Callable[[], SSHForwardTracker] +SSHPortForwardTrackerFactory = Callable[[], SSHPortForwardTracker] +SSHPathForwardTrackerFactory = Callable[[], SSHPathForwardTracker] class SSHForwarder(asyncio.BaseProtocol): @@ -300,7 +301,9 @@ class SSHLocalForwarder(SSHForwarder): """Local forwarding connection handler""" def __init__(self, conn: 'SSHConnection', coro: SSHForwarderCoro, - tracker_factory: Optional[SSHForwardTrackerFactory] = None): + tracker_factory: + Optional[Union[SSHPortForwardTrackerFactory, + SSHPathForwardTrackerFactory]] = None): super().__init__() self._conn = conn self._coro = coro @@ -315,7 +318,7 @@ def _create_tracker(self) -> None: try: self._tracker = self._tracker_factory() - except Exception: # pylint: disable=broad-exception-caught + except Exception: # pylint: disable=broad-except # A buggy factory must not break forwarding; # self._tracker remains the __init__ default of None. pass @@ -327,7 +330,7 @@ def data_received(self, data: bytes, if self._tracker is not None: try: self._tracker.forward_local_bytes(data) - except Exception: # pylint: disable=broad-exception-caught + except Exception: # pylint: disable=broad-except pass super().data_received(data, datatype) @@ -338,7 +341,7 @@ def write(self, data: bytes) -> None: if self._tracker is not None: try: self._tracker.forward_remote_bytes(data) - except Exception: # pylint: disable=broad-exception-caught + except Exception: # pylint: disable=broad-except pass super().write(data) @@ -358,7 +361,7 @@ def connection_lost(self, exc: Optional[Exception]) -> None: if tracker is not None: try: tracker.connection_lost(exc) - except Exception: # pylint: disable=broad-exception-caught + except Exception: # pylint: disable=broad-except pass super().connection_lost(exc) @@ -412,7 +415,7 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None: try: cast(SSHPortForwardTracker, self._tracker).connection_made( self, orig_host, orig_port) - except Exception: # pylint: disable=broad-exception-caught + except Exception: # pylint: disable=broad-except pass self.forward(orig_host, orig_port) @@ -431,7 +434,7 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None: if self._tracker is not None: try: cast(SSHPathForwardTracker, self._tracker).connection_made(self) - except Exception: # pylint: disable=broad-exception-caught + except Exception: # pylint: disable=broad-except pass self.forward() diff --git a/asyncssh/listener.py b/asyncssh/listener.py index b77db809..c9d6e483 100644 --- a/asyncssh/listener.py +++ b/asyncssh/listener.py @@ -28,7 +28,8 @@ from typing import Sequence, Set, Tuple, Type, Union from typing_extensions import Self -from .forward import SSHForwarderCoro, SSHForwardTrackerFactory +from .forward import SSHForwarderCoro +from .forward import SSHPortForwardTrackerFactory, SSHPathForwardTrackerFactory from .forward import SSHLocalPortForwarder, SSHLocalPathForwarder from .misc import HostPort, MaybeAwait from .session import SSHTCPSession, SSHUNIXSession @@ -347,7 +348,7 @@ async def create_tcp_forward_listener(conn: 'SSHConnection', coro: SSHForwarderCoro, listen_host: str, listen_port: int, tracker_factory: - Optional[SSHForwardTrackerFactory] = + Optional[SSHPortForwardTrackerFactory] = None) -> 'SSHForwardListener': """Create a listener to forward traffic from a local TCP port over SSH""" @@ -365,7 +366,7 @@ async def create_unix_forward_listener(conn: 'SSHConnection', coro: SSHForwarderCoro, listen_path: str, tracker_factory: - Optional[SSHForwardTrackerFactory] = + Optional[SSHPathForwardTrackerFactory] = None) -> 'SSHForwardListener': """Create a listener to forward a local UNIX domain socket over SSH""" diff --git a/tests/test_forward.py b/tests/test_forward.py index 6e21fb56..3a31fc2d 100644 --- a/tests/test_forward.py +++ b/tests/test_forward.py @@ -705,6 +705,8 @@ async def test_forward_local_port_tracker_factory_fires_made_and_lost(self): lost = asyncio.Event() class _RecordingTracker(asyncssh.SSHPortForwardTracker): + """Tracker which records connection_made and connection_lost""" + def connection_made(self, forwarder, orig_host, orig_port): events.append(('made', forwarder, orig_host, orig_port)) @@ -729,13 +731,16 @@ def connection_lost(self, exc): self.assertIsInstance(made[3], int) @asynctest - async def test_forward_local_port_tracker_factory_per_connection(self): - """The factory is called once per accepted connection""" + async def test_tracker_factory_invoked_once_per_connection(self): + """A distinct tracker instance is created for each accepted + connection, and each instance's connection_lost fires once""" trackers = [] lost_events = [] class _CountingTracker(asyncssh.SSHPortForwardTracker): + """Tracker which records each instance created by the factory""" + def __init__(self): trackers.append(self) lost_events.append(asyncio.Event()) @@ -767,6 +772,8 @@ async def test_forward_local_port_tracker_byte_hooks(self): lost = asyncio.Event() class _ByteTracker(asyncssh.SSHPortForwardTracker): + """Tracker which records bytes seen in both forwarding directions""" + def forward_local_bytes(self, data): local_bytes.extend(data) @@ -805,6 +812,8 @@ async def test_forward_local_port_tracker_hook_exception_swallowed(self): """A tracker whose hooks raise does not break forwarding""" class _BuggyTracker(asyncssh.SSHPortForwardTracker): + """Tracker whose hooks all raise, to verify they're swallowed""" + def connection_made(self, forwarder, orig_host, orig_port): raise RuntimeError('made boom') @@ -832,11 +841,13 @@ async def test_forward_local_port_tracker_lost_fires_exactly_once(self): lost_count = 0 class _Counting(asyncssh.SSHPortForwardTracker): + """Tracker which counts how many times connection_lost fires""" + def connection_lost(self, exc): nonlocal lost_count lost_count += 1 - async def deny(_h, _p): + async def deny(_orig_host, _orig_port): return False async with self.connect() as conn: @@ -1313,6 +1324,8 @@ async def test_forward_local_path_tracker_factory(self): lost = asyncio.Event() class _RecordingTracker(asyncssh.SSHPathForwardTracker): + """Tracker which records connection_made and connection_lost""" + def connection_made(self, forwarder): events.append(('made', forwarder)) @@ -1336,6 +1349,26 @@ def connection_lost(self, exc): made = next(event for event in events if event[0] == 'made') self.assertIsInstance(made[1], asyncssh.SSHForwarder) + @asynctest + async def test_forward_local_path_tracker_hook_exception_swallowed(self): + """A path tracker whose connection_made raises does not break + forwarding""" + + class _BuggyTracker(asyncssh.SSHPathForwardTracker): + """Tracker whose connection_made hook raises, to verify it's + swallowed""" + + def connection_made(self, forwarder): + raise RuntimeError('made boom') + + async with self.connect() as conn: + async with conn.forward_local_path( + 'local', '/echo', + tracker_factory=_BuggyTracker): + await self._check_local_unix_connection('local') + + try_remove('local') + @asynctest async def test_forward_local_port_to_path_accept_handler(self): """Test forwarding of port to UNIX path with accept handler""" From 8ca835dc1cbe3f494f82e2c209ba3ec1125c9305 Mon Sep 17 00:00:00 2001 From: Alex MKX Date: Fri, 10 Jul 2026 21:13:10 +0300 Subject: [PATCH 4/5] docs(api): document Port/Path trackers directly, drop non-public base autoclass SSHForwardTracker is no longer exported from the package root, so the standalone autoclass directive would break the Sphinx build. Per the PR #807 review, document SSHPortForwardTracker and SSHPathForwardTracker directly, listing each callback under both (connection_made signatures differ; connection_lost / forward_local_bytes / forward_remote_bytes are inherited from the base). --- docs/api.rst | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index f3f08d51..a752a876 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1014,11 +1014,13 @@ Forward Tracker Classes The ``forward_local_*`` methods on :class:`SSHClientConnection` accept an optional ``tracker_factory`` argument: a zero-argument callable invoked -once per accepted connection which returns a new -:class:`SSHForwardTracker` instance. asyncssh then calls that instance's -hooks for the life of the connection, giving applications a passive view -of per-connection lifecycle and byte flow -- useful for idle-based -auto-shutdown, connection counting, or traffic metrics. +once per accepted connection which returns a tracker instance -- +:class:`SSHPortForwardTracker` for TCP listeners or +:class:`SSHPathForwardTracker` for UNIX domain listeners. asyncssh then +calls that instance's hooks for the life of the connection, giving +applications a passive view of per-connection lifecycle and byte flow -- +useful for idle-based auto-shutdown, connection counting, or traffic +metrics. The hooks are pure observers: they run inside the asyncio event loop, must not block, and never alter the forwarded data (return values are @@ -1033,8 +1035,9 @@ Use :class:`SSHPortForwardTracker` with the TCP-listener methods :class:`SSHPathForwardTracker` with the UNIX-domain-listener methods (:meth:`forward_local_path() ` and :meth:`forward_local_path_to_port() -`). The two differ only in -the signature of ``connection_made``. +`). The two classes share +the same set of hooks and differ only in the signature of +``connection_made``. .. code-block:: python @@ -1052,25 +1055,23 @@ the signature of ``connection_made``. '', 0, 'remote-host', 80, tracker_factory=lambda: ConnCounter(counter)) -.. autoclass:: SSHForwardTracker() +.. autoclass:: SSHPortForwardTracker() - ============================== = + ==================================== = + .. automethod:: connection_made .. automethod:: connection_lost .. automethod:: forward_local_bytes .. automethod:: forward_remote_bytes - ============================== = - -.. autoclass:: SSHPortForwardTracker() - - ============================== = - .. automethod:: connection_made - ============================== = + ==================================== = .. autoclass:: SSHPathForwardTracker() - ============================== = + ==================================== = .. automethod:: connection_made - ============================== = + .. automethod:: connection_lost + .. automethod:: forward_local_bytes + .. automethod:: forward_remote_bytes + ==================================== = Listener Classes From fb9ea52b353984a1938dd9a3a592364384005824 Mon Sep 17 00:00:00 2001 From: Alex MKX Date: Sun, 12 Jul 2026 11:50:51 +0300 Subject: [PATCH 5/5] review: address ronf 2026-07-12 follow-up on PR #807 - __all__: order tracker classes case-sensitive alphabetically (SSHPathForwardTracker before SSHPortForwardTracker, after SSHListener), kept compact on one line - connection.py: drop the '(or SSHForwardTracker subclass)' clause from the four tracker_factory param docs; SSHForwardTracker is non-public - no change needed to :type tracker_factory: factory-alias refs; sphinx -W build is already clean (nitpicky mode off in docs/conf.py) --- asyncssh/__init__.py | 8 ++++---- asyncssh/connection.py | 20 ++++++++------------ 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/asyncssh/__init__.py b/asyncssh/__init__.py index a7d999a2..a1f589dd 100644 --- a/asyncssh/__init__.py +++ b/asyncssh/__init__.py @@ -41,7 +41,7 @@ from .config import ConfigParseError from .forward import SSHForwarder -from .forward import SSHPortForwardTracker, SSHPathForwardTracker +from .forward import SSHPathForwardTracker, SSHPortForwardTracker from .connection import SSHAcceptor, SSHClientConnection, SSHServerConnection from .connection import SSHClientConnectionOptions, SSHServerConnectionOptions @@ -148,9 +148,9 @@ 'SSHAgentKeyPair', 'SSHAuthorizedKeys', 'SSHCertificate', 'SSHClient', 'SSHClientChannel', 'SSHClientConnection', 'SSHClientConnectionOptions', 'SSHClientProcess', 'SSHClientSession', 'SSHCompletedProcess', - 'SSHForwarder', 'SSHPortForwardTracker', - 'SSHPathForwardTracker', 'SSHKey', 'SSHKeyPair', 'SSHKnownHosts', - 'SSHLineEditorChannel', 'SSHListener', 'SSHReader', 'SSHServer', + 'SSHForwarder', 'SSHKey', 'SSHKeyPair', 'SSHKnownHosts', + 'SSHLineEditorChannel', 'SSHListener', 'SSHPathForwardTracker', + 'SSHPortForwardTracker', 'SSHReader', 'SSHServer', 'SSHServerChannel', 'SSHServerConnection', 'SSHServerConnectionOptions', 'SSHServerProcess', 'SSHServerProcessFactory', 'SSHServerSession', diff --git a/asyncssh/connection.py b/asyncssh/connection.py index 96bd048e..2fc783b9 100644 --- a/asyncssh/connection.py +++ b/asyncssh/connection.py @@ -3238,9 +3238,8 @@ async def forward_local_port( reject and close it. :param tracker_factory: An optional callable invoked once per accepted connection - which returns a new :class:`SSHPortForwardTracker` (or - :class:`SSHForwardTracker` subclass) for observing that - connection's lifecycle. `None` (default) disables tracking + which returns a new :class:`SSHPortForwardTracker` for observing + that connection's lifecycle. `None` (default) disables tracking with no overhead. :type listen_host: `str` :type listen_port: `int` @@ -3324,9 +3323,8 @@ async def forward_local_path( The path on the remote host to forward the connections to :param tracker_factory: An optional callable invoked once per accepted connection - which returns a new :class:`SSHPathForwardTracker` (or - :class:`SSHForwardTracker` subclass) for observing that - connection's lifecycle. `None` (default) disables tracking + which returns a new :class:`SSHPathForwardTracker` for observing + that connection's lifecycle. `None` (default) disables tracking with no overhead. :type listen_path: `str` :type dest_path: `str` @@ -5347,9 +5345,8 @@ async def forward_local_port_to_path( reject and close it. :param tracker_factory: An optional callable invoked once per accepted connection - which returns a new :class:`SSHPortForwardTracker` (or - :class:`SSHForwardTracker` subclass) for observing that - connection's lifecycle. `None` (default) disables tracking + which returns a new :class:`SSHPortForwardTracker` for observing + that connection's lifecycle. `None` (default) disables tracking with no overhead. :type listen_host: `str` :type listen_port: `int` @@ -5424,9 +5421,8 @@ async def forward_local_path_to_port( The port number to forward the connections to :param tracker_factory: An optional callable invoked once per accepted connection - which returns a new :class:`SSHPathForwardTracker` (or - :class:`SSHForwardTracker` subclass) for observing that - connection's lifecycle. `None` (default) disables tracking + which returns a new :class:`SSHPathForwardTracker` for observing + that connection's lifecycle. `None` (default) disables tracking with no overhead. :type listen_path: `str` :type dest_host: `str`