diff --git a/README.md b/README.md index d19ea55..8f10675 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,10 @@ MediaFlow Proxy is a powerful and flexible solution for proxifying various types - Retrieve public IP address of the MediaFlow Proxy server for use with Debrid services - Support for HTTP/HTTPS/SOCKS5 proxy forwarding - Protect against unauthorized access and network bandwidth abuses -- Support for play expired or self-signed SSL certificates server streams +- Support for play expired or self-signed SSL certificates server streams `(verify_ssl=false)` default is `false` +- Flexible request proxy usage control per request `(use_request_proxy=true/false)` default is `true` +- Obfuscating endpoint parameters by encrypting them to hide sensitive information from third-party. +- Optional IP-based access control restriction & expiration for encrypted URLs to prevent unauthorized access ## Configuration @@ -151,10 +154,10 @@ Once the server is running, for more details on the available endpoints and thei ### Examples -#### Proxy HTTPS Stream +#### Proxy HTTPS Stream (without using configured proxy) ```bash -mpv "http://localhost:8888/proxy/stream?d=https://jsoncompare.org/LearningContainer/SampleFiles/Video/MP4/sample-mp4-file.mp4&api_password=your_password" +mpv "http://localhost:8888/proxy/stream?d=https://jsoncompare.org/LearningContainer/SampleFiles/Video/MP4/sample-mp4-file.mp4&api_password=your_password&use_request_proxy=false" ``` #### Proxy HTTPS self-signed certificate Stream @@ -217,6 +220,38 @@ This will output a properly encoded URL that can be used with players like VLC. vlc "http://127.0.0.1:8888/proxy/mpd/manifest?key_id=nrQFDeRLSAKTLifXUIPiZg&key=FmY0xnWCPCNaSpRG-tUuTQ&api_password=dedsec&d=https%3A%2F%2Fmedia.axprod.net%2FTestVectors%2Fv7-MultiDRM-SingleKey%2FManifest_1080p_ClearKey.mpd" ``` +### Generating Encrypted URLs + +To generate an encrypted URL with optional IP restriction and expiration, Use the `/generate_encrypted_or_encoded_url` endpoint via swagger UI or programmatically as shown below: +```python +import requests + +url = "http://localhost:8888/generate_encrypted_or_encoded_url" +data = { + "mediaflow_proxy_url": "http://localhost:8888", + "endpoint": "/proxy/mpd/manifest", + "destination_url": "https://media.axprod.net/TestVectors/v7-MultiDRM-SingleKey/Manifest_1080p_ClearKey.mpd", + "query_params": { + "key_id": "nrQFDeRLSAKTLifXUIPiZg", + "key": "FmY0xnWCPCNaSpRG-tUuTQ" + }, + "request_headers": { + "referer": "https://media.axprod.net/", + "origin": "https://media.axprod.net", + }, + "expiration": 3600, # URL will expire in 1 hour + "ip": "123.123.123.123", # Optional: Restrict access to this IP + "api_password": "your_password" +} + +response = requests.post(url, json=data) +encrypted_url = response.json()["encoded_url"] +print(encrypted_url) +``` + +You can then use the `encoded_url` in your player or application to access the media stream. + + ### Using MediaFlow Proxy with Debrid Services and Stremio Addons MediaFlow Proxy can be particularly useful when working with Debrid services (like Real-Debrid, AllDebrid) and Stremio addons. The `/proxy/ip` endpoint allows you to retrieve the public IP address of the MediaFlow Proxy server, which is crucial for routing Debrid streams correctly. diff --git a/mediaflow_proxy/main.py b/mediaflow_proxy/main.py index 7518ece..e048c36 100644 --- a/mediaflow_proxy/main.py +++ b/mediaflow_proxy/main.py @@ -9,6 +9,9 @@ from starlette.staticfiles import StaticFiles from mediaflow_proxy.configs import settings from mediaflow_proxy.routes import proxy_router +from mediaflow_proxy.schemas import GenerateUrlRequest +from mediaflow_proxy.utils.crypto_utils import EncryptionHandler, EncryptionMiddleware +from mediaflow_proxy.utils.http_utils import encode_mediaflow_proxy_url logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") app = FastAPI() @@ -21,6 +24,7 @@ app.add_middleware( allow_methods=["*"], allow_headers=["*"], ) +app.add_middleware(EncryptionMiddleware) async def verify_api_key(api_key: str = Security(api_password_query), api_key_alt: str = Security(api_password_header)): @@ -50,6 +54,25 @@ async def get_favicon(): return RedirectResponse(url="/logo.png") +@app.post("/generate_encrypted_or_encoded_url") +async def generate_encrypted_or_encoded_url(request: GenerateUrlRequest): + if "api_password" not in request.query_params: + request.query_params["api_password"] = request.api_password + + encoded_url = encode_mediaflow_proxy_url( + request.mediaflow_proxy_url, + request.endpoint, + request.destination_url, + request.query_params, + request.request_headers, + request.response_headers, + EncryptionHandler(request.api_password) if request.api_password else None, + request.expiration, + str(request.ip) if request.ip else None, + ) + return {"encoded_url": encoded_url} + + app.include_router(proxy_router, prefix="/proxy", tags=["proxy"], dependencies=[Depends(verify_api_key)]) static_path = resources.files("mediaflow_proxy").joinpath("static") diff --git a/mediaflow_proxy/mpd_processor.py b/mediaflow_proxy/mpd_processor.py index 61894e5..26b6f94 100644 --- a/mediaflow_proxy/mpd_processor.py +++ b/mediaflow_proxy/mpd_processor.py @@ -7,6 +7,7 @@ from fastapi import Request, Response, HTTPException from mediaflow_proxy.configs import settings from mediaflow_proxy.drm.decrypter import decrypt_segment +from mediaflow_proxy.utils.crypto_utils import encryption_handler from mediaflow_proxy.utils.http_utils import encode_mediaflow_proxy_url, get_original_scheme, ProxyRequestHeaders logger = logging.getLogger(__name__) @@ -107,6 +108,7 @@ def build_hls(mpd_dict: dict, request: Request, key_id: str = None, key: str = N """ hls = ["#EXTM3U", "#EXT-X-VERSION:6"] query_params = dict(request.query_params) + has_encrypted = query_params.pop("has_encrypted", False) video_profiles = {} audio_profiles = {} @@ -120,6 +122,7 @@ def build_hls(mpd_dict: dict, request: Request, key_id: str = None, key: str = N playlist_url = encode_mediaflow_proxy_url( proxy_url, query_params=query_params, + encryption_handler=encryption_handler if has_encrypted else None, ) if "video" in profile["mimeType"]: @@ -193,6 +196,7 @@ def build_hls_playlist(mpd_dict: dict, profiles: list[dict], request: Request) - query_params = dict(request.query_params) query_params.pop("profile_id", None) query_params.pop("d", None) + has_encrypted = query_params.pop("has_encrypted", False) for segment in segments: if mpd_dict["isLive"]: @@ -207,6 +211,7 @@ def build_hls_playlist(mpd_dict: dict, profiles: list[dict], request: Request) - encode_mediaflow_proxy_url( proxy_url, query_params=query_params, + encryption_handler=encryption_handler if has_encrypted else None, ) ) added_segments += 1 diff --git a/mediaflow_proxy/schemas.py b/mediaflow_proxy/schemas.py new file mode 100644 index 0000000..5f6eaad --- /dev/null +++ b/mediaflow_proxy/schemas.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel, Field, IPvAnyAddress + + +class GenerateUrlRequest(BaseModel): + mediaflow_proxy_url: str = Field(..., description="The base URL for the mediaflow proxy.") + endpoint: str | None = Field(None, description="The specific endpoint to be appended to the base URL.") + destination_url: str | None = Field(None, description="The destination URL to which the request will be proxied.") + query_params: dict | None = Field(None, description="Query parameters to be included in the request.") + request_headers: dict | None = Field(None, description="Headers to be included in the request.") + response_headers: dict | None = Field(None, description="Headers to be included in the response.") + expiration: int | None = Field( + None, description="Expiration time for the URL in seconds. If not provided, the URL will not expire." + ) + api_password: str | None = Field( + None, description="API password for encryption. If not provided, the URL will only be encoded." + ) + ip: IPvAnyAddress | None = Field(None, description="The IP address to restrict the URL to.") diff --git a/mediaflow_proxy/utils/crypto_utils.py b/mediaflow_proxy/utils/crypto_utils.py new file mode 100644 index 0000000..8b93568 --- /dev/null +++ b/mediaflow_proxy/utils/crypto_utils.py @@ -0,0 +1,99 @@ +import base64 +import json +import time +from urllib.parse import urlencode + +from Crypto.Cipher import AES +from Crypto.Random import get_random_bytes +from Crypto.Util.Padding import pad, unpad +from fastapi import HTTPException, Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse + +from mediaflow_proxy.configs import settings + + +class EncryptionHandler: + def __init__(self, secret_key: str): + self.secret_key = secret_key.encode("utf-8").ljust(32)[:32] + + def encrypt_data(self, data: dict, expiration: int = None, ip: str = None) -> str: + if expiration: + data["exp"] = int(time.time()) + expiration + if ip: + data["ip"] = ip + json_data = json.dumps(data).encode("utf-8") + iv = get_random_bytes(16) + cipher = AES.new(self.secret_key, AES.MODE_CBC, iv) + encrypted_data = cipher.encrypt(pad(json_data, AES.block_size)) + return base64.urlsafe_b64encode(iv + encrypted_data).decode("utf-8") + + def decrypt_data(self, token: str, client_ip: str) -> dict: + try: + encrypted_data = base64.urlsafe_b64decode(token.encode("utf-8")) + iv = encrypted_data[:16] + cipher = AES.new(self.secret_key, AES.MODE_CBC, iv) + decrypted_data = unpad(cipher.decrypt(encrypted_data[16:]), AES.block_size) + data = json.loads(decrypted_data) + + if "exp" in data: + if data["exp"] < time.time(): + raise HTTPException(status_code=401, detail="Token has expired") + del data["exp"] # Remove expiration from the data + + if "ip" in data: + if data["ip"] != client_ip: + raise HTTPException(status_code=403, detail="IP address mismatch") + del data["ip"] # Remove IP from the data + + return data + except Exception as e: + raise HTTPException(status_code=401, detail="Invalid or expired token") + + +class EncryptionMiddleware(BaseHTTPMiddleware): + def __init__(self, app): + super().__init__(app) + self.encryption_handler = encryption_handler + + async def dispatch(self, request: Request, call_next): + encrypted_token = request.query_params.get("token") + if encrypted_token: + try: + client_ip = self.get_client_ip(request) + decrypted_data = self.encryption_handler.decrypt_data(encrypted_token, client_ip) + # Modify request query parameters with decrypted data + query_params = dict(request.query_params) + query_params.pop("token") # Remove the encrypted token from query params + query_params.update(decrypted_data) # Add decrypted data to query params + query_params["has_encrypted"] = True + + # Create a new request scope with updated query parameters + new_query_string = urlencode(query_params) + request.scope["query_string"] = new_query_string.encode() + request._query_params = query_params + except HTTPException as e: + return JSONResponse(content={"error": str(e.detail)}, status_code=e.status_code) + + response = await call_next(request) + return response + + @staticmethod + def get_client_ip(request: Request) -> str | None: + """ + Extract the client's real IP address from the request headers or fallback to the client host. + """ + x_forwarded_for = request.headers.get("X-Forwarded-For") + if x_forwarded_for: + # In some cases, this header can contain multiple IPs + # separated by commas. + # The first one is the original client's IP. + return x_forwarded_for.split(",")[0].strip() + # Fallback to X-Real-IP if X-Forwarded-For is not available + x_real_ip = request.headers.get("X-Real-IP") + if x_real_ip: + return x_real_ip + return request.client.host if request.client else "127.0.0.1" + + +encryption_handler = EncryptionHandler(settings.api_password) diff --git a/mediaflow_proxy/utils/http_utils.py b/mediaflow_proxy/utils/http_utils.py index 3d4119b..1e51f7f 100644 --- a/mediaflow_proxy/utils/http_utils.py +++ b/mediaflow_proxy/utils/http_utils.py @@ -3,6 +3,7 @@ import typing from dataclasses import dataclass from functools import partial from urllib import parse +from urllib.parse import urlencode import anyio import httpx @@ -16,6 +17,7 @@ from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_excep from mediaflow_proxy.configs import settings from mediaflow_proxy.const import SUPPORTED_REQUEST_HEADERS +from mediaflow_proxy.utils.crypto_utils import EncryptionHandler logger = logging.getLogger(__name__) @@ -215,9 +217,12 @@ def encode_mediaflow_proxy_url( query_params: dict | None = None, request_headers: dict | None = None, response_headers: dict | None = None, + encryption_handler: EncryptionHandler = None, + expiration: int = None, + ip: str = None, ) -> str: """ - Encodes a MediaFlow proxy URL with query parameters and headers. + Encodes & Encrypt (Optional) a MediaFlow proxy URL with query parameters and headers. Args: mediaflow_proxy_url (str): The base MediaFlow proxy URL. @@ -226,6 +231,9 @@ def encode_mediaflow_proxy_url( query_params (dict, optional): Additional query parameters to include. Defaults to None. request_headers (dict, optional): Headers to include as query parameters. Defaults to None. response_headers (dict, optional): Headers to include as query parameters. Defaults to None. + encryption_handler (EncryptionHandler, optional): The encryption handler to use. Defaults to None. + expiration (int, optional): The expiration time for the encrypted token. Defaults to None. + ip (str, optional): The public IP address to include in the query parameters. Defaults to None. Returns: str: The encoded MediaFlow proxy URL. @@ -243,8 +251,12 @@ def encode_mediaflow_proxy_url( query_params.update( {key if key.startswith("r_") else f"r_{key}": value for key, value in response_headers.items()} ) - # Encode the query parameters - encoded_params = parse.urlencode(query_params, quote_via=parse.quote) + + if encryption_handler: + encrypted_token = encryption_handler.encrypt_data(query_params, expiration, ip) + encoded_params = urlencode({"token": encrypted_token}) + else: + encoded_params = urlencode(query_params) # Construct the full URL if endpoint is None: diff --git a/mediaflow_proxy/utils/m3u8_processor.py b/mediaflow_proxy/utils/m3u8_processor.py index 5738d34..9817681 100644 --- a/mediaflow_proxy/utils/m3u8_processor.py +++ b/mediaflow_proxy/utils/m3u8_processor.py @@ -3,6 +3,7 @@ from urllib import parse from pydantic import HttpUrl +from mediaflow_proxy.utils.crypto_utils import encryption_handler from mediaflow_proxy.utils.http_utils import encode_mediaflow_proxy_url, get_original_scheme @@ -74,10 +75,13 @@ class M3U8Processor: str: The proxied URL. """ full_url = parse.urljoin(base_url, url) + query_params = dict(self.request.query_params) + has_encrypted = query_params.pop("has_encrypted", False) return encode_mediaflow_proxy_url( self.mediaflow_proxy_url, "", full_url, query_params=dict(self.request.query_params), + encryption_handler=encryption_handler if has_encrypted else None, )