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:
@@ -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
|
||||
memorisee), barre de defilement de l'historique, `Maj+PagePrec/Suiv`, `Ctrl+Fin` pour revenir
|
||||
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
|
||||
avec des serveurs de test qui redemarrent souvent
|
||||
- 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)
|
||||
sftp_panel.py client SFTP a deux panneaux
|
||||
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/
|
||||
theme.qss feuille de style Qt "bleu nuit"
|
||||
data/
|
||||
|
||||
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)
|
||||
87
tests/test_tunnels.py
Normal file
87
tests/test_tunnels.py
Normal 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")
|
||||
Reference in New Issue
Block a user