erplibre/script/reverse_proxy/local_cert.py

193 lines
6.1 KiB
Python
Raw Permalink Normal View History

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