l heure vient de la frontiere : la derniere dependance vivante tombe

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
Daniel Allaire 2026-09-11 12:08:13 -04:00
parent 989398f3cf
commit 6786d15068
15 changed files with 399 additions and 11 deletions

View file

@ -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,

View file

@ -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)). |

View file

@ -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

View file

@ -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). |

View file

@ -3,3 +3,33 @@
# (source unique : inventories/<env>/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

View file

@ -0,0 +1,5 @@
---
- name: Restart chrony
ansible.builtin.systemd:
name: chrony
state: restarted

View file

@ -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"

View file

@ -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 %}

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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)

View file

@ -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",

View file

@ -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).
#

View file

@ -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.