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:
parent
d61c187dcf
commit
83c05b8320
5 changed files with 110 additions and 5 deletions
42
CHANGELOG.md
42
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
10
Makefile
10
Makefile
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }}"
|
||||||
|
|
|
||||||
|
|
@ -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 []):
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue