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.
@@ -0,0 +1,5 @@
1
+ """SSH Auto Port Forwarder - Automatically forward ports from remote SSH servers."""
2
+
3
+ from ssh_auto_forward.__version__ import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ssh-auto-forward = ssh_auto_forward.cli:main