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.
- sshpushpull-0.1.0a0.dist-info/METADATA +190 -0
- sshpushpull-0.1.0a0.dist-info/RECORD +7 -0
- sshpushpull-0.1.0a0.dist-info/WHEEL +6 -0
- sshpushpull-0.1.0a0.dist-info/entry_points.txt +2 -0
- sshpushpull-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- sshpushpull-0.1.0a0.dist-info/top_level.txt +1 -0
- sshpushpull.py +725 -0
|
@@ -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,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())
|