diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e91b5..58afe79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/roles/serveur_powerdns/defaults/main.yml b/roles/serveur_powerdns/defaults/main.yml index 6fcbbaf..dc00bcc 100644 --- a/roles/serveur_powerdns/defaults/main.yml +++ b/roles/serveur_powerdns/defaults/main.yml @@ -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 diff --git a/roles/serveur_powerdns/tasks/zones-publiques.yml b/roles/serveur_powerdns/tasks/zones-publiques.yml index bd44276..a91bc54 100644 --- a/roles/serveur_powerdns/tasks/zones-publiques.yml +++ b/roles/serveur_powerdns/tasks/zones-publiques.yml @@ -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-]+)+\.?$') diff --git a/roles/serveur_powerdns/templates/pdns-public.conf.j2 b/roles/serveur_powerdns/templates/pdns-public.conf.j2 index f682c04..b43d397 100644 --- a/roles/serveur_powerdns/templates/pdns-public.conf.j2 +++ b/roles/serveur_powerdns/templates/pdns-public.conf.j2 @@ -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= diff --git a/scripts/appliquer_opnsense.py b/scripts/appliquer_opnsense.py index d4acc9a..a414c46 100644 --- a/scripts/appliquer_opnsense.py +++ b/scripts/appliquer_opnsense.py @@ -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 diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index 46ce2cb..920227c 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -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_"): diff --git a/scripts/proxmox_api.py b/scripts/proxmox_api.py index bac2373..c387cd6 100644 --- a/scripts/proxmox_api.py +++ b/scripts/proxmox_api.py @@ -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 diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index c69f0eb..41bff62 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -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 [] diff --git a/scripts/site_intrants.py b/scripts/site_intrants.py index a6a29c9..9264b7e 100644 --- a/scripts/site_intrants.py +++ b/scripts/site_intrants.py @@ -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"), diff --git a/scripts/underlay.py b/scripts/underlay.py index aec4864..633f849 100644 --- a/scripts/underlay.py +++ b/scripts/underlay.py @@ -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-`), 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 (`..//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 []