diff --git a/README.md b/README.md index 33f1d3d..d6f67b6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,127 @@ # Plex Premium Hack -WIP \ No newline at end of file + +This repository contains a "mock" proxy that sits in your network and tricks Plex into thinking you have a Plex Premium +subscription. + +### Requirements + +- A router that can redirect traffic (i.e. OPNsense, pfSense, DD-WRT...) +- (alternative) a DNS server that can redirect traffic *(some apps won't work due to DNS pinning)* +- A reverse proxy (i.e. Traefik, Nginx, Caddy...) + +## How to setup ? + +Due to the nature of this hack, you'll have to : + +- generate a new certificate authority (CA) for the proxy +- trust or patch the CA on clients and/or apps that will connect to your Plex server + +### 1. Generate a new Certificate Authority (CA) + +in writing... + +### 2. Setup reverse proxy + +In my case I'm using Traefik, so here is an example configuration : + +```yaml +tls: + certificates: + # use certificates generated in step 1 + - certFile: /etc/traefik/ssl/custom/plexfakeclients.crt + keyFile: /etc/traefik/ssl/custom/plexfakeclients.key + +http: + routers: + plex: + entryPoints: + - https + service: plex + rule: Host(`plex..com`) + # you may want to use TLS here too (don't use the custom CA cert generated in step 1) + plex_proxy: + entryPoints: + - https + service: plex_proxy + rule: Host(`clients.plex.tv`) || Host(`plex.tv`) + tls: { } + + services: + plex: + loadBalancer: + servers: + - url: http://:32400 + plex_proxy: + loadBalancer: + servers: + - url: http://:8000 +``` + +### 3. Redirect traffic + +For this to work we need to redirect the domain `clients.plex.tv` and `plex.tv` to our proxy. +This is easily done if you own a router that can do this but might be tricky if you don't. +> [!IMPORTANT] +> Mobile/desktop apps tends to use hardcoded DNS servers so if you don't have a router that can redirect traffic, you +> will not be able to use this hack. +> It might be possible to patch the app to use a custom DNS server but the apps are usually obfuscated and it's not easy +> to do so. + +#### OPNsense / pfSense + +First, find the IP address behind the plex domains. + +```bash +dig clients.plex.tv +short +# 172.64.151.205 +# 104.18.36.51 + +dig plex.tv +short +# 52.17.59.150 +# 52.49.56.127 +``` + +Then go into `Firewall` > `Aliases` and create two aliases: + +- `plex_ips` + - Type: Host(s) + - Content: +- `plex_do_not_proxy` + - Type: Host(s) + - Content: and + +Then go into `Firewall` > `NAT` > `Port Forward` and create a new rule: + +- Interface: `LAN` +- Protocol: `TCP` +- Source / Invert: [☑️] +- Source: *(select alias)* `plex_do_not_proxy` +- Source Port Range: `any` +- Destination: *(select alias)* `plex_ips` +- Destination Port Range: `443` +- Redirect Target IP: `` +- Redirect Target Port: `443` + +Finally go to `Firewall` > `NAT` > `Outbound` and create a new rule *(select Hybrid mode if needed)*: + +- Interface: `LAN` +- TCP/IP Version: `IPv4` +- Protocol: `any` +- Source address: `any` +- Destination address: +- Destination port : `443` +- Translation / target: `Interface address` + +##### Test the redirection + +Now if you try to go to `https://clients.plex.tv/api/hack` you should see a JSON response along the lines of : + +```json +{ + "status": "OK, Plex Pass features proxy enabled" +} +``` + +If you see the Plex "Oops, 404" page then something is wrong with your redirection or proxy. + +## Patch PlexAmp diff --git a/const.py b/const.py index 9930e24..2ba3550 100644 --- a/const.py +++ b/const.py @@ -194,6 +194,111 @@ FEATURES_DICT = [{"id": "guided-upgrade", "uuid": "c9d9b7ee-fdd9-474e-b143-5039c FEATURES = [feature["id"] for feature in FEATURES_DICT] +PLEXAMP_FEATURES = [ + "guided-upgrade", + "increase-password-complexity", + "upgrade-3ds2", + "ad-countdown-timer", + "adaptive_bitrate", + "amazon-loop-debug", + "Android - Dolby Vision", + "Android - PiP", + "avod-ad-analysis", + "avod-new-media", + "blacklist_get_signin", + "camera_upload", + "CU Sunset", + "client-radio-stations", + "cloudsync", + "cloudflare-turnstile-required", + "common-sense-media-ratings-premium", + "comments_and_replies_push_notifications", + "friend_request_push_notifications", + "community_access_plex_tv", + "community_friends_group_notifications", + "companions_sonos", + "content_filter", + "custom-home-removal", + "grandfather-sync", + "disable-facebook-auth", + "disable_home_user_friendships", + "disable_sharing_friendships", + "downloads-gating", + "drm_support", + "dvr", + "dvr-block-unsupported-countries", + "le_isrg_root_x1", + "epg-recent-channels", + "federated-auth", + "global-continue-watching", + "hwtranscode", + "hardware_transcoding", + "home", + "HRK_enable_EUR", + "imagga-v2", + "ios14-privacy-banner", + "item_clusters", + "iterable-notification-tokens", + "keep-payment-method", + "kevin-bacon", + "lets_encrypt", + "lightning-dvr-pivot", + "livetv", + "allow_dvr", + "live-tv-support-incomplete-segments", + "tuner-sharing", + "lyrics", + "metadata_search", + "vod_cloudflare", + "music_videos", + "new_plex_pass_prices", + "news-provider-sunset-modal", + "nominatim", + "pass", + "photos-favorites", + "photos-metadata-edition", + "photosV6-edit", + "photosV6-tv-albums", + "pms_health", + "premium-dashboard", + "premium_music_metadata", + "rate-limit-client-token", + "reactions_push_notifications_settings", + "shared_server_notification", + "shared_source_notification", + "redirect-subscription-to-account-page", + "scrobbling-service-plex-tv", + "album-types", + "collections", + "music-analysis", + "radio", + "session_bandwidth_restrictions", + "session_kick", + "exclude restrictions", + "signin_notification", + "signin_with_apple", + "skip-data-licensing-consent", + "sleep-timer", + "spring_serve_ad_provider", + "sync", + "trailers", + "transcoder_cache", + "boost-voices", + "TREBLE-show-features", + "silence-removal", + "sweet-fades", + "visualizers", + "volume-leveling", + "two-factor-authentication", + "unsupportedtuners", + "plexpass_from_billing_context", + "vod-schema", + "watch-together-invite", + "watchlist-rss", + "web_server_dashboard", + "webhooks" +] + ENTITLEMENTS = ["deprecated_google_iap_activation", "all", "roku", "android", "xbox_one", "xbox_360", "windows", "windows_phone", "ios"] @@ -202,3 +307,6 @@ CURRENT_DATE_MINUS_ONE_DAY_Z = (datetime.now(timezone.utc) - timedelta(days=1)). NEXT_MONTH_DATE_Z = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%dT%H:%M:%SZ") CURRENT_DATE_Z = (datetime.now(timezone.utc)).strftime("%Y-%m-%dT%H:%M:%SZ") CURRENT_DATE_PLUS_30MIN_Z = (datetime.now(timezone.utc) + timedelta(minutes=30)).strftime("%Y-%m-%dT%H:%M:%SZ") +TIMESTAMP_CURRENT = int(datetime.now().timestamp()) +TIMESTAMP_CURRENT_MINUS_30MIN = int((datetime.now() - timedelta(minutes=30)).timestamp()) +TIMESTAMP_CURRENT_PLUS_30DAYS = int((datetime.now() + timedelta(days=30)).timestamp()) \ No newline at end of file diff --git a/main_app.py b/main_app.py index 3585431..d73b357 100644 --- a/main_app.py +++ b/main_app.py @@ -7,7 +7,8 @@ from starlette.responses import JSONResponse from client import AsyncCustomHost, NameSolver from const import OFFICIAL_API, FEATURES_DICT, ENTITLEMENTS, CURRENT_DATE_MINUS_ONE_DAY, FEATURES, \ - CURRENT_DATE_MINUS_ONE_DAY_Z, NEXT_MONTH_DATE_Z + CURRENT_DATE_MINUS_ONE_DAY_Z, NEXT_MONTH_DATE_Z, PLEXAMP_FEATURES, TIMESTAMP_CURRENT_PLUS_30DAYS, \ + TIMESTAMP_CURRENT_MINUS_30MIN app = FastAPI() @@ -235,14 +236,65 @@ async def fake_user_signin(request: Request): "plan": "monthly", "paymentNotificationId": "1234567", "canUpgrade": True, - "features": FEATURES_DICT + "features": PLEXAMP_FEATURES if "plexamp" in request.headers.get( + "X-Plex-Product").lower() else FEATURES_DICT }, "subscriptions": [ { "id": 1234567, "mode": "monthly", - "startsAt": 1755963427, - "renewsAt": 1758585600, + "startsAt": TIMESTAMP_CURRENT_MINUS_30MIN, + "renewsAt": TIMESTAMP_CURRENT_PLUS_30DAYS, + "endsAt": None, + "billing": { + "paymentMethodId": 1234567, + "internalPaymentMethod": {} + }, + "canceled": False, + "gracePeriod": False, + "onHold": False, + "canReactivate": False, + "canUpgrade": False, + "canDowngrade": False, + "canConvert": False, + "type": "plexpass", + "braintreeId": "xyzxyz", + "state": "active" + } + ] + } + + return await return_edited_response(upstream_response, data_override) + +@app.get("/api/v2/user.json") +async def fake_get_user_json(request: Request): + upstream_response = await call_official(request, request.url.path.lstrip("/")) + + data_override = None + + content_type = upstream_response.headers.get("content-type", "") + if content_type.startswith("application/json"): + data_override = { + "subscriptionDescription": "Monthly Plex Pass", + "roles": ["plexpass"], + "entitlements": ENTITLEMENTS, + "subscription": { + "active": True, + "subscribedAt": CURRENT_DATE_MINUS_ONE_DAY_Z, + "status": "Active", + "paymentService": "braintree", + "plan": "monthly", + "paymentNotificationId": "1234567", + "canUpgrade": True, + "features": PLEXAMP_FEATURES if "plexamp" in request.headers.get( + "X-Plex-Product").lower() else FEATURES_DICT + }, + "subscriptions": [ + { + "id": 1234567, + "mode": "monthly", + "startsAt": TIMESTAMP_CURRENT_MINUS_30MIN, + "renewsAt": TIMESTAMP_CURRENT_PLUS_30DAYS, "endsAt": None, "billing": { "paymentMethodId": 1234567, @@ -265,6 +317,12 @@ async def fake_user_signin(request: Request): return await return_edited_response(upstream_response, data_override) + +@app.get("/api/hack") +async def hack_endpoint(): + return {"status": "OK, Plex Pass features proxy enabled"} + + @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]) async def catch_all(request: Request, path: str): upstream_response = await call_official(request, path)