expositions-etat : le certificat SERVI, pas celui du disque
Renommer une exposition touche cinq choses. Quatre suivent au deploiement ; la cinquieme est celle que le navigateur regarde. serveur_nginx pose le vhost et laisse le SAN en arriere — c est client_pki qui reemet, et rien ne le reclame. Mesure deux fois le 2026-09-10. Le controle compare trois listes : les expositions du plan, les noms du certificat servi, et le code que rend le vhost. Dans les deux sens : un nom reste dans le SAN apres avoir quitte le plan continue d etre authentifie. Piege trouve en l ecrivant : client_pki met toujours le FQDN et le nom court de la machine dans le SAN. Les compter comme vestiges faisait crier le controle a chaque execution — et un controle qui crie toujours ne se lit plus. Deux formes de terminaison, une seule question : le locataire termine sur un edge, le site sur la machine elle-meme. Le porteur se derive de l une ou de l autre. Ce n est pas une preuve : rien de statique ne peut lire un certificat servi. Les quatre controles a la demande n etaient nommes nulle part comme famille ; ils ont maintenant leur section dans les runbooks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
parent
5f5af70a9c
commit
989398f3cf
6 changed files with 315 additions and 4 deletions
46
CHANGELOG.md
46
CHANGELOG.md
|
|
@ -1,5 +1,51 @@
|
|||
# CHANGELOG — Set-OPS
|
||||
|
||||
## 2026-09-11 (3) — `make expositions-etat` : le certificat SERVI, pas celui du disque
|
||||
|
||||
Renommer une exposition touche cinq choses. Quatre suivent au deploiement ; la cinquieme,
|
||||
non — et c'est celle que le navigateur regarde.
|
||||
|
||||
serveur_powerdns la zone publie le nouveau nom OK
|
||||
serveur_keycloak le client OIDC accepte le retour OK
|
||||
le service il fabrique ses URL avec le bon nom OK (P67)
|
||||
serveur_nginx le vhost repond sur le nouveau nom OK
|
||||
client_pki le SAN du certificat porte le nom NON il faut le rejouer
|
||||
|
||||
Mesure DEUX FOIS le 2026-09-10, sur `grafana -> observatoire` puis `icinga -> vigie`. Le
|
||||
symptome est trompeur : le site repond, la page s'affiche, et c'est le NAVIGATEUR qui
|
||||
refuse — avec une erreur de certificat que personne ne relie a un renommage fait la veille.
|
||||
|
||||
### Il regarde dans les deux sens
|
||||
|
||||
Un nom RESTE dans le SAN apres avoir quitte le plan est un nom que le certificat continue
|
||||
d'authentifier. C'est exactement ce qu'avait laisse le premier renommage.
|
||||
|
||||
Le piege de ce second sens, trouve en l'ecrivant : `client_pki` met TOUJOURS le FQDN et le
|
||||
nom court de la machine dans le SAN. Les compter comme vestiges faisait crier le controle a
|
||||
chaque execution — et un controle qui crie toujours ne se lit plus.
|
||||
|
||||
### Deux formes de terminaison, une seule question
|
||||
|
||||
Le locataire termine son TLS sur un edge ; `sans_exposition` y designe le porteur. Le SITE
|
||||
n'a pas d'edge — `site-forge-01` ecoute lui-meme sur 443 — et la variable n'y existe donc
|
||||
pas. Le porteur se derive alors de l'application : son `hote` dit quelle machine la sert.
|
||||
|
||||
OPS-Chezlepro 6 expositions toutes servies, certificat conforme, rien en trop
|
||||
SITE 5 expositions INJOIGNABLE depuis ce poste
|
||||
|
||||
Le second resultat n'accuse pas le service, et le controle le dit : les zones du SITE ne
|
||||
sont pas routees depuis le plan d'administration du locataire. Le mur est la frontiere.
|
||||
|
||||
### Pourquoi pas une preuve
|
||||
|
||||
`make prouver` est STATIQUE — il lit le depot, zero appel reseau, et c'est ce qui le rend
|
||||
rejouable partout. Rien de statique ne peut lire un certificat SERVI : il faut ouvrir la
|
||||
connexion.
|
||||
|
||||
Les quatre controles a la demande n'etaient nommes nulle part comme famille —
|
||||
`routes-fabric-etat` n'apparaissait dans aucune documentation. Ils ont maintenant leur
|
||||
section (§8 des runbooks). Une garde que personne ne sait lancer ne sert a rien.
|
||||
|
||||
## 2026-09-11 (2) — La piste d'audit sort de la machine auditee
|
||||
|
||||
`auditd` tournait, onze regles etaient armees, et rien de tout cela ne servait a grand-chose.
|
||||
|
|
|
|||
9
Makefile
9
Makefile
|
|
@ -1079,6 +1079,15 @@ routes-fabric-etat: ansible-runtime ## Les hyperviseurs routent-ils toutes les z
|
|||
@# ne ressemblait pas a une route manquante — il ressemblait a un pare-feu.
|
||||
python3 scripts/routes_fabric_etat.py
|
||||
|
||||
.PHONY: expositions-etat
|
||||
expositions-etat: ## Chaque exposition est-elle servie sous un certificat qui la porte ? — ne corrige rien
|
||||
@# `serveur_nginx` POSE LE VHOST ET LAISSE LE SAN EN ARRIERE. Mesure deux fois le
|
||||
@# 2026-09-10, sur `grafana -> observatoire` puis `icinga -> vigie` : la zone publiait
|
||||
@# le nouveau nom, le vhost repondait, le client OIDC l'acceptait — et le certificat
|
||||
@# servi portait encore l'ancien. C'est `client_pki` qui reemet, et rien ne le reclame.
|
||||
@# Ajouter `--site` pour l'ecosysteme du SITE.
|
||||
python3 scripts/expositions_etat.py $(if $(SITE),--site)
|
||||
|
||||
.PHONY: gabarit-etat
|
||||
gabarit-etat: ## Le gabarit porte-t-il ce que le SITE declare ? — ne corrige rien, regarde
|
||||
python3 scripts/gabarit_etat.py
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@
|
|||
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 34 VM placee(s), aucun nom ni VMID en collision. |
|
||||
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 33 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 22, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
|
||||
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
|
||||
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 59 scripts expliques et atteignables, 116 cibles make documentees, 68 roles avec README. |
|
||||
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 60 scripts expliques et atteignables, 117 cibles make documentees, 68 roles avec README. |
|
||||
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (137 cle(s) declaree(s) par l'instance). |
|
||||
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 34 revendication(s) de port, aucune collision entre roles co-localises (36 groupes). |
|
||||
| P34 | Chaque document declare son lecteur | — | ✅ OK | 44 document(s) declarent leur lecteur (37 genere(s) exempte(s)). |
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 41 role(s) serveur/client tous nommes, 41 groupe(s) cite(s) en table existent tous. |
|
||||
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. |
|
||||
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
|
||||
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 55 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
||||
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 56 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
||||
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
|
||||
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 155 regle(s) du site. |
|
||||
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
|
||||
|
|
@ -72,7 +72,7 @@
|
|||
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (68 preuves, 68 roles, 41 groupes). |
|
||||
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
|
||||
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
|
||||
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `53e2f78` (publie le 2026-09-11). |
|
||||
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `5f5af70` (publie le 2026-09-11). |
|
||||
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 47 champ(s) sur 6 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
|
||||
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:5, serveurs:3 champ(s) lus par validateur). |
|
||||
| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s |
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
# Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main.
|
||||
remote: ssh://git@forge.genese.internal/genome/set-ops-public.wiki.git
|
||||
source: 53e2f78
|
||||
source: 5f5af70
|
||||
date: 2026-09-11
|
||||
|
|
|
|||
|
|
@ -282,3 +282,47 @@ connue et courte. Deux remèdes de fond existent, aucun n'est appliqué :
|
|||
|
||||
*Une dépendance qu'on documente sans la mesurer reste une dépendance qu'on découvrira au
|
||||
mauvais moment.*
|
||||
|
||||
## 8. Les contrôles à la demande — ce que `make prouver` ne peut pas voir
|
||||
|
||||
`make prouver` est **statique** : il lit le dépôt, zéro appel réseau. C'est ce qui le rend
|
||||
rejouable partout, par n'importe qui, et présentable comme pièce justificative. Le prix de
|
||||
cette propriété : il ne peut rien dire de ce qui ne s'observe qu'en ouvrant une connexion.
|
||||
|
||||
Quatre contrôles comblent ce creux. Aucun ne corrige quoi que ce soit — ils regardent, et
|
||||
rendent `0` si tout concorde.
|
||||
|
||||
| Contrôle | Ce qu'il compare | Le piège qu'il attrape |
|
||||
| --- | --- | --- |
|
||||
| `make expositions-etat` | Expositions du plan ↔ **SAN du certificat servi** ↔ code du vhost | Un renommage déployé partout **sauf** dans le certificat |
|
||||
| `make gabarit-etat` | Gabarit déclaré ↔ VM modèle réelle | Le modèle a dérivé de ce que le plan promet |
|
||||
| `make routes-fabric-etat` | Zones déclarées ↔ routes déclarées ↔ routes vivantes | Une route **vivante mais non déclarée** — elle part au redémarrage |
|
||||
| `make frontiere-plan` | Registre des flux ↔ règles de la frontière | Une règle posée à la main, qu'aucune déclaration ne porte |
|
||||
|
||||
Ajouter `SITE=1` à `expositions-etat` pour interroger l'écosystème du SITE plutôt que
|
||||
l'instance montée.
|
||||
|
||||
### Pourquoi `expositions-etat` existe
|
||||
|
||||
Renommer une exposition touche cinq choses. Quatre suivent au déploiement ; la cinquième,
|
||||
non :
|
||||
|
||||
```
|
||||
serveur_powerdns la zone publie le nouveau nom ✓
|
||||
serveur_keycloak le client OIDC accepte le retour ✓
|
||||
le service il fabrique ses URL avec le bon nom ✓ (P67)
|
||||
serveur_nginx le vhost répond sur le nouveau nom ✓
|
||||
client_pki le SAN du certificat porte le nom ✗ il faut le rejouer
|
||||
```
|
||||
|
||||
Le symptôme est trompeur : le site répond, la page s'affiche, et c'est le **navigateur**
|
||||
qui refuse — avec une erreur de certificat que personne ne relie à un renommage fait la
|
||||
veille. Mesuré deux fois le 2026-09-10, sur `grafana → observatoire` puis `icinga → vigie`.
|
||||
|
||||
Le contrôle regarde **dans les deux sens**. Un nom resté dans le SAN après avoir quitté le
|
||||
plan est un nom que le certificat continue d'authentifier : c'est exactement ce qu'avait
|
||||
laissé le premier renommage, jusqu'au passage de `client_pki`.
|
||||
|
||||
> Un `INJOIGNABLE` ne condamne pas le service : il dit que **ce poste** n'a pas pu ouvrir
|
||||
> la connexion. Les zones du SITE ne sont pas routées depuis le plan d'administration du
|
||||
> locataire — le mur est la frontière, pas le vhost.
|
||||
|
|
|
|||
212
scripts/expositions_etat.py
Normal file
212
scripts/expositions_etat.py
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Chaque exposition du plan est-elle VRAIMENT servie, et par le bon certificat ?
|
||||
Ne corrige rien.
|
||||
|
||||
POURQUOI CE CONTROLE EXISTE (2026-09-11).
|
||||
|
||||
Renommer une exposition touche cinq choses, et une seule ne suit pas toute seule. Mesure
|
||||
deux fois le 2026-09-10, sur `grafana -> observatoire` puis sur `icinga -> vigie` :
|
||||
|
||||
serveur_powerdns la zone publie le nouveau nom ✓ au deploiement
|
||||
serveur_keycloak le client OIDC accepte le retour ✓ au deploiement
|
||||
le service il fabrique ses URL avec le bon nom ✓ (P67)
|
||||
serveur_nginx le vhost repond sur le nouveau nom ✓ au deploiement
|
||||
client_pki le SAN du certificat porte le nom ✗ PAS au deploiement
|
||||
|
||||
`serveur_nginx` pose le vhost et laisse le SAN en arriere : c'est `client_pki` qui reemet,
|
||||
et rien ne le reclame. Le symptome est trompeur — le site repond, la page s'affiche, et
|
||||
c'est le NAVIGATEUR qui refuse, avec une erreur de certificat que personne ne relie a un
|
||||
renommage fait la veille.
|
||||
|
||||
CE QU'ON COMPARE, ET DANS LES DEUX SENS :
|
||||
|
||||
1. les expositions DECLAREES par `plan/applications.yml` (la verite) ;
|
||||
2. les noms PORTES par le certificat reellement SERVI (pas celui sur le disque) ;
|
||||
3. le code que le vhost REND pour ce nom.
|
||||
|
||||
Le sens inverse compte autant : un nom qui reste dans le SAN apres avoir disparu du plan
|
||||
est un nom que le certificat continue d'authentifier. C'est exactement ce qu'a laisse le
|
||||
premier renommage, jusqu'au passage de `client_pki`.
|
||||
|
||||
POURQUOI CE N'EST PAS UNE PREUVE DU HARNAIS. `make prouver` est STATIQUE : il lit le
|
||||
depot, zero appel reseau, et c'est ce qui le rend rejouable partout. Rien de statique ne
|
||||
peut lire un certificat SERVI — il faut ouvrir la connexion. C'est donc un controle a la
|
||||
demande, comme `make gabarit-etat` et `make routes-fabric-etat`.
|
||||
|
||||
Usage :
|
||||
python3 scripts/expositions_etat.py # l'instance montee
|
||||
python3 scripts/expositions_etat.py --site # l'ecosysteme du SITE
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
RACINE = Path(__file__).resolve().parent.parent
|
||||
|
||||
# LE PORT EST UNE CONVENTION, PAS UNE DERIVATION. Le champ `port:` du plan est celui que
|
||||
# le service ECOUTE en local (Forgejo 3000), pas celui sous lequel il est EXPOSE. Toutes
|
||||
# les expositions de Set-OPS sont servies en TLS sur 443 ; le jour ou ce ne sera plus vrai,
|
||||
# c'est ici qu'il faudra le declarer.
|
||||
PORT = 443
|
||||
|
||||
|
||||
def _san_servi(adresse: str, fqdn: str) -> tuple[list[str], str]:
|
||||
"""Les noms du certificat que le serveur PRESENTE pour ce SNI. ([], raison) si muet."""
|
||||
try:
|
||||
poignee = subprocess.run(
|
||||
["openssl", "s_client", "-connect", f"{adresse}:{PORT}",
|
||||
"-servername", fqdn, "-verify_quiet"],
|
||||
stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=20)
|
||||
except subprocess.TimeoutExpired:
|
||||
return [], "silence (aucune reponse TLS)"
|
||||
if "BEGIN CERTIFICATE" not in poignee.stdout:
|
||||
premiere = (poignee.stderr or "").strip().splitlines()
|
||||
return [], (premiere[0][:70] if premiere else "aucun certificat presente")
|
||||
texte = subprocess.run(
|
||||
["openssl", "x509", "-noout", "-ext", "subjectAltName"],
|
||||
input=poignee.stdout, capture_output=True, text=True, timeout=20).stdout
|
||||
return sorted(set(re.findall(r"DNS:([^\s,]+)", texte))), ""
|
||||
|
||||
|
||||
def _code_http(adresse: str, fqdn: str) -> str:
|
||||
r = subprocess.run(
|
||||
["curl", "-sk", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "12",
|
||||
"--resolve", f"{fqdn}:{PORT}:{adresse}", f"https://{fqdn}/"],
|
||||
capture_output=True, text=True, timeout=25)
|
||||
return (r.stdout or "000").strip()
|
||||
|
||||
|
||||
def _expositions_et_porteurs(site: bool) -> tuple[dict[str, tuple[str, str]], str]:
|
||||
"""{fqdn expose: (adresse qui le sert, nom court du porteur)}. L'adresse vient de `sans_exposition`, pose par
|
||||
le generateur sur l'hote dont les groupes contiennent l'edge de cette exposition —
|
||||
la MEME derivation que celle qui alimente le certificat. Interroger une autre source
|
||||
reviendrait a fabriquer une troisieme liste."""
|
||||
import yaml
|
||||
if site:
|
||||
# L'INVENTAIRE DU SITE EST DYNAMIQUE, PAS STATIQUE : il rend
|
||||
# `{groupe: {hosts: [noms]}, _meta: {hostvars: {...}}}` — la forme qu'Ansible
|
||||
# attend d'un script, et non l'arbre `all.children` d'un `hosts.yml`.
|
||||
import site_inventaire
|
||||
inv = site_inventaire.inventaire()
|
||||
hotes = dict((inv.get("_meta") or {}).get("hostvars") or {})
|
||||
etiquette = "SITE"
|
||||
else:
|
||||
from inventory_rules import instance_courante
|
||||
chemin = instance_courante().resolve()
|
||||
fichier = next(iter(sorted((chemin / "inventories").glob("*/hosts.yml"))), None)
|
||||
if not fichier:
|
||||
return {}, ""
|
||||
data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {}
|
||||
enfants = (data.get("all") or {}).get("children") or {}
|
||||
hotes = {}
|
||||
for etat in ("hotes_actifs", "hotes_planifies"):
|
||||
for nom, v in ((enfants.get(etat) or {}).get("hosts") or {}).items():
|
||||
hotes[nom] = v or {}
|
||||
etiquette = chemin.name
|
||||
|
||||
porteurs: dict[str, tuple[str, str]] = {}
|
||||
for nom, v in hotes.items():
|
||||
adresse = str(v.get("ansible_host") or "")
|
||||
for fqdn in (v.get("sans_exposition") or []):
|
||||
if adresse:
|
||||
porteurs[str(fqdn)] = (adresse, str(nom))
|
||||
if porteurs:
|
||||
return porteurs, etiquette
|
||||
|
||||
# PAS D'EDGE : LA MACHINE SERT SON PROPRE TLS (le cas du SITE).
|
||||
#
|
||||
# `sans_exposition` est pose sur l'hote dont les groupes contiennent l'EDGE de
|
||||
# l'exposition. Le site n'en a aucun — `site-forge-01` ecoute lui-meme sur 443. Le
|
||||
# porteur se derive alors de l'application : son `groupe` dit quel hote la sert.
|
||||
# Deux formes de terminaison, une seule question posee.
|
||||
import underlay as underlay_mod
|
||||
apps = (underlay_mod.lire_plan_site("applications.yml") or {}).get("applications") or {}
|
||||
hote_du_groupe: dict[str, tuple[str, str]] = {}
|
||||
for nom, v in hotes.items():
|
||||
adresse = str(v.get("ansible_host") or "")
|
||||
if adresse:
|
||||
hote_du_groupe.setdefault(str(nom), (adresse, str(nom)))
|
||||
for app in apps.values():
|
||||
if not isinstance(app, dict):
|
||||
continue
|
||||
cible = hote_du_groupe.get(str(app.get("hote") or ""))
|
||||
if not cible:
|
||||
continue
|
||||
for fqdn in (app.get("expose") or []):
|
||||
porteurs[str(fqdn)] = cible
|
||||
return porteurs, etiquette
|
||||
|
||||
|
||||
def main() -> int:
|
||||
site = "--site" in sys.argv[1:]
|
||||
porteurs, etiquette = _expositions_et_porteurs(site)
|
||||
if not porteurs:
|
||||
print("Aucune exposition derivee : rien a comparer. "
|
||||
"(`make instancier` a-t-il tourne ?)")
|
||||
return 0
|
||||
|
||||
print(f"{etiquette} : {len(porteurs)} exposition(s) declaree(s), "
|
||||
f"servies sur le port {PORT}.\n")
|
||||
|
||||
ecarts: list[str] = []
|
||||
# Un meme porteur sert souvent plusieurs noms : on ne lit son certificat qu'une fois
|
||||
# par (adresse, sni) et on garde le SAN pour la comparaison inverse.
|
||||
san_par_adresse: dict[str, list[str]] = {}
|
||||
|
||||
noms_propres: dict[str, str] = {a: n for a, n in porteurs.values()}
|
||||
for fqdn, (adresse, _porteur) in sorted(porteurs.items()):
|
||||
san, raison = _san_servi(adresse, fqdn)
|
||||
if not san:
|
||||
print(f" {fqdn:<34} {adresse:<14} INJOIGNABLE — {raison}")
|
||||
ecarts.append(f"{fqdn} : {raison} (depuis CE poste ; "
|
||||
f"la frontiere peut etre le mur, pas le service)")
|
||||
continue
|
||||
san_par_adresse.setdefault(adresse, san)
|
||||
code = _code_http(adresse, fqdn)
|
||||
dans_le_san = fqdn in san
|
||||
etat = "OK" if dans_le_san and code.startswith(("2", "3")) else "ECART"
|
||||
print(f" {fqdn:<34} {adresse:<14} cert={'oui' if dans_le_san else 'NON'} "
|
||||
f"http={code} {etat}")
|
||||
if not dans_le_san:
|
||||
ecarts.append(f"{fqdn} : le certificat SERVI ne porte pas ce nom — "
|
||||
f"rejouer `client_pki` sur le porteur ; `serveur_nginx` "
|
||||
f"seul ne reemet pas le SAN")
|
||||
elif not code.startswith(("2", "3")):
|
||||
ecarts.append(f"{fqdn} : le certificat porte le nom mais le vhost rend "
|
||||
f"{code} — le nom est authentifie, rien ne le sert")
|
||||
|
||||
# LE SENS INVERSE : un nom reste dans le SAN apres avoir quitte le plan.
|
||||
declares = set(porteurs)
|
||||
for adresse, san in sorted(san_par_adresse.items()):
|
||||
propre = noms_propres.get(adresse, "")
|
||||
for nom in san:
|
||||
if nom in declares:
|
||||
continue
|
||||
# LE PORTEUR S'AUTHENTIFIE SOUS SON PROPRE NOM, et ce n'est pas un reste :
|
||||
# `client_pki` met toujours le FQDN et le nom court de la machine dans le SAN.
|
||||
# Les confondre avec un vestige de renommage ferait crier ce controle a chaque
|
||||
# execution — un controle qui crie toujours ne se lit plus.
|
||||
if propre and nom.split(".")[0] == propre:
|
||||
continue
|
||||
ecarts.append(f"{nom} : porte par le certificat de {adresse} alors "
|
||||
f"qu'aucune application ne l'expose — reste d'un "
|
||||
f"renommage, encore authentifie")
|
||||
|
||||
print()
|
||||
if ecarts:
|
||||
print(f"ECART — {len(ecarts)} :")
|
||||
for e in ecarts:
|
||||
print(f" - {e}")
|
||||
return 1
|
||||
print("Conforme : chaque exposition declaree est servie, sous un certificat qui la "
|
||||
"porte, et le certificat ne porte rien d'autre.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in a new issue