From 188dad9960743a35b54e2f958c48847572f1913e Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 2 Aug 2026 04:28:38 -0400 Subject: [PATCH] [ADD] deploy: SSH port forwarding, to reach Odoo from the local browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reaching a VM's Odoo from the workstation browser meant remembering the -L syntax and which side « localhost » refers to. Entry [3] of the Deploy menu asks for a host, a remote port (8069 by default) and a local one, then holds the tunnel open. Nothing has to be said about jumps: the ProxyJump already in ~/.ssh/config applies on its own, which is what makes a NESTED VM reachable — its address means nothing from here, only from its parent. Two guards, both from getting it wrong by hand: · a local port already in use is reported before ssh fails on it; · a local port that differs from the remote one gets a warning, because Odoo redirects using web.base.url and would send the browser to an address that does not exist locally. With matching ports and the usual web.base.url = http://localhost:8069, there is nothing to adjust. The host list is read from ~/.ssh/config by a small shared helper: it expands a Host line carrying several names and drops the wildcard patterns, which are rules rather than machines. Verified against a config holding « Host * », a plain host and a two-name line: the three real names listed in order, selection by number and by name, 8069/8069 by default, 9072:localhost:8072 warning about web.base.url, matching ports staying silent, an empty host cancelling without running anything, and the busy-port probe answering correctly on a socket bound then released. Co-Authored-By: Claude Opus 4.8 (1M context) --- script/todo/todo.py | 92 +++++++++++++++++++++++++++++++++++++++- script/todo/todo_i18n.py | 48 +++++++++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 9491ac4..626ceba 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -876,6 +876,11 @@ class TODO: {"section": t("Local")}, {"prompt_description": t("Clone ERPLibre locally (git clone)")}, {"prompt_description": t("Configure sshfs")}, + { + "prompt_description": t( + "SSH port forwarding (open Odoo in the browser)" + ) + }, {"section": t("Remote & services")}, {"prompt_description": t("SSH (remote host)...")}, { @@ -901,10 +906,12 @@ class TODO: elif status == "2": self._configure_sshfs() elif status == "3": - self.prompt_execute_deploy_ssh() + self._deploy_port_forward() elif status == "4": - self.prompt_execute_qemu() + self.prompt_execute_deploy_ssh() elif status == "5": + self.prompt_execute_qemu() + elif status == "6": self._deploy_ntfy_server() else: print(t("Command not found !")) @@ -5330,6 +5337,87 @@ class TODO: except Exception as e: print(f"{t('Error installing NTFY server: ')}{e}") + @staticmethod + def _ssh_config_hosts(): + """Noms d'hôtes déclarés dans ~/.ssh/config, dans l'ordre du fichier. + + Une ligne « Host » peut porter plusieurs noms : on les rend tous. Les + motifs (`*`, `?`) sont écartés — ce sont des règles, pas des machines + auxquelles se connecter.""" + path = os.path.expanduser("~/.ssh/config") + names = [] + try: + with open(path, encoding="utf-8") as fh: + for line in fh: + if not re.match(r"^[ \t]*Host[ \t]+", line): + continue + for name in line.split()[1:]: + if "*" in name or "?" in name or name in names: + continue + names.append(name) + except OSError: + pass + return names + + @staticmethod + def _port_is_free(port): + """Vrai si rien n'écoute sur ce port en local.""" + import socket + + with socket.socket() as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(("127.0.0.1", int(port))) + return True + except OSError: + return False + + def _deploy_port_forward(self): + """Ouvre un tunnel SSH pour joindre un service distant depuis le + navigateur local. + + Le port distant est vu DEPUIS la machine cible : « -L + local:localhost:distant ». Un rebond éventuel n'a pas à être indiqué — + le ProxyJump du bloc ~/.ssh/config s'applique tout seul, ce qui rend + joignable une VM imbriquée sans route directe.""" + print(f"\n🔌 {t('SSH port forwarding')}") + hosts = self._ssh_config_hosts() + if hosts: + for i, name in enumerate(hosts, 1): + print(f" [{i}] {name}") + host = input(f"{t('Host (number or name):')} ").strip() + if not host: + print(t("Cancelled.")) + return + if host.isdigit() and 1 <= int(host) <= len(hosts): + host = hosts[int(host) - 1] + + raw = input(f"{t('Remote port (default:')} 8069): ").strip() + remote = raw if raw.isdigit() else "8069" + raw = input(f"{t('Local port (default:')} {remote}): ").strip() + local = raw if raw.isdigit() else remote + + 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): "))): + return + if local != remote: + # Odoo redirige d'après web.base.url : un port local différent + # renvoie le navigateur vers une adresse qui n'existe pas chez lui. + print(f" ⚠ {t('Local port differs from the remote one.')}") + print(f" {t('Odoo redirects using web.base.url; check it')}") + print(f" {t('matches http://localhost:')}{local}") + + cmd = f"ssh -N -L {local}:localhost:{remote} {shlex.quote(host)}" + print(f"\n 🌐 http://localhost:{local}") + print(f" {t('Will execute:')} {cmd}") + print(f" {t('Ctrl+C closes the tunnel.')}\n") + try: + self.execute.exec_command_live(cmd, source_erplibre=False) + except KeyboardInterrupt: + pass + print(f"\n {t('Tunnel closed.')}") + def _configure_sshfs(self): import getpass import re diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index bdfc1c2..630e8b7 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1399,6 +1399,54 @@ TRANSLATIONS = { "fr": "Choix (numéro, vide = garder) :", "en": "Choice (number, blank = keep):", }, + "SSH port forwarding (open Odoo in the browser)": { + "fr": "🔌 Redirection de port SSH (ouvrir Odoo dans le navigateur)", + "en": "🔌 SSH port forwarding (open Odoo in the browser)", + }, + "SSH port forwarding": { + "fr": "Redirection de port SSH", + "en": "SSH port forwarding", + }, + "Host (number or name):": { + "fr": "Hôte (numéro ou nom) :", + "en": "Host (number or name):", + }, + "Remote port (default:": { + "fr": "Port distant (défaut :", + "en": "Remote port (default:", + }, + "Local port (default:": { + "fr": "Port local (défaut :", + "en": "Local port (default:", + }, + "Local port already in use:": { + "fr": "Port local déjà occupé :", + "en": "Local port already in use:", + }, + "Try anyway? (y/N): ": { + "fr": "Essayer quand même ? (o/N, défaut : non) : ", + "en": "Try anyway? (y/N, default: no): ", + }, + "Local port differs from the remote one.": { + "fr": "Le port local diffère du port distant.", + "en": "Local port differs from the remote one.", + }, + "Odoo redirects using web.base.url; check it": { + "fr": "Odoo redirige d'après web.base.url ; vérifier qu'il", + "en": "Odoo redirects using web.base.url; check it", + }, + "matches http://localhost:": { + "fr": "vaut bien http://localhost:", + "en": "matches http://localhost:", + }, + "Ctrl+C closes the tunnel.": { + "fr": "Ctrl+C referme le tunnel.", + "en": "Ctrl+C closes the tunnel.", + }, + "Tunnel closed.": { + "fr": "Tunnel refermé.", + "en": "Tunnel closed.", + }, "SSH configuration (~/.ssh/config, ProxyJump)": { "fr": "🔑 Configuration SSH (~/.ssh/config, ProxyJump)", "en": "🔑 SSH configuration (~/.ssh/config, ProxyJump)",