inter-sites : le second serveur de noms, sur l'autre site, par le tunnel

- underlay.py : sites pairs lus dans leur depot frere (sites_pairs_actifs, sur_le_site,
  dns_public_des_pairs).
- Contrat du site : dns_public_pairs ; serveur_powerdns notifie et admet le pair ;
  resoudre_flux l'admet sur 5300.
- devis_opnsense : opnsense_liens_sites (regles et routes selon ou vit chaque locataire) ;
  P24 juge les routes du lien a part ; noms d'alias < 32 caracteres gardes ; le refus du
  groupe wireguard ne tranche plus (quick=0), et la garde le verifie.
- appliquer_opnsense : quick=0 transmis et dans l'identite d'une regle.
- proxmox_api : SETOPS_PROXMOX_API pour joindre l'API par un rebond.

Temoin depuis l'Internet : les deux zones repondent sur 51.79.45.193, signees.
95 preuves (make verifier), make test passe. CHANGELOG 149.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-14 03:26:52 -04:00
parent 65a4268871
commit 968caed3b1
10 changed files with 298 additions and 5 deletions

View file

@ -1,3 +1,45 @@
## 2026-10-14 (149) — Le second serveur de noms, sur l'autre site, par le tunnel
**95 preuves.** `make verifier` passe (site de Chezlepro), `make test` passe. Témoin de bout en
bout, depuis l'Internet : `chezlepro.ca` et `technolibre.ca` répondent sur l'adresse publique du
site de TechnoLibre (`51.79.45.193:53`, par le relais), signées DNSSEC, à la série du dernier
transfert ; une zone inconnue reçoit `REFUSED`. Un NOTIFY forcé des primaires fait retirer les
deux zones par le secondaire une seconde plus tard.
**La réplication du DNS public passe par le lien entre les sites (phase 2).**
`dns_public_pairs` est `actif` des deux côtés.
- `underlay.py` lit un site PAIR dans son dépôt frère par les mêmes fonctions qu'il emploie pour
lui-même (`sites_pairs_actifs`, `sur_le_site`, `dns_public_des_pairs`) — pas de seconde
lecture du plan qui finirait par diverger.
- Le contrat du site porte `dns_public_pairs` ; les primaires des locataires le notifient
(`only-notify`, `also-notify`) et le laissent frapper sur 5300 (pare-feu de la machine et
pare-feu Proxmox).
- `opnsense_liens_sites` : le devis dérive, selon OÙ VIT chaque locataire (la fiche qu'il
porte), l'entrée du transfert par le tunnel et la sortie des NOTIFY là où il vit, les routes
/32 vers ses primaires et l'entrée des NOTIFY là où il est répliqué. P24 juge ces routes à
part : chacune doit désigner une adresse qu'une règle du lien utilise.
- `bifrost-1` : `wg0` assigné (`opt11`), passerelle `INTERSITES_GW` ; `requin` : sa passerelle.
- Témoin : le devis de Chezlepro ne change que par le lien (deux alias, quatre règles, une
route) ; chez les locataires, seule `infra-dns-01` change.
**Trois règles d'OPNsense apprises sur la frontière de production.**
- **Le groupe `wireguard` contient toutes les interfaces WireGuard, assignées comprises, et ses
règles passent avant les leurs** : le refus audible `quick` du groupe (posé pour les tunnels
d'administration) a rejeté le transfert avant les règles d'`opt11` — et aurait rejeté tout
ce qui arrive par le relais sur `requin`. Il ne tranche plus (`quick=0`) ; le devis refuse un
refus `quick` sur ce groupe dès qu'une interface WireGuard assignée porte ses règles.
- **Un nom d'alias fait moins de 32 caractères** : `SETOPS_DNS_PUBLIC_SITE_TECHNOLIBRE` (34) a
été refusé, et ses quatre règles avec. Gardé au devis, avant tout envoi.
- **Enregistrer une instance WireGuard avec un seul champ vide ses relations** (`peers`).
**Le pare-feu Proxmox de Chezlepro**, rattrapé au passage : la règle `srv-dovecot` du port
12345 (SASL supprimé par `63f5fda`) n'avait jamais été retirée ; commentaires de `srv-loki` et
`srv-postgresql` à jour. Hors du site, l'API n'est joignable que par un rebond :
`SETOPS_PROXMOX_API=hote:port` le désigne le temps d'une commande, sans toucher à la carte.
**Reste** : un nom pour ce second serveur (`dns_public_nom` du site de TechnoLibre), puis les
enregistrements NS des zones et du registre ; la phase 3, les sauvegardes croisées.
## 2026-10-14 (148) — Le lien entre les sites, et ce qu'il a montré en ne devant rien ouvrir
**95 preuves.** `make verifier` passe (site de Chezlepro), `make test` passe. Témoin : avec le lien

View file

@ -95,6 +95,9 @@ serveur_powerdns_sonde_tolerance: 900
# tire les zones, et l'adresse publique vers laquelle elles pointent. Le locataire ne les
# recopie pas — il les recoit, comme ses adresses d'amorcage.
serveur_powerdns_dns_public_site: "{{ dns_public_site | default('') }}"
# LES SERVEURS PUBLICS DES SITES PAIRS (2026-10-14) — le second serveur de noms, sur un autre
# site, joint par le tunnel entre les sites. Du contrat du site, comme `dns_public_site`.
serveur_powerdns_dns_public_pairs: "{{ dns_public_pairs | default([]) }}"
serveur_powerdns_ip_publique: "{{ ip_publique_site | default('') }}"
# L'ADRESSE DES NOMS EXPOSES : celle DU LOCATAIRE (2026-09-29), que le site lui attribue et
# que la frontiere redirige vers son web frontal. `ip_publique_site` ne porte plus que les

View file

@ -75,6 +75,7 @@
# `allow-axfr-ips` SANS signature — mesure le 2026-09-16. Le serveur public du site
# ne doit donc jamais y figurer.
- serveur_powerdns_dns_public_site not in (serveur_powerdns_allow_axfr_ips | default([]))
- serveur_powerdns_dns_public_pairs | intersect(serveur_powerdns_allow_axfr_ips | default([])) | length == 0
- (vault_tsig_dns_public | default('')) | length > 0
# LE NOM DU SERVEUR DE NOMS VIENT DU SITE. Sans lui, NS et SOA seraient vides.
- serveur_powerdns_dns_public_nom is search('^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+\.?$')

View file

@ -45,8 +45,10 @@ allow-axfr-ips=127.0.0.1/32
# Par defaut, un primaire notifie AUSSI les adresses de ses enregistrements NS. Ici, ce
# sont les adresses PUBLIQUES des serveurs de noms : le primaire enverrait des
# notifications vers Internet depuis l'interieur du locataire. `only-notify` les borne.
only-notify={{ serveur_powerdns_dns_public_site }}/32
also-notify={{ serveur_powerdns_dns_public_site }}
# ET CEUX DES SITES PAIRS (2026-10-14) : le second serveur de noms tire la zone par le tunnel
# entre les sites, et doit apprendre qu'elle a change comme le premier.
only-notify={{ ([serveur_powerdns_dns_public_site] + serveur_powerdns_dns_public_pairs) | map('regex_replace', '$', '/32') | join(',') }}
also-notify={{ ([serveur_powerdns_dns_public_site] + serveur_powerdns_dns_public_pairs) | join(',') }}
version-string=anonymous
security-poll-suffix=

View file

@ -142,7 +142,8 @@ def cle_regle(r: dict) -> str:
# n'a plus la cle du devis : elle est recreee, au lieu de passer pour conforme alors que
# ses reponses partent par le WAN et se perdent.
via = (f"|reply:{r['replyto']}" if r.get("replyto") else "") + \
(f"|via:{r['passerelle']}" if r.get("passerelle") else "")
(f"|via:{r['passerelle']}" if r.get("passerelle") else "") + \
("|lent" if r.get("rapide") is False else "")
return (f"setops:{marque}{r['tenant']}:{r['interface']}:{r['sens']}:{r['protocole']}:"
f"{r['source']}->{r['destination']}:{ports}{via}")
@ -306,6 +307,10 @@ def _corps_regle(r: dict, k: str) -> dict:
corps["replyto"] = r["replyto"]
if r.get("passerelle"):
corps["gateway"] = r["passerelle"]
# `quick=0` : la regle ne tranche pas, le dernier verdict qui correspond l'emporte (voir le
# refus du groupe `wireguard` dans le devis).
if r.get("rapide") is False:
corps["quick"] = "0"
return corps

View file

@ -1491,6 +1491,102 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"raison": str(s.get("motif") or "bruit de journal, deja refuse par defaut"),
})
# LE LIEN ENTRE LES SITES (2026-10-14) — la replication du DNS public par le tunnel.
#
# `opnsense_liens_sites` declare, par site pair, l'interface du tunnel ; le prochain saut
# est la `frontiere_pair` de ce site dans la carte. Rien n'est emis tant que le pair n'est
# pas ACTIF des deux cotes (`dns_public_pairs`, P82).
#
# OU TOURNE UN LOCATAIRE ? Les deux cartes le declarent — l'une parce qu'elle l'heberge,
# l'autre pour pouvoir le reprendre. La reponse est la fiche qu'il porte : le site qui la
# lui a remise est celui ou il vit. Les regles s'inversent selon elle :
# - la ou il vit : le transfert ENTRE par le tunnel (5300 vers ses primaires), et ses
# NOTIFY SORTENT vers le serveur public du pair, route par le tunnel ;
# - la ou il est replique : ses primaires sont ROUTES par le tunnel (/32 — ils priment
# sur les routes de reprise), et ses NOTIFY ENTRENT par le tunnel.
_liens_sites = [l for l in (_intr.get("opnsense_liens_sites") or [])
if str(l.get("site") or "") in underlay_mod.sites_pairs_actifs()]
def _site_reel(_nom_t: str) -> str:
try:
import devis_reseau as _dr
_f = _dr.DOSSIER_INSTANCES / _nom_t / "fiche-site.yml"
return str((yaml.safe_load(_f.read_text(encoding="utf-8")) or {}).get("site") or "")
except (OSError, ImportError):
return ""
def _primaires_publiees(_nom_t: str) -> list[str]:
_face = face_publiee(_nom_t)
if _face is not None and not (_face.get("dns_public") or {}).get("zones"):
return []
return cibles_du_tenant("serveur_powerdns", _nom_t)
_ici = underlay_mod.chemin().resolve().parent.name if underlay_mod.chemin() else ""
for _lien in _liens_sites:
_pair = str(_lien["site"])
_if_lien = str(_lien.get("interface") or "")
_saut = next((str(h["ip"]) for h in underlay_mod.hotes(_u)
if str(h.get("role")) == "frontiere_pair" and str(h.get("site")) == _pair
and h.get("ip")), "")
if not _if_lien or not _saut:
print(f"note : lien vers {_pair} sans interface ou sans `frontiere_pair` dans la "
f"carte — aucune regle emise.", file=sys.stderr)
continue
with underlay_mod.sur_le_site(_pair):
_dns_pair = sorted(underlay_mod.adresses_site_portant("serveur_dns_public"))
_ici_vivent = sorted({ip for _n, _p, _x in tenants if _site_reel(_n) == _ici
for ip in _primaires_publiees(_n)})
_la_bas = sorted({ip for _n, _p, _x in tenants if _site_reel(_n) == _pair
for ip in _primaires_publiees(_n)})
# MOINS DE 32 CARACTERES, regle d'OPNsense : `SETOPS_DNS_PUBLIC_SITE_TECHNOLIBRE` (34)
# a ete refuse par le boitier le 2026-10-14. Le nom du pair, sans son prefixe `SITE-`.
_court = _pair.upper().replace("-", "_").removeprefix("SITE_")
_a_dns_pair = f"SETOPS_DNSPUB_{_court}"
if _ici_vivent and _dns_pair:
alias[_a_dns_pair] = {"type": "host", "contenu": _dns_pair,
"description": f"Serveur DNS public du site pair {_pair}"}
alias["SETOPS_DNS_PRIMAIRES_ICI"] = {
"type": "host", "contenu": _ici_vivent,
"description": "Primaires DNS des locataires qui vivent sur ce site"}
for _proto in ("tcp", "udp"):
regles.append({
"sens": "in", "interface": _if_lien, "protocole": _proto,
"source": _a_dns_pair, "destination": "SETOPS_DNS_PRIMAIRES_ICI",
"ports": ["5300"], "chiffrement": "clair", "role": "serveur_powerdns",
"tenant": "INTERSITES",
"raison": f"Le serveur public de {_pair} tire les zones par le tunnel (AXFR "
f"signe TSIG, SOA avant). La cle decide qui lit ; ceci, qui frappe.",
})
regles.append({
"sens": "out", "interface": if_transit, "protocole": _proto,
"source": "SETOPS_DNS_PRIMAIRES_ICI", "destination": _a_dns_pair,
"ports": ["53"], "chiffrement": "clair", "role": "serveur_powerdns",
"tenant": "INTERSITES",
"raison": f"NOTIFY des primaires vers le serveur public de {_pair} : une zone "
f"publique a change. Son contenu n'a rien de confidentiel.",
})
for _ip in _dns_pair:
routes.append({"reseau": f"{_ip}/32", "prochain_saut": _saut, "tenant": "INTERSITES",
"description": f"Serveur DNS public de {_pair}, par le tunnel"})
if _la_bas and "SETOPS_SITE_SERVEUR_DNS_PUBLIC" in alias:
alias["SETOPS_DNS_PRIMAIRES_DISTANTS"] = {
"type": "host", "contenu": _la_bas,
"description": f"Primaires DNS des locataires qui vivent sur {_pair}"}
for _proto in ("tcp", "udp"):
regles.append({
"sens": "in", "interface": _if_lien, "protocole": _proto,
"source": "SETOPS_DNS_PRIMAIRES_DISTANTS",
"destination": "SETOPS_SITE_SERVEUR_DNS_PUBLIC",
"ports": ["53"], "chiffrement": "clair", "role": "serveur_dns_public",
"tenant": "INTERSITES",
"raison": f"NOTIFY des primaires de {_pair}, par le tunnel : une zone "
f"publique a change, le second serveur de noms la retire.",
})
for _ip in _la_bas:
routes.append({"reseau": f"{_ip}/32", "prochain_saut": _saut, "tenant": "INTERSITES",
"description": f"Primaire DNS d'un locataire de {_pair}, par le "
f"tunnel (prime sur la route de reprise)"})
# L'INTERIEUR REFUSE A VOIX HAUTE, LA FRONTIERE AUSSI (2026-10-01).
#
# Les machines refusaient deja en le disant (nftables : `reject` admin-prohibited ;
@ -1527,6 +1623,14 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"chiffrement": "sans objet",
"raison": ("Refus par defaut AUDIBLE sur une interface interne : un flux non "
"declare echoue tout de suite au lieu d'expirer. Le WAN reste muet."),
# LE GROUPE `wireguard` NE TRANCHE PAS (2026-10-14). OPNsense y range TOUTES les
# interfaces WireGuard, assignees comprises (relais, lien entre les sites), et
# evalue les regles de groupe AVANT celles des interfaces : un refus `quick` sur le
# groupe a rejete le transfert DNS du site pair avant que les regles d'`opt11` ne
# le laissent passer (mesure sur `bifrost-1`, regle n° 71). Non definitif, il ne
# sert que si rien d'autre ne correspond — les tunnels d'administration gardent
# leur refus audible, les interfaces assignees decident pour elles-memes.
**({"rapide": False} if _itf == if_vpn else {}),
})
# DEUX DECLARATIONS JUSTES NE FONT QU'UNE REGLE (2026-09-10).
@ -1816,6 +1920,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
**({"if_publique": if_public, "passerelle_publique": gw_public} if gw_public else {}),
"if_transit": _ou_marqueur(intrants, "opnsense_if_transit", IF_TRANSIT),
"if_gestion": _ou_marqueur(intrants, "opnsense_if_gestion", IF_GESTION),
"if_vpn": if_vpn,
"reseaux_gestion": reseaux_gestion,
"alias": alias,
"routes": routes,
@ -2051,7 +2156,49 @@ def verifier(devis: dict) -> tuple[bool, list[str]]:
# `connect()` vers une adresse inexistante aboutissait toujours. Un alias plus etroit
# que les routes est la panne symetrique : la frontiere achemine vers un reseau que
# le filtre refuse. Les deux se voient ici, pas a l'usage.
routes_declarees = {str(r["reseau"]) for r in devis.get("routes") or []}
# LE GROUPE `wireguard` NE TRANCHE PAS quand une interface WireGuard assignee porte ses
# propres regles (2026-10-14) : son refus `quick`, evalue avant elles, les rendrait mortes.
_vpn = str(devis.get("if_vpn") or "wireguard")
_assignees = {str(devis.get("if_publique") or "")} | {
str(r["interface"]) for r in devis["regles"] if r.get("tenant") == "INTERSITES"}
_assignees.discard("")
if any(r["interface"] in _assignees and str(r.get("action") or "pass") == "pass"
for r in devis["regles"]):
for r in devis["regles"]:
if (r["interface"] == _vpn and str(r.get("action")) in ("reject", "block")
and r.get("rapide") is not False and str(r.get("source")) == "any"):
erreurs.append(f"refus `quick` sur le groupe `{_vpn}` : evalue avant les regles "
f"des interfaces WireGuard assignees ({', '.join(sorted(_assignees))}), "
f"il les rendrait mortes.")
# UN NOM D'ALIAS QU'OPNSENSE ACCEPTE (2026-10-14) : il commence par une lettre ou un seul
# souligne, fait MOINS de 32 caracteres, et ne contient que lettres, chiffres et
# soulignes. Le boitier refusait `SETOPS_DNS_PUBLIC_SITE_TECHNOLIBRE` (34) — et avec lui
# les quatre regles qui le citaient. La garde le dit ici, avant tout envoi.
import re as _re
for _n in sorted(devis["alias"]):
if not _re.fullmatch(r"(?:[A-Za-z]|_(?!_))[A-Za-z0-9_]{0,30}", _n):
erreurs.append(f"alias `{_n}` ({len(_n)} caracteres) : OPNsense le refuserait "
"(moins de 32 caracteres, lettres, chiffres et soulignes).")
# LES ROUTES DU LIEN ENTRE LES SITES (2026-10-14) se jugent a part : elles visent des
# adresses d'un autre site (serveur public du pair, primaires distants), jamais un
# reseau de locataire. Meme principe, en /32 : chacune doit designer une adresse d'un
# alias du lien, et cet alias doit servir a une regle — rien de route que le filtre
# refuserait.
_lien = [r for r in devis.get("routes") or [] if r.get("tenant") == "INTERSITES"]
_alias_lien = {n for n in devis["alias"] if n.startswith("SETOPS_DNSPUB_")
or n == "SETOPS_DNS_PRIMAIRES_DISTANTS"}
_servis = {n for n in _alias_lien for r in devis["regles"]
if n in (str(r["source"]).lstrip("!"), str(r["destination"]).lstrip("!"))}
_adresses_lien = {f"{c}/32" for n in _servis for c in devis["alias"][n]["contenu"]}
for r in _lien:
if str(r["reseau"]) not in _adresses_lien:
erreurs.append(f"{r['reseau']} est route par le lien entre les sites, mais aucune "
"regle du lien ne la designe : la frontiere y acheminerait des "
"paquets que le filtre refuserait.")
routes_declarees = {str(r["reseau"]) for r in devis.get("routes") or []
if r.get("tenant") != "INTERSITES"}
autorises: set[str] = set()
for nom, a in devis["alias"].items():
if nom.startswith("SETOPS_TENANT_"):

