Set-OPS-Public/scripts/vpn_admin.py
Daniel Allaire d90513ca91 acces d'administration : un tunnel WireGuard nominatif, pas le runner en rebond
Le runner detient la voute et les cles SSH : en faire la porte des humains reunirait deux
pouvoirs que le depot separe. Instance `admins` a cote du tunnel site-a-site, un pair par
personne et par appareil, cles publiques seules au plan. Le reseau du tunnel est un reseau
d'administration : pare-feux d'hote, contrat des locataires et regles de bordure en derivent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:42:48 -04:00

321 lines
14 KiB
Python

#!/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 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 = {str(p["nom"]): p for p in (vpn.get("pairs") or [])
if str(p.get("etat", "present")) == "present"}
retires = {str(p["nom"]) for p in (vpn.get("pairs") or [])
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
print(" service :", api("/api/wireguard/service/reconfigure", {}).get("status", "?"))
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:
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)
if not a_faire:
print("\n Rien a faire.")
return 0
if os.environ.get("CONFIRMER") != "true":
print("\n PLAN SEUL — aucune ecriture. Rejouer avec CONFIRMER=true pour appliquer.")
return 0
return appliquer(api, p, vpn)
def cmd_pair(args) -> int:
vpn = plan_vpn()
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.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")
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 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)
if d is None:
raise SystemExit(f"REFUS : aucun pair « {args.nom} » au plan du site.")
api = api_frontiere()
srv, _pairs = etat(api)
nom = str(vpn.get("nom") or "admins")
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()
print(f"[Interface]\n# PrivateKey : celle affichee par `pair-nouveau` pour {args.nom}\n"
f"PrivateKey = <cle privee de l'appareil>\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())}\n"
f"PersistentKeepalive = 25")
return 0
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.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.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.set_defaults(fn=cmd_config)
args = ap.parse_args()
return args.fn(args)
if __name__ == "__main__":
sys.exit(main())