Author SHA1 Message Date
arcodangeandClaude Opus 5 6d09adab0c feat(minio) — un téléversement abandonné laisse des parts que RIEN ne montre, et le plafond du tunnel est enfin mesuré
Helm Charts / Detect changed charts (pull_request) Successful in 26s
Helm Charts / Library charts tool (pull_request) Skipped
Helm Charts / Application charts pgcat (pull_request) Skipped
## Le plafond, mesuré — la section du README qui disait « à vérifier » ne le dit plus

`minio/README.md` portait : « ce plafond n'a PAS été mesuré ici, il doit l'être
avec un vrai téléversement avant d'annoncer une limite aux utilisateurs ».

Sonde par PUT NON SIGNÉ vers le bucket : rien ne s'écrit, et les deux réponses se
distinguent proprement — un 403 vient de MinIO (le corps a donc traversé le
tunnel), un 413 vient du tunnel.

    100 Mio → 403   corps passé
    101 Mio → 413   refusé par le tunnel

⚠ Le plafond est EXACTEMENT 100 Mio, alors que kadans-api annonçait 200 Mio.

⚠ Et la parade retenue n'est PAS celle que le README recommandait. Ramener le
gabarit sous le plafond aurait aussi fermé les cours longs (~29 min au palier
« travail »). C'est le téléversement en PARTS qui a été livré (kadans-api #188).

## Ce que ça crée comme déchet, et à qui il appartient

Un téléversement en parts jamais refermé laisse ses parts dans le bucket :
`mc ls` n'en dit rien, la console non plus, aucun objet ne les montre. Une fuite
qui ne se voit qu'à la facture — ou à la saturation d'un volume de 50 Gi.

L'app abandonne ce qu'elle ouvre quand elle échoue en route. Elle ne peut PAS
rattraper le navigateur qui ferme l'onglet.

⚠ LE README DU MODULE DISAIT « il ne pose ni quota, ni règle de cycle de vie » —
et cette règle-ci ne le contredit pas, elle en précise la frontière. La question
qui tranche est « à QUOI cette connaissance appartient-elle ? » :

  - une EXPIRATION DE CONTENU (« ces vidéos se purgent à 90 jours ») demande de
    connaître le produit. Elle est chez l'app ;
  - un téléversement incomplet n'est le contenu de PERSONNE. Aucune app ne veut
    le garder, aucune ne peut le voir depuis son code. C'est un déchet de
    PROTOCOLE, produit par le mécanisme même du bucket : il est chez celui qui
    crée les buckets.

Test pratique écrit au README : si répondre à « combien de temps ? » exige de
connaître le produit, c'est chez l'app. Ici la réponse n'exige que de connaître
S3 — passé l'expiration des URL signées (2 h côté kadans-api), un téléversement
ne peut plus RIEN recevoir. D'où 1 jour, douze fois la marge, et pas 7.

⚠ Non paramétrable (YAGNI) : un seul cas. Le déclencheur pour en faire une
variable est écrit — une app qui signerait des parts au-delà de 24 h.

## ⚠ Ce réglage-ci fonctionne, contrairement au CORS par bucket

MinIO communautaire stubbe `PutBucketCors` en 501 (`cmd/dummy-handlers.go`), ce
qui avait déjà coûté une tentative d'IaC — c'est écrit dans le `iac/main.tf` du
dépôt front. Les handlers de CYCLE DE VIE, eux, n'y figurent PAS : vérifié à la
source avant d'écrire une ligne, pour ne pas répéter exactement cette erreur.

## Le plancher de version, et pourquoi il est là

