Files
Loki/internal/loki/web_auth.go
T
MichaelandClaude Opus 5.5 01c11be4ae Correctifs repris d'AJEAN et d'OpenFox : LSP, shell, clé web, MCP, recall, compactage, relances
- LSP (lsp.go) : un signal resté dans le tampon par une republication
  tardive faisait rendre « aucun diagnostic » à l'édition suivante, avant même
  que le serveur ait vu la nouvelle version. Le canal est vidé, une
  publication d'une version antérieure est ignorée, et check attend 350 ms de
  calme après la dernière publication (liste vide puis analyse : c'est
  l'analyse qui compte), toujours borné par lspDiagWait. Au passage :
  interblocage latent levé (writeMsg → markDead reprenait mu, déjà tenu par
  check) — dead devient atomique.
- run_shell : ErrWaitDelay (commande finie, process laissé en arrière-plan
  sur les tubes) rend la sortie au lieu de « WaitDelay expired » qui
  l'effaçait (AJEAN 7403a8a0).
- Clé de pilotage web : lue depuis un cache à chaque requête au lieu d'ouvrir
  la base sous dbMu (AJEAN #120). Lectures réussies seulement, invalidé par
  toute écriture du bucket d'état, par la date/taille du fichier (autre
  process) et au bout d'une seconde.
- MCP : l'appel suit le contexte du tour (le bouton stop l'annule), délai par
  serveur (callTimeoutSec) et allongé par l'argument timeout de l'outil,
  plafonné à 30 min ; un délai dépassé n'est plus relancé (OpenFox 2ef584b8).
- Recall : les blocs d'une compaction s'archivent en une seule transaction
  au lieu d'une par bloc (AJEAN #130).
- Compactage en cours de tour : après un essai qui n'a rien réduit, pas de
  nouvel essai avant +5 % de la fenêtre (sauf à 90 %), pour les tâches et
  sous-agents aussi ; la cause d'un résumé raté est journalisée (AJEAN
  68659e73).
