erplibre/script/reverse_proxy/local_cert.py
Mathieu Benoit 9b76f4477c [ADD] reverse proxy: local CA and server certificate for HTTPS tests
Testing Odoo behind HTTPS needs a certificate the browser accepts,
WebSocket over wss included. local_cert.py issues, through the openssl
command, a local authority to import once and a server certificate it
signs for localhost, the loopbacks, the host name and its addresses.
The authority is kept across issues, so a new name needs no new import;
the directory is 0700 and the keys 0600, the authority being able to
sign for any name. Server certificates last 397 days, under the 398
that browsers accept.
Checked: 6 tests, openssl verify passes, SAN and key modes asserted.

--- FR ---

[ADD] reverse proxy : autorité locale et certificat pour tests HTTPS

Tester Odoo derrière HTTPS exige un certificat que le navigateur accepte,
WebSocket en wss comprise. local_cert.py émet, par la commande openssl,
une autorité locale à importer une fois et un certificat serveur qu'elle
signe pour localhost, les boucles locales, le nom d'hôte et ses adresses.
L'autorité est gardée d'une émission à l'autre : un nouveau nom n'exige
aucun nouvel import. Répertoire en 0700 et clés en 0600, l'autorité
pouvant signer pour tout nom. Certificats serveur de 397 jours, sous les
398 qu'acceptent les navigateurs.
Vérifié : 6 tests, openssl verify passe, SAN et droits des clés vérifiés.

Assisted-by: Claude Opus 5.5
2026-09-26 02:17:19 -04:00

192 lines
6.1 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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