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<i>-internet (RFC 1918
exclus en nomatch) et depuis l admin quand le poste en est client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-29 13:58:01 -04:00
parent 1bd211d62b
commit 1493059271
9 changed files with 184 additions and 24 deletions

View file

@ -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<i>-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<i>-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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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<index>-internet`.",
"# ============================================================",
"",
"## 0. Prerequis — TROIS verrous, et le REJECT se pose PAR VM",

View file

@ -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<i>-internet` ;
2. tout flux entrant `externe` (hors SSH, reserve a l'administration) d'un role porte
donne une regle depuis `+t<i>-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<i>-internet "
f"({len(blocs)} tenant(s)) ; exclusions RFC 1918 en `nomatch`.")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -9,9 +9,8 @@
graph LR
R["<b>nginx</b>"]
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. |

View file

@ -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 | — |