acces d'administration : un tunnel WireGuard nominatif, pas le runner en rebond

Le runner detient la voute et les cles SSH : en faire la porte des humains reunirait deux
pouvoirs que le depot separe. Instance `admins` a cote du tunnel site-a-site, un pair par
personne et par appareil, cles publiques seules au plan. Le reseau du tunnel est un reseau
d'administration : pare-feux d'hote, contrat des locataires et regles de bordure en derivent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-17 14:42:48 -04:00
parent ee386dca00
commit d90513ca91
9 changed files with 511 additions and 14 deletions

View file

@ -1,5 +1,29 @@
# CHANGELOG — Set-OPS
## 2026-09-17 (3) — L'administration entre par un tunnel nominatif, pas par le runner
Question posee : « le runner ne devrait-il pas etre le rebond SSH des admins ? » Non — il
detient la cle de la voute du site et les cles SSH de toutes les machines. Une session humaine
compromise deviendrait le plan de controle, et reconstruire le runner couperait l'acces.
- **Instance WireGuard `admins`** (port 51821, `10.37.29.0/24`), creee par
`scripts/vpn_admin.py` — a COTE du tunnel site-a-site vers le site pair, jamais melee a lui.
Perimetre strict : un pair attache a une autre instance n'est jamais touche.
- **Un pair = une personne ET un appareil.** Le plan ne porte que des cles PUBLIQUES ;
`pair-nouveau` tire la paire et affiche la privee une fois. `etat: absent` revoque, et tout
pair de notre instance que le plan ne declare plus est RETIRE.
- **Le reseau du tunnel est un reseau d'administration, et tout en derive** : pare-feux des
machines du site (`resoudre_flux`), contrat vers les locataires (`site_intrants`, donc leurs
machines aussi), regles de la frontiere (`devis_opnsense`).
- **`devis_opnsense` connait une troisieme voie d'arrivee.** Un CIDR d'administration etait
soit « gestion » soit « WAN » ; celui d'un tunnel n'est ni l'un ni l'autre, et sa regle
aurait ete posee sur une patte que ce trafic n'emprunte jamais.
- **Un seul port ouvert sur l'Internet** : 51821/udp vers l'adresse publique de la frontiere.
Mesure : `wg1` active en `10.37.29.1` ; `pfctl` porte la regle du port et les 15 acces du
tunnel ; les 31 machines (site + deux locataires) acceptent `10.37.29.0/24` en SSH,
0 failed. `make prouver` : CONFORME, 82 OK. Document : `docs/acces-administration.md`.
## 2026-09-17 (2) — La frontiere rejoint la supervision du materiel
- **Exportateur** : greffon officiel `os-node_exporter` installe et configure par l'API

View file

@ -823,6 +823,13 @@ site-intrants: ## Les intrants que le SITE monte expose a ses locataires (derive
site-intrants-verifier: ## Le locataire monte suit-il encore son site ? (aucune ecriture)
python3 scripts/site_intrants.py --verifier
.PHONY: vpn-admin-plan vpn-admin-appliquer
vpn-admin-plan: ## Acces WireGuard des admins : ce que la frontiere porte face au plan (aucune ecriture)
python3 scripts/vpn_admin.py plan
vpn-admin-appliquer: ## Pose l'instance et les pairs declares, retire ceux qui ne le sont plus — CONFIRMER=true
CONFIRMER=$(CONFIRMER) python3 scripts/vpn_admin.py appliquer
.PHONY: dnssec-ds dnssec-verifier
dnssec-ds: ## DS a remettre au registraire, calcules depuis la voute du locataire (aucune ecriture)
python3 scripts/dnssec.py ds $(ZONE)

View file

