From 53ef2966908e3af3a55e1d5da20935393b314131 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 25 Sep 2026 05:25:16 -0400 Subject: [PATCH] [ADD] reverse proxy: dev front for Odoo 18, websocket relayed both ways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single local address routes pages to the Odoo web port and /websocket to the bus port, with X-Forwarded-* set for proxy_mode; a client value for these headers is replaced. Only the request head is read: bytes then pass through unchanged in both directions, so an upgraded WebSocket stays open and a gzip or chunked response reaches the browser intact. Each connection carries one request; production keeps nginx (script/nginx). Checked: 15 tests on real sockets (routing, headers, 5 MB body, gzip, WebSocket echo, 502, 400, 431); byte-identical to direct against Odoo 18. --- FR --- [ADD] reverse proxy : façade de dev pour Odoo 18, websocket dans 2 sens Une seule adresse locale envoie les pages au port web d'Odoo et /websocket au port du bus, avec les X-Forwarded-* de proxy_mode ; la valeur qu'un client donne à ces en-têtes est remplacée. Seule la tête est lue : les octets passent ensuite tels quels dans les deux sens, une WebSocket montée reste ouverte et une réponse gzip ou en morceaux arrive intacte. Une requête par connexion ; la production garde nginx. Vérifié : 15 tests sur vrais sockets (routage, en-têtes, corps de 5 Mo, gzip, écho WebSocket, 502, 400, 431) ; identique octet pour octet au direct face à Odoo 18. Assisted-by: Claude Opus 5.5 --- script/reverse_proxy/main.py | 313 +++++++++++++++++++++++++++++++++ test/test_reverse_proxy.py | 324 +++++++++++++++++++++++++++++++++++ 2 files changed, 637 insertions(+) create mode 100755 script/reverse_proxy/main.py create mode 100644 test/test_reverse_proxy.py diff --git a/script/reverse_proxy/main.py b/script/reverse_proxy/main.py new file mode 100755 index 0000000..0de8028 --- /dev/null +++ b/script/reverse_proxy/main.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +# © 2025-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Mandataire inverse de développement devant Odoo, sans nginx. + +Visé : Odoo 18. Une seule adresse pour le navigateur : les pages vont au +port web d'Odoo (8069), le bus — /websocket — à son port dédié (8072). Odoo, +lancé avec proxy_mode, lit les en-têtes X-Forwarded-* posés ici. + +Le mandataire ne lit que la TÊTE de chaque requête : il choisit le port, +réécrit les en-têtes, puis relaie les octets tels quels dans les deux sens. +Le corps n'est ni lu en entier ni décompressé, une réponse gzip ou en +morceaux passe à l'identique, et une connexion montée en WebSocket reste +ouverte tant qu'un des deux bouts parle. + +Chaque connexion porte UNE requête : l'amont reçoit « Connection: close », +ferme après sa réponse, et le navigateur rouvre pour la suivante. Relayer +plusieurs requêtes sur une connexion demanderait de suivre la longueur de +chaque corps ; pour un outil de poste, une connexion de plus est moins +chère que cette comptabilité. En production, nginx (script/nginx/) garde +la main. +""" + +import argparse +import asyncio +import sys +from dataclasses import dataclass + +# Une tête au-delà est refusée (431) : aucun navigateur n'en envoie de si +# longue, et la lire en entier laisserait un client remplir la mémoire. +MAX_HEAD = 64 * 1024 + +DEFAULT_WEBSOCKET_PATHS = ("/websocket",) + +# En-têtes propres à UN saut : jamais relayés tels quels. Upgrade et +# Connection sont reposés pour une montée en WebSocket. +HOP_BY_HOP = { + "connection", + "keep-alive", + "proxy-connection", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "upgrade", +} + +# Posés par ce mandataire seul : la valeur d'un client est écartée, Odoo en +# proxy_mode prendrait sinon une adresse inventée pour celle du visiteur. +FORWARDED = { + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "x-real-ip", +} + +RELAY_CHUNK = 64 * 1024 + + +@dataclass(frozen=True) +class ProxyConfig: + odoo_host: str = "127.0.0.1" + web_port: int = 8069 + websocket_port: int = 8072 + websocket_paths: tuple = DEFAULT_WEBSOCKET_PATHS + forwarded_proto: str = "http" + + +def parse_head(head): + """Découpe une tête de requête brute. + + :param head: octets jusqu'à la ligne vide « \\r\\n\\r\\n » comprise + :return: (méthode, cible, version, [(nom, valeur), …]) dans l'ordre reçu + :raises ValueError: ligne de requête ou en-tête illisible + """ + lines = head.decode("latin-1").split("\r\n") + parts = lines[0].split(" ") + if len(parts) != 3 or not parts[2].startswith("HTTP/"): + raise ValueError("ligne de requête illisible") + method, target, version = parts + headers = [] + for line in lines[1:]: + if not line: + continue + name, sep, value = line.partition(":") + if not sep or not name or name != name.strip(): + raise ValueError("en-tête illisible") + headers.append((name, value.strip())) + return method, target, version, headers + + +def is_websocket_path(target, config): + """Vrai quand la cible vise le bus, comparée par segment entier. + + « /websocket » et « /websocket/… » vont au bus, « /websocketX » non. + """ + path = target.split("?", 1)[0] + return any( + path == p or path.startswith(p.rstrip("/") + "/") + for p in config.websocket_paths + ) + + +def is_upgrade(headers): + """Vrai quand la requête demande une montée de protocole (WebSocket).""" + connection = ",".join( + v for n, v in headers if n.lower() == "connection" + ).lower() + has_upgrade = any(n.lower() == "upgrade" for n, _ in headers) + return has_upgrade and "upgrade" in connection + + +def rewrite_head(method, target, version, headers, client_ip, config): + """La tête envoyée à Odoo. + + Retire les en-têtes d'un saut et ceux du mandataire venus du client, pose + X-Forwarded-For/-Host/-Proto et X-Real-IP, puis la conduite de connexion : + « Upgrade » et « Connection: Upgrade » pour une montée en WebSocket, + « Connection: close » sinon. + + :return: la tête en octets, ligne vide finale comprise + """ + upgrade = is_upgrade(headers) + upgrade_value = next((v for n, v in headers if n.lower() == "upgrade"), "") + host = next((v for n, v in headers if n.lower() == "host"), "") + kept = [ + (n, v) + for n, v in headers + if n.lower() not in HOP_BY_HOP and n.lower() not in FORWARDED + ] + kept += [ + ("X-Forwarded-For", client_ip), + ("X-Real-IP", client_ip), + ("X-Forwarded-Host", host), + ("X-Forwarded-Proto", config.forwarded_proto), + ] + if upgrade: + kept += [("Upgrade", upgrade_value), ("Connection", "Upgrade")] + else: + kept.append(("Connection", "close")) + lines = [f"{method} {target} {version}"] + lines += [f"{n}: {v}" for n, v in kept] + return ("\r\n".join(lines) + "\r\n\r\n").encode("latin-1") + + +async def _reply_error(writer, status, reason): + body = f"{status} {reason}\n".encode() + writer.write( + f"HTTP/1.1 {status} {reason}\r\nContent-Type: text/plain\r\n" + f"Content-Length: {len(body)}\r\nConnection: close\r\n\r\n".encode() + + body + ) + try: + await writer.drain() + except ConnectionError: + pass + + +async def _pipe(reader, writer, half_close): + """Copie reader vers writer jusqu'à la fin du flux. + + half_close : à la fin, fermer seulement l'écriture (write_eof) plutôt que + rien — le client qui a fini d'envoyer attend encore la réponse. + """ + try: + while data := await reader.read(RELAY_CHUNK): + writer.write(data) + await writer.drain() + if half_close and writer.can_write_eof(): + writer.write_eof() + except (ConnectionError, OSError): + pass + + +async def handle(reader, writer, config): + """Sert une connexion cliente : une requête, relayée puis fermée.""" + peer = writer.get_extra_info("peername") + client_ip = peer[0] if peer else "" + upstream_writer = None + try: + try: + head = await reader.readuntil(b"\r\n\r\n") + except asyncio.LimitOverrunError: + await _reply_error(writer, 431, "Request Header Fields Too Large") + return + except asyncio.IncompleteReadError: + return + try: + method, target, version, headers = parse_head(head) + except ValueError: + await _reply_error(writer, 400, "Bad Request") + return + port = ( + config.websocket_port + if is_websocket_path(target, config) + else config.web_port + ) + try: + upstream_reader, upstream_writer = await asyncio.open_connection( + config.odoo_host, port + ) + except OSError as e: + print(f"Odoo injoignable sur {config.odoo_host}:{port} : {e}") + await _reply_error(writer, 502, "Bad Gateway") + return + upstream_writer.write( + rewrite_head(method, target, version, headers, client_ip, config) + ) + # Ce que le client a déjà envoyé après la tête — un début de corps — + # est dans le tampon du lecteur : _pipe le relaie en premier. + to_client = asyncio.create_task( + _pipe(upstream_reader, writer, half_close=False) + ) + to_odoo = asyncio.create_task( + _pipe(reader, upstream_writer, half_close=True) + ) + # La fin de la réponse clôt l'échange ; un client qui ferme le premier + # (onglet fermé, WebSocket quittée) le clôt aussi. + done, _ = await asyncio.wait( + {to_client, to_odoo}, return_when=asyncio.FIRST_COMPLETED + ) + if to_odoo in done and not to_client.done(): + # Le client a fini d'envoyer : la réponse peut encore venir. + await to_client + for task in (to_client, to_odoo): + task.cancel() + finally: + for w in (upstream_writer, writer): + if w is not None: + w.close() + + +async def serve(config, listen, port): + """Démarre l'écoute et rend le serveur asyncio, déjà à l'écoute.""" + return await asyncio.start_server( + lambda r, w: handle(r, w, config), listen, port, limit=MAX_HEAD + ) + + +def get_config(argv=None): + parser = argparse.ArgumentParser( + description=( + "Mandataire inverse de développement devant Odoo : pages vers le" + " port web, bus vers le port websocket. En production, préférer" + " nginx (script/nginx/)." + ) + ) + parser.add_argument( + "--listen", + default="127.0.0.1", + help="Adresse d'écoute ; 0.0.0.0 pour l'exposer au réseau.", + ) + parser.add_argument("--port", type=int, default=8080) + parser.add_argument("--odoo-host", default="127.0.0.1") + parser.add_argument("--web-port", type=int, default=8069) + parser.add_argument( + "--websocket-port", + type=int, + default=8072, + help="Port du bus d'Odoo (gevent).", + ) + parser.add_argument( + "--websocket-path", + action="append", + help=( + "Chemin routé vers le port du bus ; répétable. Défaut :" + f" {', '.join(DEFAULT_WEBSOCKET_PATHS)}." + ), + ) + parser.add_argument( + "--forwarded-proto", + default="http", + choices=("http", "https"), + help="Valeur de X-Forwarded-Proto, https derrière une terminaison TLS.", + ) + return parser.parse_args(argv) + + +def config_from_args(args): + return ProxyConfig( + odoo_host=args.odoo_host, + web_port=args.web_port, + websocket_port=args.websocket_port, + websocket_paths=tuple(args.websocket_path or DEFAULT_WEBSOCKET_PATHS), + forwarded_proto=args.forwarded_proto, + ) + + +async def _run(args): + config = config_from_args(args) + server = await serve(config, args.listen, args.port) + print( + f"Mandataire sur {args.listen}:{args.port} → pages" + f" {config.odoo_host}:{config.web_port}, bus" + f" {config.odoo_host}:{config.websocket_port}" + f" ({', '.join(config.websocket_paths)})." + " Odoo doit tourner avec proxy_mode = True." + ) + async with server: + await server.serve_forever() + + +def main(argv=None): + try: + asyncio.run(_run(get_config(argv))) + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/test_reverse_proxy.py b/test/test_reverse_proxy.py new file mode 100644 index 0000000..f716dac --- /dev/null +++ b/test/test_reverse_proxy.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le mandataire de développement devant Odoo, éprouvé sur de vrais sockets. + +Chaque amont est un faux Odoo local qui note ce qu'il reçoit : le routage, +les en-têtes réécrits et le relais se vérifient sur les octets, pas sur une +maquette de la bibliothèque réseau. +""" + +import asyncio +import gzip +import hashlib +import unittest + +from script.reverse_proxy import main as rp + + +async def lire_tete(reader): + """La tête d'un message HTTP, jusqu'à la ligne vide comprise.""" + return await reader.readuntil(b"\r\n\r\n") + + +def entetes(tete): + """Les en-têtes d'une tête brute, en liste (nom en minuscules, valeur).""" + lignes = tete.decode("latin-1").split("\r\n")[1:] + out = [] + for ligne in lignes: + if ":" in ligne: + nom, valeur = ligne.split(":", 1) + out.append((nom.strip().lower(), valeur.strip())) + return out + + +class FauxOdoo: + """Un amont qui retient chaque tête reçue et répond par `repondre`.""" + + def __init__(self, repondre): + self.repondre = repondre + self.tetes = [] + self.serveur = None + self.port = None + + async def _servir(self, reader, writer): + try: + tete = await lire_tete(reader) + self.tetes.append(tete) + await self.repondre(tete, reader, writer) + except (asyncio.IncompleteReadError, ConnectionError): + pass + finally: + writer.close() + + async def demarrer(self): + self.serveur = await asyncio.start_server(self._servir, "127.0.0.1", 0) + self.port = self.serveur.sockets[0].getsockname()[1] + return self + + async def arreter(self): + self.serveur.close() + await self.serveur.wait_closed() + + +def reponse_fixe(corps=b"ok", extra=b""): + async def repondre(tete, reader, writer): + writer.write( + b"HTTP/1.1 200 OK\r\nContent-Length: " + + str(len(corps)).encode() + + b"\r\n" + + extra + + b"\r\n" + + corps + ) + await writer.drain() + + return repondre + + +class BaseProxy(unittest.IsolatedAsyncioTestCase): + async def monter(self, web, ws, **options): + self.config = rp.ProxyConfig( + odoo_host="127.0.0.1", + web_port=web.port, + websocket_port=ws.port, + **options, + ) + self.serveur = await rp.serve(self.config, "127.0.0.1", 0) + self.port = self.serveur.sockets[0].getsockname()[1] + self.addCleanup(self._fermer) + + async def _fermer(self): + self.serveur.close() + await self.serveur.wait_closed() + + async def requete(self, brut): + """Envoie une requête brute et rend la réponse entière.""" + reader, writer = await asyncio.open_connection("127.0.0.1", self.port) + writer.write(brut) + await writer.drain() + reponse = await asyncio.wait_for(reader.read(), timeout=10) + writer.close() + return reponse + + +class TestRoutage(BaseProxy): + async def asyncSetUp(self): + self.web = await FauxOdoo(reponse_fixe(b"web")).demarrer() + self.ws = await FauxOdoo(reponse_fixe(b"ws")).demarrer() + self.addAsyncCleanup(self.web.arreter) + self.addAsyncCleanup(self.ws.arreter) + await self.monter(self.web, self.ws) + + async def corps_pour(self, chemin): + rep = await self.requete( + b"GET " + chemin + b" HTTP/1.1\r\nHost: odoo.test\r\n\r\n" + ) + return rep.split(b"\r\n\r\n", 1)[1] + + async def test_une_page_va_au_port_web(self): + self.assertEqual(await self.corps_pour(b"/web/login"), b"web") + + async def test_websocket_va_au_port_du_bus(self): + self.assertEqual(await self.corps_pour(b"/websocket?v=1"), b"ws") + + async def test_longpolling_n_est_plus_route_vers_le_bus(self): + # Le bus d'Odoo 18 ne sert que /websocket ; /longpolling reste une + # page ordinaire, sauf --websocket-path explicite. + self.assertEqual(await self.corps_pour(b"/longpolling/poll"), b"web") + + async def test_un_prefixe_seul_ne_suffit_pas(self): + # « /websocketX » n'est pas « /websocket » : le segment entier compte. + self.assertEqual(await self.corps_pour(b"/websocketX"), b"web") + + +class TestEntetes(BaseProxy): + async def asyncSetUp(self): + self.web = await FauxOdoo(reponse_fixe()).demarrer() + self.ws = await FauxOdoo(reponse_fixe()).demarrer() + self.addAsyncCleanup(self.web.arreter) + self.addAsyncCleanup(self.ws.arreter) + + async def test_les_en_tetes_du_mandataire_sont_poses(self): + await self.monter(self.web, self.ws, forwarded_proto="https") + await self.requete( + b"GET /web HTTP/1.1\r\nHost: odoo.test:8080\r\n\r\n" + ) + recus = dict(entetes(self.web.tetes[0])) + self.assertEqual(recus["x-forwarded-host"], "odoo.test:8080") + self.assertEqual(recus["x-forwarded-proto"], "https") + self.assertEqual(recus["x-forwarded-for"], "127.0.0.1") + self.assertEqual(recus["x-real-ip"], "127.0.0.1") + + async def test_un_x_forwarded_for_du_client_est_remplace(self): + # Odoo en proxy_mode croit cet en-tête : venu du client, il lui + # ferait prendre une adresse inventée pour celle du visiteur. + await self.monter(self.web, self.ws) + await self.requete( + b"GET /web HTTP/1.1\r\nHost: h\r\n" + b"X-Forwarded-For: 203.0.113.9\r\nX-Real-IP: 203.0.113.9\r\n\r\n" + ) + recus = entetes(self.web.tetes[0]) + self.assertEqual( + [v for n, v in recus if n == "x-forwarded-for"], ["127.0.0.1"] + ) + self.assertEqual( + [v for n, v in recus if n == "x-real-ip"], ["127.0.0.1"] + ) + + async def test_une_requete_ordinaire_ferme_sa_connexion_amont(self): + await self.monter(self.web, self.ws) + await self.requete( + b"GET /web HTTP/1.1\r\nHost: h\r\nConnection: keep-alive\r\n" + b"Keep-Alive: timeout=5\r\n\r\n" + ) + recus = entetes(self.web.tetes[0]) + self.assertEqual([v for n, v in recus if n == "connection"], ["close"]) + self.assertNotIn("keep-alive", [n for n, _ in recus]) + + +class TestRelais(BaseProxy): + async def test_une_reponse_gzip_en_morceaux_passe_intacte(self): + corps = gzip.compress(b"x" * 50_000) + taille = hex(len(corps))[2:].encode() + brut_amont = ( + b"HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\n" + b"Transfer-Encoding: chunked\r\n\r\n" + + taille + + b"\r\n" + + corps + + b"\r\n0\r\n\r\n" + ) + + async def repondre(tete, reader, writer): + writer.write(brut_amont) + await writer.drain() + + web = await FauxOdoo(repondre).demarrer() + ws = await FauxOdoo(reponse_fixe()).demarrer() + self.addAsyncCleanup(web.arreter) + self.addAsyncCleanup(ws.arreter) + await self.monter(web, ws) + rep = await self.requete(b"GET /web HTTP/1.1\r\nHost: h\r\n\r\n") + self.assertEqual(rep, brut_amont) + + async def test_un_gros_corps_arrive_entier(self): + envoye = bytes(range(256)) * 20_000 # 5 Mo + + async def repondre(tete, reader, writer): + n = int(dict(entetes(tete))["content-length"]) + recu = await reader.readexactly(n) + empreinte = hashlib.sha256(recu).hexdigest().encode() + writer.write( + b"HTTP/1.1 200 OK\r\nContent-Length: " + + str(len(empreinte)).encode() + + b"\r\n\r\n" + + empreinte + ) + await writer.drain() + + web = await FauxOdoo(repondre).demarrer() + ws = await FauxOdoo(reponse_fixe()).demarrer() + self.addAsyncCleanup(web.arreter) + self.addAsyncCleanup(ws.arreter) + await self.monter(web, ws) + rep = await self.requete( + b"POST /web/binary/upload HTTP/1.1\r\nHost: h\r\nContent-Length: " + + str(len(envoye)).encode() + + b"\r\n\r\n" + + envoye + ) + self.assertEqual( + rep.split(b"\r\n\r\n", 1)[1], + hashlib.sha256(envoye).hexdigest().encode(), + ) + + async def test_le_websocket_relaie_dans_les_deux_sens(self): + async def repondre(tete, reader, writer): + writer.write( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n" + b"Connection: Upgrade\r\n\r\n" + ) + await writer.drain() + # Écho tant que le client parle : ce que l'ancien script, + # requête puis réponse, ne pouvait pas porter. + while data := await reader.read(65536): + writer.write(data) + await writer.drain() + + web = await FauxOdoo(reponse_fixe()).demarrer() + ws = await FauxOdoo(repondre).demarrer() + self.addAsyncCleanup(web.arreter) + self.addAsyncCleanup(ws.arreter) + await self.monter(web, ws) + + reader, writer = await asyncio.open_connection("127.0.0.1", self.port) + writer.write( + b"GET /websocket HTTP/1.1\r\nHost: h\r\nUpgrade: websocket\r\n" + b"Connection: Upgrade\r\nSec-WebSocket-Key: x\r\n\r\n" + ) + await writer.drain() + tete = await asyncio.wait_for(lire_tete(reader), timeout=10) + self.assertTrue(tete.startswith(b"HTTP/1.1 101")) + for message in (b"premier", b"second"): + writer.write(message) + await writer.drain() + self.assertEqual( + await asyncio.wait_for( + reader.readexactly(len(message)), timeout=10 + ), + message, + ) + writer.close() + + recus = dict(entetes(ws.tetes[0])) + self.assertEqual(recus["upgrade"], "websocket") + self.assertEqual(recus["connection"], "Upgrade") + + +class TestErreurs(BaseProxy): + async def asyncSetUp(self): + self.web = await FauxOdoo(reponse_fixe()).demarrer() + self.ws = await FauxOdoo(reponse_fixe()).demarrer() + self.addAsyncCleanup(self.ws.arreter) + + async def test_un_odoo_arrete_rend_502(self): + await self.monter(self.web, self.ws) + await self.web.arreter() + rep = await self.requete(b"GET /web HTTP/1.1\r\nHost: h\r\n\r\n") + self.assertTrue(rep.startswith(b"HTTP/1.1 502"), rep[:40]) + + async def test_une_tete_illisible_rend_400(self): + self.addAsyncCleanup(self.web.arreter) + await self.monter(self.web, self.ws) + rep = await self.requete(b"n'importe quoi\r\n\r\n") + self.assertTrue(rep.startswith(b"HTTP/1.1 400"), rep[:40]) + self.assertEqual(self.web.tetes, []) + + async def test_une_tete_demesuree_rend_431(self): + self.addAsyncCleanup(self.web.arreter) + await self.monter(self.web, self.ws) + rep = await self.requete( + b"GET /web HTTP/1.1\r\nX-Long: " + + b"a" * (rp.MAX_HEAD + 10) + + b"\r\n\r\n" + ) + self.assertTrue(rep.startswith(b"HTTP/1.1 431"), rep[:40]) + + +class TestLigneDeCommande(unittest.TestCase): + def test_les_defauts_ecoutent_en_local(self): + args = rp.get_config([]) + self.assertEqual(args.listen, "127.0.0.1") + self.assertEqual(args.port, 8080) + self.assertEqual(args.web_port, 8069) + self.assertEqual(args.websocket_port, 8072) + + def test_les_chemins_du_bus_se_remplacent(self): + args = rp.get_config(["--websocket-path", "/bus"]) + self.assertEqual(rp.config_from_args(args).websocket_paths, ("/bus",)) + + +if __name__ == "__main__": + unittest.main()