mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
L'endpoint compatible OpenAI n'était pas servi par Loki : le panneau annonçait l'adresse de llama-server lui-même, http://<ip>:8080/v1. Dans le déploiement de référence de ce fork, cette adresse ne peut joindre personne — le port 8080 n'est pas publié par le conteneur, l'entrypoint sème HOST=127.0.0.1, et l'IP annoncée est celle du bridge Docker. L'autre voie proposée, « exposer en public (ajean.link) », exigeait un jeton de relais que ce fork ne permet plus d'obtenir : l'interrupteur ne pouvait que renvoyer vers un panneau supprimé. Désormais, Loki sert /v1/* SUR SON PROPRE PORT et relaie vers le moteur. L'API est donc joignable partout où l'interface l'est — IP du réseau local, nom de domaine, reverse proxy — sans publier de second port ni ouvrir le moteur. Serveur - mountOAI (llm_oai.go) monte /v1/ sur le mux, et RIEN d'autre : ni /metrics, ni /props, ni /slots, qui divulgueraient le modèle chargé et l'état des slots. Le filtre interne d'oaiHandler reste en seconde barrière. - requireCompletionKey (web_auth.go) garde cette surface avec la clé des COMPLÉTIONS, pas celle de pilotage : un client OpenAI n'a qu'un en-tête Authorization, et on veut pouvoir lui donner l'accès au modèle sans le droit de redémarrer la machine. Erreurs au format d'OpenAI (body.error.message), que les SDK savent présenter. Le préflight CORS passe sans clé — il n'en porte jamais, et le refuser casserait tout client tiers de navigateur. - effectiveAPIKeyErr (backend_config.go) devient la source unique de la clé exigée : base d'abord, config.env en repli, exactement comme le moteur. Sans ce miroir, un API_KEY résiduel donnait un endpoint « ouvert » côté Loki et un 401 côté moteur, sans rien pour l'expliquer. Lecture ratée = refus, jamais ouverture (même raisonnement que readWebKeyErr). - oaiHandler passe à ReverseProxy.Rewrite : le port du moteur est relu à chaque requête au lieu d'être figé à la construction — il visait l'ancien port dès qu'on changeait PORT, jusqu'au redémarrage de Loki. - withLocalAuth (relay_link.go) n'injecte plus la clé de pilotage sur /v1 : elle aurait été refusée par la garde, et surtout relayée au moteur. Le trafic du tunnel est marqué (en-tête effacé avant d'être posé, sinon un client le forge) et la surface y reste fermée tant que oai_public est faux — la promesse du tunnel est tenue. Adresse affichée - web_public_url.go : normalisation d'une adresse publique saisie à la main (schéma ajouté, /v1 recopié toléré, chemin refusé), origine de la requête via Host + X-Forwarded-Proto, et la règle de priorité entre les deux. - Le calcul quitte le navigateur pour le serveur : c'est la concaténation côté client qui produisait l'adresse fantôme. Interface - Le panneau perd l'interrupteur ajean.link et l'interrupteur d'écoute LAN — ce dernier n'a plus d'objet, et deux interrupteurs pour « rendre l'IA joignable » était la confusion à lever. La route /api/network et `loki network` restent pour qui veut exposer le moteur en direct. - Il gagne un champ « adresse publique » (facultatif, pour le reverse proxy) et un avertissement rouge tant qu'aucune clé n'est définie — l'endpoint est maintenant ouvert PARTOUT où l'interface l'est, ça ne se dit pas à voix basse. Le démarrage de `loki web` le crie aussi. Vérifié bout en bout sur le serveur réel : liste des modèles à travers Loki avec la clé (200), sans la clé (401), et complétion en streaming dont les tokens arrivent espacés de 120 ms — le flux traverse bien le double proxy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
169 lines
6.8 KiB
Go
169 lines
6.8 KiB
Go
package loki
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"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 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
|
|
}
|