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
541 lines
18 KiB
Python
Executable file
541 lines
18 KiB
Python
Executable file
#!/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 configparser
|
|
import ssl
|
|
import sys
|
|
import time
|
|
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
|
|
|
|
# Délais de la mise en relation seulement, jamais du relais : une WebSocket
|
|
# ouverte reste muette des minutes entre deux notifications.
|
|
HEAD_TIMEOUT = 30.0
|
|
CONNECT_TIMEOUT = 10.0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProxyConfig:
|
|
"""Réglages du mandataire.
|
|
|
|
head_timeout borne l'attente de la tête d'une requête (408 au-delà),
|
|
connect_timeout la connexion à Odoo (504). trust_forwarded prolonge les
|
|
X-Forwarded-* reçus au lieu de les remplacer, pour un mandataire placé
|
|
derrière un autre. log reçoit une ligne par requête ; None le rend muet.
|
|
"""
|
|
|
|
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"
|
|
head_timeout: float = HEAD_TIMEOUT
|
|
connect_timeout: float = CONNECT_TIMEOUT
|
|
trust_forwarded: bool = False
|
|
log: object = print
|
|
|
|
|
|
def read_odoo_config(path):
|
|
"""Les ports et réglages d'Odoo utiles au mandataire.
|
|
|
|
Lit la section [options] d'un config.conf. Les anciens noms, xmlrpc_port
|
|
et longpolling_port, servent de repli ; une option absente
|
|
ou illisible — un fichier absent compris — garde le défaut d'Odoo.
|
|
|
|
:return: {"web_port", "websocket_port", "proxy_mode", "workers"}
|
|
"""
|
|
cfg = configparser.ConfigParser(interpolation=None)
|
|
cfg.read(path)
|
|
|
|
def entier(noms, defaut):
|
|
for nom in noms:
|
|
try:
|
|
return cfg.getint("options", nom)
|
|
except (configparser.Error, ValueError):
|
|
continue
|
|
return defaut
|
|
|
|
try:
|
|
proxy_mode = cfg.getboolean("options", "proxy_mode")
|
|
except (configparser.Error, ValueError):
|
|
proxy_mode = False
|
|
return {
|
|
"web_port": entier(("http_port", "xmlrpc_port"), 8069),
|
|
"websocket_port": entier(("gevent_port", "longpolling_port"), 8072),
|
|
"proxy_mode": proxy_mode,
|
|
"workers": entier(("workers",), 0),
|
|
}
|
|
|
|
|
|
def parse_head(head):
|
|
"""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)
|
|
|
|
def first(name):
|
|
return next((v for n, v in headers if n.lower() == name), "")
|
|
|
|
upgrade_value = first("upgrade")
|
|
host = first("host")
|
|
forwarded_for = client_ip
|
|
real_ip = client_ip
|
|
forwarded_host = host
|
|
forwarded_proto = config.forwarded_proto
|
|
if config.trust_forwarded:
|
|
# Derrière un autre mandataire : sa chaîne est prolongée, et ce qu'il
|
|
# dit de l'hôte et du protocole du visiteur l'emporte.
|
|
chain = ", ".join(
|
|
v for n, v in headers if n.lower() == "x-forwarded-for"
|
|
)
|
|
if chain:
|
|
forwarded_for = f"{chain}, {client_ip}"
|
|
real_ip = chain.split(",")[0].strip()
|
|
real_ip = first("x-real-ip") or real_ip
|
|
forwarded_host = first("x-forwarded-host") or host
|
|
forwarded_proto = first("x-forwarded-proto") or forwarded_proto
|
|
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", forwarded_for),
|
|
("X-Real-IP", real_ip),
|
|
("X-Forwarded-Host", forwarded_host),
|
|
("X-Forwarded-Proto", 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, detail=""):
|
|
body = f"{status} {reason}\n{detail}".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, on_first=None):
|
|
"""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.
|
|
on_first reçoit le premier bloc lu, sans le retenir : le journal y lit
|
|
le statut de la réponse.
|
|
"""
|
|
try:
|
|
while data := await reader.read(RELAY_CHUNK):
|
|
if on_first is not None:
|
|
on_first(data)
|
|
on_first = None
|
|
writer.write(data)
|
|
await writer.drain()
|
|
if half_close and writer.can_write_eof():
|
|
writer.write_eof()
|
|
except (ConnectionError, OSError):
|
|
pass
|
|
|
|
|
|
def _status_of(chunk):
|
|
"""Le statut d'une réponse d'après son premier bloc, « ? » sinon."""
|
|
parts = chunk.split(b" ", 2)
|
|
if len(parts) >= 2 and parts[0].startswith(b"HTTP/"):
|
|
return parts[1].decode("latin-1")
|
|
return "?"
|
|
|
|
|
|
def unreachable_detail(config, port):
|
|
"""Pourquoi Odoo ne répond pas sur ce port, en une ligne."""
|
|
where = f"{config.odoo_host}:{port}"
|
|
if port == config.websocket_port:
|
|
return (
|
|
f"Odoo ne répond pas sur {where} (bus). Le bus n'écoute que si"
|
|
" Odoo tourne avec workers >= 1.\n"
|
|
)
|
|
return f"Odoo ne répond pas sur {where} (web). Odoo est-il démarré ?\n"
|
|
|
|
|
|
async def _open_upstream(host, port):
|
|
return await asyncio.open_connection(host, port)
|
|
|
|
|
|
async def handle(reader, writer, config):
|
|
"""Sert une connexion cliente : une requête, relayée puis fermée.
|
|
|
|
Une ligne de journal par requête : client, méthode, cible, route (web
|
|
ou bus), statut et durée — celle d'une WebSocket va jusqu'à sa fermeture.
|
|
"""
|
|
started = time.monotonic()
|
|
peer = writer.get_extra_info("peername")
|
|
client_ip = peer[0] if peer else ""
|
|
upstream_writer = None
|
|
request = "-"
|
|
route = "-"
|
|
status = {"code": "?"}
|
|
|
|
def journal():
|
|
if config.log is not None:
|
|
ms = int((time.monotonic() - started) * 1000)
|
|
config.log(
|
|
f"{client_ip} {request} → {route} {status['code']} {ms} ms"
|
|
)
|
|
|
|
try:
|
|
try:
|
|
head = await asyncio.wait_for(
|
|
reader.readuntil(b"\r\n\r\n"), config.head_timeout
|
|
)
|
|
except asyncio.TimeoutError:
|
|
await _reply_error(writer, 408, "Request Timeout")
|
|
return
|
|
except asyncio.LimitOverrunError:
|
|
status["code"] = "431"
|
|
await _reply_error(writer, 431, "Request Header Fields Too Large")
|
|
journal()
|
|
return
|
|
except asyncio.IncompleteReadError:
|
|
return
|
|
try:
|
|
method, target, version, headers = parse_head(head)
|
|
except ValueError:
|
|
status["code"] = "400"
|
|
await _reply_error(writer, 400, "Bad Request")
|
|
journal()
|
|
return
|
|
request = f"{method} {target}"
|
|
bus = is_websocket_path(target, config)
|
|
route = "bus" if bus else "web"
|
|
port = config.websocket_port if bus else config.web_port
|
|
try:
|
|
upstream_reader, upstream_writer = await asyncio.wait_for(
|
|
_open_upstream(config.odoo_host, port), config.connect_timeout
|
|
)
|
|
except asyncio.TimeoutError:
|
|
status["code"] = "504"
|
|
await _reply_error(
|
|
writer,
|
|
504,
|
|
"Gateway Timeout",
|
|
unreachable_detail(config, port),
|
|
)
|
|
journal()
|
|
return
|
|
except OSError:
|
|
status["code"] = "502"
|
|
await _reply_error(
|
|
writer, 502, "Bad Gateway", unreachable_detail(config, port)
|
|
)
|
|
journal()
|
|
return
|
|
upstream_writer.write(
|
|
rewrite_head(method, target, version, headers, client_ip, config)
|
|
)
|
|
|
|
def note_status(chunk):
|
|
status["code"] = _status_of(chunk)
|
|
|
|
# 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, on_first=note_status
|
|
)
|
|
)
|
|
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()
|
|
journal()
|
|
finally:
|
|
for w in (upstream_writer, writer):
|
|
if w is not None:
|
|
w.close()
|
|
|
|
|
|
async def serve(config, listen, port, ssl_context=None):
|
|
"""Démarre l'écoute et rend le serveur asyncio, déjà à l'écoute.
|
|
|
|
ssl_context : écoute en HTTPS ; Odoo, derrière, reste en HTTP clair.
|
|
"""
|
|
return await asyncio.start_server(
|
|
lambda r, w: handle(r, w, config),
|
|
listen,
|
|
port,
|
|
limit=MAX_HEAD,
|
|
ssl=ssl_context,
|
|
)
|
|
|
|
|
|
def make_ssl_context(cert, key):
|
|
"""Contexte serveur TLS depuis un certificat et sa clé (PEM)."""
|
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
context.load_cert_chain(cert, key)
|
|
return context
|
|
|
|
|
|
async def probe(config):
|
|
"""Tente une connexion aux deux ports d'Odoo.
|
|
|
|
:return: {"web": bool, "bus": bool}, vrai quand le port accepte
|
|
"""
|
|
result = {}
|
|
for role, port in (
|
|
("web", config.web_port),
|
|
("bus", config.websocket_port),
|
|
):
|
|
try:
|
|
_, w = await asyncio.wait_for(
|
|
_open_upstream(config.odoo_host, port), config.connect_timeout
|
|
)
|
|
w.close()
|
|
result[role] = True
|
|
except (OSError, asyncio.TimeoutError):
|
|
result[role] = False
|
|
return result
|
|
|
|
|
|
def startup_warnings(state, config):
|
|
"""Les avertissements à dire au démarrage d'après probe(), [] sinon."""
|
|
warnings = []
|
|
if not state.get("web"):
|
|
warnings.append(unreachable_detail(config, config.web_port).strip())
|
|
if not state.get("bus"):
|
|
warnings.append(
|
|
unreachable_detail(config, config.websocket_port).strip()
|
|
)
|
|
return warnings
|
|
|
|
|
|
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",
|
|
choices=("http", "https"),
|
|
help=(
|
|
"Valeur de X-Forwarded-Proto. Défaut : https avec --tls-cert,"
|
|
" http sinon."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--tls-cert",
|
|
help="Certificat PEM : le mandataire écoute alors en HTTPS.",
|
|
)
|
|
parser.add_argument("--tls-key", help="Clé PEM du certificat.")
|
|
parser.add_argument(
|
|
"--trust-forwarded",
|
|
action="store_true",
|
|
help=(
|
|
"Prolonger les X-Forwarded-* reçus au lieu de les remplacer, pour"
|
|
" un mandataire placé derrière un autre. À éviter face à des"
|
|
" navigateurs : ils pourraient se faire passer pour une autre"
|
|
" adresse."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--quiet",
|
|
action="store_true",
|
|
help="Ne pas écrire une ligne par requête.",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
if bool(args.tls_cert) != bool(args.tls_key):
|
|
parser.error("--tls-cert et --tls-key vont ensemble")
|
|
return args
|
|
|
|
|
|
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
|
|
or ("https" if args.tls_cert else "http"),
|
|
trust_forwarded=args.trust_forwarded,
|
|
log=None if args.quiet else print,
|
|
)
|
|
|
|
|
|
async def _run(args):
|
|
config = config_from_args(args)
|
|
context = None
|
|
if args.tls_cert:
|
|
context = make_ssl_context(args.tls_cert, args.tls_key)
|
|
server = await serve(config, args.listen, args.port, ssl_context=context)
|
|
scheme = "https" if context else "http"
|
|
print(
|
|
f"Mandataire sur {scheme}://{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."
|
|
)
|
|
for warning in startup_warnings(await probe(config), config):
|
|
print(f"⚠️ {warning}")
|
|
async with server:
|
|
await server.serve_forever()
|
|
|
|
|
|
def main(argv=None):
|
|
# Une ligne de journal doit paraître à sa requête, y compris quand la
|
|
# sortie est un tube ou un fichier, que Python tamponne sinon par blocs.
|
|
sys.stdout.reconfigure(line_buffering=True)
|
|
try:
|
|
asyncio.run(_run(get_config(argv)))
|
|
except KeyboardInterrupt:
|
|
pass
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|