diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot35-routes-de-base-tunnel-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot35-routes-de-base-tunnel-design.md new file mode 100644 index 0000000..f9d6a74 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot35-routes-de-base-tunnel-design.md @@ -0,0 +1,48 @@ +# Lot 35 — Routes de base par le tunnel du relais + +## Contexte + +La façade sert toujours neuf routes, sans variable (`api.rs`) : + +- `/api/ping` ; +- `/api/status` ; +- `/api/config` ; +- `/api/engine/cmdline` ; +- `/api/model/layers` ; +- `/api/paths` ; +- `/api/ram` ; +- `/api/vram` ; +- `/api/service/log`. + +Mais le tunnel du relais (accès distant, E2E) sert le mux du Go tel quel. +`withFacadeChat` ne remet à la façade que les routes des lots. Un utilisateur +distant tombait donc encore sur les handlers Go de ces neuf routes : deux +implémentations à garder d'accord. + +## Le lot + +- Go (`facade_core.go`) : une table des routes de base, remises à la façade + par `withFacadeChat` dès qu'elle tourne (adresse et jeton interne + présents), sans variable. Façade muette : 502 « façade indisponible ». + Sans façade (`LOKI_FACADE=0`), le Go les sert lui-même. +- Rust (`api.rs`) : une requête relayée par le Go (jeton interne) a déjà été + authentifiée par lui. Ni clé ni hôte local ne sont exigés de nouveau, + comme pour les routes des lots ; la version du Go est lue quand même pour + `/api/ping`. + +## Tests + +- Go (`facade_core_test.go`) : + - les neuf routes relayées avec le jeton ; + - une route du Go gardée ; + - façade muette ; + - sans façade, servies ici. +- Rust (`tests/lot2.rs`) : + - relayée par le Go avec un hôte distant et une clé posée : servie par la + façade, pas renvoyée au Go ; + - sans jeton, ou avec un faux : refusée. + +## Reste à faire + +L'interface statique et `/v1` passent encore par le Go sur le tunnel. Ses +handlers sont gardés pour `LOKI_FACADE=0`. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index ddd4a15..e4f8d05 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -142,6 +142,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { held, unavailable = facadeBench, "banc d'essai indisponible : " case facadeTuneRoutes[path]: held, unavailable = facadeTune, "optimiseur indisponible : " + case facadeCoreRoutes[path]: + held, unavailable = facadeCore, "façade indisponible : " default: return h } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 2dade60..73d9f59 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -312,7 +312,7 @@ func TestFacadeRelaieLesModeles(t *testing.T) { t.Fatalf("reçu par la façade : %v", got) } // Le chat, lui, reste ici (LOKI_CHAT absent), et les routes voisines aussi. - for _, p := range []string{"/api/chat/send", "/api/model/layers", "/api/presets"} { + for _, p := range []string{"/api/chat/send", "/api/mem/health", "/api/presets"} { w := httptest.NewRecorder() withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, nil)) if w.Body.String() != "go" { @@ -1125,9 +1125,9 @@ func TestFacadeMoteurImage(t *testing.T) { t.Fatalf("relais : %v", calls) } w := httptest.NewRecorder() - withFacadeChat("/api/engine/cmdline", local)(w, httptest.NewRequest("GET", "/api/engine/cmdline", nil)) + withFacadeChat("/api/llamacpp/install", local)(w, httptest.NewRequest("POST", "/api/llamacpp/install", nil)) if w.Body.String() != "go" { - t.Fatalf("/api/engine/cmdline doit rester au Go : %q", w.Body.String()) + t.Fatalf("/api/llamacpp/install doit rester au Go : %q", w.Body.String()) } // Sans jeton interne, rien ne part. diff --git a/internal/loki/facade_core.go b/internal/loki/facade_core.go new file mode 100644 index 0000000..c5ba4ee --- /dev/null +++ b/internal/loki/facade_core.go @@ -0,0 +1,26 @@ +package loki + +// facade_core.go — les routes de base que la façade sert toujours (api.rs, +// lot 35 de la migration) : santé, état, configuration, ligne de commande du +// moteur, mémoire vive et vidéo, journal du service, chemins, couches du +// modèle. Quand elles arrivent par le tunnel du relais (accès distant, E2E), +// le Go les lui remet aussi, comme les routes des lots : ses propres +// handlers ne servent plus que sans façade (LOKI_FACADE=0). + +var facadeCoreRoutes = map[string]bool{ + "/api/ping": true, + "/api/status": true, + "/api/config": true, + "/api/engine/cmdline": true, + "/api/model/layers": true, + "/api/paths": true, + "/api/ram": true, + "/api/vram": true, + "/api/service/log": true, +} + +// facadeCore : une façade tourne devant ce processus (son adresse et le +// jeton interne sont là). +func facadeCore() bool { + return facadeAddr() != "" && internalToken() != "" +} diff --git a/internal/loki/facade_core_test.go b/internal/loki/facade_core_test.go new file mode 100644 index 0000000..5e64593 --- /dev/null +++ b/internal/loki/facade_core_test.go @@ -0,0 +1,49 @@ +package loki + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// Lot 35 : les routes de base arrivent à la façade par le tunnel du relais +// dès qu'elle tourne, sans variable ; sans façade (LOKI_FACADE=0 : ni +// adresse ni jeton), le Go les sert lui-même. +func TestFacadeRelaieLesRoutesDeBase(t *testing.T) { + testHome(t) + f := demarrerFacade(t) + t.Setenv(facadeChatEnv, "") + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + for p := range facadeCoreRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil)) + if w.Body.String() != f.reply { + t.Fatalf("%s : réponse %q", p, w.Body.String()) + } + } + if got := f.recu(); len(got) != len(facadeCoreRoutes) || !strings.HasSuffix(strings.TrimSpace(got[0]), "jeton") { + t.Fatalf("reçu par la façade : %v", got) + } + // Une route que le Go garde : ici. + w := httptest.NewRecorder() + withFacadeChat("/api/mem/health", local)(w, httptest.NewRequest("GET", "/api/mem/health", nil)) + if w.Body.String() != "go" { + t.Fatalf("route du Go relayée : %q", w.Body.String()) + } + // Façade muette : 502 qui le dit. + f.srv.Close() + w = httptest.NewRecorder() + withFacadeChat("/api/status", local)(w, httptest.NewRequest("GET", "/api/status", nil)) + if w.Code != http.StatusBadGateway || !strings.Contains(w.Body.String(), "façade indisponible : ") { + t.Fatalf("façade muette : %d %s", w.Code, w.Body.String()) + } + // Sans façade : le Go sert. + t.Setenv(facadeAddrEnv, "") + w = httptest.NewRecorder() + withFacadeChat("/api/status", local)(w, httptest.NewRequest("GET", "/api/status", nil)) + if w.Body.String() != "go" { + t.Fatalf("sans façade : %q", w.Body.String()) + } +} diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index 626c5d3..121e657 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -136,11 +136,21 @@ pub fn handles(_method: &Method, path: &str) -> bool { ) } -pub async fn serve(ctx: &Ctx, req: Request) -> Response { +/// `from_go` : relayée par le Go (tunnel du relais), qui l'a déjà +/// authentifiée. +pub async fn serve(ctx: &Ctx, req: Request, from_go: bool) -> Response { let (parts, _body) = req.into_parts(); - let version = match ctx.guard(&parts.headers).await { - Ok(v) => v, - Err(refus) => return *refus, + let version = if from_go { + ctx.internal + .auth() + .await + .map(|i| i.version) + .unwrap_or_default() + } else { + match ctx.guard(&parts.headers).await { + Ok(v) => v, + Err(refus) => return *refus, + } }; match parts.uri.path() { // Vérifie la connectivité ET la clé (200 = bonne clé, 401 = mauvaise). diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index 49a5f37..23bd36c 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -266,7 +266,7 @@ impl Proxy { return oai::serve(&self.ctx, &self.engine, req).await; } if api::handles(req.method(), path) { - return api::serve(&self.ctx, req).await; + return api::serve(&self.ctx, req, from_go).await; } if let Some(chat) = &self.ctx.chat && crate::chatx::handles(path) diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index 2f62f7e..27254fb 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -76,6 +76,12 @@ struct Banc { } async fn banc(cle: Option<&str>) -> Banc { + banc_avec(cle, false).await +} + +/// `tenu` : la façade tient au moins un groupe de routes (le Go lui en +/// relaie alors par le tunnel du relais). +async fn banc_avec(cle: Option<&str>, tenu: bool) -> Banc { let etat = Arc::new(FauxGo::default()); *etat.cle.lock().unwrap() = cle.map(String::from); let backend = faux_go(etat.clone()).await; @@ -96,7 +102,7 @@ async fn banc(cle: Option<&str>) -> Banc { chat: None, models: None, dictate: None, - settings: false, + settings: tenu, files: None, push: None, mem: None, @@ -235,3 +241,39 @@ async fn ui_servie_par_rust() { "seule la police inconnue doit atteindre le Go : {vus:?}" ); } + +/// Lot 35 : les routes de base arrivent aussi par le tunnel du relais, que +/// le Go a déjà authentifiées (jeton interne) : ni clé ni hôte local exigés. +/// Sans le jeton, la règle de toujours. +#[tokio::test] +async fn routes_de_base_relayees_par_le_go() { + let b = banc_avec(Some("secret"), true).await; + let (code, _, body) = get( + &b, + "/api/ping", + &[("host", "loki.distant.example"), ("x-loki-internal", JETON)], + ) + .await; + assert_eq!(code, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + assert!( + b.etat + .vus + .lock() + .unwrap() + .iter() + .all(|(p, _)| p != "/api/ping"), + "servie par la façade, pas relayée au Go" + ); + let (code, _, _) = get(&b, "/api/ping", &[("host", "loki.distant.example")]).await; + assert_ne!(code, StatusCode::OK); + let (code, _, _) = get( + &b, + "/api/ping", + &[ + ("host", "loki.distant.example"), + ("x-loki-internal", "faux"), + ], + ) + .await; + assert_ne!(code, StatusCode::OK); +}