mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-14 02:38:20 +02:00
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:
1 parent
4b859c4eb6
commit
a99d988e70
4 files changed
+233
-1
No files matched your search
@@ -0,0 +1,74 @@
|
||||
# Migration Rust — lot 2 : UI statique, authentification, `/api/ping`
|
||||
|
||||
Date : 2026-10-13 · Statut : validé · Prérequis : lot 1 (façade `loki-rs`).
|
||||
|
||||
## But
|
||||
|
||||
Faire passer en Rust les premières routes réelles, et surtout poser le socle
|
||||
dont toutes les routes `/api/*` suivantes auront besoin : l'authentification.
|
||||
|
||||
## Périmètre
|
||||
|
||||
1. **UI statique servie par Rust**, embarquée dans le binaire à la
|
||||
compilation depuis `internal/loki/ui/` (même source que le `go:embed`) :
|
||||
|
||||
| Route | Content-Type | Cache-Control | Autre |
|
||||
|---|---|---|---|
|
||||
| `/`, `/index.html` | `text/html; charset=utf-8` | `no-store, max-age=0` | |
|
||||
| `/marked.min.js` | `application/javascript` | `public, max-age=86400` | |
|
||||
| `/sw.js` | `application/javascript` | `no-store, max-age=0` | `Service-Worker-Allowed: /` |
|
||||
| `/manifest.webmanifest` | `application/manifest+json` | `public, max-age=3600` | |
|
||||
| `/fonts/<nom>.woff2` | `font/woff2` | `public, max-age=31536000, immutable` | |
|
||||
|
||||
GET et HEAD seulement (les autres méthodes restent relayées au Go).
|
||||
Compression gzip si le client l'accepte, sauf polices (comme
|
||||
`web_gzip.go`) ; `Vary: Accept-Encoding` sur les types compressibles.
|
||||
Version compressée calculée une fois, à la première demande.
|
||||
|
||||
2. **API interne Go** `GET /internal/v1/auth` → `{"web_key": "…", "version": "…"}`.
|
||||
- Servie **uniquement sur l'écoute TCP directe** de `loki web`
|
||||
(enveloppe autour du mux dans `cmdWeb`) : le tunnel du relais sert le mux
|
||||
nu et ne la voit jamais.
|
||||
- Exige l'en-tête `X-Loki-Internal` égal à `LOKI_INTERNAL_TOKEN` (≥ 32
|
||||
caractères, comparaison en temps constant). Jeton absent, trop court ou
|
||||
faux → 404 (rien ne révèle l'existence de la route).
|
||||
- Base illisible → 503 : la façade fermera l'accès (jamais ouvert par
|
||||
défaut sur une panne d'E/S, même règle que `requireWebAuth`).
|
||||
- La façade tire un jeton aléatoire de 32 octets à chaque démarrage et le
|
||||
passe au Go par l'environnement.
|
||||
|
||||
3. **Authentification de pilotage en Rust** (`auth.rs`), port à l'identique de
|
||||
`requireWebAuth` + `crossSiteReject` + `localHostName` + `checkBearer` :
|
||||
- clé relue à **chaque requête** (API interne) : un changement de clé prend
|
||||
effet immédiatement ;
|
||||
- API interne en erreur (Go absent, 503, réponse illisible) → 503
|
||||
`{"error":"configuration illisible — réessaie dans un instant"}` ;
|
||||
- refus site tiers / hôte non local sans clé → 403 `{"error":"<raison>"}`
|
||||
(mêmes messages que le Go) ;
|
||||
- Bearer absent ou faux → 401 `{"error":"non autorisé"}` +
|
||||
`WWW-Authenticate: Bearer realm="loki"` ;
|
||||
- `X-Loki-Via` n'est jamais pris en compte côté Rust (effacé à l'entrée,
|
||||
le trafic du tunnel ne traverse pas la façade).
|
||||
|
||||
4. **`/api/ping` en Rust** : `{"ok":true,"service":"loki","version":"<version Go>"}`
|
||||
après authentification — première route `/api/*` servie par Rust.
|
||||
|
||||
5. **Durcissement de la façade** : `/internal/*` venant de l'extérieur → 404 ;
|
||||
en-tête `X-Loki-Internal` entrant effacé avant relais.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
Toutes les autres routes `/api/*`, `/v1`, le flux chat : lots suivants.
|
||||
|
||||
## Tests
|
||||
|
||||
- Go : route interne (sans jeton → 404, mauvais jeton → 404, bon jeton → 200
|
||||
avec clé et version, autres chemins relayés au mux).
|
||||
- Rust : cas de `TestCrossSiteReject` repris un pour un ; `local_host_name`
|
||||
(IP, IPv6 entre crochets, nom sans point, suffixes locaux, hôte de
|
||||
confiance) ; Bearer ; statiques (types, cache, gzip, HEAD, police inconnue
|
||||
→ relayée) ; intégration façade + faux backend interne : ping 200 sans clé,
|
||||
401 avec clé sans Bearer, 200 avec bon Bearer, 503 si l'API interne tombe,
|
||||
403 rebinding, `/internal/v1/auth` depuis l'extérieur → 404.
|
||||
- Bout en bout local : vrai `loki` Go derrière la façade, `loki set-web-key`,
|
||||
ping avec et sans clé.
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user