mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-13 18:28:03 +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
143 lines
5.4 KiB
Go
143 lines
5.4 KiB
Go
package loki
|
|
|
|
// web_public_url.go — l'adresse par laquelle on atteint cette instance.
|
|
//
|
|
// Le panneau « Accès OpenAI » doit afficher une adresse qu'on puisse coller
|
|
// telle quelle dans un client tiers. Il annonçait auparavant l'adresse du
|
|
// MOTEUR (http://<ip>:8080/v1), qui n'est joignable dans aucun déploiement en
|
|
// conteneur — le port n'y est pas publié — et dont l'IP, en conteneur, est
|
|
// celle du bridge Docker. Deux façons de faire mieux, dans cet ordre :
|
|
//
|
|
// 1. l'adresse publique enregistrée, quand l'utilisateur passe par un nom de
|
|
// domaine ou un reverse proxy et l'a saisie une fois pour toutes ;
|
|
// 2. sinon, l'origine de la requête elle-même — c'est-à-dire exactement le
|
|
// domaine ou l'IP qu'on a tapés pour ouvrir l'interface.
|
|
//
|
|
// Le calcul est ici, en Go, et pas dans le navigateur : une seule règle de
|
|
// priorité, réutilisable par la CLI et les journaux, et plus de construction
|
|
// d'URL à coups de concaténation côté client — c'est ce qui avait produit
|
|
// l'adresse fantôme.
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// publicURL renvoie l'adresse publique enregistrée, normalisée, ou "".
|
|
func publicURL() string { return getStr(bkState, "public_url") }
|
|
|
|
// setPublicURL normalise puis enregistre l'adresse publique. Une chaîne vide
|
|
// efface le réglage (on repasse alors sur l'origine de la requête). Renvoie la
|
|
// valeur réellement stockée.
|
|
func setPublicURL(raw string) (string, error) {
|
|
clean, err := normalizePublicURL(raw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := putStr(bkState, "public_url", clean); err != nil {
|
|
return "", err
|
|
}
|
|
return clean, nil
|
|
}
|
|
|
|
// normalizePublicURL ramène une saisie humaine à une origine canonique :
|
|
// "schéma://hôte[:port]", sans slash final. Pure, donc testable sans E/S.
|
|
//
|
|
// Les refus sont volontairement bavards : cette valeur est recopiée dans la
|
|
// configuration d'un autre logiciel, une erreur silencieuse s'y paierait en
|
|
// « ça ne marche pas » sans indice.
|
|
func normalizePublicURL(raw string) (string, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return "", nil // effacement : ce n'est pas une erreur
|
|
}
|
|
// Sans schéma, url.Parse lit « exemple.fr:8090 » comme un schéma « exemple.fr ».
|
|
// Quelqu'un qui tape un nom de domaine nu veut https.
|
|
if !strings.Contains(raw, "://") {
|
|
raw = "https://" + raw
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return "", fmt.Errorf("adresse illisible : %s", raw)
|
|
}
|
|
scheme := strings.ToLower(u.Scheme)
|
|
if scheme != "http" && scheme != "https" {
|
|
return "", fmt.Errorf("adresse en %s:// — seuls http:// et https:// sont acceptés", scheme)
|
|
}
|
|
if u.Host == "" {
|
|
return "", fmt.Errorf("adresse sans nom de machine")
|
|
}
|
|
if u.User != nil {
|
|
return "", fmt.Errorf("l'adresse ne doit pas porter d'identifiants (%s@…)", u.User.Username())
|
|
}
|
|
if u.RawQuery != "" || u.Fragment != "" {
|
|
return "", fmt.Errorf("l'adresse ne doit porter ni paramètre ni ancre")
|
|
}
|
|
// Chemin : on tolère le « /v1 » que l'utilisateur recopie naturellement
|
|
// depuis l'adresse affichée, et rien d'autre — le reste supposerait que le
|
|
// reverse proxy réécrit les chemins, ce qu'on ne peut pas vérifier d'ici.
|
|
switch strings.TrimSuffix(u.Path, "/") {
|
|
case "", "/v1":
|
|
default:
|
|
return "", fmt.Errorf("l'adresse ne doit pas contenir de chemin (%s) : Loki sert /v1 à la racine de son port", u.Path)
|
|
}
|
|
host := u.Host
|
|
if h, port, err := net.SplitHostPort(host); err == nil {
|
|
if h == "" {
|
|
return "", fmt.Errorf("adresse sans nom de machine")
|
|
}
|
|
if n, err := net.LookupPort("tcp", port); err != nil || n == 0 {
|
|
return "", fmt.Errorf("port invalide : %s", port)
|
|
}
|
|
} else if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
|
|
// IPv6 littéral doit être entre crochets, sinon on ne sait pas où
|
|
// s'arrête l'adresse et où commence le port.
|
|
return "", fmt.Errorf("adresse IPv6 à écrire entre crochets : [%s]", host)
|
|
}
|
|
return scheme + "://" + host, nil
|
|
}
|
|
|
|
// requestBase renvoie l'origine par laquelle CETTE requête a atteint Loki :
|
|
// "schéma://hôte[:port]", sans slash final.
|
|
//
|
|
// L'hôte vient de r.Host, c'est-à-dire de l'en-tête Host du navigateur : le
|
|
// domaine réellement tapé, correct même derrière un reverse proxy qui ne
|
|
// réécrit pas Host. On n'honore PAS X-Forwarded-Host, qui élargirait la surface
|
|
// de confiance sans rien apporter ici.
|
|
//
|
|
// Le schéma, lui, a besoin de X-Forwarded-Proto : derrière un proxy qui termine
|
|
// le TLS, Loki ne voit qu'une requête en clair et annoncerait http:// pour une
|
|
// interface servie en https://. Ces en-têtes sont forgeables quand il n'y a pas
|
|
// de proxy devant — sans conséquence : le résultat n'est qu'une chaîne affichée
|
|
// à un propriétaire déjà authentifié, jamais une décision d'accès.
|
|
func requestBase(r *http.Request) string {
|
|
scheme := "http"
|
|
switch {
|
|
case r.TLS != nil:
|
|
scheme = "https"
|
|
}
|
|
if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" {
|
|
first := strings.ToLower(strings.TrimSpace(strings.Split(fwd, ",")[0]))
|
|
if first == "http" || first == "https" {
|
|
scheme = first
|
|
}
|
|
}
|
|
host := r.Host
|
|
if host == "" {
|
|
host = "localhost"
|
|
}
|
|
return scheme + "://" + host
|
|
}
|
|
|
|
// oaiBaseURL est l'adresse à coller dans un client tiers : l'adresse publique
|
|
// enregistrée si elle existe, sinon celle par laquelle la requête est arrivée.
|
|
func oaiBaseURL(r *http.Request) string {
|
|
if u := publicURL(); u != "" {
|
|
return u
|
|
}
|
|
return requestBase(r)
|
|
}
|