View file

@ -92,6 +92,15 @@ class Cluster:
base = hebergeur()
heb = yaml.safe_load((base / "proxmox-hebergeur.yml").read_text(encoding="utf-8")) or {}
v = voute(base)
# LE TEMPS D'UNE COMMANDE, PAR UN REBOND (2026-10-14). Hors du site, l'API du cluster
# n'est joignable que par une redirection SSH a travers la frontiere ; la carte, elle,
# continue de dire l'adresse reelle. `SETOPS_PROXMOX_API=hote:port` la remplace pour
# cette seule invocation — jamais ecrit nulle part.
_rebond = os.environ.get("SETOPS_PROXMOX_API", "").strip()
if _rebond:
_h, _, _p = _rebond.rpartition(":")
return cls(_h, int(_p), heb.get("proxmox_api_user"), v["proxmox_api_token_id"],
v["proxmox_api_token_secret"]), base
return cls(heb.get("proxmox_api_host"), heb.get("proxmox_api_port", 8006),
heb.get("proxmox_api_user"), v["proxmox_api_token_id"],
v["proxmox_api_token_secret"]), base

View file

@ -539,7 +539,10 @@ def _adresses_dns_public_site() -> list[str]:
"""
try:
import underlay as underlay_mod
return underlay_mod.adresses_site_portant("serveur_dns_public")
# Et ceux des sites PAIRS actifs (2026-10-14) : le second serveur de noms frappe par
# le tunnel entre les sites. Vide tant que la replication n'est pas activee.
return sorted(set(underlay_mod.adresses_site_portant("serveur_dns_public"))
| set(underlay_mod.dns_public_des_pairs()))
except Exception:
return []

View file

@ -160,6 +160,10 @@ def contrat() -> dict:
# QUI TIRE SES ZONES PUBLIQUES — le seul serveur a qui le primaire notifie, et le
# seul que son pare-feu laisse frapper. Une ADRESSE : c'est un pair de flux.
"dns_public_site": _une("serveur_dns_public"),
# ET LES SERVEURS PUBLICS DES SITES PAIRS (2026-10-14) — le second serveur de noms, sur
# un autre site, par le tunnel entre les sites. Vide tant que `dns_public_pairs` n'est
# pas `actif` des deux cotes : rien ne change pour le locataire avant.
"dns_public_pairs": underlay_mod.dns_public_des_pairs(),
# 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(),
@ -189,6 +193,7 @@ OU_LE_LOCATAIRE_LE_DIT = {
"serveur_ops_forge_cle_hote": ("serveur_ops.yml", "serveur_ops_forge_cle_hote"),
"client_backup_cible": ("client_backup.yml", "client_backup_cible"),
"dns_public_site": ("all/10-intrants.yml", "dns_public_site"),
"dns_public_pairs": ("all/10-intrants.yml", "dns_public_pairs"),
"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"),

View file

@ -217,6 +217,82 @@ def adresses_site_portant(groupe: str) -> list[str]:
if m.get("ip") and groupe in svc.get(m["nom"], [])})
# --- LES SITES PAIRS (2026-10-14) ------------------------------------------------------
#
# Deux sites relies par un tunnel se doivent la replication du DNS public (et, plus tard, les
# sauvegardes croisees). Ce qu'un site sait de son pair, il le LIT dans le depot frere du pair
# (`../SITE-<nom>`), par les memes fonctions qu'il emploie pour lui-meme : une seconde lecture
# du plan finirait par diverger de la premiere. Un pair est ACTIF quand `dns_public_pairs` le
# dit — declare des deux cotes, garde par P82.
def sites_pairs_actifs() -> list[str]:
"""Les sites avec lesquels CE site replique son DNS public, `etat: actif` seulement."""
liens = (lire_plan_site("10-intrants.yml") or {}).get("dns_public_pairs") or []
return sorted({str(x.get("site")) for x in liens
if isinstance(x, dict) and str(x.get("etat", "actif")) == "actif"
and x.get("site")})
def carte_du_site(nom: str) -> Path | None:
"""La carte d'un site FRERE (`../<nom>/underlay.yml`, a cote du depot de ce site)."""
c = chemin()
if not c:
return None
p = c.resolve().parent.parent / nom / "underlay.yml"
return p if p.is_file() else None
class sur_le_site:
"""Le temps d'un bloc, `chemin()` designe la carte d'un autre site (frere).
Toutes les lectures du plan passent par `chemin()` ; les rediriger ainsi garantit qu'on
lit le pair EXACTEMENT comme on se lit soi-meme. Restaure l'etat d'avant en sortant,
exception comprise.
"""
def __init__(self, nom: str):
self.carte = carte_du_site(nom)
if self.carte is None:
raise FileNotFoundError(f"site pair `{nom}` : aucune carte a cote de ce site")
def __enter__(self):
self._avant = os.environ.get("SETOPS_UNDERLAY")
os.environ["SETOPS_UNDERLAY"] = str(self.carte)
return self
def __exit__(self, *_):
if self._avant is None:
os.environ.pop("SETOPS_UNDERLAY", None)
else:
os.environ["SETOPS_UNDERLAY"] = self._avant
return False
def dns_public_des_pairs() -> list[str]:
"""Les adresses du DNS public de chaque site pair ACTIF — derivees de leur plan.
Un pair sans carte a cote de ce site ne rend rien et ne fait pas echouer : le lien est
declare, mais son vis-a-vis n'est pas lisible d'ici (poste qui n'a pas ce depot).
"""
out: set[str] = set()
for nom in sites_pairs_actifs():
try:
with sur_le_site(nom):
out.update(adresses_site_portant("serveur_dns_public"))
except FileNotFoundError:
continue
return sorted(out)
def tenants_heberges_par(nom: str) -> list[str]:
"""Les locataires que le site `nom` heberge (sa carte, `tenants:`)."""
try:
with sur_le_site(nom):
return sorted((charger() or {}).get("tenants") or {})
except FileNotFoundError:
return []
def reseaux(underlay: dict | None) -> list[dict]:
return (underlay or {}).get("reseaux", []) or []