From d4fa4488ccf7ba326438c73c917bc3a9a29237db Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Tue, 13 Oct 2026 22:40:10 +0200 Subject: [PATCH] =?UTF-8?q?Fa=C3=A7ade=20Rust,=20lot=202=20:=20interface,?= =?UTF-8?q?=20authentification=20et=20/api/ping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'interface (index, marked, service worker, manifeste, polices) est désormais servie par loki-rs, embarquée par build.rs depuis la même source que le go:embed — les deux binaires d'un build servent la même UI. Mêmes en-têtes de cache que le Go, gzip à la première demande puis gardé en mémoire, jamais sur les polices ni en HEAD. L'authentification de pilotage est portée à l'identique (requireWebAuth, crossSiteReject, localHostName, Bearer en temps constant, mêmes messages, mêmes codes) ; les cas de TestCrossSiteReject sont repris un pour un. La clé est demandée à l'API interne du Go à chaque requête ; si elle ne répond pas proprement, la façade ferme (503). /api/ping est la première route authentifiée servie par Rust. Le jeton de l'API interne est tiré au hasard à chaque démarrage et passé au Go par l'environnement. La façade rend 404 sur /internal/* venu de l'extérieur et efface X-Loki-Internal entrant avant tout relais. Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 8 +- README.md | 5 +- rust/Cargo.lock | 50 +++++ rust/loki-rs/Cargo.toml | 2 + rust/loki-rs/build.rs | 47 +++++ rust/loki-rs/src/auth.rs | 382 ++++++++++++++++++++++++++++++++++++ rust/loki-rs/src/child.rs | 8 +- rust/loki-rs/src/lib.rs | 6 +- rust/loki-rs/src/main.rs | 30 ++- rust/loki-rs/src/proxy.rs | 65 +++++- rust/loki-rs/src/ui.rs | 214 ++++++++++++++++++++ rust/loki-rs/tests/lot2.rs | 214 ++++++++++++++++++++ rust/loki-rs/tests/proxy.rs | 10 +- 13 files changed, 1023 insertions(+), 18 deletions(-) create mode 100644 rust/loki-rs/build.rs create mode 100644 rust/loki-rs/src/auth.rs create mode 100644 rust/loki-rs/src/ui.rs create mode 100644 rust/loki-rs/tests/lot2.rs diff --git a/Dockerfile b/Dockerfile index 7931eba..db21c3d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,8 +50,12 @@ RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest FROM rust:1-alpine AS rustbuild RUN apk add --no-cache musl-dev WORKDIR /src -COPY rust/ ./ -RUN cargo build --release --locked && mkdir -p /out && cp target/release/loki-rs /out/loki-rs +# L'UI est embarquée par build.rs depuis la même source que le go:embed : les +# deux binaires d'une image servent toujours la même interface. +COPY internal/loki/ui/ internal/loki/ui/ +COPY rust/ rust/ +RUN cd rust && cargo build --release --locked \ + && mkdir -p /out && cp target/release/loki-rs /out/loki-rs # ── Étape 1 bis : serveur de dictée (Parakeet via sherpa-onnx) ────────── # On TÉLÉCHARGE un binaire préconstruit, on ne compile plus rien. C'est le diff --git a/README.md b/README.md index eee231a..6beea7f 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,10 @@ seule image. seule l'UI (8090) l'est. - **Migration vers Rust en cours**, par lots : la façade `loki-rs` (`rust/loki-rs`) prend le port public et relaie au serveur Go ; chaque lot - lui fait reprendre des routes. Plan et feuille de route : + lui fait reprendre des routes. Déjà servis par Rust : l'interface (HTML, + JS, polices) et `/api/ping`, avec l'authentification de pilotage portée à + l'identique (la clé est demandée au Go par une API interne locale, + protégée par un jeton tiré à chaque démarrage). Plan et feuille de route : `docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md`. ## Démarrage rapide (Docker, GPU NVIDIA) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index a40f881..59e3f37 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -61,6 +67,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -103,6 +118,17 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -297,7 +323,9 @@ name = "loki-rs" version = "0.1.0" dependencies = [ "bytes", + "flate2", "futures-util", + "getrandom", "hex", "http-body-util", "hyper", @@ -317,6 +345,16 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.4" @@ -469,6 +507,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -663,6 +707,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/rust/loki-rs/Cargo.toml b/rust/loki-rs/Cargo.toml index fd8756e..6b9fa01 100644 --- a/rust/loki-rs/Cargo.toml +++ b/rust/loki-rs/Cargo.toml @@ -5,6 +5,8 @@ edition = "2024" [dependencies] bytes = "1.12.1" +flate2 = "1.1.10" +getrandom = "0.4.3" hex = "0.4.3" http-body-util = "0.1.5" hyper = { version = "1.12.0", features = ["http1", "server", "client"] } diff --git a/rust/loki-rs/build.rs b/rust/loki-rs/build.rs new file mode 100644 index 0000000..0037ede --- /dev/null +++ b/rust/loki-rs/build.rs @@ -0,0 +1,47 @@ +//! Embarque l'UI web dans le binaire, depuis la MÊME source que le `go:embed` +//! du serveur Go (`internal/loki/ui/`) : les deux binaires d'un build servent +//! donc toujours la même interface. Génère `$OUT_DIR/ui_assets.rs`. + +use std::fmt::Write as _; +use std::path::PathBuf; + +fn main() { + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let ui = manifest.join("../../internal/loki/ui"); + let ui = ui + .canonicalize() + .unwrap_or_else(|e| panic!("UI introuvable ({}) : {e}", ui.display())); + println!("cargo:rerun-if-changed={}", ui.display()); + + let mut out = String::new(); + for name in [ + "index.html", + "marked.min.js", + "sw.js", + "manifest.webmanifest", + ] { + let p = ui.join(name); + assert!(p.is_file(), "fichier d'UI manquant : {}", p.display()); + println!("cargo:rerun-if-changed={}", p.display()); + let ident = name.replace(['.', '-'], "_").to_uppercase(); + writeln!(out, "pub static {ident}: &[u8] = include_bytes!({:?});", p).unwrap(); + } + + let fonts_dir = ui.join("fonts"); + println!("cargo:rerun-if-changed={}", fonts_dir.display()); + let mut fonts: Vec = std::fs::read_dir(&fonts_dir) + .unwrap_or_else(|e| panic!("{} : {e}", fonts_dir.display())) + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|x| x == "woff2")) + .collect(); + fonts.sort(); + out.push_str("pub static FONTS: &[(&str, &[u8])] = &[\n"); + for p in &fonts { + let name = p.file_name().unwrap().to_str().unwrap(); + writeln!(out, " ({name:?}, include_bytes!({p:?})),").unwrap(); + } + out.push_str("];\n"); + + let dst = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("ui_assets.rs"); + std::fs::write(dst, out).unwrap(); +} diff --git a/rust/loki-rs/src/auth.rs b/rust/loki-rs/src/auth.rs new file mode 100644 index 0000000..91dbd9c --- /dev/null +++ b/rust/loki-rs/src/auth.rs @@ -0,0 +1,382 @@ +//! Authentification de l'API de pilotage, portée de `web_auth.go` +//! (`requireWebAuth`, `crossSiteReject`, `localHostName`, `checkBearer`). +//! +//! La clé de pilotage vit dans la base du Go : elle est demandée à son API +//! interne (`/internal/v1/auth`, voir `web_internal.go`) à CHAQUE requête, +//! comme le Go la relit à chaque requête — un changement de clé prend effet +//! immédiatement. Si l'API interne ne répond pas proprement, on FERME (503) : +//! une panne ne doit jamais désarmer l'authentification. +//! +//! `X-Loki-Via` (trafic du tunnel) n'est jamais consulté ici : la façade +//! l'efface à l'entrée, et le tunnel ne passe pas par elle. + +use bytes::Bytes; +use http_body_util::{BodyExt, Empty, Full}; +use hyper::header::{self, HeaderMap, HeaderValue}; +use hyper::{Request, Response, StatusCode}; +use hyper_util::client::legacy::{Client, connect::HttpConnector}; +use hyper_util::rt::TokioExecutor; +use serde::Deserialize; +use std::net::{IpAddr, SocketAddr}; +use std::time::Duration; + +use crate::proxy::Body; + +pub const INTERNAL_HEADER: &str = "x-loki-internal"; +pub const INTERNAL_ENV: &str = "LOKI_INTERNAL_TOKEN"; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct AuthInfo { + pub web_key: String, + pub version: String, +} + +/// Client de l'API interne du Go. +pub struct InternalClient { + client: Client>, + backend: SocketAddr, + token: String, +} + +impl InternalClient { + pub fn new(backend: SocketAddr, token: String) -> Self { + InternalClient { + client: Client::builder(TokioExecutor::new()).build_http(), + backend, + token, + } + } + + pub async fn auth(&self) -> Result { + let req = Request::get(format!("http://{}/internal/v1/auth", self.backend)) + .header(INTERNAL_HEADER, &self.token) + .body(Empty::new()) + .map_err(|e| e.to_string())?; + let fut = async { + let resp = self.client.request(req).await.map_err(|e| e.to_string())?; + if resp.status() != StatusCode::OK { + return Err(format!("API interne : {}", resp.status())); + } + let body = resp + .into_body() + .collect() + .await + .map_err(|e| e.to_string())?; + serde_json::from_slice(&body.to_bytes()).map_err(|e| e.to_string()) + }; + tokio::time::timeout(Duration::from_secs(5), fut) + .await + .map_err(|_| "API interne : délai dépassé".to_string())? + } +} + +/// Ce qu'il faut savoir de l'hôte pour juger qu'un nom est « local ». +#[derive(Debug, Clone, Default)] +pub struct HostEnv { + /// Nom de la machine (du conteneur), en minuscules. + pub hostname: Option, + /// `LOKI_TRUSTED_HOSTS`, en minuscules. + pub trusted: Vec, +} + +impl HostEnv { + pub fn from_env() -> Self { + let trusted = std::env::var("LOKI_TRUSTED_HOSTS") + .unwrap_or_default() + .split(',') + .map(|t| t.trim().to_lowercase()) + .filter(|t| !t.is_empty()) + .collect(); + HostEnv { + hostname: hostname().map(|h| h.to_lowercase()), + trusted, + } + } +} + +fn hostname() -> Option { + // Ce que lit os.Hostname du Go sous Linux. + let h = std::fs::read_to_string("/proc/sys/kernel/hostname") + .ok() + .or_else(|| std::env::var("HOSTNAME").ok()) + .or_else(|| std::env::var("COMPUTERNAME").ok())?; + let h = h.trim().to_string(); + (!h.is_empty()).then_some(h) +} + +/// Vérifie la requête comme `requireWebAuth` : `None` = autorisée, sinon la +/// réponse de refus. +pub fn authorize( + headers: &HeaderMap, + info: &Result, + env: &HostEnv, +) -> Option> { + let info = match info { + Ok(i) => i, + Err(e) => { + eprintln!("[loki-rs] clé de pilotage illisible : {e}"); + return Some(json_error( + StatusCode::SERVICE_UNAVAILABLE, + "configuration illisible — réessaie dans un instant", + )); + } + }; + let keyed = !info.web_key.is_empty(); + if let Some(msg) = cross_site_reject(headers, keyed, env) { + return Some(json_error(StatusCode::FORBIDDEN, &msg)); + } + if !keyed || check_bearer(headers, &info.web_key) { + return None; + } + let mut r = json_error(StatusCode::UNAUTHORIZED, "non autorisé"); + r.headers_mut().insert( + header::WWW_AUTHENTICATE, + HeaderValue::from_static(r#"Bearer realm="loki""#), + ); + Some(r) +} + +/// Raison du refus d'une requête venue d'un site tiers, ou `None`. +pub fn cross_site_reject(headers: &HeaderMap, keyed: bool, env: &HostEnv) -> Option { + let get = |k: header::HeaderName| headers.get(k).and_then(|v| v.to_str().ok()); + let host = get(header::HOST).unwrap_or(""); + if get(header::HeaderName::from_static("sec-fetch-site")) + .is_some_and(|v| v.eq_ignore_ascii_case("cross-site")) + { + return Some("requête d'un site tiers refusée".into()); + } + match get(header::ORIGIN) { + Some("null") => return Some("origine non autorisée".into()), + Some(o) if !o.is_empty() && !origin_host(o).eq_ignore_ascii_case(host) => { + return Some(format!("origine non autorisée : {o}")); + } + _ => {} + } + if !keyed && !local_host_name(host, env) { + return Some(format!( + "hôte « {host} » non autorisé sans clé de pilotage — définis-en une \ + (loki set-web-key) ou ajoute ce nom à LOKI_TRUSTED_HOSTS" + )); + } + None +} + +/// Partie « hôte[:port] » d'une origine, comme `url.Parse(o).Host` en Go. +fn origin_host(o: &str) -> &str { + let rest = match o.find("://") { + Some(i) => &o[i + 3..], + None => match o.strip_prefix("//") { + Some(r) => r, + None => return "", + }, + }; + let end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let auth = &rest[..end]; + auth.rsplit_once('@').map(|(_, h)| h).unwrap_or(auth) +} + +/// L'hôte désigne-t-il la machine ou le réseau local (et non un domaine +/// public, seul utilisable pour un DNS rebinding) ? +pub fn local_host_name(hostport: &str, env: &HostEnv) -> bool { + let h = split_host(hostport).unwrap_or(hostport); + let h = h.trim_matches(['[', ']', '.']).to_lowercase(); + if h.is_empty() || h.parse::().is_ok() || !h.contains('.') { + return true; + } + const SUFFIXES: [&str; 7] = [ + ".localhost", + ".local", + ".lan", + ".home", + ".internal", + ".home.arpa", + ".localdomain", + ]; + if SUFFIXES.iter().any(|s| h.ends_with(s)) { + return true; + } + if let Some(me) = env.hostname.as_deref().filter(|m| !m.is_empty()) + && (h == me || h.starts_with(&format!("{me}."))) + { + return true; + } + env.trusted.contains(&h) +} + +/// `net.SplitHostPort` réduit à ce qui sert ici : l'hôte, si `s` porte un port. +fn split_host(s: &str) -> Option<&str> { + if let Some(rest) = s.strip_prefix('[') { + let (host, after) = rest.split_once(']')?; + return after.starts_with(':').then_some(host); + } + let (host, port) = s.split_once(':')?; + (!port.contains(':')).then_some(host) +} + +/// `Authorization: Bearer `, comparaison en temps constant. +pub fn check_bearer(headers: &HeaderMap, key: &str) -> bool { + let Some(got) = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + else { + return false; + }; + constant_time_eq(got.trim().as_bytes(), key.as_bytes()) +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +/// JSON comme `sendJSON` du Go (json.Encoder : saut de ligne final). +pub fn json(status: StatusCode, v: &serde_json::Value) -> Response { + let mut s = serde_json::to_string(v).unwrap_or_else(|_| "{}".into()); + s.push('\n'); + let mut r = Response::new(Full::new(Bytes::from(s)).map_err(|n| match n {}).boxed()); + *r.status_mut() = status; + r.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + r +} + +fn json_error(status: StatusCode, msg: &str) -> Response { + json(status, &serde_json::json!({ "error": msg })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env() -> HostEnv { + HostEnv { + hostname: Some("tour-unraid".into()), + trusted: vec!["loki.exemple.fr".into()], + } + } + + fn headers(host: &str, origin: &str, fetch_site: &str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert(header::HOST, host.parse().unwrap()); + if !origin.is_empty() { + h.insert(header::ORIGIN, origin.parse().unwrap()); + } + if !fetch_site.is_empty() { + h.insert("sec-fetch-site", fetch_site.parse().unwrap()); + } + h + } + + /// Repris un pour un de TestCrossSiteReject (web_auth_crosssite_test.go), + /// hors cas « tunnel » (le tunnel ne traverse pas la façade). + #[test] + fn cross_site_comme_le_go() { + #[rustfmt::skip] + let cas: &[(&str, &str, &str, &str, bool, bool)] = &[ + // nom, host, origin, sec-fetch-site, keyed, refusé + ("curl sur IP", "192.168.1.10:8090", "", "", false, false), + ("localhost", "localhost:8090", "http://localhost:8090", "", false, false), + ("nom sans point", "tower:8090", "http://tower:8090", "", false, false), + ("nom .local", "tower.local:8090", "", "", false, false), + ("origine tierce", "192.168.1.10:8090", "http://evil.example", "", false, true), + ("origine null", "localhost:8090", "null", "", false, true), + ("sec-fetch cross-site", "localhost:8090", "", "cross-site", false, true), + ("rebinding sans clé", "evil.example:8090", "http://evil.example:8090", "", false, true), + ("domaine avec clé", "evil.example:8090", "", "", true, false), + ("domaine de confiance", "loki.exemple.fr", "https://loki.exemple.fr", "", false, false), + ]; + for (nom, host, origin, fs, keyed, refuse) in cas { + let got = cross_site_reject(&headers(host, origin, fs), *keyed, &env()).is_some(); + assert_eq!(got, *refuse, "{nom}"); + } + } + + #[test] + fn messages_identiques_au_go() { + let m = cross_site_reject(&headers("evil.example", "", ""), false, &env()).unwrap(); + assert_eq!( + m, + "hôte « evil.example » non autorisé sans clé de pilotage — définis-en une \ + (loki set-web-key) ou ajoute ce nom à LOKI_TRUSTED_HOSTS" + ); + let m = cross_site_reject(&headers("x", "http://evil", ""), true, &env()).unwrap(); + assert_eq!(m, "origine non autorisée : http://evil"); + } + + #[test] + fn noms_locaux() { + let e = env(); + for h in [ + "", + "10.0.0.2", + "[::1]:8090", + "::1", + "tower", + "nas.lan", + "x.home.arpa:443", + "TOUR-UNRAID", + "tour-unraid.maison.fr", + "loki.exemple.fr:443", + "localhost.", + ] { + assert!(local_host_name(h, &e), "{h} devrait être local"); + } + for h in [ + "evil.example", + "evil.example:8090", + "loki.exemple.fr.evil.com", + ] { + assert!(!local_host_name(h, &e), "{h} ne devrait pas être local"); + } + } + + #[test] + fn hote_d_origine() { + assert_eq!(origin_host("https://a.b:8090"), "a.b:8090"); + assert_eq!(origin_host("http://u:p@a.b/x?y"), "a.b"); + assert_eq!(origin_host("//a.b"), "a.b"); + assert_eq!(origin_host("a.b"), ""); + } + + #[test] + fn bearer() { + let mut h = HeaderMap::new(); + assert!(!check_bearer(&h, "k")); + h.insert(header::AUTHORIZATION, "Bearer k ".parse().unwrap()); + assert!(check_bearer(&h, "k")); + assert!(!check_bearer(&h, "kk")); + h.insert(header::AUTHORIZATION, "bearer k".parse().unwrap()); + assert!(!check_bearer(&h, "k")); + } + + #[test] + fn autorisation() { + let e = env(); + let ok = |k: &str| { + Ok(AuthInfo { + web_key: k.into(), + version: "0.15.0".into(), + }) + }; + let h = headers("localhost:8090", "", ""); + assert!(authorize(&h, &ok(""), &e).is_none()); + let r = authorize(&h, &ok("s"), &e).unwrap(); + assert_eq!(r.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + r.headers()[header::WWW_AUTHENTICATE], + r#"Bearer realm="loki""# + ); + let mut hb = h.clone(); + hb.insert(header::AUTHORIZATION, "Bearer s".parse().unwrap()); + assert!(authorize(&hb, &ok("s"), &e).is_none()); + let r = authorize(&hb, &Err("panne".into()), &e).unwrap(); + assert_eq!(r.status(), StatusCode::SERVICE_UNAVAILABLE); + let r = authorize(&headers("evil.example", "", ""), &ok(""), &e).unwrap(); + assert_eq!(r.status(), StatusCode::FORBIDDEN); + } +} diff --git a/rust/loki-rs/src/child.rs b/rust/loki-rs/src/child.rs index c499b44..9816b06 100644 --- a/rust/loki-rs/src/child.rs +++ b/rust/loki-rs/src/child.rs @@ -41,13 +41,14 @@ pub struct Supervisor { impl Supervisor { /// Lance ` web ` en écoute locale et le maintient en vie. - pub fn spawn(go_bin: PathBuf, port: u16) -> Supervisor { + /// `env` : variables ajoutées à l'environnement du Go (jeton interne…). + pub fn spawn(go_bin: PathBuf, port: u16, env: Vec<(String, String)>) -> Supervisor { let (tx, mut rx) = oneshot::channel::(); let done = tokio::spawn(async move { let mut delay = Duration::ZERO; loop { let started = Instant::now(); - let mut child = match launch(&go_bin, port) { + let mut child = match launch(&go_bin, port, &env) { Ok(c) => c, Err(e) => { eprintln!( @@ -99,8 +100,9 @@ impl Supervisor { } } -fn launch(go_bin: &Path, port: u16) -> std::io::Result { +fn launch(go_bin: &Path, port: u16, env: &[(String, String)]) -> std::io::Result { Command::new(go_bin) + .envs(env.iter().map(|(k, v)| (k, v))) .arg("web") .arg(port.to_string()) .env("LOKI_WEB_BIND", "127.0.0.1") diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index cebf1af..e978ff4 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -1,9 +1,13 @@ -//! loki-rs — façade Rust de Loki (lot 1 de la migration Go → Rust). +//! loki-rs — façade Rust de Loki (migration Go → Rust par lots). //! //! - `proxy` : façade HTTP/SSE/WebSocket devant le serveur Go ; //! - `child` : supervision du processus `loki web` (Go) ; +//! - `ui` : UI web statique embarquée (lot 2) ; +//! - `auth` : authentification de l'API de pilotage (lot 2) ; //! - `update` : mise à jour des binaires au démarrage du conteneur. +pub mod auth; pub mod child; pub mod proxy; +pub mod ui; pub mod update; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index c0a7a61..ba58908 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -7,7 +7,7 @@ //! loki-rs version //! ``` -use loki_rs::{child, proxy, update}; +use loki_rs::{auth, child, proxy, update}; use std::net::SocketAddr; use std::path::PathBuf; use std::process::ExitCode; @@ -89,7 +89,20 @@ fn serve() -> ExitCode { "[loki-rs] serveur Go : {} web {go_port} (local)", go_bin.display() ); - let sup = child::Supervisor::spawn(go_bin, go_port); + // Jeton de l'API interne du Go : tiré au hasard à chaque démarrage, + // connu seulement de la façade et du Go qu'elle lance. + let token = match internal_token() { + Ok(t) => t, + Err(e) => { + eprintln!("[loki-rs] aléa système indisponible : {e}"); + return ExitCode::FAILURE; + } + }; + let sup = child::Supervisor::spawn( + go_bin, + go_port, + vec![(auth::INTERNAL_ENV.to_string(), token.clone())], + ); wait_for(backend, Duration::from_secs(30)).await; let addr = SocketAddr::from(([0, 0, 0, 0], web_port)); @@ -102,7 +115,12 @@ fn serve() -> ExitCode { } }; eprintln!("[loki-rs] façade : http://{addr} → {backend}"); - let res = proxy::serve(listener, backend, shutdown_signal()).await; + let cfg = proxy::Config { + backend, + internal_token: token, + host_env: auth::HostEnv::from_env(), + }; + let res = proxy::serve(listener, cfg, shutdown_signal()).await; eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go"); sup.shutdown(Duration::from_secs(10)).await; match res { @@ -115,6 +133,12 @@ fn serve() -> ExitCode { }) } +fn internal_token() -> Result { + let mut b = [0u8; 32]; + getrandom::fill(&mut b)?; + Ok(hex::encode(b)) +} + /// Attend que le Go accepte les connexions, sans bloquer au-delà de `max` : /// la façade ouvre quand même et répond 502 en attendant. async fn wait_for(addr: SocketAddr, max: Duration) { diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index 3210f69..1ddacfb 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -1,6 +1,6 @@ -//! Façade HTTP : reçoit tout le trafic public et le relaie au serveur Go -//! (`loki web` sur 127.0.0.1). Les lots suivants de la migration y brancheront -//! les routes réécrites en Rust ; au lot 1, tout est relayé. +//! Façade HTTP : reçoit tout le trafic public. Les routes déjà réécrites en +//! Rust sont servies ici (lot 2 : UI statique, `/api/ping`) ; tout le reste +//! est relayé au serveur Go (`loki web` sur 127.0.0.1). //! //! Règles, héritées de `web_server.go` : //! - aucun tampon : un flux SSE (chat, `/v1` en streaming) part trame par trame ; @@ -27,6 +27,9 @@ use std::sync::Arc; use std::time::Duration; use tokio::net::TcpListener; +use crate::auth::{self, HostEnv, INTERNAL_HEADER, InternalClient}; +use crate::ui; + pub type Body = BoxBody; /// En-tête posé par le tunnel du relais côté Go (`viaTunnelHeader`). @@ -43,19 +46,35 @@ const HOP_BY_HOP: [&str; 8] = [ "upgrade", ]; +/// Réglages de la façade. +pub struct Config { + /// Serveur Go (`loki web`), en écoute locale. + pub backend: SocketAddr, + /// Jeton de l'API interne du Go (`LOKI_INTERNAL_TOKEN`). + pub internal_token: String, + pub host_env: HostEnv, +} + struct Proxy { client: Client, backend: SocketAddr, + internal: InternalClient, + host_env: HostEnv, } /// Sert la façade sur `listener` jusqu'à ce que `shutdown` se termine. pub async fn serve( listener: TcpListener, - backend: SocketAddr, + cfg: Config, shutdown: impl Future, ) -> std::io::Result<()> { let client = Client::builder(TokioExecutor::new()).build_http(); - let proxy = Arc::new(Proxy { client, backend }); + let proxy = Arc::new(Proxy { + client, + backend: cfg.backend, + internal: InternalClient::new(cfg.backend, cfg.internal_token), + host_env: cfg.host_env, + }); tokio::pin!(shutdown); loop { tokio::select! { @@ -91,9 +110,43 @@ pub async fn serve( impl Proxy { async fn handle(&self, mut req: Request) -> Response { + // Jamais de confiance accordée sur la foi d'un en-tête du client : + // ni « je viens du tunnel », ni « je suis la façade ». + req.headers_mut().remove(VIA_TUNNEL); + req.headers_mut().remove(INTERNAL_HEADER); + + let path = req.uri().path(); + // L'API interne du Go n'est jamais joignable de l'extérieur. + if path.starts_with("/internal/") { + return plain(StatusCode::NOT_FOUND, "404 page not found"); + } + if let Some(resp) = ui::serve(req.method(), path, req.headers()) { + return resp; + } + if path == "/api/ping" { + return self.ping(req.headers()).await; + } + self.forward(req).await + } + + /// `/api/ping` : vérifie la connectivité ET la clé (200 = bonne clé, + /// 401 = mauvaise clé), comme `handlePing`. + async fn ping(&self, headers: &HeaderMap) -> Response { + let info = self.internal.auth().await; + if let Some(refus) = auth::authorize(headers, &info, &self.host_env) { + return refus; + } + let version = info.map(|i| i.version).unwrap_or_default(); + auth::json( + StatusCode::OK, + &serde_json::json!({ "ok": true, "service": "loki", "version": version }), + ) + } + + /// Relais vers le Go (HTTP, SSE, WebSocket). + async fn forward(&self, mut req: Request) -> Response { let upgrade = upgrade_protocol(req.headers()); strip_hop_by_hop(req.headers_mut()); - req.headers_mut().remove(VIA_TUNNEL); let client_upgrade = upgrade.as_ref().map(|proto| { let h = req.headers_mut(); h.insert(header::CONNECTION, HeaderValue::from_static("upgrade")); diff --git a/rust/loki-rs/src/ui.rs b/rust/loki-rs/src/ui.rs new file mode 100644 index 0000000..05553fc --- /dev/null +++ b/rust/loki-rs/src/ui.rs @@ -0,0 +1,214 @@ +//! UI web statique, servie par la façade (lot 2). Mêmes en-têtes que les +//! routes de `web_server.go`, même compression que `web_gzip.go`. + +use crate::proxy::Body; +use bytes::Bytes; +use flate2::Compression; +use flate2::write::GzEncoder; +use http_body_util::{BodyExt, Full}; +use hyper::header::{self, HeaderMap, HeaderValue}; +use hyper::{Method, Response}; +use std::io::Write; +use std::sync::OnceLock; + +mod assets { + include!(concat!(env!("OUT_DIR"), "/ui_assets.rs")); +} + +struct Asset { + body: &'static [u8], + ctype: &'static str, + cache: &'static str, + /// Version gzip, calculée à la première demande qui l'accepte. + gz: OnceLock>, +} + +impl Asset { + const fn new(body: &'static [u8], ctype: &'static str, cache: &'static str) -> Self { + Asset { + body, + ctype, + cache, + gz: OnceLock::new(), + } + } +} + +const NO_STORE: &str = "no-store, max-age=0"; + +static INDEX: Asset = Asset::new(assets::INDEX_HTML, "text/html; charset=utf-8", NO_STORE); +static MARKED: Asset = Asset::new( + assets::MARKED_MIN_JS, + "application/javascript", + "public, max-age=86400", +); +static SW: Asset = Asset::new(assets::SW_JS, "application/javascript", NO_STORE); +static MANIFEST: Asset = Asset::new( + assets::MANIFEST_WEBMANIFEST, + "application/manifest+json", + "public, max-age=3600", +); + +/// Réponse statique pour cette requête, ou `None` si la route n'est pas une +/// ressource de l'UI (elle part alors au Go, qui rendra son 404 habituel). +pub fn serve(method: &Method, path: &str, headers: &HeaderMap) -> Option> { + let head = method == Method::HEAD; + if !head && method != Method::GET { + return None; + } + // Comme gzipHandler : HEAD jamais compressé, et Vary posé dès que le + // client accepte gzip (même pour une réponse qui ne l'est pas). + let accepts_gzip = !head + && headers + .get_all(header::ACCEPT_ENCODING) + .iter() + .filter_map(|v| v.to_str().ok()) + .any(|v| v.contains("gzip")); + + let mut resp = match path { + "/" | "/index.html" => asset(&INDEX, accepts_gzip), + "/marked.min.js" => asset(&MARKED, accepts_gzip), + "/sw.js" => { + let mut r = asset(&SW, accepts_gzip); + r.headers_mut() + .insert("service-worker-allowed", HeaderValue::from_static("/")); + r + } + "/manifest.webmanifest" => asset(&MANIFEST, accepts_gzip), + p if p.starts_with("/fonts/") => font(p)?, + _ => return None, + }; + if accepts_gzip { + resp.headers_mut() + .append(header::VARY, HeaderValue::from_static("Accept-Encoding")); + } + if head { + let len = resp.headers().get(header::CONTENT_LENGTH).cloned(); + let mut empty = Response::new(full(Bytes::new())); + *empty.headers_mut() = std::mem::take(resp.headers_mut()); + if let Some(len) = len { + empty.headers_mut().insert(header::CONTENT_LENGTH, len); + } + return Some(empty); + } + Some(resp) +} + +fn asset(a: &'static Asset, gzip: bool) -> Response { + let (body, encoded) = if gzip { + (a.gz.get_or_init(|| compress(a.body)).as_slice(), true) + } else { + (a.body, false) + }; + let mut r = Response::new(full(Bytes::from_static(body))); + let h = r.headers_mut(); + h.insert(header::CONTENT_TYPE, HeaderValue::from_static(a.ctype)); + h.insert(header::CACHE_CONTROL, HeaderValue::from_static(a.cache)); + h.insert(header::CONTENT_LENGTH, HeaderValue::from(body.len())); + if encoded { + h.insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip")); + } + r +} + +/// `/fonts/.woff2` — comme le Go, seul le dernier segment compte. +/// Jamais compressées : woff2 l'est déjà. +fn font(path: &str) -> Option> { + let name = path.rsplit('/').next()?; + if !name.ends_with(".woff2") { + return None; + } + let (_, body) = assets::FONTS.iter().find(|(n, _)| *n == name)?; + let mut r = Response::new(full(Bytes::from_static(body))); + let h = r.headers_mut(); + h.insert(header::CONTENT_TYPE, HeaderValue::from_static("font/woff2")); + h.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + ); + h.insert(header::CONTENT_LENGTH, HeaderValue::from(body.len())); + Some(r) +} + +fn compress(b: &[u8]) -> Vec { + let mut enc = GzEncoder::new(Vec::with_capacity(b.len() / 3), Compression::default()); + enc.write_all(b).expect("gzip en mémoire"); + enc.finish().expect("gzip en mémoire") +} + +fn full(b: Bytes) -> Body { + Full::new(b).map_err(|n| match n {}).boxed() +} + +/// Taille de l'index embarqué (tests). +pub fn index_bytes() -> &'static [u8] { + INDEX.body +} + +#[cfg(test)] +mod tests { + use super::*; + + fn get(path: &str, gzip: bool) -> Option> { + let mut h = HeaderMap::new(); + if gzip { + h.insert( + header::ACCEPT_ENCODING, + HeaderValue::from_static("gzip, br"), + ); + } + serve(&Method::GET, path, &h) + } + + fn hv<'a>(r: &'a Response, k: &str) -> &'a str { + r.headers() + .get(k) + .map(|v| v.to_str().unwrap()) + .unwrap_or("") + } + + #[test] + fn en_tetes_comme_le_go() { + let r = get("/", false).unwrap(); + assert_eq!(hv(&r, "content-type"), "text/html; charset=utf-8"); + assert_eq!(hv(&r, "cache-control"), "no-store, max-age=0"); + assert_eq!(hv(&r, "vary"), ""); + let r = get("/sw.js", false).unwrap(); + assert_eq!(hv(&r, "service-worker-allowed"), "/"); + assert_eq!(hv(&r, "cache-control"), "no-store, max-age=0"); + let r = get("/marked.min.js", false).unwrap(); + assert_eq!(hv(&r, "cache-control"), "public, max-age=86400"); + let r = get("/manifest.webmanifest", false).unwrap(); + assert_eq!(hv(&r, "content-type"), "application/manifest+json"); + } + + #[test] + fn polices() { + let (name, _) = assets::FONTS[0]; + let r = get(&format!("/fonts/{name}"), true).unwrap(); + assert_eq!(hv(&r, "content-type"), "font/woff2"); + assert_eq!(hv(&r, "content-encoding"), ""); + assert_eq!(hv(&r, "vary"), "Accept-Encoding"); + assert!(get("/fonts/absente.woff2", false).is_none()); + assert!(get("/fonts/inter.ttf", false).is_none()); + } + + #[test] + fn gzip_si_accepte() { + let r = get("/", true).unwrap(); + assert_eq!(hv(&r, "content-encoding"), "gzip"); + assert_eq!(hv(&r, "vary"), "Accept-Encoding"); + let len: usize = hv(&r, "content-length").parse().unwrap(); + assert!(len < index_bytes().len() / 2, "gzip inefficace : {len}"); + } + + #[test] + fn head_et_methodes() { + let h = HeaderMap::new(); + let r = serve(&Method::HEAD, "/", &h).unwrap(); + assert_eq!(hv(&r, "content-length"), index_bytes().len().to_string()); + assert!(serve(&Method::POST, "/", &h).is_none()); + assert!(get("/api/status", false).is_none()); + assert!(get("/index.htm", false).is_none()); + } +} diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs new file mode 100644 index 0000000..924f9ac --- /dev/null +++ b/rust/loki-rs/tests/lot2.rs @@ -0,0 +1,214 @@ +//! Lot 2 : UI statique, authentification et /api/ping servis par la façade, +//! devant un faux serveur Go qui imite son API interne. + +use bytes::Bytes; +use flate2::read::GzDecoder; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper::{Request, Response, StatusCode, service::service_fn}; +use hyper_util::client::legacy::Client; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use std::convert::Infallible; +use std::io::Read; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use tokio::net::TcpListener; +use tokio::sync::oneshot; + +const JETON: &str = "0123456789abcdef0123456789abcdef"; + +/// État du faux Go : clé de pilotage (`None` = base illisible → 503) et +/// journal des requêtes reçues (chemin + en-tête x-loki-internal). +#[derive(Default)] +struct FauxGo { + cle: Mutex>, + vus: Mutex)>>, +} + +async fn faux_go(etat: Arc) -> SocketAddr { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + tokio::spawn(async move { + while let Ok((s, _)) = l.accept().await { + let etat = etat.clone(); + tokio::spawn(async move { + let svc = service_fn(move |req: Request| { + let etat = etat.clone(); + async move { + let jeton = req + .headers() + .get("x-loki-internal") + .map(|v| v.to_str().unwrap().to_string()); + let path = req.uri().path().to_string(); + etat.vus.lock().unwrap().push((path.clone(), jeton.clone())); + let (code, body) = if path == "/internal/v1/auth" { + if jeton.as_deref() != Some(JETON) { + (404, String::new()) + } else { + match etat.cle.lock().unwrap().clone() { + Some(k) => { + (200, format!(r#"{{"web_key":"{k}","version":"0.15.0"}}"#)) + } + None => (503, r#"{"error":"x"}"#.into()), + } + } + } else { + (200, format!("go:{path}")) + }; + let mut r = Response::new(Full::new(Bytes::from(body))); + *r.status_mut() = StatusCode::from_u16(code).unwrap(); + Ok::<_, Infallible>(r) + } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(s), svc) + .await; + }); + } + }); + addr +} + +struct Banc { + facade: SocketAddr, + etat: Arc, + _stop: oneshot::Sender<()>, +} + +async fn banc(cle: Option<&str>) -> Banc { + let etat = Arc::new(FauxGo::default()); + *etat.cle.lock().unwrap() = cle.map(String::from); + let backend = faux_go(etat.clone()).await; + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let facade = l.local_addr().unwrap(); + let (tx, rx) = oneshot::channel::<()>(); + let cfg = loki_rs::proxy::Config { + backend, + internal_token: JETON.into(), + host_env: loki_rs::auth::HostEnv { + hostname: None, + trusted: vec!["loki.exemple.fr".into()], + }, + }; + tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { + let _ = rx.await; + })); + Banc { + facade, + etat, + _stop: tx, + } +} + +async fn get( + b: &Banc, + path: &str, + headers: &[(&str, &str)], +) -> (StatusCode, hyper::HeaderMap, Bytes) { + let mut req = Request::get(format!("http://{}{path}", b.facade)); + for (k, v) in headers { + req = req.header(*k, *v); + } + let client: Client<_, Full> = Client::builder(TokioExecutor::new()).build_http(); + let resp = client + .request(req.body(Full::new(Bytes::new())).unwrap()) + .await + .unwrap(); + let (parts, body) = resp.into_parts(); + ( + parts.status, + parts.headers, + body.collect().await.unwrap().to_bytes(), + ) +} + +#[tokio::test] +async fn ping_sans_cle() { + let b = banc(Some("")).await; + let (code, h, body) = get(&b, "/api/ping", &[]).await; + assert_eq!(code, StatusCode::OK); + assert_eq!(h["content-type"], "application/json"); + assert_eq!( + &body[..], + b"{\"ok\":true,\"service\":\"loki\",\"version\":\"0.15.0\"}\n" + ); + // Servi par Rust : le Go n'a vu que l'appel à son API interne, jeton compris. + let vus = b.etat.vus.lock().unwrap().clone(); + assert_eq!( + vus, + vec![("/internal/v1/auth".to_string(), Some(JETON.to_string()))] + ); +} + +#[tokio::test] +async fn ping_avec_cle() { + let b = banc(Some("secret")).await; + let (code, h, body) = get(&b, "/api/ping", &[]).await; + assert_eq!(code, StatusCode::UNAUTHORIZED); + assert_eq!(h["www-authenticate"], r#"Bearer realm="loki""#); + assert_eq!(&body[..], "{\"error\":\"non autorisé\"}\n".as_bytes()); + let (code, _, _) = get(&b, "/api/ping", &[("authorization", "Bearer faux")]).await; + assert_eq!(code, StatusCode::UNAUTHORIZED); + let (code, _, _) = get(&b, "/api/ping", &[("authorization", "Bearer secret")]).await; + assert_eq!(code, StatusCode::OK); +} + +#[tokio::test] +async fn ferme_si_la_base_est_illisible() { + let b = banc(None).await; + let (code, _, _) = get(&b, "/api/ping", &[]).await; + assert_eq!(code, StatusCode::SERVICE_UNAVAILABLE); +} + +#[tokio::test] +async fn refuse_le_rebinding() { + let b = banc(Some("")).await; + let (code, _, body) = get(&b, "/api/ping", &[("host", "evil.example:8090")]).await; + assert_eq!(code, StatusCode::FORBIDDEN); + assert!(String::from_utf8_lossy(&body).contains("non autorisé sans clé")); + let (code, _, _) = get(&b, "/api/ping", &[("host", "loki.exemple.fr")]).await; + assert_eq!(code, StatusCode::OK); +} + +#[tokio::test] +async fn api_interne_fermee_de_l_exterieur() { + let b = banc(Some("secret")).await; + let (code, _, body) = get(&b, "/internal/v1/auth", &[("x-loki-internal", JETON)]).await; + assert_eq!(code, StatusCode::NOT_FOUND); + assert!(!String::from_utf8_lossy(&body).contains("secret")); + assert!( + b.etat.vus.lock().unwrap().is_empty(), + "requête relayée au Go" + ); +} + +#[tokio::test] +async fn jeton_entrant_efface_avant_relais() { + let b = banc(Some("")).await; + let (code, _, body) = get(&b, "/api/status", &[("x-loki-internal", JETON)]).await; + assert_eq!(code, StatusCode::OK); + assert_eq!(&body[..], b"go:/api/status"); + let vus = b.etat.vus.lock().unwrap().clone(); + assert_eq!(vus, vec![("/api/status".to_string(), None)]); +} + +#[tokio::test] +async fn ui_servie_par_rust() { + let b = banc(Some("")).await; + let (code, h, body) = get(&b, "/", &[("accept-encoding", "gzip")]).await; + assert_eq!(code, StatusCode::OK); + assert_eq!(h["content-encoding"], "gzip"); + assert_eq!(h["cache-control"], "no-store, max-age=0"); + let mut html = Vec::new(); + GzDecoder::new(&body[..]).read_to_end(&mut html).unwrap(); + assert_eq!(html, loki_rs::ui::index_bytes()); + // Police inconnue : relayée au Go (qui rendra son 404). + let (_, _, body) = get(&b, "/fonts/absente.woff2", &[]).await; + assert_eq!(&body[..], b"go:/fonts/absente.woff2"); + let vus = b.etat.vus.lock().unwrap().clone(); + assert_eq!( + vus.len(), + 1, + "seule la police inconnue doit atteindre le Go : {vus:?}" + ); +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index ab80072..6c2ab14 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -91,7 +91,12 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = l.local_addr().unwrap(); let (tx, rx) = oneshot::channel::<()>(); - tokio::spawn(loki_rs::proxy::serve(l, backend, async move { + let cfg = loki_rs::proxy::Config { + backend, + internal_token: "jeton-de-test".into(), + host_env: Default::default(), + }; + tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; })); (addr, tx) @@ -198,7 +203,8 @@ async fn backend_absent_donne_502() { .local_addr() .unwrap(); let (f, _stop) = facade(dead).await; - let req = Request::get(format!("http://{f}/")) + // Route relayée au Go (« / » est servie par la façade elle-même). + let req = Request::get(format!("http://{f}/api/status")) .body(Full::new(Bytes::new())) .unwrap(); let resp = client().request(req).await.unwrap();