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:
parent
712a7da1fd
commit
e84b155a5f
4 changed files with 168 additions and 6 deletions
30
CHANGELOG.md
30
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
49
scripts/tests/test_pool_ips_publiques.py
Normal file
49
scripts/tests/test_pool_ips_publiques.py
Normal 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())
|
||||
Loading…
Reference in a new issue