From 1493059271f13ad7b56cdfc614981606145c31d8 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 29 Sep 2026 13:58:01 -0400 Subject: [PATCH] edge ferme a l Internet ; le pare-feu Proxmox recoit les flux externe L edge n expose qu a sa zone d administration (plus de pair externe) ; le public passera par le web frontal. Le devis Proxmox recoit les flux externe depuis +t-internet (RFC 1918 exclus en nomatch) et depuis l admin quand le poste en est client. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 30 ++++++++ Makefile | 1 + docs/registre-flux.md | 5 +- roles/serveur_nginx/meta/flux.yml | 15 ++-- scripts/appliquer_proxmox_fw.py | 16 +++- scripts/devis_proxmox_fw.py | 38 +++++++++- scripts/tests/test_proxmox_fw_externe.py | 93 ++++++++++++++++++++++++ wiki/Rôle-serveur_nginx.md | 8 +- wiki/Rôles.md | 2 +- 9 files changed, 184 insertions(+), 24 deletions(-) create mode 100644 scripts/tests/test_proxmox_fw_externe.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 83fb734..b84f4c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # CHANGELOG — Set-OPS +## 2026-09-29 (33) — L'edge fermé à l'Internet ; le pare-feu Proxmox reçoit ce que la frontière laisse entrer + +**La règle, mise au point par l'exploitant** : les URL `*.internal` des edges ne s'ouvrent +qu'à la zone d'administration de leur écosystème ; ce qui sera publié sur l'Internet le sera +par le **web frontal**, pas par l'edge. + +**L'edge déclarait pourtant 80/443 `externe`.** Conséquences, dérivées du même registre : +la frontière portait « WAN, n'importe qui → edge 80/443 » chez les deux locataires, et le +pare-feu de la machine (`nftables`) de chaque edge — site compris — acceptait 80 et 443 de +toute source. Seul le pare-feu Proxmox, qui sautait tout `externe`, fermait la porte. +Désormais `serveur_nginx` ne déclare plus d'`externe` : 443 depuis `flotte` et `admin`, 80 +(redirection) depuis `admin`. Vérifié avant : le poste joint les edges par le réseau de +gestion (10.37.0.17, règle `admin`), et les journaux d'accès des trois edges ne montrent +aucun client hors flotte et administration. + +**Le pare-feu Proxmox et les flux `externe`.** Il les sautait tous (« la frontière s'en +charge ») ; depuis que les VM rejettent par défaut, un service publié aurait été refusé par +sa propre VM — et le poste ne joignait pas Dovecot 993, que la frontière lui ouvre. Même +partage que la frontière : un flux entrant `externe` est reçu depuis `+t-internet` +(`0.0.0.0/1`, `128.0.0.0/1`, les trois blocs RFC 1918 exclus en `nomatch` — jamais une source +vide, et un IPSet refuse `/0`), et depuis `+t-admin` quand le poste en est client +(`poste`, vrai par défaut ; faux pour le 25 de Postfix). Le SSH `externe` reste réservé à +l'administration. Rendu : Postfix 25 (Internet), Dovecot 993 (Internet, admin), ICMP +« fragmentation nécessaire » (découverte de MTU). `appliquer_proxmox_fw` pose et relit les +exclusions. `test_proxmox_fw_externe.py` garde les invariants : edge jamais ouvert à +l'Internet, tout `externe` reçu, aucune source vide, exclusions en `nomatch`. + +**Le web frontal** ne déclare encore que le 80 depuis l'edge : le rendre public (son propre +TLS, sa redirection à la frontière) est l'étape suivante, pas celle-ci. + ## 2026-09-29 (32) — L'edge expose aux personnes ; entre machines du site, on va au service **La règle, énoncée par l'exploitant** : l'edge sert à exposer des interfaces web ; les diff --git a/Makefile b/Makefile index 4f5bcfc..5d9be91 100644 --- a/Makefile +++ b/Makefile @@ -319,6 +319,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire) python3 scripts/tests/test_sondes_syntaxe.py python3 scripts/tests/test_empreinte_plancher.py python3 scripts/tests/test_expositions_sans_port.py + python3 scripts/tests/test_proxmox_fw_externe.py # LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une # journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine diff --git a/docs/registre-flux.md b/docs/registre-flux.md index 46a331b..607646b 100644 --- a/docs/registre-flux.md +++ b/docs/registre-flux.md @@ -74,8 +74,7 @@ | `serveur_nextcloud` | egress | 443 | tcp | edge | tls-requis | Découverte OIDC auprès de Keycloak (via son FQDN publié à l'edge). | | `serveur_nextcloud` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base de données Nextcloud (verify-full). | | `serveur_nextcloud` | egress | 9980 | tcp | serveur_collabora | tls-cible | Vérifications WOPI serveur->Collabora (édition en ligne). TLS interne = feuille de route edge->backends. | -| `serveur_nginx` | ingress | 80 | tcp | externe | clair | HTTP entrant — redirection permanente vers HTTPS. | -| `serveur_nginx` | ingress | 443 | tcp | externe | tls-requis | HTTPS entrant — services exposés (terminaison TLS à l'edge). | +| `serveur_nginx` | ingress | 80 | tcp | admin | clair | HTTP depuis le reseau d'administration — redirection permanente vers HTTPS. | | `serveur_nginx` | ingress | 443 | tcp | flotte | tls-requis | HTTPS depuis le tenant : les FQDN publiés vivent à l'edge (découverte OIDC, appels inter-services par nom). | | `serveur_nginx` | ingress | 443 | tcp | admin | tls-requis | HTTPS depuis le reseau d'administration : l'exploitant administre les services par leur interface web, servie par l'edge. | | `serveur_nginx` | egress | derive | tcp | expositions | tls-cible | Proxy vers les backends exposés (host:port dérivés des expose ; TLS interne = roadmap edge→backends). | @@ -135,4 +134,4 @@ - **starttls** : 6 flux - **tls** : 11 flux - **tls-cible** : 2 flux -- **tls-requis** : 35 flux +- **tls-requis** : 34 flux diff --git a/roles/serveur_nginx/meta/flux.yml b/roles/serveur_nginx/meta/flux.yml index 4844886..24d8cad 100644 --- a/roles/serveur_nginx/meta/flux.yml +++ b/roles/serveur_nginx/meta/flux.yml @@ -3,18 +3,17 @@ # Cas « edge » : ingress depuis l'extérieur (frontière publique) + egress vers les backends, # ces derniers dérivés des `expose:` des applications (pair: expositions). flux: + # PAS D'`externe` (2026-09-29). L'edge expose les interfaces web de l'ecosysteme a SA + # zone d'administration, et a personne d'autre ; ce qui sera publie sur l'Internet le sera + # par le WEB FRONTAL. Les deux entrees `externe` qui figuraient ici posaient a la frontiere + # « WAN, n'importe qui -> edge 80/443 » chez chaque locataire — une ouverture que rien ne + # devait porter (le pare-feu Proxmox de l'edge la rejetait deja). - sens: ingress port: 80 protocole: tcp - pair: externe + pair: admin chiffrement: clair - raison: "HTTP entrant — redirection permanente vers HTTPS." - - sens: ingress - port: 443 - protocole: tcp - pair: externe - chiffrement: tls-requis - raison: "HTTPS entrant — services exposés (terminaison TLS à l'edge)." + raison: "HTTP depuis le reseau d'administration — redirection permanente vers HTTPS." # L'edge sert AUSSI les hôtes du tenant : les FQDN publiés (`expose`) n'existent qu'ici, # et un service interne qui doit joindre un autre service passe par son nom publié — pas # par une adresse. C'est le cas de toute découverte OIDC : oauth2-proxy interroge diff --git a/scripts/appliquer_proxmox_fw.py b/scripts/appliquer_proxmox_fw.py index abdfcc1..e8f8539 100644 --- a/scripts/appliquer_proxmox_fw.py +++ b/scripts/appliquer_proxmox_fw.py @@ -74,6 +74,11 @@ def _norm(cidr: str) -> str: return c +def _membre(m: str) -> dict: + """Corps de l'API pour un membre d'IPSet : `!cidr` devient une exclusion (`nomatch`).""" + return {"cidr": m[1:], "nomatch": 1} if m.startswith("!") else {"cidr": m} + + 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(), @@ -130,7 +135,10 @@ def plan(api: Cluster, devis: dict) -> dict: ipsets_poses = {} for s in (_lire(api, "/cluster/firewall/ipset") or []): 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 []) + # `nomatch` : une EXCLUSION (l'IPSet « internet » exclut les blocs prives). On la + # lit comme on l'ecrit, prefixee de `!` — sinon elle se lirait comme une inclusion. + membres = [("!" if m.get("nomatch") else "") + str(m.get("cidr")) + for m in (api(f"/cluster/firewall/ipset/{s['name']}") or []) if isinstance(m, dict)] ipsets_poses[s["name"]] = sorted({_norm(m) for m in membres}) @@ -243,12 +251,12 @@ def appliquer(api: Cluster, p: dict) -> int: if _fait(api("/cluster/firewall/ipset", "POST", {"name": n, "comment": v["role"][:100]}), f"ipset {n}"): for m in v["membres"]: - _fait(api(f"/cluster/firewall/ipset/{n}", "POST", {"cidr": m}), f"{n} += {m}") + _fait(api(f"/cluster/firewall/ipset/{n}", "POST", _membre(m)), f"{n} += {m}") for n, (avant, apres) in sorted(p["ipsets_majer"].items()): for m in sorted(set(apres) - set(avant)): - _fait(api(f"/cluster/firewall/ipset/{n}", "POST", {"cidr": m}), f"{n} += {m}") + _fait(api(f"/cluster/firewall/ipset/{n}", "POST", _membre(m)), f"{n} += {m}") for m in sorted(set(avant) - set(apres)): - _fait(api(f"/cluster/firewall/ipset/{n}/{m}", "DELETE"), f"{n} -= {m}") + _fait(api(f"/cluster/firewall/ipset/{n}/{m.lstrip('!')}", "DELETE"), f"{n} -= {m}") # 2. Groupes. Une mise a jour REMPLACE les regles : elles sont a nous, et les # comparer une a une pour un edit partiel couterait plus que de les reposer. diff --git a/scripts/devis_proxmox_fw.py b/scripts/devis_proxmox_fw.py index f16eab5..9a168df 100644 --- a/scripts/devis_proxmox_fw.py +++ b/scripts/devis_proxmox_fw.py @@ -10,7 +10,9 @@ PARTAGE DES ROLES (cf. docs/sdn-evpn.md) : profondeur — une VM compromise franchit deux barrieres) ; - `make devis-opnsense` : tout ce qui ENTRE ou SORT d'un tenant. -Les flux `pair: externe` sont donc SAUTES ici : ils relevent de la frontiere. +Les flux `pair: externe` : la frontiere decide QUI entre (redirection, filtre WAN) ; ce +devis laisse ARRIVER ce qu'elle a laisse passer. Sans lui, un service publie serait rejete +par sa propre VM (`policy_in: REJECT`). Voir `_EXTERNE` plus bas. NON destructif : ce script n'ecrit rien sur le cluster. Il produit un devis a relire, puis (a venir) un JSON pour l'API Proxmox. @@ -30,6 +32,9 @@ from pathlib import Path import yaml RACINE = Path(__file__).resolve().parents[1] + +# L'Internet vu par un IPSet Proxmox : `!` = entree `nomatch` (exclusion). +INTERNET = ("0.0.0.0/1", "128.0.0.0/1", "!10.0.0.0/8", "!172.16.0.0/12", "!192.168.0.0/16") sys.path.insert(0, str(RACINE / "scripts")) from resoudre_flux import ( # noqa: E402 @@ -158,6 +163,14 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: # 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. + # L'INTERNET, SANS LES ESPACES PRIVES (2026-09-29). Source des flux `externe` : la + # meme definition que la frontiere (`!SETOPS_INTERNES`). Jamais une source vide — + # une regle sans source ouvre le port a tout le monde, voisins compris. Un IPSet + # Proxmox refuse `0.0.0.0/0` : deux moities, et les trois blocs RFC 1918 exclus. + ipsets[f"t{n['index']}-internet"] = { + "role": "Internet (tout sauf RFC 1918) — flux `externe`", + "membres": list(INTERNET), + } if admin_avec_tunnel(nom): ipsets[f"t{n['index']}-admin"] = { "role": "reseaux d'administration (intrant nftables_admin_ssh)", @@ -190,9 +203,28 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: # la moitie `flotte` — plus aucun hote joignable en SSH depuis # l'interieur, Ansible compris. Meme piege pour le SMTP interne de # Postfix, declare `[externe, client_smtp]`. + # CE QUE LA FRONTIERE LAISSE ENTRER DOIT POUVOIR ARRIVER (2026-09-29). Meme + # partage que `devis_opnsense` : le SSH `externe` est celui de la gestion, + # reserve a l'administration (garde anti-lockout, plus bas) ; tout autre + # flux `externe` vient de l'Internet, et du poste s'il en est client + # (`poste`, vrai par defaut — le 25 de Postfix ne l'est pas). + if "externe" in pairs and not ( + str(fl.get("protocole", "tcp")).lower() != "icmp" and "22" in _ports(fl)): + sources = [(f"t{n['index']}-internet", "internet")] + if fl.get("poste", True) and admin_avec_tunnel(nom): + sources.append((f"t{n['index']}-admin", "administration (poste)")) + for cle_src, via in sources: + utilises.add(cle_src) + for cible in _cibles(fl): + regles.append({ + "action": "ACCEPT", "sens": "IN", + "source": f"+{cle_src}", "via": via, + **cible, + "raison": fl.get("raison", ""), + }) pairs = [p for p in pairs if p != "externe"] if not pairs: - continue # purement externe : la frontiere s'en charge + continue if pairs == ["localhost"]: continue # boucle locale : aucune regle inter-noeud if not _resoudre_sources(data, fl.get("pair"), tous, ips): @@ -324,7 +356,7 @@ def rendre(devis: dict) -> str: "# barrieres disent la meme chose, et ne peuvent pas diverger.", "#", "# Ce qui ENTRE ou SORT d'un tenant n'est PAS ici : c'est la frontiere", - "# (`make devis-opnsense`). Les flux `pair: externe` sont sautes.", + "# (`make devis-opnsense`). Les flux `pair: externe` arrivent depuis `+t-internet`.", "# ============================================================", "", "## 0. Prerequis — TROIS verrous, et le REJECT se pose PAR VM", diff --git a/scripts/tests/test_proxmox_fw_externe.py b/scripts/tests/test_proxmox_fw_externe.py new file mode 100644 index 0000000..2ca1828 --- /dev/null +++ b/scripts/tests/test_proxmox_fw_externe.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Le pare-feu Proxmox laisse ARRIVER ce que la frontiere laisse entrer — et l'edge reste +ferme a l'Internet. + +POURQUOI (2026-09-29). Le devis est-ouest sautait tout flux `externe`, « la frontiere s'en +charge ». Mais depuis que les VM rejettent par defaut, un service publie (Postfix 25, Dovecot +993) aurait ete refuse par sa propre VM, et le poste ne joignait pas Dovecot. A l'inverse, +l'edge declarait 80/443 `externe` : la frontiere posait « WAN, n'importe qui -> edge ». Or +l'edge n'expose les interfaces qu'a la zone d'administration ; le public passera par le web +frontal. + +Invariants gardes, sur le devis rendu depuis les plans montes : + 1. `serveur_nginx` ne declare aucun flux `externe`, et aucune regle d'edge ne vient de + `+t-internet` ; + 2. tout flux entrant `externe` (hors SSH, reserve a l'administration) d'un role porte + donne une regle depuis `+t-internet` ; + 3. l'IPSet « internet » exclut les trois blocs RFC 1918, et aucune regle n'a de source vide ; + 4. `appliquer_proxmox_fw` pose une exclusion en `nomatch`. +""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import yaml + +RACINE = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(RACINE / "scripts")) + + +def flux(role: str) -> list[dict]: + f = RACINE / "roles" / role / "meta" / "flux.yml" + return (yaml.safe_load(f.read_text(encoding="utf-8")) or {}).get("flux") or [] if f.exists() else [] + + +def pairs(fl: dict) -> list[str]: + p = fl.get("pair") + return [str(x) for x in (p if isinstance(p, list) else [p])] + + +def main() -> int: + echecs: list[str] = [] + if any("externe" in pairs(fl) for fl in flux("serveur_nginx")): + echecs.append("serveur_nginx declare un flux `externe` : l'edge serait ouvert a l'Internet") + + r = subprocess.run([sys.executable, str(RACINE / "scripts/devis_proxmox_fw.py"), "--json"], + capture_output=True, text=True, cwd=RACINE) + try: + devis = json.loads(r.stdout) + except ValueError: + print(f"SAUTE devis indisponible ici (pas de plans montes ?) : {r.stderr.strip()[:120]}") + devis = [] + blocs = devis if isinstance(devis, list) else devis.get("blocs", []) + for b in blocs: + idx = next((k.split("-")[0] for k in b.get("ipsets", {}) if k.endswith("-internet")), None) + roles = {g["role"]: g.get("regles", []) for g in b.get("groupes", [])} + for role, regles in roles.items(): + for rg in regles: + if not str(rg.get("source", "")).strip(): + echecs.append(f"{b['tenant']} {role} : regle sans source ({rg})") + if role == "serveur_nginx" and "-internet" in str(rg.get("source")): + echecs.append(f"{b['tenant']} : l'edge recoit l'Internet ({rg})") + for fl in flux(role): + if fl.get("sens") != "ingress" or "externe" not in pairs(fl): + continue + if str(fl.get("protocole", "tcp")).lower() != "icmp" and "22" in str(fl.get("port")): + continue + if not any("-internet" in str(rg.get("source")) for rg in regles): + echecs.append(f"{b['tenant']} {role} : flux `externe` {fl.get('port')} sans regle " + f"depuis l'Internet — la VM rejetterait ce que la frontiere laisse entrer") + if idx: + membres = b["ipsets"][f"{idx}-internet"]["membres"] + for prive in ("!10.0.0.0/8", "!172.16.0.0/12", "!192.168.0.0/16"): + if prive not in membres: + echecs.append(f"{b['tenant']} : l'IPSet internet n'exclut pas {prive[1:]}") + + from appliquer_proxmox_fw import _membre + if _membre("!10.0.0.0/8") != {"cidr": "10.0.0.0/8", "nomatch": 1} or _membre("1.2.3.0/24") != {"cidr": "1.2.3.0/24"}: + echecs.append("appliquer_proxmox_fw._membre ne rend pas `!cidr` en exclusion `nomatch`") + + for e in echecs: + print(f"ECHEC {e}") + if echecs: + return 1 + print(f"OK — edge ferme a l'Internet ; flux `externe` recus depuis +t-internet " + f"({len(blocs)} tenant(s)) ; exclusions RFC 1918 en `nomatch`.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/wiki/Rôle-serveur_nginx.md b/wiki/Rôle-serveur_nginx.md index c0d0775..db6a3ac 100644 --- a/wiki/Rôle-serveur_nginx.md +++ b/wiki/Rôle-serveur_nginx.md @@ -9,9 +9,8 @@ graph LR R["nginx"] E0["admin"] -->|"443 · tls-requis"| R - E1["externe"] -->|"443 · tls-requis"| R - E2["externe"] -->|"80 · clair"| R - E3["flotte"] -->|"443 · tls-requis"| R + E1["admin"] -->|"80 · clair"| R + E2["flotte"] -->|"443 · tls-requis"| R R -.->|"sonde « edge »"| ICINGA[["Icinga"]] ``` @@ -19,8 +18,7 @@ graph LR | port | depuis | chiffrement | pourquoi | |---|---|---|---| -| `80` | `externe` | clair | HTTP entrant — redirection permanente vers HTTPS. | -| `443` | `externe` | tls-requis | HTTPS entrant — services exposés (terminaison TLS à l'edge). | +| `80` | `admin` | clair | HTTP depuis le reseau d'administration — redirection permanente vers HTTPS. | | `443` | `flotte` | tls-requis | HTTPS depuis le tenant : les FQDN publiés vivent à l'edge (découverte OIDC, appels inter-services par nom). | | `443` | `admin` | tls-requis | HTTPS depuis le reseau d'administration : l'exploitant administre les services par leur interface web, servie par l'edge. | diff --git a/wiki/Rôles.md b/wiki/Rôles.md index 72d6b62..0968b00 100644 --- a/wiki/Rôles.md +++ b/wiki/Rôles.md @@ -55,7 +55,7 @@ Chaque fiche est construite depuis ce que le rôle **déclare lui-même** — `m | [`serveur_keycloak`](Rôle-serveur_keycloak) | 1 | 1 | — | | [`serveur_loki`](Rôle-serveur_loki) | 4 | 2 | — | | [`serveur_nextcloud`](Rôle-serveur_nextcloud) | 1 | 1 | — | -| [`serveur_nginx`](Rôle-serveur_nginx) | 4 | 1 | — | +| [`serveur_nginx`](Rôle-serveur_nginx) | 3 | 1 | — | | [`serveur_oauth2_proxy`](Rôle-serveur_oauth2_proxy) | 1 | 1 | — | | [`serveur_openldap`](Rôle-serveur_openldap) | 2 | 1 | — | | [`serveur_ops`](Rôle-serveur_ops) | 1 | 2 | — |