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:",