API interne pour la façade Rust : clé de pilotage à la demande

Lot 2 de la migration : la façade va authentifier elle-même ses routes
/api/*. La clé de pilotage vit dans la base bbolt, verrouillée par ce
process ; la façade la demande donc à GET /internal/v1/auth, à chaque
requête — un changement de clé prend effet tout de suite, comme avant.

La réponse contient un secret, d'où trois verrous : route branchée
uniquement sur l'écoute TCP directe (le tunnel du relais sert le mux nu),
jeton tiré au hasard par la façade à chaque démarrage et exigé en
temps constant, 404 sans rien révéler sinon. Base illisible : 503, la
façade fermera l'accès plutôt que de l'ouvrir.

Spec du lot 2 jointe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-13 22:35:00 +02:00
1 parent 4b859c4eb6
commit a99d988e70
4 files changed
+233 -1

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
package loki
import (
"crypto/subtle"
"net/http"
"os"
"strings"
)
// API interne entre la façade Rust (rust/loki-rs) et ce serveur.
//
// La façade reprend des routes une à une ; pour authentifier celles qu'elle
// sert elle-même, elle doit connaître la clé de pilotage, qui vit dans la base
// bbolt — verrouillée en exclusif par ce process. Elle la demande donc ici, à
// chaque requête (même règle que requireWebAuth : un changement de clé prend
// effet immédiatement).
//
// Trois verrous, parce que la réponse contient un secret :
// - la route n'existe que sur l'écoute TCP directe (withInternalAPI enveloppe
// le mux dans cmdWeb) : le tunnel du relais sert le mux nu et ne la voit
// jamais ;
// - elle exige le jeton que la façade tire au hasard à chaque démarrage et
// transmet par l'environnement (LOKI_INTERNAL_TOKEN) ;
// - sans jeton, jeton trop court ou faux : 404, rien ne trahit la route.
const (
internalTokenEnv = "LOKI_INTERNAL_TOKEN"
internalTokenHeader = "X-Loki-Internal"
internalPrefix = "/internal/"
// Un jeton plus court qu'un secret de 128 bits désactive l'API interne :
// mieux vaut une façade qui ferme l'accès qu'une route devinable.
internalTokenMinLen = 32
)
func internalToken() string {
t := strings.TrimSpace(os.Getenv(internalTokenEnv))
if len(t) < internalTokenMinLen {
return ""
}
return t
}
// withInternalAPI sert /internal/* et passe tout le reste à next.
func withInternalAPI(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, internalPrefix) {
next.ServeHTTP(w, r)
return
}
tok := internalToken()
got := r.Header.Get(internalTokenHeader)
if tok == "" || subtle.ConstantTimeCompare([]byte(got), []byte(tok)) != 1 {
http.NotFound(w, r)
return
}
switch r.URL.Path {
case "/internal/v1/auth":
handleInternalAuth(w, r)
default:
http.NotFound(w, r)
}
})
}
// handleInternalAuth : de quoi authentifier une requête de pilotage côté Rust.
// Base illisible → 503 : la façade ferme alors l'accès, elle n'ouvre jamais
// sur une panne d'E/S (cf. readWebKeyErr).
func handleInternalAuth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET attendu"})
return
}
key, err := readWebKeyErr()
if err != nil {
sendJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "configuration illisible"})
return
}
w.Header().Set("Cache-Control", "no-store")
sendJSON(w, http.StatusOK, map[string]any{"web_key": key, "version": Version})
}
+75
View File
@@ -0,0 +1,75 @@
package loki
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const jetonTest = "0123456789abcdef0123456789abcdef"
func appelInterne(t *testing.T, path, jeton string) *httptest.ResponseRecorder {
t.Helper()
suite := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("mux"))
})
r := httptest.NewRequest("GET", path, nil)
if jeton != "" {
r.Header.Set(internalTokenHeader, jeton)
}
w := httptest.NewRecorder()
withInternalAPI(suite).ServeHTTP(w, r)
return w
}
func TestAPIInterneFermeeSansJeton(t *testing.T) {
t.Setenv(internalTokenEnv, "")
if w := appelInterne(t, "/internal/v1/auth", jetonTest); w.Code != 404 {
t.Fatalf("sans jeton configuré : %d, attendu 404", w.Code)
}
// Jeton configuré mais trop court : API désactivée, même avec le bon.
t.Setenv(internalTokenEnv, "court")
if w := appelInterne(t, "/internal/v1/auth", "court"); w.Code != 404 {
t.Fatalf("jeton trop court : %d, attendu 404", w.Code)
}
}
func TestAPIInterneRefuseMauvaisJeton(t *testing.T) {
t.Setenv(internalTokenEnv, jetonTest)
for _, j := range []string{"", "x", strings.ToUpper(jetonTest)} {
if w := appelInterne(t, "/internal/v1/auth", j); w.Code != 404 {
t.Fatalf("jeton %q : %d, attendu 404", j, w.Code)
}
}
}
func TestAPIInterneAuth(t *testing.T) {
testHome(t)
t.Setenv(internalTokenEnv, jetonTest)
if err := putStr(bkState, "web_key", "loki-web-secret"); err != nil {
t.Fatal(err)
}
w := appelInterne(t, "/internal/v1/auth", jetonTest)
if w.Code != 200 {
t.Fatalf("code %d : %s", w.Code, w.Body.String())
}
var got map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["web_key"] != "loki-web-secret" || got["version"] != Version {
t.Fatalf("réponse : %+v", got)
}
if w := appelInterne(t, "/internal/v1/autre", jetonTest); w.Code != 404 {
t.Fatalf("route inconnue : %d", w.Code)
}
}
func TestAPIInterneLaisseLeResteAuMux(t *testing.T) {
t.Setenv(internalTokenEnv, jetonTest)
if w := appelInterne(t, "/api/ping", ""); w.Body.String() != "mux" {
t.Fatalf("relais au mux : %q", w.Body.String())
}
}
+3 -1
View File
@@ -96,7 +96,9 @@ func cmdWeb(args []string) error {
// clients tiers, en plein milieu d'une génération.
// gzipHandler : l'UI (~590 Ko) et les gros JSON partent compressés ; les flux
// SSE et les binaires passent tels quels (voir web_gzip.go).
srv := &http.Server{Handler: gzipHandler(mux), ReadHeaderTimeout: 10 * time.Second}
// withInternalAPI : l'API interne de la façade Rust (web_internal.go) n'est
// branchée QUE sur cette écoute directe — le tunnel sert le mux nu.
srv := &http.Server{Handler: gzipHandler(withInternalAPI(mux)), ReadHeaderTimeout: 10 * time.Second}
return srv.Serve(ln)
}