sshpushpull 0.1.0a0__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: sshpushpull
3
+ Version: 0.1.0a0
4
+ Summary: Dead simple, jargon-free Python tool to make a local TCP port available on a remote host or make a remote TCP port available locally.
5
+ Author-email: Jifeng Wu <jifengwu2k@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jifengwu2k/sshpushpull
8
+ Project-URL: Bug Tracker, https://github.com/jifengwu2k/sshpushpull/issues
9
+ Classifier: Programming Language :: Python :: 2
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=2
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: enum34; python_version < "3.4"
16
+ Requires-Dist: paramiko
17
+ Requires-Dist: typing; python_version < "3.5"
18
+ Dynamic: license-file
19
+
20
+ Dead simple, jargon-free Python tool to make a local TCP port available on a remote host or make a remote TCP port available locally, with auto-reconnect.
21
+
22
+ This is a Python reimplementation of [push-pull-port](https://github.com/jifengwu2k/push-pull-port) that uses `paramiko` instead of shelling out to `autossh`, making it cross-platform and self-contained.
23
+
24
+ ## Motivation
25
+
26
+ **Modern work is mobile.** Whether you're at home, in a cafe, or on the move with 4G, you need secure, on-demand access to your devices and services - without wrestling with complex forwarding rules or VPNs.
27
+
28
+ `sshpushpull` lets you instantly "push" or "pull" any TCP port via SSH with human-friendly commands.
29
+
30
+ - Expose your device's SSH, VNC, or web apps at a moment's notice.
31
+ - Stop and start tunnels with a single command.
32
+ - Failures show up *immediately* - no silent, sneaky background errors.
33
+ - Auto-reconnects after transient network drops with exponential backoff.
34
+
35
+ **It's the Unix philosophy, everywhere:** portable, composable, and under your control.
36
+
37
+ ## Prerequisites
38
+
39
+ - Python 2 or Python 3
40
+ - `paramiko` (installed automatically via `pip install sshpushpull`)
41
+ - SSH access to the remote host
42
+ - For unattended tunnels, set up SSH key-based authentication so you're not prompted for a password on every reconnect.
43
+
44
+ > **Note:** When using `push`, to make the pushed port accessible from other hosts, ensure the remote SSH server has `GatewayPorts clientspecified` set in `/etc/ssh/sshd_config`.
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ pip install sshpushpull
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ `sshpushpull` provides two subcommands:
55
+
56
+ - `push` — Make a local TCP port available on a remote host (equivalent to `ssh -R`)
57
+ - `pull` — Make a remote TCP port available locally (equivalent to `ssh -L`)
58
+
59
+ Both commands run in the foreground and auto-reconnect after transient network failures with exponential backoff (up to 10 seconds).
60
+
61
+ ### Push: Expose a local port on a remote host
62
+
63
+ ```
64
+ sshpushpull push -l <local_port> -r <remote_port> -u <user> -h <host> [--port <ssh_port>] [--password <pwd> | --rsa-key <path> | --ed25519-key <path>] [-x]
65
+ ```
66
+
67
+ | Option | Description |
68
+ |--------|-------------|
69
+ | `-l`, `--local-port` | Local TCP port to push from |
70
+ | `-r`, `--remote-port` | Remote TCP port to push to |
71
+ | `-u`, `--username` | SSH username on the remote host |
72
+ | `-h`, `--host` | Remote SSH host name or address |
73
+ | `--port` | SSH server port (default: 22) |
74
+ | `--password` | Password for SSH authentication |
75
+ | `--rsa-key` | Path to RSA private key for SSH authentication |
76
+ | `--ed25519-key` | Path to Ed25519 private key for SSH authentication |
77
+ | `-x` | Open remote port on localhost only (default: open on all interfaces) |
78
+
79
+ **Examples:**
80
+
81
+ ```bash
82
+ # Make local port 3000 available on dev.example.com:3001 using password auth
83
+ sshpushpull push -l 3000 -r 3001 -u dev -h dev.example.com --password secret
84
+
85
+ # Using an Ed25519 key
86
+ sshpushpull push -l 3000 -r 3001 -u dev -h dev.example.com --ed25519-key ~/.ssh/id_ed25519
87
+
88
+ # If SSH is running on port 2222
89
+ sshpushpull push -l 3000 -r 3001 -u dev -h dev.example.com --port 2222 --rsa-key ~/.ssh/id_rsa
90
+
91
+ # Only allow access from the remote host's own localhost
92
+ sshpushpull push -l 3000 -r 3001 -u dev -h dev.example.com --ed25519-key ~/.ssh/id_ed25519 -x
93
+ ```
94
+
95
+ ### Pull: Access a remote port locally
96
+
97
+ ```
98
+ sshpushpull pull -r <remote_port> -l <local_port> -u <user> -h <host> [--port <ssh_port>] [--password <pwd> | --rsa-key <path> | --ed25519-key <path>]
99
+ ```
100
+
101
+ | Option | Description |
102
+ |--------|-------------|
103
+ | `-r`, `--remote-port` | Remote TCP port to pull from |
104
+ | `-l`, `--local-port` | Local TCP port to pull to |
105
+ | `-u`, `--username` | SSH username on the remote host |
106
+ | `-h`, `--host` | Remote SSH host name or address |
107
+ | `--port` | SSH server port (default: 22) |
108
+ | `--password` | Password for SSH authentication |
109
+ | `--rsa-key` | Path to RSA private key for SSH authentication |
110
+ | `--ed25519-key` | Path to Ed25519 private key for SSH authentication |
111
+
112
+ **Examples:**
113
+
114
+ ```bash
115
+ # Access remote port 3306 (database) through local port 3307
116
+ sshpushpull pull -r 3306 -l 3307 -u admin -h db.internal --password secret
117
+
118
+ # Using an Ed25519 key
119
+ sshpushpull pull -r 3306 -l 3307 -u admin -h db.internal --ed25519-key ~/.ssh/id_ed25519
120
+
121
+ # If SSH is running on port 2222
122
+ sshpushpull pull -r 3306 -l 3307 -u admin -h db.internal --port 2222 --rsa-key ~/.ssh/id_rsa
123
+ ```
124
+
125
+ ## Foreground Operation: Visibility Over Stealth
126
+
127
+ We intentionally run all tunnels in the **foreground**. This ensures:
128
+
129
+ - **Immediate Error Visibility:** Any connection issues, authentication failures, or port conflicts are clearly printed to your terminal, so you can respond and debug without guessing.
130
+ - **No Silent Failures:** By avoiding background daemons, you won't miss subtle (or catastrophic) tunnel dropouts that go unnoticed.
131
+ - **Stopping the tunnel:** Simply press `Ctrl-C` to stop the tunnel. You can also close the terminal window/tab to end it.
132
+
133
+ > Tip: If you ever want to run the tunnel in the background, you can use a terminal multiplexer like `tmux` to keep tunnels running while detached.
134
+
135
+ ## Auto-Reconnect
136
+
137
+ Both `push` and `pull` automatically reconnect after transient network failures. The reconnect strategy uses exponential backoff:
138
+
139
+ 1. First retry: 1 second
140
+ 2. Second retry: 2 seconds
141
+ 3. Third retry: 4 seconds
142
+ 4. ...up to a maximum of 10 seconds
143
+
144
+ After a successful reconnection, the backoff resets to 1 second.
145
+
146
+ ## Why We Use Push/Pull Instead of Forward/Reverse
147
+
148
+ ### The Problem with Traditional Terms
149
+
150
+ The standard SSH port forwarding terms (`local forwarding` vs `remote forwarding`) are notoriously confusing because:
151
+
152
+ 1. **Perspective Dependence**
153
+ The "remote" and "local" labels depend on which machine initiates the SSH connection, not the actual service exposure direction users care about.
154
+
155
+ 2. **Cognitive Mismatch**
156
+ When developers want to:
157
+ - **Expose a local service** remotely, they must remember this is called "remote forwarding" (`-R`)
158
+ - **Access a remote service** locally, this is called "local forwarding" (`-L`)
159
+
160
+ 3. **Implementation-First Naming**
161
+ The terms describe SSH's technical implementation rather than user intent.
162
+
163
+ ### Our Push/Pull Metaphor
164
+
165
+ We intentionally avoid `forward/reverse` terminology in favor of intuitive action verbs:
166
+
167
+ | User Goal | Traditional Term | Our Term | SSH Option |
168
+ |--------------------------------------------|---------------------|----------|------------|
169
+ | Make local service available on remote host| Remote Forwarding | Push | `ssh -R` |
170
+ | Access remote service through local port | Local Forwarding | Pull | `ssh -L` |
171
+
172
+ ### Key Advantages
173
+
174
+ 1. **Intent-Oriented**
175
+ - `push`: "I want to make this local port available there"
176
+ - `pull`: "I want to access that remote port here"
177
+
178
+ 2. **Directionally Clear**
179
+ Eliminates ambiguity about "whose local/remote" we're referring to.
180
+
181
+ 3. **Cloud-Native Alignment**
182
+ Matches modern service mesh concepts (ingress/egress) better than SSH's 1990s perspective.
183
+
184
+ ## Contributing
185
+
186
+ Contributions are welcome! Please submit pull requests or open issues on the GitHub repository.
187
+
188
+ ## License
189
+
190
+ This project is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,7 @@
1
+ sshpushpull.py,sha256=OARvtJJTVhEnzFUhRNUB6CdvBIM8Z1sXvFilrkCUsj8,23775
2
+ sshpushpull-0.1.0a0.dist-info/licenses/LICENSE,sha256=hvfX-ADssuMYgrXDUAjMMut4l8W3meA31ZAYfpdlJKY,1066
3
+ sshpushpull-0.1.0a0.dist-info/METADATA,sha256=nYBo1oYhYDfQht1eNw48MdDPyZcCAFQosH8QNYj6p0I,8010
4
+ sshpushpull-0.1.0a0.dist-info/WHEEL,sha256=TdQ5LtNwLuxTCjgxN51AgdU5w-KkB9ttmLbzjTH02pg,109
5
+ sshpushpull-0.1.0a0.dist-info/entry_points.txt,sha256=h5sYXhRgX3tNo441rHt_we5fu45fwB2G8FRqmiBTPFU,49
6
+ sshpushpull-0.1.0a0.dist-info/top_level.txt,sha256=G-97g9EupkD35_WPl0aSKhevDKaRtLUiv598e_Fieik,12
7
+ sshpushpull-0.1.0a0.dist-info/RECORD,,
@@ -0,0 +1,6 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py2-none-any
5
+ Tag: py3-none-any
6
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sshpushpull = sshpushpull:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jifeng Wu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ sshpushpull
sshpushpull.py ADDED
@@ -0,0 +1,725 @@
1
+ #!/usr/bin/env python
2
+ # Copyright (c) 2026 Jifeng Wu
3
+ # Licensed under the MIT License. See LICENSE file in the project root for full license information.
4
+ from __future__ import print_function
5
+
6
+ import argparse
7
+ import logging
8
+ import signal
9
+ import socket
10
+ import sys
11
+ import threading
12
+ import time
13
+
14
+ from enum import Enum
15
+ from typing import Callable, Optional, Tuple, Union
16
+
17
+ import paramiko # type: ignore[import-untyped]
18
+
19
+ if sys.version_info[0] == 2:
20
+ TEXT_TYPE = unicode # type: ignore[name-defined]
21
+ BINARY_TYPES = (str,)
22
+ else:
23
+ TEXT_TYPE = str
24
+ BINARY_TYPES = (bytes,)
25
+
26
+
27
+ class ConnState(Enum):
28
+ STARTING = "STARTING"
29
+ CONNECTING = "CONNECTING"
30
+ CONNECTED = "CONNECTED"
31
+ RECONNECT_WAIT = "RECONNECT_WAIT"
32
+ RECONNECTING = "RECONNECTING"
33
+ SHUTTING_DOWN = "SHUTTING_DOWN"
34
+ STOPPED = "STOPPED"
35
+
36
+
37
+ class RetryableConnectError(Exception):
38
+ __slots__ = ()
39
+
40
+
41
+ class FatalConnectError(Exception):
42
+ __slots__ = ()
43
+
44
+
45
+ class Runtime(object):
46
+ __slots__ = (
47
+ "mode",
48
+ "host",
49
+ "port",
50
+ "username",
51
+ "password",
52
+ "publickey",
53
+ "local_port",
54
+ "remote_port",
55
+ "local_only",
56
+ "state",
57
+ "transport",
58
+ "transport_sock",
59
+ "last_error",
60
+ "stop_requested",
61
+ "backoff",
62
+ "max_backoff",
63
+ "lock",
64
+ "forward_active",
65
+ "forward_port",
66
+ "listener",
67
+ "accept_thread",
68
+ )
69
+
70
+ def __init__(
71
+ self,
72
+ mode,
73
+ host,
74
+ port,
75
+ username,
76
+ password,
77
+ publickey,
78
+ local_port,
79
+ remote_port,
80
+ local_only=False,
81
+ ):
82
+ # type: (str, str, int, str, Optional[str], Optional[paramiko.PKey], int, int, bool) -> None
83
+ self.mode = mode # type: str
84
+ self.host = host # type: str
85
+ self.port = port # type: int
86
+ self.username = username # type: str
87
+ self.password = password # type: Optional[str]
88
+ self.publickey = publickey # type: Optional[paramiko.PKey]
89
+ self.local_port = local_port # type: int
90
+ self.remote_port = remote_port # type: int
91
+ self.local_only = local_only # type: bool
92
+ self.state = ConnState.STARTING # type: ConnState
93
+ self.transport = None # type: Optional[paramiko.Transport]
94
+ self.transport_sock = None # type: Optional[socket.socket]
95
+ self.last_error = None # type: Optional[Exception]
96
+ self.stop_requested = False # type: bool
97
+ self.backoff = 1 # type: int
98
+ self.max_backoff = 10 # type: int
99
+ self.lock = threading.RLock()
100
+ self.forward_active = False # type: bool
101
+ self.forward_port = None # type: Optional[int]
102
+ self.listener = None # type: Optional[socket.socket]
103
+ self.accept_thread = None # type: Optional[threading.Thread]
104
+
105
+
106
+ class SignalStopHandler(object):
107
+ __slots__ = ("ctx",)
108
+
109
+ def __init__(self, ctx):
110
+ # type: (Runtime) -> None
111
+ self.ctx = ctx # type: Runtime
112
+
113
+ def __call__(self, signum, frame):
114
+ # type: (int, object) -> None
115
+ del frame
116
+ # Only set the flag here — do NOT call set_state() or logging,
117
+ # because logging acquires a lock which may be held by the
118
+ # interrupted thread, causing a deadlock.
119
+ self.ctx.stop_requested = True
120
+
121
+
122
+ def set_state(ctx, new_state, reason=""):
123
+ # type: (Runtime, ConnState, str) -> None
124
+ old_state = ctx.state
125
+ ctx.state = new_state
126
+ if reason:
127
+ logging.info("[state] %s -> %s: %s" % (old_state.value, new_state.value, reason))
128
+ else:
129
+ logging.info("[state] %s -> %s" % (old_state.value, new_state.value))
130
+
131
+
132
+ def connect_upstream(ctx):
133
+ # type: (Runtime) -> None
134
+ try:
135
+ sock = socket.create_connection((ctx.host, ctx.port))
136
+ transport = paramiko.Transport(sock)
137
+ transport.start_client()
138
+ transport.set_keepalive(15)
139
+
140
+ if ctx.publickey is not None:
141
+ transport.auth_publickey(ctx.username, ctx.publickey)
142
+ else:
143
+ transport.auth_password(ctx.username, str(ctx.password))
144
+
145
+ if not transport.is_authenticated():
146
+ raise FatalConnectError("upstream authentication failed")
147
+
148
+ with ctx.lock:
149
+ ctx.transport_sock = sock
150
+ ctx.transport = transport
151
+ except paramiko.AuthenticationException as error:
152
+ raise FatalConnectError(str(error))
153
+ except ValueError as error:
154
+ raise FatalConnectError(str(error))
155
+ except Exception as error:
156
+ raise RetryableConnectError(str(error))
157
+
158
+
159
+ def close_transport(ctx):
160
+ # type: (Runtime) -> None
161
+ with ctx.lock:
162
+ transport = ctx.transport
163
+ transport_sock = ctx.transport_sock
164
+ ctx.transport = None
165
+ ctx.transport_sock = None
166
+ if transport is not None:
167
+ try:
168
+ transport.close()
169
+ except Exception:
170
+ logging.info("[cleanup] ignored upstream transport close failure")
171
+ if transport_sock is not None:
172
+ try:
173
+ transport_sock.close()
174
+ except Exception:
175
+ logging.info("[cleanup] ignored upstream socket close failure")
176
+
177
+
178
+ def transport_is_alive(ctx):
179
+ # type: (Runtime) -> bool
180
+ with ctx.lock:
181
+ transport = ctx.transport
182
+ return transport is not None and transport.is_active()
183
+
184
+
185
+ def get_transport(ctx):
186
+ # type: (Runtime) -> Optional[paramiko.Transport]
187
+ with ctx.lock:
188
+ return ctx.transport
189
+
190
+
191
+ def install_signal_handlers(ctx):
192
+ # type: (Runtime) -> None
193
+ handler = SignalStopHandler(ctx)
194
+ signal.signal(signal.SIGINT, handler)
195
+ if hasattr(signal, "SIGTERM"):
196
+ signal.signal(signal.SIGTERM, handler)
197
+
198
+
199
+ def make_thread(target, args, name):
200
+ # type: (Callable[..., None], Tuple, str) -> threading.Thread
201
+ thread = threading.Thread(target=target, args=args, name=name) # type: ignore[call-arg]
202
+ thread.daemon = True
203
+ return thread
204
+
205
+
206
+ def safe_close(channel):
207
+ # type: (Optional[paramiko.Channel]) -> None
208
+ if channel is None:
209
+ return
210
+ try:
211
+ channel.close()
212
+ except Exception:
213
+ logging.info("[cleanup] ignored channel close failure")
214
+
215
+
216
+ def safe_close_socket(sock):
217
+ # type: (Optional[socket.socket]) -> None
218
+ if sock is None:
219
+ return
220
+ try:
221
+ sock.close()
222
+ except Exception:
223
+ logging.info("[cleanup] ignored socket close failure")
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Push helpers
228
+ # ---------------------------------------------------------------------------
229
+
230
+
231
+ def push_forwarded_handler(ctx, channel, origin, server):
232
+ # type: (Runtime, paramiko.Channel, Tuple[str, int], Tuple[str, int]) -> None
233
+ del origin
234
+ del server
235
+
236
+ def run_bridge():
237
+ # type: () -> None
238
+ local_sock = None # type: Optional[socket.socket]
239
+ try:
240
+ local_sock = socket.create_connection(("localhost", ctx.local_port), timeout=10)
241
+ bidirectional_bridge(local_sock, channel)
242
+ except Exception as error:
243
+ errno_val = getattr(error, "errno", None)
244
+ if errno_val == 111: # ECONNREFUSED
245
+ logging.info(
246
+ "[push] Connection refused: nothing is listening on localhost:%s. "
247
+ "Check that your local service is running (e.g. 'ss -tlnp | grep %s' or 'netstat -tlnp | grep %s')."
248
+ % (ctx.local_port, ctx.local_port, ctx.local_port)
249
+ )
250
+ else:
251
+ logging.info("[push] failed to bridge incoming forwarded connection: %s" % (error,))
252
+ finally:
253
+ safe_close(channel)
254
+ safe_close_socket(local_sock)
255
+
256
+ bridge_thread = make_thread(run_bridge, (), "push-bridge")
257
+ bridge_thread.start()
258
+
259
+
260
+ def activate_forward(ctx):
261
+ # type: (Runtime) -> bool
262
+ transport = get_transport(ctx)
263
+ if transport is None or not transport.is_active():
264
+ return False
265
+
266
+ bind_host = "localhost" if ctx.local_only else ""
267
+ try:
268
+ active_port = transport.request_port_forward(
269
+ bind_host,
270
+ ctx.remote_port,
271
+ handler=lambda channel, origin, server: push_forwarded_handler(
272
+ ctx, channel, origin, server
273
+ ),
274
+ )
275
+ with ctx.lock:
276
+ ctx.forward_active = True
277
+ ctx.forward_port = active_port
278
+ logging.info(
279
+ "[push] remote port forwarding active: %s:%s -> localhost:%s"
280
+ % (
281
+ "localhost" if ctx.local_only else "0.0.0.0",
282
+ active_port,
283
+ ctx.local_port,
284
+ )
285
+ )
286
+ return True
287
+ except Exception as error:
288
+ logging.info(
289
+ "[push] failed to request remote forward %s:%s: %s"
290
+ % (
291
+ bind_host if bind_host else "0.0.0.0",
292
+ ctx.remote_port,
293
+ error,
294
+ )
295
+ )
296
+ return False
297
+
298
+
299
+ def cancel_forward(ctx):
300
+ # type: (Runtime) -> None
301
+ with ctx.lock:
302
+ forward_port = ctx.forward_port
303
+ ctx.forward_active = False
304
+ ctx.forward_port = None
305
+ transport = get_transport(ctx)
306
+ if transport is not None and transport.is_active() and forward_port is not None:
307
+ try:
308
+ bind_host = "localhost" if ctx.local_only else ""
309
+ transport.cancel_port_forward(bind_host, forward_port)
310
+ logging.info("[push] cancelled remote forward %s" % (forward_port,))
311
+ except Exception:
312
+ logging.info("[cleanup] ignored remote forward cancel failure")
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # Pull helpers
317
+ # ---------------------------------------------------------------------------
318
+
319
+
320
+ def bind_local_listener(local_port):
321
+ # type: (int) -> socket.socket
322
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
323
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
324
+ sock.bind(("localhost", local_port))
325
+ sock.listen()
326
+ return sock
327
+
328
+
329
+ def close_listener(ctx):
330
+ # type: (Runtime) -> None
331
+ listener = ctx.listener
332
+ if listener is not None:
333
+ try:
334
+ listener.close()
335
+ except Exception:
336
+ logging.info("[cleanup] ignored listener close failure")
337
+ ctx.listener = None
338
+
339
+
340
+ def accept_loop(ctx):
341
+ # type: (Runtime) -> None
342
+ listener = ctx.listener
343
+ if listener is None:
344
+ raise RuntimeError("listener not initialized")
345
+ while not ctx.stop_requested:
346
+ try:
347
+ client_sock, client_addr = listener.accept()
348
+ except OSError as e:
349
+ # On Python 2, EINTR is not auto-retried; on Python 3 it is
350
+ # (PEP 475), but checking errno is harmless on both.
351
+ if hasattr(e, "errno") and e.errno == 4: # errno.EINTR
352
+ continue
353
+ break
354
+ except IOError as e:
355
+ if hasattr(e, "errno") and e.errno == 4:
356
+ continue
357
+ break
358
+ thread = make_thread(
359
+ handle_pull_client,
360
+ (ctx, client_sock, client_addr),
361
+ "pull-client-%s" % (client_addr[1],),
362
+ )
363
+ thread.start()
364
+
365
+
366
+ def handle_pull_client(ctx, client_sock, client_addr):
367
+ # type: (Runtime, socket.socket, Tuple[str, int]) -> None
368
+ channel = None # type: Optional[paramiko.Channel]
369
+ try:
370
+ transport = get_transport(ctx)
371
+ if transport is None or not transport.is_active():
372
+ return
373
+
374
+ channel = transport.open_channel(
375
+ kind="direct-tcpip",
376
+ dest_addr=("localhost", ctx.remote_port),
377
+ src_addr=client_addr,
378
+ )
379
+ if channel is None:
380
+ return
381
+
382
+ logging.info(
383
+ "[pull] forwarding localhost:%s -> remote localhost:%s for %s:%s"
384
+ % (
385
+ ctx.local_port,
386
+ ctx.remote_port,
387
+ client_addr[0],
388
+ client_addr[1],
389
+ )
390
+ )
391
+ bidirectional_bridge(client_sock, channel)
392
+ except Exception as error:
393
+ logging.info("[pull] failed to bridge connection: %s" % (error,))
394
+ finally:
395
+ safe_close(channel)
396
+ safe_close_socket(client_sock)
397
+
398
+
399
+ # ---------------------------------------------------------------------------
400
+ # Shared: bidirectional byte pump
401
+ # ---------------------------------------------------------------------------
402
+
403
+
404
+ def pump_bytes(src, dst):
405
+ # type: (Union[paramiko.Channel, socket.socket], Union[paramiko.Channel, socket.socket]) -> None
406
+ buf_size = 32768
407
+ try:
408
+ while True:
409
+ data = src.recv(buf_size)
410
+ if not data:
411
+ break
412
+ dst.sendall(data)
413
+ except Exception:
414
+ pass
415
+ finally:
416
+ try:
417
+ if isinstance(dst, paramiko.Channel):
418
+ dst.shutdown_write()
419
+ else:
420
+ dst.shutdown(socket.SHUT_WR)
421
+ except Exception:
422
+ pass
423
+
424
+
425
+ def bidirectional_bridge(left, right):
426
+ # type: (socket.socket, paramiko.Channel) -> None
427
+ left_to_right = make_thread(pump_bytes, (left, right), "bridge-left-to-right")
428
+ right_to_left = make_thread(pump_bytes, (right, left), "bridge-right-to-left")
429
+ left_to_right.start()
430
+ right_to_left.start()
431
+ left_to_right.join()
432
+ right_to_left.join()
433
+
434
+
435
+ # ---------------------------------------------------------------------------
436
+ # Main application: unified state machine for push / pull
437
+ # ---------------------------------------------------------------------------
438
+
439
+
440
+ def app(ctx):
441
+ # type: (Runtime) -> int
442
+ install_signal_handlers(ctx)
443
+
444
+ while True:
445
+ # Check for stop_requested in every iteration so the main thread
446
+ # (not the signal handler) drives the state transition, keeping
447
+ # logging calls signal-safe.
448
+ if ctx.stop_requested and ctx.state not in (
449
+ ConnState.SHUTTING_DOWN,
450
+ ConnState.STOPPED,
451
+ ):
452
+ set_state(ctx, ConnState.SHUTTING_DOWN, "stop requested")
453
+
454
+ if ctx.state == ConnState.STARTING:
455
+ if ctx.mode == "pull":
456
+ ctx.listener = bind_local_listener(ctx.local_port)
457
+ ctx.accept_thread = make_thread(accept_loop, (ctx,), "accept-loop")
458
+ ctx.accept_thread.start()
459
+ set_state(ctx, ConnState.CONNECTING, "%s mode ready" % (ctx.mode,))
460
+
461
+ elif ctx.state == ConnState.CONNECTING:
462
+ try:
463
+ connect_upstream(ctx)
464
+ ctx.backoff = 1
465
+ logging.info(
466
+ "Connected upstream to %s:%s as %s"
467
+ % (ctx.host, ctx.port, ctx.username)
468
+ )
469
+
470
+ if ctx.mode == "push":
471
+ if not activate_forward(ctx):
472
+ close_transport(ctx)
473
+ set_state(ctx, ConnState.RECONNECT_WAIT, "forward activation failed")
474
+ continue
475
+ access_str = "open on localhost only" if ctx.local_only else "open on all interfaces"
476
+ logging.info(
477
+ "Pushing localhost:%s on your machine to %s (%s) via ssh -p %s %s@%s (Press Ctrl+C to stop)"
478
+ % (
479
+ ctx.local_port,
480
+ ctx.remote_port,
481
+ access_str,
482
+ ctx.port,
483
+ ctx.username,
484
+ ctx.host,
485
+ )
486
+ )
487
+ else:
488
+ logging.info(
489
+ "Pulling localhost:%s on ssh -p %s %s@%s to localhost:%s on your machine (Press Ctrl+C to stop)"
490
+ % (
491
+ ctx.remote_port,
492
+ ctx.port,
493
+ ctx.username,
494
+ ctx.host,
495
+ ctx.local_port,
496
+ )
497
+ )
498
+ set_state(ctx, ConnState.CONNECTED, "upstream connect ok")
499
+ except RetryableConnectError as error:
500
+ ctx.last_error = error
501
+ set_state(ctx, ConnState.RECONNECT_WAIT, "connect failed: %s" % (error,))
502
+ except FatalConnectError as error:
503
+ logging.error("Fatal: %s" % (error,))
504
+ return 1
505
+
506
+ elif ctx.state == ConnState.CONNECTED:
507
+ if ctx.stop_requested:
508
+ if ctx.mode == "push":
509
+ cancel_forward(ctx)
510
+ set_state(ctx, ConnState.SHUTTING_DOWN, "shutdown requested")
511
+ elif not transport_is_alive(ctx):
512
+ if ctx.mode == "push":
513
+ cancel_forward(ctx)
514
+ close_transport(ctx)
515
+ set_state(ctx, ConnState.RECONNECT_WAIT, "upstream lost")
516
+ else:
517
+ time.sleep(0.2)
518
+
519
+ elif ctx.state == ConnState.RECONNECT_WAIT:
520
+ if ctx.stop_requested:
521
+ set_state(ctx, ConnState.SHUTTING_DOWN, "shutdown requested during reconnect wait")
522
+ else:
523
+ delay = ctx.backoff
524
+ logging.info("[reconnect] waiting %ss" % (delay,))
525
+ time.sleep(delay)
526
+ ctx.backoff = min(ctx.backoff * 2, ctx.max_backoff)
527
+ set_state(ctx, ConnState.RECONNECTING, "retrying upstream connect")
528
+
529
+ elif ctx.state == ConnState.RECONNECTING:
530
+ try:
531
+ connect_upstream(ctx)
532
+ ctx.backoff = 1
533
+ if ctx.mode == "push":
534
+ if not activate_forward(ctx):
535
+ close_transport(ctx)
536
+ set_state(ctx, ConnState.RECONNECT_WAIT, "reconnected but forward activation failed")
537
+ continue
538
+ access_str = "open on localhost only" if ctx.local_only else "open on all interfaces"
539
+ logging.info(
540
+ "Reconnected. Pushing localhost:%s to %s (%s) via ssh -p %s %s@%s"
541
+ % (
542
+ ctx.local_port,
543
+ ctx.remote_port,
544
+ access_str,
545
+ ctx.port,
546
+ ctx.username,
547
+ ctx.host,
548
+ )
549
+ )
550
+ else:
551
+ logging.info(
552
+ "Reconnected. Pulling localhost:%s on ssh -p %s %s@%s to localhost:%s"
553
+ % (
554
+ ctx.remote_port,
555
+ ctx.port,
556
+ ctx.username,
557
+ ctx.host,
558
+ ctx.local_port,
559
+ )
560
+ )
561
+ set_state(ctx, ConnState.CONNECTED, "upstream reconnected")
562
+ except RetryableConnectError as error:
563
+ ctx.last_error = error
564
+ set_state(ctx, ConnState.RECONNECT_WAIT, "reconnect failed: %s" % (error,))
565
+ except FatalConnectError as error:
566
+ logging.error("Fatal: %s" % (error,))
567
+ return 1
568
+
569
+ elif ctx.state == ConnState.SHUTTING_DOWN:
570
+ if ctx.mode == "push":
571
+ cancel_forward(ctx)
572
+ else:
573
+ close_listener(ctx)
574
+ close_transport(ctx)
575
+ set_state(ctx, ConnState.STOPPED, "cleanup complete")
576
+
577
+ elif ctx.state == ConnState.STOPPED:
578
+ return 0
579
+
580
+ else:
581
+ raise RuntimeError("Unhandled state: %s" % (ctx.state,))
582
+
583
+
584
+ def main():
585
+ # type: () -> int
586
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
587
+
588
+ parser = argparse.ArgumentParser(
589
+ description="Dead simple, jargon-free Python tool to make a local TCP port available on a remote host or make a remote TCP port available locally, with auto-reconnect."
590
+ )
591
+ subparsers = parser.add_subparsers(dest="mode", help="available commands")
592
+
593
+ push_parser = subparsers.add_parser(
594
+ "push",
595
+ help="push localhost:<local_port> to <remote_port> on the remote SSH host (ssh -R equivalent)",
596
+ )
597
+ push_parser.add_argument(
598
+ "--host",
599
+ required=True,
600
+ help="upstream SSH server host name or address",
601
+ )
602
+ push_parser.add_argument(
603
+ "--port",
604
+ required=False,
605
+ type=int,
606
+ default=22,
607
+ help="upstream SSH server port number (default: 22)",
608
+ )
609
+ push_parser.add_argument(
610
+ "--username",
611
+ required=True,
612
+ help="upstream SSH username",
613
+ )
614
+ push_auth = push_parser.add_mutually_exclusive_group(required=True)
615
+ push_auth.add_argument(
616
+ "--password",
617
+ help="password used to connect to the remote SSH server",
618
+ )
619
+ push_auth.add_argument(
620
+ "--rsa-key",
621
+ help="user-facing path to the RSA private key used for the upstream SSH server",
622
+ )
623
+ push_auth.add_argument(
624
+ "--ed25519-key",
625
+ help="user-facing path to the Ed25519 private key used for the upstream SSH server",
626
+ )
627
+ push_parser.add_argument(
628
+ "--local-port",
629
+ required=True,
630
+ type=int,
631
+ help="local TCP port to push from",
632
+ )
633
+ push_parser.add_argument(
634
+ "--remote-port",
635
+ required=True,
636
+ type=int,
637
+ help="remote TCP port to push to",
638
+ )
639
+ push_parser.add_argument(
640
+ "--local-only",
641
+ action="store_true",
642
+ default=False,
643
+ help="open remote port on localhost only",
644
+ )
645
+
646
+ pull_parser = subparsers.add_parser(
647
+ "pull",
648
+ help="pull localhost:<remote_port> on the remote SSH host to localhost:<local_port> (ssh -L equivalent)",
649
+ )
650
+ pull_parser.add_argument(
651
+ "--host",
652
+ required=True,
653
+ help="upstream SSH server host name or address",
654
+ )
655
+ pull_parser.add_argument(
656
+ "--port",
657
+ required=False,
658
+ type=int,
659
+ default=22,
660
+ help="upstream SSH server port number (default: 22)",
661
+ )
662
+ pull_parser.add_argument(
663
+ "--username",
664
+ required=True,
665
+ help="upstream SSH username",
666
+ )
667
+ pull_auth = pull_parser.add_mutually_exclusive_group(required=True)
668
+ pull_auth.add_argument(
669
+ "--password",
670
+ help="password used to connect to the remote SSH server",
671
+ )
672
+ pull_auth.add_argument(
673
+ "--rsa-key",
674
+ help="user-facing path to the RSA private key used for the upstream SSH server",
675
+ )
676
+ pull_auth.add_argument(
677
+ "--ed25519-key",
678
+ help="user-facing path to the Ed25519 private key used for the upstream SSH server",
679
+ )
680
+ pull_parser.add_argument(
681
+ "--local-port",
682
+ required=True,
683
+ type=int,
684
+ help="local TCP port to pull to",
685
+ )
686
+ pull_parser.add_argument(
687
+ "--remote-port",
688
+ required=True,
689
+ type=int,
690
+ help="remote TCP port to pull from",
691
+ )
692
+
693
+ args = parser.parse_args()
694
+
695
+ if args.mode not in ("push", "pull"):
696
+ parser.print_help()
697
+ return 1
698
+
699
+ password = args.password # type: Optional[str]
700
+ publickey = None # type: Optional[paramiko.PKey]
701
+ if args.rsa_key is not None:
702
+ publickey = paramiko.RSAKey.from_private_key_file(args.rsa_key)
703
+ elif args.ed25519_key is not None:
704
+ publickey = paramiko.Ed25519Key.from_private_key_file(args.ed25519_key)
705
+
706
+ if password is None and publickey is None:
707
+ parser.print_help()
708
+ return 1
709
+
710
+ ctx = Runtime(
711
+ mode=args.mode,
712
+ host=args.host,
713
+ port=args.port,
714
+ username=args.username,
715
+ password=password,
716
+ publickey=publickey,
717
+ local_port=args.local_port,
718
+ remote_port=args.remote_port,
719
+ local_only=getattr(args, "local_only", False),
720
+ )
721
+ return app(ctx)
722
+
723
+
724
+ if __name__ == "__main__":
725
+ sys.exit(main())