flux : plus aucune sortie vers Internet sans un role qui le dise

La sortie 80/443 que le socle donnait a toute machine ne servait plus a apt
(tout passe par le cache du site) mais a de la telemetrie et a des sorties
non declarees. Elle devient conditionnelle (seulement_si artefacts_amorcage
vide) ; la telemetrie de Grafana, Loki, Alloy et la verification de version
de Collabora sont coupees a la source ; rspamd et la sonde du genome
declarent leur sortie. Le devis du site evalue enfin seulement_si, ce qui
retire aussi le 465/587 vers un Postfix du site qui n'ecoute que sur 25.
Garde : test_conditions_site.py. Journal (125).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-10 23:11:13 -04:00
parent dd4ca09245
commit ee0e33d452
14 changed files with 296 additions and 15 deletions

View file

@ -1,5 +1,71 @@
# CHANGELOG — Set-OPS
## 2026-10-10 (125) — Plus aucune machine ne sort vers Internet sans un rôle qui le dise
**94 preuves.** `make verifier` passe, `make test` aussi avec le nouveau
`test_conditions_site.py` ; lancé sur l'ancien code, ce test échoue sur ses quatre
contrôles. Le plan de la frontière est vide après application (308 règles inchangées).
### Une sortie générale qui ne servait plus à ce qu'elle disait
Le socle donnait à chaque machine des trois écosystèmes une sortie 80/443 vers tout
Internet, « pour les dépôts apt ». Sur les 26 machines des locataires, toutes les sources
apt sont pourtant en `http://` et passent par le cache du site ; le site vise son propre
cache depuis le 2026-09-14. Le journal de la frontière, sur une semaine, et trois relevés
de sa table d'états (20 min, 1 h, puis le cache apt du site pour dater les cas rares) ont
attribué chaque sortie restante à sa machine :
- les cartes de rspamd (maps.rspamd.com, ~2 700 connexions par jour et par locataire) et
la carte SURBL (sa-update.surbl.org, ~245), depuis les deux `edge-mta-01` ;
- grafana.com (versions) et stats.grafana.org (compteurs d'usage), depuis Grafana, Loki,
et surtout Alloy, présent sur **toutes** les machines ;
- collabora.co.uk, la vérification de version de Collabora toutes les 10 heures ;
- le repère du génome de `site-ops-01` vers eregion.
PyPI, Galaxy, S3 et GitHub n'apparaissent plus après le 2026-10-09 au matin (entrée 121).
### Coupé à la source, déclaré dans le rôle, retiré du socle
La télémétrie est coupée là où elle naît : `--disable-reporting` pour Alloy, `analytics:
reporting_enabled: false` pour Loki, `GF_ANALYTICS_*`, Gravatar et le fil de nouvelles
pour Grafana, `fetch_update_check=0` et `allow_update_popup=false` pour Collabora. Les
mises à jour arrivent par apt, comme le reste.
Ce qui sert vraiment est déclaré dans le rôle qui en a besoin : rspamd sort en 443 et 80
(son repli local fige les listes à la version du paquet ; Daniel a choisi de garder des
cartes fraîches, comme Postfix garde son 25), `serveur_ops_site` en 443 pour sa sonde.
La raison de la sortie du cache du site nomme maintenant le dépôt de binaires.
La sortie du socle devient conditionnelle (`seulement_si: artefacts_amorcage` vide) : elle
ne vaut plus que pour un écosystème sans aucune source d'artefacts.
### Le devis du site ignorait les conditions
`make flux` retirait la sortie des machines du site, le devis de la frontière la gardait :
la boucle du site n'évaluait pas `seulement_si`, celle des locataires si. Elle l'évalue
désormais sur les variables d'hôte de l'inventaire du site (inventaire illisible : la règle
reste émise). Second effet, voulu : les règles 465/587 vers le Postfix du site, qui n'écoute
que sur 25 (`serveur_postfix_submission_actif` vaut `false`), sont retirées sur six pattes.
`test_conditions_site.py` refuse qu'un rôle universel (socle, durcissement, `client_*`)
ouvre Internet sans condition, et que la boucle du site cesse d'évaluer les conditions.
### Déployé et mesuré
Configurations sur les 26 machines des locataires et les 9 du site, sans échec ; vérifiées
sur les processus en cours. Les hyperviseurs portent aussi `client_journal` mais n'ont pas
été touchés : leur configuration Alloy et leur épinglage n'ont jamais reçu le changement du
2026-10-03, écart antérieur à traiter à part. Frontière : 7 créations (deux alias rspamd,
quatre règles rspamd, la sonde du génome), 32 retraits, relus. Aucun blocage vers Internet
dans le journal après l'application, aucune erreur de carte dans rspamd.
**Limites.** Trois destinations du site restent non attribuées (Leaseweb 89.149.222.99,
Gravatar, freebsd.org, une ou deux fois par jour) ; si elles venaient d'une autre machine
que le cache, elles sont désormais bloquées et le journal nommera la machine. Deux
connexions isolées par reconstruction (Google pendant l'installation d'Icinga sur `mon-01`,
Cloudflare pendant celle de Nextcloud sur `collab-01`) seront bloquées à la prochaine : la
reconstruction dira si elles comptaient.
## 2026-10-09 (124) — La raison du flux doit survivre au chargement du pare-feu
**94 preuves.** Les huit tests de libellés passent avec `make test`, ainsi que

View file

@ -335,6 +335,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
python3 scripts/tests/test_expositions_sans_port.py
python3 scripts/tests/test_proxmox_fw_externe.py
python3 scripts/tests/test_libelles_parefeu.py
python3 scripts/tests/test_conditions_site.py
python3 scripts/tests/test_sonde_tcp.py
python3 scripts/tests/test_restauration.py
python3 scripts/tests/test_repertoires_partages.py

View file

@ -430,3 +430,12 @@ Reste à trancher sur une interface **physique** : `ip ?`, `access-group ?`, `sp
déclare 53 en UDP et TCP, la récursion depuis la racine que le choix souverain implique.
Le `block out` de la section 5 est donc un vrai default-deny **assumé**, et le devis
l'énonce désormais au lieu de le poser en silence.
- **La sortie générale n'existe plus — 2026-10-10.** Le 123 était déjà passé à la frontière
(2026-09-11). Le 443 et le 80 du socle sont devenus conditionnels (`seulement_si:
artefacts_amorcage` vide) : le site et ses locataires prennent apt par le cache du site,
et ne les reçoivent plus. Mesurée à la frontière, cette sortie ne servait plus qu'à ce
qu'aucun rôle ne déclarait : télémétrie de Grafana, d'Alloy et de Loki et vérification
de version de Collabora (coupées à la source), cartes de rspamd et repère du génome
(déclarés dans `serveur_rspamd` et `serveur_ops_site`). Chaque sortie vers Internet porte
désormais le nom du rôle qui en a besoin. `test_conditions_site.py` refuse qu'un rôle
universel en rouvre une sans condition.

View file

@ -23,7 +23,7 @@
| `serveur_backup_site` | ingress | 22 | tcp | voisins_site | ssh | Les écosystèmes de ce site déposent leur état ici tant qu'ils n'ont pas leur propre dépôt. SFTP par un utilisateur restreint, jamais un compte d'administration : le site reçoit des octets chiffrés par restic, il ne peut pas les lire. |
| `serveur_cache_site` | ingress | 3142 | tcp | voisins_site | clair | Servir les caches des écosystèmes voisins. Debian n'est ainsi téléchargé qu'une fois pour tout le site, et le cache ne voit que des requêtes AGRÉGÉES — jamais quelle machine installe quoi. |
| `serveur_cache_site` | egress | 80 | tcp | externe | clair | Remplir le cache depuis les dépôts Debian amont. En clair parce que les dépôts apt sont signés : l'intégrité vient de la signature, pas du transport. |
| `serveur_cache_site` | egress | 443 | tcp | externe | tls-requis | Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga). Le cache les relaie pour que la flotte n'ait pas à sortir elle-même. |
| `serveur_cache_site` | egress | 443 | tcp | externe | tls-requis | Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga), et le dépôt de binaires (roues PyPI, collections Galaxy, versions GitHub, Nextcloud, Forgejo). Le site les relaie pour que la flotte n'ait pas à sortir elle-même. |
| `serveur_collabora` | ingress | 9980 | tcp | edge | clair | Éditeur servi au navigateur via l'edge (WebSocket WOPI ; TLS terminé à l'edge). |
| `serveur_collabora` | ingress | 9980 | tcp | localhost | clair | Vérifications WOPI serveur→Collabora depuis Nextcloud co-localisé. |
| `serveur_debian` | ingress | 22 | tcp | flotte, externe | ssh | Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense). |
@ -31,9 +31,9 @@
| `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 | 53 | udp | frontiere | n-a | Résolution de noms à l'amorçage, avant que le résolveur de l'écosystème n'existe. Sans elle, les machines d'un site neuf ne peuvent pas résoudre leurs dépôts de paquets — et rien ne peut donc s'installer, y compris le résolveur lui-même. |
| `serveur_debian` | egress | 53 | tcp | frontiere | n-a | Réponses longues et bascule TCP, obligatoires en DNS. Déclarer l'UDP sans le TCP donne une résolution qui marche jusqu'à la première réponse tronquée. |
| `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 | 80 | tcp | externe | clair | Dépôts apt en clair et redirections des miroirs, pour un écosystème SANS amont d'artefacts. L'intégrité vient de la signature des paquets, pas du transport. |
| `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 | 443 | tcp | externe | tls-requis | Dépôts apt en direct, pour un écosystème SANS aucune source d'artefacts. Le site et ses locataires passent par le cache du site et ne la reçoivent pas. |
| `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_dns_public` | ingress | 53 | udp | voisins_site | clair | NOTIFY des primaires des locataires du site : une zone publique a change. Le contenu d'une zone publique n'a rien de secret ; ce qui compte est QUI peut le modifier, et c'est TSIG qui le garde, sur le transfert. |
| `serveur_dns_public` | ingress | 53 | tcp | voisins_site | clair | Repli TCP des notifications des primaires des locataires. |
@ -89,6 +89,7 @@
| `serveur_ops_site` | egress | 22 | tcp | fabric | ssh | Shell des hyperviseurs : ce que l'API ne couvre pas — configuration reseau, ponts, deplacement de disques. Un pouvoir distinct de l'API, donc declare a part. |
| `serveur_ops_site` | egress | 22 | tcp | serveur_ops_tenant | ssh | Insemination : amorcer le runner d'un tenant — socle, moteur, plan, plancher de resolution — pour qu'il prenne ensuite le relais sur ses propres machines. Ne transporte aucun secret : la voute est remise par un humain. |
| `serveur_ops_site` | egress | 443 | tcp | fabric | tls-requis | API de la frontiere OPNsense : poser les alias et les regles qui ouvrent les flux du tenant qu'on materialise. Preparer le terrain sans cela laisserait un terrain injoignable. |
| `serveur_ops_site` | egress | 443 | tcp | externe | tls-requis | Repere du genome : `git ls-remote` anonyme sur eregion (forge.alliance-boreale.ca) pour dire si la forge du site a pris du retard. Lecture seule, sans identifiant. |
| `serveur_ops_site` | egress | 8006 | tcp | fabric | tls-requis | API de l'hyperviseur : créer, cloner et détruire les VM de la fabric. Le seul flux par lequel un écosystème peut en matérialiser un autre. |
| `serveur_ops_tenant` | ingress | 22 | tcp | runner_site | ssh | Insémination : le runner du SITE amorce ce runner-ci — socle, moteur, plan, plancher de résolution — jusqu'à ce qu'un humain lui remette sa voûte. Vers cette machine seule, jamais vers le reste de l'écosystème. |
| `serveur_postfix` | ingress | 25 | tcp | externe, client_smtp | starttls | SMTP entrant : courrier externe (MX) et notifications internes (client_smtp). |
@ -119,6 +120,8 @@
| `serveur_resolveur_site` | ingress | 53 | tcp | voisins_site | clair | Réponses longues et bascule TCP, obligatoires en DNS. |
| `serveur_rspamd` | ingress | 11332 | tcp | localhost | clair | Protocole milter consommé par Postfix co-localisé (analyse + signature DKIM). Local uniquement. |
| `serveur_rspamd` | ingress | 11334 | tcp | localhost | clair | Interface de contrôle rspamd (statistiques, apprentissage) en local. |
| `serveur_rspamd` | egress | 80 | tcp | externe | clair | Carte de hachages SURBL (sa-update.surbl.org), publiee en clair par l'amont. Une carte alteree ne peut que mal classer un courriel ; elle n'execute rien. |
| `serveur_rspamd` | egress | 443 | tcp | externe | tls-requis | Cartes de rspamd (maps.rspamd.com) : messageries gratuites et jetables, redirecteurs, listes blanches DMARC/SPF/DKIM, hameconnage. Sans elles l'antispam garde les copies du paquet, qui vieillissent. |
| `serveur_step_ca` | ingress | 8443 | tcp | flotte | tls-requis | ACME + API step-ca : chaque nœud (client_pki) émet/renouvelle ses certificats et récupère la racine. |
| `serveur_web_dorsal` | ingress | 80 | tcp | edge, serveur_web_frontal | clair | Front nginx local des webapps et des sites statiques, relaye par l'edge (noms internes) et par le web frontal (noms publics). |
| `serveur_web_dorsal` | egress | 443 | tcp | flotte | tls | git clone/pull du depot de chaque app (Forgejo souverain) au deploiement. |
@ -128,10 +131,10 @@
## Synthèse chiffrement
- **clair** : 51 flux
- **clair** : 52 flux
- **n-a** : 8 flux
- **ssh** : 8 flux
- **starttls** : 6 flux
- **tls** : 10 flux
- **tls-cible** : 2 flux
- **tls-requis** : 34 flux
- **tls-requis** : 36 flux

View file

@ -194,12 +194,17 @@
notify: Redemarrer alloy
# Le port d'ecoute est IMPOSE ici plutot que subi : voir defaults/main.yml.
- name: Imposer le port de l interface locale d Alloy
#
# AUCUN RAPPORT D'USAGE (mesure du 2026-10-10). Par defaut, Alloy envoie a
# stats.grafana.org la liste de ses composants actifs : relevé a la frontiere depuis
# CHAQUE machine des trois ecosystemes, ~70 connexions par jour et par locataire. Il
# passait par la sortie 443 generale du socle, qui n'existe plus.
- name: Imposer le port de l interface locale d Alloy, sans rapport d usage
ansible.builtin.lineinfile:
path: /etc/default/alloy
regexp: "^CUSTOM_ARGS="
line: >-
CUSTOM_ARGS="--server.http.listen-addr={{ client_journal_http_adresse }}:{{ client_journal_http_port }}"
CUSTOM_ARGS="--server.http.listen-addr={{ client_journal_http_adresse }}:{{ client_journal_http_port }} --disable-reporting"
create: false
notify: Redemarrer alloy

View file

@ -42,5 +42,6 @@ flux:
pair: externe
chiffrement: tls-requis
raison: >-
Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga). Le cache
les relaie pour que la flotte n'ait pas à sortir elle-même.
Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga), et le
dépôt de binaires (roues PyPI, collections Galaxy, versions GitHub, Nextcloud,
Forgejo). Le site les relaie pour que la flotte n'ait pas à sortir elle-même.

