diff --git a/CHANGELOG.md b/CHANGELOG.md index a479be6..d3c6718 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # CHANGELOG — Set-OPS +## 2026-09-17 (5) — Un tunnel d'administration par locataire : il declare, le site sert + +- **Nouveau registre de plan** : `plan/acces.yml` (`acces_admin_vpn`), une entree par personne + ET par appareil, cle PUBLIQUE seule. Decrit au schema (donc editable a la console), valide + par `valider_acces` : nom `personne-appareil`, cle WireGuard bien formee, adresse en /32 + dans le tunnel derive, jamais celle de la frontiere, jamais en double. +- **Tout derive de l'index** : reseau `10..29.0/24`, port `52000+index`, instance + `wg`. Rien a allouer, aucune collision (P21 garde l'unicite des index). +- **`scripts/vpn_admin.py`** tient desormais le tunnel du site ET celui de chaque locataire ; + `--locataire` vise le bon. Le nom du pair sur le boitier porte l'ecosysteme : deux + locataires peuvent avoir chacun leur `daniel-portable`. +- **L'isolement est GARDE** : `verifier` refuse une regle dont la source est le tunnel d'un + locataire et la destination autre chose que SON supernet — sans quoi un ecosysteme + obtiendrait un acces chez un voisin en ajoutant un pair dans son propre plan. Mise en + defaut eprouvee ; P24 l'execute avant toute ecriture. +- **Pare-feu des machines** : `resoudre_flux` derive le reseau du tunnel du plan du locataire, + et seulement s'il declare un pair ACTIF. + +Mesure : `wg17` actif en 10.17.29.1 avec son pair, port 52017 ouvert sur le WAN, deux regles +bornees a `SETOPS_TENANT_CHEZ17`, 13 machines de Chezlepro redeployees (0 failed). +`make prouver` : CONFORME, 82 OK. + ## 2026-09-17 (4) — Le tunnel d'administration, eprouve en production : deux trous refermes Le tunnel montait, la poignee de main se faisait, une machine de LOCATAIRE repondait — et diff --git a/docs/acces-administration.md b/docs/acces-administration.md index bd7bb24..542db03 100644 --- a/docs/acces-administration.md +++ b/docs/acces-administration.md @@ -74,6 +74,36 @@ Les deux règles sont désormais émises par `devis_opnsense` dès que `acces_ad déclaré (`ports_frontiere`, par défaut 22 et 443). Une règle **par port** : OPNsense refuse « 22,443 » dans un champ de port, et aucun flux jusque-là n'en portait deux. +## Un tunnel par locataire (2026-09-17) + +Chaque écosystème a le sien, et **c'est lui qui décide qui entre chez lui**. Le site porte la +route, jamais la liste des gens — même partage que les zones DNS publiques. + +| | | +|---|---| +| déclaré par | le **locataire**, dans `plan/acces.yml` (registre `acces_admin_vpn`) | +| réseau | `10..29.0/24` — **dérivé** de son index, hors de ses six zones (.16 à .21) | +| port | `52000 + index` — dérivé, donc sans collision : P21 garde déjà l'unicité des index | +| instance | `wg` — un rang (2, 3, 4…) renommerait les interfaces le jour où un écosystème part | +| ce qu'il atteint | **son supernet, et rien d'autre** : SSH et consoles web | + +**L'isolement est gardé, pas promis.** `devis_opnsense.verifier` refuse toute règle dont la +source est le tunnel d'un locataire et la destination autre chose que **son** supernet — +sinon, un écosystème obtiendrait un accès chez un voisin en ajoutant un pair dans son propre +plan. La garde est exécutée par P24, avant toute écriture. Mise en défaut vérifiée. + +**Le pare-feu de ses machines suit tout seul** : `resoudre_flux` ajoute le réseau du tunnel aux +sources d'administration **dérivées de son plan**, et seulement s'il déclare au moins un pair +actif — déclarer un réseau que personne n'emprunte ouvrirait le SSH à un tunnel qui n'existe +pas. + +```bash +python3 scripts/vpn_admin.py pair-nouveau --nom prenom-appareil --locataire OPS- +# coller le bloc rendu dans OPS-/plan/acces.yml +make vpn-admin-plan && CONFIRMER=true make vpn-admin-appliquer +python3 scripts/vpn_admin.py config --nom prenom-appareil --locataire OPS- +``` + ## Ce que le tunnel donne, et ce qu'il ne donne pas `AllowedIPs` est **dérivé** de la carte : zones du site, fabric, lien de transit et supernets diff --git a/docs/audit/preuve-2026-09-17.md b/docs/audit/preuve-2026-09-17.md index 3c5a7f0..d194e2c 100644 --- a/docs/audit/preuve-2026-09-17.md +++ b/docs/audit/preuve-2026-09-17.md @@ -62,7 +62,7 @@ | P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). | | P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 101 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. | | P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (138 lignes). | -| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 302 regles `pass`), tous non consignes et tous motives. | +| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 305 regles `pass`), tous non consignes et tous motives. | | P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 | | P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. | | P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b | @@ -73,7 +73,7 @@ | P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. | | P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). | | P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `392da4d` (publie le 2026-09-14). | -| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 52 champ(s) sur 6 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp | +| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 55 champ(s) sur 7 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp | | P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:8, serveurs:3 champ(s) lus par validateur). | | P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s | | P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 41 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, serveur_ | diff --git a/docs/audit/schema-plan.json b/docs/audit/schema-plan.json index d693a8e..02ed33f 100644 --- a/docs/audit/schema-plan.json +++ b/docs/audit/schema-plan.json @@ -400,6 +400,45 @@ "description": "Le nom public, ex. `chezlepro.ca`." } }, + "acces_admin_vpn": { + "title": "Acces d'administration (WireGuard)", + "x-fichier": "plan/acces.yml", + "x-racine": "acces_admin_vpn", + "entite": { + "type": "object", + "properties": { + "cle_publique": { + "type": "string", + "title": "Cle publique", + "description": "Cle WireGuard PUBLIQUE de l'appareil (44 caracteres). La privee ne quitte jamais l'appareil." + }, + "adresse": { + "type": "string", + "title": "Adresse dans le tunnel", + "description": "Un /32 du reseau derive `10..29.0/24`." + }, + "etat": { + "type": "string", + "title": "Etat", + "description": "`absent` revoque l'acces au prochain passage.", + "enum": [ + "present", + "absent" + ], + "default": "present" + } + }, + "additionalProperties": false, + "required": [ + "adresse", + "cle_publique" + ] + }, + "x-clef": { + "title": "Pair", + "description": "`personne-appareil`, ex. `daniel-portable` — un pair par appareil, pour revoquer l'un sans l'autre." + } + }, "nomenclature": { "title": "Nomenclature", "x-fichier": "plan/nomenclature.yml", diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index 01c4f50..06deb5e 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -1465,6 +1465,71 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: "raison": _raison_vpn, }) + # --- LES TUNNELS DES LOCATAIRES (2026-09-17) ----------------------------------- + # + # UN LOCATAIRE DECLARE QUI ENTRE CHEZ LUI (`plan/acces.yml`), le site porte la route. + # Reseau et port derivent de son index ; l'instance WireGuard est posee par + # `scripts/vpn_admin.py`. + # + # L'ISOLEMENT EST LA REGLE, PAS UNE INTENTION : la destination est le supernet DU MEME + # locataire, jamais le site ni un voisin. P83 refuse toute regle qui s'en ecarterait. + import devis_reseau as _dr + for _nom_t, _pfx_t, _n_t in tenants: + _idx_t = _n_t.get("index") + if _idx_t is None: + continue + try: + _acces_t = (yaml.safe_load( + (_dr.DOSSIER_INSTANCES / _nom_t / "plan" / "acces.yml").read_text(encoding="utf-8")) + or {}).get("acces_admin_vpn") or {} + except (OSError, yaml.YAMLError): + continue + if not [n for n, c in _acces_t.items() + if isinstance(c, dict) and str(c.get("etat", "present")) == "present"]: + continue # aucun pair actif : aucune route, aucune regle + _etiq_t = f"{_pfx_t}{_idx_t}" + _alias_tunnel = f"SETOPS_ADMIN_{_etiq_t}_TUNNEL" + alias[_alias_tunnel] = { + "type": "network", + "contenu": [f"10.{int(_idx_t)}.29.0/24"], + "description": f"Tunnel d'administration de {_nom_t} (plan/acces.yml) — " + f"n'atteint que son propre ecosysteme", + } + for _port_t, _chiffre_t, _pourquoi_t in ( + ("22", "ssh", "SSH d'administration du locataire, par SON tunnel nominatif."), + ("443", "tls", "Consoles web de son ecosysteme, par SON tunnel nominatif.")): + regles.append({ + "sens": "in", + "interface": if_vpn, + "protocole": "tcp", + "source": _alias_tunnel, + "destination": f"SETOPS_TENANT_{_etiq_t}", + "ports": [_port_t], + "chiffrement": _chiffre_t, + "role": "acces_admin_vpn", + "tenant": _nom_t, + "raison": _pourquoi_t, + }) + if str(intrants.get("opnsense_wan_ip") or ""): + alias.setdefault("SETOPS_FRONTIERE_WAN", { + "type": "host", + "contenu": [str(intrants["opnsense_wan_ip"])], + "description": "Adresse publique de la frontiere — porte des tunnels d'administration", + }) + regles.append({ + "sens": "in", + "interface": if_wan, + "protocole": "udp", + "source": "any", + "destination": "SETOPS_FRONTIERE_WAN", + "ports": [str(52000 + int(_idx_t))], + "chiffrement": "wireguard", + "role": "acces_admin_vpn", + "tenant": _nom_t, + "raison": f"Poignee de main WireGuard des administrateurs de {_nom_t} " + f"(port derive de son index).", + }) + # --- LA PORTE DU TUNNEL D'ADMINISTRATION (2026-09-17) -------------------------- # # Le tunnel ne peut pas s'etablir si son port n'est pas ouvert sur le WAN. C'est le SEUL @@ -1772,6 +1837,24 @@ def verifier(devis: dict) -> tuple[bool, list[str]]: if not ipaddress.ip_address(d["cible"]).is_private: erreurs.append(f"Redirection vers {d['cible']} : une cible de redirection est interne.") + # UN TUNNEL DE LOCATAIRE NE VOIT QUE SON LOCATAIRE (2026-09-17). + # + # Chaque ecosysteme declare qui entre chez lui ; le site porte la route. Si cette route + # menait ailleurs — au site, a un voisin — le locataire aurait obtenu, en ajoutant un + # pair dans SON plan, un acces chez quelqu'un d'autre. C'est la seule chose que cette + # delegation ne doit jamais permettre, et c'est pourquoi elle se garde ici, avant toute + # ecriture, plutot que de se relire sur le boitier. + for r in devis["regles"]: + src = str(r.get("source") or "") + if not (src.startswith("SETOPS_ADMIN_") and src.endswith("_TUNNEL")): + continue + attendu = "SETOPS_TENANT_" + src[len("SETOPS_ADMIN_"):-len("_TUNNEL")] + if str(r.get("destination")) != attendu: + erreurs.append( + f"Le tunnel {src} vise {r.get('destination')} : un tunnel de locataire ne " + f"doit atteindre que {attendu}. Un ecosysteme obtiendrait un acces chez un " + f"autre en ajoutant un pair dans son propre plan.") + gestion = devis.get("reseaux_gestion") or [] for r in devis["regles"]: src = str(r.get("source") or "") diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index 13383c2..9e9d195 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -730,6 +730,84 @@ def fqdn_exposition(domaine: str, nom: str) -> str: return f"{nom}.{domaine}" +# --- L'ACCES D'ADMINISTRATION D'UN LOCATAIRE (2026-09-17) ------------------------------ +# +# LE LOCATAIRE DECLARE QUI ENTRE CHEZ LUI, le site ne fait que porter la route. C'est la +# meme ligne de partage que les zones DNS publiques : le locataire ecrit, le site sert. +# Seule la cle PUBLIQUE figure ici — une cle privee ne quitte jamais l'appareil. +# +# L'ADRESSE ET LE PORT DU TUNNEL SE DERIVENT DE L'INDEX, comme tout l'adressage : reseau +# `10..29.0/24` (hors des six zones, qui occupent .16 a .21) et port `52000+index`. +# Rien a allouer, rien a tenir a jour, aucune collision possible — l'unicite de l'index est +# deja gardee par P21. +ETATS_PAIR_VPN = {"present", "absent"} +# Une cle X25519 fait 32 octets : 43 caracteres base64 puis `=`, et l'avant-dernier ne peut +# valoir que l'un des seize symboles dont les deux derniers bits sont nuls — 0, 4 et 8 en font +# partie, et les oublier refusait des cles parfaitement valides (eprouve sur une vraie cle). +_CLE_WIREGUARD = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=$") +_NOM_PAIR = re.compile(r"^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])$") + + +def reseau_vpn_locataire(index: int) -> str: + return f"10.{int(index)}.29.0/24" + + +def port_vpn_locataire(index: int) -> int: + return 52000 + int(index) + + +def charger_acces(path: Path | None) -> dict: + """Charge le registre des acces d'administration (acces_admin_vpn).""" + if not path or not path.exists(): + return {"acces_admin_vpn": {}} + with path.open("r", encoding="utf-8") as fichier: + data = yaml.safe_load(fichier) or {} + if not isinstance(data, dict): + raise ValueError(f"{path} ne contient pas une table YAML.") + data.setdefault("acces_admin_vpn", {}) + return data + + +def valider_acces(registre: dict, index: int | None = None) -> None: + """Valide les pairs d'administration declares par le locataire.""" + pairs = registre.get("acces_admin_vpn") or {} + if not isinstance(pairs, dict): + raise ValueError("acces_admin_vpn doit etre une table (un pair par clef).") + reseau = ipaddress.ip_network(reseau_vpn_locataire(index), strict=False) if index is not None else None + adresses: dict[str, str] = {} + for nom, conf in pairs.items(): + lieu = f"Pair d'administration '{nom}'" + if not isinstance(conf, dict): + raise ValueError(f"{lieu}: table attendue.") + # UN PAIR = UNE PERSONNE ET UN APPAREIL : `prenom-appareil`. Un nom partage + # rendrait la revocation collective — retirer l'appareil perdu couperait les autres. + if not _NOM_PAIR.match(str(nom)): + raise ValueError(f"{lieu}: nom attendu `personne-appareil` (minuscules, tirets).") + etat = str(conf.get("etat", "present")) + if etat not in ETATS_PAIR_VPN: + raise ValueError(f"{lieu}: etat '{etat}' inconnu ({', '.join(sorted(ETATS_PAIR_VPN))}).") + cle = str(conf.get("cle_publique", "")).strip() + if not _CLE_WIREGUARD.match(cle): + raise ValueError(f"{lieu}: `cle_publique` attendue (cle WireGuard, 44 caracteres " + f"base64). La cle PRIVEE ne va jamais au plan.") + adr = str(conf.get("adresse", "")).strip() + try: + ip = ipaddress.ip_interface(adr) + except ValueError: + raise ValueError(f"{lieu}: `adresse` attendue, ex. 10.X.29.2/32.") from None + if ip.network.prefixlen != 32: + raise ValueError(f"{lieu}: l'adresse d'un pair est un /32 — deux pairs qui se " + f"chevauchent se volent leurs paquets.") + if reseau is not None and ip.ip not in reseau: + raise ValueError(f"{lieu}: {ip.ip} est hors du tunnel {reseau} (derive de l'index).") + if reseau is not None and ip.ip == next(reseau.hosts()): + raise ValueError(f"{lieu}: {ip.ip} est l'adresse de la frontiere dans ce tunnel.") + if str(ip.ip) in adresses and etat == "present": + raise ValueError(f"{lieu}: {ip.ip} est deja l'adresse de '{adresses[str(ip.ip)]}'.") + if etat == "present": + adresses[str(ip.ip)] = str(nom) + + def valider_domaines(registre: dict) -> None: """Valide la coherence du registre des domaines publics.""" domaines = registre.get("domaines_publics") or {} diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index 96d55f3..647caca 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -222,8 +222,32 @@ def _sources_admin_ssh() -> list[str]: data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {} if isinstance(data, dict) and data.get("nftables_admin_ssh"): sources = data["nftables_admin_ssh"] - return [str(s) for s in sources] if isinstance(sources, list) else [str(sources)] - return [] + recus = [str(s) for s in sources] if isinstance(sources, list) else [str(sources)] + return sorted(set(recus) | set(_tunnel_admin_locataire())) + return sorted(set(_tunnel_admin_locataire())) + + +def _tunnel_admin_locataire() -> list[str]: + """Le tunnel d'administration QUE CE LOCATAIRE declare — derive, jamais recopie. + + Le site donne `nftables_admin_ssh` par contrat : son plan d'administration et son propre + tunnel. Le locataire, lui, decide de SES acces (`plan/acces.yml`) ; le reseau de son + tunnel se derive de son index. L'ecrire une seconde fois dans les intrants ferait diverger + ce que la frontiere route et ce que ses machines acceptent. + + AUCUN PAIR, AUCUNE SOURCE : declarer un reseau que personne n'emprunte ouvrirait le SSH + de toutes ses machines a un tunnel qui n'existe pas. + """ + from inventory_rules import charger_acces, charger_nomenclature, reseau_vpn_locataire + base = INSTANCE + try: + pairs = charger_acces(base / "plan" / "acces.yml").get("acces_admin_vpn") or {} + index = charger_nomenclature(base / "plan" / "nomenclature.yml").get("index") + except (OSError, ValueError): + return [] + actifs = [n for n, c in pairs.items() + if isinstance(c, dict) and str(c.get("etat", "present")) == "present"] + return [reseau_vpn_locataire(index)] if (actifs and index is not None) else [] def _ip_par_hote(data: dict) -> dict[str, str]: diff --git a/scripts/schema_plan.py b/scripts/schema_plan.py index 7c59669..bbde5f7 100644 --- a/scripts/schema_plan.py +++ b/scripts/schema_plan.py @@ -232,6 +232,23 @@ REGISTRES: dict = { "aide": "Secondes (60 a 604800) ; defaut de la zone sinon."}}}, }, }, + "acces_admin_vpn": { + "titre": "Acces d'administration (WireGuard)", + "fichier": "plan/acces.yml", + "racine": "acces_admin_vpn", + "clef": {"libelle": "Pair", "aide": "`personne-appareil`, ex. `daniel-portable` — " + "un pair par appareil, pour revoquer l'un sans l'autre."}, + "champs": { + "cle_publique": {"type": "string", "requis": True, "libelle": "Cle publique", + "aide": "Cle WireGuard PUBLIQUE de l'appareil (44 caracteres). " + "La privee ne quitte jamais l'appareil."}, + "adresse": {"type": "string", "requis": True, "libelle": "Adresse dans le tunnel", + "aide": "Un /32 du reseau derive `10..29.0/24`."}, + "etat": {"type": "string", "libelle": "Etat", "defaut": "present", + "enum": ["present", "absent"], + "aide": "`absent` revoque l'acces au prochain passage."}, + }, + }, "nomenclature": { "titre": "Nomenclature", "fichier": "plan/nomenclature.yml", diff --git a/scripts/vpn_admin.py b/scripts/vpn_admin.py index 8460f29..a6f18f9 100644 --- a/scripts/vpn_admin.py +++ b/scripts/vpn_admin.py @@ -41,6 +41,51 @@ import devis_opnsense as devis_mod # noqa: E402 import underlay as underlay_mod # noqa: E402 +def tunnels() -> list[dict]: + """Tous les tunnels a tenir : celui du SITE, puis un par locataire qui declare des pairs. + + DEUX DECLARANTS, DEUX PORTEES. Le site declare le sien dans son plan (`acces_admin_vpn`). + Un LOCATAIRE declare les siens dans SON plan (`plan/acces.yml`) : c'est lui qui decide qui + entre chez lui, et le site ne porte que la route — meme partage que les zones DNS + publiques. Son reseau, son port et son numero d'instance se DERIVENT de son index : rien + a allouer, aucune collision possible (P21 garde deja l'unicite des index). + """ + import devis_reseau + from inventory_rules import (charger_acces, charger_nomenclature, port_vpn_locataire, + reseau_vpn_locataire, valider_acces) + out: list[dict] = [] + site = (underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("acces_admin_vpn") or {} + if site.get("reseau") and site.get("port"): + out.append({**site, + "nom": str(site.get("nom") or "admins"), + "portee": "site", + "pairs": {str(p["nom"]): p for p in (site.get("pairs") or [])}}) + for nom_depot, _prefixe, _n in devis_reseau.decouvrir_du_site(): + base = RACINE.parent / nom_depot + registre = charger_acces(base / "plan" / "acces.yml") + index = charger_nomenclature(base / "plan" / "nomenclature.yml").get("index") + if index is None or not (registre.get("acces_admin_vpn") or {}): + continue + valider_acces(registre, index) + slug = nom_depot.lower().removeprefix("ops-") + out.append({ + "nom": f"admins-{slug}", + "portee": nom_depot, + # L'INSTANCE EST L'INDEX : `wg17`, `wg23`. Un rang (2, 3, 4...) changerait de + # numero le jour ou un locataire part, et renommerait l'interface des autres. + "instance": int(index), + "port": port_vpn_locataire(index), + "reseau": reseau_vpn_locataire(index), + "adresse_frontiere": f"{next(ipaddress.ip_network(reseau_vpn_locataire(index)).hosts())}/24", + "mtu": int(site.get("mtu", 1412)), + # Le nom du pair sur le boitier porte le locataire : deux ecosystemes peuvent + # avoir chacun un `daniel-portable` sans se marcher dessus. + "pairs": {f"{slug}-{n}": {"nom": f"{slug}-{n}", **c} + for n, c in (registre.get("acces_admin_vpn") or {}).items()}, + }) + return out + + def plan_vpn() -> dict: d = (underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("acces_admin_vpn") or {} if not d.get("reseau") or not d.get("port"): @@ -118,9 +163,9 @@ def rapprocher(api: appl.Frontiere, vpn: dict) -> dict: srv, pairs = etat(api) nom = str(vpn.get("nom") or "admins") uuid_srv = next((u for u, s in srv.items() if str(s.get("name")) == nom), None) - declares = {str(p["nom"]): p for p in (vpn.get("pairs") or []) + declares = {n: p for n, p in (vpn.get("pairs") or {}).items() if str(p.get("etat", "present")) == "present"} - retires = {str(p["nom"]) for p in (vpn.get("pairs") or []) + retires = {n for n, p in (vpn.get("pairs") or {}).items() if str(p.get("etat", "present")) == "absent"} poses = {} @@ -240,34 +285,67 @@ def _uuid_pair(api: appl.Frontiere, nom_p: str) -> str: def cmd_plan(args) -> int: - vpn = plan_vpn() api = api_frontiere() - p = rapprocher(api, vpn) - print(f"Acces d'administration WireGuard — instance « {p['nom'] } », " - f"reseau {vpn['reseau']}, port {vpn['port']}\n") - a_faire = afficher(p, vpn) + tous = tunnels() + if not tous: + print("Aucun tunnel declare — ni au plan du site, ni chez un locataire.") + return 0 + plans, a_faire = [], False + for vpn in tous: + p = rapprocher(api, vpn) + plans.append((p, vpn)) + print(f"Tunnel « {p['nom']} » ({vpn['portee']}) — reseau {vpn['reseau']}, " + f"port {vpn['port']}\n") + a_faire = afficher(p, vpn) or a_faire + print() if not a_faire: - print("\n Rien a faire.") + print(" Rien a faire.") return 0 if os.environ.get("CONFIRMER") != "true": - print("\n PLAN SEUL — aucune ecriture. Rejouer avec CONFIRMER=true pour appliquer.") + print(" PLAN SEUL — aucune ecriture. Rejouer avec CONFIRMER=true pour appliquer.") return 0 - return appliquer(api, p, vpn) + code = 0 + for p, vpn in plans: + code = appliquer(api, p, vpn) or code + return code + + +def _tunnel_vise(portee: str | None) -> dict: + """Le tunnel du site par defaut ; celui d'un locataire avec `--locataire OPS-X`.""" + tous = tunnels() + if portee: + for t in tous: + if t["portee"].lower() == portee.lower(): + return t + raise SystemExit(f"REFUS : aucun tunnel declare pour « {portee} » " + f"(connus : {', '.join(t['portee'] for t in tous) or 'aucun'}).") + for t in tous: + if t["portee"] == "site": + return t + raise SystemExit("REFUS : le plan du site ne declare aucun tunnel.") def cmd_pair(args) -> int: - vpn = plan_vpn() + vpn = _tunnel_vise(getattr(args, "locataire", None)) priv, pub = _cle_privee() reseau = ipaddress.ip_network(str(vpn["reseau"]), strict=False) - prises = {str(p.get("adresse", "")).split("/")[0] for p in (vpn.get("pairs") or [])} + prises = {str(p.get("adresse", "")).split("/")[0] for p in (vpn.get("pairs") or {}).values()} prises.add(str(vpn["adresse_frontiere"]).split("/")[0]) libre = next((str(h) for h in reseau.hosts() if str(h) not in prises), None) - print(f"# Pair « {args.nom} » — a AJOUTER au plan du site (plan/10-intrants.yml) :\n") - print(" pairs:") - print(f" - nom: {args.nom}") - print(f" cle_publique: \"{pub}\"") - print(f" adresse: {libre}/32") - print(" etat: present\n") + if vpn["portee"] == "site": + print(f"# Pair « {args.nom} » — a AJOUTER au plan du site (plan/10-intrants.yml) :\n") + print(" pairs:") + print(f" - nom: {args.nom}") + print(f" cle_publique: \"{pub}\"") + print(f" adresse: {libre}/32") + print(" etat: present\n") + else: + print(f"# Pair « {args.nom} » — a AJOUTER au plan de {vpn['portee']} (plan/acces.yml) :\n") + print("acces_admin_vpn:") + print(f" {args.nom}:") + print(f" cle_publique: \"{pub}\"") + print(f" adresse: {libre}/32") + print(" etat: present\n") print("# La cle PRIVEE ci-dessous ne s'affiche qu'une fois : elle appartient a l'appareil,") print("# elle n'entre ni dans le depot ni dans une voute.\n") print(f"PrivateKey = {priv}\n") @@ -276,13 +354,15 @@ def cmd_pair(args) -> int: def cmd_config(args) -> int: - vpn = plan_vpn() - d = next((p for p in (vpn.get("pairs") or []) if str(p.get("nom")) == args.nom), None) + vpn = _tunnel_vise(getattr(args, "locataire", None)) + d = next((p for n, p in (vpn.get("pairs") or {}).items() + if n == args.nom or str(p.get("nom")) == args.nom + or n.endswith(f"-{args.nom}")), None) if d is None: - raise SystemExit(f"REFUS : aucun pair « {args.nom} » au plan du site.") + raise SystemExit(f"REFUS : aucun pair « {args.nom} » au plan de {vpn['portee']}.") api = api_frontiere() srv, _pairs = etat(api) - nom = str(vpn.get("nom") or "admins") + nom = str(vpn["nom"]) s = next((x for x in srv.values() if str(x.get("name")) == nom), None) if s is None: raise SystemExit(f"REFUS : l'instance « {nom} » n'existe pas encore sur la frontiere " @@ -295,11 +375,16 @@ def cmd_config(args) -> int: f"[Peer]\n" f"PublicKey = {s.get('pubkey')}\n" f"Endpoint = {intr.get('opnsense_wan_ip')}:{vpn['port']}\n" - f"AllowedIPs = {', '.join(reseaux_joignables())}\n" + f"AllowedIPs = {', '.join(reseaux_joignables() if vpn['portee'] == 'site' else [_supernet_de(vpn)])}\n" f"PersistentKeepalive = 25") return 0 +def _supernet_de(vpn: dict) -> str: + """CE QU'UN TUNNEL DE LOCATAIRE ATTEINT : son ecosysteme, et rien d'autre.""" + return f"10.{ipaddress.ip_network(str(vpn['reseau']), strict=False).network_address.packed[1]}.0.0/16" + + def main() -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) sous = ap.add_subparsers(dest="commande", required=True) @@ -309,9 +394,12 @@ def main() -> int: p.set_defaults(fn=cmd_plan) p = sous.add_parser("pair-nouveau", help="tire une paire de cles et rend la ligne de plan") p.add_argument("--nom", required=True, help="personne-appareil, ex. daniel-portable") + p.add_argument("--locataire", help="le tunnel de cet ecosysteme (ex. OPS-Chezlepro) ; " + "sans lui, celui du site") p.set_defaults(fn=cmd_pair) p = sous.add_parser("config", help="la configuration a coller dans l'appareil") p.add_argument("--nom", required=True) + p.add_argument("--locataire") p.set_defaults(fn=cmd_config) args = ap.parse_args() return args.fn(args)