premiere VM tenant : quatre defauts leves sur le chemin

`infra-pki-01` recreee pour eprouver la chaine complete. Tout ce qui derive du
seed est exact : VMID, pont t17serv sans etiquette, adresse, passerelle, pool,
et desormais le gabarit de calcul.

1. Le pare-feu est-ouest aurait enferme Ansible : `t<idx>-srv-debian`
   n'autorisait SSH que depuis la flotte du tenant, et `admin_de(nom)` etait
   collecte puis jamais utilise. IPSet `t<idx>-admin` dedie + regle sourcee
   dessus. Pas d'ajout a `flotte`, qui sert aussi LDAP, SQL et les metriques.

2. L'applicateur ne convergeait pas : Proxmox range `x/32` en `x`, et la
   comparaison litterale laissait un ecart perpetuel. `_norm()` le ferme.

3. `cloner-vm` refusait toute VM de tenant : son garde exigeait un VLAN, vide
   par construction en SDN. Accepte desormais si un pont est fourni.

4. Le clonage ignore `cores`/`memory` : la VM heritait du gabarit (2/2048) au
   lieu du plan (1/1024). Une tache les repose apres le clone.

Et le troisieme verrou de D-64 : `proxmox_clone_parefeu_interface: true`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-06 20:33:12 -04:00
parent d61c187dcf
commit 83c05b8320
5 changed files with 110 additions and 5 deletions

View file

@ -2,6 +2,48 @@
## 2026-08-06 — le chemin nord-sud devient dérivable ## 2026-08-06 — le chemin nord-sud devient dérivable
### La première VM tenant, et les quatre défauts qu'elle a révélés
`infra-pki-01` recréée pour éprouver la chaîne complète. Le CA est le bon premier service :
il est le seul rôle **exempté** de `client_pki` — il ne s'enrôle pas auprès de lui-même — donc
sans dépendance amont.
Tout ce qui dérive du seed est exact :
```
VMID 117402101 pool Chezlepro-17
carte bridge=t17serv, firewall=1, aucune étiquette VLAN
adresse 10.27.19.21/24, gw 10.27.19.1
calcul 1 cœur / 1024 Mo
depuis le VRF : ping 0.08 ms, port 22 ouvert
```
Mais y arriver a demandé quatre corrections, et chacune serait passée inaperçue.
**Le pare-feu est-ouest aurait enfermé Ansible.** `t<idx>-srv-debian` n'autorisait SSH que
depuis `+t<idx>-flotte` — les hôtes du tenant. `admin_de(nom)` était collecté dans le devis
puis **jamais utilisé**. La première VM passée en `policy_in=DROP` se serait fermée derrière
l'outil qui venait de la configurer. Un IPSet `t<idx>-admin` dédié porte désormais l'intrant
`nftables_admin_ssh`, et une règle s'y source. Pas d'ajout à `flotte` : ce mot-clé sert aussi
LDAP, SQL et les métriques, qu'il aurait ouverts au réseau d'administration.
**L'applicateur ne convergeait pas.** Proxmox range `192.168.255.2/32` sous la forme
`192.168.255.2`. Comparés littéralement, l'écart ne se referme jamais — chaque passage croit
devoir corriger. `_norm()` ramène les deux à la même forme.
**`cloner-vm` refusait toute VM de tenant.** Son garde exigeait `VLAN=`, alors qu'en SDN
l'étiquette est portée par le VNet et le VLAN est volontairement vide. Il accepte désormais un
VLAN vide **si** un pont est fourni — sans quoi la VM ne serait branchée nulle part.
**Le clonage ignore `cores` et `memory`.** L'API Proxmox ne les accepte pas au moment du
clone : la VM héritait du gabarit — 2 cœurs / 2048 Mo contre 1 / 1024 au plan. Le plan était
contredit sans un mot. Une tâche les repose après le clone, et la mesure le confirme.
**Troisième verrou posé** : `proxmox_clone_parefeu_interface: true`. Sans `firewall=1` sur la
carte, les groupes de sécurité affectés à la VM ne s'appliquent jamais — le filtrage est-ouest
serait posé et sans effet (D-64).
### Le DROP se pose par VM, pas au datacenter (D-64) ### Le DROP se pose par VM, pas au datacenter (D-64)
Le devis enseignait un geste dangereux : « pare-feu activé, politique d'entrée DROP » **au Le devis enseignait un geste dangereux : « pare-feu activé, politique d'entrée DROP » **au

