Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2202414c0 | ||
|
|
f9adc2ec23 | ||
|
|
49763852d8 | ||
|
|
f53c3adb7d | ||
|
|
7d13a8d764 | ||
|
|
1dd905dc5f | ||
|
|
2cb19809c6 | ||
|
|
9de9a9663b | ||
|
|
2bdc486ae6 | ||
|
|
901aa9a9dc | ||
|
|
e8ab19962b | ||
|
|
5c7dce96e1 | ||
|
|
7074a94d7e | ||
|
|
ec71571b98 | ||
|
|
0e1b6e1062 | ||
|
|
287e3dcf1e | ||
|
|
91a0f09b49 | ||
|
|
4ca4a05370 | ||
|
|
f49a79e393 | ||
|
|
1490f4c514 | ||
|
|
dce19141a8 | ||
|
|
d146affbcd | ||
|
|
2202e7bbfe | ||
|
|
84edfc8640 | ||
|
|
342c4ab346 | ||
|
|
5fbc7f6ff8 | ||
|
|
bbf6653464 | ||
|
|
347ebee1b0 | ||
|
|
53710d9fde | ||
|
|
6f398f4ddc | ||
|
|
ffce6990a3 | ||
|
|
b9626d878b | ||
|
|
bc62304bc3 | ||
|
|
8e236230d3 |
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
name: MinIO
|
||||||
|
|
||||||
|
# À LA DEMANDE, et seulement à la demande. Deux raisons, chacune suffisante :
|
||||||
|
#
|
||||||
|
# 1. Ce workflow ne PEUT PAS aboutir sans un humain : l'auth Vault passe par
|
||||||
|
# un flux OIDC dont le lien doit être ouvert dans un navigateur connecté.
|
||||||
|
# Déclenché tout seul, il occupe un runner jusqu'à son timeout — et retarde
|
||||||
|
# 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:
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
# cancel any previously-started, yet still active runs of this workflow on the same branch
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.ref }}-${{ github.workflow }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
.vault_step: &vault_step
|
||||||
|
name: read vault secret
|
||||||
|
uses: https://gitea.arcodange.lab/arcodange-org/vault-action.git@main
|
||||||
|
id: vault-secrets
|
||||||
|
with:
|
||||||
|
url: https://vault.arcodange.lab
|
||||||
|
caCertificate: ${{ secrets.HOMELAB_CA_CERT }}
|
||||||
|
jwtGiteaOIDC: ${{ needs.gitea_vault_auth.outputs.gitea_vault_jwt }}
|
||||||
|
role: gitea_cicd_minio
|
||||||
|
method: jwt
|
||||||
|
path: gitea_jwt
|
||||||
|
secrets: |
|
||||||
|
kvv1/google/credentials credentials | GOOGLE_BACKEND_CREDENTIALS ;
|
||||||
|
kvv1/gitea/tofu_module_reader ssh_private_key | TERRAFORM_SSH_KEY ;
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gitea_vault_auth:
|
||||||
|
name: Auth with gitea for vault
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
gitea_vault_jwt: ${{steps.gitea_vault_jwt.outputs.id_token}}
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- name: Auth with gitea for vault
|
||||||
|
id: gitea_vault_jwt
|
||||||
|
run: |
|
||||||
|
echo -n "${{ secrets.vault_oauth__sh_b64 }}" | base64 -d | bash
|
||||||
|
|
||||||
|
tofu:
|
||||||
|
name: Tofu - minio IAC
|
||||||
|
needs:
|
||||||
|
- gitea_vault_auth
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
OPENTOFU_VERSION: 1.8.2
|
||||||
|
TERRAFORM_VAULT_AUTH_JWT: ${{ needs.gitea_vault_auth.outputs.gitea_vault_jwt }}
|
||||||
|
VAULT_CACERT: "${{ github.workspace }}/homelab.pem"
|
||||||
|
steps:
|
||||||
|
- *vault_step
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: prepare vault self signed cert
|
||||||
|
run: echo -n "${{ secrets.HOMELAB_CA_CERT }}" | base64 -d > $VAULT_CACERT
|
||||||
|
- name: terraform apply
|
||||||
|
uses: dflook/terraform-apply@v1
|
||||||
|
with:
|
||||||
|
path: minio/iac
|
||||||
|
auto_approve: true
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -2,12 +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
|
||||||
|
|
||||||
on: #[push,pull_request]
|
# À LA DEMANDE, et seulement à la demande — comme minio.yaml, et pour les mêmes
|
||||||
|
# deux raisons : l'auth Vault exige qu'un humain ouvre un lien OIDC (sans lui, le
|
||||||
|
# run squatte un runner jusqu'au timeout), et l'apply se fait en `auto_approve`
|
||||||
|
# contre la prod.
|
||||||
|
#
|
||||||
|
# ⚠ Ce qui change AUSSI de nature : `hashicorp-vault/**/*.tfvars` compte autant
|
||||||
|
# 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:
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
push: &vaultPaths
|
|
||||||
paths:
|
|
||||||
- 'hashicorp-vault/**/*.tf'
|
|
||||||
pull_request: *vaultPaths
|
|
||||||
|
|
||||||
# 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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ tools:
|
|||||||
grafana: {}
|
grafana: {}
|
||||||
plausible: {}
|
plausible: {}
|
||||||
prometheus: {}
|
prometheus: {}
|
||||||
|
minio: {}
|
||||||
|
|||||||
@@ -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` |
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Exposition PUBLIQUE grafana.arcodange.fr, EN PLUS du .lab interne (qui reste
|
||||||
|
# inchangé derrière son middleware localIp@file). Pattern kadans
|
||||||
|
# (chart/templates/ingress-public.yaml) : entrypoint `web` — le TLS est terminé
|
||||||
|
# en amont par le tunnel Cloudflare (wildcard *.arcodange.fr →
|
||||||
|
# traefik.kube-system.svc:80, cf. cms/cloudflare module cf_tunnel) — et
|
||||||
|
# middleware crowdsec (WAF/bouncer, convention .fr).
|
||||||
|
# Pas de basic-auth ici, contrairement à kadans : Grafana a sa propre
|
||||||
|
# authentification (login requis, pas d'accès anonyme dans grafana.ini).
|
||||||
|
#
|
||||||
|
# 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:
|
||||||
|
name: grafana-public
|
||||||
|
namespace: tools
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: web
|
||||||
|
traefik.ingress.kubernetes.io/router.middlewares: kube-system-crowdsec@kubernetescrd
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
rules:
|
||||||
|
- host: grafana.arcodange.fr
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: grafana
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
@@ -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
|
||||||
@@ -724,6 +726,14 @@ grafana: &grafana_config
|
|||||||
editable: true
|
editable: true
|
||||||
options:
|
options:
|
||||||
path: /var/lib/grafana/dashboards/prospection
|
path: /var/lib/grafana/dashboards/prospection
|
||||||
|
- name: 'kadans'
|
||||||
|
orgId: 1
|
||||||
|
folder: 'Kadans'
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
editable: true
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards/kadans
|
||||||
|
|
||||||
# - name: 'default'
|
# - name: 'default'
|
||||||
# orgId: 1
|
# orgId: 1
|
||||||
@@ -778,6 +788,121 @@ grafana: &grafana_config
|
|||||||
k8s-views-pods:
|
k8s-views-pods:
|
||||||
url: https://raw.githubusercontent.com/dotdc/grafana-dashboards-kubernetes/master/dashboards/k8s-views-pods.json
|
url: https://raw.githubusercontent.com/dotdc/grafana-dashboards-kubernetes/master/dashboards/k8s-views-pods.json
|
||||||
token: ''
|
token: ''
|
||||||
|
# Jobs d'analyse Kadans : la file de la façade (pod kadans-jobs, annoté
|
||||||
|
# prometheus.io/*) + le worker du LAPTOP (cible statique kadans-worker-mac).
|
||||||
|
# Couleurs de STATUT sémantiques (pending/running/done/failed) — jamais
|
||||||
|
# recyclées pour de l'identité ; le sommeil du Mac est un ÉTAT, pas une
|
||||||
|
# alerte (« sa disponibilité n'impacte que la latence »).
|
||||||
|
kadans:
|
||||||
|
kadans-jobs:
|
||||||
|
json: |
|
||||||
|
{
|
||||||
|
"annotations": { "list": [] },
|
||||||
|
"editable": true,
|
||||||
|
"graphTooltip": 1,
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"tags": ["kadans", "jobs", "arcodange"],
|
||||||
|
"title": "Kadans — jobs d'analyse (façade + worker Mac)",
|
||||||
|
"uid": "kadans-jobs-analyse",
|
||||||
|
"time": { "from": "now-24h", "to": "now" },
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"type": "stat", "title": "Worker Mac", "id": 1,
|
||||||
|
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 0 },
|
||||||
|
"targets": [{ "expr": "up{job=\"kadans-worker-mac\"}", "refId": "A" }],
|
||||||
|
"fieldConfig": { "defaults": {
|
||||||
|
"mappings": [
|
||||||
|
{ "type": "value", "options": { "1": { "text": "En écoute", "color": "green" }, "0": { "text": "Endormi", "color": "text" } } },
|
||||||
|
{ "type": "special", "options": { "match": "null", "result": { "text": "Endormi", "color": "text" } } }
|
||||||
|
],
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [{ "color": "text", "value": null }] }
|
||||||
|
}, "overrides": [] },
|
||||||
|
"options": { "colorMode": "value", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat", "title": "Dernier poll (il y a)", "id": 2,
|
||||||
|
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 0 },
|
||||||
|
"targets": [{ "expr": "time() - kadans_worker_dernier_poll_timestamp_seconds", "refId": "A" }],
|
||||||
|
"fieldConfig": { "defaults": {
|
||||||
|
"unit": "s", "decimals": 0,
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [
|
||||||
|
{ "color": "green", "value": null }, { "color": "yellow", "value": 600 }
|
||||||
|
] }
|
||||||
|
}, "overrides": [] },
|
||||||
|
"options": { "colorMode": "value", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat", "title": "Jobs en attente", "id": 3,
|
||||||
|
"gridPos": { "h": 5, "w": 6, "x": 12, "y": 0 },
|
||||||
|
"targets": [{ "expr": "sum(kadans_jobs_jobs{statut=\"pending\"})", "refId": "A" }],
|
||||||
|
"fieldConfig": { "defaults": {
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [{ "color": "text", "value": null }] }
|
||||||
|
}, "overrides": [] },
|
||||||
|
"options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat", "title": "Plus ancien job en attente", "id": 4,
|
||||||
|
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 0 },
|
||||||
|
"targets": [{ "expr": "kadans_jobs_plus_ancien_pending_age_seconds", "refId": "A" }],
|
||||||
|
"fieldConfig": { "defaults": {
|
||||||
|
"unit": "s", "decimals": 0,
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [
|
||||||
|
{ "color": "green", "value": null },
|
||||||
|
{ "color": "yellow", "value": 3600 },
|
||||||
|
{ "color": "red", "value": 86400 }
|
||||||
|
] }
|
||||||
|
}, "overrides": [] },
|
||||||
|
"options": { "colorMode": "value", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries", "title": "La file, par statut", "id": 5,
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 },
|
||||||
|
"targets": [{ "expr": "kadans_jobs_jobs", "legendFormat": "{{statut}}", "refId": "A" }],
|
||||||
|
"fieldConfig": { "defaults": {
|
||||||
|
"custom": { "lineWidth": 2, "fillOpacity": 8, "pointSize": 4 }, "min": 0
|
||||||
|
}, "overrides": [
|
||||||
|
{ "matcher": { "id": "byName", "options": "pending" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "yellow" } }] },
|
||||||
|
{ "matcher": { "id": "byName", "options": "running" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } }] },
|
||||||
|
{ "matcher": { "id": "byName", "options": "done" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } }] },
|
||||||
|
{ "matcher": { "id": "byName", "options": "failed" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }] }
|
||||||
|
] },
|
||||||
|
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries", "title": "Publications (par heure)", "id": 6,
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 5 },
|
||||||
|
"targets": [{ "expr": "increase(kadans_jobs_publies_total[1h])", "legendFormat": "{{issue}}", "refId": "A" }],
|
||||||
|
"fieldConfig": { "defaults": {
|
||||||
|
"custom": { "lineWidth": 2, "fillOpacity": 8, "pointSize": 4 }, "min": 0, "decimals": 0
|
||||||
|
}, "overrides": [
|
||||||
|
{ "matcher": { "id": "byName", "options": "done" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } }] },
|
||||||
|
{ "matcher": { "id": "byName", "options": "failed" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }] }
|
||||||
|
] },
|
||||||
|
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries", "title": "Lanes exécutées par le worker (par heure)", "id": 7,
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 13 },
|
||||||
|
"targets": [{ "expr": "increase(kadans_worker_lanes_total[1h])", "legendFormat": "{{lane}} — {{issue}}", "refId": "A" }],
|
||||||
|
"fieldConfig": { "defaults": {
|
||||||
|
"custom": { "lineWidth": 2, "fillOpacity": 8, "pointSize": 4 }, "min": 0, "decimals": 0
|
||||||
|
}, "overrides": [] },
|
||||||
|
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries", "title": "Durée moyenne d'une lane", "id": 8,
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 },
|
||||||
|
"targets": [{
|
||||||
|
"expr": "increase(kadans_worker_lane_duree_seconds_sum[1h]) / increase(kadans_worker_lane_duree_seconds_count[1h])",
|
||||||
|
"legendFormat": "{{lane}}", "refId": "A"
|
||||||
|
}],
|
||||||
|
"fieldConfig": { "defaults": {
|
||||||
|
"unit": "s", "custom": { "lineWidth": 2, "fillOpacity": 0, "pointSize": 5 }, "min": 0
|
||||||
|
}, "overrides": [] },
|
||||||
|
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
prospection:
|
prospection:
|
||||||
prospection:
|
prospection:
|
||||||
json: |
|
json: |
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
locals {
|
locals {
|
||||||
factory_crowdsec_conf_sa_name = "factory-ansible-tool-crowdsec-traefik-plugin"
|
factory_crowdsec_conf_sa_name = "factory-ansible-tool-crowdsec-traefik-plugin"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ data "vault_policy_document" "ops" {
|
|||||||
path = "kvv1/google/credentials"
|
path = "kvv1/google/credentials"
|
||||||
capabilities = ["read"]
|
capabilities = ["read"]
|
||||||
}
|
}
|
||||||
|
# Provisionneur MinIO — l'app crée ses buckets depuis son dépôt sans le root.
|
||||||
|
# ADR : factory/doc/adr/20260726-stockage-objet-minio.md
|
||||||
|
rule {
|
||||||
|
path = "kvv2/data/minio/provisioner"
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
|
||||||
# read cloudflare related secrets
|
# read cloudflare related secrets
|
||||||
rule {
|
rule {
|
||||||
path = "kvv1/cloudflare/${local.name}*"
|
path = "kvv1/cloudflare/${local.name}*"
|
||||||
@@ -178,6 +185,19 @@ data "vault_policy_document" "app" {
|
|||||||
path = "postgres/creds/${local.name}*"
|
path = "postgres/creds/${local.name}*"
|
||||||
capabilities = ["read"]
|
capabilities = ["read"]
|
||||||
}
|
}
|
||||||
|
# Ses identifiants MinIO. INCONDITIONNEL : le chemin porte le nom de l'app,
|
||||||
|
# donc la règle ne peut exposer que ses propres clés ; une app sans stockage
|
||||||
|
# lit un chemin qui n'existe pas.
|
||||||
|
# ADR : factory/doc/adr/20260726-stockage-objet-minio.md
|
||||||
|
rule {
|
||||||
|
path = "kvv2/data/minio/${local.name}/*"
|
||||||
|
capabilities = ["read", "list"]
|
||||||
|
}
|
||||||
|
rule {
|
||||||
|
# Le document lui-même (la règle ci-dessus ne couvre que ses descendants).
|
||||||
|
path = "kvv2/data/minio/${local.name}"
|
||||||
|
capabilities = ["read", "list"]
|
||||||
|
}
|
||||||
# Extra shared paths this app's prod runtime may read (e.g. backup creds).
|
# Extra shared paths this app's prod runtime may read (e.g. backup creds).
|
||||||
dynamic "rule" {
|
dynamic "rule" {
|
||||||
for_each = var.kv_read_paths
|
for_each = var.kv_read_paths
|
||||||
@@ -204,6 +224,15 @@ data "vault_policy_document" "app_non_prod" {
|
|||||||
path = "postgres/creds/${each.key}*"
|
path = "postgres/creds/${each.key}*"
|
||||||
capabilities = ["read"]
|
capabilities = ["read"]
|
||||||
}
|
}
|
||||||
|
# Idem prod : chaque instance lit les identifiants MinIO portant SON nom.
|
||||||
|
rule {
|
||||||
|
path = "kvv2/data/minio/${each.key}/*"
|
||||||
|
capabilities = ["read", "list"]
|
||||||
|
}
|
||||||
|
rule {
|
||||||
|
path = "kvv2/data/minio/${each.key}"
|
||||||
|
capabilities = ["read", "list"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
resource "vault_policy" "app_non_prod" {
|
resource "vault_policy" "app_non_prod" {
|
||||||
for_each = toset(local.non_prod_instances)
|
for_each = toset(local.non_prod_instances)
|
||||||
|
|||||||
@@ -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 là où 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)
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ applications = [
|
|||||||
name = "plausible"
|
name = "plausible"
|
||||||
service_account_namespaces = ["tools"]
|
service_account_namespaces = ["tools"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name = "minio"
|
||||||
|
service_account_namespaces = ["tools"]
|
||||||
|
},
|
||||||
{ name = "prospection" },
|
{ name = "prospection" },
|
||||||
{ name = "kadans" },
|
{ name = "kadans" },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# MinIO — stockage objet S3 du homelab (3 × Raspberry Pi 5, arm64).
|
||||||
|
#
|
||||||
|
# Pourquoi ici : c'est une BRIQUE PARTAGÉE, au même titre que pgbouncer ou
|
||||||
|
# clickhouse — le namespace `tools` en héberge le serveur ; les buckets, quotas
|
||||||
|
# et identifiants d'une application vivent, eux, avec cette application.
|
||||||
|
#
|
||||||
|
# Premier consommateur : Kadans (ADR-012 « MinIO local d'abord », ADR-013
|
||||||
|
# « OPFS local-first, MinIO/R2 = paliers payants »). La bascule vers Cloudflare
|
||||||
|
# R2 est prévue par l'ADR-012 aux seuils : 100+ utilisateurs actifs, > 10 To/mois
|
||||||
|
# de bande passante, ou dispersion géographique.
|
||||||
|
#
|
||||||
|
# Mode STANDALONE assumé : 3 nœuds, mais la donnée servie ici est DÉRIVÉE (le
|
||||||
|
# master d'une vidéo reste sur l'appareil de son propriétaire — ADR-018 du front) ;
|
||||||
|
# la redondance de Longhorn suffit, l'erasure coding distribué de MinIO coûterait
|
||||||
|
# de la RAM et des IOPS que des Pi n'ont pas à dépenser pour ça.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
apiVersion: v2
|
||||||
|
name: minio
|
||||||
|
description: A Helm chart for Kubernetes
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- name: tool
|
||||||
|
version: 0.1.0
|
||||||
|
repository: https://gitea.arcodange.lab/api/packages/arcodange-org/helm
|
||||||
|
- name: minio
|
||||||
|
version: 5.4.0
|
||||||
|
repository: https://charts.min.io/
|
||||||
|
|
||||||
|
# A chart can be either an 'application' or a 'library' chart.
|
||||||
|
#
|
||||||
|
# Application charts are a collection of templates that can be packaged into versioned archives
|
||||||
|
# to be deployed.
|
||||||
|
#
|
||||||
|
# Library charts provide useful utilities or functions for the chart developer. They're included as
|
||||||
|
# a dependency of application charts to inject those utilities and functions into the rendering
|
||||||
|
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
|
||||||
|
type: application
|
||||||
|
version: 0.1.0
|
||||||
|
appVersion: "latest"
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
# 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](https://gitea.arcodange.lab/arcodange/kadans/src/branch/main/docs/adr/012-video-storage-minio-first.md)
|
||||||
|
« MinIO local d'abord » — bascule vers Cloudflare R2 prévue aux seuils :
|
||||||
|
100+ utilisateurs actifs, > 10 To/mois, ou dispersion géographique.
|
||||||
|
- [ADR-013](https://gitea.arcodange.lab/arcodange/kadans/src/branch/main/docs/adr/013-video-storage-opfs-local-first.md)
|
||||||
|
le gratuit est **local-first** (la vidéo ne quitte pas l'appareil) ; MinIO sert
|
||||||
|
les **paliers payants**.
|
||||||
|
- [ADR-018](https://gitea.arcodange.lab/arcodange/kadans/src/branch/main/docs/adr/018-qualite-video-au-transfert.md)
|
||||||
|
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 `longhorn` ≈ **250 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 **là** 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 :
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
# 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.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
terraform {
|
||||||
|
backend "gcs" {
|
||||||
|
bucket = "arcodange-tf"
|
||||||
|
prefix = "tools/minio/main"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Rôle Vault + politique pour que le VaultAuth du chart puisse lire kvv2/minio/*
|
||||||
|
# (module partagé du repo — même recette que plausible).
|
||||||
|
module "app_roles" {
|
||||||
|
source = "git::ssh://[email protected]:2222/arcodange-org/tools.git//hashicorp-vault/iac/modules/app_roles?depth=1&ref=main"
|
||||||
|
name = "minio"
|
||||||
|
service_account_namespaces = ["tools"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Identifiants ROOT de MinIO. Générés ici, jamais choisis à la main et jamais
|
||||||
|
# écrits dans le dépôt : le chart officiel les lit dans le secret k8s
|
||||||
|
# `minio-config` (clés rootUser / rootPassword), matérialisé par le Vault
|
||||||
|
# Secrets Operator depuis ce chemin.
|
||||||
|
resource "random_password" "root" {
|
||||||
|
length = 40
|
||||||
|
special = false # les outils S3 transportent mal certains caractères en URL
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
config = {
|
||||||
|
rootUser = "kadans-root"
|
||||||
|
rootPassword = random_password.root.result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "vault_kv_secret_v2" "config" {
|
||||||
|
mount = "kvv2"
|
||||||
|
name = "minio/config"
|
||||||
|
cas = 1
|
||||||
|
data_json = jsonencode(local.config)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# `minio_app` — déclarer ses buckets depuis SON dépôt
|
||||||
|
|
||||||
|
Chacun son périmètre : les buckets d'une application appartiennent au dépôt de
|
||||||
|
cette application. Ce module **standardise** la déclaration, il ne la détient
|
||||||
|
pas — sans lui, chaque bucket de chaque app deviendrait une PR sur `tools`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Dans l'`iac/` de l'app :
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
# Le provisionneur MinIO : des droits d'administration MINIMAUX (créer un
|
||||||
|
# bucket, un compte de service, une politique), jamais le root — qui, lui, ne
|
||||||
|
# sort pas du pipeline `minio`.
|
||||||
|
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::ssh://[email protected]:2222/arcodange-org/tools.git//minio/iac/modules/minio_app?depth=1&ref=main"
|
||||||
|
app = "mon-app" # = le nom de son rôle Vault
|
||||||
|
buckets = ["mon-app-fichiers"]
|
||||||
|
providers = { minio = minio }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis, côté chart : une `VaultStaticSecret` sur `kvv2/minio/<app>` et l'injection
|
||||||
|
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 ;
|
||||||
|
- 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
|
||||||
|
l'app, donc il ne peut exposer que ses propres clés).
|
||||||
|
|
||||||
|
## Plusieurs buckets
|
||||||
|
|
||||||
|
C'est le cas courant : deux contenus aux **cycles de vie différents** méritent
|
||||||
|
deux buckets. Il suffit de les lister — le compte de service existant gagne
|
||||||
|
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.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Module `minio_app` — une app déclare SES buckets depuis SON dépôt.
|
||||||
|
# Décisions : factory/doc/adr/20260726-stockage-objet-minio.md
|
||||||
|
|
||||||
|
resource "minio_s3_bucket" "app" {
|
||||||
|
for_each = toset(var.buckets)
|
||||||
|
bucket = each.key
|
||||||
|
acl = "private" # l'accès passe par des URL présignées
|
||||||
|
force_destroy = false # détruire un bucket doit être un geste explicite
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "minio_iam_policy" "app" {
|
||||||
|
name = "${var.app}-app"
|
||||||
|
policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Effect = "Allow"
|
||||||
|
Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
|
||||||
|
Resource = [for b in var.buckets : "arn:aws:s3:::${b}/*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Effect = "Allow"
|
||||||
|
Action = ["s3:ListBucket", "s3:GetBucketLocation"]
|
||||||
|
Resource = [for b in var.buckets : "arn:aws:s3:::${b}"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "random_password" "app" {
|
||||||
|
length = 40
|
||||||
|
special = false # les outils S3 transportent mal certains caractères en URL
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "minio_iam_user" "app" {
|
||||||
|
name = "${var.app}-app"
|
||||||
|
secret = random_password.app.result
|
||||||
|
force_destroy = false # une rotation ne recrée pas l'utilisateur : les objets gardent leur propriétaire
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "minio_iam_user_policy_attachment" "app" {
|
||||||
|
user_name = minio_iam_user.app.id
|
||||||
|
policy_name = minio_iam_policy.app.id
|
||||||
|
}
|
||||||
|
|
||||||
|
# Lu par le pod de l'app — `app_policy` lui accorde déjà ce chemin.
|
||||||
|
resource "vault_kv_secret_v2" "app" {
|
||||||
|
mount = "kvv2"
|
||||||
|
name = "minio/${var.app}"
|
||||||
|
data_json = jsonencode({
|
||||||
|
MINIO_ENDPOINT = var.endpoint
|
||||||
|
MINIO_BUCKET = var.buckets[0]
|
||||||
|
MINIO_BUCKETS = join(",", var.buckets)
|
||||||
|
MINIO_ACCESS_KEY = minio_iam_user.app.id
|
||||||
|
MINIO_SECRET_KEY = random_password.app.result
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
output "vault_path" {
|
||||||
|
value = "kvv2/minio/${var.app}"
|
||||||
|
description = "Où le pod lira ses identifiants (VaultStaticSecret)."
|
||||||
|
}
|
||||||
|
|
||||||
|
output "buckets" {
|
||||||
|
value = var.buckets
|
||||||
|
description = "Écho des buckets créés."
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
terraform {
|
||||||
|
required_providers {
|
||||||
|
minio = {
|
||||||
|
source = "aminueza/minio"
|
||||||
|
configuration_aliases = [minio]
|
||||||
|
}
|
||||||
|
vault = {
|
||||||
|
source = "hashicorp/vault"
|
||||||
|
}
|
||||||
|
random = {
|
||||||
|
source = "hashicorp/random"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
variable "app" {
|
||||||
|
type = string
|
||||||
|
description = "Nom de l'app (= son rôle Vault). Décide du chemin du secret et du nom du compte de service."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "buckets" {
|
||||||
|
type = list(string)
|
||||||
|
description = "Ses buckets, créés ici (privés). Le compte de service n'a de droits que sur eux."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "endpoint" {
|
||||||
|
type = string
|
||||||
|
default = "s3.arcodange.fr"
|
||||||
|
description = "Hôte de l'API S3, sans schéma. Public : le runner CI n'est pas dans le LAN."
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
terraform {
|
||||||
|
required_providers {
|
||||||
|
vault = {
|
||||||
|
source = "vault"
|
||||||
|
version = "4.4.0"
|
||||||
|
}
|
||||||
|
minio = {
|
||||||
|
source = "aminueza/minio"
|
||||||
|
version = "3.3.0"
|
||||||
|
}
|
||||||
|
random = {
|
||||||
|
source = "hashicorp/random"
|
||||||
|
version = "3.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "vault" {
|
||||||
|
address = "https://vault.arcodange.lab"
|
||||||
|
auth_login_jwt { # TERRAFORM_VAULT_AUTH_JWT environment variable
|
||||||
|
mount = "gitea_jwt"
|
||||||
|
role = "gitea_cicd_minio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Provider MinIO — crée le compte de provisionnement (provisioner.tf).
|
||||||
|
provider "minio" {
|
||||||
|
minio_server = var.minio_endpoint
|
||||||
|
minio_user = local.config.rootUser
|
||||||
|
minio_password = local.config.rootPassword
|
||||||
|
minio_ssl = true
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Compte de PROVISIONNEMENT : crée buckets, politiques et comptes de service —
|
||||||
|
# aucun droit sur les objets. C'est lui que lisent les rôles CI des apps, pour
|
||||||
|
# qu'elles déclarent leurs buckets sans qu'on leur confie le root.
|
||||||
|
# Décisions : factory/doc/adr/20260726-stockage-objet-minio.md
|
||||||
|
#
|
||||||
|
# Noms d'actions confirmés par le premier apply réel (kadans, 2026-07-26) : le
|
||||||
|
# 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" {
|
||||||
|
name = "provisioner"
|
||||||
|
policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Effect = "Allow"
|
||||||
|
Action = [
|
||||||
|
"admin:CreateUser",
|
||||||
|
"admin:DeleteUser",
|
||||||
|
"admin:ListUsers",
|
||||||
|
"admin:GetUser",
|
||||||
|
"admin:CreatePolicy",
|
||||||
|
"admin:DeletePolicy",
|
||||||
|
"admin:GetPolicy",
|
||||||
|
"admin:ListUserPolicies",
|
||||||
|
"admin:AttachUserOrGroupPolicy",
|
||||||
|
]
|
||||||
|
Resource = ["arn:aws:s3:::*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
# 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"
|
||||||
|
Action = ["s3:CreateBucket", "s3:DeleteBucket", "s3:ListBucket", "s3:ListAllMyBuckets", "s3:GetBucketLocation", "s3:GetBucketPolicy", "s3:PutBucketPolicy"]
|
||||||
|
Resource = ["arn:aws:s3:::*"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "random_password" "provisioner" {
|
||||||
|
length = 40
|
||||||
|
special = false
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "minio_iam_user" "provisioner" {
|
||||||
|
name = "provisioner"
|
||||||
|
secret = random_password.provisioner.result
|
||||||
|
force_destroy = false
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "minio_iam_user_policy_attachment" "provisioner" {
|
||||||
|
user_name = minio_iam_user.provisioner.id
|
||||||
|
policy_name = minio_iam_policy.provisioner.id
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "vault_kv_secret_v2" "provisioner" {
|
||||||
|
mount = "kvv2"
|
||||||
|
name = "minio/provisioner"
|
||||||
|
data_json = jsonencode({
|
||||||
|
MINIO_ENDPOINT = var.minio_endpoint
|
||||||
|
MINIO_ACCESS_KEY = minio_iam_user.provisioner.id
|
||||||
|
MINIO_SECRET_KEY = random_password.provisioner.result
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
variable "minio_endpoint" {
|
||||||
|
type = string
|
||||||
|
default = "s3.arcodange.fr"
|
||||||
|
description = "Hôte de l'API S3, sans schéma. Public : le runner CI n'est pas dans le LAN."
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{{- if eq .Values.tool.kind "HelmChart" -}}
|
||||||
|
{{- include "tool.helm-chart-config.tpl" . -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{{- if eq .Values.tool.kind "HelmChart" -}}
|
||||||
|
{{- include "tool.helm-chart.tpl" . -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Exposition PUBLIQUE s3.arcodange.fr — TLS terminé par le tunnel Cloudflare,
|
||||||
|
# middleware crowdsec. Le `.lab` interne reste inchangé.
|
||||||
|
#
|
||||||
|
# ⚠ PAS de basic-auth ici, contrairement à kadans-public : une requête S3 porte
|
||||||
|
# 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
|
||||||
|
metadata:
|
||||||
|
name: minio-public
|
||||||
|
namespace: tools
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: web
|
||||||
|
traefik.ingress.kubernetes.io/router.middlewares: kube-system-crowdsec@kubernetescrd
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
rules:
|
||||||
|
- host: s3.arcodange.fr
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: minio
|
||||||
|
port:
|
||||||
|
number: 9000
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{{- /* Identifiants MinIO : jamais dans le dépôt. Le Vault Secrets Operator
|
||||||
|
authentifie ce chart auprès de Vault (rôle créé par iac/) puis
|
||||||
|
matérialise le secret k8s consommé via `existingSecret`. Même recette
|
||||||
|
que plausible. */ -}}
|
||||||
|
apiVersion: secrets.hashicorp.com/v1beta1
|
||||||
|
kind: VaultAuth
|
||||||
|
metadata:
|
||||||
|
name: minio
|
||||||
|
namespace: tools
|
||||||
|
spec:
|
||||||
|
vaultConnectionRef: default
|
||||||
|
method: kubernetes
|
||||||
|
mount: kubernetes
|
||||||
|
kubernetes:
|
||||||
|
role: minio
|
||||||
|
serviceAccount: minio
|
||||||
|
audiences:
|
||||||
|
- vault
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{{- /* kvv2/minio/config → secret `minio-config` (clés rootUser / rootPassword,
|
||||||
|
les deux noms exigés par le chart officiel). Le mot de passe est généré
|
||||||
|
par Terraform (iac/), jamais choisi à la main. */ -}}
|
||||||
|
apiVersion: secrets.hashicorp.com/v1beta1
|
||||||
|
kind: VaultStaticSecret
|
||||||
|
metadata:
|
||||||
|
name: minio
|
||||||
|
namespace: tools
|
||||||
|
spec:
|
||||||
|
type: kv-v2
|
||||||
|
mount: kvv2
|
||||||
|
path: minio/config
|
||||||
|
destination:
|
||||||
|
name: minio-config
|
||||||
|
create: true
|
||||||
|
refreshAfter: 30s
|
||||||
|
vaultAuthRef: minio
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
minio: &minio_config
|
||||||
|
# Image officielle MinIO — multi-arch, arm64 inclus (les nœuds sont des Pi 5).
|
||||||
|
image:
|
||||||
|
repository: quay.io/minio/minio
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
# STANDALONE : un seul serveur, un seul volume. La donnée servie ici est
|
||||||
|
# DÉRIVÉE (le master reste chez l'utilisateur) et Longhorn réplique déjà le
|
||||||
|
# volume ; l'erasure coding distribué coûterait de la RAM que les Pi n'ont pas
|
||||||
|
# à dépenser pour ça. `replicas` est ignoré en standalone.
|
||||||
|
mode: standalone
|
||||||
|
replicas: 1
|
||||||
|
|
||||||
|
# Le rôle Vault du module `app_roles` borne l'authentification au SERVICE
|
||||||
|
# ACCOUNT nommé comme l'app (`bound_service_account_names = [minio]`) : on
|
||||||
|
# aligne donc le SA du pod sur ce nom, plutôt que le « minio-sa » par défaut
|
||||||
|
# du chart amont — un SA pour le pod, le même pour Vault, rien à réconcilier.
|
||||||
|
serviceAccount:
|
||||||
|
create: true
|
||||||
|
name: minio
|
||||||
|
|
||||||
|
# Identifiants JAMAIS dans le dépôt : le secret est matérialisé par le
|
||||||
|
# Vault Secrets Operator depuis kvv2/minio/config (voir resources/ et iac/).
|
||||||
|
# Le chart lit `.data.rootUser` et `.data.rootPassword` de ce secret.
|
||||||
|
existingSecret: minio-config
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
storageClass: longhorn
|
||||||
|
accessMode: ReadWriteOnce
|
||||||
|
# 50 Gi ≈ 250 heures de cours au palier « travail » de Kadans (360p,
|
||||||
|
# 3,4 Mo/min — ADR-018 du front). Longhorn réplique ce volume sur les
|
||||||
|
# nœuds : compter ×3 sur la capacité du cluster avant d'augmenter.
|
||||||
|
size: 50Gi
|
||||||
|
|
||||||
|
# Des Pi 5 à 8 Go partagés avec le reste de `tools` : on borne franchement.
|
||||||
|
# MinIO standalone est frugal ; la limite protège les voisins, pas MinIO.
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 512Mi
|
||||||
|
cpu: 100m
|
||||||
|
limits:
|
||||||
|
memory: 2Gi
|
||||||
|
|
||||||
|
# API S3 (les applications parlent ici).
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
ingressClassName: traefik
|
||||||
|
path: /
|
||||||
|
hosts:
|
||||||
|
- s3.arcodange.lab
|
||||||
|
|
||||||
|
# Console d'administration (humains).
|
||||||
|
consoleIngress:
|
||||||
|
enabled: true
|
||||||
|
ingressClassName: traefik
|
||||||
|
path: /
|
||||||
|
hosts:
|
||||||
|
- minio.arcodange.lab
|
||||||
|
|
||||||
|
# Buckets créés au déploiement. `versioning: false` assumé : ces objets sont
|
||||||
|
# DÉRIVÉS et re-générables depuis le master local — versionner doublerait le
|
||||||
|
# stockage pour un filet dont on n'a pas besoin.
|
||||||
|
# AUCUN bucket ici : chaque app déclare les siens depuis son dépôt, via le
|
||||||
|
# module `iac/modules/minio_app`.
|
||||||
|
# ADR : factory/doc/adr/20260726-stockage-objet-minio.md
|
||||||
|
buckets: []
|
||||||
|
|
||||||
|
# Métriques : Prometheus (namespace `tools`) scrape déjà la façade et le
|
||||||
|
# laptop (ADR-0014 du dossier) — MinIO rejoint la même vue.
|
||||||
|
metrics:
|
||||||
|
serviceMonitor:
|
||||||
|
enabled: false # pas d'opérateur Prometheus ici : scrape par annotation
|
||||||
|
environment:
|
||||||
|
MINIO_PROMETHEUS_AUTH_TYPE: "public"
|
||||||
|
# CORS : le navigateur téléverse directement (URL présignées) — origines
|
||||||
|
# EXACTES, jamais « * » : une URL qui fuite serait sinon rejouable partout.
|
||||||
|
MINIO_API_CORS_ALLOW_ORIGIN: "https://kadans.arcodange.fr,https://kadans.arcodange.lab"
|
||||||
|
|
||||||
|
tool:
|
||||||
|
# kind: 'SubChart' or 'HelmChart', if subchart then uncomment Chart.yaml dependency, else comment and use tool library with helm chart template
|
||||||
|
kind: 'SubChart'
|
||||||
|
repo: https://charts.min.io/
|
||||||
|
chart: minio
|
||||||
|
version: 5.4.0
|
||||||
|
values: *minio_config
|
||||||
+57
-1
@@ -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
|
||||||
|
|||||||
@@ -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: ''
|
||||||
|
|||||||
+102
-8
@@ -819,6 +819,22 @@ prometheus: &prometheus_config
|
|||||||
static_configs:
|
static_configs:
|
||||||
- targets:
|
- targets:
|
||||||
- localhost:9090
|
- localhost:9090
|
||||||
|
# Le worker d'analyse Kadans sur le LAPTOP du fondateur (tier 1 VOLATILE,
|
||||||
|
# kadans-jobs cmd/worker, /metrics stdlib sur :9105). Un scrape en échec
|
||||||
|
# n'est PAS un incident : le Mac dort — `up == 0` raconte exactement ça
|
||||||
|
# (latence des analyses, jamais une indisponibilité produit). Ne PAS
|
||||||
|
# alerter sur cette cible.
|
||||||
|
# ⚠ IP DHCP : réserver 192.168.1.103 pour le Mac au routeur (ou remplacer
|
||||||
|
# par un nom résolvable du LAN) — sinon la cible dérive au renouvellement.
|
||||||
|
kadans-worker-mac:
|
||||||
|
enabled: true
|
||||||
|
job_name: "kadans-worker-mac"
|
||||||
|
static_configs:
|
||||||
|
- targets:
|
||||||
|
- 192.168.1.103:9105
|
||||||
|
labels:
|
||||||
|
tier: laptop
|
||||||
|
app: kadans-worker
|
||||||
kubernetes-api-servers:
|
kubernetes-api-servers:
|
||||||
enabled: true
|
enabled: true
|
||||||
job_name: ""
|
job_name: ""
|
||||||
@@ -1121,9 +1137,9 @@ prometheus: &prometheus_config
|
|||||||
alerting_rules.yml:
|
alerting_rules.yml:
|
||||||
groups:
|
groups:
|
||||||
# Pipeline prospection (métriques poussées au Pushgateway job=prospection en fin de
|
# Pipeline prospection (métriques poussées au Pushgateway job=prospection en fin de
|
||||||
# run). NB : la LIVRAISON des alertes (Alertmanager → Telegram/…) n'est pas encore
|
# run). Livraison : Alertmanager → receiver telegram (chaîne testée live 2026-07-10,
|
||||||
# câblée dans ce cluster — ces règles s'évaluent et sont visibles dans Prometheus
|
# firing + resolved reçus). Visibles aussi dans Prometheus /alerts + le dashboard
|
||||||
# /alerts + le dashboard Grafana « Prospection » (panneau Alertes actives).
|
# Grafana « Prospection » (panneau Alertes actives).
|
||||||
- name: prospection
|
- name: prospection
|
||||||
rules:
|
rules:
|
||||||
- alert: ProspectionRunStale
|
- alert: ProspectionRunStale
|
||||||
@@ -1162,15 +1178,93 @@ prometheus: &prometheus_config
|
|||||||
annotations:
|
annotations:
|
||||||
summary: "Prospection — 0 offre (mission) collectée"
|
summary: "Prospection — 0 offre (mission) collectée"
|
||||||
description: "Aucune offre au dernier run : collecte France Travail / Free-Work potentiellement cassée."
|
description: "Aucune offre au dernier run : collecte France Travail / Free-Work potentiellement cassée."
|
||||||
- alert: ProspectionBriefNotSent
|
# Vidéo quotidienne : deux modes d'échec distincts, tous deux gardés contre le
|
||||||
expr: prospection_brief_telegram_pushed == 0
|
# skip volontaire — metrics.py met brief_rendered=0 aussi quand l'étape est
|
||||||
|
# SAUTÉE (cadence/kill-switch), seul step_status distingue skip(2) d'erreur(0).
|
||||||
|
- alert: ProspectionBriefFailed
|
||||||
|
# PRODUCTION en échec : l'étape brief a tourné mais n'a pas rendu la vidéo.
|
||||||
|
expr: prospection_brief_rendered == 0 and on(job) prospection_step_status{step="brief"} != 2
|
||||||
for: 15m
|
for: 15m
|
||||||
labels:
|
labels:
|
||||||
severity: info
|
severity: warning
|
||||||
app: prospection
|
app: prospection
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Prospection — brief non poussé sur Telegram"
|
summary: "Prospection — la vidéo du brief quotidien n'a pas été produite"
|
||||||
description: "Le brief vidéo n'a pas été diffusé sur Telegram au dernier run."
|
description: "L'étape brief a échoué au dernier run (rendu vidéo KO : TTS/ffmpeg/PIL ou erreur amont). Voir les logs du CronJob prospection."
|
||||||
|
- alert: ProspectionBriefNotSent
|
||||||
|
# LIVRAISON en échec : vidéo produite mais pas poussée sur Telegram.
|
||||||
|
expr: prospection_brief_telegram_pushed == 0 and prospection_brief_rendered == 1
|
||||||
|
for: 15m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
app: prospection
|
||||||
|
annotations:
|
||||||
|
summary: "Prospection — brief produit mais non poussé sur Telegram"
|
||||||
|
description: "La vidéo du brief a été rendue mais l'envoi Telegram a échoué (token/chat_id/API). Voir les logs du CronJob prospection."
|
||||||
|
# Santé du socle : être prévenu quand (ou juste avant que) le homelab tombe.
|
||||||
|
# Incident 2026-07-23 : build CI sans limites sur pi1 → RAM épuisée (0 swap),
|
||||||
|
# load15 >100, traefik + apiserver k3s affamés → tout *.arcodange.lab injoignable
|
||||||
|
# (Gitea compris, pourtant sain sur pi2). Prometheus vit sur pi3 et Alertmanager
|
||||||
|
# sur pi2 : cette chaîne d'alerte survit donc à la perte de pi1.
|
||||||
|
- name: homelab
|
||||||
|
rules:
|
||||||
|
- alert: NoeudInjoignable
|
||||||
|
# node-exporter ne répond plus : nœud éteint, réseau HS, ou surcharge telle
|
||||||
|
# que plus rien n'y répond (le cas de l'incident).
|
||||||
|
expr: up{job="kubernetes-service-endpoints", app_kubernetes_io_name="prometheus-node-exporter"} == 0
|
||||||
|
for: 3m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
app: homelab
|
||||||
|
annotations:
|
||||||
|
summary: "Homelab — {{ $labels.node }} est injoignable"
|
||||||
|
description: "node-exporter de {{ $labels.node }} ({{ $labels.instance }}) ne répond plus depuis 3 min : nœud down ou en surcharge sévère."
|
||||||
|
- alert: IngressLabIndisponible
|
||||||
|
# Plus aucun replica traefik dispo — ou kube-state-metrics muet (il vit sur
|
||||||
|
# pi1 : quand pi1 tombe, la métrique disparaît au lieu de passer à 0).
|
||||||
|
expr: >-
|
||||||
|
kube_deployment_status_replicas_available{namespace="kube-system",deployment="traefik"} < 1
|
||||||
|
or absent(kube_deployment_status_replicas_available{namespace="kube-system",deployment="traefik"})
|
||||||
|
for: 3m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
app: homelab
|
||||||
|
annotations:
|
||||||
|
summary: "Homelab — ingress traefik indisponible : *.arcodange.lab est HS"
|
||||||
|
description: "Aucun replica traefik disponible (ou métrique absente = kube-state-metrics muet). Gitea, ArgoCD, Grafana, Vault… sont injoignables via leurs URLs .lab. Gitea reste accessible en direct : http://192.168.1.202:3000."
|
||||||
|
- alert: NoeudPressionMemoire
|
||||||
|
# Précurseur direct de l'incident : <500 Mo dispo sur un Pi 8 Go — sans
|
||||||
|
# swap, le kernel part en thrash bien avant d'atteindre 0.
|
||||||
|
expr: node_memory_MemAvailable_bytes < 500 * 1024 * 1024
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
app: homelab
|
||||||
|
annotations:
|
||||||
|
summary: "Homelab — mémoire critique sur {{ $labels.node }}"
|
||||||
|
description: "{{ $labels.node }} n'a plus que {{ $value | humanize1024 }}B de mémoire disponible depuis 5 min : risque imminent de thrash (pas de swap sur les Pis). Suspect n°1 : un job CI trop gourmand."
|
||||||
|
- alert: NoeudEnSurcharge
|
||||||
|
expr: node_load15 > 8
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
app: homelab
|
||||||
|
annotations:
|
||||||
|
summary: "Homelab — {{ $labels.node }} en surcharge (load15 = {{ $value | humanize }})"
|
||||||
|
description: "load15 > 8 depuis 10 min sur {{ $labels.node }} (4 cœurs) : quelque chose sature la machine, l'ingress et l'API k3s sont en danger si c'est pi1."
|
||||||
|
- alert: CertificatNonRenouvele
|
||||||
|
# Les certs .lab durent 24 h et cert-manager renouvelle à ~16 h d'âge : s'il
|
||||||
|
# reste <4 h, le renouvellement a raté plusieurs fois (incident 2026-07-24 :
|
||||||
|
# step-issuer ne résolvait plus ssl-ca.arcodange.lab, wildcard expiré au matin
|
||||||
|
# → « le https ne fonctionne plus » sur tout *.arcodange.lab).
|
||||||
|
expr: certmanager_certificate_expiration_timestamp_seconds - time() < 4 * 3600
|
||||||
|
for: 15m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
app: homelab
|
||||||
|
annotations:
|
||||||
|
summary: "Homelab — cert {{ $labels.namespace }}/{{ $labels.name }} expire dans {{ $value | humanizeDuration }}"
|
||||||
|
description: "Le renouvellement automatique (cert-manager → step-issuer → step-ca) est en échec. Vérifier : kubectl get certificaterequest -A, logs step-issuer (résolution DNS de ssl-ca.arcodange.lab), santé de step-ca sur pi1:8443."
|
||||||
# groups:
|
# groups:
|
||||||
# - name: Instances
|
# - name: Instances
|
||||||
# rules:
|
# rules:
|
||||||
|
|||||||
+30
-7
@@ -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: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user