mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
Logos absents : l'hébergeur des picons du fournisseur répond 503 sur tout, y compris sa racine (serveur en maintenance). Le proxy relayait ce 503 au navigateur, qui redemandait chaque logo à chaque affichage de la grille — des centaines de requêtes sortantes mortes par seconde. - Un statut >= 400 sur une image rend désormais un pixel transparent avec `Cache-Control: max-age=600`, au lieu de propager l'erreur - Après trois échecs consécutifs, l'hôte est marqué hors service cinq minutes : plus aucun appel sortant, réponse servie localement - Le corps d'erreur amont est drainé, sinon la connexion restait ouverte jusqu'au timeout Guide TV très lent (4 à 8 s par chaîne dans les logs) : `player_api` demande un appel par chaîne, et le panneau met ~4 s à répondre — une grille de trente chaînes dépassait la minute. - Le dump `xmltv.php` du panneau passe en premier : une requête couvre toutes les chaînes, réutilisée trois heures - L'interrogation chaîne par chaîne reste le repli, puis la source XMLTV externe - Une source XMLTV en échec n'est plus retéléchargée à chaque consultation : quinze minutes de recul - Index borné à 48 h en avant : un dump national couvre sept jours pour un millier de chaînes, soit des centaines de Mo pour un guide qui n'affiche que le programme courant Lecture saccadée : le relais FFmpeg -> navigateur de `/turbo.ts` ne transmettait pas la pause du client à la source. Un lecteur plus lent que le flux laissait FFmpeg produire à pleine vitesse et le serveur empilait les paquets en mémoire, d'où la dérive derrière le direct. La contre-pression est maintenant propagée (`pipeWithBackpressure`). Le serveur tourne sur un seul isolate : le flot de requêtes mortes et les attentes EPG de plusieurs secondes partageaient la boucle d'événements avec les paquets vidéo. Tests ajoutés : cache d'échecs par hôte, contre-pression du relais, priorité des sources EPG, recul après échec XMLTV, horizon d'index. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
355 lines
13 KiB
Dart
355 lines
13 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'dart:typed_data';
|
||
import 'package:shelf/shelf.dart';
|
||
import 'package:http/http.dart' as http;
|
||
import '../database/database.dart';
|
||
import '../middleware/auth_middleware.dart';
|
||
import '../models/playlist_config.dart';
|
||
import '../utils/log_redactor.dart';
|
||
import 'asset_failure_cache.dart';
|
||
|
||
/// PNG transparent 1×1, servi à la place d'un logo introuvable.
|
||
final Uint8List _placeholderPixel = base64Decode(
|
||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
|
||
);
|
||
|
||
/// 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 propagée au navigateur.
|
||
bool _isStaticAssetUrl(Uri url) =>
|
||
url.path.endsWith('.png') ||
|
||
url.path.endsWith('.jpg') ||
|
||
url.path.endsWith('.jpeg') ||
|
||
url.path.endsWith('.gif') ||
|
||
url.path.endsWith('.webp') ||
|
||
url.path.endsWith('.ico') ||
|
||
url.path.contains('/picons/') ||
|
||
url.path.contains('/logos/');
|
||
|
||
/// Réponse de repli pour une image indisponible.
|
||
///
|
||
/// Le `max-age` est essentiel : sans lui le navigateur redemande le logo à
|
||
/// chaque affichage de la grille et le flot de requêtes mortes reprend
|
||
/// immédiatement, coupure serveur ou pas.
|
||
Response _placeholderImageResponse() => Response.ok(
|
||
_placeholderPixel,
|
||
headers: {
|
||
'content-type': 'image/png',
|
||
'cache-control': 'public, max-age=600',
|
||
'access-control-allow-origin': '*',
|
||
},
|
||
);
|
||
|
||
/// Returns true when [host] must never be proxied (loopback, private LAN,
|
||
/// link-local/cloud-metadata ranges) — SSRF protection for asset URLs that
|
||
/// bypass the playlist-domain allowlist.
|
||
bool isForbiddenProxyHost(String host) {
|
||
final lower = host.toLowerCase();
|
||
if (lower == 'localhost' || lower == '::1') return true;
|
||
|
||
final ip = InternetAddress.tryParse(lower);
|
||
if (ip == null) return false; // Hostname: validated by domain allowlist path
|
||
|
||
if (ip.isLoopback || ip.isLinkLocal) return true;
|
||
if (ip.type == InternetAddressType.IPv4) {
|
||
final parts = ip.address.split('.').map(int.parse).toList();
|
||
if (parts[0] == 10) return true; // 10.0.0.0/8
|
||
if (parts[0] == 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
||
if (parts[0] == 192 && parts[1] == 168) return true; // 192.168.0.0/16
|
||
if (parts[0] == 169 && parts[1] == 254) return true; // metadata/link-local
|
||
if (parts[0] == 0) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// Handler for the Xtream Proxy
|
||
class ProxyHandler {
|
||
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
|
||
final AppDatabase _db;
|
||
final http.Client _client = http.Client();
|
||
|
||
final Map<String, (PlaylistConfig, DateTime)> _playlistCache = {};
|
||
static const _cacheDuration = Duration(minutes: 5);
|
||
|
||
/// Hôtes d'images réputés hors service (voir [AssetFailureCache]).
|
||
final AssetFailureCache _assetFailures = AssetFailureCache();
|
||
|
||
static const _allowedHeaders = [
|
||
'content-type',
|
||
'content-range',
|
||
'accept-ranges',
|
||
'cache-control',
|
||
'server',
|
||
'date',
|
||
];
|
||
|
||
static const _allowedRequestHeaders = [
|
||
'user-agent',
|
||
'accept',
|
||
'range',
|
||
'referer',
|
||
];
|
||
|
||
Future<PlaylistConfig?> _getCachedPlaylist(Request request) async {
|
||
// Basic caching to avoid DB overhead on every video segment
|
||
final now = DateTime.now();
|
||
const cacheKey =
|
||
'global_playlist'; // Currently app has one primary playlist per user/global
|
||
|
||
if (_playlistCache.containsKey(cacheKey)) {
|
||
final (cached, expiry) = _playlistCache[cacheKey]!;
|
||
if (now.isBefore(expiry)) return cached;
|
||
}
|
||
|
||
final playlist = await _getPlaylist(request);
|
||
if (playlist != null) {
|
||
_playlistCache[cacheKey] = (playlist, now.add(_cacheDuration));
|
||
}
|
||
return playlist;
|
||
}
|
||
|
||
ProxyHandler(this._getPlaylist, this._db);
|
||
|
||
/// Create Xtream proxy handler with M3U8 URL rewriting support
|
||
Handler get handler {
|
||
return (Request request) async {
|
||
final path = request.url.path;
|
||
|
||
// Only handle /api/xtream/* requests
|
||
// CRITICAL: Check path BEFORE anything else so non-proxy requests fall through to static handler
|
||
if (!path.startsWith('api/xtream/')) {
|
||
// Return 404 so Cascade tries next handler (streamingRouter)
|
||
return Response.notFound(null);
|
||
}
|
||
|
||
// Authentification par session. Les requêtes initiées par le navigateur
|
||
// (img src, hls.js) ne portent pas d'en-tête Authorization mais envoient
|
||
// le cookie HttpOnly `session` (SameSite=Lax, même origine) posé au
|
||
// login : extractAuthToken accepte les deux. Un proxy ouvert offrait un
|
||
// rebond SSRF non authentifié vers n'importe quel hôte public via les
|
||
// extensions d'image.
|
||
final token = extractAuthToken(request);
|
||
if (token == null || _db.findSessionByToken(token) == null) {
|
||
return Response(401, body: 'Unauthorized');
|
||
}
|
||
|
||
Uri? targetUrl;
|
||
|
||
// === PROXY LOGIC ===
|
||
try {
|
||
// Extract target URL from request
|
||
// Format: /api/xtream/http://server:port/path
|
||
String apiPath = path.substring('api/xtream/'.length);
|
||
|
||
// Decode URL if it's encoded
|
||
if (apiPath.startsWith('http%3A') || apiPath.startsWith('https%3A')) {
|
||
apiPath = Uri.decodeComponent(apiPath);
|
||
}
|
||
|
||
if (!apiPath.startsWith('http://') && !apiPath.startsWith('https://')) {
|
||
return Response.badRequest(
|
||
body: 'Invalid API URL. Expected format: /api/xtream/http://...',
|
||
);
|
||
}
|
||
|
||
String fullUrl = apiPath;
|
||
if (request.url.query.isNotEmpty) {
|
||
if (fullUrl.contains('?')) {
|
||
fullUrl = '$fullUrl&${request.url.query}';
|
||
} else {
|
||
fullUrl = '$fullUrl?${request.url.query}';
|
||
}
|
||
}
|
||
|
||
targetUrl = Uri.parse(fullUrl);
|
||
|
||
// Only plain http(s) may be proxied
|
||
if (targetUrl.scheme != 'http' && targetUrl.scheme != 'https') {
|
||
return Response.forbidden('Unsupported URL scheme');
|
||
}
|
||
|
||
// Never proxy to loopback/private/link-local targets (SSRF)
|
||
if (isForbiddenProxyHost(targetUrl.host)) {
|
||
print('[Proxy] Blocked SSRF attempt to private host: ${targetUrl.host}');
|
||
return Response.forbidden('Access to this host is forbidden');
|
||
}
|
||
|
||
// SSRF Protection - but allow images/static assets from any host
|
||
// Xtream providers often use separate CDN servers for picons/images
|
||
final isStaticAsset = _isStaticAssetUrl(targetUrl);
|
||
|
||
// Hôte d'images déjà constaté mort : on sert le pixel tout de suite.
|
||
// Aucun appel sortant, aucune entrée de log — c'est précisément le
|
||
// flot qu'on cherche à éteindre.
|
||
if (isStaticAsset && _assetFailures.isDown(targetUrl.host)) {
|
||
return _placeholderImageResponse();
|
||
}
|
||
|
||
String? allowedHost;
|
||
if (!isStaticAsset) {
|
||
// For API calls, enforce domain allowlist
|
||
final playlist = await _getCachedPlaylist(request);
|
||
if (playlist == null) {
|
||
return Response.forbidden(
|
||
'No active playlist configuration found to validate request',
|
||
);
|
||
}
|
||
|
||
final targetHost = targetUrl.host.toLowerCase();
|
||
allowedHost = Uri.parse(playlist.dns).host.toLowerCase();
|
||
|
||
if (targetHost != allowedHost) {
|
||
print(
|
||
'[Proxy] Blocked SSRF attempt to $targetHost (Allowed: $allowedHost)',
|
||
);
|
||
return Response.forbidden(
|
||
'Access to this domain is forbidden by policy',
|
||
);
|
||
}
|
||
}
|
||
|
||
final proxyHeaders = <String, String>{
|
||
'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18',
|
||
'Accept': '*/*',
|
||
'Accept-Encoding': 'identity',
|
||
'Keep-Alive': 'timeout=30, max=100', // Request persistent connection
|
||
};
|
||
|
||
// Forward Range header if present
|
||
if (request.headers['range'] != null) {
|
||
proxyHeaders['range'] = request.headers['range']!;
|
||
}
|
||
|
||
try {
|
||
print('[Proxy] Forwarding to: ${LogRedactor.redactUrl(targetUrl.toString())}');
|
||
|
||
// Forward safe request headers
|
||
for (final header in _allowedRequestHeaders) {
|
||
if (request.headers.containsKey(header)) {
|
||
proxyHeaders[header] = request.headers[header]!;
|
||
}
|
||
}
|
||
|
||
List<int>? postBody;
|
||
if (request.method == 'POST') {
|
||
final bodyBytes = await request.read().toList();
|
||
postBody = bodyBytes.expand((i) => i).toList();
|
||
}
|
||
|
||
// Redirections suivies MANUELLEMENT : chaque destination est
|
||
// revalidée (hôte privé, allowlist de domaine). Avec
|
||
// followRedirects, la validation ne portait que sur l'URL
|
||
// initiale — une 302 du serveur amont suffisait pour atteindre
|
||
// un hôte interne malgré l'anti-SSRF.
|
||
http.StreamedResponse response;
|
||
var currentUrl = targetUrl;
|
||
var redirects = 0;
|
||
while (true) {
|
||
final proxyRequest = http.Request(request.method, currentUrl);
|
||
proxyRequest.headers.addAll(proxyHeaders);
|
||
proxyRequest.followRedirects = false;
|
||
if (postBody != null) proxyRequest.bodyBytes = postBody;
|
||
|
||
// Added 90s timeout to allow frontend (60s) to time out gracefully first
|
||
response = await _client
|
||
.send(proxyRequest)
|
||
.timeout(const Duration(seconds: 90));
|
||
|
||
final location = response.headers['location'];
|
||
final isRedirect = response.statusCode >= 300 &&
|
||
response.statusCode < 400 &&
|
||
location != null;
|
||
if (!isRedirect) break;
|
||
|
||
if (++redirects > 3) {
|
||
return Response.forbidden('Too many redirects');
|
||
}
|
||
final next = Uri.parse(location);
|
||
currentUrl = next.isAbsolute ? next : currentUrl.resolve(location);
|
||
if (currentUrl.scheme != 'http' && currentUrl.scheme != 'https') {
|
||
return Response.forbidden('Unsupported redirect scheme');
|
||
}
|
||
if (isForbiddenProxyHost(currentUrl.host)) {
|
||
print(
|
||
'[Proxy] Blocked SSRF redirect to private host: ${currentUrl.host}',
|
||
);
|
||
return Response.forbidden('Access to this host is forbidden');
|
||
}
|
||
if (allowedHost != null &&
|
||
currentUrl.host.toLowerCase() != allowedHost) {
|
||
print(
|
||
'[Proxy] Blocked redirect to ${currentUrl.host} (Allowed: $allowedHost)',
|
||
);
|
||
return Response.forbidden(
|
||
'Access to this domain is forbidden by policy',
|
||
);
|
||
}
|
||
}
|
||
|
||
// Une image en erreur n'a rien à transmettre au navigateur : le
|
||
// relais du 503 déclenchait un nouveau cycle de requêtes à chaque
|
||
// rendu. On sert le pixel, mis en cache, et on compte l'échec.
|
||
if (isStaticAsset) {
|
||
if (response.statusCode >= 400) {
|
||
// Compté sur l'hôte demandé, pas sur la cible finale d'une
|
||
// redirection : c'est cette clé-là que consulte le garde-fou
|
||
// en tête de requête.
|
||
_assetFailures.recordFailure(targetUrl.host);
|
||
// Le corps d'erreur doit être consommé, sinon la connexion
|
||
// reste ouverte jusqu'au timeout.
|
||
unawaited(response.stream.drain<void>().catchError((_) {}));
|
||
return _placeholderImageResponse();
|
||
}
|
||
_assetFailures.recordSuccess(targetUrl.host);
|
||
}
|
||
|
||
// Build response headers from source response
|
||
final responseHeaders = <String, String>{
|
||
'access-control-allow-origin': '*',
|
||
'connection': 'keep-alive',
|
||
'keep-alive': 'timeout=30',
|
||
};
|
||
|
||
// Forward specific safe headers
|
||
for (final header in _allowedHeaders) {
|
||
if (response.headers.containsKey(header)) {
|
||
responseHeaders[header] = response.headers[header]!;
|
||
}
|
||
}
|
||
|
||
// ALWAYS stream the response for maximum performance and to avoid memory issues with large JSONs
|
||
return Response(
|
||
response.statusCode,
|
||
body: response.stream,
|
||
headers: responseHeaders,
|
||
);
|
||
} catch (e) {
|
||
rethrow;
|
||
}
|
||
} catch (e) {
|
||
// Redaction : une ClientException porte l'URL amont, credentials
|
||
// Xtream inclus. Jamais de détail d'exception vers le client.
|
||
print(
|
||
'[ProxyHandler] error on ${LogRedactor.redactUrl(path)}: '
|
||
'${LogRedactor.redactUrl('$e')}',
|
||
);
|
||
|
||
// Return transparent 1x1 pixel image fallback for images
|
||
if (targetUrl != null && _isStaticAssetUrl(targetUrl)) {
|
||
_assetFailures.recordFailure(targetUrl.host);
|
||
return _placeholderImageResponse();
|
||
}
|
||
|
||
return Response.internalServerError(
|
||
body: jsonEncode({'error': 'Proxy error'}),
|
||
headers: {'content-type': 'application/json'},
|
||
);
|
||
}
|
||
};
|
||
}
|
||
}
|