`abort_incomplete_multipart_upload` est apparu en **3.10.0** — mesuré en
interrogeant le schéma du provider version par version (3.9.0 ne l'a pas,
3.10.0 l'a). Le module déclare donc `>= 3.10.0` LUI-MÊME.

⚠ Sans ce plancher, un appelant resté sur 3.3.0 échouerait au plan sur un
« unsupported block type » qui ne dit pas qu'il faut monter de version. Avec, il
lit dès `tofu init` : « no available releases match the given constraints 3.3.0,
>= 3.10.0 ».

## ⚠ ORDRE DE FUSION

Cette PR fait passer tout appelant du module sous le plancher 3.10.0. Le dépôt
`kadans` épingle encore 3.3.0 : **son bump doit atterrir AVANT celle-ci**, sinon
son apply casse entre les deux fusions.

## Preuve

`tofu validate` contre le schéma RÉEL du provider 3.10.0, module instancié depuis
un bac à sable (aucun backend, aucun appel à MinIO ni Vault) : « Success! The
configuration is valid. »

Et le plancher a été éprouvé plutôt que relu : épinglé à 3.3.0, `tofu init` rend
bien le refus cité ci-dessus.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 20:06:02 +02:00
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 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
12 changed files with 409 additions and 23 deletions
+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
```
## 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
## 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
# helm-chart*.yaml ne rendent rien ; ce fichier, lui, est rendu tel quel et
# 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
kind: Ingress
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.domains.0.main: 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
hosts:
- grafana.arcodange.lab
+2
View File
@@ -17,6 +17,8 @@ vault: &vault_config
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.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
hosts:
- host: vault.arcodange.lab
+35 -12
View File
@@ -78,20 +78,43 @@ l'URL signée et de sa durée de vie courte (15 min pour déposer, 1 h pour lire
jamais `*` : une URL présignée qui fuiterait serait sinon rejouable depuis
n'importe quel site.
### ⚠ À vérifier avant de s'y fier : la taille maximale d'une requête
### La taille maximale d'une requête — **mesurée le 2026-08-20**
Le trafic public passe par un **tunnel Cloudflare**. Les offres gratuites de
Cloudflare plafonnent la taille du corps d'une requête proxifiée (de l'ordre de
**100 Mo**) — ce plafond n'a **pas** été mesuré ici, il doit l'être avec un vrai
téléversement avant d'annoncer une limite aux utilisateurs.
Le trafic public passe par un **tunnel Cloudflare**, qui plafonne la taille du
corps d'une requête proxifiée. Ce plafond était marqué ici « à vérifier avant de
s'y fier ». Il l'est.
Ce qu'on sait, en revanche, et qui rend le sujet peu urgent : sur le corpus réel
du fondateur (707 vidéos, ~2 ans), **la durée moyenne est de 53 secondes** et
**deux vidéos seulement dépassent 5 minutes**. Au palier « travail » de l'ADR-018
(360p ≈ 3,4 Mo/min), 100 Mo représentent ~29 minutes de cours : le corpus entier
passe très largement. Si la limite se confirme, le plafond de 200 Mio annoncé
côté API mérite d'être ramené sous celle du tunnel — mieux vaut refuser tôt, avec
une phrase claire, qu'échouer au milieu d'un téléversement.
Sonde : un PUT **non signé** vers le bucket. Rien ne s'écrit, et les deux
réponses se distinguent proprement — un 403 vient de MinIO (donc le corps a
traversé le tunnel), un 413 vient du tunnel lui-même.
| Corps | Réponse | Lecture |
|---:|---|---|
| 50 Mio | `403` | corps passé, refus de signature |
| **100 Mio** | `403` | **corps passé** |
| **101 Mio** | `413` | **refusé par le tunnel** |
| 200 Mio | `413` | refusé par le tunnel |
**Le plafond est exactement 100 Mio.** Or `kadans-api` annonçait un gabarit de
200 Mio — le **double**. Une vidéo entre les deux était acceptée, signée,
téléversée pendant ~100 Mo… puis coupée.
**La parade retenue n'est PAS celle que cette section recommandait.** Ramener
le gabarit sous le plafond du tunnel aurait aussi fermé les cours longs (au
palier « travail » de l'ADR-018, 360p ≈ 3,4 Mo/min, 100 Mio ≈ 29 min). C'est le
**téléversement en plusieurs parts** qui a été livré (kadans-api PR #188) : des
parts de 8 Mio passent chacune très en dessous du plafond, sans rétrécir la
promesse.
Le corpus, lui, reste largement sous la limite : sur les 707 vidéos du fondateur
(~2 ans), **la durée moyenne est de 53 secondes** et **deux seulement dépassent
5 minutes**. C'est ce qui explique que le défaut n'ait jamais été rencontré — et
pourquoi il attendait le premier cours entier.
**Conséquence pour le bucket, et elle est ici :** un téléversement en parts
jamais refermé laisse des parts que *rien ne montre*. Le module `minio_app` pose
donc sur chaque bucket une règle de cycle de vie
`AbortIncompleteMultipartUpload` à **1 jour** — voir son `main.tf`.
## Donner à une app l'accès au stockage
+47 -3
View File
@@ -38,6 +38,8 @@ des variables dans le Deployment.
## Ce que le module garantit
- les buckets sont **privés** — l'accès passe par des URL présignées ;
- les **téléversements abandonnés** sont ramassés au bout d'un jour (voir plus
bas : c'est de l'hygiène de protocole, pas un choix de l'app) ;
- le compte de service ne peut **rien** toucher d'autre que ces buckets-là ;
- ses clés vont dans `kvv2/minio/<app>`, que le module Vault central autorise
déjà l'app à lire (règle **inconditionnelle** : le chemin porte le nom de
@@ -51,6 +53,48 @@ l'accès, **sans nouvelle clé**.
## Ce que le module ne fait PAS
Il ne pose ni quota, ni règle de cycle de vie, ni versioning : ces choix
appartiennent à l'app et varient d'un bucket à l'autre. À ajouter le jour où
un besoin réel apparaît, pas avant.
Il ne pose ni quota, ni **expiration de contenu**, ni versioning : ces choix
appartiennent à l'app et varient d'un bucket à l'autre. « Ces vidéos se purgent
à 90 jours » est une décision de produit, elle se prend dans le dépôt du
produit.
### ⚠ L'exception, et la ligne qu'elle trace
Ce module pose **une seule** règle de cycle de vie :
`AbortIncompleteMultipartUpload` à 1 jour, sur chaque bucket qu'il crée.
Elle a l'air de contredire le paragraphe ci-dessus. Elle ne le contredit pas —
elle en précise la frontière, et c'est la question « à QUOI cette connaissance
appartient-elle ? » qui tranche :
- une **expiration de contenu** porte sur des objets que l'app a voulus, qu'elle
montre, et dont elle seule sait combien de temps ils valent. Elle varie d'une
app à l'autre : elle est chez l'app ;
- un **téléversement en plusieurs parts jamais refermé** n'est le contenu de
personne. Ce sont des morceaux qu'aucune API ne montre — ni `mc ls`, ni la
console — laissés par un navigateur qui a fermé l'onglet. **Aucune app ne veut
les garder**, et aucune ne peut les voir depuis son propre code. C'est un
déchet de PROTOCOLE, produit par le mécanisme même du bucket : il est chez
celui qui crée les buckets.
Le test pratique : si la réponse à « combien de temps ? » demande de connaître le
produit, c'est chez l'app. Ici, la réponse ne demande que de connaître S3 — passé
l'expiration des URL signées, un téléversement en cours ne peut plus rien
recevoir, il occupe seulement.
⚠ Le délai n'est **pas** paramétrable, et c'est voulu (YAGNI) : un seul cas
existe. Le jour où une app signe des parts pour plus de 24 h, ce sera le
déclencheur pour en faire une variable — pas avant.
### Plancher de version
⚠ Ce module exige **`aminueza/minio >= 3.10.0`** : `abort_incomplete_multipart_upload`
y est apparu (mesuré en interrogeant le schéma du provider version par version —
3.9.0 ne l'a pas). Un appelant resté plus bas se verra dire, dès `tofu init` :
```
no available releases match the given constraints 3.3.0, >= 3.10.0
```
… ce qui NOMME la version à atteindre, au lieu d'un « unsupported block type »
au plan, qui ne l'aurait pas dite.
+42
View File
@@ -8,6 +8,48 @@ resource "minio_s3_bucket" "app" {
force_destroy = false # détruire un bucket doit être un geste explicite
}
# Les téléversements ABANDONNÉS ne s'accumulent pas
#
# Un téléversement S3 en plusieurs parts qui n'est jamais refermé laisse ses
# parts dans le bucket : elles occupent de l'espace et AUCUN objet ne les
# montre. `mc ls` n'en dit rien, la console non plus. C'est donc une fuite qui
# ne se voit qu'à la facture ou à la saturation du volume, qui est ici de
# 50 Gi et déjà dimensionné à 250 h de cours.
#
# L'application abandonne ce qu'elle ouvre quand elle échoue en route. Ce
# qu'elle ne peut PAS rattraper : un navigateur qui ferme l'onglet, perd le
# réseau, ou expire. Ce cas- appartient au BUCKET, pas au code applicatif
# c'est la seule place d' l'on voit un téléversement que plus personne ne
# suit.
#
# POURQUOI UN JOUR, ET PAS SEPT. Les URL de parts sont signées pour quelques
# heures (2 h côté kadans-api). Passé ce délai, un téléversement en cours ne
# peut PLUS rien recevoir : il est mort, il occupe seulement. Un jour laisse
# douze fois la marge nécessaire au plus long téléversement possible, sans
# garder des déchets une semaine.
#
# CE N'EST PAS PARAMÉTRABLE, ET C'EST VOULU (YAGNI) : un seul cas existe. Le
# jour une app signe des parts pour plus de 24 h, ce sera le déclencheur pour
# en faire une variable pas avant.
#
# CE RÉGLAGE-CI FONCTIONNE VRAIMENT, contrairement au CORS par bucket. MinIO
# édition communautaire stubbe `PutBucketCors` (501, cf. `cmd/dummy-handlers.go`
# du serveur), ce qui a déjà coûté une tentative d'IaC ; les handlers de CYCLE DE
# VIE, eux, n'y figurent PAS vérifié à la source le 2026-08-20.
resource "minio_ilm_policy" "app" {
for_each = minio_s3_bucket.app
bucket = each.value.bucket
rule {
id = "abandon-televersements-incomplets"
status = "Enabled"
abort_incomplete_multipart_upload {
days_after_initiation = "1d"
}
}
}
resource "minio_iam_policy" "app" {
name = "${var.app}-app"
policy = jsonencode({
+7
View File
@@ -2,6 +2,13 @@ terraform {
required_providers {
minio = {
source = "aminueza/minio"
# PLANCHER, ET IL N'EST PAS DÉCORATIF : `abort_incomplete_multipart_upload`
# est apparu en 3.10.0 (mesuré en interrogeant le schéma du provider,
# version par version : 3.9.0 ne l'a pas, 3.10.0 l'a). Sans cette
# contrainte, un appelant resté sur 3.3.0 échouerait au plan avec un
# « unsupported block type » qui ne dit pas qu'il faut monter de version.
# Le plancher fait dire à tofu la vraie phrase, au bon moment.
version = ">= 3.10.0"
configuration_aliases = [minio]
}
vault = {
+4
View File
@@ -5,6 +5,10 @@
# sa propre signature (SigV4), et un défi HTTP Basic casserait le PUT présigné
# 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
apiVersion: networking.k8s.io/v1
kind: Ingress
+2
View File
@@ -61,6 +61,8 @@ ingressRoute:
rule: Host(`analytics.arcodange.lab`)
# -- List of [middleware objects](https://doc.traefik.io/traefik/routing/providers/kubernetes-crd/#kind-middleware) for the ingress route.
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
# -- Use an existing secret containing the TLS certificate.
tlsSecretName: ''
+30 -7
View File
@@ -140,13 +140,36 @@ redis: &redis_config
runAsUser: 999
# -- Compute resources used by the container. More info [here](https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/).
resources: {}
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
#
# ⚠ CE N'EST PAS DU CONFORT — c'est ce qui empêche Redis de mourir en boucle.
#
# Le sous-chart FIGE ses sondes à `timeoutSeconds: 1` sur `redis-cli ping`, et
# n'expose aucune valeur pour les surcharger. Sur ce matériel (Raspberry Pi),
# un conteneur SANS réservation tombe dans la classe QoS `BestEffort` : il est
# 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).
affinity: {}