From 3e53cbd47ddb1f5efd9346077b99bce59bdfc5d4 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 2 Aug 2026 05:09:55 -0400 Subject: [PATCH] [IMP] deploy: probe the remote port before opening the tunnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stopped service on the far side showed up only as a wall of « channel N: open failed: connect failed: Connection refused », one line per browser request, saying nothing about WHICH end refused. The tunnel itself was fine; there was simply nothing to reach. Diagnosing it took three commands. The port is now probed first, and the answer is plain: ⚠ Rien n'écoute sur le port 8069 de test-vm_02+erplibre-ubuntu-2404 Démarrer le service là-bas, ou continuer quand même. Continuer quand même ? (o/N) The probe opens a real TCP connection to « localhost: » from the remote host rather than reading its listening table. That is exactly what the tunnel will do — same host resolution, same IPv4/IPv6 choice — so it cannot say open where the tunnel would fail. It also needs no ss or netstat, which minimal images lack. Three outcomes, three behaviours: listening goes straight through, closed warns and asks (default no), and an inconclusive probe — unreachable host, no bash — says so and continues rather than blocking on its own uncertainty. Verified against the real VM: port 22 open, port 9999 closed, an unknown host inconclusive, and port 8069 correctly reported closed after the Odoo service had been stopped — the very case that prompted this. Then at flow level: refusing aborts without opening anything, forcing opens the tunnel anyway, and an inconclusive probe still opens it. Co-Authored-By: Claude Opus 4.8 (1M context) --- script/todo/todo.py | 53 ++++++++++++++++++++++++++++++++++++++++ script/todo/todo_i18n.py | 24 ++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/script/todo/todo.py b/script/todo/todo.py index 626ceba..df67a63 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5372,6 +5372,43 @@ class TODO: except OSError: return False + @staticmethod + def _remote_port_open(host, port): + """Quelqu'un écoute-t-il sur ce port DEPUIS l'hôte distant ? + + True / False / None quand on n'a pas pu conclure (hôte injoignable, + pas de bash). On teste une vraie connexion TCP vers « localhost » et + non la table d'écoute : c'est exactement ce que fera le tunnel, y + compris le choix IPv4/IPv6 de la résolution. + """ + probe = ( + f"exec 3<>/dev/tcp/localhost/{int(port)} && echo OPEN" + " || echo CLOSED" + ) + try: + res = subprocess.run( + [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + host, + f"bash -c {shlex.quote(probe)} 2>/dev/null", + ], + capture_output=True, + text=True, + timeout=45, + ) + except (OSError, subprocess.SubprocessError): + return None + out = res.stdout.strip() + if "OPEN" in out: + return True + if "CLOSED" in out: + return False + return None + def _deploy_port_forward(self): """Ouvre un tunnel SSH pour joindre un service distant depuis le navigateur local. @@ -5397,6 +5434,22 @@ class TODO: raw = input(f"{t('Local port (default:')} {remote}): ").strip() local = raw if raw.isdigit() else remote + # Sonde AVANT d'ouvrir : sans elle, un service arrêté à l'autre bout + # ne se manifeste que par un mur de « channel N: open failed » à + # chaque requête du navigateur, qui ne dit pas d'où vient le refus. + print(f" {t('Checking the remote port...')}") + listening = self._remote_port_open(host, remote) + if listening is False: + print( + f" ⚠ {t('Nothing is listening on port')} {remote}" + f" {t('of')} {host}" + ) + print(f" {t('Start the service there, or continue anyway.')}") + if not self._is_yes(input(t("Continue anyway? (y/N): "))): + return + elif listening is None: + print(f" ℹ️ {t('Could not probe the remote port; going on.')}") + if not self._port_is_free(local): print(f" ⚠ {t('Local port already in use:')} {local}") if not self._is_yes(input(t("Try anyway? (y/N): "))): diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index ab78bcc..9942eb5 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1419,6 +1419,30 @@ TRANSLATIONS = { "fr": "Port local (défaut :", "en": "Local port (default:", }, + "Checking the remote port...": { + "fr": "Vérification du port distant...", + "en": "Checking the remote port...", + }, + "Nothing is listening on port": { + "fr": "Rien n'écoute sur le port", + "en": "Nothing is listening on port", + }, + "of": { + "fr": "de", + "en": "of", + }, + "Start the service there, or continue anyway.": { + "fr": "Démarrer le service là-bas, ou continuer quand même.", + "en": "Start the service there, or continue anyway.", + }, + "Continue anyway? (y/N): ": { + "fr": "Continuer quand même ? (o/N, défaut : non) : ", + "en": "Continue anyway? (y/N, default: no): ", + }, + "Could not probe the remote port; going on.": { + "fr": "Impossible de sonder le port distant ; on continue.", + "en": "Could not probe the remote port; going on.", + }, "Local port already in use:": { "fr": "Port local déjà occupé :", "en": "Local port already in use:",