new version

This commit is contained in:
UrloMythus
2026-05-19 20:28:26 +02:00
parent fbee2c1855
commit bd208c63ff
99 changed files with 1287 additions and 225 deletions
+47 -11
View File
@@ -16,7 +16,12 @@ playlist_builder_router = APIRouter()
def rewrite_m3u_links_streaming(
m3u_lines_iterator: Iterator[str], base_url: str, api_password: Optional[str]
m3u_lines_iterator: Iterator[str],
base_url: str,
api_password: Optional[str],
max_res: bool = False,
redirect_stream: bool = True,
no_proxy: bool = True,
) -> Iterator[str]:
"""
Rewrites links from an M3U line iterator according to the specified rules,
@@ -99,7 +104,13 @@ def rewrite_m3u_links_streaming(
processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}"
elif "vixsrc.to" in logical_line:
encoded_url = urllib.parse.quote(logical_line, safe="")
processed_url_content = f"{base_url}/extractor/video?host=VixCloud&redirect_stream=true&d={encoded_url}&max_res=true&no_proxy=true"
processed_url_content = (
f"{base_url}/extractor/video?host=VixCloud"
f"&redirect_stream={str(redirect_stream).lower()}"
f"&d={encoded_url}"
f"&max_res={str(max_res).lower()}"
f"&no_proxy={str(no_proxy).lower()}"
)
elif ".m3u8" in logical_line:
encoded_url = urllib.parse.quote(logical_line, safe="")
processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}"
@@ -232,7 +243,14 @@ def parse_channel_entries(lines: list[str]) -> list[list[str]]:
return entries
async def async_generate_combined_playlist(playlist_definitions: list[str], base_url: str, api_password: Optional[str]):
async def async_generate_combined_playlist(
playlist_definitions: list[str],
base_url: str,
api_password: Optional[str],
max_res: bool = False,
redirect_stream: bool = True,
no_proxy: bool = True,
):
"""Genera una playlist combinata da multiple definizioni, scaricando in parallelo."""
# Prepara i task di download
download_tasks = []
@@ -318,7 +336,9 @@ async def async_generate_combined_playlist(playlist_definitions: list[str], base
if should_proxy:
# Usa un iteratore fittizio per processare una sola linea
rewritten_url_iter = rewrite_m3u_links_streaming(iter([url]), base_url, api_password)
rewritten_url_iter = rewrite_m3u_links_streaming(
iter([url]), base_url, api_password, max_res, redirect_stream, no_proxy
)
yield next(rewritten_url_iter, url) # Prende l'URL riscritto, con fallback all'originale
else:
yield url # Lascia l'URL invariato
@@ -327,7 +347,9 @@ async def async_generate_combined_playlist(playlist_definitions: list[str], base
for playlist_data in unsorted_playlists_data:
lines_iterator = iter(playlist_data["lines"])
if playlist_data["proxy"]:
lines_iterator = rewrite_m3u_links_streaming(lines_iterator, base_url, api_password)
lines_iterator = rewrite_m3u_links_streaming(
lines_iterator, base_url, api_password, max_res, redirect_stream, no_proxy
)
for line in yield_header_once(lines_iterator):
yield line
@@ -336,14 +358,26 @@ async def async_generate_combined_playlist(playlist_definitions: list[str], base
@playlist_builder_router.get("/playlist")
async def proxy_handler(
request: Request,
d: str = Query(..., description="Query string con le definizioni delle playlist", alias="d"),
api_password: Optional[str] = Query(None, description="Password API per MFP"),
d: str = Query(..., description="Semicolon-separated playlist URL definitions"),
api_password: Optional[str] = Query(None, description="MFP API password"),
max_res: bool = Query(False, description="Redirect extractor streams to the highest available resolution"),
redirect_stream: bool = Query(True, description="Redirect extractor to the direct stream URL"),
no_proxy: bool = Query(True, description="Skip proxying internal segment URLs for extractor streams"),
):
"""
Endpoint per il proxy delle playlist M3U con supporto MFP.
Proxy and merge one or more M3U playlists, rewriting stream URLs through MFP.
Formato query string: playlist1&url1;playlist2&url2
Esempio: https://mfp.com:pass123&http://provider.com/playlist.m3u
**`d` format** — semicolon-separated playlist definitions:
- Plain URL: `http://provider.com/playlist.m3u`
- With sort prefix: `sort:http://provider.com/playlist.m3u`
- Without rewriting: `no_proxy:http://provider.com/playlist.m3u`
- Combinable: `sort:no_proxy:http://provider.com/playlist.m3u`
Stream URLs in each playlist are automatically rewritten to route through the
appropriate MFP proxy endpoint (`/proxy/hls`, `/proxy/mpd`, `/extractor/video`).
**Extractor parameters** (`max_res`, `redirect_stream`, `no_proxy`) apply to
streams that go through `/extractor/video` (e.g. vixsrc.to links).
"""
try:
if not d:
@@ -371,7 +405,9 @@ async def proxy_handler(
base_url = base_url_part
async def generate_response():
async for line in async_generate_combined_playlist(playlist_definitions, base_url, api_password):
async for line in async_generate_combined_playlist(
playlist_definitions, base_url, api_password, max_res, redirect_stream, no_proxy
):
yield line
return StreamingResponse(
+203 -1
View File
@@ -1,9 +1,13 @@
import asyncio
import ipaddress
import logging
import re
from functools import lru_cache
from typing import Annotated
from urllib.parse import quote, unquote
from urllib.parse import quote, unquote, urlparse
import aiohttp
from aiohttp import ClientTimeout
from fastapi import Request, Depends, APIRouter, Query, HTTPException, Response
from fastapi.datastructures import QueryParams
@@ -30,6 +34,7 @@ from mediaflow_proxy.utils.extractor_helpers import (
check_and_extract_sportsonline_stream,
)
from mediaflow_proxy.utils.hls_prebuffer import hls_prebuffer
from mediaflow_proxy.utils.http_client import create_aiohttp_session
from mediaflow_proxy.utils.http_utils import (
get_proxy_headers,
ProxyRequestHeaders,
@@ -452,6 +457,203 @@ def _build_hls_query_params(request: Request, destination: str) -> str:
return "&".join(params)
MEDIAFLOW_IP_PLACEHOLDER = "{mediaflow_ip}"
_IP_DETECT_URLS = ["https://api.ipify.org", "https://checkip.amazonaws.com"]
_cached_public_ip: str | None = None
_public_ip_lock: asyncio.Lock | None = None
async def _resolve_public_ip() -> str | None:
"""Return MediaFlow's public IP: configured value, cached detection, or None."""
global _cached_public_ip, _public_ip_lock
if settings.public_ip:
return settings.public_ip
if _cached_public_ip:
return _cached_public_ip
if _public_ip_lock is None:
_public_ip_lock = asyncio.Lock()
async with _public_ip_lock:
if _cached_public_ip:
return _cached_public_ip
for url in _IP_DETECT_URLS:
try:
async with aiohttp.ClientSession() as sess:
async with sess.get(url, timeout=ClientTimeout(total=5)) as resp:
ip = (await resp.text()).strip()
if ip:
_cached_public_ip = ip
return ip
except Exception:
continue
return None
_IP_DISCLOSURE_HEADERS = frozenset(
{
"x-forwarded-for",
"x-real-ip",
"x-client-ip",
"true-client-ip",
"forwarded",
"cf-connecting-ip",
"x-original-forwarded-for",
"x-cluster-client-ip",
}
)
_HOP_BY_HOP_HEADERS = frozenset(
{
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
}
)
# Headers that callers must not inject via h_* params — they enable host-header
# injection, HTTP request smuggling, or break the session's own framing logic.
_BLOCKED_REQUEST_HEADERS = frozenset(
{
"host",
"content-length",
"transfer-encoding",
"content-encoding",
}
)
def _check_forward_destination(destination: str) -> None:
"""SSRF guard and allowlist/denylist check for /proxy/forward."""
parsed = urlparse(destination)
# Only allow http(s) — blocks file://, ftp://, gopher://, data:, javascript:, etc.
scheme = (parsed.scheme or "").lower()
if scheme not in ("http", "https"):
raise HTTPException(
status_code=400,
detail=f"Invalid URL scheme '{scheme}'. Only http and https are allowed.",
)
hostname = (parsed.hostname or "").lower()
if not hostname:
raise HTTPException(status_code=400, detail="Invalid destination URL: no hostname")
# Allowlist check (if configured)
allowed = settings.forward_allowed_hosts
if allowed and hostname not in {h.lower() for h in allowed}:
raise HTTPException(status_code=403, detail=f"Host '{hostname}' is not in forward_allowed_hosts")
# Explicit denylist
denied = {h.lower() for h in settings.forward_denied_hosts}
if hostname in denied:
raise HTTPException(status_code=403, detail=f"Host '{hostname}' is denied")
# Always block loopback literals
if hostname in ("localhost", "ip6-localhost", "ip6-loopback"):
raise HTTPException(status_code=403, detail="Forwarding to localhost is not allowed")
# Block private/loopback/link-local IPs given as literals
try:
addr = ipaddress.ip_address(hostname)
if addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_unspecified:
raise HTTPException(status_code=403, detail="Forwarding to private/loopback addresses is not allowed")
except ValueError:
pass # Not a numeric IP — hostname-based SSRF is the caller's responsibility
@proxy_router.api_route(
"/forward",
methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
)
async def proxy_forward_endpoint(
request: Request,
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
destination: str = Query(..., description="The destination URL to forward to.", alias="d"),
):
"""
Generic transparent HTTP forwarding endpoint.
Forwards any HTTP method (including POST with body) to the given destination URL
using MediaFlow's outbound IP. Useful for IP-bound API calls (e.g. debrid service
APIs, extractor POST requests) where the request must appear to originate from
MediaFlow rather than the addon server.
Pass outbound headers via ``h_<name>=<value>`` query params. The upstream response
(status code, headers, body) is returned verbatim. IP-disclosure headers are
stripped before forwarding so the caller's IP is not leaked.
"""
destination = sanitize_url(destination)
_check_forward_destination(destination)
# Strip IP-disclosure headers — the whole point is hiding the origin IP
for h in _IP_DISCLOSURE_HEADERS:
proxy_headers.request.pop(h, None)
# Strip headers that could enable host-header injection or HTTP smuggling
for h in _BLOCKED_REQUEST_HEADERS:
proxy_headers.request.pop(h, None)
body = await request.body()
if len(body) > settings.forward_max_request_body_bytes:
raise HTTPException(status_code=413, detail="Request body too large")
max_response_bytes = settings.forward_max_response_body_bytes
# Substitute {mediaflow_ip} placeholder with MediaFlow's actual public IP so
# debrid services receive a consistent ip= parameter that matches the TCP source.
if MEDIAFLOW_IP_PLACEHOLDER in destination or MEDIAFLOW_IP_PLACEHOLDER.encode() in body:
public_ip = await _resolve_public_ip()
if public_ip:
destination = destination.replace(MEDIAFLOW_IP_PLACEHOLDER, public_ip)
body = body.replace(MEDIAFLOW_IP_PLACEHOLDER.encode(), public_ip.encode())
async with create_aiohttp_session(destination) as (session, proxy_url):
try:
async with session.request(
method=request.method,
url=destination,
headers=proxy_headers.request,
data=body if body else None,
proxy=proxy_url,
timeout=ClientTimeout(total=settings.transport_config.timeout),
allow_redirects=True,
) as upstream_resp:
resp_body = await upstream_resp.content.read(max_response_bytes + 1)
if len(resp_body) > max_response_bytes:
raise HTTPException(status_code=502, detail="Upstream response too large")
resp_headers = {k: v for k, v in upstream_resp.headers.items() if k.lower() not in _HOP_BY_HOP_HEADERS}
resp_headers.update(proxy_headers.response)
return Response(
content=resp_body,
status_code=upstream_resp.status,
headers=resp_headers,
)
except aiohttp.ClientResponseError as e:
raise HTTPException(status_code=e.status, detail=f"Upstream error: {e.message}")
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="Upstream timeout")
except aiohttp.ClientError as e:
raise HTTPException(status_code=502, detail=f"Upstream connection error: {e}")
@proxy_router.get("/ip")
async def get_public_ip_endpoint():
"""Return MediaFlow's public IP address."""
ip = await _resolve_public_ip()
if ip is None:
raise HTTPException(status_code=503, detail="Could not determine public IP")
return {"ip": ip}
@proxy_router.head("/stream")
@proxy_router.get("/stream")
@proxy_router.head("/stream/{filename:path}")