đ
Publié en avril 2025
đ Mots-clĂ©s SEO : cert-manager, Letâs Encrypt, Apache, Kubernetes, certificat SSL automatique, reverse proxy, TLS, automatisation
Table of Contents
đ Introduction
La sĂ©curitĂ© des services web repose en grande partie sur lâusage de certificats TLS/SSL. Dans Kubernetes, cert-manager permet de gĂ©nĂ©rer et renouveler des certificats automatiquement via Letâs Encrypt.
Mais lorsque lâon utilise un reverse proxy Apache en dehors du cluster, il est nĂ©cessaire de rĂ©cupĂ©rer et dâintĂ©grer ces certificats dans la configuration Apache. Cet article explique comment automatiser cette synchronisation grĂące Ă un script Python personnalisable.
đŻ Objectifs
- â RĂ©cupĂ©rer les certificats depuis Kubernetes via lâAPI
- â VĂ©rifier leur expiration pour limiter les traitements inutiles
- â GĂ©nĂ©rer dynamiquement des VirtualHosts Apache avec support HTTPS
- â Recharger Apache uniquement en cas de changement dĂ©tectĂ©
1ïžâŁ Donner accĂšs Ă lâAPI Kubernetes
đ§ Manifeste Kubernetes Ă appliquer (certificates.yaml
)
apiVersion: v1
kind: ServiceAccount
metadata:
name: cert-reader-sa
namespace: your-namespace
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: cert-reader-role
namespace: your-namespace
rules:
- apiGroups: ["cert-manager.io"]
resources: ["certificates"]
verbs: ["get", "list"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: cert-reader-binding
namespace: your-namespace
subjects:
- kind: ServiceAccount
name: cert-reader-sa
roleRef:
kind: Role
name: cert-reader-role
apiGroup: rbac.authorization.k8s.io
---
apiVersion: v1
kind: Secret
metadata:
name: cert-reader-token
annotations:
kubernetes.io/service-account.name: "cert-reader-sa"
type: kubernetes.io/service-account-token
Remplace
your-namespace
par le nom réel du namespace contenant tes certificats.
đ Commandes pour rĂ©cupĂ©rer les accĂšs
# RĂ©cupĂ©rer lâURL de lâAPI Kubernetes
kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}'
# RĂ©cupĂ©rer le token dâaccĂšs
kubectl get secret cert-reader-token -n your-namespace -o jsonpath='{.data.token}' | base64 --decode
2ïžâŁ Principe de fonctionnement du script Python
Le script sync_apache_certs_vhosts.py
:
- RécupÚre les objets
Certificate
depuis Kubernetes - RécupÚre les
Secrets
TLS associés - Vérifie si les certificats existants sur disque expirent bientÎt
- GénÚre automatiquement les VirtualHosts Apache
- Décommente le bloc HTTPS une fois le certificat valide
- Recharge Apache si nécessaire
Il dĂ©tecte Ă©galement lâannotation personnalisĂ©e suivante pour dĂ©finir dynamiquement le backend dâun reverse proxy :
annotations:
apache-proxy-backend: "https://backend.monapp.com/"
đ ïž Exemple de certificat Kubernetes
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: my-cert
namespace: your-namespace
annotations:
apache-proxy-backend: "https://backend.monapp.com/"
spec:
dnsNames:
- monapp.mondomaine.com
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
secretName: my-cert-secret
âïž Exemple de VirtualHost Apache gĂ©nĂ©rĂ©
<VirtualHost *:80>
ServerName monapp.mondomaine.com
<Location /.well-known/acme-challenge/ >
ProxyPreserveHost On
RequestHeader set Host "monapp.mondomaine.com"
ProxyPass http://ingress-k8s/.well-known/acme-challenge/
ProxyPassReverse http://ingress-k8s/.well-known/acme-challenge/
</Location>
RewriteEngine On
RewriteCond %{REQUEST_URI} !^/.well-known/acme-challenge/ [NC]
RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L]
</VirtualHost>
<VirtualHost *:443>
ServerName monapp.mondomaine.com
SSLEngine on
SSLCertificateFile /etc/apache2/certs/monapp_mondomaine_com/monapp_mondomaine_com.crt
SSLCertificateKeyFile /etc/apache2/certs/monapp_mondomaine_com/monapp_mondomaine_com.key
ProxyPreserveHost On
ProxyPass / https://backend.monapp.com/
ProxyPassReverse / https://backend.monapp.com/
SSLProxyEngine On
SSLProxyVerify none
SSLProxyCheckPeerCN off
SSLProxyCheckPeerName off
</VirtualHost>
đ Automatiser lâexĂ©cution avec cron
Ajoute une tùche cron pour exécuter le script chaque heure :
echo '0 * * * * root /usr/bin/python3 /usr/local/bin/sync_apache_certs_vhosts.py' > /etc/cron.d/sync_apache_certs_vhosts
systemctl restart cron
VĂ©rifie lâexĂ©cution via les logs :
grep sync_apache_certs_vhosts /var/log/syslog
â RĂ©sumĂ©
- đ Certificats Letâs Encrypt gĂ©rĂ©s automatiquement via cert-manager
- đ IntĂ©gration continue avec Apache sans intervention manuelle
- đĄïž VirtualHosts HTTPS configurĂ©s et activĂ©s dynamiquement
- âïž Apache rechargĂ© uniquement si une modification est dĂ©tectĂ©e
đ Annexe : Script Python complet sync_apache_certs_vhosts.py
#!/usr/bin/env python3
import os
import json
import base64
import requests
import subprocess
import re
from datetime import datetime, timedelta
# Variables Ă adapter
KUBE_API_SERVER = "https://your-k8s-api-server:6443"
KUBE_TOKEN = "TON_TOKEN_K8S_ICI"
NAMESPACE = "your-namespace"
CERT_BASE_DIR = "/etc/apache2/certs/"
VHOST_DIR = "/etc/apache2/sites-available/"
APACHE_TEST_CMD = "apachectl configtest"
APACHE_RELOAD_CMD = "systemctl reload apache2"
APACHE_ENABLE_SITE_CMD = "a2ensite"
DOMAIN_SUFFIX = ".mondomaine.com"
EXPIRATION_THRESHOLD = 72 # heures
RECENT_THRESHOLD_HOURS = 2
HEADERS = {
"Authorization": f"Bearer {KUBE_TOKEN}",
"Accept": "application/json"
}
REQUESTS_VERIFY = False
def format_name(domain):
return domain.replace(".", "_")
def is_cert_expiring(cert_path):
if not os.path.exists(cert_path):
print(f"â ïž Certificat absent ({cert_path}), renouvellement nĂ©cessaire.")
return True
try:
result = subprocess.run(
["openssl", "x509", "-enddate", "-noout", "-in", cert_path],
capture_output=True,
text=True,
check=True
)
expiry_str = result.stdout.strip().split("=")[-1]
expiry_date = datetime.strptime(expiry_str, "%b %d %H:%M:%S %Y %Z")
time_remaining = expiry_date - datetime.utcnow()
if time_remaining < timedelta(hours=EXPIRATION_THRESHOLD):
print(f"â ïž Expiration proche : {time_remaining}")
return True
else:
print(f"â
Certificat valide jusquâĂ {expiry_date}")
return False
except Exception as e:
print(f"â Erreur vĂ©rif cert : {e}")
return True
def get_certificates():
url = f"{KUBE_API_SERVER}/apis/cert-manager.io/v1/namespaces/{NAMESPACE}/certificates"
response = requests.get(url, headers=HEADERS, verify=REQUESTS_VERIFY)
if response.status_code != 200:
print(f"â Erreur API : {response.status_code} - {response.text}")
return []
certs = response.json().get("items", [])
filtered = []
for cert in certs:
dns_names = cert["spec"].get("dnsNames", [])
valid_dns = [dns for dns in dns_names if dns.endswith(DOMAIN_SUFFIX) and dns.count('.') == DOMAIN_SUFFIX.count('.')]
if valid_dns:
cert["filtered_dns_names"] = valid_dns
filtered.append(cert)
return filtered
def extract_cert(secret_name, domain):
url = f"{KUBE_API_SERVER}/api/v1/namespaces/{NAMESPACE}/secrets/{secret_name}"
response = requests.get(url, headers=HEADERS, verify=REQUESTS_VERIFY)
if response.status_code != 200:
print(f"â Erreur rĂ©cupĂ©ration Secret: {response.status_code}")
return None, None
secret = response.json()["data"]
domain_safe = format_name(domain)
cert_dir = os.path.join(CERT_BASE_DIR, domain_safe)
cert_path = os.path.join(cert_dir, f"{domain_safe}.crt")
key_path = os.path.join(cert_dir, f"{domain_safe}.key")
if not is_cert_expiring(cert_path):
return None, None
os.makedirs(cert_dir, exist_ok=True)
with open(cert_path, "wb") as f:
f.write(base64.b64decode(secret["tls.crt"]))
with open(key_path, "wb") as f:
f.write(base64.b64decode(secret["tls.key"]))
return cert_path, key_path
def generate_vhost(domain, backend=None, cert_path=None, key_path=None):
domain_safe = format_name(domain)
vhost_path = os.path.join(VHOST_DIR, f"{domain_safe}.conf")
final_cert_path = os.path.join(CERT_BASE_DIR, domain_safe, f"{domain_safe}.crt")
final_key_path = os.path.join(CERT_BASE_DIR, domain_safe, f"{domain_safe}.key")
if os.path.exists(vhost_path):
print(f"â ïž VHost {vhost_path} existe dĂ©jĂ .")
return None
with open(vhost_path, "w") as f:
f.write(f"""<VirtualHost *:80>
ServerName {domain}
<Location /.well-known/acme-challenge/ >
ProxyPreserveHost On
RequestHeader set Host "{domain}"
ProxyPass http://ingress/.well-known/acme-challenge/
ProxyPassReverse http://ingress/.well-known/acme-challenge/
</Location>
RewriteEngine On
RewriteCond %{{REQUEST_URI}} !^/.well-known/acme-challenge/ [NC]
RewriteRule ^(.*)$ https://%{{HTTP_HOST}}$1 [R=301,L]
</VirtualHost>
# <VirtualHost *:443>
# ServerName {domain}
# SSLEngine on
# SSLCertificateFile {final_cert_path}
# SSLCertificateKeyFile {final_key_path}
""")
if backend:
f.write(f"""# ProxyPreserveHost On
# ProxyPass / {backend}
# ProxyPassReverse / {backend}
""")
if backend.startswith("https://"):
f.write("""# SSLProxyEngine On
# SSLProxyVerify none
# SSLProxyCheckPeerCN off
# SSLProxyCheckPeerName off
""")
f.write("# </VirtualHost>\n")
subprocess.run([APACHE_ENABLE_SITE_CMD, f"{domain_safe}.conf"], check=True)
print(f"â
VHost généré et activé : {vhost_path}")
return vhost_path
def activate_https_in_vhost(vhost_path):
with open(vhost_path, "r") as f:
content = f.read()
pattern = r"(?ms)#\s*<VirtualHost \*:443>.*?#\s*</VirtualHost>"
def uncomment_block(match):
block = match.group(0)
return re.sub(r"(?m)^#\s?", "", block)
new_content, count = re.subn(pattern, uncomment_block, content)
if count > 0:
with open(vhost_path, "w") as f:
f.write(new_content)
print(f"â
HTTPS activé dans {vhost_path}.")
else:
print("âčïž Aucun bloc HTTPS commentĂ© trouvĂ©.")
def is_cert_recent(cert_path, threshold_hours=RECENT_THRESHOLD_HOURS):
if not os.path.exists(cert_path):
return False
try:
result = subprocess.run(
["openssl", "x509", "-startdate", "-noout", "-in", cert_path],
capture_output=True,
text=True,
check=True
)
start_date_str = result.stdout.strip().split("=")[-1]
start_date = datetime.strptime(start_date_str, "%b %d %H:%M:%S %Y %Z")
age = datetime.utcnow() - start_date
return age < timedelta(hours=threshold_hours)
except Exception as e:
print(f"â Erreur rĂ©cence cert {cert_path}: {e}")
return False
def test_apache_config():
result = subprocess.run(APACHE_TEST_CMD, shell=True, capture_output=True, text=True)
return "Syntax OK" in result.stdout + result.stderr
def reload_apache():
if test_apache_config():
subprocess.run(APACHE_RELOAD_CMD, shell=True, check=True)
print("â
Apache rechargé.")
else:
print("â Erreur de config Apache. Pas de reload.")
def main():
certs = get_certificates()
if not certs:
print("â ïž Aucun certificat trouvĂ©.")
return
apache_needs_reload = False
for cert in certs:
domain = cert["filtered_dns_names"][0]
secret_name = cert["spec"]["secretName"]
backend = cert["metadata"].get("annotations", {}).get("apache-proxy-backend")
cert_ready = any(
cond["type"] == "Ready" and cond["status"] == "True"
for cond in cert.get("status", {}).get("conditions", [])
)
cert_path, key_path = None, None
if cert_ready:
cert_path, key_path = extract_cert(secret_name, domain)
if cert_path:
apache_needs_reload = True
vhost_path = generate_vhost(domain, backend, cert_path, key_path)
if vhost_path:
apache_needs_reload = True
if cert_ready and cert_path and is_cert_recent(cert_path):
activate_https_in_vhost(vhost_path)
apache_needs_reload = True
if apache_needs_reload:
reload_apache()
if __name__ == "__main__":
main()