From 83e747078e215e86ce3c669e0eaecedac3e9e925 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 25 Sep 2026 06:12:08 -0400 Subject: [PATCH] [ADD] todo network: start the Odoo reverse proxy, local or network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Network gains a fourth entry that starts script/reverse_proxy/main.py with the web and bus ports read from config.conf, falling back to the older xmlrpc_port and longpolling_port names. It asks for this machine only or the whole network, local by default, and warns when proxy_mode is off or workers is 0: the first makes Odoo ignore X-Forwarded-*, the second leaves no bus port, so /websocket fails behind the proxy too. Checked: 48 tests pass (proxy, config reading, menu wiring, i18n); the French screen renders with the real config.conf. --- FR --- [ADD] todo network : lancer le mandataire inverse Odoo, local ou réseau Network gagne une quatrième entrée qui lance script/reverse_proxy/main.py avec les ports web et bus lus dans config.conf, repli sur les anciens noms xmlrpc_port et longpolling_port. Elle demande cette machine seule ou tout le réseau, local par défaut, et prévient quand proxy_mode est éteint ou workers vaut 0 : le premier fait ignorer X-Forwarded-* à Odoo, le second ne laisse aucun port de bus, et /websocket échoue aussi derrière le proxy. Vérifié : 48 tests passent (mandataire, lecture du config, câblage du menu, i18n) ; l'écran français s'affiche avec le vrai config.conf. Assisted-by: Claude Opus 5.5 --- script/reverse_proxy/main.py | 33 +++++++++++ script/todo/todo.py | 48 ++++++++++++++++ script/todo/todo_i18n.py | 37 ++++++++++++ test/test_reverse_proxy.py | 56 ++++++++++++++++++ test/test_todo_reverse_proxy_menu.py | 85 ++++++++++++++++++++++++++++ 5 files changed, 259 insertions(+) create mode 100644 test/test_todo_reverse_proxy_menu.py diff --git a/script/reverse_proxy/main.py b/script/reverse_proxy/main.py index 0de8028..0956456 100755 --- a/script/reverse_proxy/main.py +++ b/script/reverse_proxy/main.py @@ -24,6 +24,7 @@ la main. import argparse import asyncio +import configparser import sys from dataclasses import dataclass @@ -67,6 +68,38 @@ class ProxyConfig: forwarded_proto: str = "http" +def read_odoo_config(path): + """Les ports et réglages d'Odoo utiles au mandataire. + + Lit la section [options] d'un config.conf. Les anciens noms, xmlrpc_port + et longpolling_port, servent de repli ; une option absente + ou illisible — un fichier absent compris — garde le défaut d'Odoo. + + :return: {"web_port", "websocket_port", "proxy_mode", "workers"} + """ + cfg = configparser.ConfigParser(interpolation=None) + cfg.read(path) + + def entier(noms, defaut): + for nom in noms: + try: + return cfg.getint("options", nom) + except (configparser.Error, ValueError): + continue + return defaut + + try: + proxy_mode = cfg.getboolean("options", "proxy_mode") + except (configparser.Error, ValueError): + proxy_mode = False + return { + "web_port": entier(("http_port", "xmlrpc_port"), 8069), + "websocket_port": entier(("gevent_port", "longpolling_port"), 8072), + "proxy_mode": proxy_mode, + "workers": entier(("workers",), 0), + } + + def parse_head(head): """Découpe une tête de requête brute. diff --git a/script/todo/todo.py b/script/todo/todo.py index 9ecb717..1707639 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5398,6 +5398,11 @@ class TODO( "VPN - Tunnels (L2TP/IPsec, WireGuard, OpenVPN...)" ) }, + { + "prompt_description": t( + "Odoo reverse proxy (pages and websocket on one port)" + ) + }, ] help_info = self.fill_help_info(choices) @@ -5412,6 +5417,8 @@ class TODO( self.generate_network_performance_test() elif status == "3": self.prompt_execute_vpn() + elif status == "4": + self.network_reverse_proxy() else: print(t("Command not found !")) @@ -5439,6 +5446,47 @@ class TODO( single_source_erplibre=True, ) + def network_reverse_proxy(self, config_path="./config.conf"): + """Lance script/reverse_proxy/main.py avec les ports de config_path. + + Demande l'écoute — la machine seule ou tout le réseau — puis signale + les réglages d'Odoo sans lesquels le mandataire ne sert à rien : + proxy_mode (Odoo ignore sinon les en-têtes X-Forwarded-*) et workers + (à 0, aucun port de bus n'écoute et /websocket échoue). Le + mandataire tourne au premier plan ; Ctrl+C le rend au menu. + """ + from script.reverse_proxy.main import read_odoo_config + + odoo = read_odoo_config(config_path) + print(f"\n{t('Listen on:')}") + print(f" [1] {t('Local only (127.0.0.1)')} *") + print(f" [2] {t('Whole network (0.0.0.0)')}") + choice = input(t("Choice (1-2, default 1): ")).strip() + listen = "0.0.0.0" if choice == "2" else "127.0.0.1" + if listen == "0.0.0.0": + reachable = t( + "The proxy is reachable by every machine on the network." + ) + print(f"⚠️ {reachable}") + print( + f"{t('Ports read from')} {config_path} : web" + f" {odoo['web_port']}, bus {odoo['websocket_port']}" + ) + if not odoo["proxy_mode"]: + missing = t("is missing: Odoo ignores the X-Forwarded-* headers.") + print(f"⚠️ proxy_mode = True {missing}") + if odoo["workers"] < 1: + no_bus = t("no bus port listens, /websocket will fail.") + print(f"⚠️ workers = {odoo['workers']} : {no_bus}") + print(t("Ctrl+C stops the proxy.")) + self.execute.exec_command_live( + "./script/reverse_proxy/main.py" + f" --listen {listen}" + f" --web-port {odoo['web_port']}" + f" --websocket-port {odoo['websocket_port']}", + source_erplibre=False, + ) + def prompt_execute_security(self): print(f"🤖 {t('Dependency security audit!')}") choices = [ diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 4a0d5a8..d73508d 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -13362,6 +13362,43 @@ TRANSLATIONS = { "fr": "🚇 VPN - Tunnels (L2TP/IPsec, WireGuard, OpenVPN...)", "en": "🚇 VPN - Tunnels (L2TP/IPsec, WireGuard, OpenVPN...)", }, + # Network — reverse proxy + "Odoo reverse proxy (pages and websocket on one port)": { + "fr": "🔀 Mandataire inverse Odoo (pages et websocket sur un port)", + "en": "🔀 Odoo reverse proxy (pages and websocket on one port)", + }, + "Listen on:": { + "fr": "Écoute :", + "en": "Listen on:", + }, + "Local only (127.0.0.1)": { + "fr": "🏠 Cette machine seulement (127.0.0.1)", + "en": "🏠 This machine only (127.0.0.1)", + }, + "Whole network (0.0.0.0)": { + "fr": "🌐 Tout le réseau (0.0.0.0)", + "en": "🌐 Whole network (0.0.0.0)", + }, + "The proxy is reachable by every machine on the network.": { + "fr": "Le mandataire est joignable par toute machine du réseau.", + "en": "The proxy is reachable by every machine on the network.", + }, + "Ports read from": { + "fr": "Ports lus dans", + "en": "Ports read from", + }, + "is missing: Odoo ignores the X-Forwarded-* headers.": { + "fr": "manque : Odoo ignore les en-têtes X-Forwarded-*.", + "en": "is missing: Odoo ignores the X-Forwarded-* headers.", + }, + "no bus port listens, /websocket will fail.": { + "fr": "aucun port de bus n'écoute, /websocket échouera.", + "en": "no bus port listens, /websocket will fail.", + }, + "Ctrl+C stops the proxy.": { + "fr": "Ctrl+C arrête le mandataire.", + "en": "Ctrl+C stops the proxy.", + }, "VPN tunnels: connect, profiles, vault secrets": { "fr": "Tunnels VPN : connexion, profils, secrets du coffre", "en": "VPN tunnels: connect, profiles, vault secrets", diff --git a/test/test_reverse_proxy.py b/test/test_reverse_proxy.py index f716dac..4742f75 100644 --- a/test/test_reverse_proxy.py +++ b/test/test_reverse_proxy.py @@ -307,6 +307,62 @@ class TestErreurs(BaseProxy): self.assertTrue(rep.startswith(b"HTTP/1.1 431"), rep[:40]) +class TestLectureDeConfig(unittest.TestCase): + """Les ports et réglages lus dans un config.conf d'Odoo.""" + + def ecrire(self, contenu): + import tempfile + + f = tempfile.NamedTemporaryFile( + "w", suffix=".conf", delete=False, encoding="utf-8" + ) + f.write(contenu) + f.close() + self.addCleanup(__import__("os").unlink, f.name) + return f.name + + def test_ports_et_reglages_d_odoo_18(self): + chemin = self.ecrire( + "[options]\nhttp_port = 9069\ngevent_port = 9072\n" + "proxy_mode = True\nworkers = 2\n" + ) + self.assertEqual( + rp.read_odoo_config(chemin), + { + "web_port": 9069, + "websocket_port": 9072, + "proxy_mode": True, + "workers": 2, + }, + ) + + def test_les_anciens_noms_servent_de_repli(self): + # xmlrpc_port et longpolling_port : les anciens noms d'Odoo. + chemin = self.ecrire( + "[options]\nxmlrpc_port = 7069\nlongpolling_port = 7072\n" + ) + lu = rp.read_odoo_config(chemin) + self.assertEqual((lu["web_port"], lu["websocket_port"]), (7069, 7072)) + + def test_un_fichier_absent_rend_les_defauts_d_odoo(self): + self.assertEqual( + rp.read_odoo_config("/nexiste/pas/config.conf"), + { + "web_port": 8069, + "websocket_port": 8072, + "proxy_mode": False, + "workers": 0, + }, + ) + + def test_une_valeur_illisible_garde_le_defaut(self): + chemin = self.ecrire( + "[options]\nhttp_port = False\nworkers = beaucoup\n" + ) + lu = rp.read_odoo_config(chemin) + self.assertEqual((lu["web_port"], lu["workers"]), (8069, 0)) + + class TestLigneDeCommande(unittest.TestCase): def test_les_defauts_ecoutent_en_local(self): args = rp.get_config([]) diff --git a/test/test_todo_reverse_proxy_menu.py b/test/test_todo_reverse_proxy_menu.py new file mode 100644 index 0000000..d2c8cad --- /dev/null +++ b/test/test_todo_reverse_proxy_menu.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""L'entrée « mandataire inverse » du menu Network de TODO.""" + +import os +import tempfile +import unittest +from unittest.mock import patch + +from script.todo.todo import TODO + + +class TestMenuNetwork(unittest.TestCase): + def test_l_option_4_lance_le_mandataire(self): + """Les choix du menu et ses branches sont tenus à la main : [4] doit + afficher le mandataire ET appeler sa méthode.""" + todo = TODO() + with ( + patch.object(TODO, "network_reverse_proxy") as mock_rp, + patch("click.prompt", side_effect=["4", "0"]) as mock_prompt, + patch("script.todo.todo_telemetry.record"), + ): + todo.prompt_execute_network() + mock_rp.assert_called_once_with() + self.assertIn("[4]", mock_prompt.call_args_list[0].args[0]) + + +class TestLancement(unittest.TestCase): + def config(self, contenu): + f = tempfile.NamedTemporaryFile( + "w", suffix=".conf", delete=False, encoding="utf-8" + ) + f.write(contenu) + f.close() + self.addCleanup(os.unlink, f.name) + return f.name + + def lancer(self, reponse, contenu): + todo = TODO() + with ( + patch("builtins.input", return_value=reponse), + patch.object(todo.execute, "exec_command_live") as mock_exec, + patch("builtins.print") as mock_print, + ): + todo.network_reverse_proxy(config_path=self.config(contenu)) + imprime = " ".join( + str(a) for c in mock_print.call_args_list for a in c.args + ) + return mock_exec.call_args.args[0], imprime + + def test_local_par_defaut_avec_les_ports_du_config(self): + cmd, _ = self.lancer( + "", + "[options]\nhttp_port = 9069\ngevent_port = 9072\n" + "proxy_mode = True\nworkers = 2\n", + ) + self.assertIn("./script/reverse_proxy/main.py", cmd) + self.assertIn("--listen 127.0.0.1", cmd) + self.assertIn("--web-port 9069", cmd) + self.assertIn("--websocket-port 9072", cmd) + + def test_reseau_ecoute_sur_toutes_les_interfaces(self): + cmd, _ = self.lancer( + "2", "[options]\nproxy_mode = True\nworkers = 2\n" + ) + self.assertIn("--listen 0.0.0.0", cmd) + + def test_les_reglages_manquants_sont_signales(self): + # Sans proxy_mode Odoo ignore X-Forwarded-*, sans workers le bus + # (8072) n'existe pas : le mandataire n'y changerait rien. + _, imprime = self.lancer("1", "[options]\nworkers = 0\n") + self.assertIn("proxy_mode", imprime) + self.assertIn("workers", imprime) + + def test_rien_n_est_signale_quand_tout_est_regle(self): + _, imprime = self.lancer( + "1", "[options]\nproxy_mode = True\nworkers = 2\n" + ) + self.assertNotIn("proxy_mode", imprime) + + +if __name__ == "__main__": + unittest.main()