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,
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)

View File

@@ -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:

View File

@@ -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 <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
services:
# Service-specific configuration goes here