325 lines
12 KiB
Python
325 lines
12 KiB
Python
|
|
#!/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()
|