[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
This commit is contained in:
parent
1f4efee16b
commit
53ef296690
2 changed files with 637 additions and 0 deletions
313
script/reverse_proxy/main.py
Executable file
313
script/reverse_proxy/main.py
Executable file
|
|
@ -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())
|
||||
324
test/test_reverse_proxy.py
Normal file
324
test/test_reverse_proxy.py
Normal file
|
|
@ -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()
|
||||
Loading…
Reference in a new issue