Claude: passage au login isolé + refresh (scope user:profile requis par /usage)

This commit is contained in:
2026-06-15 11:03:51 +02:00
parent 789fc92c39
commit 74d33eb693
6 changed files with 150 additions and 38 deletions

View File

@@ -1,7 +1,10 @@
# ── Monitorink — copier en .env et compléter (ne jamais versionner .env) ── # ── 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. # Credentials Claude d'un login ISOLÉ dédié à Monitorink (scope user:profile requis par
MONITORINK_CLAUDE_TOKEN=sk-ant-oat01-xxxxxxxx # /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/<version>) # User-Agent attendu par l'endpoint /usage (doit ressembler à claude-code/<version>)
MONITORINK_CLAUDE_UA=claude-code/2.1.172 MONITORINK_CLAUDE_UA=claude-code/2.1.172
# Burn rate via ccusage (nécessite ccusage installé + ~/.claude/projects monté). 0/1 # Burn rate via ccusage (nécessite ccusage installé + ~/.claude/projects monté). 0/1

View File

@@ -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 le **% restant** (`100 utilisation`) et le temps avant reset. *(ChatGPT : hors scope, aucune
API officielle de quota restant.)* API officielle de quota restant.)*
Le backend s'authentifie avec un **token longue durée dédié** généré par `claude setup-token` L'endpoint `/usage` exige le scope OAuth `user:profile`, que le token `claude setup-token`
(env `MONITORINK_CLAUDE_TOKEN`) — il ne touche jamais aux credentials de Claude Code et ne fait **n'a pas** (403). Le backend utilise donc les credentials d'un **login Claude isolé dédié**
aucun refresh (le token est valide ~1 an). à 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) ## Démarrage backend (dev, sur Mac)
@@ -46,8 +48,10 @@ cp ../.env.example ../.env # puis compléter
## Déploiement (homelab, Docker + Caddy) ## Déploiement (homelab, Docker + Caddy)
1. Générer le token sur le homelab : `ssh -t homelab claude setup-token`. 1. Login Claude isolé dédié (scopes complets) sur le homelab :
2. Créer `.env` (depuis `.env.example`) avec le token, les coords météo, et la config HA. `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 3. `docker compose up -d --build` (le service s'expose via caddy-docker-proxy sur
`monitorink.homelab.nestor-server.fr`). `monitorink.homelab.nestor-server.fr`).

View File

@@ -48,7 +48,13 @@ class Config:
height: int = field(default_factory=lambda: int(_get("MONITORINK_HEIGHT", "1680"))) height: int = field(default_factory=lambda: int(_get("MONITORINK_HEIGHT", "1680")))
# --- Claude --- # --- 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( claude_ua: str = field(
default_factory=lambda: _get("MONITORINK_CLAUDE_UA", "claude-code/2.1.172") default_factory=lambda: _get("MONITORINK_CLAUDE_UA", "claude-code/2.1.172")
) )

View File

@@ -7,14 +7,21 @@ Validé empiriquement (compte Max 5x) :
réponse: { five_hour:{utilization,resets_at}, seven_day:{...}, réponse: { five_hour:{utilization,resets_at}, seven_day:{...},
seven_day_opus, seven_day_sonnet, extra_usage } 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` ⚠️ L'endpoint exige le scope OAuth `user:profile`. Le token `claude setup-token` ne l'a PAS
(env MONITORINK_CLAUDE_TOKEN). Aucun refresh/écriture n'est effectué ici : en cas de 401, (403). On utilise donc les credentials d'un **login Claude isolé dédié** (CLAUDE_CONFIG_DIR
on remonte un état d'erreur pour affichage (« token à régénérer »). 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 from __future__ import annotations
import asyncio
import json import json
import os
import subprocess import subprocess
import tempfile
import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -23,6 +30,11 @@ import httpx
from config import config from config import config
USAGE_URL = "https://api.anthropic.com/api/oauth/usage" 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 @dataclass
@@ -77,20 +89,85 @@ def _parse_window(raw: dict | None) -> Window | None:
return Window(utilization=util, resets_at=dt) 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: def _burn_rate_from_ccusage() -> float | None:
"""Burn rate du bloc 5h actif via `ccusage blocks --json` (best-effort).""" """Burn rate du bloc 5h actif via `ccusage blocks --json` (best-effort)."""
try: try:
proc = subprocess.run( proc = subprocess.run(
["ccusage", "blocks", "--active", "--json"], ["ccusage", "blocks", "--active", "--json"],
capture_output=True, capture_output=True, text=True, timeout=20,
text=True,
timeout=20,
) )
if proc.returncode != 0: if proc.returncode != 0:
return None return None
data = json.loads(proc.stdout) data = json.loads(proc.stdout)
blocks = data.get("blocks") or [] for b in data.get("blocks") or []:
for b in blocks:
if b.get("isActive"): if b.get("isActive"):
bp = b.get("burnRate") or {} bp = b.get("burnRate") or {}
rate = bp.get("tokensPerMinute") or bp.get("tokensPerMinuteForIndicator") rate = bp.get("tokensPerMinute") or bp.get("tokensPerMinuteForIndicator")
@@ -101,23 +178,31 @@ def _burn_rate_from_ccusage() -> float | None:
async def fetch_usage() -> ClaudeUsage: async def fetch_usage() -> ClaudeUsage:
if not config.claude_token: if not os.path.exists(config.claude_creds_path):
return ClaudeUsage(ok=False, error="MONITORINK_CLAUDE_TOKEN manquant") 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: try:
async with httpx.AsyncClient(timeout=15) as client: token = await _valid_token()
resp = await client.get(USAGE_URL, headers=headers) 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: except httpx.HTTPError as exc:
return ClaudeUsage(ok=False, error=f"réseau: {exc}") return ClaudeUsage(ok=False, error=f"réseau: {exc}")
if resp.status_code == 401: 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: if resp.status_code != 200:
return ClaudeUsage(ok=False, error=f"HTTP {resp.status_code}") return ClaudeUsage(ok=False, error=f"HTTP {resp.status_code}")

View File

@@ -1,11 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Sonde de l'endpoint /usage Claude pour valider le parser de Monitorink. """Sonde de l'endpoint /usage Claude pour valider le parser de Monitorink.
Usage (sur le homelab, après `claude setup-token`) : Lit l'access token depuis un fichier .credentials.json (login Claude isolé) puis appelle
MONITORINK_CLAUDE_TOKEN="sk-ant-oat01-..." python3 dev/probe_usage.py /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) Usage (sur le homelab, après le login isolé) :
+ l'interprétation que Monitorink en fait. Sortie partageable sans risque. 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 json
import os import os
@@ -14,9 +17,17 @@ import urllib.error
import urllib.request import urllib.request
URL = "https://api.anthropic.com/api/oauth/usage" URL = "https://api.anthropic.com/api/oauth/usage"
token = os.environ.get("MONITORINK_CLAUDE_TOKEN", "").strip()
if not token: path = (sys.argv[1] if len(sys.argv) > 1 else "") or os.environ.get("MONITORINK_CLAUDE_CREDS", "")
sys.exit("MONITORINK_CLAUDE_TOKEN manquant (export-le ou préfixe la commande)") 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={ req = urllib.request.Request(URL, headers={
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",

View File

@@ -5,9 +5,12 @@ services:
container_name: monitorink container_name: monitorink
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
# Optionnel : burn rate via ccusage (lecture seule des logs Claude Code du homelab). volumes:
# Décommenter et passer MONITORINK_CCUSAGE=1 si voulu. # Login Claude ISOLÉ dédié à Monitorink (lecture/écriture pour le refresh du token).
# volumes: # 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 # - /home/jerem/.claude/projects:/root/.claude/projects:ro
networks: networks:
- nestorr - nestorr