Set-OPS-Public/scripts/remise.py

571 lines
25 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Remet un ecosysteme a son proprietaire — le paquet, le registre, et la garde.
POURQUOI CE SCRIPT EXISTE (2026-09-16). Livrer un ecosysteme se terminait par une phrase :
« tes cles te seront remises separement ». Ce qui se passait ensuite n'etait ecrit nulle
part — ni ce qu'on remet, ni dans quel ordre, ni ce qu'on garde. Un geste qui donne le
controle d'une organisation ne peut pas etre le seul geste du depot sans procedure, sans
outil et sans preuve.
LA REMISE SE FAIT EN DEUX TEMPS, et ce decoupage est tout le sujet.
TEMPS 1 — l'IDENTITE. Le client recoit de quoi gouverner ses gens tout de suite : la
cle de SA voute, sa voute chiffree, la racine de SON autorite. Des la premiere
connexion il cree, retire et habilite sans nous. L'hebergeur garde l'acces MACHINE,
parce qu'il exploite encore.
TEMPS 2 — la MACHINE. A une date convenue : la cle SSH du client entre au plan, celle
de l'hebergeur en sort, la voute change de mot de passe, les secrets applicatifs
tournent. C'est l'etape 8 de `docs/migration-tenant.md` — « revoquer, pas
transmettre » — appliquee a une livraison plutot qu'a un depart.
CE QUE CE SCRIPT NE FAIT JAMAIS :
- il n'affiche AUCUNE valeur de secret. Ni a l'ecran, ni dans le registre, ni dans un
journal : seulement des noms, des tailles et des empreintes SHA256 ;
- il n'emporte QUE l'ecosysteme monte. La cle du SITE et celles des autres locataires
vivent dans le meme dossier `~/.config` ; un motif large les embarquerait toutes, et
remettre a un client les secrets de ses voisins serait la faute que cette procedure
existe pour rendre impossible ;
- il ne fait pas tourner les secrets a la place d'un humain. Une valeur se saisit par
`voute.py saisir --remplacer`, sans echo. Un script qui engendre et ecrit tout seul
connait ce qu'il ecrit ;
- il ne DECLARE pas le temps 2 fait parce qu'on le lui dit. Il le MESURE dans le plan :
la cle du client presente, celle de l'hebergeur absente, la cle de voute changee.
Usage :
python3 scripts/remise.py recenser # ce qui partirait, sans rien ecrire
python3 scripts/remise.py paquet --vers <repertoire> # temps 1 : fabrique et relit
python3 scripts/remise.py inscrire --recu-par "…" --courriel "…" [--dans N_JOURS]
python3 scripts/remise.py verifier # etat de la remise (code de sortie)
python3 scripts/remise.py recleer --confirmer # temps 2 : mesure, puis estampille
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import os
import subprocess
import sys
import tarfile
import tempfile
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(RACINE / "scripts"))
from inventory_rules import ecriture_atomique, instance_courante # noqa: E402
from voutes import voutes # noqa: E402
MAISON = Path.home()
FICHIER_REMISE = "remise.yml"
# Le delai par defaut du second temps. Ni une regle de droit ni un caprice : un mois laisse
# au client le temps d'ouvrir son compte et de vivre avec son ecosysteme, et reste assez
# court pour qu'une revocation oubliee se voie encore comme un oubli plutot que comme un
# etat de fait.
DELAI_TEMPS2_JOURS = 30
# --- Ce qui appartient a CET ecosysteme, et rien d'autre -----------------------------
def _instance() -> Path:
base = instance_courante()
if not base.exists():
raise SystemExit("REFUS : aucune instance montee (symlink `instance/` ou "
"SETOPS_INSTANCE). Une remise vise UN ecosysteme, jamais la flotte.")
return base.resolve()
def _cle_de_voute() -> Path:
"""La cle de la voute de l'instance MONTEE — jamais celle du site ni d'un voisin.
`voutes()` nomme les trois roles (instance, hebergeur, voisin) ; on ne retient que le
premier. C'est la seule ligne qui empeche une remise d'emporter la flotte.
"""
for role, _nom, chemin in voutes():
if role == "instance":
if not chemin.is_file():
raise SystemExit(
f"REFUS : la cle de voute de cet ecosysteme est absente ({chemin}).\n"
" Sans elle, le client recevrait une voute qu'il ne peut pas ouvrir.")
return chemin
raise SystemExit("REFUS : aucune voute d'instance nommee (`voutes.py etat`).")
def _voute_chiffree(base: Path) -> Path | None:
for nom in ("principal", "production", "lab"):
p = base / "inventories" / nom / "group_vars" / "all" / "vault.yml"
if p.is_file():
return p
return None
def _racine_ac() -> Path | None:
"""La racine de l'AC, si elle a ete recuperee. PUBLIQUE — elle voyage en clair.
On ne la va PAS la chercher soi-meme : `make ca-racine` interroge une machine vivante
et affiche l'empreinte a comparer. Un script de remise qui l'irait chercher en silence
priverait l'operateur du seul geste qui distingue sa racine d'une racine interceptee.
"""
for p in (Path.cwd() / "root_ca.crt", RACINE / "root_ca.crt"):
if p.is_file():
return p
return None
def _empreinte(f: Path) -> str:
return hashlib.sha256(f.read_bytes()).hexdigest()
def _recenser(base: Path) -> list[tuple[Path, str, str]]:
"""(fichier, nature, raison). `nature` vaut `secret` ou `public`."""
pieces: list[tuple[Path, str, str]] = [
(_cle_de_voute(), "secret",
"La cle de SA voute — ce qui ouvre les secrets de CET ecosysteme, et d'aucun autre."),
]
v = _voute_chiffree(base)
if v:
pieces.append((v, "chiffre",
"Sa voute, telle qu'elle est versionnee : chiffree, inutile sans la cle."))
ca = _racine_ac()
if ca:
pieces.append((ca, "public",
"La racine de SON autorite de certification — publique, a installer."))
return pieces
# --- Le registre : ce qui a ete remis, a qui, et ce qui reste du ----------------------
def _chemin_registre(base: Path) -> Path:
return base / FICHIER_REMISE
def _lire_registre(base: Path) -> dict:
p = _chemin_registre(base)
if not p.is_file():
return {}
return (yaml.safe_load(p.read_text(encoding="utf-8")) or {}).get("remise") or {}
def _ecrire_registre(base: Path, remise: dict) -> Path:
entete = (
"# REMISE de cet ecosysteme — a qui il a ete remis, et ce qui reste du.\n"
"#\n"
"# GENERE par `make remise-inscrire` / `make remise-recleer`. Ne pas editer a la\n"
"# main : reinscrire.\n"
"#\n"
"# IL NE PORTE AUCUN SECRET, par construction : des noms, des tailles et des\n"
"# empreintes SHA256. Une empreinte prouve qu'on a remis CE fichier-la sans rien\n"
"# reveler de son contenu.\n"
"#\n"
"# Le second temps — `recleer` — n'est pas une formalite : tant qu'il n'a pas eu\n"
"# lieu, l'hebergeur garde l'acces machine d'un client qui, lui, se croit chez lui.\n"
"---\n")
cible = _chemin_registre(base)
with ecriture_atomique(cible) as f:
f.write(entete)
yaml.safe_dump({"remise": remise}, f, default_flow_style=False,
sort_keys=False, allow_unicode=True)
return cible
def _organisation(base: Path) -> str:
for nom in ("principal", "production", "lab"):
f = base / "inventories" / nom / "group_vars" / "all" / "10-intrants.yml"
if f.is_file():
d = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
if d.get("organisation"):
return str(d["organisation"])
return base.name
# --- Le plan : ce qu'il dit des cles d'administration --------------------------------
def _cles_admin_du_plan(base: Path) -> list[dict]:
for nom in ("principal", "production", "lab"):
f = base / "inventories" / nom / "group_vars" / "all" / "10-intrants.yml"
if f.is_file():
d = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
if "ssh_baseline_cles_admin" in d:
return list(d.get("ssh_baseline_cles_admin") or [])
return []
def _etat_des_cles(base: Path) -> tuple[list[str], list[str]]:
"""(presentes, absentes) — les raisons declarees, pas les cles elles-memes."""
presentes, absentes = [], []
for e in _cles_admin_du_plan(base):
if not isinstance(e, dict):
continue
etiquette = str(e.get("pourquoi") or str(e.get("cle", ""))[:40])
(absentes if str(e.get("etat", "present")) == "absent" else presentes).append(etiquette)
return presentes, absentes
# --- Les commandes --------------------------------------------------------------------
def cmd_recenser(_args) -> int:
base = _instance()
pieces = _recenser(base)
print(f" REMISE DE : {_organisation(base)} ({base.name})\n")
for f, nature, raison in pieces:
print(f" [{nature}] {raison}")
print(f" {f.name:<44} {f.stat().st_size:>6} o {_empreinte(f)[:16]}…")
if not _racine_ac():
print("\n MANQUE la racine de l'AC (`root_ca.crt`) — elle est PUBLIQUE et le")
print(" client en a besoin pour que son navigateur cesse de crier :")
print(" make ca-racine puis make ca-empreinte (a comparer)")
print("\n CE QUI NE PART PAS, ET C'EST VOULU : la cle du SITE, celles des autres")
print(" locataires, et les cles SSH de l'hebergeur. Elles vivent dans le meme")
print(" dossier ; seul l'ecosysteme monte est emporte.")
print("\n RECENSEMENT SEUL — rien n'a ete ecrit.")
return 0
def _refuser_destination_interne(dest: Path) -> None:
"""Une remise DANS l'infrastructure ne remet rien : elle boucle.
Meme refus que `exporter_cles.py`, et pour la meme raison : le depot de sauvegarde est
chiffre par un mot de passe qui vit dans la voute que ce paquet ouvre.
"""
for i in (MAISON / "Espace Chezlepro" / "DépôtsSurForge", Path("/srv/restic")):
try:
dest.resolve().relative_to(i.resolve())
except (ValueError, OSError):
continue
raise SystemExit(
f"REFUS : {dest} est DANS l'infrastructure ({i}).\n"
" Un paquet de remise se depose sur un support remis en main propre,\n"
" pas dans les depots que ce meme paquet permet d'ouvrir.")
LISEZ_MOI = """PAQUET DE REMISE — VOTRE ECOSYSTEME
===================================
Ce support porte ce qui vous rend maitre de votre ecosysteme. Il ne contient AUCUN
mot de passe en clair : la phrase de passe de l'archive vous est transmise par un
autre canal, et sans elle ce support est du bruit.
OUVRIR L'ARCHIVE
gpg --decrypt setops-remise-*.tar.gpg > remise.tar
tar -xvf remise.tar
CE QUE VOUS Y TROUVEZ
setops-vault-<votre-ecosysteme> la CLE de votre voute. A ranger dans
~/.config/ , droits 0600, et NULLE PART ailleurs.
vault.yml votre voute, chiffree. Inutile sans la cle.
root_ca.crt la racine de votre autorite de certification.
PUBLIQUE : elle s'installe, elle ne se cache pas.
REMETTRE LA CLE EN PLACE
mkdir -p ~/.config && umask 077
cp setops-vault-* ~/.config/
chmod 600 ~/.config/setops-vault-*
VERIFIER LA RACINE AVANT DE L'INSTALLER
openssl x509 -in root_ca.crt -noout -subject -fingerprint -sha256
Comparez cette empreinte avec celle qui vous a ete transmise separement. Installer une
autorite, c'est lui donner le droit de signer n'importe quel nom pour votre navigateur :
la comparaison est ce qui distingue votre racine d'une racine interceptee.
CE QUI RESTE A FAIRE, ET QUI N'EST PAS FACULTATIF
1. Ouvrir votre compte et changer le mot de passe a usage unique (votre document de
livraison, section 3).
2. Ranger la phrase de passe de cette archive AILLEURS que sur ce support. Separes,
ils ne valent rien l'un sans l'autre ; ensemble, ils valent votre ecosysteme.
3. Faire une seconde copie de ce support, dans un autre lieu physique.
CE QUE VOTRE HEBERGEUR DETIENT ENCORE
Tant que le SECOND TEMPS de la remise n'a pas eu lieu, votre hebergeur garde l'acces
machine a votre ecosysteme — c'est ce qui lui permet de l'exploiter pour vous. Ce second
temps fait entrer VOTRE cle, sortir la sienne, et change le mot de passe de votre voute.
Sa date est inscrite dans le registre de remise de votre depot (remise.yml).
"""
def cmd_paquet(args) -> int:
base = _instance()
dest = Path(args.vers).expanduser()
_refuser_destination_interne(dest)
if not dest.is_dir():
raise SystemExit(f"REFUS : {dest} n'existe pas ou n'est pas un repertoire.")
pieces = _recenser(base)
if not _racine_ac():
raise SystemExit(
"REFUS : la racine de l'AC (`root_ca.crt`) n'est pas la.\n"
" Sans elle, le client installe un ecosysteme dont son navigateur refuse\n"
" tous les certificats — et il apprendra a cliquer sur « continuer quand\n"
" meme », ce qui vaut pire que pas de TLS du tout.\n"
" La recuperer : make ca-racine puis make ca-empreinte")
org = _organisation(base)
attendu = {f.name: _empreinte(f) for f, _, _ in pieces}
print(f" REMISE DE : {org} ({base.name})\n")
for f, nature, _ in pieces:
print(f" [{nature:<7}] {f.name:<40} {_empreinte(f)[:16]}…")
suffixe = "tar" if args.support_chiffre else "tar.gpg"
archive = dest / f"setops-remise-{base.name.lower()}.{suffixe}"
if archive.exists():
raise SystemExit(
f"REFUS : {archive} existe deja.\n"
" On n'ecrase pas un paquet de remise : il est peut-etre celui qu'on vient\n"
" de verifier. Le deplacer, ou choisir une autre destination.")
with tempfile.TemporaryDirectory() as tmp:
brut = Path(tmp) / "remise.tar"
with tarfile.open(brut, "w") as tar:
for f, _, _ in pieces:
tar.add(f, arcname=f.name)
if args.support_chiffre:
# LE SUPPORT EST DEJA CHIFFRE AU REPOS (LUKS). Meme raisonnement que
# `exporter_cles.py` : la seconde couche ne protege que du vol du support, que
# LUKS couvre deja, au prix d'une phrase de passe stockee nulle part. Le choix
# reste EXPLICITE : le silence ne doit jamais pencher du cote de la divulgation.
brut.replace(archive)
relu_noms = set(tarfile.open(archive).getnames())
manquants = [n for n in attendu if n not in relu_noms]
if manquants:
archive.unlink(missing_ok=True)
raise SystemExit(f"ECHEC de la relecture — paquet SUPPRIME. "
f"Manquent : {', '.join(manquants)}")
print(f"\n Ecrit EN CLAIR : {archive}")
print(" LA PROTECTION EST CELLE DU SUPPORT — demonte-le quand il ne sert pas.")
else:
print("\n Une phrase de passe va etre demandee (deux fois : ecriture, puis")
print(" verification). Elle n'est stockee NULLE PART — elle se transmet au")
print(" client par un AUTRE canal que ce support.\n")
r = subprocess.run(["gpg", "--symmetric", "--cipher-algo", "AES256",
"--s2k-digest-algo", "SHA512", "--output", str(archive),
str(brut)])
if r.returncode != 0 or not archive.exists():
raise SystemExit("ECHEC du chiffrement — rien n'a ete ecrit.")
# ECRIRE PUIS RELIRE (D-68). Un paquet qu'on n'a pas rouvert n'est pas un
# paquet : c'est un fichier dont on espere quelque chose. Et celui-la, on ne
# le rouvrira qu'une fois chez le client, quand il sera trop tard pour refaire.
print("\n Relecture de ce qui vient d'etre ecrit…\n")
relu = Path(tmp) / "relu.tar"
r = subprocess.run(["gpg", "--quiet", "--output", str(relu), "--decrypt",
str(archive)])
if r.returncode != 0:
archive.unlink(missing_ok=True)
raise SystemExit(
"ECHEC de la relecture — le paquet a ete SUPPRIME.\n"
" Un paquet de remise qu'on ne sait pas rouvrir est pire que rien :\n"
" il donne le sentiment d'avoir remis.")
ecarts = []
with tarfile.open(relu) as tar:
noms = tar.getnames()
for nom, emp in attendu.items():
if nom not in noms:
ecarts.append(f"{nom} : ABSENT du paquet")
continue
membre = tar.extractfile(nom)
if (hashlib.sha256(membre.read()).hexdigest() if membre else "") != emp:
ecarts.append(f"{nom} : empreinte differente")
for nom in noms:
if nom not in attendu:
ecarts.append(f"{nom} : PRESENT en trop dans le paquet")
if ecarts:
archive.unlink(missing_ok=True)
print(" ECARTS — le paquet a ete SUPPRIME :", file=sys.stderr)
for e in ecarts:
print(f" ! {e}", file=sys.stderr)
return 1
(dest / "LISEZ-MOI-REMISE.txt").write_text(LISEZ_MOI, encoding="utf-8")
try:
archive.chmod(0o600)
acces = "0600"
except OSError:
acces = "support sans droits Unix (FAT/exFAT) — c'est le chiffrement qui protege"
print(f" VERIFIE : {len(attendu)} piece(s) relues, empreintes identiques.")
print(f" Paquet : {archive} ({acces})")
print(f" + {dest / 'LISEZ-MOI-REMISE.txt'}")
print("\n IL RESTE DEUX GESTES, et le second n'a pas d'outil :")
print(" 1. inscrire la remise : make remise-inscrire RECU_PAR=\"…\" COURRIEL=\"…\"")
print(" 2. transmettre la phrase de passe par un AUTRE canal que ce support.")
return 0
def cmd_inscrire(args) -> int:
base = _instance()
pieces = _recenser(base)
ancien = _lire_registre(base)
aujourd_hui = dt.date.today()
echeance = aujourd_hui + dt.timedelta(days=int(args.dans))
remise = {
"organisation": _organisation(base),
"ecosysteme": base.name,
"temps_1": {
"remis_le": aujourd_hui.isoformat(),
"remis_par": args.remis_par or os.environ.get("USER", "inconnu"),
"recu_par": {"nom": args.recu_par, "courriel": args.courriel},
"pieces": [{"fichier": f.name, "nature": nature,
"empreinte_sha256": _empreinte(f)} for f, nature, _ in pieces],
},
# LE RESPONSABLE DESIGNE (D-18) EST DECLARE ICI, ET NULLE PART AILLEURS.
# `docs/migration-tenant.md` le decide depuis longtemps et laisse ouverte la
# question « ou est-il declare ». La remise est le moment ou la reponse existe :
# c'est la personne qui RECOIT, et la nommer ailleurs qu'au moment de la remise
# reviendrait a la deviner.
"responsable_designe": {"nom": args.recu_par, "courriel": args.courriel},
"temps_2": {
"du_le": echeance.isoformat(),
"fait_le": (ancien.get("temps_2") or {}).get("fait_le"),
"gestes": [
"la cle SSH du client entre au plan (`etat: present`)",
"celle de l'hebergeur en sort (`etat: absent`), et le deploiement l'applique",
"la voute change de mot de passe (`ansible-vault rekey`), cle chez le client",
"les secrets applicatifs tournent (`voute.py saisir --remplacer`), puis deploiement",
],
},
}
cible = _ecrire_registre(base, remise)
print(f"{cible} inscrit — {len(pieces)} piece(s) remises a {args.recu_par}.")
print(f" Second temps du le {echeance.isoformat()} "
f"({args.dans} jours). `make remise-verifier` le rappellera.")
return 0
def _verdict(base: Path) -> tuple[int, list[str]]:
r = _lire_registre(base)
if not r:
return 0, [f"{base.name} : aucune remise inscrite — cet ecosysteme n'a pas ete remis."]
lignes, code = [], 0
t1 = r.get("temps_1") or {}
t2 = r.get("temps_2") or {}
lignes.append(f"{r.get('organisation', base.name)} — temps 1 remis le "
f"{t1.get('remis_le', '?')} a {(t1.get('recu_par') or {}).get('nom', '?')}")
presentes, absentes = _etat_des_cles(base)
if t2.get("fait_le"):
lignes.append(f" temps 2 : fait le {t2['fait_le']}")
# LA CONTRADICTION QU'ON CHERCHE : un registre qui dit « revoque » pendant que le
# plan garde la cle de l'hebergeur. C'est le seul mensonge que ce fichier puisse
# porter sans qu'on s'en apercoive, parce qu'il flatte tout le monde.
if not absentes:
code = 1
lignes.append(" CONTRADICTION : le registre dit le temps 2 fait, et le plan "
"ne revoque AUCUNE cle d'administration.")
else:
du = t2.get("du_le")
if du and dt.date.fromisoformat(str(du)) < dt.date.today():
code = 1
retard = (dt.date.today() - dt.date.fromisoformat(str(du))).days
lignes.append(f" temps 2 : DU DEPUIS {retard} jour(s) ({du}) — l'hebergeur garde "
f"l'acces machine d'un client qui se croit chez lui.")
else:
lignes.append(f" temps 2 : du le {du or '?'} — pas encore fait.")
lignes.append(f" cles d'administration au plan : {len(presentes)} presente(s), "
f"{len(absentes)} revoquee(s)")
return code, lignes
def cmd_verifier(_args) -> int:
base = _instance()
code, lignes = _verdict(base)
for l in lignes:
print(l)
return code
def cmd_recleer(args) -> int:
"""Le second temps : on MESURE le plan, puis on estampille. Jamais l'inverse."""
base = _instance()
r = _lire_registre(base)
if not r:
raise SystemExit("REFUS : aucune remise inscrite — le second temps n'a rien a suivre.\n"
" Commencer par `make remise-inscrire`.")
presentes, absentes = _etat_des_cles(base)
cle_voute = _cle_de_voute()
empreinte_inscrite = next(
(p.get("empreinte_sha256") for p in ((r.get("temps_1") or {}).get("pieces") or [])
if p.get("fichier") == cle_voute.name), None)
manques = []
if not absentes:
manques.append("aucune cle d'administration n'est declaree `etat: absent` — celle "
"de l'hebergeur doit sortir du plan, puis le deploiement l'applique")
if not presentes:
manques.append("aucune cle d'administration `present` — le client doit avoir la "
"sienne AVANT qu'on retire la notre, sinon plus personne n'entre")
if empreinte_inscrite and _empreinte(cle_voute) == empreinte_inscrite:
manques.append("la cle de voute est IDENTIQUE a celle remise au temps 1 — la voute "
"n'a pas ete re-cletee (`ansible-vault rekey`)")
if manques:
print(" LE SECOND TEMPS N'EST PAS FAIT — rien n'a ete estampille :", file=sys.stderr)
for m in manques:
print(f" ! {m}", file=sys.stderr)
print("\n L'ordre, et il ne se permute pas : la cle du client entre, puis la "
"notre sort,\n puis la voute est re-cletee, puis les secrets tournent, puis "
"on deploie.\n Voir docs/remise-au-client.md §4.", file=sys.stderr)
return 1
if not args.confirmer:
print(" Tout est mesure comme fait. Estampiller le registre :")
print(" make remise-recleer CONFIRMER=true")
return 0
r.setdefault("temps_2", {})["fait_le"] = dt.date.today().isoformat()
r["temps_2"]["constate"] = {
"cles_presentes": presentes,
"cles_revoquees": absentes,
"cle_de_voute_changee": True,
}
cible = _ecrire_registre(base, r)
print(f"{cible} — second temps estampille le {r['temps_2']['fait_le']}.")
print(" L'hebergeur n'a plus d'acces machine declare a cet ecosysteme.")
return 0
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="commande", required=True)
sub.add_parser("recenser", help="Ce qui partirait, sans rien ecrire.")
pp = sub.add_parser("paquet", help="Temps 1 : fabrique le paquet, et le relit.")
pp.add_argument("--vers", required=True, help="Repertoire de destination (support remis).")
pp.add_argument("--support-chiffre", action="store_true",
help="Le support est DEJA chiffre au repos (LUKS) : tar clair.")
pi = sub.add_parser("inscrire", help="Inscrit la remise dans le depot du locataire.")
pi.add_argument("--recu-par", required=True, help="Qui recoit — le responsable designe.")
pi.add_argument("--courriel", required=True, help="Son courriel.")
pi.add_argument("--remis-par", default=None, help="Qui remet (defaut : $USER).")
pi.add_argument("--dans", default=str(DELAI_TEMPS2_JOURS),
help=f"Jours avant le second temps (defaut : {DELAI_TEMPS2_JOURS}).")
sub.add_parser("verifier", help="Etat de la remise (code de sortie).")
pr = sub.add_parser("recleer", help="Temps 2 : mesure le plan, puis estampille.")
pr.add_argument("--confirmer", action="store_true", help="Estampiller le registre.")
args = ap.parse_args(argv)
return {
"recenser": cmd_recenser, "paquet": cmd_paquet, "inscrire": cmd_inscrire,
"verifier": cmd_verifier, "recleer": cmd_recleer,
}[args.commande](args)
if __name__ == "__main__":
raise SystemExit(main())