Compare commits
No commits in common. "main" and "v2026.10.07" have entirely different histories.
main
...
v2026.10.0
122 changed files with 1118 additions and 9274 deletions
1459
CHANGELOG.md
1459
CHANGELOG.md
File diff suppressed because it is too large
Load diff
21
CLAUDE.md
21
CLAUDE.md
|
|
@ -4,3 +4,24 @@
|
||||||
|
|
||||||
`AGENTS.md` est la **source d'autorité unique** de ce dépôt. Claude Code doit le lire
|
`AGENTS.md` est la **source d'autorité unique** de ce dépôt. Claude Code doit le lire
|
||||||
et le respecter intégralement avant toute modification.
|
et le respecter intégralement avant toute modification.
|
||||||
|
|
||||||
|
En cas de contradiction entre `CLAUDE.md` et `AGENTS.md`, **suivre `AGENTS.md`**.
|
||||||
|
|
||||||
|
Ce fichier est volontairement mince : il ne redéclare pas la doctrine (template, SSH,
|
||||||
|
pare-feu, cloud-init, handlers, Makefile, groupes…). Toute cette doctrine vit dans
|
||||||
|
`AGENTS.md`, pour éviter la duplication et les dérives qu'elle provoque.
|
||||||
|
|
||||||
|
## Les cinq règles absolues
|
||||||
|
|
||||||
|
1. **Un seul agent IA** travaille dans ce dépôt à la fois (Codex *ou* Claude, jamais les deux).
|
||||||
|
2. **`instance/inventories/*/hosts.yml` est un artefact GÉNÉRÉ** depuis le plan
|
||||||
|
(`instance/plan/`). On l'édite jamais à la main : on édite le plan, puis
|
||||||
|
`make instancier` → `make instancier-appliquer`.
|
||||||
|
3. **Aucun secret en clair** (mots de passe, clés privées, tokens, certificats) — Vault
|
||||||
|
ou stockage hors dépôt uniquement.
|
||||||
|
4. **Toute action destructive ou risquée exige une confirmation explicite** (ex.
|
||||||
|
`CONFIRMER=true`, `confirm_destructive_action: true`). Sans elle, refuser l'exécution.
|
||||||
|
5. **Rien n'est « prêt » sans validation.** Au minimum `--syntax-check` du playbook
|
||||||
|
touché, `ansible-lint` si disponible, et l'entrée `CHANGELOG.md` correspondante.
|
||||||
|
|
||||||
|
Le détail de chacune de ces règles, et tout le reste, est dans `AGENTS.md`.
|
||||||
|
|
|
||||||
33
Makefile
33
Makefile
|
|
@ -318,15 +318,10 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
|
||||||
python3 scripts/tests/test_raser.py
|
python3 scripts/tests/test_raser.py
|
||||||
python3 scripts/tests/test_raser_resultat.py
|
python3 scripts/tests/test_raser_resultat.py
|
||||||
python3 scripts/tests/test_temoins.py
|
python3 scripts/tests/test_temoins.py
|
||||||
python3 scripts/tests/test_genome_colis.py
|
|
||||||
python3 scripts/tests/test_verifier_depot.py
|
|
||||||
python3 scripts/tests/test_apps_nextcloud.py
|
|
||||||
python3 scripts/tests/test_verrou_runner.py
|
|
||||||
python3 scripts/tests/test_adressage_derive.py
|
python3 scripts/tests/test_adressage_derive.py
|
||||||
python3 scripts/tests/test_gui_intrants.py
|
python3 scripts/tests/test_gui_intrants.py
|
||||||
python3 scripts/tests/test_runbooks.py
|
python3 scripts/tests/test_runbooks.py
|
||||||
python3 scripts/tests/test_portee_console.py
|
python3 scripts/tests/test_portee_console.py
|
||||||
python3 scripts/tests/test_forge_cles.py
|
|
||||||
python3 scripts/tests/test_devis_placement.py
|
python3 scripts/tests/test_devis_placement.py
|
||||||
python3 scripts/tests/test_sonder.py
|
python3 scripts/tests/test_sonder.py
|
||||||
python3 scripts/tests/test_fraicheur_icinga.py
|
python3 scripts/tests/test_fraicheur_icinga.py
|
||||||
|
|
@ -335,14 +330,6 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
|
||||||
python3 scripts/tests/test_empreinte_plancher.py
|
python3 scripts/tests/test_empreinte_plancher.py
|
||||||
python3 scripts/tests/test_expositions_sans_port.py
|
python3 scripts/tests/test_expositions_sans_port.py
|
||||||
python3 scripts/tests/test_proxmox_fw_externe.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_relais_du_site.py
|
|
||||||
python3 scripts/tests/test_sasl_local.py
|
|
||||||
python3 scripts/tests/test_dovecot_sonde_et_imap.py
|
|
||||||
python3 scripts/tests/test_parite_tls_site.py
|
|
||||||
python3 scripts/tests/test_exportateur_pg_tls.py
|
|
||||||
python3 scripts/tests/test_frontiere_pki.py
|
|
||||||
python3 scripts/tests/test_sonde_tcp.py
|
python3 scripts/tests/test_sonde_tcp.py
|
||||||
python3 scripts/tests/test_restauration.py
|
python3 scripts/tests/test_restauration.py
|
||||||
python3 scripts/tests/test_repertoires_partages.py
|
python3 scripts/tests/test_repertoires_partages.py
|
||||||
|
|
@ -1386,26 +1373,6 @@ genome-pousser: ## Le runner du site pousse le genome sur la forge du site — D
|
||||||
SETOPS_DEPOT="$(DEPOT)" ansible-playbook -i scripts/site_inventaire.py \
|
SETOPS_DEPOT="$(DEPOT)" ansible-playbook -i scripts/site_inventaire.py \
|
||||||
playbooks/maintenance/genome_pousser.yml "$${supp[@]}" $(ARGS)
|
playbooks/maintenance/genome_pousser.yml "$${supp[@]}" $(ARGS)
|
||||||
|
|
||||||
.PHONY: forge-cles
|
|
||||||
forge-cles: ## Le site enregistre sur le depot de chaque locataire la cle de lecture que sa face publie — CONFIRMER=true pour ecrire
|
|
||||||
@set -e; \
|
|
||||||
voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \
|
|
||||||
supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \
|
|
||||||
CONFIRMER="$(CONFIRMER)" ansible-playbook -i scripts/site_inventaire.py \
|
|
||||||
playbooks/maintenance/forge_cles.yml "$${supp[@]}" $(ARGS)
|
|
||||||
|
|
||||||
.PHONY: forge-comptes
|
|
||||||
forge-comptes: ## Les comptes de la forge du site : un par mainteneur (plan du site), et le lecteur de la console — CONFIRMER=true pour ecrire
|
|
||||||
@set -e; \
|
|
||||||
voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \
|
|
||||||
supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \
|
|
||||||
CONFIRMER="$(CONFIRMER)" ansible-playbook -i scripts/site_inventaire.py \
|
|
||||||
playbooks/maintenance/forge_comptes.yml "$${supp[@]}" $(ARGS)
|
|
||||||
|
|
||||||
.PHONY: forge-etancheite
|
|
||||||
forge-etancheite: ansible-runtime ## La forge du site laisse-t-elle chaque runner lire ses depots, et personne d'autre ? (mesure, aucune ecriture)
|
|
||||||
python3 scripts/forge_etancheite.py
|
|
||||||
|
|
||||||
.PHONY: depots-perimes
|
.PHONY: depots-perimes
|
||||||
depots-perimes: ansible-runtime _instance-requise ## Depots qu'un runner ne declare plus : regarde, et retire avec CONFIRMER=true
|
depots-perimes: ansible-runtime _instance-requise ## Depots qu'un runner ne declare plus : regarde, et retire avec CONFIRMER=true
|
||||||
@# DESTRUCTIF SUR CONFIRMATION SEULEMENT. Sans `CONFIRMER=true`, ce geste REGARDE :
|
@# DESTRUCTIF SUR CONFIRMATION SEULEMENT. Sans `CONFIRMER=true`, ce geste REGARDE :
|
||||||
|
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
# Preuve de conformite — Set-OPS — 2026-10-09
|
|
||||||
|
|
||||||
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
|
|
||||||
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
|
|
||||||
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
|
|
||||||
> [`docs/audit/README.md`](README.md), et le registre trace :
|
|
||||||
> [`docs/audit/affirmations.md`](affirmations.md).
|
|
||||||
|
|
||||||
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
|
|
||||||
- **Verdict** : ✅ CONFORME (94 OK · 0 echec · 0 saute)
|
|
||||||
|
|
||||||
## Preuves
|
|
||||||
|
|
||||||
| # | Preuve | Affirmations | Statut | Detail |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | } \| to_nice_json }}`. |
|
|
||||||
| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK |
|
|
||||||
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 3 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre : plan et inventaire applique coincident. |
|
|
||||||
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
|
|
||||||
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
|
|
||||||
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
|
|
||||||
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
|
|
||||||
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 42 groupes classes, aucun cycle, aucune arete en arriere ; playbooks/site.yml a jour. |
|
|
||||||
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 119 flux, schéma + matrice OK. |
|
|
||||||
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
|
|
||||||
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
|
|
||||||
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
|
|
||||||
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
|
|
||||||
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
|
|
||||||
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
|
|
||||||
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 13 hotes, 33 groupes (inventaire dechiffre et parse). |
|
|
||||||
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
|
|
||||||
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 35 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
|
|
||||||
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 30 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
|
|
||||||
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) et 7 zone(s) de site : adressage 100% derive du seed index. |
|
|
||||||
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 4 instance(s) federee(s), aucun index en collision. |
|
|
||||||
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
|
|
||||||
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 14 reseau(x), aucune collision avec la plage tenant. |
|
|
||||||
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
|
|
||||||
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 40 groupe(s), 108 regle(s). |
|
|
||||||
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 13 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
|
|
||||||
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
|
|
||||||
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
|
|
||||||
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
|
|
||||||
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
|
|
||||||
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 78 scripts expliques et atteignables, 151 cibles make documentees, 69 roles avec README. |
|
|
||||||
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (148 cle(s) declaree(s) par l'instance). |
|
|
||||||
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 44 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
|
|
||||||
| P34 | Chaque document declare son lecteur | — | ✅ OK | 50 document(s) declarent leur lecteur (47 genere(s) exempte(s)). |
|
|
||||||
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). |
|
|
||||||
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (11 groupe(s) au catalogue). |
|
|
||||||
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
|
|
||||||
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. |
|
|
||||||
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 97 page(s) de wiki toutes atteignables. |
|
|
||||||
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
|
|
||||||
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 74 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
|
||||||
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 3 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal). |
|
|
||||||
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 201 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 102 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 (137 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 304 regles `pass`), tous non consignes et tous motives. |
|
|
||||||
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
|
|
||||||
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
|
|
||||||
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere : WAN muet, interfa |
|
|
||||||
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
|
|
||||||
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. |
|
|
||||||
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
|
|
||||||
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (94 preuves, 69 roles, 42 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 `e9215a2` (publie le 2026-10-07). |
|
|
||||||
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 56 champ(s) sur 7 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:9, 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 | 47 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, client_s |
|
|
||||||
| 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 | 13 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
|
|
||||||
| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. |
|
|
||||||
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
|
|
||||||
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 27 artefact(s) direct(s) tenus par le depot du site. |
|
|
||||||
| P71 | Pool du site : le genome ne nait pas chez un tenant | — | ✅ OK | `site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant. |
|
|
||||||
| P72 | Annuaire : aucun service ne se lie avec le compte du maitre | — | ✅ OK | 5 role(s) consultent l'annuaire, chacun avec SON compte de service ; seul `amorcage_acces` garde celui d'administration, et il provisionne au lieu de consommer. |
|
|
||||||
| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 10 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). |
|
|
||||||
| P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal ». |
|
|
||||||
| P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. |
|
|
||||||
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 189 gabarits de role : tous se rendent. |
|
|
||||||
| P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. |
|
|
||||||
| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). |
|
|
||||||
| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 4 ecosysteme(s) (OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, SITE-Chezlepro) : pattes, edges, certificats, rechargements, jumeaux d'amorcage, sorties du |
|
|
||||||
| P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. |
|
|
||||||
| P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des voutes portees, source d'inventaire `instance/inventories/principal/hosts.yml`, et 15 route(s) POST exigent toutes un pouvoir. |
|
|
||||||
| P82 | DNS public : les zones publiees sont servies, signees avant d'etre exposees | — | ✅ OK | 2 zone(s) publique(s) declaree(s), toutes servies par le site ; 2 replication(s) declaree(s) des deux cotes ; transfert ouvert a la cle seule ; aucune expositio |
|
|
||||||
| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 144 etapes, 150 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. |
|
|
||||||
| P84 | La fiche du site dit exactement ce que chaque locataire porte | — | ✅ OK | 2 fiche(s) de site : chacune dit exactement ce que son locataire porte (copies, racine, inventaire genere). |
|
|
||||||
| P85 | La face reseau du locataire dit exactement ce que le site en tire | — | ✅ OK | 2 face(s) reseau : chacune dit exactement ce que le site en tire (decouverte, devis Proxmox, frontiere, DNS public, sauvegarde). |
|
|
||||||
| P86 | Machine par machine, le locataire et Proxmox admettent les memes entrees | — | ✅ OK | 2 locataire(s) : machine par machine et port par port, nftables et Proxmox admettent les memes sources. |
|
|
||||||
| P87 | La frontiere designe chaque locataire par les identites qu'il publie | — | ✅ OK | 2 locataire(s) : la frontiere les designe par les identites qu'ils publient (supernet, administration, tunnel, groupes, routes, sortie). |
|
|
||||||
| P88 | Ce qui entre depuis l'Internet chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : regles WAN, redirections et tunnel disent exactement ce que leur face reseau ouvre a tous. |
|
|
||||||
| P89 | Ce que l'administration atteint chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : par la gestion, le VPN et le tunnel, l'administration atteint exactement ce que leur face reseau lui ouvre. |
|
|
||||||
| P90 | Ce qui sort d'un locataire vers l'Internet, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : la frontiere laisse sortir exactement ce que leur face reseau publie. |
|
|
||||||
| P91 | La fiche du site est deposee a jour, et l'inventaire se genere sans le site | — | ✅ OK | 2 locataire(s) : fiche du site deposee a jour, et inventaire genere sans le site identique a l'octet pres. |
|
|
||||||
| P92 | Chaque locataire a publie sa face reseau a jour | — | ✅ OK | 2 locataire(s) : face reseau publiee, a jour. |
|
|
||||||
| P93 | La face publie, machine par machine, les parametres de clonage de l'inventaire | — | ✅ OK | 26 machine(s) : la face publie exactement les parametres de clonage de l'inventaire. |
|
|
||||||
| P94 | Creer, raser et placer un locataire par sa face visent les memes machines | — | ✅ OK | 2 locataire(s) : creer, raser et placer visent par la face les memes machines que par l'instance montee. |
|
|
||||||
|
|
||||||
## Couverture des affirmations ✅ du registre
|
|
||||||
|
|
||||||
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
|
|
||||||
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
|
|
||||||
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
|
|
||||||
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
|
|
||||||
elles restent hors du harnais recurrent (rien d'executable a rejouer).
|
|
||||||
|
|
||||||
## Declarations d'intention (⚪ invérifiables localement — assumees)
|
|
||||||
|
|
||||||
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
|
|
||||||
comme declarations d'intention, non comme preuves :
|
|
||||||
|
|
||||||
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
|
|
||||||
contre une flotte vivante.
|
|
||||||
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
|
|
||||||
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
|
|
||||||
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
|
|
||||||
|
|
||||||
_Rapport genere le 2026-10-09._
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
# Preuve de conformite — Set-OPS — 2026-10-11
|
|
||||||
|
|
||||||
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
|
|
||||||
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
|
|
||||||
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
|
|
||||||
> [`docs/audit/README.md`](README.md), et le registre trace :
|
|
||||||
> [`docs/audit/affirmations.md`](affirmations.md).
|
|
||||||
|
|
||||||
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
|
|
||||||
- **Verdict** : ✅ CONFORME (95 OK · 0 echec · 0 saute)
|
|
||||||
|
|
||||||
## Preuves
|
|
||||||
|
|
||||||
| # | Preuve | Affirmations | Statut | Detail |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | [0m[0m |
|
|
||||||
| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK |
|
|
||||||
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 3 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre : plan et inventaire applique coincident. |
|
|
||||||
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
|
|
||||||
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
|
|
||||||
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
|
|
||||||
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
|
|
||||||
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 42 groupes classes, aucun cycle, aucune arete en arriere ; playbooks/site.yml a jour. |
|
|
||||||
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 123 flux, schéma + matrice OK. |
|
|
||||||
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
|
|
||||||
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
|
|
||||||
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
|
|
||||||
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
|
|
||||||
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
|
|
||||||
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
|
|
||||||
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 13 hotes, 33 groupes (inventaire dechiffre et parse). |
|
|
||||||
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
|
|
||||||
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 35 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
|
|
||||||
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 30 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
|
|
||||||
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) et 7 zone(s) de site : adressage 100% derive du seed index. |
|
|
||||||
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 4 instance(s) federee(s), aucun index en collision. |
|
|
||||||
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
|
|
||||||
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 14 reseau(x), aucune collision avec la plage tenant. |
|
|
||||||
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
|
|
||||||
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 40 groupe(s), 108 regle(s). |
|
|
||||||
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 13 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
|
|
||||||
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
|
|
||||||
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
|
|
||||||
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
|
|
||||||
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
|
|
||||||
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 81 scripts expliques et atteignables, 154 cibles make documentees, 69 roles avec README. |
|
|
||||||
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (150 cle(s) declaree(s) par l'instance). |
|
|
||||||
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 44 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
|
|
||||||
| P34 | Chaque document declare son lecteur | — | ✅ OK | 51 document(s) declarent leur lecteur (48 genere(s) exempte(s)). |
|
|
||||||
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). |
|
|
||||||
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (11 groupe(s) au catalogue). |
|
|
||||||
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
|
|
||||||
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. |
|
|
||||||
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 97 page(s) de wiki toutes atteignables. |
|
|
||||||
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
|
|
||||||
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 77 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
|
||||||
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 3 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal). |
|
|
||||||
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 175 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 103 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 (141 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 278 regles `pass`), tous non consignes et tous motives. |
|
|
||||||
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
|
|
||||||
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
|
|
||||||
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere : WAN muet, interfa |
|
|
||||||
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
|
|
||||||
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. |
|
|
||||||
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
|
|
||||||
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (95 preuves, 69 roles, 42 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 `e9215a2` (publie le 2026-10-07). |
|
|
||||||
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 56 champ(s) sur 7 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:9, 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 | 47 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, client_s |
|
|
||||||
| 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 | 13 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
|
|
||||||
| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. |
|
|
||||||
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
|
|
||||||
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 27 artefact(s) direct(s) tenus par le depot du site. |
|
|
||||||
| P71 | Pool du site : le genome ne nait pas chez un tenant | — | ✅ OK | `site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant. |
|
|
||||||
| P72 | Annuaire : aucun service ne se lie avec le compte du maitre | — | ✅ OK | 5 role(s) consultent l'annuaire, chacun avec SON compte de service ; seul `amorcage_acces` garde celui d'administration, et il provisionne au lieu de consommer. |
|
|
||||||
| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 11 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). |
|
|
||||||
| P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal ». |
|
|
||||||
| P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. |
|
|
||||||
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 189 gabarits de role : tous se rendent. |
|
|
||||||
| P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. |
|
|
||||||
| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). |
|
|
||||||
| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 4 ecosysteme(s) (OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, SITE-Chezlepro) : pattes, edges, certificats, rechargements, jumeaux d'amorcage, sorties du |
|
|
||||||
| P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. |
|
|
||||||
| P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des voutes portees, source d'inventaire `instance/inventories/principal/hosts.yml`, et 15 route(s) POST exigent toutes un pouvoir. |
|
|
||||||
| P82 | DNS public : les zones publiees sont servies, signees avant d'etre exposees | — | ✅ OK | 2 zone(s) publique(s) declaree(s), toutes servies par le site ; 2 replication(s) declaree(s) des deux cotes ; transfert ouvert a la cle seule ; aucune expositio |
|
|
||||||
| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 148 etapes, 153 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. |
|
|
||||||
| P84 | La fiche du site dit exactement ce que chaque locataire porte | — | ✅ OK | 2 fiche(s) de site : chacune dit exactement ce que son locataire porte (copies, racine, inventaire genere). |
|
|
||||||
| P85 | La face reseau du locataire dit exactement ce que le site en tire | — | ✅ OK | 2 face(s) reseau : chacune dit exactement ce que le site en tire (decouverte, devis Proxmox, frontiere, DNS public, sauvegarde). |
|
|
||||||
| P86 | Machine par machine, le locataire et Proxmox admettent les memes entrees | — | ✅ OK | 2 locataire(s) : machine par machine et port par port, nftables et Proxmox admettent les memes sources. |
|
|
||||||
| P87 | La frontiere designe chaque locataire par les identites qu'il publie | — | ✅ OK | 2 locataire(s) : la frontiere les designe par les identites qu'ils publient (supernet, administration, tunnel, groupes, routes, sortie). |
|
|
||||||
| P88 | Ce qui entre depuis l'Internet chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : regles WAN, redirections et tunnel disent exactement ce que leur face reseau ouvre a tous. |
|
|
||||||
| P89 | Ce que l'administration atteint chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : par la gestion, le VPN et le tunnel, l'administration atteint exactement ce que leur face reseau lui ouvre. |
|
|
||||||
| P90 | Ce qui sort d'un locataire vers l'Internet, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : la frontiere laisse sortir exactement ce que leur face reseau publie. |
|
|
||||||
| P91 | La fiche du site est deposee a jour, et l'inventaire se genere sans le site | — | ✅ OK | 2 locataire(s) : fiche du site deposee a jour, et inventaire genere sans le site identique a l'octet pres. |
|
|
||||||
| P92 | Chaque locataire a publie sa face reseau a jour | — | ✅ OK | 2 locataire(s) : face reseau publiee, a jour. |
|
|
||||||
| P93 | La face publie, machine par machine, les parametres de clonage de l'inventaire | — | ✅ OK | 26 machine(s) : la face publie exactement les parametres de clonage de l'inventaire. |
|
|
||||||
| P94 | Creer, raser et placer un locataire par sa face visent les memes machines | — | ✅ OK | 2 locataire(s) : creer, raser et placer visent par la face les memes machines que par l'instance montee. |
|
|
||||||
| P95 | Chaque locataire publie la cle qui lira son depot du genome, et sa flotte la connait | — | ✅ OK | 2 locataire(s) : depot et cle de lecture publies, cle connue de leur flotte. |
|
|
||||||
|
|
||||||
## Couverture des affirmations ✅ du registre
|
|
||||||
|
|
||||||
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
|
|
||||||
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
|
|
||||||
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
|
|
||||||
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
|
|
||||||
elles restent hors du harnais recurrent (rien d'executable a rejouer).
|
|
||||||
|
|
||||||
## Declarations d'intention (⚪ invérifiables localement — assumees)
|
|
||||||
|
|
||||||
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
|
|
||||||
comme declarations d'intention, non comme preuves :
|
|
||||||
|
|
||||||
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
|
|
||||||
contre une flotte vivante.
|
|
||||||
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
|
|
||||||
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
|
|
||||||
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
|
|
||||||
|
|
||||||
_Rapport genere le 2026-10-11._
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
# Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main.
|
# Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main.
|
||||||
remote: ssh://git@eregion.chezlepro.ca:2222/Alliance-Boreale/Set-OPS-Public.wiki.git
|
remote: ssh://git@eregion.chezlepro.ca:2222/Alliance-Boreale/Set-OPS-Public.wiki.git
|
||||||
source: e9215a2
|
source: 3fe7e3c
|
||||||
date: 2026-10-07
|
date: 2026-09-29
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ README de rôles). Cette page comble ces deux trous.
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| rôles | 69 | `roles/*/` |
|
| rôles | 69 | `roles/*/` |
|
||||||
| README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
|
| README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
|
||||||
| documents | 47 | `docs/*.md` |
|
| documents | 46 | `docs/*.md` |
|
||||||
| pièces d'audit | 53 | `docs/audit/*` |
|
| pièces d'audit | 51 | `docs/audit/*` |
|
||||||
| unités de wiki | 27 | `wiki/*.md` |
|
| unités de wiki | 27 | `wiki/*.md` |
|
||||||
| décisions en vigueur | 85 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
|
| décisions en vigueur | 85 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
|
||||||
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document |
|
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document |
|
||||||
|
|
@ -36,7 +36,7 @@ README de rôles). Cette page comble ces deux trous.
|
||||||
| Sujet | Documents |
|
| Sujet | Documents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Autorité / gouvernance** | `AGENTS.md` (source d'autorité), `CLAUDE.md`, `docs/MISE-A-JOUR-CODEX-CLAUDE.md` |
|
| **Autorité / gouvernance** | `AGENTS.md` (source d'autorité), `CLAUDE.md`, `docs/MISE-A-JOUR-CODEX-CLAUDE.md` |
|
||||||
| **Le modèle (plan)** | `docs/architecture-set-ops.md` (survol) → `docs/plan-et-generation.md` (à fond) → `docs/meta-classe.md` (concept) → `docs/conception-contextes.md` (site et locataire : deux classes, un contrat) → `docs/depots-prives-conception.md` (chaque runner ne lit que ses dépôts ; chantier) |
|
| **Le modèle (plan)** | `docs/architecture-set-ops.md` (survol) → `docs/plan-et-generation.md` (à fond) → `docs/meta-classe.md` (concept) → `docs/conception-contextes.md` (site et locataire : deux classes, un contrat) |
|
||||||
| **Services, maturité, dette** | `docs/catalogue-services.md` (**la carte de maturité + la cruft y sont déjà**) |
|
| **Services, maturité, dette** | `docs/catalogue-services.md` (**la carte de maturité + la cruft y sont déjà**) |
|
||||||
| **Exploitation / VM** | `docs/vm-lifecycle.md`, `docs/procedure-template-debian13-proxmox.md`, `docs/config-proxmox.md`, `docs/nomenclature-vm.md`, `docs/multi-instances.md` |
|
| **Exploitation / VM** | `docs/vm-lifecycle.md`, `docs/procedure-template-debian13-proxmox.md`, `docs/config-proxmox.md`, `docs/nomenclature-vm.md`, `docs/multi-instances.md` |
|
||||||
| **Conceptions de domaine** | `docs/identite-sso.md`, `docs/courriel-conception.md`, `docs/bindings-conception.md`, `docs/dns-interne.md`, `docs/dimensionnement-ressources.md`, `docs/integrations-vm.md` |
|
| **Conceptions de domaine** | `docs/identite-sso.md`, `docs/courriel-conception.md`, `docs/bindings-conception.md`, `docs/dns-interne.md`, `docs/dimensionnement-ressources.md`, `docs/integrations-vm.md` |
|
||||||
|
|
|
||||||
|
|
@ -184,9 +184,7 @@ les publie désormais (`sources_declarees`). La frontière, en trois temps : les
|
||||||
l'inventaire d'un locataire se génère sans son site, à l'octet près (P91). Le locataire publie sa face
|
l'inventaire d'un locataire se génère sans son site, à l'octet près (P91). Le locataire publie sa face
|
||||||
réseau (P92) ; les comptes de sauvegarde, le DNS public, le pare-feu Proxmox, la frontière et la
|
réseau (P92) ; les comptes de sauvegarde, le DNS public, le pare-feu Proxmox, la frontière et la
|
||||||
découverte des locataires du site la lisent. La matérialisation aussi : la face publie les
|
découverte des locataires du site la lisent. La matérialisation aussi : la face publie les
|
||||||
paramètres de clonage (P93), sauf la clé du runner du site. Le site l'ajoute lui-même en
|
paramètres de clonage (P93) ; `locataire-creer`, `locataire-raser` et `placement-plan TENANT=`
|
||||||
clonant le runner du locataire (`clonage_rendu`, 2026-10-11) : elle ne transite plus par la
|
|
||||||
face, et un locataire peut calculer sa face sans le plan de son site ; `locataire-creer`, `locataire-raser` et `placement-plan TENANT=`
|
|
||||||
nomment leur locataire au lieu de le monter, et visent les mêmes machines, à l'argument près de
|
nomment leur locataire au lieu de le monter, et visent les mêmes machines, à l'argument près de
|
||||||
la ligne `ansible-playbook` (P94, `test_appels_locataire.py`) ; `reconstruire-locataire` les
|
la ligne `ansible-playbook` (P94, `test_appels_locataire.py`) ; `reconstruire-locataire` les
|
||||||
emploie. Reste : la preuve par reconstruction.
|
emploie. Reste : la preuve par reconstruction.
|
||||||
|
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
# Dépôts privés sur la forge du site — conception du chantier
|
|
||||||
|
|
||||||
> **Pour qui :** le mainteneur qui décide de ce chantier puis le conduit, et quiconque se demande
|
|
||||||
> pourquoi un runner ne lit que ses propres dépôts.
|
|
||||||
|
|
||||||
Préparé le 2026-10-11. Décisions D1 à D3 prises le même jour par Daniel : les trois recommandations.
|
|
||||||
|
|
||||||
## 1. Le constat
|
|
||||||
|
|
||||||
Sur la forge du site, l'organisation `genome` porte cinq dépôts, tous **publics** :
|
|
||||||
|
|
||||||
| Dépôt | Contenu | Qui en a besoin |
|
|
||||||
|---|---|---|
|
|
||||||
| `set-ops-public` | le moteur | tous les runners |
|
|
||||||
| `set-ops-modeles` | les modèles génériques | le runner du site |
|
|
||||||
| `site-chezlepro` | la carte de la fabric, le plan du site | le runner du site |
|
|
||||||
| `ops-chezlepro` | le plan, l'inventaire et la face de Chezlepro | son runner, le runner du site |
|
|
||||||
| `ops-technolibre` | le plan, l'inventaire et la face de Technolibre | son runner, le runner du site |
|
|
||||||
|
|
||||||
Mesuré le 2026-10-11 : depuis le runner de Technolibre, `git ls-remote` sans identifiant lit la
|
|
||||||
tête de `ops-chezlepro` (`b4fbd85`) et de `site-chezlepro` (`51dae24`). Chaque locataire peut donc
|
|
||||||
lire le plan de l'autre, et la carte du site. **Aucun n'en a besoin** : chaque runner de locataire
|
|
||||||
ne clone que le moteur et son propre dépôt ; ce que le site lui donne arrive par la fiche déposée
|
|
||||||
dans son dépôt.
|
|
||||||
|
|
||||||
D'où ça vient : le 2026-09-12, à la première reconstruction du site, les dépôts créés privés ont
|
|
||||||
cassé le clonage anonyme des runners. Ils sont passés publics, avec l'argument que seuls ceux
|
|
||||||
qui ont franchi la frontière joignent la forge (`scripts/forge_amorcer.py`). Les autres locataires
|
|
||||||
en font partie.
|
|
||||||
|
|
||||||
## 2. La cible
|
|
||||||
|
|
||||||
- **Publics** : `set-ops-public` et `set-ops-modeles`. Ils sont génériques, identiques pour tous.
|
|
||||||
- **Privés** : `site-chezlepro` et chaque `ops-<locataire>`.
|
|
||||||
- **Le runner du site** lit et pousse tous les dépôts avec sa clé, déjà enregistrée comme clé de
|
|
||||||
déploiement en écriture par `genome_pousser.yml`. Mesuré le 2026-10-11 : il lit les cinq dépôts
|
|
||||||
en SSH, avec une vérification stricte de la clé d'hôte. Rien à créer pour lui.
|
|
||||||
- **Le runner d'un locataire** lit son seul dépôt, avec une clé de déploiement **en lecture
|
|
||||||
seule** : la clé publique de son runner, déjà déclarée au plan (`ssh_baseline_cles_admin`) et
|
|
||||||
dont la privée lui est remise à l'armement depuis sa voûte (`vault_runner_cle_ssh`).
|
|
||||||
- **Aucun secret ne change de main.** Le locataire publie la clé publique de son runner dans sa
|
|
||||||
face réseau ; le site l'enregistre sur le dépôt de ce locataire, avec ses propres
|
|
||||||
identifiants d'administration de la forge (`vault_forgejo_admin`, déjà dans sa voûte).
|
|
||||||
|
|
||||||
### Éprouvé le 2026-10-11 (étape 1)
|
|
||||||
|
|
||||||
Clés de lecture enregistrées par `make forge-cles`, flux posé sur `site-forge-01` et à la
|
|
||||||
frontière. Depuis chaque runner, `git ls-remote` en SSH, clé d'hôte de la forge épinglée (lue
|
|
||||||
sur sa machine par l'accès d'administration) :
|
|
||||||
|
|
||||||
| Depuis | son dépôt | l'autre locataire | `site-chezlepro` | `set-ops-public` |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| runner de Technolibre | lu (`0172dd8`) | refusé | refusé | refusé |
|
|
||||||
| runner de Chezlepro | lu (`74d5567`) | refusé | refusé | refusé |
|
|
||||||
|
|
||||||
Le refus vient de la forge (« Deploy Key … is not authorized to read »), **alors que les dépôts
|
|
||||||
sont encore publics** : une clé de déploiement ne vaut que pour le dépôt qui l'a enregistrée.
|
|
||||||
Conséquence : le moteur, public, continue de se cloner en HTTPS sans identifiant ; seuls les
|
|
||||||
dépôts privés passent par SSH.
|
|
||||||
|
|
||||||
## 3. Le nœud : l'insémination
|
|
||||||
|
|
||||||
Le site amorce le runner d'un locataire **avant** que celui-ci ait reçu sa clé stable (elle
|
|
||||||
arrive à l'armement, geste humain, après). Pendant l'insémination, le rôle `serveur_ops` clone
|
|
||||||
pourtant le dépôt du locataire : avec un dépôt privé, ce clonage échoue. Même chose au premier
|
|
||||||
déploiement du runner du site, dont la clé est neuve.
|
|
||||||
|
|
||||||
**Remède proposé : le colis.** Quand un runner ne peut pas encore lire un dépôt privé, le
|
|
||||||
contrôleur qui joue `serveur_ops` (le runner du site pendant l'insémination, le poste
|
|
||||||
ailleurs) lui apporte ce dépôt en `git bundle`. Le mécanisme existe déjà : `genome_colis.py` porte
|
|
||||||
le génome du poste au runner du site. Le runner clone le colis, puis pointe `origin` vers l'URL
|
|
||||||
SSH de la forge. Ses tirages suivants passent par sa clé, une fois armé.
|
|
||||||
|
|
||||||
## 4. Les décisions à prendre
|
|
||||||
|
|
||||||
**D1 — Comment le runner d'un locataire lit-il son dépôt ?**
|
|
||||||
- *Clé de déploiement en SSH (recommandé).* Le runner continue de tirer lui-même ; il garde son
|
|
||||||
autonomie (émancipation, `make` lancé sur le runner, garde `verifier_genome_a_jour`). Coût : un
|
|
||||||
flux nouveau, du runner de chaque locataire vers la forge du site, en SSH.
|
|
||||||
- *Colis seulement.* Aucun flux nouveau, aucune clé de déploiement pour les locataires : le
|
|
||||||
contrôleur apporte toujours le dépôt. Mais le runner ne tire plus son dépôt lui-même, et sa
|
|
||||||
garde de génome n'a plus de forge à laquelle se comparer.
|
|
||||||
|
|
||||||
**D2 — Sur quelle écoute SSH ?**
|
|
||||||
- *Le `sshd` de la machine, port 22 (recommandé pour commencer).* Le runner du site et le poste
|
|
||||||
l'utilisent déjà pour pousser ; l'utilisateur `git` y est contraint par la forge. Le flux
|
|
||||||
n'ouvre que les adresses des runners de locataires, pas leurs supernets.
|
|
||||||
- *Le serveur SSH intégré de Forgejo, sur un port dédié.* Il n'expose pas le `sshd` de la
|
|
||||||
machine, mais sa cohabitation avec le `sshd` existant reste à éprouver avant de s'y fier.
|
|
||||||
|
|
||||||
**D3 — La preuve.** Ce chantier touche l'insémination : je propose de le prouver par une
|
|
||||||
reconstruction complète de Technolibre (raser, créer, inséminer, armer, monter), dépôts
|
|
||||||
privés, avant de reprendre le lot 3b.
|
|
||||||
|
|
||||||
## 5. Ce qui change dans le code
|
|
||||||
|
|
||||||
État au 2026-10-11 : tout ce tableau est écrit, sauf la bascule elle-même. La clé d'hôte de
|
|
||||||
la forge est déclarée dans le plan du site (`forge_ssh_cle_hote`), offerte par la fiche
|
|
||||||
(`serveur_ops_forge_cle_hote`) et copiée par chaque locataire. Le moteur reste cloné en HTTPS.
|
|
||||||
|
|
||||||
| Pièce | Changement |
|
|
||||||
|---|---|
|
|
||||||
| `serveur_ops_depots` | un drapeau `prive: true` sur les dépôts `instance`, `tenant` et `hebergeur` |
|
|
||||||
| `roles/serveur_ops` | URL SSH et clé du runner pour les dépôts privés ; colis du contrôleur quand le runner ne peut pas encore les lire ; clé d'hôte de la forge épinglée |
|
|
||||||
| `contexte.py` (face) | la face publie la clé publique de lecture du runner (`genome.cle_lecture`) |
|
|
||||||
| `contexte.py` (fiche) | la fiche du site publie la clé d'hôte SSH de sa forge, que le runner du locataire épingle |
|
|
||||||
| nouvelle cible `forge-cles` | le site enregistre, d'après les faces, la clé de lecture de chaque locataire sur son dépôt, et retire celles qui ne sont plus publiées |
|
|
||||||
| `forge_amorcer.py` | crée les dépôts privés comme privés |
|
|
||||||
| flux | `serveur_forge_site` : entrée SSH depuis les runners des locataires hébergés (paire nouvelle, résolue depuis les faces) ; `serveur_ops` : sortie correspondante |
|
|
||||||
| `genome_etat.yml` | lit la forge par SSH ou authentifié : l'API anonyme répondrait 404 pour un dépôt privé |
|
|
||||||
| `sonde-genome-servi` | ne compte plus que les dépôts publics (au moins le moteur) : c'est ce qu'un écosystème neuf clone sans rien détenir ; les privés sont suivis par `genome-etat`, `forge-cles` et les runners |
|
|
||||||
|
|
||||||
Inchangés : `sonde-genome-a-jour` (son repère ne compare que `set-ops-public`), le poste (il
|
|
||||||
pousse déjà en SSH), la console (`etat_forge` lit `origin`, qui deviendra l'URL SSH).
|
|
||||||
|
|
||||||
## 6. Les preuves à écrire
|
|
||||||
|
|
||||||
- **Hors ligne (`make prouver`)** : chaque dépôt `instance`, `tenant` ou `hebergeur` est déclaré
|
|
||||||
privé ; chaque face publie une clé de lecture, et cette clé est dans `ssh_baseline_cles_admin`.
|
|
||||||
- **Sur le terrain (`make forge-etancheite`)**, avec témoins :
|
|
||||||
- sans identifiant, un dépôt privé refuse et un dépôt public répond ;
|
|
||||||
- le runner d'un locataire lit son dépôt et **ne lit pas** celui de l'autre ;
|
|
||||||
- les clés de déploiement de chaque dépôt sont exactement : runner du site en écriture, runner
|
|
||||||
du locataire en lecture.
|
|
||||||
- **Par reconstruction** : D3.
|
|
||||||
|
|
||||||
## 7. L'ordre de bascule
|
|
||||||
|
|
||||||
**Fait le 2026-10-11, étapes 1 à 5.** Bascule par `make forge-cles CONFIRMER=true`, mesurée
|
|
||||||
ETANCHE par `make forge-etancheite`. Preuve par reconstruction de Technolibre (§D3) : le runner
|
|
||||||
neuf a reçu son dépôt en colis à l'insémination (il n'avait pas encore de clé), l'a tiré en SSH
|
|
||||||
une fois armé, et la flotte est remontée avec ses témoins d'état intacts. La reconstruction a
|
|
||||||
d'abord révélé un dossier SSH manquant sur une machine neuve (corrigé, `3fc16c8`).
|
|
||||||
|
|
||||||
Chaque étape se vérifie avant la suivante, et la bascule se défait d'un appel par dépôt
|
|
||||||
(repasser public).
|
|
||||||
|
|
||||||
1. **Éprouver** sur la forge du site, sans rien basculer : enregistrer la clé de lecture du
|
|
||||||
runner de Technolibre sur `ops-technolibre`, ouvrir le flux pour ce seul runner, et mesurer
|
|
||||||
qu'il lit son dépôt en SSH.
|
|
||||||
2. **Construire** le code et les preuves (§5, §6) ; `make verifier`.
|
|
||||||
3. **Déployer sans basculer** : flux et frontière (`make frontiere-appliquer CONFIRMER=true`,
|
|
||||||
lancé par Daniel), clés de déploiement, remotes SSH sur les trois runners. Les dépôts sont
|
|
||||||
encore publics : chaque runner doit déjà tirer en SSH.
|
|
||||||
4. **Basculer** `site-chezlepro`, `ops-chezlepro` et `ops-technolibre` en privé. Mesurer avec
|
|
||||||
`make forge-etancheite`, puis la veille des trois consoles.
|
|
||||||
5. **Prouver** par la reconstruction de Technolibre (D3).
|
|
||||||
6. Reprendre le lot 3b : les demandes vivent alors dans le dépôt de chaque locataire, lisibles
|
|
||||||
par lui et par le site seulement.
|
|
||||||
|
|
@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met
|
||||||
|
|
||||||
## Le trou qu'il comble
|
## Le trou qu'il comble
|
||||||
|
|
||||||
`scripts/prouver.py` porte 95 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
|
`scripts/prouver.py` porte 94 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
|
||||||
dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est
|
dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est
|
||||||
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
|
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
|
||||||
propriétaire, que rien n'est codé en dur.
|
propriétaire, que rien n'est codé en dur.
|
||||||
|
|
|
||||||
|
|
@ -66,31 +66,6 @@ Un **résolveur** (miroir de `instancier`) agrège, par serveur, les `flux.yml`
|
||||||
- **le registre d'audit** : `docs/registre-flux.md` (généré) + une cible `make flux`.
|
- **le registre d'audit** : `docs/registre-flux.md` (généré) + une cible `make flux`.
|
||||||
|
|
||||||
## Activation prudente
|
## Activation prudente
|
||||||
|
|
||||||
### Descriptions conservées par les pare-feux
|
|
||||||
|
|
||||||
La `raison` non vide du flux et son rôle alimentent les champs natifs : `comment`
|
|
||||||
pour nftables et Proxmox, `description` pour le filtrage OPNsense (`descr` pour ses
|
|
||||||
redirections). Un commentaire de fichier `# ...` ne survit pas au chargement nftables.
|
|
||||||
Les règles communes (administration, connexions établies, refus) portent aussi leur motif.
|
|
||||||
|
|
||||||
Les libellés sont sur une ligne. Le commentaire nftables est borné à 127 octets UTF-8 ;
|
|
||||||
OPNsense garde sa clé `setops:...` complète et borne le texte qui suit pour rester dans
|
|
||||||
255 octets. Une raison abrégée reste disponible en entier dans le registre des flux.
|
|
||||||
Le commentaire Proxmox conserve aussi le pair nommé dans le devis.
|
|
||||||
|
|
||||||
Une correction de description apparaît dans `make proxmox-fw-plan` et
|
|
||||||
`make frontiere-plan`. Son application met à jour le champ sur la règle existante,
|
|
||||||
puis le relit : elle ne recrée pas la règle et ne modifie ni son activation ni sa
|
|
||||||
politique. Les confirmations habituelles restent requises pour appliquer ces devis.
|
|
||||||
`make test` vérifie notamment cette convergence et les limites des champs.
|
|
||||||
|
|
||||||
Références des champs natifs : [nftables](https://netfilter.org/projects/nftables/manpage.html),
|
|
||||||
[API Proxmox](https://github.com/proxmox/pve-firewall/blob/master/src/PVE/API2/Firewall/Rules.pm),
|
|
||||||
[descriptions OPNsense](https://github.com/opnsense/core/blob/master/src/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/DescriptionField.php).
|
|
||||||
|
|
||||||
### Activer le filtrage
|
|
||||||
|
|
||||||
Activer nftables = **action destructive** (peut couper l'accès) → confirmation explicite +
|
Activer nftables = **action destructive** (peut couper l'accès) → confirmation explicite +
|
||||||
déploiement graduel (garder l'accès SSH/Ansible, tester par nœud).
|
déploiement graduel (garder l'accès SSH/Ansible, tester par nœud).
|
||||||
|
|
||||||
|
|
@ -144,7 +119,7 @@ Le registre confondait deux situations :
|
||||||
|
|
||||||
| Situation | Exemple | `partage` |
|
| Situation | Exemple | `partage` |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| le rôle **ouvre** l'écoute | `serveur_dovecot` lie 24 (LMTP réseau) | absent |
|
| le rôle **ouvre** l'écoute | `serveur_dovecot` lie 12345 (SASL réseau) | absent |
|
||||||
| le rôle **décrit** celle d'un autre | `serveur_backup` emprunte le sshd de `serveur_debian` | `true` |
|
| le rôle **décrit** celle d'un autre | `serveur_backup` emprunte le sshd de `serveur_debian` | `true` |
|
||||||
|
|
||||||
Sans cette distinction, la seule co-location légitime de la flotte — `tcp/22` sur le
|
Sans cette distinction, la seule co-location légitime de la flotte — `tcp/22` sur le
|
||||||
|
|
@ -155,3 +130,4 @@ ignorée, ce qui est pire que de ne pas l'avoir.
|
||||||
**Corollaire à retenir** : un port qu'on **subit** (le défaut amont d'un logiciel) doit être
|
**Corollaire à retenir** : un port qu'on **subit** (le défaut amont d'un logiciel) doit être
|
||||||
imposé et déclaré comme les autres. Celui d'Alloy ne l'était pas, et c'est la seule raison
|
imposé et déclaré comme les autres. Celui d'Alloy ne l'était pas, et c'est la seule raison
|
||||||
pour laquelle la collision a pu durer des semaines.
|
pour laquelle la collision a pu durer des semaines.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -430,12 +430,3 @@ 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.
|
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
|
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.
|
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.
|
|
||||||
|
|
|
||||||
|
|
@ -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_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` | 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 | 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), 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_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_collabora` | ingress | 9980 | tcp | edge | clair | Éditeur servi au navigateur via l'edge (WebSocket WOPI ; TLS terminé à l'edge). |
|
| `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_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). |
|
| `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` | 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 | 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 | 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 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 | 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 | 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 | 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 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 | 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_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 | 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. |
|
| `serveur_dns_public` | ingress | 53 | tcp | voisins_site | clair | Repli TCP des notifications des primaires des locataires. |
|
||||||
|
|
@ -43,8 +43,8 @@
|
||||||
| `serveur_dns_public` | egress | 5300 | udp | primaires_dns_locataires | clair | SOA demande au primaire avant chaque transfert : c'est lui qui dit si la zone a change. Sans lui, le secondaire garde sa premiere copie pour toujours. |
|
| `serveur_dns_public` | egress | 5300 | udp | primaires_dns_locataires | clair | SOA demande au primaire avant chaque transfert : c'est lui qui dit si la zone a change. Sans lui, le secondaire garde sa premiere copie pour toujours. |
|
||||||
| `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). |
|
| `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). |
|
||||||
| `serveur_dovecot` | ingress | 993 | tcp | externe | tls-requis | Accès courriel des utilisateurs (IMAPS). Frontière publique gérée à l'OPNsense. |
|
| `serveur_dovecot` | ingress | 993 | tcp | externe | tls-requis | Accès courriel des utilisateurs (IMAPS). Frontière publique gérée à l'OPNsense. |
|
||||||
|
| `serveur_dovecot` | ingress | 12345 | tcp | serveur_postfix | tls | Authentification SASL déléguée : Postfix valide les identifiants de soumission contre Dovecot. |
|
||||||
| `serveur_dovecot` | egress | 636 | tcp | serveur_openldap | tls-requis | userdb/passdb : Dovecot résout et authentifie les comptes sur l'annuaire (LDAPS). |
|
| `serveur_dovecot` | egress | 636 | tcp | serveur_openldap | tls-requis | userdb/passdb : Dovecot résout et authentifie les comptes sur l'annuaire (LDAPS). |
|
||||||
| `serveur_forge_site` | ingress | 22 | tcp | runners_locataires | ssh | Chaque runner de locataire lit son propre depot du genome, prive, avec sa cle de deploiement en lecture seule. Il ne lit ni le depot d'un autre locataire, ni celui du site. |
|
|
||||||
| `serveur_forge_site` | ingress | 443 | tcp | voisins_site | tls-requis | Servir le génome aux écosystèmes de ce site : c'est de cette forge qu'ils clonent leur moteur, leurs modèles et la carte de la fabric (D-81). Sans ce flux, un écosystème neuf ne peut pas se reproduire. |
|
| `serveur_forge_site` | ingress | 443 | tcp | voisins_site | tls-requis | Servir le génome aux écosystèmes de ce site : c'est de cette forge qu'ils clonent leur moteur, leurs modèles et la carte de la fabric (D-81). Sans ce flux, un écosystème neuf ne peut pas se reproduire. |
|
||||||
| `serveur_forgejo` | ingress | 3000 | tcp | edge | clair | Interface web + Git HTTP derrière un edge : le nginx termine le TLS et parle en clair à la forge. C'est le cas de tout tenant. |
|
| `serveur_forgejo` | ingress | 3000 | tcp | edge | clair | Interface web + Git HTTP derrière un edge : le nginx termine le TLS et parle en clair à la forge. C'est le cas de tout tenant. |
|
||||||
| `serveur_forgejo` | ingress | derive | tcp | flotte, admin | tls | Sans edge devant elle — la forge du SITE — elle sert son propre TLS sur le port du schéma (443), avec le certificat de la machine. `derive` parce que le port vient de `serveur_forgejo_http_port` : écrire 3000 en dur ici serait faux pour elle, et rien ne le signalerait puisque ce flux ne traverse pas la frontière. |
|
| `serveur_forgejo` | ingress | derive | tcp | flotte, admin | tls | Sans edge devant elle — la forge du SITE — elle sert son propre TLS sur le port du schéma (443), avec le certificat de la machine. `derive` parce que le port vient de `serveur_forgejo_http_port` : écrire 3000 en dur ici serait faux pour elle, et rien ne le signalerait puisque ce flux ne traverse pas la frontière. |
|
||||||
|
|
@ -66,7 +66,7 @@
|
||||||
| `serveur_keycloak` | ingress | 8080 | tcp | edge | clair | Console et endpoints OIDC servis au navigateur et aux applications via l'edge (TLS terminé à l'edge). |
|
| `serveur_keycloak` | ingress | 8080 | tcp | edge | clair | Console et endpoints OIDC servis au navigateur et aux applications via l'edge (TLS terminé à l'edge). |
|
||||||
| `serveur_keycloak` | egress | 636 | tcp | serveur_openldap | tls-requis | Fédération de l'annuaire OpenLDAP (LDAPS). |
|
| `serveur_keycloak` | egress | 636 | tcp | serveur_openldap | tls-requis | Fédération de l'annuaire OpenLDAP (LDAPS). |
|
||||||
| `serveur_keycloak` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Persistance Keycloak dans PostgreSQL (verify-full). |
|
| `serveur_keycloak` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Persistance Keycloak dans PostgreSQL (verify-full). |
|
||||||
| `serveur_loki` | ingress | 1514 | tcp | fabric | tls-requis | Journaux de la frontiere, en TLS mutuel. Elle n'accueille aucun agent et n'emet que du syslog ; sans ce flux, le seul equipement qui voit passer TOUT le trafic n'ecrit nulle part. |
|
| `serveur_loki` | ingress | 1514 | tcp | fabric | clair | Journaux de la frontiere. Elle n'accueille aucun agent et n'emet que du syslog ; sans ce flux, le seul equipement qui voit passer TOUT le trafic n'ecrit nulle part. |
|
||||||
| `serveur_loki` | ingress | 3100 | tcp | client_journal | tls | Réception des journaux poussés par Alloy (client_journal) en HTTPS (http_tls_config, cert step-ca). |
|
| `serveur_loki` | ingress | 3100 | tcp | client_journal | tls | Réception des journaux poussés par Alloy (client_journal) en HTTPS (http_tls_config, cert step-ca). |
|
||||||
| `serveur_loki` | ingress | 3100 | tcp | localhost | clair | Requêtes de Grafana co-localisé (datasource Loki en localhost). |
|
| `serveur_loki` | ingress | 3100 | tcp | localhost | clair | Requêtes de Grafana co-localisé (datasource Loki en localhost). |
|
||||||
| `serveur_loki` | ingress | 3100 | tcp | fabric | tls | Journaux des hyperviseurs. La machine qui PORTE les VM ecrivait ses journaux nulle part ailleurs que sur son propre disque — invisibles le jour ou c'est elle qui casse. |
|
| `serveur_loki` | ingress | 3100 | tcp | fabric | tls | Journaux des hyperviseurs. La machine qui PORTE les VM ecrivait ses journaux nulle part ailleurs que sur son propre disque — invisibles le jour ou c'est elle qui casse. |
|
||||||
|
|
@ -89,7 +89,6 @@
|
||||||
| `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 | 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 | 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 | 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_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_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). |
|
| `serveur_postfix` | ingress | 25 | tcp | externe, client_smtp | starttls | SMTP entrant : courrier externe (MX) et notifications internes (client_smtp). |
|
||||||
|
|
@ -97,10 +96,11 @@
|
||||||
| `serveur_postfix` | ingress | 587 | tcp | flotte, externe | starttls | Soumission authentifiée (STARTTLS obligatoire) : agents internes et clients de courriel des utilisateurs. |
|
| `serveur_postfix` | ingress | 587 | tcp | flotte, externe | starttls | Soumission authentifiée (STARTTLS obligatoire) : agents internes et clients de courriel des utilisateurs. |
|
||||||
| `serveur_postfix` | egress | 24 | tcp | serveur_dovecot | tls-requis | Remise finale par LMTP au mailstore (Dovecot), en TLS vérifié. |
|
| `serveur_postfix` | egress | 24 | tcp | serveur_dovecot | tls-requis | Remise finale par LMTP au mailstore (Dovecot), en TLS vérifié. |
|
||||||
| `serveur_postfix` | egress | 25 | tcp | externe | starttls | Relais sortant vers les MX distants (STARTTLS opportuniste). |
|
| `serveur_postfix` | egress | 25 | tcp | externe | starttls | Relais sortant vers les MX distants (STARTTLS opportuniste). |
|
||||||
| `serveur_postfix` | egress | 636 | tcp | serveur_openldap | tls-requis | Tables virtuelles (domaines/alias/boîtes) résolues sur l'annuaire, et mots de passe de soumission vérifiés par le Dovecot local (LDAPS). |
|
| `serveur_postfix` | egress | 636 | tcp | serveur_openldap | tls-requis | Tables virtuelles (domaines/alias/boîtes) résolues sur l'annuaire (LDAPS). |
|
||||||
| `serveur_postfix` | egress | 11332 | tcp | localhost | clair | Filtre milter rspamd co-localisé (antispam + signature DKIM). |
|
| `serveur_postfix` | egress | 11332 | tcp | localhost | clair | Filtre milter rspamd co-localisé (antispam + signature DKIM). |
|
||||||
|
| `serveur_postfix` | egress | 12345 | tcp | serveur_dovecot | tls | Validation SASL des identifiants de soumission contre Dovecot. |
|
||||||
| `serveur_postgresql` | ingress | 5432 | tcp | serveur_keycloak, serveur_forgejo, serveur_icinga, serveur_nextcloud | tls-requis | Connexions applicatives à PostgreSQL (verify-full ; pg_hba hostssl). |
|
| `serveur_postgresql` | ingress | 5432 | tcp | serveur_keycloak, serveur_forgejo, serveur_icinga, serveur_nextcloud | tls-requis | Connexions applicatives à PostgreSQL (verify-full ; pg_hba hostssl). |
|
||||||
| `serveur_postgresql` | ingress | 9187 | tcp | serveur_prometheus | tls | Metriques PostgreSQL lues par l'observatoire, en HTTPS verifie. Series, pas verdicts : ce qui derive lentement — cache qui decroche, connexions qui montent, bases qui grossissent — n'a que le graphe pour se faire voir. |
|
| `serveur_postgresql` | ingress | 9187 | tcp | serveur_prometheus | clair | Metriques PostgreSQL lues par l'observatoire. Series, pas verdicts : ce qui derive lentement — cache qui decroche, connexions qui montent, bases qui grossissent — n'a que le graphe pour se faire voir. En clair pour l'instant, contrairement a `client_metrique` : dette inscrite, a fermer par `--web.config.file` + `client_pki`. |
|
||||||
| `serveur_powerdns` | ingress | 5300 | tcp | dns_public_site | clair | AXFR signe TSIG par le serveur DNS public du site, vers l'instance publique uniquement. |
|
| `serveur_powerdns` | ingress | 5300 | tcp | dns_public_site | clair | AXFR signe TSIG par le serveur DNS public du site, vers l'instance publique uniquement. |
|
||||||
| `serveur_powerdns` | ingress | 5300 | udp | dns_public_site | clair | Interrogation du SOA par le secondaire du site avant chaque transfert. |
|
| `serveur_powerdns` | ingress | 5300 | udp | dns_public_site | clair | Interrogation du SOA par le secondaire du site avant chaque transfert. |
|
||||||
| `serveur_powerdns` | ingress | derive | udp | flotte | clair | Zone souveraine. 53 seul sur son hôte, 5300 sur la loopback derrière le résolveur. |
|
| `serveur_powerdns` | ingress | derive | udp | flotte | clair | Zone souveraine. 53 seul sur son hôte, 5300 sur la loopback derrière le résolveur. |
|
||||||
|
|
@ -109,7 +109,7 @@
|
||||||
| `serveur_powerdns` | egress | 53 | tcp | externe | clair | Repli TCP de la résolution sortante du serveur autoritatif (réponses dépassant la taille UDP). |
|
| `serveur_powerdns` | egress | 53 | tcp | externe | clair | Repli TCP de la résolution sortante du serveur autoritatif (réponses dépassant la taille UDP). |
|
||||||
| `serveur_prometheus` | ingress | 9090 | tcp | localhost | clair | Console Prometheus consommée en local par Grafana co-localisé (pas d'exposition inter-nœud). |
|
| `serveur_prometheus` | ingress | 9090 | tcp | localhost | clair | Console Prometheus consommée en local par Grafana co-localisé (pas d'exposition inter-nœud). |
|
||||||
| `serveur_prometheus` | egress | 9100 | tcp | client_metrique | tls | Scrape des node_exporter (HTTPS via cert step-ca) sur chaque nœud instrumenté. |
|
| `serveur_prometheus` | egress | 9100 | tcp | client_metrique | tls | Scrape des node_exporter (HTTPS via cert step-ca) sur chaque nœud instrumenté. |
|
||||||
| `serveur_prometheus` | egress | 9100 | tcp | frontiere | tls | Scrape de l'exportateur de la frontiere (os-node_exporter), sur sa seule patte de supervision : l'equipement qui voit passer TOUT le trafic n'avait ni temperature ni charge dans la supervision. |
|
| `serveur_prometheus` | egress | 9100 | tcp | frontiere | clair | Scrape de l'exportateur de la frontiere (os-node_exporter), sur sa seule patte de supervision : l'equipement qui voit passer TOUT le trafic n'avait ni temperature ni charge dans la supervision. |
|
||||||
| `serveur_prometheus` | egress | 9100 | tcp | fabric | clair | Scrape des hyperviseurs : la machine qui PORTE les VM etait invisible de la supervision qui les surveille. Elle ne peut pas pousser (route par defaut gelee, D-57), donc on la tire. |
|
| `serveur_prometheus` | egress | 9100 | tcp | fabric | clair | Scrape des hyperviseurs : la machine qui PORTE les VM etait invisible de la supervision qui les surveille. Elle ne peut pas pousser (route par defaut gelee, D-57), donc on la tire. |
|
||||||
| `serveur_redis` | ingress | 6379 | tcp | localhost | clair | Cache/verrous consommés uniquement par l'application co-localisée (ex. Nextcloud). Aucune exposition inter-nœud. |
|
| `serveur_redis` | ingress | 6379 | tcp | localhost | clair | Cache/verrous consommés uniquement par l'application co-localisée (ex. Nextcloud). Aucune exposition inter-nœud. |
|
||||||
| `serveur_resolveur` | ingress | 53 | udp | flotte | clair | Toute la flotte du tenant résout ici — et nulle part ailleurs. |
|
| `serveur_resolveur` | ingress | 53 | udp | flotte | clair | Toute la flotte du tenant résout ici — et nulle part ailleurs. |
|
||||||
|
|
@ -119,8 +119,6 @@
|
||||||
| `serveur_resolveur_site` | ingress | 53 | tcp | voisins_site | clair | Réponses longues et bascule TCP, obligatoires en DNS. |
|
| `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 | 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` | 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_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` | 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. |
|
| `serveur_web_dorsal` | egress | 443 | tcp | flotte | tls | git clone/pull du depot de chaque app (Forgejo souverain) au deploiement. |
|
||||||
|
|
@ -130,10 +128,10 @@
|
||||||
|
|
||||||
## Synthèse chiffrement
|
## Synthèse chiffrement
|
||||||
|
|
||||||
- **clair** : 49 flux
|
- **clair** : 51 flux
|
||||||
- **n-a** : 8 flux
|
- **n-a** : 8 flux
|
||||||
- **ssh** : 9 flux
|
- **ssh** : 8 flux
|
||||||
- **starttls** : 6 flux
|
- **starttls** : 6 flux
|
||||||
- **tls** : 10 flux
|
- **tls** : 10 flux
|
||||||
- **tls-cible** : 2 flux
|
- **tls-cible** : 2 flux
|
||||||
- **tls-requis** : 37 flux
|
- **tls-requis** : 34 flux
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ Nommer un point ouvert vaut mieux qu'une ligne rassurante sans mécanisme derri
|
||||||
Aucune de ces responsabilités n'est laissée à la parole :
|
Aucune de ces responsabilités n'est laissée à la parole :
|
||||||
|
|
||||||
```
|
```
|
||||||
make prouver le dépôt est-il cohérent avec lui-même (95 preuves, zéro réseau)
|
make prouver le dépôt est-il cohérent avec lui-même (94 preuves, zéro réseau)
|
||||||
make remise-verifier le second temps de la remise est-il fait, ou en retard ? (P80)
|
make remise-verifier le second temps de la remise est-il fait, ou en retard ? (P80)
|
||||||
make certificats-plan ce que le disque porte contre ce que la mémoire sert
|
make certificats-plan ce que le disque porte contre ce que la mémoire sert
|
||||||
make expositions-plan chaque service publié répond-il, et depuis où
|
make expositions-plan chaque service publié répond-il, et depuis où
|
||||||
|
|
|
||||||
|
|
@ -219,26 +219,6 @@ runbooks:
|
||||||
Deposer chez chaque locataire la fiche que le site lui destine. Son runner n'a pas
|
Deposer chez chaque locataire la fiche que le site lui destine. Son runner n'a pas
|
||||||
le depot du site : c'est par elle que son inventaire se genere sans lui. A commiter
|
le depot du site : c'est par elle que son inventaire se genere sans lui. A commiter
|
||||||
dans le depot de chaque locataire.
|
dans le depot de chaque locataire.
|
||||||
- cible: forge-cles
|
|
||||||
nature: ecriture
|
|
||||||
fixes: {CONFIRMER: "true"}
|
|
||||||
pourquoi: >-
|
|
||||||
Enregistrer sur le depot de chaque locataire la cle de lecture que sa face publie, et
|
|
||||||
retirer celle qui ne l'est plus. Sans elle, son runner ne lit plus son depot prive.
|
|
||||||
- cible: forge-comptes
|
|
||||||
nature: ecriture
|
|
||||||
fixes: {CONFIRMER: "true"}
|
|
||||||
pourquoi: >-
|
|
||||||
Un compte par mainteneur declare, ouvert sur les seuls depots de ses locataires :
|
|
||||||
c'est par un ticket qu'il demande au site. Et le lecteur de la console du site, qui
|
|
||||||
met ces demandes dans « A traiter ».
|
|
||||||
- cible: forge-etancheite
|
|
||||||
nature: mesure
|
|
||||||
portee: poste
|
|
||||||
pourquoi: >-
|
|
||||||
Sans identifiant, chaque depot prive exige une authentification et le moteur
|
|
||||||
repond ; chaque runner de locataire lit son depot et pas celui d'un autre. Du
|
|
||||||
poste, qui voit le site et les runners.
|
|
||||||
- cible: site-verifier
|
- cible: site-verifier
|
||||||
nature: mesure
|
nature: mesure
|
||||||
pourquoi: "Le playbook du site correspond-il encore aux couches declarees ?"
|
pourquoi: "Le playbook du site correspond-il encore aux couches declarees ?"
|
||||||
|
|
@ -256,19 +236,6 @@ runbooks:
|
||||||
Cloner les VM d'un locataire NOMME, d'apres la face reseau qu'il publie : le
|
Cloner les VM d'un locataire NOMME, d'apres la face reseau qu'il publie : le
|
||||||
runner du site materialise sans monter son depot. Memes machines, memes
|
runner du site materialise sans monter son depot. Memes machines, memes
|
||||||
parametres que `flotte-creer` sur l'instance montee (P93, P94).
|
parametres que `flotte-creer` sur l'instance montee (P93, P94).
|
||||||
# UNE SEULE MACHINE, A LA DEMANDE D'UN MAINTENEUR (2026-10-11). `locataire-creer`
|
|
||||||
# clone TOUTES les VM manquantes ; une demande de locataire en vise une. Les
|
|
||||||
# parametres viennent de la meme face reseau (`clonage`), par le meme chemin
|
|
||||||
# (`PARAMS_CLONAGE`). Sans `TENANT`, `creer-vm` exige une instance montee — que le
|
|
||||||
# site n'a pas : l'oubli se refuse, il ne cree rien de travers.
|
|
||||||
- cible: creer-vm
|
|
||||||
nature: ecriture
|
|
||||||
variables: [TENANT, HOTE]
|
|
||||||
duree: "~4 min 30"
|
|
||||||
pourquoi: >-
|
|
||||||
Cloner UNE machine d'un locataire nomme, d'apres sa face reseau publiee. Elle
|
|
||||||
n'existe pour le site que si son mainteneur l'a mise au plan, a applique
|
|
||||||
l'inventaire, puis a republie sa face.
|
|
||||||
- cible: locataire-raser
|
- cible: locataire-raser
|
||||||
nature: destructif
|
nature: destructif
|
||||||
variables: [TENANT, INSTANCE]
|
variables: [TENANT, INSTANCE]
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
---
|
|
||||||
# LE SITE ENREGISTRE LA CLE DE LECTURE DE CHAQUE LOCATAIRE SUR SON DEPOT (2026-10-11).
|
|
||||||
#
|
|
||||||
# Voir `docs/depots-prives-conception.md` et `scripts/forge_cles.py`. Le script tourne SUR le
|
|
||||||
# runner du site : il y lit les faces que le site tire (et rien d'autre de ses locataires), et
|
|
||||||
# il y joint la forge. Le mot de passe d'administration vient de la voute du site, par
|
|
||||||
# l'environnement, et n'est jamais affiche.
|
|
||||||
#
|
|
||||||
# make forge-cles montre ce qui serait fait
|
|
||||||
# make forge-cles CONFIRMER=true l'ecrit, puis relit
|
|
||||||
- name: Les cles de lecture des locataires, sur la forge du site
|
|
||||||
hosts: serveur_ops_site
|
|
||||||
gather_facts: false
|
|
||||||
become: true
|
|
||||||
become_user: "{{ serveur_ops_utilisateur | default('setops') }}"
|
|
||||||
vars:
|
|
||||||
forge_cles_racine: "{{ serveur_ops_racine | default('/opt/setops') }}"
|
|
||||||
forge_cles_moteur: "{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | first).dest }}"
|
|
||||||
forge_cles_confirmer: "{{ lookup('env', 'CONFIRMER') == 'true' }}"
|
|
||||||
tasks:
|
|
||||||
- name: Exiger le mot de passe d'administration de la forge (voute du site)
|
|
||||||
ansible.builtin.assert:
|
|
||||||
that: vault_forgejo_admin | default('') | length > 0
|
|
||||||
fail_msg: "vault_forgejo_admin absent : la voute du site n'est pas chargee."
|
|
||||||
no_log: true
|
|
||||||
|
|
||||||
- name: Confronter les faces aux cles de la forge
|
|
||||||
ansible.builtin.command:
|
|
||||||
argv: "{{ ['python3', 'scripts/forge_cles.py']
|
|
||||||
+ (['--confirmer'] if (forge_cles_confirmer | bool) else []) }}"
|
|
||||||
chdir: "{{ forge_cles_racine }}/{{ forge_cles_moteur }}"
|
|
||||||
environment:
|
|
||||||
FORGEJO_ADMIN: "{{ vault_forgejo_admin }}"
|
|
||||||
PATH: "{{ forge_cles_racine }}/venv/bin:/usr/local/bin:/usr/bin:/bin"
|
|
||||||
# En simulation, le script ne fait que montrer (sans --confirmer) : il peut tourner.
|
|
||||||
check_mode: false
|
|
||||||
register: forge_cles
|
|
||||||
changed_when: "'ajoutee' in forge_cles.stdout or 'retiree' in forge_cles.stdout or 'rendu ' in forge_cles.stdout"
|
|
||||||
failed_when: forge_cles.rc not in [0]
|
|
||||||
|
|
||||||
- name: Ce que la forge porte
|
|
||||||
ansible.builtin.debug:
|
|
||||||
msg: "{{ forge_cles.stdout_lines }}"
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
---
|
|
||||||
# LES COMPTES DE LA FORGE DU SITE (2026-10-11, lot 3b).
|
|
||||||
#
|
|
||||||
# Voir `docs/depots-prives-conception.md` et `scripts/forge_comptes.py`. Le script tourne SUR le
|
|
||||||
# runner du site : il y lit le plan du site (`mainteneurs`) et les faces de ses locataires, et
|
|
||||||
# il y joint la forge. Le mot de passe d'administration vient de la voute du site, par
|
|
||||||
# l'environnement, et n'est jamais affiche.
|
|
||||||
#
|
|
||||||
# make forge-comptes montre ce qui serait fait
|
|
||||||
# make forge-comptes CONFIRMER=true l'ecrit, puis relit
|
|
||||||
- name: Les comptes de la forge du site
|
|
||||||
hosts: serveur_ops_site
|
|
||||||
gather_facts: false
|
|
||||||
become: true
|
|
||||||
become_user: "{{ serveur_ops_utilisateur | default('setops') }}"
|
|
||||||
vars:
|
|
||||||
forge_comptes_racine: "{{ serveur_ops_racine | default('/opt/setops') }}"
|
|
||||||
forge_comptes_moteur: "{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | first).dest }}"
|
|
||||||
forge_comptes_confirmer: "{{ lookup('env', 'CONFIRMER') == 'true' }}"
|
|
||||||
tasks:
|
|
||||||
- name: Exiger le mot de passe d'administration de la forge (voute du site)
|
|
||||||
ansible.builtin.assert:
|
|
||||||
that: vault_forgejo_admin | default('') | length > 0
|
|
||||||
fail_msg: "vault_forgejo_admin absent : la voute du site n'est pas chargee."
|
|
||||||
no_log: true
|
|
||||||
|
|
||||||
- name: Confronter le plan du site aux comptes de la forge
|
|
||||||
ansible.builtin.command:
|
|
||||||
argv: "{{ ['python3', 'scripts/forge_comptes.py']
|
|
||||||
+ (['--confirmer'] if (forge_comptes_confirmer | bool) else []) }}"
|
|
||||||
chdir: "{{ forge_comptes_racine }}/{{ forge_comptes_moteur }}"
|
|
||||||
environment:
|
|
||||||
FORGEJO_ADMIN: "{{ vault_forgejo_admin }}"
|
|
||||||
PATH: "{{ forge_comptes_racine }}/venv/bin:/usr/local/bin:/usr/bin:/bin"
|
|
||||||
# En simulation, le script ne fait que montrer (sans --confirmer) : il peut tourner.
|
|
||||||
check_mode: false
|
|
||||||
register: forge_comptes
|
|
||||||
# LES LIGNES D'ACTION, PAS DES MOTS : « jeton a creer », dans le plan, contient « jeton »
|
|
||||||
# et faisait compter un changement alors que rien n'etait ecrit (2026-10-11).
|
|
||||||
changed_when: forge_comptes.stdout is search('(?m)^ (cree|collab|retire|equipe|jeton) ')
|
|
||||||
# 1 = un ecart DIT (un courriel a remplir, par exemple) : il s'affiche, il n'empeche pas
|
|
||||||
# le reste. 2 = le script a refuse de travailler.
|
|
||||||
failed_when: forge_comptes.rc not in [0, 1]
|
|
||||||
|
|
||||||
- name: Ce que la forge porte
|
|
||||||
ansible.builtin.debug:
|
|
||||||
msg: "{{ forge_comptes.stdout_lines }}"
|
|
||||||
|
|
@ -61,11 +61,6 @@
|
||||||
url: >-
|
url: >-
|
||||||
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
|
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
|
||||||
| default('genome') }}/{{ item.depot }}/branches/{{ item.branche | default('main') }}
|
| default('genome') }}/{{ item.depot }}/branches/{{ item.branche | default('main') }}
|
||||||
# AUTHENTIFIE (2026-10-11) : les depots des locataires et du site deviennent prives, et
|
|
||||||
# l'API anonyme y repondrait 404 — un « absent » qui n'en serait pas un.
|
|
||||||
url_username: forgejo-admin
|
|
||||||
url_password: "{{ vault_forgejo_admin }}"
|
|
||||||
force_basic_auth: true
|
|
||||||
validate_certs: false
|
validate_certs: false
|
||||||
status_code: [200, 404]
|
status_code: [200, 404]
|
||||||
loop: "{{ genome_compares }}"
|
loop: "{{ genome_compares }}"
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,6 @@
|
||||||
loop: "{{ genome_a_pousser }}"
|
loop: "{{ genome_a_pousser }}"
|
||||||
loop_control:
|
loop_control:
|
||||||
label: "{{ item.dest }}"
|
label: "{{ item.dest }}"
|
||||||
register: genome_emballe
|
|
||||||
changed_when: true
|
changed_when: true
|
||||||
|
|
||||||
- name: Porter les colis jusqu'au runner
|
- name: Porter les colis jusqu'au runner
|
||||||
|
|
@ -216,16 +215,11 @@
|
||||||
|
|
||||||
- name: Demander à la forge ce qu'elle porte maintenant
|
- name: Demander à la forge ce qu'elle porte maintenant
|
||||||
# On interroge son API plutôt que `git ls-remote` : c'est la forge elle-même qui
|
# On interroge son API plutôt que `git ls-remote` : c'est la forge elle-même qui
|
||||||
# répond. Lecture authentifiée depuis le 2026-10-11 (dépôts privés).
|
# répond, et la lecture est anonyme — aucun justificatif n'entre ici.
|
||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
url: >-
|
url: >-
|
||||||
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
|
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
|
||||||
| default('genome') }}/{{ item.depot }}/branches/{{ item.branche | default('main') }}
|
| default('genome') }}/{{ item.depot }}/branches/{{ item.branche | default('main') }}
|
||||||
# AUTHENTIFIE (2026-10-11) : les depots des locataires et du site deviennent prives, et
|
|
||||||
# l'API anonyme y repondrait 404 — un « absent » qui n'en serait pas un.
|
|
||||||
url_username: forgejo-admin
|
|
||||||
url_password: "{{ vault_forgejo_admin }}"
|
|
||||||
force_basic_auth: true
|
|
||||||
validate_certs: false
|
validate_certs: false
|
||||||
status_code: [200]
|
status_code: [200]
|
||||||
loop: "{{ genome_a_pousser }}"
|
loop: "{{ genome_a_pousser }}"
|
||||||
|
|
@ -261,50 +255,6 @@
|
||||||
label: "{{ item.depot }}"
|
label: "{{ item.depot }}"
|
||||||
index_var: idx
|
index_var: idx
|
||||||
|
|
||||||
# LES RELEASES AUSSI (2026-10-07). Le colis ne portait que la branche : `v2026.08.21` et
|
|
||||||
# `v2026.10.07` manquaient a la forge du site. Il porte desormais les etiquettes ; on
|
|
||||||
# demande a la forge si elle a TOUTES celles du poste. 404 = aucune etiquette sur ce
|
|
||||||
# depot, ce qui est juste quand le poste n'en a pas non plus.
|
|
||||||
- name: Demander à la forge les étiquettes qu'elle porte
|
|
||||||
ansible.builtin.uri:
|
|
||||||
url: >-
|
|
||||||
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
|
|
||||||
| default('genome') }}/{{ item.depot }}/git/refs/tags
|
|
||||||
# AUTHENTIFIE (2026-10-11) : les depots des locataires et du site deviennent prives, et
|
|
||||||
# l'API anonyme y repondrait 404 — un « absent » qui n'en serait pas un.
|
|
||||||
url_username: forgejo-admin
|
|
||||||
url_password: "{{ vault_forgejo_admin }}"
|
|
||||||
force_basic_auth: true
|
|
||||||
validate_certs: false
|
|
||||||
status_code: [200, 404]
|
|
||||||
loop: "{{ genome_a_pousser }}"
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.depot }}"
|
|
||||||
register: genome_forge_etiquettes
|
|
||||||
retries: 4
|
|
||||||
delay: 3
|
|
||||||
until: genome_forge_etiquettes is succeeded
|
|
||||||
changed_when: false
|
|
||||||
|
|
||||||
- name: La forge porte-t-elle toutes les releases du poste ?
|
|
||||||
vars:
|
|
||||||
genome_etiquettes_poste: "{{ (genome_emballe.results[idx].stdout | from_json).etiquettes | default([]) }}"
|
|
||||||
genome_etiquettes_forge: >-
|
|
||||||
{{ (genome_forge_etiquettes.results[idx].json | default([]) if genome_forge_etiquettes.results[idx].status == 200 else [])
|
|
||||||
| map(attribute='ref') | map('regex_replace', '^refs/tags/', '') | list }}
|
|
||||||
ansible.builtin.assert:
|
|
||||||
that:
|
|
||||||
- genome_etiquettes_poste | difference(genome_etiquettes_forge) | length == 0
|
|
||||||
fail_msg: >-
|
|
||||||
{{ item.depot }} : la forge n'a pas
|
|
||||||
{{ genome_etiquettes_poste | difference(genome_etiquettes_forge) | join(', ') }}.
|
|
||||||
success_msg: >-
|
|
||||||
{{ item.depot }} : {{ genome_etiquettes_poste | length }} etiquette(s) du poste, toutes sur la forge.
|
|
||||||
loop: "{{ genome_a_pousser }}"
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.depot }}"
|
|
||||||
index_var: idx
|
|
||||||
|
|
||||||
# LE RUNNER DU SITE AVANCE SON PROPRE CLONE, ET SA CONSOLE NE LE SAIT PAS (2026-09-20).
|
# LE RUNNER DU SITE AVANCE SON PROPRE CLONE, ET SA CONSOLE NE LE SAIT PAS (2026-09-20).
|
||||||
#
|
#
|
||||||
# C'est LUI qui pousse, donc il doit d'abord recevoir : ce playbook fait avancer le
|
# C'est LUI qui pousse, donc il doit d'abord recevoir : ce playbook fait avancer le
|
||||||
|
|
|
||||||
|
|
@ -11,23 +11,11 @@
|
||||||
gather_facts: false
|
gather_facts: false
|
||||||
become: false
|
become: false
|
||||||
tasks:
|
tasks:
|
||||||
# UN PROMETHEUS QUI VIENT DE REDEMARRER N'A PAS ENCORE RELEVE SES CIBLES (2026-10-08).
|
- name: Interroger l'API des cibles actives
|
||||||
# Depuis qu'obs-01 est sauvegarde, son premier depot (copie a froid, en fin de
|
|
||||||
# deploiement) relance Prometheus : a la reconstruction de Technolibre, la recette l'a
|
|
||||||
# interroge 18 s apres, et deux cibles n'avaient pas encore de releve reussi — saines
|
|
||||||
# quelques minutes plus tard. On redemande donc, une minute au plus, avant de juger :
|
|
||||||
# une vraie panne echoue toujours, une minute plus tard.
|
|
||||||
- name: Interroger l'API des cibles actives (jusqu'a ce que toutes soient relevees, une minute au plus)
|
|
||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
url: "http://localhost:9090/api/v1/targets?state=active"
|
url: "http://localhost:9090/api/v1/targets?state=active"
|
||||||
return_content: true
|
return_content: true
|
||||||
register: prom_targets
|
register: prom_targets
|
||||||
until: >-
|
|
||||||
((prom_targets.content | default('{}') | from_json).get('data', {}).get('activeTargets', [])
|
|
||||||
| rejectattr('health', 'equalto', 'up') | list | length == 0)
|
|
||||||
or (prom_targets.attempts | default(0) | int >= 6)
|
|
||||||
retries: 6
|
|
||||||
delay: 10
|
|
||||||
|
|
||||||
- name: Analyser up / down
|
- name: Analyser up / down
|
||||||
vars:
|
vars:
|
||||||
|
|
|
||||||
|
|
@ -83,29 +83,6 @@ client_backup_catalogue:
|
||||||
# PAS DE `serveur_web_frontal` (retire le 2026-09-30) : le frontal RELAIE, il ne sert
|
# PAS DE `serveur_web_frontal` (retire le 2026-09-30) : le frontal RELAIE, il ne sert
|
||||||
# rien lui-meme — ses vhosts, son WAF et sa page 404 se redeploient depuis le depot.
|
# rien lui-meme — ses vhosts, son WAF et sa page 404 se redeploient depuis le depot.
|
||||||
# `/srv/web` venait de l'epoque ou il servait du statique ; ses instantanes etaient vides.
|
# `/srv/web` venait de l'epoque ou il servait du statique ; ses instantanes etaient vides.
|
||||||
# LES METRIQUES ET LES JOURNAUX (2026-10-07). Ils repartaient de zero a chaque
|
|
||||||
# reconstruction. Ils s'ecrivent en continu : une copie A FROID (service arrete le temps
|
|
||||||
# de copier, `setops-copie-a-froid`) les pose dans le repertoire de preparation, et c'est
|
|
||||||
# cette copie que restic emporte. `vivant` dit ou vit la base, pour la restauration et les
|
|
||||||
# temoins, qui la comparent a l'instantane.
|
|
||||||
serveur_prometheus:
|
|
||||||
nom: prometheus
|
|
||||||
vivant: "{{ serveur_prometheus_donnees | default('/var/lib/prometheus/metrics2') }}"
|
|
||||||
commande: >-
|
|
||||||
/usr/local/sbin/setops-copie-a-froid
|
|
||||||
{{ serveur_prometheus_service | default('prometheus') }}
|
|
||||||
{{ serveur_prometheus_donnees | default('/var/lib/prometheus/metrics2') }}
|
|
||||||
{{ client_backup_staging }}/prometheus/metrics2 --blocs-immuables
|
|
||||||
chemins: ["{{ client_backup_staging }}/prometheus"]
|
|
||||||
serveur_loki:
|
|
||||||
nom: loki
|
|
||||||
vivant: "{{ serveur_loki_chemin | default('/var/lib/loki') }}"
|
|
||||||
commande: >-
|
|
||||||
/usr/local/sbin/setops-copie-a-froid
|
|
||||||
{{ serveur_loki_service | default('loki') }}
|
|
||||||
{{ serveur_loki_chemin | default('/var/lib/loki') }}
|
|
||||||
{{ client_backup_staging }}/loki/donnees --lier chunks --copier chunks/index
|
|
||||||
chemins: ["{{ client_backup_staging }}/loki"]
|
|
||||||
serveur_icinga:
|
serveur_icinga:
|
||||||
# L'AC D'ICINGA FAIT L'IDENTITE DE SA BASE (2026-10-07). Icinga 2 derive l'environnement
|
# L'AC D'ICINGA FAIT L'IDENTITE DE SA BASE (2026-10-07). Icinga 2 derive l'environnement
|
||||||
# d'Icinga DB de la cle publique de son AC, et chaque identifiant d'hote, de service et
|
# d'Icinga DB de la cle publique de son AC, et chaque identifiant d'hote, de service et
|
||||||
|
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# Géré par Set-OPS (rôle client_backup). Ne pas éditer à la main.
|
|
||||||
#
|
|
||||||
# setops-copie-a-froid SERVICE SOURCE DESTINATION [--blocs-immuables] [--lier REL]... [--copier REL]...
|
|
||||||
#
|
|
||||||
# Copie COHERENTE d'une base qui ecrit en continu (Prometheus, Loki) : le service est
|
|
||||||
# arrete le temps de la copie, puis redemarre — QUOI QU'IL ARRIVE a la copie, et seulement
|
|
||||||
# s'il tournait avant. La destination n'est remplacee qu'une fois la copie complete.
|
|
||||||
#
|
|
||||||
# POURQUOI A FROID (decision de l'exploitant, 2026-10-07 : « on ne prend pas de risque »).
|
|
||||||
# Copier a chaud un WAL qu'on ecrit, c'est sauvegarder un fichier a moitie ecrit. L'autre
|
|
||||||
# voie — ne garder que les blocs fermes — perdait jusqu'aux deux dernieres heures de
|
|
||||||
# metriques a chaque reconstruction. Mesure au site : 2,9 Go de Prometheus se lisent en
|
|
||||||
# 4,2 s, son WAL seul en 0,3 s ; Loki entier en 2 s.
|
|
||||||
#
|
|
||||||
# `--blocs-immuables` : les sous-dossiers nommes par un ULID (26 caracteres, `01…`) sont
|
|
||||||
# les blocs de Prometheus, que rien ne reecrit apres leur fermeture. On les recopie par
|
|
||||||
# LIENS DURS — instantane, sans place — et le reste (WAL, `chunks_head`) par une vraie
|
|
||||||
# copie. Si la destination est sur un autre systeme de fichiers, on copie tout.
|
|
||||||
#
|
|
||||||
# `--lier REL` lie tout un sous-dossier ; `--copier REL` y copie vraiment un sous-dossier qui,
|
|
||||||
# lui, bouge (2026-10-07). Pour Loki : `--lier chunks --copier chunks/index`. Ses morceaux sont
|
|
||||||
# adresses par leur contenu (`debut:fin:somme`, decode sur site-mon-01) et ne bougent jamais ;
|
|
||||||
# mais `chunks/index/` loge aussi `delete_requests.gz`, la base du compacteur, mesuree
|
|
||||||
# reecrite trois semaines apres sa naissance. La lier, c'etait laisser la sauvegarde changer
|
|
||||||
# avec elle. Copier 6 550 morceaux arretait Loki 13 s ; les lier prend 40 ms.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
service="${1:?service}"
|
|
||||||
source="${2:?source}"
|
|
||||||
dest="${3:?destination}"
|
|
||||||
shift 3
|
|
||||||
blocs="" lier=() copier=()
|
|
||||||
while (( $# )); do
|
|
||||||
case "$1" in
|
|
||||||
--blocs-immuables) blocs=1; shift ;;
|
|
||||||
--lier) lier+=("$2"); shift 2 ;;
|
|
||||||
--copier) copier+=("$2"); shift 2 ;;
|
|
||||||
*) echo "copie a froid : option inconnue : $1" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
[[ -d "${source}" ]] || { echo "copie a froid : ${source} n'existe pas — rien a copier." >&2; exit 0; }
|
|
||||||
|
|
||||||
tmp="${dest}.en-cours"
|
|
||||||
rm -rf "${tmp}"
|
|
||||||
mkdir -p "$(dirname "${dest}")"
|
|
||||||
|
|
||||||
actif=0
|
|
||||||
if systemctl is-active --quiet "${service}"; then actif=1; fi
|
|
||||||
relancer() { if (( actif )); then systemctl start "${service}"; fi; }
|
|
||||||
|
|
||||||
debut=$(date +%s%N)
|
|
||||||
if (( actif )); then systemctl stop "${service}"; fi
|
|
||||||
trap relancer EXIT
|
|
||||||
|
|
||||||
lie() { cp -al "$1" "$2" 2>/dev/null || { rm -rf "$2"; cp -a "$1" "$2"; }; }
|
|
||||||
mkdir -p "${tmp}"
|
|
||||||
for e in "${source}"/* "${source}"/.[!.]*; do
|
|
||||||
[[ -e "${e}" ]] || continue
|
|
||||||
n="$(basename "${e}")"
|
|
||||||
if [[ -n "${blocs}" && -d "${e}" && ${#n} -eq 26 && "${n}" == 01* ]] \
|
|
||||||
|| [[ " ${lier[*]:-} " == *" ${n} "* ]]; then
|
|
||||||
lie "${e}" "${tmp}/${n}"
|
|
||||||
else
|
|
||||||
cp -a "${e}" "${tmp}/${n}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
# Ce qui bouge, a l'interieur d'un dossier lie : remplace par une vraie copie.
|
|
||||||
for c in "${copier[@]:+${copier[@]}}"; do
|
|
||||||
if [[ -e "${source}/${c}" ]]; then
|
|
||||||
rm -rf "${tmp:?}/${c}"
|
|
||||||
mkdir -p "$(dirname "${tmp}/${c}")"
|
|
||||||
cp -a "${source}/${c}" "${tmp}/${c}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
trap - EXIT
|
|
||||||
relancer
|
|
||||||
fin=$(date +%s%N)
|
|
||||||
|
|
||||||
rm -rf "${dest}"
|
|
||||||
mv "${tmp}" "${dest}"
|
|
||||||
echo "copie a froid de ${source} : ${service} $( (( actif )) && echo "arrete (jusqu a la relance, rejeu du WAL non compris) $(( (fin - debut) / 1000000 )) ms" || echo "n'etait pas actif")"
|
|
||||||
|
|
@ -19,8 +19,7 @@ VERDICTS, PAR JEU :
|
||||||
clés et certificats de l'AC, AC d'Icinga et son environnement, clé DKIM,
|
clés et certificats de l'AC, AC d'Icinga et son environnement, clé DKIM,
|
||||||
`instanceid` de Nextcloud, mot de passe d'une entrée de l'annuaire ; une
|
`instanceid` de Nextcloud, mot de passe d'une entrée de l'annuaire ; une
|
||||||
base dont plus aucune ligne d'avant ne subsiste ; une ligne d'historique
|
base dont plus aucune ligne d'avant ne subsiste ; une ligne d'historique
|
||||||
perdue ; des métriques qui ne couvrent plus l'avant-rasage ; des journaux
|
perdue. Exactement ce que la reconstruction du
|
||||||
dont aucun morceau d'avant ne subsiste. Exactement ce que la reconstruction du
|
|
||||||
2026-09-30 avait perdu sans que rien ne le dise.
|
2026-09-30 avait perdu sans que rien ne le dise.
|
||||||
|
|
||||||
Code de sortie : 0 sans écart, 1 avec au moins un écart, 2 erreur d'usage.
|
Code de sortie : 0 sans écart, 1 avec au moins un écart, 2 erreur d'usage.
|
||||||
|
|
@ -31,7 +30,6 @@ import argparse
|
||||||
import base64
|
import base64
|
||||||
import collections
|
import collections
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
@ -176,80 +174,6 @@ def instance_id(r: Rapport, avant: Path, vivant: Path) -> None:
|
||||||
r.ecarts.append(f"IDENTITE instanceid : {a} -> {v or '(absent)'}")
|
r.ecarts.append(f"IDENTITE instanceid : {a} -> {v or '(absent)'}")
|
||||||
|
|
||||||
|
|
||||||
# --- Les bases copiees a froid : Prometheus et Loki (2026-10-07) ----------------------
|
|
||||||
#
|
|
||||||
# Leurs FICHIERS ne se comparent pas : Prometheus fusionne ses blocs, Loki compacte son
|
|
||||||
# index, et la retention (15 j, 31 j) en retire chaque jour. On compare ce qui compte.
|
|
||||||
|
|
||||||
def blocs_prometheus(racine: Path) -> list[tuple[int, int]]:
|
|
||||||
out = []
|
|
||||||
for meta in racine.glob("01*/meta.json") if racine.is_dir() else []:
|
|
||||||
try:
|
|
||||||
m = json.loads(meta.read_text())
|
|
||||||
out.append((int(m["minTime"]), int(m["maxTime"])))
|
|
||||||
except (ValueError, KeyError):
|
|
||||||
continue
|
|
||||||
return sorted(out)
|
|
||||||
|
|
||||||
|
|
||||||
def segments_wal(racine: Path) -> list[str]:
|
|
||||||
return sorted(p.name for p in (racine / "wal").glob("[0-9]*")) if (racine / "wal").is_dir() else []
|
|
||||||
|
|
||||||
|
|
||||||
def jeu_prometheus(avant: Path, vivant: Path) -> Rapport:
|
|
||||||
"""Les métriques vivantes couvrent-elles encore l'avant-rasage ?
|
|
||||||
|
|
||||||
Le repère : le DÉBUT du bloc le plus récent de l'instantané — quelques heures avant le
|
|
||||||
rasage. Un bloc vivant doit couvrir cet instant. La rétention ne l'a pas retiré (il a
|
|
||||||
moins de 15 jours) ; une base qui n'a pas été restaurée ne peut pas le couvrir : elle
|
|
||||||
est née après la reconstruction. Une base de moins de deux heures n'a pas encore de
|
|
||||||
bloc : son WAL fait alors le repère, et doit se retrouver dans la base vivante, ou y
|
|
||||||
avoir été fermé en bloc.
|
|
||||||
"""
|
|
||||||
r = Rapport("prometheus")
|
|
||||||
a, v = blocs_prometheus(avant), blocs_prometheus(vivant)
|
|
||||||
r.compte = len(a)
|
|
||||||
if a:
|
|
||||||
t = max(debut for debut, _ in a)
|
|
||||||
if not any(debut <= t < fin for debut, fin in v):
|
|
||||||
r.ecarts.append(f"aucun bloc vivant ne couvre {t} (debut du dernier bloc d'avant) : "
|
|
||||||
f"metriques d'avant non restaurees")
|
|
||||||
if v and min(d for d, _ in v) > min(d for d, _ in a):
|
|
||||||
r.changes.append(f"plus ancienne donnee : {min(d for d, _ in a)} -> {min(d for d, _ in v)} (retention)")
|
|
||||||
else:
|
|
||||||
wal = segments_wal(avant)
|
|
||||||
if wal and not v and wal[0] not in segments_wal(vivant):
|
|
||||||
r.ecarts.append(f"le WAL d'avant ({wal[0]}...) n'est pas dans la base vivante, et elle n'a "
|
|
||||||
f"aucun bloc : metriques d'avant non restaurees")
|
|
||||||
if len(v) != len(a):
|
|
||||||
r.changes.append(f"{len(a)} bloc(s) avant, {len(v)} maintenant (fusions, retention, nouveaux)")
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
def jeu_loki(avant: Path, vivant: Path) -> Rapport:
|
|
||||||
"""Des morceaux de journal d'avant subsistent-ils ? Aucun : non restaurés.
|
|
||||||
|
|
||||||
La rétention (31 j) en retire chaque jour : une perte PARTIELLE est listée. Une perte
|
|
||||||
TOTALE, elle, ne vient que d'une base qui n'a pas été remise.
|
|
||||||
"""
|
|
||||||
r = Rapport("loki")
|
|
||||||
# LES MORCEAUX, PAS L'INDEX (2026-10-08). `chunks/index/` loge l'index TSDB et la base du
|
|
||||||
# compacteur : il les fusionne et les remplace. A la reconstruction de Technolibre, trois
|
|
||||||
# de ces fichiers « d'avant » manquaient, et le temoin les disait retires « (retention ?) »
|
|
||||||
# — aucun journal ne l'etait (Loki rendait ceux d'avant le rasage, minute par minute).
|
|
||||||
morceaux = lambda racine: {rel for rel in arbre(racine / "chunks") if not rel.startswith("index/")} # noqa: E731
|
|
||||||
a = morceaux(avant)
|
|
||||||
v = morceaux(vivant)
|
|
||||||
r.compte = len(a)
|
|
||||||
if a and not (a & v):
|
|
||||||
r.ecarts.append(f"aucun des {len(a)} morceau(x) de journal d'avant ne subsiste : journaux non restaures")
|
|
||||||
elif a - v:
|
|
||||||
r.retires.append(f"{len(a - v)} morceau(x) d'avant retire(s) (retention ?)")
|
|
||||||
if v - a:
|
|
||||||
r.nouveaux.append(f"{len(v - a)} morceau(x) nouveau(x)")
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
# --- L'annuaire ----------------------------------------------------------------------
|
# --- L'annuaire ----------------------------------------------------------------------
|
||||||
|
|
||||||
def entrees_ldif(texte: str) -> dict[str, dict[str, list[str]]]:
|
def entrees_ldif(texte: str) -> dict[str, dict[str, list[str]]]:
|
||||||
|
|
@ -393,8 +317,6 @@ def main(argv: list[str] | None = None, interroger=psql) -> int:
|
||||||
ap.add_argument("--heure", default="")
|
ap.add_argument("--heure", default="")
|
||||||
ap.add_argument("--hote", default="")
|
ap.add_argument("--hote", default="")
|
||||||
ap.add_argument("--jeu", action="append", default=[], help="NOM=CHEMIN[:CHEMIN...]")
|
ap.add_argument("--jeu", action="append", default=[], help="NOM=CHEMIN[:CHEMIN...]")
|
||||||
ap.add_argument("--vivant", action="append", default=[],
|
|
||||||
help="NOM=CHEMIN : ou vit la base d'un jeu copie a froid")
|
|
||||||
ap.add_argument("--ldif", default="", help="chemin du LDIF dans l'instantané")
|
ap.add_argument("--ldif", default="", help="chemin du LDIF dans l'instantané")
|
||||||
ap.add_argument("--ldif-vivant", type=Path, default=None)
|
ap.add_argument("--ldif-vivant", type=Path, default=None)
|
||||||
ap.add_argument("--dump", default="", help="chemin du pg_dumpall dans l'instantané")
|
ap.add_argument("--dump", default="", help="chemin du pg_dumpall dans l'instantané")
|
||||||
|
|
@ -405,17 +327,9 @@ def main(argv: list[str] | None = None, interroger=psql) -> int:
|
||||||
print(" SANS OBJET : ce noeud ne detient aucun etat.")
|
print(" SANS OBJET : ce noeud ne detient aucun etat.")
|
||||||
return 0
|
return 0
|
||||||
ecart = False
|
ecart = False
|
||||||
vivants = dict(v.partition("=")[::2] for v in a.vivant)
|
|
||||||
for spec in a.jeu:
|
for spec in a.jeu:
|
||||||
nom, _, chemins = spec.partition("=")
|
nom, _, chemins = spec.partition("=")
|
||||||
# La copie a froid est le SEUL sous-dossier de la preparation du jeu.
|
if nom == "openldap":
|
||||||
copie = lambda: next((d for d in sorted((a.avant / chemins.split(":")[0].lstrip("/")).glob("*")) # noqa: E731
|
|
||||||
if d.is_dir()), a.avant / "absent")
|
|
||||||
if nom == "prometheus" and nom in vivants:
|
|
||||||
r, unite = jeu_prometheus(copie(), Path(vivants[nom])), "bloc(s)"
|
|
||||||
elif nom == "loki" and nom in vivants:
|
|
||||||
r, unite = jeu_loki(copie(), Path(vivants[nom])), "morceau(x)"
|
|
||||||
elif nom == "openldap":
|
|
||||||
r, unite = jeu_annuaire(a.avant / a.ldif.lstrip("/"), a.ldif_vivant or Path("/nonexistent")), "entree(s)"
|
r, unite = jeu_annuaire(a.avant / a.ldif.lstrip("/"), a.ldif_vivant or Path("/nonexistent")), "entree(s)"
|
||||||
elif nom == "postgresql":
|
elif nom == "postgresql":
|
||||||
r, unite = jeu_postgresql(a.avant / a.dump.lstrip("/"), interroger), "table(s)"
|
r, unite = jeu_postgresql(a.avant / a.dump.lstrip("/"), interroger), "table(s)"
|
||||||
|
|
|
||||||
|
|
@ -56,17 +56,6 @@
|
||||||
group: root
|
group: root
|
||||||
mode: "0700"
|
mode: "0700"
|
||||||
|
|
||||||
# LA COPIE A FROID (2026-10-07) : les jeux qui s'ecrivent en continu (Prometheus, Loki)
|
|
||||||
# arretent leur service le temps de copier. L'outil est deploye avec le script qui l'appelle.
|
|
||||||
- name: Déployer l'outil de copie à froid
|
|
||||||
when: client_backup_jobs | length > 0
|
|
||||||
ansible.builtin.copy:
|
|
||||||
src: setops-copie-a-froid.sh
|
|
||||||
dest: /usr/local/sbin/setops-copie-a-froid
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0700"
|
|
||||||
|
|
||||||
- name: Déployer l'unité et le timer systemd
|
- name: Déployer l'unité et le timer systemd
|
||||||
when: client_backup_jobs | length > 0
|
when: client_backup_jobs | length > 0
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
|
|
@ -127,7 +116,6 @@
|
||||||
state: absent
|
state: absent
|
||||||
loop:
|
loop:
|
||||||
- /usr/local/sbin/setops-sauvegarder.sh
|
- /usr/local/sbin/setops-sauvegarder.sh
|
||||||
- /usr/local/sbin/setops-copie-a-froid
|
|
||||||
- /etc/systemd/system/setops-sauvegarde.service
|
- /etc/systemd/system/setops-sauvegarde.service
|
||||||
- /etc/systemd/system/setops-sauvegarde.timer
|
- /etc/systemd/system/setops-sauvegarde.timer
|
||||||
- /etc/systemd/system/setops-sauvegarde-avant-raser.service
|
- /etc/systemd/system/setops-sauvegarde-avant-raser.service
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,6 @@
|
||||||
# choisir [--instantane ID] (pour Ansible) JSON de l'instantane candidat
|
# choisir [--instantane ID] (pour Ansible) JSON de l'instantane candidat
|
||||||
# fichiers [opts] CHEMIN... remettre des repertoires (rsync --delete)
|
# fichiers [opts] CHEMIN... remettre des repertoires (rsync --delete)
|
||||||
# --proprietaire U:G --remplacer --vers DOSSIER (repetition, rien n'est touche)
|
# --proprietaire U:G --remplacer --vers DOSSIER (repetition, rien n'est touche)
|
||||||
# --depuis CHEMIN_DANS_L_INSTANTANE (un seul CHEMIN) : ce qui a ete sauvegarde
|
|
||||||
# ailleurs que la ou ca vit (copie a froid)
|
|
||||||
# annuaire [opts] BASE_DN remplacer la base LDAP (slapadd)
|
# annuaire [opts] BASE_DN remplacer la base LDAP (slapadd)
|
||||||
# --essai (slapadd -u a blanc, rien n'est touche)
|
# --essai (slapadd -u a blanc, rien n'est touche)
|
||||||
# base [opts] NOM rejouer une base depuis le pg_dumpall
|
# base [opts] NOM rejouer une base depuis le pg_dumpall
|
||||||
|
|
@ -60,9 +58,6 @@ JEUX=({% for j in client_backup_jobs %} "{{ j.nom }}"{% endfor %} )
|
||||||
# Les chemins de chaque jeu, separes par « : » — ce que les temoins extraient et comparent.
|
# Les chemins de chaque jeu, separes par « : » — ce que les temoins extraient et comparent.
|
||||||
declare -A CHEMINS_JEU=({% for j in client_backup_jobs %} ["{{ j.nom }}"]="{{ j.chemins | join(':') }}"{% endfor %} )
|
declare -A CHEMINS_JEU=({% for j in client_backup_jobs %} ["{{ j.nom }}"]="{{ j.chemins | join(':') }}"{% endfor %} )
|
||||||
ETIQUETTE_AVANT_RASER="{{ client_backup_etiquette_avant_raser }}"
|
ETIQUETTE_AVANT_RASER="{{ client_backup_etiquette_avant_raser }}"
|
||||||
# Ou vit la base des jeux copies a froid (Prometheus, Loki) : leur instantane porte la
|
|
||||||
# copie, pas la base. Les temoins comparent l'une a l'autre.
|
|
||||||
declare -A VIVANT_JEU=({% for j in client_backup_jobs if j.vivant is defined %} ["{{ j.nom }}"]="{{ j.vivant }}"{% endfor %} )
|
|
||||||
|
|
||||||
dire() { printf '%s\n' "$*" >&2; }
|
dire() { printf '%s\n' "$*" >&2; }
|
||||||
mourir() { dire "setops-restaurer: $*"; exit 1; }
|
mourir() { dire "setops-restaurer: $*"; exit 1; }
|
||||||
|
|
@ -159,28 +154,22 @@ vide() { # vrai si le chemin est absent, ou un repertoire sans contenu
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_fichiers() {
|
cmd_fichiers() {
|
||||||
local proprio="" remplacer=0 vers="" depuis="" chemins=() src dest n
|
local proprio="" remplacer=0 vers="" chemins=() src dest n
|
||||||
while (( $# )); do
|
while (( $# )); do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--proprietaire) proprio="$2"; shift 2 ;;
|
--proprietaire) proprio="$2"; shift 2 ;;
|
||||||
--remplacer) remplacer=1; shift ;;
|
--remplacer) remplacer=1; shift ;;
|
||||||
--vers) vers="$2"; shift 2 ;;
|
--vers) vers="$2"; shift 2 ;;
|
||||||
--depuis) depuis="$2"; shift 2 ;;
|
|
||||||
--instantane) INSTANTANE="$2"; shift 2 ;;
|
--instantane) INSTANTANE="$2"; shift 2 ;;
|
||||||
*) chemins+=("$1"); shift ;;
|
*) chemins+=("$1"); shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
[[ -n "${chemins[*]:-}" ]] || mourir "fichiers : aucun chemin"
|
[[ -n "${chemins[*]:-}" ]] || mourir "fichiers : aucun chemin"
|
||||||
# LA COPIE A FROID (2026-10-07) : l'instantane porte la copie posee dans le repertoire de
|
|
||||||
# preparation, pas la base. `--depuis` dit ou la prendre ; le CHEMIN, ou la remettre.
|
|
||||||
# (Un second element, plutot qu'une longueur : la sequence accolade-diese ouvre un
|
|
||||||
# commentaire Jinja dans ce gabarit.)
|
|
||||||
if [[ -n "${depuis}" && -n "${chemins[1]:-}" ]]; then mourir "fichiers --depuis : un seul chemin"; fi
|
|
||||||
resoudre_instantane
|
resoudre_instantane
|
||||||
if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi
|
if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi
|
||||||
if [[ -n "${depuis}" ]]; then extraire "${depuis}"; else extraire "${chemins[@]}"; fi
|
extraire "${chemins[@]}"
|
||||||
for c in "${chemins[@]}"; do
|
for c in "${chemins[@]}"; do
|
||||||
src="${TMP}${depuis:-${c}}"
|
src="${TMP}${c}"
|
||||||
dest="${vers}${c}"
|
dest="${vers}${c}"
|
||||||
if [[ ! -e "${src}" ]]; then echo "ABSENT de ${INSTANTANE} : ${c}"; continue; fi
|
if [[ ! -e "${src}" ]]; then echo "ABSENT de ${INSTANTANE} : ${c}"; continue; fi
|
||||||
if ! vide "${dest}"; then
|
if ! vide "${dest}"; then
|
||||||
|
|
@ -369,7 +358,6 @@ cmd_temoins() {
|
||||||
IFS=: read -r -a morceaux <<< "${CHEMINS_JEU[${j}]}"
|
IFS=: read -r -a morceaux <<< "${CHEMINS_JEU[${j}]}"
|
||||||
chemins+=("${morceaux[@]}")
|
chemins+=("${morceaux[@]}")
|
||||||
args+=(--jeu "${j}=${CHEMINS_JEU[${j}]}")
|
args+=(--jeu "${j}=${CHEMINS_JEU[${j}]}")
|
||||||
if [[ -n "${VIVANT_JEU[${j}]:-}" ]]; then args+=(--vivant "${j}=${VIVANT_JEU[${j}]}"); fi
|
|
||||||
done
|
done
|
||||||
extraire "${chemins[@]}"
|
extraire "${chemins[@]}"
|
||||||
if [[ -s "${TMP}${LDIF}" ]]; then
|
if [[ -s "${TMP}${LDIF}" ]]; then
|
||||||
|
|
|
||||||
|
|
@ -56,17 +56,9 @@ rapporter() { # $1=code $2=texte
|
||||||
|
|
||||||
maintenant=$(date +%s)
|
maintenant=$(date +%s)
|
||||||
|
|
||||||
# LE PLUS RECENT DE TOUS LES GROUPES, PAS LE PREMIER (2026-10-08). `--latest 1` rend le
|
|
||||||
# dernier instantane de CHAQUE groupe — machine ET liste de chemins. Ajouter un jeu change la
|
|
||||||
# liste : deux groupes, et `d[0]` etait l'ancien, fige. Sur site-mon-01, des le lendemain de
|
|
||||||
# l'arrivee de prometheus, loki et icinga : « EN RETARD : 26 h » sur une sauvegarde de 2 h 30,
|
|
||||||
# et « PERIME » promis a 50 h. On prend donc le plus recent de tous.
|
|
||||||
json=$(restic snapshots --latest 1 --json 2>/dev/null)
|
json=$(restic snapshots --latest 1 --json 2>/dev/null)
|
||||||
horodatage=$(printf '%s' "${json}" | python3 -c \
|
horodatage=$(printf '%s' "${json}" | python3 -c \
|
||||||
'import sys,json,re,datetime
|
'import sys,json;d=json.load(sys.stdin);print(d[0]["time"] if d else "")' 2>/dev/null)
|
||||||
d=json.load(sys.stdin) or []
|
|
||||||
t=lambda x: datetime.datetime.fromisoformat(re.sub(r"Z$","+00:00",re.sub(r"(\.\d{6})\d+",r"\1",x["time"]))).timestamp()
|
|
||||||
print(max(d,key=t)["time"] if d else "")' 2>/dev/null)
|
|
||||||
if [[ -z "${horodatage}" ]]; then
|
if [[ -z "${horodatage}" ]]; then
|
||||||
# ON NE DISTINGUE PAS ICI « depot vide » de « depot injoignable », et c'est voulu :
|
# ON NE DISTINGUE PAS ICI « depot vide » de « depot injoignable », et c'est voulu :
|
||||||
# dans les deux cas il n'y a PAS de sauvegarde exploitable, et c'est ce qui compte.
|
# dans les deux cas il n'y a PAS de sauvegarde exploitable, et c'est ce qui compte.
|
||||||
|
|
|
||||||
|
|
@ -21,5 +21,3 @@ client_backup_groupes_etat:
|
||||||
- serveur_rspamd
|
- serveur_rspamd
|
||||||
- serveur_web_dorsal
|
- serveur_web_dorsal
|
||||||
- serveur_icinga
|
- serveur_icinga
|
||||||
- serveur_prometheus
|
|
||||||
- serveur_loki
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,7 @@ client_journal_config: "/etc/alloy/config.alloy"
|
||||||
|
|
||||||
# Point de collecte Loki (obs-01, serveur_loki, VLAN 14).
|
# Point de collecte Loki (obs-01, serveur_loki, VLAN 14).
|
||||||
# Hôte Loki dérivé du groupe serveur_loki (FQDN via le plancher) ; override par instance au besoin.
|
# Hôte Loki dérivé du groupe serveur_loki (FQDN via le plancher) ; override par instance au besoin.
|
||||||
# LE SCHEMA SUIT L'INTERRUPTEUR TLS (2026-10-12) — meme defaut que Grafana le 2026-09-28 :
|
client_journal_loki_url: "http://{{ (groups.get('serveur_loki') or ['obs-01']) | first }}.{{ domaine_interne }}:3100/loki/api/v1/push"
|
||||||
# une URL `http://` ecrite a cote d'un `client_journal_loki_tls: true` parlerait en clair a
|
|
||||||
# un Loki qui n'ecoute qu'en HTTPS.
|
|
||||||
client_journal_loki_url: >-
|
|
||||||
{{ 'https' if client_journal_loki_tls | bool else 'http' }}://{{
|
|
||||||
(groups.get('serveur_loki') or ['obs-01']) | first }}.{{ domaine_interne }}:3100/loki/api/v1/push
|
|
||||||
|
|
||||||
# Depot apt officiel Grafana (partage avec serveur_loki / serveur_grafana).
|
# Depot apt officiel Grafana (partage avec serveur_loki / serveur_grafana).
|
||||||
client_journal_depot_cle_url: "{{ client_journal_depot_schema }}://apt.grafana.com/gpg-full.key"
|
client_journal_depot_cle_url: "{{ client_journal_depot_schema }}://apt.grafana.com/gpg-full.key"
|
||||||
|
|
@ -23,16 +18,10 @@ client_journal_depot_source: "deb [signed-by=/etc/apt/keyrings/grafana.asc] {{ c
|
||||||
# --- TLS (zero-confiance) : Alloy pousse vers Loki en https + verifie root_ca ---
|
# --- TLS (zero-confiance) : Alloy pousse vers Loki en https + verifie root_ca ---
|
||||||
client_journal_loki_tls: false
|
client_journal_loki_tls: false
|
||||||
client_journal_loki_ca: "/etc/step/certs/root_ca.crt"
|
client_journal_loki_ca: "/etc/step/certs/root_ca.crt"
|
||||||
# UNE MACHINE SANS PKI PEUT QUAND MEME VERIFIER (2026-10-12). Un hyperviseur n'a pas
|
|
||||||
# `client_pki` : aucune racine sous /etc/step. Pour verifier Loki, il lui suffit du
|
|
||||||
# certificat PUBLIC de l'autorite — pas d'un certificat a lui. Non vide, ce contenu est
|
|
||||||
# pose a `client_journal_loki_ca` (au site, `ac-racine-site.crt` lu par l'inventaire).
|
|
||||||
client_journal_loki_ca_contenu: ""
|
|
||||||
|
|
||||||
# Port de l'interface HTTP locale d'Alloy (diagnostic). DECLARE ici parce que son defaut
|
# Port de l'interface HTTP locale d'Alloy (diagnostic). DECLARE ici parce que son defaut
|
||||||
# amont — 12345 — entrait en collision avec `serveur_dovecot_sasl_port`, qui valait 12345
|
# amont — 12345 — entre en collision avec `serveur_dovecot_sasl_port`, qui vaut 12345 lui
|
||||||
# lui aussi (SASL reseau, pour la soumission :587 ; retire le 2026-10-12 — il passait en
|
# aussi et qui est un choix DELIBERE de Set-OPS (SASL reseau, pour la soumission :587).
|
||||||
# clair). Le port reste declare : le defaut amont, lui, n'a pas change.
|
|
||||||
#
|
#
|
||||||
# Sur `infra-mail-01`, les deux cohabitent : le premier demarre gagne, l'autre boucle sur
|
# Sur `infra-mail-01`, les deux cohabitent : le premier demarre gagne, l'autre boucle sur
|
||||||
# « bind: address already in use ». Constate le 2026-08-08 lors de la premiere
|
# « bind: address already in use ». Constate le 2026-08-08 lors de la premiere
|
||||||
|
|
@ -92,17 +81,6 @@ client_journal_syslog_protocole: "tcp"
|
||||||
# du site — jamais fabrique ici.
|
# du site — jamais fabrique ici.
|
||||||
client_journal_syslog_emetteur: "frontiere"
|
client_journal_syslog_emetteur: "frontiere"
|
||||||
|
|
||||||
# LE RECEPTEUR SYSLOG EN TLS, AUTHENTIFIANT SES CLIENTS (2026-10-12). Le syslog de la
|
|
||||||
# frontiere passait en clair. Vrai : Alloy sert le certificat de la machine (client_pki,
|
|
||||||
# recopie la ou `alloy` le lit, et a chaque renouvellement) et EXIGE un certificat client
|
|
||||||
# signe par une autorite de `client_journal_syslog_clients_ca_contenu` — au site, celle de
|
|
||||||
# la frontiere seule : personne d'autre ne peut y ecrire.
|
|
||||||
client_journal_syslog_tls: false
|
|
||||||
client_journal_syslog_tls_dir: "/etc/alloy/tls"
|
|
||||||
client_journal_syslog_tls_source_cert: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.crt"
|
|
||||||
client_journal_syslog_tls_source_cle: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.key"
|
|
||||||
client_journal_syslog_clients_ca_contenu: ""
|
|
||||||
|
|
||||||
# LE JOURNAL D'AUDIT, EXPEDIE COMME LE RESTE (2026-09-11).
|
# LE JOURNAL D'AUDIT, EXPEDIE COMME LE RESTE (2026-09-11).
|
||||||
#
|
#
|
||||||
# DERIVE DE L'APPARTENANCE AU GROUPE, pas declare par instance : `auditd` vient avec
|
# DERIVE DE L'APPARTENANCE AU GROUPE, pas declare par instance : `auditd` vient avec
|
||||||
|
|
|
||||||
|
|
@ -184,104 +184,6 @@
|
||||||
append: true
|
append: true
|
||||||
notify: Redemarrer alloy
|
notify: Redemarrer alloy
|
||||||
|
|
||||||
# --- RECEPTEUR SYSLOG EN TLS (2026-10-12) --------------------------------------------
|
|
||||||
# Meme pont que node_exporter, Loki, Dovecot : le certificat de la machine, recopie la ou le
|
|
||||||
# service le lit, recopie a chaque renouvellement. Avant la configuration : Alloy redemarre
|
|
||||||
# avec des fichiers deja en place.
|
|
||||||
- name: Syslog TLS — repertoire des certificats (alloy)
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: "{{ client_journal_syslog_tls_dir }}"
|
|
||||||
state: directory
|
|
||||||
owner: "{{ client_journal_utilisateur }}"
|
|
||||||
group: "{{ client_journal_utilisateur }}"
|
|
||||||
mode: "0700"
|
|
||||||
when:
|
|
||||||
- client_journal_syslog_ecoute | length > 0
|
|
||||||
- client_journal_syslog_tls | bool
|
|
||||||
|
|
||||||
- name: Syslog TLS — script de synchronisation du certificat
|
|
||||||
ansible.builtin.template:
|
|
||||||
src: setops-alloy-cert-sync.sh.j2
|
|
||||||
dest: /usr/local/sbin/setops-alloy-cert-sync
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0755"
|
|
||||||
when:
|
|
||||||
- client_journal_syslog_ecoute | length > 0
|
|
||||||
- client_journal_syslog_tls | bool
|
|
||||||
|
|
||||||
- name: Syslog TLS — unites de synchronisation (service + path)
|
|
||||||
ansible.builtin.template:
|
|
||||||
src: "{{ item.s }}"
|
|
||||||
dest: "{{ item.d }}"
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0644"
|
|
||||||
loop:
|
|
||||||
- { s: "setops-alloy-cert-sync.service.j2", d: "/etc/systemd/system/setops-alloy-cert-sync.service" }
|
|
||||||
- { s: "setops-alloy-cert-sync.path.j2", d: "/etc/systemd/system/setops-alloy-cert-sync.path" }
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.d | basename }}"
|
|
||||||
when:
|
|
||||||
- client_journal_syslog_ecoute | length > 0
|
|
||||||
- client_journal_syslog_tls | bool
|
|
||||||
|
|
||||||
- name: Syslog TLS — activer la surveillance du certificat (path)
|
|
||||||
ansible.builtin.systemd:
|
|
||||||
name: setops-alloy-cert-sync.path
|
|
||||||
enabled: true
|
|
||||||
state: started
|
|
||||||
daemon_reload: true
|
|
||||||
when:
|
|
||||||
- client_journal_syslog_ecoute | length > 0
|
|
||||||
- client_journal_syslog_tls | bool
|
|
||||||
- not ansible_check_mode
|
|
||||||
|
|
||||||
- name: Syslog TLS — synchroniser le certificat maintenant
|
|
||||||
ansible.builtin.command: /usr/local/sbin/setops-alloy-cert-sync
|
|
||||||
changed_when: false
|
|
||||||
when:
|
|
||||||
- client_journal_syslog_ecoute | length > 0
|
|
||||||
- client_journal_syslog_tls | bool
|
|
||||||
- not ansible_check_mode
|
|
||||||
|
|
||||||
# SEULS CES EMETTEURS PEUVENT ECRIRE. Sans certificat d'autorite, le recepteur n'accepterait
|
|
||||||
# aucun client : on refuse de deployer plutot que d'eteindre la journalisation de la
|
|
||||||
# frontiere en silence. Un CERTIFICAT, pas seulement une valeur non vide — et cette forme-la
|
|
||||||
# echappe a P32, qui ne lit pas les `when:` et aurait exige la variable de chaque locataire.
|
|
||||||
- name: Syslog TLS — exiger les autorites des emetteurs admis
|
|
||||||
ansible.builtin.assert:
|
|
||||||
that:
|
|
||||||
- client_journal_syslog_clients_ca_contenu is search('BEGIN CERTIFICATE')
|
|
||||||
fail_msg: >-
|
|
||||||
client_journal_syslog_tls est vrai, mais aucune autorite d'emetteur n'est fournie
|
|
||||||
(client_journal_syslog_clients_ca_contenu) : le recepteur refuserait tout le monde.
|
|
||||||
when:
|
|
||||||
- client_journal_syslog_ecoute | length > 0
|
|
||||||
- client_journal_syslog_tls | bool
|
|
||||||
|
|
||||||
- name: Syslog TLS — poser les autorites des emetteurs admis
|
|
||||||
ansible.builtin.copy:
|
|
||||||
content: "{{ client_journal_syslog_clients_ca_contenu }}"
|
|
||||||
dest: "{{ client_journal_syslog_tls_dir }}/clients.crt"
|
|
||||||
owner: "{{ client_journal_utilisateur }}"
|
|
||||||
group: "{{ client_journal_utilisateur }}"
|
|
||||||
mode: "0644"
|
|
||||||
notify: Redemarrer alloy
|
|
||||||
when:
|
|
||||||
- client_journal_syslog_ecoute | length > 0
|
|
||||||
- client_journal_syslog_tls | bool
|
|
||||||
|
|
||||||
- name: Poser l'autorite qui signe Loki (machine sans PKI)
|
|
||||||
ansible.builtin.copy:
|
|
||||||
content: "{{ client_journal_loki_ca_contenu }}"
|
|
||||||
dest: "{{ client_journal_loki_ca }}"
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0644"
|
|
||||||
notify: Redemarrer alloy
|
|
||||||
when: client_journal_loki_ca_contenu | length > 0
|
|
||||||
|
|
||||||
- name: Deployer la configuration Alloy
|
- name: Deployer la configuration Alloy
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: config.alloy.j2
|
src: config.alloy.j2
|
||||||
|
|
@ -292,17 +194,12 @@
|
||||||
notify: Redemarrer alloy
|
notify: Redemarrer alloy
|
||||||
|
|
||||||
# Le port d'ecoute est IMPOSE ici plutot que subi : voir defaults/main.yml.
|
# 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:
|
ansible.builtin.lineinfile:
|
||||||
path: /etc/default/alloy
|
path: /etc/default/alloy
|
||||||
regexp: "^CUSTOM_ARGS="
|
regexp: "^CUSTOM_ARGS="
|
||||||
line: >-
|
line: >-
|
||||||
CUSTOM_ARGS="--server.http.listen-addr={{ client_journal_http_adresse }}:{{ client_journal_http_port }} --disable-reporting"
|
CUSTOM_ARGS="--server.http.listen-addr={{ client_journal_http_adresse }}:{{ client_journal_http_port }}"
|
||||||
create: false
|
create: false
|
||||||
notify: Redemarrer alloy
|
notify: Redemarrer alloy
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,16 +141,6 @@ loki.source.syslog "frontiere" {
|
||||||
protocol = "{{ client_journal_syslog_protocole }}"
|
protocol = "{{ client_journal_syslog_protocole }}"
|
||||||
label_structured_data = true
|
label_structured_data = true
|
||||||
labels = { job = "syslog", host = "{{ client_journal_syslog_emetteur }}" }
|
labels = { job = "syslog", host = "{{ client_journal_syslog_emetteur }}" }
|
||||||
{% if client_journal_syslog_tls | bool %}
|
|
||||||
// TLS, ET UN CERTIFICAT CLIENT EXIGE : un `ca_file` suffit a l'exiger dans ce
|
|
||||||
// recepteur (`client_auth_type` n'y est pas accepte — `alloy validate`, 2026-10-12).
|
|
||||||
tls_config {
|
|
||||||
cert_file = "{{ client_journal_syslog_tls_dir }}/alloy.crt"
|
|
||||||
key_file = "{{ client_journal_syslog_tls_dir }}/alloy.key"
|
|
||||||
ca_file = "{{ client_journal_syslog_tls_dir }}/clients.crt"
|
|
||||||
min_version = "TLS12"
|
|
||||||
}
|
|
||||||
{% endif %}
|
|
||||||
}
|
}
|
||||||
forward_to = [loki.relabel.syslog.receiver]
|
forward_to = [loki.relabel.syslog.receiver]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
# Gere par Set-OPS (role client_journal). Ne pas editer a la main.
|
|
||||||
[Unit]
|
|
||||||
Description=Set-OPS — surveille le certificat step_ca du recepteur syslog d'Alloy
|
|
||||||
|
|
||||||
[Path]
|
|
||||||
PathChanged={{ client_journal_syslog_tls_source_cert }}
|
|
||||||
Unit=setops-alloy-cert-sync.service
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
# Gere par Set-OPS (role client_journal). Ne pas editer a la main.
|
|
||||||
[Unit]
|
|
||||||
Description=Set-OPS — synchronise le certificat step_ca vers le recepteur syslog d'Alloy
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
ExecStart=/usr/local/sbin/setops-alloy-cert-sync
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
# Gere par Set-OPS (role client_journal). Ne pas editer a la main.
|
|
||||||
# Recopie le certificat step_ca (client_pki, root:root) la ou `alloy` le lit, pour le
|
|
||||||
# recepteur syslog en TLS. Idempotent ; appele au deploiement ET au renouvellement (.path).
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
SRC_CERT="{{ client_journal_syslog_tls_source_cert }}"
|
|
||||||
SRC_KEY="{{ client_journal_syslog_tls_source_cle }}"
|
|
||||||
DST="{{ client_journal_syslog_tls_dir }}"
|
|
||||||
|
|
||||||
[ -f "$SRC_CERT" ] && [ -f "$SRC_KEY" ] || exit 0
|
|
||||||
|
|
||||||
install -o {{ client_journal_utilisateur }} -g {{ client_journal_utilisateur }} -m 0644 "$SRC_CERT" "$DST/alloy.crt"
|
|
||||||
install -o {{ client_journal_utilisateur }} -g {{ client_journal_utilisateur }} -m 0600 "$SRC_KEY" "$DST/alloy.key"
|
|
||||||
|
|
||||||
# Le recepteur lit son certificat au demarrage : un nouveau certificat sur disque resterait
|
|
||||||
# sans effet jusqu'au prochain redemarrage (renouvellement-cert-recharger-consommateur).
|
|
||||||
systemctl try-restart {{ client_journal_service }} 2>/dev/null || true
|
|
||||||
|
|
@ -110,11 +110,6 @@
|
||||||
when: not (common_packages_desarmer_maj_auto | bool)
|
when: not (common_packages_desarmer_maj_auto | bool)
|
||||||
failed_when: false # une unite absente sur cette image n'est pas une faute
|
failed_when: false # une unite absente sur cette image n'est pas une faute
|
||||||
|
|
||||||
# AVANT TOUTE OPERATION APT : pas de rattrapage herite du gabarit (voir minuteurs.yml).
|
|
||||||
- name: Empecher le rattrapage des minuteurs apt herite du gabarit
|
|
||||||
ansible.builtin.include_tasks: minuteurs.yml
|
|
||||||
when: not (common_packages_desarmer_maj_auto | bool)
|
|
||||||
|
|
||||||
- name: Mettre à jour le cache APT
|
- name: Mettre à jour le cache APT
|
||||||
ansible.builtin.apt:
|
ansible.builtin.apt:
|
||||||
update_cache: true
|
update_cache: true
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
---
|
|
||||||
# LE RATTRAPAGE HERITE DU GABARIT (2026-10-08).
|
|
||||||
#
|
|
||||||
# `apt-daily.timer` et `apt-daily-upgrade.timer` sont PERSISTANTS : systemd garde la date
|
|
||||||
# de leur dernier passage (`/var/lib/systemd/timers/stamp-*`) et rattrape un passage
|
|
||||||
# manque. Une VM clonee herite de ces traces — celle d'`apt-daily` datait du 2026-06-21,
|
|
||||||
# jour de fabrication du gabarit. Elle « rattrape » donc des son demarrage, avec un delai
|
|
||||||
# aleatoire jusqu'a 60 min : a la reconstruction de Technolibre, `apt-daily-upgrade` est
|
|
||||||
# parti cinq minutes apres la naissance d'`infra-pki-01`, en pleine serie d'operations apt
|
|
||||||
# du role. Il tenait le verrou du CACHE DES ARCHIVES, qu'`apt-get` n'attend jamais
|
|
||||||
# (`DPkg::Lock::Timeout` ne couvre que dpkg) :
|
|
||||||
#
|
|
||||||
# 'apt-get autoremove' failed: E: Could not get lock /var/cache/apt/archives/lock.
|
|
||||||
#
|
|
||||||
# Une trace plus ancienne que la naissance de la machine n'est pas la sienne : on la remet a
|
|
||||||
# l'heure, et on relance le minuteur, qui recalcule son prochain passage (le matin suivant)
|
|
||||||
# sans rattraper. Les mises a jour automatiques restent armees (decision du 2026-09-11) ;
|
|
||||||
# seul disparait un passage herite. Sur une machine en service, la trace est recente :
|
|
||||||
# rien ne change.
|
|
||||||
- name: Naissance de la machine (cle d'hote SSH, posee au premier demarrage)
|
|
||||||
ansible.builtin.stat:
|
|
||||||
path: /etc/ssh/ssh_host_ed25519_key.pub
|
|
||||||
register: common_packages_naissance
|
|
||||||
|
|
||||||
- name: Traces des minuteurs apt persistants
|
|
||||||
ansible.builtin.stat:
|
|
||||||
path: "/var/lib/systemd/timers/stamp-{{ item }}"
|
|
||||||
loop: "{{ common_packages_unites_maj_auto | select('match', '.*[.]timer$') | list }}"
|
|
||||||
register: common_packages_traces
|
|
||||||
|
|
||||||
- name: Remettre a l'heure une trace heritee du gabarit
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: "{{ item.stat.path }}"
|
|
||||||
state: touch
|
|
||||||
mode: "0644"
|
|
||||||
loop: >-
|
|
||||||
{{ common_packages_traces.results | selectattr('stat.exists')
|
|
||||||
| selectattr('stat.mtime', 'lt', common_packages_naissance.stat.mtime | default(0))
|
|
||||||
| list }}
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.item }}"
|
|
||||||
register: common_packages_traces_remises
|
|
||||||
when: common_packages_naissance.stat.exists
|
|
||||||
|
|
||||||
# Relancer recalcule le prochain passage d'apres la trace neuve. Relancer un minuteur ne
|
|
||||||
# declenche pas sa cible.
|
|
||||||
- name: Relancer les minuteurs dont la trace a ete remise
|
|
||||||
ansible.builtin.systemd_service:
|
|
||||||
name: "{{ item.item.item }}"
|
|
||||||
state: restarted
|
|
||||||
loop: "{{ common_packages_traces_remises.results | default([]) | selectattr('changed') | list }}"
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.item.item }}"
|
|
||||||
|
|
||||||
# UN PASSAGE DEJA PARTI se termine avant nos operations apt, plutot que de se les disputer.
|
|
||||||
# `is-active` rend 0 si l'une au moins des unites tourne.
|
|
||||||
- name: Attendre la fin d'un passage apt automatique deja en cours
|
|
||||||
ansible.builtin.command:
|
|
||||||
argv: ["systemctl", "is-active", "--quiet", "apt-daily.service", "apt-daily-upgrade.service"]
|
|
||||||
register: common_packages_passage
|
|
||||||
changed_when: false
|
|
||||||
failed_when: false
|
|
||||||
until: common_packages_passage.rc != 0
|
|
||||||
retries: 30
|
|
||||||
delay: 10
|
|
||||||
|
|
@ -7,25 +7,25 @@ table inet setops_filter {
|
||||||
chain input {
|
chain input {
|
||||||
type filter hook input priority 0; policy {{ nftables_baseline_input_policy }};
|
type filter hook input priority 0; policy {{ nftables_baseline_input_policy }};
|
||||||
|
|
||||||
iif "lo" accept comment "socle: communications locales"
|
iif "lo" accept
|
||||||
ct state established,related accept comment "socle: connexions etablies et associees"
|
ct state established,related accept
|
||||||
ct state invalid drop comment "socle: paquets invalides"
|
ct state invalid drop
|
||||||
|
|
||||||
{% if nftables_baseline_allow_icmp | bool %}
|
{% if nftables_baseline_allow_icmp | bool %}
|
||||||
ip protocol icmp accept comment "socle: diagnostic et erreurs reseau IPv4"
|
ip protocol icmp accept
|
||||||
ip6 nexthdr icmpv6 accept comment "socle: diagnostic et erreurs reseau IPv6"
|
ip6 nexthdr icmpv6 accept
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if nftables_baseline_allow_ssh | bool %}
|
{% if nftables_baseline_allow_ssh | bool %}
|
||||||
tcp dport {{ nftables_baseline_ssh_port }} accept comment "administration: acces SSH du gabarit de repli"
|
tcp dport {{ nftables_baseline_ssh_port }} accept
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for port in nftables_baseline_extra_tcp_ports %}
|
{% for port in nftables_baseline_extra_tcp_ports %}
|
||||||
tcp dport {{ port }} accept comment "repli: port TCP supplementaire declare par l'instance"
|
tcp dport {{ port }} accept
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% for port in nftables_baseline_extra_udp_ports %}
|
{% for port in nftables_baseline_extra_udp_ports %}
|
||||||
udp dport {{ port }} accept comment "repli: port UDP supplementaire declare par l'instance"
|
udp dport {{ port }} accept
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,9 @@ quarante minutes sur la **première** machine.
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **dépôts apt** | ce rôle — un cache, servi à la flotte |
|
| **dépôts apt** | ce rôle — un cache, servi à la flotte |
|
||||||
| **téléchargements directs** (Forgejo, Keycloak, Nextcloud et ses applications, oauth2-proxy) | ce rôle — le **dépôt de binaires** (`/var/lib/setops/artefacts-directs`, servi sous `/setops-binaires` sur le même port) |
|
| **téléchargements directs** (Forgejo, Keycloak, Nextcloud, oauth2-proxy, collections) | le **cache du contrôleur** : il télécharge une fois et pousse par SSH |
|
||||||
| **ce que le runner installe** (roues Python, collections Ansible, `verrou-runner.yml`) | ce rôle — le même dépôt de binaires |
|
|
||||||
|
|
||||||
Les rôles consommateurs prennent ces fichiers au dépôt du site d'abord, vérifient leur somme
|
Deux mécanismes parce que ce sont deux problèmes : on ne sert pas un dépôt apt par `scp`.
|
||||||
(ou leur signature), et ne sortent sur Internet qu'en repli, en le disant. Depuis le
|
|
||||||
2026-10-09, une reconstruction ne demande plus rien à PyPI, Galaxy ni à l'app store de
|
|
||||||
Nextcloud quand le dépôt est rempli.
|
|
||||||
|
|
||||||
## Ce qu'il ne couvre pas encore
|
## Ce qu'il ne couvre pas encore
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -199,14 +199,6 @@
|
||||||
label: "{{ item }}"
|
label: "{{ item }}"
|
||||||
when: serveur_artefacts_directs_actif | bool
|
when: serveur_artefacts_directs_actif | bool
|
||||||
|
|
||||||
# CE QUE LE RUNNER INSTALLE (2026-10-09) : ses roues Python et ses collections, verrouillees
|
|
||||||
# a l'octet dans `verrou-runner.yml`, a la racine du moteur. Lues, pas recopiees.
|
|
||||||
- name: Lire le verrou du runner (roues Python, collections Ansible)
|
|
||||||
ansible.builtin.include_vars:
|
|
||||||
file: "{{ role_path }}/../../verrou-runner.yml"
|
|
||||||
name: serveur_artefacts_verrou_runner
|
|
||||||
when: serveur_artefacts_directs_actif | bool
|
|
||||||
|
|
||||||
# `force: false` ET UN `dest` NOMME PAR LA VERSION : une publication ne change pas sous
|
# `force: false` ET UN `dest` NOMME PAR LA VERSION : une publication ne change pas sous
|
||||||
# le meme nom, donc un fichier deja present est le bon. Une nouvelle version porte un
|
# le meme nom, donc un fichier deja present est le bon. Une nouvelle version porte un
|
||||||
# autre nom et se telecharge d'elle-meme — le depot se met a jour en suivant les roles,
|
# autre nom et se telecharge d'elle-meme — le depot se met a jour en suivant les roles,
|
||||||
|
|
@ -219,18 +211,10 @@
|
||||||
ansible.builtin.get_url:
|
ansible.builtin.get_url:
|
||||||
url: "{{ item.url }}"
|
url: "{{ item.url }}"
|
||||||
dest: "{{ serveur_artefacts_directs_dir }}/{{ item.nom }}"
|
dest: "{{ serveur_artefacts_directs_dir }}/{{ item.nom }}"
|
||||||
checksum: "{{ ('sha256:' ~ item.sha256) if item.sha256 is defined else omit }}"
|
|
||||||
mode: "0644"
|
mode: "0644"
|
||||||
force: false
|
force: false
|
||||||
timeout: 60
|
timeout: 60
|
||||||
# Les applications de Nextcloud (2026-10-08) : declarees par `serveur_nextcloud`, lues
|
loop: "{{ serveur_artefacts_directs }}"
|
||||||
# telles quelles — meme `nom`, meme `url`, rien de recopie ici.
|
|
||||||
# Et le verrou du runner (2026-10-09). Un artefact qui declare sa somme est verifie ici :
|
|
||||||
# le site ne tient pas un fichier altere en chemin.
|
|
||||||
loop: >-
|
|
||||||
{{ serveur_artefacts_directs + (serveur_nextcloud_apps | default([]))
|
|
||||||
+ (serveur_artefacts_verrou_runner.python | default([]))
|
|
||||||
+ (serveur_artefacts_verrou_runner.collections | default([])) }}
|
|
||||||
loop_control:
|
loop_control:
|
||||||
label: "{{ item.nom }}"
|
label: "{{ item.nom }}"
|
||||||
register: serveur_artefacts_directs_tires
|
register: serveur_artefacts_directs_tires
|
||||||
|
|
|
||||||
|
|
@ -62,14 +62,9 @@ for noeud in {{ serveur_backup_noeuds_attendus | sort | join(' ') }}; do
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Le plus recent de tous les groupes (machine ET liste de chemins), pas le premier : voir
|
|
||||||
# `client_backup/templates/verifier-mon-depot.sh.j2` (2026-10-08).
|
|
||||||
json=$(restic -r "${depot}" snapshots --latest 1 --json 2>/dev/null)
|
json=$(restic -r "${depot}" snapshots --latest 1 --json 2>/dev/null)
|
||||||
horodatage=$(printf '%s' "${json}" | python3 -c \
|
horodatage=$(printf '%s' "${json}" | python3 -c \
|
||||||
'import sys,json,re,datetime
|
'import sys,json;d=json.load(sys.stdin);print(d[0]["time"] if d else "")' 2>/dev/null)
|
||||||
d=json.load(sys.stdin) or []
|
|
||||||
t=lambda x: datetime.datetime.fromisoformat(re.sub(r"Z$","+00:00",re.sub(r"(\.\d{6})\d+",r"\1",x["time"]))).timestamp()
|
|
||||||
print(max(d,key=t)["time"] if d else "")' 2>/dev/null)
|
|
||||||
if [[ -z "${horodatage}" ]]; then
|
if [[ -z "${horodatage}" ]]; then
|
||||||
rapporter "${noeud}" 2 "AUCUN INSTANTANE dans ${depot} — depot present mais vide."
|
rapporter "${noeud}" 2 "AUCUN INSTANTANE dans ${depot} — depot present mais vide."
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,5 @@ flux:
|
||||||
pair: externe
|
pair: externe
|
||||||
chiffrement: tls-requis
|
chiffrement: tls-requis
|
||||||
raison: >-
|
raison: >-
|
||||||
Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga), et le
|
Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga). Le cache
|
||||||
dépôt de binaires (roues PyPI, collections Galaxy, versions GitHub, Nextcloud,
|
les relaie pour que la flotte n'ait pas à sortir elle-même.
|
||||||
Forgejo). Le site les relaie pour que la flotte n'ait pas à sortir elle-même.
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,6 @@
|
||||||
# Surcharge de l'unité du paquet. Le `coolwsd.xml` livré (439 lignes commentées) reste
|
# 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
|
# intact : le remplacer par un gabarit maison obligerait à suivre son évolution à chaque
|
||||||
# version amont, et ferait perdre les commentaires qui expliquent chaque réglage.
|
# 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]
|
[Service]
|
||||||
ExecStart=
|
ExecStart=
|
||||||
ExecStart=/usr/bin/coolwsd --version --o:sys_template_path=/opt/cool/systemplate \
|
ExecStart=/usr/bin/coolwsd --version --o:sys_template_path=/opt/cool/systemplate \
|
||||||
|
|
@ -16,8 +12,6 @@ ExecStart=/usr/bin/coolwsd --version --o:sys_template_path=/opt/cool/systemplate
|
||||||
--o:ssl.termination={{ serveur_collabora_ssl_termination | bool | lower }} \
|
--o:ssl.termination={{ serveur_collabora_ssl_termination | bool | lower }} \
|
||||||
--o:server_name={{ serveur_collabora_server_name }} \
|
--o:server_name={{ serveur_collabora_server_name }} \
|
||||||
--o:storage.wopi.alias_group[0].host=https://{{ serveur_collabora_domaine_nextcloud }}:443 \
|
--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.enable={{ serveur_collabora_admin_actif | bool | lower }}{% if serveur_collabora_admin_actif | bool %} \
|
||||||
--o:admin_console.username={{ serveur_collabora_admin_utilisateur }} \
|
--o:admin_console.username={{ serveur_collabora_admin_utilisateur }} \
|
||||||
--o:admin_console.password={{ serveur_collabora_admin_mot_de_passe }}{% endif %}
|
--o:admin_console.password={{ serveur_collabora_admin_mot_de_passe }}{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -9,39 +9,22 @@ flux:
|
||||||
chiffrement: ssh
|
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)."
|
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 apt directe : SEULEMENT SANS AMONT D'ARTEFACTS (2026-10-10) ---
|
# --- Sortie générale de la flotte ---
|
||||||
#
|
# Portée par le socle parce qu'elle vaut pour TOUS les hôtes, sans exception.
|
||||||
# C'ÉTAIT UNE SORTIE 80/443 VERS TOUT INTERNET, POUR TOUTE MACHINE. Sa raison, « dépôts
|
# Non déclarée, la frontière la bloquerait : son `block out` final est un vrai
|
||||||
# apt », n'était plus vraie chez les locataires : leurs 26 machines prennent TOUTES leurs
|
# default-deny, et la flotte perdrait ses mises à jour et son horloge en silence.
|
||||||
# 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
|
- sens: egress
|
||||||
port: 443
|
port: 443
|
||||||
protocole: tcp
|
protocole: tcp
|
||||||
pair: externe
|
pair: externe
|
||||||
chiffrement: tls-requis
|
chiffrement: tls-requis
|
||||||
seulement_si: {variable: artefacts_amorcage, non_vide: false}
|
raison: "Dépôts apt en HTTPS (Debian, Smallstep, Grafana, Icinga, Forgejo, Nextcloud) — sans quoi aucun correctif de sécurité n'entre."
|
||||||
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
|
- sens: egress
|
||||||
port: 80
|
port: 80
|
||||||
protocole: tcp
|
protocole: tcp
|
||||||
pair: externe
|
pair: externe
|
||||||
chiffrement: clair
|
chiffrement: clair
|
||||||
seulement_si: {variable: artefacts_amorcage, non_vide: false}
|
raison: "Dépôts apt en clair et redirections HTTP des miroirs (l'intégrité vient de la signature des paquets, pas du transport)."
|
||||||
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) ---
|
# --- Decouverte de MTU de chemin (PMTUD) ---
|
||||||
# L'overlay EVPN plafonne a 1450 : tout ce qui traverse la frontiere depend de ces
|
# 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
|
# deux messages. Bloques, la connexion s'etablit, les petites requetes passent et les
|
||||||
|
|
|
||||||
|
|
@ -34,15 +34,16 @@ bout-en-bout en mono-domaine.
|
||||||
| `serveur_dovecot_ldap_filter` | `(&(objectClass=inetOrgPerson)(mail=%{user}))` | Login = adresse courriel |
|
| `serveur_dovecot_ldap_filter` | `(&(objectClass=inetOrgPerson)(mail=%{user}))` | Login = adresse courriel |
|
||||||
| `serveur_dovecot_tls_actif` | `true` | Pont de certificat step_ca |
|
| `serveur_dovecot_tls_actif` | `true` | Pont de certificat step_ca |
|
||||||
| `serveur_dovecot_lmtp_reseau` | `true` | LMTP :24 pour un MTA **distant** (edge-mta) |
|
| `serveur_dovecot_lmtp_reseau` | `true` | LMTP :24 pour un MTA **distant** (edge-mta) |
|
||||||
|
| `serveur_dovecot_sasl_reseau` | `false` | Auth SASL réseau :12345 pour la soumission :587 |
|
||||||
|
|
||||||
## Flux (`meta/flux.yml`)
|
## Flux (`meta/flux.yml`)
|
||||||
`993/tcp` IMAPS (utilisateurs) · `24/tcp` LMTP ← Postfix (TLS vérifié) ·
|
`993/tcp` IMAPS (utilisateurs) · `24/tcp` LMTP ← Postfix (TLS vérifié) ·
|
||||||
`636/tcp` → OpenLDAP (userdb/passdb).
|
`12345/tcp` SASL ← Postfix · `636/tcp` → OpenLDAP (userdb/passdb).
|
||||||
|
|
||||||
## Notes / limites
|
## Notes / limites
|
||||||
- **Mono-domaine** : le chemin ne conserve pas le domaine. Le multi-domaine demandera de le
|
- **Mono-domaine** : le chemin ne conserve pas le domaine. Le multi-domaine demandera de le
|
||||||
préserver des deux côtés (raffinement Étape B).
|
préserver des deux côtés (raffinement Étape B).
|
||||||
- LMTP **réseau** doit être restreint au nœud MTA par pare-feu — c'est ce que
|
- LMTP et SASL **réseau** doivent être restreints au nœud MTA par pare-feu — c'est ce que
|
||||||
fait `nftables_baseline` à partir de `meta/flux.yml` (`pair: serveur_postfix`).
|
fait `nftables_baseline` à partir de `meta/flux.yml` (`pair: serveur_postfix`).
|
||||||
- Si Postfix est **co-localisé**, le rôle bascule sur les sockets sous le chroot
|
- Si Postfix est **co-localisé**, le rôle bascule sur les sockets sous le chroot
|
||||||
(`/var/spool/postfix/private`) plutôt que sur le réseau (détection automatique).
|
(`/var/spool/postfix/private`) plutôt que sur le réseau (détection automatique).
|
||||||
|
|
@ -51,13 +52,3 @@ bout-en-bout en mono-domaine.
|
||||||
## Prérequis
|
## Prérequis
|
||||||
- `serveur_dovecot requiert serveur_openldap actif` (`docs/dependances-groupes.yml`).
|
- `serveur_dovecot requiert serveur_openldap actif` (`docs/dependances-groupes.yml`).
|
||||||
- `client_pki` appliqué sur le nœud (couche `pki_client`, avant la couche `services`).
|
- `client_pki` appliqué sur le nœud (couche `pki_client`, avant la couche `services`).
|
||||||
- **Authentification de la soumission : locale à Postfix** (2026-10-12). Le port SASL
|
|
||||||
réseau `12345` est retiré : le client SASL Dovecot de Postfix ne chiffre pas, et les mots
|
|
||||||
de passe y passaient en clair. `serveur_postfix` appelle `tasks/auth_locale.yml`, qui
|
|
||||||
pose sur sa machine un Dovecot d'**authentification seule** (cœur + LDAP, aucun port),
|
|
||||||
joint par un socket local et vérifiant sur l'annuaire en LDAPS.
|
|
||||||
- **IMAP en TLS direct seulement** (2026-10-12) : l'écoute en clair 143, ouverte par défaut,
|
|
||||||
est éteinte (`inet_listener imap { port = 0 }`) ; seul le 993 répond.
|
|
||||||
- **Sonde « boites »** : `doveadm auth lookup` du compte d'amorçage (`amorcage_acces_courriel`),
|
|
||||||
c'est-à-dire une recherche dans le **passdb** LDAP, sans mot de passe. `doveadm user` ne
|
|
||||||
prouvait rien : le userdb `static` (`static_allow_all_users`) répond pour n'importe quel nom.
|
|
||||||
|
|
|
||||||
|
|
@ -40,14 +40,11 @@ serveur_dovecot_postfix_socket_dir: "/var/spool/postfix/private"
|
||||||
serveur_dovecot_lmtp_reseau: true
|
serveur_dovecot_lmtp_reseau: true
|
||||||
serveur_dovecot_lmtp_port: 24
|
serveur_dovecot_lmtp_port: 24
|
||||||
|
|
||||||
# --- Authentification de la soumission : LOCALE A POSTFIX, plus jamais par le reseau ---
|
# --- Auth SASL réseau : pour la soumission :587 servie par un Postfix DISTANT (edge-mta).
|
||||||
# Jusqu'au 2026-10-12, Postfix (edge-mta) interrogeait ce Dovecot sur :12345 ; le client
|
# Dovecot vérifie le mot de passe (passdb LDAP prouvé) pour Postfix. À restreindre au MTA
|
||||||
# SASL Dovecot de Postfix ne chiffre pas, et les mots de passe passaient en clair. Ce port
|
# par pare-feu ; le trafic d'auth devrait être isolé/chiffré en prod.
|
||||||
# n'existe plus : `serveur_postfix` pose a cote de lui un Dovecot d'authentification seule
|
serveur_dovecot_sasl_reseau: false
|
||||||
# (`tasks/auth_locale.yml`), qui verifie sur l'annuaire en LDAPS.
|
serveur_dovecot_sasl_port: 12345
|
||||||
serveur_dovecot_auth_locale_paquets:
|
|
||||||
- dovecot-core
|
|
||||||
- dovecot-ldap
|
|
||||||
|
|
||||||
# TLS sur le port LMTP. Doit rester ACCORDE avec `serveur_postfix_lmtp_tls_verify` du
|
# TLS sur le port LMTP. Doit rester ACCORDE avec `serveur_postfix_lmtp_tls_verify` du
|
||||||
# cote emetteur : l'un sans l'autre, la livraison interne s'arrete — soit elle part en
|
# cote emetteur : l'un sans l'autre, la livraison interne s'arrete — soit elle part en
|
||||||
|
|
@ -55,13 +52,7 @@ serveur_dovecot_auth_locale_paquets:
|
||||||
serveur_dovecot_lmtp_tls: true
|
serveur_dovecot_lmtp_tls: true
|
||||||
|
|
||||||
# --- Sonde de supervision -----------------------------------------------------------
|
# --- Sonde de supervision -----------------------------------------------------------
|
||||||
# UN COMPTE QUI DOIT EXISTER, sous son IDENTIFIANT DE CONNEXION (l'attribut `mail` : c'est
|
# UN COMPTE QUI DOIT EXISTER. Le resoudre prouve la chaine entiere : demon, configuration,
|
||||||
# ce que cherche le filtre de connexion). Le trouver prouve la chaine : demon, configuration,
|
# bind LDAP, mappage userdb. Variable, donc mise en defaut avec un nom inexistant sans
|
||||||
# liaison LDAPS, filtre. Variable, donc mise en defaut avec un nom inexistant sans toucher a
|
# toucher a l'annuaire.
|
||||||
# l'annuaire.
|
serveur_dovecot_sonde_utilisateur: "daniel"
|
||||||
#
|
|
||||||
# Le compte d'amorcage, pas un nom ecrit (2026-10-12) : ce defaut valait `daniel`, qui
|
|
||||||
# n'existe chez aucun locataire — et la sonde, qui interrogeait le userdb statique, le disait
|
|
||||||
# trouve. Le compte d'amorcage est le seul que Set-OPS sait exister ; son adresse est un
|
|
||||||
# intrant obligatoire de toute instance.
|
|
||||||
serveur_dovecot_sonde_utilisateur: "{{ amorcage_acces_courriel | default('') }}"
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,12 @@ flux:
|
||||||
pair: serveur_postfix
|
pair: serveur_postfix
|
||||||
chiffrement: tls-requis
|
chiffrement: tls-requis
|
||||||
raison: "Remise LMTP depuis Postfix (edge-mta -> mailstore), en TLS vérifié (lmtp_tls_security_level=verify)."
|
raison: "Remise LMTP depuis Postfix (edge-mta -> mailstore), en TLS vérifié (lmtp_tls_security_level=verify)."
|
||||||
|
- sens: ingress
|
||||||
|
port: 12345
|
||||||
|
protocole: tcp
|
||||||
|
pair: serveur_postfix
|
||||||
|
chiffrement: tls
|
||||||
|
raison: "Authentification SASL déléguée : Postfix valide les identifiants de soumission contre Dovecot."
|
||||||
- sens: ingress
|
- sens: ingress
|
||||||
port: 993
|
port: 993
|
||||||
protocole: tcp
|
protocole: tcp
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
---
|
|
||||||
# AUTHENTIFICATION LOCALE POUR POSTFIX (2026-10-12) — voir templates/auth-locale.conf.j2.
|
|
||||||
#
|
|
||||||
# Appelee par `serveur_postfix` la ou la soumission existe, APRES que Postfix a demarre :
|
|
||||||
# le socket vit dans `/var/spool/postfix/private`, que Postfix cree a son premier demarrage.
|
|
||||||
# Ni IMAP, ni LMTP, ni aucun port : la machine n'entre pas dans le groupe `serveur_dovecot`,
|
|
||||||
# et aucun de ses flux ne s'ouvre ici.
|
|
||||||
- name: Authentification locale — resoudre l'annuaire (role partage)
|
|
||||||
ansible.builtin.include_role:
|
|
||||||
name: resoudre_annuaire
|
|
||||||
vars:
|
|
||||||
# Le compte de Dovecot, comme sur la machine des boites : meme verification, meme droit.
|
|
||||||
resoudre_annuaire_bind_cn: "dovecot"
|
|
||||||
resoudre_annuaire_secret: "vault_ldap_bind_dovecot"
|
|
||||||
|
|
||||||
- name: Authentification locale — adopter la connexion annuaire
|
|
||||||
ansible.builtin.set_fact:
|
|
||||||
serveur_dovecot_ldap_uris: "{{ resoudre_annuaire_uri }}:{{ resoudre_annuaire_port }}"
|
|
||||||
serveur_dovecot_ldap_base: "{{ resoudre_annuaire_base_dn }}"
|
|
||||||
serveur_dovecot_ldap_bind_dn: "{{ resoudre_annuaire_bind_dn }}"
|
|
||||||
serveur_dovecot_ldap_bind_password: "{{ resoudre_annuaire_bind_password }}"
|
|
||||||
no_log: true
|
|
||||||
|
|
||||||
- name: Authentification locale — exiger le mot de passe de liaison (Vault)
|
|
||||||
ansible.builtin.assert:
|
|
||||||
that:
|
|
||||||
- serveur_dovecot_ldap_bind_password | length > 0
|
|
||||||
fail_msg: "vault_ldap_bind_dovecot requis : l'authentification locale se lie a l'annuaire sous le compte de Dovecot."
|
|
||||||
|
|
||||||
# Le coeur et le pilote LDAP, RIEN d'autre : sans `dovecot-imapd` ni `dovecot-lmtpd`, aucun
|
|
||||||
# protocole n'est declare et Dovecot ne lance aucun service de connexion.
|
|
||||||
- name: Authentification locale — installer Dovecot (coeur + LDAP)
|
|
||||||
ansible.builtin.apt:
|
|
||||||
name: "{{ serveur_dovecot_auth_locale_paquets }}"
|
|
||||||
state: present
|
|
||||||
update_cache: true
|
|
||||||
register: serveur_dovecot_auth_locale_apt
|
|
||||||
|
|
||||||
- name: Authentification locale — assurer le repertoire du socket (sous le chroot Postfix)
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: "{{ serveur_dovecot_postfix_socket_dir }}"
|
|
||||||
state: directory
|
|
||||||
owner: postfix
|
|
||||||
group: root
|
|
||||||
mode: "0700"
|
|
||||||
|
|
||||||
- name: Authentification locale — deployer la configuration (drop-in Dovecot 2.4)
|
|
||||||
ansible.builtin.template:
|
|
||||||
src: auth-locale.conf.j2
|
|
||||||
dest: /etc/dovecot/conf.d/99-setops.conf
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0640"
|
|
||||||
no_log: true # contient le mot de passe de liaison LDAP
|
|
||||||
notify: Redémarrer dovecot
|
|
||||||
|
|
||||||
- name: Authentification locale — desactiver l'auth systeme (LDAP est l'unique autorite)
|
|
||||||
ansible.builtin.replace:
|
|
||||||
path: /etc/dovecot/conf.d/10-auth.conf
|
|
||||||
regexp: '^!include auth-system\.conf\.ext'
|
|
||||||
replace: '#!include auth-system.conf.ext'
|
|
||||||
notify: Redémarrer dovecot
|
|
||||||
# En simulation, le paquet qu'on vient d'« installer » n'a pose aucun fichier.
|
|
||||||
when: not (ansible_check_mode and serveur_dovecot_auth_locale_apt is changed)
|
|
||||||
|
|
||||||
- name: Authentification locale — valider la configuration (doveconf)
|
|
||||||
ansible.builtin.command: doveconf -n
|
|
||||||
changed_when: false
|
|
||||||
when: not ansible_check_mode
|
|
||||||
|
|
||||||
- name: Authentification locale — activer et demarrer Dovecot
|
|
||||||
when: not ansible_check_mode
|
|
||||||
ansible.builtin.systemd:
|
|
||||||
name: "{{ serveur_dovecot_service }}"
|
|
||||||
enabled: true
|
|
||||||
state: started
|
|
||||||
daemon_reload: true
|
|
||||||
|
|
@ -15,18 +15,6 @@
|
||||||
serveur_dovecot_ldap_bind_password: "{{ resoudre_annuaire_bind_password }}"
|
serveur_dovecot_ldap_bind_password: "{{ resoudre_annuaire_bind_password }}"
|
||||||
no_log: true
|
no_log: true
|
||||||
|
|
||||||
# UNE INSTANCE QUI DEMANDE ENCORE L'ANCIEN PORT (2026-10-12). `serveur_dovecot_sasl_reseau`
|
|
||||||
# ouvrait :12345, ou Postfix envoyait les mots de passe de soumission EN CLAIR. Le gabarit
|
|
||||||
# ne le lit plus ; une instance qui le pose encore croirait l'avoir — on le dit.
|
|
||||||
- name: Refuser l'authentification reseau retiree (serveur_dovecot_sasl_reseau)
|
|
||||||
ansible.builtin.assert:
|
|
||||||
that:
|
|
||||||
- not (serveur_dovecot_sasl_reseau | default(false) | bool)
|
|
||||||
fail_msg: >-
|
|
||||||
`serveur_dovecot_sasl_reseau` est retire (2026-10-12) : il faisait passer les mots de
|
|
||||||
passe de soumission en clair. Postfix verifie desormais par un Dovecot local
|
|
||||||
(serveur_dovecot/tasks/auth_locale.yml). Retire la ligne des group_vars de l'instance.
|
|
||||||
|
|
||||||
- name: Exiger le mot de passe de bind LDAP (Vault)
|
- name: Exiger le mot de passe de bind LDAP (Vault)
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
that:
|
that:
|
||||||
|
|
|
||||||
|
|
@ -23,16 +23,6 @@ ssl_server_cert_file = {{ serveur_dovecot_tls_dir }}/cert.pem
|
||||||
ssl_server_key_file = {{ serveur_dovecot_tls_dir }}/key.pem
|
ssl_server_key_file = {{ serveur_dovecot_tls_dir }}/key.pem
|
||||||
ssl_min_protocol = TLSv1.2
|
ssl_min_protocol = TLSv1.2
|
||||||
|
|
||||||
{# LE 143 NE S'OUVRE PLUS (2026-10-12). L'IMAP en clair ecoutait par defaut a cote du 993 :
|
|
||||||
le pare-feu le bloquait et `ssl = required` refusait toute connexion avant STARTTLS, mais
|
|
||||||
le port repondait. Directive : tout transit chiffre — on n'offre que le TLS direct (993,
|
|
||||||
RFC 8314). `port = 0` eteint l'ecoute ; eprouve sur banc Debian 13 (le 993 reste). #}
|
|
||||||
service imap-login {
|
|
||||||
inet_listener imap {
|
|
||||||
port = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Auth / annuaire LDAP (serveur_openldap) ---
|
# --- Auth / annuaire LDAP (serveur_openldap) ---
|
||||||
auth_mechanisms = plain login
|
auth_mechanisms = plain login
|
||||||
ldap_uris = {{ serveur_dovecot_ldap_uris }}
|
ldap_uris = {{ serveur_dovecot_ldap_uris }}
|
||||||
|
|
@ -79,6 +69,16 @@ service lmtp {
|
||||||
}
|
}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if serveur_dovecot_sasl_reseau %}
|
||||||
|
# --- Auth SASL réseau (soumission :587 depuis le MTA distant edge-mta) ---
|
||||||
|
# À restreindre au nœud MTA par pare-feu.
|
||||||
|
service auth {
|
||||||
|
inet_listener sasl {
|
||||||
|
port = {{ serveur_dovecot_sasl_port }}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
# --- Sockets pour Postfix (remise LMTP + auth SASL) ---
|
# --- Sockets pour Postfix (remise LMTP + auth SASL) ---
|
||||||
# Seulement si Postfix est co-localisé (l'utilisateur postfix existe). Sinon Dovecot
|
# Seulement si Postfix est co-localisé (l'utilisateur postfix existe). Sinon Dovecot
|
||||||
# tourne en autonome (IMAP) ; les sockets seront ajoutés au déploiement de serveur_postfix.
|
# tourne en autonome (IMAP) ; les sockets seront ajoutés au déploiement de serveur_postfix.
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
# Géré par Set-OPS (rôle serveur_dovecot, authentification locale). Ne pas éditer à la main.
|
|
||||||
# Syntaxe Dovecot 2.4.
|
|
||||||
{#
|
|
||||||
AUTHENTIFICATION SEULE, A COTE DE POSTFIX (2026-10-12).
|
|
||||||
|
|
||||||
Postfix confiait la verification des mots de passe de soumission a la machine des
|
|
||||||
boites, par `smtpd_sasl_path = inet:<mailstore>:12345`. Le client SASL Dovecot de
|
|
||||||
Postfix ne sait pas chiffrer : chaque mot de passe traversait le reseau en clair, et le
|
|
||||||
registre le disait `tls`. Directive du 2026-10-12 : tout transit chiffre.
|
|
||||||
|
|
||||||
On ne chiffre pas ce flux, on le supprime. Ce Dovecot-ci ne sert ni IMAP ni LMTP (aucun
|
|
||||||
paquet de protocole n'est installe) et n'ecoute AUCUN port : Postfix lui parle par un
|
|
||||||
socket local, et il verifie le mot de passe sur l'annuaire en LDAPS — le chemin que
|
|
||||||
Postfix emprunte deja sur cette machine. Le mot de passe ne voyage plus que dans TLS.
|
|
||||||
|
|
||||||
Eprouve sur banc Debian 13 avant d'ecrire le role : processus `dovecot`, `auth`,
|
|
||||||
`anvil`, `log`, `config` seulement ; 235 avec le bon mot de passe, 535 avec un faux,
|
|
||||||
AUTH non offert hors TLS.
|
|
||||||
#}
|
|
||||||
dovecot_config_version = 2.4.0
|
|
||||||
|
|
||||||
# Garder l'adresse complète (user@domaine) comme identité — domaines virtuels.
|
|
||||||
auth_username_format = %{user}
|
|
||||||
auth_mechanisms = plain login
|
|
||||||
ldap_uris = {{ serveur_dovecot_ldap_uris }}
|
|
||||||
ldap_base = {{ serveur_dovecot_ldap_base }}
|
|
||||||
ldap_auth_dn = {{ serveur_dovecot_ldap_bind_dn }}
|
|
||||||
ldap_auth_dn_password = {{ serveur_dovecot_ldap_bind_password }}
|
|
||||||
|
|
||||||
{# Le meme passdb que la machine des boites : une identite, un mot de passe, une regle.
|
|
||||||
Pas de userdb : une verification SASL n'en consulte aucun. #}
|
|
||||||
passdb ldap {
|
|
||||||
ldap_filter = {{ serveur_dovecot_ldap_filter }}
|
|
||||||
ldap_bind = yes
|
|
||||||
}
|
|
||||||
|
|
||||||
service auth {
|
|
||||||
unix_listener {{ serveur_dovecot_postfix_socket_dir }}/auth {
|
|
||||||
user = postfix
|
|
||||||
group = postfix
|
|
||||||
mode = 0660
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +1,22 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# GENERE par Set-OPS (role serveur_dovecot). Ne pas editer a la main.
|
# GENERE par Set-OPS (role serveur_dovecot). Ne pas editer a la main.
|
||||||
#
|
#
|
||||||
# SONDE « boites » — Dovecot repond et trouve un compte dans l'annuaire
|
# SONDE « boites » — Dovecot repond et resout un utilisateur dans l'annuaire
|
||||||
#
|
#
|
||||||
# Contrat : docs/supervision-conception.md (API des greffons Nagios) — une ligne, 0/1/2.
|
# Contrat : docs/supervision-conception.md (API des greffons Nagios) — une ligne, 0/1/2.
|
||||||
# Mise en defaut PAR PARAMETRE : `serveur_dovecot_sonde_utilisateur` (compte inexistant).
|
# Mise en defaut PAR PARAMETRE : `serveur_dovecot_sonde_utilisateur` (compte inexistant).
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
UTILISATEUR={{ serveur_dovecot_sonde_utilisateur | quote }}
|
UTILISATEUR={{ serveur_dovecot_sonde_utilisateur }}
|
||||||
|
|
||||||
[[ -n "${UTILISATEUR}" ]] || { echo "Aucun compte a chercher (serveur_dovecot_sonde_utilisateur vide)."; exit 3; }
|
|
||||||
systemctl is-active --quiet dovecot || { echo "Dovecot n'est pas actif."; exit 2; }
|
systemctl is-active --quiet dovecot || { echo "Dovecot n'est pas actif."; exit 2; }
|
||||||
|
|
||||||
# LE PASSDB, PAS LE USERDB (2026-10-12). La sonde demandait `doveadm user`, et le userdb est
|
# ON RESOUT UN UTILISATEUR REEL, PAS UN PING DE SERVICE. C'est le bind LDAP qui tombe le
|
||||||
# `static` avec `static_allow_all_users` (requis par la remise LMTP) : il repond pour
|
# plus souvent, et il ne se voit que quand on demande un compte : le demon reste debout,
|
||||||
# N'IMPORTE QUEL nom. Elle disait « daniel » resolu chez deux locataires ou ce compte
|
# le port reste ouvert, et toute authentification echoue.
|
||||||
# n'existe pas — verte quoi qu'il arrive.
|
sortie=$(doveadm user "${UTILISATEUR}" 2>&1)
|
||||||
#
|
if ! printf '%s' "${sortie}" | grep -qE '^uid[[:space:]]'; then
|
||||||
# `doveadm auth lookup` interroge le passdb : Dovecot cherche le compte dans l'annuaire,
|
echo "Dovecot ne resout pas « ${UTILISATEUR} » — l'annuaire ne repond plus : ${sortie:0:70}"
|
||||||
# sous SON compte de liaison, en LDAPS, avec le filtre de connexion. Aucune tentative de
|
|
||||||
# mot de passe — rien ne compte pour le verrouillage du compte. Mesure : 0 s'il existe,
|
|
||||||
# 67 s'il n'existe pas, une erreur si l'annuaire ne repond plus.
|
|
||||||
sortie=$(doveadm auth lookup "${UTILISATEUR}" 2>&1)
|
|
||||||
rc=$?
|
|
||||||
if [[ ${rc} -eq 67 ]]; then
|
|
||||||
echo "Dovecot ne trouve pas « ${UTILISATEUR} » dans l'annuaire : plus personne de ce nom ne peut se connecter."
|
|
||||||
exit 2
|
|
||||||
elif [[ ${rc} -ne 0 ]] || ! printf '%s' "${sortie}" | grep -q '^passdb: '; then
|
|
||||||
echo "Dovecot n'interroge plus l'annuaire (rc=${rc}) : ${sortie:0:70}"
|
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
echo "Dovecot : « ${UTILISATEUR} » trouve dans l'annuaire (passdb LDAP), service actif."
|
echo "Dovecot : « ${UTILISATEUR} » resolu dans l'annuaire, service actif."
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,7 @@ serveur_forge_site_port: "{{ serveur_forgejo_port | default(443) }}"
|
||||||
# `serveur_ops_depots` nomme des depots sous ce proprietaire, pas ailleurs.
|
# `serveur_ops_depots` nomme des depots sous ce proprietaire, pas ailleurs.
|
||||||
serveur_forge_site_organisation: "genome"
|
serveur_forge_site_organisation: "genome"
|
||||||
|
|
||||||
# COMBIEN DE DEPOTS PUBLICS AU MINIMUM. La sonde lit l'API SANS identifiant : elle voit ce
|
# COMBIEN DE DEPOTS AU MINIMUM. Un site sert au moins le moteur et sa propre carte ; en
|
||||||
# qu'un ecosysteme neuf peut cloner sans rien detenir. Depuis le 2026-10-11, la carte du
|
# dessous de deux, la forge n'a pas de quoi faire naitre un ecosysteme. On ne fixe pas un
|
||||||
# site et les plans des locataires sont PRIVES (`docs/depots-prives-conception.md`) : il
|
# compte exact : le nombre de locataires change, la sonde ne doit pas changer avec.
|
||||||
# reste au moins le moteur. Les depots prives se surveillent ailleurs — `make genome-etat`,
|
serveur_forge_site_sonde_depots_minimum: 2
|
||||||
# `make forge-cles`, et chaque runner qui les tire. On ne fixe pas un compte exact : le
|
|
||||||
# nombre de depots change, la sonde ne doit pas changer avec.
|
|
||||||
serveur_forge_site_sonde_depots_minimum: 1
|
|
||||||
|
|
|
||||||
|
|
@ -39,22 +39,3 @@ flux:
|
||||||
# de le demander : seul le registre distingue « j'ouvre » de « j'autorise une source
|
# de le demander : seul le registre distingue « j'ouvre » de « j'autorise une source
|
||||||
# de plus sur l'écoute d'un autre ».
|
# de plus sur l'écoute d'un autre ».
|
||||||
partage: true
|
partage: true
|
||||||
|
|
||||||
# LA LECTURE DE SON DEPOT PAR LE RUNNER D'UN LOCATAIRE (2026-10-11,
|
|
||||||
# `docs/depots-prives-conception.md`). Les depots des locataires deviennent prives : chacun
|
|
||||||
# se lit avec une cle de deploiement en lecture seule, et une cle de deploiement ne passe
|
|
||||||
# que par SSH. `runners_locataires` : les seuls runners, lus dans les faces publiees — pas
|
|
||||||
# les supernets. L'utilisateur `git` de ce `sshd` est contraint par la forge : la cle n'y
|
|
||||||
# ouvre aucune session, seulement le depot qui l'a enregistree.
|
|
||||||
- sens: ingress
|
|
||||||
port: 22
|
|
||||||
protocole: tcp
|
|
||||||
pair: runners_locataires
|
|
||||||
chiffrement: ssh
|
|
||||||
raison: >-
|
|
||||||
Chaque runner de locataire lit son propre depot du genome, prive, avec sa cle de
|
|
||||||
deploiement en lecture seule. Il ne lit ni le depot d'un autre locataire, ni celui
|
|
||||||
du site.
|
|
||||||
# CE ROLE N'OUVRE AUCUNE ECOUTE — il emprunte le `sshd` du socle, comme
|
|
||||||
# `serveur_ops_tenant` (voir P33).
|
|
||||||
partage: true
|
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ nombre=${lecture%% *}
|
||||||
vides=${lecture#*|}
|
vides=${lecture#*|}
|
||||||
|
|
||||||
if (( nombre < MINIMUM )); then
|
if (( nombre < MINIMUM )); then
|
||||||
echo "La forge du genome ne sert que ${nombre} depot(s) public(s) pour « ${ORG} » (minimum ${MINIMUM}) : un ecosysteme neuf n'a rien a cloner."
|
echo "La forge du genome ne porte que ${nombre} depot(s) pour « ${ORG} » (minimum ${MINIMUM}) : un ecosysteme neuf n'a rien a cloner."
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,16 +47,8 @@ serveur_forgejo_tls_cle: "/etc/step/certs/{{ ansible_fqdn | default(ansible_host
|
||||||
serveur_forgejo_groupe: "serveur_forgejo"
|
serveur_forgejo_groupe: "serveur_forgejo"
|
||||||
serveur_forgejo_db_host: "" # dérivé (FQDN) par resoudre_base au déploiement
|
serveur_forgejo_db_host: "" # dérivé (FQDN) par resoudre_base au déploiement
|
||||||
|
|
||||||
# Relais courriel : DERIVE de l'inventaire, l'hote qui porte `serveur_postfix` (2026-10-12).
|
# Relais courriel.
|
||||||
#
|
serveur_forgejo_smtp: "infra-mail-01.{{ domaine_interne }}"
|
||||||
# Le defaut nommait `infra-mail-01`, la machine des BOITES d'un locataire, ou rien n'ecoute
|
|
||||||
# le port 25 ; au site, ce nom n'existe meme pas. La forge du site n'a donc jamais remis un
|
|
||||||
# seul courriel : « mot de passe oublie » repondait 200 et le journal disait
|
|
||||||
# `lookup infra-mail-01.genese.internal ... server misbehaving`. Sans relais dans
|
|
||||||
# l'ecosysteme, la valeur est vide et le courriel de la forge s'eteint (`app.ini`).
|
|
||||||
serveur_forgejo_smtp: >-
|
|
||||||
{{ ((groups['serveur_postfix'] | default([]) | first) ~ '.' ~ domaine_interne)
|
|
||||||
if (groups['serveur_postfix'] | default([])) else '' }}
|
|
||||||
|
|
||||||
# Secrets OBLIGATOIRES (Ansible Vault).
|
# Secrets OBLIGATOIRES (Ansible Vault).
|
||||||
serveur_forgejo_secret_key: "{{ vault_forgejo_secret_key | default('') }}" # rempli depuis la voute (vault_forgejo_secret_key)
|
serveur_forgejo_secret_key: "{{ vault_forgejo_secret_key | default('') }}" # rempli depuis la voute (vault_forgejo_secret_key)
|
||||||
|
|
|
||||||
|
|
@ -68,11 +68,8 @@ ACCOUNT_LINKING = auto
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
[mailer]
|
[mailer]
|
||||||
ENABLED = {{ 'true' if serveur_forgejo_smtp else 'false' }}
|
ENABLED = true
|
||||||
{# STARTTLS EXIGE, CERTIFICAT VERIFIE (2026-10-12). `smtp` seul remettait en clair :
|
PROTOCOL = smtp
|
||||||
Forgejo le signalait (« insecure SMTP protocol to non-local address »). Le relais
|
|
||||||
porte un certificat de l'autorite de l'ecosysteme, que la machine reconnait. #}
|
|
||||||
PROTOCOL = smtp+starttls
|
|
||||||
SMTP_ADDR = {{ serveur_forgejo_smtp }}
|
SMTP_ADDR = {{ serveur_forgejo_smtp }}
|
||||||
SMTP_PORT = 25
|
SMTP_PORT = 25
|
||||||
FROM = forge@{{ domaine_interne }}
|
FROM = forge@{{ domaine_interne }}
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,6 @@ Environment=GF_SERVER_DOMAIN={{ serveur_grafana_hostname }}
|
||||||
Environment=GF_SERVER_ROOT_URL={{ serveur_grafana_root_url }}
|
Environment=GF_SERVER_ROOT_URL={{ serveur_grafana_root_url }}
|
||||||
Environment=GF_SECURITY_ADMIN_PASSWORD={{ serveur_grafana_admin_password }}
|
Environment=GF_SECURITY_ADMIN_PASSWORD={{ serveur_grafana_admin_password }}
|
||||||
Environment=GF_USERS_ALLOW_SIGN_UP=false
|
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
|
{# 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
|
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. #}
|
synthese) ; sans lui, Grafana revient a son accueil par defaut, sans erreur. #}
|
||||||
|
|
|
||||||
|
|
@ -45,10 +45,7 @@ flux:
|
||||||
port: 1514
|
port: 1514
|
||||||
protocole: tcp
|
protocole: tcp
|
||||||
pair: fabric
|
pair: fabric
|
||||||
# EN TLS, CLIENT AUTHENTIFIE (2026-10-12) : Alloy sert le certificat de la machine et
|
chiffrement: clair
|
||||||
# exige un certificat emis par l'autorite de la frontiere ; la frontiere verifie Alloy
|
|
||||||
# contre la racine du site (scripts/frontiere_pki.py).
|
|
||||||
chiffrement: tls-requis
|
|
||||||
raison: >-
|
raison: >-
|
||||||
Journaux de la frontiere, en TLS mutuel. Elle n'accueille aucun agent et n'emet que du
|
Journaux de la frontiere. Elle n'accueille aucun agent et n'emet que du syslog ; sans
|
||||||
syslog ; sans ce flux, le seul equipement qui voit passer TOUT le trafic n'ecrit nulle part.
|
ce flux, le seul equipement qui voit passer TOUT le trafic n'ecrit nulle part.
|
||||||
|
|
|
||||||
|
|
@ -195,56 +195,6 @@
|
||||||
group: loki
|
group: loki
|
||||||
mode: "0750"
|
mode: "0750"
|
||||||
|
|
||||||
# --- LA BASE D'UNE INCARNATION PRECEDENTE (2026-10-07) --------------------------------
|
|
||||||
#
|
|
||||||
# Les journaux repartaient de zero a chaque reconstruction. client_backup en garde une
|
|
||||||
# copie a froid (jeu `loki`) ; on la remet ICI, avant que Loki ne demarre sur sa
|
|
||||||
# configuration : une base sans aucun morceau de journal est une base neuve. Service
|
|
||||||
# arrete pendant la remise ; « Activer et demarrer Loki » plus bas le relance.
|
|
||||||
- name: La base de Loki a-t-elle deja des morceaux de journal ?
|
|
||||||
ansible.builtin.find:
|
|
||||||
paths: "{{ serveur_loki_chemin }}/chunks"
|
|
||||||
recurse: true
|
|
||||||
register: serveur_loki_morceaux
|
|
||||||
|
|
||||||
- name: Restauration — la base d'une incarnation precedente ?
|
|
||||||
ansible.builtin.include_role:
|
|
||||||
name: client_backup
|
|
||||||
tasks_from: restaurer.yml
|
|
||||||
vars:
|
|
||||||
client_backup_restaurer_jeu: serveur_loki
|
|
||||||
client_backup_restaurer_vierge: "{{ serveur_loki_morceaux.matched == 0 }}"
|
|
||||||
|
|
||||||
- name: Restauration — remettre la base, service arrete
|
|
||||||
when: client_backup_restauration.etat == 'a_restaurer'
|
|
||||||
block:
|
|
||||||
- name: Restauration — arreter Loki
|
|
||||||
ansible.builtin.systemd:
|
|
||||||
name: "{{ serveur_loki_service }}"
|
|
||||||
state: stopped
|
|
||||||
|
|
||||||
- name: Restauration — remettre la copie a froid a sa place
|
|
||||||
changed_when: true
|
|
||||||
ansible.builtin.command:
|
|
||||||
argv:
|
|
||||||
- /usr/local/sbin/setops-restaurer
|
|
||||||
- fichiers
|
|
||||||
- --instantane
|
|
||||||
- "{{ client_backup_restauration.instantane }}"
|
|
||||||
- --depuis
|
|
||||||
- "{{ client_backup_restauration.chemins[0] }}/donnees"
|
|
||||||
- --remplacer
|
|
||||||
- --proprietaire
|
|
||||||
- loki:loki
|
|
||||||
- "{{ serveur_loki_chemin }}"
|
|
||||||
|
|
||||||
- name: Restauration — acter la base remise
|
|
||||||
ansible.builtin.include_role:
|
|
||||||
name: client_backup
|
|
||||||
tasks_from: acter.yml
|
|
||||||
vars:
|
|
||||||
client_backup_acter_etat: restaure
|
|
||||||
|
|
||||||
# --- TLS : synchroniser le cert step_ca AVANT d'activer http_tls_config ---
|
# --- TLS : synchroniser le cert step_ca AVANT d'activer http_tls_config ---
|
||||||
- name: TLS — repertoire des certificats (loki)
|
- name: TLS — repertoire des certificats (loki)
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,3 @@ compactor:
|
||||||
|
|
||||||
limits_config:
|
limits_config:
|
||||||
retention_period: {{ serveur_loki_retention }}
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -74,31 +74,6 @@ serveur_nextcloud_http_port: 80
|
||||||
# IP de l'edge → trusted_proxies (pour voir la vraie IP client, pas throttler l'edge).
|
# IP de l'edge → trusted_proxies (pour voir la vraie IP client, pas throttler l'edge).
|
||||||
serveur_nextcloud_edge_ip: "{{ hostvars[(groups['serveur_nginx'] | default([]) | first) | default('')].ansible_host | default('') }}"
|
serveur_nextcloud_edge_ip: "{{ hostvars[(groups['serveur_nginx'] | default([]) | first) | default('')].ansible_host | default('') }}"
|
||||||
|
|
||||||
# --- LES APPLICATIONS NE VIENNENT PLUS DE L'APP STORE (2026-10-08) -------------------
|
|
||||||
#
|
|
||||||
# `occ app:install` les tirait d'apps.nextcloud.com a chaque construction. Le 2026-10-08,
|
|
||||||
# l'app store servait son catalogue (11,7 Mo) a 77 ko/s — 18,8 Mo/s ailleurs depuis la meme
|
|
||||||
# machine : Nextcloud a abandonne, et la reconstruction de Technolibre s'est arretee. Le
|
|
||||||
# site tient desormais ces archives dans son depot de binaires, comme celle de Nextcloud
|
|
||||||
# (`serveur_artefacts`), et le role les installe sans l'app store.
|
|
||||||
#
|
|
||||||
# EPINGLEES, VERIFIEES DEUX FOIS : la somme de l'archive (ci-dessous), puis la signature de
|
|
||||||
# son code (`occ integrity:check-app`, contre l'autorite de signature de Nextcloud, hors
|
|
||||||
# ligne ; eprouve : une ligne ajoutee a un fichier donne INVALID_HASH). Versions relevees
|
|
||||||
# sur Chezlepro, ou elles tournent sur Nextcloud 34.0.4. `nom` et `url` portent la version
|
|
||||||
# en toutes lettres : `test_apps_nextcloud.py` verifie qu'elles disent la meme.
|
|
||||||
serveur_nextcloud_apps:
|
|
||||||
- id: user_oidc
|
|
||||||
version: "8.11.0"
|
|
||||||
nom: "nextcloud-app-user_oidc-8.11.0.tar.gz"
|
|
||||||
url: "https://github.com/nextcloud-releases/user_oidc/releases/download/v8.11.0/user_oidc-v8.11.0.tar.gz"
|
|
||||||
sha256: "887920504c2f0e0d055abd66225ee7b66d756f50fe5a3739779d6b2ce108633f"
|
|
||||||
- id: richdocuments
|
|
||||||
version: "11.1.2"
|
|
||||||
nom: "nextcloud-app-richdocuments-11.1.2.tar.gz"
|
|
||||||
url: "https://github.com/nextcloud-releases/richdocuments/releases/download/v11.1.2/richdocuments-v11.1.2.tar.gz"
|
|
||||||
sha256: "96766a94cf417a716e47ed89c6ff2411c54954caa916afcdddc4d9668dffb557"
|
|
||||||
|
|
||||||
# --- SSO OIDC (Keycloak) — app user_oidc ---
|
# --- SSO OIDC (Keycloak) — app user_oidc ---
|
||||||
serveur_nextcloud_oidc_actif: true
|
serveur_nextcloud_oidc_actif: true
|
||||||
serveur_nextcloud_oidc_nom: "{{ organisation | default('Organisation') }}"
|
serveur_nextcloud_oidc_nom: "{{ organisation | default('Organisation') }}"
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
---
|
|
||||||
# UNE APPLICATION NEXTCLOUD, SANS L'APP STORE (2026-10-08) — voir `serveur_nextcloud_apps`.
|
|
||||||
#
|
|
||||||
# entree : serveur_nextcloud_app_id (user_oidc, richdocuments)
|
|
||||||
#
|
|
||||||
# Le depot du SITE d'abord, l'amont ensuite (meme patron que l'archive de Nextcloud,
|
|
||||||
# `installer.yml`) ; la somme verifiee dans les deux cas ; puis la signature du code ; puis
|
|
||||||
# l'activation. Une version installee qui differe de l'epinglee ARRETE : une mise a niveau
|
|
||||||
# d'application est une decision (et un `occ upgrade`), pas un effet de bord.
|
|
||||||
- name: "Application Nextcloud — l'application epinglee ({{ serveur_nextcloud_app_id }})"
|
|
||||||
ansible.builtin.set_fact:
|
|
||||||
serveur_nextcloud_app: >-
|
|
||||||
{{ serveur_nextcloud_apps | selectattr('id', 'equalto', serveur_nextcloud_app_id) | first }}
|
|
||||||
|
|
||||||
- name: "Application Nextcloud — deja presente ? ({{ serveur_nextcloud_app_id }})"
|
|
||||||
ansible.builtin.slurp:
|
|
||||||
src: "{{ serveur_nextcloud_racine }}/apps/{{ serveur_nextcloud_app.id }}/appinfo/info.xml"
|
|
||||||
register: serveur_nextcloud_app_info
|
|
||||||
failed_when: false
|
|
||||||
|
|
||||||
- name: "Application Nextcloud — la version presente est-elle l'epinglee ? ({{ serveur_nextcloud_app_id }})"
|
|
||||||
vars:
|
|
||||||
presente: >-
|
|
||||||
{{ (serveur_nextcloud_app_info.content | b64decode
|
|
||||||
| regex_search('<version>([^<]+)</version>', '\\1') | default([''], true) | first)
|
|
||||||
if serveur_nextcloud_app_info.content is defined else '' }}
|
|
||||||
ansible.builtin.assert:
|
|
||||||
that:
|
|
||||||
- presente | length == 0 or presente == serveur_nextcloud_app.version
|
|
||||||
fail_msg: >-
|
|
||||||
{{ serveur_nextcloud_app.id }} {{ presente }} est installee, {{ serveur_nextcloud_app.version }}
|
|
||||||
est epinglee. Une mise a niveau d'application se decide (et passe par `occ upgrade`) :
|
|
||||||
ajuster `serveur_nextcloud_apps`, ou mettre a niveau a la main.
|
|
||||||
quiet: true
|
|
||||||
|
|
||||||
- name: "Application Nextcloud — l'obtenir et la deposer ({{ serveur_nextcloud_app_id }})"
|
|
||||||
when: serveur_nextcloud_app_info.content is not defined
|
|
||||||
block:
|
|
||||||
- name: "Application Nextcloud — le cache du controleur ({{ serveur_nextcloud_app_id }})"
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: "{{ serveur_nextcloud_cache_local }}"
|
|
||||||
state: directory
|
|
||||||
mode: "0700"
|
|
||||||
delegate_to: localhost
|
|
||||||
become: false
|
|
||||||
|
|
||||||
# `failed_when: false` : le depot est une COMMODITE ; sans lui, on retombe sur l'amont.
|
|
||||||
# La somme est verifiee ici aussi : un fichier altere chez le site ne passe pas.
|
|
||||||
- name: "Application Nextcloud — au depot du site, avant de sortir sur Internet ({{ serveur_nextcloud_app_id }})"
|
|
||||||
ansible.builtin.get_url:
|
|
||||||
url: "{{ setops_depot_binaires }}/{{ serveur_nextcloud_app.nom }}"
|
|
||||||
dest: "{{ serveur_nextcloud_cache_local }}/{{ serveur_nextcloud_app.nom }}"
|
|
||||||
checksum: "sha256:{{ serveur_nextcloud_app.sha256 }}"
|
|
||||||
mode: "0644"
|
|
||||||
timeout: 30
|
|
||||||
delegate_to: localhost
|
|
||||||
become: false
|
|
||||||
register: serveur_nextcloud_app_site
|
|
||||||
failed_when: false
|
|
||||||
when: setops_depot_binaires | default('') | length > 0
|
|
||||||
|
|
||||||
- name: "Application Nextcloud — dans le cache du controleur ? ({{ serveur_nextcloud_app_id }})"
|
|
||||||
ansible.builtin.stat:
|
|
||||||
path: "{{ serveur_nextcloud_cache_local }}/{{ serveur_nextcloud_app.nom }}"
|
|
||||||
checksum_algorithm: sha256
|
|
||||||
register: serveur_nextcloud_app_cache
|
|
||||||
delegate_to: localhost
|
|
||||||
become: false
|
|
||||||
|
|
||||||
# ON LE DIT, FORT : une reconstruction qui sort sur Internet doit se voir.
|
|
||||||
- name: "Application Nextcloud — absente du depot du site : prise en amont ({{ serveur_nextcloud_app_id }})"
|
|
||||||
ansible.builtin.debug:
|
|
||||||
msg: >-
|
|
||||||
{{ serveur_nextcloud_app.nom }} n'est pas au depot du site
|
|
||||||
({{ setops_depot_binaires | default('aucun depot declare') }}) : prise sur Internet.
|
|
||||||
when: not (serveur_nextcloud_app_cache.stat.exists
|
|
||||||
and serveur_nextcloud_app_cache.stat.checksum == serveur_nextcloud_app.sha256)
|
|
||||||
|
|
||||||
- name: "Application Nextcloud — en amont (repli) ({{ serveur_nextcloud_app_id }})"
|
|
||||||
ansible.builtin.get_url:
|
|
||||||
url: "{{ serveur_nextcloud_app.url }}"
|
|
||||||
dest: "{{ serveur_nextcloud_cache_local }}/{{ serveur_nextcloud_app.nom }}"
|
|
||||||
checksum: "sha256:{{ serveur_nextcloud_app.sha256 }}"
|
|
||||||
mode: "0644"
|
|
||||||
timeout: 60
|
|
||||||
delegate_to: localhost
|
|
||||||
become: false
|
|
||||||
when: not (serveur_nextcloud_app_cache.stat.exists
|
|
||||||
and serveur_nextcloud_app_cache.stat.checksum == serveur_nextcloud_app.sha256)
|
|
||||||
|
|
||||||
- name: "Application Nextcloud — deposer le code dans apps/ ({{ serveur_nextcloud_app_id }})"
|
|
||||||
ansible.builtin.unarchive:
|
|
||||||
src: "{{ serveur_nextcloud_cache_local }}/{{ serveur_nextcloud_app.nom }}"
|
|
||||||
dest: "{{ serveur_nextcloud_racine }}/apps"
|
|
||||||
owner: "{{ serveur_nextcloud_utilisateur }}"
|
|
||||||
group: "{{ serveur_nextcloud_utilisateur }}"
|
|
||||||
creates: "{{ serveur_nextcloud_racine }}/apps/{{ serveur_nextcloud_app.id }}/appinfo/info.xml"
|
|
||||||
|
|
||||||
- name: "Application Nextcloud — signature du code, puis activation ({{ serveur_nextcloud_app_id }})"
|
|
||||||
become: true
|
|
||||||
become_user: "{{ serveur_nextcloud_utilisateur }}"
|
|
||||||
environment:
|
|
||||||
PGSSLMODE: "{{ serveur_nextcloud_pg_sslmode }}"
|
|
||||||
PGSSLROOTCERT: "{{ serveur_nextcloud_ca_racine }}"
|
|
||||||
when: not ansible_check_mode
|
|
||||||
block:
|
|
||||||
# Contre l'autorite de signature de Nextcloud, sans reseau. Rien a dire = intact.
|
|
||||||
- name: "Application Nextcloud — le code est-il celui que Nextcloud a signe ? ({{ serveur_nextcloud_app_id }})"
|
|
||||||
ansible.builtin.command:
|
|
||||||
argv: ["php", "{{ serveur_nextcloud_racine }}/occ", "integrity:check-app", "{{ serveur_nextcloud_app.id }}"]
|
|
||||||
register: serveur_nextcloud_app_integrite
|
|
||||||
changed_when: false
|
|
||||||
failed_when: serveur_nextcloud_app_integrite.rc != 0 or serveur_nextcloud_app_integrite.stdout | trim | length > 0
|
|
||||||
|
|
||||||
- name: "Application Nextcloud — activer ({{ serveur_nextcloud_app_id }})"
|
|
||||||
ansible.builtin.shell:
|
|
||||||
executable: /bin/bash
|
|
||||||
cmd: |
|
|
||||||
set -eo pipefail
|
|
||||||
OCC="php {{ serveur_nextcloud_racine }}/occ"
|
|
||||||
APP="{{ serveur_nextcloud_app.id }}"
|
|
||||||
if $OCC app:list --enabled | grep -qw "$APP"; then echo SETOPS_OK
|
|
||||||
else $OCC app:enable "$APP" && echo SETOPS_CHANGED; fi
|
|
||||||
register: serveur_nextcloud_app_active
|
|
||||||
changed_when: "'SETOPS_CHANGED' in serveur_nextcloud_app_active.stdout"
|
|
||||||
|
|
@ -1,10 +1,4 @@
|
||||||
---
|
---
|
||||||
# L'APPLICATION VIENT DU DEPOT DU SITE, PLUS DE L'APP STORE (2026-10-08) : `app_tierce.yml`.
|
|
||||||
- name: Installer l'app richdocuments (depot du site, somme, signature, activation)
|
|
||||||
ansible.builtin.include_tasks: app_tierce.yml
|
|
||||||
vars:
|
|
||||||
serveur_nextcloud_app_id: richdocuments
|
|
||||||
|
|
||||||
- name: Collabora / Office (occ, en www-data avec TLS PG)
|
- name: Collabora / Office (occ, en www-data avec TLS PG)
|
||||||
become: true
|
become: true
|
||||||
become_user: "{{ serveur_nextcloud_utilisateur }}"
|
become_user: "{{ serveur_nextcloud_utilisateur }}"
|
||||||
|
|
@ -13,6 +7,16 @@
|
||||||
PGSSLROOTCERT: "{{ serveur_nextcloud_ca_racine }}"
|
PGSSLROOTCERT: "{{ serveur_nextcloud_ca_racine }}"
|
||||||
when: not ansible_check_mode
|
when: not ansible_check_mode
|
||||||
block:
|
block:
|
||||||
|
- name: Installer + activer l'app richdocuments (idempotent)
|
||||||
|
ansible.builtin.shell:
|
||||||
|
executable: /bin/bash
|
||||||
|
cmd: |
|
||||||
|
set -eo pipefail
|
||||||
|
OCC="php {{ serveur_nextcloud_racine }}/occ"
|
||||||
|
if $OCC app:list | grep -qw richdocuments; then echo SETOPS_OK; else $OCC app:install richdocuments && echo SETOPS_CHANGED; fi
|
||||||
|
register: serveur_nextcloud_office_app
|
||||||
|
changed_when: "'SETOPS_CHANGED' in serveur_nextcloud_office_app.stdout"
|
||||||
|
|
||||||
- name: URL serveur -> Collabora (locale, rapide)
|
- name: URL serveur -> Collabora (locale, rapide)
|
||||||
ansible.builtin.command:
|
ansible.builtin.command:
|
||||||
argv: ["php", "{{ serveur_nextcloud_racine }}/occ", "config:app:set", "richdocuments", "wopi_url", "--value={{ serveur_nextcloud_wopi_url }}"]
|
argv: ["php", "{{ serveur_nextcloud_racine }}/occ", "config:app:set", "richdocuments", "wopi_url", "--value={{ serveur_nextcloud_wopi_url }}"]
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,4 @@
|
||||||
---
|
---
|
||||||
# L'APPLICATION VIENT DU DEPOT DU SITE, PLUS DE L'APP STORE (2026-10-08) : `app_tierce.yml`.
|
|
||||||
- name: Installer l'app user_oidc (depot du site, somme, signature, activation)
|
|
||||||
ansible.builtin.include_tasks: app_tierce.yml
|
|
||||||
vars:
|
|
||||||
serveur_nextcloud_app_id: user_oidc
|
|
||||||
|
|
||||||
- name: SSO OIDC (occ, en www-data avec TLS PG)
|
- name: SSO OIDC (occ, en www-data avec TLS PG)
|
||||||
become: true
|
become: true
|
||||||
become_user: "{{ serveur_nextcloud_utilisateur }}"
|
become_user: "{{ serveur_nextcloud_utilisateur }}"
|
||||||
|
|
@ -13,6 +7,16 @@
|
||||||
PGSSLROOTCERT: "{{ serveur_nextcloud_ca_racine }}"
|
PGSSLROOTCERT: "{{ serveur_nextcloud_ca_racine }}"
|
||||||
when: not ansible_check_mode
|
when: not ansible_check_mode
|
||||||
block:
|
block:
|
||||||
|
- name: Installer + activer l'app user_oidc (idempotent)
|
||||||
|
ansible.builtin.shell:
|
||||||
|
executable: /bin/bash
|
||||||
|
cmd: |
|
||||||
|
set -eo pipefail
|
||||||
|
OCC="php {{ serveur_nextcloud_racine }}/occ"
|
||||||
|
if $OCC app:list | grep -qw user_oidc; then echo SETOPS_OK; else $OCC app:install user_oidc && echo SETOPS_CHANGED; fi
|
||||||
|
register: serveur_nextcloud_oidc_app
|
||||||
|
changed_when: "'SETOPS_CHANGED' in serveur_nextcloud_oidc_app.stdout"
|
||||||
|
|
||||||
- name: Ne plus annoncer le formulaire de connexion locale
|
- name: Ne plus annoncer le formulaire de connexion locale
|
||||||
# Masquage, pas fermeture : `?direct=1` reste le chemin de secours documente par
|
# Masquage, pas fermeture : `?direct=1` reste le chemin de secours documente par
|
||||||
# l'amont. Voir serveur_nextcloud_connexion_locale dans defaults/main.yml.
|
# l'amont. Voir serveur_nextcloud_connexion_locale dans defaults/main.yml.
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ serveur_ops_paquets:
|
||||||
# Ansible est ÉPINGLÉ sur la même famille que le poste du mainteneur (core 2.18) : un
|
# Ansible est ÉPINGLÉ sur la même famille que le poste du mainteneur (core 2.18) : un
|
||||||
# écosystème qui se reconstruit avec une version différente de celle qui l'a construit ne
|
# écosystème qui se reconstruit avec une version différente de celle qui l'a construit ne
|
||||||
# reproduit pas la même chose, et l'écart ne se voit qu'au premier échec.
|
# reproduit pas la même chose, et l'écart ne se voit qu'au premier échec.
|
||||||
serveur_ops_ansible: "ansible-core==2.18.19"
|
serveur_ops_ansible: "ansible-core>=2.18,<2.19"
|
||||||
|
|
||||||
# --- D'OÙ VIENT LE GÉNOME ----------------------------------------------------
|
# --- D'OÙ VIENT LE GÉNOME ----------------------------------------------------
|
||||||
#
|
#
|
||||||
|
|
@ -101,29 +101,6 @@ serveur_ops_forge_amont_ac_depot: "{{ serveur_ops_racine }}/.ac-amont.crt"
|
||||||
serveur_ops_depots:
|
serveur_ops_depots:
|
||||||
- { depot: "set-ops-public", dest: "Set-OPS-public", role: "moteur" }
|
- { depot: "set-ops-public", dest: "Set-OPS-public", role: "moteur" }
|
||||||
|
|
||||||
# --- LES DEPOTS PRIVES (2026-10-11, `docs/depots-prives-conception.md`) --------
|
|
||||||
#
|
|
||||||
# Le plan d'un ecosysteme et la carte du site ne regardent que leur runner — et le runner du
|
|
||||||
# site. Sur la forge du site, ils sont PRIVES : chaque runner les lit en SSH, avec SA cle,
|
|
||||||
# enregistree comme cle de deploiement en lecture sur son seul depot (`make forge-cles`).
|
|
||||||
# Le moteur et les modeles restent publics, et se clonent comme avant, en HTTPS anonyme :
|
|
||||||
# une cle de deploiement ne vaut que pour le depot qui l'a enregistree (mesure du jour).
|
|
||||||
serveur_ops_roles_prives: ["instance", "tenant", "hebergeur"]
|
|
||||||
# La cle d'hote SSH de la forge, epinglee : le runner sait a qui il parle avant de presenter
|
|
||||||
# sa cle. Au site, elle vient de son plan (`forge_ssh_cle_hote`) ; chez un locataire, de sa
|
|
||||||
# fiche (`serveur_ops_forge_cle_hote`, copie verifiee par `make site-intrants`).
|
|
||||||
serveur_ops_forge_cle_hote: ""
|
|
||||||
serveur_ops_forge_ssh_hote: "{{ serveur_ops_forge_url | urlsplit('hostname') }}"
|
|
||||||
serveur_ops_forge_known_hosts: "{{ serveur_ops_racine }}/.ssh/known_hosts_forge"
|
|
||||||
serveur_ops_cle_privee: "{{ serveur_ops_racine }}/.ssh/id_{{ serveur_ops_cle_type }}"
|
|
||||||
# Ou le CONTROLEUR fabrique ses colis avant de les porter (jamais dans un depot).
|
|
||||||
serveur_ops_colis_local: "{{ lookup('env', 'TMPDIR') | default('/tmp', true) }}"
|
|
||||||
# La commande SSH des depots prives, posee dans LEUR config git (`core.sshCommand`) : la
|
|
||||||
# console, la garde du genome et `make monter` la reprennent sans rien savoir de tout ceci.
|
|
||||||
serveur_ops_ssh_prive: >-
|
|
||||||
ssh -i {{ serveur_ops_cle_privee }} -o BatchMode=yes -o IdentitiesOnly=yes
|
|
||||||
-o StrictHostKeyChecking=yes -o UserKnownHostsFile={{ serveur_ops_forge_known_hosts }}
|
|
||||||
|
|
||||||
# --- LES DEUX SYMLINKS (D-80) ------------------------------------------------
|
# --- LES DEUX SYMLINKS (D-80) ------------------------------------------------
|
||||||
#
|
#
|
||||||
# `instance` dit QUEL tenant on pilote.
|
# `instance` dit QUEL tenant on pilote.
|
||||||
|
|
|
||||||
|
|
@ -108,139 +108,79 @@
|
||||||
become: false
|
become: false
|
||||||
run_once: true
|
run_once: true
|
||||||
|
|
||||||
# LE VERROU DU RUNNER (2026-10-09) — `verrou-runner.yml`, a la racine du moteur.
|
# LE CONTROLEUR ET LA CIBLE DOIVENT PARTAGER LEUR PYTHON, et ca ne se devine pas.
|
||||||
#
|
#
|
||||||
# Les roues ne sont plus resolues par `pip download` contre PyPI, a chaque passage : elles
|
# `pip download` rend les roues du systeme qui telecharge. `ansible-core`, `proxmoxer` et
|
||||||
# sont ECRITES, une par une, avec leur somme, et le SITE les tient dans son depot de
|
# `requests` sont universelles (`py3-none-any`), mais `pyyaml` porte du C compile : une
|
||||||
# binaires. Le runner de Technolibre portait deux versions de six bibliotheques — PyPI avait
|
# roue `cp313` posee sur un python 3.12 ne s'installe pas. L'echec serait « No matching
|
||||||
# bouge entre l'insemination et le montage. Desormais, ce qui s'installe est ce que le
|
# distribution found » a l'installation — un message qui accuse le depot local alors que
|
||||||
# verrou nomme, et rien d'autre.
|
# le coupable est l'ecart entre deux machines. On le mesure ici, ou on peut encore le dire.
|
||||||
- name: Lire le verrou du runner (roues Python, collections Ansible)
|
- name: Lire le Python du controleur
|
||||||
ansible.builtin.include_vars:
|
ansible.builtin.command:
|
||||||
file: "{{ playbook_dir }}/../../verrou-runner.yml"
|
argv: ["python3", "-c", "import sys; print('%d.%d' % sys.version_info[:2])"]
|
||||||
name: serveur_ops_verrou
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
run_once: true
|
||||||
|
changed_when: false
|
||||||
|
# LIRE UNE VERSION N'EST PAS CHANGER, ET `--check` RENDAIT LA GARDE MUETTE (2026-09-14).
|
||||||
|
#
|
||||||
|
# Sans `check_mode: false`, cette commande est sautee en mode idempotent : la garde qui
|
||||||
|
# suit compare alors contre du VIDE et refuse.
|
||||||
|
#
|
||||||
|
# Le controleur tourne Python et site-ops-01 tourne Python 3.13
|
||||||
|
#
|
||||||
|
# L'espace apres « Python » est tout le diagnostic — il n'y avait rien a comparer. Un
|
||||||
|
# essai a blanc rendait donc un echec sur le runner, alors que le deploiement reel passe.
|
||||||
|
check_mode: false
|
||||||
|
register: serveur_ops_python_controleur
|
||||||
|
|
||||||
# LA CIBLE DOIT PORTER LE PYTHON DES ROUES VERROUILLEES. `pyyaml`, `cffi`, `markupsafe`...
|
- name: Exiger que le controleur et la cible partagent leur version de Python
|
||||||
# portent du C compile pour un Python donne : une roue `cp313` posee sur un 3.12 ne
|
|
||||||
# s'installe pas, et l'echec accuserait le depot local. Le controleur, lui, ne compile plus
|
|
||||||
# rien : il ne fait que transporter des fichiers ecrits.
|
|
||||||
- name: Exiger que la cible porte le Python des roues verrouillees
|
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
that:
|
that:
|
||||||
- ansible_python_version.split('.')[0:2] | join('.') == serveur_ops_verrou.python_cible | string
|
- serveur_ops_python_controleur.stdout | trim
|
||||||
|
== ansible_python_version.split('.')[0:2] | join('.')
|
||||||
fail_msg: >-
|
fail_msg: >-
|
||||||
{{ inventory_hostname }} tourne Python {{ ansible_python_version }} ; les roues de
|
Le controleur tourne Python {{ serveur_ops_python_controleur.stdout | trim }} et
|
||||||
`verrou-runner.yml` sont faites pour Python {{ serveur_ops_verrou.python_cible }}.
|
{{ inventory_hostname }} tourne Python
|
||||||
Monter le verrou (nouvelles roues, nouvelles sommes), ou garder la cible sur ce Python.
|
{{ ansible_python_version.split('.')[0:2] | join('.') }}. Les roues telechargees ne
|
||||||
|
s'installeraient pas sur la cible, et l'echec accuserait le depot local au lieu de
|
||||||
|
cet ecart. Materialiser depuis un controleur de meme famille — pendant une
|
||||||
|
insemination, c'est le runner du SITE, qui porte le meme Debian que la machine
|
||||||
|
qu'il amorce.
|
||||||
|
|
||||||
# LE DEPOT DU SITE D'ABORD, L'AMONT ENSUITE — la somme verifiee dans les deux cas.
|
# `argv` ET NON `cmd` : le chemin du controleur peut contenir une ESPACE (« Espace
|
||||||
# `failed_when: false` : le depot est une COMMODITE ; sans lui, on retombe sur l'amont, et on
|
# Chezlepro/… » chez le mainteneur). Meme piege que pour ansible-galaxy le 2026-08-23.
|
||||||
# le dit (tache suivante).
|
- name: Telecharger les roues sur le controleur (une seule fois par empreinte)
|
||||||
- name: Prendre les roues verrouillees au depot du site
|
ansible.builtin.command:
|
||||||
ansible.builtin.get_url:
|
argv:
|
||||||
url: "{{ setops_depot_binaires }}/{{ item.nom }}"
|
- "python3"
|
||||||
dest: "{{ serveur_ops_cache_roues }}/{{ item.nom }}"
|
- "-m"
|
||||||
checksum: "sha256:{{ item.sha256 }}"
|
- "pip"
|
||||||
mode: "0644"
|
- "download"
|
||||||
timeout: 30
|
- "--dest"
|
||||||
loop: "{{ serveur_ops_verrou.python }}"
|
- "{{ serveur_ops_cache_roues }}"
|
||||||
loop_control:
|
- "--requirement"
|
||||||
label: "{{ item.nom }}"
|
- "{{ serveur_ops_roues_source }}"
|
||||||
|
- "{{ serveur_ops_ansible }}"
|
||||||
|
- "pyyaml"
|
||||||
delegate_to: localhost
|
delegate_to: localhost
|
||||||
become: false
|
become: false
|
||||||
run_once: true
|
run_once: true
|
||||||
failed_when: false
|
register: serveur_ops_roues_telechargees
|
||||||
when:
|
changed_when: "'Saved' in serveur_ops_roues_telechargees.stdout"
|
||||||
- setops_depot_binaires | default('') | length > 0
|
# LE GARDE-FOU SUIT LE RESULTAT, PAS LE GESTE (lecon du 2026-08-27) : on saute quand le
|
||||||
- not ansible_check_mode
|
# cache PORTE deja des roues, pas quand une commande a deja tourne un jour.
|
||||||
|
|
||||||
- name: Les roues verrouillees sont-elles dans le cache du controleur ?
|
|
||||||
ansible.builtin.stat:
|
|
||||||
path: "{{ serveur_ops_cache_roues }}/{{ item.nom }}"
|
|
||||||
checksum_algorithm: sha256
|
|
||||||
loop: "{{ serveur_ops_verrou.python }}"
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.nom }}"
|
|
||||||
delegate_to: localhost
|
|
||||||
become: false
|
|
||||||
run_once: true
|
|
||||||
register: serveur_ops_roues_cache
|
|
||||||
|
|
||||||
# ON LE DIT, FORT : une reconstruction qui sort sur Internet doit se voir.
|
|
||||||
- name: Dire les roues absentes du depot du site (prises en amont)
|
|
||||||
ansible.builtin.debug:
|
|
||||||
msg: >-
|
|
||||||
{{ serveur_ops_roues_cache.results | rejectattr('stat.exists') | map(attribute='item.nom') | list }}
|
|
||||||
— absentes du depot du site ({{ setops_depot_binaires | default('aucun depot declare') }}) : prises sur Internet.
|
|
||||||
run_once: true
|
|
||||||
# PAS EN SIMULATION : `--check` ne telecharge rien depuis le site, le cache reste vide et
|
|
||||||
# ce message accuserait le depot a tort (vu le 2026-10-09 sur le runner de Technolibre).
|
|
||||||
when:
|
|
||||||
- not ansible_check_mode
|
|
||||||
- serveur_ops_roues_cache.results | rejectattr('stat.exists') | list | length > 0
|
|
||||||
|
|
||||||
- name: Prendre en amont les roues que le site n'a pas (repli)
|
|
||||||
ansible.builtin.get_url:
|
|
||||||
url: "{{ item.item.url }}"
|
|
||||||
dest: "{{ serveur_ops_cache_roues }}/{{ item.item.nom }}"
|
|
||||||
checksum: "sha256:{{ item.item.sha256 }}"
|
|
||||||
mode: "0644"
|
|
||||||
timeout: 60
|
|
||||||
# ABSENTE OU ALTEREE : `pip --no-index` ne verifie pas les sommes, un fichier present mais
|
|
||||||
# different passerait. On le reprend, et `get_url` le verifie.
|
|
||||||
loop: "{{ serveur_ops_roues_cache.results }}"
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.item.nom }}"
|
|
||||||
delegate_to: localhost
|
|
||||||
become: false
|
|
||||||
run_once: true
|
|
||||||
when:
|
|
||||||
- not ansible_check_mode
|
|
||||||
- not item.stat.exists or item.stat.checksum != item.item.sha256
|
|
||||||
|
|
||||||
# LES ROUES DU VERROU, ET ELLES SEULES. Copier le cache entier deposait aussi les versions
|
|
||||||
# d'hier (deux `cryptography`, deux `urllib3`...) ; `--no-index` choisissait alors la plus
|
|
||||||
# haute, que personne n'avait choisie.
|
|
||||||
# LE DOSSIER D'ABORD (2026-10-09). L'ancienne copie du cache ENTIER (`src: dossier/`) creait
|
|
||||||
# sa destination ; une copie fichier par fichier ne la cree pas. Sur un runner neuf, la
|
|
||||||
# reconstruction de Technolibre s'est arretee la (« Destination directory … does not exist »)
|
|
||||||
# — invisible en simulant sur un runner ou le dossier existait deja.
|
|
||||||
- name: Assurer le depot des roues sur le runner
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: "{{ serveur_ops_roues_depot }}"
|
|
||||||
state: directory
|
|
||||||
owner: "{{ serveur_ops_utilisateur }}"
|
|
||||||
group: "{{ serveur_ops_utilisateur }}"
|
|
||||||
mode: "0755"
|
|
||||||
|
|
||||||
- name: Deposer sur le runner les roues du verrou
|
|
||||||
ansible.builtin.copy:
|
|
||||||
src: "{{ serveur_ops_cache_roues }}/{{ item.nom }}"
|
|
||||||
dest: "{{ serveur_ops_roues_depot }}/{{ item.nom }}"
|
|
||||||
owner: "{{ serveur_ops_utilisateur }}"
|
|
||||||
group: "{{ serveur_ops_utilisateur }}"
|
|
||||||
mode: "0644"
|
|
||||||
loop: "{{ serveur_ops_verrou.python }}"
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.nom }}"
|
|
||||||
when: not ansible_check_mode
|
when: not ansible_check_mode
|
||||||
|
|
||||||
- name: Relever les roues deposees sur le runner
|
- name: Deposer les roues sur le runner
|
||||||
ansible.builtin.find:
|
ansible.builtin.copy:
|
||||||
paths: "{{ serveur_ops_roues_depot }}"
|
src: "{{ serveur_ops_cache_roues }}/"
|
||||||
patterns: "*.whl"
|
dest: "{{ serveur_ops_roues_depot }}/"
|
||||||
register: serveur_ops_roues_deposees
|
owner: "{{ serveur_ops_utilisateur }}"
|
||||||
|
group: "{{ serveur_ops_utilisateur }}"
|
||||||
- name: Retirer du runner les roues que le verrou ne nomme pas
|
mode: "0644"
|
||||||
ansible.builtin.file:
|
directory_mode: "0755"
|
||||||
path: "{{ item.path }}"
|
when: not ansible_check_mode
|
||||||
state: absent
|
|
||||||
loop: >-
|
|
||||||
{{ serveur_ops_roues_deposees.files
|
|
||||||
| rejectattr('path', 'search', '/(' ~ (serveur_ops_verrou.python | map(attribute='nom')
|
|
||||||
| map('regex_escape') | join('|')) ~ ')$') | list }}
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.path | basename }}"
|
|
||||||
|
|
||||||
# `--no-index` EST LE POINT, PAS UNE OPTIMISATION. Sans lui, pip resterait capable de
|
# `--no-index` EST LE POINT, PAS UNE OPTIMISATION. Sans lui, pip resterait capable de
|
||||||
# sortir vers PyPI le jour ou le depot local serait incomplet — et la reussite dependrait
|
# sortir vers PyPI le jour ou le depot local serait incomplet — et la reussite dependrait
|
||||||
|
|
@ -349,8 +289,7 @@
|
||||||
version: "{{ item.branche | default('main') }}"
|
version: "{{ item.branche | default('main') }}"
|
||||||
update: true
|
update: true
|
||||||
force: false
|
force: false
|
||||||
# LES DEPOTS PUBLICS SEULEMENT (2026-10-11). Les prives suivent, plus bas, en SSH.
|
loop: "{{ serveur_ops_depots }}"
|
||||||
loop: "{{ serveur_ops_depots | rejectattr('role', 'in', serveur_ops_roles_prives) | list }}"
|
|
||||||
loop_control:
|
loop_control:
|
||||||
label: "{{ item.depot }} → {{ item.dest }}"
|
label: "{{ item.depot }} → {{ item.dest }}"
|
||||||
become: true
|
become: true
|
||||||
|
|
@ -361,186 +300,6 @@
|
||||||
delay: 6
|
delay: 6
|
||||||
until: serveur_ops_clone is succeeded
|
until: serveur_ops_clone is succeeded
|
||||||
|
|
||||||
# --- LES DEPOTS PRIVES : EN SSH, OU EN COLIS TANT QUE LE RUNNER NE PEUT PAS ---------------
|
|
||||||
#
|
|
||||||
# (2026-10-11, `docs/depots-prives-conception.md`.) Le plan d'un ecosysteme et la carte du
|
|
||||||
# site sont prives sur la forge du site. Le runner les lit en SSH, avec SA cle, que le site a
|
|
||||||
# enregistree comme cle de deploiement sur ce seul depot (`make forge-cles`).
|
|
||||||
#
|
|
||||||
# MAIS PAS ENCORE A L'INSEMINATION. Le site amorce le runner d'un locataire AVANT l'armement,
|
|
||||||
# donc avant que la voute lui remette sa cle stable : la forge ne le connait pas. Le
|
|
||||||
# controleur qui joue ce role — le runner du site, ou le poste — porte deja ce depot, frere
|
|
||||||
# du moteur ; il le lui apporte en COLIS (`git bundle`). Le runner pointe ensuite `origin`
|
|
||||||
# vers la forge en SSH : une fois arme, il tire lui-meme. Rien n'est devine : si le
|
|
||||||
# controleur n'a pas le depot, on refuse.
|
|
||||||
- name: Exiger la cle d'hote de la forge pour lire un depot prive
|
|
||||||
ansible.builtin.assert:
|
|
||||||
that: serveur_ops_forge_cle_hote | length > 0
|
|
||||||
fail_msg: >-
|
|
||||||
Ce runner lit des depots prives ({{ serveur_ops_depots
|
|
||||||
| selectattr('role', 'in', serveur_ops_roles_prives) | map(attribute='depot') | join(', ') }}),
|
|
||||||
mais `serveur_ops_forge_cle_hote` est vide. Au site : `forge_ssh_cle_hote` dans son plan.
|
|
||||||
Chez un locataire : la copier depuis sa fiche (`make site-intrants`).
|
|
||||||
when: serveur_ops_depots | selectattr('role', 'in', serveur_ops_roles_prives) | list | length > 0
|
|
||||||
|
|
||||||
# LE DOSSIER SSH D'ABORD (mesure du 2026-10-11, insemination de Technolibre). Il n'etait cree
|
|
||||||
# que plus bas, avec la cle du poste : sur un runner existant il etait la, et les trois
|
|
||||||
# deploiements comme l'epreuve du colis n'ont rien vu. Une machine NEUVE s'est arretee ici :
|
|
||||||
# « Destination directory /opt/setops/.ssh does not exist ». La tache du bas reste, inchangee.
|
|
||||||
- name: Assurer le repertoire SSH du poste (avant d'y epingler la forge)
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: "{{ serveur_ops_racine }}/.ssh"
|
|
||||||
state: directory
|
|
||||||
owner: "{{ serveur_ops_utilisateur }}"
|
|
||||||
group: "{{ serveur_ops_utilisateur }}"
|
|
||||||
mode: "0700"
|
|
||||||
when: serveur_ops_forge_cle_hote | length > 0
|
|
||||||
|
|
||||||
- name: Epingler la cle d'hote de la forge
|
|
||||||
ansible.builtin.copy:
|
|
||||||
content: "{{ serveur_ops_forge_ssh_hote }} {{ serveur_ops_forge_cle_hote | trim }}\n"
|
|
||||||
dest: "{{ serveur_ops_forge_known_hosts }}"
|
|
||||||
owner: "{{ serveur_ops_utilisateur }}"
|
|
||||||
group: "{{ serveur_ops_utilisateur }}"
|
|
||||||
mode: "0644"
|
|
||||||
when: serveur_ops_forge_cle_hote | length > 0
|
|
||||||
|
|
||||||
- name: Le runner lit-il deja chaque depot prive en SSH ?
|
|
||||||
ansible.builtin.command:
|
|
||||||
argv: [git, ls-remote, "git@{{ serveur_ops_forge_ssh_hote }}:{{ serveur_ops_forge_organisation }}/{{ item.depot }}.git", HEAD]
|
|
||||||
environment:
|
|
||||||
GIT_SSH_COMMAND: "{{ serveur_ops_ssh_prive }}"
|
|
||||||
become: true
|
|
||||||
become_user: "{{ serveur_ops_utilisateur }}"
|
|
||||||
loop: "{{ serveur_ops_depots | selectattr('role', 'in', serveur_ops_roles_prives) | list }}"
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.depot }}"
|
|
||||||
register: serveur_ops_prives_ssh
|
|
||||||
changed_when: false
|
|
||||||
failed_when: false
|
|
||||||
check_mode: false
|
|
||||||
|
|
||||||
- name: Tirer en SSH les depots prives que le runner peut lire
|
|
||||||
ansible.builtin.git:
|
|
||||||
repo: "git@{{ serveur_ops_forge_ssh_hote }}:{{ serveur_ops_forge_organisation }}/{{ item.item.depot }}.git"
|
|
||||||
dest: "{{ serveur_ops_racine }}/{{ item.item.dest }}"
|
|
||||||
version: "{{ item.item.branche | default('main') }}"
|
|
||||||
key_file: "{{ serveur_ops_cle_privee }}"
|
|
||||||
ssh_opts: "-o StrictHostKeyChecking=yes -o UserKnownHostsFile={{ serveur_ops_forge_known_hosts }} -o IdentitiesOnly=yes"
|
|
||||||
accept_hostkey: false
|
|
||||||
update: true
|
|
||||||
force: false
|
|
||||||
loop: "{{ serveur_ops_prives_ssh.results | selectattr('rc', 'equalto', 0) | list }}"
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.item.depot }} (SSH)"
|
|
||||||
become: true
|
|
||||||
become_user: "{{ serveur_ops_utilisateur }}"
|
|
||||||
when: not ansible_check_mode
|
|
||||||
register: serveur_ops_prives_tires
|
|
||||||
|
|
||||||
- name: Faire le colis des depots prives que le runner ne lit pas encore (controleur)
|
|
||||||
ansible.builtin.command:
|
|
||||||
argv: [git, -C, "{{ serveur_ops_freres }}/{{ item.item.dest }}", bundle, create,
|
|
||||||
"{{ serveur_ops_colis_local }}/{{ item.item.depot }}.bundle",
|
|
||||||
"refs/heads/{{ item.item.branche | default('main') }}"]
|
|
||||||
loop: "{{ serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list }}"
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.item.depot }} (colis)"
|
|
||||||
delegate_to: localhost
|
|
||||||
become: false
|
|
||||||
changed_when: false
|
|
||||||
when: not ansible_check_mode
|
|
||||||
vars:
|
|
||||||
serveur_ops_freres: "{{ role_path | dirname | dirname | dirname }}"
|
|
||||||
register: serveur_ops_colis_faits
|
|
||||||
|
|
||||||
- name: Assurer le dossier des colis sur le runner
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: "{{ serveur_ops_racine }}/.colis"
|
|
||||||
state: directory
|
|
||||||
owner: "{{ serveur_ops_utilisateur }}"
|
|
||||||
group: "{{ serveur_ops_utilisateur }}"
|
|
||||||
mode: "0700"
|
|
||||||
when:
|
|
||||||
- not ansible_check_mode
|
|
||||||
- serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list | length > 0
|
|
||||||
|
|
||||||
- name: Porter les colis au runner
|
|
||||||
ansible.builtin.copy:
|
|
||||||
src: "{{ serveur_ops_colis_local }}/{{ item.item.depot }}.bundle"
|
|
||||||
dest: "{{ serveur_ops_racine }}/.colis/{{ item.item.depot }}.bundle"
|
|
||||||
owner: "{{ serveur_ops_utilisateur }}"
|
|
||||||
group: "{{ serveur_ops_utilisateur }}"
|
|
||||||
mode: "0600"
|
|
||||||
loop: "{{ serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list }}"
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.item.depot }} (colis)"
|
|
||||||
when: not ansible_check_mode
|
|
||||||
|
|
||||||
- name: Recevoir chaque colis (clone neuf, ou avance rapide), puis viser la forge en SSH
|
|
||||||
ansible.builtin.shell: |
|
|
||||||
set -eu
|
|
||||||
d="{{ serveur_ops_racine }}/{{ item.item.dest }}"
|
|
||||||
c="{{ serveur_ops_racine }}/.colis/{{ item.item.depot }}.bundle"
|
|
||||||
b="{{ item.item.branche | default('main') }}"
|
|
||||||
# `git bundle verify` EXIGE UN DEPOT (mesure du 2026-10-11, a l'epreuve du colis) : hors
|
|
||||||
# d'un depot il refuse, et c'est justement le cas de l'insemination. Le clone verifie le
|
|
||||||
# colis lui-meme (un colis tronque y echoue) ; un depot existant le verifie avant de tirer.
|
|
||||||
if [ ! -d "$d/.git" ]; then
|
|
||||||
git clone -q --branch "$b" "$c" "$d"
|
|
||||||
echo "CLONE"
|
|
||||||
else
|
|
||||||
git -C "$d" bundle verify -q "$c" >/dev/null
|
|
||||||
avant=$(git -C "$d" rev-parse HEAD)
|
|
||||||
git -C "$d" fetch -q "$c" "refs/heads/$b:refs/remotes/colis/$b"
|
|
||||||
git -C "$d" merge -q --ff-only "colis/$b"
|
|
||||||
[ "$avant" = "$(git -C "$d" rev-parse HEAD)" ] || echo "AVANCE"
|
|
||||||
fi
|
|
||||||
git -C "$d" remote set-url origin "git@{{ serveur_ops_forge_ssh_hote }}:{{ serveur_ops_forge_organisation }}/{{ item.item.depot }}.git"
|
|
||||||
git -C "$d" fetch -q origin 2>/dev/null && git -C "$d" branch -q --set-upstream-to "origin/$b" || true
|
|
||||||
rm -f "$c"
|
|
||||||
args:
|
|
||||||
executable: /bin/bash
|
|
||||||
loop: "{{ serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list }}"
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.item.depot }} (colis)"
|
|
||||||
become: true
|
|
||||||
become_user: "{{ serveur_ops_utilisateur }}"
|
|
||||||
when: not ansible_check_mode
|
|
||||||
register: serveur_ops_colis_recus
|
|
||||||
changed_when: "'CLONE' in serveur_ops_colis_recus.stdout or 'AVANCE' in serveur_ops_colis_recus.stdout"
|
|
||||||
|
|
||||||
- name: Dire quels depots prives sont venus par colis
|
|
||||||
ansible.builtin.debug:
|
|
||||||
msg: >-
|
|
||||||
{{ item.item.depot }} : le runner ne le lit pas encore en SSH ({{ (item.stderr | default('')
|
|
||||||
| trim).splitlines()[:1] | join('') }}). Apporte en colis par le controleur. S'il est
|
|
||||||
arme et que ca dure : `make forge-cles` au site, et la cle de sa voute.
|
|
||||||
loop: "{{ serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list }}"
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.item.depot }}"
|
|
||||||
|
|
||||||
- name: Retirer les colis qui resteraient (un passage interrompu en laisse)
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: "{{ serveur_ops_racine }}/.colis"
|
|
||||||
state: absent
|
|
||||||
when:
|
|
||||||
- not ansible_check_mode
|
|
||||||
- serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list | length == 0
|
|
||||||
|
|
||||||
- name: Poser la commande SSH de la forge dans chaque depot prive
|
|
||||||
community.general.git_config:
|
|
||||||
name: core.sshCommand
|
|
||||||
scope: local
|
|
||||||
repo: "{{ serveur_ops_racine }}/{{ item.dest }}"
|
|
||||||
value: "{{ serveur_ops_ssh_prive | trim }}"
|
|
||||||
loop: "{{ serveur_ops_depots | selectattr('role', 'in', serveur_ops_roles_prives) | list }}"
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.depot }}"
|
|
||||||
become: true
|
|
||||||
become_user: "{{ serveur_ops_utilisateur }}"
|
|
||||||
when: not ansible_check_mode
|
|
||||||
|
|
||||||
# CE QUI A AVANCE, ET CE QUE CA OBLIGE A RELIRE (2026-09-20).
|
# CE QUI A AVANCE, ET CE QUE CA OBLIGE A RELIRE (2026-09-20).
|
||||||
#
|
#
|
||||||
# SEUL LE MOTEUR PORTE LE CODE DE LA CONSOLE. Un plan qui avance ne change pas
|
# SEUL LE MOTEUR PORTE LE CODE DE LA CONSOLE. Un plan qui avance ne change pas
|
||||||
|
|
@ -577,107 +336,48 @@
|
||||||
become: false
|
become: false
|
||||||
run_once: true
|
run_once: true
|
||||||
|
|
||||||
# LES COLLECTIONS DU VERROU (2026-10-09), meme chemin que les roues : le depot du site
|
# `argv` ET NON `cmd` : le chemin du controleur peut contenir une ESPACE (« Espace
|
||||||
# d'abord, Galaxy en repli dit, la somme verifiee. `collection download` sortait sur Galaxy
|
# Chezlepro/… » chez le mainteneur), et `cmd` la lit comme un separateur d'arguments.
|
||||||
# a chaque runner neuf.
|
# Constate le 2026-08-23 : ansible-galaxy recevait « /home/…/Espace » puis
|
||||||
- name: Prendre les collections verrouillees au depot du site
|
# « Chezlepro/… » comme deux arguments, et refusait — « positional collection_name arg
|
||||||
ansible.builtin.get_url:
|
# and --requirements-file are mutually exclusive ». Le message ne parlait pas du tout du
|
||||||
url: "{{ setops_depot_binaires }}/{{ item.nom }}"
|
# vrai probleme.
|
||||||
dest: "{{ serveur_ops_cache_collections }}/{{ item.nom }}"
|
- name: Telecharger les collections dans le cache du controleur (une seule fois)
|
||||||
checksum: "sha256:{{ item.sha256 }}"
|
ansible.builtin.command:
|
||||||
mode: "0644"
|
argv:
|
||||||
timeout: 30
|
- "ansible-galaxy"
|
||||||
loop: "{{ serveur_ops_verrou.collections }}"
|
- "collection"
|
||||||
loop_control:
|
- "download"
|
||||||
label: "{{ item.nom }}"
|
- "-r"
|
||||||
|
- "{{ serveur_ops_requirements_controleur }}"
|
||||||
|
- "-p"
|
||||||
|
- "{{ serveur_ops_cache_collections }}"
|
||||||
|
creates: "{{ serveur_ops_cache_collections }}/requirements.yml"
|
||||||
delegate_to: localhost
|
delegate_to: localhost
|
||||||
become: false
|
become: false
|
||||||
run_once: true
|
run_once: true
|
||||||
failed_when: false
|
when: not ansible_check_mode
|
||||||
when:
|
|
||||||
- setops_depot_binaires | default('') | length > 0
|
|
||||||
- not ansible_check_mode
|
|
||||||
|
|
||||||
- name: Les collections verrouillees sont-elles dans le cache du controleur ?
|
- name: Deposer les collections sur le poste
|
||||||
ansible.builtin.stat:
|
|
||||||
path: "{{ serveur_ops_cache_collections }}/{{ item.nom }}"
|
|
||||||
checksum_algorithm: sha256
|
|
||||||
loop: "{{ serveur_ops_verrou.collections }}"
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.nom }}"
|
|
||||||
delegate_to: localhost
|
|
||||||
become: false
|
|
||||||
run_once: true
|
|
||||||
register: serveur_ops_collections_cache
|
|
||||||
|
|
||||||
- name: Dire les collections absentes du depot du site (prises sur Galaxy)
|
|
||||||
ansible.builtin.debug:
|
|
||||||
msg: >-
|
|
||||||
{{ serveur_ops_collections_cache.results | rejectattr('stat.exists') | map(attribute='item.nom') | list }}
|
|
||||||
— absentes du depot du site : prises sur Internet.
|
|
||||||
run_once: true
|
|
||||||
# PAS EN SIMULATION : `--check` ne telecharge rien depuis le site, le cache reste vide et
|
|
||||||
# ce message accuserait le depot a tort (vu le 2026-10-09 sur le runner de Technolibre).
|
|
||||||
when:
|
|
||||||
- not ansible_check_mode
|
|
||||||
- serveur_ops_collections_cache.results | rejectattr('stat.exists') | list | length > 0
|
|
||||||
|
|
||||||
- name: Prendre sur Galaxy les collections que le site n'a pas (repli)
|
|
||||||
ansible.builtin.get_url:
|
|
||||||
url: "{{ item.item.url }}"
|
|
||||||
dest: "{{ serveur_ops_cache_collections }}/{{ item.item.nom }}"
|
|
||||||
checksum: "sha256:{{ item.item.sha256 }}"
|
|
||||||
mode: "0644"
|
|
||||||
timeout: 60
|
|
||||||
loop: "{{ serveur_ops_collections_cache.results }}"
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.item.nom }}"
|
|
||||||
delegate_to: localhost
|
|
||||||
become: false
|
|
||||||
run_once: true
|
|
||||||
when:
|
|
||||||
- not ansible_check_mode
|
|
||||||
- not item.stat.exists or item.stat.checksum != item.item.sha256
|
|
||||||
|
|
||||||
- name: Assurer le depot des collections sur le runner
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: "{{ serveur_ops_racine }}/.collections-hors-ligne"
|
|
||||||
state: directory
|
|
||||||
owner: "{{ serveur_ops_utilisateur }}"
|
|
||||||
group: "{{ serveur_ops_utilisateur }}"
|
|
||||||
mode: "0755"
|
|
||||||
|
|
||||||
- name: Deposer sur le poste les collections du verrou
|
|
||||||
register: serveur_ops_depot_collections
|
register: serveur_ops_depot_collections
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
src: "{{ serveur_ops_cache_collections }}/{{ item.nom }}"
|
src: "{{ serveur_ops_cache_collections }}/"
|
||||||
dest: "{{ serveur_ops_racine }}/.collections-hors-ligne/{{ item.nom }}"
|
dest: "{{ serveur_ops_racine }}/.collections-hors-ligne/"
|
||||||
owner: "{{ serveur_ops_utilisateur }}"
|
owner: "{{ serveur_ops_utilisateur }}"
|
||||||
group: "{{ serveur_ops_utilisateur }}"
|
group: "{{ serveur_ops_utilisateur }}"
|
||||||
mode: "0644"
|
mode: "0644"
|
||||||
loop: "{{ serveur_ops_verrou.collections }}"
|
directory_mode: "0755"
|
||||||
loop_control:
|
|
||||||
label: "{{ item.nom }}"
|
|
||||||
when: not ansible_check_mode
|
|
||||||
|
|
||||||
# Le meme format que celui qu'ecrivait `collection download` : des archives en RELATIF,
|
|
||||||
# que l'installation lit depuis ce dossier (`chdir`).
|
|
||||||
- name: Ecrire la liste hors ligne des collections du verrou
|
|
||||||
register: serveur_ops_depot_collections_liste
|
|
||||||
ansible.builtin.copy:
|
|
||||||
dest: "{{ serveur_ops_racine }}/.collections-hors-ligne/requirements.yml"
|
|
||||||
owner: "{{ serveur_ops_utilisateur }}"
|
|
||||||
group: "{{ serveur_ops_utilisateur }}"
|
|
||||||
mode: "0644"
|
|
||||||
content: |
|
|
||||||
# Gere par Set-OPS (role serveur_ops), d'apres verrou-runner.yml. Ne pas editer.
|
|
||||||
collections:
|
|
||||||
{% for c in serveur_ops_verrou.collections %}
|
|
||||||
- name: {{ c.nom }}
|
|
||||||
version: {{ (c.nom | replace('.tar.gz', '')).split('-') | last }}
|
|
||||||
{% endfor %}
|
|
||||||
when: not ansible_check_mode
|
when: not ansible_check_mode
|
||||||
|
|
||||||
|
# `chdir` OBLIGATOIRE : le `requirements.yml` produit par `collection download` nomme les
|
||||||
|
# archives en RELATIF (`community-postgresql-4.2.0.tar.gz`), et ansible-galaxy les cherche
|
||||||
|
# depuis le repertoire COURANT, pas depuis celui du fichier. Sans chdir : « Could not find
|
||||||
|
# community-postgresql-4.2.0.tar.gz » alors que l'archive est bien la, a cote.
|
||||||
|
# LE GARDE-FOU DOIT SUIVRE LE RESULTAT, PAS LE GESTE (2026-08-27). La condition
|
||||||
|
# d'installation ne regardait que le DEPOT des archives. Le jour ou l'on a corrige le
|
||||||
|
# CHEMIN d'installation, les archives etaient inchangees : le role a donc conclu qu'il
|
||||||
|
# n'y avait rien a faire, et les collections sont restees a l'ancien endroit. Le meme
|
||||||
|
# piege qu'en 2026-08-24, deplace d'un cran. On mesure desormais la DESTINATION.
|
||||||
- name: Voir si les collections sont deja la ou le moteur les cherche
|
- name: Voir si les collections sont deja la ou le moteur les cherche
|
||||||
ansible.builtin.stat:
|
ansible.builtin.stat:
|
||||||
path: >-
|
path: >-
|
||||||
|
|
@ -731,7 +431,6 @@
|
||||||
# d'autre ne l'aurait vu. Les conditions vont donc dans UNE liste.
|
# d'autre ne l'aurait vu. Les conditions vont donc dans UNE liste.
|
||||||
when:
|
when:
|
||||||
- serveur_ops_depot_collections is changed
|
- serveur_ops_depot_collections is changed
|
||||||
or serveur_ops_depot_collections_liste is changed
|
|
||||||
or not serveur_ops_collections_en_place.stat.exists
|
or not serveur_ops_collections_en_place.stat.exists
|
||||||
- not ansible_check_mode
|
- not ansible_check_mode
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,16 +70,3 @@ flux:
|
||||||
Insemination : amorcer le runner d'un tenant — socle, moteur, plan, plancher de
|
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
|
resolution — pour qu'il prenne ensuite le relais sur ses propres machines. Ne
|
||||||
transporte aucun secret : la voute est remise par un humain.
|
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.
|
|
||||||
|
|
|
||||||
|
|
@ -227,23 +227,6 @@
|
||||||
- serveur_ops_tenant_ssh_cle | length > 0
|
- serveur_ops_tenant_ssh_cle | length > 0
|
||||||
- not ansible_check_mode
|
- not ansible_check_mode
|
||||||
|
|
||||||
# LA CLE QUI LIT LE DEPOT DU GENOME (2026-10-11). Le site l'enregistre sur le depot de ce
|
|
||||||
# locataire d'apres sa face (`serveur_ops_tenant_cle_publique`) : si la voute en remettait une
|
|
||||||
# autre, le runner arme ne pourrait plus tirer son plan, et l'echec viendrait bien plus tard.
|
|
||||||
- name: Exiger que la cle du runner soit celle que la face publie pour lire le genome
|
|
||||||
ansible.builtin.assert:
|
|
||||||
that:
|
|
||||||
- (serveur_ops_tenant_ssh_publique.stdout.split()[:2] | join(' '))
|
|
||||||
== (serveur_ops_tenant_cle_publique.split()[:2] | join(' '))
|
|
||||||
fail_msg: >-
|
|
||||||
La cle SSH du runner (issue de la voute) n'est PAS `serveur_ops_tenant_cle_publique` :
|
|
||||||
la forge du site n'autorise que celle-ci a lire le depot de ce locataire. Corriger la
|
|
||||||
declaration (puis republier la face), ou la voute.
|
|
||||||
when:
|
|
||||||
- serveur_ops_tenant_ssh_cle | length > 0
|
|
||||||
- serveur_ops_tenant_cle_publique | default('') | length > 0
|
|
||||||
- not ansible_check_mode
|
|
||||||
|
|
||||||
- name: Dire qu'aucune identite n'est conservee
|
- name: Dire qu'aucune identite n'est conservee
|
||||||
ansible.builtin.debug:
|
ansible.builtin.debug:
|
||||||
msg: >-
|
msg: >-
|
||||||
|
|
|
||||||
|
|
@ -38,13 +38,12 @@ serveur_postfix_mailstore_hote: ""
|
||||||
serveur_postfix_lmtp_port: 24
|
serveur_postfix_lmtp_port: 24
|
||||||
|
|
||||||
# --- Soumission :587 (clients authentifiés) ---
|
# --- Soumission :587 (clients authentifiés) ---
|
||||||
# SASL délégué à un Dovecot d'AUTHENTIFICATION SEULE, sur cette machine, par socket local
|
# SASL délégué à Dovecot (passdb LDAP prouvé) sur le nœud mail-store, via son auth réseau.
|
||||||
# (`serveur_dovecot/tasks/auth_locale.yml`) ; il vérifie sur l'annuaire en LDAPS. Jusqu'au
|
|
||||||
# 2026-10-12, Postfix interrogeait le Dovecot du mailstore sur :12345 — en clair, faute de
|
|
||||||
# TLS dans ce protocole.
|
|
||||||
# 587 (STARTTLS requis) et 465 (TLS direct) ; seuls les authentifiés relaient. Publics depuis le
|
# 587 (STARTTLS requis) et 465 (TLS direct) ; seuls les authentifiés relaient. Publics depuis le
|
||||||
# 2026-09-29 : les clients de courriel y arrivent par l'adresse publique du locataire.
|
# 2026-09-29 : les clients de courriel y arrivent par l'adresse publique du locataire.
|
||||||
serveur_postfix_submission_actif: false
|
serveur_postfix_submission_actif: false
|
||||||
|
serveur_postfix_sasl_hote: "{{ serveur_postfix_mailstore_hote }}"
|
||||||
|
serveur_postfix_sasl_port: 12345
|
||||||
|
|
||||||
# --- Annuaire LDAP (validation domaines/boîtes/alias) ---
|
# --- Annuaire LDAP (validation domaines/boîtes/alias) ---
|
||||||
# Connexion à l'annuaire (serveur, base, bind_dn, bind_password) : fournie par le rôle partagé
|
# Connexion à l'annuaire (serveur, base, bind_dn, bind_password) : fournie par le rôle partagé
|
||||||
|
|
@ -60,26 +59,6 @@ serveur_postfix_tls_source_cert: "/etc/step/certs/{{ ansible_fqdn | default(ansi
|
||||||
serveur_postfix_tls_source_cle: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.key"
|
serveur_postfix_tls_source_cle: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.key"
|
||||||
serveur_postfix_tls_dir: "/etc/postfix/tls"
|
serveur_postfix_tls_dir: "/etc/postfix/tls"
|
||||||
|
|
||||||
# TOUT CE QUI TRANSITE EST CHIFFRE, A L'INTERNE COMME A L'EXTERNE (directive du 2026-10-12).
|
|
||||||
#
|
|
||||||
# Un RELAIS ne parle qu'a des emetteurs de son ecosysteme (la forge) et a des MX distants :
|
|
||||||
# il peut EXIGER le TLS des deux cotes. `may` laissait une forge sans STARTTLS remettre en
|
|
||||||
# clair, sans que rien ne le dise. Le serveur de courriel COMPLET d'un locataire reste en
|
|
||||||
# `may` pour l'instant : un MX public qui exige le TLS refuse le courrier des serveurs qui ne
|
|
||||||
# l'offrent pas (RFC 3207) — c'est une decision de l'Etape B, pas un reglage par defaut.
|
|
||||||
serveur_postfix_tls_entrant: "{{ 'encrypt' if serveur_postfix_mode == 'relais' else 'may' }}"
|
|
||||||
# EN SORTIE, LE RELAIS VERIFIE AUSSI QUI IL JOINT (2026-10-12) : `encrypt` chiffrait vers un
|
|
||||||
# serveur dont il ne verifiait pas l'identite (« Untrusted » au journal). Il n'ecrit qu'a
|
|
||||||
# quelques destinataires connus ; un MX au certificat invalide fait refuser le message au lieu
|
|
||||||
# de le livrer, et c'est voulu. Mesure avant : mx.chezlepro.ca et Gmail « Verified ».
|
|
||||||
serveur_postfix_tls_sortant: "{{ 'verify' if serveur_postfix_mode == 'relais' else 'may' }}"
|
|
||||||
# Les autorites PUBLIQUES du systeme : celles des MX de l'Internet, pas celle de l'ecosysteme.
|
|
||||||
# UN FICHIER, PAS UN DOSSIER : `smtp` tourne en chroot ; `CAfile` est lu AVANT d'y entrer,
|
|
||||||
# `CApath` dedans — sur une copie que Debian refait au redemarrage, et qui vieillit entre deux.
|
|
||||||
serveur_postfix_tls_ca_publiques: "/etc/ssl/certs/ca-certificates.crt"
|
|
||||||
# Une ligne par session TLS au journal : ce qui se dit chiffre doit pouvoir se MESURER.
|
|
||||||
serveur_postfix_tls_journal: 1
|
|
||||||
|
|
||||||
# Zero-confiance : verifier le TLS de la livraison LMTP vers Dovecot (cert step-ca).
|
# Zero-confiance : verifier le TLS de la livraison LMTP vers Dovecot (cert step-ca).
|
||||||
serveur_postfix_lmtp_tls_verify: false
|
serveur_postfix_lmtp_tls_verify: false
|
||||||
serveur_postfix_lmtp_tls_cafile: "/etc/step/certs/root_ca.crt"
|
serveur_postfix_lmtp_tls_cafile: "/etc/step/certs/root_ca.crt"
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,12 @@ flux:
|
||||||
pair: serveur_dovecot
|
pair: serveur_dovecot
|
||||||
chiffrement: tls-requis
|
chiffrement: tls-requis
|
||||||
raison: "Remise finale par LMTP au mailstore (Dovecot), en TLS vérifié."
|
raison: "Remise finale par LMTP au mailstore (Dovecot), en TLS vérifié."
|
||||||
|
- sens: egress
|
||||||
|
port: 12345
|
||||||
|
protocole: tcp
|
||||||
|
pair: serveur_dovecot
|
||||||
|
chiffrement: tls
|
||||||
|
raison: "Validation SASL des identifiants de soumission contre Dovecot."
|
||||||
- sens: egress
|
- sens: egress
|
||||||
port: 11332
|
port: 11332
|
||||||
protocole: tcp
|
protocole: tcp
|
||||||
|
|
@ -47,7 +53,7 @@ flux:
|
||||||
protocole: tcp
|
protocole: tcp
|
||||||
pair: serveur_openldap
|
pair: serveur_openldap
|
||||||
chiffrement: tls-requis
|
chiffrement: tls-requis
|
||||||
raison: "Tables virtuelles (domaines/alias/boîtes) résolues sur l'annuaire, et mots de passe de soumission vérifiés par le Dovecot local (LDAPS)."
|
raison: "Tables virtuelles (domaines/alias/boîtes) résolues sur l'annuaire (LDAPS)."
|
||||||
- sens: egress
|
- sens: egress
|
||||||
port: 25
|
port: 25
|
||||||
protocole: tcp
|
protocole: tcp
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,7 @@
|
||||||
# 465 (`submissions`, TLS DIRECT, 2026-09-29) A COTE DU 587 (STARTTLS). La RFC 8314 recommande
|
# 465 (`submissions`, TLS DIRECT, 2026-09-29) A COTE DU 587 (STARTTLS). La RFC 8314 recommande
|
||||||
# le TLS direct pour la soumission : aucun echange en clair avant le chiffrement. Memes
|
# le TLS direct pour la soumission : aucun echange en clair avant le chiffrement. Memes
|
||||||
# restrictions que le 587 : authentifie, sinon refuse.
|
# restrictions que le 587 : authentifie, sinon refuse.
|
||||||
- name: Activer la soumission :587 et :465 (clients authentifiés, SASL Dovecot local)
|
- name: Activer la soumission :587 et :465 (clients authentifiés, SASL Dovecot distant)
|
||||||
ansible.builtin.blockinfile:
|
ansible.builtin.blockinfile:
|
||||||
path: /etc/postfix/master.cf
|
path: /etc/postfix/master.cf
|
||||||
marker: "# {mark} SETOPS SUBMISSION 587"
|
marker: "# {mark} SETOPS SUBMISSION 587"
|
||||||
|
|
@ -202,7 +202,7 @@
|
||||||
-o smtpd_tls_security_level=encrypt
|
-o smtpd_tls_security_level=encrypt
|
||||||
-o smtpd_sasl_auth_enable=yes
|
-o smtpd_sasl_auth_enable=yes
|
||||||
-o smtpd_sasl_type=dovecot
|
-o smtpd_sasl_type=dovecot
|
||||||
-o smtpd_sasl_path=private/auth
|
-o smtpd_sasl_path=inet:{{ serveur_postfix_sasl_hote }}:{{ serveur_postfix_sasl_port }}
|
||||||
-o smtpd_sasl_security_options=noanonymous
|
-o smtpd_sasl_security_options=noanonymous
|
||||||
-o smtpd_client_restrictions=permit_sasl_authenticated,reject
|
-o smtpd_client_restrictions=permit_sasl_authenticated,reject
|
||||||
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
||||||
|
|
@ -211,7 +211,7 @@
|
||||||
-o smtpd_tls_wrappermode=yes
|
-o smtpd_tls_wrappermode=yes
|
||||||
-o smtpd_sasl_auth_enable=yes
|
-o smtpd_sasl_auth_enable=yes
|
||||||
-o smtpd_sasl_type=dovecot
|
-o smtpd_sasl_type=dovecot
|
||||||
-o smtpd_sasl_path=private/auth
|
-o smtpd_sasl_path=inet:{{ serveur_postfix_sasl_hote }}:{{ serveur_postfix_sasl_port }}
|
||||||
-o smtpd_sasl_security_options=noanonymous
|
-o smtpd_sasl_security_options=noanonymous
|
||||||
-o smtpd_client_restrictions=permit_sasl_authenticated,reject
|
-o smtpd_client_restrictions=permit_sasl_authenticated,reject
|
||||||
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
||||||
|
|
@ -231,17 +231,6 @@
|
||||||
state: started
|
state: started
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
|
|
||||||
# LES MOTS DE PASSE NE QUITTENT PLUS LA MACHINE (2026-10-12). `smtpd_sasl_path` visait
|
|
||||||
# `inet:<mailstore>:12345`, que le client SASL Dovecot de Postfix ne sait pas chiffrer :
|
|
||||||
# chaque soumission y envoyait son mot de passe en clair. Le socket `private/auth` est
|
|
||||||
# servi par un Dovecot d'authentification seule, pose ici, qui verifie en LDAPS. Apres le
|
|
||||||
# demarrage de Postfix : c'est lui qui cree `private/` a sa premiere mise en route.
|
|
||||||
- name: Authentification de la soumission — Dovecot local (aucun port)
|
|
||||||
ansible.builtin.include_role:
|
|
||||||
name: serveur_dovecot
|
|
||||||
tasks_from: auth_locale.yml
|
|
||||||
when: serveur_postfix_submission_actif | bool
|
|
||||||
|
|
||||||
# --- Sonde de supervision (docs/supervision-conception.md) --------------------------
|
# --- Sonde de supervision (docs/supervision-conception.md) --------------------------
|
||||||
# Le role qui possede la verite depose sa propre sonde ; le porteur (`client_sante`) la
|
# Le role qui possede la verite depose sa propre sonde ; le porteur (`client_sante`) la
|
||||||
# fait tourner et pousse le verdict, sans savoir ce qu'elle mesure.
|
# fait tourner et pousse le verdict, sans savoir ce qu'elle mesure.
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,7 @@ local_recipient_maps =
|
||||||
qu'elle sorte au nom du SITE — pas d'un locataire. #}
|
qu'elle sorte au nom du SITE — pas d'un locataire. #}
|
||||||
relayhost =
|
relayhost =
|
||||||
{# `mynetworks` seul decide qui peut faire relayer : la loopback, donc les services de
|
{# `mynetworks` seul decide qui peut faire relayer : la loopback, donc les services de
|
||||||
cette machine, et les machines dont un role declare un flux vers ce relais (la forge ;
|
cette machine, et rien d'autre. Un relais ouvert est repere et exploite en heures. #}
|
||||||
au site, `site_inventaire.reseaux_confiance_relais`). Rien d'autre : un relais ouvert
|
|
||||||
est repere et exploite en heures. #}
|
|
||||||
smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination
|
smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination
|
||||||
{% else %}
|
{% else %}
|
||||||
# --- Boîtes virtuelles : validation LDAP + remise LMTP vers le nœud mail-store (Dovecot) ---
|
# --- Boîtes virtuelles : validation LDAP + remise LMTP vers le nœud mail-store (Dovecot) ---
|
||||||
|
|
@ -39,14 +37,9 @@ lmtp_host_lookup = native
|
||||||
# --- TLS (certificat step_ca) ---
|
# --- TLS (certificat step_ca) ---
|
||||||
smtpd_tls_cert_file = {{ serveur_postfix_tls_dir }}/cert.pem
|
smtpd_tls_cert_file = {{ serveur_postfix_tls_dir }}/cert.pem
|
||||||
smtpd_tls_key_file = {{ serveur_postfix_tls_dir }}/key.pem
|
smtpd_tls_key_file = {{ serveur_postfix_tls_dir }}/key.pem
|
||||||
smtpd_tls_security_level = {{ serveur_postfix_tls_entrant }}
|
smtpd_tls_security_level = may
|
||||||
smtpd_tls_protocols = >=TLSv1.2, !SSLv2, !SSLv3
|
smtpd_tls_protocols = >=TLSv1.2, !SSLv2, !SSLv3
|
||||||
smtpd_tls_mandatory_protocols = >=TLSv1.2
|
smtp_tls_security_level = may
|
||||||
smtpd_tls_loglevel = {{ serveur_postfix_tls_journal }}
|
|
||||||
smtp_tls_security_level = {{ serveur_postfix_tls_sortant }}
|
|
||||||
smtp_tls_CAfile = {{ serveur_postfix_tls_ca_publiques }}
|
|
||||||
smtp_tls_mandatory_protocols = >=TLSv1.2
|
|
||||||
smtp_tls_loglevel = {{ serveur_postfix_tls_journal }}
|
|
||||||
{% if serveur_postfix_lmtp_tls_verify | default(false) %}
|
{% if serveur_postfix_lmtp_tls_verify | default(false) %}
|
||||||
# Zero-confiance : livraison LMTP vers Dovecot chiffree ET verifiee (cert step-ca).
|
# Zero-confiance : livraison LMTP vers Dovecot chiffree ET verifiee (cert step-ca).
|
||||||
# `wrappermode` = TLS des la connexion : c'est ce que sait offrir un `inet_listener`
|
# `wrappermode` = TLS des la connexion : c'est ce que sait offrir un `inet_listener`
|
||||||
|
|
|
||||||
|
|
@ -72,12 +72,6 @@ serveur_postgresql_exportateur_service: prometheus-postgres-exporter
|
||||||
serveur_postgresql_exportateur_port: 9187
|
serveur_postgresql_exportateur_port: 9187
|
||||||
serveur_postgresql_exportateur_utilisateur: setops_metriques
|
serveur_postgresql_exportateur_utilisateur: setops_metriques
|
||||||
serveur_postgresql_exportateur_mot_de_passe: "{{ vault_pg_exportateur | default('') }}"
|
serveur_postgresql_exportateur_mot_de_passe: "{{ vault_pg_exportateur | default('') }}"
|
||||||
# L'EXPORTATEUR SERT EN TLS QUAND LA BASE LE FAIT (2026-10-12). Meme interrupteur que
|
|
||||||
# PostgreSQL, pas un second : la machine a alors son certificat (client_pki), et Prometheus
|
|
||||||
# lit ce meme interrupteur (`meta/metriques.yml`, `tls_si`) pour choisir son schema. Deux
|
|
||||||
# interrupteurs pourraient diverger — l'un servirait en TLS, l'autre lirait en clair.
|
|
||||||
serveur_postgresql_exportateur_tls_dir: "/etc/prometheus-postgres-exporter/tls"
|
|
||||||
serveur_postgresql_exportateur_web_config: "/etc/prometheus-postgres-exporter/web-config.yml"
|
|
||||||
|
|
||||||
# --- RESTAURATION (2026-09-30) — voir tasks/main.yml ---------------------------------
|
# --- RESTAURATION (2026-09-30) — voir tasks/main.yml ---------------------------------
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,20 @@ flux:
|
||||||
port: 9187
|
port: 9187
|
||||||
protocole: tcp
|
protocole: tcp
|
||||||
pair: [serveur_prometheus]
|
pair: [serveur_prometheus]
|
||||||
# DETTE FERMEE (2026-10-12). Inscrite `clair` le 2026-09-14 : l'exportateur sert
|
# `clair`, ET C'EST UNE DETTE QU'ON ECRIT PLUTOT QUE DE LA TAIRE (2026-09-14).
|
||||||
# desormais en HTTPS (`--web.config.file`, certificat de la machine resynchronise a chaque
|
#
|
||||||
# renouvellement), et Prometheus verifie contre l'autorite des que
|
# `client_metrique` sert deja ses metriques en TLS, certificat synchronise par
|
||||||
# `serveur_postgresql_tls_actif` est vrai — partout, au site comme chez les locataires.
|
# `client_pki`. Cet exportateur-ci ne le fait pas encore : il faudrait lui poser un
|
||||||
chiffrement: tls
|
# `--web.config.file` et l'abonner au meme renouvellement.
|
||||||
|
#
|
||||||
|
# CE QUI TRANSITE N'EST PAS ANODIN : nombre de connexions, tailles des bases, ratios
|
||||||
|
# de cache. Pas de donnee applicative, mais l'etat interne d'un serveur de donnees.
|
||||||
|
# Le flux ne sort pas de la zone, et la frontiere ne le laisse passer que depuis
|
||||||
|
# l'observatoire — ce n'est pas une excuse, c'est ce qui rend la dette tenable en
|
||||||
|
# attendant.
|
||||||
|
chiffrement: clair
|
||||||
raison: >-
|
raison: >-
|
||||||
Metriques PostgreSQL lues par l'observatoire, en HTTPS verifie. Series, pas verdicts :
|
Metriques PostgreSQL lues par l'observatoire. Series, pas verdicts : ce qui derive
|
||||||
ce qui derive lentement — cache qui decroche, connexions qui montent, bases qui
|
lentement — cache qui decroche, connexions qui montent, bases qui grossissent — n'a
|
||||||
grossissent — n'a que le graphe pour se faire voir.
|
que le graphe pour se faire voir. En clair pour l'instant, contrairement a
|
||||||
|
`client_metrique` : dette inscrite, a fermer par `--web.config.file` + `client_pki`.
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,6 @@ exportateur:
|
||||||
service: prometheus-postgres-exporter
|
service: prometheus-postgres-exporter
|
||||||
port: 9187
|
port: 9187
|
||||||
job: postgresql
|
job: postgresql
|
||||||
# LE SCHEMA SUIT UN INTERRUPTEUR DE LA CIBLE (2026-10-12). Prometheus lit cette variable
|
|
||||||
# dans les hostvars de chaque hote du groupe : toutes vraies, il collecte en HTTPS et
|
|
||||||
# verifie contre l'autorite ; sinon en HTTP. Le role, lui, sert en TLS sur le meme
|
|
||||||
# interrupteur — une seule valeur decide des deux bouts.
|
|
||||||
tls_si: serveur_postgresql_tls_actif
|
|
||||||
|
|
||||||
# LES PANNEAUX : ce qui merite d'etre REGARDE DANS LE TEMPS.
|
# LES PANNEAUX : ce qui merite d'etre REGARDE DANS LE TEMPS.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -330,100 +330,6 @@
|
||||||
state: present
|
state: present
|
||||||
when: serveur_postgresql_exportateur_actif | bool
|
when: serveur_postgresql_exportateur_actif | bool
|
||||||
|
|
||||||
# --- TLS DE L'EXPORTATEUR (2026-10-12) -----------------------------------------------
|
|
||||||
# Le 9187 etait le seul port de metriques en clair, chez les locataires comme au site :
|
|
||||||
# une dette inscrite au registre le 2026-09-14. Meme pont que `client_metrique` : le
|
|
||||||
# certificat de la machine, copie la ou `prometheus` le lit, recopie a chaque renouvellement.
|
|
||||||
- name: Exportateur TLS — repertoire des certificats (prometheus)
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: "{{ serveur_postgresql_exportateur_tls_dir }}"
|
|
||||||
state: directory
|
|
||||||
owner: prometheus
|
|
||||||
group: prometheus
|
|
||||||
mode: "0700"
|
|
||||||
when:
|
|
||||||
- serveur_postgresql_exportateur_actif | bool
|
|
||||||
- serveur_postgresql_tls_actif | bool
|
|
||||||
|
|
||||||
- name: Exportateur TLS — script de synchronisation du certificat
|
|
||||||
ansible.builtin.template:
|
|
||||||
src: setops-pg-exportateur-cert-sync.sh.j2
|
|
||||||
dest: /usr/local/sbin/setops-pg-exportateur-cert-sync
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0755"
|
|
||||||
when:
|
|
||||||
- serveur_postgresql_exportateur_actif | bool
|
|
||||||
- serveur_postgresql_tls_actif | bool
|
|
||||||
|
|
||||||
- name: Exportateur TLS — unites de synchronisation (service + path)
|
|
||||||
ansible.builtin.template:
|
|
||||||
src: "{{ item.s }}"
|
|
||||||
dest: "{{ item.d }}"
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0644"
|
|
||||||
loop:
|
|
||||||
- { s: "setops-pg-exportateur-cert-sync.service.j2", d: "/etc/systemd/system/setops-pg-exportateur-cert-sync.service" }
|
|
||||||
- { s: "setops-pg-exportateur-cert-sync.path.j2", d: "/etc/systemd/system/setops-pg-exportateur-cert-sync.path" }
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.d | basename }}"
|
|
||||||
when:
|
|
||||||
- serveur_postgresql_exportateur_actif | bool
|
|
||||||
- serveur_postgresql_tls_actif | bool
|
|
||||||
|
|
||||||
- name: Exportateur TLS — activer la surveillance du certificat (path)
|
|
||||||
ansible.builtin.systemd:
|
|
||||||
name: setops-pg-exportateur-cert-sync.path
|
|
||||||
enabled: true
|
|
||||||
state: started
|
|
||||||
daemon_reload: true
|
|
||||||
when:
|
|
||||||
- serveur_postgresql_exportateur_actif | bool
|
|
||||||
- serveur_postgresql_tls_actif | bool
|
|
||||||
- not ansible_check_mode
|
|
||||||
|
|
||||||
- name: Exportateur TLS — synchroniser le certificat maintenant
|
|
||||||
ansible.builtin.command: /usr/local/sbin/setops-pg-exportateur-cert-sync
|
|
||||||
changed_when: false
|
|
||||||
when:
|
|
||||||
- serveur_postgresql_exportateur_actif | bool
|
|
||||||
- serveur_postgresql_tls_actif | bool
|
|
||||||
- not ansible_check_mode
|
|
||||||
|
|
||||||
- name: Exportateur TLS — garde-fou, ne pas activer sans certificat en place
|
|
||||||
ansible.builtin.stat:
|
|
||||||
path: "{{ serveur_postgresql_exportateur_tls_dir }}/exportateur.key"
|
|
||||||
register: serveur_postgresql_exportateur_cle
|
|
||||||
when:
|
|
||||||
- serveur_postgresql_exportateur_actif | bool
|
|
||||||
- serveur_postgresql_tls_actif | bool
|
|
||||||
- not ansible_check_mode
|
|
||||||
|
|
||||||
- name: Exportateur TLS — exiger le certificat synchronise
|
|
||||||
ansible.builtin.assert:
|
|
||||||
that:
|
|
||||||
- serveur_postgresql_exportateur_cle.stat.exists
|
|
||||||
fail_msg: >-
|
|
||||||
Certificat de l'exportateur absent ({{ serveur_postgresql_exportateur_tls_dir }}) :
|
|
||||||
la machine porte-t-elle client_pki ? TLS non active, plutot qu'un exportateur muet.
|
|
||||||
when:
|
|
||||||
- serveur_postgresql_exportateur_actif | bool
|
|
||||||
- serveur_postgresql_tls_actif | bool
|
|
||||||
- not ansible_check_mode
|
|
||||||
|
|
||||||
- name: Exportateur TLS — deployer la web-config
|
|
||||||
ansible.builtin.template:
|
|
||||||
src: web-config-exportateur.yml.j2
|
|
||||||
dest: "{{ serveur_postgresql_exportateur_web_config }}"
|
|
||||||
owner: root
|
|
||||||
group: prometheus
|
|
||||||
mode: "0640"
|
|
||||||
notify: Redemarrer l exportateur de metriques
|
|
||||||
when:
|
|
||||||
- serveur_postgresql_exportateur_actif | bool
|
|
||||||
- serveur_postgresql_tls_actif | bool
|
|
||||||
|
|
||||||
# LA CHAINE DE CONNEXION NE PASSE PAS PAR LA LIGNE DE COMMANDE. Un `DATA_SOURCE_NAME` en
|
# LA CHAINE DE CONNEXION NE PASSE PAS PAR LA LIGNE DE COMMANDE. Un `DATA_SOURCE_NAME` en
|
||||||
# argument serait lisible dans `ps` par tout le monde sur la machine ; dans un fichier a
|
# argument serait lisible dans `ps` par tout le monde sur la machine ; dans un fichier a
|
||||||
# 0600, il ne l'est que par root et le service.
|
# 0600, il ne l'est que par root et le service.
|
||||||
|
|
@ -434,9 +340,7 @@
|
||||||
DATA_SOURCE_NAME="postgresql://{{ serveur_postgresql_exportateur_utilisateur
|
DATA_SOURCE_NAME="postgresql://{{ serveur_postgresql_exportateur_utilisateur
|
||||||
}}:{{ serveur_postgresql_exportateur_mot_de_passe
|
}}:{{ serveur_postgresql_exportateur_mot_de_passe
|
||||||
}}@127.0.0.1:5432/postgres?sslmode=disable"
|
}}@127.0.0.1:5432/postgres?sslmode=disable"
|
||||||
ARGS="--web.listen-address=:{{ serveur_postgresql_exportateur_port }}{{
|
ARGS="--web.listen-address=:{{ serveur_postgresql_exportateur_port }}"
|
||||||
(' --web.config.file=' ~ serveur_postgresql_exportateur_web_config)
|
|
||||||
if serveur_postgresql_tls_actif | bool else '' }}"
|
|
||||||
dest: /etc/default/prometheus-postgres-exporter
|
dest: /etc/default/prometheus-postgres-exporter
|
||||||
owner: root
|
owner: root
|
||||||
group: root
|
group: root
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
# Gere par Set-OPS (role serveur_postgresql). Ne pas editer a la main.
|
|
||||||
[Unit]
|
|
||||||
Description=Set-OPS — surveille le certificat step_ca de l'exportateur PostgreSQL
|
|
||||||
|
|
||||||
[Path]
|
|
||||||
PathChanged={{ serveur_postgresql_tls_source_cert }}
|
|
||||||
Unit=setops-pg-exportateur-cert-sync.service
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
# Gere par Set-OPS (role serveur_postgresql). Ne pas editer a la main.
|
|
||||||
[Unit]
|
|
||||||
Description=Set-OPS — synchronise le certificat step_ca vers l'exportateur PostgreSQL
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
ExecStart=/usr/local/sbin/setops-pg-exportateur-cert-sync
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
# Gere par Set-OPS (role serveur_postgresql). Ne pas editer a la main.
|
|
||||||
# Synchronise le cert step_ca (client_pki, root:root) vers un emplacement lisible par
|
|
||||||
# 'prometheus' (l'utilisateur de l'exportateur). Cle en 0600. Idempotent ; appele au
|
|
||||||
# deploiement ET au renouvellement (unite .path).
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
SRC_CERT="{{ serveur_postgresql_tls_source_cert }}"
|
|
||||||
SRC_KEY="{{ serveur_postgresql_tls_source_cle }}"
|
|
||||||
DST="{{ serveur_postgresql_exportateur_tls_dir }}"
|
|
||||||
|
|
||||||
[ -f "$SRC_CERT" ] && [ -f "$SRC_KEY" ] || exit 0
|
|
||||||
|
|
||||||
install -o prometheus -g prometheus -m 0644 "$SRC_CERT" "$DST/exportateur.crt"
|
|
||||||
install -o prometheus -g prometheus -m 0600 "$SRC_KEY" "$DST/exportateur.key"
|
|
||||||
|
|
||||||
# REDEMARRAGE, pas rechargement : meme famille que node_exporter (client_metrique), dont
|
|
||||||
# l'ExecReload Debian envoie un SIGHUP que le binaire ne sait pas honorer. `try-restart` :
|
|
||||||
# ne demarre pas un exportateur qu'on aurait volontairement arrete.
|
|
||||||
systemctl try-restart {{ serveur_postgresql_exportateur_service }} 2>/dev/null || true
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# Gere par Set-OPS (role serveur_postgresql). TLS de l'exportateur (exporter-toolkit).
|
|
||||||
# Cert/cle step_ca synchronises par setops-pg-exportateur-cert-sync (proprietaire prometheus).
|
|
||||||
tls_server_config:
|
|
||||||
cert_file: {{ serveur_postgresql_exportateur_tls_dir }}/exportateur.crt
|
|
||||||
key_file: {{ serveur_postgresql_exportateur_tls_dir }}/exportateur.key
|
|
||||||
|
|
@ -4,9 +4,6 @@ serveur_prometheus_paquets:
|
||||||
|
|
||||||
serveur_prometheus_service: "prometheus"
|
serveur_prometheus_service: "prometheus"
|
||||||
serveur_prometheus_config: "/etc/prometheus/prometheus.yml"
|
serveur_prometheus_config: "/etc/prometheus/prometheus.yml"
|
||||||
# Le dossier de la base (defaut du paquet Debian, que `ARGS` ne change pas). Lu aussi par
|
|
||||||
# client_backup : la copie a froid et la restauration le visent.
|
|
||||||
serveur_prometheus_donnees: "/var/lib/prometheus/metrics2"
|
|
||||||
|
|
||||||
serveur_prometheus_intervalle: "15s"
|
serveur_prometheus_intervalle: "15s"
|
||||||
serveur_prometheus_retention: "15d"
|
serveur_prometheus_retention: "15d"
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,7 @@ flux:
|
||||||
port: 9100
|
port: 9100
|
||||||
protocole: tcp
|
protocole: tcp
|
||||||
pair: frontiere
|
pair: frontiere
|
||||||
# HTTPS VERIFIE (2026-10-12) : certificat emis par l'autorite de la frontiere.
|
chiffrement: clair
|
||||||
chiffrement: tls
|
|
||||||
raison: >-
|
raison: >-
|
||||||
Scrape de l'exportateur de la frontiere (os-node_exporter), sur sa seule patte de
|
Scrape de l'exportateur de la frontiere (os-node_exporter), sur sa seule patte de
|
||||||
supervision : l'equipement qui voit passer TOUT le trafic n'avait ni temperature ni
|
supervision : l'equipement qui voit passer TOUT le trafic n'avait ni temperature ni
|
||||||
|
|
|
||||||
|
|
@ -6,58 +6,6 @@
|
||||||
update_cache: true
|
update_cache: true
|
||||||
cache_valid_time: 3600
|
cache_valid_time: 3600
|
||||||
|
|
||||||
# --- LA BASE D'UNE INCARNATION PRECEDENTE (2026-10-07) --------------------------------
|
|
||||||
#
|
|
||||||
# Les metriques repartaient de zero a chaque reconstruction. client_backup en garde une
|
|
||||||
# copie a froid (jeu `prometheus`) ; on la remet ICI, juste apres l'installation : le paquet
|
|
||||||
# demarre Prometheus aussitot, mais sa base n'a encore aucun bloc (le premier se ferme au
|
|
||||||
# bout de deux heures). Service arrete pendant la remise ; la tache « Activer et demarrer »
|
|
||||||
# plus bas le relance.
|
|
||||||
- name: La base de Prometheus a-t-elle deja des blocs ?
|
|
||||||
ansible.builtin.find:
|
|
||||||
paths: "{{ serveur_prometheus_donnees }}"
|
|
||||||
file_type: directory
|
|
||||||
patterns: "01*"
|
|
||||||
register: serveur_prometheus_blocs
|
|
||||||
|
|
||||||
- name: Restauration — la base d'une incarnation precedente ?
|
|
||||||
ansible.builtin.include_role:
|
|
||||||
name: client_backup
|
|
||||||
tasks_from: restaurer.yml
|
|
||||||
vars:
|
|
||||||
client_backup_restaurer_jeu: serveur_prometheus
|
|
||||||
client_backup_restaurer_vierge: "{{ serveur_prometheus_blocs.matched == 0 }}"
|
|
||||||
|
|
||||||
- name: Restauration — remettre la base, service arrete
|
|
||||||
when: client_backup_restauration.etat == 'a_restaurer'
|
|
||||||
block:
|
|
||||||
- name: Restauration — arreter Prometheus
|
|
||||||
ansible.builtin.systemd:
|
|
||||||
name: "{{ serveur_prometheus_service }}"
|
|
||||||
state: stopped
|
|
||||||
|
|
||||||
- name: Restauration — remettre la copie a froid a sa place
|
|
||||||
changed_when: true
|
|
||||||
ansible.builtin.command:
|
|
||||||
argv:
|
|
||||||
- /usr/local/sbin/setops-restaurer
|
|
||||||
- fichiers
|
|
||||||
- --instantane
|
|
||||||
- "{{ client_backup_restauration.instantane }}"
|
|
||||||
- --depuis
|
|
||||||
- "{{ client_backup_restauration.chemins[0] }}/metrics2"
|
|
||||||
- --remplacer
|
|
||||||
- --proprietaire
|
|
||||||
- prometheus:prometheus
|
|
||||||
- "{{ serveur_prometheus_donnees }}"
|
|
||||||
|
|
||||||
- name: Restauration — acter la base remise
|
|
||||||
ansible.builtin.include_role:
|
|
||||||
name: client_backup
|
|
||||||
tasks_from: acter.yml
|
|
||||||
vars:
|
|
||||||
client_backup_acter_etat: restaure
|
|
||||||
|
|
||||||
# --- LES CIBLES QUE LES ROLES DECLARENT (2026-09-14) ----------------------------------
|
# --- LES CIBLES QUE LES ROLES DECLARENT (2026-09-14) ----------------------------------
|
||||||
#
|
#
|
||||||
# `serveur_prometheus_cibles_supplementaires` existait depuis longtemps — une liste libre,
|
# `serveur_prometheus_cibles_supplementaires` existait depuis longtemps — une liste libre,
|
||||||
|
|
|
||||||
|
|
@ -43,21 +43,9 @@ scrape_configs:
|
||||||
| map('extract', hostvars) | selectattr('ansible_host', 'defined')
|
| map('extract', hostvars) | selectattr('ansible_host', 'defined')
|
||||||
| map(attribute='ansible_host')
|
| map(attribute='ansible_host')
|
||||||
| map('regex_replace', '$', ':' ~ (expo.port | default(0))) | list %}
|
| map('regex_replace', '$', ':' ~ (expo.port | default(0))) | list %}
|
||||||
{# TLS SI LA DECLARATION LE DIT ET QUE CHAQUE CIBLE L'A (2026-10-12) : `tls_si` nomme
|
|
||||||
l'interrupteur que le role exportateur lit lui aussi. Une seule cible sans lui, et le
|
|
||||||
job reste en HTTP — mieux qu'un job HTTPS qui en perdrait une sans rien dire. #}
|
|
||||||
{% set tls = namespace(oui=(expo.tls_si | default('') | length > 0) if expo else false) %}
|
|
||||||
{% for h in (groups.get(role, []) | intersect(groups.get('hotes_actifs', [])) | sort) %}
|
|
||||||
{% if tls.oui and not (hostvars[h][expo.tls_si] | default(false) | bool) %}{% set tls.oui = false %}{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% if expo and cibles %}
|
{% if expo and cibles %}
|
||||||
|
|
||||||
- job_name: {{ expo.job }}
|
- job_name: {{ expo.job }}
|
||||||
{% if tls.oui %}
|
|
||||||
scheme: https
|
|
||||||
tls_config:
|
|
||||||
ca_file: {{ serveur_prometheus_metriques_ca }}
|
|
||||||
{% endif %}
|
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets: {{ cibles | to_json }}
|
- targets: {{ cibles | to_json }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -67,13 +55,6 @@ scrape_configs:
|
||||||
{% for job in serveur_prometheus_cibles_supplementaires %}
|
{% for job in serveur_prometheus_cibles_supplementaires %}
|
||||||
|
|
||||||
- job_name: {{ job.job }}
|
- job_name: {{ job.job }}
|
||||||
{# UNE CIBLE HORS FLOTTE PEUT SERVIR EN TLS (2026-10-12) : la frontiere, certifiee par son
|
|
||||||
autorite subordonnee, verifiee contre la meme racine que le reste. #}
|
|
||||||
{% if job.tls | default(false) %}
|
|
||||||
scheme: https
|
|
||||||
tls_config:
|
|
||||||
ca_file: {{ serveur_prometheus_metriques_ca }}
|
|
||||||
{% endif %}
|
|
||||||
static_configs:
|
static_configs:
|
||||||
{% if job.hotes | default({}) %}
|
{% if job.hotes | default({}) %}
|
||||||
{# UNE CIBLE NOMMEE PAR BLOC : l'etiquette `hote` s'attache a un `static_config`, pas a
|
{# UNE CIBLE NOMMEE PAR BLOC : l'etiquette `hote` s'attache a un `static_config`, pas a
|
||||||
|
|
|
||||||
|
|
@ -13,32 +13,3 @@ flux:
|
||||||
pair: localhost
|
pair: localhost
|
||||||
chiffrement: clair
|
chiffrement: clair
|
||||||
raison: "Interface de contrôle rspamd (statistiques, apprentissage) en local."
|
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.
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ sys.path.insert(0, str(RACINE / "scripts"))
|
||||||
|
|
||||||
import devis_opnsense as devis_mod # noqa: E402
|
import devis_opnsense as devis_mod # noqa: E402
|
||||||
from proxmox_api import voute as voute_proxmox # noqa: E402
|
from proxmox_api import voute as voute_proxmox # noqa: E402
|
||||||
from resoudre_flux import borner_libelle, libelle_flux # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def _voute(base: Path) -> dict:
|
def _voute(base: Path) -> dict:
|
||||||
|
|
@ -61,17 +60,7 @@ class Frontiere:
|
||||||
def __init__(self, url: str, cle: str, secret: str, verifier: bool):
|
def __init__(self, url: str, cle: str, secret: str, verifier: bool):
|
||||||
self.base = url.rstrip("/")
|
self.base = url.rstrip("/")
|
||||||
self.auth = "Basic " + base64.b64encode(f"{cle}:{secret}".encode()).decode()
|
self.auth = "Basic " + base64.b64encode(f"{cle}:{secret}".encode()).decode()
|
||||||
# VERIFIER CONTRE LA RACINE DU SITE (2026-10-12). Depuis que la frontiere sert un
|
self.ctx = None if verifier else ssl._create_unverified_context()
|
||||||
# certificat de son autorite (scripts/frontiere_pki.py), il se verifie — mais pas
|
|
||||||
# contre le magasin du poste, qui ne connait pas la racine du site. Sans verification,
|
|
||||||
# chaque appel envoyait la cle d'API a quiconque repondait sur l'adresse, et c'est par
|
|
||||||
# ce canal que les cles du magasin ont transite le meme jour.
|
|
||||||
if verifier:
|
|
||||||
hebergeur = devis_mod.depot_hebergeur()
|
|
||||||
racine = hebergeur / "ac-racine-site.crt" if hebergeur else None
|
|
||||||
self.ctx = ssl.create_default_context(cafile=str(racine) if racine and racine.is_file() else None)
|
|
||||||
else:
|
|
||||||
self.ctx = ssl._create_unverified_context()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _formulaire(corps: dict) -> bytes:
|
def _formulaire(corps: dict) -> bytes:
|
||||||
|
|
@ -156,13 +145,6 @@ def cle_redirection(d: dict) -> str:
|
||||||
f"->{d['cible']}:{d['port_local']}")
|
f"->{d['cible']}:{d['port_local']}")
|
||||||
|
|
||||||
|
|
||||||
def _description(cle: str, role: str, raison: str) -> str:
|
|
||||||
"""Garde l'identite complete en tete, puis le sens du flux (255 octets au total)."""
|
|
||||||
prefixe = cle + " — "
|
|
||||||
return prefixe + borner_libelle(libelle_flux(role, raison),
|
|
||||||
255 - len(prefixe.encode("utf-8")))
|
|
||||||
|
|
||||||
|
|
||||||
def _corps_redirection(d: dict, k: str) -> dict:
|
def _corps_redirection(d: dict, k: str) -> dict:
|
||||||
"""Une redirection d'OPNsense 26.7 (`d_nat`), eprouvee le 2026-09-16.
|
"""Une redirection d'OPNsense 26.7 (`d_nat`), eprouvee le 2026-09-16.
|
||||||
|
|
||||||
|
|
@ -184,8 +166,7 @@ def _corps_redirection(d: dict, k: str) -> dict:
|
||||||
"natreflection": "disable",
|
"natreflection": "disable",
|
||||||
"pass": "",
|
"pass": "",
|
||||||
"log": "0",
|
"log": "0",
|
||||||
"descr": _description(k, str(d.get("role") or "publication"),
|
"descr": f"{k} — {d.get('role', '')}"[:255],
|
||||||
str(d.get("raison") or "Publication du service vers sa cible interne")),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -251,8 +232,7 @@ def _corps_nat(n: dict, k: str) -> dict:
|
||||||
"source_net": n["source"],
|
"source_net": n["source"],
|
||||||
"destination_net": n["destination"],
|
"destination_net": n["destination"],
|
||||||
"target": n["cible"],
|
"target": n["cible"],
|
||||||
"description": _description(k, str(n["tenant"]),
|
"description": f"{k} — sortie tenant",
|
||||||
"Traduction des adresses en sortie"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -288,7 +268,7 @@ def _corps_regle(r: dict, k: str) -> dict:
|
||||||
"source_net": r["source"],
|
"source_net": r["source"],
|
||||||
"destination_net": dst[1:] if nie else dst,
|
"destination_net": dst[1:] if nie else dst,
|
||||||
"destination_not": "1" if nie else "0",
|
"destination_not": "1" if nie else "0",
|
||||||
"description": _description(k, str(r["role"]), str(r["raison"])),
|
"description": f"{k} — {r['role']}",
|
||||||
}
|
}
|
||||||
if r["protocole"] in ("tcp", "udp") and r["ports"]:
|
if r["protocole"] in ("tcp", "udp") and r["ports"]:
|
||||||
corps["destination_port"] = ",".join(map(str, r["ports"]))
|
corps["destination_port"] = ",".join(map(str, r["ports"]))
|
||||||
|
|
@ -411,20 +391,6 @@ def plan(api: Frontiere, devis: dict) -> dict:
|
||||||
if d.startswith("setopsrdr:"):
|
if d.startswith("setopsrdr:"):
|
||||||
rdr_posees[d.split(" — ")[0]] = x
|
rdr_posees[d.split(" — ")[0]] = x
|
||||||
|
|
||||||
# Les identifiants restent stables : corriger une justification se fait par UUID,
|
|
||||||
# sans retirer/recreer la regle ni modifier son filtrage ou son activation.
|
|
||||||
descriptions_majer = []
|
|
||||||
for objet, demandes, existantes, rendre, champ in (
|
|
||||||
("filter", voulues, posees, _corps_regle, "description"),
|
|
||||||
("source_nat", nat_voulus, nat_poses, _corps_nat, "description"),
|
|
||||||
("d_nat", rdr_voulues, rdr_posees, _corps_redirection, "descr")):
|
|
||||||
for k in sorted(demandes.keys() & existantes.keys()):
|
|
||||||
attendu = rendre(demandes[k], k)[champ]
|
|
||||||
if str(existantes[k].get(champ) or existantes[k].get("description") or "") != attendu:
|
|
||||||
descriptions_majer.append({"objet": objet, "cle": k,
|
|
||||||
"uuid": existantes[k]["uuid"],
|
|
||||||
"champ": champ, "valeur": attendu})
|
|
||||||
|
|
||||||
rt_voulues = {cle_route(r): r for r in devis.get("routes") or []}
|
rt_voulues = {cle_route(r): r for r in devis.get("routes") or []}
|
||||||
rt_posees = {}
|
rt_posees = {}
|
||||||
for x in (_lire(api, "/api/routes/routes/searchroute/",
|
for x in (_lire(api, "/api/routes/routes/searchroute/",
|
||||||
|
|
@ -437,7 +403,6 @@ def plan(api: Frontiere, devis: dict) -> dict:
|
||||||
# et transporte dans le plan pour que l'affichage puisse le dire.
|
# et transporte dans le plan pour que l'affichage puisse le dire.
|
||||||
_reelles = routes_du_noyau(api)
|
_reelles = routes_du_noyau(api)
|
||||||
return {
|
return {
|
||||||
"descriptions_majer": descriptions_majer,
|
|
||||||
"routes_noyau_lisible": _reelles is not None,
|
"routes_noyau_lisible": _reelles is not None,
|
||||||
# Presente dans le modele mais ETEINTE : elle compte « posee » partout ailleurs,
|
# Presente dans le modele mais ETEINTE : elle compte « posee » partout ailleurs,
|
||||||
# et n'est installee nulle part. C'est la forme exacte du defaut du 2026-09-02.
|
# et n'est installee nulle part. C'est la forme exacte du defaut du 2026-09-02.
|
||||||
|
|
@ -477,8 +442,6 @@ def afficher(p: dict) -> bool:
|
||||||
for k, r in sorted(p["regles_creer"].items()):
|
for k, r in sorted(p["regles_creer"].items()):
|
||||||
print(f" + regle {r['interface']:<5} {r['protocole']:<4} "
|
print(f" + regle {r['interface']:<5} {r['protocole']:<4} "
|
||||||
f"{str(r['ports'] or ''):<9} {r['source'][:30]:<30} -> {r['destination'][:24]}")
|
f"{str(r['ports'] or ''):<9} {r['source'][:30]:<30} -> {r['destination'][:24]}")
|
||||||
for d in p["descriptions_majer"]:
|
|
||||||
print(f" ~ description {d['valeur']}")
|
|
||||||
for k, n in sorted(p["nat_creer"].items()):
|
for k, n in sorted(p["nat_creer"].items()):
|
||||||
print(f" + NAT sortant {n['interface']:<5} {n['source'][:30]:<30} "
|
print(f" + NAT sortant {n['interface']:<5} {n['source'][:30]:<30} "
|
||||||
f"-> {n['cible']}")
|
f"-> {n['cible']}")
|
||||||
|
|
@ -532,11 +495,9 @@ def afficher(p: dict) -> bool:
|
||||||
retirer = len(p["regles_retirer"]) + len(p["alias_retirer"]) + len(p["nat_retirer"]) \
|
retirer = len(p["regles_retirer"]) + len(p["alias_retirer"]) + len(p["nat_retirer"]) \
|
||||||
+ len(p["routes_retirer"]) + len(p["rdr_retirer"])
|
+ len(p["routes_retirer"]) + len(p["rdr_retirer"])
|
||||||
print(f"\n a creer : {creer} | a retirer : {retirer}"
|
print(f"\n a creer : {creer} | a retirer : {retirer}"
|
||||||
f" | descriptions a corriger : {len(p['descriptions_majer'])}"
|
f" | inchange : {len(p['regles_garder']) + len(p['nat_garder']) + len(p['rdr_garder'])}"
|
||||||
f" | inchange : {len(p['regles_garder']) + len(p['nat_garder']) + len(p['rdr_garder']) - len(p['descriptions_majer'])}"
|
|
||||||
f" + {len(p['routes_garder'])} routes")
|
f" + {len(p['routes_garder'])} routes")
|
||||||
return any(p[c] for c in ("alias_creer", "alias_majer", "alias_retirer",
|
return any(p[c] for c in ("alias_creer", "alias_majer", "alias_retirer",
|
||||||
"descriptions_majer",
|
|
||||||
"regles_creer", "regles_retirer",
|
"regles_creer", "regles_retirer",
|
||||||
"nat_creer", "nat_retirer", "rdr_creer", "rdr_retirer",
|
"nat_creer", "nat_retirer", "rdr_creer", "rdr_retirer",
|
||||||
"routes_creer", "routes_retirer"))
|
"routes_creer", "routes_retirer"))
|
||||||
|
|
@ -580,15 +541,6 @@ def appliquer(api: Frontiere, p: dict) -> int:
|
||||||
_fait(api("/api/firewall/d_nat/add_rule/", {"rule": _corps_redirection(d, k)}),
|
_fait(api("/api/firewall/d_nat/add_rule/", {"rule": _corps_redirection(d, k)}),
|
||||||
f"redirection {k}")
|
f"redirection {k}")
|
||||||
|
|
||||||
for d in p["descriptions_majer"]:
|
|
||||||
chemin = f"/api/firewall/{d['objet']}"
|
|
||||||
if _fait(api(f"{chemin}/set_rule/{d['uuid']}",
|
|
||||||
{"rule": {d["champ"]: d["valeur"]}}), f"description {d['cle']}"):
|
|
||||||
relu = _lire(api, f"{chemin}/get_rule/{d['uuid']}").get("rule") or {}
|
|
||||||
if relu.get(d["champ"]) != d["valeur"]:
|
|
||||||
echecs += 1
|
|
||||||
print(f" ! ECHEC description {d['cle']} : relecture differente de l'ecriture")
|
|
||||||
|
|
||||||
# 2bis. Routes, meme ordre et pour une raison plus forte encore : une route
|
# 2bis. Routes, meme ordre et pour une raison plus forte encore : une route
|
||||||
# manquante coupe la flotte, une route en trop ne fait qu'acheminer vers un
|
# manquante coupe la flotte, une route en trop ne fait qu'acheminer vers un
|
||||||
# VRF qui la jettera. En cas de doute, on reste large.
|
# VRF qui la jettera. En cas de doute, on reste large.
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ RACINE = Path(__file__).resolve().parent.parent
|
||||||
sys.path.insert(0, str(RACINE / "scripts"))
|
sys.path.insert(0, str(RACINE / "scripts"))
|
||||||
|
|
||||||
from proxmox_api import Cluster # noqa: E402
|
from proxmox_api import Cluster # noqa: E402
|
||||||
from resoudre_flux import libelle_flux # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def _prefixe(bloc: dict) -> str:
|
def _prefixe(bloc: dict) -> str:
|
||||||
|
|
@ -91,17 +90,6 @@ def _regle_cle(r: dict) -> tuple:
|
||||||
str(r.get("icmp_type") or r.get("icmp-type") or ""))
|
str(r.get("icmp_type") or r.get("icmp-type") or ""))
|
||||||
|
|
||||||
|
|
||||||
def _commentaire_regle(groupe: dict, regle: dict) -> str:
|
|
||||||
role = str(groupe.get("role") or groupe["nom"])
|
|
||||||
if regle.get("via"):
|
|
||||||
role += f" / {regle['via']}"
|
|
||||||
return libelle_flux(role, str(regle["raison"]))
|
|
||||||
|
|
||||||
|
|
||||||
def _commentaire_affectation(hote: str, groupe: dict) -> str:
|
|
||||||
return libelle_flux(hote, f"Appliquer les flux de {groupe.get('role') or groupe['nom']}")
|
|
||||||
|
|
||||||
|
|
||||||
def _prefixes_retires() -> tuple[str, ...]:
|
def _prefixes_retires() -> tuple[str, ...]:
|
||||||
"""Les etiquettes des tenants RETIRES : `t29-` pour la zone `t29`.
|
"""Les etiquettes des tenants RETIRES : `t29-` pour la zone `t29`.
|
||||||
|
|
||||||
|
|
@ -174,7 +162,6 @@ def plan(api: Cluster, devis: dict) -> dict:
|
||||||
o = api(f"/nodes/{noeud}/qemu/{vmid}/firewall/options") or {}
|
o = api(f"/nodes/{noeud}/qemu/{vmid}/firewall/options") or {}
|
||||||
affect_posees[vmid] = {
|
affect_posees[vmid] = {
|
||||||
"noeud": noeud,
|
"noeud": noeud,
|
||||||
"regles": [r for r in regles if isinstance(r, dict) and r.get("type") == "group"],
|
|
||||||
"groupes": sorted(str(r.get("action")) for r in regles
|
"groupes": sorted(str(r.get("action")) for r in regles
|
||||||
if isinstance(r, dict) and r.get("type") == "group"),
|
if isinstance(r, dict) and r.get("type") == "group"),
|
||||||
"actif": str(o.get("enable", "")),
|
"actif": str(o.get("enable", "")),
|
||||||
|
|
@ -183,35 +170,6 @@ def plan(api: Cluster, devis: dict) -> dict:
|
||||||
"politique": str(o.get("policy_in", "")).upper(),
|
"politique": str(o.get("policy_in", "")).upper(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Une justification changee ne change pas la politique. Mettre le commentaire a
|
|
||||||
# jour EN PLACE : purger le groupe pour ce seul motif couperait ses autorisations.
|
|
||||||
regles_commenter = {}
|
|
||||||
for nom, groupe in groupes_voulus.items():
|
|
||||||
actuelles = groupes_poses.get(nom)
|
|
||||||
voulues = {_regle_cle(r): _commentaire_regle(groupe, r) for r in groupe["regles"]}
|
|
||||||
if actuelles is None or set(voulues) != {_regle_cle(r) for r in actuelles}:
|
|
||||||
continue # groupe absent ou politique differente : traitement habituel
|
|
||||||
changements = [{"pos": r["pos"], "comment": voulues[_regle_cle(r)]}
|
|
||||||
for r in actuelles
|
|
||||||
if str(r.get("comment") or "") != voulues[_regle_cle(r)]]
|
|
||||||
if changements:
|
|
||||||
regles_commenter[nom] = changements
|
|
||||||
|
|
||||||
affect_commenter = {}
|
|
||||||
for vmid, affect in affect_voulues.items():
|
|
||||||
posee = affect_posees.get(vmid)
|
|
||||||
if not posee:
|
|
||||||
continue
|
|
||||||
changements = []
|
|
||||||
for r in posee["regles"]:
|
|
||||||
nom = str(r.get("action"))
|
|
||||||
if nom in affect["groupes"]:
|
|
||||||
commentaire = _commentaire_affectation(affect["hote"], groupes_voulus[nom])
|
|
||||||
if str(r.get("comment") or "") != commentaire:
|
|
||||||
changements.append({"pos": r["pos"], "comment": commentaire})
|
|
||||||
if changements:
|
|
||||||
affect_commenter[vmid] = changements
|
|
||||||
|
|
||||||
opts = api("/cluster/firewall/options") or {}
|
opts = api("/cluster/firewall/options") or {}
|
||||||
return {
|
return {
|
||||||
"ipsets_creer": {n: v for n, v in ipsets_voulus.items() if n not in ipsets_poses},
|
"ipsets_creer": {n: v for n, v in ipsets_voulus.items() if n not in ipsets_poses},
|
||||||
|
|
@ -224,8 +182,6 @@ def plan(api: Cluster, devis: dict) -> dict:
|
||||||
and {_regle_cle(r) for r in g["regles"]}
|
and {_regle_cle(r) for r in g["regles"]}
|
||||||
!= {_regle_cle(r) for r in groupes_poses[n]}},
|
!= {_regle_cle(r) for r in groupes_poses[n]}},
|
||||||
"groupes_retirer": sorted(n for n in groupes_poses if n not in groupes_voulus),
|
"groupes_retirer": sorted(n for n in groupes_poses if n not in groupes_voulus),
|
||||||
"regles_commenter": regles_commenter,
|
|
||||||
"affect_commenter": affect_commenter,
|
|
||||||
"affect_majer": {v: a for v, a in affect_voulues.items()
|
"affect_majer": {v: a for v, a in affect_voulues.items()
|
||||||
if v in affect_posees
|
if v in affect_posees
|
||||||
and (affect_posees[v]["groupes"] != a["groupes"]
|
and (affect_posees[v]["groupes"] != a["groupes"]
|
||||||
|
|
@ -251,11 +207,6 @@ def afficher(p: dict) -> bool:
|
||||||
print(f" ~ groupe {n:<22} regles differentes")
|
print(f" ~ groupe {n:<22} regles differentes")
|
||||||
for n in p["groupes_retirer"]:
|
for n in p["groupes_retirer"]:
|
||||||
print(f" - groupe PERIME {n}")
|
print(f" - groupe PERIME {n}")
|
||||||
for n, regles in sorted(p["regles_commenter"].items()):
|
|
||||||
print(f" ~ commentaires {n:<22} {len(regles)} regle(s), politique inchangee")
|
|
||||||
for v, regles in sorted(p["affect_commenter"].items()):
|
|
||||||
if v not in p["affect_majer"]:
|
|
||||||
print(f" ~ commentaires VM {v:<22} {len(regles)} affectation(s)")
|
|
||||||
for v, a in sorted(p["affect_majer"].items()):
|
for v, a in sorted(p["affect_majer"].items()):
|
||||||
print(f" ~ VM {v:<12} {a['hote']:<16} -> {', '.join(a['groupes'])}")
|
print(f" ~ VM {v:<12} {a['hote']:<16} -> {', '.join(a['groupes'])}")
|
||||||
if p["vm_absentes"]:
|
if p["vm_absentes"]:
|
||||||
|
|
@ -279,7 +230,6 @@ def afficher(p: dict) -> bool:
|
||||||
print(" le reste du parc. La remettre a ACCEPT.")
|
print(" le reste du parc. La remettre a ACCEPT.")
|
||||||
creer = len(p["ipsets_creer"]) + len(p["groupes_creer"])
|
creer = len(p["ipsets_creer"]) + len(p["groupes_creer"])
|
||||||
majer = len(p["ipsets_majer"]) + len(p["groupes_majer"]) + len(p["affect_majer"])
|
majer = len(p["ipsets_majer"]) + len(p["groupes_majer"]) + len(p["affect_majer"])
|
||||||
majer += len(p["regles_commenter"]) + len(set(p["affect_commenter"]) - set(p["affect_majer"]))
|
|
||||||
retirer = len(p["ipsets_retirer"]) + len(p["groupes_retirer"])
|
retirer = len(p["ipsets_retirer"]) + len(p["groupes_retirer"])
|
||||||
print(f"\n a creer : {creer} | a mettre a jour : {majer} | a retirer : {retirer}")
|
print(f"\n a creer : {creer} | a mettre a jour : {majer} | a retirer : {retirer}")
|
||||||
return bool(creer or majer or retirer)
|
return bool(creer or majer or retirer)
|
||||||
|
|
@ -319,21 +269,13 @@ def appliquer(api: Cluster, p: dict) -> int:
|
||||||
{"group": n, "comment": str(g.get("role", ""))[:100]}), f"groupe {n}")
|
{"group": n, "comment": str(g.get("role", ""))[:100]}), f"groupe {n}")
|
||||||
for r in g["regles"]:
|
for r in g["regles"]:
|
||||||
corps = {"type": str(r.get("sens", "in")).lower(), "action": r["action"],
|
corps = {"type": str(r.get("sens", "in")).lower(), "action": r["action"],
|
||||||
"enable": 1, "comment": _commentaire_regle(g, r)}
|
"enable": 1, "comment": str(r.get("via") or g.get("role", ""))[:100]}
|
||||||
for cle, champ in (("source", "source"), ("proto", "proto"), ("dport", "dport"),
|
for cle, champ in (("source", "source"), ("proto", "proto"), ("dport", "dport"),
|
||||||
("icmp_type", "icmp-type")):
|
("icmp_type", "icmp-type")):
|
||||||
if r.get(cle):
|
if r.get(cle):
|
||||||
corps[champ] = r[cle]
|
corps[champ] = r[cle]
|
||||||
_fait(api(f"/cluster/firewall/groups/{n}", "POST", corps), f"regle de {n}")
|
_fait(api(f"/cluster/firewall/groups/{n}", "POST", corps), f"regle de {n}")
|
||||||
|
|
||||||
for n, regles in sorted(p["regles_commenter"].items()):
|
|
||||||
for r in regles:
|
|
||||||
chemin = f"/cluster/firewall/groups/{n}/{r['pos']}"
|
|
||||||
if _fait(api(chemin, "PUT", {"comment": r["comment"]}), f"commentaire de {n}/{r['pos']}"):
|
|
||||||
if (_lire(api, chemin) or {}).get("comment") != r["comment"]:
|
|
||||||
echecs += 1
|
|
||||||
print(f" ! ECHEC commentaire {n}/{r['pos']} : relecture differente de l'ecriture")
|
|
||||||
|
|
||||||
# 3. Affectations aux VM, et activation de leur pare-feu — sans quoi les groupes
|
# 3. Affectations aux VM, et activation de leur pare-feu — sans quoi les groupes
|
||||||
# seraient poses sur une machine qui ne filtre pas.
|
# seraient poses sur une machine qui ne filtre pas.
|
||||||
for v, a in sorted(p["affect_majer"].items()):
|
for v, a in sorted(p["affect_majer"].items()):
|
||||||
|
|
@ -344,9 +286,7 @@ def appliquer(api: Cluster, p: dict) -> int:
|
||||||
f"purge VM {v}")
|
f"purge VM {v}")
|
||||||
for g in a["groupes"]:
|
for g in a["groupes"]:
|
||||||
_fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/rules", "POST",
|
_fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/rules", "POST",
|
||||||
{"type": "group", "action": g, "enable": 1,
|
{"type": "group", "action": g, "enable": 1}), f"VM {v} += {g}")
|
||||||
"comment": _commentaire_affectation(a["hote"], p["_voulus"]["groupes"][g])}),
|
|
||||||
f"VM {v} += {g}")
|
|
||||||
# `policy_in` sur LA VM : c'est ce qui rend les groupes ci-dessus exclusifs.
|
# `policy_in` sur LA VM : c'est ce qui rend les groupes ci-dessus exclusifs.
|
||||||
# Au datacenter, la meme valeur s'appliquerait a tout ce qui n'est pas un tenant.
|
# Au datacenter, la meme valeur s'appliquerait a tout ce qui n'est pas un tenant.
|
||||||
#
|
#
|
||||||
|
|
@ -368,17 +308,6 @@ def appliquer(api: Cluster, p: dict) -> int:
|
||||||
_fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/options", "PUT",
|
_fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/options", "PUT",
|
||||||
{"enable": 1, "policy_in": POLITIQUE_VM}), f"pare-feu de la VM {v}")
|
{"enable": 1, "policy_in": POLITIQUE_VM}), f"pare-feu de la VM {v}")
|
||||||
|
|
||||||
for v, regles in sorted(p["affect_commenter"].items()):
|
|
||||||
if v in p["affect_majer"]:
|
|
||||||
continue # les nouvelles affectations portent deja leur commentaire
|
|
||||||
noeud = p["affect_posees"][v]["noeud"]
|
|
||||||
for r in regles:
|
|
||||||
chemin = f"/nodes/{noeud}/qemu/{v}/firewall/rules/{r['pos']}"
|
|
||||||
if _fait(api(chemin, "PUT", {"comment": r["comment"]}), f"commentaire VM {v}/{r['pos']}"):
|
|
||||||
if (_lire(api, chemin) or {}).get("comment") != r["comment"]:
|
|
||||||
echecs += 1
|
|
||||||
print(f" ! ECHEC commentaire VM {v}/{r['pos']} : relecture differente de l'ecriture")
|
|
||||||
|
|
||||||
# 4. Retraits : groupes avant IPSets, un IPSet reference ne se supprime pas.
|
# 4. Retraits : groupes avant IPSets, un IPSet reference ne se supprime pas.
|
||||||
for n in p["groupes_retirer"]:
|
for n in p["groupes_retirer"]:
|
||||||
for r in reversed(api(f"/cluster/firewall/groups/{n}") or []):
|
for r in reversed(api(f"/cluster/firewall/groups/{n}") or []):
|
||||||
|
|
@ -438,7 +367,6 @@ def main() -> int:
|
||||||
raise SystemExit(f"REFUS : VM hors du devis ou du cluster : {inconnues}")
|
raise SystemExit(f"REFUS : VM hors du devis ou du cluster : {inconnues}")
|
||||||
ecartees = len(p["affect_majer"])
|
ecartees = len(p["affect_majer"])
|
||||||
p["affect_majer"] = {v: a for v, a in p["affect_majer"].items() if v in args.vm}
|
p["affect_majer"] = {v: a for v, a in p["affect_majer"].items() if v in args.vm}
|
||||||
p["affect_commenter"] = {v: a for v, a in p["affect_commenter"].items() if v in args.vm}
|
|
||||||
ecartees -= len(p["affect_majer"])
|
ecartees -= len(p["affect_majer"])
|
||||||
print(f" (portee restreinte : {ecartees} affectation(s) de VM laissee(s) pour plus tard)\n")
|
print(f" (portee restreinte : {ecartees} affectation(s) de VM laissee(s) pour plus tard)\n")
|
||||||
quelque_chose = afficher(p)
|
quelque_chose = afficher(p)
|
||||||
|
|
|
||||||
|
|
@ -406,16 +406,6 @@ class Locataire(Ecosysteme):
|
||||||
sort_keys=False), encoding="utf-8")
|
sort_keys=False), encoding="utf-8")
|
||||||
return chemin
|
return chemin
|
||||||
|
|
||||||
def genome_publie(self) -> dict:
|
|
||||||
"""Le depot du genome de ce locataire sur la forge de son site, et la cle publique de
|
|
||||||
son runner qui le lira (`serveur_ops_tenant_cle_publique`). Lus dans SES variables du
|
|
||||||
runner — la declaration meme que le role `serveur_ops` clone."""
|
|
||||||
gv = _yaml(self.dossier_inventaire() / "group_vars" / "serveur_ops.yml")
|
|
||||||
depot = next((str(d.get("depot")) for d in (gv.get("serveur_ops_depots") or [])
|
|
||||||
if isinstance(d, dict) and d.get("role") == "instance"), "")
|
|
||||||
cle = " ".join(str(gv.get("serveur_ops_tenant_cle_publique") or "").split()[:2])
|
|
||||||
return {"depot": depot, "cle_lecture": cle}
|
|
||||||
|
|
||||||
def face_publiee(self) -> dict | None:
|
def face_publiee(self) -> dict | None:
|
||||||
"""La face reseau qu'il a publiee, ou None."""
|
"""La face reseau qu'il a publiee, ou None."""
|
||||||
return _yaml(self.depot / NOM_FICHIER_FACE_RESEAU) or None
|
return _yaml(self.depot / NOM_FICHIER_FACE_RESEAU) or None
|
||||||
|
|
@ -463,13 +453,11 @@ class Locataire(Ecosysteme):
|
||||||
# LES PARAMETRES DE CLONAGE de chaque machine (M1 de la materialisation, 2026-10-05) :
|
# LES PARAMETRES DE CLONAGE de chaque machine (M1 de la materialisation, 2026-10-05) :
|
||||||
# exactement ce que `make creer-vm` evalue aujourd'hui (`parametres-proxmox`), calcule
|
# exactement ce que `make creer-vm` evalue aujourd'hui (`parametres-proxmox`), calcule
|
||||||
# ici par la MEME fonction. Le runner du site clonera d'apres eux, sans monter ce depot.
|
# ici par la MEME fonction. Le runner du site clonera d'apres eux, sans monter ce depot.
|
||||||
# SANS LA CLE DU RUNNER DU SITE (2026-10-11) : elle n'est pas au locataire, et il ne
|
|
||||||
# peut pas la lire hors du poste. Le site l'ajoute en clonant (`clonage_rendu`).
|
|
||||||
from inventory_host import parametres_proxmox_hote
|
from inventory_host import parametres_proxmox_hote
|
||||||
|
|
||||||
def clonage(h: str) -> dict:
|
def clonage(h: str) -> dict:
|
||||||
try:
|
try:
|
||||||
lignes = parametres_proxmox_hote(data, h, self.inventaire(), avec_cle_du_site=False)
|
lignes = parametres_proxmox_hote(data, h, self.inventaire())
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return {"erreur": str(e)}
|
return {"erreur": str(e)}
|
||||||
out = {}
|
out = {}
|
||||||
|
|
@ -587,12 +575,6 @@ class Locataire(Ecosysteme):
|
||||||
if isinstance(c, dict)}},
|
if isinstance(c, dict)}},
|
||||||
"dns_public": dns_public,
|
"dns_public": dns_public,
|
||||||
"sauvegarde": {"cle_publique": cle_sauvegarde},
|
"sauvegarde": {"cle_publique": cle_sauvegarde},
|
||||||
# SON DEPOT DU GENOME, ET LA CLE QUI LE LIT (2026-10-11,
|
|
||||||
# `docs/depots-prives-conception.md`). Le depot devient prive sur la forge du site ;
|
|
||||||
# le site enregistre cette cle publique comme cle de deploiement EN LECTURE sur ce
|
|
||||||
# seul depot. Une cle publique, rien de secret : la privee vit dans la voute du
|
|
||||||
# locataire et n'arrive au runner qu'a l'armement.
|
|
||||||
"genome": self.genome_publie(),
|
|
||||||
# Ses valeurs de placement (noeud, stockage, pont par defaut, gabarit, options de
|
# Ses valeurs de placement (noeud, stockage, pont par defaut, gabarit, options de
|
||||||
# clone) : non sensibles, les jetons vivent dans sa voute. Le devis du placement les
|
# clone) : non sensibles, les jetons vivent dans sa voute. Le devis du placement les
|
||||||
# confronte au cluster.
|
# confronte au cluster.
|
||||||
|
|
@ -1147,37 +1129,16 @@ def verifier_face_publiee(locataire: "Locataire") -> list[str]:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def clonage_rendu(machine: dict, cle_site: str) -> dict:
|
def lignes_clonage(face: dict, hote: str) -> list[str]:
|
||||||
"""Les parametres de clonage d'une machine publiee, tels que LE SITE les clone.
|
"""Les lignes shell que `parametres-proxmox` rend pour cet hote — depuis la face publiee.
|
||||||
|
Meme forme, a l'octet pres : `SETOPS_X='valeur'`, une valeur pouvant tenir sur plusieurs
|
||||||
LE SITE QUI CLONE POSE SA PROPRE CLE (2026-10-11). Elle passait par la face du locataire :
|
lignes (les cles d'amorcage). `make creer-vm TENANT=` les evalue comme les autres."""
|
||||||
le poste la lisait dans le plan du site et la publiait, le site la relisait. Le runner du
|
|
||||||
locataire, sans ce plan, recalculait une face sans elle. Elle ne quitte plus le site :
|
|
||||||
sur la machine qui porte `serveur_ops_tenant`, et sur elle seule, il la met EN TETE des
|
|
||||||
cles d'amorcage — l'ordre meme de `inventory_host._cle_amorcage`, pour que les deux
|
|
||||||
chemins rendent la meme VM. Une face d'avant, qui la porte deja, n'est pas doublee.
|
|
||||||
"""
|
|
||||||
from inventory_host import GROUPE_RUNNER_TENANT
|
|
||||||
c = dict(machine.get("clonage") or {})
|
|
||||||
if cle_site and GROUPE_RUNNER_TENANT in (machine.get("groupes") or []) and "erreur" not in c:
|
|
||||||
autres = [x for x in str(c.get("SETOPS_CLES_AMORCAGE") or "").split("\n")
|
|
||||||
if x.strip() and x.strip() != cle_site]
|
|
||||||
c["SETOPS_CLES_AMORCAGE"] = "\n".join([cle_site] + autres)
|
|
||||||
return c
|
|
||||||
|
|
||||||
|
|
||||||
def lignes_clonage(face: dict, hote: str, cle_site: str | None = None) -> list[str]:
|
|
||||||
"""Les lignes shell que `parametres-proxmox` rend pour cet hote — depuis la face publiee,
|
|
||||||
plus la cle du runner du site monte (`clonage_rendu`). Meme forme, a l'octet pres :
|
|
||||||
`SETOPS_X='valeur'`, une valeur pouvant tenir sur plusieurs lignes (les cles
|
|
||||||
d'amorcage). `make creer-vm TENANT=` les evalue comme les autres."""
|
|
||||||
from inventory_host import cle_runner_du_site
|
|
||||||
m = (face.get("machines") or {}).get(hote)
|
m = (face.get("machines") or {}).get(hote)
|
||||||
if m is None:
|
if m is None:
|
||||||
raise SystemExit(f"Hote absent de la face reseau publiee : {hote}.")
|
raise SystemExit(f"Hote absent de la face reseau publiee : {hote}.")
|
||||||
if "erreur" in (m.get("clonage") or {}):
|
c = m.get("clonage") or {}
|
||||||
raise SystemExit(m["clonage"]["erreur"])
|
if "erreur" in c:
|
||||||
c = clonage_rendu(m, cle_runner_du_site() if cle_site is None else cle_site)
|
raise SystemExit(c["erreur"])
|
||||||
return [f"{k}='{v}'" for k, v in c.items()]
|
return [f"{k}='{v}'" for k, v in c.items()]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1191,8 +1152,6 @@ def verifier_clonage(locataire: "Locataire", face: dict | None = None) -> list[s
|
||||||
`inventory_host.py parametres-proxmox` sur l'inventaire du locataire — la commande meme que
|
`inventory_host.py parametres-proxmox` sur l'inventaire du locataire — la commande meme que
|
||||||
`make creer-vm` evalue ? Lancee a part, pour chaque machine : un temoin independant."""
|
`make creer-vm` evalue ? Lancee a part, pour chaque machine : un temoin independant."""
|
||||||
import subprocess
|
import subprocess
|
||||||
from inventory_host import cle_runner_du_site
|
|
||||||
cle_site = cle_runner_du_site()
|
|
||||||
f = face if face is not None else (locataire.face_publiee() or {})
|
f = face if face is not None else (locataire.face_publiee() or {})
|
||||||
ecarts: list[str] = []
|
ecarts: list[str] = []
|
||||||
for h, m in sorted((f.get("machines") or {}).items()):
|
for h, m in sorted((f.get("machines") or {}).items()):
|
||||||
|
|
@ -1210,17 +1169,10 @@ def verifier_clonage(locataire: "Locataire", face: dict | None = None) -> list[s
|
||||||
cle, _, valeur = jeton.partition("=")
|
cle, _, valeur = jeton.partition("=")
|
||||||
if cle.startswith("SETOPS_"):
|
if cle.startswith("SETOPS_"):
|
||||||
attendu[cle] = valeur
|
attendu[cle] = valeur
|
||||||
# CE QUE LE SITE CLONERA, pas seulement ce que la face porte : la face, plus la cle de
|
|
||||||
# son runner (`clonage_rendu`). C'est cela qui doit egaler le chemin du poste, cle
|
|
||||||
# comprise — la VM nee d'un chemin ou de l'autre doit etre la meme.
|
|
||||||
publie = m.get("clonage") or {}
|
publie = m.get("clonage") or {}
|
||||||
if cle_site and cle_site in str(publie.get("SETOPS_CLES_AMORCAGE") or ""):
|
if publie != attendu:
|
||||||
ecarts.append(f"{h} : la face porte la cle du runner du site ; elle n'est pas au "
|
diff = sorted(k for k in set(publie) | set(attendu) if publie.get(k) != attendu.get(k))
|
||||||
"locataire (le site l'ajoute en clonant) : republier la face")
|
ecarts.append(f"{h} : la face differe de parametres-proxmox ({', '.join(diff)})")
|
||||||
rendu = clonage_rendu(m, cle_site)
|
|
||||||
if rendu != attendu:
|
|
||||||
diff = sorted(k for k in set(rendu) | set(attendu) if rendu.get(k) != attendu.get(k))
|
|
||||||
ecarts.append(f"{h} : le clonage par la face differe de parametres-proxmox ({', '.join(diff)})")
|
|
||||||
return ecarts
|
return ecarts
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -435,35 +435,6 @@ def _services_du_plan_site() -> dict[str, list[str]]:
|
||||||
return {h: sorted(set(g)) for h, g in out.items()}
|
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]:
|
def _ports_du_plan_site() -> dict[str, str]:
|
||||||
"""Port declare au plan pour chaque role du site — ce que `port: derive` vaut ici.
|
"""Port declare au plan pour chaque role du site — ce que `port: derive` vaut ici.
|
||||||
|
|
||||||
|
|
@ -971,7 +942,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
||||||
alias["SETOPS_RUNNERS_TENANTS"] = {
|
alias["SETOPS_RUNNERS_TENANTS"] = {
|
||||||
"type": "host",
|
"type": "host",
|
||||||
"contenu": _runners_tenants,
|
"contenu": _runners_tenants,
|
||||||
"description": "Runners des tenants heberges — insemination, lecture de leur depot",
|
"description": "Runners des tenants heberges — cible de l'insemination",
|
||||||
}
|
}
|
||||||
|
|
||||||
# LES AUTORITATIFS DES LOCATAIRES — la source du DNS public du site (2026-09-16).
|
# LES AUTORITATIFS DES LOCATAIRES — la source du DNS public du site (2026-09-16).
|
||||||
|
|
@ -1018,31 +989,17 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
||||||
"contenu": _ips,
|
"contenu": _ips,
|
||||||
"description": f"Machines du site portant {_role}",
|
"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, []):
|
for _fl in flux_par_role.get(_role, []):
|
||||||
_entrant = _fl.get("sens") == "ingress"
|
_entrant = _fl.get("sens") == "ingress"
|
||||||
_pairs_fl = _pairs(_fl)
|
_pairs_fl = _pairs(_fl)
|
||||||
if "frontiere" in _pairs_fl:
|
if "frontiere" in _pairs_fl:
|
||||||
continue # bloc dedie, plus bas (voir la boucle des tenants)
|
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:
|
if _entrant and "voisins_site" in _pairs_fl:
|
||||||
# Un tenant qui s'adresse au site : le paquet penetre le pare-feu par
|
# 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,
|
# le lien de TRANSIT, pas par la patte du site. Une regle par voisin,
|
||||||
# parce qu'une regle porte une source.
|
# parce qu'une regle porte une source.
|
||||||
_portees = [(f"SETOPS_TENANT_{p2}{n2['index']}", if_transit)
|
_portees = [(f"SETOPS_TENANT_{p2}{n2['index']}", if_transit)
|
||||||
for _n2, p2, n2 in tenants]
|
for _n2, p2, n2 in tenants]
|
||||||
elif _entrant and "runners_locataires" in _pairs_fl:
|
|
||||||
# LES SEULS RUNNERS DES LOCATAIRES (2026-10-11) : chacun lit son depot
|
|
||||||
# du genome en SSH. Ils entrent, comme tout locataire, par le lien de
|
|
||||||
# TRANSIT ; la source est l'alias de leurs adresses, pas un supernet.
|
|
||||||
# Aucun runner declare, aucune regle — jamais le repli.
|
|
||||||
if not _runners_tenants:
|
|
||||||
continue
|
|
||||||
_portees = [("SETOPS_RUNNERS_TENANTS", if_transit)]
|
|
||||||
elif _entrant and "flotte" in _pairs_fl:
|
elif _entrant and "flotte" in _pairs_fl:
|
||||||
# CE QUI ETAIT GRATUIT DEVIENT POLICE (2026-08-25).
|
# CE QUI ETAIT GRATUIT DEVIENT POLICE (2026-08-25).
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -46,12 +46,6 @@ sys.path.insert(0, str(RACINE / "scripts"))
|
||||||
import underlay as underlay_mod # noqa: E402
|
import underlay as underlay_mod # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
# LES DEPOTS QUI NAISSENT PRIVES (2026-10-11, `docs/depots-prives-conception.md`) : le plan
|
|
||||||
# d'un ecosysteme et la carte du site ne regardent que leur runner et celui du site. La MEME
|
|
||||||
# liste que `serveur_ops_roles_prives` du role, qui les clone en SSH.
|
|
||||||
ROLES_PRIVES = {"instance", "tenant", "hebergeur"}
|
|
||||||
|
|
||||||
|
|
||||||
def _depots_declares() -> list[dict]:
|
def _depots_declares() -> list[dict]:
|
||||||
"""`serveur_ops_depots` du plan du SITE — la source que le runner lit."""
|
"""`serveur_ops_depots` du plan du SITE — la source que le runner lit."""
|
||||||
import yaml
|
import yaml
|
||||||
|
|
@ -134,10 +128,8 @@ def main() -> int:
|
||||||
f"le runner clonerait ce qui est la et echouerait sur le reste, plus tard.")
|
f"le runner clonerait ce qui est la et echouerait sur le reste, plus tard.")
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
prives = {str(d["depot"]) for d in depots if str(d.get("role")) in ROLES_PRIVES}
|
|
||||||
if not a.confirmer:
|
if not a.confirmer:
|
||||||
print(f"\n{len(prets)} depot(s) seraient crees puis pousses, dont prives : "
|
print(f"\n{len(prets)} depot(s) seraient crees puis pousses.\n"
|
||||||
f"{', '.join(sorted(prives)) or 'aucun'}.\n"
|
|
||||||
f"Relancer avec : make forge-amorcer CONFIRMER=true")
|
f"Relancer avec : make forge-amorcer CONFIRMER=true")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
@ -214,12 +206,7 @@ def main() -> int:
|
||||||
st, _ = _api(base, f"/repos/{a.organisation}/{nom}", auth=auth)
|
st, _ = _api(base, f"/repos/{a.organisation}/{nom}", auth=auth)
|
||||||
if st == 404:
|
if st == 404:
|
||||||
st, rep = _api(base, f"/orgs/{a.organisation}/repos", "POST",
|
st, rep = _api(base, f"/orgs/{a.organisation}/repos", "POST",
|
||||||
{"name": nom, "private": nom in prives}, auth)
|
{"name": nom, "private": False}, auth)
|
||||||
# PRIVE OU PUBLIC SELON CE QU'IL PORTE (2026-10-11). Le moteur et les modeles
|
|
||||||
# restent publics ; le plan d'un ecosysteme et la carte du site naissent prives,
|
|
||||||
# et leurs runners les clonent en SSH (`roles/serveur_ops`, colis compris). Le
|
|
||||||
# recit de 2026-09-12 ci-dessous reste vrai pour les depots publics :
|
|
||||||
#
|
|
||||||
# PUBLIC DANS LE PERIMETRE DE LA FORGE, ET C'EST VOULU (2026-09-12).
|
# PUBLIC DANS LE PERIMETRE DE LA FORGE, ET C'EST VOULU (2026-09-12).
|
||||||
#
|
#
|
||||||
# Cree en `private` au premier essai — par reflexe. Le runner clone
|
# Cree en `private` au premier essai — par reflexe. Le runner clone
|
||||||
|
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Le site enregistre, sur le depot de chaque locataire, la cle qui le lira. Rien d'autre.
|
|
||||||
|
|
||||||
POURQUOI (2026-10-11, `docs/depots-prives-conception.md`). Les depots des locataires deviennent
|
|
||||||
prives sur la forge du site. Chaque runner de locataire lit LE SIEN avec une cle de deploiement
|
|
||||||
EN LECTURE SEULE : la cle publique que sa face reseau publie (`genome.cle_lecture`). Le site
|
|
||||||
l'enregistre avec ses propres identifiants d'administration de la forge ; aucun secret ne passe
|
|
||||||
du locataire au site, ni l'inverse.
|
|
||||||
|
|
||||||
CE QUE LE SCRIPT TOUCHE, EXACTEMENT. Les seules cles de deploiement intitulees
|
|
||||||
`lecture — runner de <locataire>`, sur le seul depot que la face nomme :
|
|
||||||
- absente -> ajoutee, en lecture seule ;
|
|
||||||
- perimee (autre cle sous le meme titre) -> retiree : une cle qui n'est plus publiee est une
|
|
||||||
revocation, la garder ouvrirait le depot a un runner disparu ;
|
|
||||||
- presente en ECRITURE -> refus : une cle de lecture qui pourrait pousser n'est pas a corriger
|
|
||||||
en silence.
|
|
||||||
La cle du runner du SITE (`runner du site — ...`, posee par `genome_pousser.yml`) n'est jamais
|
|
||||||
touchee. Seuls les locataires dont la face nomme CE site comme hebergeur sont traites.
|
|
||||||
|
|
||||||
Lance sur le runner du site (il porte les faces et joint la forge), par `make forge-cles`.
|
|
||||||
Le mot de passe d'administration arrive par l'environnement (`FORGEJO_ADMIN`), depuis la voute
|
|
||||||
du site ; il n'est jamais affiche.
|
|
||||||
|
|
||||||
Usage :
|
|
||||||
FORGEJO_ADMIN=... python3 scripts/forge_cles.py # montre ce qui serait fait
|
|
||||||
FORGEJO_ADMIN=... python3 scripts/forge_cles.py --confirmer
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import ssl
|
|
||||||
import sys
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
RACINE = Path(__file__).resolve().parent.parent
|
|
||||||
sys.path.insert(0, str(RACINE / "scripts"))
|
|
||||||
|
|
||||||
PREFIXE_TITRE = "lecture — runner de "
|
|
||||||
# LA VISIBILITE SUIT LE ROLE (2026-10-11) : le plan d'un ecosysteme et la carte du site sont
|
|
||||||
# prives ; le moteur et les modeles, publics. La MEME liste que `serveur_ops_roles_prives`.
|
|
||||||
ROLES_PRIVES = {"instance", "tenant", "hebergeur"}
|
|
||||||
|
|
||||||
|
|
||||||
def titre_de(locataire: str) -> str:
|
|
||||||
return f"{PREFIXE_TITRE}{locataire}"
|
|
||||||
|
|
||||||
|
|
||||||
def _deux(cle: str) -> str:
|
|
||||||
"""Le type et le corps d'une cle publique : le commentaire ne l'identifie pas."""
|
|
||||||
return " ".join(str(cle or "").split()[:2])
|
|
||||||
|
|
||||||
|
|
||||||
def demandes(site_nom: str | None = None) -> list[dict]:
|
|
||||||
"""(locataire, depot, cle) de chaque locataire dont la face nomme ce site hebergeur."""
|
|
||||||
import contexte as _ctx
|
|
||||||
sortie = []
|
|
||||||
for site, loc in _ctx.couples_hebergeur():
|
|
||||||
if site_nom and site.nom != site_nom:
|
|
||||||
continue
|
|
||||||
face = loc.face_publiee() or {}
|
|
||||||
if face.get("hebergeur") != site.nom:
|
|
||||||
continue
|
|
||||||
g = face.get("genome") or {}
|
|
||||||
sortie.append({"locataire": loc.nom, "depot": str(g.get("depot") or ""),
|
|
||||||
"cle": _deux(g.get("cle_lecture"))})
|
|
||||||
return sortie
|
|
||||||
|
|
||||||
|
|
||||||
def plan(voulues: list[dict], existantes: dict[str, list[dict]]) -> tuple[list, list, list]:
|
|
||||||
"""Ce qu'il faut ajouter, retirer, et refuser. Pur : aucun appel a la forge.
|
|
||||||
|
|
||||||
`existantes` : depot -> cles de deploiement telles que l'API les rend
|
|
||||||
({id, title, key, read_only}).
|
|
||||||
"""
|
|
||||||
ajouter, retirer, refus = [], [], []
|
|
||||||
for v in voulues:
|
|
||||||
if not v["depot"] or not v["cle"]:
|
|
||||||
refus.append(f"{v['locataire']} : sa face ne publie ni depot ni cle de lecture")
|
|
||||||
continue
|
|
||||||
cles = existantes.get(v["depot"], [])
|
|
||||||
titre = titre_de(v["locataire"])
|
|
||||||
meme_cle = [k for k in cles if _deux(k.get("key")) == v["cle"]]
|
|
||||||
if any(not k.get("read_only", True) for k in meme_cle):
|
|
||||||
refus.append(f"{v['locataire']} : sa cle de lecture est enregistree EN ECRITURE sur "
|
|
||||||
f"{v['depot']} — a retirer a la main, apres verification")
|
|
||||||
continue
|
|
||||||
if not meme_cle:
|
|
||||||
ajouter.append(v)
|
|
||||||
for k in cles:
|
|
||||||
if k.get("title") == titre and _deux(k.get("key")) != v["cle"]:
|
|
||||||
retirer.append({**v, "id": k.get("id")})
|
|
||||||
return ajouter, retirer, refus
|
|
||||||
|
|
||||||
|
|
||||||
def visibilites_voulues() -> dict[str, bool]:
|
|
||||||
"""depot -> prive ?, d'apres `serveur_ops_depots` du plan du site monte (son runner)."""
|
|
||||||
import underlay as underlay_mod
|
|
||||||
plan = underlay_mod.lire_plan_site("serveurs.yml") or {}
|
|
||||||
for srv in (plan.get("serveurs") or {}).values():
|
|
||||||
d = (srv.get("variables") or {}).get("serveur_ops_depots")
|
|
||||||
if d:
|
|
||||||
return {str(x["depot"]): str(x.get("role")) in ROLES_PRIVES for x in d}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def plan_visibilite(voulues: dict[str, bool], actuelles: dict[str, bool]) -> list[tuple[str, bool]]:
|
|
||||||
"""(depot, prive voulu) pour chaque depot dont la visibilite differe. Pur."""
|
|
||||||
return [(d, v) for d, v in sorted(voulues.items()) if d in actuelles and actuelles[d] != v]
|
|
||||||
|
|
||||||
|
|
||||||
class Forge:
|
|
||||||
def __init__(self, base: str, organisation: str, mot_de_passe: str,
|
|
||||||
utilisateur: str = "forgejo-admin"):
|
|
||||||
self.base = base.rstrip("/")
|
|
||||||
self.org = organisation
|
|
||||||
self.auth = base64.b64encode(f"{utilisateur}:{mot_de_passe}".encode()).decode()
|
|
||||||
self.ctx = ssl.create_default_context()
|
|
||||||
|
|
||||||
def _api(self, chemin: str, methode: str = "GET", corps: dict | None = None):
|
|
||||||
req = urllib.request.Request(f"{self.base}/api/v1{chemin}", method=methode,
|
|
||||||
data=json.dumps(corps).encode() if corps is not None else None)
|
|
||||||
req.add_header("Authorization", f"Basic {self.auth}")
|
|
||||||
req.add_header("Content-Type", "application/json")
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=20, context=self.ctx) as r:
|
|
||||||
texte = r.read().decode() or "null"
|
|
||||||
return r.status, json.loads(texte)
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
return e.code, (e.read().decode(errors="replace") or "")[:200]
|
|
||||||
|
|
||||||
def cles(self, depot: str) -> list[dict]:
|
|
||||||
st, rep = self._api(f"/repos/{self.org}/{depot}/keys?limit=50")
|
|
||||||
if st != 200:
|
|
||||||
raise RuntimeError(f"lecture des cles de {depot} : HTTP {st} {rep}")
|
|
||||||
return rep or []
|
|
||||||
|
|
||||||
def ajouter(self, depot: str, titre: str, cle: str) -> int:
|
|
||||||
return self._api(f"/repos/{self.org}/{depot}/keys", "POST",
|
|
||||||
{"title": titre, "key": cle, "read_only": True})[0]
|
|
||||||
|
|
||||||
def retirer(self, depot: str, ident: int) -> int:
|
|
||||||
return self._api(f"/repos/{self.org}/{depot}/keys/{ident}", "DELETE")[0]
|
|
||||||
|
|
||||||
def prive(self, depot: str) -> bool | None:
|
|
||||||
st, rep = self._api(f"/repos/{self.org}/{depot}")
|
|
||||||
return bool(rep.get("private")) if st == 200 and isinstance(rep, dict) else None
|
|
||||||
|
|
||||||
def rendre(self, depot: str, prive: bool) -> int:
|
|
||||||
return self._api(f"/repos/{self.org}/{depot}", "PATCH", {"private": prive})[0]
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
||||||
ap.add_argument("--confirmer", action="store_true")
|
|
||||||
ap.add_argument("--forge", default=os.environ.get("SETOPS_FORGE_URL", ""),
|
|
||||||
help="https://forge.<domaine> (defaut : derive du plan du site)")
|
|
||||||
ap.add_argument("--organisation", default="genome")
|
|
||||||
ap.add_argument("--site", help="ne traiter que les locataires de ce site")
|
|
||||||
a = ap.parse_args(argv)
|
|
||||||
mdp = os.environ.get("FORGEJO_ADMIN", "")
|
|
||||||
if not mdp:
|
|
||||||
print("REFUS : FORGEJO_ADMIN absent (mot de passe d'administration de la forge, voute du site).",
|
|
||||||
file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
base = a.forge
|
|
||||||
if not base:
|
|
||||||
import underlay as underlay_mod
|
|
||||||
dom = str((underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("domaine_interne") or "")
|
|
||||||
if not dom:
|
|
||||||
print("REFUS : ni --forge ni `domaine_interne` au plan du site.", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
base = f"https://forge.{dom}"
|
|
||||||
voulues = demandes(a.site)
|
|
||||||
forge = Forge(base, a.organisation, mdp)
|
|
||||||
# LES CLES D'ABORD, LA VISIBILITE ENSUITE : un depot ne devient prive que quand son runner
|
|
||||||
# peut deja le lire. L'ordre inverse couperait le runner entre les deux.
|
|
||||||
vis_voulues = visibilites_voulues()
|
|
||||||
vis_actuelles = {d: v for d in vis_voulues if (v := forge.prive(d)) is not None}
|
|
||||||
a_basculer = plan_visibilite(vis_voulues, vis_actuelles)
|
|
||||||
if not voulues and not a_basculer:
|
|
||||||
print("Aucun locataire ne nomme ce site hebergeur, et chaque depot a sa visibilite.")
|
|
||||||
return 0
|
|
||||||
existantes = {v["depot"]: forge.cles(v["depot"]) for v in voulues if v["depot"]}
|
|
||||||
ajouter, retirer, refus = plan(voulues, existantes)
|
|
||||||
for v in voulues:
|
|
||||||
etat = ("a ajouter" if v in ajouter else "refus" if any(v["locataire"] in r for r in refus)
|
|
||||||
else "deja enregistree")
|
|
||||||
# LA FIN DE LA CLE, PAS SON DEBUT : toutes les ed25519 commencent par les memes 32
|
|
||||||
# caracteres, et deux lignes differentes s'affichaient identiques (2026-10-11).
|
|
||||||
print(f" {v['locataire']:<18} {v['depot']:<18} …{v['cle'][-16:]} {etat}")
|
|
||||||
for x in retirer:
|
|
||||||
print(f" {x['locataire']:<18} {x['depot']:<18} cle perimee #{x['id']} a retirer")
|
|
||||||
for r in refus:
|
|
||||||
print(f" REFUS {r}")
|
|
||||||
print("Visibilite des depots (d'apres le role que leur donne le plan du site) :")
|
|
||||||
for d, v in sorted(vis_voulues.items()):
|
|
||||||
act = vis_actuelles.get(d)
|
|
||||||
etat = ("introuvable" if act is None else "ok" if act == v
|
|
||||||
else f"a rendre {'prive' if v else 'public'}")
|
|
||||||
print(f" {d:<18} {'prive ' if v else 'public'} {etat}")
|
|
||||||
if not a.confirmer:
|
|
||||||
print("\nRien n'a ete ecrit. Relancer avec --confirmer (make forge-cles CONFIRMER=true).")
|
|
||||||
return 1 if refus else 0
|
|
||||||
echecs = 0
|
|
||||||
for x in retirer:
|
|
||||||
st = forge.retirer(x["depot"], x["id"])
|
|
||||||
echecs += st not in (204, 404)
|
|
||||||
print(f" retiree {x['depot']} #{x['id']} : HTTP {st}")
|
|
||||||
for v in ajouter:
|
|
||||||
st = forge.ajouter(v["depot"], titre_de(v["locataire"]), v["cle"])
|
|
||||||
echecs += st not in (201,)
|
|
||||||
print(f" ajoutee {v['depot']} ({v['locataire']}) : HTTP {st}")
|
|
||||||
# LA VISIBILITE, SEULEMENT SI LES CLES SONT EN PLACE (voir plus haut).
|
|
||||||
relu = {v["depot"]: forge.cles(v["depot"]) for v in voulues if v["depot"]}
|
|
||||||
reste_a, reste_r, _ = plan(voulues, relu)
|
|
||||||
if reste_a or reste_r or echecs or refus:
|
|
||||||
print(f"\nECART apres ecriture des cles : {len(reste_a)} a ajouter, {len(reste_r)} a retirer, "
|
|
||||||
f"{echecs} echec(s), {len(refus)} refus. Aucune visibilite n'a ete changee.")
|
|
||||||
return 1
|
|
||||||
for d, v in a_basculer:
|
|
||||||
st = forge.rendre(d, v)
|
|
||||||
echecs += st != 200
|
|
||||||
print(f" rendu {'prive ' if v else 'public'} {d} : HTTP {st}")
|
|
||||||
# RELIRE, PAS SE FIER AU CODE DE RETOUR (D-68).
|
|
||||||
vis_relues = {d: forge.prive(d) for d in vis_voulues}
|
|
||||||
reste_v = plan_visibilite(vis_voulues, {d: x for d, x in vis_relues.items() if x is not None})
|
|
||||||
if reste_v or echecs:
|
|
||||||
print(f"\nECART apres ecriture : {len(reste_v)} visibilite(s) a corriger, {echecs} echec(s).")
|
|
||||||
return 1
|
|
||||||
print("\nChaque depot de locataire porte exactement la cle de lecture que sa face publie, "
|
|
||||||
"et chaque depot la visibilite que son role demande.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Les comptes de la forge du site : un par mainteneur, et le lecteur de la console du site.
|
|
||||||
|
|
||||||
POURQUOI (2026-10-11, lot 3b). Un mainteneur demande quelque chose au site (une machine a
|
|
||||||
creer, une ouverture) par un TICKET dans le depot prive de son locataire, sur la forge du site.
|
|
||||||
Il ecrit avec SON compte : chaque demande dit qui l'a faite. La console du site lit toutes les
|
|
||||||
demandes avec un compte qui ne peut QUE lire.
|
|
||||||
|
|
||||||
CE QUE LE SCRIPT TIENT, d'apres le plan du site (`plan/mainteneurs.yml`) :
|
|
||||||
- chaque mainteneur declare a un compte, collaborateur en LECTURE sur les seuls depots des
|
|
||||||
locataires qu'il maintient (la lecture suffit a ouvrir et commenter un ticket) ; une
|
|
||||||
collaboration qui n'est plus declaree est retiree. Un compte neuf nait avec un mot de passe
|
|
||||||
aleatoire que PERSONNE ne voit : la personne choisit le sien par « mot de passe oublie » ;
|
|
||||||
- le compte `console-site`, membre de l'equipe `Lecteurs` de l'organisation (lecture des
|
|
||||||
tickets et du code, sur tous ses depots), et son jeton a portee LECTURE, range sur le runner
|
|
||||||
du site hors de tout depot (`~/.config/setops-forge-jeton-console`, 0600). Absent, il est
|
|
||||||
recree ; l'ancien est revoque.
|
|
||||||
|
|
||||||
Lance sur le runner du site par `make forge-comptes` (mot de passe d'administration par
|
|
||||||
l'environnement, `FORGEJO_ADMIN`, depuis la voute du site ; jamais affiche).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
import ssl
|
|
||||||
import sys
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
RACINE = Path(__file__).resolve().parent.parent
|
|
||||||
sys.path.insert(0, str(RACINE / "scripts"))
|
|
||||||
|
|
||||||
COMPTE_CONSOLE = "console-site"
|
|
||||||
EQUIPE_LECTEURS = "Lecteurs"
|
|
||||||
JETON_NOM = "console-site-lecture"
|
|
||||||
JETON_FICHIER = Path.home() / ".config" / "setops-forge-jeton-console"
|
|
||||||
|
|
||||||
|
|
||||||
def _basic(utilisateur: str, mdp: str) -> str:
|
|
||||||
return "Basic " + base64.b64encode(f"{utilisateur}:{mdp}".encode()).decode()
|
|
||||||
|
|
||||||
|
|
||||||
class Forge:
|
|
||||||
def __init__(self, base: str, mdp_admin: str, organisation: str = "genome"):
|
|
||||||
self.base = base.rstrip("/")
|
|
||||||
self.org = organisation
|
|
||||||
self.admin = _basic("forgejo-admin", mdp_admin)
|
|
||||||
self.ctx = ssl.create_default_context()
|
|
||||||
|
|
||||||
def api(self, chemin: str, methode: str = "GET", corps=None, auth: str | None = None):
|
|
||||||
req = urllib.request.Request(f"{self.base}/api/v1{chemin}", method=methode,
|
|
||||||
data=json.dumps(corps).encode() if corps is not None else None)
|
|
||||||
req.add_header("Authorization", auth or self.admin)
|
|
||||||
req.add_header("Content-Type", "application/json")
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=20, context=self.ctx) as r:
|
|
||||||
texte = r.read().decode()
|
|
||||||
return r.status, (json.loads(texte) if texte else None)
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
return e.code, (e.read().decode(errors="replace") or "")[:200]
|
|
||||||
|
|
||||||
|
|
||||||
def mainteneurs_declares() -> dict[str, dict]:
|
|
||||||
import underlay as underlay_mod
|
|
||||||
m = (underlay_mod.lire_plan_site("mainteneurs.yml") or {}).get("mainteneurs") or {}
|
|
||||||
return {str(k): dict(v or {}) for k, v in m.items()}
|
|
||||||
|
|
||||||
|
|
||||||
def depots_des_locataires() -> dict[str, str]:
|
|
||||||
"""locataire -> son depot du genome, d'apres sa face publiee (`genome.depot`)."""
|
|
||||||
import contexte as _ctx
|
|
||||||
out = {}
|
|
||||||
for site, loc in _ctx.couples_hebergeur():
|
|
||||||
face = loc.face_publiee() or {}
|
|
||||||
if face.get("hebergeur") == site.nom and (face.get("genome") or {}).get("depot"):
|
|
||||||
out[loc.nom] = str(face["genome"]["depot"])
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def plan_collaborations(voulues: dict[str, set[str]], actuelles: dict[str, set[str]]):
|
|
||||||
"""(ajouter, retirer) : listes de (compte, depot). Pur. `actuelles` ne porte que les
|
|
||||||
depots de locataires, jamais le moteur : on ne retire que ce que ce script gere."""
|
|
||||||
ajouter = sorted((c, d) for c, ds in voulues.items() for d in ds - actuelles.get(c, set()))
|
|
||||||
retirer = sorted((c, d) for c, ds in actuelles.items() for d in ds - voulues.get(c, set()))
|
|
||||||
return ajouter, retirer
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None) -> int:
|
|
||||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
||||||
ap.add_argument("--confirmer", action="store_true")
|
|
||||||
a = ap.parse_args(argv)
|
|
||||||
mdp = os.environ.get("FORGEJO_ADMIN", "")
|
|
||||||
if not mdp:
|
|
||||||
print("REFUS : FORGEJO_ADMIN absent (voute du site).", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
import underlay as underlay_mod
|
|
||||||
dom = str((underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("domaine_interne") or "")
|
|
||||||
forge = Forge(f"https://forge.{dom}", mdp)
|
|
||||||
mainteneurs = mainteneurs_declares()
|
|
||||||
depots = depots_des_locataires()
|
|
||||||
refus = []
|
|
||||||
voulues: dict[str, set[str]] = {}
|
|
||||||
for compte, m in sorted(mainteneurs.items()):
|
|
||||||
if not str(m.get("courriel") or "").strip():
|
|
||||||
refus.append(f"{compte} : `courriel` vide au plan du site (la forge l'exige, et c'est "
|
|
||||||
f"par lui que la personne choisit son mot de passe)")
|
|
||||||
continue
|
|
||||||
inconnus = [l for l in m.get("locataires") or [] if l not in depots]
|
|
||||||
if inconnus:
|
|
||||||
refus.append(f"{compte} : locataire(s) sans face ni depot ici : {', '.join(inconnus)}")
|
|
||||||
voulues[compte] = {depots[l] for l in m.get("locataires") or [] if l in depots}
|
|
||||||
|
|
||||||
# L'ETAT DE LA FORGE
|
|
||||||
st, users = forge.api("/admin/users?limit=100")
|
|
||||||
if st != 200:
|
|
||||||
print(f"REFUS : lecture des comptes : HTTP {st}", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
existants = {u["login"] for u in users}
|
|
||||||
actuelles: dict[str, set[str]] = {}
|
|
||||||
for d in sorted(set(depots.values())):
|
|
||||||
st, collab = forge.api(f"/repos/{forge.org}/{d}/collaborators?limit=100")
|
|
||||||
for c in (collab or []) if st == 200 else []:
|
|
||||||
actuelles.setdefault(c["login"], set()).add(d)
|
|
||||||
ajouter, retirer = plan_collaborations(voulues, actuelles)
|
|
||||||
a_creer = sorted(c for c in voulues if c not in existants)
|
|
||||||
st, equipes = forge.api(f"/orgs/{forge.org}/teams")
|
|
||||||
lecteurs = next((t for t in (equipes or []) if t.get("name") == EQUIPE_LECTEURS), None) if st == 200 else None
|
|
||||||
|
|
||||||
print("Mainteneurs (plan du site) :")
|
|
||||||
for c in sorted(mainteneurs):
|
|
||||||
print(f" {c:<16} {'a creer' if c in a_creer else 'existe' if c in existants else 'refuse'}"
|
|
||||||
f" depots : {', '.join(sorted(voulues.get(c, set()))) or '—'}")
|
|
||||||
for c, d in ajouter:
|
|
||||||
print(f" + collaboration (lecture) {c} sur {d}")
|
|
||||||
for c, d in retirer:
|
|
||||||
print(f" - collaboration {c} sur {d} (plus declaree)")
|
|
||||||
print(f"Lecteur de la console : compte {COMPTE_CONSOLE} "
|
|
||||||
f"{'existe' if COMPTE_CONSOLE in existants else 'a creer'}, equipe {EQUIPE_LECTEURS} "
|
|
||||||
f"{'existe' if lecteurs else 'a creer'}, jeton {'present' if JETON_FICHIER.is_file() else 'a creer'}")
|
|
||||||
for r in refus:
|
|
||||||
print(f" REFUS {r}")
|
|
||||||
if not a.confirmer:
|
|
||||||
print("\nRien n'a ete ecrit. Relancer avec --confirmer (make forge-comptes CONFIRMER=true).")
|
|
||||||
return 1 if refus else 0
|
|
||||||
|
|
||||||
echecs = 0
|
|
||||||
|
|
||||||
def creer_compte(login: str, courriel: str, nom: str, oblige_changer: bool) -> str:
|
|
||||||
nonlocal echecs
|
|
||||||
mdp_compte = secrets.token_urlsafe(32)
|
|
||||||
st, rep = forge.api("/admin/users", "POST", {
|
|
||||||
"username": login, "email": courriel, "full_name": nom, "password": mdp_compte,
|
|
||||||
"must_change_password": oblige_changer, "send_notify": False, "visibility": "private"})
|
|
||||||
if st != 201:
|
|
||||||
echecs += 1
|
|
||||||
print(f" ECHEC creation {login} : HTTP {st} {rep}")
|
|
||||||
else:
|
|
||||||
print(f" cree {login}")
|
|
||||||
return mdp_compte
|
|
||||||
|
|
||||||
for c in a_creer:
|
|
||||||
m = mainteneurs[c]
|
|
||||||
creer_compte(c, str(m["courriel"]).strip(), str(m.get("nom") or c), True)
|
|
||||||
print(f" -> {c} choisit son mot de passe par « mot de passe oublie » sur "
|
|
||||||
f"https://forge.{dom}/user/forgot_password")
|
|
||||||
for c, d in ajouter:
|
|
||||||
st, _ = forge.api(f"/repos/{forge.org}/{d}/collaborators/{c}", "PUT", {"permission": "read"})
|
|
||||||
echecs += st != 204
|
|
||||||
print(f" collab {c} sur {d} : HTTP {st}")
|
|
||||||
for c, d in retirer:
|
|
||||||
st, _ = forge.api(f"/repos/{forge.org}/{d}/collaborators/{c}", "DELETE")
|
|
||||||
echecs += st != 204
|
|
||||||
print(f" retire {c} de {d} : HTTP {st}")
|
|
||||||
|
|
||||||
# LE LECTEUR DE LA CONSOLE DU SITE
|
|
||||||
mdp_console = None
|
|
||||||
if COMPTE_CONSOLE not in existants:
|
|
||||||
mdp_console = creer_compte(COMPTE_CONSOLE, f"{COMPTE_CONSOLE}@{dom}",
|
|
||||||
"Console du site (lecture seule)", False)
|
|
||||||
if lecteurs is None:
|
|
||||||
st, lecteurs = forge.api(f"/orgs/{forge.org}/teams", "POST", {
|
|
||||||
"name": EQUIPE_LECTEURS, "permission": "read", "includes_all_repositories": True,
|
|
||||||
"description": "Lecture seule des tickets et du code — consoles",
|
|
||||||
"units": ["repo.code", "repo.issues"]})
|
|
||||||
echecs += st != 201
|
|
||||||
print(f" equipe {EQUIPE_LECTEURS} : HTTP {st}")
|
|
||||||
if isinstance(lecteurs, dict) and lecteurs.get("id"):
|
|
||||||
st, _ = forge.api(f"/teams/{lecteurs['id']}/members/{COMPTE_CONSOLE}", "PUT")
|
|
||||||
echecs += st not in (204,)
|
|
||||||
if not JETON_FICHIER.is_file():
|
|
||||||
# UN JETON NE SE CREE QU'AVEC L'IDENTITE DE SON PROPRIETAIRE. Le compte console n'a pas
|
|
||||||
# de mot de passe connu : on lui en donne un neuf, le temps de creer le jeton, puis il
|
|
||||||
# est jete. L'ancien jeton du meme nom est revoque d'abord.
|
|
||||||
if mdp_console is None:
|
|
||||||
mdp_console = secrets.token_urlsafe(32)
|
|
||||||
st, _ = forge.api(f"/admin/users/{COMPTE_CONSOLE}", "PATCH",
|
|
||||||
{"login_name": COMPTE_CONSOLE, "source_id": 0, "password": mdp_console,
|
|
||||||
"must_change_password": False})
|
|
||||||
echecs += st != 200
|
|
||||||
auth = _basic(COMPTE_CONSOLE, mdp_console)
|
|
||||||
forge.api(f"/users/{COMPTE_CONSOLE}/tokens/{JETON_NOM}", "DELETE", auth=auth)
|
|
||||||
st, rep = forge.api(f"/users/{COMPTE_CONSOLE}/tokens", "POST",
|
|
||||||
{"name": JETON_NOM, "scopes": ["read:issue", "read:repository"]}, auth=auth)
|
|
||||||
if st == 201 and isinstance(rep, dict) and rep.get("sha1"):
|
|
||||||
JETON_FICHIER.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
JETON_FICHIER.touch(mode=0o600)
|
|
||||||
JETON_FICHIER.write_text(rep["sha1"] + "\n", encoding="utf-8")
|
|
||||||
os.chmod(JETON_FICHIER, 0o600)
|
|
||||||
print(f" jeton {JETON_NOM} : cree, range dans {JETON_FICHIER} (0600)")
|
|
||||||
else:
|
|
||||||
echecs += 1
|
|
||||||
print(f" ECHEC jeton : HTTP {st} {rep}")
|
|
||||||
|
|
||||||
if echecs or refus:
|
|
||||||
print(f"\nECART : {echecs} echec(s), {len(refus)} refus.")
|
|
||||||
return 1
|
|
||||||
print("\nComptes et collaborations conformes au plan du site ; la console du site peut lire.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""La forge du site laisse-t-elle chaque runner lire ses depots, et personne d'autre ?
|
|
||||||
|
|
||||||
POURQUOI (2026-10-11, `docs/depots-prives-conception.md`). Le plan d'un ecosysteme et la carte
|
|
||||||
du site sont prives sur la forge du site ; chaque runner de locataire lit le sien avec une cle
|
|
||||||
de deploiement en lecture seule. Deux mesures, depuis deux endroits :
|
|
||||||
|
|
||||||
SANS IDENTIFIANT, depuis ce poste (HTTPS) :
|
|
||||||
- un depot PRIVE doit EXIGER une authentification. Un refus RESEAU ne compte pas : il ne
|
|
||||||
dit rien de la forge, seulement du chemin (une sonde rapporte son errno) ;
|
|
||||||
- un depot PUBLIC (le moteur, les modeles) doit repondre : un ecosysteme neuf le clone
|
|
||||||
sans rien detenir.
|
|
||||||
DEPUIS CHAQUE RUNNER DE LOCATAIRE (SSH, sa cle, la cle d'hote epinglee) :
|
|
||||||
- il lit SON depot ;
|
|
||||||
- la forge lui REFUSE les autres depots prives (« not authorized »).
|
|
||||||
|
|
||||||
Le resultat se lit ligne par ligne ; le code de sortie vaut 1 au moindre ecart.
|
|
||||||
|
|
||||||
Usage :
|
|
||||||
python3 scripts/forge_etancheite.py (make forge-etancheite)
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
RACINE = Path(__file__).resolve().parent.parent
|
|
||||||
sys.path.insert(0, str(RACINE / "scripts"))
|
|
||||||
ROLES_PRIVES = {"instance", "tenant", "hebergeur"}
|
|
||||||
|
|
||||||
|
|
||||||
def classer_https(rc: int, sortie: str) -> str:
|
|
||||||
"""'lu', 'authentification', ou 'reseau' — ce que dit un `git ls-remote` anonyme."""
|
|
||||||
if rc == 0:
|
|
||||||
return "lu"
|
|
||||||
s = sortie.lower()
|
|
||||||
if any(m in s for m in ("could not read username", "terminal prompts disabled",
|
|
||||||
"authentication failed", "401", "403")):
|
|
||||||
return "authentification"
|
|
||||||
return "reseau"
|
|
||||||
|
|
||||||
|
|
||||||
def classer_ssh(rc: int, sortie: str) -> str:
|
|
||||||
"""'lu', 'refuse' (la forge a juge), ou 'reseau' (personne n'a juge)."""
|
|
||||||
if rc == 0:
|
|
||||||
return "lu"
|
|
||||||
s = sortie.lower()
|
|
||||||
if "not authorized" in s or "permission denied" in s or "does not exist" in s:
|
|
||||||
return "refuse"
|
|
||||||
return "reseau"
|
|
||||||
|
|
||||||
|
|
||||||
def _site_et_depots():
|
|
||||||
import underlay as underlay_mod
|
|
||||||
plan = underlay_mod.lire_plan_site("serveurs.yml") or {}
|
|
||||||
depots = []
|
|
||||||
for srv in (plan.get("serveurs") or {}).values():
|
|
||||||
d = (srv.get("variables") or {}).get("serveur_ops_depots")
|
|
||||||
if d:
|
|
||||||
depots = list(d)
|
|
||||||
dom = str((underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("domaine_interne") or "")
|
|
||||||
racine_ac = (underlay_mod.chemin() or Path()).resolve().parent / "ac-racine-site.crt"
|
|
||||||
return dom, depots, racine_ac
|
|
||||||
|
|
||||||
|
|
||||||
def mesurer_https(dom: str, depots: list[dict], racine_ac: Path) -> list[str]:
|
|
||||||
ecarts = []
|
|
||||||
env = dict(os.environ, GIT_TERMINAL_PROMPT="0", GIT_ASKPASS="/bin/false")
|
|
||||||
if racine_ac.is_file():
|
|
||||||
env["GIT_SSL_CAINFO"] = str(racine_ac)
|
|
||||||
print(f"Sans identifiant, depuis ce poste (https://forge.{dom}) :")
|
|
||||||
for d in depots:
|
|
||||||
nom, prive = str(d["depot"]), str(d.get("role")) in ROLES_PRIVES
|
|
||||||
r = subprocess.run(["git", "ls-remote", f"https://forge.{dom}/genome/{nom}.git", "HEAD"],
|
|
||||||
capture_output=True, text=True, timeout=30, env=env)
|
|
||||||
verdict = classer_https(r.returncode, r.stdout + r.stderr)
|
|
||||||
attendu = "authentification" if prive else "lu"
|
|
||||||
ok = verdict == attendu
|
|
||||||
print(f" {'OK ' if ok else 'ECART'} {nom:<18} {'prive ' if prive else 'public'} -> {verdict}"
|
|
||||||
+ ("" if ok else f" (attendu : {attendu})"))
|
|
||||||
if not ok:
|
|
||||||
ecarts.append(f"{nom} : {verdict}, attendu {attendu}")
|
|
||||||
return ecarts
|
|
||||||
|
|
||||||
|
|
||||||
def mesurer_runners(depots: list[dict]) -> list[str]:
|
|
||||||
import contexte as _ctx
|
|
||||||
import yaml
|
|
||||||
prives = [str(d["depot"]) for d in depots if str(d.get("role")) in ROLES_PRIVES]
|
|
||||||
ecarts = []
|
|
||||||
for site, loc in _ctx.couples_hebergeur():
|
|
||||||
face = loc.face_publiee() or {}
|
|
||||||
if face.get("hebergeur") != site.nom:
|
|
||||||
continue
|
|
||||||
sien = str((face.get("genome") or {}).get("depot") or "")
|
|
||||||
runners = sorted((face.get("groupes") or {}).get("serveur_ops_tenant") or [])
|
|
||||||
gv = yaml.safe_load((loc.dossier_inventaire() / "group_vars" / "serveur_ops.yml")
|
|
||||||
.read_text(encoding="utf-8")) or {}
|
|
||||||
dest = next((str(x.get("dest")) for x in gv.get("serveur_ops_depots") or []
|
|
||||||
if x.get("role") == "instance"), "")
|
|
||||||
if not (sien and runners and dest):
|
|
||||||
ecarts.append(f"{loc.nom} : face sans depot, sans runner, ou plan sans `role: instance`")
|
|
||||||
continue
|
|
||||||
print(f"\nDepuis le runner de {loc.nom} ({runners[0]}), par l'URL de son origin :")
|
|
||||||
# LA COMMANDE SSH DU DEPOT LUI-MEME (`core.sshCommand`, posee par serveur_ops) : on
|
|
||||||
# mesure ce que le runner fait vraiment, pas une commande refaite a cote.
|
|
||||||
script = (f"cd /opt/setops/{dest} && base=$(git remote get-url origin) && base=${{base%/*}} && "
|
|
||||||
f"for n in {' '.join(prives)}; do "
|
|
||||||
"if out=$(git ls-remote \"$base/$n.git\" HEAD 2>&1 >/dev/null); then echo \"MESURE $n lu\"; "
|
|
||||||
"else echo \"MESURE $n $(echo $out | tr '\\n' ' ')\"; fi; done")
|
|
||||||
r = subprocess.run(["ansible", "-i", str(loc.inventaire()), runners[0], "-b",
|
|
||||||
"--become-user", "setops", "-m", "shell", "-a", script],
|
|
||||||
capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
|
|
||||||
mesures = {}
|
|
||||||
for ligne in r.stdout.splitlines():
|
|
||||||
if ligne.startswith("MESURE "):
|
|
||||||
_m, n, reste = ligne.split(" ", 2)
|
|
||||||
mesures[n] = ("lu", "") if reste.strip() == "lu" else (classer_ssh(1, reste), reste.strip())
|
|
||||||
if not mesures:
|
|
||||||
ecarts.append(f"{loc.nom} : aucune mesure rendue par {runners[0]} ({(r.stderr or r.stdout)[-160:]})")
|
|
||||||
print(f" ECART aucune mesure rendue")
|
|
||||||
continue
|
|
||||||
for n in prives:
|
|
||||||
verdict, detail = mesures.get(n, ("absent", ""))
|
|
||||||
attendu = "lu" if n == sien else "refuse"
|
|
||||||
ok = verdict == attendu
|
|
||||||
print(f" {'OK ' if ok else 'ECART'} {n:<18} -> {verdict}"
|
|
||||||
+ ("" if ok else f" (attendu : {attendu}) {detail[:90]}"))
|
|
||||||
if not ok:
|
|
||||||
ecarts.append(f"{loc.nom} -> {n} : {verdict}, attendu {attendu}")
|
|
||||||
return ecarts
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
dom, depots, racine_ac = _site_et_depots()
|
|
||||||
if not dom or not depots:
|
|
||||||
print("REFUS : aucun site monte, ou son plan ne declare pas `serveur_ops_depots`.")
|
|
||||||
return 2
|
|
||||||
ecarts = mesurer_https(dom, depots, racine_ac) + mesurer_runners(depots)
|
|
||||||
if ecarts:
|
|
||||||
print(f"\nNON ETANCHE : {len(ecarts)} ecart(s).")
|
|
||||||
return 1
|
|
||||||
print("\nETANCHE : chaque runner lit son depot, aucun celui d'un autre ; le public reste public.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
@ -1,592 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""La frontiere certifiee par la PKI du site, avec sa propre autorite subordonnee.
|
|
||||||
|
|
||||||
python3 scripts/frontiere_pki.py plan # lit, ne change rien
|
|
||||||
CONFIRMER=true python3 scripts/frontiere_pki.py appliquer # pose ce qui manque
|
|
||||||
CONFIRMER=true python3 scripts/frontiere_pki.py regenerer # refait l'autorite a neuf
|
|
||||||
CONFIRMER=true python3 scripts/frontiere_pki.py interface # R3 : l'interface web passe au certificat
|
|
||||||
|
|
||||||
POURQUOI (2026-10-12). Directive : tout transit chiffre, a l'interne comme a l'externe. La
|
|
||||||
frontiere (OPNsense) etait le seul element du site sans certificat de l'autorite : son
|
|
||||||
interface servait un certificat auto-signe, son exportateur de metriques et son syslog
|
|
||||||
parlaient en clair. Daniel a demande qu'elle soit certifiee par la PKI du site ET qu'elle
|
|
||||||
porte sa propre autorite subordonnee — pour ses services et, plus tard, les certificats des
|
|
||||||
VPN d'administration.
|
|
||||||
|
|
||||||
CE QUE LE SCRIPT POSE, dans le magasin d'OPNsense (Systeme > Confiance) :
|
|
||||||
1. la RACINE du site, certificat seul (pour que la chaine se lie et se verifie) ;
|
|
||||||
2. l'AUTORITE de la frontiere, signee par la racine, bornee : aucune autorite sous elle
|
|
||||||
(pathlen 0), signature de certificats seulement, noms sous le domaine du site et
|
|
||||||
adresses du site — rien d'autre (contrainte de noms critique) ;
|
|
||||||
3. le certificat de SERVICES (interface, syslog : serveur et client) ;
|
|
||||||
4. le certificat de l'EXPORTATEUR, a part : il tourne en `nobody`, qui ne doit pas pouvoir
|
|
||||||
lire la cle des services.
|
|
||||||
|
|
||||||
OU NAIT CHAQUE CLE. Celle de l'autorite nait SUR la frontiere (openssl), dans un dossier de
|
|
||||||
root, et y est importee par l'API LOCALE de la boite : elle ne transite ni par le poste ni
|
|
||||||
par le controleur. La racine signe sur `site-pki-01` ; sa cle n'en sort pas, son mot de passe
|
|
||||||
vient de la voute de l'underlay et passe par l'entree standard. Les cles des certificats sont
|
|
||||||
generees par OPNsense lui-meme.
|
|
||||||
|
|
||||||
ELIGIBILITE DES NOMS. La contrainte rend invalide tout nom hors du domaine : le certificat ne
|
|
||||||
porte donc ni le nom court, ni le transit, ni la grappe — seulement le FQDN et les pattes du
|
|
||||||
site (`setops_frontiere`, derive par l'inventaire du site).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import shlex
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
RACINE = Path(__file__).resolve().parent.parent
|
|
||||||
sys.path.insert(0, str(RACINE / "scripts"))
|
|
||||||
|
|
||||||
import devis_opnsense as devis_mod # noqa: E402
|
|
||||||
import site_inventaire as si # noqa: E402
|
|
||||||
from appliquer_opnsense import Frontiere, _voute # noqa: E402
|
|
||||||
|
|
||||||
JOUR = 86400
|
|
||||||
SOUS_AC_VALIDITE_H = 5 * 365 * 24 # 5 ans, dans la validite de la racine
|
|
||||||
CERT_VALIDITE_J = 397 # plafond des navigateurs
|
|
||||||
SOUS_AC_MARGE_J = 365 # en deca : a refaire
|
|
||||||
CERT_MARGE_J = 30
|
|
||||||
HOTE_PKI = "site-pki-01"
|
|
||||||
REBOND = "ansible@{admin}"
|
|
||||||
|
|
||||||
|
|
||||||
def _ssh(cible: list[str], commande: str, entree: str | None = None) -> str:
|
|
||||||
r = subprocess.run(["ssh", "-o", "BatchMode=yes", *cible, commande], input=entree,
|
|
||||||
capture_output=True, text=True, timeout=120)
|
|
||||||
if r.returncode != 0:
|
|
||||||
raise SystemExit(f"echec ssh {cible[-1]} : {(r.stderr or r.stdout).strip()[:400]}")
|
|
||||||
return r.stdout
|
|
||||||
|
|
||||||
|
|
||||||
class Contexte:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
inv = si.inventaire()
|
|
||||||
hv = inv["_meta"]["hostvars"]
|
|
||||||
pki = hv.get(HOTE_PKI) or {}
|
|
||||||
self.f = pki.get("setops_frontiere") or {}
|
|
||||||
if not self.f.get("admin") or not self.f.get("adresses"):
|
|
||||||
raise SystemExit(f"`setops_frontiere` absent de {HOTE_PKI} : l'inventaire du site ne la derive pas.")
|
|
||||||
self.domaine = str(pki.get("domaine_interne") or "")
|
|
||||||
self.ip_pki = str(pki.get("ansible_host") or "")
|
|
||||||
self.nom = str(self.f["nom"])
|
|
||||||
self.fqdn = f"{self.nom}.{self.domaine}"
|
|
||||||
base = devis_mod.depot_hebergeur()
|
|
||||||
self.racine_pem = (base / "ac-racine-site.crt").read_text(encoding="utf-8")
|
|
||||||
intr = devis_mod.intrants_frontiere()
|
|
||||||
self.v = _voute(base)
|
|
||||||
self.api = Frontiere(str(intr["opnsense_api_url"]), self.v["vault_opnsense_api_key"],
|
|
||||||
self.v["vault_opnsense_api_secret"],
|
|
||||||
bool(intr.get("opnsense_api_verifier_certs")))
|
|
||||||
self.frontiere = [f"ansible@{self.f['admin']}"]
|
|
||||||
self.pki = ["-J", REBOND.format(admin=self.f["admin"]), f"ansible@{self.ip_pki}"]
|
|
||||||
self.desc_racine = "Set-OPS racine du site"
|
|
||||||
self.desc_sous_ac = f"Set-OPS autorite de {self.nom}"
|
|
||||||
self.desc_services = f"Set-OPS {self.nom} services"
|
|
||||||
self.desc_expo = f"Set-OPS {self.nom} exportateur"
|
|
||||||
|
|
||||||
def lister(self, quoi: str) -> list[dict]:
|
|
||||||
"""Les autorites (`ca`) ou certificats (`cert`) du magasin — SANS LEURS CLES.
|
|
||||||
|
|
||||||
PAS PAR L'API (mesure du 2026-10-12). `/api/trust/{ca,cert}/search` renvoie chaque
|
|
||||||
objet ENTIER, cle privee comprise (`prv`) : lister le magasin depuis le poste y
|
|
||||||
rapatriait la cle de l'autorite. La lecture se fait donc SUR la frontiere, dans
|
|
||||||
`config.xml`, et seuls les champs publics en sortent.
|
|
||||||
"""
|
|
||||||
lecteur = (
|
|
||||||
"import base64,json,sys,xml.etree.ElementTree as E\n"
|
|
||||||
"c=E.parse('/conf/config.xml').getroot()\n"
|
|
||||||
"out=[]\n"
|
|
||||||
f"for n in c.iter({quoi!r}):\n"
|
|
||||||
" crt=base64.b64decode(n.findtext('crt') or '').decode()\n"
|
|
||||||
" out.append({k:(n.findtext(k) or '') for k in ('refid','descr','caref')}|{'crt':crt,'uuid':n.get('uuid') or ''})\n"
|
|
||||||
"json.dump(out,sys.stdout)\n")
|
|
||||||
import json
|
|
||||||
rows = json.loads(_ssh(self.frontiere, "sudo -n /usr/local/bin/python3 -", entree=lecteur))
|
|
||||||
for r in rows:
|
|
||||||
r["valid_to"] = _fin_de_validite(r["crt"])
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _fin_de_validite(pem: str) -> str:
|
|
||||||
"""Fin de validite (epoch) d'un certificat PEM, lue par openssl sur le poste."""
|
|
||||||
if "BEGIN CERTIFICATE" not in pem:
|
|
||||||
return "0"
|
|
||||||
r = subprocess.run(["openssl", "x509", "-noout", "-enddate", "-dateopt", "iso_8601"],
|
|
||||||
input=pem, capture_output=True, text=True)
|
|
||||||
fin = r.stdout.strip().split("=", 1)[-1]
|
|
||||||
try:
|
|
||||||
return str(int(time.mktime(time.strptime(fin, "%Y-%m-%d %H:%M:%SZ")) - time.timezone))
|
|
||||||
except ValueError:
|
|
||||||
return "0"
|
|
||||||
|
|
||||||
|
|
||||||
def _valide_jusqua(row: dict) -> float:
|
|
||||||
try:
|
|
||||||
return float(row.get("valid_to") or 0)
|
|
||||||
except ValueError:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def _meilleur(rows: list[dict], descr: str) -> dict | None:
|
|
||||||
rows = [r for r in rows if r.get("descr") == descr]
|
|
||||||
return max(rows, key=_valide_jusqua) if rows else None
|
|
||||||
|
|
||||||
|
|
||||||
def etat(c: Contexte) -> dict:
|
|
||||||
cas, certs = c.lister("ca"), c.lister("cert")
|
|
||||||
maintenant = time.time()
|
|
||||||
racine = _meilleur(cas, c.desc_racine)
|
|
||||||
sous_ac = _meilleur(cas, c.desc_sous_ac)
|
|
||||||
e = {
|
|
||||||
"racine": racine,
|
|
||||||
"sous_ac": sous_ac,
|
|
||||||
"services": _meilleur(certs, c.desc_services),
|
|
||||||
"exportateur": _meilleur(certs, c.desc_expo),
|
|
||||||
}
|
|
||||||
e["a_faire"] = []
|
|
||||||
if not racine:
|
|
||||||
e["a_faire"].append("racine")
|
|
||||||
if not sous_ac or _valide_jusqua(sous_ac) < maintenant + SOUS_AC_MARGE_J * JOUR:
|
|
||||||
e["a_faire"].append("sous_ac")
|
|
||||||
for cle in ("services", "exportateur"):
|
|
||||||
r = e[cle]
|
|
||||||
if (not r or _valide_jusqua(r) < maintenant + CERT_MARGE_J * JOUR
|
|
||||||
or (sous_ac and r.get("caref") != sous_ac.get("refid"))):
|
|
||||||
e["a_faire"].append(cle)
|
|
||||||
return e
|
|
||||||
|
|
||||||
|
|
||||||
def afficher(c: Contexte, e: dict) -> None:
|
|
||||||
print(f"Frontiere {c.nom} ({c.f['admin']}) — domaine {c.domaine}, adresses {', '.join(c.f['adresses'])}")
|
|
||||||
for cle, desc in (("racine", c.desc_racine), ("sous_ac", c.desc_sous_ac),
|
|
||||||
("services", c.desc_services), ("exportateur", c.desc_expo)):
|
|
||||||
r = e[cle]
|
|
||||||
if r:
|
|
||||||
fin = time.strftime("%Y-%m-%d", time.localtime(_valide_jusqua(r)))
|
|
||||||
print(f" {'A REFAIRE' if cle in e['a_faire'] else 'ok':9} {desc} (refid {r.get('refid')}, jusqu'au {fin})")
|
|
||||||
else:
|
|
||||||
print(f" {'A POSER':9} {desc}")
|
|
||||||
|
|
||||||
|
|
||||||
GABARIT_SOUS_AC = """{{
|
|
||||||
"subject": {{{{ toJson .Subject }}}},
|
|
||||||
"keyUsage": ["certSign", "crlSign"],
|
|
||||||
"basicConstraints": {{"isCA": true, "maxPathLen": 0}},
|
|
||||||
"nameConstraints": {{
|
|
||||||
"critical": true,
|
|
||||||
"permittedDNSDomains": [{domaine}],
|
|
||||||
"permittedIPRanges": [{supernet}]
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def poser_racine(c: Contexte) -> str:
|
|
||||||
r = c.api("/api/trust/ca/add", {"ca": {"action": "existing", "descr": c.desc_racine,
|
|
||||||
"crt_payload": c.racine_pem}})
|
|
||||||
if r.get("result") != "saved":
|
|
||||||
raise SystemExit(f"import de la racine refuse : {r}")
|
|
||||||
print(f" racine importee (certificat seul) : {r.get('uuid')}")
|
|
||||||
return str(r.get("uuid"))
|
|
||||||
|
|
||||||
|
|
||||||
def poser_sous_ac(c: Contexte) -> None:
|
|
||||||
import json
|
|
||||||
# 1. LA CLE NAIT SUR LA FRONTIERE. Dossier de root, 0700 ; la demande seule en sort.
|
|
||||||
sujet = f"/O=Set-OPS Internal CA/CN=Set-OPS {c.nom} CA"
|
|
||||||
dossier = _ssh(c.frontiere, "sudo -n sh -c 'umask 077; mktemp -d /root/setops-pki.XXXXXX'").strip()
|
|
||||||
csr = _ssh(c.frontiere, "sudo -n sh -c " + shlex.quote(
|
|
||||||
f"umask 077; cd {dossier} && openssl ecparam -name prime256v1 -genkey -noout -out sous-ac.key"
|
|
||||||
f" && openssl req -new -key sous-ac.key -subj {shlex.quote(sujet)} -out sous-ac.csr && cat sous-ac.csr"))
|
|
||||||
try:
|
|
||||||
# 2. LA RACINE SIGNE SUR site-pki-01. Le mot de passe passe par l'entree standard.
|
|
||||||
gabarit = GABARIT_SOUS_AC.format(domaine=json.dumps(c.domaine), supernet=json.dumps(c.f["supernet"]))
|
|
||||||
script = (
|
|
||||||
"set -e; umask 077; D=$(mktemp -d); trap 'rm -rf \"$D\"' EXIT; "
|
|
||||||
f"printf %s {shlex.quote(csr)} > \"$D/s.csr\"; printf %s {shlex.quote(gabarit)} > \"$D/s.tpl\"; "
|
|
||||||
"cat > \"$D/mdp\"; "
|
|
||||||
f"step certificate sign --template \"$D/s.tpl\" --not-after {SOUS_AC_VALIDITE_H}h "
|
|
||||||
"--password-file \"$D/mdp\" \"$D/s.csr\" /etc/step-ca/certs/root_ca.crt /etc/step-ca/secrets/root_ca_key")
|
|
||||||
crt = _ssh(c.pki, "sudo -n bash -c " + shlex.quote(script), entree=c.v["vault_step_ca_password"])
|
|
||||||
if "BEGIN CERTIFICATE" not in crt:
|
|
||||||
raise SystemExit("la racine n'a rien rendu de signe")
|
|
||||||
# 3. VERIFIER AVANT D'IMPORTER : chaine vers la racine du site, et cle correspondante.
|
|
||||||
verif = _ssh(c.frontiere, "sudo -n sh -c " + shlex.quote(
|
|
||||||
f"cd {dossier} && cat > sous-ac.crt && printf %s {shlex.quote(c.racine_pem)} > racine.crt"
|
|
||||||
" && openssl verify -CAfile racine.crt sous-ac.crt"
|
|
||||||
" && [ \"$(openssl x509 -in sous-ac.crt -noout -pubkey)\" = \"$(openssl pkey -in sous-ac.key -pubout)\" ]"
|
|
||||||
" && echo CLE-OK"), entree=crt)
|
|
||||||
if "sous-ac.crt: OK" not in verif or "CLE-OK" not in verif:
|
|
||||||
raise SystemExit(f"le certificat signe ne se verifie pas : {verif.strip()[:200]}")
|
|
||||||
print(" autorite signee par la racine, chaine et cle verifiees sur la frontiere")
|
|
||||||
# 4. IMPORT PAR L'API LOCALE de la boite : la cle est lue sur place par curl.
|
|
||||||
conf = f'user = "{c.v["vault_opnsense_api_key"]}:{c.v["vault_opnsense_api_secret"]}"\n'
|
|
||||||
rep = _ssh(c.frontiere, "sudo -n sh -c " + shlex.quote(
|
|
||||||
f"cd {dossier} && curl -sk -K - https://127.0.0.1/api/trust/ca/add"
|
|
||||||
" --data-urlencode 'ca[action]=existing'"
|
|
||||||
f" --data-urlencode {shlex.quote('ca[descr]=' + c.desc_sous_ac)}"
|
|
||||||
" --data-urlencode 'ca[crt_payload]@sous-ac.crt'"
|
|
||||||
" --data-urlencode 'ca[prv_payload]@sous-ac.key'"), entree=conf)
|
|
||||||
if '"saved"' not in rep:
|
|
||||||
raise SystemExit(f"import de l'autorite refuse : {rep.strip()[:300]}")
|
|
||||||
print(" autorite importee dans le magasin d'OPNsense (cle generee et restee sur la boite)")
|
|
||||||
finally:
|
|
||||||
_ssh(c.frontiere, f"sudo -n rm -P -rf {shlex.quote(dossier)}")
|
|
||||||
|
|
||||||
|
|
||||||
def poser_certificat(c: Contexte, descr: str, sous_ac: dict, type_cert: str) -> None:
|
|
||||||
corps = {"cert": {
|
|
||||||
"action": "internal", "descr": descr, "caref": sous_ac["refid"],
|
|
||||||
"key_type": "prime256v1", "digest": "sha256", "lifetime": str(CERT_VALIDITE_J),
|
|
||||||
"cert_type": type_cert, "private_key_location": "firewall",
|
|
||||||
"commonname": c.fqdn, "organization": "Set-OPS", "country": "CA",
|
|
||||||
"altnames_dns": c.fqdn, "altnames_ip": "\n".join(c.f["adresses"]),
|
|
||||||
}}
|
|
||||||
r = c.api("/api/trust/cert/add", corps)
|
|
||||||
if r.get("result") != "saved":
|
|
||||||
raise SystemExit(f"emission de « {descr} » refusee : {r}")
|
|
||||||
print(f" emis : {descr} ({type_cert})")
|
|
||||||
|
|
||||||
|
|
||||||
def racine_dans_le_systeme(c: Contexte) -> bool:
|
|
||||||
"""La racine est-elle dans le magasin du SYSTEME (FreeBSD), pas seulement dans celui d'OPNsense ?
|
|
||||||
|
|
||||||
syslog-ng verifie son destinataire contre `/etc/ssl/certs`. OPNsense y recopie ses
|
|
||||||
autorites racines au demarrage ou au rechargement global — pas a l'import. Mesure du
|
|
||||||
2026-10-12 : racine importee, absente de `/etc/ssl/certs`.
|
|
||||||
"""
|
|
||||||
# LE SYSTEME LUI-MEME JUGE, pas une recherche de texte : le nom de l'autorite est dans le
|
|
||||||
# base64, invisible a `grep` — premiere ecriture de cette fonction, fausse pour ca.
|
|
||||||
sortie = _ssh(c.frontiere, "openssl verify -CApath /etc/ssl/certs /dev/stdin 2>&1; true",
|
|
||||||
entree=c.racine_pem)
|
|
||||||
return sortie.strip().endswith(": OK")
|
|
||||||
|
|
||||||
|
|
||||||
def recopier_racine(c: Contexte) -> None:
|
|
||||||
_ssh(c.frontiere, "sudo -n /usr/local/sbin/configctl system trust configure")
|
|
||||||
if not racine_dans_le_systeme(c):
|
|
||||||
raise SystemExit("la racine n'entre pas dans le magasin du systeme apres `trust configure`.")
|
|
||||||
print(" racine recopiee dans le magasin du systeme (configctl system trust configure)")
|
|
||||||
|
|
||||||
|
|
||||||
def fichier_autorite(c: Contexte) -> Path:
|
|
||||||
"""Le certificat PUBLIC de l'autorite de la frontiere, a cote de `ac-racine-site.crt`.
|
|
||||||
|
|
||||||
L'inventaire du site le lit pour dire au recepteur syslog QUI peut lui ecrire : les
|
|
||||||
certificats emis par cette autorite, et eux seuls.
|
|
||||||
"""
|
|
||||||
return devis_mod.depot_hebergeur() / f"ac-frontiere-{c.nom}.crt"
|
|
||||||
|
|
||||||
|
|
||||||
def publier_autorite(c: Contexte, sous_ac: dict) -> bool:
|
|
||||||
f = fichier_autorite(c)
|
|
||||||
pem = sous_ac["crt"].strip() + "\n"
|
|
||||||
if f.is_file() and f.read_text(encoding="utf-8") == pem:
|
|
||||||
return False
|
|
||||||
f.write_text(pem, encoding="utf-8")
|
|
||||||
print(f" publie : {f} (a committer dans le depot du site)")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# --- USAGE : LE SYSLOG VERS LE LOKI DU SITE (F2) -------------------------------------
|
|
||||||
|
|
||||||
def _loki_syslog(c: Contexte) -> tuple[str, str]:
|
|
||||||
inv = si.inventaire()
|
|
||||||
for h, v in inv["_meta"]["hostvars"].items():
|
|
||||||
ecoute = str(v.get("client_journal_syslog_ecoute") or "")
|
|
||||||
if ecoute and v.get("client_journal_syslog_tls"):
|
|
||||||
ip, port = ecoute.rsplit(":", 1)
|
|
||||||
return ip, port
|
|
||||||
return "", ""
|
|
||||||
|
|
||||||
|
|
||||||
def destinations_syslog(c: Contexte) -> list[dict]:
|
|
||||||
ip, port = _loki_syslog(c)
|
|
||||||
r = c.api("/api/syslog/settings/searchDestinations", {"rowCount": 100, "current": 1})
|
|
||||||
return [d for d in (r.get("rows") or []) if d.get("hostname") == ip and str(d.get("port")) == port]
|
|
||||||
|
|
||||||
|
|
||||||
def poignee_tls_vers_loki(c: Contexte, services: dict) -> str:
|
|
||||||
"""Une VRAIE poignee de main, depuis la frontiere, avec SON certificat de services.
|
|
||||||
|
|
||||||
LA GARDE AVANT DE BASCULER (2026-10-12). Une destination syslog morte a deja eteint TOUTE
|
|
||||||
la journalisation de la frontiere pendant dix jours. On ne passe en `tls4` que si le
|
|
||||||
recepteur accepte ce certificat et que la frontiere verifie le sien contre la racine
|
|
||||||
du site — la meme verification que syslog-ng fera. Rend la conclusion d'openssl.
|
|
||||||
"""
|
|
||||||
ip, port = _loki_syslog(c)
|
|
||||||
script = (
|
|
||||||
"set -e; umask 077; D=$(mktemp -d /root/setops-essai.XXXXXX); trap 'rm -P -rf \"$D\"' EXIT; "
|
|
||||||
"/usr/local/bin/python3 - \"$D\" <<'PY'\n"
|
|
||||||
"import base64,sys,xml.etree.ElementTree as E\n"
|
|
||||||
"c=E.parse('/conf/config.xml').getroot()\n"
|
|
||||||
"for n in c.iter('cert'):\n"
|
|
||||||
f" if n.findtext('refid')=={services['refid']!r}:\n"
|
|
||||||
" open(sys.argv[1]+'/c.pem','wb').write(base64.b64decode(n.findtext('crt')))\n"
|
|
||||||
" open(sys.argv[1]+'/k.pem','wb').write(base64.b64decode(n.findtext('prv')))\n"
|
|
||||||
"PY\n"
|
|
||||||
f"echo | openssl s_client -connect {ip}:{port} -cert \"$D/c.pem\" -key \"$D/k.pem\" "
|
|
||||||
f"-CApath /etc/ssl/certs -verify_ip {ip} -verify_return_error -brief 2>&1 | head -20")
|
|
||||||
return _ssh(c.frontiere, "sudo -n sh -c " + shlex.quote(script))
|
|
||||||
|
|
||||||
|
|
||||||
def basculer_syslog(c: Contexte, services: dict) -> None:
|
|
||||||
dest = destinations_syslog(c)
|
|
||||||
if not dest:
|
|
||||||
print(" syslog : aucune destination vers le Loki du site — rien a basculer")
|
|
||||||
return
|
|
||||||
a_faire = [d for d in dest if d.get("transport") != "tls4" or d.get("certificate") != services["refid"]]
|
|
||||||
if not a_faire:
|
|
||||||
return
|
|
||||||
essai = poignee_tls_vers_loki(c, services)
|
|
||||||
if "Verification: OK" not in essai or "Protocol version: TLS" not in essai:
|
|
||||||
raise SystemExit("syslog : le recepteur n'accepte pas encore la poignee TLS — destination laissee "
|
|
||||||
f"telle quelle (deployer d'abord client_journal au site).\n{essai.strip()[:300]}")
|
|
||||||
for d in a_faire:
|
|
||||||
r = c.api(f"/api/syslog/settings/setDestination/{d['uuid']}",
|
|
||||||
{"destination": {"transport": "tls4", "certificate": services["refid"]}})
|
|
||||||
if r.get("result") != "saved":
|
|
||||||
raise SystemExit(f"syslog : destination refusee : {r}")
|
|
||||||
r = c.api("/api/syslog/service/reconfigure", {})
|
|
||||||
if r.get("status") not in ("ok", "OK"):
|
|
||||||
raise SystemExit(f"syslog : rechargement refuse : {r}")
|
|
||||||
print(f" syslog : {len(a_faire)} destination(s) en tls4, certificat de services (poignee verifiee)")
|
|
||||||
|
|
||||||
|
|
||||||
# --- USAGE : L'EXPORTATEUR DE METRIQUES EN TLS (F4) -----------------------------------
|
|
||||||
|
|
||||||
EXPO_DIR = "/usr/local/etc/node_exporter"
|
|
||||||
# OPNSENSE REECRIT `/etc/rc.conf.d/node_exporter` a chaque sauvegarde du greffon, et son
|
|
||||||
# greffon n'offre aucune option TLS. FreeBSD lit ENSUITE `/usr/local/etc/rc.conf.d/<nom>`
|
|
||||||
# (rc.subr : `for _d in /etc ${local_startup}`), qu'OPNsense ne touche pas : on y ajoute
|
|
||||||
# l'option aux arguments du greffon, au lieu de les remplacer. Mesure du 2026-10-12.
|
|
||||||
EXPO_RC = "/usr/local/etc/rc.conf.d/node_exporter"
|
|
||||||
EXPO_RC_CONTENU = (
|
|
||||||
"# Gere par Set-OPS (scripts/frontiere_pki.py). Lu APRES /etc/rc.conf.d/node_exporter,\n"
|
|
||||||
"# qu'OPNsense reecrit : ajoute le TLS aux arguments du greffon sans les remplacer.\n"
|
|
||||||
f'node_exporter_args="${{node_exporter_args}} --web.config.file={EXPO_DIR}/web-config.yml"\n')
|
|
||||||
EXPO_WEB = (
|
|
||||||
"# Gere par Set-OPS (scripts/frontiere_pki.py). TLS de l'exportateur (exporter-toolkit).\n"
|
|
||||||
"tls_server_config:\n"
|
|
||||||
f" cert_file: {EXPO_DIR}/tls/exportateur.crt\n"
|
|
||||||
f" key_file: {EXPO_DIR}/tls/exportateur.key\n")
|
|
||||||
|
|
||||||
|
|
||||||
def _ecoute_exportateur(c: Contexte) -> str:
|
|
||||||
sortie = _ssh(c.frontiere, "grep -h '^node_exporter_listen_address' /etc/rc.conf.d/node_exporter; true")
|
|
||||||
return sortie.split("=", 1)[-1].strip().strip('"') if "=" in sortie else ""
|
|
||||||
|
|
||||||
|
|
||||||
def poignee_exportateur(c: Contexte) -> str:
|
|
||||||
"""Le point de vue de Prometheus : HTTPS sur l'adresse d'ecoute, verifie par la racine."""
|
|
||||||
ecoute = _ecoute_exportateur(c)
|
|
||||||
if not ecoute:
|
|
||||||
return "aucune adresse d'ecoute"
|
|
||||||
ip = ecoute.rsplit(":", 1)[0]
|
|
||||||
return _ssh(c.frontiere, f"echo | openssl s_client -connect {shlex.quote(ecoute)} -CApath /etc/ssl/certs "
|
|
||||||
f"-verify_ip {shlex.quote(ip)} -verify_return_error -brief 2>&1 | head -20; true")
|
|
||||||
|
|
||||||
|
|
||||||
def exportateur_en_tls(c: Contexte) -> bool:
|
|
||||||
return "Verification: OK" in poignee_exportateur(c)
|
|
||||||
|
|
||||||
|
|
||||||
def poser_exportateur(c: Contexte, expo: dict, sous_ac: dict) -> None:
|
|
||||||
"""Ecrit certificat (+ autorite de la frontiere) et cle de l'exportateur, depuis config.xml,
|
|
||||||
SUR la boite. La cle de l'exportateur — et elle seule — est lisible par `nobody`."""
|
|
||||||
script = (
|
|
||||||
"set -e; umask 022; "
|
|
||||||
f"mkdir -p {EXPO_DIR}/tls /usr/local/etc/rc.conf.d; "
|
|
||||||
"/usr/local/bin/python3 - <<'PY'\n"
|
|
||||||
"import base64,os,xml.etree.ElementTree as E\n"
|
|
||||||
"c=E.parse('/conf/config.xml').getroot()\n"
|
|
||||||
"def trouve(tag,refid):\n"
|
|
||||||
" return next(n for n in c.iter(tag) if n.findtext('refid')==refid)\n"
|
|
||||||
f"f=trouve('cert',{expo['refid']!r}); a=trouve('ca',{sous_ac['refid']!r})\n"
|
|
||||||
"crt=base64.b64decode(f.findtext('crt'))+base64.b64decode(a.findtext('crt'))\n"
|
|
||||||
f"d={EXPO_DIR + '/tls'!r}\n"
|
|
||||||
"open(d+'/exportateur.crt','wb').write(crt); os.chmod(d+'/exportateur.crt',0o644)\n"
|
|
||||||
"k=d+'/exportateur.key'; fd=os.open(k,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o640)\n"
|
|
||||||
"os.write(fd,base64.b64decode(f.findtext('prv'))); os.close(fd); os.chown(k,0,65534); os.chmod(k,0o640)\n"
|
|
||||||
"PY\n"
|
|
||||||
f"printf %s {shlex.quote(EXPO_WEB)} > {EXPO_DIR}/web-config.yml; "
|
|
||||||
f"printf %s {shlex.quote(EXPO_RC_CONTENU)} > {EXPO_RC}; "
|
|
||||||
"/usr/local/etc/rc.d/node_exporter restart >/dev/null 2>&1; sleep 2")
|
|
||||||
_ssh(c.frontiere, "sudo -n sh -c " + shlex.quote(script))
|
|
||||||
essai = poignee_exportateur(c)
|
|
||||||
if "Verification: OK" not in essai:
|
|
||||||
raise SystemExit(f"exportateur : la poignee HTTPS ne se verifie pas apres la pose.\n{essai.strip()[:300]}")
|
|
||||||
print(f" exportateur : HTTPS sur {_ecoute_exportateur(c)}, certificat verifie par la racine du site")
|
|
||||||
|
|
||||||
|
|
||||||
# --- USAGE : L'INTERFACE WEB (F3) — CLASSE R3 ------------------------------------------
|
|
||||||
#
|
|
||||||
# Aucune API : la page d'administration ecrit `system/webgui/ssl-certref` puis redemarre
|
|
||||||
# lighttpd, qui porte AUSSI l'API. Un certificat que lighttpd refuserait couperait l'un et
|
|
||||||
# l'autre. Le chemin de secours est SSH (sshd ne depend pas de lighttpd) :
|
|
||||||
# sudo php -r '...ssl-certref = <ancien>...' puis configctl webgui restart
|
|
||||||
# `write_config` garde la version precedente dans /conf/backup. Geste separe d'`appliquer`,
|
|
||||||
# avec sa propre confirmation (AGENTS.md, R3).
|
|
||||||
|
|
||||||
def certref_interface(c: Contexte) -> str:
|
|
||||||
return _ssh(c.frontiere, "sudo -n /usr/local/bin/python3 -c \"import xml.etree.ElementTree as E;"
|
|
||||||
"print(E.parse('/conf/config.xml').getroot().findtext('system/webgui/ssl-certref') or '')\"").strip()
|
|
||||||
|
|
||||||
|
|
||||||
def poser_certref_interface(c: Contexte, refid: str) -> None:
|
|
||||||
php = ("require_once('config.inc'); require_once('util.inc'); global $config; "
|
|
||||||
f"$config['system']['webgui']['ssl-certref'] = '{refid}'; "
|
|
||||||
"write_config('Set-OPS : certificat de l interface web');")
|
|
||||||
_ssh(c.frontiere, "sudo -n /usr/local/bin/php -r " + shlex.quote(php)
|
|
||||||
+ " && sudo -n /usr/local/sbin/configctl webgui restart >/dev/null; sleep 4")
|
|
||||||
|
|
||||||
|
|
||||||
def interface_verifiee(c: Contexte) -> str:
|
|
||||||
"""Depuis le POSTE : la poignee HTTPS de l'interface, verifiee contre la racine du site."""
|
|
||||||
racine = devis_mod.depot_hebergeur() / "ac-racine-site.crt"
|
|
||||||
r = subprocess.run(["openssl", "s_client", "-connect", f"{c.f['admin']}:443", "-CAfile", str(racine),
|
|
||||||
"-verify_ip", c.f["admin"], "-verify_return_error", "-brief"],
|
|
||||||
input="", capture_output=True, text=True, timeout=20)
|
|
||||||
return r.stdout + r.stderr
|
|
||||||
|
|
||||||
|
|
||||||
def basculer_interface(c: Contexte) -> None:
|
|
||||||
e = etat(c)
|
|
||||||
if not e["services"]:
|
|
||||||
raise SystemExit("aucun certificat de services : lancer d'abord `appliquer`.")
|
|
||||||
avant = certref_interface(c)
|
|
||||||
if avant == e["services"]["refid"]:
|
|
||||||
print(" interface : deja servie par le certificat de services")
|
|
||||||
return
|
|
||||||
print(f" interface : ssl-certref {avant} -> {e['services']['refid']} (retour arriere : {avant})")
|
|
||||||
poser_certref_interface(c, e["services"]["refid"])
|
|
||||||
essai = interface_verifiee(c)
|
|
||||||
if "Verification: OK" not in essai:
|
|
||||||
print(" interface : la verification ECHOUE — retour arriere immediat")
|
|
||||||
poser_certref_interface(c, avant)
|
|
||||||
raise SystemExit(f"interface remise sur {avant}.\n{essai.strip()[:300]}")
|
|
||||||
print(f" interface : HTTPS sur {c.f['admin']}:443 verifie contre la racine du site")
|
|
||||||
|
|
||||||
|
|
||||||
def references(c: Contexte, refid: str) -> int:
|
|
||||||
"""Combien de fois `refid` apparait dans config.xml HORS de sa propre definition."""
|
|
||||||
n = _ssh(c.frontiere, f"sudo -n grep -c {shlex.quote(refid)} /conf/config.xml; true").strip()
|
|
||||||
return max(int(n or 0) - 1, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def regenerer(c: Contexte) -> None:
|
|
||||||
"""Retire l'autorite de la frontiere et ses certificats, pour les refaire a neuf.
|
|
||||||
|
|
||||||
QUAND : une cle a pu etre exposee. Premier usage, le 2026-10-12 : la liste par l'API
|
|
||||||
avait rapatrie les cles sur le poste avant que `lister` ne lise sur la frontiere.
|
|
||||||
REFUSE si l'un d'eux est employe ailleurs (interface, syslog...) : le retirer couperait
|
|
||||||
ce service — c'est alors un geste a part, avec son retour arriere.
|
|
||||||
"""
|
|
||||||
e = etat(c)
|
|
||||||
certs = [e[k] for k in ("services", "exportateur") if e[k]]
|
|
||||||
employes = [r["descr"] for r in certs if references(c, r["refid"]) > 0]
|
|
||||||
if employes:
|
|
||||||
raise SystemExit(f"refus : employe(s) ailleurs dans config.xml — {', '.join(employes)}")
|
|
||||||
|
|
||||||
def retirer(quoi: str, r: dict) -> None:
|
|
||||||
rep = c.api(f"/api/trust/{quoi}/del/{r['uuid']}", {})
|
|
||||||
if rep.get("result") != "deleted":
|
|
||||||
raise SystemExit(f"retrait de « {r['descr']} » refuse : {rep}")
|
|
||||||
print(f" retire : {r['descr']}")
|
|
||||||
|
|
||||||
for r in certs:
|
|
||||||
retirer("cert", r)
|
|
||||||
# L'AUTORITE ENSUITE, et seulement si plus rien ne la nomme : ses propres certificats
|
|
||||||
# viennent de partir, toute autre reference serait un usage qu'on ne connait pas.
|
|
||||||
if e["sous_ac"]:
|
|
||||||
if references(c, e["sous_ac"]["refid"]) > 0:
|
|
||||||
raise SystemExit("refus : l'autorite est encore nommee dans config.xml apres le retrait de ses certificats")
|
|
||||||
retirer("ca", e["sous_ac"])
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
action = sys.argv[1] if len(sys.argv) > 1 else "plan"
|
|
||||||
if action not in ("plan", "appliquer", "regenerer", "interface"):
|
|
||||||
raise SystemExit(__doc__)
|
|
||||||
c = Contexte()
|
|
||||||
if action == "interface":
|
|
||||||
print(f"Interface de {c.nom} : ssl-certref = {certref_interface(c)}")
|
|
||||||
if os.environ.get("CONFIRMER") != "true":
|
|
||||||
print("Geste R3 (l'interface ET l'API passent par lighttpd) : CONFIRMER=true pour basculer.")
|
|
||||||
return 0
|
|
||||||
basculer_interface(c)
|
|
||||||
return 0
|
|
||||||
if action == "regenerer":
|
|
||||||
if os.environ.get("CONFIRMER") != "true":
|
|
||||||
raise SystemExit("regenerer retire l'autorite de la frontiere : CONFIRMER=true.")
|
|
||||||
regenerer(c)
|
|
||||||
action = "appliquer"
|
|
||||||
e = etat(c)
|
|
||||||
if e["racine"] and not racine_dans_le_systeme(c):
|
|
||||||
e["a_faire"].append("racine_systeme")
|
|
||||||
if e["sous_ac"] and (not fichier_autorite(c).is_file()
|
|
||||||
or fichier_autorite(c).read_text(encoding="utf-8").strip() != e["sous_ac"]["crt"].strip()):
|
|
||||||
e["a_faire"].append("publier")
|
|
||||||
afficher(c, e)
|
|
||||||
if "racine_systeme" in e["a_faire"]:
|
|
||||||
print(" A FAIRE racine recopiee dans le magasin du systeme (syslog-ng en depend)")
|
|
||||||
if "publier" in e["a_faire"]:
|
|
||||||
print(f" A FAIRE publier l'autorite dans {fichier_autorite(c).name} (le recepteur syslog la lit)")
|
|
||||||
if e["exportateur"]:
|
|
||||||
ok = exportateur_en_tls(c)
|
|
||||||
print(f" {'ok' if ok else 'A FAIRE':9} exportateur en HTTPS ({_ecoute_exportateur(c)}), verifie par la racine")
|
|
||||||
if not ok:
|
|
||||||
e["a_faire"].append("exportateur_tls")
|
|
||||||
if e["services"]:
|
|
||||||
for d in destinations_syslog(c):
|
|
||||||
ok = d.get("transport") == "tls4" and d.get("certificate") == e["services"]["refid"]
|
|
||||||
print(f" {'ok' if ok else 'A FAIRE':9} syslog vers {d.get('hostname')}:{d.get('port')} "
|
|
||||||
f"en {d.get('transport')} — attendu tls4 + certificat de services")
|
|
||||||
if not ok:
|
|
||||||
e["a_faire"].append("syslog")
|
|
||||||
if not e["a_faire"]:
|
|
||||||
print("Rien a faire.")
|
|
||||||
if action == "plan" or not e["a_faire"]:
|
|
||||||
return 0
|
|
||||||
# L'ETAT EST RELU PLUS BAS (apres chaque pose) : ce qui ne se relit pas — la bascule du
|
|
||||||
# syslog — se retient ici. Premiere ecriture : relu, oublie, « Fait. » sans rien faire.
|
|
||||||
syslog_a_faire = "syslog" in e["a_faire"]
|
|
||||||
expo_a_faire = "exportateur_tls" in e["a_faire"] or "exportateur" in e["a_faire"]
|
|
||||||
if os.environ.get("CONFIRMER") != "true":
|
|
||||||
print("\nCONFIRMER=true pour poser ce qui manque.")
|
|
||||||
return 0
|
|
||||||
print()
|
|
||||||
if "racine" in e["a_faire"]:
|
|
||||||
poser_racine(c)
|
|
||||||
if "sous_ac" in e["a_faire"]:
|
|
||||||
poser_sous_ac(c)
|
|
||||||
if not racine_dans_le_systeme(c):
|
|
||||||
recopier_racine(c)
|
|
||||||
e = etat(c)
|
|
||||||
if not e["sous_ac"] or e["sous_ac"].get("caref") != (e["racine"] or {}).get("refid"):
|
|
||||||
raise SystemExit("l'autorite n'est pas rattachee a la racine dans le magasin — arret.")
|
|
||||||
if "services" in e["a_faire"]:
|
|
||||||
poser_certificat(c, c.desc_services, e["sous_ac"], "combined_server_client")
|
|
||||||
if "exportateur" in e["a_faire"]:
|
|
||||||
poser_certificat(c, c.desc_expo, e["sous_ac"], "server_cert")
|
|
||||||
publier_autorite(c, etat(c)["sous_ac"])
|
|
||||||
if syslog_a_faire:
|
|
||||||
basculer_syslog(c, etat(c)["services"])
|
|
||||||
if expo_a_faire:
|
|
||||||
e2 = etat(c)
|
|
||||||
poser_exportateur(c, e2["exportateur"], e2["sous_ac"])
|
|
||||||
print()
|
|
||||||
afficher(c, etat(c))
|
|
||||||
print("Fait.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
@ -28,13 +28,6 @@ DEUX GESTES, DEUX COTES :
|
||||||
genome_colis.py etat <depot>
|
genome_colis.py etat <depot>
|
||||||
Des deux cotes. Ce que cette copie porte, pour le confronter a la forge.
|
Des deux cotes. Ce que cette copie porte, pour le confronter a la forge.
|
||||||
|
|
||||||
LES ETIQUETTES VOYAGENT AVEC LA BRANCHE (2026-10-07). Le colis ne portait que `main` : les
|
|
||||||
releases n'arrivaient jamais sur la forge du site, qui fait pourtant autorite (D-81).
|
|
||||||
`v2026.08.21` et `v2026.10.07` y manquaient, et il a fallu les pousser a la main. Le colis
|
|
||||||
emporte desormais toutes les etiquettes du poste ; le runner les recoit et les pousse. Une
|
|
||||||
etiquette qui existe deja SOUS UN AUTRE OBJET est refusee, des deux cotes : une release ne
|
|
||||||
bouge pas.
|
|
||||||
|
|
||||||
`--ff-only` est le garde-fou. Si les deux histoires ont diverge, on s'arrete plutot que de
|
`--ff-only` est le garde-fou. Si les deux histoires ont diverge, on s'arrete plutot que de
|
||||||
fabriquer une fusion que personne n'a decidee — et le message de git nomme alors exactement
|
fabriquer une fusion que personne n'a decidee — et le message de git nomme alors exactement
|
||||||
ce qu'il faut reconcilier.
|
ce qu'il faut reconcilier.
|
||||||
|
|
@ -62,18 +55,13 @@ def _git(depot: Path, *args: str, env: dict | None = None) -> str:
|
||||||
return (r.stdout or "").strip()
|
return (r.stdout or "").strip()
|
||||||
|
|
||||||
|
|
||||||
def etiquettes(depot: Path) -> list[str]:
|
|
||||||
return sorted(e for e in _git(depot, "tag", "-l").splitlines() if e)
|
|
||||||
|
|
||||||
|
|
||||||
def emballer(depot: Path, colis: Path, branche: str) -> int:
|
def emballer(depot: Path, colis: Path, branche: str) -> int:
|
||||||
if not (depot / ".git").exists():
|
if not (depot / ".git").exists():
|
||||||
raise SystemExit(f"{depot} n'est pas un depot git — rien a emballer.")
|
raise SystemExit(f"{depot} n'est pas un depot git — rien a emballer.")
|
||||||
colis.parent.mkdir(parents=True, exist_ok=True)
|
colis.parent.mkdir(parents=True, exist_ok=True)
|
||||||
_git(depot, "bundle", "create", str(colis), branche, "--tags")
|
_git(depot, "bundle", "create", str(colis), branche)
|
||||||
print(json.dumps({"depot": str(depot), "colis": str(colis),
|
print(json.dumps({"depot": str(depot), "colis": str(colis),
|
||||||
"tete": _git(depot, "log", "-1", "--format=%H"),
|
"tete": _git(depot, "log", "-1", "--format=%H")}))
|
||||||
"etiquettes": etiquettes(depot)}))
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -113,10 +101,6 @@ def recevoir(depot: Path, colis: Path, pushurl: str, cle: str | None,
|
||||||
f"refs/heads/{branche}:refs/remotes/colis/{branche}")
|
f"refs/heads/{branche}:refs/remotes/colis/{branche}")
|
||||||
_git(depot, "merge", "--ff-only", f"refs/remotes/colis/{branche}")
|
_git(depot, "merge", "--ff-only", f"refs/remotes/colis/{branche}")
|
||||||
apres = _git(depot, "log", "-1", "--format=%H")
|
apres = _git(depot, "log", "-1", "--format=%H")
|
||||||
# Sans `--force` : une etiquette deja presente sous un autre objet fait echouer la
|
|
||||||
# recuperation (« would clobber existing tag ») — c'est voulu.
|
|
||||||
if any("refs/tags/" in l for l in _git(depot, "bundle", "list-heads", str(colis)).splitlines()):
|
|
||||||
_git(depot, "fetch", str(colis), "refs/tags/*:refs/tags/*")
|
|
||||||
|
|
||||||
env = None
|
env = None
|
||||||
if cle:
|
if cle:
|
||||||
|
|
@ -130,15 +114,14 @@ def recevoir(depot: Path, colis: Path, pushurl: str, cle: str | None,
|
||||||
# seule l'ECRITURE s'authentifie, et seulement le temps de cette commande.
|
# seule l'ECRITURE s'authentifie, et seulement le temps de cette commande.
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["git", "-C", str(depot), "-c", f"remote.origin.pushurl={pushurl}",
|
["git", "-C", str(depot), "-c", f"remote.origin.pushurl={pushurl}",
|
||||||
"push", "origin", branche, "refs/tags/*:refs/tags/*"],
|
"push", "origin", branche],
|
||||||
capture_output=True, text=True, env=env)
|
capture_output=True, text=True, env=env)
|
||||||
sortie = ((r.stdout or "") + (r.stderr or "")).strip()
|
sortie = ((r.stdout or "") + (r.stderr or "")).strip()
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
raise SystemExit(f"la poussee a echoue :\n {sortie}")
|
raise SystemExit(f"la poussee a echoue :\n {sortie}")
|
||||||
|
|
||||||
print(json.dumps({"avant": avant, "apres": apres,
|
print(json.dumps({"avant": avant, "apres": apres,
|
||||||
"pousse": f"{branche} -> {branche}" in sortie,
|
"pousse": f"{branche} -> {branche}" in sortie, "sortie": sortie[:400]}))
|
||||||
"etiquettes": etiquettes(depot), "sortie": sortie[:400]}))
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -225,15 +225,11 @@ def _intrants_communs(inventaire: Path | None) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def parametres_proxmox_hote(data: dict, host: str,
|
def parametres_proxmox_hote(data: dict, host: str,
|
||||||
inventaire: Path | None = None,
|
inventaire: Path | None = None) -> list[str]:
|
||||||
avec_cle_du_site: bool = True) -> list[str]:
|
|
||||||
"""Emet les parametres de clonage Proxmox d'un hote, lus depuis l'inventaire.
|
"""Emet les parametres de clonage Proxmox d'un hote, lus depuis l'inventaire.
|
||||||
|
|
||||||
Sortie shell-evaluable (`SETOPS_*='valeur'`) consommee par `make creer-vm`.
|
Sortie shell-evaluable (`SETOPS_*='valeur'`) consommee par `make creer-vm`.
|
||||||
L'inventaire genere est la seule source : aucun parametre n'est saisi a la main.
|
L'inventaire genere est la seule source : aucun parametre n'est saisi a la main.
|
||||||
|
|
||||||
`avec_cle_du_site=False` : ce que le LOCATAIRE publie dans sa face. La cle du runner du
|
|
||||||
site n'est pas a lui ; c'est le site qui l'ajoute en clonant (`contexte.clonage_rendu`).
|
|
||||||
"""
|
"""
|
||||||
if not groupes_hote(data, host):
|
if not groupes_hote(data, host):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
|
@ -289,7 +285,7 @@ def parametres_proxmox_hote(data: dict, host: str,
|
||||||
# La cle vient du PLAN DU SITE, pas du disque local : on materialise depuis le runner
|
# La cle vient du PLAN DU SITE, pas du disque local : on materialise depuis le runner
|
||||||
# comme depuis le poste de l'exploitant, et un `lookup` local rendrait deux valeurs
|
# comme depuis le poste de l'exploitant, et un `lookup` local rendrait deux valeurs
|
||||||
# differentes selon qui agit — la VM nee du poste n'accepterait pas le runner.
|
# differentes selon qui agit — la VM nee du poste n'accepterait pas le runner.
|
||||||
lignes.append(f"SETOPS_CLES_AMORCAGE='{_cle_amorcage(data, host, inventaire, avec_cle_du_site)}'")
|
lignes.append(f"SETOPS_CLES_AMORCAGE='{_cle_amorcage(data, host, inventaire)}'")
|
||||||
|
|
||||||
return lignes
|
return lignes
|
||||||
|
|
||||||
|
|
@ -297,24 +293,7 @@ def parametres_proxmox_hote(data: dict, host: str,
|
||||||
GROUPE_RUNNER_TENANT = "serveur_ops_tenant"
|
GROUPE_RUNNER_TENANT = "serveur_ops_tenant"
|
||||||
|
|
||||||
|
|
||||||
def cle_runner_du_site() -> str:
|
def _cle_amorcage(data: dict, host: str, inventaire: Path | None = None) -> str:
|
||||||
"""La cle publique du runner du SITE MONTE (`runner_cle_publique`), ou ''.
|
|
||||||
|
|
||||||
LE SITE QUI CLONE POSE SA PROPRE CLE (2026-10-11). Elle transitait par la face du
|
|
||||||
locataire : lue ici dans le plan du site, publiee dans la face, relue par le site. Le
|
|
||||||
runner du locataire, qui n'a pas ce plan, calculait donc une face sans elle — et la
|
|
||||||
republier de la-bas l'aurait retiree. Elle ne quitte plus le site.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
import underlay as underlay_mod
|
|
||||||
return str((underlay_mod.lire_plan_site("10-intrants.yml") or {})
|
|
||||||
.get("runner_cle_publique") or "").strip()
|
|
||||||
except Exception:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _cle_amorcage(data: dict, host: str, inventaire: Path | None = None,
|
|
||||||
avec_cle_du_site: bool = True) -> str:
|
|
||||||
"""Les cles publiques a poser sur cet hote A SA NAISSANCE. Une par ligne.
|
"""Les cles publiques a poser sur cet hote A SA NAISSANCE. Une par ligne.
|
||||||
|
|
||||||
DEUX CLES, DEUX PORTEES, ET LA DIFFERENCE EST TOUTE L'ARCHITECTURE :
|
DEUX CLES, DEUX PORTEES, ET LA DIFFERENCE EST TOUTE L'ARCHITECTURE :
|
||||||
|
|
@ -344,12 +323,16 @@ def _cle_amorcage(data: dict, host: str, inventaire: Path | None = None,
|
||||||
"""
|
"""
|
||||||
cles: list[str] = []
|
cles: list[str] = []
|
||||||
|
|
||||||
# 1. LA CLE DU SITE — le runner du tenant, et lui seul. Absente de ce que le locataire
|
# 1. LA CLE DU SITE — le runner du tenant, et lui seul.
|
||||||
# publie (`avec_cle_du_site=False`) : le site l'ajoute lui-meme en clonant.
|
if GROUPE_RUNNER_TENANT in groupes_hote(data, host):
|
||||||
if avec_cle_du_site and GROUPE_RUNNER_TENANT in groupes_hote(data, host):
|
try:
|
||||||
c = cle_runner_du_site()
|
import underlay as underlay_mod
|
||||||
|
c = str((underlay_mod.lire_plan_site("10-intrants.yml") or {})
|
||||||
|
.get("runner_cle_publique") or "").strip()
|
||||||
if c:
|
if c:
|
||||||
cles.append(c)
|
cles.append(c)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# 2. LA CLE DU TENANT — toutes ses machines, y compris son runner (il se joint
|
# 2. LA CLE DU TENANT — toutes ses machines, y compris son runner (il se joint
|
||||||
# lui-meme comme les autres, et l'exclure creerait un cas particulier de plus).
|
# lui-meme comme les autres, et l'exclure creerait un cas particulier de plus).
|
||||||
|
|
|
||||||
|
|
@ -3882,37 +3882,12 @@ def preuve_depot_binaires_complet() -> tuple[bool, str]:
|
||||||
if not tenus:
|
if not tenus:
|
||||||
return False, "Le depot de binaires ne declare aucun artefact (serveur_artefacts_directs vide)."
|
return False, "Le depot de binaires ne declare aucun artefact (serveur_artefacts_directs vide)."
|
||||||
|
|
||||||
# LES LISTES D'ARTEFACTS (2026-10-08). Les applications de Nextcloud sont declarees en
|
|
||||||
# LISTE par leur role (`serveur_nextcloud_apps`) ; leur `dest` est donc un modele
|
|
||||||
# (`{{ serveur_nextcloud_app.nom }}`), et le site les tient en parcourant cette liste.
|
|
||||||
# Un modele attend CHACUN des noms de sa liste ; ces noms ne sont tenus que si la tache
|
|
||||||
# du site parcourt bien la liste — sans quoi le depot ne les tiendrait pas.
|
|
||||||
import yaml as _yaml
|
|
||||||
listes = {"serveur_nextcloud_app.nom": ("serveur_nextcloud", "serveur_nextcloud_apps")}
|
|
||||||
taches_site = (roles_dir / "serveur_artefacts" / "tasks" / "main.yml").read_text(encoding="utf-8")
|
|
||||||
noms_de: dict[str, list[str]] = {}
|
|
||||||
for modele, (role_liste, liste) in listes.items():
|
|
||||||
d = _yaml.safe_load((roles_dir / role_liste / "defaults" / "main.yml").read_text(encoding="utf-8")) or {}
|
|
||||||
noms_de[modele] = [str(a["nom"]) for a in d.get(liste) or []]
|
|
||||||
if liste in taches_site:
|
|
||||||
tenus |= set(noms_de[modele])
|
|
||||||
|
|
||||||
attendus: dict[str, str] = {}
|
attendus: dict[str, str] = {}
|
||||||
# LE VERROU DU RUNNER (2026-10-09) : ses roues et ses collections, que `serveur_ops` va
|
|
||||||
# chercher. Tenues seulement si le site lit ce verrou.
|
|
||||||
verrou = _yaml.safe_load((RACINE / "verrou-runner.yml").read_text(encoding="utf-8")) or {}
|
|
||||||
noms_verrou = [str(e["nom"]) for k in ("python", "collections") for e in verrou.get(k) or []]
|
|
||||||
for nom in noms_verrou:
|
|
||||||
attendus[nom] = "serveur_ops (verrou-runner.yml)"
|
|
||||||
if "verrou-runner.yml" in taches_site:
|
|
||||||
tenus |= set(noms_verrou)
|
|
||||||
for role in sorted(p.name for p in roles_dir.iterdir() if p.is_dir()):
|
for role in sorted(p.name for p in roles_dir.iterdir() if p.is_dir()):
|
||||||
for tf in sorted((roles_dir / role / "tasks").glob("*.yml")) if (roles_dir / role / "tasks").is_dir() else []:
|
for tf in sorted((roles_dir / role / "tasks").glob("*.yml")) if (roles_dir / role / "tasks").is_dir() else []:
|
||||||
texte = tf.read_text(encoding="utf-8")
|
texte = tf.read_text(encoding="utf-8")
|
||||||
for dest in _re.findall(r'^\s*dest:\s*"\{\{\s*\w+_cache_local\s*\}\}/([^"]+)"', texte, _re.M):
|
for dest in _re.findall(r'^\s*dest:\s*"\{\{\s*\w+_cache_local\s*\}\}/([^"]+)"', texte, _re.M):
|
||||||
m = _re.fullmatch(r'\{\{\s*([\w.]+)\s*\}\}', dest)
|
attendus[dest] = f"{role}/tasks/{tf.name}"
|
||||||
for nom in (noms_de.get(m.group(1), [dest]) if m else [dest]):
|
|
||||||
attendus[nom] = f"{role}/tasks/{tf.name}"
|
|
||||||
|
|
||||||
manquants = sorted(f"{nom} ({ou})" for nom, ou in attendus.items() if nom not in tenus)
|
manquants = sorted(f"{nom} ({ou})" for nom, ou in attendus.items() if nom not in tenus)
|
||||||
if manquants:
|
if manquants:
|
||||||
|
|
@ -4920,47 +4895,6 @@ def preuve_clonage_publie() -> tuple[bool, str]:
|
||||||
return True, f"{n} machine(s) : la face publie exactement les parametres de clonage de l'inventaire."
|
return True, f"{n} machine(s) : la face publie exactement les parametres de clonage de l'inventaire."
|
||||||
|
|
||||||
|
|
||||||
def preuve_cle_lecture_genome() -> tuple[bool, str]:
|
|
||||||
"""Chaque locataire publie-t-il son depot du genome et la cle de son runner qui le lira —
|
|
||||||
et cette cle est-elle l'une de celles que sa flotte connait ?
|
|
||||||
|
|
||||||
POURQUOI (2026-10-11, `docs/depots-prives-conception.md`). Les depots des locataires
|
|
||||||
deviennent prives sur la forge du site : chaque runner lit le sien avec une cle de
|
|
||||||
deploiement en lecture seule, que le site enregistre d'apres la face. Une cle absente
|
|
||||||
laisserait le runner sans genome apres l'insemination ; une cle hors de
|
|
||||||
`ssh_baseline_cles_admin` serait une identite que l'armement ne remet pas — la forge
|
|
||||||
attendrait une cle que le runner n'a jamais.
|
|
||||||
"""
|
|
||||||
sys.path.insert(0, str(RACINE / "scripts"))
|
|
||||||
import contexte as _ctx
|
|
||||||
locataires = sorted({l for _s, l in _ctx.couples_hebergeur()}, key=lambda l: l.nom)
|
|
||||||
if not locataires:
|
|
||||||
return True, "Aucun locataire frere : aucune cle de lecture a confronter."
|
|
||||||
fautes = []
|
|
||||||
for l in locataires:
|
|
||||||
g = l.genome_publie()
|
|
||||||
if not g["depot"]:
|
|
||||||
fautes.append(f"{l.nom} : aucun depot `role: instance` dans `serveur_ops_depots`")
|
|
||||||
if not g["cle_lecture"]:
|
|
||||||
fautes.append(f"{l.nom} : `serveur_ops_tenant_cle_publique` absente")
|
|
||||||
continue
|
|
||||||
gv = l.dossier_inventaire() / "group_vars" / "all"
|
|
||||||
connues = set()
|
|
||||||
for f in sorted(gv.glob("*.yml")) if gv.is_dir() else []:
|
|
||||||
if "vault" in f.name:
|
|
||||||
continue
|
|
||||||
d = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
|
|
||||||
for e in (d.get("ssh_baseline_cles_admin") or []) if isinstance(d, dict) else []:
|
|
||||||
if isinstance(e, dict) and str(e.get("etat", "present")) == "present":
|
|
||||||
connues.add(" ".join(str(e.get("cle", "")).split()[:2]))
|
|
||||||
if g["cle_lecture"] not in connues:
|
|
||||||
fautes.append(f"{l.nom} : la cle de lecture n'est pas dans `ssh_baseline_cles_admin`")
|
|
||||||
if fautes:
|
|
||||||
return False, f"{len(fautes)} ecart(s) : " + " | ".join(fautes[:4])
|
|
||||||
return True, (f"{len(locataires)} locataire(s) : depot et cle de lecture publies, cle connue "
|
|
||||||
f"de leur flotte.")
|
|
||||||
|
|
||||||
|
|
||||||
def preuve_materialisation_par_la_face() -> tuple[bool, str]:
|
def preuve_materialisation_par_la_face() -> tuple[bool, str]:
|
||||||
"""Les commandes du site qui nomment leur locataire tirent-elles de sa face les memes
|
"""Les commandes du site qui nomment leur locataire tirent-elles de sa face les memes
|
||||||
intrants que l'ancien chemin ?
|
intrants que l'ancien chemin ?
|
||||||
|
|
@ -5191,8 +5125,6 @@ PREUVES: list[dict] = [
|
||||||
"refs": [], "func": preuve_clonage_publie},
|
"refs": [], "func": preuve_clonage_publie},
|
||||||
{"id": "P94", "titre": "Creer, raser et placer un locataire par sa face visent les memes machines",
|
{"id": "P94", "titre": "Creer, raser et placer un locataire par sa face visent les memes machines",
|
||||||
"refs": [], "func": preuve_materialisation_par_la_face},
|
"refs": [], "func": preuve_materialisation_par_la_face},
|
||||||
{"id": "P95", "titre": "Chaque locataire publie la cle qui lira son depot du genome, et sa flotte la connait",
|
|
||||||
"refs": [], "func": preuve_cle_lecture_genome},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,9 @@ liste que `genome-pousser` — lue, pas recopiee) :
|
||||||
demande de fusion acceptee, par exemple) : publier sans les integrer les ecraserait
|
demande de fusion acceptee, par exemple) : publier sans les integrer les ecraserait
|
||||||
sur la forge du site ;
|
sur la forge du site ;
|
||||||
2. signaler ce qui n'est pas committe — cela ne part pas ;
|
2. signaler ce qui n'est pas committe — cela ne part pas ;
|
||||||
3. pousser sur eregion, la branche ET les etiquettes (2026-10-07 : les releases n'allaient
|
3. pousser sur eregion ;
|
||||||
nulle part d'elles-memes ; une etiquette deplacee est refusee, une release ne bouge pas) ;
|
puis `make genome-pousser`, puis `make genome-etat` : la forge du site doit porter la meme
|
||||||
puis `make genome-pousser`, qui porte aussi les etiquettes et verifie que la forge les a,
|
tete que le poste, sinon on le dit.
|
||||||
puis `make genome-etat` : la forge du site doit porter la meme tete que le poste, sinon on
|
|
||||||
le dit.
|
|
||||||
|
|
||||||
make publier tous les depots du genome
|
make publier tous les depots du genome
|
||||||
make publier DEPOT=ops-chezlepro
|
make publier DEPOT=ops-chezlepro
|
||||||
|
|
@ -83,7 +81,7 @@ def main() -> int:
|
||||||
if sale:
|
if sale:
|
||||||
print(f" ~ {d['depot']:<17} {len(sale)} changement(s) NON committe(s) — ils ne partent pas")
|
print(f" ~ {d['depot']:<17} {len(sale)} changement(s) NON committe(s) — ils ne partent pas")
|
||||||
avance = git(depot, "rev-list", "--count", f"{distant}..HEAD").stdout.strip() or "?"
|
avance = git(depot, "rev-list", "--count", f"{distant}..HEAD").stdout.strip() or "?"
|
||||||
r = git(depot, "push", "-q", "eregion", branche, "refs/tags/*:refs/tags/*")
|
r = git(depot, "push", "-q", "eregion", branche)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
refus.append(d["depot"])
|
refus.append(d["depot"])
|
||||||
print(f" !! {d['depot']:<17} push refuse : {r.stderr.strip()[:120]}")
|
print(f" !! {d['depot']:<17} push refuse : {r.stderr.strip()[:120]}")
|
||||||
|
|
|
||||||
|
|
@ -77,14 +77,9 @@ CHIFFREMENT = {"tls-requis", "tls", "starttls", "ssh", "tls-cible", "clair", "n-
|
||||||
# POURQUOI PAS LE NOM DU ROLE. `serveur_powerdns` existe AUSSI au site (`site-dns-01`) : une
|
# POURQUOI PAS LE NOM DU ROLE. `serveur_powerdns` existe AUSSI au site (`site-dns-01`) : une
|
||||||
# sortie declaree « vers serveur_powerdns » aurait vise le PowerDNS du site lui-meme, et
|
# sortie declaree « vers serveur_powerdns » aurait vise le PowerDNS du site lui-meme, et
|
||||||
# jamais ceux des locataires. Le mot ne peut designer que les bonnes machines.
|
# jamais ceux des locataires. Le mot ne peut designer que les bonnes machines.
|
||||||
# `runners_locataires` — LES RUNNERS DES LOCATAIRES QUE CE SITE HEBERGE (2026-10-11), vus du
|
|
||||||
# site. Chacun lit SON depot du genome sur la forge du site, en SSH, avec une cle de
|
|
||||||
# deploiement en lecture seule (`docs/depots-prives-conception.md`). Des HOTES, lus dans les
|
|
||||||
# faces publiees — jamais les supernets : ouvrir le SSH de la forge a toutes les machines d'un
|
|
||||||
# locataire en ferait un droit d'entree general.
|
|
||||||
MOTS_PAIR = {"edge", "flotte", "externe", "localhost", "expositions", "derive", "admin",
|
MOTS_PAIR = {"edge", "flotte", "externe", "localhost", "expositions", "derive", "admin",
|
||||||
"voisins_site", "fabric", "frontiere", "runner_site",
|
"voisins_site", "fabric", "frontiere", "runner_site",
|
||||||
"dns_public_site", "primaires_dns_locataires", "runners_locataires"}
|
"dns_public_site", "primaires_dns_locataires"}
|
||||||
GROUPE_EDGE = "serveur_nginx"
|
GROUPE_EDGE = "serveur_nginx"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -92,28 +87,6 @@ class ErreurFlux(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def libelle_flux(role: str, raison: str) -> str:
|
|
||||||
"""Le role et sa justification, sur une ligne lisible par les trois pare-feux."""
|
|
||||||
texte = f"{role}: {raison}"
|
|
||||||
return " ".join("".join(c if c.isprintable() else " " for c in texte).split())
|
|
||||||
|
|
||||||
|
|
||||||
def borner_libelle(texte: str, octets: int) -> str:
|
|
||||||
"""Borne un commentaire UTF-8 sans couper un caractere ; la raison reste au registre."""
|
|
||||||
contenu = texte.encode("utf-8")
|
|
||||||
if len(contenu) <= octets:
|
|
||||||
return texte
|
|
||||||
if octets < 4:
|
|
||||||
raise ErreurFlux("Identifiant trop long : aucune place pour la description du flux.")
|
|
||||||
return contenu[:octets - 3].decode("utf-8", errors="ignore").rstrip() + "..."
|
|
||||||
|
|
||||||
|
|
||||||
def commentaire_nft(role: str, raison: str) -> str:
|
|
||||||
"""Un commentaire NATIF (conserve par nft), avec guillemets et UTF-8 proteges."""
|
|
||||||
texte = borner_libelle(libelle_flux(role, raison), 127)
|
|
||||||
return "comment " + json.dumps(texte, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
def charger_flux() -> dict[str, list[dict]]:
|
def charger_flux() -> dict[str, list[dict]]:
|
||||||
"""{role: [flux, ...]} pour chaque role portant un meta/flux.yml."""
|
"""{role: [flux, ...]} pour chaque role portant un meta/flux.yml."""
|
||||||
resultat: dict[str, list[dict]] = {}
|
resultat: dict[str, list[dict]] = {}
|
||||||
|
|
@ -209,8 +182,6 @@ def valider(flux_par_role: dict[str, list[dict]]) -> tuple[int, int]:
|
||||||
for cle in ("sens", "port", "protocole", "pair", "chiffrement", "raison"):
|
for cle in ("sens", "port", "protocole", "pair", "chiffrement", "raison"):
|
||||||
if cle not in fl:
|
if cle not in fl:
|
||||||
erreurs.append(f"{ref} : cle manquante {cle}")
|
erreurs.append(f"{ref} : cle manquante {cle}")
|
||||||
if not isinstance(fl.get("raison"), str) or not fl["raison"].strip():
|
|
||||||
erreurs.append(f"{ref} : raison attendue (texte non vide pour le libelle du pare-feu)")
|
|
||||||
if fl.get("sens") not in SENS:
|
if fl.get("sens") not in SENS:
|
||||||
erreurs.append(f"{ref} : sens invalide {fl.get('sens')!r}")
|
erreurs.append(f"{ref} : sens invalide {fl.get('sens')!r}")
|
||||||
if fl.get("protocole") not in PROTO:
|
if fl.get("protocole") not in PROTO:
|
||||||
|
|
@ -405,7 +376,7 @@ def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict
|
||||||
if p in ("localhost", "externe", "expositions", "derive"):
|
if p in ("localhost", "externe", "expositions", "derive"):
|
||||||
continue # lo (auto), frontiere OPNsense, ou hors perimetre noeud
|
continue # lo (auto), frontiere OPNsense, ou hors perimetre noeud
|
||||||
if p in ("admin", "voisins_site", "runner_site", "dns_public_site",
|
if p in ("admin", "voisins_site", "runner_site", "dns_public_site",
|
||||||
"primaires_dns_locataires", "runners_locataires"):
|
"primaires_dns_locataires"):
|
||||||
# Aucun des trois n'est un groupe d'hotes DE CET ECOSYSTEME : ce sont des
|
# Aucun des trois n'est un groupe d'hotes DE CET ECOSYSTEME : ce sont des
|
||||||
# sources qui lui sont EXTERIEURES. `admin` vient de l'intrant
|
# sources qui lui sont EXTERIEURES. `admin` vient de l'intrant
|
||||||
# `nftables_admin_ssh` ; `voisins_site` des supernets des autres tenants
|
# `nftables_admin_ssh` ; `voisins_site` des supernets des autres tenants
|
||||||
|
|
@ -480,27 +451,9 @@ def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict
|
||||||
srcs |= set(_adresses_runner_site())
|
srcs |= set(_adresses_runner_site())
|
||||||
if "dns_public_site" in demandes:
|
if "dns_public_site" in demandes:
|
||||||
srcs |= set(_adresses_dns_public_site())
|
srcs |= set(_adresses_dns_public_site())
|
||||||
if "runners_locataires" in demandes and site:
|
|
||||||
srcs |= set(_adresses_runners_locataires())
|
|
||||||
return sorted(srcs)
|
return sorted(srcs)
|
||||||
|
|
||||||
|
|
||||||
def _adresses_runners_locataires() -> list[str]:
|
|
||||||
"""Les runners des locataires que CE site heberge, lus dans leurs faces publiees.
|
|
||||||
|
|
||||||
La MEME lecture que la frontiere (`devis_opnsense.cibles_du_tenant`), sur la MEME liste
|
|
||||||
de locataires (`decouvrir_du_site`) : le pare-feu de la forge et celui de la bordure ne
|
|
||||||
doivent pas pouvoir nommer deux ensembles differents. Rend [] sans site monte.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
import devis_opnsense
|
|
||||||
import devis_reseau
|
|
||||||
return sorted({ip for nom, _p, _n in devis_reseau.decouvrir_du_site()
|
|
||||||
for ip in devis_opnsense.cibles_du_tenant("serveur_ops_tenant", nom)})
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _adresses_runner_site() -> list[str]:
|
def _adresses_runner_site() -> list[str]:
|
||||||
"""L'adresse du runner du SITE — la source du flux d'insemination.
|
"""L'adresse du runner du SITE — la source du flux d'insemination.
|
||||||
|
|
||||||
|
|
@ -811,8 +764,7 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) -
|
||||||
ip_par_hote = _ip_par_hote(data)
|
ip_par_hote = _ip_par_hote(data)
|
||||||
ports_plan = _ports_du_plan_site() if site else _ports_du_plan()
|
ports_plan = _ports_du_plan_site() if site else _ports_du_plan()
|
||||||
regles_admin = [
|
regles_admin = [
|
||||||
f" ip saddr {{ {cidr} }} tcp dport 22 accept "
|
f" ip saddr {{ {cidr} }} tcp dport 22 accept # administration (garde anti-lockout, intrant nftables_admin_ssh)"
|
||||||
+ commentaire_nft("administration", "Garde anti-lockout, intrant nftables_admin_ssh")
|
|
||||||
for cidr in admin_ssh
|
for cidr in admin_ssh
|
||||||
]
|
]
|
||||||
if site:
|
if site:
|
||||||
|
|
@ -926,7 +878,7 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) -
|
||||||
if local and not srcs:
|
if local and not srcs:
|
||||||
continue # lo deja accepte
|
continue # lo deja accepte
|
||||||
regles.append(f" {saddr}{_selecteur_nft(fl['protocole'], port)} accept"
|
regles.append(f" {saddr}{_selecteur_nft(fl['protocole'], port)} accept"
|
||||||
f" {commentaire_nft(g, fl['raison'])}")
|
f" # {g}: {fl.get('raison','')}")
|
||||||
entree = {"proto": proto, "port": str(port), "role": g,
|
entree = {"proto": proto, "port": str(port), "role": g,
|
||||||
"sources": list(srcs) if saddr else ["0.0.0.0/0"]}
|
"sources": list(srcs) if saddr else ["0.0.0.0/0"]}
|
||||||
# « TOUT LE MONDE » NE DOIT PAS EFFACER QUI ETAIT NOMME (2026-10-04). L'hote
|
# « TOUT LE MONDE » NE DOIT PAS EFFACER QUI ETAIT NOMME (2026-10-04). L'hote
|
||||||
|
|
@ -1011,11 +963,11 @@ def _rendre_nft(hote: str, regles: list[str]) -> str:
|
||||||
f"table inet setops_flux {{\n"
|
f"table inet setops_flux {{\n"
|
||||||
f" chain input {{\n"
|
f" chain input {{\n"
|
||||||
f" type filter hook input priority 0; policy drop;\n\n"
|
f" type filter hook input priority 0; policy drop;\n\n"
|
||||||
f" iif \"lo\" accept comment \"socle: communications locales\"\n"
|
f" iif \"lo\" accept\n"
|
||||||
f" ct state established,related accept comment \"socle: connexions etablies et associees\"\n"
|
f" ct state established,related accept\n"
|
||||||
f" ct state invalid drop comment \"socle: paquets invalides\"\n"
|
f" ct state invalid drop\n"
|
||||||
f" ip protocol icmp accept comment \"socle: diagnostic et erreurs reseau IPv4\"\n"
|
f" ip protocol icmp accept\n"
|
||||||
f" ip6 nexthdr icmpv6 accept comment \"socle: diagnostic et erreurs reseau IPv6\"\n\n"
|
f" ip6 nexthdr icmpv6 accept\n\n"
|
||||||
f"{corps}\n\n"
|
f"{corps}\n\n"
|
||||||
f" # L'INTERNE PARLE, LA BORDURE SE TAIT (2026-08-28).\n"
|
f" # L'INTERNE PARLE, LA BORDURE SE TAIT (2026-08-28).\n"
|
||||||
f" #\n"
|
f" #\n"
|
||||||
|
|
@ -1040,7 +992,7 @@ def _rendre_nft(hote: str, regles: list[str]) -> str:
|
||||||
f" # moitie du choix : 982 000 entrees par jour, dont 82 %% un balayage\n"
|
f" # moitie du choix : 982 000 entrees par jour, dont 82 %% un balayage\n"
|
||||||
f" # Internet contre le port VNC. Repondre a chacune serait un vecteur\n"
|
f" # Internet contre le port VNC. Repondre a chacune serait un vecteur\n"
|
||||||
f" # d'amplification, source usurpee comprise.\n"
|
f" # d'amplification, source usurpee comprise.\n"
|
||||||
f" reject with icmpx type admin-prohibited comment \"socle: refus des entrees non autorisees\"\n"
|
f" reject with icmpx type admin-prohibited\n"
|
||||||
f" }}\n\n"
|
f" }}\n\n"
|
||||||
f" # LA CHAINE `forward` RESTE MUETTE, et ce n'est pas un oubli : elle porte le\n"
|
f" # LA CHAINE `forward` RESTE MUETTE, et ce n'est pas un oubli : elle porte le\n"
|
||||||
f" # trafic qui TRAVERSE cet hote, pas celui qui lui est destine. Y repondre\n"
|
f" # trafic qui TRAVERSE cet hote, pas celui qui lui est destine. Y repondre\n"
|
||||||
|
|
@ -1049,9 +1001,9 @@ def _rendre_nft(hote: str, regles: list[str]) -> str:
|
||||||
f" # defaut qu'on corrige dans `input`.\n"
|
f" # defaut qu'on corrige dans `input`.\n"
|
||||||
f" chain forward {{\n"
|
f" chain forward {{\n"
|
||||||
f" type filter hook forward priority 0; policy drop;\n"
|
f" type filter hook forward priority 0; policy drop;\n"
|
||||||
f" ct state established,related accept comment \"transit: connexions etablies et associees\"\n"
|
f" ct state established,related accept\n"
|
||||||
f" iifname \"docker0\" accept comment \"transit: trafic depuis le pont docker0\"\n"
|
f" iifname \"docker0\" accept\n"
|
||||||
f" oifname \"docker0\" accept comment \"transit: trafic vers le pont docker0\"\n"
|
f" oifname \"docker0\" accept\n"
|
||||||
f" }}\n"
|
f" }}\n"
|
||||||
f" chain output {{ type filter hook output priority 0; policy accept; }}\n"
|
f" chain output {{ type filter hook output priority 0; policy accept; }}\n"
|
||||||
f"}}\n"
|
f"}}\n"
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue