---
tags: note
type: "note"
nature: process
ressource: true
univers: PERSO
projects:
  - "[[03_PROJETS/VPS-IA]]"
date: "[[01_JOURNAL/2026/2026-10-16]]"
status: in-progress
---
# Sécurisation VPS OVH — plan d'actions

> Plan dérivé de l'audit du 16/10/2026 : [[04_NOTES/VPS-IA_Audit-Securite-VPS-2026-10-16]].
**Rien n'est encore appliqué** : tout est à réaliser par toi, dans l'ordre.

## 0. Règles d'or

1. **Une seule action à la fois** : exécuter → vérifier → étape suivante.
2. Tout se passe en **root** : tape `sudo -i` une fois connecté en `shizendo`.
3. **Ne ferme jamais ta session pendant une manipulation** : ouvre un **2ᵉ terminal** pour tester.
4. Secours ultime : la **console OVH** (KVM du VPS) donne root même si tu es bloqué en SSH.
5. Avant chaque modification de fichier : `cp -a fichier fichier.bak`.
6. Si une étape échoue : on s'arrête, on fait le retour arrière, on ne continue pas.

## Récapitulatif

| # | Action | Priorité | Risque si erreur | État |
|---|---|---|---|---|
| 1 | SSH par clé publique, mot de passe coupé | **P0** | 🔴 se verrouiller → console OVH | ✅ fait & vérifié |
| 2 | Durcir fail2ban (SSH) | **P0** | 🟡 se faire bannir soi-même | ✅ fait & vérifié |
| 3 | SilverBullet → Ollama local, puis SSO | **P0** | 🟡 casse l'IA si mal ordonné | ✅ fait & vérifié |
| 4 | Débloquer la rotation des logs | **P0** | 🟢 rien | ✅ fait — **confirmation ce matin** |
| 5 | MariaDB : fermer l'accès IP publique | P1 | 🔴 BDD indisponible ~5 s | ✅ fait & vérifié |
| 6 | Ollama : fermer `0.0.0.0` | P1 | 🟡 Ollama ne démarre pas | ✅ fait & vérifié |
| 7 | Pare-feu : règles inutiles | P1 | 🟡 couper un service | ✅ fait & vérifié (`8449` conservé) |
| 8 | Lire les logs fail2ban avec `opencode` | P1 | 🟢 rien | ✅ fait & vérifié |
| 9 | Supervision quotidienne | P1 | 🟢 rien | 🟡 log ✅ (1ʳᵉ entrée du 17/10 à 12:20) · **9c ⏳ alerte ntfy à brancher** |
| 10 | Nettoyage (domaines, comptes, **secrets**) | P2 | 🔴 toucher au mail/DNS | 🟡 **10b ✅ fait** · 10a + 10c ⏳ |
| 11 | Renforcements avancés | P2 | 🟢 à définir | ⏳ à cadrer |

---

## Étape 1 — SSH par clé publique (P0)

> 🔎 **Constat** : `shizendo` se connectait en **mot de passe** (27 sessions password en 2 semaines) alors que le serveur encaissait ~2 000 tentatives de force brute par jour ; un seul secret séparait un attaquant de root.
> 🎯 **Objectif & résultat** : imposer la clé publique et couper le mot de passe → **vérifié** : `Accepted publickey for shizendo` dans `auth.log`, `PasswordAuthentication no` en global, essai par mdp = `Permission denied (publickey,password)`. **Le brute force ne peut plus aboutir.**

* [x] Étape 1 — SSH par clé publique (P0)

📖 **Procédure complète Windows 11** (pas de `ssh-copy-id`) : [[04_NOTES/VPS-IA_SSH-Connexion-Cle-Windows]]

**Pourquoi** : aujourd'hui `shizendo` se connecte avec un **mot de passe** alors qu'il a les droits sudo. Le serveur reçoit ~2 000 tentatives de force brute par jour : une seule protection (le mot de passe) sépare l'attaquant de root. Avec une clé, le mot de passe devient inutile.

### 1a. Créer et installer la clé (sur TON ordinateur, pas sur le serveur)

```bash
ssh-keygen -t ed25519 -C "grk-2026"      # Entrée, puis Entrée (passphrase facultative)
ssh-copy-id -p 222 shizendo@datagrandest.net   # demande le mot de passe une dernière fois
```

Pour plus de clarté le fichier est nommé : `id_ed25519_grk-2026`

### 1b. Tester dans un **nouveau** terminal

```bash
ssh -p 222 shizendo@datagrandest.net
# attendu : connexion SANS mot de passe
sudo -i
# attendu : accès root OK
```
**Ne passe pas à 1c tant que ça ne marche pas.** (Pas de `ssh-copy-id` ? Colle la clé à la main :
`cat ~/.ssh/id_ed25519.pub | ssh -p 222 shizendo@datagrandest.net "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 700 ~/.ssh"`)

### 1c. Couper le mot de passe — via YunoHost (méthode retenue)

**Prérequis** : la clé est validée (étape 1b + `Accepted publickey for shizendo` dans `auth.log` le 17/10).

**Pourquoi passer par YunoHost** : `/etc/ssh/sshd_config` est **généré** depuis le modèle YunoHost. Une édition manuelle serait signalée, voire écrasée à la prochaine régénération (`yunohost tools regen-conf ssh`). Le réglage officiel, lui, est suivi : **plus rien à refaire après un upgrade.**

**Option A — webadmin (recommandée)** :
Réglages globaux → section **SSH** → interrupteur **« Authentification par mot de passe »** → désactivé.

**Option B — équivalent en ligne de commande** :
```bash
sudo yunohost settings set security.ssh.ssh_password_authentication -v no
```
YunoHost régénère `sshd_config` et exécute `sshd -t` automatiquement (hook de paramètre + `test_conf` du service) : rien à éditer, rien à recharger à la main.

**Ce qui est conservé** :
- `PermitRootLogin no` (inchangé) ;
- le bloc `Match Group sftp.app,!ssh.app` garde `PasswordAuthentication yes` → les comptes SFTP d'apps (sync rclone/unison…) continuent de fonctionner ;
- la **console OVH** reste le secours d'accès root.

### 1d. Vérifier (3ᵉ terminal)

```bash
ssh -p 222 -o PreferredAuthentications=password shizendo@datagrandest.net
# attendu : Permission denied (publickey,password)

ssh -p 222 shizendo@datagrandest.net
# attendu : connexion sans mot de passe

sudo -i
# attendu : root OK
```

**Retour arrière** : réactiver l'interrupteur dans le webadmin (option B : `sudo yunohost settings set security.ssh.ssh_password_authentication -v yes`), sinon console OVH.

---

## Étape 2 — Durcir fail2ban (P0)

> 🔎 **Constat** : `maxretry=10` / `bantime=10m` → un botnet revenait tester au bout de 10 minutes ; 1 066 bans cumulés sur la jail `sshd`.
> 🎯 **Objectif & résultat** : passer à 3 essais et 1 h d'éviction, avec incrémentation jusqu'à 1 semaine → **config vérifiée** ; **3 fois moins d'essais tolérés et 6 fois plus longtemps d'éviction.**

* [x] Étape 2 — Durcir fail2ban (P0)

**Pourquoi** : la jail SSH bannit après **10 échecs pendant 10 minutes**, et seulement **10 minutes**. Un botnet peut donc tester des milliers de mots de passe en boucle.

```bash
cp -a /etc/fail2ban/jail.d/yunohost-jails.conf /etc/fail2ban/jail.d/yunohost-jails.conf.bak
nano /etc/fail2ban/jail.d/yunohost-jails.conf
```
Modifie le bloc `[sshd]` :

```ini
[sshd]
port = 222
enabled = true
maxretry = 3
findtime = 10m
bantime = 1h
bantime.increment = true
bantime.max = 1w
```

```bash
fail2ban-client reload
fail2ban-client status sshd        # attendu : Number of jail ... + Total banned
```

⚠️ **3 erreurs de mot de passe = ton IP est bloquée 1 h.** Pour débloquer :
```bash
fail2ban-client set sshd unbanip <TON_IP>
```

---

## Étape 3 — SilverBullet → Ollama local, puis SSO (P0)

> 🔎 **Constat** : `https://ollama.datagrandest.net/api/embed` répondait **200 sans aucune authentification** ; `OLLAMA_HOST=0.0.0.0` + `OLLAMA_ORIGINS=*` ; SilverBullet appelait via l'URL publique (cassée dès que le SSO s'active).
> 🎯 **Objectif & résultat** : appels SilverBullet en **local**, **SSO** pour l'extérieur, réseau fermé → **vérifié** : `302` externe, `127.0.0.1:11434`, log `Proxying to http://127.0.0.1:11434/api/embed`, embeddings ✅. **API inaccessible depuis Internet, IA intacte.**

* [x] Étape 3 — SilverBullet → Ollama local, puis SSO (P0)

**Pourquoi cet ordre** : SilverBullet (plug `silverbullet-ai`) appelle Ollama via l'URL **publique** `https://ollama.datagrandest.net/v1` (page `80_CONFIG/IA/config.md`). Activer le SSO tout de suite casserait l'appel : requête serveur sans session → redirection 302 → le plug reçoit du HTML au lieu du JSON.
**Donc : 1) on bascule l'appel en local, 2) on teste, 3) on active le SSO.**

### 3a. Changer l'URL dans SilverBullet (dans l'espace, pas sur le serveur)

Page `80_CONFIG/IA/config.md`, bloc `ollama` :

```lua
local ollama_url = "http://127.0.0.1:11434/v1"   -- avant : https://ollama.datagrandest.net/v1

ollama = {
  baseUrl = ollama_url,
  useProxy = true,          -- OBLIGATOIRE (voir ci-dessous)
  preferredModels = { "bge-m3", "nomic-embed-text" }
},
```

- `useProxy = true` : ici c'est **SilverBullet (côté serveur)** qui contacte Ollama via `/shizendo/.proxy/127.0.0.1:11434`. `useProxy = false` ferait faire le `fetch` **au navigateur**, c'est-à-dire à **ta propre machine** (qui n'a aucun modèle) → ça échouerait.
  - La doc dit de mettre `false` + `OLLAMA_ORIGINS` pour « Ollama local » : cela décrit le cas **modèle IA sur le PC de l'utilisateur**, pas notre cas (modèle sur le VPS, même hôte que SilverBullet). `OLLAMA_ORIGINS` n'est nécessaire que pour un appel cross-origin depuis le navigateur → inutile ici.
  - `--useProxy = true` est commenté dans ta config : le défaut du plug est déjà `true`, mais on le rend **explicite** pour ne pas dépendre d'une version du plug.
  - Vérifié le 17/10 : `curl -H "Authorization: Bearer $SB_TOKEN" https://sb.datagrandest.net/shizendo/.proxy/127.0.0.1:11434/api/tags` → `200` + liste des modèles (et `401` sans token).
- utilise `127.0.0.1` et non `localhost` (évite un échec de connexion IPv6 sur `::1`).
- le champ s'écrivait `apiKay` (faute de frappe) : la clé n'a **jamais** été envoyée. En local elle est inutile → **la retirer**.

### 3b. Tester AVANT d'aller plus loin

Ouvre SilverBullet : un chat IA + une recherche par embeddings (`indexEmbeddings = true` tourne avec `ollama:bge-m3`). **Tout doit répondre normalement**, sinon on n'avance pas.

### 3c. Activer le SSO

```bash
yunohost user permission list -f | grep -i ollama
# attendu : ollama.main ... avec visitors

yunohost user permission remove ollama.main visitors
# la config SSO se régénère automatiquement
```

### 3d. Vérifier

```bash
curl -sI https://ollama.datagrandest.net/ | head -1
# attendu : HTTP/2 302 vers .../yunohost/sso

curl -s https://ollama.datagrandest.net/api/tags | head -c 80
# attendu : plus la liste des modèles en clair

curl -s http://127.0.0.1:11434/api/tags | head -c 80
# attendu : SilverBullet répond toujours (appel local)
```

**Ensuite** : l'étape 6 (`OLLAMA_HOST=127.0.0.1`) devient sans risque.
**Retour arrière** : remettre l'URL publique dans `80_CONFIG/IA/config.md` + `yunohost user permission add ollama.main visitors`.

---

### 3e. ⚠️ Dépannage : « l'ancienne URL reste utilisée »

**Symptôme** : la config est pourtant bonne (`config.get("ai")` renvoie la nouvelle URL), mais `AI: Connectivity Test` affiche encore `https://ollama.datagrandest.net/v1` et les logs montrent `Proxying to https://ollama.datagrandest.net/…`.

**Cause** : le plug stocke l'objet modèle **avec sa `baseUrl`** dans son stockage local (`ai.selectedEmbeddingModel`, `ai.selectedTextModel`), et cette valeur **prime** sur la configuration :

```js
baseUrl: t.baseUrl || n.baseUrl || Xi(t.provider)   // t = valeur persistée
```

Ni le F5, ni le redémarrage de SilverBullet, ni `AI: Refresh Config` ne touchent à ces clés.

**Correctif** (palette de commandes SilverBullet) :
- minimal : `AI: Select Embedding Model from Config` → reconstruit l'objet depuis la config ;
- complet : `AI: Reset Selected Models` → efface les 3 sélections (`text`, `image`, `embedding`) et les refait depuis la config.
  ⚠️ effet de bord : le modèle de **chat** repart au défaut (`ovh:Mistral-Small-3.2-24B-Instruct-2506`) → à re-sélectionner si tu avais un autre.

**Vérification** : `AI: Connectivity Test` → `API Endpoint = http://127.0.0.1:11434/v1` + `✅ Successfully generated embeddings` ; logs → `Proxying to http://127.0.0.1:11434/api/embed`.

---

## Étape 4 — Débloquer la rotation des logs (P0)

> 🔎 **Constat** : `logrotate` échouait **chaque nuit à 00:00** (répertoires en `770` sur un groupe non-root) → `/var/log/guacamole` et `/var/log/coturn` jamais purgés, `/var/log` bloqué à 558 Mo alors que le disque est à 73 %.
> 🎯 **Objectif & résultat** : ajouter `su <groupe>` dans les 2 fichiers et sortir les `.bak` du dossier → **dry-run sans aucune ligne `^error:`** ; résultat attendu ce soir : `systemctl status logrotate` sans `× failed` et des logs enfin purgés.

**Pourquoi** : `logrotate` purge et compresse les logs **chaque nuit à 00:00**. S'il détecte un répertoire « non sûr » (droits d'écriture pour un groupe autre que `root`), il **saute le bloc et sort en erreur** → ces logs ne sont jamais purgés et grossissent indéfiniment. Constat : `/var/log/guacamole` est en `770` (groupe `guacamole`).