View file

@ -3,6 +3,10 @@
# Surcharge de l'unité du paquet. Le `coolwsd.xml` livré (439 lignes commentées) reste
# intact : le remplacer par un gabarit maison obligerait à suivre son évolution à chaque
# version amont, et ferait perdre les commentaires qui expliquent chaque réglage.
#
# AUCUNE VÉRIFICATION DE VERSION (mesure du 2026-10-10) : par défaut coolwsd interroge
# collabora.co.uk toutes les 10 heures, et le dit aux utilisateurs par une fenêtre. Les
# mises à jour arrivent par apt ; `fetch_update_check=0` coupe l'appel, la fenêtre suit.
[Service]
ExecStart=
ExecStart=/usr/bin/coolwsd --version --o:sys_template_path=/opt/cool/systemplate \
@ -12,6 +16,8 @@ ExecStart=/usr/bin/coolwsd --version --o:sys_template_path=/opt/cool/systemplate
--o:ssl.termination={{ serveur_collabora_ssl_termination | bool | lower }} \
--o:server_name={{ serveur_collabora_server_name }} \
--o:storage.wopi.alias_group[0].host=https://{{ serveur_collabora_domaine_nextcloud }}:443 \
--o:fetch_update_check=0 \
--o:allow_update_popup=false \
--o:admin_console.enable={{ serveur_collabora_admin_actif | bool | lower }}{% if serveur_collabora_admin_actif | bool %} \
--o:admin_console.username={{ serveur_collabora_admin_utilisateur }} \
--o:admin_console.password={{ serveur_collabora_admin_mot_de_passe }}{% endif %}

