ssh-auto-forward 0.0.1__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.
- ssh_auto_forward/__init__.py +5 -0
- ssh_auto_forward/__version__.py +1 -0
- ssh_auto_forward/cli.py +653 -0
- ssh_auto_forward-0.0.1.dist-info/METADATA +165 -0
- ssh_auto_forward-0.0.1.dist-info/RECORD +7 -0
- ssh_auto_forward-0.0.1.dist-info/WHEEL +4 -0
- ssh_auto_forward-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
ssh_auto_forward/cli.py
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
"""CLI entry point for ssh-auto-forward."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import configparser
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import socket
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
15
|
+
|
|
16
|
+
import paramiko
|
|
17
|
+
from paramiko import SSHClient
|
|
18
|
+
|
|
19
|
+
from ssh_auto_forward.__version__ import __version__
|
|
20
|
+
|
|
21
|
+
# Configure logging
|
|
22
|
+
logging.basicConfig(
|
|
23
|
+
level=logging.INFO,
|
|
24
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
25
|
+
datefmt="%H:%M:%S",
|
|
26
|
+
)
|
|
27
|
+
logger = logging.getLogger("ssh-auto-forward")
|
|
28
|
+
|
|
29
|
+
# Ports to skip by default (well-known ports < 1000)
|
|
30
|
+
DEFAULT_SKIP_PORTS = set(range(0, 1000))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SSHTunnel:
|
|
34
|
+
"""Represents a single SSH tunnel (forwarded port)."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, ssh_client: SSHClient, remote_host: str, remote_port: int, local_port: int):
|
|
37
|
+
self.ssh_client = ssh_client
|
|
38
|
+
self.remote_host = remote_host
|
|
39
|
+
self.remote_port = remote_port
|
|
40
|
+
self.local_port = local_port
|
|
41
|
+
self.transport = ssh_client.get_transport()
|
|
42
|
+
self.server_socket = None
|
|
43
|
+
self.forward_thread = None
|
|
44
|
+
self.active = False
|
|
45
|
+
|
|
46
|
+
def start(self):
|
|
47
|
+
"""Start the port forwarding in a background thread."""
|
|
48
|
+
try:
|
|
49
|
+
# Create a local server socket
|
|
50
|
+
self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
51
|
+
self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
52
|
+
self.server_socket.bind(("127.0.0.1", self.local_port))
|
|
53
|
+
self.server_socket.listen(5)
|
|
54
|
+
self.server_socket.settimeout(1.0) # Non-blocking accept
|
|
55
|
+
|
|
56
|
+
self.active = True
|
|
57
|
+
self.forward_thread = threading.Thread(
|
|
58
|
+
target=self._forward_loop,
|
|
59
|
+
daemon=True,
|
|
60
|
+
name=f"Tunnel-{self.local_port}->{self.remote_port}"
|
|
61
|
+
)
|
|
62
|
+
self.forward_thread.start()
|
|
63
|
+
logger.debug(f"✓ Tunnel active: localhost:{self.local_port} -> {self.remote_host}:{self.remote_port}")
|
|
64
|
+
return True
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.error(f"Failed to start tunnel for port {self.remote_port}: {e}")
|
|
67
|
+
if self.server_socket:
|
|
68
|
+
self.server_socket.close()
|
|
69
|
+
self.server_socket = None
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
def _forward_loop(self):
|
|
73
|
+
"""Main loop that accepts connections and forwards them."""
|
|
74
|
+
while self.active:
|
|
75
|
+
try:
|
|
76
|
+
client_sock, addr = self.server_socket.accept()
|
|
77
|
+
threading.Thread(
|
|
78
|
+
target=self._handler,
|
|
79
|
+
args=(client_sock,),
|
|
80
|
+
daemon=True,
|
|
81
|
+
).start()
|
|
82
|
+
except socket.timeout:
|
|
83
|
+
continue
|
|
84
|
+
except Exception as e:
|
|
85
|
+
if self.active:
|
|
86
|
+
logger.debug(f"Error accepting connection: {e}")
|
|
87
|
+
|
|
88
|
+
def _handler(self, client_sock):
|
|
89
|
+
"""Handle a single forwarded connection."""
|
|
90
|
+
chan = None
|
|
91
|
+
try:
|
|
92
|
+
# Open a direct-tcpip channel through SSH
|
|
93
|
+
chan = self.transport.open_channel(
|
|
94
|
+
"direct-tcpip",
|
|
95
|
+
(self.remote_host, self.remote_port),
|
|
96
|
+
("127.0.0.1", 0),
|
|
97
|
+
)
|
|
98
|
+
# Pipe data between client socket and SSH channel
|
|
99
|
+
self._pipe(client_sock, chan)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.debug(f"Connection error: {e}")
|
|
102
|
+
finally:
|
|
103
|
+
try:
|
|
104
|
+
client_sock.close()
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
try:
|
|
108
|
+
if chan:
|
|
109
|
+
chan.close()
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
def _pipe(self, sock, chan):
|
|
114
|
+
"""Pipe data between socket and SSH channel."""
|
|
115
|
+
try:
|
|
116
|
+
while self.active:
|
|
117
|
+
# Check if either end is closed
|
|
118
|
+
if chan.closed or chan.eof_received:
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
# Use select to wait for data on either end
|
|
122
|
+
import select
|
|
123
|
+
r, w, x = select.select([sock, chan], [], [], 1.0)
|
|
124
|
+
if sock in r:
|
|
125
|
+
data = sock.recv(4096)
|
|
126
|
+
if not data:
|
|
127
|
+
break
|
|
128
|
+
chan.send(data)
|
|
129
|
+
if chan in r:
|
|
130
|
+
data = chan.recv(4096)
|
|
131
|
+
if not data:
|
|
132
|
+
break
|
|
133
|
+
sock.send(data)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
finally:
|
|
137
|
+
try:
|
|
138
|
+
sock.close()
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
141
|
+
try:
|
|
142
|
+
chan.close()
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
def stop(self):
|
|
147
|
+
"""Stop the tunnel."""
|
|
148
|
+
self.active = False
|
|
149
|
+
try:
|
|
150
|
+
if self.server_socket:
|
|
151
|
+
self.server_socket.close()
|
|
152
|
+
except Exception as e:
|
|
153
|
+
logger.debug(f"Error closing server socket: {e}")
|
|
154
|
+
logger.info(f"✗ Tunnel stopped: localhost:{self.local_port} -> {self.remote_host}:{self.remote_port}")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class SSHAutoForwarder:
|
|
158
|
+
"""Main class that manages SSH connection and auto port forwarding."""
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
host_alias: str,
|
|
163
|
+
ssh_config_path: Optional[str] = None,
|
|
164
|
+
skip_ports: Optional[Set[int]] = None,
|
|
165
|
+
port_range: Tuple[int, int] = (3000, 10000),
|
|
166
|
+
scan_interval: int = 5,
|
|
167
|
+
):
|
|
168
|
+
self.host_alias = host_alias
|
|
169
|
+
self.ssh_config_path = ssh_config_path or self._find_ssh_config()
|
|
170
|
+
self.skip_ports = skip_ports or DEFAULT_SKIP_PORTS
|
|
171
|
+
self.port_range = port_range
|
|
172
|
+
self.scan_interval = scan_interval
|
|
173
|
+
|
|
174
|
+
self.ssh_client = None
|
|
175
|
+
self.tunnels: Dict[int, SSHTunnel] = {} # remote_port -> tunnel
|
|
176
|
+
self.local_port_map: Dict[int, int] = {} # remote_port -> local_port
|
|
177
|
+
self.failed_ports: Set[int] = set() # Ports that failed to forward
|
|
178
|
+
self.running = False
|
|
179
|
+
self.next_alt_port = port_range[0]
|
|
180
|
+
|
|
181
|
+
# Get connection details
|
|
182
|
+
self.config = self._load_ssh_config(host_alias)
|
|
183
|
+
|
|
184
|
+
def _find_ssh_config(self) -> str:
|
|
185
|
+
"""Find the SSH config file."""
|
|
186
|
+
home = Path.home()
|
|
187
|
+
for path in [".ssh/config", ".ssh/config.d/*"]:
|
|
188
|
+
full_path = home / path
|
|
189
|
+
if full_path.exists():
|
|
190
|
+
return str(full_path)
|
|
191
|
+
return os.path.expanduser("~/.ssh/config")
|
|
192
|
+
|
|
193
|
+
def _load_ssh_config(self, host_alias: str) -> dict:
|
|
194
|
+
"""Load configuration for a host from SSH config."""
|
|
195
|
+
config = {
|
|
196
|
+
"hostname": host_alias,
|
|
197
|
+
"user": os.getenv("USER") or os.getenv("USERNAME"),
|
|
198
|
+
"port": 22,
|
|
199
|
+
"identityfile": None,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if not os.path.exists(self.ssh_config_path):
|
|
203
|
+
logger.warning(f"SSH config not found at {self.ssh_config_path}, using defaults")
|
|
204
|
+
return config
|
|
205
|
+
|
|
206
|
+
# Parse SSH config (simple parser)
|
|
207
|
+
current_host = None
|
|
208
|
+
host_pattern = None
|
|
209
|
+
|
|
210
|
+
with open(self.ssh_config_path, "r") as f:
|
|
211
|
+
for line in f:
|
|
212
|
+
line = line.strip()
|
|
213
|
+
if not line or line.startswith("#"):
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
# Handle multi-line values (rare)
|
|
217
|
+
parts = line.split(maxsplit=1)
|
|
218
|
+
if len(parts) < 2:
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
key, value = parts[0].lower(), parts[1]
|
|
222
|
+
|
|
223
|
+
if key == "host":
|
|
224
|
+
if current_host is not None and self._host_matches(current_host, host_alias):
|
|
225
|
+
break
|
|
226
|
+
# Convert SSH glob pattern to regex
|
|
227
|
+
host_pattern = value.replace(".", r"\.").replace("*", ".*").replace("?", ".")
|
|
228
|
+
current_host = value
|
|
229
|
+
elif current_host and re.match(host_pattern, host_alias):
|
|
230
|
+
if key == "hostname":
|
|
231
|
+
config["hostname"] = value
|
|
232
|
+
elif key == "user":
|
|
233
|
+
config["user"] = value
|
|
234
|
+
elif key == "port":
|
|
235
|
+
config["port"] = int(value)
|
|
236
|
+
elif key == "identityfile":
|
|
237
|
+
# Remove quotes if present
|
|
238
|
+
config["identityfile"] = value.strip('"').strip("'")
|
|
239
|
+
|
|
240
|
+
logger.info(f"Loaded config for '{host_alias}': {config['user']}@{config['hostname']}:{config['port']}")
|
|
241
|
+
return config
|
|
242
|
+
|
|
243
|
+
def _host_matches(self, pattern: str, host: str) -> bool:
|
|
244
|
+
"""Check if a host pattern matches the target host."""
|
|
245
|
+
regex = pattern.replace(".", r"\.").replace("*", ".*").replace("?", ".")
|
|
246
|
+
return re.match(regex, host) is not None
|
|
247
|
+
|
|
248
|
+
def _load_keys(self, key_path: str) -> list:
|
|
249
|
+
"""Load a private key file, trying different key formats."""
|
|
250
|
+
keys = []
|
|
251
|
+
key_types = [
|
|
252
|
+
paramiko.RSAKey,
|
|
253
|
+
paramiko.ECDSAKey,
|
|
254
|
+
paramiko.Ed25519Key,
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
for key_type in key_types:
|
|
258
|
+
try:
|
|
259
|
+
key = key_type.from_private_key_file(key_path)
|
|
260
|
+
keys.append(key)
|
|
261
|
+
logger.debug(f"Loaded key type: {key_type.__name__}")
|
|
262
|
+
break
|
|
263
|
+
except Exception:
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
return keys
|
|
267
|
+
|
|
268
|
+
def _get_agent_keys(self) -> list:
|
|
269
|
+
"""Get keys from SSH agent."""
|
|
270
|
+
keys = []
|
|
271
|
+
try:
|
|
272
|
+
agent = paramiko.AgentRequestHandler()
|
|
273
|
+
keys = agent.get_keys()
|
|
274
|
+
logger.debug(f"Found {len(keys)} key(s) in SSH agent")
|
|
275
|
+
except Exception as e:
|
|
276
|
+
logger.debug(f"SSH agent not available: {e}")
|
|
277
|
+
return keys
|
|
278
|
+
|
|
279
|
+
def _find_identity_keys(self) -> list:
|
|
280
|
+
"""Find identity keys from SSH config or default locations."""
|
|
281
|
+
keys = []
|
|
282
|
+
|
|
283
|
+
# Try identityfile from SSH config first
|
|
284
|
+
if self.config.get("identityfile"):
|
|
285
|
+
key_path = os.path.expanduser(self.config["identityfile"])
|
|
286
|
+
if os.path.exists(key_path):
|
|
287
|
+
keys.extend(self._load_keys(key_path))
|
|
288
|
+
|
|
289
|
+
# Try default key locations if no keys found yet
|
|
290
|
+
if not keys:
|
|
291
|
+
default_keys = [
|
|
292
|
+
"~/.ssh/id_rsa",
|
|
293
|
+
"~/.ssh/id_ed25519",
|
|
294
|
+
"~/.ssh/id_ecdsa",
|
|
295
|
+
"~/.ssh/id_ecdsa2",
|
|
296
|
+
"~/.ssh/id_dsa",
|
|
297
|
+
]
|
|
298
|
+
for key_path in default_keys:
|
|
299
|
+
expanded = os.path.expanduser(key_path)
|
|
300
|
+
if os.path.exists(expanded):
|
|
301
|
+
loaded = self._load_keys(expanded)
|
|
302
|
+
if loaded:
|
|
303
|
+
keys.extend(loaded)
|
|
304
|
+
logger.debug(f"Loaded key from {key_path}")
|
|
305
|
+
break
|
|
306
|
+
|
|
307
|
+
return keys
|
|
308
|
+
|
|
309
|
+
def connect(self) -> bool:
|
|
310
|
+
"""Establish SSH connection to the remote host."""
|
|
311
|
+
self.ssh_client = SSHClient()
|
|
312
|
+
self.ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
logger.info(f"Connecting to {self.config['hostname']}...")
|
|
316
|
+
|
|
317
|
+
# Prepare connection parameters
|
|
318
|
+
connect_kwargs = {
|
|
319
|
+
"hostname": self.config['hostname'],
|
|
320
|
+
"port": self.config['port'],
|
|
321
|
+
"username": self.config['user'],
|
|
322
|
+
"timeout": 10,
|
|
323
|
+
"allow_agent": False, # We'll handle agent manually
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
# Method 1: Try SSH agent keys first
|
|
327
|
+
agent_keys = self._get_agent_keys()
|
|
328
|
+
if agent_keys:
|
|
329
|
+
logger.info(f"Trying SSH agent authentication ({len(agent_keys)} key(s))...")
|
|
330
|
+
connect_kwargs["pkey"] = agent_keys[0]
|
|
331
|
+
|
|
332
|
+
# Method 2: Try identity file keys
|
|
333
|
+
if not agent_keys:
|
|
334
|
+
identity_keys = self._find_identity_keys()
|
|
335
|
+
if identity_keys:
|
|
336
|
+
logger.info("Trying identity file authentication...")
|
|
337
|
+
connect_kwargs["pkey"] = identity_keys[0]
|
|
338
|
+
else:
|
|
339
|
+
# No keys found, let SSH client try with allow_agent=True
|
|
340
|
+
logger.info("Trying with default authentication...")
|
|
341
|
+
connect_kwargs["allow_agent"] = True
|
|
342
|
+
|
|
343
|
+
self.ssh_client.connect(**connect_kwargs)
|
|
344
|
+
logger.info("✓ Connected!")
|
|
345
|
+
return True
|
|
346
|
+
|
|
347
|
+
except paramiko.AuthenticationException:
|
|
348
|
+
logger.error("Authentication failed. Check your SSH keys or credentials.")
|
|
349
|
+
logger.info("Hint: Ensure your SSH key is loaded in ssh-agent or your SSH config is correct.")
|
|
350
|
+
return False
|
|
351
|
+
except Exception as e:
|
|
352
|
+
logger.error(f"Connection failed: {e}")
|
|
353
|
+
return False
|
|
354
|
+
|
|
355
|
+
def get_remote_listening_ports(self) -> Dict[int, str]:
|
|
356
|
+
"""Get the list of listening ports on the remote server with process names."""
|
|
357
|
+
try:
|
|
358
|
+
# Try to get port + process name
|
|
359
|
+
commands = [
|
|
360
|
+
"ss -tlnp 2>/dev/null | awk 'NR>1 {print $4, $7}'",
|
|
361
|
+
"netstat -tlnp 2>/dev/null | awk 'NR>1 && /LISTEN/ {print $4, $7}'",
|
|
362
|
+
]
|
|
363
|
+
|
|
364
|
+
for cmd in commands:
|
|
365
|
+
stdin, stdout, stderr = self.ssh_client.exec_command(cmd)
|
|
366
|
+
output = stdout.read().decode().strip()
|
|
367
|
+
error = stderr.read().decode().strip()
|
|
368
|
+
|
|
369
|
+
if error and "permission denied" in error.lower():
|
|
370
|
+
continue # Try next command
|
|
371
|
+
|
|
372
|
+
ports = {}
|
|
373
|
+
for line in output.split("\n"):
|
|
374
|
+
line = line.strip()
|
|
375
|
+
if not line:
|
|
376
|
+
continue
|
|
377
|
+
parts = line.split()
|
|
378
|
+
if len(parts) >= 2:
|
|
379
|
+
# Extract port from address (e.g., "0.0.0.0:2999")
|
|
380
|
+
addr = parts[0]
|
|
381
|
+
if ":" in addr:
|
|
382
|
+
port_str = addr.split(":")[-1]
|
|
383
|
+
if port_str.isdigit():
|
|
384
|
+
port = int(port_str)
|
|
385
|
+
# Extract process name (e.g., "users:(("mdtohtml-watch",pid=518168,fd=4))")
|
|
386
|
+
proc_info = parts[1]
|
|
387
|
+
# Try to extract process name from various formats
|
|
388
|
+
proc_name = "unknown"
|
|
389
|
+
if 'users:(("' in proc_info:
|
|
390
|
+
proc_name = proc_info.split('users:(("')[1].split('"')[0]
|
|
391
|
+
elif 'users:(("' in proc_info:
|
|
392
|
+
proc_name = proc_info.split('users:(("')[1].split('"')[0]
|
|
393
|
+
elif "/" in proc_info:
|
|
394
|
+
proc_name = proc_info.split("/")[-1].split(",")[0]
|
|
395
|
+
ports[port] = proc_name
|
|
396
|
+
|
|
397
|
+
if ports:
|
|
398
|
+
return ports
|
|
399
|
+
|
|
400
|
+
# Fallback: just get ports without process names
|
|
401
|
+
commands_fallback = [
|
|
402
|
+
"ss -tln 2>/dev/null | awk 'NR>1 {print $4}' | cut -d: -f2 | sort -u",
|
|
403
|
+
]
|
|
404
|
+
|
|
405
|
+
for cmd in commands_fallback:
|
|
406
|
+
stdin, stdout, stderr = self.ssh_client.exec_command(cmd)
|
|
407
|
+
output = stdout.read().decode().strip()
|
|
408
|
+
|
|
409
|
+
ports = {}
|
|
410
|
+
for line in output.split("\n"):
|
|
411
|
+
line = line.strip()
|
|
412
|
+
if line and line.isdigit():
|
|
413
|
+
ports[int(line)] = ""
|
|
414
|
+
if ports:
|
|
415
|
+
return ports
|
|
416
|
+
|
|
417
|
+
return {}
|
|
418
|
+
|
|
419
|
+
except Exception as e:
|
|
420
|
+
logger.debug(f"Error getting remote ports: {e}")
|
|
421
|
+
return {}
|
|
422
|
+
|
|
423
|
+
def is_local_port_available(self, port: int) -> bool:
|
|
424
|
+
"""Check if a local port is available."""
|
|
425
|
+
# Check if already used for SSH forwarding
|
|
426
|
+
if port in self.local_port_map.values():
|
|
427
|
+
return False
|
|
428
|
+
|
|
429
|
+
# Check if port can be bound by a socket
|
|
430
|
+
try:
|
|
431
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
432
|
+
s.bind(("127.0.0.1", port))
|
|
433
|
+
return True
|
|
434
|
+
except OSError:
|
|
435
|
+
return False
|
|
436
|
+
|
|
437
|
+
def find_available_local_port(self, preferred_port: int) -> Optional[int]:
|
|
438
|
+
"""Find an available local port, preferring the preferred port."""
|
|
439
|
+
# Try the preferred port first
|
|
440
|
+
if self.is_local_port_available(preferred_port):
|
|
441
|
+
return preferred_port
|
|
442
|
+
|
|
443
|
+
# Increment until we find a free port
|
|
444
|
+
for offset in range(1, 1000):
|
|
445
|
+
alt_port = preferred_port + offset
|
|
446
|
+
if alt_port > 65535:
|
|
447
|
+
break # Port number too high
|
|
448
|
+
if self.is_local_port_available(alt_port):
|
|
449
|
+
return alt_port
|
|
450
|
+
|
|
451
|
+
# If still no luck, try from port 3000 upwards
|
|
452
|
+
for port in range(3000, 65535):
|
|
453
|
+
if self.is_local_port_available(port):
|
|
454
|
+
return port
|
|
455
|
+
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
def forward_port(self, remote_port: int, process_name: str = "") -> bool:
|
|
459
|
+
"""Create a tunnel for a remote port."""
|
|
460
|
+
if remote_port in self.tunnels:
|
|
461
|
+
return True # Already forwarded
|
|
462
|
+
|
|
463
|
+
if remote_port in self.skip_ports:
|
|
464
|
+
logger.debug(f"Skipping port {remote_port} (in skip list)")
|
|
465
|
+
return False
|
|
466
|
+
|
|
467
|
+
# Skip remote ports that match our local forwarding ports - they're likely our own tunnels
|
|
468
|
+
# But allow the port if it's the same as the remote port (direct forwarding)
|
|
469
|
+
used_local_ports = set(self.local_port_map.values())
|
|
470
|
+
if remote_port in used_local_ports and remote_port not in self.tunnels:
|
|
471
|
+
logger.debug(f"Skipping port {remote_port} (already used as a local forwarding port)")
|
|
472
|
+
return False
|
|
473
|
+
|
|
474
|
+
# Skip ports that previously failed (to avoid spamming errors)
|
|
475
|
+
if remote_port in self.failed_ports:
|
|
476
|
+
return False
|
|
477
|
+
|
|
478
|
+
local_port = self.find_available_local_port(remote_port)
|
|
479
|
+
if local_port is None:
|
|
480
|
+
logger.warning(f"⚠ No available local port for remote port {remote_port}")
|
|
481
|
+
self.failed_ports.add(remote_port)
|
|
482
|
+
return False
|
|
483
|
+
|
|
484
|
+
tunnel = SSHTunnel(
|
|
485
|
+
ssh_client=self.ssh_client,
|
|
486
|
+
remote_host="localhost",
|
|
487
|
+
remote_port=remote_port,
|
|
488
|
+
local_port=local_port,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
if tunnel.start():
|
|
492
|
+
self.tunnels[remote_port] = tunnel
|
|
493
|
+
self.local_port_map[remote_port] = local_port
|
|
494
|
+
|
|
495
|
+
proc_suffix = f" ({process_name})" if process_name else ""
|
|
496
|
+
if local_port != remote_port:
|
|
497
|
+
logger.info(f"✓ Forwarding remote port {remote_port} -> local port {local_port}{proc_suffix}")
|
|
498
|
+
else:
|
|
499
|
+
logger.info(f"✓ Forwarding port {remote_port}{proc_suffix}")
|
|
500
|
+
return True
|
|
501
|
+
else:
|
|
502
|
+
# Track failed ports to avoid retrying
|
|
503
|
+
self.failed_ports.add(remote_port)
|
|
504
|
+
return False
|
|
505
|
+
|
|
506
|
+
def stop_forwarding_port(self, remote_port: int):
|
|
507
|
+
"""Stop forwarding a specific port."""
|
|
508
|
+
if remote_port in self.tunnels:
|
|
509
|
+
self.tunnels[remote_port].stop()
|
|
510
|
+
del self.tunnels[remote_port]
|
|
511
|
+
del self.local_port_map[remote_port]
|
|
512
|
+
# Remove from failed ports so we can retry if the port comes back
|
|
513
|
+
self.failed_ports.discard(remote_port)
|
|
514
|
+
|
|
515
|
+
def scan_and_forward(self):
|
|
516
|
+
"""Scan for new ports and set up forwarding."""
|
|
517
|
+
remote_ports = self.get_remote_listening_ports()
|
|
518
|
+
|
|
519
|
+
if not remote_ports:
|
|
520
|
+
return
|
|
521
|
+
|
|
522
|
+
logger.debug(f"Found {len(remote_ports)} listening port(s) on remote")
|
|
523
|
+
|
|
524
|
+
# Forward new ports
|
|
525
|
+
for port, proc_name in remote_ports.items():
|
|
526
|
+
self.forward_port(port, proc_name)
|
|
527
|
+
|
|
528
|
+
# Stop forwarding closed ports
|
|
529
|
+
current_remote_ports = set(self.tunnels.keys())
|
|
530
|
+
closed_ports = current_remote_ports - set(remote_ports.keys())
|
|
531
|
+
for port in closed_ports:
|
|
532
|
+
logger.info(f"✗ Remote port {port} is no longer listening, stopping tunnel")
|
|
533
|
+
self.stop_forwarding_port(port)
|
|
534
|
+
|
|
535
|
+
# Update terminal title with status
|
|
536
|
+
self._update_terminal_title()
|
|
537
|
+
|
|
538
|
+
def _update_terminal_title(self):
|
|
539
|
+
"""Update terminal title with current status."""
|
|
540
|
+
try:
|
|
541
|
+
port_count = len(self.tunnels)
|
|
542
|
+
title = f"ssh-auto-forward: {self.host_alias} ({port_count} tunnels active)"
|
|
543
|
+
# ANSI escape code to set terminal title
|
|
544
|
+
print(f"\033]0;{title}\007", end="", flush=True)
|
|
545
|
+
except Exception:
|
|
546
|
+
pass
|
|
547
|
+
|
|
548
|
+
def run(self):
|
|
549
|
+
"""Main loop - connect and continuously scan for ports."""
|
|
550
|
+
if not self.connect():
|
|
551
|
+
logger.error("Failed to connect. Exiting.")
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
self.running = True
|
|
555
|
+
|
|
556
|
+
try:
|
|
557
|
+
logger.info("Starting port detection loop...")
|
|
558
|
+
|
|
559
|
+
# Initial scan
|
|
560
|
+
self.scan_and_forward()
|
|
561
|
+
|
|
562
|
+
# Continuous scanning
|
|
563
|
+
while self.running:
|
|
564
|
+
time.sleep(self.scan_interval)
|
|
565
|
+
self.scan_and_forward()
|
|
566
|
+
|
|
567
|
+
except KeyboardInterrupt:
|
|
568
|
+
logger.info("\nReceived interrupt signal, shutting down...")
|
|
569
|
+
finally:
|
|
570
|
+
self.shutdown()
|
|
571
|
+
|
|
572
|
+
def shutdown(self):
|
|
573
|
+
"""Clean up all tunnels and close connection."""
|
|
574
|
+
self.running = False
|
|
575
|
+
|
|
576
|
+
logger.info("Stopping all tunnels...")
|
|
577
|
+
for remote_port in list(self.tunnels.keys()):
|
|
578
|
+
self.stop_forwarding_port(remote_port)
|
|
579
|
+
|
|
580
|
+
if self.ssh_client:
|
|
581
|
+
self.ssh_client.close()
|
|
582
|
+
logger.info("✓ Disconnected")
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def main():
|
|
586
|
+
parser = argparse.ArgumentParser(
|
|
587
|
+
description="Automatically forward ports from a remote SSH server to your local machine",
|
|
588
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
589
|
+
epilog="""
|
|
590
|
+
Examples:
|
|
591
|
+
ssh-auto-forward myserver # Forward ports from host defined in SSH config
|
|
592
|
+
ssh-auto-forward myserver -v # Enable verbose logging
|
|
593
|
+
ssh-auto-forward myserver -p 4000:9000 # Use local port range 4000-9000
|
|
594
|
+
ssh-auto-forward myserver -s 22,80,443 # Skip specific ports
|
|
595
|
+
""",
|
|
596
|
+
)
|
|
597
|
+
parser.add_argument("host", help="Host alias from SSH config or hostname")
|
|
598
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose logging")
|
|
599
|
+
parser.add_argument("-i", "--interval", type=int, default=5, help="Scan interval in seconds (default: 5)")
|
|
600
|
+
parser.add_argument(
|
|
601
|
+
"-p",
|
|
602
|
+
"--port-range",
|
|
603
|
+
default="3000:10000",
|
|
604
|
+
metavar="MIN:MAX",
|
|
605
|
+
help="Local port range to use (default: 3000:10000)",
|
|
606
|
+
)
|
|
607
|
+
parser.add_argument(
|
|
608
|
+
"-s",
|
|
609
|
+
"--skip",
|
|
610
|
+
default="",
|
|
611
|
+
metavar="PORTS",
|
|
612
|
+
help="Comma-separated list of ports to skip (default: all ports < 1000)",
|
|
613
|
+
)
|
|
614
|
+
parser.add_argument("-c", "--config", help="Path to SSH config file")
|
|
615
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
616
|
+
|
|
617
|
+
args = parser.parse_args()
|
|
618
|
+
|
|
619
|
+
if args.verbose:
|
|
620
|
+
logging.getLogger("ssh-auto-forward").setLevel(logging.DEBUG)
|
|
621
|
+
paramiko.common.logging.basicConfig(level=paramiko.common.logging.DEBUG)
|
|
622
|
+
|
|
623
|
+
# Parse port range
|
|
624
|
+
try:
|
|
625
|
+
port_min, port_max = map(int, args.port_range.split(":"))
|
|
626
|
+
port_range = (port_min, port_max)
|
|
627
|
+
except ValueError:
|
|
628
|
+
logger.error("Invalid port range. Use format MIN:MAX (e.g., 3000:10000)")
|
|
629
|
+
sys.exit(1)
|
|
630
|
+
|
|
631
|
+
# Parse skip ports
|
|
632
|
+
skip_ports = DEFAULT_SKIP_PORTS.copy()
|
|
633
|
+
if args.skip:
|
|
634
|
+
try:
|
|
635
|
+
extra_skip = {int(p.strip()) for p in args.skip.split(",")}
|
|
636
|
+
skip_ports.update(extra_skip)
|
|
637
|
+
except ValueError:
|
|
638
|
+
logger.error("Invalid skip ports. Use comma-separated integers (e.g., 22,80,443)")
|
|
639
|
+
sys.exit(1)
|
|
640
|
+
|
|
641
|
+
forwarder = SSHAutoForwarder(
|
|
642
|
+
host_alias=args.host,
|
|
643
|
+
ssh_config_path=args.config,
|
|
644
|
+
skip_ports=skip_ports,
|
|
645
|
+
port_range=port_range,
|
|
646
|
+
scan_interval=args.interval,
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
forwarder.run()
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
if __name__ == "__main__":
|
|
653
|
+
main()
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ssh-auto-forward
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Auto-forward SSH ports
|
|
5
|
+
Author: alexe
|
|
6
|
+
License: WTFPL
|
|
7
|
+
Keywords: port-forwarding,remote-development,ssh,tunnel
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: paramiko>=3.4.0
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# SSH Auto Port Forwarder
|
|
20
|
+
|
|
21
|
+
Automatically detect and forward ports from a remote SSH server to your local machine. Similar to VS Code's port forwarding feature, but fully automatic.
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
- Automatically discovers listening ports on the remote server
|
|
26
|
+
- Shows process names for each forwarded port
|
|
27
|
+
- Forwards ports to your local machine via SSH tunneling
|
|
28
|
+
- Handles port conflicts by finding alternative local ports
|
|
29
|
+
- Auto-detects new ports and starts forwarding
|
|
30
|
+
- Auto-detects closed ports and stops forwarding
|
|
31
|
+
- Terminal title shows tunnel count
|
|
32
|
+
- Runs in the background with status updates
|
|
33
|
+
- Reads connection details from your SSH config
|
|
34
|
+
- Skips well-known ports (< 1000) by default
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
### With uv (recommended):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uvx ssh-auto-forward hetzner
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Install locally:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
cd portforwards
|
|
48
|
+
uv sync
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
This installs the `ssh-auto-forward` command.
|
|
52
|
+
|
|
53
|
+
### Local development:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
make run ARGS=hetzner
|
|
57
|
+
make run ARGS="hetzner -v"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
### Basic usage - uses host from your SSH config:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
ssh-auto-forward hetzner
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Options:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
-v, --verbose Enable verbose logging
|
|
72
|
+
-i, --interval SECS Scan interval in seconds (default: 5)
|
|
73
|
+
-p, --port-range MIN:MAX Local port range for remapping (default: 3000:10000)
|
|
74
|
+
-s, --skip PORTS Comma-separated ports to skip (default: all ports < 1000)
|
|
75
|
+
-c, --config PATH Path to SSH config file
|
|
76
|
+
--version Show version and exit
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Examples:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# Scan every 3 seconds
|
|
83
|
+
ssh-auto-forward hetzner -i 3
|
|
84
|
+
|
|
85
|
+
# Use specific port range
|
|
86
|
+
ssh-auto-forward hetzner -p 4000:9000
|
|
87
|
+
|
|
88
|
+
# Skip specific ports
|
|
89
|
+
ssh-auto-forward hetzner -s 22,80,443
|
|
90
|
+
|
|
91
|
+
# Verbose mode
|
|
92
|
+
ssh-auto-forward hetzner -v
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## How it works
|
|
96
|
+
|
|
97
|
+
1. Connects to your remote server using your SSH config
|
|
98
|
+
2. Runs `ss -tlnp` on the remote to find listening ports
|
|
99
|
+
3. Creates SSH tunnels for each discovered port
|
|
100
|
+
4. Continuously monitors for new/closed ports
|
|
101
|
+
5. Handles port conflicts on your local machine
|
|
102
|
+
|
|
103
|
+
## Status messages
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
✓ Connected!
|
|
107
|
+
✓ Forwarding port 2999 (python3)
|
|
108
|
+
✓ Forwarding port 7681 (ttyd)
|
|
109
|
+
✓ Forwarding remote port 19840 -> local port 3000 (node)
|
|
110
|
+
✗ Remote port 2999 is no longer listening, stopping tunnel
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The terminal title also updates to show: `ssh-auto-forward: hetzner (18 tunnels active)`
|
|
114
|
+
|
|
115
|
+
## Testing
|
|
116
|
+
|
|
117
|
+
Start a test server on your remote machine:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
ssh hetzner "python3 -m http.server 9999 --bind 127.0.0.1 &"
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Then run `ssh-auto-forward hetzner` and you should see:
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
✓ Forwarding remote port 9999 -> local port 3003 (python3)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Access it locally:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
curl http://localhost:3003/
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Stopping
|
|
136
|
+
|
|
137
|
+
Press `Ctrl+C` to stop the forwarder and close all tunnels.
|
|
138
|
+
|
|
139
|
+
## Requirements
|
|
140
|
+
|
|
141
|
+
- Python 3.10+
|
|
142
|
+
- paramiko
|
|
143
|
+
- Remote server must have `ss` or `netstat` command available
|
|
144
|
+
|
|
145
|
+
## Tests
|
|
146
|
+
|
|
147
|
+
### Unit tests (run locally, no SSH required):
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
make test
|
|
151
|
+
# or
|
|
152
|
+
uv run pytest tests/ -v
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Integration tests (require SSH access):
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
SSH_AUTO_FORWARD_TEST_HOST=hetzner uv run pytest tests_integration/ -v
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The integration tests:
|
|
162
|
+
- Test that remote ports are forwarded to the same local port when available
|
|
163
|
+
- Test that ports increment by 1 when the local port is busy
|
|
164
|
+
- Test auto-detection of new ports
|
|
165
|
+
- Test auto-cleanup when remote ports close
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
ssh_auto_forward/__init__.py,sha256=6R1wUse_b0k-zeWxZOixpeqjc_vrZjdkQ730b3LqsF0,166
|
|
2
|
+
ssh_auto_forward/__version__.py,sha256=sXLh7g3KC4QCFxcZGBTpG2scR7hmmBsMjq6LqRptkRg,22
|
|
3
|
+
ssh_auto_forward/cli.py,sha256=dtUaBqyYaKH5b1Jl7Qnop2ZSUG3VsfR6XmmBXA3l_Lg,24046
|
|
4
|
+
ssh_auto_forward-0.0.1.dist-info/METADATA,sha256=jw0B7WlgrhGZn5tnRFTqvaAoJfWpCmEzqphfLLlzq80,3892
|
|
5
|
+
ssh_auto_forward-0.0.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
6
|
+
ssh_auto_forward-0.0.1.dist-info/entry_points.txt,sha256=2DQMDbkXDrmoBg03lQq3vMJ5O1W2ZrxapDgKztQQUOQ,63
|
|
7
|
+
ssh_auto_forward-0.0.1.dist-info/RECORD,,
|