pare-feu proxmox : la procedure d activation prouvee entre au depot
eprouver_parefeu.py, make proxmox-fw-eprouver et proxmox-fw-activer-vm : matrice du devis, flux observes, avant/apres, Icinga ; sans objet derive de ce qui ecoute, VMID du bon locataire. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d243773b45
commit
5e7534d87f
4 changed files with 311 additions and 0 deletions
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -1,5 +1,27 @@
|
|||
# CHANGELOG — Set-OPS
|
||||
|
||||
## 2026-09-28 (16) — La procédure d'activation du pare-feu Proxmox entre au dépôt
|
||||
|
||||
`scripts/eprouver_parefeu.py`, par `make proxmox-fw-eprouver TENANT= HOTE=` (aucune écriture)
|
||||
et `make proxmox-fw-activer-vm TENANT= HOTE= CONFIRMER=true`. Ce qui a servi à filtrer les 26
|
||||
VM, sous une forme qui resservira : une VM reconstruite perd ses options de pare-feu.
|
||||
|
||||
Pour UNE VM : matrice tirée du devis (chaque membre de chaque source), connexions entrantes
|
||||
établies confrontées aux règles, matrice avant, activation par le runner du site (seul à
|
||||
joindre l'API du cluster), matrice après, sondes relancées, critiques d'Icinga. Refus
|
||||
d'activer si un flux réel échappe aux règles ou si un flux échoue déjà.
|
||||
|
||||
**Plus d'exclusions à la main** : un échec vers un port que rien n'écoute hors de
|
||||
`127.0.0.1` est classé « sans objet » (nginx lié en local derrière la passerelle SSO,
|
||||
frontal sans site) — c'est ce que la procédure manuelle tranchait au cas par cas.
|
||||
**Le VMID se dérive du devis**, et du bloc du BON locataire : au premier essai, le script
|
||||
visait `ops-01` de Chezlepro pour une demande sur Technolibre — les noms d'hôtes se
|
||||
répètent d'un locataire à l'autre.
|
||||
|
||||
Éprouvé : `--plan` sur `ops-01` (8090 reconnu sans objet), `--activer` de bout en bout sur
|
||||
`web-frontal-01` (runner : « Rien à faire », après = avant, Icinga : 0 critique).
|
||||
Limite écrite : un flux UDP est testé en TCP sur le même port.
|
||||
|
||||
## 2026-09-28 (15) — Les 26 VM des locataires filtrées par Proxmox ; `proxmox-fw-plan` : « Rien à faire »
|
||||
|
||||
**Chezlepro, 13/13**, dans le même ordre que Technolibre et par la même procédure, VM par
|
||||
|
|
|
|||
12
Makefile
12
Makefile
|
|
@ -709,6 +709,18 @@ proxmox-fw-appliquer: ansible-runtime ## Reconcilie le pare-feu est-ouest : IPSe
|
|||
exit 2; fi
|
||||
CONFIRMER=true python3 scripts/appliquer_proxmox_fw.py $(ARGS)
|
||||
|
||||
proxmox-fw-eprouver: ansible-runtime ## Verifie qu'activer le pare-feu d'UNE VM ne coupe rien (aucune ecriture) — TENANT=<OPS-X> HOTE=<vm>
|
||||
@if [ -z "$(TENANT)" ] || [ -z "$(HOTE)" ]; then printf '%s\n' 'Refus: TENANT=<OPS-X> et HOTE=<vm> requis.'; exit 2; fi
|
||||
python3 scripts/eprouver_parefeu.py --instance "$(TENANT)" --hote "$(HOTE)" --plan
|
||||
|
||||
proxmox-fw-activer-vm: ansible-runtime ## Active le pare-feu d'UNE VM en le prouvant avant/apres — TENANT= HOTE= CONFIRMER=true
|
||||
@# UNE VM A LA FOIS, PREUVE COMPRISE (2026-09-28). Refuse si un flux reel echappe aux
|
||||
@# regles ou si un flux echoue deja ; rejoue la matrice apres et lit Icinga.
|
||||
@if [ -z "$(TENANT)" ] || [ -z "$(HOTE)" ]; then printf '%s\n' 'Refus: TENANT=<OPS-X> et HOTE=<vm> requis.'; exit 2; fi
|
||||
@if [[ "$(CONFIRMER)" != "true" ]]; then printf '%s\n' "Refus: active le pare-feu de $(HOTE) en REJECT. Voir d'abord 'make proxmox-fw-eprouver', puis CONFIRMER=true."; exit 2; fi
|
||||
python3 scripts/eprouver_parefeu.py --instance "$(TENANT)" --hote "$(HOTE)" --activer
|
||||
|
||||
.PHONY: proxmox-fw-eprouver proxmox-fw-activer-vm
|
||||
.PHONY: placement-plan sdn-plan sdn-appliquer
|
||||
placement-plan: ansible-runtime ## Le noeud, stockage, pont et gabarit du tenant existent-ils sur ce cluster ? (aucune ecriture)
|
||||
python3 scripts/devis_placement.py
|
||||
|
|
|
|||
|
|
@ -317,6 +317,19 @@ runbooks:
|
|||
nature: ecriture
|
||||
fixes: {CONFIRMER: "true"}
|
||||
pourquoi: "Poser IPSets, groupes et affectations — et retirer ce qui ne se declare plus."
|
||||
- cible: proxmox-fw-eprouver
|
||||
nature: mesure
|
||||
variables: [TENANT, HOTE]
|
||||
pourquoi: >-
|
||||
Avant d'activer le pare-feu d'UNE VM : les regles du devis tiennent-elles face aux
|
||||
flux que la VM recoit REELLEMENT ? Une VM reconstruite a perdu ses options.
|
||||
- cible: proxmox-fw-activer-vm
|
||||
nature: ecriture
|
||||
variables: [TENANT, HOTE]
|
||||
fixes: {CONFIRMER: "true"}
|
||||
pourquoi: >-
|
||||
Activer une VM a la fois, matrice avant et apres, Icinga lu. Chaque defaut du
|
||||
2026-09-28 ne s'est montre qu'a l'activation d'UNE VM.
|
||||
- cible: devis-opnsense
|
||||
nature: mesure
|
||||
pourquoi: "La frontiere nord/sud, derivee du meme registre de flux."
|
||||
|
|
|
|||
264
scripts/eprouver_parefeu.py
Normal file
264
scripts/eprouver_parefeu.py
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Activer le pare-feu Proxmox d'UNE VM de locataire, en prouvant qu'il ne coupe rien.
|
||||
|
||||
POURQUOI UNE PROCEDURE, ET PAS SEULEMENT `proxmox-fw-appliquer` (2026-09-28). Aucune des 26 VM
|
||||
des locataires n'avait son pare-feu actif : les reconstructions clonent sans ses options.
|
||||
Tout activer d'un coup ouvrait 26 pannes possibles, et chaque defaut ne s'est montre qu'a
|
||||
l'activation d'UNE VM : le ping de supervision qu'aucune regle ne portait (l'ICMP etait saute
|
||||
par le devis), le tunnel d'administration du locataire que nftables admettait et Proxmox non.
|
||||
Une VM reconstruite perd de nouveau ses options : cette procedure servira encore.
|
||||
|
||||
CE QU'ELLE FAIT, POUR UNE VM :
|
||||
1. MATRICE tiree du DEVIS lui-meme : pour chaque regle des groupes de la VM, CHAQUE membre
|
||||
de la source autorisee tente VM:port. On teste ce qui est promis, pas ce qu'on croit ;
|
||||
2. OBSERVATION : les connexions entrantes ETABLIES sur la VM (trois releves), confrontees
|
||||
aux regles. La matrice ne voit que le DECLARE ; un flux reel non declare la passerait,
|
||||
puis serait coupe. Un seul flux hors regles : REFUS d'activer ;
|
||||
3. matrice AVANT. Un echec deja present : REFUS — il faut le comprendre d'abord. Sauf si
|
||||
rien n'ECOUTE sur ce port hors de 127.0.0.1 : « sans objet » (un nginx lie en local
|
||||
derriere une passerelle SSO, un frontal sans site) ;
|
||||
4. ACTIVATION par le runner du site (seul a joindre l'API du cluster) : `--vm <vmid>` ;
|
||||
5. matrice APRES, comparee ; puis sondes de sante relancees et critiques d'Icinga.
|
||||
|
||||
python3 scripts/eprouver_parefeu.py --instance OPS-X --hote idm-01 --plan
|
||||
python3 scripts/eprouver_parefeu.py --instance OPS-X --hote idm-01 --activer
|
||||
|
||||
LIMITE CONNUE : un flux UDP est teste en TCP sur le meme port (la matrice ne sait pas ouvrir
|
||||
une « connexion » UDP). Pour un resolveur, verifier une vraie resolution (`dig +notcp`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
RACINE = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
|
||||
from devis_reseau import DOSSIER_INSTANCES, admin_de # noqa: E402
|
||||
|
||||
|
||||
def _inventaire(instance: str) -> Path:
|
||||
return DOSSIER_INSTANCES / instance / "inventories" / "principal" / "hosts.yml"
|
||||
|
||||
|
||||
def _ip_par_hote(instance: str) -> dict[str, str]:
|
||||
import yaml
|
||||
table: dict[str, str] = {}
|
||||
|
||||
def w(n):
|
||||
if isinstance(n, dict):
|
||||
for k, v in n.items():
|
||||
if k == "hosts" and isinstance(v, dict):
|
||||
for h, hv in v.items():
|
||||
if isinstance(hv, dict) and hv.get("ansible_host"):
|
||||
table[h] = hv["ansible_host"]
|
||||
else:
|
||||
w(v)
|
||||
w(yaml.safe_load(_inventaire(instance).read_text(encoding="utf-8")))
|
||||
return table
|
||||
|
||||
|
||||
def _ansible(instance: str, hotes: str, commande: str) -> dict[str, str]:
|
||||
"""{hote: sortie} d'une commande shell jouee en root sur des hotes du locataire."""
|
||||
r = subprocess.run(["ansible", "-i", str(_inventaire(instance)), hotes, "-b", "-m", "shell",
|
||||
"-a", commande], capture_output=True, text=True, cwd=RACINE)
|
||||
sorties, hote = {}, None
|
||||
for ligne in r.stdout.splitlines():
|
||||
m = re.match(r"^(\S+) \| \w+ \| rc=\d+ >>$", ligne)
|
||||
if m:
|
||||
hote = m.group(1)
|
||||
sorties[hote] = ""
|
||||
elif hote:
|
||||
sorties[hote] += ligne + "\n"
|
||||
return sorties
|
||||
|
||||
|
||||
def matrice(instance: str, hote: str) -> tuple[list[dict], int]:
|
||||
env = dict(os.environ, SETOPS_INSTANCE=str(DOSSIER_INSTANCES / instance))
|
||||
r = subprocess.run([sys.executable, "scripts/devis_proxmox_fw.py", "--json"],
|
||||
capture_output=True, text=True, cwd=RACINE, env=env)
|
||||
if r.returncode != 0:
|
||||
raise SystemExit("Le devis ne se genere pas :\n" + r.stderr[:400])
|
||||
devis = json.loads(r.stdout)
|
||||
ips = _ip_par_hote(instance)
|
||||
h_par_ip = {ip: h for h, ip in ips.items()}
|
||||
# LE BLOC DE CE LOCATAIRE, PAS LE PREMIER QUI PORTE CE NOM. Les locataires partagent
|
||||
# leurs noms d'hotes (`ops-01` existe chez chacun) : prendre la premiere affectation
|
||||
# venue visait la VM d'un autre — constate au premier essai de ce script.
|
||||
for b in (x for x in devis["blocs"] if x.get("tenant") == instance):
|
||||
a = next((x for x in b["affectations"] if x["hote"] == hote), None)
|
||||
if a is None:
|
||||
continue
|
||||
groupes = {g["nom"]: g for g in b["groupes"]}
|
||||
tests = []
|
||||
for gn in a["groupes"]:
|
||||
for regle in groupes[gn]["regles"]:
|
||||
membres = b["ipsets"].get(regle["source"].lstrip("+"), {}).get("membres", [])
|
||||
for m in membres:
|
||||
src = h_par_ip.get(m)
|
||||
if src and src != hote:
|
||||
tests.append({"src": src, "ip": ips[hote], "proto": regle["proto"],
|
||||
"port": str(regle.get("dport") or regle.get("icmp_type")),
|
||||
"groupe": gn})
|
||||
return tests, int(a["vmid"])
|
||||
raise SystemExit(f"REFUS : « {hote} » n'a aucune affectation au devis de {instance}.")
|
||||
|
||||
|
||||
def jouer(instance: str, hote: str, tests: list[dict]) -> dict[str, str]:
|
||||
par_src: dict[str, list[dict]] = {}
|
||||
for t in tests:
|
||||
par_src.setdefault(t["src"], []).append(t)
|
||||
res: dict[str, str] = {}
|
||||
for src, xs in par_src.items():
|
||||
lignes = []
|
||||
for t in xs:
|
||||
cle = f"{t['src']}>{hote}:{t['proto']}/{t['port']}"
|
||||
essai = (f"ping -c1 -W2 {t['ip']} >/dev/null 2>&1" if t["proto"] == "icmp"
|
||||
else f"timeout 3 bash -c '</dev/tcp/{t['ip']}/{t['port']}' 2>/dev/null")
|
||||
lignes.append(f"{essai} && echo '{cle} ok' || echo '{cle} echec'")
|
||||
for sortie in _ansible(instance, src, "; ".join(lignes)).values():
|
||||
for ligne in sortie.splitlines():
|
||||
if ligne.endswith((" ok", " echec")):
|
||||
k, v = ligne.rsplit(" ", 1)
|
||||
res[k] = v
|
||||
return res
|
||||
|
||||
|
||||
def ecoutes(instance: str, hote: str) -> set[str]:
|
||||
"""Ports ecoutes HORS de la boucle locale : ce qu'un voisin peut atteindre."""
|
||||
sortie = _ansible(instance, hote, "ss -Hltn").get(hote, "")
|
||||
ports = set()
|
||||
for ligne in sortie.splitlines():
|
||||
champs = ligne.split()
|
||||
if len(champs) >= 4:
|
||||
adr, _, port = champs[3].rpartition(":")
|
||||
if not adr.startswith(("127.", "[::1]")):
|
||||
ports.add(port)
|
||||
return ports
|
||||
|
||||
|
||||
def observer(instance: str, hote: str, tests: list[dict]) -> list[str]:
|
||||
"""Les connexions entrantes etablies qu'AUCUNE regle ne couvre."""
|
||||
cmd = ('L=" $(ss -Hltn | awk \'{n=split($4,a,":"); print a[n]}\' | sort -u | tr "\\n" " ") "; '
|
||||
'for i in 1 2 3; do ss -Hnt state established | awk -v L="$L" '
|
||||
'\'{n=split($3,a,":"); lp=a[n]; m=split($4,b,":"); p=b[1]; for(k=2;k<m;k++) p=p":"b[k]; '
|
||||
'if (index(L," " lp " ")) print p" "lp}\'; sleep 5; done | sort -u')
|
||||
sortie = _ansible(instance, hote, cmd).get(hote, "")
|
||||
ips = _ip_par_hote(instance)
|
||||
h_par_ip = {ip: h for h, ip in ips.items()}
|
||||
permis: dict[str, set[str]] = {}
|
||||
for t in tests:
|
||||
permis.setdefault(t["port"], set()).add(t["src"])
|
||||
admin = [ipaddress.ip_network(n) for n in admin_de(instance)]
|
||||
hors = []
|
||||
for ligne in sortie.splitlines():
|
||||
champs = ligne.split()
|
||||
if len(champs) != 2:
|
||||
continue
|
||||
ip = champs[0].strip("[]").replace("::ffff:", "")
|
||||
port = champs[1]
|
||||
if ip.startswith("127.") or ip == "::1":
|
||||
continue
|
||||
src = h_par_ip.get(ip, ip)
|
||||
if src == hote:
|
||||
continue # vers soi-meme par sa propre adresse : `lo`, pas le pont Proxmox
|
||||
if src in permis.get(port, set()):
|
||||
continue
|
||||
if port == "22" and any(ipaddress.ip_address(ip) in n for n in admin):
|
||||
continue
|
||||
hors.append(f"{src} -> {hote}:{port}")
|
||||
return sorted(set(hors))
|
||||
|
||||
|
||||
def activer(instance: str, vmid: int, args) -> int:
|
||||
distante = (f"cd {args.racine_runner}/Set-OPS-public && export PATH={args.racine_runner}/venv/bin:$PATH "
|
||||
f"&& SETOPS_INSTANCE={args.racine_runner}/{instance} make proxmox-fw-appliquer "
|
||||
f"CONFIRMER=true ARGS='--vm {vmid}'")
|
||||
cmd = ["ssh", "-o", "BatchMode=yes", "-J", args.rebond, args.runner,
|
||||
f"sudo -u setops bash -lc {json.dumps(distante)}"]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
for ligne in r.stdout.splitlines():
|
||||
if re.search(r"~ VM|ECHEC|reconcilie|INCOMPLETE|Rien a faire", ligne):
|
||||
print(" ", ligne.strip())
|
||||
return 0 if ("reconcilie" in r.stdout or "Rien a faire" in r.stdout) and "ECHEC" not in r.stdout else 1
|
||||
|
||||
|
||||
def critiques_icinga(instance: str) -> list[str]:
|
||||
_ansible(instance, "all", "systemctl start setops-sante.service; true")
|
||||
cmd = ("P=$(sed -n '/ApiUser \"root\"/,/}/p' /etc/icinga2/conf.d/api-users.conf | "
|
||||
"grep -oP 'password = \"\\K[^\"]+'); "
|
||||
"curl -sk -u \"root:$P\" -X POST -H 'Accept: application/json' "
|
||||
"https://localhost:5665/v1/actions/reschedule-check "
|
||||
"-d '{\"type\":\"Service\",\"filter\":\"service.name==\\\"ping4\\\"\",\"force\":true}' >/dev/null; "
|
||||
"sleep 20; curl -sk -u \"root:$P\" "
|
||||
"'https://localhost:5665/v1/objects/services?filter=service.state==2&attrs=last_check_result'")
|
||||
sortie = next(iter(_ansible(instance, "serveur_icinga", cmd).values()), "{}")
|
||||
try:
|
||||
return [x["name"] for x in json.loads(sortie[sortie.index("{"):])["results"]]
|
||||
except (ValueError, KeyError):
|
||||
return ["(Icinga illisible)"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--instance", required=True, help="le locataire, ex. OPS-Technolibre")
|
||||
ap.add_argument("--hote", required=True, help="la VM, ex. idm-01")
|
||||
mode = ap.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--plan", action="store_true", help="verifier, sans rien activer")
|
||||
mode.add_argument("--activer", action="store_true", help="verifier, activer, reverifier")
|
||||
ap.add_argument("--runner", default="ansible@10.37.31.11", help="runner du site (API du cluster)")
|
||||
ap.add_argument("--rebond", default="ansible@10.37.0.1", help="rebond vers le runner")
|
||||
ap.add_argument("--racine-runner", default="/opt/setops")
|
||||
args = ap.parse_args()
|
||||
I, H = args.instance, args.hote
|
||||
|
||||
tests, vmid = matrice(I, H)
|
||||
print(f"=== {H} ({I}, VM {vmid}) — {len(tests)} test(s) tires du devis")
|
||||
hors = observer(I, H, tests)
|
||||
print(f" observation : {len(hors)} flux etabli(s) hors des regles")
|
||||
for h in hors:
|
||||
print(f" HORS : {h}")
|
||||
avant = jouer(I, H, tests)
|
||||
ecoute = ecoutes(I, H)
|
||||
def _sans_objet(cle: str) -> bool:
|
||||
proto, port = cle.rsplit(":", 1)[1].split("/", 1)
|
||||
return proto != "icmp" and port not in ecoute
|
||||
sans_objet = {k for k, v in avant.items() if v == "echec" and _sans_objet(k)}
|
||||
echecs = sorted(k for k, v in avant.items() if v == "echec" and k not in sans_objet)
|
||||
print(f" avant : {sum(v == 'ok' for v in avant.values())}/{len(avant)} ok"
|
||||
+ (f", {len(sans_objet)} sans objet (rien n'ecoute hors de 127.0.0.1)" if sans_objet else ""))
|
||||
for k in sorted(sans_objet):
|
||||
print(f" sans objet : {k}")
|
||||
for k in echecs:
|
||||
print(f" DEJA EN ECHEC : {k}")
|
||||
if args.plan:
|
||||
print(" PLAN SEUL — rien n'a ete active.")
|
||||
return 1 if (hors or echecs) else 0
|
||||
if hors or echecs:
|
||||
print(" REFUS D'ACTIVER : un flux reel hors des regles, ou un echec a comprendre d'abord.")
|
||||
return 2
|
||||
if activer(I, vmid, args) != 0:
|
||||
print(" ECHEC de l'activation.")
|
||||
return 3
|
||||
print(f" active a {time.strftime('%H:%M:%S')}")
|
||||
apres = jouer(I, H, tests)
|
||||
change = sorted(k for k in avant if avant[k] != apres.get(k))
|
||||
print(f" apres : {sum(v == 'ok' for v in apres.values())}/{len(apres)} ok ; "
|
||||
f"changements : {', '.join(change) or 'aucun'}")
|
||||
crit = critiques_icinga(I)
|
||||
print(f" Icinga : {len(crit)} critique(s)" + (f" — {', '.join(crit)}" if crit else ""))
|
||||
if change:
|
||||
print(f" RETOUR ARRIERE : remettre `enable=0` sur la VM {vmid} "
|
||||
f"(/nodes/<noeud>/qemu/{vmid}/firewall/options).")
|
||||
return 4 if (change or crit) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in a new issue