From b3b67b0c96b5780dc72b1b4b0b27e305a88e9a59 Mon Sep 17 00:00:00 2001 From: Andy Date: Thu, 19 Mar 2026 12:38:33 -0600 Subject: [PATCH] feat(session): add IP validation for session access and enhance session management --- unshackle/core/api/handlers.py | 90 +++++++++-------------------- unshackle/core/api/session_store.py | 3 +- unshackle/unshackle-example.yaml | 50 ++++++++++++++++ 3 files changed, 80 insertions(+), 63 deletions(-) diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index d64cded..626b954 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -1383,6 +1383,7 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req service_instance, session_id=session_id, ) + session.creator_ip = request.remote if request else None session.cache_tag = session_cache_tag return web.json_response( @@ -1411,15 +1412,7 @@ async def session_titles_handler(session_id: str, request: Optional[web.Request] interactive auth flows (OTP, captcha) can complete before titles are fetched. """ - from unshackle.core.api.session_store import get_session_store - - store = get_session_store() - session = await store.get(session_id) - if not session: - raise APIError( - APIErrorCode.SESSION_NOT_FOUND, - f"Session not found: {session_id}", - ) + session = await _get_validated_session(session_id, request) try: service_instance = session.service_instance @@ -1464,15 +1457,7 @@ async def session_tracks_handler( This keeps auth separate from track fetching, allowing interactive auth flows (OTP, captcha) before any tracks are requested. """ - from unshackle.core.api.session_store import get_session_store - - store = get_session_store() - session = await store.get(session_id) - if not session: - raise APIError( - APIErrorCode.SESSION_NOT_FOUND, - f"Session not found: {session_id}", - ) + session = await _get_validated_session(session_id, request) title_id = data.get("title_id") if not title_id: @@ -1586,16 +1571,7 @@ async def session_segments_handler( Returns segment URLs, init data, DRM info, and any headers/cookies needed for CDN download. """ - from unshackle.core.api.session_store import get_session_store - - store = get_session_store() - session = await store.get(session_id) - if not session: - raise APIError( - APIErrorCode.SESSION_NOT_FOUND, - f"Session not found or expired: {session_id}", - details={"session_id": session_id}, - ) + session = await _get_validated_session(session_id, request) track_ids = data.get("track_ids", []) if not track_ids: @@ -1685,6 +1661,26 @@ async def session_segments_handler( ) +async def _get_validated_session(session_id: str, request: Optional[web.Request]) -> Any: + """Fetch a session and verify the requesting IP matches the creator.""" + from unshackle.core.api.session_store import get_session_store + + store = get_session_store() + session = await store.get(session_id) + if not session: + raise APIError( + APIErrorCode.SESSION_NOT_FOUND, + f"Session not found or expired: {session_id}", + details={"session_id": session_id}, + ) + if session.creator_ip and request and request.remote != session.creator_ip: + raise APIError( + APIErrorCode.FORBIDDEN, + "Session access denied", + ) + return session + + def _resolve_handler_proxy( data: Dict[str, Any], normalized_service: str ) -> tuple[Optional[str], list]: @@ -1902,16 +1898,7 @@ async def session_license_handler( """ import base64 - from unshackle.core.api.session_store import get_session_store - - store = get_session_store() - session = await store.get(session_id) - if not session: - raise APIError( - APIErrorCode.SESSION_NOT_FOUND, - f"Session not found or expired: {session_id}", - details={"session_id": session_id}, - ) + session = await _get_validated_session(session_id, request) track_id = data.get("track_id") track_ids = data.get("track_ids") @@ -2037,31 +2024,16 @@ async def session_license_handler( async def session_info_handler(session_id: str, request: Optional[web.Request] = None) -> web.Response: """Check session validity and get session info.""" - from datetime import timezone + session = await _get_validated_session(session_id, request) from unshackle.core.api.session_store import get_session_store - store = get_session_store() - session = await store.get(session_id) - if not session: - raise APIError( - APIErrorCode.SESSION_NOT_FOUND, - f"Session not found or expired: {session_id}", - details={"session_id": session_id}, - ) - - from datetime import datetime - - now = datetime.now(timezone.utc) - elapsed = (now - session.last_accessed).total_seconds() - expires_in = max(0, store._ttl - int(elapsed)) - return web.json_response( { "session_id": session.session_id, "service": session.service_tag, "valid": True, - "expires_in": expires_in, + "expires_in": get_session_store()._ttl, "track_count": len(session.tracks), "title_count": len(session.title_map), } @@ -2075,14 +2047,8 @@ async def session_delete_handler(session_id: str, request: Optional[web.Request] from unshackle.core.api.session_store import get_session_store from unshackle.core.config import config as app_config + session = await _get_validated_session(session_id, request) store = get_session_store() - session = await store.get(session_id) - if not session: - raise APIError( - APIErrorCode.SESSION_NOT_FOUND, - f"Session not found: {session_id}", - details={"session_id": session_id}, - ) cache_tag = session.cache_tag await store.delete(session_id) diff --git a/unshackle/core/api/session_store.py b/unshackle/core/api/session_store.py index fc5412f..1c1981e 100644 --- a/unshackle/core/api/session_store.py +++ b/unshackle/core/api/session_store.py @@ -31,6 +31,7 @@ class SessionEntry: tracks: Dict[str, Track] = field(default_factory=dict) # track_id -> Track object tracks_by_title: Dict[str, Dict[str, Track]] = field(default_factory=dict) # title_key -> {track_id -> Track} chapters_by_title: Dict[str, List[Any]] = field(default_factory=dict) # title_key -> [Chapter] + creator_ip: Optional[str] = None cache_tag: Optional[str] = None # per-session cache directory tag created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) last_accessed: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) @@ -51,7 +52,7 @@ class SessionStore: @property def _ttl(self) -> int: """Session TTL in seconds from config.""" - return config.serve.get("session_ttl", 900) # 15 min default + return config.serve.get("session_ttl", 300) # 5 min default @property def _max_sessions(self) -> int: diff --git a/unshackle/unshackle-example.yaml b/unshackle/unshackle-example.yaml index ea428d4..e33aba4 100644 --- a/unshackle/unshackle-example.yaml +++ b/unshackle/unshackle-example.yaml @@ -442,8 +442,22 @@ subtitle: sidecar_format: srt # Configuration for pywidevine and pyplayready's serve functionality +# Also used for remote services (unshackle serve) serve: api_secret: "your-secret-key-here" + + # Compression level for API payloads (manifests, cache, cookies) + # 0=off, 1=fast, 6=balanced, 9=max compression (default: 1) + compression_level: 1 + + # Session inactivity timeout in seconds (default: 300 = 5 minutes) + # Sessions are automatically deleted after this many seconds of inactivity + # Each API request resets the timer + session_ttl: 300 + + # Maximum concurrent sessions before oldest is evicted (default: 100) + max_sessions: 100 + users: secret_key_for_user: devices: # Widevine devices (WVDs) this user can access @@ -456,6 +470,42 @@ serve: # playready_devices: # PlayReady device paths (auto-populated from directories.prds) # - '/path/to/device.prd' +# Remote Services Configuration +# Connect to a remote unshackle server (unshackle serve) to use its services +# without needing the service code locally. Use with: unshackle dl --remote +# If multiple servers are configured, specify which with: --server +remote_services: + # Server name (used with --server flag if multiple configured) + my-server: + url: "http://192.168.1.100:8786" + api_key: "your-secret-key-here" + + # Server-CDM mode: server handles all DRM licensing using its own CDM devices + # When false (default), client uses its own CDM and proxies license requests through the server + server_cdm: false + + # Per-service overrides for remote services + # Override downloader, decryption tool, or CDM settings per service on the client + services: + # Example: Use n_m3u8dl_re for HLS services, mp4decrypt for specific services + # EXAMPLE_SERVICE: + # downloader: n_m3u8dl_re # Override client downloader (requests, aria2c, n_m3u8dl_re, curl_impersonate) + # decryption: mp4decrypt # Override decryption tool (shaka, mp4decrypt) + + # Example: Multiple servers + # us-server: + # url: "https://us.example.com:8786" + # api_key: "us-api-key" + # server_cdm: true + # services: + # EXAMPLE: + # downloader: n_m3u8dl_re + # decryption: mp4decrypt + # eu-server: + # url: "https://eu.example.com:8786" + # api_key: "eu-api-key" + # server_cdm: false + # Configuration data for each Service services: # Service-specific configuration goes here