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:
commit
6bd73c2a7e
10 changed files with 1811 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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é
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
192
script/reverse_proxy/local_cert.py
Executable file
192
script/reverse_proxy/local_cert.py
Executable 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
541
script/reverse_proxy/main.py
Executable 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())
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
612
test/test_reverse_proxy.py
Normal 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()
|
||||
104
test/test_reverse_proxy_local_cert.py
Normal file
104
test/test_reverse_proxy_local_cert.py
Normal 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()
|
||||
170
test/test_todo_reverse_proxy_menu.py
Normal file
170
test/test_todo_reverse_proxy_menu.py
Normal 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()
|
||||
Loading…
Reference in a new issue