wireguard : rotation des cles de daniel-portable, sans afficher de privee

cle-appareil et config --cle/--vers ecrivent les secrets en 0600 ;
--portee restreint appliquer au tunnel vise. Nouvelles cles en service,
anciennes retirees, Technolibre intact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-28 12:57:15 -04:00
parent 4855cda294
commit 5f52dcb2b7
2 changed files with 84 additions and 4 deletions

View file

@ -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 <fichier>` : 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 <fichier> --vers <fichier>` : 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

View file

@ -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 = <cle privee de l'appareil>\n"
cle = (Path(args.cle).expanduser().read_text().strip() if getattr(args, "cle", None)
else "<cle privee de l'appareil>")
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)