diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be842f..6dd0642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Makefile b/Makefile index b3a024f..381ec8c 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/docs/acces-administration.md b/docs/acces-administration.md new file mode 100644 index 0000000..5668c16 --- /dev/null +++ b/docs/acces-administration.md @@ -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-/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-/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. diff --git a/docs/audit/preuve-2026-09-17.md b/docs/audit/preuve-2026-09-17.md index 6e1c0f7..5281353 100644 --- a/docs/audit/preuve-2026-09-17.md +++ b/docs/audit/preuve-2026-09-17.md @@ -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 | diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index 70d6646..a7d8c65 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -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 -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), diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index 2f119bd..7b78b45 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -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) diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index 3f904e5..96d55f3 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -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)] diff --git a/scripts/site_intrants.py b/scripts/site_intrants.py index ae63514..682c10d 100644 --- a/scripts/site_intrants.py +++ b/scripts/site_intrants.py @@ -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 ""), } diff --git a/scripts/vpn_admin.py b/scripts/vpn_admin.py new file mode 100644 index 0000000..8460f29 --- /dev/null +++ b/scripts/vpn_admin.py @@ -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 = \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())