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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-29 15:24:09 -04:00
parent 712a7da1fd
commit e84b155a5f
4 changed files with 168 additions and 6 deletions

View file

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

View file

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

View file

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

View file

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