#!/usr/bin/env python3 """L'acces d'administration par WireGuard — le plan declare, la frontiere suit. POURQUOI CETTE VOIE (2026-09-17). L'exploitant entrait par les pattes de la frontiere, une regle par zone. La question posee etait : « le runner ne devrait-il pas etre le rebond ? » Non — il detient la cle de la voute du site et les cles SSH de toutes les machines : une session humaine compromise deviendrait le plan de controle, et reconstruire le runner couperait l'acces. Un tunnel nominatif donne la meme porte unique sans reunir ces pouvoirs. CE QUE CE SCRIPT RECONCILIE, et rien d'autre : - UNE instance WireGuard (celle que le plan nomme), distincte du tunnel site-a-site ; - les PAIRS qui lui sont attaches : un par personne ET par appareil. PERIMETRE STRICT : un pair attache a une AUTRE instance n'est jamais touche — le tunnel vers le site pair vit a cote et ne doit rien craindre d'ici. AUCUNE CLE PRIVEE D'APPAREIL N'ENTRE DANS LE DEPOT. Le plan ne porte que des cles PUBLIQUES. `pair-nouveau` tire une paire, affiche la privee UNE fois (a coller dans l'appareil) et rend la ligne de plan a ajouter. Usage : python3 scripts/vpn_admin.py plan # aucune ecriture CONFIRMER=true python3 scripts/vpn_admin.py appliquer python3 scripts/vpn_admin.py pair-nouveau --nom daniel-portable python3 scripts/vpn_admin.py config --nom daniel-portable """ from __future__ import annotations import argparse import base64 import ipaddress import os import sys from pathlib import Path RACINE = Path(__file__).resolve().parent.parent sys.path.insert(0, str(RACINE / "scripts")) import appliquer_opnsense as appl # noqa: E402 import devis_opnsense as devis_mod # noqa: E402 import underlay as underlay_mod # noqa: E402 def tunnels(inclure_vides: bool = False) -> 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") # UN LOCATAIRE SANS PAIR N'A PAS DE TUNNEL — sauf quand on vient justement lui en # proposer un (`pair-nouveau`) : refuser la` aurait fait de la premiere cle un # probleme d'oeuf et de poule, et c'est ce que le premier essai a montre. if index is None or (not (registre.get("acces_admin_vpn") or {}) and not inclure_vides): 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"): raise SystemExit("REFUS : le plan du site ne declare pas `acces_admin_vpn` " "(reseau, port). Rien a reconcilier.") return d def api_frontiere() -> appl.Frontiere: base = devis_mod.depot_hebergeur() if base is None: raise SystemExit("Aucun underlay ne designe d'hebergeur : pas de frontiere a piloter.") intr = devis_mod.intrants_frontiere() v = appl._voute(base) return appl.Frontiere(str(intr.get("opnsense_api_url") or ""), v["vault_opnsense_api_key"], v["vault_opnsense_api_secret"], bool(intr.get("opnsense_api_verifier_certs"))) def _table(rep: dict, *chemin: str) -> dict: """OPNsense emboite ses listes : {'server': {'servers': {'server': {uuid: {...}}}}}.""" noeud = rep for c in chemin: noeud = (noeud or {}).get(c) or {} return noeud if isinstance(noeud, dict) else {} def _choisi(champ) -> list[str]: """Un champ a choix d'OPNsense : rend les valeurs selectionnees.""" if isinstance(champ, dict): return [k for k, v in champ.items() if isinstance(v, dict) and v.get("selected")] return [str(champ)] if champ else [] def etat(api: appl.Frontiere) -> tuple[dict, dict]: """(serveurs par uuid, pairs par uuid) tels que la frontiere les porte.""" srv = _table(appl._lire(api, "/api/wireguard/server/get"), "server", "servers", "server") pairs = _table(appl._lire(api, "/api/wireguard/client/get"), "client", "clients", "client") return srv, pairs def reseaux_joignables() -> list[str]: """Ce que le tunnel doit router : les zones du site, la fabric, les supernets locataires. DERIVE, JAMAIS ECRIT. Une liste recopiee ici prendrait du retard sur la carte a la premiere zone ajoutee — et l'administrateur decouvrirait le trou au pire moment. """ u = underlay_mod.charger() or {} out = {str(r["sous_reseau"]) for r in underlay_mod.reseaux(u) if r.get("sous_reseau") and (str(r.get("nom", "")) in ("management", "grappe-controle", "transit-frontiere") or str(r.get("nom", "")).startswith("site-"))} out |= {str(s) for s in _supernets_locataires()} return sorted(out, key=lambda c: ipaddress.ip_network(c, strict=False)) def _supernets_locataires() -> list[str]: """Les supernets des locataires heberges — decouverts, jamais listes.""" import devis_reseau out = [] for nom_depot, _prefixe, _n in devis_reseau.decouvrir_du_site(): try: nomencl = (RACINE.parent / nom_depot / "plan" / "nomenclature.yml") import yaml d = yaml.safe_load(nomencl.read_text(encoding="utf-8")) or {} idx = d.get("index") if idx is not None: out.append(f"10.{int(idx)}.0.0/16") except (OSError, ValueError, TypeError): continue return sorted(out) 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 = {n: p for n, p in (vpn.get("pairs") or {}).items() if str(p.get("etat", "present")) == "present"} retires = {n for n, p in (vpn.get("pairs") or {}).items() if str(p.get("etat", "present")) == "absent"} poses = {} if uuid_srv: for u, p in pairs.items(): if uuid_srv in _choisi(p.get("servers")): poses[str(p.get("name"))] = (u, p) def different(nom_p: str) -> bool: _u, p = poses[nom_p] d = declares[nom_p] return (str(p.get("pubkey")) != str(d.get("cle_publique")) or _choisi(p.get("tunneladdress")) != [str(d.get("adresse"))]) return { "nom": nom, "uuid_serveur": uuid_srv, "serveur_a_creer": uuid_srv is None, "serveur": srv.get(uuid_srv or "", {}), "pairs_creer": {n: d for n, d in declares.items() if n not in poses}, "pairs_majer": {n: d for n, d in declares.items() if n in poses and different(n)}, "pairs_garder": {n for n in declares if n in poses and not different(n)}, # UN PAIR ATTACHE A NOTRE INSTANCE QUE LE PLAN NE DECLARE PLUS : c'est un acces qui # survit a la decision de le retirer. On le nomme, et `appliquer` le retire. "pairs_retirer": {n: u for n, (u, _p) in poses.items() if n not in declares or n in retires}, } def afficher(p: dict, vpn: dict) -> bool: if p["serveur_a_creer"]: print(f" + instance WireGuard {p['nom']} (port {vpn['port']}, " f"{vpn['adresse_frontiere']})") else: s = p["serveur"] print(f" = instance WireGuard {p['nom']} — port {s.get('port')}, " f"cle publique {str(s.get('pubkey'))[:12]}…") for n, d in sorted(p["pairs_creer"].items()): print(f" + pair {n:<24} {d.get('adresse')}") for n, d in sorted(p["pairs_majer"].items()): print(f" ~ pair MODIFIE {n:<24} {d.get('adresse')}") for n in sorted(p["pairs_retirer"]): print(f" - pair RETIRE {n}") print(f"\n a creer : {len(p['pairs_creer']) + (1 if p['serveur_a_creer'] else 0)}" f" | a retirer : {len(p['pairs_retirer'])}" f" | inchange : {len(p['pairs_garder'])}") return bool(p["serveur_a_creer"] or p["pairs_creer"] or p["pairs_majer"] or p["pairs_retirer"]) def _cle_privee() -> tuple[str, str]: from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey from cryptography.hazmat.primitives import serialization k = X25519PrivateKey.generate() priv = k.private_bytes(encoding=serialization.Encoding.Raw, format=serialization.PrivateFormat.Raw, encryption_algorithm=serialization.NoEncryption()) pub = k.public_key().public_bytes(encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw) return base64.b64encode(priv).decode(), base64.b64encode(pub).decode() def appliquer(api: appl.Frontiere, p: dict, vpn: dict) -> int: echecs = 0 def fait(rep, quoi): nonlocal echecs if rep.get("result") in ("saved", "deleted") or rep.get("status") == "ok": return rep echecs += 1 print(f" ! ECHEC {quoi} : {str(rep)[:200]}") return {} uuid_srv = p["uuid_serveur"] if p["serveur_a_creer"]: # LA CLE DU SERVEUR NAIT SUR LE BOITIER ET Y RESTE. Elle n'ouvre rien toute seule : # c'est la cle de la FRONTIERE, pas celle d'un administrateur. Le jour ou le boitier # est refait, les appareils recoivent la nouvelle cle publique — et c'est tout. priv, pub = _cle_privee() rep = fait(api("/api/wireguard/server/add_server", {"server": { "enabled": "1", "name": p["nom"], "instance": str(vpn.get("instance", 1)), "pubkey": pub, "privkey": priv, "port": str(vpn["port"]), "mtu": str(vpn.get("mtu", 1412)), "tunneladdress": str(vpn["adresse_frontiere"]), "disableroutes": "0", "peers": "", }}), f"instance {p['nom']}") uuid_srv = rep.get("uuid") or None if uuid_srv is None: srv, _ = etat(api) uuid_srv = next((u for u, s in srv.items() if str(s.get("name")) == p["nom"]), None) for nom_p, d in sorted({**p["pairs_creer"], **p["pairs_majer"]}.items()): corps = {"client": { "enabled": "1", "name": nom_p, "pubkey": str(d["cle_publique"]), "psk": "", "tunneladdress": str(d["adresse"]), "keepalive": "25", "servers": uuid_srv or "", }} if nom_p in p["pairs_majer"]: fait(api(f"/api/wireguard/client/set_client/{_uuid_pair(api, nom_p)}", corps), f"pair {nom_p}") else: fait(api("/api/wireguard/client/add_client", corps), f"pair {nom_p}") for nom_p, u in sorted(p["pairs_retirer"].items()): fait(api(f"/api/wireguard/client/del_client/{u}", {}), f"retrait du pair {nom_p}") if echecs: print(f"\n {echecs} echec(s) — RIEN N'EST RECHARGE : le tunnel garde son etat " f"precedent. Corriger, puis rejouer.") return 1 # OPNsense repond `{"result": "ok"}` : lire `status` seul affichait « ? » apres un # rechargement reussi (constate le 2026-09-28, nouvelles cles bien en service). rep = api("/api/wireguard/service/reconfigure", {}) print(" service :", rep.get("status") or rep.get("result") or rep) return 0 def _uuid_pair(api: appl.Frontiere, nom_p: str) -> str: _srv, pairs = etat(api) return next((u for u, x in pairs.items() if str(x.get("name")) == nom_p), "") def cmd_plan(args) -> int: api = api_frontiere() tous = tunnels() if not tous: print("Aucun tunnel declare — ni au plan du site, ni chez un locataire.") return 0 # N'APPLIQUER QUE CE QU'ON VISE (2026-09-28). Sans filtre, `appliquer` reconcilie TOUS # les tunnels : une rotation de cle chez l'un aurait emporte l'ecart en attente d'un # autre — un pair retire que personne n'avait encore decide de retirer. if getattr(args, "portee", None): voulues = {x.lower() for x in args.portee} connues = {t["portee"].lower() for t in tous} if voulues - connues: raise SystemExit(f"REFUS : portee inconnue {sorted(voulues - connues)} " f"(connues : {sorted(connues)}).") tous = [t for t in tous if t["portee"].lower() in voulues] 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(" Rien a faire.") return 0 if os.environ.get("CONFIRMER") != "true": print(" PLAN SEUL — aucune ecriture. Rejouer avec CONFIRMER=true pour appliquer.") return 0 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(inclure_vides=True) 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 = _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 {}).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) 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") print(f"# Puis : python3 scripts/vpn_admin.py config --nom {args.nom}") return 0 def _ecrire_secret(chemin: Path, contenu: str) -> None: """Ecrire un fichier que seul son proprietaire lit, sans jamais en ecraser un.""" chemin = chemin.expanduser() if chemin.exists(): raise SystemExit(f"REFUS : {chemin} existe deja. On n'ecrase pas une cle.") chemin.parent.mkdir(parents=True, exist_ok=True) fd = os.open(chemin, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "w") as fh: fh.write(contenu) def cmd_cle_appareil(args) -> int: """LA CLE PRIVEE NE PASSE PAR AUCUN ECRAN (2026-09-28). `pair-nouveau` affiche la privee pour qu'un humain la colle. Lance par un assistant, ou dans un terminal journalise, cet affichage la depose dans un historique — c'est ainsi que deux cles d'administration ont du etre changees. Ici, la privee va droit dans un fichier en 0600 ; seule la PUBLIQUE s'affiche, pour le plan. Sert aussi a une ROTATION : on remplace `cle_publique` du pair existant, nom et adresse inchanges. """ priv, pub = _cle_privee() _ecrire_secret(Path(args.vers), priv + "\n") print(f"Cle privee ecrite dans {Path(args.vers).expanduser()} (0600, jamais affichee).") print(f"cle_publique: \"{pub}\"") return 0 def cmd_config(args) -> int: 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 de {vpn['portee']}.") api = api_frontiere() srv, _pairs = etat(api) 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 " f"(CONFIRMER=true python3 scripts/vpn_admin.py appliquer).") intr = devis_mod.intrants_frontiere() cle = (Path(args.cle).expanduser().read_text().strip() if getattr(args, "cle", None) else "") texte = (f"[Interface]\n# PrivateKey : celle affichee par `pair-nouveau` pour {args.nom}\n" f"PrivateKey = {cle}\n" f"Address = {d['adresse']}\n" f"MTU = {vpn.get('mtu', 1412)}\n\n" f"[Peer]\n" f"PublicKey = {s.get('pubkey')}\n" f"Endpoint = {intr.get('opnsense_wan_ip')}:{vpn['port']}\n" f"AllowedIPs = {', '.join(reseaux_joignables() if vpn['portee'] == 'site' else [_supernet_de(vpn)])}\n" f"PersistentKeepalive = 25\n") if getattr(args, "vers", None): if not getattr(args, "cle", None): raise SystemExit("REFUS : --vers exige --cle (sinon le fichier porterait un gabarit).") _ecrire_secret(Path(args.vers), texte) print(f"Configuration complete ecrite dans {Path(args.vers).expanduser()} (0600).") else: print(texte, end="") 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) for nom_cmd in ("plan", "appliquer"): p = sous.add_parser(nom_cmd, help="rapproche le plan et la frontiere " "(ecriture seulement avec CONFIRMER=true)") p.add_argument("--portee", action="append", help="restreindre a un tunnel : `site` ou `OPS-X` (repetable)") 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("cle-appareil", help="tire une paire : privee dans un FICHIER, " "publique a l'ecran (creation ou rotation)") p.add_argument("--vers", required=True, help="fichier de la cle privee (cree en 0600)") p.set_defaults(fn=cmd_cle_appareil) p = sous.add_parser("config", help="la configuration a coller dans l'appareil") p.add_argument("--nom", required=True) p.add_argument("--locataire") p.add_argument("--cle", help="fichier de la cle privee (sinon un gabarit a completer)") p.add_argument("--vers", help="ecrire la configuration complete dans ce fichier (0600)") p.set_defaults(fn=cmd_config) args = ap.parse_args() return args.fn(args) if __name__ == "__main__": sys.exit(main())