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:
114
app/dialogs/tunnel_dialog.py
Normal file
114
app/dialogs/tunnel_dialog.py
Normal 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()
|
||||
1719
app/main_window.py
1719
app/main_window.py
File diff suppressed because it is too large
Load Diff
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