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

View File

@@ -11,6 +11,12 @@ Client SSH/SFTP personnel (type MobaXterm/Termius/Bitvise), 100% local, sans com
- Confort d'affichage : zoom `Ctrl+molette` ou `Ctrl+ +/-` (`Ctrl+0` remet a zero, la taille est - Confort d'affichage : zoom `Ctrl+molette` ou `Ctrl+ +/-` (`Ctrl+0` remet a zero, la taille est
memorisee), barre de defilement de l'historique, `Maj+PagePrec/Suiv`, `Ctrl+Fin` pour revenir memorisee), barre de defilement de l'historique, `Maj+PagePrec/Suiv`, `Ctrl+Fin` pour revenir
en direct, et recherche dans l'historique avec `Ctrl+F` en direct, et recherche dans l'historique avec `Ctrl+F`
- **Tunnels SSH** (redirection de ports) : bouton "Tunnels" dans la barre d'un onglet SSH.
Redirection locale (`-L`) pour atteindre depuis votre poste un service qui n'ecoute que sur la
boucle locale du serveur, et redirection distante (`-R`) dans l'autre sens
- **Envoi simultane** : le bouton "⇉ Multi" du panneau lateral diffuse ce que vous tapez vers tous
les onglets terminal ouverts, pour piloter plusieurs serveurs d'un coup
- Journalisation de session dans un fichier (bouton "Journal"), sequences ANSI retirees pour rester lisible
- Reconnexion en un clic quand une session tombe (bouton dans la barre de l'onglet) : pratique - Reconnexion en un clic quand une session tombe (bouton dans la barre de l'onglet) : pratique
avec des serveurs de test qui redemarrent souvent avec des serveurs de test qui redemarrent souvent
- Les hotes deja approuves par votre commande `ssh` (`~/.ssh/known_hosts`) sont reconnus : pas de - Les hotes deja approuves par votre commande `ssh` (`~/.ssh/known_hosts`) sont reconnus : pas de
@@ -126,7 +132,8 @@ app/
terminal_widget.py emulateur de terminal ANSI (pyte + QPainter) terminal_widget.py emulateur de terminal ANSI (pyte + QPainter)
sftp_panel.py client SFTP a deux panneaux sftp_panel.py client SFTP a deux panneaux
main_window.py fenetre principale (arbre de sessions + onglets) main_window.py fenetre principale (arbre de sessions + onglets)
dialogs/ boites de dialogue (session, cles, connexion) tunnels.py tunnels SSH (redirection de ports -L / -R)
dialogs/ boites de dialogue (session, cles, hotes, tunnels, connexion)
resources/ resources/
theme.qss feuille de style Qt "bleu nuit" theme.qss feuille de style Qt "bleu nuit"
data/ data/

View File

