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:
parent
2a276fb6b4
commit
be8c3917e5
9 changed files with 408 additions and 27 deletions
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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_ |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 "")
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue