WireGuard vs Tailscale : quel VPN pour accéder à ton homelab depuis l’extérieur ?
Depuis un hôtel en déplacement, j'ai voulu me connecter à l'interface Proxmox de heighliner pour…
Je me lève, je fais du café, j’ouvre mon téléphone. Et depuis quelques mois, le premier message que je lis sur Telegram, c’est un résumé de l’état de mon homelab rédigé en français par Claude. Pas une alerte sèche « CPU 87% », pas une liste de métriques brutes – une vraie synthèse lisible, avec du contexte, des observations, parfois une recommandation.
« Cette nuit, heighliner a tourné à 23 % de CPU en moyenne. Pic à 61 % entre 03h12 et 03h18, corrélé avec la sauvegarde PBS hebdomadaire. Mémoire disponible stable à 11,2 GB. Rien d’anormal. »
Voilà le genre de phrase que je lis en buvant mon café. Ça m’a pris trois soirs à mettre en place. Je vais vous expliquer comment.
J’ai déjà écrit sur comment brancher Claude sur les webhooks Grafana pour enrichir les alertes en temps réel. Cet article couvre quelque chose de différent : le briefing ops quotidien. Pas réactif – proactif. Ce n’est pas « une alerte a déclenché, voilà du contexte ». C’est « voilà ce qui s’est passé cette nuit, voilà où j’en suis ».
L’angle est différent côté prompt engineering aussi. Pour une alerte, je veux une hypothèse rapide sur la cause. Pour un briefing matinal, je veux une narration qui transforme des séries temporelles en texte humain. Ce sont deux exercices distincts.
Un script Python tourne chaque matin à 07h00 via un systemd timer sur hubble (mon VPS OVH de monitoring). Il interroge l’API Prometheus pour les métriques des dernières 24 heures, formate ces données en texte structuré, envoie le tout à l’API Claude avec un prompt système calibré, et pousse le résumé sur Telegram.
Voilà ce que ça donne sur le papier :
Prometheus → Python → Claude API → Telegram
Pas de base de données, pas de serveur supplémentaire, pas de webhook. Juste un script, un timer, et une API.
C’est la première décision à prendre, et c’est aussi celle qui a le plus d’impact sur la qualité du résumé.
J’ai testé deux approches. La première : envoyer les séries temporelles brutes (timestamp, valeur, timestamp, valeur…). Claude peut en extraire des statistiques, mais le résultat est verbeux et générique – il passe plus de temps à recalculer la moyenne que je lui ai déjà calculée moi-même.
La deuxième approche, celle que j’utilise maintenant : pré-agréger côté Python et envoyer uniquement les résultats. Moyenne sur 24h, max, min, moment du pic pour chaque métrique. Claude reçoit des faits, pas des données brutes. Il peut alors se concentrer sur la narration et l’interprétation, pas sur le calcul.
def summarize_series(values: list[tuple]) -> dict:
"""
Prend une liste [(timestamp, valeur_str), ...] retournée par Prometheus
et retourne un dict avec mean, max, min, et le timestamp du max.
"""
floats = [(ts, float(v)) for ts, v in values if v != "NaN"]
if not floats:
return {"mean": None, "max": None, "min": None, "peak_at": None}
vals = [v for _, v in floats]
peak_ts, peak_val = max(floats, key=lambda x: x[1])
return {
"mean": round(sum(vals) / len(vals), 1),
"max": round(peak_val, 1),
"min": round(min(vals), 1),
"peak_at": datetime.fromtimestamp(peak_ts).strftime("%Hh%M"),
}
#!/usr/bin/env python3
# /opt/homelab-scripts/morning-briefing.py
import os
import requests
import anthropic
from datetime import datetime, timedelta, timezone
PROMETHEUS_URL = "http://hubble.arewel.com:9090"
TELEGRAM_TOKEN = os.environ["TELEGRAM_TOKEN"]
TELEGRAM_CHAT_ID = os.environ["TELEGRAM_CHAT_ID"]
ANTHROPIC_API_KEY = os.environ["ANTHROPIC_API_KEY"]
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
def query_range(promql: str, hours: int = 24, step: str = "5m") -> list:
"""Interroge Prometheus sur les dernières hours heures."""
end = datetime.now(timezone.utc)
start = end - timedelta(hours=hours)
resp = requests.get(
f"{PROMETHEUS_URL}/api/v1/query_range",
params={
"query": promql,
"start": start.isoformat() + "Z",
"end": end.isoformat() + "Z",
"step": step,
},
timeout=10,
)
resp.raise_for_status()
results = resp.json().get("data", {}).get("result", [])
if not results:
return []
return results[0].get("values", [])
def summarize_series(values: list) -> dict:
floats = [(float(ts), float(v)) for ts, v in values if v != "NaN"]
if not floats:
return {"mean": None, "max": None, "min": None, "peak_at": None}
vals = [v for _, v in floats]
peak_ts, peak_val = max(floats, key=lambda x: x[1])
return {
"mean": round(sum(vals) / len(vals), 1),
"max": round(peak_val, 1),
"min": round(min(vals), 1),
"peak_at": datetime.fromtimestamp(peak_ts, tz=timezone.utc).strftime("%Hh%M UTC"),
}
def collect_metrics() -> dict:
"""Collecte et agrège les métriques des dernières 24 heures."""
metrics = {}
# CPU heighliner (pourcentage d'utilisation, toutes cores)
cpu_values = query_range(
'100 - avg(rate(node_cpu_seconds_total{instance="heighliner:9100",'
'mode="idle"}[5m])) * 100'
)
metrics["cpu"] = summarize_series(cpu_values)
# Mémoire disponible en GB
mem_values = query_range(
'node_memory_MemAvailable_bytes{instance="heighliner:9100"} / 1073741824'
)
metrics["mem_gb"] = summarize_series(mem_values)
# Espace disque root disponible en GB
disk_values = query_range(
'node_filesystem_avail_bytes{instance="heighliner:9100",'
'mountpoint="/"} / 1073741824'
)
metrics["disk_root_gb"] = summarize_series(disk_values)
# Trafic réseau entrant en Mbps (interface principale)
net_rx_values = query_range(
'rate(node_network_receive_bytes_total{instance="heighliner:9100",'
'device="enp2s0"}[5m]) * 8 / 1000000'
)
metrics["net_rx_mbps"] = summarize_series(net_rx_values)
# Température CPU si disponible (lm-sensors via node_exporter)
temp_values = query_range(
'node_hwmon_temp_celsius{instance="heighliner:9100",chip=~"coretemp.*",'
'sensor="temp1"}'
)
metrics["cpu_temp"] = summarize_series(temp_values)
# Nombre de VMs/LXC actifs sur Proxmox (via SNMP ou Proxmox exporter si dispo)
# J'utilise une métrique custom que je pousse depuis un script cron
vm_values = query_range('homelab_proxmox_running_vms{instance="heighliner"}')
metrics["running_vms"] = summarize_series(vm_values)
return metrics
def format_metrics_for_claude(metrics: dict) -> str:
"""Transforme le dict de métriques en texte structuré pour le prompt."""
date_str = datetime.now().strftime("%A %d %B %Y")
lines = [f"Métriques homelab - heighliner - {date_str} (dernières 24h)n"]
cpu = metrics.get("cpu", {})
if cpu.get("mean") is not None:
lines.append(
f"CPU : moyenne {cpu['mean']}%, max {cpu['max']}% à {cpu['peak_at']},"
f" min {cpu['min']}%"
)
mem = metrics.get("mem_gb", {})
if mem.get("mean") is not None:
lines.append(
f"Mémoire disponible : moyenne {mem['mean']} GB, min {mem['min']} GB"
)
disk = metrics.get("disk_root_gb", {})
if disk.get("mean") is not None:
lines.append(f"Disque root disponible : {disk['min']} GB (minimum sur 24h)")
net = metrics.get("net_rx_mbps", {})
if net.get("max") is not None:
lines.append(
f"Réseau entrant : pic à {net['max']} Mbps, moyenne {net['mean']} Mbps"
)
temp = metrics.get("cpu_temp", {})
if temp.get("max") is not None:
lines.append(f"Température CPU : max {temp['max']} °C")
vms = metrics.get("running_vms", {})
if vms.get("mean") is not None:
lines.append(f"VMs/LXC actifs : {int(vms['mean'])} en moyenne")
return "n".join(lines)
def generate_briefing(metrics_text: str) -> str:
"""Envoie les métriques à Claude et retourne le briefing rédigé."""
message = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=400,
system=(
"Tu es l'assistant ops d'un homelab personnel. "
"Chaque matin, tu rédiges un briefing factuel et concis en français. "
"Ton style : direct, factuel, pas d'enthousiasme artificiel. "
"Structure imposée :n"
"1. Une phrase d'état général (stable / attention / problème).n"
"2. 2 à 4 observations notables. Signale les anomalies, les pics inhabituels, "
"les tendances. Si tout est normal, dis-le en une phrase, ne fabrique pas "
"d'observations.n"
"3. Une phrase de recommandation si nécessaire, sinon rien.n"
"Longueur totale : 80 à 120 mots maximum. "
"Pas de markdown. Pas de listes à puces. Texte courant uniquement."
),
messages=[
{
"role": "user",
"content": metrics_text,
}
],
)
return message.content[0].text
def send_telegram(text: str) -> None:
"""Envoie le briefing via le bot Telegram."""
payload = {
"chat_id": TELEGRAM_CHAT_ID,
"text": f"☀️ Briefing heighlinernn{text}",
"parse_mode": "HTML",
}
resp = requests.post(
f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage",
json=payload,
timeout=10,
)
resp.raise_for_status()
if __name__ == "__main__":
print(f"[{datetime.now().isoformat()}] Collecte des métriques Prometheus...")
metrics = collect_metrics()
metrics_text = format_metrics_for_claude(metrics)
print(metrics_text)
print("Génération du briefing via Claude API...")
briefing = generate_briefing(metrics_text)
print(f"n--- BRIEFING ---n{briefing}n")
send_telegram(briefing)
print("Briefing envoyé sur Telegram.")
Je préfère systemd à cron pour les tâches périodiques – les logs sont dans journald, les failures sont visibles avec systemctl status, et je peux relancer à la main sans éditer une crontab.
# /etc/systemd/system/homelab-briefing.service
[Unit]
Description=Briefing ops quotidien via Claude API
After=network-online.target
[Service]
Type=oneshot
User=homelab
EnvironmentFile=/etc/homelab/secrets.env
ExecStart=/opt/homelab-scripts/venv/bin/python /opt/homelab-scripts/morning-briefing.py
StandardOutput=journal
StandardError=journal
# /etc/systemd/system/homelab-briefing.timer
[Unit]
Description=Lance le briefing ops chaque matin à 07h00
[Timer]
OnCalendar=*-*-* 07:00:00
Persistent=true
[Install]
WantedBy=timers.target
# Activer et démarrer le timer
sudo systemctl enable --now homelab-briefing.timer
# Vérifier
sudo systemctl list-timers homelab-briefing.timer
# Tester sans attendre 07h00
sudo systemctl start homelab-briefing.service
sudo journalctl -u homelab-briefing.service -f
Le fichier /etc/homelab/secrets.env contient les variables d’environnement sensibles, propriété root:root, mode 600 :
ANTHROPIC_API_KEY=sk-ant-...
TELEGRAM_TOKEN=...
TELEGRAM_CHAT_ID=...
La première version du prompt système disait simplement : « Rédige un résumé des métriques homelab en français. » Le résultat était techniquement correct et totalement inutilisable. Claude listait chaque métrique avec sa valeur, ajoutait des phrases comme « La mémoire disponible est de 11,2 GB, ce qui est satisfaisant », et produisait 300 mots de compte-rendu académique. Pas ce que je voulais lire à 07h00.
Trois modifications ont tout changé :
1. Contraindre la structure. En imposant le format (1 phrase d’état, 2-4 observations, 1 recommandation ou rien), j’ai éliminé le remplissage. Claude ne peut plus diluer en reformulant les données.
2. Interdire le markdown. Les listes à puces dans Telegram sont jolies sur papier, mais • CPU moyen : 23 % ressemble à du code mort à 07h00. Texte courant uniquement.
3. Imposer une limite de mots. « 80 à 120 mots maximum » est la contrainte la plus efficace. Sans elle, Claude a tendance à être exhaustif par défaut. Avec, il est forcé de prioriser.
Une chose que j’ai laissée à la discrétion de Claude : le choix de ce qui est « notable ». Je lui fournis les données pré-agrégées et il décide si un pic CPU à 61 % mérite d’être mentionné ou non. Ça marche bien – il a le contexte pour juger, et je ne veux pas écrire une heuristique « si CPU > X alors signale ».
Voilà ce que ça me coûte sur un mois avec claude-sonnet-4-5 :
| Poste | Détail | Coût estimé |
|---|---|---|
| Tokens entrants | ~200 tokens/jour × 30 = 6 000 tokens | ~$0,009 |
| Tokens sortants | ~100 tokens/jour × 30 = 3 000 tokens | ~$0,045 |
| Total mensuel | ~$0,054 |
Soit environ $0,002/jour. En pratique, avec quelques tests et relances manuelles, je suis plutôt à $0,03/jour en phase de développement, $0,002/jour en exploitation normale.
J’aurais pu utiliser claude-haiku-4-5 pour aller encore plus bas – environ 10× moins cher. Je l’ai testé, les résultats sont acceptables mais moins nuancés. Pour ce cas d’usage, le petit écart de coût vaut la différence de qualité. À $0,054/mois, la question ne se pose pas vraiment.
Si vous avez plusieurs machines dans votre homelab, ajoutez les métriques de chaque nœud dans le même appel API. Le coût n’augmente que linéairement avec les tokens entrants, et vous évitez un appel API par machine.
Trois exemples réels depuis que j’utilise ce script :
Backup PBS qui drift. Pendant deux semaines, le pic CPU du matin se déplaçait progressivement de 03h12 à 03h41. Claude a mentionné le drift dans le briefing au bout du cinquième jour : « Le pic de backup se décale progressivement – à surveiller si PBS est configuré en mode adaptatif. » J’ai vérifié, en effet j’avais une option adaptive-scheduling activée par erreur.
Disque root qui descend trop vite. Un matin, le briefing indiquait « espace disque root : 18 GB disponibles, en baisse d’environ 2 GB sur les 7 derniers jours selon la tendance actuelle ». Je n’avais pas d’alerte configurée pour ça – juste un seuil à 10 GB. J’ai trouvé un répertoire /tmp/pve-manager-* qui s’accumulait suite à des updates avortées.
Température anormalement stable. Un contre-exemple – Claude a mentionné que la température CPU était « remarquablement stable à 38 °C sur toute la période ». Ce n’était pas une anomalie, c’était juste que j’avais changé la pâte thermique la semaine d’avant. Faux positif, mais bénin.
Claude ne voit que ce que je lui envoie. Si une VM est dans un état problématique mais que je n’exporte pas la métrique correspondante dans Prometheus, il ne le sait pas. Le script est aussi bon que l’instrumentation sous-jacente – c’est une évidence, mais ça se rappelle.
Il ne fait pas de corrélations complexes non plus. Si deux métriques bougent ensemble pour une raison non triviale, il ne l’identifiera pas spontanément à moins que les données ne le rendent évident. Pour les corrélations fines, Grafana avec des dashboards bien construits reste supérieur.
Et il arrive que le résumé soit plat quand il n’y a rien à signaler. C’est correct – c’est ce que j’ai demandé. Mais après trois semaines de « tout est stable », on a tendance à ne plus lire le briefing. J’ai ajouté une section optionnelle « fait du jour » – une anecdote ou un chiffre intéressant même quand tout va bien – mais c’est encore en test.
Le script est disponible dans mon repo de configs homelab. Si vous l’adaptez pour d’autres métriques (temperature ESP32, PiHole DNS blocks, Synology), la structure reste identique – seul le bloc collect_metrics() change.
Article connexe : Alertes Grafana enrichies par Claude API – webhooks et enrichissement temps réel →
Cet article est vivant - corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisissez celui qui vous convient.