mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-05-17 14:29:27 +00:00
feat(session): add IP validation for session access and enhance session management
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user