### À quoi servent les commandes

| Commande | Rôle | Quand s'en servir |
|---|---|---|
| `logrotate -d /etc/logrotate.conf` | **Simulation** (`-d` = dry-run) : logrotate indique ce qu'il ferait **sans rien modifier** | **avant** d'agir, pour vérifier |
| `2>&1 \| grep '^error:'` | ne garde que les blocs en échec (le reste est du bruit : « considering log… ») | lecture rapide |
| `systemctl status logrotate` | résultat de la **dernière exécution réelle**, c'est-à-dire de la nuit passée (00:00) | **après** coup, pour confirmer |

> ⚠️ L'échec `Active: failed` affiché par `systemctl status` date de **00:00, avant ta correction** : il ne dit rien de l'état actuel. C'est la **simulation** qui fait foi maintenant ; l'état réel se juge à la **nuit suivante**.

### 4a. Lister tous les blocs en échec en une seule passe

```bash
sudo logrotate -d /etc/logrotate.conf 2>&1 | grep '^error:'
```

Seuls **2** répertoires du serveur déclenchent cette règle (mode `770`, groupe non-root) :

| Répertoire | Groupe | État |
|---|---|---|
| `/var/log/guacamole` | `guacamole` | ✅ corrigé (`su guacamole guacamole`) |
| `/var/log/coturn` | `coturn` | ⏳ **reste à faire** |

Tous les autres (`forgejo`, `grist`, `nextcloud`, `ollama`, `vaultwarden`…) sont en `750` (le groupe n'a **pas** le droit d'écrire) → **aucun correctif à faire**.

### 4b. Corriger coturn (même principe que guacamole)

```bash
sudo cp -a /etc/logrotate.d/coturn /tmp/coturn.logrotate.bak   # sauvegarde HORS de /etc/logrotate.d
sudo nano /etc/logrotate.d/coturn
# ajoute « su coturn coturn » juste APRÈS l'accolade ouvrante {  (1 seul bloc)
```

### 4c. Hygiène : ne rien laisser traîner dans `/etc/logrotate.d`

```bash
sudo mkdir -p /root/backups-logrotate
sudo mv /etc/logrotate.d/*.bak /root/backups-logrotate/ 2>/dev/null
ls -1 /etc/logrotate.d/
```

Un fichier `*.bak` oublié **dans** ce dossier peut être relu par logrotate et réintroduire l'ancienne version du fichier (celle sans `su`) → d'où le déplacement des sauvegardes hors du dossier.

### 4d. Valider

```bash
sudo logrotate -d /etc/logrotate.conf 2>&1 | grep '^error:'
# attendu : AUCUNE ligne affichée

systemctl status logrotate --no-pager | head -3
# à refaire le lendemain matin : ne plus afficher « × failed »
```

**Effet attendu** : les logs `guacamole` et `coturn` sont à nouveau purgés selon leur règle (100 Mo max, 12 rotations conservées, compression), et `/var/log` (558 Mo) cesse de grossir à cause de ces blocs.

---

## Étape 5 — MariaDB : fermer l'accès IP publique (P1)

> 🔎 **Constat** : `bind-address = 127.0.0.1` était **neutralisé** par `ListenStream=3306` du socket systemd → la base était joignable sur l'IP publique, seul le pare-feu la protégeait.
> 🎯 **Objectif & résultat** : override du socket sur `127.0.0.1:3306`, `mariadb.socket` laissé **enabled** (standard Debian) → **vérifié** : écoute locale uniquement, IP publique = `Connection refused`, Nextcloud/Grist/Vaultwarden OK. **Défense en profondeur rétablie.**

* [x] Étape 5 — MariaDB : fermer l'accès IP publique (P1)

