From a99d988e708b76ded2cef2c391659b91aa8f95d0 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Tue, 13 Oct 2026 22:35:00 +0200 Subject: [PATCH] =?UTF-8?q?API=20interne=20pour=20la=20fa=C3=A7ade=20Rust?= =?UTF-8?q?=20:=20cl=C3=A9=20de=20pilotage=20=C3=A0=20la=20demande?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../2026-10-13-rust-migration-lot2-design.md | 74 +++++++++++++++++ internal/loki/web_internal.go | 81 +++++++++++++++++++ internal/loki/web_internal_test.go | 75 +++++++++++++++++ internal/loki/web_server.go | 4 +- 4 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-10-13-rust-migration-lot2-design.md create mode 100644 internal/loki/web_internal.go create mode 100644 internal/loki/web_internal_test.go diff --git a/docs/superpowers/specs/2026-10-13-rust-migration-lot2-design.md b/docs/superpowers/specs/2026-10-13-rust-migration-lot2-design.md new file mode 100644 index 0000000..2df2846 --- /dev/null +++ b/docs/superpowers/specs/2026-10-13-rust-migration-lot2-design.md @@ -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/.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":""}` + (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":""}` + 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é. diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go new file mode 100644 index 0000000..ed30060 --- /dev/null +++ b/internal/loki/web_internal.go @@ -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}) +} diff --git a/internal/loki/web_internal_test.go b/internal/loki/web_internal_test.go new file mode 100644 index 0000000..1c2a1a9 --- /dev/null +++ b/internal/loki/web_internal_test.go @@ -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()) + } +} diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index 7bdbb30..e137bea 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -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) }