diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index 5fc683f..ac64e47 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -84,6 +84,9 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The `pre-commit` hook runs `check_python_version.py` on staged files: it reports source that does not parse under the Python of `conf/python-erplibre-version`, without blocking the commit, and says when no such interpreter was there to check. Neither black nor flake8 sees that fault — black's target bounds what it writes, never what it accepts - `Deploy › Local › [4]` opens a SOCKS proxy over SSH — `ssh -D`, port 1080 by default — so the browser reaches, FROM the remote machine, an interface listening only on its loopback or a host of its network. The address comes from `~/.ssh/config` or by hand; an alias is passed to ssh as is, so its `ProxyJump` still applies and a nested VM stays reachable. The Firefox settings print before the tunnel opens, the command only returning on Ctrl+C - `make format_test` formats `test/` and `long_test/`, which no target covered: 75 files out of 210 followed no standard, and only a file a diff reported was ever touched +- `script/reverse_proxy/main.py` — a development reverse proxy for Odoo 18 without nginx: one address sends pages to the web port and `/websocket` to the bus port, with the `X-Forwarded-*` headers of `proxy_mode` set by the proxy alone (`--trust-forwarded` extends a chain instead, behind another proxy). Only the request head is read, so an upgraded WebSocket stays open and a gzip or chunked response passes byte for byte. It serves HTTPS with `--tls-cert`/`--tls-key`, logs one line per request (route, status, duration; `--quiet` silences it), says at start which Odoo port does not answer, and names that port in a 502. A request head waits 30 s (408) and the connection to Odoo 10 s (504), never an open WebSocket. Listens on `127.0.0.1:8080` by default, `--listen 0.0.0.0` opens it to the network; one request per connection, production keeps nginx +- `script/reverse_proxy/local_cert.py` issues a local authority, imported once in the browser, and a server certificate it signs for localhost, the host name and its addresses, under `~/.erplibre/reverse_proxy_tls/` with keys in 0600; the authority is kept when the certificate is issued again +- `TODO › Execute › Network › Odoo reverse proxy` starts it with the web and bus ports read from `config.conf`, asks for this machine only or the whole network and for HTTP or HTTPS, issues the local certificate on first HTTPS use, and warns when `proxy_mode` is off or `workers` is 0 — without a worker no bus port listens and `/websocket` fails. `Network › Local TLS certificates` issues it again with extra names @@ -140,6 +143,9 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Le hook `pre-commit` lance `check_python_version.py` sur les fichiers indexés : il signale le source qui ne parse pas sous le Python de `conf/python-erplibre-version`, sans bloquer le commit, et dit quand aucun interpréteur de cette version n'était là pour vérifier. Ni black ni flake8 ne voient ce défaut — la cible de black borne ce qu'il écrit, jamais ce qu'il accepte - `Déploiement › Local › [4]` ouvre un proxy SOCKS par SSH — `ssh -D`, port 1080 par défaut — pour que le navigateur atteigne, DEPUIS la machine distante, une interface qui n'écoute que sur sa boucle locale ou un hôte de son réseau. L'adresse vient de `~/.ssh/config` ou de la saisie ; un alias part tel quel à ssh, si bien que son `ProxyJump` s'applique encore et qu'une VM imbriquée reste joignable. Le réglage de Firefox s'affiche avant l'ouverture du tunnel, la commande ne rendant la main qu'au Ctrl+C - `make format_test` formate `test/` et `long_test/`, qu'aucune cible ne couvrait : 75 fichiers sur 210 ne suivaient aucune norme, et seul un fichier signalé par un diff était touché +- `script/reverse_proxy/main.py` — un mandataire inverse de développement pour Odoo 18, sans nginx : une seule adresse envoie les pages au port web et `/websocket` au port du bus, les en-têtes `X-Forwarded-*` de `proxy_mode` n'étant posés que par le mandataire (`--trust-forwarded` prolonge plutôt une chaîne, derrière un autre mandataire). Seule la tête de la requête est lue : une WebSocket montée reste ouverte et une réponse gzip ou en morceaux passe octet pour octet. Il sert en HTTPS avec `--tls-cert`/`--tls-key`, écrit une ligne par requête (route, statut, durée ; `--quiet` la coupe), dit au démarrage quel port d'Odoo ne répond pas, et nomme ce port dans un 502. La tête d'une requête attend 30 s (408) et la connexion à Odoo 10 s (504), jamais une WebSocket ouverte. Écoute par défaut sur `127.0.0.1:8080`, `--listen 0.0.0.0` l'ouvre au réseau ; une requête par connexion, la production garde nginx +- `script/reverse_proxy/local_cert.py` émet une autorité locale, importée une fois dans le navigateur, et un certificat serveur qu'elle signe pour localhost, le nom d'hôte et ses adresses, sous `~/.erplibre/reverse_proxy_tls/` avec des clés en 0600 ; l'autorité est gardée quand le certificat est réémis +- `TODO › Execute › Network › Mandataire inverse Odoo` le lance avec les ports web et bus lus dans `config.conf`, demande cette machine seule ou tout le réseau et HTTP ou HTTPS, émet le certificat local au premier usage en HTTPS, et prévient quand `proxy_mode` est éteint ou que `workers` vaut 0 — sans worker, aucun port de bus n'écoute et `/websocket` échoue. `Network › Certificats TLS locaux` le réémet avec d'autres noms ## Changed diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 0934fc3..ba3ed12 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -64,6 +64,9 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Le hook `pre-commit` lance `check_python_version.py` sur les fichiers indexés : il signale le source qui ne parse pas sous le Python de `conf/python-erplibre-version`, sans bloquer le commit, et dit quand aucun interpréteur de cette version n'était là pour vérifier. Ni black ni flake8 ne voient ce défaut — la cible de black borne ce qu'il écrit, jamais ce qu'il accepte - `Déploiement › Local › [4]` ouvre un proxy SOCKS par SSH — `ssh -D`, port 1080 par défaut — pour que le navigateur atteigne, DEPUIS la machine distante, une interface qui n'écoute que sur sa boucle locale ou un hôte de son réseau. L'adresse vient de `~/.ssh/config` ou de la saisie ; un alias part tel quel à ssh, si bien que son `ProxyJump` s'applique encore et qu'une VM imbriquée reste joignable. Le réglage de Firefox s'affiche avant l'ouverture du tunnel, la commande ne rendant la main qu'au Ctrl+C - `make format_test` formate `test/` et `long_test/`, qu'aucune cible ne couvrait : 75 fichiers sur 210 ne suivaient aucune norme, et seul un fichier signalé par un diff était touché +- `script/reverse_proxy/main.py` — un mandataire inverse de développement pour Odoo 18, sans nginx : une seule adresse envoie les pages au port web et `/websocket` au port du bus, les en-têtes `X-Forwarded-*` de `proxy_mode` n'étant posés que par le mandataire (`--trust-forwarded` prolonge plutôt une chaîne, derrière un autre mandataire). Seule la tête de la requête est lue : une WebSocket montée reste ouverte et une réponse gzip ou en morceaux passe octet pour octet. Il sert en HTTPS avec `--tls-cert`/`--tls-key`, écrit une ligne par requête (route, statut, durée ; `--quiet` la coupe), dit au démarrage quel port d'Odoo ne répond pas, et nomme ce port dans un 502. La tête d'une requête attend 30 s (408) et la connexion à Odoo 10 s (504), jamais une WebSocket ouverte. Écoute par défaut sur `127.0.0.1:8080`, `--listen 0.0.0.0` l'ouvre au réseau ; une requête par connexion, la production garde nginx +- `script/reverse_proxy/local_cert.py` émet une autorité locale, importée une fois dans le navigateur, et un certificat serveur qu'elle signe pour localhost, le nom d'hôte et ses adresses, sous `~/.erplibre/reverse_proxy_tls/` avec des clés en 0600 ; l'autorité est gardée quand le certificat est réémis +- `TODO › Execute › Network › Mandataire inverse Odoo` le lance avec les ports web et bus lus dans `config.conf`, demande cette machine seule ou tout le réseau et HTTP ou HTTPS, émet le certificat local au premier usage en HTTPS, et prévient quand `proxy_mode` est éteint ou que `workers` vaut 0 — sans worker, aucun port de bus n'écoute et `/websocket` échoue. `Network › Certificats TLS locaux` le réémet avec d'autres noms ## Modifié diff --git a/CHANGELOG.md b/CHANGELOG.md index 77f19f7..eacc032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The `pre-commit` hook runs `check_python_version.py` on staged files: it reports source that does not parse under the Python of `conf/python-erplibre-version`, without blocking the commit, and says when no such interpreter was there to check. Neither black nor flake8 sees that fault — black's target bounds what it writes, never what it accepts - `Deploy › Local › [4]` opens a SOCKS proxy over SSH — `ssh -D`, port 1080 by default — so the browser reaches, FROM the remote machine, an interface listening only on its loopback or a host of its network. The address comes from `~/.ssh/config` or by hand; an alias is passed to ssh as is, so its `ProxyJump` still applies and a nested VM stays reachable. The Firefox settings print before the tunnel opens, the command only returning on Ctrl+C - `make format_test` formats `test/` and `long_test/`, which no target covered: 75 files out of 210 followed no standard, and only a file a diff reported was ever touched +- `script/reverse_proxy/main.py` — a development reverse proxy for Odoo 18 without nginx: one address sends pages to the web port and `/websocket` to the bus port, with the `X-Forwarded-*` headers of `proxy_mode` set by the proxy alone (`--trust-forwarded` extends a chain instead, behind another proxy). Only the request head is read, so an upgraded WebSocket stays open and a gzip or chunked response passes byte for byte. It serves HTTPS with `--tls-cert`/`--tls-key`, logs one line per request (route, status, duration; `--quiet` silences it), says at start which Odoo port does not answer, and names that port in a 502. A request head waits 30 s (408) and the connection to Odoo 10 s (504), never an open WebSocket. Listens on `127.0.0.1:8080` by default, `--listen 0.0.0.0` opens it to the network; one request per connection, production keeps nginx +- `script/reverse_proxy/local_cert.py` issues a local authority, imported once in the browser, and a server certificate it signs for localhost, the host name and its addresses, under `~/.erplibre/reverse_proxy_tls/` with keys in 0600; the authority is kept when the certificate is issued again +- `TODO › Execute › Network › Odoo reverse proxy` starts it with the web and bus ports read from `config.conf`, asks for this machine only or the whole network and for HTTP or HTTPS, issues the local certificate on first HTTPS use, and warns when `proxy_mode` is off or `workers` is 0 — without a worker no bus port listens and `/websocket` fails. `Network › Local TLS certificates` issues it again with extra names ## Changed diff --git a/script/reverse_proxy/local_cert.py b/script/reverse_proxy/local_cert.py new file mode 100755 index 0000000..acd47f3 --- /dev/null +++ b/script/reverse_proxy/local_cert.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Certificats TLS locaux pour tester le mandataire inverse en HTTPS. + +Deux étages, comme un vrai déploiement : une autorité locale (ca.crt), à +importer UNE fois dans le navigateur, et un certificat serveur qu'elle signe +pour les noms et adresses de ce poste. L'autorité se garde d'une émission à +l'autre, si bien qu'ajouter un nom ne fait pas réapparaître l'alerte du +navigateur ; seul le certificat serveur est refait. + +Le répertoire est en 0700 et les clés en 0600 : une autorité importée peut +signer pour n'importe quel nom, et quiconque lit ca.key peut se faire +passer pour n'importe quel site auprès de ce navigateur. + +Tout passe par la commande openssl, présente sur les systèmes pris en +charge ; aucune dépendance Python n'est ajoutée. +""" + +import argparse +import ipaddress +import os +import socket +import subprocess +import sys +import tempfile + +DEFAULT_DIR = os.path.join( + os.path.expanduser("~/.erplibre"), "reverse_proxy_tls" +) + +CA_DAYS = 3650 +# Les navigateurs refusent un certificat serveur valide plus de 398 jours. +SERVER_DAYS = 397 + + +def paths(directory): + """Les quatre fichiers du répertoire, par rôle.""" + return { + "ca_crt": os.path.join(directory, "ca.crt"), + "ca_key": os.path.join(directory, "ca.key"), + "server_crt": os.path.join(directory, "server.crt"), + "server_key": os.path.join(directory, "server.key"), + } + + +def exists(directory): + """Vrai quand le certificat serveur et sa clé sont en place.""" + p = paths(directory) + return os.path.isfile(p["server_crt"]) and os.path.isfile(p["server_key"]) + + +def default_names(): + """localhost, les boucles locales, le nom d'hôte et ses adresses IPv4. + + Un nom qui ne se résout pas est simplement omis : le certificat reste + valide pour les autres. + """ + names = ["localhost", "127.0.0.1", "::1"] + host = socket.gethostname() + if host: + names.append(host) + try: + infos = socket.getaddrinfo(host, None, socket.AF_INET) + except OSError: + infos = [] + for info in infos: + names.append(info[4][0]) + try: + out = subprocess.run( + ["ip", "-o", "-4", "addr", "show"], + capture_output=True, + text=True, + timeout=5, + ).stdout + except (OSError, subprocess.SubprocessError): + out = "" + for line in out.splitlines(): + parts = line.split() + if "inet" in parts: + names.append(parts[parts.index("inet") + 1].split("/")[0]) + unique = [] + for name in names: + if name not in unique: + unique.append(name) + return unique + + +def _san(names): + """subjectAltName : IP pour une adresse, DNS pour un nom.""" + entries = [] + for name in names: + try: + ipaddress.ip_address(name) + entries.append(f"IP:{name}") + except ValueError: + entries.append(f"DNS:{name}") + return ",".join(entries) + + +def _openssl(*args): + subprocess.run( + ["openssl", *args], check=True, capture_output=True, text=True + ) + + +def _private(path): + os.chmod(path, 0o600) + + +def issue(directory, names): + """Émet le certificat serveur pour `names`, et l'autorité s'il le faut. + + :param directory: répertoire des certificats, créé en 0700 au besoin + :param names: noms DNS et adresses IP que le certificat doit couvrir + :return: paths(directory) + :raises subprocess.CalledProcessError: openssl a refusé une étape + """ + os.makedirs(directory, mode=0o700, exist_ok=True) + os.chmod(directory, 0o700) + p = paths(directory) + if not (os.path.isfile(p["ca_crt"]) and os.path.isfile(p["ca_key"])): + _openssl( + "req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", p["ca_key"], "-out", p["ca_crt"], + "-days", str(CA_DAYS), + "-subj", "/CN=ERPLibre local test CA", + "-addext", "basicConstraints=critical,CA:TRUE,pathlen:0", + "-addext", "keyUsage=critical,keyCertSign,cRLSign", + ) # fmt: skip + _private(p["ca_key"]) + with tempfile.TemporaryDirectory() as tmp: + csr = os.path.join(tmp, "server.csr") + ext = os.path.join(tmp, "server.ext") + with open(ext, "w", encoding="utf-8") as f: + f.write( + "basicConstraints=critical,CA:FALSE\n" + "keyUsage=critical,digitalSignature,keyEncipherment\n" + "extendedKeyUsage=serverAuth\n" + f"subjectAltName={_san(names)}\n" + ) + _openssl( + "req", "-newkey", "rsa:2048", "-nodes", + "-keyout", p["server_key"], "-out", csr, + "-subj", f"/CN={names[0]}", + ) # fmt: skip + _private(p["server_key"]) + _openssl( + "x509", "-req", "-in", csr, + "-CA", p["ca_crt"], "-CAkey", p["ca_key"], "-CAcreateserial", + "-out", p["server_crt"], "-days", str(SERVER_DAYS), + "-extfile", ext, + ) # fmt: skip + serial = os.path.join(directory, "ca.srl") + if os.path.exists(serial): + _private(serial) + return p + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=( + "Certificats TLS locaux pour tester le mandataire inverse en" + " HTTPS : une autorité à importer dans le navigateur, et un" + " certificat serveur qu'elle signe." + ) + ) + parser.add_argument("--dir", default=DEFAULT_DIR) + parser.add_argument( + "--name", + action="append", + help="Nom ou adresse de plus à couvrir ; répétable.", + ) + args = parser.parse_args(argv) + names = default_names() + [ + n for n in (args.name or []) if n not in default_names() + ] + p = issue(args.dir, names) + print(f"Autorité : {p['ca_crt']}") + print(f"Serveur : {p['server_crt']}") + print(f"Clé : {p['server_key']}") + print(f"Noms : {', '.join(names)}") + print( + "Importer l'autorité une fois dans le navigateur (Firefox :" + " Paramètres › Certificats › Autorités › Importer)." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/reverse_proxy/main.py b/script/reverse_proxy/main.py new file mode 100755 index 0000000..14febe8 --- /dev/null +++ b/script/reverse_proxy/main.py @@ -0,0 +1,541 @@ +#!/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()) diff --git a/script/todo/todo.py b/script/todo/todo.py index 9ecb717..1f31afc 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5398,6 +5398,16 @@ class TODO( "VPN - Tunnels (L2TP/IPsec, WireGuard, OpenVPN...)" ) }, + { + "prompt_description": t( + "Odoo reverse proxy (pages and websocket on one port)" + ) + }, + { + "prompt_description": t( + "Local TLS certificates for testing (HTTPS)" + ) + }, ] help_info = self.fill_help_info(choices) @@ -5412,6 +5422,10 @@ class TODO( self.generate_network_performance_test() elif status == "3": self.prompt_execute_vpn() + elif status == "4": + self.network_reverse_proxy() + elif status == "5": + self.network_local_certificates() else: print(t("Command not found !")) @@ -5439,6 +5453,99 @@ class TODO( single_source_erplibre=True, ) + def network_reverse_proxy( + self, config_path="./config.conf", cert_dir=None + ): + """Lance script/reverse_proxy/main.py avec les ports de config_path. + + Demande l'écoute — la machine seule ou tout le réseau — et le + protocole. En HTTPS, le certificat local de cert_dir sert, émis au + premier usage. Signale ensuite les réglages d'Odoo sans lesquels le + mandataire ne sert à rien : proxy_mode (Odoo ignore sinon les + en-têtes X-Forwarded-*) et workers (à 0, aucun port de bus n'écoute + et /websocket échoue). Le mandataire tourne au premier plan ; Ctrl+C + le rend au menu. + """ + from script.reverse_proxy import local_cert + from script.reverse_proxy.main import read_odoo_config + + cert_dir = cert_dir or local_cert.DEFAULT_DIR + odoo = read_odoo_config(config_path) + print(f"\n{t('Listen on:')}") + print(f" [1] {t('Local only (127.0.0.1)')} *") + print(f" [2] {t('Whole network (0.0.0.0)')}") + choice = input(t("Choice (1-2, default 1): ")).strip() + listen = "0.0.0.0" if choice == "2" else "127.0.0.1" + print(f"\n{t('Protocol:')}") + print(" [1] HTTP *") + print(f" [2] HTTPS ({t('local certificate')})") + https = input(t("Choice (1-2, default 1): ")).strip() == "2" + tls = "" + if https: + files = local_cert.paths(cert_dir) + if not local_cert.exists(cert_dir): + print(t("No local certificate yet: issuing one.")) + local_cert.issue(cert_dir, local_cert.default_names()) + print( + f"{t('Authority to import in the browser:')} {files['ca_crt']}" + ) + tls = ( + f" --tls-cert {files['server_crt']}" + f" --tls-key {files['server_key']}" + ) + if listen == "0.0.0.0": + reachable = t( + "The proxy is reachable by every machine on the network." + ) + print(f"⚠️ {reachable}") + print( + f"{t('Ports read from')} {config_path} : web" + f" {odoo['web_port']}, bus {odoo['websocket_port']}" + ) + if not odoo["proxy_mode"]: + missing = t("is missing: Odoo ignores the X-Forwarded-* headers.") + print(f"⚠️ proxy_mode = True {missing}") + if odoo["workers"] < 1: + no_bus = t("no bus port listens, /websocket will fail.") + print(f"⚠️ workers = {odoo['workers']} : {no_bus}") + print(t("Ctrl+C stops the proxy.")) + self.execute.exec_command_live( + "./script/reverse_proxy/main.py" + f" --listen {listen}" + f" --web-port {odoo['web_port']}" + f" --websocket-port {odoo['websocket_port']}" + tls, + source_erplibre=False, + ) + + def network_local_certificates(self, cert_dir=None): + """Émet le certificat local du mandataire, et son autorité au besoin. + + Couvre localhost, les boucles locales, le nom d'hôte et ses adresses, + plus les noms saisis. L'autorité existante est gardée : déjà importée + dans le navigateur, elle y reste valable. + """ + from script.reverse_proxy import local_cert + + cert_dir = cert_dir or local_cert.DEFAULT_DIR + names = local_cert.default_names() + print(f"{t('Names covered:')} {', '.join(names)}") + extra = input( + t("Other names or addresses, comma separated (Enter: none): ") + ) + for name in (n.strip() for n in extra.split(",")): + if name and name not in names: + names.append(name) + files = local_cert.issue(cert_dir, names) + print(f"{t('Authority to import in the browser:')} {files['ca_crt']}") + print(f"{t('Server certificate:')} {files['server_crt']}") + print( + t( + "Import the authority once (Firefox: Settings › Certificates" + " › Authorities › Import); a new server certificate needs no" + " new import." + ) + ) + def prompt_execute_security(self): print(f"🤖 {t('Dependency security audit!')}") choices = [ diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 4a0d5a8..012b60d 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -13362,6 +13362,79 @@ TRANSLATIONS = { "fr": "🚇 VPN - Tunnels (L2TP/IPsec, WireGuard, OpenVPN...)", "en": "🚇 VPN - Tunnels (L2TP/IPsec, WireGuard, OpenVPN...)", }, + # Network — reverse proxy + "Odoo reverse proxy (pages and websocket on one port)": { + "fr": "🔀 Mandataire inverse Odoo (pages et websocket sur un port)", + "en": "🔀 Odoo reverse proxy (pages and websocket on one port)", + }, + "Listen on:": { + "fr": "Écoute :", + "en": "Listen on:", + }, + "Local only (127.0.0.1)": { + "fr": "🏠 Cette machine seulement (127.0.0.1)", + "en": "🏠 This machine only (127.0.0.1)", + }, + "Whole network (0.0.0.0)": { + "fr": "🌐 Tout le réseau (0.0.0.0)", + "en": "🌐 Whole network (0.0.0.0)", + }, + "The proxy is reachable by every machine on the network.": { + "fr": "Le mandataire est joignable par toute machine du réseau.", + "en": "The proxy is reachable by every machine on the network.", + }, + "Ports read from": { + "fr": "Ports lus dans", + "en": "Ports read from", + }, + "is missing: Odoo ignores the X-Forwarded-* headers.": { + "fr": "manque : Odoo ignore les en-têtes X-Forwarded-*.", + "en": "is missing: Odoo ignores the X-Forwarded-* headers.", + }, + "no bus port listens, /websocket will fail.": { + "fr": "aucun port de bus n'écoute, /websocket échouera.", + "en": "no bus port listens, /websocket will fail.", + }, + "Ctrl+C stops the proxy.": { + "fr": "Ctrl+C arrête le mandataire.", + "en": "Ctrl+C stops the proxy.", + }, + "Local TLS certificates for testing (HTTPS)": { + "fr": "🔐 Certificats TLS locaux pour les tests (HTTPS)", + "en": "🔐 Local TLS certificates for testing (HTTPS)", + }, + "Protocol:": { + "fr": "Protocole :", + "en": "Protocol:", + }, + "local certificate": { + "fr": "certificat local", + "en": "local certificate", + }, + "No local certificate yet: issuing one.": { + "fr": "Aucun certificat local : émission d'un certificat.", + "en": "No local certificate yet: issuing one.", + }, + "Authority to import in the browser:": { + "fr": "Autorité à importer dans le navigateur :", + "en": "Authority to import in the browser:", + }, + "Server certificate:": { + "fr": "Certificat serveur :", + "en": "Server certificate:", + }, + "Names covered:": { + "fr": "Noms couverts :", + "en": "Names covered:", + }, + "Other names or addresses, comma separated (Enter: none): ": { + "fr": "Autres noms ou adresses, séparés par des virgules (Entrée : aucun) : ", + "en": "Other names or addresses, comma separated (Enter: none): ", + }, + "Import the authority once (Firefox: Settings › Certificates › Authorities › Import); a new server certificate needs no new import.": { + "fr": "Importer l'autorité une seule fois (Firefox : Paramètres › Certificats › Autorités › Importer) ; un nouveau certificat serveur n'exige aucun nouvel import.", + "en": "Import the authority once (Firefox: Settings › Certificates › Authorities › Import); a new server certificate needs no new import.", + }, "VPN tunnels: connect, profiles, vault secrets": { "fr": "Tunnels VPN : connexion, profils, secrets du coffre", "en": "VPN tunnels: connect, profiles, vault secrets", diff --git a/test/test_reverse_proxy.py b/test/test_reverse_proxy.py new file mode 100644 index 0000000..d39bd75 --- /dev/null +++ b/test/test_reverse_proxy.py @@ -0,0 +1,612 @@ +#!/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 os +import shutil +import ssl +import tempfile +import unittest +from unittest.mock import patch + +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): + # Muet par défaut : le journal a ses propres tests. + options.setdefault("log", None) + 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 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"]) + + +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)) + + +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() diff --git a/test/test_reverse_proxy_local_cert.py b/test/test_reverse_proxy_local_cert.py new file mode 100644 index 0000000..b2ccebc --- /dev/null +++ b/test/test_reverse_proxy_local_cert.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Les certificats locaux du mandataire : une autorité, un certificat serveur. + +Les fichiers sont réellement produits par openssl dans un répertoire +temporaire, puis relus par openssl et par le module ssl de Python. +""" + +import os +import shutil +import ssl +import stat +import subprocess +import tempfile +import unittest + +from script.reverse_proxy import local_cert + + +@unittest.skipUnless(shutil.which("openssl"), "openssl absent") +class TestCertificatsLocaux(unittest.TestCase): + def setUp(self): + self.dossier = os.path.join(tempfile.mkdtemp(), "tls") + self.addCleanup(shutil.rmtree, os.path.dirname(self.dossier)) + + def texte(self, crt): + return subprocess.run( + ["openssl", "x509", "-in", crt, "-noout", "-text"], + capture_output=True, + text=True, + check=True, + ).stdout + + def test_le_serveur_est_signe_par_l_autorite(self): + chemins = local_cert.issue(self.dossier, ["localhost", "127.0.0.1"]) + contexte = ssl.create_default_context(cafile=chemins["ca_crt"]) + # Le chargement échoue si la clé ne correspond pas au certificat. + serveur = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + serveur.load_cert_chain(chemins["server_crt"], chemins["server_key"]) + verif = subprocess.run( + [ + "openssl", + "verify", + "-CAfile", + chemins["ca_crt"], + chemins["server_crt"], + ], + capture_output=True, + text=True, + ) + self.assertEqual(verif.returncode, 0, verif.stdout + verif.stderr) + self.assertIsNotNone(contexte) + + def test_les_noms_vont_dans_le_subject_alt_name(self): + chemins = local_cert.issue( + self.dossier, ["localhost", "127.0.0.1", "odoo.test", "::1"] + ) + texte = self.texte(chemins["server_crt"]) + for attendu in ( + "DNS:localhost", + "DNS:odoo.test", + "IP Address:127.0.0.1", + "IP Address:0:0:0:0:0:0:0:1", + ): + self.assertIn(attendu, texte) + self.assertIn("TLS Web Server Authentication", texte) + + def test_les_cles_ne_sont_lisibles_que_par_leur_proprietaire(self): + chemins = local_cert.issue(self.dossier, ["localhost"]) + for cle in (chemins["ca_key"], chemins["server_key"]): + mode = stat.S_IMODE(os.stat(cle).st_mode) + self.assertEqual(mode, 0o600, f"{cle} en {oct(mode)}") + mode = stat.S_IMODE(os.stat(self.dossier).st_mode) + self.assertEqual(mode, 0o700) + + def test_l_autorite_se_garde_d_une_emission_a_l_autre(self): + # Importée une fois dans le navigateur, elle doit rester la même : + # une nouvelle autorité ferait réapparaître l'alerte. + premier = local_cert.issue(self.dossier, ["localhost"]) + with open(premier["ca_crt"], "rb") as f: + avant = f.read() + second = local_cert.issue(self.dossier, ["localhost", "odoo.test"]) + with open(second["ca_crt"], "rb") as f: + self.assertEqual(f.read(), avant) + self.assertIn("DNS:odoo.test", self.texte(second["server_crt"])) + + def test_l_existence_se_constate(self): + self.assertFalse(local_cert.exists(self.dossier)) + local_cert.issue(self.dossier, ["localhost"]) + self.assertTrue(local_cert.exists(self.dossier)) + + +class TestNomsParDefaut(unittest.TestCase): + def test_la_boucle_locale_y_est_toujours(self): + noms = local_cert.default_names() + for attendu in ("localhost", "127.0.0.1", "::1"): + self.assertIn(attendu, noms) + self.assertEqual(len(noms), len(set(noms))) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_todo_reverse_proxy_menu.py b/test/test_todo_reverse_proxy_menu.py new file mode 100644 index 0000000..6b55c09 --- /dev/null +++ b/test/test_todo_reverse_proxy_menu.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""L'entrée « mandataire inverse » du menu Network de TODO.""" + +import os +import shutil +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +from script.todo.todo import TODO + + +class TestMenuNetwork(unittest.TestCase): + def test_l_option_4_lance_le_mandataire(self): + """Les choix du menu et ses branches sont tenus à la main : [4] doit + afficher le mandataire ET appeler sa méthode.""" + todo = TODO() + with ( + patch.object(TODO, "network_reverse_proxy") as mock_rp, + patch("click.prompt", side_effect=["4", "0"]) as mock_prompt, + patch("script.todo.todo_telemetry.record"), + ): + todo.prompt_execute_network() + mock_rp.assert_called_once_with() + self.assertIn("[4]", mock_prompt.call_args_list[0].args[0]) + + +class TestMenuCertificats(unittest.TestCase): + def test_l_option_5_genere_les_certificats(self): + todo = TODO() + with ( + patch.object(TODO, "network_local_certificates") as mock_cert, + patch("click.prompt", side_effect=["5", "0"]) as mock_prompt, + patch("script.todo.todo_telemetry.record"), + ): + todo.prompt_execute_network() + mock_cert.assert_called_once_with() + self.assertIn("[5]", mock_prompt.call_args_list[0].args[0]) + + @unittest.skipUnless(shutil.which("openssl"), "openssl absent") + def test_les_noms_saisis_s_ajoutent_aux_noms_par_defaut(self): + d = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, d) + dossier = os.path.join(d, "tls") + with ( + patch("builtins.input", return_value="odoo.test, 10.0.0.9"), + patch("builtins.print"), + ): + TODO().network_local_certificates(cert_dir=dossier) + texte = subprocess.run( + [ + "openssl", + "x509", + "-in", + os.path.join(dossier, "server.crt"), + "-noout", + "-text", + ], + capture_output=True, + text=True, + check=True, + ).stdout + for attendu in ( + "DNS:odoo.test", + "IP Address:10.0.0.9", + "DNS:localhost", + ): + self.assertIn(attendu, texte) + + +class TestLancement(unittest.TestCase): + def config(self, contenu): + f = tempfile.NamedTemporaryFile( + "w", suffix=".conf", delete=False, encoding="utf-8" + ) + f.write(contenu) + f.close() + self.addCleanup(os.unlink, f.name) + return f.name + + def lancer(self, reponse, contenu, protocole="1", cert_dir=None): + todo = TODO() + with ( + patch("builtins.input", side_effect=[reponse, protocole]), + patch.object(todo.execute, "exec_command_live") as mock_exec, + patch("builtins.print") as mock_print, + ): + todo.network_reverse_proxy( + config_path=self.config(contenu), + cert_dir=cert_dir or self.dossier_vide(), + ) + imprime = " ".join( + str(a) for c in mock_print.call_args_list for a in c.args + ) + return mock_exec.call_args.args[0], imprime + + def test_local_par_defaut_avec_les_ports_du_config(self): + cmd, _ = self.lancer( + "", + "[options]\nhttp_port = 9069\ngevent_port = 9072\n" + "proxy_mode = True\nworkers = 2\n", + ) + self.assertIn("./script/reverse_proxy/main.py", cmd) + self.assertIn("--listen 127.0.0.1", cmd) + self.assertIn("--web-port 9069", cmd) + self.assertIn("--websocket-port 9072", cmd) + + def test_reseau_ecoute_sur_toutes_les_interfaces(self): + cmd, _ = self.lancer( + "2", "[options]\nproxy_mode = True\nworkers = 2\n" + ) + self.assertIn("--listen 0.0.0.0", cmd) + + def test_les_reglages_manquants_sont_signales(self): + # Sans proxy_mode Odoo ignore X-Forwarded-*, sans workers le bus + # (8072) n'existe pas : le mandataire n'y changerait rien. + _, imprime = self.lancer("1", "[options]\nworkers = 0\n") + self.assertIn("proxy_mode", imprime) + self.assertIn("workers", imprime) + + def dossier_vide(self): + d = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, d) + return os.path.join(d, "tls") + + def test_http_par_defaut_sans_certificat(self): + cmd, _ = self.lancer("", "[options]\nproxy_mode = True\nworkers = 2\n") + self.assertNotIn("--tls-cert", cmd) + + @unittest.skipUnless(shutil.which("openssl"), "openssl absent") + def test_https_genere_le_certificat_manquant_et_le_passe(self): + dossier = self.dossier_vide() + cmd, imprime = self.lancer( + "", + "[options]\nproxy_mode = True\nworkers = 2\n", + protocole="2", + cert_dir=dossier, + ) + self.assertIn(f"--tls-cert {dossier}/server.crt", cmd) + self.assertIn(f"--tls-key {dossier}/server.key", cmd) + self.assertTrue(os.path.isfile(os.path.join(dossier, "ca.crt"))) + self.assertIn("ca.crt", imprime) + + def test_https_reprend_le_certificat_existant(self): + dossier = self.dossier_vide() + with patch( + "script.reverse_proxy.local_cert.exists", return_value=True + ), patch("script.reverse_proxy.local_cert.issue") as mock_issue: + cmd, _ = self.lancer( + "", + "[options]\nproxy_mode = True\nworkers = 2\n", + protocole="2", + cert_dir=dossier, + ) + mock_issue.assert_not_called() + self.assertIn("--tls-cert", cmd) + + def test_rien_n_est_signale_quand_tout_est_regle(self): + _, imprime = self.lancer( + "1", "[options]\nproxy_mode = True\nworkers = 2\n" + ) + self.assertNotIn("proxy_mode", imprime) + + +if __name__ == "__main__": + unittest.main()