Files
Loki/rust/loki-rs/tests/lot2.rs
T
MichaelandClaude Opus 5.5 d4fa4488cc Façade Rust, lot 2 : interface, authentification et /api/ping
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 <noreply@anthropic.com>
2026-10-13 22:40:10 +02:00

215 lines
7.6 KiB
Rust

//! 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<Option<String>>,
vus: Mutex<Vec<(String, Option<String>)>>,
}
async fn faux_go(etat: Arc<FauxGo>) -> 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<Incoming>| {
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<FauxGo>,
_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<Bytes>> = 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:?}"
);
}