Author SHA1 Message Date
arcodange d2202414c0 Merge pull request 'fix(redis) — il mourait toutes les 90 s, et c'est le .fr qui tombait' (#28) from arcodange/redis-sonde into main
Helm Charts / Detect changed charts (push) Successful in 1m15s
Helm Charts / Library charts tool (push) Has been skipped
Helm Charts / Application charts pgcat (push) Has been skipped
Reviewed-on: #28
2026-07-29 20:23:45 +02:00
arcodange f9adc2ec23 Merge pull request 'fix(pgbouncer,vault) — ce qu'un client laisse sur une connexion, le suivant n'en hérite plus' (#25) from arcodange/fuite-set-role into main
Helm Charts / Detect changed charts (push) Successful in 1m12s
Helm Charts / Library charts tool (push) Has been skipped
Helm Charts / Application charts pgcat (push) Has been skipped
Reviewed-on: #25
2026-07-29 20:22:10 +02:00
arcodange 49763852d8 Merge pull request 'doc(réseau) — le confort LAN ne se règle pas dans Traefik : la mesure, les deux pièges, la voie retenue' (#24) from arcodange/confort-lan into main
Helm Charts / Detect changed charts (push) Successful in 1m8s
Helm Charts / Library charts tool (push) Has been skipped
Helm Charts / Application charts pgcat (push) Has been skipped
Reviewed-on: #24
2026-07-29 18:36:05 +02:00
arcodangeandClaude Opus 5 f53c3adb7d fix(redis) — il mourait toutes les 90 s, et c'est le .fr qui tombait
Helm Charts / Detect changed charts (pull_request) Successful in 1m14s
Helm Charts / Application charts pgcat (pull_request) Has been skipped
Helm Charts / Library charts tool (pull_request) Has been skipped
Symptôme observé : `kadans.arcodange.fr` et `gitea.arcodange.fr` en 403, corps
vide, depuis n'importe quel client extérieur — pendant que `arcodange.fr` et
`www` répondaient 200. Ça ressemble à un bannissement d'IP. Ça n'en était pas un.

La chaîne, remontée de bout en bout :

1. Le sous-chart fige ses sondes à `timeoutSeconds: 1` sur `redis-cli ping` et
   n'expose aucune valeur pour les surcharger.
2. Sans réservation de ressources, le conteneur est en QoS `BestEffort` : sur un
   Raspberry Pi chargé, lancer `redis-cli` dépasse la seconde.
3. « Liveness probe failed: command timed out after 1s » 836 fois en 23 jours,
   135 redémarrages, SIGTERM après ~90 s de vie à chaque tour.
4. Le plugin crowdsec de Traefik utilise ce Redis comme cache de décisions.
   Cache injoignable ⇒ `isCrowdsecStreamHealthy:false` ⇒ refus par défaut de tout
   client absent de `clientTrustedIPs` ⇒ le 403.

D'où le motif trompeur : seuls les hôtes portant le middleware crowdsec
tombaient, et depuis le LAN (IP de confiance) tout paraissait sain — l'origine
répondait 401, son défi d'authentification normal. Trois fausses pistes en sont
sorties : Cloudflare, un bannissement crowdsec, une règle de pare-feu. Aucune
n'était la bonne, et `cscli decisions list` disait `isBanned:false` du début à la
fin.

La réservation fait passer le conteneur en `Burstable` et lui garantit sa part.
Les limites restent larges : Redis n'est pas ce qui sature ce nœud.

⚠ Ce commit ne corrige PAS la sonde elle-même — elle reste à 1 s, et elle reste
inatteignable depuis les valeurs. Si le clignotement revient malgré la
réservation, il faudra un patch Kustomize au niveau de l'Application ArgoCD, ou
abandonner ce sous-chart. Une vérification en production a été appliquée à chaud
(`timeoutSeconds: 5`, `failureThreshold: 6`) pour rétablir l'accès immédiatement,
mais `selfHeal: true` la ré-écrasera : elle n'est pas la correction, ce commit
l'est.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GdUCA5Uz8QyMwa2P4Pg2hK
2026-07-29 01:56:58 +02:00
arcodangeandClaude Opus 5 7d13a8d764 doc(pgbouncer) — la version mesurée n'était pas la déployée, et DISCARD ALL a un prix
Helm Charts / Detect changed charts (pull_request) Successful in 28s
Helm Charts / Library charts tool (pull_request) Has been skipped
Helm Charts / Application charts pgcat (pull_request) Has been skipped
Trois corrections au raisonnement, toutes issues d'une contre-vérification qui
a refait les mesures au lieu de les relire.

1. La mesure invoquait pgbouncer 1.25.2 « la version en prod ». Le pod tourne
   1.23.1 (ghcr.io/icoretech/pgbouncer-docker:1.23.1-fixed, chart 2.3.1, up
   133 j). Tout a été rejoué sur 1.23.1 avec la ConfigMap extraite du cluster :
   les conclusions tiennent, mais la preuve invoquée portait sur autre chose.

2. La portée de la fuite était laissée en suspens, donc lue au pire. Les pools
   sont partitionnés par (base, utilisateur) — 210 pools, aucun mélange. Le
   seul héritier possible du SET ROLE de kadans-api est kadans-api. Défaut
   d'hygiène, pas brèche inter-applications. Le dire baisse la gravité, et
   c'est plus utile qu'une alarme vague.

3. ⚠ Le point manquant, et c'est l'inverse de ce qu'on croyait : DISCARD ALL
   rend la protection de db.go STRICTEMENT PLUS FRAGILE. Aujourd'hui
   (DEALLOCATE ALL) elle survivrait à un passage en transaction pooling ;
   après, non — le SET ROLE d'AfterConnect est effacé entre deux transactions,
   et poserRoleProprietaire ne peut pas le voir puisqu'il relit current_role à
   l'ouverture. Sans danger tant que pool_mode reste `session` (le défaut, non
   déclaré) — mais c'est précisément le genre de trappe qui se referme des mois
   plus tard sur quelqu'un qui optimise. Les quatre combinaisons sont mesurées
   et écrites au-dessus de la ligne.

La ceinture Vault couvre ce cas (un défaut de rôle survit à DISCARD ALL), mais
seulement au renouvellement des baux. D'où l'avertissement explicite.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GdUCA5Uz8QyMwa2P4Pg2hK
2026-07-28 23:23:18 +02:00
arcodangeandClaude Opus 5 1dd905dc5f fix(pgbouncer,vault) — ce qu'un client laisse sur une connexion, le suivant n'en hérite plus
Helm Charts / Detect changed charts (pull_request) Successful in 58s
Helm Charts / Library charts tool (pull_request) Has been skipped
Helm Charts / Application charts pgcat (pull_request) Has been skipped
Un pgbouncer partagé n'a qu'UNE barrière entre deux clients d'un même pool : le
`server_reset_query`. La nôtre ne nettoyait que les requêtes préparées. Tout le
reste de l'état de session — variables, LISTEN, tables TEMP, et le rôle
courant — traversait la déconnexion et tombait dans les mains du client suivant.

CE QUI A ÉTÉ MESURÉ, PAS SUPPOSÉ

pgbouncer 1.25.2 (la version qui tourne), monté avec NOTRE configuration :
`pool_mode = session`, `server_reset_query = DEALLOCATE ALL`,
`server_reset_query_always = 1`, `default_pool_size = 1`. Le client 1 pose son
état puis se déconnecte ; le client 2, qui n'a rien demandé, arrive.

  DEALLOCATE ALL (l'existant)   client 2 hérite : work_mem=17MB, 1 LISTEN,
                                la table TEMP du client 1, et son SET ROLE —
                                au point de créer des tables appartenant à un
                                rôle qui n'est pas le sien.
  DISCARD ALL (ce commit)       client 2 obtient work_mem=4MB, 0 LISTEN, pas
                                de table TEMP, et son PROPRE rôle.

POURQUOI `DISCARD ALL` NE PEUT RIEN CASSER

C'est le défaut de pgbouncer, et un sur-ensemble STRICT des deux valeurs qui
l'ont précédé ici : il contient `DEALLOCATE ALL` — donc le correctif crowdsec de
07e2c6d (« prepared statement already exists ») est conservé, et c'est vérifié :
deux clients successifs préparent le même nom sans erreur — et il contient
`SELECT pg_advisory_unlock_all()`, la valeur que pose le sous-chart.

Et il ne touche jamais un client vivant : en `pool_mode = session` la connexion
serveur n'est rendue qu'à la déconnexion, donc le reset ne court pas entre deux
requêtes d'une même session. Vérifié : table TEMP, `SET work_mem` et `LISTEN`
d'un client VIVANT survivent intacts.

PORTÉE DE LA FUITE — ce qu'elle est, et ce qu'elle n'est pas

Les pools de pgbouncer sont partitionnés par (base, utilisateur) : 210 pools
mesurés sur l'instance, aucun ne mélange deux bases ni deux comptes. Un rôle
posé par kadans ne peut donc PAS atterrir chez crowdsec ou plausible : le seul
héritier possible est un client de la même base avec le même identifiant, donc
l'application elle-même. Ce n'est pas une élévation de privilège entre
applications — c'est un défaut d'hygiène, et il est déjà là aujourd'hui pour
n'importe quel `SET` de n'importe quelle application.

LA CEINTURE, CÔTÉ VAULT

`ALTER ROLE "{{name}}" SET ROLE <app>_role` dans les creation_statements fait de
l'endossement un défaut de CONNEXION, immune au `pool_mode` comme au
`server_reset_query`, et valable pour les clients qui ne sont pas l'application
(le psql d'un job). Elle n'apporte aucun privilège : le `GRANT` de la ligne
précédente rend déjà le rôle éphémère membre du rôle stable — d'où le fait
qu'elle ne peut pas échouer là où le GRANT réussit.

Elle règle à la racine ce que le CronJob `pg-fix-table-ownership` rattrape tous
les jours à 03:00 : les objets naissent chez le rôle stable au lieu d'être
réattribués après coup. ⚠ Elle ne vaut que pour les identifiants créés APRÈS
l'apply — elle ne protège donc PAS ce soir ; c'est `DISCARD ALL` qui protège ce
soir.

Mesuré (PostgreSQL 16, compte CREATEROLE non-superutilisateur comme celui de
Vault) : l'instruction passe, le login donne bien `current_role` = rôle stable
avec `session_user` = rôle éphémère, un `ALTER ROLE … RESET role` la retire, et
elle SURVIT à `RESET ALL` / `DISCARD ALL` — les deux mesures composent au lieu
de s'annuler.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GdUCA5Uz8QyMwa2P4Pg2hK
2026-07-28 22:48:49 +02:00
arcodangeandClaude Opus 5 2cb19809c6 doc(reseau) — le confort LAN ne se règle pas dans Traefik : mesure, pièges, voie retenue
Helm Charts / Detect changed charts (pull_request) Successful in 1m6s
Helm Charts / Library charts tool (pull_request) Has been skipped
Helm Charts / Application charts pgcat (pull_request) Has been skipped
Demande : « ne pas avoir le basic auth Traefik quand on est sur le Wi-Fi partagé
avec le homelab ». La réponse intuitive — une règle `ClientIP(192.168.1.0/24)` —
ne peut pas marcher, et la variante « je regarde CF-Connecting-IP » est un
contournement d'authentification. Ce commit écrit la mesure pour qu'on ne
re-découvre ni l'une ni l'autre.

Mesuré (2026-07-28, homelab) :

- `kadans.arcodange.fr` résout vers Cloudflare MÊME depuis le LAN, et redescend
  par le tunnel cloudflared. L'adresse de socket vue par Traefik est donc
  toujours celle d'un pod, en 10.42.x.x.
- Traefik SAIT qui est le vrai client (journal d'accès : l'IPv6 de la maison,
  `2a01:cb04:dff:cf00::/64` — pas l'IPv4 qu'`ipify` injecte dans `localIp`), car
  l'entrypoint `web` fait confiance aux en-têtes venant de 10.42.0.0/16.
- Mais le matcher de routeur `ClientIP()`, lui, juge la SOCKET. Prouvé par trois
  routeurs temporaires vers un Service sans endpoint (503 = règle matchée,
  401 = repli sur le routeur normal) : témoin 503, `ClientIP(<IPv6 maison>)` 401,
  `ClientIP(10.42.0.0/16)` 503. Sondes retirées après mesure.

D'où deux pièges, écrits là où on les rencontrerait :

- `localIp@file` sur un routeur `.fr` laisserait entrer Internet entier, parce
  que `10.42.0.0/16` est dans son `sourceRange` et qu'`ipAllowList` juge lui
  aussi la socket. Vérifié : aucun routeur `.fr` ne le porte aujourd'hui — le
  piège est latent, et les cinq endroits d'où on pourrait le recopier
  (2 gabarits `.fr`, 3 values `.lab`) portent désormais l'avertissement.
- Un en-tête posé par le client ne peut pas piloter une exemption d'auth :
  Cloudflare ne retire pas les en-têtes inconnus, et Traefik reste joignable en
  direct sur 192.168.1.201.

Voie retenue, et pourquoi celle-là : la décision « suis-je à la maison ? » doit
être prise là où la vraie IP est native et non falsifiable, donc au bord, chez
Cloudflare. Une règle de transformation y pré-remplit l'en-tête `Authorization`
pour les IP du foyer. Traefik ne bouge pas, aucun certificat public à produire,
aucun changement DNS. Surface d'attaque ajoutée : AUCUNE — `kadans:kkadans` est
déjà en clair dans le chart de kadans, Cloudflare ne fait que le taper à notre
place. Et quand l'IP du foyer dérive, la règle cesse de matcher : le navigateur
redemande le mot de passe. Dégradation douce, pas de panne.

Le doc écrit l'action Cloudflare mot pour mot (expression, en-tête, valeur, et
l'équivalent Terraform pour `cms/cloudflare`) plutôt que de la supposer faite,
avec les deux commandes qui la vérifient — et nomme le repli si Cloudflare
refuse de modifier `Authorization` : Cloudflare Access avec une politique de
bypass, PAS un en-tête secret.

Enfin, un contre-exemple utile : le bouncer CrowdSec, lui, juge la VRAIE IP
(journal à l'appui). Son `clientTrustedIPs` contient `10.42.0.0/16` sans que ce
soit un trou — noté pour que personne ne « corrige » cette ligne en croyant y
reproduire le piège.

⚠ Aucun des réglages en cause ne vit dans ce dépôt : les valeurs Helm de
Traefik, `dynamic.yaml`/`localIp` et le Middleware crowdsec sont dans `factory`
(ansible), le tunnel et le DNS dans `cms`. Ce dépôt reçoit la mesure et les
garde-fous parce que c'est lui qui héberge les gabarits `.fr` et les
consommateurs de `localIp@file`.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GdUCA5Uz8QyMwa2P4Pg2hK
2026-07-28 14:29:35 +02:00
arcodange 9de9a9663b Merge pull request 'ci — arrêter les runs qui ne peuvent pas aboutir, et le doublon push+PR' (#23) from arcodange/moins-de-runs-inutiles into main
Helm Charts / Detect changed charts (push) Successful in 18s
Helm Charts / Library charts tool (push) Has been skipped
Helm Charts / Application charts pgcat (push) Has been skipped
2026-07-26 12:39:13 +02:00
arcodangeandClaude Opus 5 2bdc486ae6 ci — arrêter les runs qui ne peuvent pas aboutir, et le doublon push+PR
Helm Charts / Detect changed charts (pull_request) Successful in 17s
Helm Charts / Library charts tool (pull_request) Has been skipped
Helm Charts / Application charts pgcat (pull_request) Has been skipped
Quatre runs d'une branche déjà mergée ont bloqué, ce matin, l'apply qu'on
attendait. Deux causes, indépendantes :

1. Les workflows tofu (minio, vault, crowdsec, plausible) s'authentifient à
   Vault par un flux OIDC dont un HUMAIN doit ouvrir le lien. Déclenchés tout
   seuls, ils ne peuvent qu'occuper un runner jusqu'au timeout. Ils font en
   plus `apply` en `auto_approve` CONTRE LA PROD : partir sur le push d'une
   branche, c'est appliquer du code que personne n'a relu. → `workflow_dispatch`
   seul, ce qui écrit enfin ce qu'ils faisaient déjà.

   (crowdsec et plausible passaient de toute façon par une ancre YAML, donc
   leurs triggers étaient INERTES — issues 113 → 117 de kadans.)

2. `push` sur toutes les branches + `pull_request` = DEUX runs par commit dès
   qu'une branche a une PR. Vérifié : runs 258/259 et 260/261 portent le même
   SHA. helmcharts, qui travaille seul et mérite de rester automatique, prend
   la forme éprouvée de la CI de kadans : push sur `main`, PR pour la branche.

Chaque clé de trigger porte un corps explicite : un `pull_request:` nu n'est
pas une forme éprouvée ici, et son mode d'échec est le silencieux.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA
2026-07-26 12:37:46 +02:00
arcodange 901aa9a9dc Merge pull request 'fix(minio) — le provisionneur ne pouvait pas créer de bucket : s3:ListBucket manquait' (#22) from arcodange/provisioner-listbucket into main
Helm Charts / Detect changed charts (push) Successful in 1m0s
Helm Charts / Library charts tool (push) Has been skipped
Helm Charts / Application charts pgcat (push) Has been skipped
MinIO / Auth with gitea for vault (push) Successful in 23s
MinIO / Tofu - minio IAC (push) Successful in 49s
2026-07-26 11:44:18 +02:00
arcodangeandClaude Opus 5 e8ab19962b fix(minio) — le provisionneur ne pouvait pas créer de bucket : s3:ListBucket manquait
Helm Charts / Detect changed charts (push) Successful in 15s
Helm Charts / Detect changed charts (pull_request) Successful in 16s
Helm Charts / Library charts tool (push) Has been skipped
MinIO / Auth with gitea for vault (push) Failing after 8m31s
MinIO / Tofu - minio IAC (push) Has been skipped
Helm Charts / Library charts tool (pull_request) Has been skipped
Helm Charts / Application charts pgcat (push) Has been skipped
MinIO / Auth with gitea for vault (pull_request) Failing after 8m32s
MinIO / Tofu - minio IAC (pull_request) Has been skipped
Helm Charts / Application charts pgcat (pull_request) Has been skipped
Le premier apply réel (kadans) a créé la politique, le compte de service,
l'attachement et le secret Vault — puis a échoué sur le bucket lui-même :

    Error: [FATAL] unable to check bucket (kadans-videos): Access Denied.

Le provider teste l'existence du bucket AVANT de le créer (HeadBucket), et
MinIO exige `s3:ListBucket` pour ça. C'est le seul point que l'ADR annonçait
comme non éprouvé ; il l'est maintenant.

`s3:ListBucket` donne la vue des CLÉS d'un bucket, pas leur contenu :
GetObject / PutObject restent absents, donc le provisionneur — partagé entre
les rôles CI — ne peut toujours pas lire les vidéos d'une autre application.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA
2026-07-26 11:43:04 +02:00
arcodange 5c7dce96e1 Merge pull request 'feat(minio) — un compte de service par app consommatrice, borné à son bucket' (#21) from arcodange/minio-comptes-de-service into main
Helm Charts / Detect changed charts (push) Successful in 19s
Hashicorp Vault / Auth with gitea for vault (push) Successful in 2m58s
MinIO / Auth with gitea for vault (push) Successful in 5m52s
Helm Charts / Library charts tool (push) Has been skipped
Hashicorp Vault / Tofu - Vault IAC (push) Successful in 53s
MinIO / Tofu - minio IAC (push) Successful in 33s
Helm Charts / Application charts pgcat (push) Has been skipped
2026-07-26 10:46:39 +02:00
16 changed files with 434 additions and 57 deletions
+8 -5
View File
@@ -2,12 +2,15 @@
# template source: https://github.com/bretfisher/docker-build-workflow/blob/main/templates/call-docker-build.yaml # template source: https://github.com/bretfisher/docker-build-workflow/blob/main/templates/call-docker-build.yaml
name: Crowdsec name: Crowdsec
on: #[push,pull_request] # À LA DEMANDE, et seulement à la demande — comme minio.yaml : auth Vault par
# flux OIDC (un humain doit ouvrir un lien) et apply `auto_approve` contre la prod.
#
# Note : les triggers `push`/`pull_request` retirés ici étaient de toute façon
# INERTES — ils passaient par une ancre YAML (`&`/`*`), que le parseur
# d'événements de Gitea ne résout pas (vécu sur arcodange/kadans, issues 113
# → 117). Ce workflow ne partait déjà qu'à la main ; c'est maintenant écrit.
on:
workflow_dispatch: {} workflow_dispatch: {}
push: &crowdsecPaths
paths:
- 'crowdsec/**/*.tf'
pull_request: *crowdsecPaths
# cancel any previously-started, yet still active runs of this workflow on the same branch # cancel any previously-started, yet still active runs of this workflow on the same branch
concurrency: concurrency:
+26 -8
View File
@@ -2,14 +2,32 @@
# template source: https://github.com/bretfisher/docker-build-workflow/blob/main/templates/call-docker-build.yaml # template source: https://github.com/bretfisher/docker-build-workflow/blob/main/templates/call-docker-build.yaml
name: Helm Charts name: Helm Charts
on: [push,pull_request,workflow_dispatch] # Celui-ci travaille SEUL (pas d'auth Vault, pas d'apply) : on le garde
# push: &helmPaths # turns out gitea don't handle well the paths filter # automatique. Mais `push` sur TOUTES les branches + `pull_request` faisait
# paths: # partir DEUX runs pour le même commit dès qu'une branche avait une PR.
# - '*/\.yaml' #
# - '*/\.tpl' # Même forme que la CI de kadans : la branche est couverte par `pull_request`,
# - '*/NOTES.txt' # `main` par le `push` d'après-merge. Un run par événement, aucun angle mort.
# - '*/\.helmignore' #
# pull_request: *helmPaths # (Le filtre de chemins d'origine, resté en commentaire des années sous un
# « gitea don't handle well the paths filter », n'était probablement pas en
# cause : il passait par une ancre YAML, et le parseur d'événements de Gitea ne
# les résout pas — issues 113 → 117 de kadans. Le job `filter-chart` fait déjà
# ce tri au niveau job, donc on n'y retouche pas.)
#
# ⚠ Chaque clé porte un CORPS explicite : un `pull_request:` nu (valeur nulle)
# n'est pas une forme éprouvée sur ce Gitea, et son mode d'échec est le
# silencieux — aucun run, aucune erreur. On copie la forme qui tourne (kadans
# ci.yml), listes dupliquées à la main, sans ancre.
on:
workflow_dispatch: {}
push:
branches: [main]
paths-ignore:
- '**.md'
pull_request:
paths-ignore:
- '**.md'
# cancel any previously-started, yet still active runs of this workflow on the same branch # cancel any previously-started, yet still active runs of this workflow on the same branch
concurrency: concurrency:
+11 -12
View File
@@ -1,20 +1,19 @@
--- ---
name: MinIO name: MinIO
# ⚠ Triggers écrits EN TOUTES LETTRES, sans ancre YAML (`&`/`*`). # À LA DEMANDE, et seulement à la demande. Deux raisons, chacune suffisante :
# Une ancre dans un trigger Gitea Actions fait taire push ET pull_request — #
# en silence, aucun run, aucune erreur (vécu sur arcodange/kadans, issues 113 # 1. Ce workflow ne PEUT PAS aboutir sans un humain : l'auth Vault passe par
# → 117). Les autres workflows de ce repo utilisent encore des ancres : à # un flux OIDC dont le lien doit être ouvert dans un navigateur connecté.
# vérifier séparément, c'est probablement pour ça qu'ils ne partent qu'à la # Déclenché tout seul, il occupe un runner jusqu'à son timeout — et retarde
# main (workflow_dispatch). # les runs que quelqu'un attend vraiment.
# 2. Il fait `terraform apply` en `auto_approve` CONTRE LA PROD. Se déclencher
# sur le push d'une branche, c'est appliquer du code que personne n'a relu.
#
# Au passage : `push` (toutes branches) + `pull_request` faisait partir DEUX runs
# par commit d'une branche en PR — le même SHA, deux fois.
on: on:
workflow_dispatch: {} workflow_dispatch: {}
push:
paths:
- 'minio/**/*.tf'
pull_request:
paths:
- 'minio/**/*.tf'
# cancel any previously-started, yet still active runs of this workflow on the same branch # cancel any previously-started, yet still active runs of this workflow on the same branch
concurrency: concurrency:
+7 -5
View File
@@ -2,12 +2,14 @@
# template source: https://github.com/bretfisher/docker-build-workflow/blob/main/templates/call-docker-build.yaml # template source: https://github.com/bretfisher/docker-build-workflow/blob/main/templates/call-docker-build.yaml
name: Plausible name: Plausible
on: #[push,pull_request] # À LA DEMANDE, et seulement à la demande — comme minio.yaml : auth Vault par
# flux OIDC (un humain doit ouvrir un lien) et apply `auto_approve` contre la prod.
#
# Note : les triggers `push`/`pull_request` retirés ici étaient de toute façon
# INERTES (ancre YAML non résolue par Gitea, issues 113 → 117 de kadans). Ce
# workflow ne partait déjà qu'à la main ; c'est maintenant écrit.
on:
workflow_dispatch: {} workflow_dispatch: {}
push: &plausiblePaths
paths:
- 'plausible/**/*.tf'
pull_request: *plausiblePaths
# cancel any previously-started, yet still active runs of this workflow on the same branch # cancel any previously-started, yet still active runs of this workflow on the same branch
concurrency: concurrency:
+10 -15
View File
@@ -2,23 +2,18 @@
# template source: https://github.com/bretfisher/docker-build-workflow/blob/main/templates/call-docker-build.yaml # template source: https://github.com/bretfisher/docker-build-workflow/blob/main/templates/call-docker-build.yaml
name: Hashicorp Vault name: Hashicorp Vault
# ⚠ Triggers écrits EN TOUTES LETTRES, sans ancre YAML (`&`/`*`) : une ancre # À LA DEMANDE, et seulement à la demande — comme minio.yaml, et pour les mêmes
# dans un trigger Gitea Actions fait taire push ET pull_request, en silence # deux raisons : l'auth Vault exige qu'un humain ouvre un lien OIDC (sans lui, le
# (vécu sur arcodange/kadans, issues 113 → 117) — c'est probablement pourquoi # run squatte un runner jusqu'au timeout), et l'apply se fait en `auto_approve`
# ce workflow ne partait qu'à la main. # contre la prod.
# Et `*.tfvars` compte AUTANT que `*.tf` : la liste des applications (donc les #
# rôles gitea_cicd_<app>) vit dans terraform.tfvars — l'oublier, c'est ajouter # ⚠ Ce qui change AUSSI de nature : `hashicorp-vault/**/*.tfvars` compte autant
# une app sans jamais créer son rôle. # que `*.tf` — la liste des applications (donc les rôles gitea_cicd_<app>) vit
# dans terraform.tfvars. Ce n'est plus un filtre de chemins mais ça reste vrai
# du POURQUOI on relance : ajouter une app au tfvars sans relancer ce workflow,
# c'est une app sans rôle CI.
on: on:
workflow_dispatch: {} workflow_dispatch: {}
push:
paths:
- 'hashicorp-vault/**/*.tf'
- 'hashicorp-vault/**/*.tfvars'
pull_request:
paths:
- 'hashicorp-vault/**/*.tf'
- 'hashicorp-vault/**/*.tfvars'
# cancel any previously-started, yet still active runs of this workflow on the same branch # cancel any previously-started, yet still active runs of this workflow on the same branch
concurrency: concurrency:
+7
View File
@@ -8,6 +8,13 @@ pour chaque dossier de premier niveau contenant un fichier Chart.yaml (sauf les
le pousser dans le registre helm de gitea le pousser dans le registre helm de gitea
``` ```
## Réseau
- [Ce que Traefik voit du client](doc/ce-que-traefik-voit.md) — pourquoi
`ClientIP()` ne voit que le tunnel sur les hôtes `.fr`, pourquoi `localIp@file`
n'a rien à y faire, et comment obtenir « pas de mot de passe depuis la maison »
sans créer un contournement d'authentification.
## pgbouncer ## pgbouncer
## prometheus ## prometheus
+224
View File
@@ -0,0 +1,224 @@
# Ce que Traefik voit du client — et pourquoi « pas de mot de passe à la maison » ne se règle pas dans Traefik
Mesuré le 2026-07-28 sur le homelab (`kubectl --context=default`, k3s pi1/pi2/pi3).
Ce document existe parce que la question « peut-on sauter le basic auth quand on est
sur le Wi-Fi de la maison ? » a une réponse contre-intuitive, et que la réponse
intuitive est un **trou de sécurité**.
## 1. Le chemin réel d'une requête `.fr`
```
navigateur (maison)
└─ DNS : kadans.arcodange.fr → 172.67.130.8 / 104.21.3.16 (Cloudflare, PAS le LAN)
└─ edge Cloudflare (proxy orange)
└─ tunnel cloudflared (sortant, pod du cluster, CIDR 10.42.0.0/16)
└─ traefik.kube-system.svc:80 (entrypoint `web`, pas de TLS ici)
└─ crowdsec → basic auth → service
```
Le tunnel est **sortant** : il n'y a aucune redirection de port, aucun chemin
depuis Internet vers Traefik autre que Cloudflare. Le nom `.fr` est un CNAME
proxifié vers `<tunnel-id>.cfargotunnel.com` (`cms/cloudflare`, module
`cloudflared_tunnel`, mapping unique `*.arcodange.fr`).
**Conséquence n°1 : même depuis le salon, la requête sort de la maison, fait un
aller-retour par Cloudflare, et revient par le tunnel.** L'adresse de la socket
que Traefik accepte est toujours celle d'un pod `cloudflared` en `10.42.x.x`.
Le `.lab`, lui, va droit au but : `kadans.arcodange.lab → 192.168.1.201`.
## 2. Deux notions d'« IP client », et elles ne coïncident pas
L'entrypoint `web` fait confiance aux en-têtes transmis depuis le CIDR des pods
(`ports.web.forwardedHeaders.trustedIPs: ["10.42.0.0/16"]`, playbook
`factory/.../playbooks/system/k3s_config.yml`). Traefik **sait donc** qui est le
vrai client — le journal d'accès le prouve, sur une requête émise depuis la maison :
```
2a01:cb04:dff:cf00:f9d7:4026:9ffc:b042 - kadans [28/Jul/2026:14:22:49 +0200]
"GET /?sonde=… HTTP/1.1" 200 … "kadans-kadans-public-kadans-arcodange-fr@kubernetes"
```
Deux choses à retenir de cette ligne :
- Traefik **résout** le vrai client depuis `X-Forwarded-For` ;
- la maison sort en **IPv6** (`2a01:cb04:dff:cf00::/64`), pas en IPv4. Le
`86.238.234.54/32` que le playbook injecte dans `localIp` via `ipify` n'est
qu'**une** des deux adresses publiques du foyer, et pas celle qu'un navigateur
moderne utilise en priorité.
Mais **le matcher de routeur `ClientIP()` n'utilise PAS cette résolution.** Il
juge l'adresse de la socket. Mesuré, pas supposé — trois routeurs temporaires
posés sur `kadans.arcodange.fr`, pointant vers un Service sans endpoint (donc
`503` si la règle matche, et repli sur le routeur normal — `401` — sinon) :
| sonde | règle ajoutée à `Host(kadans.arcodange.fr)` | attendu si… | mesuré |
| ----- | -------------------------------------------- | ------------------------------ | ------ |
| T | *(témoin, aucune contrainte d'IP)* | le dispositif fonctionne → 503 | **503** |
| A | `ClientIP("2a01:cb04:dff:cf00::/64")` | `ClientIP` lit X-Forwarded-For | **401** |
| B | `ClientIP("10.42.0.0/16")` | `ClientIP` lit la socket | **503** |
Le témoin valide le montage ; A échoue, B réussit. **`ClientIP()` voit le pod
`cloudflared`, jamais le vrai client.** (Sondes supprimées après mesure.)
## 3. Les deux pièges qui en découlent
### Piège A — `localIp@file` ne doit JAMAIS toucher un routeur `.fr`
`localIp` est un `ipAllowList` dont le `sourceRange` contient `10.42.0.0/16`
(le CIDR des pods). Comme `ipAllowList` juge lui aussi l'adresse de la socket
par défaut, **l'attacher à un hôte `.fr` laisserait passer Internet entier** :
toute requête arrivant par le tunnel se présente en `10.42.x.x`, donc « locale ».
État vérifié le 2026-07-28 : aucun routeur `.fr` ne porte `localIp@file`. Le
piège est **latent**, pas ouvert. Il le reste tant que personne ne recopie
`localIp@file` depuis un values `.lab` (grafana, vault, plausible) vers un
`ingress-public.yaml`. Les deux gabarits `.fr` de ce dépôt portent un
avertissement à cet endroit précis.
### Piège B — se fier à un en-tête est un contournement d'authentification
« Sauter le basic auth si `CF-Connecting-IP` est celle de la maison » (ou si un
en-tête maison est présent) fait reposer une **exemption d'authentification** sur
une valeur que le client écrit lui-même. Cloudflare ne retire pas les en-têtes
inconnus : n'importe qui sur Internet peut les envoyer. Et Traefik reste joignable
en direct sur le LAN (`192.168.1.201:80`). **On ne prend pas cette voie.**
## 4. Pourquoi ce n'est pas réparable dans Traefik
Traefik enchaîne ses middlewares **inconditionnellement** : il n'existe pas de
« basic auth sauf si … ». Le seul moyen de sauter un middleware est de faire
matcher un **autre routeur** — et le seul discriminant d'IP au niveau routeur est
`ClientIP()`, qui, on vient de le mesurer, ne voit que le tunnel. Un
`ipAllowList` avec `ipStrategy.depth` verrait bien la vraie IP, mais un
`ipAllowList` **bloque** ; il ne sait pas « laisser entrer sans mot de passe ».
Faire pointer le LAN vers Traefik (DNS à double horizon, ou enregistrement
Cloudflare « DNS only ») rendrait `ClientIP(192.168.1.0/24)` honnête — mais
coûterait, en plus du DNS : un **certificat publiquement valide** pour
`kadans.arcodange.fr` sur Traefik (aujourd'hui il sert la CA interne,
`CN=arcodange.lab` ; un `curl --resolve … 192.168.1.201` échoue au TLS, code
`000`), donc un nouveau `certResolver` DNS-01 Cloudflare et le jeton qui va avec,
plus un routeur `websecure` pour un hôte qui vit aujourd'hui en `web` clair.
Beaucoup, pour du confort. Et « DNS only » retirerait en prime le bouclier
Cloudflare de cet hôte.
**La décision doit donc être prise là où la vraie IP est native et non
falsifiable : au bord, chez Cloudflare (`ip.src`).**
## 5. La voie retenue — Cloudflare tape le mot de passe à ta place
Le basic auth de `kadans.arcodange.fr` est un **garde-fou assumé**, pas un
secret : `kadans:kkadans`, et son hash bcrypt est en clair dans
`chart/values.yaml` du dépôt `kadans`. C'est ce fait qui rend la solution
simple **et** sans coût de sécurité :
> Une règle Cloudflare pré-remplit l'en-tête `Authorization` pour les requêtes
> venant du réseau de la maison. Traefik ne change pas, le mot de passe reste
> exigé partout ailleurs. Cloudflare ne fait que le **taper à ta place**.
Surface d'attaque ajoutée : **aucune**. Un attaquant qui forge cet en-tête
obtient exactement ce que `curl -u kadans:kkadans` lui donne déjà aujourd'hui.
C'est la différence de fond avec le piège B : ici on ne crée pas d'exemption, on
automatise une saisie.
Et quand l'IP du foyer change (elle est dynamique), la règle cesse simplement de
matcher : **le navigateur redemande le mot de passe**. Dégradation douce, jamais
de panne.
### Action manuelle du fondateur (à faire une fois)
Cloudflare → zone `arcodange.fr`**Rules****Transform Rules** → **Modify
Request Header** → *Create rule* :
- **Nom** : `kadans — confort LAN : Authorization pré-remplie depuis la maison`
- **When incoming requests match** (éditeur d'expression) :
```
(http.host eq "kadans.arcodange.fr" and ip.src in {86.238.234.54 2a01:cb04:dff:cf00::/64})
```
- **Then** → *Set static* :
- Header name : `Authorization`
- Value : `Basic a2FkYW5zOmtrYWRhbnM=` *(base64 de `kadans:kkadans`)*
Équivalent Terraform, si on préfère le déclarer dans `cms/cloudflare` :
```hcl
resource "cloudflare_ruleset" "confort_lan_kadans" {
zone_id = cloudflare_zone.arcodange_fr.id
name = "confort LAN"
kind = "zone"
phase = "http_request_late_transform"
rules = [{
expression = "(http.host eq \"kadans.arcodange.fr\" and ip.src in {86.238.234.54 2a01:cb04:dff:cf00::/64})"
description = "Depuis la maison, Cloudflare tape le garde-fou basic auth a notre place"
action = "rewrite"
action_parameters = {
headers = {
"Authorization" = { operation = "set", value = "Basic a2FkYW5zOmtrYWRhbnM=" }
}
}
}]
}
```
### Vérifier que ça marche
```bash
# depuis la maison — attendu : 200, SANS -u
curl -s -o /dev/null -w '%{http_code}\n' https://kadans.arcodange.fr/
# depuis l'extérieur (partage de connexion du téléphone) — attendu : 401
curl -s -o /dev/null -w '%{http_code}\n' https://kadans.arcodange.fr/
```
### Ce qu'il faudra surveiller
- **Les deux adresses du foyer sont dynamiques.** Le préfixe IPv6 relevé le
2026-07-28 est `2a01:cb04:dff:cf00::/64` et l'IPv4 `86.238.234.54` — la même
que celle qu'`ipify` injecte dans `localIp` à chaque passage du playbook
`k3s_config.yml`. S'il dérive, élargir en `/56` avant de soupçonner Cloudflare.
- ⚠ **Non vérifié en vrai** : que Cloudflare accepte `Authorization` comme
en-tête de requête modifiable (certains en-têtes sont réservés). Si l'éditeur
refuse la règle, le repli est **Cloudflare Access** avec une politique
*Bypass* sur les mêmes IP et une politique *Allow* par e-mail ailleurs — et le
basic auth de Traefik est alors retiré. Le repli n'est **pas** un en-tête
secret (piège B).
### La version encore plus simple, si le confort ne vaut pas la règle
Le mot de passe est public. Ce qu'il achète réellement, c'est « le produit en
chantier n'est pas indexé ni tombé dessus par hasard ». Un `robots.txt` +
`X-Robots-Tag: noindex` fait ce travail-là sans mot de passe du tout. Retirer le
garde-fou est une décision produit, pas une décision d'infra : elle n'est pas
prise ici, seulement nommée.
## 6. Ce qui a été vérifié et qui va bien
Le bouncer CrowdSec, lui, **juge la vraie IP** — contrairement à `ClientIP()`.
Journal Traefik sur une requête de la maison et sur des robots :
```
CrowdsecBouncerTraefikPlugin: ServeHTTP ip:2a01:cb04:dff:cf00:f9d7:4026:9ffc:b042 isTrusted:false
CrowdsecBouncerTraefikPlugin: ServeHTTP ip:144.24.58.222 isTrusted:false
```
Son `clientTrustedIPs` contient pourtant `10.42.0.0/16` : ce n'est **pas** un
trou, parce que le plugin ne compare pas cette liste à l'adresse de la socket
mais à l'IP résolue. La convention `.fr` (« crowdsec devant tout ») tient donc
ce qu'elle promet. Écrit ici pour que personne ne « corrige » cette ligne en
croyant reproduire le piège A.
---
**Où vit quoi** (aucun de ces réglages n'est dans ce dépôt — vérifié) :
| réglage | dépôt / fichier |
| ------------------------------------------- | ------------------------------------------------------------------------ |
| valeurs Helm Traefik, `dynamic.yaml`, `localIp` | `factory` — `ansible/arcodange/factory/playbooks/system/k3s_config.yml` |
| Middleware `crowdsec@kube-system` | `factory` — `.../playbooks/tools/roles/crowdsec/tasks/main.yml` |
| tunnel + DNS `*.arcodange.fr` | `cms` — `cloudflare/iac.tf`, `cloudflare/modules/cloudflared_tunnel/` |
| basic auth `.fr` de kadans | `kadans` — `chart/values.yaml` (`public.basicAuth`) |
| gabarits d'exposition `.fr` de ce dépôt | `grafana/templates/ingress-public.yaml`, `minio/templates/ingress-public.yaml` |
+6
View File
@@ -10,6 +10,12 @@
# NB : ce chart grafana est en mode `tool.kind: SubChart`, donc les templates # NB : ce chart grafana est en mode `tool.kind: SubChart`, donc les templates
# helm-chart*.yaml ne rendent rien ; ce fichier, lui, est rendu tel quel et # helm-chart*.yaml ne rendent rien ; ce fichier, lui, est rendu tel quel et
# appliqué par ArgoCD (app `grafana`, destination namespace `tools`). # appliqué par ArgoCD (app `grafana`, destination namespace `tools`).
#
# ⚠ NE JAMAIS ajouter `localIp@file` ici (ni sur aucun routeur `.fr`). Le trafic
# `.fr` arrive par le tunnel cloudflared, donc avec l'adresse d'un pod en
# 10.42.x.x — or `10.42.0.0/16` est dans le `sourceRange` de `localIp`, et
# `ipAllowList` juge l'adresse de la SOCKET. Le middleware « IP locale
# seulement » laisserait donc entrer Internet entier. Mesuré : doc/ce-que-traefik-voit.md §3.
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
+2
View File
@@ -285,6 +285,8 @@ grafana: &grafana_config
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
traefik.ingress.kubernetes.io/router.tls.domains.0.main: arcodange.lab traefik.ingress.kubernetes.io/router.tls.domains.0.main: arcodange.lab
traefik.ingress.kubernetes.io/router.tls.domains.0.sans: grafana.arcodange.lab traefik.ingress.kubernetes.io/router.tls.domains.0.sans: grafana.arcodange.lab
# ⚠ Valable parce que cet hôte est en `.lab` (le client arrive en direct sur
# 192.168.1.201). À NE PAS recopier sur un hôte `.fr` : cf. doc/ce-que-traefik-voit.md §3.
traefik.ingress.kubernetes.io/router.middlewares: localIp@file traefik.ingress.kubernetes.io/router.middlewares: localIp@file
hosts: hosts:
- grafana.arcodange.lab - grafana.arcodange.lab
@@ -30,9 +30,36 @@ resource "vault_database_secret_backend_role" "role" {
backend = local.vault_mount_postgres.path backend = local.vault_mount_postgres.path
name = local.instance name = local.instance
db_name = "postgres" db_name = "postgres"
# Le rôle ÉPHÉMÈRE endosse le rôle STABLE, dès le login
#
# En PostgreSQL, un objet appartient au rôle qui l'a CRÉÉ. Comme chaque
# démarrage de pod obtient un rôle `v-kubernet-` neuf, toute migration crée
# des objets que le pod SUIVANT ne peut plus lire. C'est ce qui a mis l'API
# kadans à terre une demi-journée le 2026-07-28 (« permission denied for table
# qualification_video »), et c'est ce que le CronJob `pg-fix-table-ownership`
# rattrape tous les jours à 03:00 a posteriori, et pour les seules TABLES.
#
# `ALTER ROLE SET ROLE` fait de l'endossement un DÉFAUT DE CONNEXION : plus
# rien à poser côté application, et ça vaut aussi pour les clients qui ne sont
# pas l'application (le `psql` d'un job, une console d'exploitation).
#
# AUCUN privilège nouveau : le `GRANT` de la ligne précédente rend déjà le
# rôle éphémère MEMBRE du rôle stable. Endosser une casquette qu'on porte
# déjà, ce n'est pas une élévation et c'est pourquoi cette instruction ne
# peut pas échouer le `GRANT` réussit.
#
# MESURÉ (PostgreSQL 16, compte CREATEROLE non-superutilisateur, comme celui
# de Vault) : l'instruction passe, le login donne `session_user=v-test-1` /
# `current_role=proprio_v`, et un `ALTER ROLE RESET role` la retire.
# Vérifié aussi qu'elle SURVIT à `RESET ALL` / `DISCARD ALL` donc elle
# compose avec le `server_reset_query` de pgbouncer au lieu de s'y opposer.
#
# Ne vaut que pour les identifiants créés APRÈS l'apply : les baux en cours
# gardent leur ancien comportement jusqu'à leur renouvellement.
creation_statements = [ creation_statements = [
"CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';", "CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';",
"GRANT ${local.owner_role} TO \"{{name}}\";", "GRANT ${local.owner_role} TO \"{{name}}\";",
"ALTER ROLE \"{{name}}\" SET ROLE ${local.owner_role};",
] ]
revocation_statements = [ revocation_statements = [
"REASSIGN OWNED BY \"{{name}}\" TO ${local.owner_role};", # reassign must be executed in the database where the reassgined objects are - TODO (one connection per database/app) "REASSIGN OWNED BY \"{{name}}\" TO ${local.owner_role};", # reassign must be executed in the database where the reassgined objects are - TODO (one connection per database/app)
+2
View File
@@ -17,6 +17,8 @@ vault: &vault_config
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
traefik.ingress.kubernetes.io/router.tls.domains.0.main: arcodange.lab traefik.ingress.kubernetes.io/router.tls.domains.0.main: arcodange.lab
traefik.ingress.kubernetes.io/router.tls.domains.0.sans: vault.arcodange.lab traefik.ingress.kubernetes.io/router.tls.domains.0.sans: vault.arcodange.lab
# ⚠ Valable parce que cet hôte est en `.lab` (le client arrive en direct sur
# 192.168.1.201). À NE PAS recopier sur un hôte `.fr` : cf. doc/ce-que-traefik-voit.md §3.
traefik.ingress.kubernetes.io/router.middlewares: localIp@file traefik.ingress.kubernetes.io/router.middlewares: localIp@file
hosts: hosts:
- host: vault.arcodange.lab - host: vault.arcodange.lab
+11 -4
View File
@@ -3,8 +3,9 @@
# qu'elles déclarent leurs buckets sans qu'on leur confie le root. # qu'elles déclarent leurs buckets sans qu'on leur confie le root.
# Décisions : factory/doc/adr/20260726-stockage-objet-minio.md # Décisions : factory/doc/adr/20260726-stockage-objet-minio.md
# #
# Noms d'actions issus de la documentation MinIO, NON éprouvés contre le # Noms d'actions confirmés par le premier apply réel (kadans, 2026-07-26) : le
# serveur : le premier apply les confirmera ou les corrigera. # bloc admin passe tel quel ; côté s3 il manquait `s3:ListBucket`, que le
# provider appelle AVANT de créer un bucket pour savoir s'il existe déjà.
resource "minio_iam_policy" "provisioner" { resource "minio_iam_policy" "provisioner" {
name = "provisioner" name = "provisioner"
policy = jsonencode({ policy = jsonencode({
@@ -26,9 +27,15 @@ resource "minio_iam_policy" "provisioner" {
Resource = ["arn:aws:s3:::*"] Resource = ["arn:aws:s3:::*"]
}, },
{ {
# s3:GetObject / s3:PutObject volontairement ABSENTS. # s3:GetObject / s3:PutObject volontairement ABSENTS : le provisionneur
# ne LIT ni n'ÉCRIT aucun objet, c'est ce qui rend son partage entre
# rôles CI acceptable.
#
# `s3:ListBucket` est la seule concession : MinIO le demande pour un
# HeadBucket, et le provider teste l'existence du bucket avant de le
# créer. Il donne la vue des CLÉS d'un bucket, jamais leur contenu.
Effect = "Allow" Effect = "Allow"
Action = ["s3:CreateBucket", "s3:DeleteBucket", "s3:ListAllMyBuckets", "s3:GetBucketLocation", "s3:GetBucketPolicy", "s3:PutBucketPolicy"] Action = ["s3:CreateBucket", "s3:DeleteBucket", "s3:ListBucket", "s3:ListAllMyBuckets", "s3:GetBucketLocation", "s3:GetBucketPolicy", "s3:PutBucketPolicy"]
Resource = ["arn:aws:s3:::*"] Resource = ["arn:aws:s3:::*"]
}, },
] ]
+4
View File
@@ -5,6 +5,10 @@
# sa propre signature (SigV4), et un défi HTTP Basic casserait le PUT présigné # sa propre signature (SigV4), et un défi HTTP Basic casserait le PUT présigné
# auquel le navigateur ne peut pas répondre. # auquel le navigateur ne peut pas répondre.
# #
# ⚠ Et PAS de `localIp@file` non plus : sur un routeur `.fr`, ce middleware
# laisserait entrer Internet entier (le tunnel se présente en 10.42.x.x, qui est
# dans son `sourceRange`). Mesuré : doc/ce-que-traefik-voit.md §3.
#
# ADR : factory/doc/adr/20260726-stockage-objet-minio.md # ADR : factory/doc/adr/20260726-stockage-objet-minio.md
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
+57 -1
View File
@@ -14,7 +14,63 @@ pgbouncer: &pgbouncer_config
auth_type: scram-sha-256 auth_type: scram-sha-256
auth_query: SELECT uname, phash FROM user_lookup($1) auth_query: SELECT uname, phash FROM user_lookup($1)
ignore_startup_parameters: extra_float_digits # unsupported jdbc extra_float_digits=2 argument ignore_startup_parameters: extra_float_digits # unsupported jdbc extra_float_digits=2 argument
server_reset_query: DEALLOCATE ALL # fix prepared statement already exist (crowdsec) # Ce pgbouncer est PARTAGÉ (crowdsec, plausible, kadans, + le compte que
# Vault utilise sur la base `postgres`). Ce qu'un client laisse derrière
# lui sur une connexion serveur, le client SUIVANT en hérite : le reset
# est la SEULE barrière entre deux clients d'un même pool.
#
# `DEALLOCATE ALL` ne nettoie QUE les requêtes préparées — d'où sa mise en
# place (07e2c6d, « prepared statement already exists » de crowdsec).
# Tout le reste de l'état de session passait au suivant. MESURÉ contre un
# pgbouncer **1.23.1** — la version RÉELLEMENT déployée (image
# ghcr.io/icoretech/pgbouncer-docker:1.23.1-fixed, chart pgbouncer-2.3.1),
# montée avec CETTE configuration extraite du cluster (session,
# pool_size=1) : le client 2, qui n'avait rien demandé, héritait de
# `work_mem=17MB`, du `LISTEN canal_test` du client 1, de sa table TEMP —
# et de son `SET ROLE`, au point de créer des tables appartenant à un
# autre rôle que le sien.
#
# Portée de la fuite, mesurée et non supposée : les pools sont partitionnés
# par (base, utilisateur) — 210 pools, aucun ne mélange deux bases ni deux
# comptes. Le seul héritier possible d'un `SET ROLE` posé par kadans-api
# est un client de la base `kadans` avec le MÊME identifiant éphémère,
# c'est-à-dire kadans-api elle-même. Défaut d'hygiène, pas brèche
# inter-applications.
#
# `DISCARD ALL` est le défaut de pgbouncer, et c'est un SUR-ENSEMBLE strict
# des deux valeurs qui l'ont précédé ici : il contient `DEALLOCATE ALL`
# (donc le correctif crowdsec est conservé — vérifié : deux clients
# successifs préparent le même nom sans erreur) ET
# `SELECT pg_advisory_unlock_all()` (la valeur que pose le sous-chart).
#
# Il ne peut RIEN casser pour un client vivant : en `pool_mode = session`
# la connexion serveur n'est rendue qu'à la déconnexion du client, donc le
# reset ne court jamais entre deux requêtes d'une même session. Vérifié :
# table TEMP, `SET work_mem` et `LISTEN` d'un client VIVANT survivent.
#
# ⚠⚠ CE QUE CETTE LIGNE REND FRAGILE — et c'est l'inverse de ce qu'on croit.
#
# `pool_mode` n'est PAS déclaré ici, donc il vaut `session`, le défaut.
# C'est ce qui rend `DISCARD ALL` sans danger. **Le jour où quelqu'un
# écrira `pool_mode: transaction`, il cassera kadans-api en silence** :
# son `SET ROLE` est posé UNE FOIS à l'ouverture (`AfterConnect`, db.go),
# et en transaction pooling `DISCARD ALL` court ENTRE deux transactions —
# donc le rôle est effacé avant les migrations suivantes. MESURÉ, les
# quatre combinaisons, propriétaire de la table créée :
#
# session + DEALLOCATE ALL → rôle stable ✅ (mais la fuite reste)
# session + DISCARD ALL → rôle stable ✅ ← ce qu'on déploie
# transaction + DEALLOCATE ALL → rôle stable ✅ (par ACCIDENT : la fuite
# qu'on referme est ce qui le sauvait)
# transaction + DISCARD ALL → rôle ÉPHÉMÈRE ❌ le défaut du 28/07,
# qui a mis l'API à terre une demi-journée
#
# Et `poserRoleProprietaire` ne peut pas le voir : sa relecture de
# `current_role` a lieu à l'ouverture, où le rôle est encore correct.
# Ce qui couvre ce cas, c'est la ceinture Vault (`ALTER ROLE … SET ROLE`,
# module app_roles) : un défaut de rôle survit à `DISCARD ALL`. **Avant de
# passer en transaction pooling, vérifier que les baux Vault ont tourné.**
server_reset_query: DISCARD ALL # défaut pgbouncer — ⚠ ne pas réduire : voir ci-dessus
server_idle_timeout: 7200 server_idle_timeout: 7200
pgbouncerExporter: pgbouncerExporter:
enabled: false enabled: false
+2
View File
@@ -61,6 +61,8 @@ ingressRoute:
rule: Host(`analytics.arcodange.lab`) rule: Host(`analytics.arcodange.lab`)
# -- List of [middleware objects](https://doc.traefik.io/traefik/routing/providers/kubernetes-crd/#kind-middleware) for the ingress route. # -- List of [middleware objects](https://doc.traefik.io/traefik/routing/providers/kubernetes-crd/#kind-middleware) for the ingress route.
middlewares: middlewares:
# ⚠ Valable parce que la règle ci-dessus est en `.lab` (le client arrive en direct
# sur 192.168.1.201). À NE PAS recopier sur un hôte `.fr` : cf. doc/ce-que-traefik-voit.md §3.
- name: localIp@file - name: localIp@file
# -- Use an existing secret containing the TLS certificate. # -- Use an existing secret containing the TLS certificate.
tlsSecretName: '' tlsSecretName: ''
+30 -7
View File
@@ -140,13 +140,36 @@ redis: &redis_config
runAsUser: 999 runAsUser: 999
# -- Compute resources used by the container. More info [here](https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/). # -- Compute resources used by the container. More info [here](https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/).
resources: {} #
# limits: # ⚠ CE N'EST PAS DU CONFORT — c'est ce qui empêche Redis de mourir en boucle.
# cpu: 100m #
# memory: 128Mi # Le sous-chart FIGE ses sondes à `timeoutSeconds: 1` sur `redis-cli ping`, et
# requests: # n'expose aucune valeur pour les surcharger. Sur ce matériel (Raspberry Pi),
# cpu: 100m # un conteneur SANS réservation tombe dans la classe QoS `BestEffort` : il est
# memory: 128Mi # le premier affamé quand le nœud est chargé, et le simple lancement de
# `redis-cli` y dépasse la seconde.
#
# Mesuré le 2026-07-29 sur `redis-0` : « Liveness probe failed: command timed
# out: "redis-cli ping" timed out after 1s » **836 fois en 23 jours**, 135
# redémarrages, le conteneur tué par SIGTERM après ~90 s de vie à chaque tour.
#
# ⚠ CE QUE ÇA CASSAIT, ET QUI N'AVAIT RIEN À VOIR AVEC REDIS EN APPARENCE : le
# plugin crowdsec de Traefik utilise ce Redis comme cache de décisions. Cache
# injoignable ⇒ `isCrowdsecStreamHealthy:false` ⇒ le plugin REFUSE PAR DÉFAUT
# tout client non listé dans `clientTrustedIPs` ⇒ **403, corps vide, sur
# `kadans.arcodange.fr` et `gitea.arcodange.fr`**, pendant que les hôtes qui ne
# portent pas ce middleware répondaient normalement. Le symptôme ne nomme ni
# Redis, ni crowdsec, ni la sonde — il ressemble à un bannissement d'IP, et
# c'est par là qu'on cherche d'abord.
#
# Une réservation fait passer le conteneur en `Burstable` et lui garantit sa
# part. Les limites restent larges : Redis n'est pas ce qui sature ce nœud.
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
# -- Pod-level affinity. More info [here](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling). # -- Pod-level affinity. More info [here](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling).
affinity: {} affinity: {}