View file

@ -9,22 +9,39 @@ flux:
chiffrement: ssh
raison: "Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense)."
# --- Sortie générale de la flotte ---
# Portée par le socle parce qu'elle vaut pour TOUS les hôtes, sans exception.
# Non déclarée, la frontière la bloquerait : son `block out` final est un vrai
# default-deny, et la flotte perdrait ses mises à jour et son horloge en silence.
# --- Sortie apt directe : SEULEMENT SANS AMONT D'ARTEFACTS (2026-10-10) ---
#
# C'ÉTAIT UNE SORTIE 80/443 VERS TOUT INTERNET, POUR TOUTE MACHINE. Sa raison, « dépôts
# apt », n'était plus vraie chez les locataires : leurs 26 machines prennent TOUTES leurs
# sources en `http://` par le cache du site (`artefacts_amorcage`), aucune n'a de dépôt
# HTTPS. Mesuré à la frontière, elle ne servait plus qu'à ce qu'aucun rôle ne déclarait :
# télémétrie de Grafana, d'Alloy et de Loki, vérification de version de Collabora,
# cartes de rspamd, repère du génome. Les trois premiers sont coupés à la source ; les
# deux derniers ont désormais leur propre flux, dans leur rôle.
#
# ELLE NE RESTE QUE LÀ OÙ IL N'Y A AUCUN AMONT. Le site non plus ne la reçoit pas : ses
# machines visent son propre cache depuis le 2026-09-14 (`site_inventaire.py`,
# `artefacts_amorcage`), et le cache, lui, porte ses propres sorties 80/443
# (`serveur_cache_site`, `serveur_artefacts`). Seul un écosystème sans aucune source
# d'artefacts — un laboratoire isolé — prend encore ses paquets en direct.
- sens: egress
port: 443
protocole: tcp
pair: externe
chiffrement: tls-requis
raison: "Dépôts apt en HTTPS (Debian, Smallstep, Grafana, Icinga, Forgejo, Nextcloud) — sans quoi aucun correctif de sécurité n'entre."
seulement_si: {variable: artefacts_amorcage, non_vide: false}
raison: >-
Dépôts apt en direct, pour un écosystème SANS aucune source d'artefacts. Le site
et ses locataires passent par le cache du site et ne la reçoivent pas.
- sens: egress
port: 80
protocole: tcp
pair: externe
chiffrement: clair
raison: "Dépôts apt en clair et redirections HTTP des miroirs (l'intégrité vient de la signature des paquets, pas du transport)."
seulement_si: {variable: artefacts_amorcage, non_vide: false}
raison: >-
Dépôts apt en clair et redirections des miroirs, pour un écosystème SANS amont
d'artefacts. L'intégrité vient de la signature des paquets, pas du transport.
# --- Decouverte de MTU de chemin (PMTUD) ---
# L'overlay EVPN plafonne a 1450 : tout ce qui traverse la frontiere depend de ces
# deux messages. Bloques, la connexion s'etablit, les petites requetes passent et les

