feat(session): add IP validation for session access and enhance session management

This commit is contained in:
Andy
2026-03-19 12:38:33 -06:00
parent 9d21d8a246
commit b3b67b0c96
3 changed files with 80 additions and 63 deletions

View File

@@ -1383,6 +1383,7 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req
service_instance, service_instance,
session_id=session_id, session_id=session_id,
) )
session.creator_ip = request.remote if request else None
session.cache_tag = session_cache_tag session.cache_tag = session_cache_tag
return web.json_response( 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 interactive auth flows (OTP, captcha) can complete before titles
are fetched. are fetched.
""" """
from unshackle.core.api.session_store import get_session_store 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}",
)
try: try:
service_instance = session.service_instance service_instance = session.service_instance
@@ -1464,15 +1457,7 @@ async def session_tracks_handler(
This keeps auth separate from track fetching, allowing interactive This keeps auth separate from track fetching, allowing interactive
auth flows (OTP, captcha) before any tracks are requested. auth flows (OTP, captcha) before any tracks are requested.
""" """
from unshackle.core.api.session_store import get_session_store 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}",
)
title_id = data.get("title_id") title_id = data.get("title_id")
if not 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 Returns segment URLs, init data, DRM info, and any headers/cookies
needed for CDN download. needed for CDN download.
""" """
from unshackle.core.api.session_store import get_session_store 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 or expired: {session_id}",
details={"session_id": session_id},
)
track_ids = data.get("track_ids", []) track_ids = data.get("track_ids", [])
if not 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( def _resolve_handler_proxy(
data: Dict[str, Any], normalized_service: str data: Dict[str, Any], normalized_service: str
) -> tuple[Optional[str], list]: ) -> tuple[Optional[str], list]:
@@ -1902,16 +1898,7 @@ async def session_license_handler(
""" """
import base64 import base64
from unshackle.core.api.session_store import get_session_store 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 or expired: {session_id}",
details={"session_id": session_id},
)
track_id = data.get("track_id") track_id = data.get("track_id")
track_ids = data.get("track_ids") 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: async def session_info_handler(session_id: str, request: Optional[web.Request] = None) -> web.Response:
"""Check session validity and get session info.""" """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 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( return web.json_response(
{ {
"session_id": session.session_id, "session_id": session.session_id,
"service": session.service_tag, "service": session.service_tag,
"valid": True, "valid": True,
"expires_in": expires_in, "expires_in": get_session_store()._ttl,
"track_count": len(session.tracks), "track_count": len(session.tracks),
"title_count": len(session.title_map), "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.api.session_store import get_session_store
from unshackle.core.config import config as app_config from unshackle.core.config import config as app_config
session = await _get_validated_session(session_id, request)
store = 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}",
details={"session_id": session_id},
)
cache_tag = session.cache_tag cache_tag = session.cache_tag
await store.delete(session_id) await store.delete(session_id)

View File

@@ -31,6 +31,7 @@ class SessionEntry:
tracks: Dict[str, Track] = field(default_factory=dict) # track_id -> Track object 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} 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] 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 cache_tag: Optional[str] = None # per-session cache directory tag
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
last_accessed: 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 @property
def _ttl(self) -> int: def _ttl(self) -> int:
"""Session TTL in seconds from config.""" """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 @property
def _max_sessions(self) -> int: def _max_sessions(self) -> int:

View File

@@ -442,8 +442,22 @@ subtitle:
sidecar_format: srt sidecar_format: srt
# Configuration for pywidevine and pyplayready's serve functionality # Configuration for pywidevine and pyplayready's serve functionality
# Also used for remote services (unshackle serve)
serve: serve:
api_secret: "your-secret-key-here" 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: users:
secret_key_for_user: secret_key_for_user:
devices: # Widevine devices (WVDs) this user can access devices: # Widevine devices (WVDs) this user can access
@@ -456,6 +470,42 @@ serve:
# playready_devices: # PlayReady device paths (auto-populated from directories.prds) # playready_devices: # PlayReady device paths (auto-populated from directories.prds)
# - '/path/to/device.prd' # - '/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 <name>
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 # Configuration data for each Service
services: services:
# Service-specific configuration goes here # Service-specific configuration goes here