Files
Loki/internal/loki/web_public_url_test.go
T
Claude b518e98b43 Accès OpenAI : servi par Loki, par domaine ou par IP
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
2026-08-16 19:45:43 +00:00

117 lines
4.1 KiB
Go

package loki
// L'adresse annoncée dans le panneau « Accès OpenAI » est ce qu'on recopie dans
// la configuration d'un autre logiciel : une valeur fausse ne se voit pas, elle
// se paie en « ça ne marche pas » sans indice. D'où ces tests.
import (
"encoding/json"
"net/http/httptest"
"testing"
)
func TestNormalisePublicURL(t *testing.T) {
for _, c := range []struct {
saisie, veut string
refus bool
}{
// Un nom nu vaut https : personne ne tape le schéma.
{saisie: "ia.exemple.fr", veut: "https://ia.exemple.fr"},
{saisie: " ia.exemple.fr ", veut: "https://ia.exemple.fr"},
{saisie: "http://192.168.1.20:8090", veut: "http://192.168.1.20:8090"},
{saisie: "192.168.1.20:8090", veut: "https://192.168.1.20:8090"},
{saisie: "HTTPS://Exemple.fr/", veut: "https://Exemple.fr"},
// Le « /v1 » recopié depuis l'adresse affichée est toléré et retiré :
// c'est le geste naturel, pas une faute.
{saisie: "https://ia.exemple.fr/v1", veut: "https://ia.exemple.fr"},
{saisie: "https://ia.exemple.fr/v1/", veut: "https://ia.exemple.fr"},
{saisie: "[::1]:8090", veut: "https://[::1]:8090"},
// Vide = effacement, pas erreur.
{saisie: " ", veut: ""},
// Refus, chacun pour une raison qu'on veut pouvoir expliquer.
{saisie: "ftp://ia.exemple.fr", refus: true},
{saisie: "https://ia.exemple.fr/base", refus: true},
{saisie: "https://user:mdp@ia.exemple.fr", refus: true},
{saisie: "https://ia.exemple.fr?x=1", refus: true},
{saisie: "https://ia.exemple.fr:port", refus: true},
} {
got, err := normalizePublicURL(c.saisie)
if c.refus {
if err == nil {
t.Errorf("%q accepté → %q, refus attendu", c.saisie, got)
}
continue
}
if err != nil {
t.Errorf("%q refusé : %v", c.saisie, err)
continue
}
if got != c.veut {
t.Errorf("%q → %q, attendu %q", c.saisie, got, c.veut)
}
}
}
// L'origine de la requête : le domaine réellement tapé, et le schéma que le
// reverse proxy annonce. Sans X-Forwarded-Proto, une interface servie en https
// afficherait une adresse en http — inutilisable telle quelle.
func TestRequestBase(t *testing.T) {
for _, c := range []struct{ nom, hôte, proto, veut string }{
{"ip et port", "192.168.1.20:8090", "", "http://192.168.1.20:8090"},
{"domaine derrière un proxy TLS", "ia.exemple.fr", "https", "https://ia.exemple.fr"},
{"chaîne de proxys", "ia.exemple.fr", "https, http", "https://ia.exemple.fr"},
{"en-tête douteux ignoré", "ia.exemple.fr", "gopher", "http://ia.exemple.fr"},
} {
t.Run(c.nom, func(t *testing.T) {
r := httptest.NewRequest("GET", "http://placeholder/api/apikey", nil)
r.Host = c.hôte
if c.proto != "" {
r.Header.Set("X-Forwarded-Proto", c.proto)
}
if got := requestBase(r); got != c.veut {
t.Errorf("requestBase = %q, attendu %q", got, c.veut)
}
})
}
}
// L'adresse enregistrée l'emporte sur celle du navigateur : c'est tout l'objet
// du champ, pour le cas du reverse proxy où Loki ne voit qu'un appel interne.
func TestAdresseAffichee(t *testing.T) {
testHome(t)
lire := func() map[string]any {
r := httptest.NewRequest("GET", "http://placeholder/api/apikey", nil)
r.Host = "ia.exemple.fr"
r.Header.Set("X-Forwarded-Proto", "https")
rec := httptest.NewRecorder()
handleAPIKey(rec, r) // GET seulement : le POST redémarre le moteur
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("réponse illisible (%d) : %s", rec.Code, rec.Body.String())
}
return out
}
if got := lire()["url"]; got != "https://ia.exemple.fr/v1" {
t.Errorf("sans adresse enregistrée : url = %v", got)
}
if _, err := setPublicURL("autre.exemple.fr"); err != nil {
t.Fatal(err)
}
out := lire()
if out["url"] != "https://autre.exemple.fr/v1" {
t.Errorf("avec adresse enregistrée : url = %v", out["url"])
}
if out["public_url"] != "https://autre.exemple.fr" {
t.Errorf("public_url = %v", out["public_url"])
}
// Effacement : on repasse sur l'origine de la requête.
if _, err := setPublicURL(""); err != nil {
t.Fatal(err)
}
if got := lire()["url"]; got != "https://ia.exemple.fr/v1" {
t.Errorf("après effacement : url = %v", got)
}
}