package loki import ( "crypto/rand" "crypto/subtle" "encoding/hex" "fmt" "net" "net/http" "net/url" "os" "strings" ) // 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 (pas de cache) 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. func readWebKeyErr() (string, error) { b, err := getBytesErr(bkState, "web_key") return string(b), err } // 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 .", "invalid_request_error", "invalid_api_key") return } next.ServeHTTP(w, r) }) } // checkBearer reports whether the request carries the expected key as an // "Authorization: Bearer " header. La comparaison est à temps constant. // PAS de repli ?key= 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 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) à :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 /
). // - 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 }