From b518e98b43e306015d1b9c910a191130a140e9db Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 19:45:43 +0000 Subject: [PATCH] =?UTF-8?q?Acc=C3=A8s=20OpenAI=20:=20servi=20par=20Loki,?= =?UTF-8?q?=20par=20domaine=20ou=20par=20IP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'endpoint compatible OpenAI n'était pas servi par Loki : le panneau annonçait l'adresse de llama-server lui-même, http://: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 Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd --- README.md | 23 ++- internal/loki/backend_config.go | 37 +++- internal/loki/backend_serve.go | 4 +- internal/loki/llm_oai.go | 81 ++++++-- internal/loki/llm_oai_test.go | 251 +++++++++++++++++++++++++ internal/loki/relay_link.go | 12 ++ internal/loki/ui/index.html | 148 +++++---------- internal/loki/ui/src/index.tmpl.html | 29 ++- internal/loki/ui/src/js/06-settings.js | 113 ++++------- internal/loki/ui/src/styles.css | 6 + internal/loki/web_api.go | 60 +++--- internal/loki/web_auth.go | 57 ++++++ internal/loki/web_public_url.go | 142 ++++++++++++++ internal/loki/web_public_url_test.go | 116 ++++++++++++ internal/loki/web_server.go | 20 +- 15 files changed, 858 insertions(+), 241 deletions(-) create mode 100644 internal/loki/llm_oai_test.go create mode 100644 internal/loki/web_public_url.go create mode 100644 internal/loki/web_public_url_test.go diff --git a/README.md b/README.md index 6cfe154..39d5f77 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,8 @@ Héritées d'AJEAN : - **Agent** : shell, fichiers, workspace (`agent on`). - **Serveurs MCP** : Node.js est inclus dans l'image pour les serveurs `npx`. - **Presets** de configuration par modèle, bench, auto-détection GPU. -- **API OpenAI-compatible** exposable (`network on`, protégée par clé). +- **API OpenAI-compatible** exposable, protégée par clé (voir ci-dessous — ce + fork la sert autrement que l'amont). Ajoutées par ce fork : @@ -195,6 +196,25 @@ Ajoutées par ce fork : latérale s'escamote pour rendre toute la largeur au fil ; les discussions s'y cherchent au clavier et les jauges **GPU / VRAM / mémoire vive** restent visibles en pied de colonne. +- **API OpenAI servie par Loki** : `/v1/*` est exposé **sur le port de + l'interface** (8090) et relayé vers llama-server, au lieu d'annoncer l'adresse + du moteur. Conséquence directe : l'API est joignable partout où l'interface + l'est — par l'IP du réseau local comme par un nom de domaine — sans publier de + second port ni ouvrir le moteur. L'amont annonçait `http://:8080/v1`, une + adresse injoignable en conteneur (le port 8080 n'y est pas publié, et l'IP + détectée est celle du bridge Docker). + - Authentification par la **clé API** du panneau (`Authorization: Bearer …`), + vérifiée par Loki **et** par le moteur. Sans clé, l'endpoint est ouvert et + l'interface le dit en rouge. + - **Adresse publique** : un champ où saisir son domaine, pour le cas du + reverse proxy où Loki ne voit qu'un appel interne. Laissé vide, l'adresse + affichée suit celle du navigateur. + - **TLS** : mettre un reverse proxy devant (Caddy, Nginx, Traefik). Loki + honore `X-Forwarded-Proto` pour annoncer une adresse en `https`. + - L'ancienne exposition publique via le relais de l'amont + (`.oai.ajean.link`) est retirée de l'interface : elle exigeait un + jeton de relais que ce fork ne permet plus d'obtenir, l'interrupteur ne + pouvait donc qu'échouer. - **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés dans le fil. - **Paramètres** : les réglages d'application (identité, apparence, accès @@ -227,6 +247,7 @@ Retirées par ce fork : | Choix du modèle | lien Hugging Face collé à la main | recherche intégrée + verdict VRAM + projecteur lié | | Historique de tchat | conversation unique | discussions multiples, titrées et persistées | | Accès distant | relais chiffré ajean.link | retiré de l'interface | +| Endpoint OpenAI | `:8080/v1` du moteur, ouvert par `network on` | `/v1` servi par Loki sur le port de l'interface, protégé par la clé API | | Mise à jour | `ajean update` (binaire GitHub) | `docker compose pull` | Le reste — mémoire, outils, protocole, moteur d'inférence — est celui d'AJEAN. diff --git a/internal/loki/backend_config.go b/internal/loki/backend_config.go index ab22b72..99d582b 100644 --- a/internal/loki/backend_config.go +++ b/internal/loki/backend_config.go @@ -12,10 +12,41 @@ import ( "strings" ) +// readAPIKeyErr renvoie la clé Bearer des complétions en distinguant « aucune +// clé » d'une LECTURE RATÉE — même nuance que readWebKeyErr, et pour la même +// raison : sans clé, l'endpoint est OUVERT. Confondre les deux, c'est ouvrir +// l'accès aux complétions parce que la base était momentanément verrouillée par +// une commande CLI. requireCompletionKey refuse donc plutôt que d'ouvrir. +func readAPIKeyErr() (string, error) { + b, err := getBytesErr(bkState, "api_key") + return string(b), err +} + // readAPIKey renvoie la clé Bearer de llama-server, ou "" si aucune n'est -// définie. Elle est rangée hors de la configuration pour survivre aux -// changements de preset, qui remplacent la configuration en bloc. -func readAPIKey() string { return getStr(bkState, "api_key") } +// définie (ou illisible). Elle est rangée hors de la configuration pour survivre +// aux changements de preset, qui remplacent la configuration en bloc. Réservé à +// l'affichage et aux appels internes ; toute décision d'accès passe par +// effectiveAPIKeyErr. +func readAPIKey() string { k, _ := readAPIKeyErr(); return k } + +// effectiveAPIKeyErr renvoie la clé que llama-server exige RÉELLEMENT : celle +// rangée en base, sinon la clé résiduelle de config.env (repli rétro-compatible, +// cf. backend_serve.go). +// +// Une seule source pour les deux côtés du relais. Quand la garde de Loki ne +// regardait que la clé en base, un API_KEY oublié dans config.env donnait un +// endpoint « ouvert » côté Loki et un 401 côté moteur — le client voyait un +// refus sans jamais comprendre quelle clé fournir. +func effectiveAPIKeyErr() (string, error) { + k, err := readAPIKeyErr() + if err != nil { + return "", err + } + if k != "" { + return k, nil + } + return strings.TrimSpace(ReadConfig()["API_KEY"]), nil +} // authHeader sets the Authorization: Bearer header on req when an API key is // configured, so Loki's own internal calls (chat/web/bench/test) authenticate diff --git a/internal/loki/backend_serve.go b/internal/loki/backend_serve.go index c711d98..9e42bb1 100644 --- a/internal/loki/backend_serve.go +++ b/internal/loki/backend_serve.go @@ -176,9 +176,7 @@ func cmdServe(args []string) error { // exige alors l'en-tête "Authorization: Bearer ". La clé est lue depuis // $LOKI_HOME/.api_key en priorité (elle survit ainsi aux changements de preset // qui réécrivent config.env), avec config.env comme repli rétro-compatible. - if k := readAPIKey(); k != "" { - llmArgs = append(llmArgs, "--api-key", k) - } else if k := cfg["API_KEY"]; k != "" { + if k, _ := effectiveAPIKeyErr(); k != "" { llmArgs = append(llmArgs, "--api-key", k) } // EXTRA_ARGS is appended verbatim, split like the shell would — quotes kept diff --git a/internal/loki/llm_oai.go b/internal/loki/llm_oai.go index 762ac4b..b18352d 100644 --- a/internal/loki/llm_oai.go +++ b/internal/loki/llm_oai.go @@ -40,15 +40,67 @@ import ( // oaiSuffix est le domaine sous lequel on autorise l'émission de certificats. const oaiSuffix = ".oai.ajean.link" +// viaTunnelHeader marque une requête arrivée par le tunnel du relais (posé par +// withLocalAuth, qui l'efface d'abord — un client ne peut donc pas le forger). +// Il sert à garder la promesse du tunnel : hors du front TLS dédié, la surface +// des complétions y reste fermée tant que oaiPublicEnabled() est faux. +const viaTunnelHeader = "X-Loki-Via" + +// sendOAIError répond au format d'erreur d'OpenAI. Les SDK officiels lisent +// body.error.message ; un {"error":"…"} plat leur fait afficher un message vide, +// et certains clients (LiteLLM, LangChain) échouent carrément au décodage. +func sendOAIError(w http.ResponseWriter, status int, msg, typ, code string) { + sendJSON(w, status, map[string]any{"error": map[string]any{ + "message": msg, "type": typ, "param": nil, "code": code, + }}) +} + +// mountOAI branche la surface compatible OpenAI sur le mux de Loki, protégée par +// la clé des complétions (web_auth.go). +// +// C'est ce qui rend l'API joignable par le nom de domaine ou l'IP de +// L'INTERFACE, sans publier de second port : le moteur peut rester sur la boucle +// locale, et un reverse proxy devant Loki suffit pour le TLS. L'adresse annoncée +// auparavant (celle du moteur, port 8080) n'était joignable dans aucun +// déploiement en conteneur, où ce port n'est pas publié. +// +// ⚠️ On ne monte QUE /v1/. Surtout pas /health, /props, /metrics ni /slots — que +// oaiHandler autorise pour le tunnel : ils divulgueraient le modèle chargé, la +// taille de contexte et l'état des slots, sans authentification quand aucune clé +// n'est définie. Le filtre interne d'oaiHandler reste en seconde barrière. +func mountOAI(mux *http.ServeMux) { + mux.Handle("/v1/", requireCompletionKey(oaiHandler())) +} + // oaiHandler construit le reverse-proxy vers llama-server, restreint à la surface -// compatible OpenAI. On NE touche PAS à l'en-tête Authorization : le SaaS envoie -// la vraie clé (.api_key), que llama-server valide lui-même (--api-key). +// compatible OpenAI. On NE touche PAS à l'en-tête Authorization : le client +// envoie la vraie clé, que llama-server valide lui-même (--api-key). Elle est +// donc vérifiée deux fois — par Loki puis par le moteur — et c'est voulu : le +// moteur reste protégé même si on l'expose un jour en direct. func oaiHandler() http.Handler { - llama := &url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())} - lp := httputil.NewSingleHostReverseProxy(llama) - lp.FlushInterval = -1 // streaming SSE des complétions - lp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) { - http.Error(w, "llama-server injoignable: "+e.Error(), http.StatusBadGateway) + lp := &httputil.ReverseProxy{ + // FlushInterval négatif = on vide le tampon à chaque écriture : c'est ce + // qui fait arriver les tokens un par un chez le client au lieu d'un bloc + // en fin de génération. + // ⚠️ Ne JAMAIS envelopper le ResponseWriter sur ce chemin (compteur, + // journalisation…) : un wrapper qui n'implémente pas http.Flusher + // retransforme le flux en réponse bufferisée, sans autre symptôme qu'une + // attente inexplicable. + FlushInterval: -1, + // Rewrite (et non Director) : le port du moteur est relu À CHAQUE + // REQUÊTE. Capturé à la construction, il figeait l'ancienne valeur dès + // qu'on changeait PORT depuis l'interface, et le proxy visait dans le + // vide jusqu'au redémarrage de Loki. Rewrite n'hérite pas non plus des + // X-Forwarded-* entrants : un client ne peut pas les forger vers le + // moteur. + Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(&url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())}) + pr.Out.Host = pr.In.Host + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, e error) { + sendOAIError(w, http.StatusBadGateway, + "moteur injoignable : "+e.Error(), "api_error", "upstream_unavailable") + }, } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Path @@ -56,7 +108,8 @@ func oaiHandler() http.Handler { lp.ServeHTTP(w, r) return } - http.Error(w, "not found (endpoint OpenAI: /v1/*)", http.StatusNotFound) + sendOAIError(w, http.StatusNotFound, + "chemin inconnu — l'endpoint compatible OpenAI est /v1/*", "invalid_request_error", "not_found") }) } @@ -72,14 +125,14 @@ func runOAIFront(rawLn net.Listener, tlsCfg *tls.Config) error { return srv.Serve(tls.NewListener(rawLn, tlsCfg)) } -// oaiPublicEnabled indique si l'accès OpenAI public est activé pour cette -// machine. Piloté par l'UI et lu en direct → activable/coupable sans redémarrer -// le service de lien. +// oaiPublicEnabled indique si le front TLS du tunnel doit servir la surface +// OpenAI. Drapeau désormais en LECTURE SEULE : plus aucune interface ne +// l'allume, depuis que l'endpoint est servi par Loki sur son propre port (voir +// mountOAI). Il ne subsiste que pour les installations migrées depuis AJEAN dont +// le tunnel sert encore ce front — et pour son défaut, qui est le refus : +// demuxTunnelStream ferme le flux quand il est absent. func oaiPublicEnabled() bool { return getBool(bkState, "oai_public") } -// setOAIPublic active (on) ou coupe (off) l'accès OpenAI public. -func setOAIPublic(on bool) error { return putBool(bkState, "oai_public", on) } - // oaiTLSConfig renvoie une config TLS qui, à la demande, obtient/renouvelle via // Let's Encrypt (TLS-ALPN-01) le certificat de tout nom en *.oai.ajean.link, et // répond elle-même aux challenges ACME. La clé privée est stockée dans diff --git a/internal/loki/llm_oai_test.go b/internal/loki/llm_oai_test.go new file mode 100644 index 0000000..6c2c142 --- /dev/null +++ b/internal/loki/llm_oai_test.go @@ -0,0 +1,251 @@ +package loki + +// Tests de la surface compatible OpenAI servie par Loki (mountOAI). +// +// Elle est joignable partout où l'interface l'est : c'est tout l'intérêt, et +// c'est aussi ce qui rend sa garde d'accès critique. Ces tests verrouillent les +// quatre promesses : la clé est exigée quand elle existe, l'amont n'est jamais +// appelé quand elle manque, l'en-tête du client arrive INTACT au moteur (qui la +// revalide), et rien d'autre que /v1 n'est monté. + +import ( + "bufio" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" +) + +// fauxMoteur monte un llama-server de comédie et pointe la configuration +// dessus : le proxy vise 127.0.0.1:LLMPort(), c'est donc tout ce qu'il faut. +// Renvoie le serveur et un pointeur sur les en-têtes de la dernière requête +// reçue (nil tant qu'il n'a rien reçu). +func fauxMoteur(t *testing.T, h http.HandlerFunc) (*httptest.Server, func() http.Header) { + t.Helper() + var vues http.Header + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + vues = r.Header.Clone() + if h != nil { + h(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"loki"}]}`)) + })) + t.Cleanup(srv.Close) + port := srv.URL[strings.LastIndexByte(srv.URL, ':')+1:] + if _, err := strconv.Atoi(port); err != nil { + t.Fatalf("port du faux moteur illisible : %q", srv.URL) + } + if err := SetConfigKey("PORT", port); err != nil { + t.Fatal(err) + } + return srv, func() http.Header { return vues } +} + +// appelOAI joue une requête à travers la garde + le proxy, sans monter tout le +// serveur web (newWebMux a des effets de bord : MCP, migrations, conversation). +func appelOAI(t *testing.T, méthode, chemin, clé string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(méthode, chemin, nil) + if clé != "" { + req.Header.Set("Authorization", "Bearer "+clé) + } + rec := httptest.NewRecorder() + requireCompletionKey(oaiHandler()).ServeHTTP(rec, req) + return rec +} + +// erreurOAI décode le corps au format d'erreur d'OpenAI — celui que les SDK +// savent présenter à l'utilisateur. +func erreurOAI(t *testing.T, rec *httptest.ResponseRecorder) (message, code string) { + t.Helper() + var body struct { + Error struct { + Message string `json:"message"` + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("corps illisible (%d) : %s", rec.Code, rec.Body.String()) + } + return body.Error.Message, body.Error.Code +} + +// Sans clé configurée, l'endpoint est ouvert — comme llama-server sans +// --api-key. Le panneau et le démarrage le disent en rouge ; ici on vérifie +// seulement que le comportement est bien celui-là, et pas un refus silencieux. +func TestOAISansCleLaissePasser(t *testing.T) { + testHome(t) + _, vues := fauxMoteur(t, nil) + rec := appelOAI(t, "GET", "/v1/models", "") + if rec.Code != 200 { + t.Fatalf("code %d, corps %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"loki"`) { + t.Errorf("réponse du moteur non relayée : %s", rec.Body.String()) + } + if vues() == nil { + t.Error("le moteur n'a pas été appelé") + } +} + +// La bonne clé passe, ET l'en-tête arrive INTACT au moteur : c'est le contrat. +// llama-server la valide lui aussi (--api-key), il reste donc protégé même si +// on l'expose un jour en direct. +func TestOAIBonneCleEtEnTeteRelayee(t *testing.T) { + testHome(t) + _, vues := fauxMoteur(t, nil) + if err := writeAPIKey("sk-loki-test"); err != nil { + t.Fatal(err) + } + rec := appelOAI(t, "GET", "/v1/models", "sk-loki-test") + if rec.Code != 200 { + t.Fatalf("code %d, corps %s", rec.Code, rec.Body.String()) + } + if got := vues().Get("Authorization"); got != "Bearer sk-loki-test" { + t.Errorf("en-tête transmis au moteur = %q, attendu la clé du client", got) + } +} + +// Mauvaise clé, et clé absente : 401 au format OpenAI, et le moteur n'est JAMAIS +// appelé — une garde qui laisse passer la requête avant de refuser ne protège +// rien. +func TestOAICleRefusee(t *testing.T) { + for _, cas := range []struct{ nom, envoyée string }{ + {"mauvaise clé", "sk-loki-pasbonne"}, + {"aucune clé", ""}, + } { + t.Run(cas.nom, func(t *testing.T) { + testHome(t) + _, vues := fauxMoteur(t, nil) + if err := writeAPIKey("sk-loki-test"); err != nil { + t.Fatal(err) + } + rec := appelOAI(t, "POST", "/v1/chat/completions", cas.envoyée) + if rec.Code != 401 { + t.Fatalf("code %d, attendu 401 — corps %s", rec.Code, rec.Body.String()) + } + if rec.Header().Get("WWW-Authenticate") == "" { + t.Error("en-tête WWW-Authenticate absent") + } + msg, code := erreurOAI(t, rec) + if code != "invalid_api_key" || msg == "" { + t.Errorf("erreur = {code:%q, message:%q}", code, msg) + } + if vues() != nil { + t.Error("le moteur a été appelé malgré le refus") + } + }) + } +} + +// La clé résiduelle de config.env fait foi quand la base n'en a pas : c'est +// celle que le moteur exige (backend_serve.go). Sans ce miroir, Loki laissait +// passer et llama refusait — un 401 que rien n'expliquait. +func TestOAICleResiduelleDeConfig(t *testing.T) { + testHome(t) + _, vues := fauxMoteur(t, nil) + if err := SetConfigKey("API_KEY", "sk-ancienne"); err != nil { + t.Fatal(err) + } + if rec := appelOAI(t, "GET", "/v1/models", ""); rec.Code != 401 { + t.Fatalf("sans clé : code %d, attendu 401", rec.Code) + } + if vues() != nil { + t.Fatal("le moteur a été appelé malgré le refus") + } + if rec := appelOAI(t, "GET", "/v1/models", "sk-ancienne"); rec.Code != 200 { + t.Fatalf("avec la clé de config.env : code %d, attendu 200", rec.Code) + } +} + +// Un préflight CORS ne porte jamais d'Authorization. Le refuser casserait tout +// client tiers qui tourne dans un navigateur. +func TestOAIPreflightPasseSansCle(t *testing.T) { + testHome(t) + _, vues := fauxMoteur(t, nil) + if err := writeAPIKey("sk-loki-test"); err != nil { + t.Fatal(err) + } + if rec := appelOAI(t, "OPTIONS", "/v1/chat/completions", ""); rec.Code == 401 { + t.Fatalf("préflight refusé : %s", rec.Body.String()) + } + if vues() == nil { + t.Error("le préflight n'a pas atteint le moteur, qui répond ses en-têtes CORS") + } +} + +// Seul /v1/ est monté. /metrics, /props et /slots divulgueraient le modèle +// chargé et l'état du moteur — sans clé, à qui joint l'adresse. +func TestOAIMonteSeulementV1(t *testing.T) { + testHome(t) + mux := http.NewServeMux() + mountOAI(mux) + témoin := false + mux.HandleFunc("/", func(http.ResponseWriter, *http.Request) { témoin = true }) + + for _, chemin := range []string{"/metrics", "/props", "/slots", "/api/ping", "/"} { + témoin = false + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest("GET", chemin, nil)) + if !témoin { + t.Errorf("%s n'a pas été servi par le handler racine — il est passé à l'endpoint OpenAI", chemin) + } + } + // …et /v1/, lui, ne va PAS au handler racine. + témoin = false + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest("GET", "/v1/models", nil)) + if témoin { + t.Error("/v1/models a été servi par le handler racine") + } +} + +// Le streaming doit arriver en morceaux. Si FlushInterval régresse, le premier +// événement n'arrive qu'à la fin de la génération — invisible en test unitaire +// classique, d'où ce test qui bloque l'amont entre deux écritures. +func TestOAIStreamingArriveEnMorceaux(t *testing.T) { + testHome(t) + débloque := make(chan struct{}) + fauxMoteur(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fl, ok := w.(http.Flusher) + if !ok { + t.Error("le faux moteur ne sait pas vider son tampon") + return + } + _, _ = w.Write([]byte("data: un\n\n")) + fl.Flush() + <-débloque + _, _ = w.Write([]byte("data: deux\n\n")) + fl.Flush() + }) + + front := httptest.NewServer(requireCompletionKey(oaiHandler())) + defer front.Close() + defer close(débloque) + + resp, err := http.Get(front.URL + "/v1/chat/completions") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + lu := make(chan string, 1) + go func() { + ligne, _ := bufio.NewReader(resp.Body).ReadString('\n') + lu <- ligne + }() + select { + case ligne := <-lu: + if !strings.Contains(ligne, "un") { + t.Fatalf("première ligne = %q", ligne) + } + case <-time.After(3 * time.Second): + t.Fatal("rien reçu avant la fin de la génération : le flux est bufferisé") + } +} diff --git a/internal/loki/relay_link.go b/internal/loki/relay_link.go index 9df054b..2d9cc00 100644 --- a/internal/loki/relay_link.go +++ b/internal/loki/relay_link.go @@ -541,6 +541,18 @@ func newLinkHandler(mux *http.ServeMux) http.Handler { // à chaque requête de l'autre côté. func withLocalAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // ⚠️ Marquage AVANT tout : on efface d'abord, sinon un client forge + // l'en-tête et se fait passer pour du trafic de tunnel (ou l'inverse). + r.Header.Del(viaTunnelHeader) + r.Header.Set(viaTunnelHeader, "tunnel") + // La surface des complétions a SA clé (celle du moteur, fournie par le + // client). Y injecter la clé de PILOTAGE la ferait refuser par la garde, + // et surtout la relaierait à llama-server — un secret d'administration + // envoyé à un process qui n'en a aucun usage. + if strings.HasPrefix(r.URL.Path, "/v1/") { + next.ServeHTTP(w, r) + return + } if webKey := readWebKey(); webKey != "" && r.Header.Get("Authorization") == "" { r.Header.Set("Authorization", "Bearer "+webKey) } diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index f31524e..61d16a7 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -1835,6 +1835,12 @@ html[data-files="1"] #files-btn{color:var(--accent)} .row>button:has(svg){display:inline-flex;align-items:center;gap:7px} /* Le « + » des sections : même carré que les autres commandes de la barre. */ .preset-add{width:26px;height:26px;border-radius:8px} +/* Étiquettes de groupe : même traitement que les titres de section — la + maquette n'a qu'un style de titre, et le monospace en capitales de la charte + précédente criait au milieu des formulaires. */ +.subhead,.io-cap,.pages-bar .pages-title,.skillshead>span{ + font-family:var(--font);text-transform:none;letter-spacing:0; + font-size:12.5px;font-weight:600;color:var(--dim)} +
Endpoint compatible OpenAI?Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor, Open WebUI…) sur cette adresse — le modèle s'y appelle loki. L'endpoint est servi par Loki sur le port de cette interface : aucun autre port à ouvrir. Si une clé est définie, le client doit envoyer Authorization: Bearer <clé>.
-
Adresse locale (réseau) — modèle loki
+
Adresse de l'endpoint — modèle loki
- - - - - -
Clé API?Protège l'endpoint des complétions (llama-server). Indispensable avant d'exposer le serveur sur internet. Modifier la clé redémarre le service.
+
Clé API?Protège l'endpoint des complétions — vérifiée par Loki et par le moteur. Indispensable dès que l'adresse ci-dessus est joignable par d'autres. Modifier la clé redémarre le moteur.
+
@@ -3460,27 +3461,39 @@ async function copyText(txt, msg){ function copyApiKey(){ copyText(OAI_KEY, OAI_KEY?'clé copiée':'aucune clé'); } function renderApiKey(d){ OAI_KEY = d.key || ''; - // URL de l'endpoint : llama-server tourne sur d.port (≠ port de l'UI web). - // On prend l'hôte annoncé par le serveur (IP LAN détectée côté Go) : dans le - // tunnel ajean.link, location.hostname serait le domaine du relais (faux) — - // l'accès OpenAI reste TOUJOURS l'adresse locale de la machine. - const host = d.host || location.hostname; - document.getElementById('oai-url').value = 'http://'+host+':'+d.port+'/v1'; - // Endpoint PUBLIC (ajean.link) : affiché seulement si l'accès public est activé. - const tg = document.getElementById('oai-public-toggle'); - if(tg) tg.checked = !!d.oai_public; - const pubWrap = document.getElementById('oai-public-wrap'); - if(d.oai_public && d.machine){ - document.getElementById('oai-public-url').value = 'https://'+d.machine+'.oai.ajean.link/v1'; - pubWrap.style.display = ''; - } else { - pubWrap.style.display = 'none'; + // L'adresse vient du SERVEUR (web_public_url.go) : adresse publique + // enregistrée, sinon l'origine par laquelle cette page a été ouverte. Elle + // était auparavant recomposée ici — 'http://'+host+':'+port+'/v1' — à partir + // du port du MOTEUR et d'une IP détectée côté Go, ce qui donnait en conteneur + // une adresse que personne ne pouvait joindre (bridge Docker, port non publié). + // location.origin ne sert plus que de filet si le serveur ne dit rien. + document.getElementById('oai-url').value = d.url || (location.origin + '/v1'); + // Champ d'adresse publique : on ne l'écrase pas pendant la frappe. + const pa = document.getElementById('public-addr'); + if(pa && document.activeElement !== pa) pa.value = d.public_url || ''; + // Sans clé, l'endpoint est ouvert — et il l'est maintenant partout où + // l'interface l'est. Ça ne se dit pas à voix basse. + const warn = document.getElementById('oai-open-warn'); + if(warn){ + warn.style.display = d.set ? 'none' : ''; + warn.textContent = d.set ? '' : + '⚠ Aucune clé : toute personne capable de joindre cette adresse peut utiliser votre modèle.'; } const inp=document.getElementById('oai-key'); - if(!d.set){ inp.value='(aucune clé — serveur ouvert)'; inp.style.opacity=.6; } + if(!d.set){ inp.value='(aucune clé — endpoint ouvert)'; inp.style.opacity=.6; } else { inp.style.opacity=1; inp.value = OAI_REVEAL ? OAI_KEY : d.masked; } document.getElementById('oai-key-eye').style.display = d.set ? '' : 'none'; } +// Enregistre l'adresse publique. Le serveur normalise et refuse en clair : on +// relaie sa raison telle quelle, elle est écrite pour être lue. +async function savePublicAddr(){ + const inp = document.getElementById('public-addr'); + if(!inp) return; + const r = await jpost('/api/oai/public', {url: inp.value}); + if(!r || !r.ok){ toast('adresse refusée : ' + ((r && r.error) || '')); return; } + toast(r.url ? 'adresse enregistrée' : 'adresse effacée — celle du navigateur sera utilisée'); + loadApiKey(); +} async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); } // --- Export de la conversation --------------------------------------------- // xTurnsTotal : nombre d'échanges du fil, borne haute du curseur. Vient de @@ -3612,50 +3625,11 @@ async function downloadExport(url){ toast('exporté : ' + name); }catch(e){ toast('erreur : ' + e.message); } } -// --- Écoute réseau du moteur (HOST + pare-feu) ------------------------------ -// Le retour d'utilisateur qui a motivé ce réglage : « à part le chat dans le -// navigateur, impossible d'utiliser ton URL dans les logiciels en local ». Le -// moteur écoutait sur 127.0.0.1 et rien ne le disait nulle part. -function renderNetwork(st){ - if(!st) return; - const cb = document.getElementById('lan-toggle'); - if(cb) cb.checked = !!st.exposed; - const warn = document.getElementById('lan-warn'); - if(!warn) return; - if(!st.exposed){ - warn.style.display = ''; - warn.innerHTML = '
Le moteur n\'écoute que sur cette machine : l\'adresse ci-dessus ne répond pas depuis un autre ordinateur.
'; - return; - } - // Exposé mais bloqué par le pare-feu : le cas le plus déroutant (« ça écoute - // partout » et pourtant rien ne passe). On donne la commande à coller. - if(st.hint){ - warn.style.display = ''; - // --err et non --warn : la palette est volontairement monochrome et --warn y - // est un gris, illisible comme alerte. Ici il y a une vraie action à faire. - warn.innerHTML = '
⚠ ' - + escHtml(st.hint).replace(/\n/g,'
') + '
'; - return; - } - warn.style.display = 'none'; -} -async function loadNetwork(){ - try{ const r = await jget('/api/network'); renderNetwork(r && r.status); }catch(e){} -} -async function toggleLAN(){ - const cb = document.getElementById('lan-toggle'); - const on = cb.checked; - const r = await jpost('/api/network', {exposed:on}); - if(!r || !r.ok){ cb.checked = !on; toast('erreur : ' + ((r&&r.error)||'')); return; } - renderNetwork(r.status); - // llama-server ne lit --host qu'au lancement : sans redémarrage, l'interrupteur - // affiche un état que le moteur en cours ne respecte pas encore. - if(await askConfirm('Le moteur doit redémarrer pour appliquer ce changement (le modèle sera rechargé).', - {title: on ? 'Ouvrir sur le réseau' : 'Fermer sur le réseau', okText:'Redémarrer'})){ - await act('restart'); // même chemin que les boutons du panneau Moteur - } - loadApiKey(); -} +// L'écoute réseau du moteur (HOST + pare-feu) n'a plus d'interrupteur : depuis +// que Loki sert /v1 sur son propre port, ouvrir le moteur sur le réseau n'a plus +// d'objet — et deux interrupteurs pour « rendre l'IA joignable » était +// exactement la confusion qu'on voulait lever. La route /api/network et la +// commande `loki network` restent, pour qui veut exposer le moteur en direct. function toggleKeyReveal(){ OAI_REVEAL=!OAI_REVEAL; const inp=document.getElementById('oai-key'); if(OAI_KEY) inp.value = OAI_REVEAL ? OAI_KEY : (OAI_KEY.slice(0,8)+'…'+OAI_KEY.slice(-4)); const eye=document.getElementById('oai-key-eye'); if(eye) eye.textContent = OAI_REVEAL ? 'masquer' : 'afficher'; } async function apiKeyAction(action){ if(action==='clear' && !await askConfirm('Retirer la clé rend l\'endpoint OpenAI accessible SANS authentification. Le service va redémarrer.', {title:'Retirer la clé API ?', okText:'Retirer'})) return; @@ -3664,24 +3638,6 @@ async function apiKeyAction(action){ toast('application…'); renderApiKey(await jpost('/api/apikey', {action})); } -async function toggleOAIPublic(){ - const cb = document.getElementById('oai-public-toggle'); - const on = cb.checked; - // L'accès OpenAI public passe par ajean.link (.oai.ajean.link) : il exige - // que l'accès distant soit activé sur ce serveur. Sinon, on annule et on explique. - if(on){ - let linked = false; - try{ const s = await jget('/api/link/status'); linked = !!(s && s.linked); }catch(e){} - if(!linked){ - cb.checked = false; - await askAlert('Vous devez activer l\'accès distant (ajean.link) pour bénéficier de cette fonctionnalité. Ouvrez le panneau « Accès distant » pour connecter ce serveur.', {title:'Accès distant requis'}); - return; - } - } - await jpost('/api/oai/public', {enabled:on}); - toast(on ? 'accès public activé' : 'accès public coupé'); - loadApiKey(); -} async function apiKeySet(){ const k = await askPrompt('Colle ta clé API (ou laisse vide pour annuler) :', {title:'Définir la clé API', placeholder:'sk-…'}); if(!k || !k.trim()) return; @@ -3725,7 +3681,7 @@ async function loadAll(){ // MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute // suppression de module doit donc retirer son appel de cette ligne, et le test // navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard. - await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp()]); + await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp()]); releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index 9eb1480..b04a801 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -283,35 +283,30 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
Accès OpenAI -
Endpoint compatible OpenAI?Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur ces adresses. Si une clé est définie, le client doit envoyer Authorization: Bearer <clé>.
- +
Endpoint compatible OpenAI?Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor, Open WebUI…) sur cette adresse — le modèle s'y appelle loki. L'endpoint est servi par Loki sur le port de cette interface : aucun autre port à ouvrir. Si une clé est définie, le client doit envoyer Authorization: Bearer <clé>.
-
Adresse locale (réseau) — modèle loki
+
Adresse de l'endpoint — modèle loki
- - - - - -
Clé API?Protège l'endpoint des complétions (llama-server). Indispensable avant d'exposer le serveur sur internet. Modifier la clé redémarre le service.
+
Clé API?Protège l'endpoint des complétions — vérifiée par Loki et par le moteur. Indispensable dès que l'adresse ci-dessus est joignable par d'autres. Modifier la clé redémarre le moteur.
+
diff --git a/internal/loki/ui/src/js/06-settings.js b/internal/loki/ui/src/js/06-settings.js index 92297d6..c28f57f 100644 --- a/internal/loki/ui/src/js/06-settings.js +++ b/internal/loki/ui/src/js/06-settings.js @@ -231,27 +231,39 @@ async function copyText(txt, msg){ function copyApiKey(){ copyText(OAI_KEY, OAI_KEY?'clé copiée':'aucune clé'); } function renderApiKey(d){ OAI_KEY = d.key || ''; - // URL de l'endpoint : llama-server tourne sur d.port (≠ port de l'UI web). - // On prend l'hôte annoncé par le serveur (IP LAN détectée côté Go) : dans le - // tunnel ajean.link, location.hostname serait le domaine du relais (faux) — - // l'accès OpenAI reste TOUJOURS l'adresse locale de la machine. - const host = d.host || location.hostname; - document.getElementById('oai-url').value = 'http://'+host+':'+d.port+'/v1'; - // Endpoint PUBLIC (ajean.link) : affiché seulement si l'accès public est activé. - const tg = document.getElementById('oai-public-toggle'); - if(tg) tg.checked = !!d.oai_public; - const pubWrap = document.getElementById('oai-public-wrap'); - if(d.oai_public && d.machine){ - document.getElementById('oai-public-url').value = 'https://'+d.machine+'.oai.ajean.link/v1'; - pubWrap.style.display = ''; - } else { - pubWrap.style.display = 'none'; + // L'adresse vient du SERVEUR (web_public_url.go) : adresse publique + // enregistrée, sinon l'origine par laquelle cette page a été ouverte. Elle + // était auparavant recomposée ici — 'http://'+host+':'+port+'/v1' — à partir + // du port du MOTEUR et d'une IP détectée côté Go, ce qui donnait en conteneur + // une adresse que personne ne pouvait joindre (bridge Docker, port non publié). + // location.origin ne sert plus que de filet si le serveur ne dit rien. + document.getElementById('oai-url').value = d.url || (location.origin + '/v1'); + // Champ d'adresse publique : on ne l'écrase pas pendant la frappe. + const pa = document.getElementById('public-addr'); + if(pa && document.activeElement !== pa) pa.value = d.public_url || ''; + // Sans clé, l'endpoint est ouvert — et il l'est maintenant partout où + // l'interface l'est. Ça ne se dit pas à voix basse. + const warn = document.getElementById('oai-open-warn'); + if(warn){ + warn.style.display = d.set ? 'none' : ''; + warn.textContent = d.set ? '' : + '⚠ Aucune clé : toute personne capable de joindre cette adresse peut utiliser votre modèle.'; } const inp=document.getElementById('oai-key'); - if(!d.set){ inp.value='(aucune clé — serveur ouvert)'; inp.style.opacity=.6; } + if(!d.set){ inp.value='(aucune clé — endpoint ouvert)'; inp.style.opacity=.6; } else { inp.style.opacity=1; inp.value = OAI_REVEAL ? OAI_KEY : d.masked; } document.getElementById('oai-key-eye').style.display = d.set ? '' : 'none'; } +// Enregistre l'adresse publique. Le serveur normalise et refuse en clair : on +// relaie sa raison telle quelle, elle est écrite pour être lue. +async function savePublicAddr(){ + const inp = document.getElementById('public-addr'); + if(!inp) return; + const r = await jpost('/api/oai/public', {url: inp.value}); + if(!r || !r.ok){ toast('adresse refusée : ' + ((r && r.error) || '')); return; } + toast(r.url ? 'adresse enregistrée' : 'adresse effacée — celle du navigateur sera utilisée'); + loadApiKey(); +} async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); } // --- Export de la conversation --------------------------------------------- // xTurnsTotal : nombre d'échanges du fil, borne haute du curseur. Vient de @@ -383,50 +395,11 @@ async function downloadExport(url){ toast('exporté : ' + name); }catch(e){ toast('erreur : ' + e.message); } } -// --- Écoute réseau du moteur (HOST + pare-feu) ------------------------------ -// Le retour d'utilisateur qui a motivé ce réglage : « à part le chat dans le -// navigateur, impossible d'utiliser ton URL dans les logiciels en local ». Le -// moteur écoutait sur 127.0.0.1 et rien ne le disait nulle part. -function renderNetwork(st){ - if(!st) return; - const cb = document.getElementById('lan-toggle'); - if(cb) cb.checked = !!st.exposed; - const warn = document.getElementById('lan-warn'); - if(!warn) return; - if(!st.exposed){ - warn.style.display = ''; - warn.innerHTML = '
Le moteur n\'écoute que sur cette machine : l\'adresse ci-dessus ne répond pas depuis un autre ordinateur.
'; - return; - } - // Exposé mais bloqué par le pare-feu : le cas le plus déroutant (« ça écoute - // partout » et pourtant rien ne passe). On donne la commande à coller. - if(st.hint){ - warn.style.display = ''; - // --err et non --warn : la palette est volontairement monochrome et --warn y - // est un gris, illisible comme alerte. Ici il y a une vraie action à faire. - warn.innerHTML = '
⚠ ' - + escHtml(st.hint).replace(/\n/g,'
') + '
'; - return; - } - warn.style.display = 'none'; -} -async function loadNetwork(){ - try{ const r = await jget('/api/network'); renderNetwork(r && r.status); }catch(e){} -} -async function toggleLAN(){ - const cb = document.getElementById('lan-toggle'); - const on = cb.checked; - const r = await jpost('/api/network', {exposed:on}); - if(!r || !r.ok){ cb.checked = !on; toast('erreur : ' + ((r&&r.error)||'')); return; } - renderNetwork(r.status); - // llama-server ne lit --host qu'au lancement : sans redémarrage, l'interrupteur - // affiche un état que le moteur en cours ne respecte pas encore. - if(await askConfirm('Le moteur doit redémarrer pour appliquer ce changement (le modèle sera rechargé).', - {title: on ? 'Ouvrir sur le réseau' : 'Fermer sur le réseau', okText:'Redémarrer'})){ - await act('restart'); // même chemin que les boutons du panneau Moteur - } - loadApiKey(); -} +// L'écoute réseau du moteur (HOST + pare-feu) n'a plus d'interrupteur : depuis +// que Loki sert /v1 sur son propre port, ouvrir le moteur sur le réseau n'a plus +// d'objet — et deux interrupteurs pour « rendre l'IA joignable » était +// exactement la confusion qu'on voulait lever. La route /api/network et la +// commande `loki network` restent, pour qui veut exposer le moteur en direct. function toggleKeyReveal(){ OAI_REVEAL=!OAI_REVEAL; const inp=document.getElementById('oai-key'); if(OAI_KEY) inp.value = OAI_REVEAL ? OAI_KEY : (OAI_KEY.slice(0,8)+'…'+OAI_KEY.slice(-4)); const eye=document.getElementById('oai-key-eye'); if(eye) eye.textContent = OAI_REVEAL ? 'masquer' : 'afficher'; } async function apiKeyAction(action){ if(action==='clear' && !await askConfirm('Retirer la clé rend l\'endpoint OpenAI accessible SANS authentification. Le service va redémarrer.', {title:'Retirer la clé API ?', okText:'Retirer'})) return; @@ -435,24 +408,6 @@ async function apiKeyAction(action){ toast('application…'); renderApiKey(await jpost('/api/apikey', {action})); } -async function toggleOAIPublic(){ - const cb = document.getElementById('oai-public-toggle'); - const on = cb.checked; - // L'accès OpenAI public passe par ajean.link (.oai.ajean.link) : il exige - // que l'accès distant soit activé sur ce serveur. Sinon, on annule et on explique. - if(on){ - let linked = false; - try{ const s = await jget('/api/link/status'); linked = !!(s && s.linked); }catch(e){} - if(!linked){ - cb.checked = false; - await askAlert('Vous devez activer l\'accès distant (ajean.link) pour bénéficier de cette fonctionnalité. Ouvrez le panneau « Accès distant » pour connecter ce serveur.', {title:'Accès distant requis'}); - return; - } - } - await jpost('/api/oai/public', {enabled:on}); - toast(on ? 'accès public activé' : 'accès public coupé'); - loadApiKey(); -} async function apiKeySet(){ const k = await askPrompt('Colle ta clé API (ou laisse vide pour annuler) :', {title:'Définir la clé API', placeholder:'sk-…'}); if(!k || !k.trim()) return; @@ -496,7 +451,7 @@ async function loadAll(){ // MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute // suppression de module doit donc retirer son appel de cette ligne, et le test // navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard. - await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp()]); + await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp()]); releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css index 0ea05fd..31f0c1c 100644 --- a/internal/loki/ui/src/styles.css +++ b/internal/loki/ui/src/styles.css @@ -1804,3 +1804,9 @@ html[data-files="1"] #files-btn{color:var(--accent)} .row>button:has(svg){display:inline-flex;align-items:center;gap:7px} /* Le « + » des sections : même carré que les autres commandes de la barre. */ .preset-add{width:26px;height:26px;border-radius:8px} +/* Étiquettes de groupe : même traitement que les titres de section — la + maquette n'a qu'un style de titre, et le monospace en capitales de la charte + précédente criait au milieu des formulaires. */ +.subhead,.io-cap,.pages-bar .pages-title,.skillshead>span{ + font-family:var(--font);text-transform:none;letter-spacing:0; + font-size:12.5px;font-weight:600;color:var(--dim)} diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go index 06644d0..22fdbda 100644 --- a/internal/loki/web_api.go +++ b/internal/loki/web_api.go @@ -546,51 +546,59 @@ func handleAPIKey(w http.ResponseWriter, r *http.Request) { _ = serviceAction("restart") } } - k := readAPIKey() + k, _ := effectiveAPIKeyErr() sendJSON(w, 200, map[string]any{ "ok": true, "set": k != "", "key": k, "masked": maskAPIKey(k), - "port": LLMPort(), - "host": localIP(), - // Accès OpenAI PUBLIC via ajean.link (passthrough SNI, VPS aveugle) : si - // activé, l'URL publique est https://.oai.ajean.link/v1. - "oai_public": oaiPublicEnabled(), - "machine": machineID(), + // L'adresse à coller dans un client tiers. Calculée côté serveur (voir + // web_public_url.go) : adresse publique enregistrée, sinon l'origine par + // laquelle cette requête est arrivée — donc le domaine ou l'IP que + // l'utilisateur a réellement tapés. + "url": oaiBaseURL(r) + "/v1", + "public_url": publicURL(), }) } -// handleOAIPublic pilote le drapeau d'accès OpenAI public (exposition via -// ajean.link). GET renvoie l'état ; POST {enabled} l'active/coupe en direct -// (aucun redémarrage : le démux du tunnel relit le drapeau à chaque connexion). -func handleOAIPublic(w http.ResponseWriter, r *http.Request) { +// handlePublicAddress lit et enregistre l'adresse publique de cette instance — +// le nom de domaine ou l'IP par lesquels on l'atteint. +// +// GET → {ok, url, set} +// POST {url:"ia.exemple.fr"} → {ok, url} normalisée, ou 400 avec la raison +// +// Elle remplace l'ancien interrupteur d'exposition via le relais de l'amont : +// ce chemin exigeait un jeton de relais que ce fork ne permet plus d'obtenir, et +// l'interrupteur ne pouvait donc qu'échouer. Aucun redémarrage ici — c'est un +// libellé d'affichage, pas un réglage du moteur. +func handlePublicAddress(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { var req struct { - Enabled *bool `json:"enabled"` + URL string `json:"url"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": "requête illisible"}) + return + } + clean, err := setPublicURL(req.URL) + if err != nil { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) return } - if req.Enabled != nil { - if err := setOAIPublic(*req.Enabled); err != nil { - sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) - return - } - } + sendJSON(w, 200, map[string]any{"ok": true, "url": clean, "set": clean != ""}) + return } - sendJSON(w, 200, map[string]any{ - "ok": true, - "enabled": oaiPublicEnabled(), - "machine": machineID(), - }) + u := publicURL() + sendJSON(w, 200, map[string]any{"ok": true, "url": u, "set": u != ""}) } // localIP best-effort renvoie l'IPv4 LAN primaire de la machine (l'IP source du -// trafic sortant), ou "localhost" à défaut. Sert à annoncer l'endpoint OpenAI -// avec une adresse correcte sur le réseau local MÊME quand l'UI est atteinte via -// le tunnel ajean.link (où location.hostname serait le domaine du relais, faux). +// trafic sortant), ou "localhost" à défaut. +// +// ⚠️ Elle ne sert plus qu'à décrire l'écoute BRUTE DU MOTEUR dans `loki network` +// (sys_network.go). L'endpoint annoncé à l'utilisateur, lui, est celui de Loki +// et se calcule tout autrement (web_public_url.go) : en conteneur, cette IP est +// celle du bridge Docker, que personne ne peut joindre. func localIP() string { conn, err := net.Dial("udp", "8.8.8.8:80") if err != nil { diff --git a/internal/loki/web_auth.go b/internal/loki/web_auth.go index aad542d..cda98b0 100644 --- a/internal/loki/web_auth.go +++ b/internal/loki/web_auth.go @@ -59,6 +59,63 @@ func requireWebAuth(next http.HandlerFunc) http.HandlerFunc { } } +// 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 diff --git a/internal/loki/web_public_url.go b/internal/loki/web_public_url.go new file mode 100644 index 0000000..b44228a --- /dev/null +++ b/internal/loki/web_public_url.go @@ -0,0 +1,142 @@ +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://: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) +} diff --git a/internal/loki/web_public_url_test.go b/internal/loki/web_public_url_test.go new file mode 100644 index 0000000..29d3a79 --- /dev/null +++ b/internal/loki/web_public_url_test.go @@ -0,0 +1,116 @@ +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) + } +} diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index 9516e30..6e462b4 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -53,6 +53,14 @@ func cmdWeb(args []string) error { } else { fmt.Printf("%s API protégée par clé (Authorization: Bearer …)\n", green("[ok]")) } + // Même avertissement pour la surface des complétions : depuis qu'elle est + // servie ici, elle est joignable partout où l'interface l'est. + if k, _ := effectiveAPIKeyErr(); k == "" { + fmt.Printf("%s endpoint OpenAI /v1 NON protégé (aucune clé) — quiconque joint cette adresse peut utiliser le modèle :\n", yellow("[!]")) + fmt.Printf(" %s\n", bold("loki set-api-key")) + } else { + fmt.Printf("%s endpoint OpenAI : http://%s/v1 (clé requise)\n", green("[oai]"), addr) + } // Accès distant : le tunnel vers le relais est ouvert ICI, dans le process qui // sert déjà l'UI, et avec le MÊME mux. C'est la condition d'une conversation @@ -73,7 +81,9 @@ func cmdWeb(args []string) error { // ReadHeaderTimeout : sans lui, une connexion qui n'envoie jamais sa requête // immobilise une goroutine pour toujours — et ce port écoute sur 0.0.0.0. // Surtout PAS de WriteTimeout ici : il couperait les flux SSE du chat, qui - // restent ouverts aussi longtemps que l'utilisateur regarde la page. + // restent ouverts aussi longtemps que l'utilisateur regarde la page — et + // depuis mountOAI, il couperait aussi les complétions en streaming des + // clients tiers, en plein milieu d'une génération. srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} return srv.Serve(ln) } @@ -140,6 +150,12 @@ func newWebMux() *http.ServeMux { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Write(b) }) + // Endpoint compatible OpenAI, servi par Loki SUR SON PORT et relayé vers + // llama-server (voir mountOAI). C'est ce qui le rend joignable par le nom de + // domaine ou l'IP de l'interface, sans publier de second port ni ouvrir le + // moteur sur le réseau. Il n'est PAS derrière requireWebAuth : il a sa propre + // clé, celle des complétions (web_auth.go). + mountOAI(mux) // api enregistre une route /api/* protégée par la clé de pilotage (web_auth.go). api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(h)) } api("/api/ping", handlePing) @@ -181,7 +197,7 @@ func newWebMux() *http.ServeMux { api("/api/agent/toggle", handleAgentToggle) api("/api/agent/compact", handleCompactToggle) api("/api/apikey", handleAPIKey) - api("/api/oai/public", handleOAIPublic) + api("/api/oai/public", handlePublicAddress) // adresse publique (domaine/IP) de cette instance api("/api/link/status", handleLinkStatus) // état de l'accès distant (ajean.link) api("/api/link/connect", handleLinkConnect) // clé de liaison remise par connect.html → loki link api("/api/link/start", handleLinkStart) // (re)démarre le tunnel avec la clé déjà enregistrée