View file

@ -11,6 +11,17 @@ Environment=GF_SERVER_DOMAIN={{ serveur_grafana_hostname }}
Environment=GF_SERVER_ROOT_URL={{ serveur_grafana_root_url }}
Environment=GF_SECURITY_ADMIN_PASSWORD={{ serveur_grafana_admin_password }}
Environment=GF_USERS_ALLOW_SIGN_UP=false
{# GRAFANA N'APPELLE PERSONNE (mesure du 2026-10-10). Par defaut il interroge grafana.com
(versions, greffons) et envoie ses compteurs d'usage a stats.grafana.org : ~124 et ~70
connexions par jour, depuis le site et depuis chaque locataire. Les avatars viennent de
gravatar.com, les nouvelles de grafana.com. Rien de tout cela ne sert une console
interne ; les mises a jour arrivent par apt, comme le reste. #}
Environment=GF_ANALYTICS_REPORTING_ENABLED=false
Environment=GF_ANALYTICS_CHECK_FOR_UPDATES=false
Environment=GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES=false
Environment=GF_ANALYTICS_FEEDBACK_LINKS_ENABLED=false
Environment=GF_SECURITY_DISABLE_GRAVATAR=true
Environment=GF_NEWS_NEWS_FEED_ENABLED=false
{# LA SYNTHESE COMME PAGE D'ACCUEIL (2026-10-04) : a la connexion, la question « faut-il
agir ? » plutot que la liste des tableaux. Le fichier est pose par ce role (tableau de
synthese) ; sans lui, Grafana revient a son accueil par defaut, sans erreur. #}

View file

@ -39,3 +39,7 @@ compactor:
limits_config:
retention_period: {{ serveur_loki_retention }}
{# Pas de rapport d'usage a stats.grafana.org (actif par defaut, mesure du 2026-10-10). #}
analytics:
reporting_enabled: false

View file

@ -70,3 +70,16 @@ flux:
Insemination : amorcer le runner d'un tenant — socle, moteur, plan, plancher de
resolution — pour qu'il prenne ensuite le relais sur ses propres machines. Ne
transporte aucun secret : la voute est remise par un humain.
# LE REPERE DU GENOME (2026-10-10). La sonde `sonde-genome-a-jour` compare la forge du
# site a eregion par un `git ls-remote` anonyme, toutes les 15 minutes. Elle sortait par
# la sortie 443 generale du socle, qui n'existe plus : la declarer ici la rend lisible.
# Sans Internet, la sonde passe en INCONNU (code 3) — rien ne casse.
- sens: egress
port: 443
protocole: tcp
pair: externe
chiffrement: tls-requis
raison: >-
Repere du genome : `git ls-remote` anonyme sur eregion (forge.alliance-boreale.ca)
pour dire si la forge du site a pris du retard. Lecture seule, sans identifiant.

View file

@ -13,3 +13,32 @@ flux:
pair: localhost
chiffrement: clair
raison: "Interface de contrôle rspamd (statistiques, apprentissage) en local."
# LES CARTES DE RSPAMD VIENNENT D'INTERNET, ET C'EST UN CHOIX (2026-10-10).
#
# Jusqu'ici rien ne le disait : rspamd sortait par la sortie 80/443 que le socle donnait
# a TOUTE machine. Mesure a la frontiere : ~2 700 connexions par jour et par locataire
# vers maps.rspamd.com, ~245 vers sa-update.surbl.org, depuis `edge-mta-01` seulement.
#
# Sans elles, rspamd ne tombe pas : chaque carte distante a son repli local
# (`file:///etc/rspamd/maps.d/…`), fige a la version du paquet. Les listes de messageries
# gratuites, de redirecteurs, d'hameconnage et de listes blanches DMARC vieilliraient en
# silence. On garde donc la sortie — mais declaree, et pour ce seul role, comme le 25
# sortant de Postfix.
- sens: egress
port: 443
protocole: tcp
pair: externe
chiffrement: tls-requis
raison: >-
Cartes de rspamd (maps.rspamd.com) : messageries gratuites et jetables, redirecteurs,
listes blanches DMARC/SPF/DKIM, hameconnage. Sans elles l'antispam garde les copies
du paquet, qui vieillissent.
- sens: egress
port: 80
protocole: tcp
pair: externe
chiffrement: clair
raison: >-
Carte de hachages SURBL (sa-update.surbl.org), publiee en clair par l'amont. Une
carte alteree ne peut que mal classer un courriel ; elle n'execute rien.

View file

@ -435,6 +435,35 @@ def _services_du_plan_site() -> dict[str, list[str]]:
return {h: sorted(set(g)) for h, g in out.items()}
_HOSTVARS_SITE: dict | None = None
def applicable_au_site(fl: dict, role: str, machines: list[str]) -> bool:
"""Le flux conditionnel s'applique-t-il a AU MOINS UNE machine du site qui porte le role ?
LE SITE N'EVALUAIT PAS `seulement_si` (2026-10-10). La boucle des locataires le faisait
(`applicable_chez`), celle du site emettait tout flux, condition ou non. Invisible tant
qu'aucune condition ne changeait de verdict au site ; vu le jour ou la sortie apt du
socle est devenue conditionnelle : `make flux` la retirait des machines du site, et le
devis de la frontiere la gardait. Deux couches, deux verdicts.
Meme evaluation que `resoudre_flux` pour le site : variables d'hote de l'inventaire du
site, puis defauts du role. Inventaire illisible -> la regle est emise (statu quo).
"""
global _HOSTVARS_SITE
if not fl.get("seulement_si"):
return True
if _HOSTVARS_SITE is None:
try:
import site_inventaire # noqa: PLC0415 — le site peut ne pas etre monte
_HOSTVARS_SITE = (site_inventaire.inventaire().get("_meta") or {}).get("hostvars") or {}
except Exception:
_HOSTVARS_SITE = {}
if not _HOSTVARS_SITE:
return True
return any(flux_applicable(fl, role, None, _HOSTVARS_SITE.get(m) or {}) for m in machines)
def _ports_du_plan_site() -> dict[str, str]:
"""Port declare au plan pour chaque role du site — ce que `port: derive` vaut ici.
@ -989,11 +1018,17 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"contenu": _ips,
"description": f"Machines du site portant {_role}",
}
_machines_role = [str(m["nom"]) for m in _machines_site
if _role == "serveur_debian"
or _role in _services_site.get(m["nom"], [])]
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)
# Un flux conditionnel ne vaut que la ou son reglage le dit — au site aussi.
if not applicable_au_site(_fl, _role, _machines_role):
continue
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,

View file

@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Aucune sortie vers Internet n'est donnee a TOUTE machine, et le site lit les conditions.
POURQUOI (2026-10-10). Le socle donnait a chaque machine une sortie 80/443 vers tout
Internet, « pour apt ». Apt passait deja par le cache du site : la sortie ne servait plus
qu'a ce qu'aucun role ne declarait — telemetrie de Grafana, d'Alloy et de Loki, verification
de version de Collabora, cartes de rspamd. Elle est devenue conditionnelle
(`seulement_si: artefacts_amorcage` vide), et deux defauts sont apparus :
1. un role porte par toute machine (le socle, les `client_*`) peut rouvrir Internet a
toute la flotte sans que rien ne le signale ;
2. le devis de la frontiere n'evaluait `seulement_si` que chez les locataires : au site,
tout flux conditionnel etait emis. `make flux` retirait la sortie des machines du site,
la frontiere la gardait.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
RACINE = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(RACINE / "scripts"))
import devis_opnsense as dv # noqa: E402
from resoudre_flux import charger_flux # noqa: E402
UNIVERSELS = {"serveur_debian", "serveur_durci"}
def main() -> int:
echecs: list[str] = []
flux = charger_flux()
# 1. Les roles universels n'ouvrent pas Internet sans condition (hors ICMP du PMTUD).
for role, liste in flux.items():
if role not in UNIVERSELS and not role.startswith("client_"):
continue
for fl in liste:
pairs = fl.get("pair") if isinstance(fl.get("pair"), list) else [fl.get("pair")]
if (fl.get("sens") == "egress" and "externe" in pairs
and str(fl.get("protocole")).lower() in ("tcp", "udp")
and not fl.get("seulement_si")):
echecs.append(f"{role} : sortie {fl.get('protocole')}/{fl.get('port')} vers "
f"`externe` sans condition — donnee a TOUTE machine")
# 2. L'evaluation au site, sur des variables d'hote connues.
socle = [fl for fl in flux.get("serveur_debian", [])
if fl.get("sens") == "egress" and fl.get("seulement_si")
and (fl.get("seulement_si") or {}).get("variable") == "artefacts_amorcage"]
if not socle:
echecs.append("serveur_debian : la sortie apt n'est plus conditionnee a `artefacts_amorcage`")
else:
fl = socle[0]
dv._HOSTVARS_SITE = {"avec": {"artefacts_amorcage": "10.0.0.1:3142"}, "sans": {}}
attendus = [(["avec"], False), (["sans"], True), (["avec", "sans"], True)]
for machines, attendu in attendus:
obtenu = dv.applicable_au_site(fl, "serveur_debian", machines)
if obtenu is not attendu:
echecs.append(f"applicable_au_site({machines}) = {obtenu}, attendu {attendu}")
dv._HOSTVARS_SITE = {}
if dv.applicable_au_site(fl, "serveur_debian", ["avec"]) is not True:
echecs.append("inventaire du site illisible : la regle doit rester emise (statu quo)")
dv._HOSTVARS_SITE = None
# 3. La boucle du site appelle bien l'evaluation, avant tout rendu de regle.
source = (RACINE / "scripts" / "devis_opnsense.py").read_text(encoding="utf-8")
boucle = re.search(r"for _fl in flux_par_role\.get\(_role, \[\]\):(.{0,600})", source, re.S)
if not boucle or "applicable_au_site(" not in boucle.group(1):
echecs.append("devis_opnsense : la boucle des flux du site n'evalue plus `seulement_si`")
for e in echecs:
print(f"ECHEC {e}")
if echecs:
return 1
print("OK — aucun role universel n'ouvre Internet ; le site lit les conditions.")
return 0
if __name__ == "__main__":
sys.exit(main())