Files
tools/minio
arcodangeandClaude Opus 5 2cb19809c6
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
doc(reseau) — le confort LAN ne se règle pas dans Traefik : mesure, pièges, voie retenue
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
..

MinIO — stockage objet S3 du homelab

Brique partagée du namespace tools, au même titre que pgbouncer ou clickhouse. Le serveur vit ici ; les buckets, quotas et identifiants d'une application vivent avec cette application.

Premier consommateur : Kadans

  • ADR-012 « MinIO local d'abord » — bascule vers Cloudflare R2 prévue aux seuils : 100+ utilisateurs actifs, > 10 To/mois, ou dispersion géographique.
  • ADR-013 le gratuit est local-first (la vidéo ne quitte pas l'appareil) ; MinIO sert les paliers payants.
  • ADR-018 ce qui transite est dérivé (aperçu 240p ~50 Ko, travail 360p ~3,4 Mo/min) — le master reste chez l'utilisateur. D'où le dimensionnement ci-dessous.

Ce que ce chart pose

Mode standalone (1 réplique) — la donnée est dérivée et Longhorn réplique déjà le volume ; l'erasure coding distribué coûterait de la RAM que des Pi 5 n'ont pas à dépenser pour ça
Volume 50 Gi sur longhorn250 h de cours au palier « travail ». ⚠ Longhorn réplique : compter ×3 sur la capacité du cluster avant d'augmenter
Ressources requests 512 Mi / 100 m · limit 2 Gi — la limite protège les voisins de tools, pas MinIO
API S3 s3.arcodange.lab (interne) et s3.arcodange.fr (public, tunnel Cloudflare → entrypoint web + crowdsec) — voir « Pourquoi une exposition publique »
Console minio.arcodange.lab (Traefik)
Buckets aucun ici — chaque app déclare les siens depuis son dépôt (module minio_app). Tous privés : l'accès passe par des URL signées (ADR-0002 du dossier produit)
Identifiants jamais dans le dépôt : iac/ les génère dans Vault (kvv2/minio/config), le Vault Secrets Operator les matérialise en secret minio-config, le chart les lit via existingSecret

Le ServiceAccount du pod est nommé minio (et non le minio-sa par défaut du chart amont) parce que le module Vault app_roles borne l'authentification au SA portant le nom de l'app : un seul SA, rien à réconcilier.

Première mise en service

L'ordre compte, et il compte deux fois :

  1. Workflow Hashicorp Vault — MinIO doit d'abord figurer dans hashicorp-vault/iac/terraform.tfvars (c'est fait) : c'est que naît le rôle CI gitea_cicd_minio, et non dans minio/iac. Sans cette étape, le workflow MinIO échoue sur role "gitea_cicd_minio" could not be found — il essaie de s'authentifier avec un rôle que personne n'a encore créé.
  2. Workflow MinIO — applique minio/iac : rôle Kubernetes pour le Vault Secrets Operator, et génération du mot de passe root dans kvv2/minio/config.
  3. ArgoCD synchronise l'application (déclarée dans chart/values.yaml).
  4. Vérifier : kubectl -n tools get vaultstaticsecret minio (secret matérialisé) puis kubectl -n tools get pods -l app=minio.

Note

Sans le secret minio-config, le pod ne démarre pas. C'est voulu — mieux vaut un pod en attente qu'un MinIO ouvert avec des identifiants par défaut.

Pourquoi une exposition publique (s3.arcodange.fr)

La PWA Kadans est servie en https://kadans.arcodange.fr et téléverse ses vidéos directement vers MinIO, avec des URL présignées émises par kadans-api (kadans-api#23) : les octets ne passent jamais par l'API.

Deux raisons rendent le .lab inutilisable pour ça, et ce sont des faits du navigateur, pas des préférences :

  1. Contenu mixte — une page servie en https ne peut pas émettre une requête vers http://. L'ingress .lab est en entrypoint web sans TLS.
  2. .lab n'est pas résolvable hors du LAN — la synchronisation ne marcherait qu'à la maison, ce qui vide de son sens « retrouver mes vidéos sur mon autre appareil ».

Pas de basic-auth sur cet ingress, contrairement à kadans-public : une requête S3 porte sa propre signature (SigV4). Un défi HTTP Basic casserait le PUT présigné, auquel le navigateur ne peut pas répondre. L'autorisation vient de l'URL signée et de sa durée de vie courte (15 min pour déposer, 1 h pour lire).

CORS (MINIO_API_CORS_ALLOW_ORIGIN) liste les origines EXACTES de la PWA — 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

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.

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.

Donner à une app l'accès au stockage

Rien à faire ici. Chaque application déclare ses buckets depuis son propre dépôt, avec le module que ce dépôt-ci fournit :

# iac/main.tf de l'application
data "vault_kv_secret_v2" "minio_provisioner" {
  mount = "kvv2"
  name  = "minio/provisioner"
}

provider "minio" {
  minio_server   = "s3.arcodange.fr"
  minio_user     = data.vault_kv_secret_v2.minio_provisioner.data["MINIO_ACCESS_KEY"]
  minio_password = data.vault_kv_secret_v2.minio_provisioner.data["MINIO_SECRET_KEY"]
  minio_ssl      = true
}

module "stockage" {
  source    = "git::…/tools.git//minio/iac/modules/minio_app?depth=1&ref=main"
  app       = "mon-app"
  buckets   = ["mon-app-fichiers"]
  providers = { minio = minio }
}

Voir iac/modules/minio_app/README.md. Chacun son périmètre : tools fournit le serveur, le provisionneur et le module — pas la liste des buckets. Sans ça, chaque bucket de chaque app deviendrait une PR sur l'infra partagée.

Ce que tools fournit, et pourquoi

Pièce Rôle
Le serveur le chart, son volume, ses ingress (interne + public)
Le root généré ici, écrit dans kvv2/minio/config, ne sort jamais de ce pipeline
Le provisionneur un compte aux droits d'administration MINIMAUX (créer bucket, politique, compte de service) et aucun droit sur les objets — lisible par le rôle CI de chaque app
Le module minio_app : standardise la déclaration, sans la détenir

Donner le root aux apps aurait été absurde : il lit et écrit tous les objets de toutes les apps. Le provisionneur, lui, peut créer des buckets — une nuisance si une app est compromise — mais pas lire les vidéos d'une autre.

Rotation

Depuis l'iac/ de l'app : détruire module.stockage.random_password.app et relancer son plan. La clé change, force_destroy = false garde le compte, et les objets déjà déposés conservent leur propriétaire.