View file

@ -571,11 +571,15 @@ cloner-vm: ansible-runtime
printf '%s\n' 'Refus: relancer avec HOTE=nom VMID=id_clone.'; \ printf '%s\n' 'Refus: relancer avec HOTE=nom VMID=id_clone.'; \
exit 2; \ exit 2; \
fi fi
@if [[ -z "$(VLAN)" ]]; then \ @# En SDN EVPN, l'etiquette est portee par le VNet : `instancier` emet donc un VLAN
printf '%s\n' 'Refus: relancer avec VLAN=id_vlan.'; \ @# VIDE et un pont derive (t17serv). Exiger un VLAN ici rejetait toute VM de tenant.
@# Un VLAN vide n'est accepte QUE si un pont est fourni — sinon la VM n'aurait ni
@# etiquette ni VNet, et se retrouverait branchee nulle part.
@if [[ -z "$(VLAN)" && -z "$(PONT_PROXMOX)" ]]; then \
printf '%s\n' 'Refus: relancer avec VLAN=id_vlan, ou PONT_PROXMOX=<vnet> en SDN.'; \
exit 2; \ exit 2; \
fi fi
@if ! [[ "$(VLAN)" =~ ^[0-9]+$$ ]] || (( 10#$(VLAN) < 1 || 10#$(VLAN) > 4094 )); then \ @if [[ -n "$(VLAN)" ]] && { ! [[ "$(VLAN)" =~ ^[0-9]+$$ ]] || (( 10#$(VLAN) < 1 || 10#$(VLAN) > 4094 )); }; then \
printf '%s\n' 'Refus: VLAN doit etre un nombre entre 1 et 4094.'; \ printf '%s\n' 'Refus: VLAN doit etre un nombre entre 1 et 4094.'; \
exit 2; \ exit 2; \
fi fi

View file

@ -180,6 +180,26 @@
pool: "{{ proxmox_clone_pool | default(omit, true) }}" pool: "{{ proxmox_clone_pool | default(omit, true) }}"
timeout: "{{ proxmox_clone_timeout | default(600) | int }}" timeout: "{{ proxmox_clone_timeout | default(600) | int }}"
# L'API de CLONAGE ignore `cores` et `memory` : le clone herite du gabarit, et le
# plan est contredit sans un mot. Mesure du 2026-08-06 : plan 1 coeur / 1024 Mo,
# VM creee avec 2 / 2048 — ceux du gabarit. Il faut donc les reposer APRES le clone.
- name: Appliquer le gabarit de calcul du plan (le clonage ne le fait pas)
community.general.proxmox_kvm:
api_host: "{{ proxmox_api_host_effectif }}"
api_port: "{{ proxmox_api_port_effectif | int if proxmox_api_port_effectif | length > 0 else omit }}"
api_user: "{{ proxmox_api_user_effectif }}"
api_token_id: "{{ proxmox_api_token_id_effectif }}"
api_token_secret: "{{ proxmox_api_token_secret_effectif }}"
validate_certs: "{{ proxmox_validate_certs | default(false) | bool }}"
node: "{{ proxmox_clone_noeud }}"
vmid: "{{ proxmox_clone_vmid | int }}"
cores: "{{ proxmox_clone_coeurs | int }}"
memory: "{{ proxmox_clone_memoire | int }}"
update: true
when:
- proxmox_clone_coeurs is defined and proxmox_clone_coeurs | string | length > 0
- proxmox_clone_memoire is defined and proxmox_clone_memoire | string | length > 0
- name: Ajuster le reseau de la VM clonee - name: Ajuster le reseau de la VM clonee
community.general.proxmox_nic: community.general.proxmox_nic:
api_host: "{{ proxmox_api_host_effectif }}" api_host: "{{ proxmox_api_host_effectif }}"

View file

@ -50,6 +50,20 @@ def _prefixe(bloc: dict) -> str:
return "" return ""
def _norm(cidr: str) -> str:
"""Forme comparable d'un membre d'IPSet.
Proxmox RANGE `192.168.255.2/32` sous la forme `192.168.255.2`. Comparer les deux
litteralement laisse un ecart qui ne se referme jamais : chaque passage croit devoir
corriger, et la reconciliation ne converge plus.
"""
c = str(cidr).strip()
for suffixe in ("/32", "/128"):
if c.endswith(suffixe):
return c[: -len(suffixe)]
return c
def _regle_cle(r: dict) -> tuple: def _regle_cle(r: dict) -> tuple:
"""Identite d'une regle de groupe, insensible a la position et au commentaire.""" """Identite d'une regle de groupe, insensible a la position et au commentaire."""
return (str(r.get("type") or r.get("sens") or "").lower(), return (str(r.get("type") or r.get("sens") or "").lower(),
@ -65,7 +79,8 @@ def plan(api: Cluster, devis: dict) -> dict:
ipsets_voulus, groupes_voulus, affect_voulues = {}, {}, {} ipsets_voulus, groupes_voulus, affect_voulues = {}, {}, {}
for b in devis["blocs"]: for b in devis["blocs"]:
for nom, d in (b.get("ipsets") or {}).items(): for nom, d in (b.get("ipsets") or {}).items():
ipsets_voulus[nom] = {"membres": sorted(set(d["membres"])), "role": d.get("role", "")} ipsets_voulus[nom] = {"membres": sorted({_norm(m) for m in d["membres"]}),
"role": d.get("role", "")}
for g in b.get("groupes") or []: for g in b.get("groupes") or []:
groupes_voulus[g["nom"]] = g groupes_voulus[g["nom"]] = g
for a in b.get("affectations") or []: for a in b.get("affectations") or []:
@ -77,7 +92,7 @@ def plan(api: Cluster, devis: dict) -> dict:
if isinstance(s, dict) and str(s.get("name", "")).startswith(prefixes): if isinstance(s, dict) and str(s.get("name", "")).startswith(prefixes):
membres = [str(m.get("cidr")) for m in (api(f"/cluster/firewall/ipset/{s['name']}") or []) membres = [str(m.get("cidr")) for m in (api(f"/cluster/firewall/ipset/{s['name']}") or [])
if isinstance(m, dict)] if isinstance(m, dict)]
ipsets_poses[s["name"]] = sorted(set(membres)) ipsets_poses[s["name"]] = sorted({_norm(m) for m in membres})
groupes_poses = {} groupes_poses = {}
for g in (api("/cluster/firewall/groups") or []): for g in (api("/cluster/firewall/groups") or []):

View file

@ -119,6 +119,14 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
ipsets[f"t{n['index']}-flotte"] = { ipsets[f"t{n['index']}-flotte"] = {
"role": "flotte (tous les hotes du tenant)", "membres": sorted(set(ips.values())), "role": "flotte (tous les hotes du tenant)", "membres": sorted(set(ips.values())),
} }
# Le reseau d'administration a son PROPRE IPSet. L'ajouter a `flotte` ouvrirait
# aussi tous les autres flux qui s'y referent — LDAP, SQL, metriques — a un
# reseau qui n'a rien a y faire. Un ensemble, un sens.
if admin_de(nom):
ipsets[f"t{n['index']}-admin"] = {
"role": "reseaux d'administration (intrant nftables_admin_ssh)",
"membres": sorted(admin_de(nom)),
}
for groupe in sorted(_enfants(data)): for groupe in sorted(_enfants(data)):
if not est_groupe_operationnel(groupe): if not est_groupe_operationnel(groupe):
continue continue
@ -180,6 +188,22 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"dport": port, "dport": port,
"raison": fl.get("raison", ""), "raison": fl.get("raison", ""),
}) })
# GARDE ANTI-LOCKOUT. Le plan de gestion du tenant est resolu en
# `+<t>-flotte` : les hotes du tenant, et EUX SEULS. Or Ansible se
# connecte depuis le reseau d'administration, qui n'en fait pas partie.
# Sans cette regle, la premiere VM passee en `policy_in=DROP` se ferme
# derriere l'outil qui vient de la configurer. Meme source unique que
# les nftables et la frontiere : l'intrant `nftables_admin_ssh`.
if "22" in _ports(fl) and admin_de(nom):
utilises.add(f"t{n['index']}-admin")
regles.append({
"action": "ACCEPT", "sens": "IN",
"source": f"+t{n['index']}-admin", "via": "administration",
"proto": fl.get("protocole", "tcp"), "dport": "22",
"raison": "Garde anti-lockout : Ansible se connecte depuis le "
"reseau d'administration (intrant nftables_admin_ssh), "
"qui n'appartient pas a la flotte du tenant.",
})
if regles: if regles:
groupes.append({ groupes.append({
"nom": nom_groupe(groupe, n["index"]), "role": groupe, "regles": regles, "nom": nom_groupe(groupe, n["index"]), "role": groupe, "regles": regles,