erplibre/script/reverse_proxy/main.py

542 lines
18 KiB
Python
Raw Permalink Normal View History

#!/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())