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 ## 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 ### 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 `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 exit 2; fi
CONFIRMER=true python3 scripts/appliquer_sdn.py 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 .PHONY: frontiere-plan frontiere-appliquer
frontiere-plan: ansible-runtime ## Ecart entre la frontiere OPNsense et son devis (aucune ecriture) frontiere-plan: ansible-runtime ## Ecart entre la frontiere OPNsense et son devis (aucune ecriture)
python3 scripts/appliquer_opnsense.py 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 > possible** — c'est la clé de voûte au sens propre, et la première chose à sauvegarder
> hors de la machine. > 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 | | 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 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**. 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 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 : 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 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. 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 1. **Le mot de passe d'amorçage** — imposé dès la première connexion, tu n'as pas le
choix (§3). choix (§3).

View file

@ -37,3 +37,19 @@ flux:
pair: expositions pair: expositions
chiffrement: tls-cible chiffrement: tls-cible
raison: "Proxy vers les backends exposés (host:port dérivés des expose ; TLS interne = roadmap edge→backends)." 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"): if pair in ("localhost", "expositions", "derive"):
continue continue
cle = {"flotte": f"t{n['index']}-flotte", cle = {"flotte": f"t{n['index']}-flotte",
"admin": f"t{n['index']}-admin",
"edge": nom_ipset(GROUPE_EDGE, n["index"])}.get( "edge": nom_ipset(GROUPE_EDGE, n["index"])}.get(
pair, nom_ipset(pair, n["index"])) 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) srcs_pair = _resoudre_sources(data, pair, tous, ips)
if not srcs_pair: if not srcs_pair:
continue # ce role n'est pas porte dans ce tenant 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. # en default-deny la casse en silence des que l'overlay descend sous 1500.
PROTO = {"tcp", "udp", "icmp"} PROTO = {"tcp", "udp", "icmp"}
CHIFFREMENT = {"tls-requis", "tls", "starttls", "ssh", "tls-cible", "clair", "n-a"} 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" 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]): for p in (pair if isinstance(pair, list) else [pair]):
if p in ("localhost", "externe", "expositions", "derive"): if p in ("localhost", "externe", "expositions", "derive"):
continue # lo (auto), frontiere OPNsense, ou hors perimetre noeud 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": if p == "flotte":
noms |= actifs_noms noms |= actifs_noms
else: else:
groupe = GROUPE_EDGE if p == "edge" else p groupe = GROUPE_EDGE if p == "edge" else p
noms |= {h for h in _hotes_du_groupe(data, groupe) if h in actifs_noms} 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 # Codes ICMP de destination inatteignable qu'un flux peut nommer dans `port`. L'ICMP n'a