diff --git a/script/reverse_proxy/local_cert.py b/script/reverse_proxy/local_cert.py new file mode 100755 index 0000000..acd47f3 --- /dev/null +++ b/script/reverse_proxy/local_cert.py @@ -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()) diff --git a/test/test_reverse_proxy_local_cert.py b/test/test_reverse_proxy_local_cert.py new file mode 100644 index 0000000..b2ccebc --- /dev/null +++ b/test/test_reverse_proxy_local_cert.py @@ -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()