From e84b155a5fcbd09ed6bc1ec4d4c0fc675d69a1da Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 29 Sep 2026 15:24:09 -0400 Subject: [PATCH] pool d adresses publiques : le site attribue a chaque locataire la sienne Declare dans opnsense.yml du site, rendu par le contrat (ip_publique), valide (une par locataire, jamais celle du site), compare aux alias du WAN par frontiere-plan. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 30 +++++++++++ scripts/appliquer_opnsense.py | 28 ++++++++++ scripts/site_intrants.py | 67 +++++++++++++++++++++--- scripts/tests/test_pool_ips_publiques.py | 49 +++++++++++++++++ 4 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 scripts/tests/test_pool_ips_publiques.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 30f2c0f..d2729dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # CHANGELOG — Set-OPS +## 2026-09-29 (35) — Le pool d'adresses publiques du site ; chaque locataire reçoit la sienne + +**La règle** : l'hébergeur détient un pool d'adresses publiques, et c'est le site qui en +attribue une à chaque locataire — celle de son web frontal et de son courriel. + +**L'état du boîtier, relu avant d'écrire.** Le WAN de la frontière porte `69.70.26.62` +(le site) et quatre alias IP `/32` posés par l'exploitant : `.58`, `.59`, `.60`, `.61`. +L'outillage ne gère pas les adresses virtuelles : un `frontiere-appliquer` ne les retire pas. + +**Le pool, déclaré au site** (`SITE-Chezlepro/opnsense.yml`, `opnsense_ips_publiques`) : +`.61` → OPS-Chezlepro, `.60` → OPS-Technolibre, `.59` et `.58` libres. + +- **Le contrat du site** (`site_intrants.py`) rend `ip_publique` : l'adresse attribuée au + locataire monté. Les deux locataires la déclarent (`10-intrants.yml`) ; + `make site-intrants-verifier` est conforme chez les deux, et dit l'écart si l'un + recopie une autre adresse (éprouvé : `.59` déclarée chez Chezlepro → NON CONFORME, les deux + valeurs données). +- **Le pool est validé** au même endroit : adresse invalide ou privée, adresse du site + (`opnsense_wan_ip`), locataire que le site n'héberge pas, locataire servi deux fois — + refusés (`test_pool_ips_publiques.py`). +- **`make frontiere-plan`** compare, en lecture seule, le pool aux alias du WAN : une adresse + attribuée absente du WAN, ou une adresse du WAN hors du pool, est signalée. Aujourd'hui : + 4 au pool, 4 sur le WAN. +- `git.chezlepro.ca → 69.70.26.59` retiré de la zone publique de Chezlepro : une copie de + l'ancienne production, et `.59` n'y sert plus. + +`ip_publique_site` reste l'adresse du SITE (serveurs de noms publics). La suite : le web +frontal de chaque locataire, joint par son adresse (redirection à la frontière, TLS Let's +Encrypt). + ## 2026-09-29 (34) — La frontière appliquée : l'edge hors de l'Internet, l'administration traduite chez les tenants **Un premier plan refusé.** Retirer `externe` de l'edge faisait aussi disparaître, à la diff --git a/scripts/appliquer_opnsense.py b/scripts/appliquer_opnsense.py index f16b9c8..5848a35 100644 --- a/scripts/appliquer_opnsense.py +++ b/scripts/appliquer_opnsense.py @@ -587,6 +587,33 @@ def _charger(api: Frontiere, p: dict) -> int: return 0 +def _ips_publiques(api: Frontiere) -> None: + """Le pool d'adresses publiques du site face a ce que le WAN porte vraiment. + + LECTURE SEULE (2026-09-29). Les adresses virtuelles du WAN sont posees par l'exploitant, + sur le boitier ; cet outil ne les cree ni ne les retire. Mais une adresse attribuee a un + locataire et absente du WAN ne recevrait rien le jour de l'exposition, et une adresse + du WAN hors du pool est une adresse que personne n'a declaree : les deux se disent ici. + """ + from site_intrants import pool_ips_publiques + pool = pool_ips_publiques() + if not pool: + return + wan = {str(x.get("subnet")) for x in (_lire(api, "/api/interfaces/vip_settings/searchItem/") + .get("rows") or []) + if str(x.get("interface")) == "wan"} + manquantes = sorted(set(pool) - wan) + hors_pool = sorted(wan - set(pool)) + attribuees = sum(1 for q in pool.values() if q != "libre") + print(f" adresses publiques : {len(pool)} au pool ({attribuees} attribuee(s)), " + f"{len(wan)} sur le WAN") + for ip in manquantes: + print(f" !! {ip} ({pool[ip]}) est au pool mais ABSENTE du WAN — a poser sur le boitier") + for ip in hors_pool: + print(f" !! {ip} est sur le WAN mais HORS du pool — a declarer dans opnsense.yml") + print() + + def main() -> int: base = devis_mod.depot_hebergeur() if base is None: @@ -624,6 +651,7 @@ def main() -> int: print(f"Frontiere {url} — {len(devis['regles'])} regles au devis\n") p = plan(api, devis) + _ips_publiques(api) p["_uuid_alias"] = {x["name"]: x["uuid"] for x in (_lire(api, "/api/firewall/alias/searchItem/", {"rowCount": 1000}).get("rows") or [])} # UNE ROUTE ABSENTE DU NOYAU EST DU TRAVAIL, MEME SI LE MODELE EST A JOUR. diff --git a/scripts/site_intrants.py b/scripts/site_intrants.py index 682c10d..a35ca52 100644 --- a/scripts/site_intrants.py +++ b/scripts/site_intrants.py @@ -37,7 +37,7 @@ RACINE = Path(__file__).resolve().parent.parent sys.path.insert(0, str(RACINE / "scripts")) import underlay as underlay_mod # noqa: E402 -from inventory_rules import dossier_inventaire # noqa: E402 +from inventory_rules import dossier_inventaire, instance_courante # noqa: E402 import yaml # noqa: E402 PORT_CACHE = 3142 @@ -66,17 +66,64 @@ def _nom_expose(groupe: str, domaine: str) -> str: return f"{hote}.{domaine}" if hote and domaine else "" -def _ip_publique_site() -> str: - """L'adresse publique de la frontiere du site, lue dans sa carte (`opnsense.yml`).""" +def _opnsense() -> dict: + """La carte de la frontiere du site (`opnsense.yml`), ou {}.""" try: chemin = underlay_mod.chemin() f = chemin.resolve().parent / "opnsense.yml" if chemin else None if f and f.is_file(): - return str((yaml.safe_load(f.read_text(encoding="utf-8")) or {}) - .get("opnsense_wan_ip") or "") + return yaml.safe_load(f.read_text(encoding="utf-8")) or {} except Exception: pass - return "" + return {} + + +def _ip_publique_site() -> str: + """L'adresse publique de la frontiere du site, lue dans sa carte (`opnsense.yml`).""" + return str(_opnsense().get("opnsense_wan_ip") or "") + + +LIBRE = "libre" + + +def pool_ips_publiques(carte: dict | None = None) -> dict[str, str]: + """{adresse : locataire | 'libre'} — le pool que le site attribue (`opnsense.yml`).""" + carte = _opnsense() if carte is None else carte + return {str(k): str(v) for k, v in (carte.get("opnsense_ips_publiques") or {}).items()} + + +def valider_pool(pool: dict[str, str], wan: str, locataires: list[str] | None) -> list[str]: + """Les fautes du pool : adresse invalide, adresse du site, locataire inconnu ou servi + deux fois. Une adresse attribuee a deux locataires est impossible par construction + (la cle est l'adresse) ; un locataire qui en recoit deux, lui, ne l'est pas.""" + import ipaddress + fautes: list[str] = [] + vus: dict[str, str] = {} + for ip, qui in sorted(pool.items()): + try: + a = ipaddress.ip_address(ip) + except ValueError: + fautes.append(f"`{ip}` n'est pas une adresse IP") + continue + if a.is_private or a.is_loopback: + fautes.append(f"`{ip}` n'est pas une adresse publique") + if ip == wan: + fautes.append(f"`{ip}` est l'adresse du site (opnsense_wan_ip) : elle n'est pas a attribuer") + if qui == LIBRE: + continue + if locataires is not None and qui not in locataires: + fautes.append(f"`{ip}` attribuee a « {qui} », que le site n'heberge pas " + f"(underlay : {', '.join(locataires) or 'aucun'})") + if qui in vus: + fautes.append(f"« {qui} » recoit deux adresses ({vus[qui]}, {ip}) : une par locataire") + vus[qui] = ip + return fautes + + +def ip_publique_de(locataire: str, pool: dict[str, str] | None = None) -> str: + """L'adresse publique que le site attribue a ce locataire, ou ''.""" + pool = pool_ips_publiques() if pool is None else pool + return next((ip for ip, qui in sorted(pool.items()) if qui == locataire), "") def contrat() -> dict: @@ -113,6 +160,9 @@ def contrat() -> dict: # VERS OU POINTENT SES NOMS PUBLICS — l'adresse publique de la frontiere. Un fait # du site : le locataire la recoit, il ne la recopie pas. "ip_publique_site": _ip_publique_site(), + # L'ADRESSE PUBLIQUE DE CE LOCATAIRE — tiree du pool du site, attribuee par lui + # (`opnsense_ips_publiques`). Celle de son web frontal et de son courriel. + "ip_publique": ip_publique_de(instance_courante().name), # SOUS QUEL NOM LE SITE FAIT AUTORITE — les NS et le SOA de chaque zone publique. "dns_public_nom": str(intrants.get("dns_public_nom") or ""), # QUI A LE DROIT D'ENTRER EN SSH @@ -136,6 +186,7 @@ OU_LE_LOCATAIRE_LE_DIT = { "client_backup_cible": ("client_backup.yml", "client_backup_cible"), "dns_public_site": ("all/10-intrants.yml", "dns_public_site"), "ip_publique_site": ("all/10-intrants.yml", "ip_publique_site"), + "ip_publique": ("all/10-intrants.yml", "ip_publique"), "dns_public_nom": ("all/10-intrants.yml", "dns_public_nom"), # CES DEUX-LA MANQUAIENT, ET LE SILENCE A COUTE (2026-09-13). Le site en expose SEPT ; # cette table n'en comparait que CINQ. Le controle rapportait donc « CONFORME » avec @@ -191,6 +242,10 @@ def verifier() -> int: return 0 vu = _declare_par_le_locataire() ecarts, absents = [], [] + # LE POOL D'ABORD : une attribution fautive rendrait fausse la valeur meme a comparer. + ecarts += [f"pool d'adresses publiques du site : {f}" for f in valider_pool( + pool_ips_publiques(), _ip_publique_site(), + underlay_mod.tenants_du_site(underlay_mod.charger()))] for clef, attendu in c.items(): if clef not in OU_LE_LOCATAIRE_LE_DIT or not attendu: continue diff --git a/scripts/tests/test_pool_ips_publiques.py b/scripts/tests/test_pool_ips_publiques.py new file mode 100644 index 0000000..538fc56 --- /dev/null +++ b/scripts/tests/test_pool_ips_publiques.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Le pool d'adresses publiques du site : une par locataire, jamais celle du site. + +POURQUOI (2026-09-29). C'est le SITE qui attribue a chaque locataire son adresse publique, +tiree d'un pool declare dans `opnsense.yml` ; le locataire la recoit par le contrat du site. +Avant, une seule adresse (`ip_publique_site`) etait recopiee a la main chez les deux +locataires. Une attribution fautive ne se verrait qu'au jour de l'exposition : deux +locataires sur la meme adresse, ou un web frontal sur l'adresse de la frontiere. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from site_intrants import ip_publique_de, valider_pool # noqa: E402 + +WAN = "69.70.26.10" +LOCATAIRES = ["OPS-A", "OPS-B"] + + +def main() -> int: + echecs = 0 + sain = {"69.70.26.11": "OPS-A", "69.70.26.12": "OPS-B", "69.70.26.13": "libre"} + if valider_pool(sain, WAN, LOCATAIRES): + print(f"ECHEC pool sain refuse : {valider_pool(sain, WAN, LOCATAIRES)}") + echecs += 1 + if ip_publique_de("OPS-B", sain) != "69.70.26.12" or ip_publique_de("OPS-C", sain) != "": + print("ECHEC ip_publique_de ne rend pas l'attribution du pool") + echecs += 1 + cas = { + "deux adresses pour un locataire": {"69.70.26.11": "OPS-A", "69.70.26.12": "OPS-A"}, + "adresse du site attribuee": {WAN: "OPS-A"}, + "locataire inconnu du site": {"69.70.26.11": "OPS-Z"}, + "adresse privee": {"10.0.0.5": "OPS-A"}, + "adresse invalide": {"pas-une-ip": "OPS-A"}, + } + for nom, pool in cas.items(): + if not valider_pool(pool, WAN, LOCATAIRES): + print(f"ECHEC « {nom} » accepte") + echecs += 1 + if echecs: + return 1 + print(f"OK — pool sain accepte ; {len(cas)} fautes refusees ; attribution lue par locataire.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())