From 83c05b832076a4e0ecb74a53b447653a7d76e143 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Thu, 6 Aug 2026 20:33:12 -0400 Subject: [PATCH] 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-srv-debian` n'autorisait SSH que depuis la flotte du tenant, et `admin_de(nom)` etait collecte puis jamais utilise. IPSet `t-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 --- CHANGELOG.md | 42 ++++++++++++++++++++++++++ Makefile | 10 ++++-- playbooks/proxmox/cloner_vm_debian.yml | 20 ++++++++++++ scripts/appliquer_proxmox_fw.py | 19 ++++++++++-- scripts/devis_proxmox_fw.py | 24 +++++++++++++++ 5 files changed, 110 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e49cf0c..d371702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,48 @@ ## 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-srv-debian` n'autorisait SSH que +depuis `+t-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-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 devis enseignait un geste dangereux : « pare-feu activé, politique d'entrée DROP » **au diff --git a/Makefile b/Makefile index 6f5891b..ce67708 100644 --- a/Makefile +++ b/Makefile @@ -571,11 +571,15 @@ cloner-vm: ansible-runtime printf '%s\n' 'Refus: relancer avec HOTE=nom VMID=id_clone.'; \ exit 2; \ fi - @if [[ -z "$(VLAN)" ]]; then \ - printf '%s\n' 'Refus: relancer avec VLAN=id_vlan.'; \ + @# En SDN EVPN, l'etiquette est portee par le VNet : `instancier` emet donc un 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= en SDN.'; \ exit 2; \ 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.'; \ exit 2; \ fi diff --git a/playbooks/proxmox/cloner_vm_debian.yml b/playbooks/proxmox/cloner_vm_debian.yml index 925dab4..05414c7 100644 --- a/playbooks/proxmox/cloner_vm_debian.yml +++ b/playbooks/proxmox/cloner_vm_debian.yml @@ -180,6 +180,26 @@ pool: "{{ proxmox_clone_pool | default(omit, true) }}" 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 community.general.proxmox_nic: api_host: "{{ proxmox_api_host_effectif }}" diff --git a/scripts/appliquer_proxmox_fw.py b/scripts/appliquer_proxmox_fw.py index 465ea81..314e045 100644 --- a/scripts/appliquer_proxmox_fw.py +++ b/scripts/appliquer_proxmox_fw.py @@ -50,6 +50,20 @@ def _prefixe(bloc: dict) -> str: 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: """Identite d'une regle de groupe, insensible a la position et au commentaire.""" 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 = {}, {}, {} for b in devis["blocs"]: 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 []: groupes_voulus[g["nom"]] = g 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): membres = [str(m.get("cidr")) for m in (api(f"/cluster/firewall/ipset/{s['name']}") or []) if isinstance(m, dict)] - ipsets_poses[s["name"]] = sorted(set(membres)) + ipsets_poses[s["name"]] = sorted({_norm(m) for m in membres}) groupes_poses = {} for g in (api("/cluster/firewall/groups") or []): diff --git a/scripts/devis_proxmox_fw.py b/scripts/devis_proxmox_fw.py index d2c5c5a..751f2fb 100644 --- a/scripts/devis_proxmox_fw.py +++ b/scripts/devis_proxmox_fw.py @@ -119,6 +119,14 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: ipsets[f"t{n['index']}-flotte"] = { "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)): if not est_groupe_operationnel(groupe): continue @@ -180,6 +188,22 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: "dport": port, "raison": fl.get("raison", ""), }) + # GARDE ANTI-LOCKOUT. Le plan de gestion du tenant est resolu en + # `+-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: groupes.append({ "nom": nom_groupe(groupe, n["index"]), "role": groupe, "regles": regles,