From 6786d15068c282bf7b6ad27260d2f929701f2aab Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Fri, 11 Sep 2026 12:08:13 -0400 Subject: [PATCH] l heure vient de la frontiere : la derniere dependance vivante tombe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mesure sur les quatorze : aucune sortie TCP vers une adresse publique, apt par le cache, noms autoritaires en local, unattended-upgrades masked. Et quatre pairs NTP publics par machine. Le role chrony posait le fuseau et installait le demon sans jamais toucher a ses sources : le defaut de Debian tenait depuis le premier jour, herite et jamais choisi. L autorite est la frontiere, par decision de l exploitant. Elle etait deja stratum 2 et ecoutait en 123 ; il ne manquait que le passage. 9 anciennes regles port 123 vers !SETOPS_INTERNES retirees, 9 regles nommees vers SETOPS_FRONTIERE posees : le changement resserre autant qu il centralise. Deux chemins parce que la topologie en a deux. Un tenant n atteint pas la frontiere par sa passerelle de zone — tenue par le SDN — mais par le lien de transit. Une machine du site a la frontiere pour passerelle directe. Les deux valeurs sont derivees de l underlay, via reseau_transit() plutot que d un prefixe d adresse qui aurait menti chez le prochain hebergeur. La patte face aux tenants manquait a opnsense_if_zones, pour la meme raison que grappe-controle la veille. Sonde horloge ecrite en meme temps : synchronisee ET contre la source DECLAREE. Une machine peut etre parfaitement a l heure contre quatre serveurs publics — c est exactement l etat d avant. 14 tenant + 7 site, toutes disciplinees, sources publiques = 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q --- CHANGELOG.md | 74 ++++++++++++++++++++++ docs/audit/preuve-2026-09-11.md | 10 +-- docs/audit/wiki-publie.yml | 2 +- docs/registre-flux.md | 2 +- roles/chrony/defaults/main.yml | 30 +++++++++ roles/chrony/handlers/main.yml | 5 ++ roles/chrony/tasks/main.yml | 32 ++++++++++ roles/chrony/templates/setops.conf.j2 | 8 +++ roles/chrony/templates/sonde-horloge.sh.j2 | 71 +++++++++++++++++++++ roles/serveur_debian/meta/flux.yml | 30 ++++++++- roles/serveur_debian/meta/supervision.yml | 7 ++ scripts/devis_opnsense.py | 61 +++++++++++++++++- scripts/instancier.py | 29 +++++++++ scripts/resoudre_flux.py | 36 ++++++++++- scripts/site_inventaire.py | 13 ++++ 15 files changed, 399 insertions(+), 11 deletions(-) create mode 100644 roles/chrony/handlers/main.yml create mode 100644 roles/chrony/templates/setops.conf.j2 create mode 100644 roles/chrony/templates/sonde-horloge.sh.j2 diff --git a/CHANGELOG.md b/CHANGELOG.md index d709d89..ee20dc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,79 @@ # CHANGELOG — Set-OPS +## 2026-09-11 (4) — L'heure vient de la frontiere : la derniere dependance vivante tombe + +L'exploitant a presume qu'un ecosysteme fonctionnel n'avait plus besoin d'Internet pour ses +operations internes. Mesure sur les quatorze machines : presque. + + sorties TCP etablies vers une adresse publique aucune, sur 14/14 + apt par le cache du site + noms internes autoritaires, en local + unattended-upgrades / apt-daily masked, masked, masked + + pool 2.debian.pool.ntp.org iburst QUATRE pairs publics, sur les 14 + +Le role `chrony` posait le fuseau et INSTALLAIT le demon sans jamais toucher a ses sources. +Le defaut de Debian tenait depuis le premier jour : herite, jamais choisi. + +### Pourquoi c'etait la mauvaise a laisser trainer + +step-ca emet des certificats a duree courte ; TLS refuse un certificat pas encore valide ; +Loki rejette une ligne trop loin dans le futur ; les silences d'Icinga reposent sur des +`ttl`. Une flotte dont les horloges divergent tombe en panne DE L'INTERIEUR, avec des +symptomes qui ressemblent a tout sauf a une horloge. + +Et la derive est lente : couper Internet ne casse rien le premier jour. Le systeme parait +souverain jusqu'a ce qu'il ne le soit plus, sans signal entre les deux. + +### L'autorite est la frontiere — decision de l'exploitant + +Elle etait deja `stratum 2`, synchronisee, et ecoutait en 123 sur chaque patte. Il ne +manquait que le passage : son `pf` est en refus par defaut et aucun flux NTP n'etait +declare vers elle. + + a creer : 14 | a retirer : 9 + +Les NEUF retires sont les anciennes regles `port 123 -> !SETOPS_INTERNES` : le 123 etait +autorise vers N'IMPORTE QUELLE adresse d'Internet. **Le changement resserre autant qu'il +centralise.** La destination est desormais nommee, alias `SETOPS_FRONTIERE`. + +### Deux chemins, parce que la topologie en a deux + +Un tenant n'atteint PAS la frontiere par sa passerelle de zone : `10.17.x.1` est tenue par +le SDN de Proxmox. Il sort par le lien de transit — `10.0.4.1`, patte libellee TENANTS, +qui manquait a `opnsense_if_zones` pour la meme raison que `grappe-controle` la veille. +Une machine du site, elle, a la frontiere pour passerelle directe. + +La valeur est DERIVEE des deux cotes, jamais ecrite : `instancier` prend la patte sur le +reseau de transit (via `reseau_transit()`, qui le derive de `passerelle_sortie` — le +reconnaitre a son prefixe d'adresse aurait marche ici et menti chez le prochain +hebergeur) ; `site_inventaire` prend la patte de la zone de la machine. + + 14 machines du tenant ^* 10.0.4.1 publiques=0 + 7 machines du site ^* 10.0.3x.1 ecarts en nanosecondes + +### Le drop-in ajoute, il ne retire pas + +`/etc/chrony/conf.d/` est lu EN PLUS du fichier principal. Y declarer notre serveur sans +neutraliser le `pool` de Debian aurait laisse cinq sources, dont quatre sur Internet — +l'horloge centralisee en apparence, la dependance intacte. + +### La sonde `horloge`, ecrite en meme temps + +Elle mesure DEUX choses, et la seconde est celle qui manquait : la machine est-elle +synchronisee, ET contre la source DECLAREE ? Une machine peut etre parfaitement a l'heure +en interrogeant quatre serveurs publics — c'est exactement l'etat d'avant, et mesurer la +seule derive l'aurait laisse invisible. + +Le seuil d'ecart (500 ms) est tres large, et c'est voulu : il doit crier sur une horloge qui +DECROCHE, pas sur la respiration d'un `chronyd`. L'autre moitie, elle, ne tolere rien. + +### Ce qui aura toujours besoin d'Internet, et c'est normal + +Les correctifs de securite, la construction d'un ecosysteme neuf au-dela de ce que le cache +detient, la resolution des noms externes, le courriel sortant. Aucun n'est une operation +interne. + ## 2026-09-11 (3) — `make expositions-etat` : le certificat SERVI, pas celui du disque Renommer une exposition touche cinq choses. Quatre suivent au deploiement ; la cinquieme, diff --git a/docs/audit/preuve-2026-09-11.md b/docs/audit/preuve-2026-09-11.md index 533ac3d..531f95d 100644 --- a/docs/audit/preuve-2026-09-11.md +++ b/docs/audit/preuve-2026-09-11.md @@ -44,7 +44,7 @@ | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 33 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 22, 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 | 60 scripts expliques et atteignables, 117 cibles make documentees, 68 roles avec README. | -| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (137 cle(s) declaree(s) par l'instance). | +| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (138 cle(s) declaree(s) par l'instance). | | P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 34 revendication(s) de port, aucune collision entre roles co-localises (36 groupes). | | P34 | Chaque document declare son lecteur | — | ✅ OK | 44 document(s) declarent leur lecteur (37 genere(s) exempte(s)). | | P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). | @@ -55,14 +55,14 @@ | 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 : 56 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | | P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product | -| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 155 regle(s) du site. | +| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 151 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 89 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 (125 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 217 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 219 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 — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b | @@ -72,11 +72,11 @@ | P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (68 preuves, 68 roles, 41 groupes). | | 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 `5f5af70` (publie le 2026-09-11). | +| 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 `989398f` (publie le 2026-09-11). | | P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 47 champ(s) sur 6 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:5, 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 | 25 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, serveur_ | +| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 26 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, serveur_ | | P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. | | P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (6 exposition(s)). | | P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 6 service(s) expose(s) portent le nom du plan (6 groupe(s) derive(s)). | diff --git a/docs/audit/wiki-publie.yml b/docs/audit/wiki-publie.yml index ba74b64..134c91f 100644 --- a/docs/audit/wiki-publie.yml +++ b/docs/audit/wiki-publie.yml @@ -1,5 +1,5 @@ --- # Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main. remote: ssh://git@forge.genese.internal/genome/set-ops-public.wiki.git -source: 5f5af70 +source: 989398f date: 2026-09-11 diff --git a/docs/registre-flux.md b/docs/registre-flux.md index 90a0499..adc62ef 100644 --- a/docs/registre-flux.md +++ b/docs/registre-flux.md @@ -30,7 +30,7 @@ | `serveur_debian` | ingress | echo-request | icmp | serveur_icinga | n-a | La supervision verifie que ce noeud repond (hostalive). Sans lui, elle le tient pour mort et supprime ses notifications. | | `serveur_debian` | ingress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » entrant : sans lui, un distant ne peut pas nous demander de réduire nos paquets — les transferts se figent. | | `serveur_debian` | egress | 80 | tcp | externe | clair | Dépôts apt en clair et redirections HTTP des miroirs (l'intégrité vient de la signature des paquets, pas du transport). | -| `serveur_debian` | egress | 123 | udp | externe | n-a | Synchronisation d'horloge (NTP). Une dérive fait échouer la validation des certificats step-ca et l'authentification SSO. | +| `serveur_debian` | egress | 123 | udp | frontiere | n-a | Synchronisation d'horloge (NTP) contre la frontière, autorité de temps de l'écosystème. Une dérive fait échouer la validation des certificats step-ca et l'authentification SSO. | | `serveur_debian` | egress | 443 | tcp | externe | tls-requis | Dépôts apt en HTTPS (Debian, Smallstep, Grafana, Icinga, Forgejo, Nextcloud) — sans quoi aucun correctif de sécurité n'entre. | | `serveur_debian` | egress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » sortant : c'est ainsi que nos hôtes signalent l'overlay à 1450 aux correspondants distants. | | `serveur_dovecot` | ingress | 24 | tcp | serveur_postfix | tls-requis | Remise LMTP depuis Postfix (edge-mta -> mailstore), en TLS vérifié (lmtp_tls_security_level=verify). | diff --git a/roles/chrony/defaults/main.yml b/roles/chrony/defaults/main.yml index 2058f40..876247d 100644 --- a/roles/chrony/defaults/main.yml +++ b/roles/chrony/defaults/main.yml @@ -3,3 +3,33 @@ # (source unique : inventories//group_vars/all/, panneau « Intrants » du GUI). # Laisser vide pour ne pas toucher au fuseau de la VM. chrony_timezone: "{{ fuseau_horaire | default('') }}" + +# L'HEURE VIENT DE LA FRONTIERE (2026-09-11). +# +# Ce role posait le fuseau et INSTALLAIT chrony sans jamais toucher a ses sources : le +# defaut de Debian — `pool 2.debian.pool.ntp.org` — restait en place. Herite, jamais +# choisi. Mesure sur les quatorze machines de Chezlepro : quatre pairs publics chacune. +# +# C'etait la DERNIERE dependance vivante a Internet des operations internes. Tout le +# reste passe deja par le site : les paquets par son cache, les noms par son DNS, le +# genome par sa forge, l'etat par son depot. +# +# LA VALEUR EST DERIVEE, PAS ECRITE : `instancier` (tenant) et `site_inventaire` (site) +# la posent depuis l'underlay, parce qu'eux seuls savent par quelle patte leur ecosysteme +# atteint la frontiere. Un tenant sort par le lien de transit (ses passerelles de zone +# sont tenues par le SDN) ; une machine du site a la frontiere pour passerelle directe. +# +# VIDE = ON NE TOUCHE A RIEN. Un ecosysteme sans underlay monte n'a pas de frontiere a +# viser ; lui retirer ses sources sans rien mettre en face le laisserait sans horloge. +chrony_serveurs: [] +chrony_conf: "/etc/chrony/chrony.conf" +chrony_conf_setops: "/etc/chrony/conf.d/setops.conf" + +# SONDE « horloge » — ECART MAXIMAL TOLERE, EN MILLISECONDES. +# +# CHOIX D'EXPLOITATION, pas derivation. 500 ms est tres large pour un reseau local — la +# frontiere rendait -0,42 ms a la mesure — et c'est voulu : la sonde doit crier sur une +# horloge qui DECROCHE, pas sur la respiration normale d'un `chronyd`. Le seuil qui compte +# vraiment est ailleurs, dans l'autre moitie : la source retenue doit etre celle qu'on a +# declaree, et cette moitie-la ne tolere rien. +chrony_sonde_ecart_max: 500 diff --git a/roles/chrony/handlers/main.yml b/roles/chrony/handlers/main.yml new file mode 100644 index 0000000..6c1446d --- /dev/null +++ b/roles/chrony/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart chrony + ansible.builtin.systemd: + name: chrony + state: restarted diff --git a/roles/chrony/tasks/main.yml b/roles/chrony/tasks/main.yml index 2a2ac93..e3e9150 100644 --- a/roles/chrony/tasks/main.yml +++ b/roles/chrony/tasks/main.yml @@ -14,3 +14,35 @@ name: chrony enabled: true state: started + +# LE DROP-IN AJOUTE, IL NE RETIRE PAS. `/etc/chrony/conf.d/` est lu EN PLUS du fichier +# principal : y declarer notre serveur sans neutraliser le `pool` de Debian laisserait la +# machine avec cinq sources, dont quatre sur Internet. On aurait alors centralise +# l'horloge en apparence et change strictement rien a la dependance. +- name: Retirer le pool public du fichier principal + ansible.builtin.replace: + path: "{{ chrony_conf }}" + regexp: '^(\s*(?:pool|server)\s+\S*(?:debian|ntp)\.(?:pool\.)?(?:ntp\.)?org.*)$' + replace: '# \1 # neutralise par Set-OPS : l horloge vient de la frontiere' + notify: Restart chrony + when: chrony_serveurs | length > 0 + +- name: Déclarer la frontière comme source de temps + ansible.builtin.template: + src: setops.conf.j2 + dest: "{{ chrony_conf_setops }}" + owner: root + group: root + mode: "0644" + notify: Restart chrony + when: chrony_serveurs | length > 0 + +# DEPOSEE ICI, DECLAREE PAR LE GROUPE `serveur_debian` — la derivation de supervision ne +# traverse pas les roles-taches. Meme lecon que `correctifs` et `audit`. +- name: Deposer la sonde d'horloge + ansible.builtin.template: + src: sonde-horloge.sh.j2 + dest: /usr/local/lib/setops/sondes/horloge.sh + owner: root + group: root + mode: "0750" diff --git a/roles/chrony/templates/setops.conf.j2 b/roles/chrony/templates/setops.conf.j2 new file mode 100644 index 0000000..6dba593 --- /dev/null +++ b/roles/chrony/templates/setops.conf.j2 @@ -0,0 +1,8 @@ +# Gere par Set-OPS (role chrony). Ne pas editer a la main. +# +# L'autorite de temps de l'ecosysteme est la FRONTIERE. Elle se discipline sur le pool +# public quand Internet est la ; la flotte, elle, ne sort plus. Meme patron que le cache +# apt : un seul sort, les autres non. +{% for s in chrony_serveurs %} +server {{ s }} iburst +{% endfor %} diff --git a/roles/chrony/templates/sonde-horloge.sh.j2 b/roles/chrony/templates/sonde-horloge.sh.j2 new file mode 100644 index 0000000..ffc149e --- /dev/null +++ b/roles/chrony/templates/sonde-horloge.sh.j2 @@ -0,0 +1,71 @@ +#!/bin/bash +# GENERE par Set-OPS (role chrony). Ne pas editer a la main. +# +# SONDE « horloge » — la machine est-elle synchronisee, ET contre LA SOURCE DECLAREE ? +# +# LES DEUX MOITIES COMPTENT. Une machine peut etre parfaitement a l'heure en interrogeant +# quatre serveurs publics : c'est le defaut de Debian, et il a tenu quatorze machines +# jusqu'au 2026-09-11 sans que rien ne le dise. Mesurer la seule derive laisserait ce +# retour en arriere invisible. +# +# POURQUOI L'HEURE MERITE UNE SONDE. step-ca emet des certificats a duree courte ; TLS +# refuse un certificat pas encore valide ; Loki rejette une ligne trop loin dans le futur ; +# les silences d'Icinga reposent sur des `ttl`. Une flotte dont les horloges divergent +# tombe en panne de l'interieur, avec des symptomes qui ressemblent a tout sauf a une +# horloge. +# +# Contrat : docs/supervision-conception.md (API des greffons Nagios) — une ligne, 0/1/2. +# Mise en defaut PAR PARAMETRE : `chrony_sonde_ecart_max` (le baisser a 0). +set -uo pipefail + +ECART_MAX={{ chrony_sonde_ecart_max }} +ATTENDUS="{{ chrony_serveurs | join(' ') }}" + +if ! systemctl is-active --quiet chrony; then + echo "chrony ne tourne pas : l'horloge derive sans que personne ne la corrige." \ + "| ecart=U sources=0 declarees=0" + exit 2 +fi + +# `^*` marque la source RETENUE. Sans elle, chrony tourne mais ne suit personne. +retenue=$(chronyc -n sources 2>/dev/null | awk '/^\^\*/ {print $2; exit}') +sources=$(chronyc -n sources 2>/dev/null | tail -n +3 | wc -l) + +if [[ -z "${retenue}" ]]; then + echo "chrony tourne mais n'a retenu AUCUNE source (${sources} candidate(s)) :" \ + "l'heure n'est plus disciplinee." \ + "| ecart=U sources=${sources} declarees=0" + exit 2 +fi + +# L'ecart en millisecondes, en valeur absolue. +ecart=$(chronyc tracking 2>/dev/null \ + | awk '/^Last offset/ {o=$4+0; if (o<0) o=-o; printf "%.0f", o*1000}') +# `$4+0` FORCE LE NUMERIQUE. `chronyc` prefixe la valeur d'un `+` ou d'un `-` ; sans la +# conversion, awk compare une CHAINE a zero et la valeur absolue ne mord pas. Le symptome +# etait discret — `ecart=-0ms` en perfdata — et c'est precisement ce qu'un grapheur avale +# sans rien dire. +ecart=${ecart:-0} + +declarees=0 +for s in ${ATTENDUS}; do + [[ "${retenue}" == "${s}" ]] && declarees=1 +done + +if [[ -n "${ATTENDUS}" && "${declarees}" -eq 0 ]]; then + echo "L'heure vient de ${retenue}, qui n'est PAS la source declaree (${ATTENDUS}) :" \ + "la machine est sortie de l'ecosysteme pour se synchroniser." \ + "| ecart=${ecart}ms;${ECART_MAX} sources=${sources} declarees=0" + exit 2 +fi + +if (( ecart > ECART_MAX )); then + echo "Synchronisee sur ${retenue}, mais l'ecart atteint ${ecart} ms" \ + "(seuil ${ECART_MAX} ms) : les certificats a duree courte vont souffrir." \ + "| ecart=${ecart}ms;${ECART_MAX} sources=${sources} declarees=1" + exit 1 +fi + +echo "Horloge disciplinee par ${retenue}, ecart ${ecart} ms." \ + "| ecart=${ecart}ms;${ECART_MAX} sources=${sources} declarees=1" +exit 0 diff --git a/roles/serveur_debian/meta/flux.yml b/roles/serveur_debian/meta/flux.yml index d6cf99d..bb390a8 100644 --- a/roles/serveur_debian/meta/flux.yml +++ b/roles/serveur_debian/meta/flux.yml @@ -55,9 +55,35 @@ flux: partage: true raison: "La supervision verifie que ce noeud repond (hostalive). Sans lui, elle le tient pour mort et supprime ses notifications." + # L'HEURE VIENT DE LA FRONTIÈRE, PAS D'INTERNET (2026-09-11). + # + # C'était `pair: externe` : les quatorze machines interrogeaient `2.debian.pool.ntp.org`, + # défaut de Debian jamais choisi — le rôle `chrony` posait le fuseau et laissait les + # sources telles quelles. Mesuré : quatre pairs publics par machine, sur les quatorze. + # + # C'était la DERNIÈRE dépendance vivante à Internet des opérations internes. Tout le + # reste sort déjà par le site : les paquets par son cache, les noms par son DNS, le + # génome par sa forge, l'état par son dépôt. L'heure, non. + # + # ET C'EST LA MAUVAISE À LAISSER TRAÎNER. step-ca émet des certificats à durée courte ; + # TLS refuse un certificat pas encore valide ; Loki rejette une ligne trop loin dans le + # futur ; les silences d'Icinga reposent sur des `ttl`. Une flotte dont les horloges + # divergent tombe en panne de l'intérieur, avec des symptômes qui ressemblent à tout + # sauf à une horloge. Et la dérive est lente : couper Internet ne casse rien le premier + # jour, ce qui laisse croire à une souveraineté qu'on n'a pas. + # + # La frontière est déjà `stratum 3`, synchronisée, et écoute en 123 sur chaque patte. + # Elle se discipline sur le pool public quand Internet est là ; la flotte, elle, ne sort + # plus. Même patron que le cache apt : un seul sort, les autres non. + # + # RESSERRE AUTANT QUE CENTRALISE : `pair: externe` autorisait le 123 vers N'IMPORTE + # QUELLE adresse d'Internet. La destination est désormais nommée. - sens: egress port: 123 protocole: udp - pair: externe + pair: frontiere chiffrement: n-a - raison: "Synchronisation d'horloge (NTP). Une dérive fait échouer la validation des certificats step-ca et l'authentification SSO." + raison: >- + Synchronisation d'horloge (NTP) contre la frontière, autorité de temps de + l'écosystème. Une dérive fait échouer la validation des certificats step-ca + et l'authentification SSO. diff --git a/roles/serveur_debian/meta/supervision.yml b/roles/serveur_debian/meta/supervision.yml index 7d15bb8..fb2a153 100644 --- a/roles/serveur_debian/meta/supervision.yml +++ b/roles/serveur_debian/meta/supervision.yml @@ -32,3 +32,10 @@ sondes: Des correctifs de securite attendent-ils d'etre appliques, et depuis quand ? Set-OPS desarme les mises a jour automatiques et les applique au deploiement : sans cette mesure, rien ne dit quand le geste est du. + - nom: horloge + ttl: 5400 + raison: >- + L'heure est-elle disciplinee, et par la SOURCE DECLAREE ? Une machine peut etre + parfaitement a l'heure en interrogeant quatre serveurs publics — c'est le defaut + de Debian, et il a tenu quatorze machines jusqu'au 2026-09-11 sans que rien ne le + dise. Mesurer la seule derive laisserait ce retour en arriere invisible. diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index 27452cb..43dbeba 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -253,7 +253,8 @@ def flux_frontiere(flux_par_role: dict[str, list[dict]]) -> list[tuple[str, dict (role, fl) for role, flux in sorted(flux_par_role.items()) for fl in flux - if "externe" in _pairs(fl) or "voisins_site" in _pairs(fl) + if ("externe" in _pairs(fl) or "voisins_site" in _pairs(fl) + or "frontiere" in _pairs(fl)) ] retenus.sort(key=lambda rf: (rf[1].get("sens", ""), rf[0], str(rf[1].get("port")))) return retenus @@ -540,6 +541,12 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: } destination = a entrant = fl.get("sens") == "ingress" + if "frontiere" in _pairs(fl): + # TRAITE PAR LE BLOC DEDIE, PLUS BAS — et il faut sauter ICI, sinon le + # meme flux sort DEUX FOIS : une regle nommee vers `SETOPS_FRONTIERE`, et + # une seconde par le chemin generique vers `!SETOPS_INTERNES`. La seconde + # annulerait tout le benefice — elle rouvre le port vers l'Internet entier. + continue # Le SSH de gestion est le SEUL flux `externe` a ne pas venir d'Internet : # sa `raison` dit « l'acces depuis l'exterieur est filtre a l'OPNsense ». ssh_gestion = entrant and "22" in _ports(fl) @@ -763,6 +770,8 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: for _fl in flux_par_role.get(_role, []): _entrant = _fl.get("sens") == "ingress" _pairs_fl = _pairs(_fl) + if "frontiere" in _pairs_fl: + continue # bloc dedie, plus bas (voir la boucle des tenants) if _entrant and "voisins_site" in _pairs_fl: # Un tenant qui s'adresse au site : le paquet penetre le pare-feu par # le lien de TRANSIT, pas par la patte du site. Une regle par voisin, @@ -1103,6 +1112,56 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: _uniques.append(_r) regles = _uniques + # --- LA FRONTIERE COMME PAIR : L'HEURE (2026-09-11) ---------------------------- + # + # `pair: frontiere` designe la frontiere ELLE-MEME comme destination — ni l'Internet, + # ni un voisin, ni un role du site. Le seul usage aujourd'hui est l'horloge : le socle + # declare `egress 123/udp` vers elle, et elle repond en `stratum 3`. + # + # POURQUOI UN BLOC A PART plutot qu'une branche de plus dans les deux boucles + # ci-dessus : la regle a exactement la MEME forme pour un locataire et pour une + # machine du site — seule change la patte d'arrivee. L'enfiler dans deux conditionnels + # deja longs aurait duplique la meme intention a deux endroits, et c'est ainsi qu'une + # moitie finit par prendre du retard sur l'autre. + # + # RIEN N'EST ECRIT EN DUR : le port, le protocole et la raison viennent de la + # declaration. Le jour ou un second flux vise la frontiere, il sort d'ici sans qu'on + # y touche. + _flux_frontiere = [(r, f) for r, flux in sorted(flux_par_role.items()) for f in flux + if "frontiere" in _pairs(f) and f.get("sens") != "ingress"] + if _flux_frontiere: + _pattes_frontiere = sorted({str(h["ip"]) for h in underlay_mod.hotes(_u) + if h.get("role") == "frontiere" and h.get("ip") + and str(h.get("etat", "actif")) != "reserve"}) + if _pattes_frontiere: + alias["SETOPS_FRONTIERE"] = { + "type": "host", + "contenu": _pattes_frontiere, + "description": "Pattes de la frontiere active (autorite de temps)", + } + # LES PATTES D'ARRIVEE. Un locataire entre par le lien de transit — ses + # passerelles de zone sont tenues par le SDN, pas par la frontiere. Une + # machine du site entre par la patte de SA zone. D-61 : on raisonne en + # ARRIVEE, donc `in` dans les deux cas. + _portees_f = [(f"SETOPS_TENANT_{p2}{n2['index']}", if_transit) + for _n2, p2, n2 in tenants] + _portees_f += [("SETOPS_SITE", z) for z in + sorted({_if_de(m.get("reseau")) for m in _machines_site})] + for _role_f, _fl_f in _flux_frontiere: + for _src_f, _if_f in _portees_f: + regles.append({ + "sens": "in", + "interface": _if_f, + "protocole": _fl_f.get("protocole", "udp"), + "source": _src_f, + "destination": "SETOPS_FRONTIERE", + "ports": _ports(_fl_f), + "chiffrement": _fl_f.get("chiffrement"), + "role": _role_f, + "tenant": "", + "raison": _fl_f.get("raison", ""), + }) + # 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/instancier.py b/scripts/instancier.py index d7874df..75ecf81 100644 --- a/scripts/instancier.py +++ b/scripts/instancier.py @@ -217,6 +217,24 @@ def generer() -> dict: if groupe and len(exp) == 1: hostname_par_groupe[groupe] = exp[0] + # La patte de la frontiere que les tenants traversent — calculee une fois. + _temps_frontiere: list = [] + try: + _carte = underlay_mod.charger() + if _carte: + # LE LIEN DE TRANSIT SE DECLARE, IL NE SE DEVINE PAS. `reseau_transit()` + # le derive de `passerelle_sortie` — le champ qui dit « c'est par la qu'on + # sort ». Le reconnaitre a son prefixe d'adresse aurait marche ici et menti + # chez le prochain hebergeur. + _t = underlay_mod.reseau_transit(_carte) or {} + _res_transit = {str(_t.get("nom"))} if _t.get("nom") else set() + _temps_frontiere = sorted({str(h["ip"]) for h in underlay_mod.hotes(_carte) + if h.get("role") == "frontiere" and h.get("ip") + and str(h.get("reseau")) in _res_transit + and str(h.get("etat", "actif")) != "reserve"}) + except Exception: + _temps_frontiere = [] + services_par_hote: dict = {} for app in apps.values(): services_par_hote.setdefault(app.get("hote"), set()).add(app.get("groupe")) @@ -330,6 +348,17 @@ def generer() -> dict: sans = sorted({e["fqdn"] for e in expositions if e.get("edge") in groupes}) if sans: hostvars["sans_exposition"] = sans + # L'AUTORITE DE TEMPS, DERIVEE DE L'UNDERLAY (2026-09-11). + # + # Un tenant atteint la frontiere par le LIEN DE TRANSIT : ses passerelles de zone + # (10.17.x.1) sont tenues par le SDN de Proxmox, pas par elle. On prend donc la + # patte de la frontiere active posee sur le reseau que les tenants traversent. + # + # `etat: reserve` est exclu : la seconde frontiere est declaree et ne porte encore + # aucune adresse. Vide si aucun underlay — le role ne touchera alors a rien. + if _temps_frontiere: + hostvars.setdefault("chrony_serveurs", _temps_frontiere) + # Nom public de chaque service porte par cet hote, tel que le plan l'expose. for groupe_expose in sorted(groupes & set(hostname_par_groupe)): hostvars.setdefault(f"{groupe_expose}_hostname", diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index b00f4a5..5cc6117 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -65,7 +65,7 @@ CHIFFREMENT = {"tls-requis", "tls", "starttls", "ssh", "tls-cible", "clair", "n- # tenants d'a cote) : c'est le SOCLE sur lequel les uns et les autres reposent. Le seul # a s'y adresser est le runner du site, et c'est tout son objet — materialiser. MOTS_PAIR = {"edge", "flotte", "externe", "localhost", "expositions", "derive", "admin", - "voisins_site", "fabric", "runner_site"} + "voisins_site", "fabric", "frontiere", "runner_site"} GROUPE_EDGE = "serveur_nginx" @@ -245,6 +245,31 @@ def _adresses_fabric() -> list[str]: return [] +def _adresses_frontiere() -> list[str]: + """Les pattes de la frontiere ACTIVE — l'autorite de temps de l'ecosysteme. + + PLUS ETROIT QUE `fabric`, ET C'EST LE POINT. `_adresses_fabric()` rend les + hyperviseurs ET la frontiere : s'en servir pour l'heure ouvrirait le 123 vers des + machines qui ne le servent pas. Une paire dit CE QU'EST le pair, pas ou il se trouve. + + `etat: reserve` est exclu : `bifrost-2` est declaree et ne porte aucune adresse. La + compter rendrait une regle vers une machine qui n'existe pas encore — et le jour ou + elle existera, elle heritera de la regle sans que personne ne l'ait decide. + + Rend [] quand aucun underlay n'est monte : degrader, jamais deviner. + """ + try: + import underlay as _u + carte = _u.charger() + if not carte: + return [] + return sorted({str(h["ip"]) for h in _u.hotes(carte) + if h.get("role") == "frontiere" and h.get("ip") + and str(h.get("etat", "actif")) != "reserve"}) + except Exception: + return [] + + def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict[str, str], site: bool = False) -> list[str]: """pair -> IP sources concretes (pour une regle ingress). [] = pas de regle inter-noeud.""" @@ -258,6 +283,13 @@ def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict # `nftables_admin_ssh` ; `voisins_site` des supernets des autres tenants # federes ; `runner_site` du plan du SITE. Traites a part, plus bas. continue + if p == "frontiere": + # LA FRONTIERE EST UN PAIR A ELLE SEULE (2026-09-11). Comme `fabric`, elle + # vit dans la carte de l'hebergeur et non dans un groupe d'hotes — mais plus + # etroite : `fabric` couvre aussi les hyperviseurs, qui ne servent pas + # l'heure. Resolue plus bas, jamais laissee tomber dans le cas general : une + # source vide produirait une regle sans `saddr`. + continue if p == "fabric": # LA FABRIC N'EST PAS UN GROUPE D'HOTES, ELLE EST DANS LA CARTE (2026-09-10). # @@ -285,6 +317,8 @@ def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict srcs |= set(_sources_admin_ssh_site() if site else _sources_admin_ssh()) if "fabric" in demandes: srcs |= set(_adresses_fabric()) + if "frontiere" in demandes: + srcs |= set(_adresses_frontiere()) if "voisins_site" in demandes: # DU COTE DU SITE, « VOISIN » N'EXCLUT PERSONNE (mesure du 2026-09-02). # diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index ea6b079..bb6ab53 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -207,6 +207,16 @@ def inventaire() -> dict: if str(a.get("hote")) == nom_hote and a.get("groupe")} par_reseau = {r.get("nom"): r for r in U.reseaux(u)} + # L'AUTORITE DE TEMPS, COTE SITE (2026-09-11). + # + # Ici la frontiere est la passerelle DIRECTE de chaque zone — pas besoin de passer par + # le lien de transit comme un tenant. On prend donc la patte de la frontiere posee sur + # LA ZONE DE LA MACHINE : le chemin le plus court, et celui qu'aucune regle + # supplementaire n'a besoin d'ouvrir. + _temps_par_reseau = {str(_h.get("reseau")): str(_h.get("ip")) + for _h in U.hotes(u) + if _h.get("role") == "frontiere" and _h.get("ip") + and str(_h.get("etat", "actif")) != "reserve"} # Les sous-reseaux ou vivent REELLEMENT des machines du site : l'etendue de # l'ecosysteme, telle que le plan la dessine. _zones_du_site = sorted({str((par_reseau.get(s.get("reseau")) or {}).get("sous_reseau")) @@ -368,6 +378,9 @@ def inventaire() -> dict: "site_pont": r.get("pont"), "site_noeud": srv.get("noeud"), "proxmox_vmid": srv.get("vmid"), + # La frontiere, autorite de temps — sa patte SUR LA ZONE DE CETTE MACHINE. + "chrony_serveurs": ([_temps_par_reseau[str(srv.get("reseau"))]] + if str(srv.get("reseau")) in _temps_par_reseau else []), # L'espace d'adressage de cet ecosysteme. Un tenant le derive de son index ; # le site le tient du reseau ou vivent ses machines. Meme sens, autre source. # `serveur_resolveur` s'en sert pour savoir QUI a le droit de l'interroger.