@ -0,0 +1,71 @@
# L'accès d'administration — un tunnel nominatif
> **Pour qui :** celui qui administre le site et ses écosystèmes, et celui qui se demande
> par où un humain entre, avec quelle clé, et ce qu'il peut atteindre une fois entré.
>
> Décidé le 2026-09-17, en réponse à une question : *le runner ne devrait-il pas être le
> rebond SSH des admins ?*
## Pourquoi pas le runner
Le runner du site détient **la clé de la voûte du site** et les clés SSH qui configurent
toutes les machines. En faire la porte des humains réunirait deux pouvoirs que tout le reste
du dépôt sépare :
- une session humaine compromise (un agent SSH transféré de trop) deviendrait le **plan de
contrôle** — pas seulement un rebond ;
- il **ne doit jamais entrer chez un locataire** (charte des responsabilités) ; en faire le
passage obligé des admins créerait ce chemin en fait ;
- il est **reconstructible par le code**, et c'est sa vertu. Un point d'entrée doit survivre à
la reconstruction de ce qu'il sert ;
- « qui est entré » et « qu'est-ce qui a été déployé » cesseraient de se raconter séparément.
## Ce qui a été construit
Un **second** tunnel WireGuard sur la frontière — l'instance `admins`, port 51821 — à côté du
tunnel site-à-site vers le site pair (instance `chezlePro`, port 51820), jamais mêlé à lui.
| | |
|---|---|
| déclaré | `SITE-<nom>/plan/10-intrants.yml`, clé `acces_admin_vpn` |
| réconcilié | `scripts/vpn_admin.py` (`make vpn-admin-plan`, `make vpn-admin-appliquer`) |
| réseau | `10.37.29.0/24`, la frontière en `.1` |
| un pair | **une personne ET un appareil** — révoquer l'appareil perdu ne coupe pas les autres |
| clés | seule la clé **publique** est au plan ; la privée ne quitte jamais l'appareil |
**Le réseau du tunnel est un réseau d'administration, et tout en dérive** : les pare-feux des
machines du site (`resoudre_flux`), le contrat vers les locataires (`site_intrants`, donc les
pare-feux de leurs machines) et les règles de la frontière (`devis_opnsense`). Rien à recopier
— une liste recopiée prend toujours du retard sur celle qu'elle suit.
## Ajouter un appareil
```bash
python3 scripts/vpn_admin.py pair-nouveau --nom prenom-appareil # tire la paire de clés
# coller le bloc `pairs:` rendu dans SITE-<nom>/plan/10-intrants.yml
make vpn-admin-plan # lire ce qui changerait
CONFIRMER=true make vpn-admin-appliquer # poser le pair
python3 scripts/vpn_admin.py config --nom prenom-appareil # la config de l'appareil
make flux && make deployer-groupe GROUPE=serveur_durci # les pare-feux des machines
```
La clé privée ne s'affiche **qu'une fois**, au moment où elle est tirée : elle appartient à
l'appareil. Perdue, on en tire une autre ; l'ancienne se révoque par `etat: absent`.
## Retirer un accès
`etat: absent` sur le pair, puis `CONFIRMER=true make vpn-admin-appliquer`. Le script retire
aussi tout pair **attaché à notre instance que le plan ne déclare plus** : un accès qui
survivrait à la décision de le retirer est exactement ce qu'on ne veut pas.
## 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
des locataires. Ce que la frontière laisse ensuite passer reste décidé par les flux déclarés —
SSH partout, et les consoles d'administration (Grafana, Icinga Web, la forge, le runner).
**Le seul port ouvert sur l'Internet par cette voie est le 51821/udp**, vers l'adresse publique
de la frontière. Tout le reste voyage dans le tunnel.
**Ce qui n'est pas fait** : l'enregistrement des sessions (un rebond dédié le permettrait, pas
un tunnel), et la double authentification — la possession de l'appareil fait foi.

View file

