Notes techniques¶
Documentation "as-built" : ce qui est réellement déployé, avec quelle configuration, et les difficultés rencontrées en le construisant. Complète architecture.md (vision et choix cibles) sans le dupliquer — mise à jour à chaque nouvelle brique ajoutée au projet, pas rédigée une fois pour toutes.
État actuel¶
Roadmap Phase 0 ("Socle") complète, sur un cluster k3d local de développement (myown-dev) — pas encore le mini PC cible ni de nom de domaine réel. Tous les services sont exposés en HTTP (pas HTTPS) sur des hosts myown-<service>.local:8090, à ajouter soi-même dans /etc/hosts.
| Brique | Version déployée |
|---|---|
| k3d | v5.9.0 (Kubernetes via k3s v1.35.5+k3s1) |
| ArgoCD | v3.5.0 |
| kube-prometheus-stack | chart 88.1.5 |
Uptime Kuma (chart dirsigler/uptime-kuma-helm) |
chart 4.1.0, app 2.3.0 |
| Authentik | chart/app 2026.5.6 |
| PostgreSQL (Authentik, via sous-chart Bitnami) | 17.10-bookworm |
Vaultwarden (chart guerzon/vaultwarden) |
chart 0.46.0, app 1.37.1 |
Restic (image restic/restic) |
0.19.1 |
Nextcloud (chart officiel nextcloud/helm) |
chart 9.2.5, app 34.0.2 |
| PostgreSQL (Nextcloud, via sous-chart Bitnami) | 17.5.0 |
Immich (chart officiel immich/immich) |
chart 0.12.0, app v2.6.3 |
PostgreSQL (Immich, image officielle ghcr.io/immich-app/postgres, StatefulSet maison) |
14-vectorchord0.4.3-pgvectors0.2.0 |
Tuwunel (image officielle ghcr.io/matrix-construct/tuwunel, manifests maison) |
v1.8.3 |
LiveKit (chart officiel livekit/livekit-server) |
chart 1.9.0, image livekit/livekit-server:v1.13.5 (surchargée, cf. section LiveKit) |
lk-jwt-service (image ghcr.io/element-hq/lk-jwt-service, manifests maison) |
latest (pas de tag versionné publié par le projet) |
Ollama (chart otwld/ollama-helm) |
chart 1.74.0, app 0.32.9, modèle qwen3:8b |
| SOPS | 3.10.2 |
| KSOPS | v4.5.1 |
Infrastructure de base¶
Cluster de dev (k3d)¶
Cluster dédié myown-dev, séparé de tout autre cluster local existant sur la même machine (pas de mutualisation — cf. réflexion dans vision-long-terme.md sur l'isolation par projet). Ports du load balancer k3d mappés explicitement à la création (-p "8090:80@loadbalancer" -p "8453:443@loadbalancer") pour permettre un accès HTTP/HTTPS stable — ce mapping ne peut pas être ajouté après coup à un cluster existant, il faut le prévoir dès la création ou recréer le cluster.
Traefik est inclus par défaut dans k3s/k3d — aucune installation séparée nécessaire, y compris pour la future prod sur mini PC (même comportement par défaut).
Incident (2026-08-20) : après un redémarrage de la box internet (changement d'IP DHCP) suivi d'un redémarrage de la machine hôte, k3d-myown-dev-server-0 restait bloqué en boucle de redémarrage, cluster entièrement inaccessible. Diagnostic : échec systématique et identique à chaque tentative — le nœud serveur perdait la course entre le démarrage de son apiserver et l'initialisation du backend VXLAN de flannel (Failed to get node for backend data: dial tcp 127.0.0.1:6443: connect: connection refused), pas un problème de cache de certificat TLS (vérifié : les deux IP historiques du nœud étaient bien couvertes par dynamic-cert.json). Confirmé en parallèle dans les logs dockerd : une erreur de nettoyage de règles nftables juste après le redémarrage du service Docker suivant le reboot (Deleting nftables IPv4 rules ... delete table ip docker-bridges: No such file or directory) — signe que l'état réseau de Docker n'avait pas été proprement réinitialisé après le reboot de la machine. Un simple k3d cluster stop/start ne suffisait pas, le nœud retombait identiquement dans le même échec à chaque tentative ; résolu uniquement par un redémarrage complet du service Docker (sudo systemctl restart docker), qui force une réinitialisation propre des règles réseau.
Cet artefact précis est spécifique à k3d (k3s imbriqué dans des conteneurs Docker, outil de dev uniquement) — moins probable sous cette forme exacte sur le k3s bare-metal du futur mini PC. Mais la catégorie de risque plus large (recovery après un changement réseau nécessitant un redémarrage de service, sans intervention technique possible pour un utilisateur lambda) reste réelle sur toute infra — mitigations retenues dans roadmap.md Phase 4 (IP fixe DHCP pour le mini PC, watchdog de remédiation automatique) et Phase 6 (bouton de réparation dans le dashboard familial).
ArgoCD¶
Installé via le manifest officiel (kubectl apply --server-side -f .../install.yaml) — --server-side est obligatoire, un apply classique échoue sur les CRDs d'ArgoCD (annotation metadata.annotations limitée à 262144 octets par l'API Kubernetes, dépassée par les CRDs volumineuses appliquées en client-side).
argocd-server tourne en mode insecure (TLS géré par l'Ingress plutôt que par ArgoCD lui-même) pour simplifier l'exposition locale — à revoir en prod où le TLS sera géré par Traefik + Let's Encrypt sur le vrai domaine.
Pattern app-of-apps : une seule Application racine (gitops/bootstrap/root-app.yaml) surveille gitops/apps/, chaque fichier de ce dossier est une Application pour un service. Voir gitops/README.md.
Secrets (SOPS + age + KSOPS)¶
Décidé après discussion explicite : décryptage automatique par ArgoCD (KSOPS) plutôt que sops -d | kubectl apply manuel, malgré la complexité de mise en place plus élevée — pour que la reconstruction du cluster reste entièrement automatique une fois la clé privée restaurée, dans l'idée que ce projet doit rester installable proprement par quelqu'un d'autre.
Mécanisme : un init-container (viaductoss/ksops:v4.5.1) copie un binaire kustomize incluant ksops dans argocd-repo-server, en lieu et place du kustomize standard. La clé privée age est montée depuis un Secret Kubernetes (sops-age, namespace argocd) créé hors GitOps (bootstrap manuel, comme ArgoCD lui-même). Détail complet et commandes exactes dans CLAUDE.md.
Incident : la clé privée age initiale n'avait jamais été sauvegardée nulle part (probablement affichée dans un terminal puis perdue) — rotation vers un nouvel emplacement standard (~/.config/sops/age/keys.txt, lu automatiquement par sops sans variable d'environnement) sans conséquence puisqu'aucun secret n'était encore chiffré avec l'ancienne clé.
Monitoring (kube-prometheus-stack + Uptime Kuma)¶
Alertmanager désactivé (alertmanager.enabled: false) — hors périmètre documenté dans architecture.md. Rétention Prometheus réduite à 3 jours pour un cluster de dev.
Uptime Kuma choisi via un chart communautaire (dirsigler/uptime-kuma-helm, 303 étoiles, maintenu activement) faute de chart officiel — le projet Uptime Kuma lui-même n'en publie pas. Vérifié avant usage : le chart pointe bien vers l'image Docker officielle (louislam/uptime-kuma), donc seule l'empaquetage Helm est tiers, pas l'application.
Incidents rencontrés (deux, indépendants) :
- CRDs Prometheus Operator trop volumineuses pour
kubectl applyclassique — même cause que pour ArgoCD (limite d'annotation à 262144 octets). Contournement :syncOptions: [ServerSideApply=true]sur l'ApplicationArgoCD. Insuffisant seul la première fois (CRDs déjà en échec partiel) — il a fallu les appliquer une fois manuellement en--server-sideavant que leselfHeald'ArgoCD ne reprenne la main proprement. - Mot de passe Grafana auto-généré différemment à chaque rendu — en laissant
grafana.adminPasswordvide, le chart génère une valeur aléatoire à chaquehelm template, donc à chaque comparaison faite par ArgoCD, créant unOutOfSyncpermanent. Résolu parignoreDifferencesciblé sur le Secret et l'annotation de checksum du Deployment qui en dépend — pas de mot de passe en clair committé pour autant. - Cache de découverte d'API figé au démarrage de l'opérateur — le pod
kube-prometheus-operator, démarré avant que les CRDs existent réellement dans le cluster (à cause de l'incident 1), ne "voyait" plus le typePrometheusmême une fois les CRDs corrigées et les permissions RBAC en place. Un simplekubectl rollout restarta suffi — les opérateurs Kubernetes construisent souvent leur RESTMapper/cache de découverte une fois au démarrage et ne le rafraîchissent pas dynamiquement. - Ressource
Prometheusaffichée "Unknown" dans ArgoCD malgré un service parfaitement sain — signalé par l'utilisateur, pas une panne. ArgoCD ne fournit de health check intégré que pour un nombre restreint de types (Deployment, StatefulSet, Ingress...) ; les CRDs de Prometheus Operator n'en font pas partie, donc ArgoCD n'a simplement aucun avis surPrometheus/Alertmanager/ServiceMonitor/PrometheusRule, affichés en gris par défaut. Confirmé sain sous-jacent avant tout correctif : tous les podsRunning, et la ressourcePrometheuselle-même reporteAvailable: True/Reconciled: Truedans son proprestatus.conditions. Fix : health check Lua personnalisé (resource.customizations.health.monitoring.coreos.com_Prometheusdansargocd-cm, lit exactement cette conditionAvailable) — patch hors GitOps comme celui de KSOPS, dansgitops/bootstrap/argocd-cm-health-checks-patch.yaml, appliqué à l'installation (manuel-installation.md). Vérifié après application viaargocd app get monitoring --core(l'objetApplicationlui-même ne remonte pas le détail par ressource, seul le résumé agrégé) : la ressourcePrometheuspasse de vide àHealthy.
Configuration des moniteurs Uptime Kuma + page de statut publique (Phase 3.5) : jusqu'ici Uptime Kuma était déployé mais vide. Configuré via un script Python (scripts/uptime-kuma-setup.py), pas via l'UI — pas de config déclarative possible pour Uptime Kuma (son état vit dans une base SQLite embarquée dans sa propre PVC, même catégorie que sops-age dans CLAUDE.md), donc le script est la version reproductible de ce qu'un clic dans l'UI ferait, à rejouer après chaque recréation du cluster. Six moniteurs HTTP (Authentik, Vaultwarden, Nextcloud, Immich, Tuwunel, LiveKit — uniquement les services orientés famille, jamais ArgoCD/Grafana) regroupés dans une page de statut publique /status/etat-du-systeme.
Piste initiale envisagée (accès navigateur direct pour cliquer dans l'UI) écartée : le navigateur nécessite une approbation par site que l'utilisateur n'a pas réussi à localiser dans l'interface — basculé sur l'approche scriptée à sa demande, plus reproductible de toute façon pour un futur autre opérateur (cohérent avec l'objectif Phase 6 d'installeur scriptable).
Quatre incidents réels rencontrés en écrivant ce script, aucun visible en lisant la doc de la lib cliente uptime-kuma-api (1.2.1, dernière sur PyPI) avant de s'y fier :
- Handshake Socket.IO impossible sur une instance neuve — la lib ne se connecte pas du tout (
unable to connect), y compris en contournant Traefik (testé en direct viakubectl port-forward, donc pas un problème d'ingress). Cause réelle trouvée en inspectant le bundle JS servi par le pod : Uptime Kuma 2.x ajoute une étape/setup-database(choix du moteur — SQLite ou MariaDB) avant que le serveur Socket.IO ne soit même monté ; tant que cette étape n'est pas passée, le serveur répond au chemin/socket.io/avec le HTML de la SPA au lieu du handshake attendu. La lib cliente (conçue avant cette étape) n'a aucune méthode pour ça — fait à la main viaPOST /setup-database({"dbConfig": {"type": "sqlite", ...}}, endpoint trouvé en grepant le bundle JS, pas documenté). setup()ne connecte pas automatiquement — contrairement à l'intuition (et à l'absence de mention explicite dans la docstring), créer le compte admin ne authentifie pas la session ; un appellogin()séparé est nécessaire juste après, sous peine de timeouts sur tous les appels suivants.add_monitor()échoue avec une contrainte SQLNOT NULLsur une colonneconditions— ajoutée par Uptime Kuma 2.x (conditions avancées de déclenchement) mais inconnue de la lib 1.2.1, qui omet totalement le champ à l'insertion. Contourné en rejouant les mêmes étapes internes queadd_monitor()avecconditions: []ajouté à la volée avant l'appel bas niveau.save_status_page()casse sur deux points à la fois :get_status_page()(appelé en interne) suppose une clé"incident"(singulier, objet) dans la réponse serveur, renommée"incidents"(pluriel, tableau) dans cette version ; et le reconstructeur de config de la lib ignore plusieurs champs ajoutés depuis (autoRefreshInterval,analyticsType...), que le serveur exige pourtant à la sauvegarde. Contourné en gardant tel quel l'objet de config renvoyé par le serveur (tous champs, connus ou non de la lib) et en n'y superposant que les valeurs à changer, plutôt que de le reconstruire via le helper de la lib.
Un vrai bug applicatif trouvé au passage, sans lien avec Uptime Kuma : le moniteur Authentik pointé sur / remontait systématiquement "down" malgré un service sain (health checks Kubernetes /-/health/live///-/health/ready/ verts en continu). Cause : / redirige les visiteurs non authentifiés vers /setup (flow de configuration initiale d'Authentik), qui lève une FlowNonApplicableException non gérée et répond 500 — reproductible même en dehors de tout contexte Uptime Kuma (curl direct), donc un vrai bug Authentik (le compte admin existe déjà, le flow "setup" ne devrait plus jamais s'appliquer), pas un artefact de configuration du moniteur. Corrigé côté moniteur en visant directement /-/health/live/ plutôt que la racine — la bonne pratique de toute façon (un endpoint de santé dédié plutôt que la page d'accueil), indépendamment du bug sous-jacent qui reste non résolu.
Alerting admin vers Tuwunel (Phase 3.5) : Uptime Kuma a un provider de notification "Matrix" natif — pas besoin de développer un bot webhook maison, juste le configurer. Compte Matrix dédié @alertbot:myown-tuwunel.local (inscrit via scripts/tuwunel-alertbot-setup.py, même flow UIAA + jeton d'inscription que pour les comptes utilisateurs), propriétaire d'un salon partagé #etat-du-systeme:myown-tuwunel.local (join_rule: public — n'importe qui dans le foyer peut le rejoindre en connaissant l'alias, mais visibility: private pour ne pas apparaître dans l'annuaire public des salons ; history_visibility: shared pour que quelqu'un qui rejoint plus tard puisse remonter l'historique des alertes passées). Volontairement un compte et un salon distincts de tout ce qu'Ollama utilisera en Phase 5 : alertes infra partagées d'un côté, propositions IA personnelles en DM privé de l'autre, jamais mélangés (cf. roadmap.md Phase 4/5).
Même gotcha CA que lk-jwt-service, retrouvé une deuxième fois : la notification Matrix échouait silencieusement côté UI Uptime Kuma (unable to verify the first certificate, confirmé dans les logs du pod) — Node.js embarque son propre magasin de CA (certifi-like, indépendant de l'OS), donc mkcert -install sur l'hôte ne suffit pas pour les appels HTTPS sortants du pod vers Tuwunel. Fixé avec NODE_EXTRA_CA_CERTS (équivalent Node.js du SSL_CERT_FILE déjà utilisé pour lk-jwt-service) pointé vers un ConfigMap mkcert-ca monté dans le pod, cette fois dans le namespace monitoring (un ConfigMap par namespace, pas partageable tel quel entre livekit et monitoring). Chart communautaire (dirsigler/uptime-kuma-helm) déjà prévu pour ce cas d'usage (additionalVolumes/additionalVolumeMounts/podEnv documentés dans ses values par défaut, avec un exemple de montage de certificat justement).
Piège ArgoCD rencontré en testant ce fix en direct : un premier kubectl apply du fix sur l'Application uptime-kuma semblait n'avoir aucun effet (Deployment inchangé) — en réalité appliqué correctement puis immédiatement écrasé par le selfHeal de root, qui resynchronise uptime-kuma depuis l'état commité sur master (qui ne contenait pas encore le fix, resté local). Même friction déjà documentée pour Vaultwarden/Tuwunel↔Authentik — contournée en mettant en pause syncPolicy sur root et uptime-kuma avant de retester.
Validé en conditions réelles : cycle complet panne/rétablissement déclenché à la main (URL de test cassée puis restaurée sur le moniteur LiveKit) — message [LiveKit] [✅ Up] 200 - OK bien reçu dans le salon Matrix par le compte alertbot, confirmé via l'API GET /_matrix/client/v3/rooms/{roomId}/messages plutôt que supposé fonctionnel après configuration.
Authentik (SSO)¶
Chart officiel authentik/authentik. PostgreSQL via le sous-chart Bitnami bundlé (postgresql.enabled: true, désactivé par défaut dans le chart). Pas de Redis — les versions récentes d'Authentik n'en dépendent plus (chart sans dépendance Redis, à la différence des versions historiques du projet).
Secret unique (gitops/secrets/authentik/authentik.sops.yaml, chiffré) référencé via authentik.existingSecret.secretName et postgresql.auth.existingSecret — même nom de Secret pour les deux, les clés ne se chevauchent pas.
Incident : authentik.existingSecret remplace l'intégralité du Secret de configuration généré par le chart, pas seulement ses champs sensibles — la connexion PostgreSQL (host/port/nom/utilisateur), non sensible, y est mélangée par le chart et disparaît donc aussi si on ne la fournit pas explicitement. Symptôme : authentik-server/authentik-worker en CrashLoopBackOff, tentant de se connecter à localhost:5432 (repli interne d'Authentik en l'absence totale de configuration PostgreSQL) plutôt qu'au vrai service authentik-postgresql.
Leçon retenue pour les prochains services (Vaultwarden, Nextcloud, Mailcow auront le même genre d'option) : avant de configurer un existingSecret sur un nouveau chart, toujours rendre le chart sans cette option (helm template ...) pour voir la liste complète des clés que le Secret généré contient normalement, et répliquer cette liste intégrale — ne pas supposer que seuls les champs visiblement sensibles sont concernés.
akadmin sans mot de passe stable jusqu'au 2026-08-20 : aucune clé AUTHENTIK_BOOTSTRAP_PASSWORD/AUTHENTIK_BOOTSTRAP_EMAIL n'avait jamais été posée dans authentik.sops.yaml — en l'absence de bootstrap explicite, Authentik génère un mot de passe aléatoire pour akadmin au tout premier démarrage (loggé une fois, jamais persisté nulle part). Conséquence réelle vécue par l'utilisateur : chaque recréation de cluster (dev, puis le tout premier bootstrap du mini PC) génère un akadmin différent, rendant tout mot de passe précédemment noté caduc sans avertissement. Corrigé en ajoutant ces deux clés au secret existant — mais le bootstrap ne s'applique qu'à la toute première création du compte, pas rétroactivement à un akadmin déjà existant (confirmé : u.check_password(...) restait False juste après avoir redémarré authentik-server/authentik-worker avec les nouvelles variables) : il a donc aussi fallu appliquer la même valeur directement (u.set_password(...) via ak shell) sur le compte déjà en place. Sur une future recréation complète du cluster (base vide), la variable seule suffira.
Récupération d'accès en urgence : ak create_recovery_key <minutes> akadmin (la durée est en minutes, pas en heures — piège immédiat, 24 donne 24 minutes et non 24 heures) génère un lien à usage unique (/recovery/use-token/...) permettant de redéfinir le mot de passe directement dans le navigateur, sans jamais faire transiter la nouvelle valeur ailleurs — la méthode à privilégier pour tout compte utilisateur (pas seulement akadmin) plutôt que de fixer un mot de passe par script.
Piège de retour à la ligne caché, trouvé en répétant le même schéma pour Grafana : générer un mot de passe via python3 -c "print(...)" > fichier puis le relire avec base64 -w0 < fichier pour patcher un Secret inclut le \n final de print() dans la valeur réellement stockée — un $(cat fichier) dans une substitution de commande bash l'aurait strippé automatiquement, mais une redirection de fichier directe dans base64 ne le fait pas. Résultat concret : Grafana rejetait un mot de passe qui semblait pourtant identique à l'affichage (echo/cat masquent la différence). Confirmé avec xxd (octet 0a final présent dans le Secret vivant et dans la variable d'environnement du pod, absent une fois régénéré proprement). Le même risque existait sur le AUTHENTIK_BOOTSTRAP_PASSWORD posé plus haut dans ce document (patché en direct de la même façon) — vérifié et corrigé après coup ; la valeur commitée dans authentik.sops.yaml, elle, était déjà propre (écrite via $(cat ...), qui strippe). Retenu pour la suite : toujours passer par une substitution de commande ($(cat fichier)) avant d'encoder un secret généré par script, jamais une redirection de fichier directe.
sops --set avec un chemin JSON de premier niveau plutôt que ["stringData"][...], trouvé bien plus tard (repointage sur v0.1.4) : AUTHENTIK_BOOTSTRAP_PASSWORD/AUTHENTIK_BOOTSTRAP_EMAIL avaient été ajoutés à la racine du document YAML (sops --set "[\"AUTHENTIK_BOOTSTRAP_PASSWORD\"] ...") au lieu d'être imbriqués sous stringData: comme le reste des clés — chemin JSON incorrect au moment de la correction du mot de passe akadmin, jamais remarqué sur le coup parce que le secret vivant avait été corrigé directement (kubectl patch sur .data, valeurs correctement placées), masquant le défaut du fichier git. Resté invisible jusqu'à ce qu'ArgoCD tente enfin une vraie comparaison contre ce fichier précis (le selfHeal était resté désactivé sur authentik entre-temps) : ComparisonError, "error building typed value from config resource: .AUTHENTIK_BOOTSTRAP_EMAIL: field not declared in schema" — un Secret Kubernetes n'a que apiVersion/kind/metadata/data/stringData/type/immutable comme champs racine valides. Piste explorée en premier et écartée : deux gestionnaires de champs (argocd-controller et kubectl-patch, restes du patch en direct) coexistaient sur le Secret vivant — nettoyés (managedFields filtré puis kubectl replace), sans effet sur l'erreur, confirmant que la cause réelle était bien la structure du fichier commité, pas un conflit de field-manager. Corrigé en déchiffrant, replaçant les deux clés sous stringData:, et rechiffrant.
Vaultwarden (mots de passe)¶
Premier service de la Phase 1 de la roadmap. Chart communautaire guerzon/vaultwarden (342 étoiles, mis à jour la veille du déploiement — même vérification de fraîcheur que pour Uptime Kuma), faute de chart officiel. Base SQLite par défaut du chart — pas de service de base de données séparé à ce stade. Modèle de secrets par option (adminToken.existingSecret, database.existingSecret, etc.) plutôt qu'un seul Secret monolithique comme Authentik : plus précis, pas le même risque d'oubli de champ.
Incidents rencontrés (deux) :
volumeClaimTemplatesinvalide si seulstorage.data.sizeest renseigné — le nom (metadata.name) et les modes d'accès (accessModes) du template de PVC restent vides malgré des valeurs par défaut suggérées dans les commentaires du chart, produisant un StatefulSet rejeté par l'API Kubernetes. Trouvé et corrigé avant le push grâce àkubectl apply --dry-run=clientsur le rendu complet — pas besoin d'un cycle d'échec en cluster réel pour l'attraper.OutOfSyncpersistant sur le StatefulSet malgré un état réellement synchronisé — Kubernetes normalise chaquevolumeClaimTemplateune fois le PVC provisionné : ajouteapiVersion/kind, positionnespec.volumeModepar défaut, et remplitstatus. Aucun de ces champs n'existe dans le manifest désiré.ignoreDifferences(jqPathExpressions) nécessaire sur les quatre champs, pas seulementstatuscomme il semblait suffisant au premier abord. Distinction importante faite pendant le diagnostic :argocd app diff --hard-refresh(calcul en direct) confirmait déjà un état propre alors que le badgeSync Statusagrégé restait bloqué surOutOfSync— un vrai bug d'affichage ArgoCD sur ce type de ressource, vérifié inoffensif (0 redémarrage du pod, dernière opération de synchro terminée en 0 seconde, donc no-op) plutôt qu'un signe de dérive réelle à corriger davantage.- Web vault inutilisable en HTTP — erreur navigateur "Subtle Crypto API... you need to enable HTTPS". Bitwarden/Vaultwarden chiffrent tout côté client via l'API Web Crypto du navigateur, qui exige un "contexte sécurisé" : HTTPS, ou littéralement les hostnames
localhost/127.0.0.1— un nom personnalisé commemyown-vaultwarden.localne compte pas, même pointé vers127.0.0.1via/etc/hosts. Résolu avec un certificat local viamkcert, servi sur le port HTTPS du load balancer k3d déjà réservé depuis la création du cluster (8453). Le Secret TLS est créé hors GitOps, au même titre quesops-age: un certificat de dev est propre à une machine, pas à distribuer via git. - Une resynchro forcée de
vaultwardenseule ne suffit pas toujours — après avoir modifiégitops/apps/vaultwarden.yamlet mergé,kubectl annotate application root argocd.argoproj.io/refresh=harda recalculé le diff derootmais n'a pas fait redescendre le changement vers l'Applicationvaultwardenelle-même (son.specrestait sur l'ancienne valeuringress.tls: false). Il a fallu unargocd app sync rootexplicite (pas juste un refresh) pour querootréapplique réellement l'Applicationenfant, puis unargocd app sync vaultwardenpour que le changement atteigne l'Ingress. À garder en tête : refresh recalcule un diff, sync l'applique — les deux sont parfois nécessaires en pratique même avecselfHeal: true. mkcert -installne suffisait pas pour de vrai, malgré son message de succès — CA générée et certificat serveur corrects (vérifiés côté serveur avecopenssl s_client), mais Chromium (installé en snap) affichait quand même "non sécurisé". Cause : les navigateurs installés en snap ont leur propre profil isolé (~/snap/chromium/<revision>/.local/share/pki/nssdb), quemkcert -installne détecte pas — son message "already installed" ne portait que sur le magasin système générique. Même après avoir manuellement ajouté la CA au bon endroit aveccertutil(confirmé présente avec les droits correctsCT,C,Cviacertutil -L) et redémarré le navigateur pour de vrai (unps auxa montré qu'il tournait encore en arrière-plan après la première tentative de fermeture), l'avertissement persistait — les versions récentes de Chrome/Chromium peuvent ignorer le magasin NSS système au profit de leur "Chrome Root Store" intégré. La CA apparaissait même classée en "intermédiaire" plutôt qu'en racine danschrome://certificate-manager→ Linux. Résolu uniquement en importantrootCA.pemmanuellement danschrome://certificate-manager→ Personnalisé → Certificats approuvés. Détail des étapes :manuel-installation.md.
Vaultwarden ↔ Authentik (SSO)¶
Provider OAuth2/OIDC + Application déclarés côté Authentik via un blueprint (mécanisme natif d'Authentik pour la config déclarative), pas via l'interface d'administration — cohérent avec le reste du projet, reproductible. Le blueprint lui-même vit dans un Secret chiffré SOPS/KSOPS (gitops/secrets/authentik-blueprints/), monté dans le worker Authentik via blueprints.secrets (valeur du chart). Champs exacts (notamment redirect_uris, qui a changé de format — désormais une liste d'objets {matching_mode, url} et non plus de simples chaînes) vérifiés directement contre le schéma OpenAPI et le schema.json des blueprints de l'instance réellement déployée, pas devinés depuis la documentation.
Un vrai trou d'architecture découvert en le construisant : Vaultwarden doit atteindre Authentik côté serveur (échange OIDC) pour valider une connexion — mais myown-authentik.local ne se résolvait jusque-là que depuis la machine hôte (/etc/hosts), pas depuis l'intérieur du cluster, et le port externe (8090) n'est mappé que sur le load balancer k3d, pas à l'intérieur. Résolu par deux ressources additives, hors GitOps (même statut que l'installation d'ArgoCD) :
gitops/bootstrap/traefik-internal-svc.yaml— un Service séparé (ne touche pas au Servicetraefikgéré par Helm/k3s) exposant les ports 8090/8453 à l'intérieur du cluster, en miroir du load balancer externegitops/bootstrap/coredns-custom.yaml— réécritmyown-*.localvers ce service via le mécanismeimport /etc/coredns/custom/*.overrideofficiellement supporté par k3s. Un blochosts {}classique était impossible (déjà utilisé une fois dans le Corefile par défaut — un seul autorisé par bloc serveur) ;rewrite name exactutilisé à la place, avec l'avantage de ne pas figer une IP en dur.
Ce n'est pas qu'un détail pour le SSO : sans ça, le document de découverte OIDC qu'Authentik renvoie (construit à partir du Host de la requête reçue) aurait perdu le port :8090, cassant la redirection du navigateur vers la page de connexion. Vérifié explicitement après coup : authorization_endpoint renvoyé contient bien myown-authentik.local:8090.
Trois échecs réels lors du premier test de connexion en conditions réelles (le déploiement seul ne suffit jamais à valider un flow OIDC — il faut le cliquer vraiment) :
redirect_urisavecmatching_mode: regex, tolérant un port absent — hypothèse initiale basée sur un avertissement de la documentation Vaultwarden ("ignore parfois le port du callback sur un port non standard"), qui s'est avérée non pertinente : la vraie cause de l'échec de connexion était ailleurs (domainjamais configuré côté Vaultwarden, cf. ci-dessous), pas le port. Une foisdomaincorrigé, la valeur réelle envoyée par Vaultwarden a pu être capturée dans les logs Authentik et leredirect_urisresserré enmatching_mode: strictsur cette valeur exacte plutôt que de garder une tolérance qui n'était finalement pas nécessaire.DOMAINjamais configuré côté Vaultwarden → il construit ses URLs de callback (SSO et autres) à partir de ce paramètre, absent = replié surhttp://localhost. Authentik rejetait donc unredirect_uri=http://localhost/...qui ne correspondait à rien. Trouvé en lisant le log d'accès Authentik (redirect_uriintégralement loggé sur l'événement de rejet), pas en devinant. Fix :domain: "https://myown-vaultwarden.local:8453"dans les valeurs Helm.grant_typesvide par défaut sur leOAuth2Provider— le blueprint ne le renseignait pas explicitement ; Authentik rejette silencieusement toute requêteauthorizeavecresponse_type=codesiauthorization_coden'est pas dans la liste (vide par défaut, confirmé viaak shell). Message d'erreur générique côté Vaultwarden ("malformed"), cause précise seulement visible dans les logs Authentik ("Invalid grant_type for provider"). Fix :grant_types: [authorization_code, refresh_token]explicite dans le blueprint.- Mapping de scope
emailpar défaut d'Authentik :email_verifiedcodé en dur àFalse— repéré après une 4ème tentative de connexion échouant avec "Email is not verified by the SSO provider" malgrésso.ignoreEmailVerification: truecôté Vaultwarden (ce réglage ne couvre que le cas où le claim est absent, pas présent et faux). Confirmé en lisant directement l'expression du mapping système viaak shell(ScopeMapping.objects.get(scope_name='email').expression). Fix : mapping de scope dédié à ce provider (mêmescope_name,namedifférent pour coexister avec le mapping système) renvoyantemail_verified: True— justifié ici parce qu'Authentik est le seul opérateur d'identité de confiance du projet, pas un tiers fédérant des comptes non vérifiés.
Méthode qui a permis de résoudre les quatre : à chaque échec, aller lire le log d'accès complet côté Authentik (kubectl logs deploy/authentik-server) plutôt que de se fier au message d'erreur, générique et tardif, affiché côté Vaultwarden — Authentik loggue le redirect_uri complet et la raison précise du rejet sur chaque requête /application/o/authorize/.
Découverte automatique des blueprints montés : fonctionne (le mécanisme est réel, testé), mais pas instantanément — après le montage ou la modification du Secret, il faut souvent déclencher manuellement ak apply_blueprint <chemin> (ou la tâche blueprints_discovery) pour que le changement s'applique tout de suite, plutôt que d'attendre son prochain passage périodique. Piège additionnel rencontré : le volume monté lui-même met aussi un peu de temps (kubelet) à refléter un Secret mis à jour — vérifier le contenu du fichier monté avant de conclure que l'application du blueprint a échoué.
Sauvegarde Restic (Vaultwarden, Nextcloud, Immich)¶
Premier pipeline de sauvegarde du projet (roadmap Phase 1), pensé pour être copié tel quel sur les prochains services (Nextcloud, Immich, ...). Reste dans l'Application ArgoCD vaultwarden existante (un 3ᵉ source pointant vers gitops/manifests/vaultwarden-backup/, manifests bruts sans chart Helm) plutôt qu'une Application séparée — cohérent avec "une Application par service".
Cible temporaire : le nœud ami n'existe pas encore, donc le dépôt Restic vit sur une PVC local-path dédiée (vaultwarden-restic-repo) dans le même cluster que les données qu'elle sauvegarde — ce n'est pas une vraie protection 3-2-1 tant que ce n'est pas déplacé, juste la mise en place de la mécanique. Migration future vers le nœud ami : changement de RESTIC_REPOSITORY uniquement (ex. sftp:... ou rest:...), aucune réécriture du CronJob.
CronJob quotidien (0 3 * * *), image officielle restic/restic:0.19.1, mot de passe du dépôt dans le Secret SOPS existant vaultwarden-secrets (clé RESTIC_PASSWORD, ajoutée par sops --set — pas de nouveau dossier de secret, même modèle qu'ADMIN_TOKEN/SSO_CLIENT_*). Auto-initialisation idempotente du dépôt (restic snapshots || restic init) plutôt qu'un Job d'init séparé hors GitOps. Rétention keep-daily 7 / keep-weekly 4 / keep-monthly 3 + prune, appliquée juste après chaque backup dans le même CronJob.
Monte la PVC de données Vaultwarden (data-vaultwarden-0) en lecture seule à côté de la PVC du dépôt — fonctionne parce que le cluster est mono-nœud (vrai en dev k3d comme sur la future prod mini PC) : RWO restreint l'attachement à un seul nœud, pas à un seul pod ; deux pods sur le même nœud peuvent donc monter la même PVC local-path simultanément. À revoir si la Phase 6 passe en topologie multi-nœuds (le pod de backup pourrait alors être ordonnancé sur un nœud différent de celui qui tient la PVC).
Incident rencontré lors du test de restauration : un pod de test montait la PVC du dépôt en lecture seule (cohérent avec l'intention de ne pas risquer de corrompre le dépôt lors d'une simple restauration) — mais restic restore/snapshots échouent quand même à l'écriture, même en lecture, car restic pose systématiquement un verrou (/repo/locks/...) avant toute opération, y compris en lecture. Résolu en montant le dépôt en lecture-écriture pour toute opération restic (backup et restauration) ; seule la PVC de données source (/data) a réellement besoin d'être montée en lecture seule.
Validé en conditions réelles : run manuel du CronJob (snapshot créé, ~400 KiB, politique de rétention appliquée), puis restauration complète dans un pod jetable avec diff -rq contre les données live — contenu restauré strictement identique.
Extension à Nextcloud : copié tel quel comme prévu (gitops/manifests/nextcloud-backup/, 3ᵉ source de l'Application nextcloud, mot de passe RESTIC_PASSWORD dans nextcloud-secrets), avec une différence réelle par rapport à Vaultwarden — contacts, calendrier, partages et comptes utilisateurs vivent dans Postgres, pas dans des fichiers. Une simple copie de la PVC de fichiers manquerait cette partie des données, et copier les fichiers bruts d'un Postgres vivant (plutôt qu'un export) risque une incohérence à la restauration (pas de garantie transactionnelle sur une copie de PGDATA en cours d'écriture). Ajout d'un initContainer (postgres:17-alpine, version majeure alignée sur le sous-chart Postgres déployé) qui exécute pg_dump vers un emptyDir partagé avant le backup Restic proprement dit — pg_dump prend un instantané cohérent via une transaction, sans avoir besoin de mettre Nextcloud en mode maintenance. Testé en conditions réelles avant d'écrire le manifest définitif (kubectl run jetable + pg_dump vers la vraie base) : ni le mot de passe ni le réseau ne posent de souci, la NetworkPolicy générée par le chart pour Postgres n'a pas de restriction from: (autorise tout pod du cluster sur le port 5432, pas seulement le pod Nextcloud). Le CronJob sauvegarde /data (fichiers) et /dump (export SQL) dans le même snapshot Restic. Planifié à 03:15 plutôt que 03:00 (Vaultwarden) pour ne pas superposer les deux jobs sur un cluster mono-nœud.
Validé en conditions réelles : run manuel du CronJob (28682 fichiers + dump SQL, ~1 Go, snapshot unique), puis restauration complète dans un pod jetable — dump SQL présent (1.5 Mo, cohérent avec les 12009 lignes observées lors du test pg_dump initial) et diff -rq contre les données live sans aucune différence sur les fichiers.
Extension à Immich : même copie du pattern, gitops/manifests/immich-backup/. Même raisonnement pg_dump que Nextcloud — albums, visages reconnus, personnes, liens de partage et comptes vivent dans Postgres, pas dans la bibliothèque de fichiers. Différence : l'initContainer utilise postgres:14-alpine (image standard, pas l'image ghcr.io/immich-app/postgres avec les extensions vectorielles) — pg_dump n'a pas besoin des binaires d'extension pour exporter, seule une restauration dans une nouvelle instance en aurait besoin. La PVC de cache du service machine-learning (modèles ML téléchargés) n'est volontairement pas sauvegardée : entièrement reproductible, pas une donnée utilisateur. Planifié à 03:30 (après Vaultwarden 03:00 et Nextcloud 03:15) pour étaler les trois jobs.
Validé en conditions réelles : run manuel du CronJob (7 fichiers, ~43 Mo, dump SQL de 45 Mo, snapshot unique), puis restauration complète dans un pod jetable — dump SQL présent et diff -rq contre la bibliothèque live sans aucune différence.
Extension à Tuwunel : copie exacte du pattern Vaultwarden (gitops/manifests/tuwunel-backup/, 3ᵉ source de l'Application tuwunel, mot de passe RESTIC_PASSWORD ajouté par sops --set dans le secret tuwunel-secrets existant) — pas de pg_dump nécessaire, RocksDB est embarqué comme SQLite pour Vaultwarden, une seule PVC à copier. Planifié à 03:45 (après Immich 03:30).
Nuance RocksDB découverte en validant la restauration : le diff -rq contre les données live signale systématiquement le fichier WAL actif (NNNNNN.log) comme différent, même en relançant backup puis restauration immédiatement l'un après l'autre sans action utilisateur entre les deux. Pas une corruption — confirmé en comparant taille et date de modification des deux fichiers : le live a simplement continué à grossir de quelques centaines d'octets entre l'instant du snapshot et celui du diff, parce que le service tourne et écrit en continu. Le format WAL de RocksDB est conçu justement pour tolérer une copie à chaud (même garantie de cohérence qu'un redémarrage après crash) — les 72 autres fichiers (SST déjà flush, MANIFEST, CURRENT) correspondent exactement à chaque test.
Extension à Authentik, comblant un trou resté ouvert depuis la Phase 0 (gitops/manifests/authentik-backup/, 4ᵉ source de l'Application authentik, mot de passe RESTIC_PASSWORD ajouté par sops --set dans le secret authentik-secrets existant). Forme différente des quatre autres services : confirmé en direct sur le cluster, les pods authentik-server/authentik-worker n'ont aucune PVC propre (seul le StatefulSet Postgres du sous-chart Bitnami en a une dans ce namespace) — utilisateurs, flows, providers, applications et l'état appliqué des blueprints vivent uniquement en base. Les blueprints eux-mêmes sont déjà reproductibles depuis git (secrets SOPS), pas un état runtime à sauvegarder. Résultat : sauvegarde uniquement pg_dump, sans PVC de fichiers à monter à côté (contrairement à Nextcloud/Immich qui ont les deux). Planifié à 04:00 (après Tuwunel 03:45).
Découvert en écrivant cette extension : jusqu'ici, Authentik était le seul service du projet sans aucune sauvegarde — son compte admin (akadmin) et toute sa configuration SSO ont donc été perdus à chaque recréation du cluster pendant les travaux LiveKit (recréé à la main via ak shell à chaque fois, cf. section LiveKit). Ce trou est maintenant comblé, cohérent avec les quatre autres services applicatifs.
Validé en conditions réelles : run manuel du CronJob (dump SQL de 4,17 Mio, snapshot unique), puis restauration complète dans un pod jetable — dump SQL présent (215 CREATE TABLE, compte akadmin avec son hash de mot de passe et son historique d'événements retrouvés intacts dans le contenu restauré).
Validé en conditions réelles : run manuel du CronJob (70 fichiers, ~1,7 Mio, snapshot unique), puis restauration complète dans un pod jetable — 72/73 fichiers strictement identiques à diff -rq, le seul écart (WAL actif) expliqué ci-dessus plutôt qu'ignoré.
Nextcloud (fichiers)¶
Premier service de la Phase 2. Chart officiel (nextcloud/helm, dépôt GitHub nextcloud/helm, pas un fork communautaire comme pour Vaultwarden/Uptime Kuma). Déploiement nu volontaire pour cette PR — pas de SSO, pas de Restic, pas d'app Android (cf. roadmap.md Phase 2), pour reproduire le déroulé incrémental déjà suivi pour Vaultwarden en Phase 1.
Postgres plutôt que MariaDB (le chart supporte les deux sous-charts Bitnami au choix) : cohérent avec Authentik qui utilise déjà Postgres dans ce cluster, un seul moteur de DB à opérer. Redis activé (redis.architecture: standalone, une seule instance — pas de replica, léger, cohérent avec l'acceptation du mono-nœud comme SPOF documentée dans architecture.md) : Nextcloud le recommande fortement pour le verrouillage transactionnel des fichiers dès qu'il y a plus d'un utilisateur concurrent, pertinent dès la Phase 2 (test prévu avec 2-3 utilisateurs famille).
Incident évité avant déploiement, en lisant le code source du chart (pas seulement son rendu) : au-delà de la leçon déjà documentée pour Authentik ("toujours rendre le chart sans existingSecret pour voir la forme complète du secret par défaut avant de le remplacer"), ce chart a un piège plus profond, invisible au simple rendu comparatif. Le template templates/db-secret.yaml du chart Nextcloud génère un Secret <release>-db dont le champ db-password est lu en clair, au moment du rendu Helm, directement depuis .Values.postgresql.global.postgresql.auth.password — un champ de valeur, pas une lecture au runtime via secretKeyRef. Configurer postgresql.global.postgresql.auth.existingSecret (pour éviter un mot de passe en clair dans les values) ne change donc rien à ce template précis : il continue de lire le champ password vide/par défaut ("changeme"), produisant un Secret <release>-db avec un mot de passe qui ne correspond plus à celui réellement configuré sur Postgres.
Repéré en inspectant directement templates/_helpers.tpl (macro nextcloud.env.database) avant d'écrire le moindre manifest définitif : quand postgresql.enabled: true (sous-chart embarqué, notre cas), les variables d'environnement POSTGRES_USER/POSTGRES_PASSWORD réellement injectées dans le pod ne viennent pas du Secret <release>-db, mais toujours d'un secretKeyRef vers externalDatabase.existingSecret.secretName/usernameKey/passwordKey — un nom de champ trompeur ("external") qui s'applique en réalité aussi au cas "sous-chart embarqué". Résolu en pointant externalDatabase.existingSecret (avec enabled: true, pour supprimer complètement la génération du Secret <release>-db devenu inutile) vers le même Secret SOPS nextcloud-secrets que le sous-chart Postgres, en réutilisant directement la clé password déjà définie pour l'utilisateur applicatif Postgres — une seule valeur, référencée par les deux consommateurs, pas de duplication.
Méthode qui a permis de l'éviter : helm pull --untar pour lire les templates sources (_helpers.tpl, db-secret.yaml) plutôt que de se fier uniquement à helm show values/helm template — nécessaire ici parce que le bug se manifeste seulement à l'usage (mot de passe DB incohérent au démarrage du pod), pas au rendu ni au dry-run schema-only. kubectl apply --dry-run=client sur le rendu complet a ensuite confirmé qu'aucune ressource ne référence plus le Secret <release>-db (absent du rendu une fois externalDatabase.existingSecret.enabled: true).
Contacts et calendrier ajoutés après coup, pas dans le déploiement nu initial : la roadmap Phase 2 les incluait explicitement dans le périmètre "Déploiement Nextcloud (fichiers, contacts, calendrier)", mais le déploiement nu n'installait que ce que Nextcloud active par défaut — un oubli, pas un report volontaire. Corrigé en ajoutant occ app:install calendar/contacts (idempotents comme les autres appels occ) dans le même hook before-starting déjà utilisé pour user_oidc, pas de mécanisme séparé nécessaire.
Rôle de l'app Photos native, décidé avec l'utilisateur : activée par défaut avec le reste du socle Nextcloud, elle fait doublon avec Immich (Phase 2, pas encore déployé) — l'architecture prévoit volontairement "une seule app par usage" (architecture.md §2). Décision : la garder activée en attendant, la désactiver (occ app:disable photos) dans la même PR que le déploiement d'Immich plutôt que de laisser un trou sans aucun visualiseur photo entre-temps.
Nextcloud ↔ Authentik (SSO)¶
Même méthode que Vaultwarden : blueprint Authentik déclaratif (gitops/secrets/authentik-blueprints/nextcloud-sso.sops.yaml), pas l'UI admin. Différence structurelle : le chart Nextcloud n'a pas de support OIDC natif (contrairement aux variables d'env sso.* de Vaultwarden) — ça passe par l'app officielle user_oidc (Nextcloud GmbH), installée et enregistrée via occ grâce au mécanisme nextcloud.hooks du chart (mappé sur /docker-entrypoint-hooks.d/<hook>/ de l'image Docker officielle — pas de Job séparé hors GitOps). Identifiants OAuth2 (OIDC_CLIENT_ID/OIDC_CLIENT_SECRET) injectés au pod via nextcloud.extraEnv depuis le Secret SOPS existant nextcloud-secrets. Confirmé après coup : le répertoire /var/www/html complet (donc custom_apps/, pas seulement les données utilisateur) est sur la PVC persistante du chart — l'app installée survit bien aux redémarrages du pod, une seule installation suffit.
Deux bugs réels trouvés en testant en direct sur le cluster, pas au rendu/dry-run (un helm template schema-valide ne garantit rien sur le comportement runtime d'un hook shell) :
hooks.post-installationne s'est jamais déclenché : ce hook ne s'exécute qu'à une toute première installation (quandconfig.phpn'existe pas encore) — or Nextcloud avait déjà été installé par la PR précédente. Utiliserhooks.before-startingà la place (exécuté à chaque démarrage de conteneur) résout le problème et est en réalité plus robuste pour ce cas d'usage : les deux appelsocc(app:install/app:enable,user_oidc:provider) sont idempotents (vérifié directement dans le pod), donc les rejouer à chaque démarrage ne coûte rien et garantit l'auto-réparation si la config dérive.occintrouvable dans$PATH: les exemples trouvés en ligne appellentocc ...nu, mais l'image Docker Nextcloud ne l'ajoute pas auPATH— seulphp occ ...(depuis/var/www/html, leWORKDIRpar défaut de l'image) fonctionne. Repéré en testant la commande manuellement dans le pod avant de la mettre dans le hook, pas après un échec silencieux du hook en production.
Bootstrap manuel ponctuel effectué sur le cluster de dev pour l'installation déjà en place au moment de ce correctif (php occ app:install user_oidc + php occ user_oidc:provider ... exécutés une fois via kubectl exec) — le hook corrigé (before-starting) prend le relais automatiquement pour toute future réinstallation ou nouveau cluster, aucune intervention manuelle requise pour un déploiement neuf.
Redirect URI plus simple que Vaultwarden : chemin fixe /apps/user_oidc/code sans identifiant de provider (confirmé via la doc officielle Nextcloud et le guide d'intégration officiel d'Authentik) — pas le genre d'ambiguïté sur le port/format qui avait coûté un aller-retour avec Vaultwarden.
Troisième incident, découvert en cliquant réellement le flow de connexion (le déploiement + occ user_oidc:provider qui liste bien le provider ne suffisent pas à valider un flow OIDC — même leçon que pour Vaultwarden) : user_oidc refuse purement et simplement de démarrer une connexion OIDC en HTTP, message d'erreur explicite ("Vous devez accéder à Nextcloud via HTTPS pour utiliser OpenID Connect"), sans lien avec Subtle Crypto (la raison de la contrainte HTTPS de Vaultwarden) — une exigence propre à l'app elle-même. Contrairement à un vrai helm template/dry-run, ce genre de comportement runtime n'est visible qu'en cliquant pour de vrai.
Bascule vers HTTPS sur le port 8453 (même mkcert + PVC de certificat que Vaultwarden, cf. manuel-installation.md), avec un piège supplémentaire propre au TLS-terminated-à-l'ingress : Traefik termine le TLS et transmet en HTTP en clair vers le pod, donc sans intervention Nextcloud continue de se croire accédé en HTTP même une fois l'Ingress passé en HTTPS — exactement le scénario documenté dans un ticket GitHub user_oidc (déploiement Kubernetes + reverse proxy). Résolu avec phpClientHttpsFix.enabled: true / protocol: https (valeur de premier niveau du chart, positionne OVERWRITEPROTOCOL=https), qui indique explicitement à Nextcloud de considérer toute requête comme HTTPS — repéré en lisant le template defaultConfigs/reverse-proxy.config.php.tpl du chart (déjà activé par défaut) plutôt qu'en devinant depuis la doc générique reverse-proxy de Nextcloud. Les redirect_uris du blueprint Authentik et l'URL documentée dans manuel-utilisateur.md basculent en conséquence sur https://myown-nextcloud.local:8453.
Quatrième incident, HTTPS réglé mais connexion toujours en échec ("Impossible de joindre le fournisseur OpenID Connect") : le log Nextcloud (data/nextcloud.log, pas le message générique affiché à l'écran — même méthode que pour Authentik/Vaultwarden) montre la vraie cause, OCP\Http\Client\LocalServerException: Host "myown-authentik.local" violates local access rules. Nextcloud bloque par défaut ses propres requêtes sortantes vers des hôtes qui résolvent en IP privée/interne (protection anti-SSRF, preventLocalAddress) — or myown-authentik.local résout en ClusterIP (10.43.x.x) vue de l'intérieur du cluster. Vérifié d'abord que ce n'était pas un problème réseau/DNS comme pour Vaultwarden (curl du endpoint de découverte depuis le pod Nextcloud réussit parfaitement) avant de chercher plus loin. Fix : allow_local_remote_servers => true dans un fichier de config custom (nextcloud.configs, mécanisme déjà utilisé par le chart pour ses propres fichiers par défaut) — acceptable ici parce qu'Authentik est un service qu'on opère nous-mêmes sur le même cluster, pas un tiers non maîtrisé.
Cinquième incident, trouvé en creusant les logs à cette occasion (pas lié à l'OIDC lui-même) : chaque requête loggait session_start(): Failed to read session data: redis / Redis connection not available, alors qu'un test PHP direct (new Redis(); ->connect(); ->auth();) avec les mêmes identifiants réussissait. Cause : le mot de passe Redis généré par openssl rand -base64 24 contenait un caractère +, valide dans un mot de passe brut mais mal interprété (décodé en espace) une fois placé dans la query string du DSN de session PHP (tcp://host:port?auth=MOT_DE_PASSE, parsée en application/x-www-form-urlencoded) — corrompant silencieusement le mot de passe transmis pour cet usage précis, alors que d'autres usages du même secret (env var brute, Basic Auth, userinfo d'URL) n'y sont pas sensibles. Corrigé en régénérant ce mot de passe précis en hexadécimal (openssl rand -hex 24, aucun caractère spécial possible) plutôt que de toucher aux autres secrets déjà fonctionnels du projet. Point de vigilance pour tout futur secret consommé via une query string de DSN (Redis, potentiellement d'autres) : préférer openssl rand -hex à openssl rand -base64 dès le départ.
Validé en conditions réelles : connexion SSO complète depuis le navigateur (redirection vers Authentik, authentification, retour connecté sur Nextcloud) — cinq incidents au total pour y arriver (grant_types anticipé dès le blueprint initial, les quatre ci-dessus découverts en testant), aucun résolu en devinant depuis la documentation générique.
Mapping de scope email réutilisé plutôt que dupliqué : le blueprint référence directement le mapping Vaultwarden: email (verified) créé pour Vaultwarden (!Find par son name, pas son scope_name — les mappings de scope ne sont pas propres à un provider dans Authentik, un !Find d'un blueprint peut référencer un objet créé par un autre blueprint déjà appliqué). Un seul mapping email_verified: true partagé par tous les services qu'on opère, plutôt qu'une copie par service.
Deux champs de schéma vérifiés en direct avant d'écrire le blueprint (ak shell sur le pod Authentik réel, méthode déjà utilisée pour Vaultwarden plutôt que deviner depuis la doc) :
sub_mode: user_uuid(OAuth2Provider._meta.get_field('sub_mode').choices— valeur "Based on the User's UUID", recommandée par le guide Authentik pour unsubstable dans le temps, par opposition au défauthashed_user_id)redirect_uri_type: logoutpour la redirect URI de déconnexion (authentik.providers.oauth2.models.RedirectURIType— seulement deux valeurs possibles,authorizationetlogout, aucune ambiguïté une fois vérifié)
Immich (photos)¶
Dernier service de la Phase 2. Chart officiel (immich/immich, dépôt GitHub immich-app/immich-charts). Différence structurelle majeure avec Authentik/Nextcloud : ce chart n'embarque aucun sous-chart Postgres — il attend une instance fournie séparément, avec l'extension vectorielle dont Immich a besoin pour la reconnaissance faciale et la recherche sémantique.
Postgres maison, pas Bitnami : Immich a migré vers l'extension VectorChord (repli sur pgvector si absent) — c'est le choix par défaut du projet lui-même (son image Postgres officielle et son propre docker-compose.yml), pas une extension tierce risquée. L'image officielle ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 est une image Postgres standard (variables d'env POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB, entrypoint docker-entrypoint.sh classique) — incompatible avec le chart Postgres Bitnami déjà utilisé pour Authentik/Nextcloud, dont les scripts d'entrypoint n'existent que dans les images Bitnami elles-mêmes ; échanger juste l'image casserait le pod. Déployé en StatefulSet écrit à la main (gitops/manifests/immich-postgres/), même esprit que les manifests de backup Restic déjà écrits en dehors de tout chart. Détails repris du docker-compose.yml officiel du projet plutôt qu'improvisés : PGDATA sur un sous-répertoire du volume (/var/lib/postgresql/data/pgdata, pas la racine — évite les soucis classiques de fichiers parasites créés par certains provisioners), POSTGRES_INITDB_ARGS: --data-checksums, /dev/shm en emptyDir Medium: Memory 128Mi (Postgres peut dépasser les 64 Mo par défaut sous charge).
Redis/Valkey en revanche est bundled (valkey.enabled: true, sous-chart du chart Immich) — contrairement à Postgres, pas de déploiement séparé nécessaire.
Variables d'environnement DB partagées au niveau racine (controllers.main.containers.main.env), pas dans server:/machine-learning: séparément : repéré en lisant values.yaml du chart (commentaire explicite "These entries are shared between all the Immich components") et confirmé au rendu — les mêmes DB_HOSTNAME/DB_USERNAME/DB_PASSWORD se retrouvent injectées dans les trois déploiements (server, machine-learning, et même valkey qui n'en a pourtant pas besoin, sans conséquence) exactement comme le fait déjà REDIS_HOSTNAME par défaut dans le chart. Syntaxe env.<NOM>.valueFrom.secretKeyRef vérifiée directement dans le template source du chart "common" bjw-s sous-jacent (_env.tpl) avant d'écrire le manifest, pas devinée.
PVC bibliothèque non auto-créée : le chart le dit explicitement dans son values.yaml ("Automatically creating the library volume is not supported by this chart") — PVC créée à la main dans le même dossier de manifests que Postgres, référencée via immich.persistence.library.existingClaim.
Cache du service de reconnaissance faciale/recherche sémantique : emptyDir par défaut dans le chart, donc les modèles ML (plusieurs centaines de Mo à quelques Go) seraient retéléchargés à chaque redémarrage de pod. Basculé vers persistentVolumeClaim (5Gi) pour éviter ce gaspillage de bande passante/temps sur un cluster de dev redémarré fréquemment.
Annotation par défaut de l'Ingress inerte sur Traefik : le chart pose nginx.ingress.kubernetes.io/proxy-body-size: "0" par défaut (pertinent seulement pour un ingress-nginx) ; tenter de l'effacer avec annotations: {} dans les values ne fonctionne pas (le merge Helm de deux maps ne vide pas une valeur par défaut avec une map vide côté utilisateur) — laissé tel quel, sans conséquence, Traefik ignore silencieusement une annotation qu'il ne reconnaît pas.
Validé en conditions réelles (API plutôt que navigateur — même niveau de rigueur que les tests WebDAV faits pour Nextcloud) : création du compte admin (POST /api/auth/admin-sign-up), connexion (POST /api/auth/login), upload d'une image de test (POST /api/assets), confirmé à la fois récupérable via l'API et physiquement présent sur la PVC bibliothèque (/data/upload/<user>/...) dans le pod immich-server.
Immich ↔ Authentik (SSO)¶
Différence importante avec Nextcloud : Immich a un support OAuth/OIDC natif (comme Vaultwarden), pas d'app tierce à installer. Mais à la différence de Vaultwarden (SSO configuré directement par variables d'env du chart), la config OAuth d'Immich vit dans son fichier de configuration système, normalement éditable via Administration → Settings dans l'UI — ce qui aurait demandé une étape manuelle unique à l'utilisateur.
Choix fait après discussion avec l'utilisateur : éviter toute configuration manuelle, y compris ponctuelle, cohérent avec l'objectif du projet ("Accessible aux non-techos", architecture.md §2). Immich supporte un fichier de config statique (IMMICH_CONFIG_FILE) — vérifié dans les résultats de recherche sur le code source du serveur avant de s'y fier (la doc officielle ne le précise pas explicitement) : ce fichier est fusionné avec la config par défaut au démarrage, tous les champs sont optionnels. Fournir uniquement la section oauth: ne touche donc à rien d'autre (réglages ML, quotas, template de stockage) — pas besoin de reproduire le schéma complet par défaut.
Monté via immich.existingConfiguration: immich-oauth-config + immich.configurationKind: Secret (Secret SOPS gitops/secrets/immich/immich-oauth-config.sops.yaml) plutôt que immich.configuration en clair dans les values — même logique que partout ailleurs dans ce projet (le secret ne doit jamais apparaître en clair dans un fichier commité). Nom de clé (immich-config.yaml) et chemin de montage (/config/immich-config.yaml, pointé par IMMICH_CONFIG_FILE) vérifiés directement dans templates/immich-config.yml/server.yaml du chart avant d'écrire le manifest.
Blueprint Authentik (gitops/secrets/authentik-blueprints/immich-sso.sops.yaml) : même mapping de scope partagé Vaultwarden: email (verified) que Nextcloud, sub_mode: user_uuid. Trois redirect_uris déclarées d'un coup (http://myown-immich.local:8090/auth/login, .../user-settings, et app.immich:///oauth-callback pour l'app mobile — posée dès maintenant même si l'app Android est différée en Phase 4, coût nul de les grouper). Pas de contrainte HTTPS connue côté Immich pour l'OAuth, contrairement à user_oidc de Nextcloud — reste en HTTP sur :8090.
Validé en conditions réelles, en deux temps — d'abord côté serveur sans identifiants (oauthButtonText renvoyé par GET /api/server/config confirme la config lue ; POST /api/oauth/authorize renvoie une URL Authentik complète et correcte, client_id/redirect_uri/PKCE inclus), puis clic réel de l'utilisateur dans le navigateur (redirection, authentification Authentik, retour connecté sur Immich) — succès du premier coup, aucune configuration manuelle nécessaire.
Stockage hostPath fixe (Nextcloud, Immich)¶
Demandé directement par l'utilisateur après avoir buté sur kubectl cp pour déposer un fichier de test dans Jellyfin : un PVC local-path à chemin auto-généré n'est pas navigable/modifiable directement, seulement via kubectl. Pas un problème de "mode dev" — vrai aussi sur le futur k3s bare-metal du mini PC (Kubernetes n'expose jamais un volume comme un partage réseau par défaut). Bascule faite pour Nextcloud et Immich uniquement (pas Vaultwarden/Tuwunel/Postgres — décision explicite de l'utilisateur, pas de besoin de dépôt manuel de fichiers pour ces services-là).
Nuance propre au cluster de dev, à ne pas perdre de vue : k3s y tourne dans un conteneur Docker (k3d), donc "le disque de l'hôte" du point de vue de Kubernetes est en réalité le système de fichiers de ce conteneur — pas directement le vrai disque de la machine. Vérifié en direct (docker inspect sur k3d-myown-dev-server-0) : aucun des volumes montés n'est un bind-mount d'un vrai dossier de la machine, seulement des volumes Docker nommés. Chemin choisi malgré tout sous /var/lib/rancher/k3s/storage/myown/ — pas un chemin arbitraire sur la couche writable du conteneur (qui ne survivrait qu'à un stop/start, pas à une recréation du conteneur), mais un sous-dossier du volume Docker déjà monté pour k3s lui-même (/var/lib/rancher/k3s, confirmé via docker inspect) — même durabilité que le stockage local-path actuel, mais chemin fixe et choisi plutôt qu'un UUID généré. Sur le vrai mini PC (k3s bare-metal, pas de couche Docker), ce même hostPath sera un vrai dossier directement navigable, sans aucune de ces réserves.
Nextcloud : chart officiel supporte persistence.hostPath nativement (persistence.enabled: true doit rester à true, sinon retombe sur emptyDir — vérifié dans templates/deployment.yaml du chart, le bloc de création de PVC est simplement sauté quand hostPath est renseigné). Chemin : /var/lib/rancher/k3s/storage/myown/nextcloud-data.
Immich : le chart n'accepte que existingClaim, aucun support hostPath direct — précisé explicitement dans son propre values.yaml ("Automatically creating the library volume is not supported by this chart"). Contournement standard Kubernetes : un PersistentVolume statique de type hostPath + une PersistentVolumeClaim du même nom (immich-library) liée dessus via volumeName (pas de storageClassName des deux côtés, pour empêcher tout provisionneur dynamique d'interférer), persistentVolumeReclaimPolicy: Retain plutôt que Delete — cette fois les données ne doivent jamais disparaître automatiquement si la PVC est supprimée. Trouvé en travaillant dessus : la PVC immich-library existante n'était déclarée nulle part en GitOps (créée hors bande à un moment non documenté) — un cluster neuf aurait fait échouer Immich au démarrage (existingClaim pointant sur rien). Corrigé au passage (gitops/manifests/immich-library/).
Migration réelle des données existantes, pas juste la bascule de config : pour chaque service, copie (cp -a, préserve permissions/propriétaire) depuis le vrai chemin de l'ancien PVC (local: path du PV, trouvé via kubectl get pv -o yaml, pas hostPath — piège pour qui s'attend au même champ que pour un hostPath classique) vers le nouveau chemin fixe, vérifiée diff -rq sans écart avant toute suppression (1,2 Gio pour Nextcloud, 76 Kio pour Immich — bibliothèque de test).
Piège rencontré aux deux bascules, une seule fois compris : supprimer l'ancienne PVC restait bloqué en Terminating indéfiniment (finalizer kubernetes.io/pvc-protection), alors qu'aucun pod actif ne semblait la monter. Cause réelle : les Jobs de sauvegarde Restic déjà terminés (Completed, conservés par successfulJobsHistoryLimit: 3) référencent encore l'ancienne PVC par son nom dans leur spec — le finalizer bloque tant qu'un seul pod, même arrêté depuis des heures, la référence encore. Repéré en listant tous les pods du namespace (y compris terminés) et leurs volumes, pas en supposant. Fix : supprimer les anciens Jobs de backup avant de supprimer la PVC (Nextcloud), et en plus supprimer le pod applicatif encore réellement actif (Immich, où immich-server montait activement l'ancienne PVC).
selfHeal empêche un simple kubectl scale --replicas=0 manuel — retenté sur Immich en réflexe pour libérer le montage proprement, immédiatement annulé par ArgoCD (même friction déjà documentée pour Vaultwarden/Tuwunel/uptime-kuma). Contournement finalement utilisé : supprimer directement le pod plutôt que le déployement — un remplacement de pod normal, pas une divergence d'état que selfHeal chercherait à corriger.
Validé en conditions réelles pour les deux services après bascule : Nextcloud (GET /status.php → installed: true), Immich (GET /api/server/ping → pong), volumes montés confirmés au nouveau chemin via kubectl get pod -o jsonpath, toutes les Applications ArgoCD Synced/Healthy.
Régression silencieuse trouvée bien après coup (2026-08-22) : la bascule ci-dessus a traité le problème des anciens Jobs de backup référençant l'ancienne PVC (bloquant sa suppression, cf. plus haut), mais pas celui du CronJob de sauvegarde Nextcloud lui-même, dont le template continuait de monter nextcloud-nextcloud (l'ancienne PVC, supprimée) au lieu du nouveau hostPath fixe. Conséquence : toute exécution future du job restait bloquée Pending indéfiniment (0/1 nodes are available: persistentvolumeclaim "nextcloud-nextcloud" not found), silencieusement — ArgoCD affiche un CronJob planifié comme "en bonne santé" indépendamment de si ses pods s'exécutent réellement, rien n'alerte activement. Repéré uniquement parce qu'un Application restait Progressing sans raison apparente (la vraie cause : la PVC du dépôt Restic, nextcloud-restic-repo, ne pouvait jamais se lier — WaitForFirstConsumer sans consommateur capable de démarrer). Corrigé (gitops/manifests/nextcloud-backup/cronjob.yaml, volume data en hostPath plutôt que persistentVolumeClaim) et validé avec un vrai run manuel : 29522 fichiers, 1,07 Gio, premier snapshot Nextcloud réellement réussi. Les cinq autres services (Vaultwarden/Immich/Tuwunel/Authentik/Jellyfin) vérifiés à cette occasion — aucun autre CronJob bloqué de la même façon.
Désactivation de l'app Photos de Nextcloud une fois Immich validé (décision actée avec l'utilisateur, cf. plus haut) : php occ app:disable photos ajouté au hook before-starting déjà en place (idempotent — exit 0 que l'app soit déjà désactivée ou non, vérifié avant d'écrire le manifest).
Tuwunel (messagerie)¶
Premier service de la Phase 3. Conduwuit, le choix documenté initialement dans architecture.md, s'est révélé archivé et non maintenu en amont en préparant ce déploiement — écarté avant tout déploiement réel plutôt que constaté après coup. Deux successeurs actifs identifiés : Continuwuity (continuation communautaire directe, pas de SSO natif confirmé) et Tuwunel (successeur officiel, adopté à l'échelle gouvernementale — Suisse —, staffé à temps plein, support OIDC natif déjà mergé) — Tuwunel retenu, cohérent avec le "tout par Authentik" déjà en place partout ailleurs dans ce projet (SSO pas encore branché à ce stade, cf. plus bas).
Aucun chart Helm à jour pour aucun des deux successeurs (le seul chart "conduwuit" trouvé sur Artifact Hub pointe vers le projet archivé) — déployé en manifests bruts (gitops/manifests/tuwunel/), même esprit que gitops/manifests/immich-postgres/. Stockage RocksDB embarqué (pas de Postgres séparé, comme Vaultwarden) — une seule PVC (local-path, 5Gi) montée sur /var/lib/tuwunel, pas d'étape d'init séparée : la base est créée automatiquement au premier démarrage (Created new RocksDB database with version 17 dans les logs du premier boot), puis rechargée aux démarrages suivants sans perte.
Configuration entièrement par fichier monté (gitops/secrets/tuwunel/tuwunel.sops.yaml, clé tuwunel.toml, variable TUWUNEL_CONFIG pointant dessus) plutôt que des variables d'env TUWUNEL_* individuelles comme au tout premier déploiement — bascule décidée en ajoutant le SSO (cf. section suivante), dont le bloc [[global.identity_provider]] ne peut de toute façon pas être posé par variables d'env (bug amont, tuwunel#340, le parseur env attend la struct complète et rejette un champ isolé). Plutôt que de mélanger env vars pour la base et fichier pour ce seul bloc, tout est passé en fichier — un seul endroit à lire pour comprendre la config complète d'un service, plus reproductible pour une réinstallation from-scratch.
Piège rencontré et corrigé avant le premier merge : Kubernetes injecte automatiquement, pour tout pod, des variables d'environnement "Docker links" par Service visible dans le namespace, nommées <NOM_SERVICE>_... en majuscules (<SVC>_SERVICE_HOST, <SVC>_PORT, <SVC>_PORT_<PORT>_TCP, etc.). Le Service s'appelant tuwunel — même nom que le préfixe de config de l'application elle-même —, ces variables auto-injectées seraient entrées en collision avec l'espace de noms TUWUNEL_* attendu par l'application si la config était restée par env vars. Corrigé en désactivant le mécanisme entièrement (enableServiceLinks: false sur le pod, toujours en place même après la bascule vers un fichier de config) — pattern à réutiliser si un futur service porte le même nom que son propre préfixe de variables d'env.
Point resté incertain dans le plan initial, tranché au déploiement réel : la configuration d'exemple par défaut de Tuwunel lie le service à 127.0.0.1/::1 uniquement — potentiellement inutilisable depuis le Service Kubernetes. Fixé explicitement via address = "0.0.0.0" dans tuwunel.toml, confirmé par les logs (Listening on ["tcp:0.0.0.0:8008"]) et par un curl réel réussi vers tuwunel.tuwunel.svc.cluster.local:8008 depuis un pod éphémère dans le cluster.
Premier compte inscrit devient automatiquement admin, confirmé en conditions réelles plutôt que supposé hérité de la lignée Conduit : inscription via le flow UIAA standard (POST /_matrix/client/v3/register sans auth → session retournée → nouveau POST avec m.login.registration_token et cette session), puis vérifié que le compte a bien rejoint automatiquement une room "<server_name> Admin Room" (visible dans GET /_matrix/client/v3/joined_rooms) en plus de toute room créée manuellement. Message de test envoyé avec succès dans une room créée via l'API (POST /createRoom puis PUT .../send/m.room.message/<txn>), confirmant le fonctionnement du service de bout en bout côté client-serveur.
Portée de ce premier déploiement : nu, sans SSO (branché juste après, cf. section suivante). Toujours pas de Restic ni d'app Android/Element X ni de LiveKit à ce stade, même déroulé incrémental que les autres services. Fédération (port 8448) non exposée, différée en Phase 4 (besoin de joignabilité publique réelle).
Tuwunel ↔ Authentik (SSO)¶
Deuxième étape de la Phase 3, juste après le déploiement nu. Différence structurelle avec Vaultwarden/Immich : Tuwunel n'a pas de serveur OIDC intégré, il délègue à un IdP externe (bloc [[global.identity_provider]] dans tuwunel.toml, cf. section précédente sur la bascule vers un fichier de config unique — ce bloc précis ne peut pas être posé par variables d'env, bug amont toujours ouvert).
Côté Authentik, même pattern de blueprint que les trois autres services (gitops/secrets/authentik-blueprints/tuwunel-sso.sops.yaml) : grant_types explicite (authorization_code, refresh_token — un défaut vide a déjà cassé Vaultwarden, cf. plus haut), scope mapping email réutilisant le mapping partagé "Vaultwarden: email (verified)" (même raisonnement : Authentik est le seul IdP de confiance du projet — raisonnement aussi appliqué côté Tuwunel avec trusted = true).
Gotcha repris directement d'un rapport d'utilisateur réel (pas découvert par erreur ici) : sans userid_claims = ["preferred_username"], le localpart Matrix généré à partir des claims Authentik ne correspond pas au nom d'utilisateur attendu — signalé sur tuwunel#249 (fermée, bug tuwunel corrigé après la 1.4.9.1 ; notre version v1.8.3 est largement postérieure).
Contrainte de test découverte en pratique : contrairement au tout premier déploiement de Tuwunel (une Application ArgoCD toute neuve, sans aucun état existant à défendre), Tuwunel et Authentik sont ici tous les deux déjà gérés par ArgoCD avec selfHeal: true — modifier les ressources en direct sur le cluster pour tester avant de merger se fait immédiatement écraser par la réconciliation automatique. Contournement : merger d'abord, laisser ArgoCD synchroniser depuis master, puis tester et corriger par commits de suivi si besoin — exactement le même déroulé que celui déjà documenté plus haut pour les 3 échecs réels de Vaultwarden↔Authentik.
Découverte du blueprint pas toujours immédiate après merge : même piège que Vaultwarden — après le montage du nouveau Secret dans le worker Authentik (visible sous /blueprints/mounted/secret-<nom>/), il a fallu déclencher manuellement ak apply_blueprint <chemin> pour que l'Application/OAuth2Provider apparaisse réellement côté Authentik (confirmé par un 404 sur /application/o/tuwunel/.well-known/openid-configuration avant, 200 après).
Deuxième vrai bug trouvé en testant la connexion réelle (pas en curl) : Tuwunel pose un cookie tuwunel_grant_session marqué Secure pendant l'échange OAuth avec Authentik — un navigateur le rejette silencieusement en HTTP, provoquant M_UNAUTHORIZED: Missing cookie "tuwunel_grant_session" au retour du callback, alors que la même requête construite à la main en curl (qui n'applique pas les règles de cookies d'un navigateur) semblait parfaitement correcte. Repéré uniquement grâce à un test de connexion réel au navigateur — même leçon méthodologique que pour Vaultwarden ("le déploiement seul ne suffit jamais à valider un flow OIDC"). Fix : bascule de Tuwunel sur HTTPS (:8453, certificat mkcert tuwunel-tls, hors GitOps comme les deux autres) — troisième service à atterrir sur ce port, pour une troisième raison différente (Subtle Crypto pour Vaultwarden, user_oidc pour Nextcloud, cookie Secure de la session SSO ici).
Validé en conditions réelles après le fix HTTPS : connexion complète au navigateur (compte Authentik existant akadmin), redirection jusqu'au redirectUrl avec un loginToken dans la query string (comportement standard Matrix), puis échange réel de ce jeton contre une session (POST /_matrix/client/v3/login avec m.login.token) confirmant user_id: "@akadmin:myown-tuwunel.local" — le localpart correspond bien au nom d'utilisateur Authentik grâce à userid_claims, pas un identifiant généré.
LiveKit (appels vidéo de groupe)¶
Dernière brique de la Phase 3. Architecture MatrixRTC/MSC4143 en trois parties : Tuwunel (déjà déployé) sert la découverte via .well-known/matrix/client → LiveKit (SFU média) → lk-jwt-service (émet des JWT LiveKit après vérification d'un jeton OpenID Matrix auprès de Tuwunel). Contrairement à tout ce qui a été déployé jusqu'ici (services HTTP purs derrière Traefik), LiveKit a besoin d'une vraie exposition réseau UDP/TCP pour le média WebRTC — changement de nature, pas juste un service de plus.
Mode single-port, pas la plage 50000-60000 : pour une seule instance sur un seul nœud (notre cas), le chart supporte rtc.udp_port/rtc.tcp_port (ports fixes uniques) au lieu de port_range_start/end — évite de mapper des milliers de ports sur k3d. podHostNetwork (défaut du chart, laissé tel quel) place le pod sur le network namespace réel de l'hôte, pour que la détection d'IP externe par STUN trouve l'IP LAN/publique réelle de la machine plutôt qu'une adresse Docker interne.
Cluster recréé pour ajouter les nouveaux ports (7880 TCP signalisation, 7881 TCP repli ICE, 7882 UDP média, puis 443 dans un second temps, cf. plus bas) — confirmé avec l'utilisateur plutôt que reporté à la Phase 4, les 4 services avec état (Vaultwarden, Nextcloud, Immich, Tuwunel) ayant déjà une sauvegarde Restic validée. Étape supplémentaire découverte à cette occasion : les dépôts Restic eux-mêmes vivent sur des PVC local-path dans le cluster (pas de vrai nœud ami), donc recréer le cluster sans les extraire au préalable aurait aussi perdu les sauvegardes, pas seulement les données live — kubectl cp vers le disque hôte avant suppression, puis réinjection après rebootstrap, premier test réel de portabilité des dépôts hors du cluster.
Deux bugs réels trouvés au déploiement, avant même Element Call :
key_file: /keys.yamlrejeté (storeKeysInSecret) — Kubernetes refuse unsubPathabsolu, alors que ce chart réutilisekey_fileà la fois pour lemountPathet lesubPathdu Secret de clés. Corrigé en valeur relative (keys.yaml), le fichier atterrit correctement à la racine du conteneur.- Collision de nom avec les variables d'env Kubernetes — même famille de piège que Tuwunel : un
Servicenommé exactementlivekitfait injecterLIVEKIT_PORT=tcp://...par Kubernetes, qui entre en collision avec le flag--port/LIVEKIT_PORTde LiveKit lui-même (CrashLoopBackOff, "could not parse tcp://...:80 as uint value... for flag port"). Ce chart n'a pas d'équivalent àenableServiceLinks: false— corrigé en renommant leService(fullnameOverride: livekit-sfu).
Découverte côté Tuwunel : [global.well_known] dans tuwunel.toml, clé livekit_url (forme moderne, single-key, publiant org.matrix.msc4143.rtc_foci) plutôt que l'ancien tableau [[global.well_known.rtc_transports]] — confirmé contre le tuwunel-example.toml officiel du dépôt GitHub, pas une doc tierce (une première tentative avec rtc_transports au niveau racine [global] a été silencieusement ignorée par le parseur, mauvaise table). Sans well_known.client du tout, Tuwunel ne sert même pas .well-known/matrix/client (404 confirmé en direct) — répond aussi au warning de démarrage persistant "OIDC server (Next-gen auth) requires well_known.client", sans lien avec le SSO qui fonctionnait déjà sans lui.
Trois bugs trouvés en testant un vrai join Element Call (app.element.io pointé sur notre serveur), aucun visible en curl :
MISSING_MATRIX_RTC_TRANSPORT: cause racine, la découverte.well-known/matrix/clientet.well-known/matrix/serverdoivent être servies sur le port standard 443 (implicite dans le nom de domaine), pas le port custom:8453utilisé partout ailleurs dans ce projet. Le cluster k3d ne mappait que8453:443@loadbalancer, pas le port hôte 443 littéral — deuxième recréation du cluster pour l'ajouter (-p "443:443@loadbalancer"), plutôt que de différer à la Phase 4.- Lookup TLS échoue silencieusement :
lk-jwt-serviceéchoue à vérifier le jeton OpenID auprès de Tuwunel (OPEN_ID_ERROR), retracé dans le code source degomatrixserverlib/fclient—LookupWellKnown()(fonction utilisée pour la découverte de fédération) utilise son proprehttp.Client{}codé en dur, ignorantLIVEKIT_INSECURE_SKIP_VERIFY_TLS/fclient.WithSkipVerify()qui ne couvre que le transport de requête de fédération séparé. Résultat : le conteneur ne fait pas confiance au CA mkcert de dev, l'appel échoue, etfclientretombe silencieusement sur le port par défaut 8448 (rien n'y écoute). Corrigé avecSSL_CERT_FILE(mécanisme standard Go pour le pool de certs système) pointé vers unConfigMapmontant le CA mkcert. dial tcptimeout sur le port 8008 une fois le CA de confiance réglé : la délégation.well-known/matrix/serverpointait initialement vers le port brut de Tuwunel (8008, jamais routé par Traefik) — corrigé en délégant vers:8453à la place (les requêtes de fédération utilisent toujours HTTPS quel que soit le port délégué,makeHTTPSURLfixe le schéma en dur dansgomatrixserverlib).
ServerUnreachable une fois l'auth complète : LIVEKIT_URL était réglé sur le nom DNS interne au cluster (ws://livekit-sfu.livekit.svc.cluster.local), or cette valeur est renvoyée telle quelle au navigateur comme cible de connexion WebSocket média — inaccessible depuis l'extérieur du cluster. Corrigé en wss://myown-livekit.local:8453 (public, HTTPS obligatoire — Element Call tourne sur https://app.element.io, une cible ws:// en clair serait bloquée en contenu mixte), nécessitant un nouveau certificat mkcert (livekit-tls) et un Ingress TLS dédié.
Boucle de reconnexion périodique (~15s), diagnostiquée via la console navigateur, pas les logs serveur (les logs LiveKit montraient des reconnexions "propres", flux UDP réel — pas un problème d'auth) : le SDK JS livekit-client (2.20.0, protocol 17) tenté par Element Call essaie systématiquement le chemin de signalisation WebSocket /rtc/v1 en premier à chaque (re)connexion — la version du serveur pinnée par défaut du chart (v1.9.0, protocol 16) répond 404 ("v1 RTC path not found. Consider upgrading your LiveKit server version"), et le client retombe sur l'ancien chemin /rtc qui fonctionne. Mais une NegotiationError: negotiation timed out survenant ensuite forçait une reconnexion complète, qui retentait /rtc/v1, qui échouait à nouveau — boucle. Le tag d'image du serveur LiveKit est indépendant de la version du chart (qui accuse un retard sur les releases réelles de livekit-server) : image.tag: v1.13.5 (dernière stable au moment du fix) a suffi, confirmé par /rtc/v1/validate répondant désormais 401 (jeton invalide) au lieu de 404.
Événements de call membership pollués par les tests répétés : après le fix ci-dessus, les réactions Element Call échouaient dans la room utilisée pour tout le débogage (Reaction target was not a membership event for ..., ignoring) — une dizaine de cycles join/leave/reconnexion (dont la période de boucle avant le fix) avaient probablement laissé des événements d'état org.matrix.msc3401.call.member orphelins dans cette room précise. Confirmé en testant dans une room neuve : appel et réactions fonctionnent normalement — pollution de données de test, pas un vrai bug.
Validé en conditions réelles : join complet Element Call depuis un vrai navigateur (audio publié, flux UDP réel confirmé côté serveur), réactions fonctionnelles, aucune boucle de reconnexion après le fix de version. Test multi-appareils LAN réel (deuxième machine, pas juste deux onglets) pas encore fait à ce stade.
Ollama (IA locale)¶
Chantier Phase 3.5 : déployer Ollama sur le cluster de dev et choisir le modèle avant l'implémentation réelle du connecteur mail (Phase 5). Chart communautaire otwld/ollama-helm (le plus établi des charts Ollama disponibles) — ollama.models.pull liste les modèles à télécharger au démarrage, déclaratif comme le reste du projet.
Contrainte disque réelle sur cette machine de dev : ~31 Gio libres au départ, retombés à ~13 Gio après le pull des 3 modèles de test (~14,5 Gio réels sur disque, chaque modèle pèse un peu plus que sa taille annoncée). PVC dimensionnée pour tenir plutôt que pour laisser de la marge, contrairement aux autres PVC du projet — écart à surveiller si d'autres modèles sont testés plus tard.
Comparatif Qwen3 8B / Mistral 7B / Llama 3.1 8B¶
Besoin exprimé : tri/résumé de mail en français, anglais et russe, avec extraction de données structurées (date, heure, action requise, échéance) pour la fonctionnalité Phase 5 de création d'événements calendrier — pas juste un résumé en prose. Préférence de départ pour une solution française (Mistral), à confirmer ou infirmer par des tests réels plutôt que sur la seule base de fiches techniques.
Premier essai invalidé par un biais de méthode : les trois modèles répondaient systématiquement en français quelle que soit la langue de l'email, alors même que la consigne demandait explicitement "dans la même langue que l'email" — parce que cette consigne elle-même était rédigée en français. Corrigé en rédigeant un prompt par langue cible (dans cette langue), éliminant l'ambiguïté. Une fois corrigé : 9/9 réponses dans la bonne langue, pour les trois modèles.
Qwen3 anormalement lent au premier test (37-131s par réponse) : cause trouvée en testant une requête triviale ("dis bonjour en un mot") — Qwen3 génère par défaut un raisonnement interne complet avant de répondre (mode "thinking" activé par défaut), visible dans le champ thinking de la réponse API même pour une question sans aucune difficulté. Résolu avec "think": false dans le corps de la requête (paramètre supporté nativement par l'API Ollama depuis une version récente, ignoré sans erreur par les modèles qui ne le gèrent pas) — passe de plusieurs dizaines de secondes à <1s pour une requête triviale, et ramène Qwen3 à une vitesse comparable aux deux autres modèles sur les vrais tests.
Test d'extraction structurée, deuxième itération corrigée : le premier essai ne fournissait pas la date du jour au modèle — impossible de calculer une date ISO à partir de "mardi 18 août" sans référence temporelle, ce qui pénalisait artificiellement les trois modèles sur les champs date/échéance. Corrigé en ajoutant "Today's date is 2026-08-14" au prompt.
Résultat final (3 scénarios : rendez-vous médical, demande professionnelle avec échéance, newsletter sans action requise — champs action_required/date/time/deadline comparés à la valeur attendue) :
| Modèle | Champs corrects | Erreurs notables |
|---|---|---|
| Qwen3 8B | 9/12 | Rendez-vous médical : 4/4. Les 3 erreurs restantes sont un défaut de schéma bénin (remplit date avec la date du jour plutôt que null en l'absence de date d'événement) — action_required et deadline corrects dans les 3 scénarios sans exception |
| Mistral 7B | 6/12 | Erreurs plus dispersées, pas de motif clair |
| Llama 3.1 8B | 5/12 | Vraie erreur de compréhension sur le rendez-vous médical : action_required: false alors que l'email demande explicitement de prévenir 24h à l'avance en cas d'empêchement |
Décision : Qwen3 8B retenu, malgré la préférence initiale pour une solution française (Mistral) — écart net et mesuré sur données réelles, pas seulement sur fiche technique, notamment sur l'exactitude de l'extraction (le vrai besoin Phase 5, pas juste un résumé lisible). Origine chinoise (Alibaba Cloud) acceptée en connaissance de cause après discussion explicite avec l'utilisateur. Accessoirement, Llama 3.1 ne supporte même pas officiellement le russe dans sa liste de langues (8 langues officielles, le russe n'en fait pas partie) — un point qui l'aurait de toute façon écarté vu le besoin exprimé.
Config finale : seul qwen3:8b dans ollama.models.pull (Mistral et Llama 3.1 supprimés du pod après le test, ollama rm, pour libérer le disque — ~9 Gio récupérés). OLLAMA_MAX_LOADED_MODELS: "1" — un vrai bug rencontré en testant : sans cette limite, Ollama garde le modèle précédent chargé en mémoire pendant le chargement du suivant, dépassant la limite mémoire du pod (OOMKilled, exit code 137, confirmé via kubectl describe pod) — comportement cohérent de toute façon avec le principe déjà posé pour la Phase 5 (modèle transitoire, jamais plusieurs chargés en même temps).
Pipeline complet prototypé de bout en bout : mail → extraction → proposition → confirmation → calendrier¶
Dernier morceau de la Phase 3.5 sur ce chantier : valider que toute la chaîne prévue pour la Phase 5 tient réellement, pas seulement l'extraction Ollama isolée. Chaque maillon testé avec de vraies requêtes contre les vrais services (Tuwunel, Nextcloud) déjà déployés — seule la source du mail est simulée.
Boîte mail jetable, pas un vrai compte externe : création d'un compte chez un fournisseur tiers étant hors de portée pour l'assistant (cf. CLAUDE.md, règles de sécurité), et l'utilisateur ayant explicitement préféré cette option à fournir sa vraie boîte. Choix initial (Mailpit) écarté après vérification : il ne supporte que SMTP + une UI web/API, pas IMAP — inutilisable pour tester un connecteur IMAP, alors que c'est justement le protocole à valider. Remplacé par GreenMail (greenmail/standalone), conçu spécifiquement pour ce cas d'usage (serveur de test SMTP+IMAP+POP3, aucune vraie remise de mail). Déployé en dehors du GitOps du projet (manifests bruts appliqués directement, kubectl apply hors ArgoCD) — c'est un outil de test jetable pour le développement du connecteur, pas un service destiné à rester, même logique que les scripts de comparaison de modèles restés hors dépôt.
Déroulé validé en conditions réelles, chaque étape vérifiée indépendamment plutôt que supposée fonctionnelle :
- Trois mails de test injectés via SMTP dans GreenMail (mêmes scénarios que le comparatif de modèles : rendez-vous médical, demande professionnelle avec échéance, newsletter sans action).
- Connecteur prototype : connexion IMAP réelle (protocole
imaplib, pas l'API REST d'un outil de test), récupération des mails, extraction structurée via Qwen3 — mêmes résultats que le comparatif initial (dates/échéances exactes sur les deux mails actionnables, aucune fausse alerte sur la newsletter). - Proposition envoyée en DM Matrix depuis
@alertbot(le même bot que l'alerting admin, réutilisé en mode DM privé plutôt que salon partagé — cf.roadmap.mdPhase 4/5, séparation stricte déjà actée) vers un salon de conversation directe créé pour l'occasion (createRoomavecis_direct: true,preset: trusted_private_chat). - Confirmation humaine réelle : l'utilisateur a rejoint le salon via un lien
matrix.todirect (même pattern que pour le salon d'alerte, cf. section Uptime Kuma — la jointure par recherche d'alias ne fonctionnant toujours pas de façon fiable dans Element Web) et répondu "oui" depuis son propre client. - Détection de la réponse par polling de l'historique du salon (
GET /messages, prototype seulement — une vraie implémentation Phase 5 utiliserait/syncen long-polling, pas un polling actif). - Écriture de l'événement dans le calendrier Nextcloud via CalDAV (
PUTd'un.icssur/remote.php/dav/calendars/admin/personal/<uid>.ics, calendrier par défaut confirmé viaPROPFINDavant d'écrire plutôt que deviné) — uniquement après la confirmation, aucune écriture automatique silencieuse, conforme au principe "l'IA propose, l'utilisateur valide" déjà posé pour toute la Phase 5. - Événement relu directement depuis Nextcloud (
GETsur la même URL) pour confirmer une vraie persistance, pas juste un code201en apparence correct.
Un vrai bug trouvé après coup, en vérifiant dans le vrai navigateur (pas juste l'API — la leçon déjà tirée plusieurs fois ce projet, confirmée une fois de plus) : l'événement écrit était invisible dans l'app Calendrier de Nextcloud, alors même que GET et une vraie requête REPORT avec filtre time-range (le protocole exact qu'utilise l'app elle-même, pas un raccourci) le renvoyaient correctement — la vérification API la plus poussée qu'on ait faite dans ce projet n'a pas suffi à elle seule. Cause trouvée en comparant avec l'événement d'exemple fourni par défaut par Nextcloud (bien visible, lui) : DTSTART/DTEND sans aucune information de fuseau horaire (ni Z, ni TZID — un "floating time" au sens RFC 5545) s'enregistrent et se relisent sans erreur, mais l'app Calendrier ne les affiche pas, contrairement à un horaire explicitement en UTC (Z) comme l'utilise l'événement d'exemple. Corrigé en calculant l'heure UTC correspondant à l'heure française (14h30 CEST = 12h30 UTC en août) plutôt que d'écrire l'heure locale telle quelle.
Deuxième vrai bug, trouvé de la même façon (événement toujours invisible après le fix ci-dessus) : l'écriture ciblait le compte local admin (celui déclaré dans le secret nextcloud-secrets), mais l'utilisateur navigue connecté via SSO Authentik — et user_oidc provisionne un compte Nextcloud entièrement distinct au premier login SSO, avec un identifiant opaque (un hash, pas "akadmin" ni un nom lisible : confirmé via occ user:info, backend: user_oidc). Les deux comptes existent en parallèle, chacun avec son propre calendrier "personal" — écrire chez l'un ne le rend pas visible pour l'autre. Complication supplémentaire : ce compte SSO refuse toute authentification par mot de passe local (occ user:resetpassword échoue silencieusement, comportement voulu du backend OIDC — pas de contournement de la SSO par mot de passe local, une bonne pratique de sécurité, pas un bug). Résolu avec occ user:auth-tokens:add (alias user:add-app-password), qui génère un jeton d'application valable pour l'API/CalDAV indépendamment du backend d'authentification — mécanisme prévu justement pour ce cas de figure. Identifiant du compte SSO trouvé via l'API OCS (GET /ocs/v2.php/cloud/users, admin), pas deviné.
Implication réelle pour l'implémentation Phase 5 : le connecteur devra résoudre l'identifiant de compte Nextcloud propre à chaque utilisateur (le hash user_oidc, pas un nom d'utilisateur supposé) avant d'écrire quoi que ce soit dans son calendrier — un mapping à construire, pas à improviser au moment de l'implémentation réelle.
Ce qui reste pour la vraie implémentation Phase 5 (volontairement hors scope de ce prototype) : bascule sur la vraie boîte Mailcow une fois déployée (Phase 4), remplacement du polling par un vrai /sync Matrix, ordonnancement récurrent (cron/CronJob plutôt qu'un script lancé à la main), gestion des cas d'erreur (mail déjà traité, réponse ambiguë, timeout de confirmation), et résolution propre du compte Nextcloud cible par utilisateur (cf. ci-dessus).
Jellyfin (bibliothèque films/musique)¶
Ajouté hors périmètre initial du MVP — demandé directement par l'utilisateur après une discussion sur le meilleur moyen de partager des films/musiques achetés avec une famille dispersée sur plus de 800 km, un besoin que ni Nextcloud (stockage brut, pas de confort de lecture) ni Immich (pensé pellicule/photos, pas bibliothèque de films) ne couvrent. Déployé via le chart officiel jellyfin/jellyfin-helm (dépôt GitHub sous l'org jellyfin, vérifié activement maintenu avant de le retenir — dernier push et PR mergées le 2026-08-08, pas d'archive) plutôt qu'une des nombreuses alternatives communautaires trouvées en recherche (wrenix, TrueCharts, etc.).
PVC réduites par rapport aux défauts du chart (config 5Gi, media 5Gi au lieu de 25Gi) : bibliothèque de test uniquement à ce stade (roadmap.md Phase 3.5), le disque de ce cluster de dev était déjà serré au moment du déploiement (~23Gi libres, cf. l'incident du 2026-08-20 plus haut) — la vraie taille sera reconsidérée avec materiel.md une fois le mini PC en place et un usage réel connu.
Premier déploiement en échec réel : CrashLoopBackOff immédiat, confirmé dans les logs du pod — System.IO.IOException: The configured user limit (128) on the number of inotify instances has been reached. fs.inotify.max_user_instances est un réglage au niveau de l'hôte, partagé par tous les processus de la machine — pas question de l'augmenter juste pour ce conteneur. Corrigé avec le contournement documenté par le chart lui-même plutôt qu'un changement de sysctl hôte : DOTNET_USE_POLLING_FILE_WATCHER=1, qui fait basculer la surveillance de fichiers de Jellyfin sur du polling plutôt que sur inotify.
Validé en conditions réelles après le fix : pod Running, Application ArgoCD Synced/Healthy, requête HTTP réelle contre /System/Info/Public confirmant un serveur qui répond correctement (StartupWizardCompleted: false, cohérent avec un tout premier déploiement).
Jellyfin ↔ Authentik (SSO)¶
Jellyfin n'a pas de support OIDC natif — passe par un plugin communautaire tiers. Le plugin le plus populaire (9p4/jellyfin-plugin-sso, 1462 étoiles) est archivé (dernier push mai 2026) — écarté pour la même raison que Conduwuit en Phase 3 (upstream mort). Entre les deux alternatives actives trouvées, scottfridwin/jellyfin-plugin-authentik (spécifique Authentik, config à 3 champs obligatoires seulement, activité la plus récente) retenu plutôt que Ezeqielle/jellyfin-plugin-oidc (générique, plus de traction communautaire mais setup plus manuel côté claims) — l'un ou l'autre restait un vrai choix, pas une évidence, les deux étant jeunes (créés mai 2026).
Compte admin local créé via l'API (Startup/*) plutôt qu'à la main dans l'assistant de première configuration — même logique que le reste du projet (éviter les clics manuels quand c'est reproductible).
Deux vrais bugs trouvés en l'installant réellement, pas en suivant la doc au pied de la lettre :
- Incompatibilité binaire entre le plugin et l'image Jellyfin par défaut du chart — installation acceptée par l'API, mais échec de chargement au redémarrage (
Failed to load assembly ... This error occurs when a plugin references an incompatible version of one of the shared libraries, confirmé dans les logs serveur). Cause : le chart3.2.0résout son image par défaut vers10.11.8, alors que le.deps.jsondu plugin (téléchargé et inspecté directement) référenceJellyfin.Controller/10.11.11. Corrigé en épinglantimage.tag: "10.11.11"explicitement (gitops/apps/jellyfin.yaml), même pattern que le pin de version déjà fait pour LiveKit. /System/Restartde l'API n'a aucun effet réel dans ce déploiement conteneurisé — accepté (204), mais le compteur de redémarrage du pod reste à0: pas de superviseur de processus dans le conteneur pour relancer Jellyfin après l'arrêt de son propre processus .NET. Un plugin fraîchement installé ne charge donc jamais tant qu'un vrai redémarrage du pod n'est pas déclenché (kubectl rollout restart) —scripts/jellyfin-sso-setup.pyle fait automatiquement après une installation.
Format du fichier de config du plugin vérifié en direct plutôt que deviné (la doc du plugin ne le documente pas) : /config/plugins/configurations/Jellyfin.Plugin.Authentik.xml, racine <PluginConfiguration> — confirmé en inspectant le fichier généré par un autre plugin déjà actif (TMDb) avant d'écrire quoi que ce soit.
Blueprint Authentik (gitops/secrets/authentik-blueprints/jellyfin-sso.sops.yaml) : même schéma que Vaultwarden/Nextcloud/Immich/Tuwunel — mapping de scope email partagé (Vaultwarden: email (verified)), grant_types explicite (piège déjà documenté plus haut pour Vaultwarden). Le mapping système profile d'Authentik inclut déjà groups dans son expression (vérifié via ak shell plutôt que supposé depuis la doc du plugin) — pas de mapping dédié nécessaire. Créer le secret ksops seul ne suffit pas à le monter dans authentik-worker : il a aussi fallu ajouter son nom à blueprints.secrets dans les valeurs du chart Authentik (gitops/apps/authentik.yaml) — sans ça le volume n'apparaît tout simplement pas dans le pod, contrairement à ce qu'on pourrait attendre du seul mécanisme de découverte automatique des blueprints.
Redirect URI en HTTP simple (http://myown-jellyfin.local:8090/authentik/callback), pas HTTPS comme Vaultwarden/Nextcloud/Tuwunel — vérifié dans le code source du plugin (AuthentikController.GetBaseUrl()) que le schéma est dérivé dynamiquement de la requête entrante plutôt que codé en dur, cohérent avec le déploiement HTTP :8090 de Jellyfin.
Pas d'injection automatique d'un bouton "Se connecter avec Authentik" sur la page de connexion — le plugin ne fournit aucun hook pour ça, il faut poser le HTML/CSS soi-même dans la config de branding de Jellyfin. Trouvé après coup, en lisant le code source du serveur plutôt qu'en devinant depuis les réponses de l'API (GET /Branding/Configuration et GET /System/Configuration/branding ne renvoyaient que SplashscreenEnabled — pas parce que LoginDisclaimer/CustomCss n'existent pas dans le schéma, juste parce qu'ils sont null par défaut et donc omis de la sérialisation JSON, confirmé dans MediaBrowser.Model.Branding.BrandingOptions) : il existe une route d'écriture dédiée, POST /System/Configuration/Branding (Jellyfin.Api.Controllers.ConfigurationController.UpdateBrandingConfiguration) — différente du endpoint générique POST /System/Configuration/{key}, qui accepte silencieusement une requête sans jamais la persister pour cette clé précise (piège à connaître si on tente ce chemin en premier). Snippet HTML (<form action="/authentik/login">) + CSS (couleur orange Authentik #fd4b2d) appliqués via cette route, intégrés à scripts/jellyfin-sso-setup.py pour rester reproductibles.
Récupération d'accès admin en cours de route : le jeton/mot de passe admin généré au tout premier déploiement n'avait pas été persisté en secret — retrouvé via le mécanisme de réinitialisation natif de Jellyfin (POST /Users/ForgotPassword écrit un fichier PIN dans /config, lisible via kubectl exec ; découverte en lisant DefaultPasswordResetProvider.cs : RedeemPasswordResetPin fixe temporairement le mot de passe au PIN lui-même, pas à une valeur vide comme on pourrait s'y attendre), puis mot de passe changé pour une vraie valeur aléatoire et stocké dans jellyfin-secrets (ADMIN_PASSWORD, avec SSO_CLIENT_ID/SSO_CLIENT_SECRET).
Validé en conditions réelles : configuration de branding relue après application (GET /System/Configuration/branding renvoie bien LoginDisclaimer/CustomCss non vides), script rejoué en conditions idempotentes sans erreur.
scripts/jellyfin-sso-setup.py : assistant de première configuration + installation/configuration du plugin, même pattern que uptime-kuma-setup.py (pas de config déclarative possible côté Jellyfin) — testé réellement en conditions idempotentes (rejoué contre une instance déjà configurée, aucune erreur, configuration inchangée après coup).
Validé en conditions réelles au navigateur : connexion via /authentik/login, redirection Authentik puis retour immédiat connecté (session existante réutilisée) — compte akadmin auto-provisionné confirmé via l'API /Users (IsAdministrator: false, cohérent avec l'absence du groupe Authentik jellyfin-admins à ce stade).
Sauvegarde Restic : même schéma que Vaultwarden/Tuwunel (pas de Postgres, config Jellyfin en SQLite embarquée) mais deux chemins sauvegardés au lieu d'un seul (/config et /media), même forme que le job fichiers+dump de Nextcloud. Planifiée à 04:15, après Authentik (04:00). Validée avec un vrai cycle backup + restauration : job déclenché manuellement (kubectl create job --from=cronjob/...), snapshot réel confirmé (/config + /media, tags daily/weekly/monthly), puis restauration dans un pod jetable confirmant la présence réelle de jellyfin.db et des fichiers de config (/media vide, cohérent avec une bibliothèque encore sans contenu à ce stade). Devenu partiellement obsolète depuis — voir le point suivant, /media a été retiré de ce job.
Bibliothèque média sourcée depuis Nextcloud (pas de PVC dédié)¶
Demandé directement par l'utilisateur après la bascule Nextcloud/Immich vers hostPath (section précédente) : plutôt que deux copies à synchroniser à la main (kubectl/docker cp vers le PVC Jellyfin), Jellyfin lit désormais en lecture seule le même dossier que Nextcloud — la famille dépose ses films/musiques dans Nextcloud (geste déjà connu, comme pour les photos/documents), Jellyfin scanne ce même dossier. Fonctionne uniquement parce que Nextcloud est déjà passé en hostPath fixe : un montage hostPath classique n'a pas la contrainte mono-écrivain (ReadWriteOnce) d'un PVC partagé entre deux pods.
Le chart Jellyfin supporte ça nativement — persistence.media.type: hostPath + persistence.media.hostPath: <chemin> + persistence.media.readOnly: true (vérifié dans templates/deployment.yaml du chart : readOnly s'applique au volumeMount quel que soit le type de volume sous-jacent). Chemin utilisé : .../nextcloud-data/data/admin/files — le compte admin de Nextcloud pour l'instant (à revoir une fois plusieurs comptes familiaux réels en usage ; Jellyfin n'a accès en lecture qu'à ce seul compte, pas à ceux des autres membres). Bibliothèques Jellyfin (Films, Musique) inchangées — elles pointaient déjà sur /media/Films//media/Musique, seule la nature du volume monté à /media a changé, aucune reconfiguration des bibliothèques nécessaire.
Ancien PVC jellyfin-media retiré de la sauvegarde Restic (/config seul désormais) — le sauvegarder aurait dupliqué ce que la sauvegarde Nextcloud couvre déjà pour ces mêmes fichiers, sur le même disque.
Validé en conditions réelles : deux fichiers de test (vidéo H.264 + audio MP3, générés avec gst-launch-1.0 faute de ffmpeg sur la machine) déposés dans Films/Musique côté Nextcloud, reconnus par un vrai occ files:scan admin (6 mises à jour), puis retrouvés par Jellyfin après POST /Library/Refresh — les deux items apparaissent dans GET /Items, avec un détail amusant à noter pour la suite : le fichier vidéo synthétique sans métadonnées a été mal identifié par le scraper TMDb (attribué à un vrai film sans rapport) — comportement attendu pour du contenu de test sans nom réel, pas un bug.
Bascule vers un dossier de groupe plutôt que le compte admin (2026-08-20) : le "à revoir" noté ci-dessus concrétisé dès que la question s'est vraiment posée (mini PC, premiers comptes familiaux réels). Plutôt qu'un compte Nextcloud dédié à partager (identifiants communs, mauvaise pratique), l'app officielle groupfolders (php occ app:install groupfolders, ajoutée au hook before-starting) permet à plusieurs comptes personnels distincts de déposer dans un seul dossier physique — chacun garde sa propre identité SSO. Groupe famille + dossier Mediatheque créés via occ groupfolders:create/occ groupfolders:group (voir manuel-installation.md §15 pour la séquence exacte). Piège de syntaxe : occ groupfolders:group <id> famille read create update delete share échoue ("Unable to parse permissions input") — les mots-clés valides sont read write share delete (pas create/update), confirmé via occ groupfolders:group --help plutôt que deviné depuis la doc en ligne. Chemin réel sur disque vérifié en direct plutôt que calculé : <hostPath Nextcloud>/data/__groupfolders/<id> (l'id, attribué à la création, n'est pas prévisible à l'avance) — et la structure interne d'un dossier de groupe ajoute un niveau files/ (plus trash//versions/, gérés par l'app) que le dossier personnel classique d'un utilisateur n'a pas : le contenu réel vit à .../data/__groupfolders/<id>/files/, pas directement à la racine. gitops/apps/jellyfin.yaml pointe désormais sur ce chemin, et ses bibliothèques Films/Musique (jamais configurées sur le mini PC avant ce chantier — juste le SSO l'avait été) recréées via POST /Library/VirtualFolders sur /media/files/Films et /media/files/Musique.
Vrai bug bloquant trouvé en testant l'écriture pour de vrai (pas en supposant que la permission suffisait) : MKCOL/PUT via WebDAV échouaient systématiquement en 403 Forbidden, alors que la permission de groupe (31 = tous les droits), l'appartenance au groupe, et la quota (-3 = illimité, pas un bug — vérifié, fausse piste explorée en premier) étaient tous corrects en base (oc_group_folders, oc_group_folders_groups, oc_group_user, requêtés directement en SQL pour en avoir le cœur net). Cause réelle, trouvée en testant l'écriture directement dans le conteneur en tant que www-data (pas juste en root via kubectl exec, qui masque ce genre de souci) : occ groupfolders:create avait créé __groupfolders/1 appartenant à root:root — exactement le même piège que celui déjà rencontré ce jour-même sur config.php de Nextcloud (kubectl exec s'exécute en root par défaut, pas www-data, qui est l'utilisateur réel des workers Apache). Corrigé avec chown -R www-data:www-data sur le dossier de groupe.
Script installeur (scripts/install.sh)¶
Validé par étapes sur des clusters k3d jetables (jamais contre myown-dev directement — voir plus bas pourquoi). Deux vrais problèmes trouvés en le testant contre un cluster neuf avec les ports par défaut réels (le premier essai, contre un cluster nommé différemment en parallèle de myown-dev, avait échoué pour une autre raison : contention CPU/mémoire des deux stacks k3d/containerd tournant simultanément, cluster de test jamais sorti de Waiting for containerd startup — pas un défaut du script).
Race condition confirmée entre le patch KSOPS et sa prise en compte par ArgoCD : l'étape "Activation de KSOPS" du script patchait argocd-repo-server sans attendre la fin du rollout (contrairement à l'étape argocd-server, qui a bien son kubectl rollout status). Conséquence observée en conditions réelles : l'application authentik (probablement les autres aussi) se retrouvait en ComparisonError — couldn't execute function: exec: "ksops" not found in $PATH — mis en cache par le contrôleur ArgoCD avant que le nouveau pod repo-server (avec l'init-container install-ksops) ne soit prêt. Vérifié directement : l'init-container était bien présent et le rollout bien terminé (NewReplicaSetAvailable) peu après l'erreur — un simple hard-refresh de l'Application ne suffit pas à effacer l'erreur mise en cache, il faut empêcher la comparaison prématurée en amont. Fix : kubectl rollout status deployment argocd-repo-server -n argocd --timeout=120s ajouté juste après le patch, même pattern que l'étape ArgoCD server.
Contrainte réelle de disque sur la machine de dev, distincte de la contention CPU/mémoire du premier essai : au second essai (un seul cluster actif — myown-dev arrêté via k3d cluster stop, non supprimé, pour libérer les ports), le nouveau cluster de test a fini par heurter un vrai DiskPressure: True côté kubelet, provoquant des évictions en cascade sur presque tous les pods ArgoCD (Evicted/Error/Pending). Cause : k3d cluster stop libère les ports et arrête les conteneurs mais ne libère aucun espace disque — les images déjà tirées pour myown-dev restent en cache pendant que le nouveau cluster de test tire un second jeu complet d'images (ArgoCD + 9 services, dont Ollama/qwen3:8b à 5,2 Go). Sur cette machine, la partition racine était déjà à 90% avant le test (18 Go libres sur 184 Go) — largement insuffisant pour héberger les deux jeux d'images en même temps. Résolu en supprimant le cluster de test (k3d cluster delete, ~24 Go d'images récupérés) puis en relançant myown-dev normalement (repart sur ses propres images déjà en cache, aucun nouveau pull nécessaire). Conséquence pour les tests futurs de ce script : tester contre un cluster jetable séparé nécessite soit de supprimer/nettoyer les images du cluster précédent avant de créer le nouveau, soit de disposer d'une machine avec davantage de marge disque — ce n'est pas une limite du script lui-même, mais du budget disque de la machine de dev actuelle (distinct du dimensionnement du futur mini PC, voir materiel.md).
Bug réel trouvé pendant le premier bootstrap du mini PC (2026-08-20) : la boucle mkcert dérivait le namespace directement du hostname (ns="${host#myown-}"; ns="${ns%.local}"), correct pour tous les services sauf myown-livekit-jwt.local — dérive vers un namespace livekit-jwt inexistant (le vrai namespace, partagé avec myown-livekit.local, est livekit). Passait inaperçu en dev parce que la ligne suivante avale l'erreur (|| echo "namespace pas encore créé...", pensée pour un tout autre cas — le namespace pas encore matérialisé par ArgoCD). Corrigé avec une liste host:namespace:secret explicite plutôt qu'une dérivation.
Bascule vers le mini PC (k3s bare-metal) — premier bootstrap réel (2026-08-20)¶
Premier déploiement de ce dépôt sur autre chose que le cluster de dev k3d — Dell OptiPlex 5060 reçu le jour même (materiel.md), Ubuntu Desktop 26.04, piloté en SSH depuis le poste de dev (clé dédiée, sudo sans mot de passe accordé explicitement par l'utilisateur pour cette machine dédiée). Procédure complète : manuel-installation.md section 14. Ce qui suit est le détail des vrais bugs trouvés en le faisant, pas en le lisant.
Boot UEFI/Legacy cassé après la première install Ubuntu — sans rapport avec ce projet mais bloquant : le firmware Dell était resté en mode Legacy (BIOS d'origine) pendant qu'un live-USB Ubuntu récent installait en UEFI/GPT, produisant un disque bootable dans aucun des deux modes cohérents. Diagnostic confirmé par boot-repair (Recommended repair) : "boot files... are far from the start of the disk... Your BIOS may not detect them... boot of your PC is in UEFI mode" — symptomatique d'un mismatch, pas d'un disque réellement cassé. Résolu par un reformatage complet en mode UEFI cohérent de bout en bout (BIOS basculé en UEFI avant de rebooter sur la clé, Boot List Option), plutôt qu'une réparation chirurgicale de l'entrée de boot existante — le disque n'avait encore aucune donnée réelle, repartir propre était plus sûr que corriger un état hybride.
Traefik k3s natif, pas un mapping de ports Docker à recréer : détaillé en section 14.2 du manuel. Le port 443 littéral (well-known Matrix) est couvert nativement par le Service Traefik par défaut de k3s — aucune configuration dédiée nécessaire, contrairement à ce que la double entrée 8453:443/443:443 du k3d dev laissait supposer.
hostPath Nextcloud (type: Directory) doit exister avec les bonnes permissions avant le premier démarrage — sinon cascade de pannes difficile à diagnostiquer a posteriori. Chaîne de causalité complète, du symptôme initial à la cause racine :
- Dossier absent →
FailedMount(hostPath type check failed: ... is not a directory) — cause immédiate, visible et attendue (déjà entrevue en dev,notes-techniques.mdsection stockagehostPath). - Dossier créé avec un simple
mkdir(propriétaireroot:root) → le conteneur (processus effectifwww-data,fsGroup: 33du pod, qui ne s'applique pas aux montageshostPathcomme il le ferait pour un volume classique) ne peut pas écrire →Cannot write into "config" directory!en boucle. - Ce premier échec d'écriture laisse un
config.phpde 0 octet sur le disque. L'entrypoint de l'image Nextcloud officielle ne déclenche son auto-installation (pilotée parNEXTCLOUD_ADMIN_USER/NEXTCLOUD_ADMIN_PASSWORD, pourtant correctement câblés viaexistingSecret) que siconfig/config.phpn'existe pas du tout — un fichier vide compte comme "déjà configuré" et fait sauter l'auto-installation à chaque redémarrage suivant, indéfiniment, sans jamais réessayer ni signaler d'erreur explicite à ce sujet. - Symptôme qui en découle, sans rapport apparent avec la cause réelle : la probe de liveness (
status.php,failureThreshold: 3par défaut) traite "pas encore installé" (503) comme "unhealthy" et tue le conteneur toutes les ~30-40s — largement suffisant en temps normal, mais ça empêche aussi bien l'auto-installation native que toute tentative manuelle (occ maintenance:install, tuée àexit 137en plein milieu) de jamais aboutir avant d'être interrompue. Contournement de test :failureThresholdrelevé temporairement (60) le temps de l'installation initiale. - Un
occ maintenance:installlancé manuellement viakubectl execs'exécute enroot(paswww-data) — écritconfig.phpavec des permissions640 root:root, à nouveau illisible pour les workers Apache réels. Chaque commandeoccexécutée ainsi re-casse les permissions déjà corrigées ; il faut re-chown/chmodaprès toute intervention manuelle enkubectl exec, pas seulement avant. - Autre résidu du tout premier échec (avant la correction de permissions) : un utilisateur admin partiellement créé côté Postgres, faisant échouer une réinstallation propre avec "The Login is already being used" — la base de données ne redevient pas vierge en vidant seulement le
hostPath(deux sources de vérité distinctes, filesystem et Postgres, chacune à réinitialiser explicitement).
Correctif appliqué à la source (gitops/apps/nextcloud.yaml) : le hook before-starting (installe Calendar/Contacts/Notes/Tasks/user_oidc, jusque-là supposé "sûr car idempotent" sur tout redémarrage) s'exécute en réalité avant l'auto-installation native de l'image sur un tout premier boot — hypothèse jamais testée jusqu'ici puisque le Nextcloud du cluster de dev était déjà installé quand ce hook a été ajouté. set -e + les deux branches du premier || échouant (occ pas encore fonctionnel) → le script entier avorte → CrashLoopBackOff qui empêche à son tour l'installation native de jamais se terminer. Fix : garde ajoutée en tête du hook (php occ status --output=json 2>/dev/null | grep -q '"installed":true' || exit 0) — sortie propre tant que Nextcloud n'est pas installé, le hook reprend son rôle normalement au redémarrage suivant.
La vraie prévention, pour la prochaine machine : créer le dossier hostPath avec chown root:33 + chmod g+rwx avant le tout premier démarrage du pod (section 14.4 du manuel) évite entièrement la cascade ci-dessus — l'auto-installation native fonctionne alors du premier coup, sans intervention manuelle. Le fix du hook reste une robustesse indépendante utile (protège contre tout futur délai d'installation, pas seulement celui causé par de mauvaises permissions).
selfHeal imbriqué, piège pour tester un fix en direct avant de le committer : désactiver syncPolicy sur l'Application nextcloud seule n'a pas suffi pour tester un correctif de ConfigMap en direct — root (l'Application app-of-apps, elle-même selfHeal: true) gère nextcloud comme une ressource parmi d'autres et annule silencieusement le patch appliqué sur son spec.syncPolicy, ce qui laisse nextcloud reprendre son propre selfHeal et écraser à son tour le correctif testé. Il a fallu désactiver syncPolicy sur les deux (root et nextcloud) pour qu'un test en direct tienne — même catégorie de friction déjà rencontrée pour Uptime Kuma, mais avec un niveau d'imbrication supplémentaire propre au pattern app-of-apps.
Cache ConfigMap kubelet reconfirmé (déjà documenté pour un Secret Authentik) : un patch de ConfigMap appliqué et vérifié via l'API (kubectl get configmap ... -o yaml) peut mettre jusqu'à ~1-2 minutes avant qu'un nouveau pod monté dessus reflète réellement le contenu à jour — vérifier le fichier réellement monté (kubectl exec ... cat <chemin>) avant de conclure qu'un correctif ne fonctionne pas.
Application avec CronJob (sauvegarde Restic) reste Progressing sur un cluster tout juste créé — cause réelle trouvée, pas juste une corrélation. Hypothèse initiale erronée (2026-08-20, même jour) : "le CronJob lui-même n'a pas de notion de santé claire pour ArgoCD" — semblait cohérente (corrélation exacte : les 6 Application avec un CronJob de backup restent Progressing, les 4 sans passent Healthy), mais fausse. Vérifiée pour de vrai via l'API ArgoCD (GET /api/v1/applications/<app>/resource-tree, pas en se fiant à l'UI) : le CronJob lui-même est bien Healthy ("CronJob has not been scheduled yet") — le vrai responsable est la PersistentVolumeClaim du dépôt Restic, en Pending/WaitForFirstConsumer (comportement normal de local-path tant qu'aucun pod ne l'a encore montée) jusqu'au tout premier passage planifié de la sauvegarde. Sur le cluster de dev, ces sauvegardes tournaient depuis des jours/semaines au moment où le statut était consulté, donc ces PVC étaient déjà liées ; sur un cluster tout juste créé (mini PC, 2026-08-20), les horaires planifiés (03h30-04h15) sont déjà passés pour le jour même — Progressing jusqu'au lendemain matin, résolu tout seul dès le premier passage de chaque CronJob. Vérifié fonctionnellement service par service (requête HTTP réelle contre chaque endpoint) en attendant, plutôt que de se fier au statut agrégé d'ArgoCD.
WireGuard (VPN d'accès admin)¶
Chantier Phase 3.5 : accès admin distant à terme (ArgoCD, kubectl, SSH — aucun exposé directement sur internet, architecture.md §6/§11). Déployé en service systemd sur l'hôte, volontairement hors GitOps : ce VPN sert justement à accéder au cluster pour intervenir dessus, le placer dans un Deployment k8s créerait une dépendance circulaire (cluster en panne → le VPN censé permettre d'y remédier tombe avec lui) — détail dans wireguard/README.md.
scripts/wireguard-setup.sh amorce le serveur (première exécution) ou ajoute un pair (exécutions suivantes, idempotent). Config serveur commitée chiffrée sous SOPS (wireguard/wg0.conf.sops.yaml) ; configs clientes jamais commitées, même chiffrées — écrites localement hors dépôt, à transférer directement à l'appareil de l'admin (même modèle que les autres secrets générés à l'installation, installation-utilisateur.md).
Validé en conditions réelles (2026-08-15), exécuté directement par l'utilisateur (le script a besoin de sudo interactif — installation du paquet, écriture dans /etc/wireguard/, activation du service — impossible à automatiser depuis cet environnement) : wireguard-tools installé, clés serveur + premier pair ("admin") générées, wireguard/wg0.conf.sops.yaml chiffré et vérifié (sops -d restitue exactement le contenu attendu), service wg-quick@wg0 actif, interface wg0 montée avec la bonne adresse (10.100.0.1/24, état UP, confirmé via ip addr show wg0 sans besoin de sudo). Deux messages bénins dans les logs, sans rapport avec WireGuard : wg-quick.target is a disabled or a static unit (target générique non utilisée, le service réel est wg-quick@wg0.service) et Failed to allocate directory watch: Too many open files (limite inotify système déjà présente avant ce chantier).
Handshake client réel validé (2026-08-15) : config cliente importée dans l'app WireGuard mobile via QR code (le scan doit passer par le scanner intégré à l'app WireGuard, pas l'appareil photo générique du téléphone — un scan via l'appareil photo a échoué avec "this app was not found on your device", le téléphone tentant d'interpréter le contenu comme un lien plutôt que de le passer à l'app WireGuard). Une fois le tunnel activé depuis un second appareil réel (téléphone, IP LAN 192.168.1.144) : sudo wg show confirme un handshake effectif (latest handshake: 46 seconds ago) avec du trafic bidirectionnel réel (212 B reçus, 92 B envoyés) — validation fonctionnelle complète du tunnel en intra-LAN, pas seulement structurelle.
Pas encore fait : test réel "depuis l'extérieur du LAN" (nécessite un port-forward sur un vrai routeur) explicitement différé au mini PC (Phase 4), de même que le routage vers les endpoints internes du cluster (ArgoCD, API k8s) une fois le pare-feu réel en place.
Réinstallation sur le mini PC réel (2026-08-20) — la validation du 2026-08-15 avait forcément tourné sur le poste de dev (le mini PC n'existait pas encore). Piège réel trouvé en le rejouant : le script décide amorçage vs ajout de pair uniquement selon la présence de wireguard/wg0.conf.sops.yaml dans le checkout local — lancé tel quel sur un clone frais du mini PC (le fichier existe déjà, commité), il serait parti en mode "ajout d'un pair" sur l'identité serveur du poste de dev, pas une nouvelle identité pour le mini PC. Contourné en supprimant ce fichier uniquement du checkout local du mini PC (jamais de l'historique commité) avant de lancer le script, pour forcer un amorçage réellement neuf. Le fichier résultant a été rapatrié et commité depuis le poste de dev (seul à détenir la clé de signature) en remplacement de celui du poste de dev — validé (wg-quick@wg0 actif, round-trip sops -d correct). Le service du poste de dev est retiré maintenant que le mini PC, seul point d'accès réellement conforme à l'intention de l'architecture (protéger l'accès au cluster, désormais hébergé là), assume ce rôle.
Git / CI / signature de commits¶
Voir CLAUDE.md pour le détail (workflow trunk-based, conventional commits, versioning). Un point notable : GitHub refuse le merge "Rebase and merge" dès que les commits signés sont obligatoires sur la branche (il ne peut auto-signer que les commits qu'il crée lui-même) — "Create a merge commit" est la seule méthode compatible.
npm run release bump feat/fix en patch, jamais en minor/major, tant que major vaut 0 — comportement documenté de commit-and-tag-version (comme standard-version dont il dérive), pas un bug : en 0.y.z, seul un BREAKING CHANGE bump le minor, et le major ne bouge jamais automatiquement (réservé à un --release-as 1.0.0 explicite, au moment du jalon MVP). Cohérent avec la politique déjà décrite dans CLAUDE.md, mais surprenant la première fois si on s'attend au mapping SemVer "standard" (fix/feat/breaking → patch/minor/major) qui ne s'applique qu'à partir de 1.0.0.
Premier CHANGELOG.md à deux versions → 5 erreurs markdownlint (MD032/MD022/MD024 ×3) au premier npm run release qui ajoute une deuxième section : le template de commit-and-tag-version n'insère pas de ligne vide avant l'ancien ## x.y.z, et chaque nouvelle version réutilise les mêmes titres de section (Fonctionnalités, Correctifs, Documentation) que markdownlint considère dupliqués sur tout le document. Comme ce fichier est entièrement généré (jamais édité à la main) et que le problème se reproduirait à chaque release future, CHANGELOG.md est exclu du lint via .markdownlint-cli2.jsonc (ignores) plutôt que corrigé à la main à chaque fois.
scripts/pin-release.sh casse la détection "depuis le dernier tag" de commit-and-tag-version (trouvé en coupant v0.1.4) : le tag v0.1.3 a été déplacé (git tag -f + push --force) vers un commit délibérément jamais mergé dans master (voir plus haut, "release: pin child Applications..."). commit-and-tag-version cherche le dernier tag atteignable depuis HEAD pour calculer la liste des commits à inclure — un tag pointant hors de l'ascendance de master n'est plus atteignable, l'outil retombe donc silencieusement sur le tag précédent (v0.1.2), et le CHANGELOG.md généré recense tout depuis deux versions en arrière au lieu d'une seule, sans erreur ni avertissement. Corrigé à la main pour v0.1.4 (comparaison des commits entre le vrai commit chore(release): 0.1.3 sur master et HEAD, section réécrite). Pas de vrai correctif automatique trouvé — à refaire à la main pour chaque prochaine release tant que ce mécanisme de tag déplacé existe ; noté ici pour ne pas le redécouvrir depuis zéro la prochaine fois.
Comptes de premier démarrage rejoués sur le mini PC (2026-08-20)¶
La migration bare-metal (section précédente sur k3s) n'avait couvert que l'infrastructure — les scripts de premier démarrage par service (jellyfin-sso-setup.py, uptime-kuma-setup.py, tuwunel-alertbot-setup.py, la création admin Immich) n'avaient jamais été rejoués contre le mini PC, chose remarquée par l'utilisateur plutôt que vérifiée proactivement — bon réflexe : StartupWizardCompleted: false (Jellyfin), isInitialized: false (Immich) et @alertbot disponible (Tuwunel, donc pas encore enregistré) confirmaient tous les trois qu'il ne s'agissait pas d'une supposition infondée.
Modèle de compte personnel admin, posé à cette occasion : un compte Authentik personnel (robin) créé via ak shell (username/email, mot de passe volontairement laissé unusable — l'utilisateur le définit lui-même dans l'UI en akadmin, jamais transmis à l'agent), placé dans authentik Admins (admin Authentik lui-même) et jellyfin-admins (manuel-installation.md §13). akadmin reste un compte de secours plutôt que le compte quotidien — cohérent avec l'hygiène IdP standard (ne pas driver au quotidien avec l'identité root/bootstrap). Pour Nextcloud/Tuwunel (SSO déjà câblé), pas d'action équivalente possible sans une vraie connexion interactive de l'utilisateur — la promotion en admin (groupe Nextcloud admin, salon d'administration Tuwunel) attend son premier login réel.
Bug transitoire trouvé sur POST /Startup/User (Jellyfin 10.11.11) : premier appel → 404, alors que /Startup/Configuration immédiatement avant avait réussi (confirmé : ServerName bien appliqué) et que /openapi/v1.json renvoyait aussi 404 au même moment. Reproduit en direct dans le pod (kubectl exec ... curl, donc pas un souci de routage Traefik) : GET /Startup/User → 200 (route existante), POST → 404 la première fois puis 204 en réessayant quelques secondes plus tard. Cause probable : le serveur .NET n'avait pas fini d'enregistrer toutes ses routes juste après le traitement de /Startup/Configuration — non confirmé formellement, mais un simple nouvel essai a suffi, aucune modification nécessaire côté script.
Identifiants Immich : pas de script dédié existant (contrairement à Jellyfin/Uptime Kuma/Tuwunel) — compte admin créé directement avec l'identité réelle de l'utilisateur (POST /api/auth/admin-sign-up, email personnel) plutôt qu'un compte générique, correspondant à sa demande explicite d'être admin sur les applicatifs. Mot de passe généré et persisté dans gitops/secrets/immich/immich.sops.yaml (ADMIN_EMAIL/ADMIN_PASSWORD, clés nouvelles).
Notification Matrix Uptime Kuma → Tuwunel validée pour de vrai : au-delà de l'appel add_notification() qui ne fait qu'accepter la config, un message de test envoyé directement avec le jeton d'accès du bot fraîchement enregistré (event_id renvoyé par l'API) confirme que le compte peut réellement écrire dans le salon, pas seulement que la configuration a été acceptée côté Uptime Kuma.
Nom de domaine réel + Let's Encrypt (2026-08-22)¶
Premier chantier de la Phase 4 côté infra réseau, sur offsystem.fr (Gandi) déjà acheté. Choix DNS-01 (pas HTTP-01, décidé avec l'utilisateur) : pas besoin d'exposer le port 80, permet un certificat wildcard unique (*.offsystem.fr + apex) réutilisable par tous les services au lieu d'un certificat par service. Validé d'abord sur Uptime Kuma comme canari (page de statut déjà pensée publique, aucune dépendance SSO/redirect URI à casser) — généralisation aux ~9 autres Ingress volontairement différée, tâche de suivi séparée.
cert-manager écarté délibérément avant d'écrire le moindre manifest : pas de support Gandi natif dans cert-manager, seulement des webhooks tiers communautaires (cert-manager-webhook-gandi, plusieurs forks trouvés en recherchant — signe classique d'un projet original abandonné puis repris par la communauté). À l'opposé de la discipline "composants matures activement maintenus" déjà appliquée dans ce projet (rejet de Conduwuit, choix de Tuwunel pour la même raison, cf. section Tuwunel). Le provider gandiv5 de lego, lui, est bundlé nativement dans Traefik — aucune dépendance externe, juste GANDIV5_PERSONAL_ACCESS_TOKEN en variable d'environnement (l'ancienne clé API GANDIV5_API_KEY est dépréciée par Gandi, PAT scopé LiveDNS uniquement sur offsystem.fr utilisé à la place).
Résolveur ACME ajouté sur le Traefik natif de k3s, pas un nouveau composant : gitops/bootstrap/traefik-acme-helmchartconfig.yaml, un HelmChartConfig (helm.cattle.io/v1) — mécanisme dédié fourni par k3s pour surcharger les valeurs d'un HelmChart géré par son propre contrôleur d'Addon (même nom/namespace que le HelmChart visé, ne jamais éditer ce dernier directement, il est régénéré par k3s). Vérifié en direct (kubectl get helmchart traefik -n kube-system -o yaml) que la version réellement déployée est traefik-40.1.4+up40.1.0 (upstream 40.1.0, image 3.7.8) avant de configurer quoi que ce soit, plutôt que de supposer une structure de values générique. Le port 443 natif de k3s (déjà utilisé pour la découverte Matrix well-known, cf. section LiveKit) pointe vers le même entrypoint websecure que le reste — aucune nouvelle configuration d'entrypoint nécessaire, seul un nouvel Ingress suffit une fois le résolveur en place.
Validé d'abord sur le serveur ACME staging (caServer pointé sur acme-staging-v02.api.letsencrypt.org) avant de brûler le moindre quota de production — cert "(STAGING) Let's Encrypt" obtenu et confirmé servi via openssl s_client -connect <IP>:8453 -servername status.offsystem.fr. Bascule vers la production ensuite : retirer caServer (défaut Traefik = production, pas besoin de le pointer explicitement). Piège réel trouvé au moment de la bascule : Traefik ne redemande pas automatiquement un certificat juste parce que le caServer a changé — il regarde seulement si le certificat déjà en cache dans acme.json est encore valide (pas proche de l'expiration), donc sans intervention il aurait continué à servir indéfiniment le certificat staging non reconnu. Corrigé en supprimant acme.json du pod (kubectl exec ... rm -f /data/acme.json) puis kubectl rollout restart pour forcer une réémission propre — un court transitoire sert le certificat auto-signé TRAEFIK DEFAULT CERT pendant la nouvelle négociation DNS-01, normal, pas une panne.
Wildcard déclenché via une seule annotation Ingress, sans passer par la CRD IngressRoute : confirmé dans la doc officielle Traefik que traefik.ingress.kubernetes.io/router.tls.domains.0.main/.sans fonctionne sur un objet Ingress Kubernetes classique, pas seulement IngressRoute — un seul endroit dans tout le cluster (l'Ingress du canari) porte cette déclaration ; le certificat obtenu, mis en cache dans acme.json, est automatiquement réutilisé par n'importe quel autre routeur dont le Host() tombe sous ce SAN, sans nouvelle demande ACME. Les futurs services n'auront besoin que de l'annotation router.tls.certresolver, jamais d'une nouvelle déclaration de domaine.
Ingress séparé plutôt qu'édition du chart Uptime Kuma : le template ingress.yaml du chart communautaire (dirsigler/uptime-kuma-helm 4.1.0, inspecté via helm pull --untar avant d'écrire quoi que ce soit) rend secretName: {{ .secretName }} sans condition pour chaque entrée tls, sans possibilité de l'omettre — incompatible avec un certificat géré dynamiquement par Traefik (qui a besoin de tls: {} + l'annotation certresolver, pas d'un Secret statique). Contourné avec un second Ingress brut (gitops/manifests/uptime-kuma-public/, nouvelle source sur l'Application existante uptime-kuma.yaml, converti de source: en sources:) pointant vers le même Service (uptime-kuma:3001) — purement additif, myown-uptime.local (HTTP :8090, LAN) inchangé et revérifié fonctionnel après coup.
DynDNS : IP publique traitée comme changeante par défaut, pas supposée fixe. Aucune option "IP fixe" trouvée dans l'interface Freebox (Révolution) de l'utilisateur — seule confirmation obtenue : la fonctionnalité native "DNS dynamique" existe (désactivée), ce qui suffit à établir que Free lui-même traite l'IP comme changeante. gitops/apps/gandi-dyndns.yaml (nouvelle Application, auto-sync — service infra invisible pour la famille, cohérent avec la philosophie de sync du 2026-08-15) : CronJob toutes les 5 minutes qui pousse l'IP publique courante (v4 et v6) vers les enregistrements @ et * chez Gandi plutôt qu'un sous-domaine dédié — même wildcard que le certificat, aucun nouvel enregistrement DNS à créer pour les futurs services, juste un nouvel Ingress. PUT inconditionnel plutôt qu'un diff préalable contre la valeur Gandi actuelle : no-op idempotent si inchangé, évite un parsing JSON fragile pour un job aussi bon marché à simplement rejouer. TTL des enregistrements abaissé à 120s (au lieu de 300s) pour la même raison — voir plus bas la discussion sur la fenêtre d'exposition réelle.
Fenêtre d'exposition réelle en cas de changement d'IP, calculée précisément après une question directe de l'utilisateur : pire cas = intervalle du CronJob + TTL DNS, pas juste l'un ou l'autre. Avec la config initiale (15 min / TTL 300s), le pire cas dépassait 19 minutes — plus que l'inquiétude initiale de l'utilisateur ("jusqu'à 14 minutes ?!"), pas moins. Resserré à 5 min / TTL 120s (pire cas ~7 min) : coût quasi nul (deux appels curl, PUT idempotent la plupart du temps) contre un gain réel sur la durée d'indisponibilité. Ce délai ne s'applique que si l'IP publique change réellement (rare chez Free en usage normal, pas à chaque redémarrage de box) — en fonctionnement normal le CronJob ne fait qu'un no-op toutes les 5 minutes, rien n'est jamais coupé. Et même pendant la fenêtre, seules les nouvelles résolutions DNS sont affectées : une connexion déjà établie (appel LiveKit en cours, session Nextcloud ouverte) continue de fonctionner, seule une nouvelle tentative de connexion pendant la fenêtre échouerait.
Deux vrais bugs trouvés en testant le CronJob DynDNS manuellement (kubectl create job --from=cronjob/...), pas en le lisant :
- Pas de sortie IPv6 depuis le pod, alors que l'hôte a de l'IPv6 native —
curl -sf6 https://api64.ipify.orgéchoue systématiquement (exit 7, confirmé avec un pod de debug jetable) : le CNI par défaut de k3s (flannel) est mono-pile IPv4, indépendamment de la connectivité réelle de l'hôte. Même catégorie de problème que LiveKit (podHostNetwork, cf. section LiveKit) — même fix appliqué ici (hostNetwork: true+dnsPolicy: ClusterFirstWithHostNetsur le CronJob), risque faible pour ce conteneur qui ne fait que des appels sortants, aucun port en écoute exposé. [ -n "$IPV6" ] && update_record ...sousset -eufaisait planter tout le script, pas juste sauter l'étape AAAA — quand$IPV6est vide, le test échoue, et comme il est le dernier élément réellement exécuté de la liste&&(le second membre ne s'exécute jamais, court-circuité), son code de sortie non nul déclencheerrexitimmédiatement. Résultat observé sur le premier run réel : les deux enregistrementsA(@et*) créés avec succès, puis arrêt silencieux avant le premierAAAA— pas d'erreur explicite dans les logs, juste une sortie prématurée. Corrigé avec unif/fiexplicite (sûr sousset -e, contrairement à un test utilisé directement avec&&) plutôt qu'un raccourci. Les deux bugs corrigés ensemble, revalidés avec un second run manuel : 4 enregistrements (A+AAAA×@+*) créés avec succès.
Validé de bout en bout, à chaque étape indépendamment vérifiée : résolution DNS publique (dig +short A/AAAA status.offsystem.fr @ns1.gandi.net), certificat de production servi (issuer=C=US, O=Let's Encrypt, CN=YR1, subject=CN=offsystem.fr) confirmé par openssl s_client et par un fetch HTTP réellement externe (pas depuis le LAN), page de statut publique répondant 200 sur son vrai chemin (/status/etat-du-systeme, pas juste la racine /), et confirmation visuelle finale depuis un vrai téléphone en 4G (cadenas valide, aucun avertissement) — pas seulement supposé fonctionner parce que le certificat était bon. Non-régression LAN revérifiée (myown-uptime.local:8090 toujours répondant) après coup, pas juste avant.
Redirection Freebox, terminologie différente de ce qui était attendu : l'interface de gestion des ports d'une Freebox Révolution n'a pas de champ "IP source" pouvant rester vide — un menu déroulant propose explicitement "toutes" comme option, équivalent fonctionnel de "pas de restriction" une fois trouvé, juste un intitulé différent des autres box grand public.
Généralisation aux autres services (2026-08-22)¶
Une fois le canari Uptime Kuma validé, même pattern répété sur les 8 autres services family-facing (Vaultwarden, Nextcloud, Immich, Jellyfin, Authentik, Tuwunel, LiveKit, lk-jwt-service) — un Ingress brut additif par service, backend vers le même Service que l'Ingress .local existant, une seule annotation router.tls.certresolver: letsencrypt (le certificat wildcard déjà en cache dans acme.json est automatiquement réutilisé, aucune nouvelle demande ACME par service — confirmé : les 9 hôtes publics servent exactement le même certificat, subject=CN=offsystem.fr). Grafana volontairement exclu : roadmap.md Phase 4 le documente déjà comme "réservé à l'admin", l'exposer publiquement irait à l'encontre du principe déjà établi (architecture.md §6/§11 : aucun outil d'administration exposé directement sur internet).
Pour les services déjà en manifests bruts (Tuwunel, LiveKit, lk-jwt-service) : nouvel Ingress séparé plutôt qu'un second host ajouté à l'Ingress existant — les annotations Traefik s'appliquent à tout l'objet Ingress, pas par host individuellement ; mélanger un host à secretName statique (mkcert) et un host à certResolver sur le même objet aurait risqué d'appliquer le résolveur ACME au host .local aussi. Un objet séparé garde les deux chemins strictement indépendants.
Un vrai 400 trouvé en testant chaque hôte, pas supposé fonctionner parce que le certificat était bon : nextcloud.offsystem.fr renvoyait 400 alors que les 8 autres répondaient normalement (200/302) — Nextcloud rejette toute requête dont le Host n'est pas dans trustedDomains, indépendamment du certificat/de l'Ingress. Corrigé en ajoutant nextcloud.offsystem.fr à gitops/apps/nextcloud.yaml (nextcloud.trustedDomains).
Incident réel provoqué en testant ce fix en direct, corrigé dans la foulée : php occ config:system:set trusted_domains 1 --value=... lancé via kubectl exec (donc en root, pas www-data — le même piège déjà documenté pour le bootstrap du 2026-08-20, reproduit une seconde fois) a (1) écrasé l'index 1 existant (myown-nextcloud.local) au lieu d'ajouter un nouvel index — corrigé en reposant l'index 1 avant d'ajouter le nouveau en index 2 — et (2) réécrit config.php avec root:www-data au lieu de www-data:www-data, cassant l'accès en lecture pour les workers Apache réels : myown-nextcloud.local et nextcloud.offsystem.fr sont tombés en 503 (confirmé dans les logs du pod : fopen(...): Permission denied), Nextcloud entièrement indisponible pour toute la famille pendant l'incident. Corrigé avec le même chown www-data:www-data déjà documenté pour le premier incident — reconfirme la leçon : toute commande occ lancée via kubectl exec doit être suivie d'un re-chown, pas seulement précédée d'un.
Deux autres nuances de périmètre, documentées plutôt qu'ignorées : (1) pour les services intégrés SSO (Vaultwarden, Nextcloud, Immich, Tuwunel, Jellyfin), le nouvel hôte public charge la page mais une connexion SSO complète ne peut pas encore aboutir — chaque config côté service (sso.authority de Vaultwarden, --discoveryuri de Nextcloud, redirect_uris de chaque blueprint Authentik) pointe toujours vers myown-<service>.local, pas mis à jour dans cette passe. (2) LiveKit : LIVEKIT_URL (renvoyé au navigateur comme cible de connexion média réelle) pointe toujours vers l'hôte LAN — un client Element Call externe n'utilisera pas encore livekit.offsystem.fr tant que ça et le well-known de lk-jwt-service ne sont pas mis à jour. Les deux volontairement différés — cette passe couvre la couche DNS+certificat, pas la validation fonctionnelle externe complète par service (qui suit la même discipline "une couche à la fois, validée en conditions réelles" déjà appliquée partout ailleurs dans ce projet).
Migration SSO vers les hostnames publics (2026-08-22)¶
Suite directe du chantier ci-dessus : fermer le point (1) laissé ouvert — connexion SSO complète, pas juste la page qui charge. Question posée directement par l'utilisateur avant de commencer : basculer vers les hostnames publics partout ne risque-t-il pas de faire sortir le trafic interne (serveur-à-serveur, voire un navigateur du LAN) par la Freebox puis d'y revenir ?
Réponse en deux parties, la seconde confirmée empiriquement plutôt que supposée. Les appels serveur-à-serveur (échange de code OIDC, découverte) sont faits par les pods, résolus via CoreDNS — jamais besoin de sortir du cluster une fois le split-horizon étendu (voir plus bas). Pour un navigateur LAN utilisant le nom public : un curl https://status.offsystem.fr depuis la machine de dev (sur le même LAN que le mini PC) avait déjà fonctionné correctement pendant le chantier précédent — preuve que la Freebox Révolution supporte le hairpin NAT, donc pas de blocage réel pour ce cas, juste un aller-retour de plus par rapport à une résolution strictement locale.
Découverte clé qui structure toute cette migration : Authentik construit son document de découverte OIDC dynamiquement à partir du Host de la requête qui le récupère (déjà noté section Vaultwarden↔Authentik) — donc le champ authority/discoveryuri/issuer_url de chaque service détermine directement quel hostname finit dans la redirection renvoyée au navigateur pour le login. Conséquence directe : chaque service doit désormais interroger Authentik via authentik.offsystem.fr, jamais myown-authentik.local (qui ne sera jamais résolvable pour un client réellement externe) — même si en pratique, post split-horizon, les deux résolvent au même endroit pour tout ce qui est interne au réseau.
Contrainte dure identifiée avant de toucher Tuwunel, confirmée par la doc/communauté Matrix : server_name (tuwunel.toml, [global]) est l'identité Matrix intégrée dans chaque identifiant utilisateur (@user:server_name) et alias de salon — la changer après coup casse irrécupérablement tous les comptes existants ("assumes the server name is never changed... subtle corruption that gets worse over time"). Resté à myown-tuwunel.local ; seuls issuer_url/callback_url du bloc [[global.identity_provider]] (config de connexion SSO, indépendante de l'identité Matrix) ont changé.
Étape 0, fondation : DNS split-horizon. gitops/bootstrap/coredns-custom.yaml étendu avec un second bloc offsystem.override, même mécanisme et même cible (traefik-internal.kube-system.svc.cluster.local) que myown-*.local. Validé par nslookup depuis un pod jetable : les hôtes listés résolvent en interne (10.43.134.208), un hôte non listé (nonexistent-check.offsystem.fr) retombe correctement sur la vraie résolution publique — le mécanisme est précis, pas une réécriture générale du domaine.
Canari Vaultwarden — deux vrais bugs trouvés en le faisant, pas en le lisant :
- Le port HTTP (8090) ne route pas du tout vers un hôte
*.offsystem.fr— seul 8453/443 répond, contrairement àmyown-authentik.localoù les deux marchent. Cause : lesIngresspublics créés lors du chantier précédent portent tous un bloctls, donc Traefik ne les sert que sur l'entrypointwebsecure.sso.authorityde Vaultwarden a dû passer enhttps://(pashttp://comme prévu initialement) — sans conséquence négative, le certificat étant un vrai Let's Encrypt, aucun contournement CA (mkcert) n'est nécessaire pour cet appel interne. existing SSO user with same email,POST /identity/connect/token => 400— Vaultwarden lie chaque compte SSO à un couple(issuer, sub)extrait du token, stocké dans sa propre tablesso_users(SQLite), précisément pour empêcher un vol de compte par changement d'email.issuerétant dérivé du hostname côté Authentik (cf. découverte clé ci-dessus), le changement d'autorité a cassé l'association déjà existante pour le compte réel (chartier.ro@gmail.com). Doc officielle Vaultwarden confirmée : supprimer l'association existante,SSO_SIGNUPS_MATCH_EMAIL(déjà actif par défaut, protégé du CVE associé depuis la 1.36.0 — cette instance tourne en 1.37.1) permet la ré-association automatique à la prochaine connexion. Corrigé avec l'accord explicite de l'utilisateur (son vrai coffre de mots de passe) : sauvegarde Restic manuelle d'abord,StatefulSetarrêté proprement (pas de double-écriture SQLite),DELETE FROM sso_users WHERE user_uuid = ...via un pod jetable montant la même PVC en root (l'imagesqlite3tourne en utilisateur non-root par défaut, sans droit d'écriture sur des fichiersroot:root— même catégorie que le piègewww-datade Nextcloud), StatefulSet redémarré, connexion réelle revalidée par l'utilisateur.
Immich, Nextcloud validés sans blocage équivalent — user_oidc (Nextcloud) et le mapping OAuth d'Immich ne lient pas l'identité de façon aussi stricte que Vaultwarden (pas de table dédiée composite issuer+sub), la simple mise à jour de issuerUrl/discoveryuri a suffi.
Jellyfin — encore un vrai bug, distinct des deux précédents : redirect_uri renvoyé restait en http:// même en arrivant par HTTPS (400 côté Authentik, aucune entrée redirect_uris ne correspondait). Confirmé par un test ciblé : même avec un en-tête X-Forwarded-Proto: https explicite envoyé depuis un pod jetable dans le CIDR déjà déclaré en Known Proxies de Jellyfin, le comportement ne changeait pas — le plugin Authentik SSO de Jellyfin ne s'appuie pas sur le mécanisme standard ASP.NET Core de headers transférés (Known Proxies n'a aucun effet sur lui), contrairement à l'hypothèse initiale. La vraie config se trouve directement dans les réglages du plugin lui-même : ForceHttpsRedirect (jusque-là explicitement mis à False par scripts/jellyfin-sso-setup.py), qui force le schéma https:// indépendamment de toute détection — même effet final que phpClientHttpsFix de Nextcloud, mécanisme différent. Script mis à jour pour refléter la vraie valeur nécessaire.
Tuwunel validé au niveau protocole plutôt qu'avec un client Matrix complet : curl direct sur /_matrix/client/v3/login/sso/redirect confirme un redirect_uri et un cookie tuwunel_grant_session (Secure, correctement acceptable maintenant que c'est du vrai HTTPS) tous deux corrects — un test de bout en bout avec un vrai client Element aurait apporté peu de plus pour ce qui restait à prouver (le protocole, pas l'UX).
Incident majeur, découvert en réactivant syncPolicy après le canari : réactiver selfHeal sur root/authentik/vaultwarden a immédiatement déclenché une resynchronisation — mais le root du mini PC reste pointé sur le tag v0.1.4 (voir CLAUDE.md, dev/prod séparé par tag depuis le 2026-08-20), qui ne contient aucun des changements de ce chantier ni du précédent (PR mergée dans master, jamais re-pinnée). Conséquence concrète, confirmée en direct : le ConfigMap Vaultwarden (DOMAIN/SSO_AUTHORITY) est revenu aux valeurs myown-*.local en quelques secondes, et la découverte périodique des blueprints Authentik a fait de même pour les 5 redirect_uris ajoutés — alors que le statut ArgoCD affichait Synced/Healthy partout, aucun signe visible de régression dans l'UI. Nextcloud et Jellyfin épargnés uniquement parce que leur config SSO vit dans leur propre base applicative (respectivement Postgres et le fichier de plugin interne), pas dans un objet Kubernetes qu'ArgoCD gère. syncPolicy redésactivée immédiatement, tous les correctifs réappliqués et revérifiés un par un. Leçon retenue pour la suite : ne jamais réactiver selfHeal sur le mini PC sans avoir d'abord coupé une release et rejoué scripts/pin-release.sh — la friction déjà documentée à plusieurs reprises ("selfHeal revient sur un test live non commité") s'applique tout autant, et de façon bien plus large, quand c'est le tag lui-même qui est en retard de plusieurs chantiers entiers, pas juste d'un fichier modifié localement.
Watchdog de remédiation automatique (2026-08-22/23)¶
Premier chantier Phase 4 attaqué directement (pas de canari — priorité explicite de l'utilisateur, ce chantier avant Pi-hole). Service systemd sur l'hôte (myown-watchdog.timer + .service, scripts/watchdog-setup.sh/watchdog-check.sh), volontairement hors GitOps — même raisonnement de dépendance circulaire que WireGuard (architecture.md §6) : un mécanisme censé réparer le cluster ne peut pas vivre dedans.
Conception : un seul signal de santé (k3s kubectl get --raw /healthz, capture aussi bien "arrêté" que "tourne mais ne répond pas" — le cas exact de l'incident du 2026-08-20), vérifié toutes les 60s, remédiation (systemctl restart k3s) après 3 échecs consécutifs, plafonnée à 3 tentatives/heure pour éviter une boucle infinie masquant un vrai problème.
Alerting ajouté suite à une question directe de l'utilisateur ("peut-on alerter l'admin ?") plutôt que prévu dès le départ : réutilise le salon Tuwunel #etat-du-systeme existant (même compte alertbot qu'Uptime Kuma). Limite posée clairement avant de coder plutôt que découverte après coup : Tuwunel tourne dans le cluster surveillé, donc aucune alerte ne peut sortir pendant une panne totale — mitigée par une file de notifications locale (/var/lib/myown-watchdog/pending-notifications), retentée à chaque tick tant que le cluster n'est pas de nouveau joignable. Jeton alertbot jamais dupliqué sur l'hôte : déchiffré à la volée depuis gitops/secrets/uptime-kuma/uptime-kuma.sops.yaml (pas un Secret k8s, un simple fichier chiffré du dépôt) via sops, déjà présent sur le mini PC pour WireGuard.
Trois vrais bugs trouvés coup sur coup en testant une vraie panne forcée (sudo systemctl stop k3s), aucun anticipé à l'écriture — tous de la même famille : une hypothèse qui tient dans le contexte où le script a été écrit/testé (shell interactif de l'utilisateur robin) mais pas dans celui où il tourne réellement (service systemd, en root, PATH et $HOME différents) :
sopsintrouvable —command -v sopsréussit dans le shell interactif (où~/.local/binest dans lePATH), mais le service systemd en root a unPATHpar défaut plus restreint. Corrigé en résolvant le chemin absolu à l'installation (watchdog-setup.sh, shell interactif) et en le passant viaEnvironment=MYOWN_WD_SOPS_BIN— pas de changement de logique danswatchdog-check.sh, juste une variable en plus.- Mauvais
REPO_ROOT— dérivé de l'emplacement du script lui-même (dirname "${BASH_SOURCE[0]}"/..), correct pourwatchdog-setup.sh(jamais déplacé) mais faux pourwatchdog-check.sh, copié vers/usr/local/bin/par l'installateur : à l'exécution, ça résolvait vers/usr/localau lieu du vrai dépôt (/home/robin/MyOwn), doncsopscherchait le fichier chiffré à un chemin qui n'existe pas. Même remède : résolu une fois à l'installation, passé viaEnvironment=MYOWN_WD_REPO_ROOT. - Clé
ageintrouvable — une fois les deux premiers corrigés,sopstrouvait bien le binaire et le fichier, mais le déchiffrement échouait encore : le service tourne en root (nécessaire poursystemctl restart k3s), et root n'a pas de clé à son emplacement par défaut (/root/.config/sops/age/keys.txt— la vraie clé vit dans/home/robin/.config/sops/age/keys.txt).SOPS_AGE_KEY_FILEest une variable quesopsrespecte déjà nativement — fixée explicitement dans l'unité, aucun changement de code nécessaire cette fois.
Les trois corrections suivent le même schéma : rien de tout ça n'était visible en testant la logique du script isolément (elle est correcte) — seul un vrai test de bout en bout en conditions réelles (panne forcée, observation du journal systemd) les a révélés, un par un, chacun après avoir corrigé le précédent.
Validé de bout en bout, chaque étape vérifiée indépendamment plutôt que supposée :
- Détection réelle : 3 échecs consécutifs journalisés (
1/3,2/3,3/3) sur l'intervalle attendu (~3 min). - Remédiation réelle :
systemctl restart k3sdéclenché automatiquement, cluster confirmé sain juste après (kubectl get nodes→Ready), sans aucune intervention manuelle. - Notification réelle : message reçu dans
#etat-du-systeme, confirmé par une lecture directe de l'API Matrix (GET .../messages), pas seulement supposé parce que la commande d'envoi n'avait pas échoué.
Effet de bord positif découvert en cours de route : le premier git pull du script sur le mini PC a échoué ("modifications locales seraient écrasées") sur wireguard/wg0.conf.sops.yaml. Investigation avant tout stash/checkout destructeur : comparaison des clés publiques dérivées (fichier local vs HEAD du checkout mini PC vs interface WireGuard réellement active) a montré que le fichier local du mini PC correspondait à l'interface active, mais que le HEAD local de ce checkout précis était très en retard (avant la réinstallation du 2026-08-20, jamais mis à jour depuis) — alors que master sur le poste de dev avait déjà, depuis longtemps, le bon contenu committé (4189403). git stash && git pull && git stash pop a résolu proprement, confirmé sans perte (le stash pop final n'a rien eu à réappliquer, contenu déjà identique).
Flow de récupération de compte Authentik (2026-08-22)¶
Dernier maillon manquant du modèle "admin sans accès résiduel" (installation-utilisateur.md) : créer un compte pour un vrai second utilisateur (Katia) via l'IHM Authentik, par l'utilisateur lui-même — explicitement demandé plutôt qu'un script ak shell, pour reproduire exactement l'expérience qu'auront les vrais membres de la famille plus tard.
Gap réel découvert en le faisant : l'action "Set password" d'Authentik pour un utilisateur propose de définir le mot de passe directement (l'admin le voit/le choisit) plutôt que de générer un lien de récupération à transmettre — à l'opposé du modèle "zero-knowledge" du projet. L'IHM elle-même indique pourquoi : "To create a recovery link, set a recovery flow for the current brand" — aucun Flow avec designation="recovery" n'existe sur cette installation (jamais configuré depuis Phase 0).
Décision : construire le vrai flow de recovery maintenant plutôt qu'un contournement ponctuel (fixer le mot de passe une fois puis regarder ailleurs) — c'est un mécanisme réutilisable pour chaque futur membre de la famille, pas un besoin ponctuel.
Nouveau blueprint minimal (gitops/secrets/authentik-blueprints/account-recovery.sops.yaml), réutilisant les Stage déjà existants plutôt que d'en créer de nouveaux :
authentik_flows.flow(slug: myown-recovery-flow,designation: recovery) — c'est ce champdesignationprécisément qu'Authentik vérifie pour proposer l'action "generate recovery link" dans l'IHM.- Deux
authentik_flows.flowstagebindingréutilisantdefault-password-change-promptetdefault-password-change-write— les mêmesStageque le flow de changement de mot de passe standard, juste liés à un secondFlowavec undesignationdifférent (confirmé : unStageAuthentik n'est pas exclusif à unFlow, uneFlowStageBindingest une ligne séparée par association). authentik_brands.brand(domain: authentik-default) mis à jour avecflow_recovery: !KeyOf myown-recovery-flow.
Validé en conditions réelles, pas juste vérifié par ak shell : compte Katia créé par l'utilisateur via l'IHM, lien de récupération généré et utilisé pour définir le mot de passe, connexion réussie avec ce compte — le tout sans jamais qu'un administrateur ait vu ou choisi le mot de passe de Katia.
Branchement externe de LiveKit et investigation MISSING_MATRIX_RTC_TRANSPORT (2026-08-22)¶
Dernier point ouvert de la Phase 3 (LiveKit) : un vrai test d'appel multi-appareil, initialement prévu en LAN. Le chantier domaine/SSO du jour a changé la donne — l'utilisateur a fait remarquer, à raison, qu'il était plus logique de finir le branchement externe de LiveKit et de faire directement le test en conditions réelles (2 comptes réels, réseaux différents) plutôt qu'un test LAN qui n'aurait rien prouvé pour l'usage réel.
Branchement externe : deux vrais bugs, trouvés uniquement via un vrai client Element¶
well_known.livekit_url(tuwunel.toml) etLIVEKIT_URL(lk-jwt-service, env var) pointaient encore versmyown-livekit(-jwt).local— mis à jour verslivekit.offsystem.fr/livekit-jwt.offsystem.fr. Sans surprise, déjà anticipé.- Bug non anticipé, trouvé seulement via Element X réel (
curln'avait rien détecté) :[global.well_known].clientdanstuwunel.tomlpointait toujours vershttps://myown-tuwunel.local:8453/— injoignable pour un client externe. Erreur "we couldn't reach this homeserver" à la saisie detuwunel.offsystem.fr. Corrigé (well_known.client→https://tuwunel.offsystem.fr) ;well_known.server(fédération, sujet distinct, déjà fonctionnel pour le seul consommateur interne qui en a besoin — voir plus bas) volontairement laissé inchangé. - Port-forward Freebox RTC :
7881/tcp(déjà fait plus tôt),7882/udpajouté en cours de route (voir plus bas, insuffisant à lui seul).
Recherche d'utilisateur par nom dans Element : ne fonctionne pas de façon fiable (déjà noté ailleurs dans ce projet pour la recherche de salon) — l'identifiant Matrix complet (@kate:myown-tuwunel.local) fonctionne toujours, quoique peu intuitif pour un usage familial. Le lien "share" d'un salon pointe lui aussi vers myown-tuwunel.local: c'est attendu, pas un bug — server_name est l'identité Matrix permanente gravée dans chaque salon/compte (cf. la contrainte déjà posée pour la migration SSO), un lien de partage ne fait qu'exposer cet identifiant. Sans conséquence fonctionnelle pour un client Matrix déjà connecté (résolution via sa propre session, pas une requête DNS brute vers .local) ; laissé comme point d'attention UX pour l'onboarding famille plus tard, hors sujet du jour.
Aucun serveur TURN configuré — premier vrai correctif réseau¶
Le chart LiveKit tournait avec turn.enabled: false depuis le début — uniquement du STUN/direct. Un appel entre deux comptes réels sur le même WiFi domestique échouait systématiquement côté mobile (removing participant without connection ~2-4s après le début, confirmé dans les logs livekit-sfu) alors qu'il fonctionnait depuis un PC — piles WebRTC mobiles plus strictement dépendantes de l'UDP direct, sans repli utilisable une fois celui-ci indisponible.
TURN/TLS activé (turn.enabled: true, port 5349/tcp — volontairement pas de TURN/UDP dans un premier temps, TLS étant le plus résistant aux NAT/pare-feux restrictifs). Le chart supporte nativement turn.secretName (monte le secret, câble LIVEKIT_TURN_CERT/LIVEKIT_TURN_KEY automatiquement — confirmé en lisant templates/deployment.yaml du chart directement plutôt que deviné) : plutôt qu'émettre un second certificat, réutilisation du certificat wildcard Let's Encrypt que Traefik gère déjà, extrait de son acme.json (format confirmé en le lisant directement : {"letsencrypt":{"Certificates":[{"domain":{"main":"offsystem.fr","sans":["*.offsystem.fr"]},"certificate":"<base64>","key":"<base64>"}]}}). Nouveau CronJob livekit-turn-cert-sync (gitops/manifests/livekit-turn-cert-sync/) : tourne dans kube-system (seul namespace pouvant monter la PVC traefik en lecture seule — RWO/local-path, effectivement un hostPath, ce qui permet à un second pod du même nœud unique de la monter en parallèle du pod Traefik lui-même), extrait cert+clé via jq, écrit le Secret TLS dans livekit via un Role/RoleBinding cross-namespace scopé au seul nom livekit-turn-tls (pas un accès large aux secrets de livekit). Quotidien, largement suffisant face au renouvellement Let's Encrypt (~30 jours d'avance).
Port-forward 7882/udp posé, appel toujours en échec identique — TURN à lui seul ne suffisait pas. Repris dans les logs livekit-sfu au démarrage :
found external IP via STUN {"externalIP": "82.67.34.121", "validateExternalIP": true}
could not validate external IP {"ip": "82.67.34.121", "error": "context canceled"}
using external IPs {"ips": ["192.168.1.143/...", "10.42.0.0/...", "10.100.0.1/...", ...]}
LiveKit découvre correctement l'IP publique réelle via STUN, puis tente de la valider (auto-ping à travers la boucle NAT) — cette validation échoue systématiquement (timeout), et LiveKit se rabat silencieusement sur les IP locales brutes de chaque interface de l'hôte (LAN, CIDR interne du cluster, même l'interface WireGuard — le pod hostNetwork voit toutes les interfaces réseau de l'hôte, pas seulement celle qui compte) au lieu de l'IP publique correctement découverte. Cause : la Freebox supporte le hairpin NAT en TCP (déjà validé sur le port 443) mais pas en UDP — l'auto-validation de LiveKit est elle-même un aller-retour UDP en boucle. Documenté explicitement dans le values.yaml du chart pour cette classe de NAT précise : "enable this when SFU is behind NAT that does not allow self-ping". rtc.skip_external_ip_validation: true ajouté — confirmé en clair dans les logs après coup : 82.67.34.121 apparaît maintenant correctement dans la liste des IP annoncées.
Piège de rollout distinct, trouvé en déployant ce correctif : le Deployment par défaut est en stratégie RollingUpdate, mais les pods LiveKit sont en hostNetwork sur un cluster mono-nœud — le second pod (surge) ne peut jamais être planifié en parallèle du premier (conflit de ports 7880-7882/5349 sur le même nœud), le rollout reste bloqué indéfiniment (1 old replicas are pending termination). deploymentStrategy: {type: Recreate} ajouté — touchera tout futur changement sur ce Deployment, pas seulement celui-ci.
MISSING_MATRIX_RTC_TRANSPORT — investigation longue, cause confirmée mais non résolue¶
Malgré les deux correctifs réseau ci-dessus, l'appel échouait encore, de façon identique, sur mobile (appli Element X et navigateur mobile) — jamais sur PC (Chrome, onglet privé). Fausses pistes explorées et éliminées une à une, par la preuve plutôt que par supposition :
- Cache client : un test en navigation privée sur PC fonctionnait (preuve indirecte en faveur d'un cache), mais un cycle complet côté mobile (vider le cache, fermer l'appli, se reconnecter, nouveau salon) reproduisait l'erreur à l'identique — élimine un simple cache HTTP/app léger.
server_namenon routable (myown-tuwunel.localnon résolvable hors LAN) : plausible vu le message d'erreur affichant ce domaine, mais le test en 5G (réseau mobile réel, aucun hairpin possible) échouait plus vite qu'en WiFi, pas plus lentement — incompatible avec un vrai échec réseau/DNS.- CORS/preflight sur
/rtc/transports: vérifié directement (curl -X OPTIONSavec les en-têtes exacts qu'enverrait la WebView) — réponse correcte,authorizationbien listé dansAccess-Control-Allow-Headers. - Déconnexion complète Element X ("remove this device", pas juste vider le cache — le vrai équivalent d'un logout dans cette version d'Element X) : reproduit l'erreur à l'identique sur un salon flambant neuf. Élimine tout cache côté SDK Rust (
matrix-rust-sdk), qui met effectivement en cache le résultat de/rtc/transports(confirmé en lisantcrates/matrix-sdk/src/client/mod.rssur GitHub) — mais une session neuve ne peut par définition rien avoir en cache. - Chaîne de certificat incomplète (hypothèse : un client TLS strict comme
rustlssans "AIA chasing" contrairement aux navigateurs) : vérifiée paropenssl s_client -showcerts— chaîne complète (leaf + intermédiaire + racine),Verify return code: 0.
Preuve décisive, obtenue en activant les logs d'accès Traefik (jusque-là aucun log de requête entrante n'existait — ajouté spécifiquement pour ce diagnostic, logs.access.enabled: true sur le HelmChartConfig Traefik) : la requête webapp standalone (navigateur mobile, hors appli native) vers /_matrix/client/unstable/org.matrix.msc4143/rtc/transports aboutit bel et bien — 200, taille de réponse correcte (96 octets, celle attendue). Le serveur a donc été confirmé correct à ce niveau précis. Aucune requête suivante (openid/request_token, ni vers lk-jwt-service) n'a lieu ensuite — l'échec se produit après une réponse pourtant valide, dans le code JavaScript d'Element Call lui-même (RtcTransportAutoDiscovery/_unstable_getRTCTransports, lu directement dans matrix-js-sdk et element-call sur GitHub — logique de validation elle-même triviale : type === "livekit" && "livekit_service_url" in object, satisfaite par notre payload).
Découverte de mécanisme distincte, potentiellement significative pour l'appli native (pas testée jusqu'au bout) : dans element-call, RoomWidgetClient._unstable_getRTCTransports() (mode "widget", utilisé quand Element Call tourne dans la WebView intégrée d'une appli native plutôt qu'en standalone) ne fait aucun appel HTTP direct — il délègue à l'hôte natif via l'API "widget" (postMessage), capacité encore expérimentale (MSC4515RtcTransports, marquée @experimental dans le SDK). Un hôte natif ne supportant pas encore correctement cette capacité échouerait instantanément, sans jamais toucher le réseau — cohérent avec l'appli Element X (Origin appassets.androidplatform.net, confirmé dans les logs lk-jwt-service d'un tout premier essai). Cette piste expliquerait l'appli native, mais pas le test webapp standalone ci-dessus (mode non-widget, requête réseau confirmée aboutie) — donc au mieux une explication partielle, pas la cause complète.
Une issue GitHub externe correspondant très exactement au code d'erreur (element-hq/element-x-android#6544, closed) rapportait le même symptôme, avec une résolution différente trouvée par le rapporteur : un défaut d'exposition du port de fédération Matrix standard (8448), nécessaire à la vérification d'un jeton OpenID côté fédération pour ce flux d'appel précis. Notre configuration a déjà un correctif spécifique pour ce besoin exact — well_known.server de tuwunel.toml délègue volontairement la découverte de fédération vers le port 8008 (client-serveur, où Tuwunel sert aussi l'espace de noms fédération) pour que lk-jwt-service, en interne au cluster, puisse vérifier les jetons OpenID sans vraie fédération externe — mais aucune requête openid/request_token n'a été observée dans les traces Traefik ni Tuwunel, donc ce mécanisme précis ne semble même pas être atteint dans notre cas.
Non résolu à ce jour — limitation connue, documentée plutôt que masquée. Prouvé et solide : infrastructure serveur intégralement correcte (transport RTC, TURN, IP externe, fédération interne) — vérifié par preuve directe à chaque étape, pas supposé. Ce qui reste en échec est situé dans le code client d'Element Call, sur un mécanisme encore expérimental (MSC4143/MSC4515), au-delà de ce qu'une investigation côté serveur peut résoudre sans accès à la console du navigateur du client concerné (piste non explorée : débogage USB à distance). À reprendre plus tard — soit via un accès console navigateur réel, soit simplement en réessayant après une mise à jour d'Element X/Element Call, plusieurs des mécanismes en cause étant explicitement marqués expérimentaux côté upstream.
Vérification annexe : syncPolicy d'Authentik¶
En cours d'investigation, découverte que l'Application authentik avait en fait syncPolicy.automated.selfHeal: true actif, contrairement à root/livekit/tuwunel désactivées comme prévu (incohérence avec l'inventaire de l'incident précédent). Vérifié sans conséquence : le tag actuellement pinné sur le mini PC (v0.1.5) contient déjà la bonne configuration SSO pour Tuwunel (confirmé en lisant directement le Secret de blueprint déployé) — selfHeal ne faisait donc que réaffirmer un état déjà correct, pas revenir en arrière. Les changements non commités de cette session (blueprint account-recovery, etc.) ne sont pas concernés : root étant lui-même pinné sur v0.1.5, la définition même de l'Application authentik que le cluster utilise ignore tout changement local non poussé — rien à corriger dans l'immédiat, mais syncPolicy d'authentik sera alignée sur les 3 autres (désactivée) au prochain cycle commit/release/re-pin par prudence.
Migration server_name Tuwunel vers offsystem.fr — fédération réelle validée (2026-08-23)¶
Item Phase 4 attaqué directement (priorités réordonnées par l'utilisateur ce jour-là : Pi-hole repoussé en dernier, watchdog/fédération/Mailcow devant). Blocage découvert en creusant l'item roadmap "test de fédération avec un second serveur Matrix externe" : server_name = "myown-tuwunel.local" rend la fédération structurellement impossible, pas juste non configurée — .local est un TLD réservé, bloqué au niveau DNS racine, jamais résolvable publiquement quelle que soit la config réseau.
Recherche faite avant de trancher (demande explicite de l'utilisateur, "vérifier les alternatives avant d'accepter la casse") : aucun mécanisme de migration server_name n'existe. Spec Matrix : aucune MSC finalisée sur le sujet (MSC1228/2787/4014 tous non aboutis). Implémentations : Synapse refuse de démarrer si server_name change (issue ouverte depuis 2014, jamais résolue) ; la doc Conduwuit/Tuwunel/Continuwuity dit explicitement "THIS CANNOT BE CHANGED AFTER WITHOUT A DATABASE WIPE". Les outils tiers existants (matrix-migrate-tool, procédure HIFIS, guide officiel matrix.org "Switching Providers") créent tous un nouveau compte, jamais une vraie migration d'identité. Décision de l'utilisateur en connaissance de cause : remise à zéro maintenant, pendant que le coût est bas (2 vrais comptes, @robin et @kate, plus le bot d'alerte).
Nouveau server_name : offsystem.fr, domaine apex, pas un sous-domaine — recommandation propre à Tuwunel/Continuwuity et Dendrite retrouvée dans la recherche (choix aussi permanent que le précédent, autant prendre le plus portable : @robin:offsystem.fr plutôt que @robin:tuwunel.offsystem.fr, qui exposerait "tuwunel" comme détail d'implémentation dans chaque identifiant pour toujours).
Découplage identité/hébergement, aucun nouveau composant lourd : server_name (apex) reste distinct de l'hébergement réel (tuwunel.offsystem.fr, inchangé) via .well-known/matrix/server — le même mécanisme que [global.well_known] utilisait déjà pour client/livekit_url. Juste un nouvel Ingress minimal (gitops/manifests/tuwunel-federation-delegation/, host offsystem.fr → même Service tuwunel) validé de façon totalement indépendante et sans risque avant de toucher aux données : curl https://offsystem.fr/.well-known/matrix/server interrogé une première fois pendant que well_known.server valait encore l'ancienne valeur, confirmant que le routage marchait, avant tout changement réel.
Remise à zéro réelle, pas une simple modification de config : sauvegarde Restic manuelle déclenchée d'abord (snapshot confirmé, 21 MiB), puis Deployment scalé à 0, PVC de données supprimée entièrement (pas de chirurgie partielle possible, server_name est référencé dans toute la base RocksDB), nouvelle PVC vide recréée, nouveau tuwunel.toml (server_name = "offsystem.fr", well_known.server = "tuwunel.offsystem.fr:443"), redémarrage. Incident en cours de route : selfHeal (actif sur l'Application tuwunel, oublié désactivé avant cette opération) a remis le Deployment à 1 réplique automatiquement dès le premier scale --replicas=0 — la même friction déjà documentée plusieurs fois ce projet, cette fois-ci bloquant carrément la suppression de la PVC (le pod remonté par selfHeal la retenait). syncPolicy désactivée, la manœuvre reprise proprement. Deuxième blocage réel : la PVC restait en Terminating même après ça — cause trouvée en vérifiant quels pods référençaient encore le volume : les pods de sauvegarde Restic déjà terminés (Completed) comptent toujours pour le finaliseur kubernetes.io/pvc-protection tant que leur objet n'est pas supprimé, même après la fin de leur exécution.
Ré-onboarding : robin et Katia repassent tous les deux par le même geste simple — reconnexion SSO Element/Tuwunel, qui provisionne automatiquement un nouveau compte Matrix au premier login (le compte Authentik, lui, n'a jamais bougé). Bot d'alerte regénéré (scripts/tuwunel-alertbot-setup.py, mis à jour pour cibler tuwunel.offsystem.fr avec un vrai certificat Let's Encrypt — l'ancien contournement CA mkcert n'est plus nécessaire du tout), salon #etat-du-systeme recréé, gitops/secrets/uptime-kuma/uptime-kuma.sops.yaml mis à jour, fournisseur de notification Matrix d'Uptime Kuma repointé via edit_notification (pas juste add_notification — le nom existant bloquait une simple relance du script d'installation) et validé par un vrai test de notification, confirmé par lecture directe de l'historique du salon via l'API.
Découverte rétroactive inattendue, confirmée par l'utilisateur : le blocage MISSING_MATRIX_RTC_TRANSPORT documenté la veille (section précédente) — jamais résolu après une investigation poussée qui avait fini par conclure à un bug client Element Call non identifiable sans accès console — a disparu après cette migration, sans aucun changement côté LiveKit/TURN. L'intuition initiale de l'utilisateur ("le nom de domaine local doit poser problème quelque part"), écartée à l'époque au profit d'hypothèses plus poussées, était la bonne : quelque chose dans la chaîne de préparation d'appel devait valider que l'identité du serveur ressemble à un domaine public réel, et rejetait silencieusement .local (TLD réservé) — expliquant à la fois pourquoi la découverte du transport RTC réussissait (elle ne dépend pas de cette validation) et pourquoi l'étape suivante n'était jamais atteinte, de façon identique sur l'appli et sur le web. Non creusé plus loin côté code source (le correctif venait de toute façon du même chantier), mais la corrélation est nette et confirmée par un vrai test d'appel réussi juste après le changement de server_name.
Validation de fédération, en couches, chaque niveau vérifié indépendamment plutôt que supposé :
- Testeur officiel matrix.org (
https://federationtester.matrix.org/api/report?server_name=offsystem.fr, tiers complètement indépendant) :AllChecksOK: truecôté IPv4 — certificat valide, clé Ed25519 correctement signée, délégation.well-knowncorrectement suivie.FederationOK: falseau global uniquement à cause d'un second problème distinct trouvé au passage : leServiceTraefik est IPv4 uniquement au niveau Kubernetes (ipFamilies: ["IPv4"], malgréipFamilyPolicy: PreferDualStack— le cluster n'a jamais eu de vraies plages d'adresses IPv6 configurées pour sesServices), doncconnection refuseden IPv6 pur depuis l'extérieur. Accepté comme chantier séparé (dual-stack Kubernetes complet, changement d'ampleur cluster, pas un réglage rapide) — l'IPv4 seul suffit pour l'immense majorité des cas réels (repli Happy Eyeballs standard côté client). - Test réel avec un compte matrix.org : conversation démarrée depuis un vrai compte matrix.org vers
@robin:offsystem.fr— trouvable et joignable sans problème (confirme la fédération entrante, cohérent avec le test IPv4 ci-dessus). Mais rejoindre l'invitation depuis le côté offsystem.fr échouait ("The room no longer exists or the invite is no longer valid") — bug distinct, fédération sortante cette fois. - Diagnostic du bug sortant, confirmé par preuve directe plutôt que supposé : logs serveur montrant des tentatives
make_joinrépétées vers matrix.org, échouant en boucle ("Several servers failed. Giving up"). Un pod de debug (nicolaka/netshoot) démarré dans le même namespace confirme :matrix.orgrésout bien en dual-stack (deux IPv4, deux IPv6) depuis l'intérieur du cluster, mais le réseau pod normal n'a aucune route IPv6 par défaut (ip routene montre que de l'IPv4) — uncurl -6direct échoue instantanément ("Could not connect to server"). Exactement le même bug de fond déjà rencontré une fois pourgandi-dyndns("pas d'accès sortant IPv6 depuis le réseau pod normal, malgré un hôte en IPv6 natif") — sauf que Tuwunel, contrairement àcurl, ne semble jamais retomber sur l'adresse IPv4 pourtant présente dans la même réponse DNS : chaque tentative de fédération sortante vers un pair dual-stack échoue purement à cause de l'ordre de préférence IPv6. - Correctif appliqué, même remède que
gandi-dyndns/LiveKit :hostNetwork: truesur leDeploymentTuwunel (gitops/manifests/tuwunel/deployment.yaml), avecdnsPolicy: ClusterFirstWithHostNetimpérativement à côté (sans ça, la résolution DNS interne — nécessaire pour les appels OIDC versauthentik.offsystem.fr— bascule sur le résolveur de l'hôte et casse). Le routageService/Ingressexistant continue de fonctionner sans changement supplémentaire : avechostNetwork, l'IP du pod devient celle de l'hôte, et Kubernetes construit sesEndpointsen conséquence — confirmé par un testcurlréel surtuwunel.offsystem.frjuste après le redémarrage, toujours200. - Validation finale, confirmée par preuve directe : nouvelle tentative de join côté utilisateur réussie, confirmée dans les logs serveur avec la séquence complète (
make_join finished→send_join finished→ acquisition réelle des clés de signature auprès du serveur notaire matrix.org → événement de join accepté avec un vraievent_id) — pas juste "ça a marché à l'écran", la chaîne cryptographique de fédération complète tracée de bout en bout côté serveur.
Portée du chantier IPv6 encore ouvert, notée pour plus tard : deux problèmes IPv6 distincts trouvés aujourd'hui, tous deux acceptés comme scope différé — (1) pas de dual-stack Kubernetes pour les Services (ipFamilies figé en IPv4), affecte l'accessibilité entrante pure IPv6 depuis l'extérieur ; (2) pas d'accès sortant IPv6 pour le réseau pod normal (déjà contourné au cas par cas pour gandi-dyndns, LiveKit, et maintenant Tuwunel via hostNetwork, mais aucun correctif structurel côté CNI). Les deux relèvent de la même cause racine profonde (jamais configuré dès le départ), mais un vrai correctif dual-stack CNI est un changement d'ampleur cluster, hors de portée d'un correctif ponctuel par service.
Préparation Mailcow — recherche de faisabilité Kubernetes (2026-08-23)¶
Session de préparation avant de passer sur un contexte vierge, dans le même esprit que celle du 2026-08-13 pour la Phase 3.5 : recherche, pas d'implémentation. Objectif : savoir si Mailcow peut suivre le même modèle "tout en Application ArgoCD/k3s" que le reste du projet, avant de commencer à écrire quoi que ce soit.
Verdict net et bien sourcé : non, Kubernetes n'est pas une option viable pour Mailcow.
- Aucun Helm chart officiel ni communautaire n'existe (rien sur ArtifactHub, aucun dépôt actif trouvé). Le seul projet k8s-adjacent trouvé (
Promaethius/mailcow-dind) enferme tout le stack compose dans un unique conteneur Docker-in-Docker privilégié — abandonné depuis 2019, et l'aveu même du problème plutôt qu'une solution. - Refus explicite des mainteneurs, pas juste une lacune : issue #189 — "This is not an issue for mailcow... it will not be used by mailcow" ; issue #7200 (encore ouverte, avril 2026) — "you can't just spin up existing containers as-is in k8s". Aucun témoignage de déploiement k8s fonctionnel trouvé nulle part.
- Blocages techniques concrets, vérifiés dans le
docker-compose.ymlréel :netfilter-mailcowtourne enprivileged: true+network_mode: host, manipule directement les règles iptables/nftables de l'hôte (équivalent fail2ban) — risque réel de collision avec les règles propres de k3s (kube-proxy, flannel) si co-localisé sur le même hôte que le cluster.dockerapi-mailcowpilote directement le socket Docker (/var/run/docker.sock) pour que l'interface d'admin puisse redémarrer les conteneurs — aucun équivalent Kubernetes, c'est le vrai blocage de fond, pas juste un détail de packaging. MySQL partagé entre conteneurs via socket Unix sur un volume, pas TCP. Le script de mise à jour officiel (update.sh) part du principe quedocker compose down/upexiste. - Recommandation universelle retrouvée dans la recherche : déployer Mailcow en docker-compose natif, hors du cluster k3s — rôle Ansible officiel maintenu disponible (
mailcow/mailcow-ansiblerole, dernière mise à jour novembre 2025) pour industrialiser ce déploiement plutôt que le faire à la main.
Deux options réalistes pour où faire tourner ce docker-compose, ni l'une ni l'autre tranchée — décision à prendre avec l'utilisateur à la prochaine session, pas ici :
- Sur le mini PC lui-même, à côté de k3s (pas dedans) — évite d'acheter/maintenir une deuxième machine, cohérent avec le choix déjà fait pour WireGuard/le watchdog (services hôte à côté du cluster, pas dans GitOps). Risque réel à valider concrètement avant de s'engager : collision potentielle entre les règles iptables de
netfilter-mailcowet celles de k3s sur le même hôte — pas observé dans la recherche (pas de témoignage de co-location trouvé), donc à tester en conditions réelles, pas à supposer sûr. Le nginx de Mailcow revendique aussi les ports 80/443 par défaut, déjà tenus par Traefik —HTTP_BIND/HTTPS_BINDde Mailcow sont configurables pour se lier en local uniquement, avec Traefik en frontal — cohérent avec le principe déjà en place pour tout le reste du projet (un seul point d'entrée HTTPS), mais Traefik ne découvre pas nativement un conteneur Docker externe au cluster (pas de mécanismeIngresspour lui) : demande soit une configuration dynamique de fichier statique côté Traefik, soit un mécanisme de découverte à construire. - Sur une machine séparée (VM dédiée, ou un second petit boîtier) — isole complètement le risque iptables/ports, mais coût matériel/opérationnel supplémentaire, à l'encontre du principe "un seul mini PC" déjà posé dans
architecture.mdpour la Phase 1. Le VPS façade lui-même est exclu d'office pour héberger le stockage réel (architecture.md§5.6 : "stockage réel à domicile" est une décision de souveraineté des données, pas juste de coût — la façade VPS ne fait que relayer le SMTP, jamais stocker les mails).
Reste à faire, dans l'ordre du roadmap déjà posé (roadmap.md, Phase 4) — inchangé par cette recherche, juste maintenant informé : (1) provisionnement du VPS façade (Hetzner/OVH/Scaleway — décision utilisateur, budget/juridiction déjà évoqués dans architecture.md §7) et configuration Postfix relay + SPF/DKIM/DMARC ; (2) déploiement Mailcow à domicile une fois l'option 1 ou 2 ci-dessus tranchée, connexion au relais VPS (topologie de tunnel — probablement un WireGuard dédié, distinct du VPN admin déjà en place, mais pas encore conçu) ; (3) migration progressive des correspondants ; (4) suivi de délivrabilité sur une période soutenue.
Note a posteriori (2026-08-23, même jour) : cette conclusion a été renversée quelques heures plus tard — voir la section suivante. Le blocage Kubernetes ci-dessus est resté exact pour Mailcow spécifiquement ; ce qui a changé, c'est le choix de la suite mail elle-même, pas la conclusion sur Mailcow.
Comparatif Mailcow vs Mailu vs Gmail, et pivot vers Mailu (2026-08-23)¶
Poursuite directe de la session ci-dessus, dans la même conversation : plutôt que d'accepter le hors-cluster que Mailcow imposerait, comparaison avec Mailu (github.com/Mailu/Mailu), une alternative moins connue mais pensée conteneurs dès le départ. Deux passes de recherche séparées, chacune vérifiée contre la doc officielle/le code source plutôt que devinée.
Déploiement Kubernetes — le facteur décisif¶
Chart Helm existant, maintenu par l'organisation Mailu elle-même : github.com/Mailu/helm-charts (pas dans le dépôt principal — une doc Kubernetes de 3 lignes dans Mailu/Mailu renvoie vers ce dépôt séparé, avec un aveu direct : "We are looking for maintainers"). Chart 2.7.3, appVersion 2024.06.57 contre 2024.06.58 en amont (un patch de retard, bump automatisé) — dépôt poussé le jour même de cette recherche (2026-08-22→23), 160 étoiles, 57 issues ouvertes, pas archivé. Donc : maintenu activement mais sous-staffé, ni "supporté officiellement" au sens Mailcow-du-refus, ni abandonné.
Aucun des blocages structurels de Mailcow : setup/flavors/compose/docker-compose.yml de Mailu vérifié directement — aucun des 13 services (front/admin/smtp/imap/antispam/antivirus/webdav/webmail/resolver/redis/oletools/tika/fetchmail) ne tourne en privileged: true, network_mode: host, cap_add, ni ne monte /var/run/docker.sock. Mailu délègue le filtrage réseau à nginx/rspamd plutôt qu'aux règles iptables de l'hôte — exactement ce qui rend Mailcow incompatible k8s, absent ici.
Stockage et exposition réseau : un seul PVC RWO 100Gi par défaut (persistence.single_pvc: true), ou PVC par composant (admin/postfix/dovecot/webmail 20Gi, rspamd 1Gi, clamav 2Gi, redis 8Gi). Tous les composants en Deployment sauf ClamAV (StatefulSet). Ports mail exposés via front.hostPort.enabled: true par défaut — hostPort, pas hostNetwork complet (nuance à vérifier en pratique, pas un simple copier-coller du pattern déjà utilisé pour LiveKit/Tuwunel).
Deux réserves techniques identifiées avant de commencer l'implémentation, non résolues à ce stade :
- Le README du chart liste
cert-managercomme prérequis — ce projet a délibérément écartécert-managerau profit du DNS-01 natif de Traefik (gandiv5, cf. section domaine/Let's Encrypt ci-dessus), faute de support Gandi natif côtécert-manager. À vérifier : le chart peut-il consommer un secret TLS déjà existant (celui que Traefik gère) plutôt que d'exigercert-manageren dur ? front.hostPort.enabled: truepar défaut pour les ports mail (25/465/587/143/993) — leur cohabitation avec Traefik déjà propriétaire de 80/443 via le ServiceLB de k3s n'est pas un problème de port identique (les ports mail sont différents), mais le mécanisme d'exposition (hostPortvsLoadBalancer/NodePort/externalService) reste à concevoir explicitement, pas à supposer sûr par défaut.
Comparatif fonctionnel — Gmail / Mailcow / Mailu¶
Question initiale de l'utilisateur : alias par service (façon service+tag@gmail.com mais plus robuste), classification/tags automatiques, et intégration carnet d'adresses Nextcloud.
- Alias / catch-all : égalité Mailcow/Mailu — les deux battent Gmail (alias dédié par service type
amazon@offsystem.fr, plus robuste que le+tagque beaucoup de formulaires rejettent). Mailu confirmé : UI admin dédiée, catch-all via syntaxe SQLLIKE(%@domaine.tld), aucune édition de fichier. - Filtres : égalité Mailcow/Mailu, tous deux basés sur Sieve (Dovecot Pigeonhole) avec UI par défaut — SOGo a la sienne, Roundcube a
managesieveactivé par défaut dans la liste de plugins de Mailu (confirmé dansconfiguration.rst). Aucun des deux n'exige d'écrire du Sieve brut. - Classification automatique façon Gmail (Principal/Réseaux sociaux/Promotions, ML) : absente des deux, confirmé — Rspamd (moteur identique dans les deux suites) ne fait que du scoring spam/ham binaire, pas de catégorisation multi-classes. Écart réel face à Gmail, assumé comme le compromis connu de l'auto-hébergement mail (
architecture.md§7). - Tags multi-étiquettes (façon Gmail, plusieurs tags sur un même message, différent des dossiers) : contre-intuitif, avantage Mailcow. SOGo a un vrai système de labels natif (Préférences → Mail → Labels, confirmé dans le code source SOGo, commit
800e21b, mots-clés IMAP en tableau). Roundcube n'a rien de natif — demande toujours ouverte côté cœur du projet (issue #4986), et Mailu ne bundle aucun plugin tiers de tags par défaut. - Carnet d'adresses Nextcloud → autocomplétion mail : avantage net Mailu. Le plugin
rcmcarddav(bundlé par défaut dans Roundcube côté Mailu) documente explicitement la connexion à un CardDAV externe, Nextcloud nommément cité dans sa doc (ADMIN-SETTINGS.md, authentification par jeton documentée pour le casuser_oidc). Deux réserves : non confirmé siautocomplete_addressbooks(réglage Roundcube séparé) est nécessaire en plus pour que ça alimente vraiment l'autocomplétion à la composition ; pas de méthode officiellement documentée pour injecter cette config dans le conteneur Mailu (discussion GitHub #3047, sans réponse) — probablement faisable, à valider en pratique. Côté Mailcow : confirmé impossible,SOGoUserSourcesn'accepte que LDAP ou SQL, aucun support CardDAV externe. - Calendrier dans le webmail : jugé non nécessaire dans les deux cas — Nextcloud Calendar déjà déployé et validé (Phase 2) couvre ce besoin. Différence pratique : chez Mailu, Radicale est strictement optionnel (
WEBDAV=none, composant absent si non activé) ; chez Mailcow, SOGo l'embarque de toute façon comme suite indissociable — pas de retrait possible, juste un onglet que la famille n'a pas besoin d'utiliser.
Décision¶
Mailu retenu, décision de l'utilisateur après ce comparatif complet. Le facteur décisif reste l'intégration Kubernetes/GitOps native (aucune décision d'hébergement hors-cluster à trancher, contrairement à Mailcow) — les écarts fonctionnels réels (tags pour Mailcow, contacts Nextcloud pour Mailu) sont significatifs mais plus fins que "Mailu gagne sur tout", et aucun des deux n'égale Gmail sur la classification automatique. architecture.md §5.6, roadmap.md Phase 4 et CLAUDE.md mis à jour le jour même pour refléter Mailu.
Mailu déployé et validé de bout en bout, y compris SSO (2026-08-23)¶
Suite directe de la décision ci-dessus, même session. Objectif : faire tourner Mailu pour de vrai sur le cluster de dev, avec le SSO Authentik activé dès cette étape (changement de séquencement demandé par l'utilisateur par rapport au plan initial, qui différait le SSO — le mécanisme ForwardAuth jugé assez structurant pour être validé tôt). Chaque étape a été validée en conditions réelles, pas seulement en dry-run — et chaque étape a produit au moins un vrai bug, aucun deviné depuis la doc.
Étape 0 — Mécanisme ForwardAuth prouvé isolément, avant Mailu¶
Mailu n'a pas de support OIDC natif (confirmé dans sa doc) — seulement une authentification par en-tête de confiance (PROXY_AUTH_HEADER), pensée pour un reverse-proxy comme Authentik en mode Proxy Provider "Forward auth (single application)". Premier ForwardAuth de ce repo (les 5 autres blueprints SSO existants sont tous de vrais fournisseurs OAuth2/OIDC) — isolé du risque du premier déploiement du chart Mailu lui-même en le prouvant contre une cible jetable (traefik/whoami) d'abord.
Deux vrais bugs trouvés et corrigés durablement à cette étape :
- L'outpost embarqué d'Authentik redirige vers
http://localhostau lieu du vrai domaine lors de la construction de l'URL d'autorisation OAuth2 envoyée au navigateur — le paramètreredirect_uriest correctement dérivé deX-Forwarded-Host, mais pas l'URL de base de l'endpoint d'autorisation lui-même. Cause :Outpost.config.authentik_host_browsern'a jamais été configuré (vide par défaut). Fix : nouveau script idempotentscripts/authentik-outpost-config.sh— pas un blueprint,Outpost.configest un champ JSON qui porte aussi des clés gérées par le chart (kubernetes_namespace, etc.) qu'un blueprintattrs.configdevrait reproduire intégralement pour ne pas les écraser à chaque sync. - Un
ProxyProvidercréé hors de l'UI/API admin d'Authentik ne remplit pas automatiquementredirect_uris/grant_types/property_mappings— cette logique vit dansProxyProvider.set_oauth_defaults(), appelée uniquement par le serializer de l'API, jamais par l'ORM/les blueprints. Symptôme : "Redirect URI Error" malgré un provider apparemment bien configuré. Le blueprint final (gitops/secrets/authentik-blueprints/mailu-sso.sops.yaml) renseigne donc ces champs explicitement, copiés depuis ce queset_oauth_defaults()calcule réellement (vérifié viaak shell).
Le rattachement du provider à l'outpost embarqué (Outpost.providers, relation M2M) se fait volontairement via le script (--bind-provider), pas via le blueprint : un blueprint attrs.providers remplacerait la liste entière à chaque sync plutôt que d'y ajouter, ce qui désolidariserait silencieusement tout autre provider ForwardAuth ajouté plus tard.
Validation navigateur réelle bloquée par une contrainte d'outillage découverte en cours de route : le navigateur de cette session ne partage pas le réseau//etc/hosts de la machine de dev — un override local ne suffit pas à le faire atteindre le cluster de dev plutôt que le vrai DNS public (qui pointe vers le mini PC). Résolu en déployant temporairement les mêmes ressources jetables sur le mini PC (rien de committé, tout nettoyé après coup des deux côtés) — décision de l'utilisateur après présentation des options. Validé pour de vrai : login navigateur complet contre un vrai compte jetable, en-têtes d'identité (X-Authentik-Email, X-Authentik-Username, X-Authentik-Uid, etc.) confirmés reçus côté application protégée.
Panne infra sans rapport, découverte en cherchant un certificat pour front¶
En cherchant à récupérer un certificat TLS réel pour front (voir plus bas), découvert que le mécanisme ACME de Traefik avait disparu du cluster de dev : ni le HelmChartConfig (gitops/bootstrap/traefik-acme-helmchartconfig.yaml) ni le secret gandi-dns-credentials n'existaient plus (NotFound sur les deux), le pod Traefik tournait sans aucun flag certificatesresolvers/acme, et son volume /data était un emptyDir vide plutôt que le PVC persistant attendu — confirmé via les arguments réels du Deployment et le contenu du volume. Les certificats HTTPS servis jusque-là (vérifiés valides toute la session) tenaient uniquement en mémoire côté process Traefik, sans aucune capacité de renouvellement ; un redémarrage du pod aurait cassé le HTTPS des 9+ services publics du cluster d'un coup. Le CronJob livekit-turn-cert-sync était silencieusement cassé pour la même raison (pas de acme.json à lire, pas de PVC traefik).
Cause racine non élucidée (le HelmChartConfig/secret n'est pas GitOps-managé par design — gitops/bootstrap/, appliqué manuellement — donc rien ne l'aurait réappliqué automatiquement après une éventuelle perte, mais le conteneur k3d lui-même tourne en continu depuis le 2026-08-10, bien avant les incidents documentés). Remédiation : réapplication manuelle des deux (kubectl apply du HelmChartConfig, recréation du secret par l'utilisateur avec son PAT Gandi) — confirmé fonctionnel, Traefik réémet des certificats Let's Encrypt réels immédiatement (acme.json repeuplé, ~53 Ko en moins d'une minute).
Déploiement de base — chart officiel, un vrai bug de vérification TLS¶
Chart Helm officiel (mailu/mailu, 2.7.3), rendu à blanc avant tout déploiement réel (confirmé : SQLite embarqué par défaut, pas de sous-chart MariaDB/Postgres nécessaire ; Redis bundlé). Ingress propre du chart désactivé (ingress.enabled: false, même raison que Vaultwarden/Nextcloud : ingress.tls durcit un secretName incompatible avec l'ACME dynamique de Traefik), remplacé par un Ingress additif classique avec juste l'annotation certresolver.
Premier pod bloqué en CrashLoopBackOff : mailu-admin tentait de se connecter à Redis avant que mailu-redis-master soit prêt — pas un vrai bug, une course de démarrage transitoire qui s'est résolue seule après quelques redémarrages automatiques.
Deuxième blocage, plus profond : front refusait de démarrer, MountVolume.SetUp failed... secret "mailu-certificates" not found. Compris après coup : front a besoin d'un vrai fichier certificat monté directement (STARTTLS SMTP/IMAP + sa propre terminaison HTTPS sur laquelle Traefik proxie), indépendamment de toute ressource Ingress — ingress.enabled: false ne dispense pas de ce besoin. Débloqué en extrayant manuellement le certificat fraîchement réémis depuis acme.json (le même mécanisme que livekit-turn-cert-sync, notes-techniques.md déjà documenté), puis rendu reproductible via un nouveau CronJob mailu-cert-sync (gitops/manifests/mailu-cert-sync/) — filtré sur mailu.offsystem.fr spécifiquement, pas le wildcard partagé par les autres services : confirmé en direct que cet Ingress avait déclenché l'émission de son propre certificat individuel (aucun Ingress de ce repo ne pose l'annotation SAN qui demanderait explicitement le wildcard).
Troisième bug, le plus difficile à isoler : une fois front démarré, / renvoyait un 500 générique ("Internal Server Error") en ~4 secondes. Ni les logs internes de front (nginx) ni ceux d'admin ne montraient la moindre trace de ces requêtes — signe qu'elles n'atteignaient jamais vraiment l'application. Cause réelle, confirmée via les logs Traefik eux-mêmes : tls: failed to verify certificate: x509: cannot validate certificate for 10.42.0.45 because it doesn't contain any IP SANs. front.service.annotations demande à Traefik de parler HTTPS au backend (service.serversscheme: https, nécessaire puisque front termine son propre TLS) — mais Traefik vérifie alors par défaut le certificat du backend contre l'IP du pod, que ce certificat (CN=mailu.offsystem.fr) ne couvre évidemment pas. Fix : nouvelle ressource ServersTransport (insecureSkipVerify: true, trafic interne au réseau des pods, pas un tiers usurpable) — avec un piège de nommage confirmé en direct : la référence service.serverstransport doit être préfixée par le namespace (mailu-mailu-front-transport@kubernetescrd), un nom non préfixé échoue silencieusement.
Validé en conditions réelles : login natif avec le compte admin initial (webmail Roundcube) ; flux mail complet entièrement interne au cluster (compte test@offsystem.fr créé via flask mailu user, message envoyé par SMTP direct à postfix, reçu et relu par IMAP direct depuis dovecot — chaîne LMTP complète confirmée dans les en-têtes du message).
Activation du SSO — ConfigMap non propagée, mauvaise route de callback, mauvais compte de test¶
Trois obstacles distincts avant que le vrai clic de connexion fonctionne, chacun découvert en testant pour de vrai (pas en dry-run) :
mailu-adminn'avait pas les bonnes variablesPROXY_AUTH_*, alors quefrontsi — la ConfigMap partagée générée par le chart (mailu-envvars) avait pourtant bien les bonnes valeurs après le sync Helm/ArgoCD, maismailu-admintournait depuis le tout début du déploiement et Kubernetes ne redémarre jamais un pod automatiquement juste parce qu'une ConfigMap qu'il référence change.kubectl rollout restart deploy/mailu-admina suffi une fois identifié.- Route de callback OAuth manquante : après authentification côté Authentik, le navigateur est redirigé vers
https://mailu.offsystem.fr/outpost.goauthentik.io/callback?...pour que l'outpost embarqué finalise l'échange OAuth et pose son cookie de session — cette requête doit atteindre Authentik (un autre namespace), ce qu'unIngressclassique ne sait pas exprimer (pas de backend cross-namespace sans l'astuceExternalName, que le provider Ingress de Traefik refuse par défaut — même erreur déjà rencontrée pendant la preuve ForwardAuth jetable). Sans cette route, le callback atterrit silencieusement surfront, l'échange OAuth ne se termine jamais, et le navigateur retombe sur le formulaire natif Mailu — aucune erreur visible, juste un SSO qui ne fonctionne pas. Fix : nouvelle ressourceIngressRoute(le CRD propre de Traefik, qui accepteservices[].namespacedirectement, sans contournementExternalName), scoping minimal — une seule route ajoutée plutôt qu'un changement de config Traefik cluster-wide. - Mauvais compte de test : une fois les deux points ci-dessus corrigés, la connexion aboutissait à
Internal Server Error / You don't exist. Go away! (domain=example.com). Pas un bug — le compteakadminutilisé pour le test a pour email réelroot@example.com(valeur de bootstrap générique d'Authentik), pas une adresse@offsystem.fr; Mailu refuse logiquement de créer un compte pour un domaine mail qu'il n'héberge pas. Résolu en créant un compte de test dédié avec une adresse@offsystem.frcohérente.
Découverte utile au passage, en lisant directement /app/mailu/sso/views/base.py dans le conteneur admin : la logique PROXY_AUTH_* (_proxy()) ne s'exécute que sur la route /sso/login, jamais sur /internal/auth/user (l'endpoint que front utilise pour son propre auth_request nginx, qui ne vérifie qu'une session Mailu déjà établie) — un premier test direct contre le mauvais endpoint avait fait perdre du temps avant de le comprendre.
Validé en conditions réelles, bout en bout : connexion via un vrai compte Authentik → redirection vers la page de connexion Authentik (pas le formulaire natif Mailu) → après authentification, arrivée directe dans le webmail Roundcube, sans ressaisir d'identifiants Mailu, avec le mail de bienvenue Mailu visible.
Pas encore fait : sauvegarde Restic de la PVC Mailu, intégration rcmcarddav → contacts Nextcloud, configuration Postfix relay + SPF/DKIM/DMARC sur le VPS façade déjà provisionné (voir section suivante). initialAccount (compte admin natif) conservé même avec le SSO actif, comme porte de secours — même principe déjà appliqué ailleurs dans ce projet (akadmin local d'Authentik).
VPS façade OVH — provisionné et durci (fait antérieurement, documenté ici après coup, 2026-08-23)¶
Écrit rétroactivement : ce travail a été réellement fait dans une session Claude Code distincte de celle qui tenait ce fichier à jour, et n'avait jamais été reporté ici — trou de traçabilité découvert quand l'utilisateur a demandé de "provisionner le VPS façade" dans une session qui n'avait aucune trace de ce qui avait déjà été accompli. Retrouvé et confirmé via les outils de gestion de session (recherche plein texte dans les autres transcripts, list_events), puis vérifié en direct par une vraie connexion SSH — pas pris pour argent comptant. Leçon pour la suite : toute session qui touche à une ressource hors du dépôt git (VPS, compte tiers, etc.) doit documenter l'état atteint avant de se terminer, pas seulement committer du code — c'est exactement ce que ce fichier est censé capturer au fil de l'eau.
Fournisseur retenu : OVH, après comparatif réel avec Hetzner et Scaleway (réputation IP, prix, jurisdiction RGPD, ouverture du port 25). Hetzner initialement recommandé (moins cher habituellement, port 25 débloqué facilement par ticket) mais écarté sur le moment : son tier d'entrée (CX22, ~5,49€/mois) était en rupture de stock, ne laissant que le tier supérieur à ~15€/mois — face à l'offre OVH VPS-1 à 4,50€/mois HT avec le port 25 déjà ouvert par défaut (pas de ticket à attendre), le choix s'est porté sur OVH malgré son outil anti-spam interne qui bloque automatiquement en cas de "suspicion" (risque jugé gérable pour un relais familial à faible volume avec SPF/DKIM/DMARC bien configurés, récupérable par ticket si ça arrive).
Machine réelle : OVH VPS-1, Ubuntu 26.04, hostname vps-20d1e001.vps.ovh.net, IP 51.178.46.161.
Durcissement de base entièrement bouclé et validé en conditions réelles :
- Clé SSH dédiée générée (
~/.ssh/id_ed25519_ovh_vps, séparée de la clé de signature git), copiée sur le VPS viassh-copy-idpendant la fenêtre de validité du mot de passe temporaire (7 jours, envoyé par email à la commande). - Vrai piège trouvé en voulant désactiver l'authentification par mot de passe : deux fichiers de config sshd se contredisaient (
60-cloudimg-settings.confdisaitno, le défaut sain de l'image Ubuntu ;50-cloud-init.confdisaityes, cloud-init l'ayant explicitement réactivé pour permettre la toute première connexion par mot de passe à la création du VPS) —sshdcharge les fichiers par ordre alphabétique et garde la première valeur rencontrée pour chaque directive, donc c'est50-cloud-init.confqui l'emportait silencieusement. Corrigé en éditant directement le fichier qui gagnait réellement, pas celui qu'on s'attendrait à éditer naïvement. Validé en gardant la session SSH existante ouverte et en confirmant une reconnexion réussie depuis un tout nouveau terminal avant de fermer quoi que ce soit. - Pare-feu (
ufw) : SSH uniquement autorisé en entrée (IPv4 et IPv6), tout le reste refusé par défaut — confirmé viaufw status verbose. Les ports mail (25/465/587) volontairement pas encore ouverts, à ajouter une fois le relais Postfix réellement configuré. - Mises à jour de sécurité automatiques (
unattended-upgrades) : déjà présent sur l'image Ubuntu officielle OVH, juste la configuration écrite (/etc/apt/apt.conf.d/20auto-upgrades).
Confirmé fonctionnel de nouveau aujourd'hui (2026-08-23, après la découverte du trou de documentation) : connexion SSH réelle testée avec la clé existante, whoami confirme ubuntu, sudo -n true confirme un accès root sans mot de passe toujours actif.
Pas encore fait sur ce VPS : relais Postfix (attendait à l'époque deux prérequis — nom de domaine et Mailu à la maison — tous deux résolus depuis dans ce dépôt), enregistrements SPF/DKIM/DMARC, ouverture des ports mail dans ufw, conception du tunnel VPS↔domicile (probablement WireGuard dédié, distinct du VPN admin déjà en place).
Tunnel WireGuard relais mail (VPS ↔ domicile) — bootstrapé et validé (2026-08-23)¶
Nouvelle instance WireGuard séparée du VPN admin (wireguard/), topologie inversée : le serveur tourne sur le VPS (IP publique stable) et le poste "maison" (dev aujourd'hui, mini PC après migration Mailu) se connecte en sortant — raison complète dans wireguard-mail-relay/README.md. Sous-réseau (10.100.1.0/24, port UDP 51821) et identité de clés entièrement distincts du VPN admin (10.100.0.0/24, 51820) : le VPS est un environnement moins sûr par nature (port 25 exposé publiquement), un compromis ne doit donner accès qu'au SMTP de Mailu, jamais à kubectl/SSH admin. scripts/wireguard-mail-relay-setup.sh amorce le serveur (première exécution, appliqué à distance sur le VPS via SSH — le compte ubuntu y a sudo sans mot de passe) ou ajoute un pair (exécutions suivantes) ; config serveur commitée chiffrée (wireguard-mail-relay/wg-mail0.conf.sops.yaml), config cliente jamais commitée (même principe que le VPN admin).
Deux vrais bugs trouvés en le mettant en service, aucun des deux dans le script lui-même :
- Le script a régénéré des clés alors qu'un tunnel tournait déjà : lancé en pensant amorcer pour la première fois (le fichier chiffré n'existait pas encore dans le dépôt), alors qu'un tunnel fonctionnel avait déjà été établi plus tôt dans la même session, avant une compaction de contexte qui a fait perdre cet état — confirmé a posteriori par un vrai handshake apparu 36 secondes après un "premier" lancement, impossible autrement. Le script a écrasé
/etc/wireguard/wg-mail0.confsur le VPS avec de nouvelles clés, maissystemctl enable --nowsur un service déjà actif ne relit pas le fichier (no-op) — l'interface réelle a continué de tourner sur les anciennes clés jusqu'à unsystemctl restartexplicite, moment où le client local (resté sur les anciennes clés) a cessé de matcher. Corrigé en resynchronisant les deux bouts sur les nouvelles clés déjà commitées plutôt qu'en tentant de récupérer les anciennes (aucune clé privée lisible sans sudo interactif, et rien ne dépendait encore de ce tunnel — Postfix pas encore installé). wg-quick downmanuel contourne le suivi d'état systemd : en resynchronisant le client, la commande de remise à zéro proposée commençait parsudo wg-quick down wg-mail0(hors service systemd) suivi desystemctl enable --now. Le servicewg-quick@wg-mail0estType=oneshot+RemainAfterExit=yes: systemd le considérait toujoursactive (exited)après ledownmanuel (il ne surveille pas l'existence réelle de l'interface), donc lestartqui suivait était un no-op silencieux —wg showéchouait ensuite avecUnable to access interface: No such device, sans aucune erreur visible côtésystemctl start. Résolu avecsystemctl stop(qui exécute vraimentwg-quick downet remet l'état àinactive) suivi d'unsystemctl startpropre. Diagnostic de piste fausse au passage : un messageFailed to allocate directory watch: Too many open files(inotify) est apparu en parallèle et a fait perdre du temps — bénin, déjà vu et documenté sans rapport avec WireGuard lors du premier déploiement du VPN admin (voir section précédente).
Validé en conditions réelles : sudo wg show wg-mail0 côté VPS confirme un handshake réel avec le poste de dev, trafic bidirectionnel effectif (244 B reçus / 92 B envoyés), pas seulement une interface montée sans pair actif.
Relais mail VPS — Postfix, DNS, bout-en-bout validé dans les deux sens (2026-08-24)¶
Suite directe du tunnel WireGuard ci-dessus, jusqu'au bout du plan (roadmap Phase 4, "Relais mail VPS").
Joignabilité Mailu:25 depuis le tunnel — port-forward permanent, pas hostPort (scripts/mailu-portforward-setup.sh, mail-relay-vps/README.md) : décision explicite de ne pas réactiver front.hostPort (désactivé exprès à cause d'un deadlock de rollout, voir plus haut) plutôt que de recréer une troisième fois le cluster de dev (nouveau mapping de port k3d nécessaire) pour une exposition qui sera de toute façon reconstruite proprement à la migration mini PC (k3s réel, hostPort natif). Service systemd (kubectl port-forward --address 10.100.1.2) sur la machine "maison", Requires=wg-quick@wg-mail0.service. Validé en conditions réelles : le VPS reçoit une vraie bannière SMTP Mailu (220 mailu.offsystem.fr ESMTP ready) à travers le tunnel.
Postfix sur le VPS (scripts/postfix-relay-vps-setup.sh, mail-relay-vps/postfix/transport) : relais "bête", aucune signature DKIM ici (Rspamd signe déjà côté Mailu). relay_domains=offsystem.fr routé par transport_maps directement vers 10.100.1.2:25 (pas de lookup MX) ; mydestination exclut délibérément offsystem.fr pour éviter une tentative de livraison locale ; mynetworks inclut le sous-réseau du tunnel (10.100.1.0/24) — l'authentification, c'est le tunnel WireGuard lui-même, pas de SMTP AUTH nécessaire pour le relais sortant de Mailu. Test interne validé avant tout DNS public : mail envoyé via sendmail sur le VPS vers admin@offsystem.fr, confirmé physiquement présent dans le Maildir Dovecot (pas seulement accepté en file).
Clé publique DKIM récupérée sans jamais manipuler la clé privée : celle-ci vit dans /dkim/offsystem.fr.dkim.key (conteneur mailu-admin), aucun openssl disponible dans ce conteneur — la fonction strip_key() du propre code Mailu (mailu/dkim.py, déjà utilisée par l'admin UI) a été invoquée directement via python3 -c pour n'extraire que la clé publique, jamais imprimée en clair par ailleurs.
DNS chez Gandi (scripts/gandi-mail-dns-setup.sh) : A (mail.offsystem.fr → IP VPS), MX (remplace les MX Gandi par défaut), SPF (v=spf1 ip4:<IP VPS> ~all, remplace le SPF Gandi), DKIM (clé réelle ci-dessus), DMARC (p=none, monitoring seul). Déchiffrement du token Gandi bloqué pour Claude Code par le classifieur du mode auto (même limite déjà rencontrée sur la clé privée WireGuard) — script préparé mais lancé par l'utilisateur lui-même, le déchiffrement SOPS se faisant dans son propre shell. Confirmé propagé et cohérent sur deux résolveurs externes indépendants (8.8.8.8, 1.1.1.1) avant tout test d'envoi réel.
PTR (reverse DNS) côté OVH : pas d'API OVH configurée dans ce projet — posé manuellement par l'utilisateur dans le manager OVHcloud (mail.offsystem.fr.), confirmé quasi instantané (contrairement à la propagation DNS classique de Gandi) via dig -x.
Test externe entrant réel validé : un vrai mail envoyé depuis chartier.ro@gmail.com vers admin@offsystem.fr — reçu par Postfix VPS (mail-lj1-f181.google.com, ESMTPS/STARTTLS), relayé via le tunnel, livré par LMTP dans le vrai Maildir Dovecot, en-têtes complets vérifiés (chaîne Gmail → VPS → Mailu → Dovecot), pas seulement un statut sent pris pour argent comptant.
Relais sortant Mailu → VPS configuré (gitops/apps/mailu.yaml, externalRelay.host: "10.100.1.1:25") : sans ça, Mailu tenterait une livraison directe depuis l'IP de la machine de dev (ni PTR ni réputation, et beaucoup de FAI résidentiels bloquent le port 25 sortant). Pas de nom d'utilisateur/mot de passe nécessaire — confirmé via helm template que le chart ne rend RELAYHOST/RELAYNETS qu'à partir de host/networks, sans exiger d'identifiants. Vrai bug de rollout trouvé en redémarrant mailu-postfix (même classe que le deadlock front/LiveKit, mais cette fois un verrou de PID plutôt qu'un port) : postfix/postlog: fatal: the Postfix mail system is already running en boucle — l'ancien et le nouveau pod du RollingUpdate par défaut se sont brièvement partagé le même volume de spool Postfix (persistence single_pvc: true) le temps de la bascule, sur ce cluster mono-nœud. Contourné en scale-to-zero puis scale-to-one (équivalent d'un Recreate ponctuel) plutôt qu'un rollout restart classique ; à généraliser en deploymentStrategy: Recreate pour postfix si ce redémarrage doit se refaire souvent.
Vrai bug de délivrabilité trouvé au premier test sortant réel : Gmail a rejeté le premier envoi (550-5.7.26 ... SPF ... did not pass), le VPS ayant choisi d'émettre en IPv6 natif (2001:41d0:404:200::8b58, adresse OVH réelle) alors que le SPF posé plus haut n'autorise que l'IPv4 — confirmé dans le log de bounce Postfix, pas une supposition. Corrigé avec smtp_address_preference = ipv4 (cohérent avec le choix déjà fait ailleurs dans ce projet de différer un vrai chantier IPv6 structurel plutôt que de le résoudre service par service). Test externe sortant réel validé après correction : mail envoyé depuis admin@offsystem.fr vers chartier.ro@gmail.com, accepté par la vraie adresse IPv4 de Gmail (250 2.0.0 OK), confirmé reçu en boîte de réception, pas en spam — bon signal de réputation dès le premier envoi réel sur une IP neuve.
Écart de synchronisation ArgoCD trouvé en testant externalRelay en direct : gitops/bootstrap/root-app.yaml déclare toujours automated: {selfHeal: true} en clair — alors que CLAUDE.md documente une décision explicite (2026-08-15) de garder root en sync manuel. root-app.yaml fait partie de la couche bootstrap, volontairement hors gestion GitOps (jamais resynchronisée automatiquement) : ce fichier n'a probablement jamais été corrigé après cette décision, ou un kubectl patch de test précédent n'a jamais été rendu permanent. Conséquence concrète rencontrée : patcher syncPolicy sur l'Application mailu seule ne suffisait pas à empêcher un retour en arrière — root, toujours en selfHeal, réaffirmait le spec de mailu (y compris son syncPolicy) à chaque reconciliation, contournant le patch. Les deux ont dû être désactivés (root d'abord, puis mailu) pour tester externalRelay en direct avant de committer. Pas encore résolu : root-app.yaml devrait être corrigé pour matcher l'intention documentée, et réappliqué manuellement (hors du cycle GitOps normal) — pas fait dans cette session, hors périmètre de ce chantier mail.
Pas encore fait : réactivation du syncPolicy sur mailu une fois PR #127 (deploymentStrategy: Recreate sur mailu-postfix) mergée, correction de gitops/bootstrap/root-app.yaml (écart ci-dessus), migration Mailu + de ce tunnel vers le mini PC, période de suivi de délivrabilité soutenue, migration progressive des correspondants — suite du roadmap Phase 4.
Deux pannes préexistantes du cluster de dev trouvées et corrigées en mergant la PR mail (2026-08-24)¶
Réactiver root/mailu (ci-dessus) a fait ressortir deux pannes silencieuses, ni l'une ni l'autre causée par le chantier mail — l'utilisateur avait remarqué des crashs sur livekit-sfu et tuwunel et soupçonnait à raison les changements de server_name/URLs de la veille (2026-08-23).
livekit-sfu : le sync ArgoCD échouait depuis des jours (message d'erreur reproduit tel quel : Deployment.apps "livekit-sfu" is invalid: spec.strategy.rollingUpdate: Forbidden: may not be specified when strategy type is 'Recreate' (retried 5 times)). Cause : un champ rollingUpdate résiduel restait sur l'objet Deployment réellement déployé depuis avant le passage à Recreate (fix du 2026-08-22, voir plus haut) — un merge-patch stratégique n'efface pas un champ non listé dans le nouveau manifeste, donc chaque tentative de sync produisait un objet invalide (rollingUpdate et type: Recreate simultanément, rejeté par l'API server). Résultat concret : le pod tournait depuis longtemps sur un template antérieur à ce fix, sans montage du certificat TURN (TURN tls cert required: open : no such file or directory) — le secret livekit-turn-tls existait pourtant bien (généré par le CronJob livekit-turn-cert-sync, qui tournait normalement), simplement jamais monté dans le pod. Corrigé avec un patch JSON ponctuel (suppression du champ rollingUpdate et remplacement de type en une seule opération atomique — en deux étapes séparées ça ne fonctionne pas, Kubernetes réinjecte les valeurs par défaut de rollingUpdate tant que type reste RollingUpdate), puis un sync manuel forcé (argocd app sync livekit --core — mode direct API Kubernetes, sans connexion au serveur ArgoCD ; nécessite kubectl config set-context --current --namespace=argocd sinon erreur configmap argocd-cm not found). Validé en direct : nouveau pod 1/1 Running, logs confirmant Starting TURN server et le bon montage (lkturncert visible dans les volumes du Deployment).
tuwunel : confirmé exactement l'hypothèse de l'utilisateur. Database belongs to myown-tuwunel.local; configured server name is offsystem.fr. Cannot reuse. — la base RocksDB du cluster de dev avait été créée avec l'ancienne identité (myown-tuwunel.local, Phase 3), jamais remise à zéro quand server_name est passé à offsystem.fr (migration du 2026-08-23, voir plus haut) : cette remise à zéro n'avait été rejouée que sur l'installation réelle, pas sur le cluster de dev, qui suit pourtant master automatiquement (root en selfHeal) et a donc reçu la nouvelle config sans jamais recevoir l'opération manuelle de purge de données qui va avec. Corrigé en reproduisant exactement la même recette déjà utilisée pour la vraie migration : kubectl create job --from=cronjob/tuwunel-restic-backup pour un backup à chaud (confirmé via restic snapshots — nouveau snapshot réel, 169 fichiers, 6.039 MiB, distinct des anciens), scale à 0, purge de /data sur la PVC tuwunel-data via un pod jetable, scale à 1. Le classifieur du mode auto a bloqué Claude Code sur la commande rm -rf dans le pod jetable (même limite déjà rencontrée sur le déchiffrement de secrets) — geste réel mais destructif, transmis à l'utilisateur pour exécution directe plutôt que de tenter un contournement. Validé en direct : nouvelle base RocksDB créée avec server_name=offsystem.fr dès le premier démarrage, écoute confirmée sur :8008, aucune erreur.
Les deux corrections sont strictement cantonnées au cluster de dev : aucun contexte kubectl vers le mini PC n'existe sur cette machine (kubectl config get-contexts ne liste que k3d-myown-dev et un cluster sans rapport), et les PVC/Deployments touchés sont physiquement séparés de ceux du mini PC — aucune action de cette investigation n'a pu affecter l'installation réelle.
Troisième panne trouvée, délibérément non corrigée sur décision de l'utilisateur : jellyfin reste Degraded sur dev, mais pour une raison différente et plus ancienne (bloqué depuis le bootstrap du mini PC du 2026-08-20, pas "hier"). Sa révision Deployment courante (5) référence un volume hostPath (/var/lib/rancher/k3s/storage/myown/nextcloud-data/data/__groupfolders/1) pointant vers un "group folder" Nextcloud — fonctionnalité créée via l'UI admin, donc jamais répliquée automatiquement entre l'instance Nextcloud du mini PC (où ce dossier existe manifestement) et celle, séparée, du cluster de dev (où __groupfolders/ n'existe même pas sur le disque, confirmé via kubectl debug node). Kubernetes garde intentionnellement vivant le pod de la révision précédente (fonctionnel, Jellyfin répond normalement sur dev) plutôt que de couper le service — seul le badge de santé ArgoCD est trompeur. Décision explicite de l'utilisateur : ne pas toucher — le correctif vivrait dans gitops/apps/jellyfin.yaml, fichier partagé verbatim entre dev et le mini PC ; le modifier pour dev risquerait de casser un montage qui fonctionne réellement en production, pour un gain purement cosmétique côté dev. Laissé comme un gap connu et tracé (voir CLAUDE.md), à reprendre soit en recréant un group folder équivalent dans le Nextcloud de dev, soit en rendant ce chemin spécifique au cluster plutôt que partagé, le jour où la fidélité du cluster de dev sur ce point redevient nécessaire.
Sauvegarde Restic Mailu — fait et validée (2026-08-24)¶
Ferme le dernier trou de sauvegarde connu pour Mailu. Contrairement à Nextcloud/Immich/Authentik, pas de base séparée à pg_dump : persistence.single_pvc: true met tout (base SQLite de l'admin, clé privée DKIM, maildirs Dovecot, état rspamd, config webmail) sur une seule PVC (mailu-storage) — sauvegardée en une passe, même forme que Vaultwarden/Tuwunel. RESTIC_PASSWORD ajouté au secret mailu-secrets existant via sops --set (jamais visible en clair par Claude Code, même limite déjà rencontrée deux fois ce jour-là). Nouvelle Application source gitops/manifests/mailu-backup/, planifiée à 04:30 (entre Authentik 04:00 et Jellyfin 04:15).
Le même bug de rollingUpdate résiduel bloquant Recreate (voir plus haut, LiveKit) est réapparu une deuxième fois sur mailu-postfix, cette fois en bloquant le sync de cette PR pourtant sans rapport avec Postfix — confirmation que ce n'est pas un incident isolé mais un vrai risque récurrent à chaque fois que le live cluster garde un état antérieur au passage RollingUpdate → Recreate. Recorrigé avec le même patch JSON atomique. Piste pour éviter ça durablement, pas encore posée : syncOptions: [Replace=true] sur cette ressource forcerait un remplacement complet plutôt qu'un merge-patch stratégique, éliminant la classe de bug entière au lieu de la re-corriger à chaque fois qu'elle resurgit.
Deuxième mécanisme découvert en cherchant pourquoi les nouvelles ressources (CronJob + PVC) n'apparaissaient pas après un argocd app sync mailu --core pourtant "réussi" : mailu ne se resynchronise pas tout seul avec le contenu à jour de gitops/apps/mailu.yaml — c'est root (app-of-apps) qui détient et met à jour le spec de l'objet Application mailu lui-même (dont sa liste de sources). Syncer mailu sans d'abord syncer root ne fait que réconcilier les ressources déjà déclarées dans le spec actuellement en mémoire de mailu, potentiellement périmé. Ordre correct, confirmé en direct : argocd app sync root --core (rafraîchit le spec de mailu, y compris la nouvelle source) puis argocd app sync mailu --core (applique enfin les nouvelles ressources).
Validé en conditions réelles, backup et restauration tous les deux : backup manuel déclenché (kubectl create job --from=cronjob/...), snapshot réel confirmé (85.266 MiB, pas un backup vide). Restauration testée dans un pod jetable — premier essai raté (/repo monté en lecture seule, restic restore a besoin d'écrire un verrou, boucle infinie de retries avec ce message précis : Save(<lock/...>) returned error ... read-only file system, à surveiller si ça se reproduit ailleurs), corrigé en remontant /repo en lecture-écriture (seul /data, la source, doit rester lecture seule). Contenu restauré vérifié nommément : admin/main.db (base SQLite réelle, 90 Ko), dkim/offsystem.fr.dkim.key, et les vrais mails de test envoyés/reçus plus tôt dans cette même session, retrouvés sous leur nom de fichier exact dans dovecotmail/admin@offsystem.fr/new/ — pas une supposition, une correspondance directe avec les tests d'envoi/réception déjà documentés plus haut.
Migration de fichiers personnels vers Nextcloud par WebDAV — deux plafonds d'upload trouvés et corrigés (2026-08-24)¶
Première vraie utilisation de Nextcloud comme cible d'un gros transfert (bibliothèque vidéo/musique + fichiers personnels, ~42 Gio, ~1360 fichiers) depuis le PC de l'auteur, via rclone en webdav (vendor=nextcloud, authentifié par mot de passe d'application — le compte SSO refuse l'auth par mot de passe local, même mécanisme déjà rencontré pour CalDAV). Deux plafonds distincts, tous deux invisibles tant qu'aucun fichier de plus de ~1 Gio n'avait jamais été envoyé.
1. APACHE_BODY_LIMIT (image Nextcloud officielle) plafonné à 1 Gio par défaut. Tout fichier au-dessus échouait avec un 413 Request Entity Too Large généré par Apache lui-même (page HTML de marque Apache, pas une erreur Nextcloud), avec 0 octet reçu côté serveur d'après le message SabreDAV — confirmé qu'aucune donnée n'avait été lue. upload_max_filesize/post_max_size de PHP (512M) ne sont pas en cause : un fichier de 736 Mio, déjà au-dessus, était passé sans problème, puisque WebDAV PUT lit php://input en flux, jamais $_FILES. Cause confirmée en direct par kubectl exec dans le pod (grep LimitRequestBody /etc/apache2/conf-available/apache-limits.conf → ${APACHE_BODY_LIMIT}, puis env → 1073741824, exactement 1 Gio). Fix : APACHE_BODY_LIMIT=0 (illimité, comportement documenté par l'image elle-même) via extraEnv sur le Deployment (gitops/apps/nextcloud.yaml). Testé en direct (syncPolicy désactivé le temps du test, comme d'habitude) avant d'être committé — PR #132.
2. readTimeout par défaut de Traefik (60s) sur l'entrypoint websecure. Une fois le plafond Apache levé, deux fichiers de plusieurs Gio (7,76 et 5,65 Gio, 90-140s à uploader au débit LAN réel) échouaient encore, de façon répétée, avec un 502 Bad Gateway ou un http2: server sent GOAWAY. Cause confirmée sans ambiguïté via l'access log JSON de Traefik (logs.access déjà activé pour l'investigation LiveKit) : "Duration":60000094689 et "OriginDuration":60000021876 (nanosecondes) — exactement 60,000s, sur deux fichiers différents indépendamment, quel que soit le débit réel observé (~55 Mio/s, stable). Aucun timeout n'était configuré explicitement nulle part dans le repo (vérifié dans les args du conteneur et dans le HelmChartConfig) — c'est la valeur par défaut de Traefik. Pas spécifique à Nextcloud : c'est l'entrypoint HTTPS partagé par tous les services du cluster. Fix : ports.websecure.transport.respondingTimeouts.readTimeout: 0s ajouté à l'overlay bootstrap existant (gitops/bootstrap/traefik-acme-helmchartconfig.yaml, appliqué manuellement par kubectl apply, hors ArgoCD comme le reste de ce fichier) — PR #133.
Incident distinct repéré au passage, à ne pas confondre avec le bug ci-dessus : au moment du deuxième échec, le pod Traefik s'est avéré n'avoir que quelques minutes d'âge — une session Claude Code distincte travaillait au même moment sur Mailu, sur ce même mini PC, et a déclenché deux redéploiements de Traefik en 22 secondes (confirmé dans les logs k3s : helm-controller réappliquant le HelmChart Traefik). Ressources vérifiées largement disponibles (RAM, disque, CPU) au moment des faits — ce n'était pas une question de contention, juste deux opérations lourdes se chevauchant. Les deux sessions partageaient le même clone git sur disque : git status affichait les fichiers modifiés-non-committés de l'autre session (gitops/manifests/mailu-cert-sync/cronjob.yaml, wireguard-mail-relay/wg-mail0.conf.sops.yaml) — jamais indexés ni touchés (git add <fichier> nommé explicitement, jamais git add -A), pour ne pas interférer avec son travail en cours.
Validation finale, en trois temps : rclone check sur Films et Musique confirme 0 differences found (101 et 927 fichiers, comparaison par checksum). Le check sur "Personnel" a d'abord remonté ~1700 différences — fausse alerte, pas un bug : l'utilisateur avait entre-temps réorganisé lui-même l'arborescence via l'interface web Nextcloud (déplacement des dossiers migrés dans Perso/ et Kate&Ro/) pendant que d'autres sujets étaient traités en parallèle dans cette même session. Confirmé fichier par fichier via rclone lsf -R que rien n'avait été perdu, juste déplacé.
Migration de Mailu (et du tunnel relais) du cluster de dev vers le mini PC — faite et validée avec de vrais mails (2026-08-24)¶
Dernière étape du chantier mail de Phase 4 : root du mini PC était déjà re-pinné sur v0.1.10 mais délibérément pas synchronisé (voir l'entrée précédente) — cette session fait la bascule réelle. Session concurrente avec celle qui travaillait sur la migration Nextcloud/rclone (voir l'entrée juste au-dessus) : même dépôt git sur disque, aucune interférence (fichiers de l'une jamais indexés par l'autre), mais un fichier bootstrap (traefik-acme-helmchartconfig.yaml) a fini par porter les deux correctifs dans le même commit malgré tout — les deux kubectl apply en direct ont touché le même fichier au même moment, et l'git add explicite de l'autre session a embarqué le changement déjà présent sur disque. Sans conséquence fonctionnelle, juste un rattachement de commit un peu trompeur à noter.
1. Tunnel wireguard-mail-relay étendu au mini PC comme nouveau pair (10.100.1.3), sans jamais couper le pair dev-machine existant (relais mail réel toujours actif dessus au moment des faits). scripts/wireguard-mail-relay-setup.sh --peer mini-pc a bien écrit le nouveau pair côté VPS, mais l'interface wg-mail0 y était déjà active (systemctl enable --now ne recharge pas une unité déjà démarrée) — le nouveau pair restait absent de l'état noyau tant qu'aucun rechargement n'était fait. wg syncconf (rechargement à chaud, sans couper les pairs existants) a d'abord échoué avec fopen: Permission denied : le profil AppArmor wg d'Ubuntu 26.04 (/etc/apparmor.d/wg) restreint tout accès fichier de l'exécutable wg à /etc/wireguard/** exclusivement — ni /tmp, ni /root ne sont autorisés, même en root via sudo. Contourné en écrivant la config extraite (wg-quick strip) directement dans /etc/wireguard/wg-mail0-sync.conf avant de la donner à wg syncconf, puis suppression immédiate. Handshake toujours pas établi ensuite côté mini PC (0 octet reçu) malgré une config correcte des deux côtés — résolu par un simple wg-quick down && wg-quick up côté client (mini PC), sans qu'une cause précise à cet état transitoire n'ait été identifiée.
2. argocd CLI absent du mini PC (jamais installé là — seul le kubectl local suffisait jusqu'ici) : installé en un binaire statique officiel, même version que celle du poste de dev (v3.4.4), pour reproduire exactement la recette déjà documentée (argocd app sync root --core puis argocd app sync mailu --core, dans cet ordre — root détient le spec à jour de l'Application mailu, cf. l'entrée de sauvegarde Mailu plus haut). root synchronisé sans incident : diff vérifié au préalable (git diff v0.1.9 v0.1.10 -- gitops/apps/) et confirmé strictement scopé à un bump de targetRevision à contenu identique sur les apps existantes, plus les deux nouvelles apps mailu/mailu-cert-sync — pas de risque de régression sur les 9 autres services déjà en prod.
3. mailu-front bloqué en ContainerCreating (secret "mailu-certificates" not found) : le CronJob mailu-cert-sync n'avait jamais tourné sur ce cluster tout neuf. Son premier déclenchement manuel a révélé un vrai bug de portabilité, pas propre au mini PC : le filtre jq du CronJob cherchait un certificat individuel mailu.offsystem.fr dans acme.json (comportement confirmé sur dev à l'écriture du CronJob) — mais sur le mini PC, Traefik (3.7.8, contre 3.6.13 sur dev) réutilise le wildcard *.offsystem.fr déjà en stock pour l'émission à la demande au lieu de redemander un certificat individuel redondant, donc aucune entrée mailu.offsystem.fr n'apparaît jamais dans son acme.json (confirmé en comparant un dump direct des deux acme.json). Corrigé en ajoutant un repli sur le certificat wildcard (filtré par SAN, pas par domain.main, puisque l'entrée wildcard a pour main l'apex offsystem.fr) quand aucun certificat individuel n'existe — le CronJob fonctionne désormais sous les deux comportements plutôt que d'en supposer un seul. Correctif testé en direct après avoir dû désactiver temporairement le syncPolicy de l'Application mailu-cert-sync (selfHeal réappliquait sinon la version non corrigée à chaque tentative — même friction déjà documentée plusieurs fois dans ce fichier).
4. Route mailu-outpost-callback (mailu → authentik, inter-namespaces) rejetée par Traefik sur le mini PC (service authentik/authentik-server not in the parent resource namespace mailu), alors qu'elle fonctionne sur dev sans qu'aucun flag allowCrossNamespace ne soit déclaré nulle part dans le dépôt. Confirmé que ce comportement croisé dépend d'un défaut non documenté qui diffère entre les deux versions de Traefik du cluster (mêmes 3.7.8/3.6.13 que ci-dessus). Corrigé en ajoutant explicitement providers.kubernetesCRD.allowCrossNamespace: true à gitops/bootstrap/traefik-acme-helmchartconfig.yaml (appliqué manuellement, hors ArgoCD comme le reste de ce fichier) — supprime la dépendance à un défaut de version plutôt que de la contourner une fois de plus.
5. mailu-admin répondait mais sa base SQLite n'avait jamais été migrée (sqlite3.OperationalError: no such table: domain, en boucle sur /internal/rspamd/local_domains, bloquant rspamd dont la readiness probe dépend d'un mailu-admin fonctionnel — "Admin is not up just yet, retrying" en boucle côté rspamd). Cause trouvée dans les tout premiers logs du pod mailu-admin : sa commande CLI d'initialisation (nettoyage de sessions, dépend de Redis) a échoué au tout premier démarrage avec redis.exceptions.ConnectionError: ... Connection refused — mailu-redis-master n'était pas encore prêt à ce moment précis (course au démarrage sur un cluster tout neuf, tous les pods créés simultanément) — et le conteneur a quand même démarré son serveur HTTP malgré l'échec de cette étape, sans jamais réessayer la migration Alembic derrière. Résolu par un simple redémarrage du pod mailu-admin une fois Redis effectivement prêt : les migrations Alembic (une trentaine de révisions) et la création du compte admin se sont alors déroulées normalement (created admin user), rspamd est devenu Ready dans la foulée.
6. Port-forward wg-mail0 → mailu-front:25 : scripts/mailu-portforward-setup.sh réutilisé tel quel avec MYOWN_MAILU_PF_TUNNEL_ADDR=10.100.1.3 (l'adresse du mini PC sur ce tunnel, au lieu du 10.100.1.2 par défaut du poste de dev) — front.hostPort restant délibérément désactivé (valeur partagée entre dev et mini PC dans gitops/apps/mailu.yaml, pas encore reconsidérée malgré le fait que le mini PC, en k3s bare-metal réel, n'aurait plus besoin de ce palliatif). Joignabilité confirmée depuis le VPS (nc -zv 10.100.1.3 25) avant tout changement côté relais.
7. SSO validé structurellement (pas via un vrai login navigateur — voir plus bas) : l'outil de navigateur intégré à cette session est resté injoignable (timeouts de 300s répétés sur navigate/preview_start, cause non identifiée côté extension). Validation faite entièrement en HTTP direct (curl --resolve, en contournant une entrée /etc/hosts locale périmée du poste de dev — 127.0.0.1 mailu.offsystem.fr/authentik.offsystem.fr, un reliquat des tests SSO sur le cluster de dev qui a d'abord fait pointer une première vérification vers le mauvais cluster ; non nettoyée, à garder en tête pour une prochaine session sur cette machine). Un premier essai renvoyait un 404 générique estampillé authentik au lieu d'une redirection OAuth — cause trouvée via ak shell : l'Application/Provider mailu existaient bien (créés par le blueprint), mais l'Embedded Outpost n'avait aucun provider lié (o.providers.all() vide). C'est voulu par construction (commentaire du blueprint : liaison additive via scripts/authentik-outpost-config.sh --bind-provider mailu, jamais automatique dans le blueprint pour ne pas écraser d'autres liaisons futures) — mais cette étape n'avait simplement jamais été exécutée sur l'instance Authentik du mini PC (base séparée de celle de dev). Exécutée en direct (authentik_host_browser posé + provider mailu lié à l'outpost) : la requête suivante donne un vrai 302 vers authentik.offsystem.fr/application/o/authorize/... avec cookie de session posé, et le follow de cette redirection atterrit sur une vraie page <flow-executor> (le formulaire de connexion réel d'Authentik). Arrêté volontairement à ce stade : entrer un mot de passe pour de vrai reste interdit à Claude Code par principe, quelle que soit l'autorisation — connexion réelle à faire par l'utilisateur.
8. Bascule réelle du relais VPS validée avec de vrais mails dans les deux sens, après confirmation explicite de l'utilisateur avant de toucher au flux de production : mail-relay-vps/postfix/transport retargeté de 10.100.1.2 vers 10.100.1.3, réappliqué via scripts/postfix-relay-vps-setup.sh. Entrant : mail envoyé depuis le VPS (sendmail) vers admin@offsystem.fr, log Postfix confirmant relay=10.100.1.3[10.100.1.3]:25 ... status=sent, retrouvé nommément dans le maildir Dovecot du mini PC (/mail/admin@offsystem.fr/new/) quelques secondes plus tard. Sortant : mail envoyé depuis mailu-postfix (mini PC) vers chartier.ro@gmail.com, relayé via 10.100.1.1 (le VPS) puis livré à Gmail avec un vrai 250 2.0.0 OK en réponse — confirmé côté VPS (/var/log/mail.log), pas juste supposé accepté en file.
Pas encore fait : correction de gitops/bootstrap/root-app.yaml (écart selfHeal déjà documenté plus haut, toujours pas traité), décommissionnement du cluster de dev pour Mailu (pair WireGuard dev-machine et port-forward associés — laissés en place pour l'instant, aucun risque tant que le relais VPS ne pointe plus dessus), rcmcarddav → contacts Nextcloud, période de suivi de délivrabilité soutenue, migration progressive des correspondants. Les items marqués "pas encore fait" dans une version précédente de cette entrée (réactivation du syncPolicy de mailu-cert-sync, nettoyage /etc/hosts, connexion SSO réelle) sont maintenant faits — voir l'entrée suivante pour le détail, riche en vrais rebondissements.
Première connexion SSO réelle à Mailu — un faux "compte perdu", deux vrais bugs trouvés (2026-08-24)¶
Le test de connexion SSO réelle sur mailu.offsystem.fr (laissé en suspens à la fin de la migration ci-dessus, Claude Code n'entrant jamais de mot de passe par principe) a débouché sur une session de dépannage bien plus longue que prévu, avec un faux problème et deux vrais.
Faux problème, mais qui a fait perdre beaucoup de temps : l'utilisateur a signalé avoir "perdu" le mot de passe de son compte Authentik personnel, la récupération de mot de passe échouant avec "aucun compte ne correspond". Vérification directe en base sur le mini PC : le compte robin existait bien, actif, dernière connexion récente — pas de perte réelle. Plusieurs liens de récupération générés manuellement via le mécanisme officiel d'Authentik (_create_recovery_link, reproduit via ak shell — jamais de mot de passe vu ou choisi) ont tous échoué avec le même message, y compris un format d'URL alternatif validé comme fonctionnel via un appel direct à l'API (django.test.Client, sans passer par le frontend). Cause réelle, trouvée après beaucoup de fausses pistes : une entrée /etc/hosts périmée sur le poste de dev (127.0.0.1 mailu.offsystem.fr / authentik.offsystem.fr), déjà repérée et documentée plus haut dans ce fichier pendant la migration elle-même mais jamais nettoyée — et surtout, jamais reliée au fait que l'utilisateur teste depuis ce même poste. Résultat : chaque tentative (y compris les liens de récupération, générés correctement pour le mini PC) atterrissait sur l'Authentik du cluster de dev — qui a sa propre base séparée (juste akadmin + le compte de service outpost, aucun robin), mais qui applique les mêmes blueprints que la prod puisque son root suit master en continu (selfHeal) — d'où un flow myown-recovery-flow bien présent mais sans le moindre FlowToken valide dessus, donnant exactement le même message d'erreur qu'un compte réellement introuvable. Confirmé avec nslookup par l'utilisateur (authentik.offsystem.fr → 127.0.0.1), corrigé avec un sed -i sur /etc/hosts (nécessitait un sudo interactif, non disponible pour Claude Code sur cette machine — commande transmise à l'utilisateur).
Vrai bug n°1, trouvé en creusant le faux problème avant de trouver la vraie cause : le flow myown-recovery-flow (blueprint du 2026-08-22, voir plus haut) n'a aucune étape d'identification — seulement les stages default-password-change-prompt/-write réutilisés du flow de changement de mot de passe. Il n'a jamais été conçu ni testé pour du self-service : la case "j'ai oublié mon mot de passe" sur l'écran de connexion redirige bien vers ce flow, mais sans qu'aucun utilisateur ne soit identifié (PLAN_CONTEXT_PENDING_USER absent du contexte), le UserWriteStage (configuré en never_create) échoue avec exactement le message vu par l'utilisateur — confirmé en lisant le code source du stage directement dans le conteneur (/authentik/stages/user_write/stage.py). Le mécanisme n'a en réalité été validé que dans un seul sens : un admin génère le lien pour un utilisateur déjà ciblé (cas de Katia, 2026-08-22) — jamais le self-service réel depuis la page de connexion. Pas encore corrigé — nécessiterait d'ajouter une vraie étape d'identification (recherche par username/email) au flow, ou plus probablement de retirer le lien "mot de passe oublié" de la page de connexion si le self-service n'est pas censé être supporté, pour éviter ce message trompeur à l'avenir.
Vrai bug n°2, trouvé une fois la connexion SSO effective : première tentative de connexion au webmail avec succès côté Authentik (compte chartier.ro@gmail.com), mais échec côté Mailu avec Internal Server Error — You don't exist. Go away! (domain=gmail.com). Cause confirmée en lisant /app/mailu/sso/views/base.py dans le conteneur mailu-admin : le ForwardAuth de Mailu utilise l'email brut du header X-authentik-email comme identité directe (models.User.get(email)), contrairement à tous les autres services de ce projet qui lient l'identité par sub/preferred_username (Vaultwarden via (issuer, sub), Tuwunel via userid_claims, Nextcloud via un ID opaque dérivé du sub) — Mailu est le seul à dépendre directement de la valeur de l'email. Comme l'email Authentik de l'utilisateur était son adresse Gmail personnelle (domaine non géré par Mailu), la vérification échouait. Découverte utile en creusant ce bug : proxyAuth.create: "true" (déjà activé dans gitops/apps/mailu.yaml) fait auto-provisionner une boîte au premier login SSO — exactement comme user_oidc pour Nextcloud — dès lors que le domaine de l'email correspond à un domaine Mailu existant (models.Domain.query.get(desireddomain)), avec un mot de passe interne aléatoire jamais utilisé nativement (login toujours via ForwardAuth). Décision de l'utilisateur, pas un simple contournement technique : plutôt que d'ajouter un mapping de claim spécifique à Mailu (option initialement proposée par Claude Code, qui avait surestimé à tort l'impact d'un changement d'email — vérification a posteriori : aucun autre service de ce projet ne lie l'identité par email, contrairement à ce qui avait été annoncé), l'email Authentik du compte a été changé pour la véritable adresse professionnelle visée (robin.chartier@offsystem.fr) — cohérent avec la vision du projet où chaque membre de la famille aura à terme une identité @offsystem.fr. Mailu a auto-créé le compte correspondant dès la requête suivante, confirmé par une simulation directe de la requête ForwardAuth (curl avec le header X-authentik-email depuis un pod jetable, contournant complètement le navigateur) avant même que l'utilisateur ne retente — 302 propre vers le webmail, cookie de session posé, compte visible en base juste après. Un dernier blocage (session Authentik déjà en cache côté navigateur, portant encore l'ancien email) a nécessité une déconnexion/reconnexion complète. Connexion webmail réelle confirmée par l'utilisateur.
Pas encore fait à la clôture de cette entrée : le vrai bug n°1 (flow de récupération sans étape d'identification) restait ouvert — corrigé le jour même, voir l'entrée suivante. Pas de décision prise sur l'email de Kate (même question se posera). L'entrée /etc/hosts du poste de dev est maintenant propre.
Récupération de compte reconstruite avec identification + TOTP (2026-08-24)¶
Corrige le vrai bug n°1 de l'entrée précédente : le flow myown-recovery-flow n'avait aucune étape d'identification, ne fonctionnait que via un lien admin-généré. Décision explicite de l'utilisateur sur l'approche : pas de vérification par email (dépendance circulaire pointée du doigt à raison — récupérer l'accès à Authentik/Mailu par un email envoyé via... Mailu, lui-même derrière Authentik), mais un vrai TOTP (Google Authenticator/Aegis/Ente Auth), auto-hébergeable, sans dépendance externe, fonctionnel même si le reste de l'infra est en panne.
Reconstruction du flow : IdentificationStage (recherche par username/email, pretend_user_exists: true pour ne pas permettre l'énumération de comptes) → AuthenticatorValidateStage (device_classes: ["totp"], not_configured_action: deny — un compte sans TOTP est délibérément refusé plutôt que de laisser passer sans vérification, ce qui viderait le mécanisme de son sens) → les stages default-password-change-prompt/-write déjà existants.
Vrai risque de régression trouvé et corrigé avant de merger : sans précaution, cette reconstruction aurait aussi cassé le mécanisme de lien admin-généré existant (utilisé pour Katia, qui n'a pas de TOTP) — un tel lien planifie le flow avec PLAN_CONTEXT_PENDING_USER déjà positionné ; sans contournement, il aurait fallu en plus s'identifier et fournir un code TOTP que la personne ciblée n'a pas forcément. Corrigé avec une ExpressionPolicy (myown-recovery-skip-if-preidentified, return not bool(request.context.get("pending_user"))) liée aux deux nouvelles étapes avec evaluate_on_plan: true — le contexte de planification du flow (pas de la session HTTP) permet de distinguer un lien admin (pending_user déjà présent au moment du planner.plan() de _create_recovery_link) d'un vrai self-service. Validé par simulation directe avant tout test réel : plan pour Kate (lien admin, pas de TOTP) → [prompt, write] uniquement ; plan pour robin (lien admin, a un TOTP) → pareil, cohérent ; plan self-service (aucun utilisateur pré-identifié) → [identification, mfa-validation, prompt, write].
Effet de bord non anticipé, trouvé par l'utilisateur lui-même immédiatement après avoir activé son TOTP : default-authentication-flow (le flow de connexion normal, partagé par tous les services) avait déjà une étape AuthenticatorValidateStage liée, jusque-là silencieusement ignorée faute de tout appareil enregistré (not_configured_action=skip ne s'applique que quand aucun appareil n'existe — dès qu'un compte en a un, l'étape se déclenche systématiquement). Activer le TOTP pour la récupération a donc rendu le 2FA obligatoire sur toute connexion, à tous les services SSO — jamais l'intention. Corrigé en retirant purement et simplement cette liaison (state: absent dans un nouveau blueprint dédié, login-mfa-optional.sops.yaml — le stage lui-même n'est pas supprimé, juste sa liaison au flow de connexion, facilement réversible plus tard si toute la famille veut un jour du 2FA systématique).
Validation réelle complète, chaque étape confirmée séparément : self-service (identification → code TOTP → nouveau mot de passe) testé en vrai par l'utilisateur, connexion réussie confirmée. default-authentication-flow revérifié après coup ([identification, password, login], plus de MFA). Les deux blueprints testés en direct (syncPolicy d'authentik désactivé le temps du test, comme d'habitude) avant merge — PR #139, release v0.1.12.
rcmcarddav → contacts Nextcloud dans le webmail, un vrai faux positif de durcissement PHP trouvé (2026-08-24)¶
Chantier suivant du backlog Phase 4 : relier le carnet d'adresses du webmail Mailu (plugin carddav, déjà activé par défaut dans l'image, jamais configuré) à Nextcloud Contacts. Décision d'architecture prise avant de commencer : pas de preset admin global — chaque compte Nextcloud provisionné par user_oidc a un identifiant interne opaque (occ user:list confirme, ex. ee9952eac6e5fc9bf11721e8376b405aeb60ec2a843f88691e16bca411a06ecc pour robin, pas son email), donc un preset avec substitution %u/%p ne fonctionnerait pas correctement pour plusieurs personnes — et de toute façon %p n'a rien à substituer ici (le login Roundcube passe par ForwardAuth, pas par un mot de passe IMAP réel que le plugin pourrait réutiliser). Ajout en libre-service via Settings → CardDAV, comme pour le TOTP.
Premier vrai bug trouvé en vérifiant l'URL de découverte avant de la recommander : https://nextcloud.offsystem.fr/.well-known/carddav redirige vers http:// (pas https://) — la règle mod_rewrite de Nextcloud (.htaccess généré, cible relative /remote.php/dav/) construit une URL absolue sans connaître le HTTPS d'origine ; indépendant du réglage overwriteprotocol => 'https' de Nextcloud (qui ne couvre que les URLs générées par PHP, pas ce rewrite Apache). Deux contournements testés en direct et écartés (SetEnvIf X-Forwarded-Proto HTTPS=on, sans effet sur la construction d'URL de mod_rewrite ; Header always edit Location via mod_headers, confirmé chargé — un Header set de test apparaissait bien dans la réponse — mais l'édition de Location spécifiquement n'a jamais pris effet, cause non comprise). Contournement pragmatique : utiliser directement https://nextcloud.offsystem.fr/remote.php/dav/ (vérifié fonctionnel, vrai 401 avec challenge Basic Auth, aucune confusion de protocole). Bug réel laissé ouvert, flaggé en tâche séparée (affecterait toute app mobile Contacts/Calendrier faisant de la découverte RFC 6764 depuis le domaine nu).
Deuxième vrai bug, trouvé en cliquant "Enregistrer" pour de vrai : Erreur de serveur (error). Logs nginx/php-fpm : [snuffleupagus][disabled_function][drop] Aborted execution on call of the function 'curl_setopt' ... 'Please don't turn CURLOPT_SSL_VERIFYCLIENT off.'. Cause confirmée en lisant /etc/snuffleupagus.rules (durcissement PHP intégré à l'image mailu/webmail) : la règle bloque tout appel à curl_setopt touchant l'option 64 (SSL_VERIFYPEER) en se basant uniquement sur la constante d'option, jamais sur la valeur réellement passée — un faux positif qui bloque même le cas sûr (activer la vérification), exactement ce que fait systématiquement guzzlehttp, la librairie HTTP utilisée par rcmcarddav. Le fichier lui-même contenait déjà le bon patron à suivre : des exceptions ciblées par nom de fichier existent pour des faux positifs similaires (ini_get pour guzzle/managesieve/snappymail, lignes 93-95) — repris à l'identique pour curl_setopt, scopé au fichier exact de guzzle (CurlFactory.php), sans affaiblir la règle ailleurs.
Mécanisme de correctif durable trouvé en lisant /start.py (l'entrypoint du conteneur webmail) : /etc/snuffleupagus.rules est intégralement régénéré à chaque démarrage depuis /etc/snuffleupagus.rules.tpl (Jinja2) — un simple kubectl exec pour éditer le fichier vivant n'aurait pas survécu au prochain redémarrage. Pas de point d'extension officiel (/overrides du chart ne concerne que les fichiers .inc.php de Roundcube, pas les règles Snuffleupagus) — corrigé en montant une copie complète et corrigée du template par-dessus l'original via webmail.extraVolumes/extraVolumeMounts (existants dans le chart), un ConfigMap dédié (gitops/manifests/mailu-webmail-carddav-fix/). Étant une copie complète plutôt qu'un patch, nécessitera une resynchronisation manuelle si une future version du chart/image change ce fichier en amont — noté explicitement dans le manifeste.
Fausse piste avant de trouver la vraie cause du deuxième blocage ("Could not determine the addressbook home" après le fix Snuffleupagus) : les logs du plugin (RoundcubeLogger, censé écrire via rcube::write_log() avec log_driver = stdout) n'apparaissaient nulle part, ni dans /var/www/roundcube/logs/, ni dans les logs du pod — piste abandonnée après avoir confirmé, en lisant Discovery.php, que ce message précis n'est atteint qu'après l'échec de toutes les tentatives d'authentification lors de la découverte (findCurrentUserPrincipal retourne null sans lever d'exception en cas de 401), pointant directement vers l'identifiant/mot de passe plutôt qu'un souci réseau déjà écarté. Confirmé : l'utilisateur avait essayé son email comme identifiant CardDAV (pas garanti fonctionner avec user_oidc) — réessai avec l'identifiant opaque exact, connexion réussie du premier coup.
Validé en conditions réelles par l'utilisateur : carnet d'adresses Nextcloud connecté et visible dans le webmail Mailu.
Redirection .well-known/carddav/caldav corrigée pour de vrai (2026-08-24)¶
Ferme le bug laissé volontairement ouvert dans l'entrée précédente (contourné à l'époque avec l'URL DAV directe). Root cause déjà identifiée : la règle .htaccess de Nextcloud (RewriteRule ^\.well-known/carddav /remote.php/dav/ [R=301,L]) a une cible relative sans schéma, et mod_rewrite construit lui-même l'URL absolue de redirection — qui résout en http:// puisque ce vhost écoute réellement en clair sur :80 (le TLS est terminé en amont par Traefik), indépendamment du réglage overwriteprotocol => 'https' de Nextcloud (qui ne couvre que les URLs générées par PHP).
Deux tentatives déjà écartées dans l'entrée précédente, confirmées à nouveau infructueuses avant de chercher une autre piste : SetEnvIf X-Forwarded-Proto HTTPS=on (sans effet sur la construction d'URL de mod_rewrite, qui n'est pas une convention CGI/PHP) et Header always edit Location via mod_headers (confirmé chargé — un Header set de test apparaissait bien — mais l'édition de Location en elle-même jamais effective).
Vraie cause du deuxième échec, trouvée en isolant chaque variable : une règle placée dans un fichier séparé de /etc/apache2/conf-enabled/ (la première approche évidente) ne se déclenche jamais pour les requêtes traitées par ce <VirtualHost> nommé — confirmé sans ambiguïté avec une redirection de test vers une URL immédiatement reconnaissable (https://example.com/TEST-MARKER), qui n'apparaissait jamais dans la réponse malgré une config syntaxiquement valide et un apache2ctl graceful réussi. Cause : les règles mod_rewrite déclarées dans le contexte serveur global/principal ne sont pas héritées par un VirtualHost, sauf RewriteOptions Inherit explicite dans ce VirtualHost — un piège bien documenté mais facile à oublier. Correctif réel : placer la règle directement à l'intérieur du bloc <VirtualHost>, avec une cible https:// absolue explicite (contourne complètement le problème de détection de schéma de mod_rewrite) et [L] pour empêcher la règle .htaccess de Nextcloud de se déclencher ensuite. Validé en direct avec le marqueur de test d'abord, puis avec la vraie cible — fonctionne pour /carddav et /caldav, site normal et PROPFIND direct sur /remote.php/dav/ revérifiés intacts.
Fixé durablement : /etc/apache2/sites-enabled/000-default.conf n'est pas templaté au démarrage du conteneur (contrairement à snuffleupagus.rules.tpl chez Mailu) — une copie corrigée complète montée par-dessus l'original via nextcloud.extraVolumes/extraVolumeMounts (existants dans le chart officiel) suffit, pas besoin de hook. Nouveau ConfigMap (gitops/manifests/nextcloud-wellknown-scheme-fix/). Testé en direct (syncPolicy désactivé le temps du test, comme d'habitude) avant merge.