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