- Motifs de relance : un motif vide (qui correspondait à tout) est écarté,
  une surcharge sans motif utilisable retombe sur l'embarqué, et un
  changement de retry_patterns agit sans redémarrer (OpenFox 8e9b4869,
  c52c5bf7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:05:04 +02:00

300 lines
12 KiB
Go

package loki
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
)
// web_auth.go protège l'API de pilotage (loki web) quand elle est exposée sur
// internet — c.-à-d. l'API que tout client (navigateur, app mobile, script…)
// utilise pour switcher de preset, redémarrer le service, lire le status, etc.
//
// La clé de pilotage est volontairement DISTINCTE de .api_key (qui, elle,
// protège llama-server / les complétions). On veut pouvoir donner à un client
// un accès aux complétions sans lui donner le droit de redémarrer la machine,
// et inversement. Elle est relue à chaque requête,
// depuis un cache invalidé à chaque écriture (voir readWebKeyErr), pour qu'un
// changement de clé prenne effet sans redémarrer le serveur web.
// readWebKeyErr renvoie la clé de pilotage en distinguant « aucune clé » d'une
// LECTURE RATÉE. La nuance est tout sauf cosmétique : sans clé, l'API est
// ouverte. Confondre les deux, c'est ouvrir l'API parce que la base était
// momentanément verrouillée par une commande CLI — une panne d'E/S qui désarme
// l'authentification. requireWebAuth refuse donc plutôt que d'ouvrir.
//
// requireWebAuth la consulte à CHAQUE requête : sans cache, chacune payait une
// ouverture complète de la base sous dbMu (vignettes chargées deux par deux,
// SSE, statut…), en concurrence avec l'écrivain des discussions (AJEAN #120).
// On garde la dernière lecture RÉUSSIE, invalidée comme cachedKV : écriture de
// ce process (cacheBust du bucket d'état), date/taille du fichier pour les
// autres process (la CLI), une seconde au plus. Une lecture ratée n'est jamais
// mise en cache : elle remonte, et l'API reste fermée.
func readWebKeyErr() (string, error) {
path := dbPath()
mtime, size := dbStamp()
c := &webKeyCache
c.Lock()
if c.ok && c.path == path && c.size == size && c.mtime.Equal(mtime) && time.Since(c.when) < cacheMaxAge {
k := c.key
c.Unlock()
return k, nil
}
gen := c.gen
c.Unlock()
b, err := getBytesErr(bkState, "web_key")
if err != nil {
return "", err
}
// Date et taille relevées APRÈS la lecture : ouvrir la base peut à lui seul
// toucher le fichier (Windows), et un relevé d'avant ne correspondait alors
// jamais. Une écriture externe glissée entre la lecture et le relevé reste
// couverte par l'âge maximal.
mtime, size = dbStamp()
c.Lock()
// Une invalidation pendant la lecture : la valeur lue est peut-être déjà
// l'ancienne — ne pas la garder.
if c.gen == gen {
c.ok, c.path, c.key, c.when, c.mtime, c.size = true, path, string(b), time.Now(), mtime, size
}
c.Unlock()
return string(b), nil
}
var webKeyCache struct {
sync.Mutex
ok bool
gen uint64 // incrémenté à chaque invalidation
path string // la base lue (LOKI_HOME change d'un test à l'autre)
key string
when time.Time
mtime time.Time
size int64
}
// webKeyCacheBust : appelé par cacheBust pour toute écriture du bucket d'état.
func webKeyCacheBust() {
webKeyCache.Lock()
webKeyCache.ok = false
webKeyCache.gen++
webKeyCache.Unlock()
}
// readWebKey renvoie la clé de pilotage, ou "" si aucune n'est définie (ou
// illisible). Réservé à l'affichage ; toute décision d'accès passe par
// readWebKeyErr.
func readWebKey() string { k, _ := readWebKeyErr(); return k }
// requireWebAuth wraps an HTTP handler, rejecting requests that don't present
// the configured Bearer token. When no key is configured the handler is left
// open (pratique en local) — cmdWeb avertit alors bruyamment au démarrage.
func requireWebAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key, err := readWebKeyErr()
if err != nil {
// On ne sait pas si une clé protège cette API : on ferme.
sendJSON(w, http.StatusServiceUnavailable,
map[string]any{"error": "configuration illisible — réessaie dans un instant"})
return
}
if msg := crossSiteReject(r, key != ""); msg != "" {
sendJSON(w, http.StatusForbidden, map[string]any{"error": msg})
return
}
if key == "" {
next(w, r)
return
}
if !checkBearer(r, key) {
w.Header().Set("WWW-Authenticate", `Bearer realm="loki"`)
sendJSON(w, http.StatusUnauthorized, map[string]any{"error": "non autorisé"})
return
}
next(w, r)
}
}
// requireCompletionKey protège la surface compatible OpenAI (/v1) avec la clé
// des COMPLÉTIONS, et non avec la clé de pilotage. Trois raisons, dans l'ordre
// d'importance :
//
// 1. Sémantique : c'est exactement la séparation décrite en tête de ce fichier
// — on veut pouvoir donner à une app tierce l'accès aux complétions sans lui
// donner le droit de redémarrer la machine.
// 2. Technique : un client OpenAI n'a qu'UN en-tête Authorization. S'il y met la
// clé des complétions, une garde de pilotage le refuse ; s'il y met celle de
// pilotage, llama-server (qui reçoit l'en-tête tel quel) la refuse. Les deux
// gardes ne peuvent pas être satisfaites en même temps.
// 3. Format : les SDK OpenAI lisent body.error.message ; un {"error":"…"} plat
// leur fait afficher un message vide.
func requireCompletionKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Préflight CORS : un OPTIONS ne porte JAMAIS d'Authorization. Le refuser
// casse tout client tiers qui tourne dans un navigateur (playground web,
// interface de chat hébergée ailleurs). llama-server répond lui-même le
// préflight, ses en-têtes CORS traversent le proxy tels quels.
if r.Method == http.MethodOptions {
next.ServeHTTP(w, r)
return
}
// Trafic venu du tunnel du relais : la surface OpenAI n'y est ouverte que
// si le drapeau historique l'autorise. Sans lui, le tunnel ne servait
// cette surface que par son front TLS dédié ; la monter sur le mux ne
// doit pas l'ouvrir en douce par le canal HTTP.
if r.Header.Get(viaTunnelHeader) == "tunnel" && !oaiPublicEnabled() {
sendOAIError(w, http.StatusNotFound,
"chemin inconnu — l'endpoint compatible OpenAI est /v1/*", "invalid_request_error", "not_found")
return
}
key, err := effectiveAPIKeyErr()
if err != nil {
// On ne sait pas si une clé protège cet endpoint : on ferme.
sendOAIError(w, http.StatusServiceUnavailable,
"configuration illisible — réessaie dans un instant", "api_error", "service_unavailable")
return
}
if key == "" {
// Aucune clé : endpoint ouvert, comme llama-server sans --api-key. Le
// panneau Accès OpenAI l'affiche en rouge, et cmdWeb le crie au
// démarrage — c'est le prix d'un réglage local sans friction.
next.ServeHTTP(w, r)
return
}
if !checkBearer(r, key) {
w.Header().Set("WWW-Authenticate", `Bearer realm="loki"`)
sendOAIError(w, http.StatusUnauthorized,
"Clé API invalide ou absente — envoie l'en-tête Authorization: Bearer <clé>.",
"invalid_request_error", "invalid_api_key")
return
}
next.ServeHTTP(w, r)
})
}
// checkBearer reports whether the request carries the expected key as an
// "Authorization: Bearer <clé>" header. La comparaison est à temps constant.
// PAS de repli ?key=<clé> en query string : une clé dans l'URL finit dans les
// logs des proxys (Caddy, Cloudflare), l'historique navigateur et les Referer.
func checkBearer(r *http.Request, key string) bool {
want := []byte(key)
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
got := []byte(strings.TrimSpace(h[len("Bearer "):]))
if subtle.ConstantTimeCompare(got, want) == 1 {
return true
}
}
return false
}
// cmdSetWebKey sets (or clears) the control-API key in $LOKI_HOME/.web_key.
//
// loki set-web-key <clé> définit la clé
// loki set-web-key génère une clé aléatoire
// loki set-web-key "" supprime la protection (API ouverte)
//
// Contrairement à set-api-key, aucun redémarrage n'est nécessaire : le serveur
// web relit la clé à chaque requête.
func cmdSetWebKey(args []string) error {
var key string
switch {
case len(args) == 0:
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return err
}
key = "loki-web-" + hex.EncodeToString(buf)
fmt.Printf("%s clé générée : %s\n", green("[ok]"), bold(key))
case args[0] == "" || args[0] == "off" || args[0] == "none":
key = ""
default:
key = strings.TrimSpace(args[0])
}
if err := putStr(bkState, "web_key", key); err != nil {
return err
}
if key == "" {
fmt.Printf("%s clé de pilotage supprimée — l'API web n'est plus protégée\n", yellow("[info]"))
return nil
}
fmt.Printf("%s clé de pilotage enregistrée\n", green("[ok]"))
fmt.Printf(" les clients doivent envoyer : %s\n", dim("Authorization: Bearer "+key))
fmt.Printf(" (relance 'loki web' si le serveur web tourne déjà — non requis, lu à chaud)\n")
return nil
}
// crossSiteReject refuse les requêtes qu'un SITE TIERS ouvert dans le navigateur
// (de la machine ou du réseau local) ferait en douce vers l'API. Sans clé (le
// défaut), l'API est ouverte : une page malveillante pouvait envoyer un POST
// « simple » (text/plain, sans pré-vérification CORS) à <serveur>:8090 et
// piloter Loki, jusqu'à faire exécuter des commandes à l'agent. Renvoie "" si
// la requête est acceptable, sinon la raison du refus. Repris d'AJEAN 0.15.5.
//
// - Sec-Fetch-Site: cross-site → refus (navigateurs récents, couvre aussi les
// GET déclenchés par une balise <img>/<form>).
// - Origin présent et différent de l'hôte appelé → refus (tous les navigateurs
// envoient Origin sur un POST cross-origin). Les clients hors navigateur
// (scripts, curl, apps) n'envoient pas d'Origin : non concernés.
// - Sans clé seulement : l'hôte appelé doit être local (IP, localhost, nom de
// la machine, nom sans point, .local/.lan…) ou listé dans
// LOKI_TRUSTED_HOSTS. Bloque le « DNS rebinding », où un domaine malveillant
// se fait résoudre en IP locale pour paraître même-origine. Avec une clé,
// inutile : le navigateur n'envoie jamais le Bearer tout seul. Le trafic du
// tunnel (authentifié par le relais) est lui aussi dispensé de ce contrôle.
func crossSiteReject(r *http.Request, keyed bool) string {
if strings.EqualFold(r.Header.Get("Sec-Fetch-Site"), "cross-site") {
return "requête d'un site tiers refusée"
}
if o := r.Header.Get("Origin"); o != "" && o != "null" {
u, err := url.Parse(o)
if err != nil || !strings.EqualFold(u.Host, r.Host) {
return "origine non autorisée : " + o
}
} else if o == "null" {
return "origine non autorisée"
}
if !keyed && r.Header.Get(viaTunnelHeader) != "tunnel" && !localHostName(r.Host) {
return "hôte « " + r.Host + " » non autorisé sans clé de pilotage — définis-en une (loki set-web-key) ou ajoute ce nom à LOKI_TRUSTED_HOSTS"
}
return ""
}
// localHostName : l'hôte désigne-t-il la machine ou le réseau local (et non un
// domaine public, seul utilisable pour un DNS rebinding) ?
func localHostName(hostport string) bool {
h := hostport
if hh, _, err := net.SplitHostPort(hostport); err == nil {
h = hh
}
h = strings.ToLower(strings.Trim(h, "[]."))
if h == "" || net.ParseIP(h) != nil || !strings.Contains(h, ".") {
return true
}
for _, suf := range []string{".localhost", ".local", ".lan", ".home", ".internal", ".home.arpa", ".localdomain"} {
if strings.HasSuffix(h, suf) {
return true
}
}
if me, err := os.Hostname(); err == nil && me != "" {
me = strings.ToLower(me)
if h == me || strings.HasPrefix(h, me+".") {
return true
}
}
// Échappatoire du conteneur : derrière un reverse proxy (loki.mondomaine.fr),
// le nom public n'est ni local ni celui du conteneur.
for _, t := range strings.Split(os.Getenv("LOKI_TRUSTED_HOSTS"), ",") {
if t = strings.ToLower(strings.TrimSpace(t)); t != "" && h == t {
return true
}
}
return false
}