diff --git a/.env.example b/.env.example index a4a4caf..c4743d3 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,10 @@ # ── Monitorink — copier en .env et compléter (ne jamais versionner .env) ── -# Token Claude longue durée, généré par `claude setup-token` sur le homelab. -MONITORINK_CLAUDE_TOKEN=sk-ant-oat01-xxxxxxxx +# Credentials Claude d'un login ISOLÉ dédié à Monitorink (scope user:profile requis par +# /usage -> login complet obligatoire, `claude setup-token` ne suffit PAS). +# Créer via : CLAUDE_CONFIG_DIR=/home/jerem/.monitorink-claude claude auth login +# Le backend lit/écrit ce fichier (refresh) ; il est monté dans le conteneur sur /creds. +MONITORINK_CLAUDE_CREDS=/creds/.credentials.json # User-Agent attendu par l'endpoint /usage (doit ressembler à claude-code/) MONITORINK_CLAUDE_UA=claude-code/2.1.172 # Burn rate via ccusage (nécessite ccusage installé + ~/.claude/projects monté). 0/1 diff --git a/README.md b/README.md index 184e1e8..65f0651 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,11 @@ de tokens — Anthropic n'expose pas de compteur absolu côté abonnement. Monit le **% restant** (`100 − utilisation`) et le temps avant reset. *(ChatGPT : hors scope, aucune API officielle de quota restant.)* -Le backend s'authentifie avec un **token longue durée dédié** généré par `claude setup-token` -(env `MONITORINK_CLAUDE_TOKEN`) — il ne touche jamais aux credentials de Claude Code et ne fait -aucun refresh (le token est valide ~1 an). +L'endpoint `/usage` exige le scope OAuth `user:profile`, que le token `claude setup-token` +**n'a pas** (403). Le backend utilise donc les credentials d'un **login Claude isolé dédié** +à Monitorink (`CLAUDE_CONFIG_DIR` séparé) : scopes complets + lignée de refresh token propre, +sans aucun conflit avec le Claude Code du Mac ou du homelab. Le backend lit/rafraîchit +**uniquement ce fichier isolé** (`/creds/.credentials.json`), jamais le `~/.claude` partagé. ## Démarrage backend (dev, sur Mac) @@ -46,8 +48,10 @@ cp ../.env.example ../.env # puis compléter ## Déploiement (homelab, Docker + Caddy) -1. Générer le token sur le homelab : `ssh -t homelab claude setup-token`. -2. Créer `.env` (depuis `.env.example`) avec le token, les coords météo, et la config HA. +1. Login Claude isolé dédié (scopes complets) sur le homelab : + `CLAUDE_CONFIG_DIR=/home/jerem/.monitorink-claude claude auth login` +2. Créer `.env` (depuis `.env.example`) : coords météo, config HA. Le compose monte + `~/.monitorink-claude` sur `/creds` (lecture/écriture pour le refresh du token). 3. `docker compose up -d --build` (le service s'expose via caddy-docker-proxy sur `monitorink.homelab.nestor-server.fr`). diff --git a/backend/config.py b/backend/config.py index dc92dad..6e4200c 100644 --- a/backend/config.py +++ b/backend/config.py @@ -48,7 +48,13 @@ class Config: height: int = field(default_factory=lambda: int(_get("MONITORINK_HEIGHT", "1680"))) # --- Claude --- - claude_token: str = field(default_factory=lambda: _get("MONITORINK_CLAUDE_TOKEN")) + # Chemin du fichier .credentials.json d'un login Claude ISOLÉ dédié à Monitorink + # (CLAUDE_CONFIG_DIR séparé). Le backend y lit/écrit (refresh) sans toucher le + # ~/.claude partagé. L'endpoint /usage exige le scope user:profile -> login complet + # requis (le token `claude setup-token` ne suffit pas, scope insuffisant). + claude_creds_path: str = field( + default_factory=lambda: _get("MONITORINK_CLAUDE_CREDS", "/creds/.credentials.json") + ) claude_ua: str = field( default_factory=lambda: _get("MONITORINK_CLAUDE_UA", "claude-code/2.1.172") ) diff --git a/backend/integrations/claude_usage.py b/backend/integrations/claude_usage.py index 4b93b08..79cc64d 100644 --- a/backend/integrations/claude_usage.py +++ b/backend/integrations/claude_usage.py @@ -7,14 +7,21 @@ Validé empiriquement (compte Max 5x) : réponse: { five_hour:{utilization,resets_at}, seven_day:{...}, seven_day_opus, seven_day_sonnet, extra_usage } -Le token utilisé est un token longue durée dédié généré par `claude setup-token` -(env MONITORINK_CLAUDE_TOKEN). Aucun refresh/écriture n'est effectué ici : en cas de 401, -on remonte un état d'erreur pour affichage (« token à régénérer »). +⚠️ L'endpoint exige le scope OAuth `user:profile`. Le token `claude setup-token` ne l'a PAS +(403). On utilise donc les credentials d'un **login Claude isolé dédié** (CLAUDE_CONFIG_DIR +séparé) : structure `{ claudeAiOauth: { accessToken, refreshToken, expiresAt(ms) } }`. + +Le token expire (~8 h) ; on le rafraîchit via platform.claude.com et on réécrit le fichier +isolé de façon atomique. On ne touche jamais le ~/.claude partagé. """ from __future__ import annotations +import asyncio import json +import os import subprocess +import tempfile +import time from dataclasses import dataclass from datetime import datetime, timezone @@ -23,6 +30,11 @@ import httpx from config import config USAGE_URL = "https://api.anthropic.com/api/oauth/usage" +REFRESH_URL = "https://platform.claude.com/v1/oauth/token" +CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" # client public Claude Code +EXPIRY_BUFFER_MS = 120_000 # rafraîchit 2 min avant expiration + +_refresh_lock = asyncio.Lock() @dataclass @@ -77,20 +89,85 @@ def _parse_window(raw: dict | None) -> Window | None: return Window(utilization=util, resets_at=dt) +def _load_creds() -> dict: + with open(config.claude_creds_path) as f: + return json.load(f) + + +def _write_creds(data: dict) -> None: + """Écriture atomique du fichier de credentials isolé (mode 0600).""" + path = config.claude_creds_path + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".") + try: + with os.fdopen(fd, "w") as f: + json.dump(data, f) + os.chmod(tmp, 0o600) + os.replace(tmp, path) + except BaseException: + if os.path.exists(tmp): + os.unlink(tmp) + raise + + +async def _refresh(data: dict) -> str: + """Rafraîchit l'access token, réécrit le fichier isolé, renvoie le nouvel access token.""" + o = data["claudeAiOauth"] + body = { + "grant_type": "refresh_token", + "refresh_token": o["refreshToken"], + "client_id": CLIENT_ID, + } + async with httpx.AsyncClient(timeout=20) as client: + resp = await client.post( + REFRESH_URL, json=body, + headers={"Content-Type": "application/json", "User-Agent": config.claude_ua}, + ) + resp.raise_for_status() + tok = resp.json() + o["accessToken"] = tok["access_token"] + o["refreshToken"] = tok["refresh_token"] + o["expiresAt"] = int(time.time() * 1000) + int(tok["expires_in"]) * 1000 + _write_creds(data) + return o["accessToken"] + + +async def _valid_token() -> str: + """Renvoie un access token valide, en rafraîchissant si nécessaire (sérialisé).""" + data = _load_creds() + o = data["claudeAiOauth"] + if int(o.get("expiresAt", 0)) - int(time.time() * 1000) > EXPIRY_BUFFER_MS: + return o["accessToken"] + async with _refresh_lock: + # Re-lecture après acquisition du lock : un autre coroutine a peut-être déjà rafraîchi. + data = _load_creds() + o = data["claudeAiOauth"] + if int(o.get("expiresAt", 0)) - int(time.time() * 1000) > EXPIRY_BUFFER_MS: + return o["accessToken"] + return await _refresh(data) + + +async def _get_usage(token: str) -> httpx.Response: + headers = { + "Authorization": f"Bearer {token}", + "anthropic-beta": "oauth-2025-04-20", + "User-Agent": config.claude_ua, + "Content-Type": "application/json", + } + async with httpx.AsyncClient(timeout=15) as client: + return await client.get(USAGE_URL, headers=headers) + + def _burn_rate_from_ccusage() -> float | None: """Burn rate du bloc 5h actif via `ccusage blocks --json` (best-effort).""" try: proc = subprocess.run( ["ccusage", "blocks", "--active", "--json"], - capture_output=True, - text=True, - timeout=20, + capture_output=True, text=True, timeout=20, ) if proc.returncode != 0: return None data = json.loads(proc.stdout) - blocks = data.get("blocks") or [] - for b in blocks: + for b in data.get("blocks") or []: if b.get("isActive"): bp = b.get("burnRate") or {} rate = bp.get("tokensPerMinute") or bp.get("tokensPerMinuteForIndicator") @@ -101,23 +178,31 @@ def _burn_rate_from_ccusage() -> float | None: async def fetch_usage() -> ClaudeUsage: - if not config.claude_token: - return ClaudeUsage(ok=False, error="MONITORINK_CLAUDE_TOKEN manquant") + if not os.path.exists(config.claude_creds_path): + return ClaudeUsage(ok=False, error="credentials Claude absents — login isolé requis") - headers = { - "Authorization": f"Bearer {config.claude_token}", - "anthropic-beta": "oauth-2025-04-20", - "User-Agent": config.claude_ua, - "Content-Type": "application/json", - } try: - async with httpx.AsyncClient(timeout=15) as client: - resp = await client.get(USAGE_URL, headers=headers) + token = await _valid_token() + except (OSError, KeyError, json.JSONDecodeError) as exc: + return ClaudeUsage(ok=False, error=f"credentials illisibles: {exc}") + except httpx.HTTPError as exc: + return ClaudeUsage(ok=False, error=f"refresh échoué: {exc}") + + try: + resp = await _get_usage(token) + # Token rejeté malgré le buffer -> une tentative de refresh forcé. + if resp.status_code == 401: + data = _load_creds() + async with _refresh_lock: + token = await _refresh(data) + resp = await _get_usage(token) except httpx.HTTPError as exc: return ClaudeUsage(ok=False, error=f"réseau: {exc}") if resp.status_code == 401: - return ClaudeUsage(ok=False, error="token expiré — relancer `claude setup-token`") + return ClaudeUsage(ok=False, error="auth invalide — relancer le login isolé") + if resp.status_code == 403: + return ClaudeUsage(ok=False, error="scope insuffisant (login complet requis)") if resp.status_code != 200: return ClaudeUsage(ok=False, error=f"HTTP {resp.status_code}") diff --git a/dev/probe_usage.py b/dev/probe_usage.py index d90ee9d..4c40aad 100755 --- a/dev/probe_usage.py +++ b/dev/probe_usage.py @@ -1,11 +1,14 @@ #!/usr/bin/env python3 """Sonde de l'endpoint /usage Claude pour valider le parser de Monitorink. -Usage (sur le homelab, après `claude setup-token`) : - MONITORINK_CLAUDE_TOKEN="sk-ant-oat01-..." python3 dev/probe_usage.py +Lit l'access token depuis un fichier .credentials.json (login Claude isolé) puis appelle +/usage. Affiche la structure JSON brute (uniquement des % d'usage, aucun secret) + +l'interprétation de Monitorink. Sortie partageable sans risque. -Affiche la structure JSON brute renvoyée par l'API (uniquement des % d'usage, aucun secret) -+ l'interprétation que Monitorink en fait. Sortie partageable sans risque. +Usage (sur le homelab, après le login isolé) : + python3 dev/probe_usage.py /home/jerem/.monitorink-claude/.credentials.json +ou via env : + MONITORINK_CLAUDE_CREDS=/home/jerem/.monitorink-claude/.credentials.json python3 dev/probe_usage.py """ import json import os @@ -14,9 +17,17 @@ import urllib.error import urllib.request URL = "https://api.anthropic.com/api/oauth/usage" -token = os.environ.get("MONITORINK_CLAUDE_TOKEN", "").strip() -if not token: - sys.exit("MONITORINK_CLAUDE_TOKEN manquant (export-le ou préfixe la commande)") + +path = (sys.argv[1] if len(sys.argv) > 1 else "") or os.environ.get("MONITORINK_CLAUDE_CREDS", "") +path = path.strip() +if not path: + sys.exit("Chemin du .credentials.json requis (arg ou MONITORINK_CLAUDE_CREDS)") + +try: + creds = json.load(open(path)) + token = creds["claudeAiOauth"]["accessToken"] +except (OSError, KeyError, json.JSONDecodeError) as e: + sys.exit(f"Lecture credentials impossible: {e}") req = urllib.request.Request(URL, headers={ "Authorization": f"Bearer {token}", diff --git a/docker-compose.yml b/docker-compose.yml index 19d5c53..d911a82 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,10 +5,13 @@ services: container_name: monitorink restart: unless-stopped env_file: .env - # Optionnel : burn rate via ccusage (lecture seule des logs Claude Code du homelab). - # Décommenter et passer MONITORINK_CCUSAGE=1 si voulu. - # volumes: - # - /home/jerem/.claude/projects:/root/.claude/projects:ro + volumes: + # Login Claude ISOLÉ dédié à Monitorink (lecture/écriture pour le refresh du token). + # Créé via: CLAUDE_CONFIG_DIR=/home/jerem/.monitorink-claude claude auth login + - /home/jerem/.monitorink-claude:/creds:rw + # Optionnel : burn rate via ccusage (lecture seule des logs Claude Code principaux). + # Décommenter + MONITORINK_CCUSAGE=1. + # - /home/jerem/.claude/projects:/root/.claude/projects:ro networks: - nestorr labels: