mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-13 18:27:05 +02:00
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>
467 lines
17 KiB
Rust
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(¤t);
|
|
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));
|
|
}
|
|
}
|