From c79aae200d29331aae7c0c4fee88c126e9d35c6e Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Fri, 7 Aug 2026 17:26:35 -0400 Subject: [PATCH] flux : `admin` devient un pair, et `make ca-racine` livre la racine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le sysadmin ne pouvait atteindre AUCUNE interface web de la flotte qu'il administre : le pare-feu de l'hyperviseur n'autorisait le 443 de l'edge que depuis `+t17-flotte`. Le reseau d'administration est dans `t17-admin`. L'exploitant arrive par un TROISIEME chemin que rien ne declarait : ni `externe` (Internet, affaire de la frontiere), ni `flotte` (le tenant). Le runbook de reprise supposait pourtant qu'on ouvre Keycloak dans un navigateur. `admin` devient un pair declarable — les reseaux de `nftables_admin_ssh`, deja source unique de la garde anti-lockout. `serveur_nginx` le declare pour son 443. L'edge SEUL : ouvrir les services en direct elargirait la surface pour rien. Erreur de methode de ma part : mon premier test utilisait `/dev/tcp` et concluait « atteignable ». Faux — la frontiere repond au SYN a la place de la cible. Je l'avais consigne le matin meme. `make ca-racine` / `make ca-empreinte` : l'hote de l'AC est derive du groupe `serveur_step_ca`, et la sortie insiste sur la comparaison d'empreinte. La racine est un certificat PUBLIC — hors voute, dans le magasin de confiance. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++ Makefile | 45 +++++++++++++++++++++++++++++++ docs/autorisation.md | 30 ++++++++++++++++++--- roles/serveur_nginx/meta/flux.yml | 16 +++++++++++ scripts/devis_proxmox_fw.py | 16 +++++++++++ scripts/resoudre_flux.py | 15 +++++++++-- 6 files changed, 162 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9546e39..9d5b1e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,51 @@ ## 2026-08-06 — le chemin nord-sud devient dérivable +### Le sysadmin ne pouvait atteindre aucune interface web + +Depuis le poste d'administration, `auth.chezlepro.internal` ne répondait pas — ni aucun autre +service. Le pare-feu de l'hyperviseur n'autorisait le 443 de l'edge que depuis `+t17-flotte`, +les hôtes du tenant. Le réseau d'administration est dans `t17-admin`, pas dans `t17-flotte`. + +L'exploitant arrivait donc par un **troisième chemin que rien ne déclarait** : ni `externe` +(Internet, affaire de la frontière), ni `flotte` (le tenant). Il n'est ni l'un ni l'autre — et +le runbook de reprise, écrit la veille, supposait pourtant qu'on ouvre Keycloak dans un +navigateur. + +`admin` devient un **pair déclarable**, au même titre que `flotte` et `edge` : les réseaux de +l'intrant `nftables_admin_ssh`, déjà source unique de la garde anti-lockout. `serveur_nginx` +le déclare pour son 443, et les deux générateurs le traduisent — l'IPSet `t17-admin` existait +déjà. **L'edge seul** reçoit ce droit : c'est le point d'entrée unique, et ouvrir les services +en direct élargirait la surface sans rien gagner. + +Les six interfaces répondent maintenant depuis le poste. + +**Une erreur de méthode que j'ai commise en donnant les instructions.** Mon premier test +utilisait `/dev/tcp` et concluait « atteignable ». C'était faux : la frontière répond au SYN à +la place de la cible. J'avais consigné ce piège le matin même et j'y suis retombé. Un +`connect()` ne prouve rien ; seule une lecture prouve. + +### `make ca-racine` — la racine de l'AC, et son empreinte + +Le runbook demandait de faire confiance à l'AC interne sans dire comment. Deux cibles : + +``` +make ca-racine # écrit ./root_ca.crt, affiche sujet, validité, empreinte +make ca-empreinte # la même empreinte, lue SUR l'AC — le témoin de comparaison +``` + +L'hôte de l'AC est **dérivé** du groupe `serveur_step_ca`, jamais nommé ; une instance sans +autorité interne reçoit un refus qui l'explique. + +La racine est un certificat **public** : elle n'a rien à faire dans la voûte, et tout à faire +dans le magasin de confiance de qui administre. Mais la sortie insiste sur la comparaison +d'empreinte — installer une AC, c'est lui donner le droit de signer *n'importe quel nom*. + +`step-ca` publie aussi sa racine sur `https://:8443/roots.pem`, joignable depuis le tenant. +Ce chemin n'est **pas** ouvert au réseau d'administration, délibérément : la commande ci-dessus +donne déjà le résultat, et la racine de confiance n'a pas besoin d'une porte de plus. + + ### Quatre empreintes muettes — et une VM qui en est morte `collab-01` a cessé de répondre en SSH. Le symptôme ressemblait à un problème réseau ; la diff --git a/Makefile b/Makefile index ab7dca5..37660d8 100644 --- a/Makefile +++ b/Makefile @@ -372,6 +372,51 @@ sdn-appliquer: ansible-runtime ## Reconcilie le SDN : cree ce qui manque, RETIRE exit 2; fi CONFIRMER=true python3 scripts/appliquer_sdn.py +.PHONY: ca-racine +# Ou deposer la racine. Par defaut le repertoire courant : c'est un certificat +# PUBLIC, pas un secret — il n'a rien a faire dans la voute, et tout a faire dans +# le magasin de confiance de qui administre. +CA_RACINE_DEST ?= ./root_ca.crt + +ca-racine: ansible-runtime _instance-requise ## Recupere la racine de l'AC interne + son empreinte (a verifier AVANT de l'installer) + @set -e; \ + hote="$$(python3 -c "import yaml,sys; d=yaml.safe_load(open('$(FICHIER_INVENTAIRE)')); \ + import itertools; \ + g=d['all']['children']; \ + print(next(iter((g.get('serveur_step_ca') or {}).get('hosts') or {}), ''))" 2>/dev/null || true)"; \ + if [[ -z "$$hote" ]]; then \ + printf '%s\n' "Refus: aucun hote ne porte 'serveur_step_ca' dans $(FICHIER_INVENTAIRE)." \ + "Cette instance n'a pas d'autorite de certification interne."; exit 2; fi; \ + ansible -i $(FICHIER_INVENTAIRE) "$$hote" --become \ + -m fetch -a "src=/etc/step-ca/certs/root_ca.crt dest=$(CA_RACINE_DEST) flat=yes" >/dev/null; \ + printf '%s\n' "Racine de l'AC ecrite dans $(CA_RACINE_DEST) (depuis $$hote)."; \ + printf '%s\n' "" " sujet : $$(openssl x509 -in $(CA_RACINE_DEST) -noout -subject | sed 's/^subject=//')"; \ + printf '%s\n' " valide : $$(openssl x509 -in $(CA_RACINE_DEST) -noout -enddate | sed 's/^notAfter=//')"; \ + printf '%s\n' " empreinte: $$(openssl x509 -in $(CA_RACINE_DEST) -noout -fingerprint -sha256 | sed 's/^.*=//' | tr -d ':' | tr 'A-Z' 'a-z')"; \ + printf '%s\n' "" \ + "COMPARE l'empreinte avec celle de l'AC avant de l'installer :" \ + " make ca-empreinte" \ + "" \ + "Installer une AC, c'est lui donner le droit de signer N'IMPORTE QUEL nom" \ + "pour ton navigateur. La comparaison est ce qui distingue ta racine d'une" \ + "racine interceptee — ce n'est pas une formalite." \ + "" \ + " sudo cp $(CA_RACINE_DEST) /usr/local/share/ca-certificates/setops-root.crt" \ + " sudo update-ca-certificates" \ + " (Firefox a son propre magasin : Parametres > Certificats > Autorites)" + +.PHONY: ca-empreinte +ca-empreinte: ansible-runtime _instance-requise ## Empreinte de la racine, lue SUR l'AC (le temoin de comparaison) + @set -e; \ + hote="$$(python3 -c "import yaml; d=yaml.safe_load(open('$(FICHIER_INVENTAIRE)')); \ + g=d['all']['children']; \ + print(next(iter((g.get('serveur_step_ca') or {}).get('hosts') or {}), ''))" 2>/dev/null || true)"; \ + if [[ -z "$$hote" ]]; then \ + printf '%s\n' "Refus: aucun hote ne porte 'serveur_step_ca'."; exit 2; fi; \ + ansible -i $(FICHIER_INVENTAIRE) "$$hote" --become -m command \ + -a "step certificate fingerprint /etc/step-ca/certs/root_ca.crt" 2>/dev/null \ + | tail -1 | tr -d ' \r' + .PHONY: frontiere-plan frontiere-appliquer frontiere-plan: ansible-runtime ## Ecart entre la frontiere OPNsense et son devis (aucune ecriture) python3 scripts/appliquer_opnsense.py diff --git a/docs/autorisation.md b/docs/autorisation.md index 6028e0d..3f288d0 100644 --- a/docs/autorisation.md +++ b/docs/autorisation.md @@ -112,7 +112,31 @@ n'est pas optionnel. > possible** — c'est la clé de voûte au sens propre, et la première chose à sauvegarder > hors de la machine. -### 6.2 Où administrer quoi +### 6.2 Faire confiance à l'AC interne + +Les interfaces web portent des certificats de l'autorité interne, qu'aucun navigateur ne +connaît. Récupérer la racine et **son empreinte** : + +``` +make ca-racine # écrit ./root_ca.crt, affiche sujet, validité, empreinte +make ca-empreinte # la même empreinte, lue SUR l'AC — le témoin de comparaison +``` + +**Compare les deux avant d'installer.** Ce n'est pas une formalité : installer une AC, c'est +lui donner le droit de signer *n'importe quel nom* pour ton navigateur. La comparaison est ce +qui distingue ta racine d'une racine interceptée. + +``` +sudo cp root_ca.crt /usr/local/share/ca-certificates/setops-root.crt +sudo update-ca-certificates +``` + +Firefox a son propre magasin : *Paramètres → Certificats → Autorités → Importer*. + +La racine est un certificat **public** — elle n'a rien à faire dans la voûte, et tout à faire +dans le magasin de confiance de qui administre. + +### 6.3 Où administrer quoi | Ce que tu veux faire | Où | Comment | |---|---|---| @@ -123,7 +147,7 @@ n'est pas optionnel. La troisième ligne est la seule qui passe par Set-OPS. Les deux premières t'appartiennent et **le dépôt ne les touchera plus jamais**. -### 6.3 Si tu te fermes dehors +### 6.4 Si tu te fermes dehors Les comptes locaux de chaque service existent, sont en voûte, et leur formulaire n'est pas annoncé (`authentification.md` §3). L'accès de secours passe par `sudo` sur l'hôte : @@ -137,7 +161,7 @@ occ user:resetpassword Et si LDAP lui-même est en panne, ces trois commandes fonctionnent quand même : elles ne dépendent ni de Keycloak, ni de l'annuaire. C'est le sens de D-40. -### 6.4 Ce que tu dois changer en priorité +### 6.5 Ce que tu dois changer en priorité 1. **Le mot de passe d'amorçage** — imposé dès la première connexion, tu n'as pas le choix (§3). diff --git a/roles/serveur_nginx/meta/flux.yml b/roles/serveur_nginx/meta/flux.yml index 23809ff..4844886 100644 --- a/roles/serveur_nginx/meta/flux.yml +++ b/roles/serveur_nginx/meta/flux.yml @@ -37,3 +37,19 @@ flux: pair: expositions chiffrement: tls-cible raison: "Proxy vers les backends exposés (host:port dérivés des expose ; TLS interne = roadmap edge→backends)." + + # L'EXPLOITANT administre les services par leur interface web, et il n'est ni un + # hote du tenant ni un visiteur d'Internet : il arrive du reseau d'administration, + # un troisieme chemin que rien ne declarait. Resultat au 2026-08-07 : le sysadmin + # ne pouvait atteindre AUCUNE interface web de la flotte qu'il administre — ce qui + # contredisait le runbook de reprise (docs/autorisation.md §6), lequel suppose + # qu'on ouvre Keycloak dans un navigateur. + # + # L'edge SEUL recoit ce droit : c'est le point d'entree unique, et ouvrir les + # services en direct elargirait la surface sans rien gagner. + - sens: ingress + port: 443 + protocole: tcp + pair: admin + chiffrement: tls-requis + raison: "HTTPS depuis le reseau d'administration : l'exploitant administre les services par leur interface web, servie par l'edge." diff --git a/scripts/devis_proxmox_fw.py b/scripts/devis_proxmox_fw.py index 751f2fb..0bed9c7 100644 --- a/scripts/devis_proxmox_fw.py +++ b/scripts/devis_proxmox_fw.py @@ -170,8 +170,24 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: if pair in ("localhost", "expositions", "derive"): continue cle = {"flotte": f"t{n['index']}-flotte", + "admin": f"t{n['index']}-admin", "edge": nom_ipset(GROUPE_EDGE, n["index"])}.get( pair, nom_ipset(pair, n["index"])) + # `admin` designe des RESEAUX, pas des hotes : `_resoudre_sources` + # rendrait une liste vide et la regle serait sautee. Son IPSet + # existe deja (garde anti-lockout SSH) — on s'y refere directement. + if pair == "admin": + if not admin_de(nom): + continue + utilises.add(cle) + for port in _ports(fl): + regles.append({ + "action": "ACCEPT", "sens": "IN", + "source": f"+{cle}", "via": "administration", + "proto": fl.get("protocole", "tcp"), "dport": port, + "raison": fl.get("raison", ""), + }) + continue srcs_pair = _resoudre_sources(data, pair, tous, ips) if not srcs_pair: continue # ce role n'est pas porte dans ce tenant diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index 13104c4..d6bee26 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -38,7 +38,10 @@ SENS = {"ingress", "egress"} # en default-deny la casse en silence des que l'overlay descend sous 1500. PROTO = {"tcp", "udp", "icmp"} CHIFFREMENT = {"tls-requis", "tls", "starttls", "ssh", "tls-cible", "clair", "n-a"} -MOTS_PAIR = {"edge", "flotte", "externe", "localhost", "expositions", "derive"} +# `admin` : les RESEAUX d'administration (intrant `nftables_admin_ssh`), pas des +# hotes. C'est le troisieme chemin d'arrivee, distinct de `flotte` (le tenant) et +# d'`externe` (Internet) — l'exploitant n'est ni l'un ni l'autre. +MOTS_PAIR = {"edge", "flotte", "externe", "localhost", "expositions", "derive", "admin"} GROUPE_EDGE = "serveur_nginx" @@ -202,12 +205,20 @@ def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict for p in (pair if isinstance(pair, list) else [pair]): if p in ("localhost", "externe", "expositions", "derive"): continue # lo (auto), frontiere OPNsense, ou hors perimetre noeud + if p == "admin": + # `admin` n'est pas un groupe d'hotes mais des RESEAUX : l'intrant + # `nftables_admin_ssh`, deja source unique de la garde anti-lockout. + # Il est traite a part parce qu'il rend des CIDR, pas des adresses. + continue if p == "flotte": noms |= actifs_noms else: groupe = GROUPE_EDGE if p == "edge" else p noms |= {h for h in _hotes_du_groupe(data, groupe) if h in actifs_noms} - return sorted({ip_par_hote[h] for h in noms if h in ip_par_hote}) + srcs = {ip_par_hote[h] for h in noms if h in ip_par_hote} + if "admin" in (pair if isinstance(pair, list) else [pair]): + srcs |= set(_sources_admin_ssh()) + return sorted(srcs) # Codes ICMP de destination inatteignable qu'un flux peut nommer dans `port`. L'ICMP n'a