"""Tunnels SSH (redirection de ports), facon `ssh -L` et `ssh -R`. Redirection locale (-L) : un port ouvert sur votre poste est relaye, a travers la connexion SSH, vers un hote joignable depuis le serveur. Typiquement pour atteindre l'interface web d'un service qui n'ecoute que sur la boucle locale du serveur. Redirection distante (-R) : l'inverse, un port ouvert sur le serveur est relaye vers une machine joignable depuis votre poste. Chaque tunnel tourne dans son propre thread : un relais bloque ne doit gener ni l'interface ni les autres tunnels. """ from __future__ import annotations import select import socket import socketserver import threading import paramiko DIRECTION_LOCAL = "local" DIRECTION_REMOTE = "remote" DIRECTION_LABELS = { DIRECTION_LOCAL: "Locale (-L)", DIRECTION_REMOTE: "Distante (-R)", } def describe(tunnel: dict) -> str: """Resume lisible, dans le sens de lecture du flux.""" if tunnel.get("direction") == DIRECTION_REMOTE: return (f"serveur:{tunnel['listen_port']} -> " f"{tunnel['dest_host']}:{tunnel['dest_port']} (via ce poste)") return (f"localhost:{tunnel['listen_port']} -> " f"{tunnel['dest_host']}:{tunnel['dest_port']} (via le serveur)") def _relay(sock: socket.socket, chan: paramiko.Channel) -> None: """Recopie les octets dans les deux sens jusqu'a fermeture.""" try: while True: readable, _, _ = select.select([sock, chan], [], [], 1.0) if sock in readable: data = sock.recv(4096) if not data: break chan.sendall(data) if chan in readable: data = chan.recv(4096) if not data: break sock.sendall(data) except (OSError, socket.error): pass finally: for closeable in (chan, sock): try: closeable.close() except Exception: pass class _ForwardServer(socketserver.ThreadingTCPServer): daemon_threads = True allow_reuse_address = True class _ForwardHandler(socketserver.BaseRequestHandler): transport: paramiko.Transport dest_host: str dest_port: int def handle(self): try: chan = self.transport.open_channel( "direct-tcpip", (self.dest_host, self.dest_port), self.request.getpeername() ) except Exception: return if chan is None: return _relay(self.request, chan) class Tunnel: """Un tunnel actif, avec de quoi l'arreter.""" def __init__(self, config: dict): self.config = config self.error: str | None = None self._server: _ForwardServer | None = None self._thread: threading.Thread | None = None self._transport: paramiko.Transport | None = None self._stop = threading.Event() @property def direction(self) -> str: return self.config.get("direction", DIRECTION_LOCAL) def start(self, transport: paramiko.Transport) -> None: self._transport = transport if self.direction == DIRECTION_REMOTE: self._start_remote(transport) else: self._start_local(transport) # ---------- redirection locale (-L) ---------- def _start_local(self, transport: paramiko.Transport) -> None: config = self.config handler = type( "Handler", (_ForwardHandler,), {"transport": transport, "dest_host": config["dest_host"], "dest_port": int(config["dest_port"])}, ) bind = config.get("listen_host") or "127.0.0.1" self._server = _ForwardServer((bind, int(config["listen_port"])), handler) self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) self._thread.start() # ---------- redirection distante (-R) ---------- def _start_remote(self, transport: paramiko.Transport) -> None: config = self.config bind = config.get("listen_host") or "" transport.request_port_forward(bind, int(config["listen_port"])) self._thread = threading.Thread(target=self._accept_remote, args=(transport,), daemon=True) self._thread.start() def _accept_remote(self, transport: paramiko.Transport) -> None: while not self._stop.is_set(): chan = transport.accept(1) if chan is None: continue try: sock = socket.create_connection( (self.config["dest_host"], int(self.config["dest_port"])), timeout=10 ) except OSError: try: chan.close() except Exception: pass continue threading.Thread(target=_relay, args=(sock, chan), daemon=True).start() def stop(self) -> None: self._stop.set() if self._server: try: self._server.shutdown() self._server.server_close() except Exception: pass self._server = None if self.direction == DIRECTION_REMOTE and self._transport: try: self._transport.cancel_port_forward( self.config.get("listen_host") or "", int(self.config["listen_port"]) ) except Exception: pass class TunnelManager: """Tunnels d'une session SSH donnee.""" def __init__(self, transport: paramiko.Transport): self.transport = transport self.tunnels: list[Tunnel] = [] def start(self, config: dict) -> Tunnel: tunnel = Tunnel(config) try: tunnel.start(self.transport) except Exception as exc: # noqa: BLE001 tunnel.error = str(exc) self.tunnels.append(tunnel) return tunnel def stop(self, tunnel: Tunnel) -> None: tunnel.stop() if tunnel in self.tunnels: self.tunnels.remove(tunnel) def stop_all(self) -> None: for tunnel in list(self.tunnels): self.stop(tunnel)