[ADD] deploy: SSH port forwarding, to reach Odoo from the local browser

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) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-08-02 04:28:38 -04:00
parent 00eb0a3c7a
commit 188dad9960
2 changed files with 138 additions and 2 deletions

View file

@ -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

View file

@ -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)",