[ADD] todo network: start the Odoo reverse proxy, local or network
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
This commit is contained in:
parent
53ef296690
commit
83e747078e
5 changed files with 259 additions and 0 deletions
|
|
@ -24,6 +24,7 @@ la main.
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import configparser
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
@ -67,6 +68,38 @@ class ProxyConfig:
|
||||||
forwarded_proto: str = "http"
|
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):
|
def parse_head(head):
|
||||||
"""Découpe une tête de requête brute.
|
"""Découpe une tête de requête brute.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5398,6 +5398,11 @@ class TODO(
|
||||||
"VPN - Tunnels (L2TP/IPsec, WireGuard, OpenVPN...)"
|
"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)
|
help_info = self.fill_help_info(choices)
|
||||||
|
|
||||||
|
|
@ -5412,6 +5417,8 @@ class TODO(
|
||||||
self.generate_network_performance_test()
|
self.generate_network_performance_test()
|
||||||
elif status == "3":
|
elif status == "3":
|
||||||
self.prompt_execute_vpn()
|
self.prompt_execute_vpn()
|
||||||
|
elif status == "4":
|
||||||
|
self.network_reverse_proxy()
|
||||||
else:
|
else:
|
||||||
print(t("Command not found !"))
|
print(t("Command not found !"))
|
||||||
|
|
||||||
|
|
@ -5439,6 +5446,47 @@ class TODO(
|
||||||
single_source_erplibre=True,
|
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):
|
def prompt_execute_security(self):
|
||||||
print(f"🤖 {t('Dependency security audit!')}")
|
print(f"🤖 {t('Dependency security audit!')}")
|
||||||
choices = [
|
choices = [
|
||||||
|
|
|
||||||
|
|
@ -13362,6 +13362,43 @@ TRANSLATIONS = {
|
||||||
"fr": "🚇 VPN - Tunnels (L2TP/IPsec, WireGuard, OpenVPN...)",
|
"fr": "🚇 VPN - Tunnels (L2TP/IPsec, WireGuard, OpenVPN...)",
|
||||||
"en": "🚇 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": {
|
"VPN tunnels: connect, profiles, vault secrets": {
|
||||||
"fr": "Tunnels VPN : connexion, profils, secrets du coffre",
|
"fr": "Tunnels VPN : connexion, profils, secrets du coffre",
|
||||||
"en": "VPN tunnels: connect, profiles, vault secrets",
|
"en": "VPN tunnels: connect, profiles, vault secrets",
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,62 @@ class TestErreurs(BaseProxy):
|
||||||
self.assertTrue(rep.startswith(b"HTTP/1.1 431"), rep[:40])
|
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):
|
class TestLigneDeCommande(unittest.TestCase):
|
||||||
def test_les_defauts_ecoutent_en_local(self):
|
def test_les_defauts_ecoutent_en_local(self):
|
||||||
args = rp.get_config([])
|
args = rp.get_config([])
|
||||||
|
|
|
||||||
85
test/test_todo_reverse_proxy_menu.py
Normal file
85
test/test_todo_reverse_proxy_menu.py
Normal file
|
|
@ -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()
|
||||||
Loading…
Reference in a new issue