Merge branch 'dev/odoo_reverse_proxy'

[ADD] reverse proxy: dev front for Odoo 18, HTTPS, started from TODO

6 commits. Opened on its web port, an Odoo running with workers fails to
bind its websocket, which only the bus port serves. The proxy routes both
from one address, sets X-Forwarded-* for proxy_mode and relays bytes
unchanged, so an upgraded WebSocket stays open; it serves HTTPS with a
local authority, logs each request, bounds waits except the relay, and
names the Odoo port that does not answer. TODO Network starts it from
config.conf and issues the certificates. Checked: 48 tests; against a
live Odoo 18 over HTTPS, a wss ping returns its pong.

--- FR ---

[ADD] reverse proxy : façade de dev pour Odoo 18, HTTPS, lancée de TODO

6 commits. Ouvert sur son port web, un Odoo à workers n'arrive pas à lier
sa websocket, que seul le port du bus sert. Le mandataire route les deux
depuis une adresse, pose les X-Forwarded-* de proxy_mode et relaie les
octets tels quels : une WebSocket montée reste ouverte ; il sert en HTTPS
avec une autorité locale, journalise chaque requête, borne les attentes
sauf le relais et nomme le port d'Odoo muet. TODO Network le lance depuis
config.conf et émet les certificats. Vérifié : 48 tests ; face à un Odoo
18 réel en HTTPS, un ping wss rend son pong.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-09-26 02:17:34 -04:00
commit 6bd73c2a7e
10 changed files with 1811 additions and 0 deletions

View file

@ -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
<!-- [fr] -->
@ -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
<!-- [en] -->
## Changed

View file

@ -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é

View file

@ -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

View file

@ -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())

541
script/reverse_proxy/main.py Executable file
View file

@ -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())

View file

@ -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 = [

View file

@ -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",

612
test/test_reverse_proxy.py Normal file
View file

@ -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()

View file

@ -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()

View file

@ -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()