Tunnels SSH, envoi multi-onglets, et reparation de main_window.py
Tunnels (redirection de ports), la specialite de Bitvise : un bouton dans la barre d'un onglet SSH ouvre un gestionnaire de redirections. - locale (-L) : un port de votre poste est relaye, via la connexion SSH, vers une machine joignable depuis le serveur (typiquement un service qui n'ecoute que sur la boucle locale du serveur) - distante (-R) : l'inverse Chaque tunnel tourne dans son propre thread, un relais bloque ne genant ni l'interface ni les autres tunnels. Ils sont fermes avec la session. Envoi simultane, façon MobaXterm : le bouton "Multi" diffuse la frappe vers tous les onglets terminal. La diffusion ecrit directement dans les connexions et non via les widgets terminal, sinon chaque onglet rediffuserait a son tour. Reparation : un de mes scripts de correction avait reecrit main_window.py en retraduisant deux fois les fins de ligne, produisant des sequences \r\r\n ; le fichier avait ete committe ainsi (2538 lignes au lieu de 856, une ligne vide inseree partout). Le contenu etait intact, seule la mise en forme etait touchee. Le fichier est reconstitue par normalisation des fins de ligne, et un controle d'integrite a ete ajoute a la verification de non-regression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
189
app/tunnels.py
Normal file
189
app/tunnels.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user