@ -43,26 +43,26 @@
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 5 pool(s) Proxmox, 44 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 71 scripts expliques et atteignables, 131 cibles make documentees, 69 roles avec README. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 72 scripts expliques et atteignables, 133 cibles make documentees, 69 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (147 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 42 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 48 document(s) declarent leur lecteur (43 genere(s) exempte(s)). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 49 document(s) declarent leur lecteur (43 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (3 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 8 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 96 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 67 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 68 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 5 edge(s) emettent un certificat portant les noms publies (instance-ci-1646753/production, OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 192 regle(s) du site. |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 197 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| 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 99 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| 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 283 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 299 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 |

View file

@ -25,7 +25,7 @@ README de rôles). Cette page comble ces deux trous.
|---|---|---|
| rôles | 69 | `roles/*/` |
| README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 44 | `docs/*.md` |
| documents | 45 | `docs/*.md` |
| pièces d'audit | 48 | `docs/audit/*` |
| unités de wiki | 27 | `wiki/*.md` |
| décisions en vigueur | 85 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
@ -81,6 +81,7 @@ Ce que je re-découvre sinon. **Consulter avant de concevoir un nouveau mécanis
| Pools Proxmox | un pool par tenant : les noms courts de VM sont **volontairement identiques** d'un tenant à l'autre (même fonction, même nom), et seule la console Proxmox en souffrait | `scripts/devis_proxmox_pools.py` (`make devis-proxmox-pools`) ; nom dérivé de l'`index` ; garde de collision = preuve **P28** | `decisions-architecture.md` D-37 |
| Routage | **aucun commutateur ne route** : la frontière est le seul équipement L3 ; les switches commutent | `passerelle` dit qui porte la passerelle, le SVI se dérive du rôle du porteur | `decisions-architecture.md` D-49/50 |
| **Devis de service** | LIT le système en marche et le compare à ce que le plan dérive ; n'écrit rien (D-23/D-24 portés du réseau aux services). Le playbook **relève**, Python **compare** | `playbooks/maintenance/devis-*.yml` + `scripts/devis_*.py` ; cible `make <sujet>-plan` | `devis-services.md` |
| Accès d'administration | un **tunnel WireGuard nominatif** (instance `admins`) : un pair par personne et par appareil, le réseau du tunnel devient un réseau d'administration dont les pare-feux d'hôte, le contrat vers les locataires et les règles de bordure **dérivent**. Le runner n'est pas le rebond, et le document dit pourquoi | `scripts/vpn_admin.py` (`make vpn-admin-plan`) ; déclaré dans `acces_admin_vpn` (plan du site) | `acces-administration.md` |
| Frontière nord/sud | les flux `pair: externe` — **sautés** par le pare-feu d'hôte — sont la politique de bordure | `scripts/devis_opnsense.py` (`make devis-opnsense`) ; garde d'accès admin = preuve **P24** | `frontiere-opnsense.md` |
> ⚠️ **Deux directions de binding, assumées** : `app→app` côté app (instancier),

View file

@ -468,19 +468,36 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
# frontiere (`10.0.0.0/24` ici) arrive par l'interface de GESTION ; un plan distant,
# qui revient par l'adresse publique ou un VPN, arrive par le WAN.
reseaux_gestion = reseaux_locaux_frontiere(underlay_mod.charger(), transit_underlay())
# LE TUNNEL D'ADMINISTRATION (2026-09-17). Un CIDR d'administration qui tombe dedans
# n'arrive NI par la gestion NI par le WAN : il arrive par l'interface WireGuard. Range
# ailleurs, sa regle serait posee sur une patte que ce trafic n'emprunte jamais — la
# panne la plus silencieuse de cette couche, et la troisieme fois qu'elle se presente.
_vpn_admin = (underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("acces_admin_vpn") or {}
_reseau_vpn = str(_vpn_admin.get("reseau") or "")
if_vpn = _ou_marqueur(intrants_frontiere(), "opnsense_if_admin_vpn", "wireguard")
def _par_le_vpn(cidr: str) -> bool:
if not _reseau_vpn:
return False
try:
return ipaddress.ip_network(cidr, strict=False).subnet_of(
ipaddress.ip_network(_reseau_vpn, strict=False))
except ValueError:
return False
admin_par_tenant: dict[str, list[str]] = {}
admin_par_if: dict[str, dict[str, list[str]]] = {}
for nom, pfx, n in tenants:
etiquette = f"{pfx}{n['index']}"
cidrs = admin_de(nom)
admin_par_tenant[etiquette] = cidrs
locaux = [c for c in cidrs if _porte_par(c, reseaux_gestion)]
distants = [c for c in cidrs if c not in locaux]
admin_par_if[etiquette] = {"gestion": locaux, "wan": distants}
par_vpn = [c for c in cidrs if _par_le_vpn(c)]
locaux = [c for c in cidrs if c not in par_vpn and _porte_par(c, reseaux_gestion)]
distants = [c for c in cidrs if c not in par_vpn and c not in locaux]
admin_par_if[etiquette] = {"gestion": locaux, "wan": distants, "vpn": par_vpn}
# UN ALIAS PAR INTERFACE, et le nom le dit. Un alias unique couvrant les deux
# cotes forcerait a poser la meme regle deux fois avec des sources qui ne peuvent
# pas arriver la : la moitie serait morte, sans que rien ne le signale.
for cle, portee in (("gestion", "GESTION"), ("wan", "WAN")):
for cle, portee in (("gestion", "GESTION"), ("wan", "WAN"), ("vpn", "VPN")):
part = admin_par_if[etiquette][cle]
if part:
alias[f"SETOPS_ADMIN_{etiquette}_{portee}"] = {
@ -580,6 +597,8 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
portees.append((f"SETOPS_ADMIN_{etiquette}_GESTION", if_gestion))
if admin_par_if[etiquette]["wan"]:
portees.append((f"SETOPS_ADMIN_{etiquette}_WAN", if_wan))
if admin_par_if[etiquette]["vpn"]:
portees.append((f"SETOPS_ADMIN_{etiquette}_VPN", if_vpn))
elif entrant and "voisins_site" in _pairs(fl):
# UN FLUX ENTRE TENANTS DU MEME SITE.
#
@ -624,6 +643,8 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
portees = [("any", if_wan)]
if admin_par_if[etiquette]["gestion"] and fl.get("poste", True):
portees.append((f"SETOPS_ADMIN_{etiquette}_GESTION", if_gestion))
if admin_par_if[etiquette]["vpn"] and fl.get("poste", True):
portees.append((f"SETOPS_ADMIN_{etiquette}_VPN", if_vpn))
elif "voisins_site" in _pairs(fl):
# SORTANT VERS UN VOISIN : la destination est NOMMEE, pas « tout sauf
# l'interne ». Rendre `!SETOPS_INTERNES` ici aurait ouvert le port vers
@ -756,6 +777,13 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"contenu": _admin_site,
"description": "Plan d'administration — seule source du SSH vers le site",
}
if _reseau_vpn:
alias["SETOPS_ADMIN_SITE_VPN"] = {
"type": "network",
"contenu": [_reseau_vpn],
"description": "Tunnel WireGuard des administrateurs (plan du site, "
"`acces_admin_vpn`) — arrive par l'interface WireGuard",
}
# Le socle vaut aussi pour le site : ses machines sont des Debian de la flotte,
# et c'est lui qui porte leur SSH. `site_inventaire.py` les y range.
# LE SITE RESOUT CHEZ SA PASSERELLE, ET IL FAUT LE DIRE (2026-08-25).
@ -929,7 +957,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
_portees = [("SETOPS_FABRIC", _if_de(r)) for r in _res_fabric]
if not _portees:
continue
elif _entrant and ("admin" in _pairs_fl or "22" in _ports(_fl)) and _admin_site:
elif _entrant and ("admin" in _pairs_fl or "22" in _ports(_fl)) and (_admin_site or _reseau_vpn):
# `admin` N'EST PAS QUE LE SSH (2026-09-12). Cette branche testait le
# PORT — « si c'est du 22, ça vient de l'administration ». Juste tant
# que le seul flux administratif etait SSH.
@ -944,7 +972,12 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
# Le SSH de gestion arrive par la patte d'ADMINISTRATION de la
# frontiere, pas par celle du site : c'est la que le paquet penetre
# le pare-feu. Une regle posee ailleurs ne correspondrait jamais.
_portees = [("SETOPS_ADMIN_SITE", if_gestion)]
# DEUX CHEMINS, DEUX PATTES. Le plan d'administration entre par la
# gestion ; le tunnel des admins par l'interface WireGuard. Une seule
# regle pour les deux serait juste pour l'un et morte pour l'autre.
_portees = ([("SETOPS_ADMIN_SITE", if_gestion)] if _admin_site else [])
if _reseau_vpn:
_portees.append(("SETOPS_ADMIN_SITE_VPN", if_vpn))
elif _entrant and (set(_pairs_fl) & set(_roles_site)):
# UN ROLE DU SITE QUI EN APPELLE UN AUTRE — ET CE N'EST PAS
# « L'EXTERIEUR » (mesure du 2026-09-02).
@ -1386,6 +1419,36 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"raison": _fl_f.get("raison", ""),
})
# --- 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
# flux entrant depuis l'Internet que cette voie demande — tout le reste voyage dedans.
#
# LA DESTINATION EST L'ADRESSE PUBLIQUE, PAS `any`. WireGuard ne repond qu'a une poignee
# de main correctement signee : ouvrir large ne l'affaiblirait pas, mais une frontiere
# dont la lecture dit « ce port est ouvert vers le boitier » est plus juste qu'une qui
# dit « vers n'importe quoi ».
if _reseau_vpn and _vpn_admin.get("port") and str(intrants.get("opnsense_wan_ip") or ""):
alias["SETOPS_FRONTIERE_WAN"] = {
"type": "host",
"contenu": [str(intrants["opnsense_wan_ip"])],
"description": "Adresse publique de la frontiere — porte du tunnel d'administration",
}
regles.append({
"sens": "in",
"interface": if_wan,
"protocole": "udp",
"source": "any",
"destination": "SETOPS_FRONTIERE_WAN",
"ports": [str(_vpn_admin["port"])],
"chiffrement": "wireguard",
"role": "acces_admin_vpn",
"tenant": "",
"raison": ("Poignee de main WireGuard des administrateurs du site. Le reste de "
"l'administration voyage DANS le tunnel : c'est le seul port que cette "
"voie demande sur l'Internet."),
})
# Adresse du noeud de sortie primaire sur le lien de frontiere, calculee une fois :
# elle sert au prochain saut des routes ET au commentaire qui l'explique.
_ip_sortie = adresse_noeud_de_sortie(underlay_mod.charger(), transit)

View file

@ -616,6 +616,12 @@ def _sources_admin_ssh_site() -> list[str]:
# deploie. Une passerelle seule ne suffirait pas a le laisser entrer.
if str(r.get("nom", "")) == "site-pilotage" and r.get("sous_reseau"):
srcs.append(str(r["sous_reseau"]))
# LE TUNNEL D'ADMINISTRATION, DERIVE (2026-09-17). Le declarer une seconde fois dans
# `nftables_admin_ssh` serait une liste qui en suit une autre : le jour ou le reseau du
# tunnel change, la moitie des machines refuserait l'exploitant sans rien dire.
_vpn = (underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("acces_admin_vpn") or {}
if _vpn.get("reseau"):
srcs.append(str(_vpn["reseau"]))
src = (underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("nftables_admin_ssh")
if src:
srcs += [str(s) for s in src] if isinstance(src, list) else [str(src)]

View file

@ -116,7 +116,11 @@ def contrat() -> dict:
# SOUS QUEL NOM LE SITE FAIT AUTORITE — les NS et le SOA de chaque zone publique.
"dns_public_nom": str(intrants.get("dns_public_nom") or ""),
# QUI A LE DROIT D'ENTRER EN SSH
"nftables_admin_ssh": [gestion] if gestion else [],
# QUI A LE DROIT D'ENTRER EN SSH : le plan d'administration du site ET le tunnel
# WireGuard des admins, derive de sa declaration (jamais recopie).
"nftables_admin_ssh": ([gestion] if gestion else [])
+ ([str((intrants.get("acces_admin_vpn") or {}).get("reseau"))]
if (intrants.get("acces_admin_vpn") or {}).get("reseau") else []),
# PAR OU SORTIR, ET A QUI DEMANDER L'HEURE
"passerelle_sortie": str(transit.get("passerelle_sortie") or ""),
}

321
scripts/vpn_admin.py Normal file
View file

@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""L'acces d'administration par WireGuard — le plan declare, la frontiere suit.
POURQUOI CETTE VOIE (2026-09-17). L'exploitant entrait par les pattes de la frontiere, une
regle par zone. La question posee etait : « le runner ne devrait-il pas etre le rebond ? »
Non — il detient la cle de la voute du site et les cles SSH de toutes les machines : une
session humaine compromise deviendrait le plan de controle, et reconstruire le runner
couperait l'acces. Un tunnel nominatif donne la meme porte unique sans reunir ces pouvoirs.
CE QUE CE SCRIPT RECONCILIE, et rien d'autre :
- UNE instance WireGuard (celle que le plan nomme), distincte du tunnel site-a-site ;
- les PAIRS qui lui sont attaches : un par personne ET par appareil.
PERIMETRE STRICT : un pair attache a une AUTRE instance n'est jamais touche — le tunnel vers
le site pair vit a cote et ne doit rien craindre d'ici.
AUCUNE CLE PRIVEE D'APPAREIL N'ENTRE DANS LE DEPOT. Le plan ne porte que des cles PUBLIQUES.
`pair-nouveau` tire une paire, affiche la privee UNE fois (a coller dans l'appareil) et rend
la ligne de plan a ajouter.
Usage :
python3 scripts/vpn_admin.py plan # aucune ecriture
CONFIRMER=true python3 scripts/vpn_admin.py appliquer
python3 scripts/vpn_admin.py pair-nouveau --nom daniel-portable
python3 scripts/vpn_admin.py config --nom daniel-portable
"""
from __future__ import annotations
import argparse
import base64
import ipaddress
import os
import sys
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE / "scripts"))
import appliquer_opnsense as appl # noqa: E402
import devis_opnsense as devis_mod # noqa: E402
import underlay as underlay_mod # noqa: E402
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"):
raise SystemExit("REFUS : le plan du site ne declare pas `acces_admin_vpn` "
"(reseau, port). Rien a reconcilier.")
return d
def api_frontiere() -> appl.Frontiere:
base = devis_mod.depot_hebergeur()
if base is None:
raise SystemExit("Aucun underlay ne designe d'hebergeur : pas de frontiere a piloter.")
intr = devis_mod.intrants_frontiere()
v = appl._voute(base)
return appl.Frontiere(str(intr.get("opnsense_api_url") or ""),
v["vault_opnsense_api_key"], v["vault_opnsense_api_secret"],
bool(intr.get("opnsense_api_verifier_certs")))
def _table(rep: dict, *chemin: str) -> dict:
"""OPNsense emboite ses listes : {'server': {'servers': {'server': {uuid: {...}}}}}."""
noeud = rep
for c in chemin:
noeud = (noeud or {}).get(c) or {}
return noeud if isinstance(noeud, dict) else {}
def _choisi(champ) -> list[str]:
"""Un champ a choix d'OPNsense : rend les valeurs selectionnees."""
if isinstance(champ, dict):
return [k for k, v in champ.items() if isinstance(v, dict) and v.get("selected")]
return [str(champ)] if champ else []
def etat(api: appl.Frontiere) -> tuple[dict, dict]:
"""(serveurs par uuid, pairs par uuid) tels que la frontiere les porte."""
srv = _table(appl._lire(api, "/api/wireguard/server/get"), "server", "servers", "server")
pairs = _table(appl._lire(api, "/api/wireguard/client/get"), "client", "clients", "client")
return srv, pairs
def reseaux_joignables() -> list[str]:
"""Ce que le tunnel doit router : les zones du site, la fabric, les supernets locataires.
DERIVE, JAMAIS ECRIT. Une liste recopiee ici prendrait du retard sur la carte a la
premiere zone ajoutee — et l'administrateur decouvrirait le trou au pire moment.
"""
u = underlay_mod.charger() or {}
out = {str(r["sous_reseau"]) for r in underlay_mod.reseaux(u)
if r.get("sous_reseau")
and (str(r.get("nom", "")) in ("management", "grappe-controle", "transit-frontiere")
or str(r.get("nom", "")).startswith("site-"))}
out |= {str(s) for s in _supernets_locataires()}
return sorted(out, key=lambda c: ipaddress.ip_network(c, strict=False))
def _supernets_locataires() -> list[str]:
"""Les supernets des locataires heberges — decouverts, jamais listes."""
import devis_reseau
out = []
for nom_depot, _prefixe, _n in devis_reseau.decouvrir_du_site():
try:
nomencl = (RACINE.parent / nom_depot / "plan" / "nomenclature.yml")
import yaml
d = yaml.safe_load(nomencl.read_text(encoding="utf-8")) or {}
idx = d.get("index")
if idx is not None:
out.append(f"10.{int(idx)}.0.0/16")
except (OSError, ValueError, TypeError):
continue
return sorted(out)
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 [])
if str(p.get("etat", "present")) == "present"}
retires = {str(p["nom"]) for p in (vpn.get("pairs") or [])
if str(p.get("etat", "present")) == "absent"}
poses = {}
if uuid_srv:
for u, p in pairs.items():
if uuid_srv in _choisi(p.get("servers")):
poses[str(p.get("name"))] = (u, p)
def different(nom_p: str) -> bool:
_u, p = poses[nom_p]
d = declares[nom_p]
return (str(p.get("pubkey")) != str(d.get("cle_publique"))
or _choisi(p.get("tunneladdress")) != [str(d.get("adresse"))])
return {
"nom": nom,
"uuid_serveur": uuid_srv,
"serveur_a_creer": uuid_srv is None,
"serveur": srv.get(uuid_srv or "", {}),
"pairs_creer": {n: d for n, d in declares.items() if n not in poses},
"pairs_majer": {n: d for n, d in declares.items() if n in poses and different(n)},
"pairs_garder": {n for n in declares if n in poses and not different(n)},
# UN PAIR ATTACHE A NOTRE INSTANCE QUE LE PLAN NE DECLARE PLUS : c'est un acces qui
# survit a la decision de le retirer. On le nomme, et `appliquer` le retire.
"pairs_retirer": {n: u for n, (u, _p) in poses.items()
if n not in declares or n in retires},
}
def afficher(p: dict, vpn: dict) -> bool:
if p["serveur_a_creer"]:
print(f" + instance WireGuard {p['nom']} (port {vpn['port']}, "
f"{vpn['adresse_frontiere']})")
else:
s = p["serveur"]
print(f" = instance WireGuard {p['nom']} — port {s.get('port')}, "
f"cle publique {str(s.get('pubkey'))[:12]}…")
for n, d in sorted(p["pairs_creer"].items()):
print(f" + pair {n:<24} {d.get('adresse')}")
for n, d in sorted(p["pairs_majer"].items()):
print(f" ~ pair MODIFIE {n:<24} {d.get('adresse')}")
for n in sorted(p["pairs_retirer"]):
print(f" - pair RETIRE {n}")
print(f"\n a creer : {len(p['pairs_creer']) + (1 if p['serveur_a_creer'] else 0)}"
f" | a retirer : {len(p['pairs_retirer'])}"
f" | inchange : {len(p['pairs_garder'])}")
return bool(p["serveur_a_creer"] or p["pairs_creer"] or p["pairs_majer"] or p["pairs_retirer"])
def _cle_privee() -> tuple[str, str]:
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives import serialization
k = X25519PrivateKey.generate()
priv = k.private_bytes(encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption())
pub = k.public_key().public_bytes(encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw)
return base64.b64encode(priv).decode(), base64.b64encode(pub).decode()
def appliquer(api: appl.Frontiere, p: dict, vpn: dict) -> int:
echecs = 0
def fait(rep, quoi):
nonlocal echecs
if rep.get("result") in ("saved", "deleted") or rep.get("status") == "ok":
return rep
echecs += 1
print(f" ! ECHEC {quoi} : {str(rep)[:200]}")
return {}
uuid_srv = p["uuid_serveur"]
if p["serveur_a_creer"]:
# LA CLE DU SERVEUR NAIT SUR LE BOITIER ET Y RESTE. Elle n'ouvre rien toute seule :
# c'est la cle de la FRONTIERE, pas celle d'un administrateur. Le jour ou le boitier
# est refait, les appareils recoivent la nouvelle cle publique — et c'est tout.
priv, pub = _cle_privee()
rep = fait(api("/api/wireguard/server/add_server", {"server": {
"enabled": "1", "name": p["nom"], "instance": str(vpn.get("instance", 1)),
"pubkey": pub, "privkey": priv, "port": str(vpn["port"]),
"mtu": str(vpn.get("mtu", 1412)),
"tunneladdress": str(vpn["adresse_frontiere"]),
"disableroutes": "0", "peers": "",
}}), f"instance {p['nom']}")
uuid_srv = rep.get("uuid") or None
if uuid_srv is None:
srv, _ = etat(api)
uuid_srv = next((u for u, s in srv.items() if str(s.get("name")) == p["nom"]), None)
for nom_p, d in sorted({**p["pairs_creer"], **p["pairs_majer"]}.items()):
corps = {"client": {
"enabled": "1", "name": nom_p, "pubkey": str(d["cle_publique"]), "psk": "",
"tunneladdress": str(d["adresse"]), "keepalive": "25",
"servers": uuid_srv or "",
}}
if nom_p in p["pairs_majer"]:
fait(api(f"/api/wireguard/client/set_client/{_uuid_pair(api, nom_p)}", corps),
f"pair {nom_p}")
else:
fait(api("/api/wireguard/client/add_client", corps), f"pair {nom_p}")
for nom_p, u in sorted(p["pairs_retirer"].items()):
fait(api(f"/api/wireguard/client/del_client/{u}", {}), f"retrait du pair {nom_p}")
if echecs:
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", "?"))
return 0
def _uuid_pair(api: appl.Frontiere, nom_p: str) -> str:
_srv, pairs = etat(api)
return next((u for u, x in pairs.items() if str(x.get("name")) == nom_p), "")
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)
if not a_faire:
print("\n Rien a faire.")
return 0
if os.environ.get("CONFIRMER") != "true":
print("\n PLAN SEUL — aucune ecriture. Rejouer avec CONFIRMER=true pour appliquer.")
return 0
return appliquer(api, p, vpn)
def cmd_pair(args) -> int:
vpn = plan_vpn()
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.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")
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")
print(f"# Puis : python3 scripts/vpn_admin.py config --nom {args.nom}")
return 0
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)
if d is None:
raise SystemExit(f"REFUS : aucun pair « {args.nom} » au plan du site.")
api = api_frontiere()
srv, _pairs = etat(api)
nom = str(vpn.get("nom") or "admins")
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 "
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"
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())}\n"
f"PersistentKeepalive = 25")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
sous = ap.add_subparsers(dest="commande", required=True)
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.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.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.set_defaults(fn=cmd_config)
args = ap.parse_args()
return args.fn(args)
if __name__ == "__main__":
sys.exit(main())