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>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-13 22:40:10 +02:00
1 parent a99d988e70
commit d4fa4488cc
13 files changed
+1023 -18

No files matched your search

+382
View File
@@ -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<HttpConnector, Empty<Bytes>>,
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<AuthInfo, String> {
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<String>,
/// `LOKI_TRUSTED_HOSTS`, en minuscules.
pub trusted: Vec<String>,
}
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<String> {
// 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<AuthInfo, String>,
env: &HostEnv,
) -> Option<Response<Body>> {
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<String> {
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::<IpAddr>().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 <clé>`, 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<Body> {
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<Body> {
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);
}
}
+5 -3
View File
@@ -41,13 +41,14 @@ pub struct Supervisor {
impl Supervisor {
/// Lance `<go_bin> web <port>` 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::<Duration>();
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<Child> {
fn launch(go_bin: &Path, port: u16, env: &[(String, String)]) -> std::io::Result<Child> {
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")
+5 -1
View File
@@ -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;
+27 -3
View File
@@ -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<String, getrandom::Error> {
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) {
+59 -6
View File
@@ -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<Bytes, hyper::Error>;
/// 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<HttpConnector, Incoming>,
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<Output = ()>,
) -> 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<Incoming>) -> Response<Body> {
// 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<Body> {
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<Incoming>) -> Response<Body> {
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"));
+214
View File
@@ -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<Vec<u8>>,
}
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<Response<Body>> {
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<Body> {
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/<nom>.woff2` — comme le Go, seul le dernier segment compte.
/// Jamais compressées : woff2 l'est déjà.
fn font(path: &str) -> Option<Response<Body>> {
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<u8> {
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<Response<Body>> {
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<Body>, 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());
}
}