flux : admin devient un pair, et make ca-racine livre la racine

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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-07 17:26:35 -04:00
parent cc6026641f
commit c79aae200d
6 changed files with 162 additions and 5 deletions

View file

@ -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://<ca>: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

View file

@ -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

View file

@ -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).

View file

@ -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."

View file

@ -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

View file

@ -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