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
104 lines
3.8 KiB
Python
104 lines
3.8 KiB
Python
#!/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()
|