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
192 lines
6.1 KiB
Python
Executable file
192 lines
6.1 KiB
Python
Executable file
#!/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())
|