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:
parent
cc6026641f
commit
c79aae200d
6 changed files with 162 additions and 5 deletions
45
CHANGELOG.md
45
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
45
Makefile
45
Makefile
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue