mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Merge branch 'claude/discussion-file-linking-nzgd0d'
This commit is contained in:
15 files changed
+858
-241
No files matched your search
@@ -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://<ip>: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
|
||||
(`<machine>.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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -176,9 +176,7 @@ func cmdServe(args []string) error {
|
||||
// exige alors l'en-tête "Authorization: Bearer <clé>". 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
|
||||
|
||||
+67
-14
@@ -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
|
||||
|
||||
@@ -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é")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+52
-96
@@ -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)}
|
||||
</style></head><body>
|
||||
<button id="menubtn" onclick="toggleSide()" title="menu" aria-label="ouvrir le menu"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg></button>
|
||||
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
|
||||
@@ -2088,35 +2094,30 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
</div>
|
||||
</details>
|
||||
<details><summary><span class="stitle">Accès OpenAI</span></summary>
|
||||
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur ces adresses. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer <clé></code>.</span></span></div>
|
||||
<!-- UNE carte pour tout l'endpoint : adresse locale, bascule « public » et
|
||||
adresse publique sont un seul sujet — trois cartes accolées donnaient
|
||||
trois îlots pour un même réglage. -->
|
||||
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor, Open WebUI…) sur cette adresse — le modèle s'y appelle <code>loki</code>. L'endpoint est servi par Loki <b>sur le port de cette interface</b> : aucun autre port à ouvrir. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer <clé></code>.</span></span></div>
|
||||
<div class="slist">
|
||||
<div class="rowbody">
|
||||
<div class="muted" style="margin:0 0 6px">Adresse locale (réseau) — modèle <code>loki</code></div>
|
||||
<div class="muted" style="margin:0 0 6px">Adresse de l'endpoint — modèle <code>loki</code></div>
|
||||
<div class="row" style="margin:0">
|
||||
<input id="oai-url" readonly class="io-input">
|
||||
<button class="iconbtn" onclick="copyText(document.getElementById('oai-url').value,'adresse copiée')" title="copier"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- L'adresse ci-dessus ne sert à RIEN si le moteur n'écoute que sur la
|
||||
boucle locale (le défaut sous Windows) : le chat du navigateur marche,
|
||||
mais aucun logiciel du réseau ne peut s'y brancher. Cet interrupteur
|
||||
règle HOST et pose la règle de pare-feu, voir sys_network.go. -->
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="lan-toggle" onchange="toggleLAN()"><span class="slider"></span></span> joignable depuis le réseau local<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Éteint, le moteur n'écoute que sur <b>cette machine</b> : l'adresse ci-dessus ne répond qu'ici. Allumez-le pour brancher un logiciel depuis un autre ordinateur du réseau (sous Windows, la règle de pare-feu est posée en même temps). <b>Redémarre le moteur.</b></span></span></label>
|
||||
<div id="lan-warn" class="rowbody" style="display:none"></div>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="oai-public-toggle" onchange="toggleOAIPublic()"><span class="slider"></span></span> exposer en public (ajean.link)<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Rend votre IA joignable depuis <b>n'importe où</b> via <code>https://<machine>.oai.ajean.link/v1</code> (pour brancher un SaaS). Le trafic est chiffré <b>de bout en bout</b> jusqu'à ce serveur — le relais ajean.link ne voit rien. Nécessite l'accès distant actif.</span></span></label>
|
||||
<div id="oai-public-wrap" class="rowbody" style="display:none">
|
||||
<div class="muted" style="margin:0 0 6px">Adresse publique — chiffrée de bout en bout, accessible partout</div>
|
||||
<!-- Adresse publique : ce que Loki ne peut pas deviner. Vide, l'adresse
|
||||
ci-dessus est celle par laquelle CE navigateur a ouvert l'interface —
|
||||
déjà juste dans la plupart des cas. Renseignée, elle prend le pas :
|
||||
c'est le cas du reverse proxy, où Loki ne voit qu'un appel interne. -->
|
||||
<div class="rowbody">
|
||||
<div class="muted" style="margin:0 0 6px">Adresse publique <span style="opacity:.7">— facultative</span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Le nom de domaine ou l'IP par lesquels on atteint cette interface, par exemple <code>ia.exemple.fr</code> ou <code>192.168.1.20:8090</code>. <b>Laissez vide</b> et l'adresse affichée suit celle de votre navigateur. Renseignez-la si vous passez par un <b>reverse proxy</b> ou un nom de domaine : Loki ne voit alors qu'un appel interne et ne peut pas la deviner. Sans <code>https://</code>, il est ajouté.</span></span></div>
|
||||
<div class="row" style="margin:0">
|
||||
<input id="oai-public-url" readonly class="io-input">
|
||||
<button class="iconbtn" onclick="copyText(document.getElementById('oai-public-url').value,'adresse publique copiée')" title="copier"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>
|
||||
<input id="public-addr" class="io-input" placeholder="ia.exemple.fr" onkeydown="if(event.key==='Enter')savePublicAddr()">
|
||||
<button onclick="savePublicAddr()">enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subhead">Clé API<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Protège l'endpoint des complétions (llama-server). Indispensable avant d'exposer le serveur sur internet. Modifier la clé <b>redémarre</b> le service.</span></span></div>
|
||||
<div class="subhead">Clé API<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Protège l'endpoint des complétions — vérifiée par Loki <b>et</b> par le moteur. Indispensable dès que l'adresse ci-dessus est joignable par d'autres. Modifier la clé <b>redémarre</b> le moteur.</span></span></div>
|
||||
<div class="slist"><div class="rowbody">
|
||||
<div id="oai-open-warn" style="display:none;margin:0 0 8px;font-size:11.5px;line-height:1.5;color:var(--err)"></div>
|
||||
<div class="row" style="margin:0">
|
||||
<input id="oai-key" readonly class="io-input">
|
||||
<button id="oai-key-eye" onclick="toggleKeyReveal()" title="afficher / masquer">afficher</button>
|
||||
@@ -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 = '<div class="muted" style="margin:0">Le moteur n\'écoute que sur cette machine : l\'adresse ci-dessus ne répond pas depuis un autre ordinateur.</div>';
|
||||
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 = '<div style="margin:0;color:var(--err)">⚠ '
|
||||
+ escHtml(st.hint).replace(/\n/g,'<br>') + '</div>';
|
||||
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 (<machine>.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); }
|
||||
|
||||
@@ -283,35 +283,30 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</details>
|
||||
<details><summary><span class="stitle">Accès OpenAI</span></summary>
|
||||
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur ces adresses. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer <clé></code>.</span></span></div>
|
||||
<!-- UNE carte pour tout l'endpoint : adresse locale, bascule « public » et
|
||||
adresse publique sont un seul sujet — trois cartes accolées donnaient
|
||||
trois îlots pour un même réglage. -->
|
||||
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor, Open WebUI…) sur cette adresse — le modèle s'y appelle <code>loki</code>. L'endpoint est servi par Loki <b>sur le port de cette interface</b> : aucun autre port à ouvrir. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer <clé></code>.</span></span></div>
|
||||
<div class="slist">
|
||||
<div class="rowbody">
|
||||
<div class="muted" style="margin:0 0 6px">Adresse locale (réseau) — modèle <code>loki</code></div>
|
||||
<div class="muted" style="margin:0 0 6px">Adresse de l'endpoint — modèle <code>loki</code></div>
|
||||
<div class="row" style="margin:0">
|
||||
<input id="oai-url" readonly class="io-input">
|
||||
<button class="iconbtn" onclick="copyText(document.getElementById('oai-url').value,'adresse copiée')" title="copier"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- L'adresse ci-dessus ne sert à RIEN si le moteur n'écoute que sur la
|
||||
boucle locale (le défaut sous Windows) : le chat du navigateur marche,
|
||||
mais aucun logiciel du réseau ne peut s'y brancher. Cet interrupteur
|
||||
règle HOST et pose la règle de pare-feu, voir sys_network.go. -->
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="lan-toggle" onchange="toggleLAN()"><span class="slider"></span></span> joignable depuis le réseau local<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Éteint, le moteur n'écoute que sur <b>cette machine</b> : l'adresse ci-dessus ne répond qu'ici. Allumez-le pour brancher un logiciel depuis un autre ordinateur du réseau (sous Windows, la règle de pare-feu est posée en même temps). <b>Redémarre le moteur.</b></span></span></label>
|
||||
<div id="lan-warn" class="rowbody" style="display:none"></div>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="oai-public-toggle" onchange="toggleOAIPublic()"><span class="slider"></span></span> exposer en public (ajean.link)<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Rend votre IA joignable depuis <b>n'importe où</b> via <code>https://<machine>.oai.ajean.link/v1</code> (pour brancher un SaaS). Le trafic est chiffré <b>de bout en bout</b> jusqu'à ce serveur — le relais ajean.link ne voit rien. Nécessite l'accès distant actif.</span></span></label>
|
||||
<div id="oai-public-wrap" class="rowbody" style="display:none">
|
||||
<div class="muted" style="margin:0 0 6px">Adresse publique — chiffrée de bout en bout, accessible partout</div>
|
||||
<!-- Adresse publique : ce que Loki ne peut pas deviner. Vide, l'adresse
|
||||
ci-dessus est celle par laquelle CE navigateur a ouvert l'interface —
|
||||
déjà juste dans la plupart des cas. Renseignée, elle prend le pas :
|
||||
c'est le cas du reverse proxy, où Loki ne voit qu'un appel interne. -->
|
||||
<div class="rowbody">
|
||||
<div class="muted" style="margin:0 0 6px">Adresse publique <span style="opacity:.7">— facultative</span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Le nom de domaine ou l'IP par lesquels on atteint cette interface, par exemple <code>ia.exemple.fr</code> ou <code>192.168.1.20:8090</code>. <b>Laissez vide</b> et l'adresse affichée suit celle de votre navigateur. Renseignez-la si vous passez par un <b>reverse proxy</b> ou un nom de domaine : Loki ne voit alors qu'un appel interne et ne peut pas la deviner. Sans <code>https://</code>, il est ajouté.</span></span></div>
|
||||
<div class="row" style="margin:0">
|
||||
<input id="oai-public-url" readonly class="io-input">
|
||||
<button class="iconbtn" onclick="copyText(document.getElementById('oai-public-url').value,'adresse publique copiée')" title="copier"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>
|
||||
<input id="public-addr" class="io-input" placeholder="ia.exemple.fr" onkeydown="if(event.key==='Enter')savePublicAddr()">
|
||||
<button onclick="savePublicAddr()">enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subhead">Clé API<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Protège l'endpoint des complétions (llama-server). Indispensable avant d'exposer le serveur sur internet. Modifier la clé <b>redémarre</b> le service.</span></span></div>
|
||||
<div class="subhead">Clé API<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Protège l'endpoint des complétions — vérifiée par Loki <b>et</b> par le moteur. Indispensable dès que l'adresse ci-dessus est joignable par d'autres. Modifier la clé <b>redémarre</b> le moteur.</span></span></div>
|
||||
<div class="slist"><div class="rowbody">
|
||||
<div id="oai-open-warn" style="display:none;margin:0 0 8px;font-size:11.5px;line-height:1.5;color:var(--err)"></div>
|
||||
<div class="row" style="margin:0">
|
||||
<input id="oai-key" readonly class="io-input">
|
||||
<button id="oai-key-eye" onclick="toggleKeyReveal()" title="afficher / masquer">afficher</button>
|
||||
|
||||
@@ -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 = '<div class="muted" style="margin:0">Le moteur n\'écoute que sur cette machine : l\'adresse ci-dessus ne répond pas depuis un autre ordinateur.</div>';
|
||||
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 = '<div style="margin:0;color:var(--err)">⚠ '
|
||||
+ escHtml(st.hint).replace(/\n/g,'<br>') + '</div>';
|
||||
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 (<machine>.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); }
|
||||
@@ -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)}
|
||||
+34
-26
@@ -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://<machine>.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 {
|
||||
|
||||
@@ -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 <clé>.",
|
||||
"invalid_request_error", "invalid_api_key")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// checkBearer reports whether the request carries the expected key as an
|
||||
// "Authorization: Bearer <clé>" header. La comparaison est à temps constant.
|
||||
// PAS de repli ?key=<clé> en query string : une clé dans l'URL finit dans les
|
||||
|
||||
@@ -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://<ip>:8080/v1), qui n'est joignable dans aucun déploiement en
|
||||
// conteneur — le port n'y est pas publié — et dont l'IP, en conteneur, est
|
||||
// celle du bridge Docker. Deux façons de faire mieux, dans cet ordre :
|
||||
//
|
||||
// 1. l'adresse publique enregistrée, quand l'utilisateur passe par un nom de
|
||||
// domaine ou un reverse proxy et l'a saisie une fois pour toutes ;
|
||||
// 2. sinon, l'origine de la requête elle-même — c'est-à-dire exactement le
|
||||
// domaine ou l'IP qu'on a tapés pour ouvrir l'interface.
|
||||
//
|
||||
// Le calcul est ici, en Go, et pas dans le navigateur : une seule règle de
|
||||
// priorité, réutilisable par la CLI et les journaux, et plus de construction
|
||||
// d'URL à coups de concaténation côté client — c'est ce qui avait produit
|
||||
// l'adresse fantôme.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// publicURL renvoie l'adresse publique enregistrée, normalisée, ou "".
|
||||
func publicURL() string { return getStr(bkState, "public_url") }
|
||||
|
||||
// setPublicURL normalise puis enregistre l'adresse publique. Une chaîne vide
|
||||
// efface le réglage (on repasse alors sur l'origine de la requête). Renvoie la
|
||||
// valeur réellement stockée.
|
||||
func setPublicURL(raw string) (string, error) {
|
||||
clean, err := normalizePublicURL(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := putStr(bkState, "public_url", clean); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
// normalizePublicURL ramène une saisie humaine à une origine canonique :
|
||||
// "schéma://hôte[:port]", sans slash final. Pure, donc testable sans E/S.
|
||||
//
|
||||
// Les refus sont volontairement bavards : cette valeur est recopiée dans la
|
||||
// configuration d'un autre logiciel, une erreur silencieuse s'y paierait en
|
||||
// « ça ne marche pas » sans indice.
|
||||
func normalizePublicURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", nil // effacement : ce n'est pas une erreur
|
||||
}
|
||||
// Sans schéma, url.Parse lit « exemple.fr:8090 » comme un schéma « exemple.fr ».
|
||||
// Quelqu'un qui tape un nom de domaine nu veut https.
|
||||
if !strings.Contains(raw, "://") {
|
||||
raw = "https://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("adresse illisible : %s", raw)
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return "", fmt.Errorf("adresse en %s:// — seuls http:// et https:// sont acceptés", scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return "", fmt.Errorf("adresse sans nom de machine")
|
||||
}
|
||||
if u.User != nil {
|
||||
return "", fmt.Errorf("l'adresse ne doit pas porter d'identifiants (%s@…)", u.User.Username())
|
||||
}
|
||||
if u.RawQuery != "" || u.Fragment != "" {
|
||||
return "", fmt.Errorf("l'adresse ne doit porter ni paramètre ni ancre")
|
||||
}
|
||||
// Chemin : on tolère le « /v1 » que l'utilisateur recopie naturellement
|
||||
// depuis l'adresse affichée, et rien d'autre — le reste supposerait que le
|
||||
// reverse proxy réécrit les chemins, ce qu'on ne peut pas vérifier d'ici.
|
||||
switch strings.TrimSuffix(u.Path, "/") {
|
||||
case "", "/v1":
|
||||
default:
|
||||
return "", fmt.Errorf("l'adresse ne doit pas contenir de chemin (%s) : Loki sert /v1 à la racine de son port", u.Path)
|
||||
}
|
||||
host := u.Host
|
||||
if h, port, err := net.SplitHostPort(host); err == nil {
|
||||
if h == "" {
|
||||
return "", fmt.Errorf("adresse sans nom de machine")
|
||||
}
|
||||
if n, err := net.LookupPort("tcp", port); err != nil || n == 0 {
|
||||
return "", fmt.Errorf("port invalide : %s", port)
|
||||
}
|
||||
} else if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
|
||||
// IPv6 littéral doit être entre crochets, sinon on ne sait pas où
|
||||
// s'arrête l'adresse et où commence le port.
|
||||
return "", fmt.Errorf("adresse IPv6 à écrire entre crochets : [%s]", host)
|
||||
}
|
||||
return scheme + "://" + host, nil
|
||||
}
|
||||
|
||||
// requestBase renvoie l'origine par laquelle CETTE requête a atteint Loki :
|
||||
// "schéma://hôte[:port]", sans slash final.
|
||||
//
|
||||
// L'hôte vient de r.Host, c'est-à-dire de l'en-tête Host du navigateur : le
|
||||
// domaine réellement tapé, correct même derrière un reverse proxy qui ne
|
||||
// réécrit pas Host. On n'honore PAS X-Forwarded-Host, qui élargirait la surface
|
||||
// de confiance sans rien apporter ici.
|
||||
//
|
||||
// Le schéma, lui, a besoin de X-Forwarded-Proto : derrière un proxy qui termine
|
||||
// le TLS, Loki ne voit qu'une requête en clair et annoncerait http:// pour une
|
||||
// interface servie en https://. Ces en-têtes sont forgeables quand il n'y a pas
|
||||
// de proxy devant — sans conséquence : le résultat n'est qu'une chaîne affichée
|
||||
// à un propriétaire déjà authentifié, jamais une décision d'accès.
|
||||
func requestBase(r *http.Request) string {
|
||||
scheme := "http"
|
||||
switch {
|
||||
case r.TLS != nil:
|
||||
scheme = "https"
|
||||
}
|
||||
if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" {
|
||||
first := strings.ToLower(strings.TrimSpace(strings.Split(fwd, ",")[0]))
|
||||
if first == "http" || first == "https" {
|
||||
scheme = first
|
||||
}
|
||||
}
|
||||
host := r.Host
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
return scheme + "://" + host
|
||||
}
|
||||
|
||||
// oaiBaseURL est l'adresse à coller dans un client tiers : l'adresse publique
|
||||
// enregistrée si elle existe, sinon celle par laquelle la requête est arrivée.
|
||||
func oaiBaseURL(r *http.Request) string {
|
||||
if u := publicURL(); u != "" {
|
||||
return u
|
||||
}
|
||||
return requestBase(r)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user