[IMP] todo network: HTTPS for the reverse proxy, local certificates

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
This commit is contained in:
Mathieu Benoit 2026-09-25 06:35:38 -04:00
parent 39b36b1191
commit 160ca18222
3 changed files with 190 additions and 10 deletions

View file

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

View file

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

View file

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