diff --git a/CHANGELOG.md b/CHANGELOG.md index 041e0bb..48a513d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # CHANGELOG — Set-OPS +## 2026-09-28 (7) — Rotation des clés WireGuard de l'exploitant, sans qu'aucune privée ne s'affiche + +**Pourquoi.** Les deux clés privées de `daniel-portable` (tunnel du site, tunnel de +Chezlepro) ont fui dans l'historique d'une session d'assistance. On les change. + +**Ce que `vpn_admin.py` ne savait pas faire.** `pair-nouveau` AFFICHE la privée pour qu'on +la colle ; lancé par un assistant ou dans un terminal journalisé, cet affichage la dépose +dans un historique — exactement la fuite qu'on répare. Trois ajouts : + +- `cle-appareil --vers ` : la privée va droit dans un fichier en 0600 (refus + d'écraser), seule la publique s'affiche. Sert à la création comme à la rotation ; +- `config --cle --vers ` : la configuration COMPLÈTE, écrite en 0600 ; +- `plan|appliquer --portee site --portee OPS-X` : sans filtre, `appliquer` réconcilie TOUS + les tunnels — la rotation aurait emporté l'écart en attente de Technolibre (un pair + retiré que personne n'a encore décidé de retirer). + +Et `service : ?` après un rechargement réussi : OPNsense répond `result`, pas `status`. + +**Fait.** Deux paires tirées dans `~/wireguard-rotation/` (hors dépôt), `cle_publique` +remplacée au plan du site et de Chezlepro — nom et adresse inchangés —, appliqué sur ces +deux seuls tunnels. Relu EN SERVICE sur la frontière : les deux nouvelles clés actives, +les deux anciennes absentes. Technolibre intact. + ## 2026-09-28 (6) — Les voûtes sortent du poste, à côté des clés qui les ouvrent **Ce qui manquait.** La clé USB portait les CLÉS des voûtes, pas les voûtes. Or elles sont diff --git a/scripts/vpn_admin.py b/scripts/vpn_admin.py index 45207fa..d52fd1c 100644 --- a/scripts/vpn_admin.py +++ b/scripts/vpn_admin.py @@ -278,7 +278,10 @@ def appliquer(api: appl.Frontiere, p: dict, vpn: dict) -> int: 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", "?")) + # 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 @@ -293,6 +296,16 @@ def cmd_plan(args) -> int: 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) @@ -356,6 +369,33 @@ def cmd_pair(args) -> int: 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() @@ -371,15 +411,24 @@ def cmd_config(args) -> int: 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 = \n" + 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") + 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 @@ -394,15 +443,23 @@ def main() -> int: 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)