Files
xtremflow/server/src/api/proxy.rs
T
MichaelandClaude Opus 5.5 3ea6fa0ae3 Serveur réécrit en Rust
Le serveur Dart (bin/) est remplacé par un serveur Rust (server/) :
tokio + axum, reqwest/rustls, SQLite embarqué via rusqlite. Même API,
mêmes routes et mêmes réponses : le front Flutter ne change pas.

La base de production est reprise telle quelle : schéma, migrations et
formats de dates (DateTime.toIso8601String de Dart) sont reproduits à
l'identique, les sessions et hachages existants restent valides.

Comportement conservé : cascade de routage, anti-SSRF, logs masqués,
rate limits, transcodage HLS/turbo.ts avec repli GPU, quotas fournisseur,
enregistrements (reprise, relances, fusion), season passes, EPG, logos de
repli, arrêt gracieux sur SIGTERM. Tests Dart portés (132 tests Rust) ;
la CI exécute fmt, test et clippy -D warnings, l'image Docker compile le
serveur dans un étage rust:1.98.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:29:57 +02:00

467 lines
17 KiB
Rust

//! Proxy Xtream `/api/xtream/<url>` (images, API du panneau) avec
//! protections anti-SSRF, et mémoire des hôtes d'images hors service.
use crate::http::{self, Req, Resp, extract_token};
use crate::models::PlaylistConfig;
use crate::state::AppState;
use crate::util::log_redactor::redact_url;
use axum::body::Body;
use futures::TryStreamExt;
use serde_json::json;
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// Réponse pour une image indisponible.
///
/// Statut 410 et non 404 : la cascade du serveur retombe sur l'étage suivant
/// pour tout 404 et renverrait finalement celui des fichiers statiques, sans
/// notre `cache-control`. Un 410 s'arrête ici.
///
/// Un statut d'erreur plutôt qu'une image vide : le client affiche alors son
/// propre repli (icône, initiale) au lieu d'une tuile blanche. Le `max-age`
/// est essentiel : sans lui le navigateur redemande le logo à chaque
/// affichage de la grille.
pub fn missing_image_response() -> Resp {
http::with_headers(
http::empty(410),
&[
("cache-control", "public, max-age=600"),
("access-control-allow-origin", "*"),
],
)
}
/// Mémoire courte des hôtes d'images injoignables.
///
/// POURQUOI : les URL de picons pointent chez l'hébergeur du revendeur, pas
/// sur le panneau. Quand cette machine tombe, elle répond 5xx en quelques
/// millisecondes, le navigateur réessaie à chaque rendu de la grille et le
/// proxy relaie des centaines de requêtes mortes par seconde. Au bout de
/// [threshold] échecs consécutifs, l'hôte est tenu pour mort pendant [ttl]
/// et les requêtes suivantes sont servies localement, sans appel sortant.
pub struct AssetFailureCache {
threshold: u32,
ttl: Duration,
failures: Mutex<HashMap<String, u32>>,
down_until: Mutex<HashMap<String, Instant>>,
}
impl AssetFailureCache {
pub fn new(threshold: u32, ttl: Duration) -> Self {
Self {
threshold,
ttl,
failures: Mutex::default(),
down_until: Mutex::default(),
}
}
pub fn is_down(&self, host: &str) -> bool {
self.is_down_at(host, Instant::now())
}
fn is_down_at(&self, host: &str, now: Instant) -> bool {
let key = host.to_lowercase();
let mut down = self.down_until.lock().unwrap();
let Some(until) = down.get(&key).copied() else {
return false;
};
if now < until {
return true;
}
// Coupure expirée : ardoise vierge pour laisser une vraie chance.
down.remove(&key);
self.failures.lock().unwrap().remove(&key);
false
}
pub fn record_failure(&self, host: &str) {
self.record_failure_at(host, Instant::now());
}
fn record_failure_at(&self, host: &str, now: Instant) {
let key = host.to_lowercase();
let count = {
let mut failures = self.failures.lock().unwrap();
let count = failures.entry(key.clone()).or_insert(0);
*count += 1;
*count
};
if count >= self.threshold {
self.down_until.lock().unwrap().insert(key, now + self.ttl);
}
}
pub fn record_success(&self, host: &str) {
let key = host.to_lowercase();
self.failures.lock().unwrap().remove(&key);
self.down_until.lock().unwrap().remove(&key);
}
}
/// L'URL désigne-t-elle une image ou un logo ? Ces URL échappent à
/// l'allowlist de domaine (les revendeurs hébergent leurs picons ailleurs
/// que le panneau) et méritent un repli visuel plutôt qu'une erreur.
fn is_static_asset_url(url: &reqwest::Url) -> bool {
let path = url.path();
[".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"]
.iter()
.any(|ext| path.ends_with(ext))
|| path.contains("/picons/")
|| path.contains("/logos/")
}
/// Hôte à ne jamais atteindre par le proxy (loopback, LAN privé,
/// link-local/métadonnées cloud) — protection SSRF des URL d'images qui
/// échappent à l'allowlist de domaine.
pub fn is_forbidden_proxy_host(host: &str) -> bool {
let lower = host.to_lowercase();
let lower = lower.trim_start_matches('[').trim_end_matches(']');
if lower == "localhost" || lower == "::1" {
return true;
}
let Ok(ip) = lower.parse::<IpAddr>() else {
return false; // nom d'hôte : validé par l'allowlist de domaine
};
match ip {
IpAddr::V4(v4) => {
let o = v4.octets();
v4.is_loopback()
|| o[0] == 10
|| (o[0] == 172 && (16..=31).contains(&o[1]))
|| (o[0] == 192 && o[1] == 168)
|| (o[0] == 169 && o[1] == 254)
|| o[0] == 0
}
IpAddr::V6(v6) => v6.is_loopback() || (v6.segments()[0] & 0xffc0) == 0xfe80,
}
}
fn host_of(url: &reqwest::Url) -> String {
url.host_str()
.unwrap_or("")
.trim_start_matches('[')
.trim_end_matches(']')
.to_string()
}
const ALLOWED_RESPONSE_HEADERS: [&str; 6] = [
"content-type",
"content-range",
"accept-ranges",
"cache-control",
"server",
"date",
];
const ALLOWED_REQUEST_HEADERS: [&str; 4] = ["user-agent", "accept", "range", "referer"];
pub struct ProxyHandler {
asset_failures: AssetFailureCache,
/// Playlist servant à l'allowlist, mise en cache 5 minutes pour éviter
/// une requête SQL par segment.
playlist_cache: Mutex<Option<(PlaylistConfig, Instant)>>,
}
enum ProxyError {
Response(Resp),
Failure(String),
}
impl ProxyHandler {
pub fn new() -> Self {
Self {
asset_failures: AssetFailureCache::new(3, Duration::from_secs(300)),
playlist_cache: Mutex::new(None),
}
}
fn cached_playlist(&self, state: &AppState, req: &Req) -> Option<PlaylistConfig> {
if let Some((playlist, expiry)) = self.playlist_cache.lock().unwrap().as_ref()
&& Instant::now() < *expiry
{
return Some(playlist.clone());
}
let playlist = state.get_playlist(req)?;
*self.playlist_cache.lock().unwrap() =
Some((playlist.clone(), Instant::now() + Duration::from_secs(300)));
Some(playlist)
}
pub async fn handle(&self, state: &AppState, req: &Req) -> Resp {
// Le test de chemin passe AVANT tout : les autres requêtes doivent
// retomber sur les étages suivants de la cascade.
let Some(api_path) = req.path.strip_prefix("api/xtream/") else {
return http::empty(404);
};
// Authentification par session (en-tête ou cookie HttpOnly posé au
// login : `img src` et hls.js n'envoient que le cookie). Un proxy
// ouvert offrait un rebond SSRF non authentifié.
let authorized = extract_token(req)
.is_some_and(|token| state.db.find_session_by_token(&token).is_some());
if !authorized {
return http::text(401, "Unauthorized");
}
let mut api_path = api_path.to_string();
if api_path.starts_with("http%3A") || api_path.starts_with("https%3A") {
api_path = percent_encoding::percent_decode_str(&api_path)
.decode_utf8_lossy()
.into_owned();
}
if !api_path.starts_with("http://") && !api_path.starts_with("https://") {
return http::text(
400,
"Invalid API URL. Expected format: /api/xtream/http://...",
);
}
let mut full_url = api_path;
if !req.query.is_empty() {
full_url.push(if full_url.contains('?') { '&' } else { '?' });
full_url.push_str(&req.query);
}
let target = reqwest::Url::parse(&full_url).ok();
match self.forward(state, req, target.clone()).await {
Ok(resp) => resp,
Err(ProxyError::Response(resp)) => resp,
Err(ProxyError::Failure(error)) => {
// Une erreur reqwest recopie l'URL amont, identifiants inclus :
// jamais de détail vers le client, log masqué.
println!(
"[ProxyHandler] error on {}: {}",
redact_url(&req.path),
redact_url(&error)
);
if let Some(target) = target.filter(is_static_asset_url) {
self.asset_failures.record_failure(&host_of(&target));
return missing_image_response();
}
http::json(500, &json!({"error": "Proxy error"}))
}
}
}
async fn forward(
&self,
state: &AppState,
req: &Req,
target: Option<reqwest::Url>,
) -> Result<Resp, ProxyError> {
let target =
target.ok_or_else(|| ProxyError::Failure("FormatException: invalid URL".into()))?;
if target.scheme() != "http" && target.scheme() != "https" {
return Err(ProxyError::Response(http::text(
403,
"Unsupported URL scheme",
)));
}
let target_host = host_of(&target);
if is_forbidden_proxy_host(&target_host) {
println!("[Proxy] Blocked SSRF attempt to private host: {target_host}");
return Err(ProxyError::Response(http::text(
403,
"Access to this host is forbidden",
)));
}
// Images : tout hôte autorisé (CDN de picons des revendeurs).
let is_static_asset = is_static_asset_url(&target);
// Hôte d'images déjà constaté mort : réponse immédiate, sans appel
// sortant ni log — c'est précisément le flot qu'on veut éteindre.
if is_static_asset && self.asset_failures.is_down(&target_host) {
return Ok(missing_image_response());
}
let mut allowed_host = None;
if !is_static_asset {
// Appels d'API : allowlist de domaine.
let Some(playlist) = self.cached_playlist(state, req) else {
return Err(ProxyError::Response(http::text(
403,
"No active playlist configuration found to validate request",
)));
};
let allowed = reqwest::Url::parse(&playlist.dns)
.map(|u| host_of(&u).to_lowercase())
.unwrap_or_default();
if target_host.to_lowercase() != allowed {
println!(
"[Proxy] Blocked SSRF attempt to {} (Allowed: {allowed})",
target_host.to_lowercase()
);
return Err(ProxyError::Response(http::text(
403,
"Access to this domain is forbidden by policy",
)));
}
allowed_host = Some(allowed);
}
let mut headers = reqwest::header::HeaderMap::new();
let mut set = |name: &str, value: &str| {
if let (Ok(n), Ok(v)) = (
reqwest::header::HeaderName::from_bytes(name.as_bytes()),
reqwest::header::HeaderValue::from_str(value),
) {
headers.insert(n, v);
}
};
set("user-agent", "VLC/3.0.18 LibVLC/3.0.18");
set("accept", "*/*");
set("accept-encoding", "identity");
for name in ALLOWED_REQUEST_HEADERS {
if let Some(value) = req.header(name) {
set(name, value);
}
}
println!("[Proxy] Forwarding to: {}", redact_url(target.as_str()));
let method = reqwest::Method::from_bytes(req.method.as_str().as_bytes())
.unwrap_or(reqwest::Method::GET);
let post_body = (req.method == axum::http::Method::POST).then(|| req.body.clone());
// Redirections suivies À LA MAIN : chaque destination est revalidée
// (hôte privé, allowlist). Une 302 du serveur amont suffisait sinon à
// atteindre un hôte interne malgré l'anti-SSRF.
let mut current = target.clone();
let mut redirects = 0;
let response = loop {
let mut builder = state
.http_manual
.request(method.clone(), current.clone())
.headers(headers.clone());
if let Some(body) = &post_body {
builder = builder.body(body.clone());
}
// 90 s : laisse le front (60 s) abandonner proprement le premier.
let response = tokio::time::timeout(Duration::from_secs(90), builder.send())
.await
.map_err(|_| ProxyError::Failure("TimeoutException after 0:01:30.000000".into()))?
.map_err(|e| ProxyError::Failure(format!("{e:?}")))?;
let status = response.status().as_u16();
let location = response
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let Some(location) = location.filter(|_| (300..400).contains(&status)) else {
break response;
};
redirects += 1;
if redirects > 3 {
return Err(ProxyError::Response(http::text(403, "Too many redirects")));
}
current = current
.join(&location)
.map_err(|e| ProxyError::Failure(e.to_string()))?;
if current.scheme() != "http" && current.scheme() != "https" {
return Err(ProxyError::Response(http::text(
403,
"Unsupported redirect scheme",
)));
}
let host = host_of(&current);
if is_forbidden_proxy_host(&host) {
println!("[Proxy] Blocked SSRF redirect to private host: {host}");
return Err(ProxyError::Response(http::text(
403,
"Access to this host is forbidden",
)));
}
if let Some(allowed) = &allowed_host
&& host.to_lowercase() != *allowed
{
println!("[Proxy] Blocked redirect to {host} (Allowed: {allowed})");
return Err(ProxyError::Response(http::text(
403,
"Access to this domain is forbidden by policy",
)));
}
};
// Une image en erreur n'a rien à transmettre au navigateur : relayer
// le 503 déclenchait un nouveau cycle de requêtes à chaque rendu.
// L'échec est compté sur l'hôte DEMANDÉ (clé du garde-fou ci-dessus).
let status = response.status().as_u16();
if is_static_asset {
if status >= 400 {
self.asset_failures.record_failure(&target_host);
return Ok(missing_image_response());
}
self.asset_failures.record_success(&target_host);
}
let upstream_headers = response.headers().clone();
let mut resp = http::response(
status,
Body::from_stream(response.bytes_stream().map_err(std::io::Error::other)),
);
http::set_header(&mut resp, "access-control-allow-origin", "*");
for name in ALLOWED_RESPONSE_HEADERS {
let values: Vec<&str> = upstream_headers
.get_all(name)
.iter()
.filter_map(|v| v.to_str().ok())
.collect();
if !values.is_empty() {
http::set_header(&mut resp, name, &values.join(", "));
}
}
Ok(resp)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bloque_le_loopback() {
assert!(is_forbidden_proxy_host("127.0.0.1"));
assert!(is_forbidden_proxy_host("localhost"));
assert!(is_forbidden_proxy_host("::1"));
assert!(is_forbidden_proxy_host("[::1]"));
}
#[test]
fn bloque_les_plages_privees() {
assert!(is_forbidden_proxy_host("10.0.0.5"));
assert!(is_forbidden_proxy_host("172.16.0.1"));
assert!(is_forbidden_proxy_host("172.31.255.255"));
assert!(is_forbidden_proxy_host("192.168.1.10"));
assert!(is_forbidden_proxy_host("169.254.169.254"));
assert!(is_forbidden_proxy_host("0.0.0.0"));
}
#[test]
fn autorise_les_ip_publiques_et_les_noms() {
assert!(!is_forbidden_proxy_host("8.8.8.8"));
assert!(!is_forbidden_proxy_host("172.32.0.1"));
assert!(!is_forbidden_proxy_host("cdn.example.com"));
}
#[test]
fn cache_des_hotes_morts() {
let cache = AssetFailureCache::new(3, Duration::from_secs(300));
let t0 = Instant::now();
assert!(!cache.is_down_at("cdn.example", t0));
cache.record_failure_at("cdn.example", t0);
assert!(!cache.is_down_at("cdn.example", t0));
cache.record_failure_at("cdn.example", t0);
cache.record_success("cdn.example");
cache.record_failure_at("cdn.example", t0);
assert!(!cache.is_down_at("cdn.example", t0));
cache.record_failure_at("CDN.example", t0);
cache.record_failure_at("cdn.example", t0);
assert!(cache.is_down_at("cdn.example", t0));
// Coupure expirée : ardoise vierge.
assert!(!cache.is_down_at("cdn.example", t0 + Duration::from_secs(301)));
cache.record_failure_at("cdn.example", t0);
assert!(!cache.is_down_at("cdn.example", t0));
}
}