feat(forward): SSHForwardTracker hierarchy + per-connection tracker_factory for local and remote forwards - #807
feat(forward): SSHForwardTracker hierarchy + per-connection tracker_factory for local and remote forwards#807AlexMKX wants to merge 13 commits into
Conversation
|
Thanks for the PR. Could you say a little more about the use case you have in mind for this? I'm not really seeing how this would be able to do things like byte counts or idle tracking if you are only sending connection_made and connection_lost messages through the tracker. I'm wondering if some of this could be handled by using the existing accept_handler. It only runs right now on a new client connection coming in, giving you the client IP address and port and even lets you decide based on that whether to allow the forwarded connection or not. It doesn't currently run when a connection closes (either cleanly or with an error), but that could be added as something like an error_handler. In the future, support for a progress_handler could also be added here to report on bytes transferred, much like what exists in AsyncSSH right now for the SFTP/SCP file copy functions. |
|
Hello @ronf, thank you for the questions. The main use case I have in mind is handling idle forwarded connections. This came from a practical need in this tool: https://github.com/AlexMKX/garuda-tunnel It is basically a “poor man's Teleport” for automating Kubernetes access through Terraform or an Ansible Kubernetes inventory source. It runs as a daemon, exposes local ports through a bastion, and uses AsyncSSH under the hood. For safety and operational visibility, I would like to track the number of live forwarded connections and apply an idle timeout to traffic going through the mapped ports. A per-byte counter would be better, of course, but for this use case an active connection count is a reasonable first approximation without inviting a whole committee of extra machinery into the room. I agree that accept_handler is close to this area. The main gap is that it currently only covers the beginning of the connection. Having something symmetrical for connection close, perhaps an error_handler or close_handler, would probably solve a good part of this. A future progress_handler for byte counts would also be useful and would fit nicely with what AsyncSSH already does for SFTP/SCP transfers. So I am not strongly attached to the exact tracker API shape from the PR. The important part for me is having a lightweight way to observe connection lifecycle events for forwarded connections, and later possibly traffic progress, without each user of port forwarding having to rebuild that logic around AsyncSSH internals. |
|
Thanks for the added context... I'm in the middle of some other feature work at the moment, but once that's wrapped up I'll take a closer look at this, probably in the next couple of weeks. I'm thinking that you could still have a In fact, you could probably have a factory method on |
|
I had a chance to get back to this today. My main concern about what we previously discussed is the lack of the ability to identify specific connections with this approach, especially if we want to extend it later to report specific byte counts being transferred on the connections. For local port forwarding (or even port to path), one could argue that orig_host and orig_port should uniquely identify a specific connection if you had different tracker instances per SSHListener instance, but there's no equivalent of that if we were to extend this to cover forwarding of UNIX domain socket connections. That's actually part of why I never implemented an accept handler for the One possible way to address this would be to pass in a factory function when setting up forwarding. A new instance of this would be created per forwarded connection, and once forwarding is successfully set up for a connection, the factory function would be called to create a new tracking instance for that connection, and connection_made (and later connection_lost) would be called on these instances. With this approach, other forms of monitoring should be pretty easy to add. For instance, there could be methods for data_sent() and data_received(), providing the actual data being forwarded, not just byte counts. I'm also thinking the call to connection_made() could possibly provide an |
|
Factory-per-connection makes sense — it solves the UNIX-socket identity gap and scales cleanly to byte counts and force-close, which the aggregate observer can't. For my idle use case I'd just have the factory bump a shared counter and return a per-connection object whose connection_lost decrements it. Before I rework the PR: could you pin the intended shape? Specifically — the parameter name on forward_local_port (tracker_factory?), whether the factory is sync or async, and what connection_made receives (an SSHForwarder, plus orig addr?). I'll adapt the PR (and the docs/tests) to match once the signature is settled. |
|
Yes - To handle both TCP and UNIX domain socket cases, I think we may need two different protocols, though there could be a common parent for most of the methods. The difference would be in either In terms of naming on the tracker class, I would have We should be able to share a tracker for both We could also look into supporting In terms if the callback methods, I'm thinking:
I'm thinking for now we probably don't need to separately track EOF for tunneled connections. While it's possible for half-open connections to exist, it's not common, especially for forwarding. There MIGHT be value in reporting the optional exception differently for the two directions, since technically we could get a clean close on one side but an exception on the other, or two different exceptions for the two sides. I don't know how useful this would be, but if we did this, I'm thinking |
|
Oh - one other thing. I would not include the changes.rst file in the PR. A new changes.rst entry would only appear when a new release is cut, and would be a much less detailed than what was proposed here. That said, we could add some documentation to the new SSHForwardTracker classes, and maybe even carve out a dedicated section in api.rst for discussing this tracker functionality. |
|
Thanks for your detailed response @ronf . A few things I'd like to confirm before I rework the PR:
For scope, I'll start with local forwarding (forward_local_port + forward_local_port_to_path sharing SSHPortForwardTracker; forward_local_path + forward_local_path_to_port sharing SSHPathForwardTracker) and leave the remote-forward wiring + forward_socks for follow-up PRs, designed so they slot in without major rework. Good call on changes.rst — I'll drop it from the PR. For the docs side, I can add docstrings to SSHForwardTracker / SSHPortForwardTracker / SSHPathForwardTracker covering each method's contract (when it's called, what the args are, return-value semantics), plus a dedicated section in api.rst that explains the factory pattern, shows a minimal example, and notes which forward_* methods accept tracker_factory. If you have a preferred section name or placement (e.g., right after the existing forwarding methods, or its own top-level section), let me know — otherwise I'll pick a reasonable spot and you can move it during review. |
I was thinking pure observer here. Once you start thinking about transformations or parsing information out of the streams, you're probably better off calling
Anything you did to query if a method was defined might not be much cheaper than making an unconditional call to an empty function body (which could be provided in the tracker parent class as you said). If we did find that the method lookup was expensive enough to matter, we could always have the tracker parent constructor scan for which methods exist and cache the result in a member variable. However, I'd want to measure the performance before attempting any optimizations of that sort. Keep in mind the byte callbacks are once per SSH message, not per byte, and applications transferring large amounts of data are probably using larger block sizes on writes. Also, all SSH messages have to go through a decryption step, which is likely to be FAR more expensive than a Python method call.
I'm torn on this one. If we were to split it, it seems a bit asymmetric to not also do the split on
Sounds good. I probably won't release the feature until at least local and remote are both done, but I like the idea of fully reviewing the local case first to make sure everything looks good. It keeps the size down and there'll be less throw-away work if changes are required. As for |
|
All three confirmations make sense and I'll go with them as the basis for the rework:
Scope: I'll send the rework as a local-only PR (forward_local_port + forward_local_port_to_path sharing SSHPortForwardTracker; forward_local_path + forward_local_path_to_port sharing SSHPathForwardTracker), structured so adding the remote-forward methods later is a small follow-up rather than a redesign. forward_socks I'll leave alone for now and we'll decide once the base case lands. I'll start on it now. A few small implementation-detail questions are likely to come up — whether SSHForwardTracker is an abstract base or a concrete class with no-op defaults users can subclass selectively, what happens if tracker_factory() raises, doc placement in api.rst — but those will be more productive to raise on the actual diff than upfront. I'll ping when the PR is updated. |
|
Generally speaking, all of the AsyncSSH factory classes are meant to be subclassed, with a default implementation of pretty much all of the methods so that only methods an application wants to change need to defined in the subclass. In terms of placement in api.rst, I was thinking that there was already a top-level section for forwarding, but I see now that's not the case. Instead, there's a paragraph in the "Overview" section about forwarding methods, and then a "Forwarder Classes" section. The new tracker classes can probably be added in "Forwarder Classes" below SSHForwarder, with the details of each callback being described in the method docs, similar to what is done in SSHClient and SSHServer. It's probably also worth a quick mention of these new classes in the Overview section just after the existing forwarding summary. All of the methods can be listed for the Port and Path versions of the tracker classes, but the implementation of those will mostly be coming from the definition in the parent class in most cases. The exception would be connection_made(), as its arguments will be different. This is similar to SSHTCPSession and SSHUNIXSession. |
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#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.
4962c22 to
0a670b9
Compare
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.
Rework the idle auto-stop integration onto the redesigned asyncssh forward-tracker API (fork v2.23.0+forward-tracker.3, agreed in upstream PR ronf/asyncssh#807). Externally observable behavior is unchanged. - activity.py: ActivityTracker becomes the per-daemon aggregate and a tracker factory via make_tracker(); the asyncssh-facing hooks move to a per-connection _IdleConnectionTracker(asyncssh.SSHPortForwardTracker) with _opened/_closed idempotency guards so a connection counts exactly once even if connection_lost fires twice. - ssh.py: open_local_forwards takes tracker_factory (was tracker) and forwards it as forward_local_port(tracker_factory=...). - manager.py: pass tracker_factory=activity_tracker.make_tracker. - pyproject.toml: bump asyncssh fork pin to v2.23.0+forward-tracker.3. - tests: rewrite activity/ssh-transport/idle-watchdog/manager fakes for the factory + per-connection pair. - docs/specs: add revision-history note recording the API shift.
|
Reworked the PR to match what we landed on above and force-pushed
One implementation detail worth flagging for review: The PR description above is updated to the new design. Happy to adjust naming, hook signatures, or the api.rst placement on the diff. |
ronf
left a comment
There was a problem hiding this comment.
This looks great! I've added inline comments, but they're all for pretty minor issues.
- 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
… autoclass SSHForwardTracker is no longer exported from the package root, so the standalone autoclass directive would break the Sphinx build. Per the PR ronf#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).
|
Just reviewed your update -- looks good! Based on the changes, I marked most of my previous comments as resolved. There's only a handful left. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #807 +/- ##
===========================================
- Coverage 99.85% 99.84% -0.02%
===========================================
Files 95 102 +7
Lines 27784 32305 +4521
Branches 2908 3112 +204
===========================================
+ Hits 27743 32254 +4511
- Misses 38 45 +7
- Partials 3 6 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
- __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)
…racker arg, else-pragma, test name)
It should be better now. Are there anything to fix? |
Addresses ronf's suggestion to DRY the repeated try/except-on-Exception around tracker hook calls into a single utility.
ronf
left a comment
There was a problem hiding this comment.
A few comments on the latest changes.
| """Handle incoming data from the local transport""" | ||
|
|
||
| self._notify_tracker( | ||
| self._tracker, lambda t: t.forward_local_bytes(data)) |
There was a problem hiding this comment.
Rather than creating a lambda function here, I wonder if you could pass in one of the bound tracker methods rather than the tracker object itself as the first argument. You'd probably get less type safety, passing the arguments through an "args" argument, but it would avoid the need for the lambda.
An alternative would be to use a nested function definition for the wrapper instead of a lambda, like what's done in forward.py today for defining session_factory in the _forward() method. I think that's a bit easier to read.
There was a problem hiding this comment.
Also, pylint is raising a no-self-use error on _notify_tracker(). One option would be to make that a @staticmethod.
There was a problem hiding this comment.
Switched to nested function definitions instead of lambdas, matching the session_factory style used in _forward(). Each notify() is annotated with the concrete tracker type at that call site (SSHPortForwardTracker / SSHPathForwardTracker for the connection_made hooks, _Tracker for the shared byte/connection_lost hooks in the base class).
There was a problem hiding this comment.
_notify_tracker is now a @staticmethod (generic over _Tracker, still called as self._notify_tracker(...)). Confirmed with pylint (using the repo pylintrc + the no_self_use extension, which is what surfaces this on newer pylint) that the no-self-use finding is gone.
|
|
||
| @asynctest | ||
| async def test_forward_local_port_tracker_factory_fires_made_and_lost(self): | ||
| async def test_port_tracker_made_and_lost(self): |
There was a problem hiding this comment.
Looks good. Some of the other function names here could probably also use some trimming.
There was a problem hiding this comment.
These changes all look good and I'm seeing clean runs on mypy, pylint, and tox (including coverage).
If you're happy with this code I think the next step is to look into adding the "remote" versions of this tracker support. I'm in the middle of addressing some bug reports in the "develop" branch right now, but hopefully by the time the remote support is ready to go, things will quiet down and I'll be able to get this into the next feature release...
Thanks for all your work on this so far!
There was a problem hiding this comment.
Thanks! Yes, I am happy with the current state, so I will start on the remote versions.
One design question before I get too far, about the tracker lifecycle boundary: for a connection accepted by the remote listener whose local destination socket cannot be opened, should tracker_factory still be called, and should that tracker then get connection_made followed by connection_lost(...)? I lean towards yes, so the lifecycle mirrors local forwarding (where the tracker is created for the accepted socket, before the SSH channel open can fail) and there is exactly one tracker per accepted listener connection. Happy to go the other way if you would rather a tracker only appear once both ends are established.
There was a problem hiding this comment.
Yeah, I think this makes sense. We'd want to know about the incoming connections even if the forwarding target isn't successful. Even if we deferred connection_made() until after finishing the outbound connection, there's no way to report the exception in connection_made(), so it's probably simpler to wait and report that exception in connection_lost(). That also means the generated callbacks look the same regardless of whether the failure is detected before or after the call to connection_made().
Replace tracker-notify lambdas with nested functions, make _notify_tracker a staticmethod, and shorten tracker test names.
…rwarders Lift the tracker ownership, the factory and hook exception guard and the exactly-once connection_lost logic out of SSHLocalForwarder into a shared SSHTrackedForwarder base, and add the remote counterparts beside the local ones. SSHLocalForwarder keeps its own byte hook mapping; the remote forwarders use the mirrored one, since the hook names say where the bytes were generated rather than which method carried them. Factor the socket-opening bodies of forward_connection() and forward_unix_connection() into private helpers which pair a caller supplied forwarder with the newly opened local destination connection. The forwarder is created before that connection is opened, so a tracked remote forwarder gets exactly one tracker per connection accepted on the remote listener and can be told the connection was lost when the local destination can't be reached. Also drop the remaining local/remote wording from the tracker class docstrings; the distinction there is TCP versus UNIX domain socket listeners, not local versus remote forwarding.
Add an optional tracker_factory to forward_remote_port(), forward_remote_path(), forward_remote_port_to_path() and forward_remote_path_to_port(). The tracker kind follows the listener endpoint, matching the local methods: the TCP-listener methods take an SSHPortForwardTrackerFactory and report the forwarded-tcpip origin to connection_made, and the UNIX-listener methods take an SSHPathForwardTrackerFactory. Cover both remote listener kinds with tests mirroring the local ones, plus new byte hook tests -- local and remote -- which send a request and a deliberately different reply, so a swapped pair of byte hooks fails instead of passing on an echo. Raise pylint's max-module-lines, which connection.py now exceeds.
Widen the tracker guide from forward_local_* to both method families, state the listener-endpoint rule which picks the tracker class, spell out that the byte hooks name the host where the bytes were generated rather than the direction the connection was set up in, and show the same tracker class used with a remote listener.
…lled The remote forwarding helpers create the forwarder (and therefore fire the tracker's connection_made) before opening the local destination socket, but only mapped OSError. A cancellation, or any other exception raised while opening that socket, left the tracker having seen connection_made with no matching connection_lost, breaking the documented "exactly one tracker lifecycle per accepted connection" contract. Deliver connection_lost on every exceptional exit, passing None rather than a BaseException such as CancelledError, which is not an Exception. The OSError to ChannelOpenError mapping is unchanged. Also strengthen the tracker tests: the refused-destination cases now assert the tracker count and the ordered made/lost sequence including the ChannelOpenError, and the buggy-tracker tests now record entry into each hook and assert all four ran, so they fail if a hook dispatch is dropped. Use a named factory instead of a lambda in the docs example.
|
Pushed the remote half. Commits To be explicit about review state: your 1 Aug pass applies up to Two things worth flagging:
Gates on my side: 192 passed in |
Summary
Adds an optional, observer-only hook API for watching the lifecycle of
forwarded connections (open / close / bytes in each direction), driven by a
per-connection
tracker_factory. The motivating use case is idle-basedauto-shutdown of a forwarding daemon, but the same hooks support connection
counting and passive traffic metrics.
This reflects the design worked out in the discussion below (superseding the
original single
ForwardTrackerProtocol proposal).API
A small class hierarchy mirroring the existing forwarder classes:
SSHForwardTracker— base class. No-op default hooks shared by all forwardtypes:
connection_lost(exc)— the forwarded connection closed (exc=Noneon cleanclose). Fires exactly once per connection.
forward_local_bytes(data)— a block forwarded local → tunnel.forward_remote_bytes(data)— a block forwarded tunnel → local.SSHPortForwardTracker(SSHForwardTracker)— when the listener is a TCPport. Adds
connection_made(forwarder, orig_host, orig_port).SSHPathForwardTracker(SSHForwardTracker)— when the listener is a UNIXdomain socket. Adds
connection_made(forwarder)(no address args).A new
tracker_factory: Callable[[], SSHForwardTracker] | None = Nonekeyword isadded to the eight forwarding methods, invoked once per accepted
connection. The tracker type follows the listener endpoint, not the
destination:
forward_local_portSSHPortForwardTrackerforward_local_port_to_pathSSHPortForwardTrackerforward_local_pathSSHPathForwardTrackerforward_local_path_to_portSSHPathForwardTrackerforward_remote_portSSHPortForwardTrackerforward_remote_port_to_pathSSHPortForwardTrackerforward_remote_pathSSHPathForwardTrackerforward_remote_path_to_portSSHPathForwardTrackerThe byte hooks name the host where the data was generated, so for a remote
forward the mapping is mirrored:
forward_remote_bytessees what the client ofthe remote listener sent, and
forward_local_bytessees what the localdestination sent back.
Semantics (per discussion)
data; transform/parsing use cases should drop down to
create_connection()/open_connection()instead.Hooks fire once per SSH message (not per byte), behind SSH decryption, so the
Python call cost is negligible in any realistic workload.
connection_lost(exc), symmetric with the singleconnection_made(no per-direction split).
Exceptions raised by a hook or by the factory are caught and discarded so a
buggy tracker can never break forwarding.
connection_lostis guarded so it fires exactly once even though_forward()notifies manually on a channel-open failure and the localtransport's later close would otherwise notify again.
Scope
Local and remote forwarding.
forward_socksis still left for a follow-up;the hierarchy + factory accept it without rework.
One lint-config change came with it:
connection.pywas 26 lines undermax-module-lines, sopylintrcgoes 10000 → 11000. Happy to drop that andtake the C0302 instead if you would rather keep the config untouched.
Trackers on a remote forward are created when the listener connection is
accepted, so a connection whose local destination cannot be opened still
produces exactly one tracker, with
connection_madefollowed byconnection_lost(ChannelOpenError). That is the question raised in the threadbelow — easy to flip if you would prefer a tracker only once both ends are up.
Docs
A dedicated "Forward Tracker Classes" section in
api.rst(factory pattern,example, and the list of tracker-aware methods). Per your note, no
changes.rstentry is included.
Tests
New tests in
tests/test_forward.pycovering both tracker subclasses,connection_made/connection_lostordering, per-connection factoryinvocation, both byte-hook directions, "lost fires exactly once" on a denied
forward, and exception isolation for both a buggy factory and buggy hooks —
each mirrored for the remote methods, including the
*_to_path/*_to_portcross cases.
The byte-direction tests deliberately use a distinct request and response
rather than an echo server: an echo-based test passes even if the two byte
hooks are wired backwards, which was verified by mutation. There is also a
deterministic test that a cancelled destination open still delivers
connection_lost.