**Pourquoi** : le fichier de config dit `bind-address = 127.0.0.1` **mais** le socket systemd `mariadb.socket` écoute sur `3306` **toutes interfaces** (vérifié : connexion acceptée sur l'IP publique). Le pare-feu bloque actuellement l'extérieur, mais une seule règle de trop suffirait à exposer ta base.

⚠️ **3-5 secondes d'indisponibilité** de la BDD (Nextcloud, Grist…) → à faire **hors heures de pointe**.

```bash
ss -tlnp | grep 3306        # constat : *:3306

mkdir -p /etc/systemd/system/mariadb.socket.d
cat > /etc/systemd/system/mariadb.socket.d/override.conf <<'EOF'
[Socket]
ListenStream=
ListenStream=@mariadb
ListenStream=/run/mysqld/mysqld.sock
ListenStream=127.0.0.1:3306
EOF

systemctl daemon-reload
systemctl restart mariadb.socket mariadb

ss -tlnp | grep 3306                   # attendu : 127.0.0.1:3306
systemctl is-active mariadb            # attendu : active
```

**Vérifier que les apps tournent** : Nextcloud, Grist, Vaultwarden en navigateur.
**Retour arrière** : `rm -rf /etc/systemd/system/mariadb.socket.d && systemctl daemon-reload && systemctl restart mariadb.socket mariadb`

---

## Étape 6 — Ollama : fermer `0.0.0.0` (P1)

> 🔎 **Constat** : `*:11434` sur toutes interfaces + `OLLAMA_ORIGINS=*` → exposition totale de l'API si une règle de pare-feu venait à disparaître.
> 🎯 **Objectif & résultat** : `OLLAMA_HOST=127.0.0.1` + origine restreinte → **vérifié** : écoute locale uniquement, service actif, API locale en 200, SilverBullet inchangé. **L'API n'est accessible que par le serveur lui-même.**

* [x] Étape 6 — Ollama : fermer `0.0.0.0` (P1)

**Pourquoi** : Ollama écoute sur **toutes les interfaces** (`OLLAMA_HOST=0.0.0.0`) et accepte n'importe quel site web (`OLLAMA_ORIGINS=*`). Le SSO (étape 3) protège l'accès via nginx ; ici on ferme aussi l'accès direct.

Modifier la configuration du service Ollama.

```bash
nano /etc/systemd/system/ollama.service.d/override.conf
#  OLLAMA_HOST=0.0.0.0                    -> OLLAMA_HOST=127.0.0.1
#  OLLAMA_ORIGINS=*                       -> OLLAMA_ORIGINS=https://ollama.datagrandest.net
```

Relancer le service Ollama.

```bash
systemctl daemon-reload
systemctl restart ollama
systemctl is-active ollama                   # attendu : active
ss -tlnp | grep 11434                        # attendu : 127.0.0.1:11434
curl -sI https://ollama.datagrandest.net/ | head -1   # attendu : 302 (SSO)
```

**Retour arrière** : remets les 2 valeurs d'origine, `daemon-reload` + `restart ollama`.

---

## Étape 7 — Pare-feu : supprimer les règles inutiles (P1)

> 🔎 **Constat** : plusieurs ports ouverts **sans aucun écouteur** (22, 1900, 55354, 53) et le **mDNS 5353 exposé à Internet** (fuite des noms de services) ; `8449` ouvert mais sans écouteur = probable dysfonctionnement de la fédération.
> 🎯 **Objectif & résultat** : fermer 22 / 53 (udp+tcp) / 1900 / 5353 / 55354 et **conserver 8449** → **vérifié** : allow nft = `tcp {25,80,222,443,587,993,5351,7883,8449,22000}` + `udp 5351`, `firewall.yml` synchronisé, DNS local / mail / SSH intacts. **Surface exposée réduite sans aucun service cassé.**

* [x] Étape 7 — Pare-feu : supprimer les règles inutiles (P1)

**Pourquoi** : 5 ports sont ouverts alors que **rien n'écoute** (donc inutiles), et le **mDNS (5353)** expose les noms de services de la machine à tout Internet.

⚠️ **Ne pas toucher au port 8449** (fédération tuwunel) tant que tu n'as pas compris pourquoi il n'y a aucun écouteur : c'est peut-être un vrai dysfonctionnement à corriger, pas une règle à supprimer.

```bash
yunohost firewall list

yunohost firewall close 22   -p tcp     # rien n'écoute sur 22 (sshd est sur 222)
yunohost firewall close 1900 -p udp     # UPnP : inutile sur un VPS
yunohost firewall close 55354 -p udp    # configurateur UPnP YunoHost : inutile
yunohost firewall close 5353 -p udp     # mDNS/Avahi : expose les noms de services
yunohost firewall close 53   -p udp     # DNS : le VPS résout via 127.0.0.1
yunohost firewall close 53   -p tcp

nft list ruleset | grep dport           # les ports fermés ne doivent plus apparaître
```

⚠️ YunoHost peut **recréer** ces règles après un upgrade : revérifier (`yunohost firewall list`).
⚠️ Si un jour tu héberges un serveur DNS ou du mDNS réellement utile, réouvrir avec `yunohost firewall open <port> -p <proto>`.

---

## Étape 8 — Lire les logs fail2ban avec `opencode` (P1)

> 🔎 **Constat** : `fail2ban.log` appartenait à `root:adm` → **invisible** pour le compte `opencode` : impossible de superviser les bans, d'alerter ou de les auditer.
> 🎯 **Objectif & résultat** : `usermod -aG adm opencode` → **vérifié** : `id opencode` contient `4(adm)` et `tail /var/log/fail2ban.log` répond en direct (ex. `[sshd] Found 77.83.39.12`). **Préalable à toute supervision.**

**Pourquoi** : `fail2ban.log` appartient à `root:adm` → impossible de superviser les bans depuis le compte service `opencode` (et donc d'alerter).

```bash
usermod -aG adm opencode
id opencode          # attendu : ... 4(adm) ...
```
Puis **reloguer** la session `opencode` (redémarrer le service) pour que le groupe prenne effet :
```bash
systemctl restart opencode
```
**Vérifier depuis une session `opencode`** : `tail -3 /var/log/fail2ban.log` doit répondre sans "Permission denied".

---

## Étape 9 — Supervision quotidienne (P1)

> 🔎 **Constat** : une unité `minio` en échec depuis le **20/07** passait inaperçue (⚠️ rectification du 17/10 : MinIO **avait été installé puis supprimé** → c'est un **résidu d'app**, pas une panne), le disque est à **73 %** → rien ne signalait ces dégradations.
> 🎯 **Objectif & résultat** : transformer une panne **silencieuse** en un **signal visible** : un cron quotidien (08:07) écrit services en échec / disque / fail2ban / RAM dans `/var/log/vps-check.log`. **Livré** : script + cron présents et valides ; **reste** : lancer le script une fois (le cron a été créé après 08:07, le fichier n'existe donc pas encore) et, ntfy étant installé, **pousser l'alerte au lieu d'écrire dans un fichier**.

* [x] Étape 9 — Supervision quotidienne (P1)

**Pourquoi** : `minio` est en échec depuis le **20/07** sans que personne ne l'ait remarqué, et le disque est à **73 %**. Il faut un état des lieux automatique et visible.

```bash
cat > /usr/local/sbin/vps-check.sh <<'EOF'
#!/bin/bash
echo "== $(date -Is) =="
echo "-- services en echec :"
systemctl --failed --no-legend
echo "-- disque :"
df -h / | tail -1
echo "-- fail2ban :"
fail2ban-client status 2>/dev/null | grep "Number of jail"
echo "-- ram :"
free -m | sed -n 2p
EOF
chmod 755 /usr/local/sbin/vps-check.sh

echo '7 8 * * * root /usr/local/sbin/vps-check.sh >> /var/log/vps-check.log 2>&1' > /etc/cron.d/vps-check

/usr/local/sbin/vps-check.sh     # test immédiat
tail -20 /var/log/vps-check.log
```

**Vérification humaine** : relire `/var/log/vps-check.log` chaque semaine.

### 9a. Lancer le premier tour (obligatoire)

**Constat (17/10)** : le script s'exécute, mais `/var/log/vps-check.log` reste absent.
**Cause** : la redirection `>> /var/log/vps-check.log` se trouve dans la **ligne de cron**, pas dans le script → lancé à la main, il n'écrit que sur l'écran.
**Objectif & résultat** : créer la première entrée datée.

```bash
sudo /usr/local/sbin/vps-check.sh >> /var/log/vps-check.log 2>&1
cat /var/log/vps-check.log            # première ligne datée du jour
```

Ensuite : 1ère entrée **automatique demain 08:07**.

### 9b. Brancher les alertes sur ntfy

**Constat (17/10)** : la publication est refusée par défaut :

```bash
curl -s -d "VPS: test" http://127.0.0.1:8080/vps-admin
# → {"code":40301,"http":403,"error":"forbidden"}
```

…alors que la **lecture** est ouverte — d'où la mention de l'UI : « Le sujet n'est peut-être pas protégé par un mot de passe ».
**Objectif & résultat** : publication autorisée pour le cron, sans ouvrir tout ntfy. **À faire en root** (ma whitelist est lecture seule) :

**Option A — rapide** (écriture anonyme sur ce seul sujet) :
```bash
ntfy access everyone vps-admin rw
curl -s -d "VPS: test d'alerte" http://127.0.0.1:8080/vps-admin    # attendu : 200
```

**Piège à connaître** : `ntfy` **n'est pas dans le PATH** (binaire `/var/www/ntfy/ntfy`, dossier non lisible pour un utilisateur normal) → d'où `ntfy: command not found` et le 403 qui persiste.

**Option B — recommandée (token) — à faire en root** :
```bash
NTFY=/var/www/ntfy/ntfy
sudo $NTFY user add guillaume        # définit un mot de passe ntfy
sudo $NTFY access guillaume vps-admin rw
sudo $NTFY token add guillaume       # token affiché UNE seule fois -> copier

# ranger le token DANS LE COFFRE, en tant qu'opencode :
sudo -u opencode env HOME=/var/lib/opencode \
     /var/lib/opencode/.local/bin/pass insert opencode/ntfy-token

# test (toujours en tant qu'opencode) :
T=$(HOME=/var/lib/opencode /var/lib/opencode/.local/bin/pass show opencode/ntfy-token)
curl -s -H "Authorization: Bearer $T" -d "VPS: test d'alerte" http://127.0.0.1:8080/vps-admin   # attendu : 200
```

> => **Pourquoi 403 par défaut** : la config de ntfy n'est pas lisible depuis mon compte (dossier `/var/www/ntfy` fermé) → vérifie `sudo grep -vE '^\s*#' /var/www/ntfy/server.yml` pour voir `auth-default-permit` et `auth-file`.
> Option A (si tu préfères éviter un compte) : `sudo /var/www/ntfy/ntfy access everyone vps-admin rw` — mais alors l'écriture est anonyme.

### 9c. Le découplage root / opencode — ✅ **MIS EN PLACE le 17/10 à 18:04**

```bash
-rwxr-xr-x opencode opencode  /usr/local/sbin/ntfy-alert.sh   # token via pass, conditions, curl
/etc/cron.d/vps-check :
  7 8 * * * root     /usr/local/sbin/vps-check.sh        >> /var/log/vps-check.log 2>&1
  9 8 * * * opencode /usr/local/sbin/ntfy-alert.sh
```

⚠️ **CORRECTIF REQUIS avant demain (testé à 18h)** : `pass` **n'est pas dans le PATH de cron** → la variable `T` est vide, le curl part avec un `Bearer` vide → **403 → alerte perdue en silence** (exactement le silence que cette étape devait supprimer) :

```bash
/usr/local/sbin/ntfy-alert.sh: line 2: pass: command not found   (env -i PATH=/usr/bin:/bin …)
```

Correctif (2 lignes en tête de script) :
```bash
#!/bin/bash
export HOME=/var/lib/opencode
T=$("$HOME/.local/bin/pass" show opencode/ntfy-token) || { echo "pass KO" >&2; exit 1; }
```

**Constat** : `fail2ban-client` exige root (socket `/var/run/fail2ban/fail2ban.sock` en `srwx------ root root` — testé : `you must be root`) ; à l'inverse, le **secret** (token ntfy) ne devrait pas traîner dans `/root`.
**Objectif & résultat** : chaque job a le **minimum de droits nécessaire**.

| Quoi | Qui | Pourquoi |
|---|---|---|
| Diagnostic complet (dont fail2ban) → log | **root** | socket fail2ban réservé à root |
| Conditions ( `systemctl --failed`, `df`) + **notification ntfy** | **opencode** | token dans le coffre `pass`, aucun secret en clair ni dans /root |

Fichier de notification (appartient à `opencode`, `/usr/local/sbin/ntfy-alert.sh`) :
```bash
#!/bin/bash
# ecrit dans le log si panne, puis pousse vers ntfy
T=$(pass show opencode/ntfy-token)
if [ -n "$(systemctl --failed --no-legend)" ] || [ "$(df -P / | awk 'NR==2{print $5}' | tr -d %)" -ge 80 ]; then
  curl -s -H "Authorization: Bearer $T" -d "VPS: service en echec ou disque > 80%" http://127.0.0.1:8080/vps-admin
fi
```

Fichier cron (2 lignes, un job par utilisateur) :
```
7 8 * * * root    /usr/local/sbin/vps-check.sh        >> /var/log/vps-check.log 2>&1
9 8 * * * opencode /usr/local/sbin/ntfy-alert.sh
```

Vérifications sans root : `systemctl --failed` et `df` répondent tous les deux pour `opencode` (testé le 17/10).

**Nouveau sujet** : aucun paramétrage — les sujets ntfy sont **implicites** : le nom est libre et le sujet naît à la première publication. Il suffit d'avoir le droit d'écrire dessus (`ntfy access everyone <nom> rw`).

**« Sujet non protégé par mot de passe » — oui, à prendre au sérieux** : n'importe qui peut **lire** les alertes (services, disque, noms) et, si l'écriture est anonyme, **injecter de fausses alertes**. Trois niveaux :
1. minimum : nom **non devinable** (ex. `vps-admin-7f3a9c2d`) ;
2. correct : option B (token) — lecture ouverte si ça ne te dérange pas ;
3. strict : activer l'authentification de lecture dans l'app ntfy (interface → Utilisateurs / ACL).

---

## Étape 10 — Nettoyage (P2 — prudence, peut toucher au mail/DNS)

> 🔎 **Constat** : ~16 domaines déclarés sans application, des comptes avec un shell inutile, `minio`/ `rclone` en échec résiduel, et **5 clés d'API en clair** dans l'espace SilverBullet.
> 🎯 **Objectif & résultat** : réduire la surface et nettoyer l'espace — **non réalisé à ce stade** ; priorité donnée à **10c (secrets)** avant tout nettoyage de domaine.

**Pourquoi** : ~16 domaines/vhosts ne correspondent à aucune app installée, et des comptes inutiles ont un shell.

### 10a. Domaines — TOUJOURS vérifier avant de supprimer

```bash
yunohost domain list
yunohost app list
curl -sI https://<domaine>/ | head -1        # pour chaque candidat
```
Candidats relevés par l'audit : `dagu`, `element`, `fail2ban.datagrandest.net`, `linkwarden`, `minio`, `ntfy`, `readeck`, `rclone`, `synapse`, `synapse-admin`, `hermes`.

⚠️ **Ne supprime un domaine que si** : aucune app ne l'utilise, **et** il ne porte pas de messagerie (MX), **et** tu n'en as pas besoin en DNS.
```bash
yunohost domain remove <domaine>
```

### 10b. Comptes & services (résidus d'applications)

**Constat rectifié (17/10)** : `minio` **a été installé puis supprimé** (jugé inexploitable sur le VPS) — ce n'est donc **pas une panne ignorée**, mais une **unité systemd résiduelle** encore `enabled` + `failed`. Même cas pour `rclone` (unité introuvable) et `run-u482` (débris d'un test Chromium headless).
**Objectif & résultat** : `systemctl --failed` repart à zéro (une supervision fiable commence par des états propres). → ✅ **fait le 17/10** : `minio.service` et `rclone.service` supprimés, `systemctl --failed` vide.

```bash
sudo systemctl disable minio            # résidu d'app supprimée
sudo rm /etc/systemd/system/minio.service
sudo systemctl reset-failed             # efface rclone / run-u482 / minio (état uniquement)
systemctl --failed                      # attendu : vide (logrotate se re-juge à 00:00)

last debian | head                     # compte par défaut de l'image OVH ?
passwd -l debian                        # si inutilisé : verrouille le compte
grep -E "/(bash|sh)$" /etc/passwd       # repérer les shells inutiles
```
- `debian`, `kamio`, `postgres` : ne changer leur shell qu'après avoir vérifié qu'aucun service ne les utilise (voir note d'audit).
- ⚠️ `reset-failed` n'efface que l'**état**, pas la cause : `logrotate` se re-juge à la prochaine exécution (00:00).
- nettoyer aussi `minio.datagrandest.net` (domaine orphelin, étape 10a).

### 10c. Secrets dans SilverBullet : politique + nettoyage (P1)

**Constat** : `80_CONFIG/IA/config.md` contient **5 clés d'API en clair** (OVH/Kamio, 2× OpenRouter, Mistral, Ollama) et la clé Ollama est **dupliquée** dans `03_NOTES/OLLAMA_Authentification_API-KEY.md`.
**Objectif & résultat** : réduire au **strict minimum technique**, rattacher chaque secret à son gestionnaire, et **ne jamais** que SilverBullet devienne un gestionnaire de mots de passe.

#### Pourquoi « mettre les clés dans Lua pour ne pas les afficher » ne marche pas

Introspection de l'API Lua de SilverBullet (17/10) :

```
system : getConfig, getEnv, invokeFunctionOnServer, serverSyscall, loadScripts, …   <- pas de lecture fichier, pas de shell
space  : readFile, readPage, listFiles, …                                           <- ESPACE SEULEMENT
```

→ le Lua **ne peut ni lire le coffre `pass`, ni un .kdbx, ni un fichier hors espace**. La seule façon de donner une clé au plug est de l'écrire **dans la page** (contrainte technique du plug `apiKey = …`).
→ conséquence : une valeur dans l'espace est lisible par **tout agent/skill** qui y accède (y compris moi) et par toute session SSO.

#### Rôles des coffres (décision)

| Coffre | Rôle | Ce qui y va |
|---|---|---|
| **KeePass** (fichier local, hors VPS) | **Break-glass / source de vérité** | root, admin OVH, phrase de passe du coffre GPG, **aucune dépendance au VPS** |
| **Vaultwarden** (`pass.datagrandest.net`) | **en cours de migration** → décommission à la fin | maintenu jusqu'à ce que KeePass contienne 100 % des entrées |
| **pass** (`opencode/*`) | secrets des **agents** | tokens MCP, `ntfy-token`, sb-token… |
| **SilverBullet** | **PKM : références, jamais de valeur** | nom de l'entrée + où la trouver, sauf contrainte technique ci-dessous |

> ⚠️ Vaultwarden **vit sur le même VPS** : si le serveur tombe, il tombe avec → d'où le rôle de **KeePass** en tant que vérité hors-ligne.

#### 📌 Décision finale du 17/10 : **KeePassXC seul** (abandon de Vaultwarden)

> Choix de GRK : **un seul fichier KeePass** synchronisé — *Vaultwarden reste en place **le temps de la migration** puis sera décommissionné*. via Nextcloud — PC (`KeePassXC` + `KeePassXC-Browser` pour l'autofill), téléphone (`KeePassDX`), et hors-ligne. L'extension de navigateur comble le principal point faible de KeePass (l'autofill), et ça supprime une app web exposée. `vaultwarden.datagrandest.net` n'existe plus ; l'app est sur `pass.datagrandest.net` et sera **décommissionnée**.
> Les 4 points de vigilance (synchro, brute-force hors ligne, copie hors-ligne, autorisation par site de l'extension) sont détaillés dans la section **Bilan**.

#### Analyse comparative (conservée à titre de trace)

Ton raisonnement est bon (dispersion = pire que le choix lui-même). Proposition :

| | Rôle | Taille / rythme |
|---|---|---|
| **Vaultwarden** | **les 100 % des logins quotidiens** : autofill navigateur + mobile, recherche, partage | plein, mis à jour en continu |
| **KeePassXC** | **fichier `break-glass.kdbx`** : uniquement ce qui te permet de **réparer le VPS** si Vaultwarden tombe avec (root, admin OVH, DNS OVH, Forgejo admin, phrase du coffre GPG, accès poste) | **≤ 20 entrées**, révisé 2×/an |

Pourquoi **ne pas** KeePass seul : pas d'autofill, sync manuelle (divergence des fichiers), friction → tentations (copier-coller, mots de passe réutilisés).
Pourquoi **ne pas** Vaultwarden seul : il est **hébergé sur le serveur qu'il protège** → compromission, chiffrement ou panne du VPS = tu perds les identifiants dont tu as besoin pour le réparer. KeePass casse cette boucle.

**Règle anti-dispersion** : **un lieu unique par type de secret** + une note PKM d'index (« où est quoi »). Jamais la même entrée aux deux endroits, sauf les ~10 du break-glass.
**Transport du .kdbx** : Nextcloud (fichier chiffré) + copie locale ✔ — **éviter l'e-mail** (copie en clair chez le fournisseur, historique permanent).
> `pass.datagrandest.net` est un **domaine YunoHost toujours déclaré**, alors que l'app Vaultwarden est sur `vaultwarden.datagrandest.net` → vérifie l'URL enregistrée dans KeePass.

#### Plan d'action

1. **Supprimer les clés mortes** (5 → 2) dans `80_CONFIG/IA/config.md` :
   - `ai_key` (commentée),
   - `mistral_key` (commentée, « plus actif »),
   - le 2`e` `openrouter_key` commenté,
   - `ollama_key` (inutile depuis l'option A : on est en local, sans clé).
2. **Rattacher les 2 clés restantes** à leur gestionnaire, à côté de la valeur :
```lua
-- openrouter_key : voir KeePass -> "Kamio OpenRouter" (rotation : OpenRouter console)
-- ovh_key         : voir KeePass -> "Kamio OVH Kepler"
```
3. **Purger** `03_NOTES/OLLAMA_Authentification_API-KEY.md` : variante B abandonnée → garder la procédure **sans la clé**, ou supprimer la note.
4. **Vérifier l'absence de synchro git** : `config.get("git")` → `nil` le 17/10 (aucun remote configuré) ; le plug Git existe dans l'espace → **ne pas l'activer** sur `80_CONFIG/IA/`, ou l'exclure de la synchro.
5. **Rotation** si l'espace a déjà été pushé/exporté quelque part (OpenRouter + OVH).
6. **Contrôle final** : `grep -l "sk-or-\|eyJhbGci\|1428380090" 80_CONFIG/ 03_NOTES/` → seulement les 2 clés vivantes.

---

## Étape 11 — Renforcements avancés (P2)

> 🔎 **Constat** : points restés ouverts malgré les étapes 1-10 : pas d'audit externe, pas de 2FA, 35 groupes sur `shizendo`, `8449` toujours sans écouteur, aucune alerte sur les privilèges.
> 🎯 **Objectif & résultat** : à cadrer — **aucune action décidée à ce jour**.

- **Audit externe régulier** : un `nmap` depuis l'extérieur confirme que le pare-feu bloque réellement (seul moyen de le *prouver*).
- **2FA** : seulement si tu dois absolument garder un mot de passe SSH (attention compatibilité YunoHost/PAM).
- **Réduire les 35 groupes** de `shizendo` (moindre privilège) — à faire avec précaution, certains groupes servent aux apps.
- **Alerte sur les privilèges** : surveiller toute écriture dans `/etc/sudoers.d/` et les lignes `sudo:` de `auth.log`.
- **Définir le sort du fragment sudoers** `/etc/sudoers.d/opencode-audit` : **conserver** (recommandé, lecture seule, permet les futurs audits) ou supprimer (`rm`).
- **Coffre `pass`** : l'entrée `opencode/server-password` n'est **pas** le mot de passe sudo → corriger l'entrée ou la retirer ; garder le break-glass root dans KeePass.

---

## Annexe — Les 2 domaines (datagrandest.net + shizendo.ovh)

**Constat** : deux domaines racines sont rattachés au VPS (consultables dans Webadmin → Domaines & DNS).
**Objectif & résultat** : vérifier si cela exige une configuration particulière — **réponse : non, YunoHost gère nativement plusieurs domaines**, mais 3 points doivent être suivis.

### Vérifié le 17/10 (depuis l'extérieur, via 1.1.1.1)

| Contrôle | datagrandest.net | shizendo.ovh |
|---|---|---|
| A | 217.182.65.158 ✔ | 217.182.65.158 ✔ |
| AAAA | 2001:41d0:305:2100::dff1 ✔ | identique ✔ |
| www | ✔ | ✔ |
| MX | 10 → lui-même ✔ | 10 → lui-même ✔ |
| SPF | `v=spf1 a mx -all` ✔ | identique ✔ |
| DMARC | `p=none` ⚠️ | `p=none` ⚠️ |
| Certificat TLS | 26/11/2026 ✔ | 26/11/2026 ✔ (sous-domaines aussi) |
| Protégé par SSO | ✔ | ✔ (`302` vérifié) |
| DKIM (`mail._domainkey.*`) | ✔ publié | ✔ publié |

> ⚠️ Depuis **le** VPS lui-même, `dig` renvoie `127.0.0.1` (dnsmasq local qui capte les domaines) → toujours vérifier l'extérieur avec `dig @1.1.1.1 <domaine>`.

### Ce que cela demande concrètement

1. **Rien d'urgent** : certificats, SSO et mail sont gérés par domaine, et tout est cohérent.
2. **ACME par domaine** : chaque domaine a **son** certificat, renouvelé automatiquement. Si un jour un domaine ne pointe plus vers le VPS, **ses** apps tombent (l'autre domaine continue de fonctionner).
3. **Mail par domaine** : SPF ✔ et DMARC ✔ sur les deux, et **DKIM vérifié le 17/10**. Lecture de la `keytable` : la 1re colonne donne le **selector** (ici `mail`) → la commande complète est donc `dig TXT mail._domainkey.<domaine>`. Résultat : **3/3 publiés** ✔ (`datagrandest.net`, `shizendo.ovh`, `mail.datagrandest.net`). Note : la keytable contient aussi des entrées résiduelles de domaines supprimés (ex. `pass.datagrandest.net`) → nettoyage via l'étape 10a.
4. **DMARC `p=none` sur les deux** = politique **surveillée mais non appliquée** → passer en `p=quarantine` puis `p=reject` (P2), une fois le DKIM confirmé.
5. **DNS dynamique** : en cas de changement d'IP, YunoHost ne met à jour que le domaine principal (dyndns) → `shizendo.ovh` demanderait une mise à jour manuelle (sans objet tant que l'IP OVH est fixe).
6. **Nettoyage** : plusieurs sous-domaines des deux familles sont orphelins (étape 10a).

---

## Vérification finale (checklist)

| Test | Commande | Attendu |
|---|---|---|
| SSH sans mdp | `ssh -p 222 shizendo@datagrandest.net` | connexion OK |
| SSH mdp refusé | `ssh -p 222 -o PreferredAuthentications=password ...` | Permission denied |
| Ollama protégé | `curl -sI https://ollama.datagrandest.net/` | 302 vers SSO |
| IA SilverBullet OK | chat + embeddings dans SilverBullet | fonctionne (appel local) |
| MariaDB locale | `ss -tlnp | grep 3306` | 127.0.0.1:3306 |
| Ollama local | `ss -tlnp | grep 11434` | 127.0.0.1:11434 |
| Pare-feu | `yunohost firewall list` | 22/1900/5353/55354 fermés |
| fail2ban | `fail2ban-client status sshd` | bantime 1h |
| Logs | `systemctl status logrotate` | inactive/ok le lendemain |
| Services | `systemctl --failed` | vide |
| Disque | `df -h` | < 80 % |
| Supervision | `tail -20 /var/log/vps-check.log` | ligne du jour présente |

---

## Bilan du vendredi 17 octobre 2026

### ✅ Réalisé & vérifié aujourd'hui

| Étape | État |
|---|---|
| 1 — SSH clé + mot de passe coupé | ✅ `Accepted publickey` / `Permission denied (publickey,password)` |
| 2 — fail2ban durci (3 essais / 1 h) | ✅ |
| 3 — Ollama local + SSO | ✅ 302 externe, `127.0.0.1:11434`, embeddings OK |
| 4 — logrotate (`su` guacamole + coturn, `.bak` sortis) | ✅ fait → **confirmation le 18/10** |
| 5 — MariaDB en local (override socket) | ✅ `127.0.0.1:3306`, IP publique refusée |
| 6 — Ollama `127.0.0.1` + origines restreintes | ✅ |
| 7 — Pare-feu (5 règles fermées) | ✅ (`8449` conservé) |
| 8 — Logs lisibles par `opencode` (groupe `adm`) | ✅ |
| 9a — Journal de supervision | ✅ 1ʳᵉ entrée du 17/10 à 12:20, `systemctl --failed` vide |
| 9b — Token ntfy dans `pass` | ✅ publication en 200 |
| 9c — Script + cron d'alerte | ✅ mis en place 18:04 · ⚠️ **correctif PATH `pass` à appliquer** |
| 10b — Résidus d'apps (`minio`, `rclone`) | ✅ unités supprimées, `--failed` vide |
| 10a — Nettoyage des domaines | ✅ 41 → **29 domaines** |
| 10c — Clés en clair | ✅ 5 → 2 clés + références KeePass |

### ⏳ Reste à faire (par ordre)

1. **9c — corriger le PATH de `pass`** dans `/usr/local/sbin/ntfy-alert.sh` (voir section 9c) : sinon l'alerte part avec un token vide et **tombe en 403 en silence**. *À faire avant le cron de demain 08:09.*
2. **18/10 au matin** : `systemctl status logrotate` sans `× failed` (étape 4) + 2ᵉ ligne dans `/var/log/vps-check.log` (cron 08:07).
   📬 **Rappel programmé** : **crontab** → **18/10 08:30** (`30 8 18 10 *`), envoie **un mail** (`guillaume@ryckelynck.info`) **et une alerte ntfy** avec l'état réel de `logrotate`, du cron et des services en échec à cet instant.
   • script : `~/.opencode/bin/reminder-20261018.sh` (755, HOME/PATH posés explicitement — même piège que 9c, aucun secret en dur) · vérifier avec `crontab -l`.
   🧹 **Auto-nettoyage intégré** (la syntaxe `30 8 18 10 *` est **annuelle**, elle se rejouerait le 18/10/2027) : à la fin de l'exécution, le script **retire sa propre ligne du crontab** `crontab -l | grep -v reminder-20261018.sh | crontab -` ; et un **sentinel** (`~/.local/state/reminder-20261018.done`, hors git car `.local/` est ignoré) garantit une exécution **une seule fois**, même si le retrait échouait. Si le sentinel existe déjà : aucun envoi, la ligne résiduelle est retirée puis `exit 0`.
   • tests validés le 17/10 : **alerte ntfy** envoyée (id `8uH8qIFqpsjz`) et **mail** délivré (`status=sent / 250 Ok` chez OVH, signé DKIM `s=mail`).
   ⚠️ **Incident corrigé le 17/10 (19h)** : la tâche avait été programmée avec `at`, qui **recopie tout l'environnement en clair** dans sa file d'attente → `GRIST_API_KEY`, `SB_AUTH_TOKEN`, `OPENROUTER_API_KEY`, `OPENCODE_SERVER_PASSWORD` visibles via `at -c`. **Job supprimé** (`atrm 14`, aucun fichier résiduel), **reprogrammé en crontab** (aucune variable reprise), et **règle ajoutée** dans `80_CONFIG/IA_CONTEXT/AGENTS.md` + resynchronisée dans `AGENTS.md`.
3. **8449** : port ouvert mais **0 écouteur** → investiguer la fédération tuwunel, ou supprimer la règle.
4. **Vaultwarden — maintenu le temps de la migration** (décision du 17/10 : garder Vaultwarden **jusqu'à** avoir migré 100 % des entrées vers KeePass, puis décommissionner) : l'app est désormais sur `pass.datagrandest.net` (`vaultwarden.datagrandest.net` n'existe plus) → **décision du 17/10 : on abandonne Vaultwarden** (voir 10c) → à terme `yunohost app remove vaultwarden` puis nettoyage du domaine + de la jail fail2ban `vaultwarden`.
5. **10a résiduel** : `debian`, `kamio`, `postgres` toujours en `/bin/bash` → à valider avant tout changement.
6. **Décision sudoers** : conserver (recommandé) ou supprimer `/etc/sudoers.d/opencode-audit`.
7. **Hygiène des tâches one-shot + rotation** — 3 couches (étude du 17/10, **pas de GC automatique**) :

   **A · Rotation du journal** ✅ **fait (17/10, root)** : `/etc/logrotate.d/vps-check` créé avec le bloc ci-dessous, validé par `logrotate --debug` (*Handling 1 logs — monthly, 6 rotations*) :
```
/var/log/vps-check.log {
    monthly
    rotate 6
    compress
    missingok
    notifempty
    create 0640 root adm
}
```

   **B · Auto-destruction du script one-shot** ✅ fait (17/10) : en fin d'exécution, `reminder-20261018.sh` retire sa ligne de crontab, pose le sentinel, puis `rm -f "$0"` → plus de résidu ; la trace reste dans **git** (fichier suivi).

   **C · Contrôle d'hygiène quotidien** ✅ **fait (17/10)** : `ntfy-alert.sh` réécrit (`HOME` + `PATH` en tête, chemins absolus) — **détection + alerte**, jamais de suppression automatique (*« analyser avant d'agir »*) :
   - ligne de crontab dont le script **n'existe plus** (orpheline) → alerte ;
   - script `oneshot-*.sh` **encore présent alors que son sentinel existe** → résidu non nettoyé → alerte ;
   - sentinel **seul** = état final normal → pas d'alerte.
   → Le **registre** est la **convention de nommage** (`~/.opencode/bin/oneshot-*.sh`) + les lignes de crontab et `/etc/cron.d/` : aucun fichier de gestionnaire à maintenir, aucun nouveau cron. Les scripts récurrents (`cron-*`) sont **hors périmètre** de C.
   **Nomenclature (17/10, après incident `reminder-memory-consolidation.sh`) :** one-shot = `oneshot-<date>-<sujet>.sh` (auto-destructif), récurrent = `cron-<période>-<sujet>.sh` (jamais de sentinel). Renommages effectués : `reminder-20261018.sh` → `oneshot-20261018-reminder.sh` (sentinel interne mis à jour), `reminder-memory-consolidation.sh` → `cron-hebdo-memory-consolidation.sh` (crontab `0 18 * * 5` conservé).
   **Vérifications (17/10)** : run en `env -i` → muet, `rc=0` (0 faux positif) · bac à sable → les 2 détections remontent, le script existant non · test bout-en-bout → notification reçue (`Hmr9VgVGstN1`) · script one-shot → `rm -f "$0"` validé (`bash -n`, tracé git).
   **Doc** : skill `planification-taches` mis à jour (checklist pt. 4 auto-destruction + pt. 8 rotation, tableau des fichiers, nouvelle section « Hygiène des tâches one-shot — 3 couches »).

### 🎯 P2 (ouverts, sans urgence)

- DMARC `p=none` → `quarantine` puis `reject` sur les 2 domaines ;
- audit **externe** de ports + test SSO automatisé ;
- rotation des 2 clés IA si l'espace a déjà été pushé ;
- couverture HTTP de fail2ban (tester les jails `yunohost`/`nginx-http-auth`) ;
- services encore en `0.0.0.0` (guacamole, onlyoffice, rabbitmq, slapd, uvicorn, epmd) ;
- 2FA SSH, réduction des 35 groupes de `shizendo`, alerte sur les privilèges.

### 📌 Décision arrêtée le 17/10 : **KeePassXC comme gestionnaire unique**

**Stratégie retenue** : abandonner Vaultwarden, conserver **un seul fichier KeePass** synchronisé via Nextcloud, utilisé sur PC (`KeePassXC` + extension `KeePassXC-Browser` pour l'autofill), téléphone (`KeePassDX`), et hors-ligne ; les secrets VPS restent dans le coffre `pass` **et** dans KeePass (double inscription du break-glass).

**Pourquoi c'est cohérent** : source de vérité unique (pas de dispersion), aucune dépendance au VPS pour récupérer les accès, l'extension de navigateur comble le point faible historique de KeePass (l'autofill), et ça supprime une application web exposée (surface d'attaque).

**4 points de vigilance** (à garder en tête) :
1. **Le fichier vit sur Nextcloud** (= sur le VPS) → mot de passe maître **très long** (passphrase 4-5 mots) car le .kdbx peut être *brute-forcé hors ligne* ; option recommandée : 2ᵉ facteur matériel (YubiKey / challenge-response KeePassXC).
2. **Conflits de synchro** : ne jamais éditer depuis 2 appareils hors-ligne ; activer l'historique/versions Nextcloud + la sauvegarde automatique de KeePassXC (fichier .old).
3. **Une copie hors-ligne** du .kdbx (clé USB / autre cloud) : sinon PC + téléphone morts = tu dépend de Nextcloud, donc du VPS.
4. **Extension KeePassXC-Browser** : garder l'autorisation **par site** (elle demande au premier accès) plutôt qu'un accès global ; et noter la phrase de passe maître **hors ligne** (lebesoin sans récupération possible).
