[ADD] reverse proxy: dev front for Odoo 18, websocket relayed both ways
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
2026-09-25 05:25:16 -04:00
|
|
|
#!/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
|
[IMP] reverse proxy: timeouts, access log, startup probe, TLS, chain
A client that opened a connection and sent nothing held its task forever,
and the proxy stayed silent until a 502 that named no port. The request
head now waits 30 s (408) and the connection to Odoo 10 s (504), never
the relay, so a WebSocket stays open; a line per request gives route,
status and duration; the start probes both ports and a 502 names the one
missing, with workers for the bus. --tls-cert/--tls-key serve HTTPS and
default X-Forwarded-Proto to https; --trust-forwarded extends a chain.
Checked: 32 tests; live Odoo 18 over HTTPS, wss ping returns its pong.
--- FR ---
[IMP] reverse proxy : délais, journal, sonde au démarrage, TLS, chaîne
Un client qui ouvrait une connexion sans rien envoyer gardait sa tâche
pour toujours, et le mandataire restait muet jusqu'à un 502 sans port. La
tête attend désormais 30 s (408) et la connexion à Odoo 10 s (504), jamais
le relais : une WebSocket reste ouverte ; une ligne par requête donne
route, statut et durée ; le démarrage sonde les deux ports et un 502 nomme
celui qui manque, workers pour le bus. --tls-cert/--tls-key servent en
HTTPS, X-Forwarded-Proto à https ; --trust-forwarded prolonge une chaîne.
Vérifié : 32 tests ; Odoo 18 réel en HTTPS, un ping wss rend son pong.
Assisted-by: Claude Opus 5.5
2026-09-25 06:35:29 -04:00
|
|
|
import os
|
|
|
|
|
import shutil
|
|
|
|
|
import ssl
|
|
|
|
|
import tempfile
|
[ADD] reverse proxy: dev front for Odoo 18, websocket relayed both ways
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
2026-09-25 05:25:16 -04:00
|
|
|
import unittest
|
[IMP] reverse proxy: timeouts, access log, startup probe, TLS, chain
A client that opened a connection and sent nothing held its task forever,
and the proxy stayed silent until a 502 that named no port. The request
head now waits 30 s (408) and the connection to Odoo 10 s (504), never
the relay, so a WebSocket stays open; a line per request gives route,
status and duration; the start probes both ports and a 502 names the one
missing, with workers for the bus. --tls-cert/--tls-key serve HTTPS and
default X-Forwarded-Proto to https; --trust-forwarded extends a chain.
Checked: 32 tests; live Odoo 18 over HTTPS, wss ping returns its pong.
--- FR ---
[IMP] reverse proxy : délais, journal, sonde au démarrage, TLS, chaîne
Un client qui ouvrait une connexion sans rien envoyer gardait sa tâche
pour toujours, et le mandataire restait muet jusqu'à un 502 sans port. La
tête attend désormais 30 s (408) et la connexion à Odoo 10 s (504), jamais
le relais : une WebSocket reste ouverte ; une ligne par requête donne
route, statut et durée ; le démarrage sonde les deux ports et un 502 nomme
celui qui manque, workers pour le bus. --tls-cert/--tls-key servent en
HTTPS, X-Forwarded-Proto à https ; --trust-forwarded prolonge une chaîne.
Vérifié : 32 tests ; Odoo 18 réel en HTTPS, un ping wss rend son pong.
Assisted-by: Claude Opus 5.5
2026-09-25 06:35:29 -04:00
|
|
|
from unittest.mock import patch
|
[ADD] reverse proxy: dev front for Odoo 18, websocket relayed both ways
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
2026-09-25 05:25:16 -04:00
|
|
|
|
|
|
|
|
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):
|
[IMP] reverse proxy: timeouts, access log, startup probe, TLS, chain
A client that opened a connection and sent nothing held its task forever,
and the proxy stayed silent until a 502 that named no port. The request
head now waits 30 s (408) and the connection to Odoo 10 s (504), never
the relay, so a WebSocket stays open; a line per request gives route,
status and duration; the start probes both ports and a 502 names the one
missing, with workers for the bus. --tls-cert/--tls-key serve HTTPS and
default X-Forwarded-Proto to https; --trust-forwarded extends a chain.
Checked: 32 tests; live Odoo 18 over HTTPS, wss ping returns its pong.
--- FR ---
[IMP] reverse proxy : délais, journal, sonde au démarrage, TLS, chaîne
Un client qui ouvrait une connexion sans rien envoyer gardait sa tâche
pour toujours, et le mandataire restait muet jusqu'à un 502 sans port. La
tête attend désormais 30 s (408) et la connexion à Odoo 10 s (504), jamais
le relais : une WebSocket reste ouverte ; une ligne par requête donne
route, statut et durée ; le démarrage sonde les deux ports et un 502 nomme
celui qui manque, workers pour le bus. --tls-cert/--tls-key servent en
HTTPS, X-Forwarded-Proto à https ; --trust-forwarded prolonge une chaîne.
Vérifié : 32 tests ; Odoo 18 réel en HTTPS, un ping wss rend son pong.
Assisted-by: Claude Opus 5.5
2026-09-25 06:35:29 -04:00
|
|
|
# Muet par défaut : le journal a ses propres tests.
|
|
|
|
|
options.setdefault("log", None)
|
[ADD] reverse proxy: dev front for Odoo 18, websocket relayed both ways
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
2026-09-25 05:25:16 -04:00
|
|
|
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])
|
|
|
|
|
|
|
|
|
|
|
[IMP] reverse proxy: timeouts, access log, startup probe, TLS, chain
A client that opened a connection and sent nothing held its task forever,
and the proxy stayed silent until a 502 that named no port. The request
head now waits 30 s (408) and the connection to Odoo 10 s (504), never
the relay, so a WebSocket stays open; a line per request gives route,
status and duration; the start probes both ports and a 502 names the one
missing, with workers for the bus. --tls-cert/--tls-key serve HTTPS and
default X-Forwarded-Proto to https; --trust-forwarded extends a chain.
Checked: 32 tests; live Odoo 18 over HTTPS, wss ping returns its pong.
--- FR ---
[IMP] reverse proxy : délais, journal, sonde au démarrage, TLS, chaîne
Un client qui ouvrait une connexion sans rien envoyer gardait sa tâche
pour toujours, et le mandataire restait muet jusqu'à un 502 sans port. La
tête attend désormais 30 s (408) et la connexion à Odoo 10 s (504), jamais
le relais : une WebSocket reste ouverte ; une ligne par requête donne
route, statut et durée ; le démarrage sonde les deux ports et un 502 nomme
celui qui manque, workers pour le bus. --tls-cert/--tls-key servent en
HTTPS, X-Forwarded-Proto à https ; --trust-forwarded prolonge une chaîne.
Vérifié : 32 tests ; Odoo 18 réel en HTTPS, un ping wss rend son pong.
Assisted-by: Claude Opus 5.5
2026-09-25 06:35:29 -04:00
|
|
|
class TestDelais(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_un_client_muet_recoit_408(self):
|
|
|
|
|
# Sans délai, une connexion ouverte sans requête garderait sa tâche
|
|
|
|
|
# pour toujours.
|
|
|
|
|
await self.monter(self.web, self.ws, head_timeout=0.2)
|
|
|
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", self.port)
|
|
|
|
|
rep = await asyncio.wait_for(reader.read(), timeout=5)
|
|
|
|
|
writer.close()
|
|
|
|
|
self.assertTrue(rep.startswith(b"HTTP/1.1 408"), rep[:40])
|
|
|
|
|
|
|
|
|
|
async def test_un_odoo_qui_ne_repond_pas_a_la_connexion_rend_504(self):
|
|
|
|
|
async def jamais(host, port):
|
|
|
|
|
await asyncio.sleep(3600)
|
|
|
|
|
|
|
|
|
|
await self.monter(self.web, self.ws, connect_timeout=0.2)
|
|
|
|
|
with patch.object(rp, "_open_upstream", jamais):
|
|
|
|
|
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 504"), rep[:40])
|
|
|
|
|
|
|
|
|
|
async def test_une_websocket_n_est_pas_coupee_par_les_delais(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()
|
|
|
|
|
while data := await reader.read(65536):
|
|
|
|
|
writer.write(data)
|
|
|
|
|
await writer.drain()
|
|
|
|
|
|
|
|
|
|
ws = await FauxOdoo(repondre).demarrer()
|
|
|
|
|
self.addAsyncCleanup(ws.arreter)
|
|
|
|
|
await self.monter(self.web, ws, head_timeout=0.2, connect_timeout=0.2)
|
|
|
|
|
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\n\r\n"
|
|
|
|
|
)
|
|
|
|
|
await writer.drain()
|
|
|
|
|
await asyncio.wait_for(lire_tete(reader), timeout=5)
|
|
|
|
|
await asyncio.sleep(0.6) # plus long que les deux délais
|
|
|
|
|
writer.write(b"toujours")
|
|
|
|
|
await writer.drain()
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
await asyncio.wait_for(reader.readexactly(8), timeout=5),
|
|
|
|
|
b"toujours",
|
|
|
|
|
)
|
|
|
|
|
writer.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestJournal(BaseProxy):
|
|
|
|
|
async def asyncSetUp(self):
|
|
|
|
|
self.lignes = []
|
|
|
|
|
self.web = await FauxOdoo(reponse_fixe()).demarrer()
|
|
|
|
|
self.ws = await FauxOdoo(reponse_fixe()).demarrer()
|
|
|
|
|
self.addAsyncCleanup(self.ws.arreter)
|
|
|
|
|
|
|
|
|
|
async def test_une_ligne_par_requete_avec_route_et_statut(self):
|
|
|
|
|
self.addAsyncCleanup(self.web.arreter)
|
|
|
|
|
await self.monter(self.web, self.ws, log=self.lignes.append)
|
|
|
|
|
await self.requete(b"GET /web/login HTTP/1.1\r\nHost: h\r\n\r\n")
|
|
|
|
|
await asyncio.sleep(0.05)
|
|
|
|
|
self.assertEqual(len(self.lignes), 1, self.lignes)
|
|
|
|
|
ligne = self.lignes[0]
|
|
|
|
|
for attendu in ("GET /web/login", "web", "200", "ms"):
|
|
|
|
|
self.assertIn(attendu, ligne)
|
|
|
|
|
|
|
|
|
|
async def test_un_echec_de_connexion_se_journalise(self):
|
|
|
|
|
await self.monter(self.web, self.ws, log=self.lignes.append)
|
|
|
|
|
await self.web.arreter()
|
|
|
|
|
await self.requete(b"GET /web HTTP/1.1\r\nHost: h\r\n\r\n")
|
|
|
|
|
await asyncio.sleep(0.05)
|
|
|
|
|
self.assertTrue(any("502" in ligne for ligne in self.lignes))
|
|
|
|
|
|
|
|
|
|
async def test_quiet_ne_journalise_rien(self):
|
|
|
|
|
self.addAsyncCleanup(self.web.arreter)
|
|
|
|
|
await self.monter(self.web, self.ws, log=None)
|
|
|
|
|
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 200"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestUn502QuiExplique(BaseProxy):
|
|
|
|
|
async def asyncSetUp(self):
|
|
|
|
|
self.web = await FauxOdoo(reponse_fixe()).demarrer()
|
|
|
|
|
self.ws = await FauxOdoo(reponse_fixe()).demarrer()
|
|
|
|
|
|
|
|
|
|
async def test_le_bus_ferme_nomme_le_port_et_workers(self):
|
|
|
|
|
self.addAsyncCleanup(self.web.arreter)
|
|
|
|
|
await self.monter(self.web, self.ws)
|
|
|
|
|
port = self.ws.port
|
|
|
|
|
await self.ws.arreter()
|
|
|
|
|
rep = await self.requete(b"GET /websocket HTTP/1.1\r\nHost: h\r\n\r\n")
|
|
|
|
|
self.assertTrue(rep.startswith(b"HTTP/1.1 502"))
|
|
|
|
|
self.assertIn(f":{port}".encode(), rep)
|
|
|
|
|
self.assertIn(b"workers", rep)
|
|
|
|
|
|
|
|
|
|
async def test_le_web_ferme_nomme_son_port(self):
|
|
|
|
|
self.addAsyncCleanup(self.ws.arreter)
|
|
|
|
|
await self.monter(self.web, self.ws)
|
|
|
|
|
port = self.web.port
|
|
|
|
|
await self.web.arreter()
|
|
|
|
|
rep = await self.requete(b"GET /web HTTP/1.1\r\nHost: h\r\n\r\n")
|
|
|
|
|
self.assertIn(f":{port}".encode(), rep)
|
|
|
|
|
self.assertNotIn(b"workers", rep)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSondeAuDemarrage(unittest.IsolatedAsyncioTestCase):
|
|
|
|
|
async def test_un_bus_ferme_se_signale_des_le_demarrage(self):
|
|
|
|
|
web = await FauxOdoo(reponse_fixe()).demarrer()
|
|
|
|
|
self.addAsyncCleanup(web.arreter)
|
|
|
|
|
ferme = await FauxOdoo(reponse_fixe()).demarrer()
|
|
|
|
|
port_ferme = ferme.port
|
|
|
|
|
await ferme.arreter()
|
|
|
|
|
config = rp.ProxyConfig(web_port=web.port, websocket_port=port_ferme)
|
|
|
|
|
etat = await rp.probe(config)
|
|
|
|
|
self.assertEqual(etat, {"web": True, "bus": False})
|
|
|
|
|
avis = " ".join(rp.startup_warnings(etat, config))
|
|
|
|
|
self.assertIn(str(port_ferme), avis)
|
|
|
|
|
self.assertIn("workers", avis)
|
|
|
|
|
|
|
|
|
|
async def test_rien_a_dire_quand_tout_repond(self):
|
|
|
|
|
web = await FauxOdoo(reponse_fixe()).demarrer()
|
|
|
|
|
ws = await FauxOdoo(reponse_fixe()).demarrer()
|
|
|
|
|
self.addAsyncCleanup(web.arreter)
|
|
|
|
|
self.addAsyncCleanup(ws.arreter)
|
|
|
|
|
config = rp.ProxyConfig(web_port=web.port, websocket_port=ws.port)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
rp.startup_warnings(await rp.probe(config), config), []
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestConfianceEnAmont(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_la_chaine_est_prolongee_et_non_remplacee(self):
|
|
|
|
|
await self.monter(self.web, self.ws, trust_forwarded=True)
|
|
|
|
|
await self.requete(
|
|
|
|
|
b"GET /web HTTP/1.1\r\nHost: interne\r\n"
|
|
|
|
|
b"X-Forwarded-For: 203.0.113.9\r\nX-Forwarded-Proto: https\r\n"
|
|
|
|
|
b"X-Forwarded-Host: public.test\r\n\r\n"
|
|
|
|
|
)
|
|
|
|
|
recus = entetes(self.web.tetes[0])
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
[v for n, v in recus if n == "x-forwarded-for"],
|
|
|
|
|
["203.0.113.9, 127.0.0.1"],
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
[v for n, v in recus if n == "x-forwarded-proto"], ["https"]
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
[v for n, v in recus if n == "x-forwarded-host"], ["public.test"]
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
[v for n, v in recus if n == "x-real-ip"], ["203.0.113.9"]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@unittest.skipUnless(shutil.which("openssl"), "openssl absent")
|
|
|
|
|
class TestTLS(BaseProxy):
|
|
|
|
|
async def test_une_requete_https_arrive_a_odoo_marquee_https(self):
|
|
|
|
|
from script.reverse_proxy import local_cert
|
|
|
|
|
|
|
|
|
|
dossier = os.path.join(tempfile.mkdtemp(), "tls")
|
|
|
|
|
self.addCleanup(shutil.rmtree, os.path.dirname(dossier))
|
|
|
|
|
chemins = local_cert.issue(dossier, ["localhost", "127.0.0.1"])
|
|
|
|
|
web = await FauxOdoo(reponse_fixe(b"secret")).demarrer()
|
|
|
|
|
ws = await FauxOdoo(reponse_fixe()).demarrer()
|
|
|
|
|
self.addAsyncCleanup(web.arreter)
|
|
|
|
|
self.addAsyncCleanup(ws.arreter)
|
|
|
|
|
args = rp.get_config(
|
|
|
|
|
[
|
|
|
|
|
"--tls-cert",
|
|
|
|
|
chemins["server_crt"],
|
|
|
|
|
"--tls-key",
|
|
|
|
|
chemins["server_key"],
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
self.config = rp.config_from_args(args)
|
|
|
|
|
self.assertEqual(self.config.forwarded_proto, "https")
|
|
|
|
|
self.config = rp.ProxyConfig(
|
|
|
|
|
web_port=web.port,
|
|
|
|
|
websocket_port=ws.port,
|
|
|
|
|
forwarded_proto=self.config.forwarded_proto,
|
|
|
|
|
)
|
|
|
|
|
serveur = await rp.serve(
|
|
|
|
|
self.config,
|
|
|
|
|
"127.0.0.1",
|
|
|
|
|
0,
|
|
|
|
|
ssl_context=rp.make_ssl_context(
|
|
|
|
|
chemins["server_crt"], chemins["server_key"]
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
self.addAsyncCleanup(self._arreter, serveur)
|
|
|
|
|
port = serveur.sockets[0].getsockname()[1]
|
|
|
|
|
client = ssl.create_default_context(cafile=chemins["ca_crt"])
|
|
|
|
|
reader, writer = await asyncio.open_connection(
|
|
|
|
|
"127.0.0.1", port, ssl=client
|
|
|
|
|
)
|
|
|
|
|
writer.write(b"GET /web HTTP/1.1\r\nHost: localhost\r\n\r\n")
|
|
|
|
|
await writer.drain()
|
|
|
|
|
rep = await asyncio.wait_for(reader.read(), timeout=10)
|
|
|
|
|
writer.close()
|
|
|
|
|
self.assertTrue(rep.endswith(b"secret"), rep[-40:])
|
|
|
|
|
recus = dict(entetes(web.tetes[0]))
|
|
|
|
|
self.assertEqual(recus["x-forwarded-proto"], "https")
|
|
|
|
|
|
|
|
|
|
async def _arreter(self, serveur):
|
|
|
|
|
serveur.close()
|
|
|
|
|
await serveur.wait_closed()
|
|
|
|
|
|
|
|
|
|
def test_un_certificat_sans_cle_est_refuse(self):
|
|
|
|
|
with self.assertRaises(SystemExit):
|
|
|
|
|
rp.get_config(["--tls-cert", "/tmp/x.crt"])
|
|
|
|
|
|
|
|
|
|
|
[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
2026-09-25 06:12:08 -04:00
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
[ADD] reverse proxy: dev front for Odoo 18, websocket relayed both ways
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
2026-09-25 05:25:16 -04:00
|
|
|
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()
|