@@ -0,0 +1,114 @@
"""Gestion des tunnels SSH (redirections de ports) d'une session ouverte."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QTreeWidget, QTreeWidgetItem,
QPushButton, QComboBox, QSpinBox, QLineEdit, QLabel, QMessageBox, QAbstractItemView,
)
from app.tunnels import (
DIRECTION_LOCAL, DIRECTION_REMOTE, DIRECTION_LABELS, TunnelManager, describe,
)
class TunnelDialog(QDialog):
def __init__(self, manager: TunnelManager, parent=None):
super().__init__(parent)
self.manager = manager
self.setWindowTitle("Tunnels SSH (redirection de ports)")
self.setMinimumSize(660, 400)
layout = QVBoxLayout(self)
info = QLabel(
"Une redirection <b>locale</b> ouvre un port sur votre poste et l'envoie, a travers "
"la connexion SSH, vers une machine joignable depuis le serveur — pratique pour "
"atteindre un service qui n'ecoute que sur la boucle locale du serveur.<br>"
"Une redirection <b>distante</b> fait l'inverse : un port ouvert sur le serveur est "
"relaye vers une machine joignable depuis votre poste."
)
info.setWordWrap(True)
layout.addWidget(info)
self.tree = QTreeWidget()
self.tree.setHeaderLabels(["Sens", "Redirection", "Etat"])
self.tree.setRootIsDecorated(False)
self.tree.setSelectionMode(QAbstractItemView.SingleSelection)
self.tree.setColumnWidth(0, 110)
self.tree.setColumnWidth(1, 380)
layout.addWidget(self.tree)
form = QFormLayout()
self.direction = QComboBox()
for key, label in DIRECTION_LABELS.items():
self.direction.addItem(label, key)
self.listen_port = QSpinBox(); self.listen_port.setRange(1, 65535); self.listen_port.setValue(8080)
self.dest_host = QLineEdit("127.0.0.1")
self.dest_port = QSpinBox(); self.dest_port.setRange(1, 65535); self.dest_port.setValue(80)
form.addRow("Sens :", self.direction)
form.addRow("Port a ouvrir :", self.listen_port)
form.addRow("Machine de destination :", self.dest_host)
form.addRow("Port de destination :", self.dest_port)
layout.addLayout(form)
buttons = QHBoxLayout()
self.add_btn = QPushButton("Ouvrir le tunnel")
self.add_btn.setObjectName("primary")
self.remove_btn = QPushButton("Fermer le tunnel selectionne")
buttons.addWidget(self.add_btn)
buttons.addWidget(self.remove_btn)
buttons.addStretch()
close_btn = QPushButton("Fermer")
close_btn.clicked.connect(self.accept)
buttons.addWidget(close_btn)
layout.addLayout(buttons)
self.add_btn.clicked.connect(self.add_tunnel)
self.remove_btn.clicked.connect(self.remove_tunnel)
self.refresh()
def refresh(self) -> None:
self.tree.clear()
for tunnel in self.manager.tunnels:
etat = f"Erreur : {tunnel.error}" if tunnel.error else "Actif"
item = QTreeWidgetItem([
DIRECTION_LABELS.get(tunnel.direction, tunnel.direction),
describe(tunnel.config),
etat,
])
item.setData(0, Qt.UserRole, id(tunnel))
self.tree.addTopLevelItem(item)
self.remove_btn.setEnabled(bool(self.manager.tunnels))
def add_tunnel(self) -> None:
if not self.dest_host.text().strip():
QMessageBox.warning(self, "Destination manquante", "Indiquez la machine de destination.")
return
config = {
"direction": self.direction.currentData(),
"listen_port": self.listen_port.value(),
"dest_host": self.dest_host.text().strip(),
"dest_port": self.dest_port.value(),
}
tunnel = self.manager.start(config)
if tunnel.error:
QMessageBox.critical(
self, "Tunnel impossible",
f"{describe(config)}\n\n{tunnel.error}\n\n"
"Le port est peut-etre deja utilise sur le poste.",
)
self.manager.stop(tunnel)
self.refresh()
def remove_tunnel(self) -> None:
item = self.tree.currentItem()
if not item:
return
target = item.data(0, Qt.UserRole)
for tunnel in list(self.manager.tunnels):
if id(tunnel) == target:
self.manager.stop(tunnel)
break
self.refresh()

File diff suppressed because it is too large Load Diff

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)

87
tests/test_tunnels.py Normal file
View File

@@ -0,0 +1,87 @@
"""Tunnels SSH : la redirection locale relaie-t-elle reellement le trafic ?
Le test monte un vrai serveur TCP local et un faux transport SSH dont le canal
'direct-tcpip' est un simple socket vers ce serveur : cela valide toute la
mecanique de relais sans avoir besoin d'un serveur SSH.
"""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import socket
import threading
import time
from app.tunnels import TunnelManager, DIRECTION_LOCAL, describe
class EchoServer(threading.Thread):
"""Serveur de destination : renvoie ce qu'il recoit, prefixe."""
def __init__(self):
super().__init__(daemon=True)
self.sock = socket.socket()
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(5)
self.port = self.sock.getsockname()[1]
def run(self):
while True:
try:
conn, _ = self.sock.accept()
except OSError:
return
data = conn.recv(1024)
conn.sendall(b"echo:" + data)
conn.close()
class FakeTransport:
"""Imite paramiko.Transport : ouvre un vrai socket vers la destination."""
def open_channel(self, kind, dest, src):
assert kind == "direct-tcpip"
return socket.create_connection(dest, timeout=5)
echo = EchoServer()
echo.start()
print(f"serveur de destination sur le port {echo.port}")
manager = TunnelManager(FakeTransport())
config = {
"direction": DIRECTION_LOCAL,
"listen_port": 0, # port libre choisi par le systeme
"dest_host": "127.0.0.1",
"dest_port": echo.port,
}
tunnel = manager.start(config)
assert tunnel.error is None, tunnel.error
port_local = tunnel._server.server_address[1]
print("tunnel local ouvert sur le port", port_local)
print("description :", describe({**config, "listen_port": port_local}))
time.sleep(0.3)
client = socket.create_connection(("127.0.0.1", port_local), timeout=5)
client.sendall(b"bonjour")
reponse = client.recv(1024)
client.close()
print("envoye : b'bonjour'")
print("recu :", reponse)
assert reponse == b"echo:bonjour", reponse
print()
print("=== fermeture du tunnel ===")
manager.stop_all()
time.sleep(0.3)
assert not manager.tunnels
try:
socket.create_connection(("127.0.0.1", port_local), timeout=1).close()
ferme = False
except OSError:
ferme = True
print("le port n'accepte plus de connexion :", ferme)
assert ferme, "le port doit etre libere apres fermeture"
print()
print("TUNNELS SSH : OK")