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:
snoopy
2026-09-04 11:52:25 +02:00
parent b322b95aa4
commit 9d346d76ed
5 changed files with 446 additions and 1672 deletions

189
app/tunnels.py Normal file
View 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)