acces d'administration : un tunnel par locataire, declare par lui, borne a lui

plan/acces.yml chez le locataire (cles publiques), reseau/port/instance derives de l'index.
La garde du devis refuse qu'un tunnel de locataire vise autre chose que son supernet : sans
elle, un ecosysteme s'ouvrirait un acces chez un voisin depuis son propre plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-17 16:01:54 -04:00
parent 2a276fb6b4
commit be8c3917e5
9 changed files with 408 additions and 27 deletions

View file

@ -1,5 +1,27 @@
# CHANGELOG — Set-OPS
## 2026-09-17 (5) — Un tunnel d'administration par locataire : il declare, le site sert
- **Nouveau registre de plan** : `plan/acces.yml` (`acces_admin_vpn`), une entree par personne
ET par appareil, cle PUBLIQUE seule. Decrit au schema (donc editable a la console), valide
par `valider_acces` : nom `personne-appareil`, cle WireGuard bien formee, adresse en /32
dans le tunnel derive, jamais celle de la frontiere, jamais en double.
- **Tout derive de l'index** : reseau `10.<index>.29.0/24`, port `52000+index`, instance
`wg<index>`. Rien a allouer, aucune collision (P21 garde l'unicite des index).
- **`scripts/vpn_admin.py`** tient desormais le tunnel du site ET celui de chaque locataire ;
`--locataire` vise le bon. Le nom du pair sur le boitier porte l'ecosysteme : deux
locataires peuvent avoir chacun leur `daniel-portable`.
- **L'isolement est GARDE** : `verifier` refuse une regle dont la source est le tunnel d'un
locataire et la destination autre chose que SON supernet — sans quoi un ecosysteme
obtiendrait un acces chez un voisin en ajoutant un pair dans son propre plan. Mise en
defaut eprouvee ; P24 l'execute avant toute ecriture.
- **Pare-feu des machines** : `resoudre_flux` derive le reseau du tunnel du plan du locataire,
et seulement s'il declare un pair ACTIF.
Mesure : `wg17` actif en 10.17.29.1 avec son pair, port 52017 ouvert sur le WAN, deux regles
bornees a `SETOPS_TENANT_CHEZ17`, 13 machines de Chezlepro redeployees (0 failed).
`make prouver` : CONFORME, 82 OK.
## 2026-09-17 (4) — Le tunnel d'administration, eprouve en production : deux trous refermes
Le tunnel montait, la poignee de main se faisait, une machine de LOCATAIRE repondait — et

View file

@ -74,6 +74,36 @@ Les deux règles sont désormais émises par `devis_opnsense` dès que `acces_ad
déclaré (`ports_frontiere`, par défaut 22 et 443). Une règle **par port** : OPNsense refuse
« 22,443 » dans un champ de port, et aucun flux jusque-là n'en portait deux.
## Un tunnel par locataire (2026-09-17)
Chaque écosystème a le sien, et **c'est lui qui décide qui entre chez lui**. Le site porte la
route, jamais la liste des gens — même partage que les zones DNS publiques.
| | |
|---|---|
| déclaré par | le **locataire**, dans `plan/acces.yml` (registre `acces_admin_vpn`) |
| réseau | `10.<index>.29.0/24` — **dérivé** de son index, hors de ses six zones (.16 à .21) |
| port | `52000 + index` — dérivé, donc sans collision : P21 garde déjà l'unicité des index |
| instance | `wg<index>` — un rang (2, 3, 4…) renommerait les interfaces le jour où un écosystème part |
| ce qu'il atteint | **son supernet, et rien d'autre** : SSH et consoles web |
**L'isolement est gardé, pas promis.** `devis_opnsense.verifier` refuse toute règle dont la
source est le tunnel d'un locataire et la destination autre chose que **son** supernet —
sinon, un écosystème obtiendrait un accès chez un voisin en ajoutant un pair dans son propre
plan. La garde est exécutée par P24, avant toute écriture. Mise en défaut vérifiée.
**Le pare-feu de ses machines suit tout seul** : `resoudre_flux` ajoute le réseau du tunnel aux
sources d'administration **dérivées de son plan**, et seulement s'il déclare au moins un pair
actif — déclarer un réseau que personne n'emprunte ouvrirait le SSH à un tunnel qui n'existe
pas.
```bash
python3 scripts/vpn_admin.py pair-nouveau --nom prenom-appareil --locataire OPS-<X>
# coller le bloc rendu dans OPS-<X>/plan/acces.yml
make vpn-admin-plan && CONFIRMER=true make vpn-admin-appliquer
python3 scripts/vpn_admin.py config --nom prenom-appareil --locataire OPS-<X>
```
## Ce que le tunnel donne, et ce qu'il ne donne pas
`AllowedIPs` est **dérivé** de la carte : zones du site, fabric, lien de transit et supernets

View file

@ -62,7 +62,7 @@
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 101 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (138 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 302 regles `pass`), tous non consignes et tous motives. |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 305 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |
@ -73,7 +73,7 @@
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `392da4d` (publie le 2026-09-14). |
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 52 champ(s) sur 6 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 55 champ(s) sur 7 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:8, serveurs:3 champ(s) lus par validateur). |
| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s |
| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 41 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, serveur_ |

View file

@ -400,6 +400,45 @@
"description": "Le nom public, ex. `chezlepro.ca`."
}
},
"acces_admin_vpn": {
"title": "Acces d'administration (WireGuard)",
"x-fichier": "plan/acces.yml",
"x-racine": "acces_admin_vpn",
"entite": {
"type": "object",
"properties": {
"cle_publique": {
"type": "string",
"title": "Cle publique",
"description": "Cle WireGuard PUBLIQUE de l'appareil (44 caracteres). La privee ne quitte jamais l'appareil."
},
"adresse": {
"type": "string",
"title": "Adresse dans le tunnel",
"description": "Un /32 du reseau derive `10.<index>.29.0/24`."
},
"etat": {
"type": "string",
"title": "Etat",
"description": "`absent` revoque l'acces au prochain passage.",
"enum": [
"present",
"absent"
],
"default": "present"
}
},
"additionalProperties": false,
"required": [
"adresse",
"cle_publique"
]
},
"x-clef": {
"title": "Pair",
"description": "`personne-appareil`, ex. `daniel-portable` — un pair par appareil, pour revoquer l'un sans l'autre."
}
},
"nomenclature": {
"title": "Nomenclature",
"x-fichier": "plan/nomenclature.yml",

View file

@ -1465,6 +1465,71 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"raison": _raison_vpn,
})
# --- LES TUNNELS DES LOCATAIRES (2026-09-17) -----------------------------------
#
# UN LOCATAIRE DECLARE QUI ENTRE CHEZ LUI (`plan/acces.yml`), le site porte la route.
# Reseau et port derivent de son index ; l'instance WireGuard est posee par
# `scripts/vpn_admin.py`.
#
# L'ISOLEMENT EST LA REGLE, PAS UNE INTENTION : la destination est le supernet DU MEME
# locataire, jamais le site ni un voisin. P83 refuse toute regle qui s'en ecarterait.
import devis_reseau as _dr
for _nom_t, _pfx_t, _n_t in tenants:
_idx_t = _n_t.get("index")
if _idx_t is None:
continue
try:
_acces_t = (yaml.safe_load(
(_dr.DOSSIER_INSTANCES / _nom_t / "plan" / "acces.yml").read_text(encoding="utf-8"))
or {}).get("acces_admin_vpn") or {}
except (OSError, yaml.YAMLError):
continue
if not [n for n, c in _acces_t.items()
if isinstance(c, dict) and str(c.get("etat", "present")) == "present"]:
continue # aucun pair actif : aucune route, aucune regle
_etiq_t = f"{_pfx_t}{_idx_t}"
_alias_tunnel = f"SETOPS_ADMIN_{_etiq_t}_TUNNEL"
alias[_alias_tunnel] = {
"type": "network",
"contenu": [f"10.{int(_idx_t)}.29.0/24"],
"description": f"Tunnel d'administration de {_nom_t} (plan/acces.yml) — "
f"n'atteint que son propre ecosysteme",
}
for _port_t, _chiffre_t, _pourquoi_t in (
("22", "ssh", "SSH d'administration du locataire, par SON tunnel nominatif."),
("443", "tls", "Consoles web de son ecosysteme, par SON tunnel nominatif.")):
regles.append({
"sens": "in",
"interface": if_vpn,
"protocole": "tcp",
"source": _alias_tunnel,
"destination": f"SETOPS_TENANT_{_etiq_t}",
"ports": [_port_t],
"chiffrement": _chiffre_t,
"role": "acces_admin_vpn",
"tenant": _nom_t,
"raison": _pourquoi_t,
})
if str(intrants.get("opnsense_wan_ip") or ""):
alias.setdefault("SETOPS_FRONTIERE_WAN", {
"type": "host",
"contenu": [str(intrants["opnsense_wan_ip"])],
"description": "Adresse publique de la frontiere — porte des tunnels d'administration",
})
regles.append({
"sens": "in",
"interface": if_wan,
"protocole": "udp",
"source": "any",
"destination": "SETOPS_FRONTIERE_WAN",
"ports": [str(52000 + int(_idx_t))],
"chiffrement": "wireguard",
"role": "acces_admin_vpn",
"tenant": _nom_t,
"raison": f"Poignee de main WireGuard des administrateurs de {_nom_t} "
f"(port derive de son index).",
})
# --- LA PORTE DU TUNNEL D'ADMINISTRATION (2026-09-17) --------------------------
#
# Le tunnel ne peut pas s'etablir si son port n'est pas ouvert sur le WAN. C'est le SEUL
@ -1772,6 +1837,24 @@ def verifier(devis: dict) -> tuple[bool, list[str]]:
if not ipaddress.ip_address(d["cible"]).is_private:
erreurs.append(f"Redirection vers {d['cible']} : une cible de redirection est interne.")
# UN TUNNEL DE LOCATAIRE NE VOIT QUE SON LOCATAIRE (2026-09-17).
#
# Chaque ecosysteme declare qui entre chez lui ; le site porte la route. Si cette route
# menait ailleurs — au site, a un voisin — le locataire aurait obtenu, en ajoutant un
# pair dans SON plan, un acces chez quelqu'un d'autre. C'est la seule chose que cette
# delegation ne doit jamais permettre, et c'est pourquoi elle se garde ici, avant toute
# ecriture, plutot que de se relire sur le boitier.
for r in devis["regles"]:
src = str(r.get("source") or "")
if not (src.startswith("SETOPS_ADMIN_") and src.endswith("_TUNNEL")):
continue
attendu = "SETOPS_TENANT_" + src[len("SETOPS_ADMIN_"):-len("_TUNNEL")]
if str(r.get("destination")) != attendu:
erreurs.append(
f"Le tunnel {src} vise {r.get('destination')} : un tunnel de locataire ne "
f"doit atteindre que {attendu}. Un ecosysteme obtiendrait un acces chez un "
f"autre en ajoutant un pair dans son propre plan.")
gestion = devis.get("reseaux_gestion") or []
for r in devis["regles"]:
src = str(r.get("source") or "")

View file

@ -730,6 +730,84 @@ def fqdn_exposition(domaine: str, nom: str) -> str:
return f"{nom}.{domaine}"
# --- L'ACCES D'ADMINISTRATION D'UN LOCATAIRE (2026-09-17) ------------------------------
#
# LE LOCATAIRE DECLARE QUI ENTRE CHEZ LUI, le site ne fait que porter la route. C'est la
# meme ligne de partage que les zones DNS publiques : le locataire ecrit, le site sert.
# Seule la cle PUBLIQUE figure ici — une cle privee ne quitte jamais l'appareil.
#
# L'ADRESSE ET LE PORT DU TUNNEL SE DERIVENT DE L'INDEX, comme tout l'adressage : reseau
# `10.<index>.29.0/24` (hors des six zones, qui occupent .16 a .21) et port `52000+index`.
# Rien a allouer, rien a tenir a jour, aucune collision possible — l'unicite de l'index est
# deja gardee par P21.
ETATS_PAIR_VPN = {"present", "absent"}
# Une cle X25519 fait 32 octets : 43 caracteres base64 puis `=`, et l'avant-dernier ne peut
# valoir que l'un des seize symboles dont les deux derniers bits sont nuls — 0, 4 et 8 en font
# partie, et les oublier refusait des cles parfaitement valides (eprouve sur une vraie cle).
_CLE_WIREGUARD = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=$")
_NOM_PAIR = re.compile(r"^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])$")
def reseau_vpn_locataire(index: int) -> str:
return f"10.{int(index)}.29.0/24"
def port_vpn_locataire(index: int) -> int:
return 52000 + int(index)
def charger_acces(path: Path | None) -> dict:
"""Charge le registre des acces d'administration (acces_admin_vpn)."""
if not path or not path.exists():
return {"acces_admin_vpn": {}}
with path.open("r", encoding="utf-8") as fichier:
data = yaml.safe_load(fichier) or {}
if not isinstance(data, dict):
raise ValueError(f"{path} ne contient pas une table YAML.")
data.setdefault("acces_admin_vpn", {})
return data
def valider_acces(registre: dict, index: int | None = None) -> None:
"""Valide les pairs d'administration declares par le locataire."""
pairs = registre.get("acces_admin_vpn") or {}
if not isinstance(pairs, dict):
raise ValueError("acces_admin_vpn doit etre une table (un pair par clef).")
reseau = ipaddress.ip_network(reseau_vpn_locataire(index), strict=False) if index is not None else None
adresses: dict[str, str] = {}
for nom, conf in pairs.items():
lieu = f"Pair d'administration '{nom}'"
if not isinstance(conf, dict):
raise ValueError(f"{lieu}: table attendue.")
# UN PAIR = UNE PERSONNE ET UN APPAREIL : `prenom-appareil`. Un nom partage
# rendrait la revocation collective — retirer l'appareil perdu couperait les autres.
if not _NOM_PAIR.match(str(nom)):
raise ValueError(f"{lieu}: nom attendu `personne-appareil` (minuscules, tirets).")
etat = str(conf.get("etat", "present"))
if etat not in ETATS_PAIR_VPN:
raise ValueError(f"{lieu}: etat '{etat}' inconnu ({', '.join(sorted(ETATS_PAIR_VPN))}).")
cle = str(conf.get("cle_publique", "")).strip()
if not _CLE_WIREGUARD.match(cle):
raise ValueError(f"{lieu}: `cle_publique` attendue (cle WireGuard, 44 caracteres "
f"base64). La cle PRIVEE ne va jamais au plan.")
adr = str(conf.get("adresse", "")).strip()
try:
ip = ipaddress.ip_interface(adr)
except ValueError:
raise ValueError(f"{lieu}: `adresse` attendue, ex. 10.X.29.2/32.") from None
if ip.network.prefixlen != 32:
raise ValueError(f"{lieu}: l'adresse d'un pair est un /32 — deux pairs qui se "
f"chevauchent se volent leurs paquets.")
if reseau is not None and ip.ip not in reseau:
raise ValueError(f"{lieu}: {ip.ip} est hors du tunnel {reseau} (derive de l'index).")
if reseau is not None and ip.ip == next(reseau.hosts()):
raise ValueError(f"{lieu}: {ip.ip} est l'adresse de la frontiere dans ce tunnel.")
if str(ip.ip) in adresses and etat == "present":
raise ValueError(f"{lieu}: {ip.ip} est deja l'adresse de '{adresses[str(ip.ip)]}'.")
if etat == "present":
adresses[str(ip.ip)] = str(nom)
def valider_domaines(registre: dict) -> None:
"""Valide la coherence du registre des domaines publics."""
domaines = registre.get("domaines_publics") or {}

View file

@ -222,8 +222,32 @@ def _sources_admin_ssh() -> list[str]:
data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {}
if isinstance(data, dict) and data.get("nftables_admin_ssh"):
sources = data["nftables_admin_ssh"]
return [str(s) for s in sources] if isinstance(sources, list) else [str(sources)]
return []
recus = [str(s) for s in sources] if isinstance(sources, list) else [str(sources)]
return sorted(set(recus) | set(_tunnel_admin_locataire()))
return sorted(set(_tunnel_admin_locataire()))
def _tunnel_admin_locataire() -> list[str]:
"""Le tunnel d'administration QUE CE LOCATAIRE declare — derive, jamais recopie.
Le site donne `nftables_admin_ssh` par contrat : son plan d'administration et son propre
tunnel. Le locataire, lui, decide de SES acces (`plan/acces.yml`) ; le reseau de son
tunnel se derive de son index. L'ecrire une seconde fois dans les intrants ferait diverger
ce que la frontiere route et ce que ses machines acceptent.
AUCUN PAIR, AUCUNE SOURCE : declarer un reseau que personne n'emprunte ouvrirait le SSH
de toutes ses machines a un tunnel qui n'existe pas.
"""
from inventory_rules import charger_acces, charger_nomenclature, reseau_vpn_locataire
base = INSTANCE
try:
pairs = charger_acces(base / "plan" / "acces.yml").get("acces_admin_vpn") or {}
index = charger_nomenclature(base / "plan" / "nomenclature.yml").get("index")
except (OSError, ValueError):
return []
actifs = [n for n, c in pairs.items()
if isinstance(c, dict) and str(c.get("etat", "present")) == "present"]
return [reseau_vpn_locataire(index)] if (actifs and index is not None) else []
def _ip_par_hote(data: dict) -> dict[str, str]:

View file

@ -232,6 +232,23 @@ REGISTRES: dict = {
"aide": "Secondes (60 a 604800) ; defaut de la zone sinon."}}},
},
},
"acces_admin_vpn": {
"titre": "Acces d'administration (WireGuard)",
"fichier": "plan/acces.yml",
"racine": "acces_admin_vpn",
"clef": {"libelle": "Pair", "aide": "`personne-appareil`, ex. `daniel-portable` — "
"un pair par appareil, pour revoquer l'un sans l'autre."},
"champs": {
"cle_publique": {"type": "string", "requis": True, "libelle": "Cle publique",
"aide": "Cle WireGuard PUBLIQUE de l'appareil (44 caracteres). "
"La privee ne quitte jamais l'appareil."},
"adresse": {"type": "string", "requis": True, "libelle": "Adresse dans le tunnel",
"aide": "Un /32 du reseau derive `10.<index>.29.0/24`."},
"etat": {"type": "string", "libelle": "Etat", "defaut": "present",
"enum": ["present", "absent"],
"aide": "`absent` revoque l'acces au prochain passage."},
},
},
"nomenclature": {
"titre": "Nomenclature",
"fichier": "plan/nomenclature.yml",

View file

@ -41,6 +41,51 @@ import devis_opnsense as devis_mod # noqa: E402
import underlay as underlay_mod # noqa: E402
def tunnels() -> 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")
if index is None or not (registre.get("acces_admin_vpn") or {}):
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"):
@ -118,9 +163,9 @@ 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 [])
declares = {n: p for n, p in (vpn.get("pairs") or {}).items()
if str(p.get("etat", "present")) == "present"}
retires = {str(p["nom"]) for p in (vpn.get("pairs") or [])
retires = {n for n, p in (vpn.get("pairs") or {}).items()
if str(p.get("etat", "present")) == "absent"}
poses = {}
@ -240,34 +285,67 @@ def _uuid_pair(api: appl.Frontiere, nom_p: str) -> str:
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)
tous = tunnels()
if not tous:
print("Aucun tunnel declare — ni au plan du site, ni chez un locataire.")
return 0
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("\n Rien a faire.")
print(" Rien a faire.")
return 0
if os.environ.get("CONFIRMER") != "true":
print("\n PLAN SEUL — aucune ecriture. Rejouer avec CONFIRMER=true pour appliquer.")
print(" PLAN SEUL — aucune ecriture. Rejouer avec CONFIRMER=true pour appliquer.")
return 0
return appliquer(api, p, vpn)
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()
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 = plan_vpn()
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 [])}
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)
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")
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")
@ -276,13 +354,15 @@ def cmd_pair(args) -> int:
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)
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 du site.")
raise SystemExit(f"REFUS : aucun pair « {args.nom} » au plan de {vpn['portee']}.")
api = api_frontiere()
srv, _pairs = etat(api)
nom = str(vpn.get("nom") or "admins")
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 "
@ -295,11 +375,16 @@ def cmd_config(args) -> int:
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"AllowedIPs = {', '.join(reseaux_joignables() if vpn['portee'] == 'site' else [_supernet_de(vpn)])}\n"
f"PersistentKeepalive = 25")
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)
@ -309,9 +394,12 @@ def main() -> int:
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("config", help="la configuration a coller dans l'appareil")
p.add_argument("--nom", required=True)
p.add_argument("--locataire")
p.set_defaults(fn=cmd_config)
args = ap.parse_args()
return args.fn(args)