[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:
parent
39b36b1191
commit
160ca18222
3 changed files with 190 additions and 10 deletions
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue