From 160ca18222c3260a2977a8e709f9dfaaaee5fa25 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 25 Sep 2026 06:35:38 -0400 Subject: [PATCH] [IMP] todo network: HTTPS for the reverse proxy, local certificates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reverse proxy entry now asks HTTP or HTTPS; in HTTPS it issues the local certificate on first use, passes it to the proxy and names the authority to import in the browser. A fifth Network entry issues it again with extra names or addresses typed by the user, keeping the authority already imported. Checked: 10 menu tests, among them a real openssl issue whose SAN holds the typed names; test_todo_i18n passes. --- FR --- [IMP] todo network : HTTPS pour le mandataire, certificats locaux L'entrée du mandataire demande désormais HTTP ou HTTPS ; en HTTPS, elle émet le certificat local au premier usage, le passe au mandataire et nomme l'autorité à importer dans le navigateur. Une cinquième entrée de Network le réémet avec des noms ou adresses saisis, en gardant l'autorité déjà importée. Vérifié : 10 tests du menu, dont une vraie émission openssl dont le SAN porte les noms saisis ; test_todo_i18n passe. Assisted-by: Claude Opus 5.5 --- script/todo/todo.py | 73 +++++++++++++++++++--- script/todo/todo_i18n.py | 36 +++++++++++ test/test_todo_reverse_proxy_menu.py | 91 +++++++++++++++++++++++++++- 3 files changed, 190 insertions(+), 10 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 1707639..1f31afc 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5403,6 +5403,11 @@ class TODO( "Odoo reverse proxy (pages and websocket on one port)" ) }, + { + "prompt_description": t( + "Local TLS certificates for testing (HTTPS)" + ) + }, ] help_info = self.fill_help_info(choices) @@ -5419,6 +5424,8 @@ class TODO( self.prompt_execute_vpn() elif status == "4": self.network_reverse_proxy() + elif status == "5": + self.network_local_certificates() else: print(t("Command not found !")) @@ -5446,23 +5453,46 @@ class TODO( single_source_erplibre=True, ) - def network_reverse_proxy(self, config_path="./config.conf"): + def network_reverse_proxy( + self, config_path="./config.conf", cert_dir=None + ): """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. + Demande l'écoute — la machine seule ou tout le réseau — et le + protocole. En HTTPS, le certificat local de cert_dir sert, émis au + premier usage. Signale ensuite 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 import local_cert from script.reverse_proxy.main import read_odoo_config + cert_dir = cert_dir or local_cert.DEFAULT_DIR 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" + print(f"\n{t('Protocol:')}") + print(" [1] HTTP *") + print(f" [2] HTTPS ({t('local certificate')})") + https = input(t("Choice (1-2, default 1): ")).strip() == "2" + tls = "" + if https: + files = local_cert.paths(cert_dir) + if not local_cert.exists(cert_dir): + print(t("No local certificate yet: issuing one.")) + local_cert.issue(cert_dir, local_cert.default_names()) + print( + f"{t('Authority to import in the browser:')} {files['ca_crt']}" + ) + tls = ( + f" --tls-cert {files['server_crt']}" + f" --tls-key {files['server_key']}" + ) if listen == "0.0.0.0": reachable = t( "The proxy is reachable by every machine on the network." @@ -5483,10 +5513,39 @@ class TODO( "./script/reverse_proxy/main.py" f" --listen {listen}" f" --web-port {odoo['web_port']}" - f" --websocket-port {odoo['websocket_port']}", + f" --websocket-port {odoo['websocket_port']}" + tls, source_erplibre=False, ) + def network_local_certificates(self, cert_dir=None): + """Émet le certificat local du mandataire, et son autorité au besoin. + + Couvre localhost, les boucles locales, le nom d'hôte et ses adresses, + plus les noms saisis. L'autorité existante est gardée : déjà importée + dans le navigateur, elle y reste valable. + """ + from script.reverse_proxy import local_cert + + cert_dir = cert_dir or local_cert.DEFAULT_DIR + names = local_cert.default_names() + print(f"{t('Names covered:')} {', '.join(names)}") + extra = input( + t("Other names or addresses, comma separated (Enter: none): ") + ) + for name in (n.strip() for n in extra.split(",")): + if name and name not in names: + names.append(name) + files = local_cert.issue(cert_dir, names) + print(f"{t('Authority to import in the browser:')} {files['ca_crt']}") + print(f"{t('Server certificate:')} {files['server_crt']}") + print( + t( + "Import the authority once (Firefox: Settings › Certificates" + " › Authorities › Import); a new server certificate needs no" + " new import." + ) + ) + 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 d73508d..012b60d 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -13399,6 +13399,42 @@ TRANSLATIONS = { "fr": "Ctrl+C arrête le mandataire.", "en": "Ctrl+C stops the proxy.", }, + "Local TLS certificates for testing (HTTPS)": { + "fr": "🔐 Certificats TLS locaux pour les tests (HTTPS)", + "en": "🔐 Local TLS certificates for testing (HTTPS)", + }, + "Protocol:": { + "fr": "Protocole :", + "en": "Protocol:", + }, + "local certificate": { + "fr": "certificat local", + "en": "local certificate", + }, + "No local certificate yet: issuing one.": { + "fr": "Aucun certificat local : émission d'un certificat.", + "en": "No local certificate yet: issuing one.", + }, + "Authority to import in the browser:": { + "fr": "Autorité à importer dans le navigateur :", + "en": "Authority to import in the browser:", + }, + "Server certificate:": { + "fr": "Certificat serveur :", + "en": "Server certificate:", + }, + "Names covered:": { + "fr": "Noms couverts :", + "en": "Names covered:", + }, + "Other names or addresses, comma separated (Enter: none): ": { + "fr": "Autres noms ou adresses, séparés par des virgules (Entrée : aucun) : ", + "en": "Other names or addresses, comma separated (Enter: none): ", + }, + "Import the authority once (Firefox: Settings › Certificates › Authorities › Import); a new server certificate needs no new import.": { + "fr": "Importer l'autorité une seule fois (Firefox : Paramètres › Certificats › Autorités › Importer) ; un nouveau certificat serveur n'exige aucun nouvel import.", + "en": "Import the authority once (Firefox: Settings › Certificates › Authorities › Import); a new server certificate needs no new import.", + }, "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_todo_reverse_proxy_menu.py b/test/test_todo_reverse_proxy_menu.py index d2c8cad..6b55c09 100644 --- a/test/test_todo_reverse_proxy_menu.py +++ b/test/test_todo_reverse_proxy_menu.py @@ -5,6 +5,8 @@ """L'entrée « mandataire inverse » du menu Network de TODO.""" import os +import shutil +import subprocess import tempfile import unittest from unittest.mock import patch @@ -27,6 +29,49 @@ class TestMenuNetwork(unittest.TestCase): self.assertIn("[4]", mock_prompt.call_args_list[0].args[0]) +class TestMenuCertificats(unittest.TestCase): + def test_l_option_5_genere_les_certificats(self): + todo = TODO() + with ( + patch.object(TODO, "network_local_certificates") as mock_cert, + patch("click.prompt", side_effect=["5", "0"]) as mock_prompt, + patch("script.todo.todo_telemetry.record"), + ): + todo.prompt_execute_network() + mock_cert.assert_called_once_with() + self.assertIn("[5]", mock_prompt.call_args_list[0].args[0]) + + @unittest.skipUnless(shutil.which("openssl"), "openssl absent") + def test_les_noms_saisis_s_ajoutent_aux_noms_par_defaut(self): + d = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, d) + dossier = os.path.join(d, "tls") + with ( + patch("builtins.input", return_value="odoo.test, 10.0.0.9"), + patch("builtins.print"), + ): + TODO().network_local_certificates(cert_dir=dossier) + texte = subprocess.run( + [ + "openssl", + "x509", + "-in", + os.path.join(dossier, "server.crt"), + "-noout", + "-text", + ], + capture_output=True, + text=True, + check=True, + ).stdout + for attendu in ( + "DNS:odoo.test", + "IP Address:10.0.0.9", + "DNS:localhost", + ): + self.assertIn(attendu, texte) + + class TestLancement(unittest.TestCase): def config(self, contenu): f = tempfile.NamedTemporaryFile( @@ -37,14 +82,17 @@ class TestLancement(unittest.TestCase): self.addCleanup(os.unlink, f.name) return f.name - def lancer(self, reponse, contenu): + def lancer(self, reponse, contenu, protocole="1", cert_dir=None): todo = TODO() with ( - patch("builtins.input", return_value=reponse), + patch("builtins.input", side_effect=[reponse, protocole]), 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)) + todo.network_reverse_proxy( + config_path=self.config(contenu), + cert_dir=cert_dir or self.dossier_vide(), + ) imprime = " ".join( str(a) for c in mock_print.call_args_list for a in c.args ) @@ -74,6 +122,43 @@ class TestLancement(unittest.TestCase): self.assertIn("proxy_mode", imprime) self.assertIn("workers", imprime) + def dossier_vide(self): + d = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, d) + return os.path.join(d, "tls") + + def test_http_par_defaut_sans_certificat(self): + cmd, _ = self.lancer("", "[options]\nproxy_mode = True\nworkers = 2\n") + self.assertNotIn("--tls-cert", cmd) + + @unittest.skipUnless(shutil.which("openssl"), "openssl absent") + def test_https_genere_le_certificat_manquant_et_le_passe(self): + dossier = self.dossier_vide() + cmd, imprime = self.lancer( + "", + "[options]\nproxy_mode = True\nworkers = 2\n", + protocole="2", + cert_dir=dossier, + ) + self.assertIn(f"--tls-cert {dossier}/server.crt", cmd) + self.assertIn(f"--tls-key {dossier}/server.key", cmd) + self.assertTrue(os.path.isfile(os.path.join(dossier, "ca.crt"))) + self.assertIn("ca.crt", imprime) + + def test_https_reprend_le_certificat_existant(self): + dossier = self.dossier_vide() + with patch( + "script.reverse_proxy.local_cert.exists", return_value=True + ), patch("script.reverse_proxy.local_cert.issue") as mock_issue: + cmd, _ = self.lancer( + "", + "[options]\nproxy_mode = True\nworkers = 2\n", + protocole="2", + cert_dir=dossier, + ) + mock_issue.assert_not_called() + self.assertIn("--tls-cert", cmd) + def test_rien_n_est_signale_quand_tout_est_regle(self): _, imprime = self.lancer( "1", "[options]\nproxy_mode = True\nworkers = 2\n"