From bf4cd556d17df0c79cf6ca0b505a3544dde815c5 Mon Sep 17 00:00:00 2001 From: Mathieu Broillet Date: Mon, 25 Aug 2025 18:24:54 +0200 Subject: [PATCH] refactored --- README.md | 5 +- const.py | 32 ++++- favicon.ico | Bin 0 -> 1451 bytes client.py => http_client_static_dns.py | 0 main_app.py | 189 +++---------------------- utils.py | 100 +++++++++++++ 6 files changed, 154 insertions(+), 172 deletions(-) create mode 100644 favicon.ico rename client.py => http_client_static_dns.py (100%) create mode 100644 utils.py diff --git a/README.md b/README.md index 7de66d9..ba75637 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,9 @@ subjectAltName = @alt_names [alt_names] DNS.1 = plex.tv -DNS.2 = clients.plex.tv -DNS.3 = app.plex.tv +DNS.2 = *.plex.tv +DNS.3 = *.provider.plex.tv + EOL # Create a certificate signing request (CSR) using the SAN config diff --git a/const.py b/const.py index 02c7513..ed09b39 100644 --- a/const.py +++ b/const.py @@ -1,7 +1,35 @@ -from datetime import datetime, timezone, timedelta import xml.etree.ElementTree as ET +from datetime import datetime, timezone, timedelta +from enum import Enum -OFFICIAL_API = "https://clients.plex.tv" + +class Hosts(str, Enum): + PLEX = "plex.tv" + CLIENTS = "clients.plex.tv" + APP = "app.plex.tv" + COMMUNITY = "community.plex.tv" + TOGETHER = "together.plex.tv" + FEATURES = "features.plex.tv" + EPGPROVIDER = "epg.provider.plex.tv" + DISCOVERPROVIDER = "discover.provider.plex.tv" + VODPROVIDER = "vod.provider.plex.tv" + METADATAPROVIDER = "metadata.provider.plex.tv" + ANALYTICS = "analytics.plex.tv" + + +# Hop-by-hop headers we must strip +HOP_BY_HOP_HEADERS = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "content-length", # let httpx calculate + "host", # must be rewritten +} FEATURES_DICT = [{"id": "guided-upgrade", "uuid": "c9d9b7ee-fdd9-474e-b143-5039c04e9b9b"}, {"id": "increase-password-complexity", "uuid": "9e93f8a8-7ccd-4d15-99fa-76a158027660"}, diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..175b06a1bf8d8ff4e0fe3657d87c4db51ea9028e GIT binary patch literal 1451 zcmcgs`BTyf6#sq=P*j5Q9B34;yf+P%R0<( z8fB|%mRF2|wq=s%u83GFsi9zGX=9#~E&s#L?7TOh_xa3xX5QzAHxB?nbwLoIMm%s6 z4giJ#02BrK|BTYcYO5*A&zBago@xgGX(813@=$C)0KlXCXq1qws>Q0st9s%3m%m%T znqri_Irb|tnL8yhFbKDc*u{8iX8^CK+iv>xw1+atE&4oO4gLaV*FW!yTMTmJzICaV ztGw`oV}mlr-Zhzf7kapzF+Z78oiki1m%Vjyci-4|*vp!GPE#FCy+`cI_cFqe<% zO?UM>J5-Q`?>3Hf&{xLRQcT+tYeVN#>%DV0-Rvj~KJE@_sXvI8+?K=((9!upVMNRr z#1@j{JpHvw!L8dJBvtqpLRhWYiQN-uN%;&{&a*3LRH$5XMZIJwGqD#9`rIcY*{FN7~zq*+#f2LXY0vNbd9Q3B)1&LCNx2z*Fl@V(Xj_cS;^0^ zRZl|dT%BVD0?hr(aFf!l#l3>zl%G9&C^B{fRSmB53ntw|j`K!IBMz6IX6tvvecq~Ai;LUk&U!c=kU;tX8 z@70AU*D@wHy~bZ>Bsyh)jJO`xq^9BDGUF{;UkoxOeGki@BGvh|6arG%dc0YlMV`Lm zI55bU=`UC;)Tc@imD*4gnG|rs(F0ppDMeHw)zahZ*<9!1{Zr{>MLK zIYzphC|+j8A0Mzj${TV{omi$o*Gk0)(I3yh3))#17=cT3a;E5XVkhUuPfg~*z(N}9 zdv$X&N!5>IKfE=Zs|OpBATTk$7d}L+qLy(T39TT^iFWVqaN&$7F_q^A0k{WTRp#=3 zlLt+%9Vc-jS1~D77Yu&wBITM(ZzS{|JVXW7V8(80x%Pax9)zXPAfu>1^Q)ln6#YF_;KFw$Z(IFtt&?{r8YBti;v#B?l$su%N83HjC&5+FwmBx5A1sW zshLCLqH<~@tdy?wimEBz!HMBsaSyT4Z9uWipgnr!AL!Y6Yuh%uf1o;zyxb!DC_&Qb zGWl+m1R>LQ`ut%id-}(9agHriVq_w}v~9%9vg+nNwv}(*P?|J%y|%wd)EH>yi@q(G q httpx.Response: - """Forward the incoming request to the official API and return the response.""" - print(f"Processing request to {str(request.url)}") - - # Copy request body - body = await request.body() - - # Copy headers, filtering hop-by-hop - req_headers = { - k: v - for k, v in request.headers.items() - if k.lower() not in HOP_BY_HOP_HEADERS - } - - # Rewrite Host header to match upstream - req_headers["host"] = request.url.hostname - - # Forward request upstream - upstream_response = await official_client.request( - method=request.method, - url=f"{str(request.base_url).rstrip('/').replace("http://", "https://")}/{path}", - headers=req_headers, - content=body, - params=request.query_params, - cookies=request.cookies, - ) - - # Filter response headers hop-by-hop - resp_headers = { - k: v - for k, v in upstream_response.headers.items() - if k.lower() not in HOP_BY_HOP_HEADERS - } - upstream_response.headers = resp_headers - - return upstream_response - - -async def return_edited_response( - response: httpx.Response, - new_content: dict | bytes | None = None -) -> Response: - """ - Return possibly modified response. - - If new_content is a dict and the response is JSON, it will merge/override keys. - If new_content and response data are lists, new_content will replace the entire response data. - If new_content is bytes or str, it will replace the response body entirely. - """ - - content_type = response.headers.get("content-type", "") - response.headers.pop("content-encoding", None) # body is already decoded - - if content_type.startswith("application/json"): - data = response.json() - - if isinstance(new_content, dict): # allow overrides - data = {**data, **new_content} - if isinstance(data, list) and isinstance(new_content, list): - data = new_content # replace entire list - - return JSONResponse( - content=data, - status_code=response.status_code, - headers=response.headers - ) - else: - content = new_content if isinstance(new_content, (bytes, str)) else response.content - return Response( - content=content, - status_code=response.status_code, - headers=response.headers, - media_type=content_type - ) - - -@app.get("/api/v2/home") -async def fake_get_home(request: Request): +@app.get("/api/v2/home", tags=[Hosts.CLIENTS.value]) +async def fake_get_home(request: Request, _=host_required([Hosts.CLIENTS])): upstream_response = await call_official(request, request.url.path.lstrip("/")) data_override = { @@ -122,8 +22,8 @@ async def fake_get_home(request: Request): return await return_edited_response(upstream_response, data_override) -@app.get("/api/v2/features") -async def fake_get_features(request: Request): +@app.get("/api/v2/features", tags=[Hosts.CLIENTS.value]) +async def fake_get_features(request: Request, _=host_required([Hosts.CLIENTS])): upstream_response = await call_official(request, request.url.path.lstrip("/")) data_override = FEATURES_DICT @@ -131,8 +31,8 @@ async def fake_get_features(request: Request): return await return_edited_response(upstream_response, data_override) -@app.get("/api/v2/user") -async def fake_get_user(request: Request): +@app.get("/api/v2/user", tags=[Hosts.CLIENTS.value]) +async def fake_get_user(request: Request, _=host_required([Hosts.CLIENTS])): upstream_response = await call_official(request, request.url.path.lstrip("/")) data_override = None @@ -164,7 +64,7 @@ async def fake_get_user(request: Request): subscription.set("status", "Active") subscription.set("paymentService", "braintree") subscription.set("plan", "monthly") - subscription.set("paymentNotificationId", "3421431") + subscription.set("paymentNotificationId", "1234567") features = subscription.find("features") if features is not None: @@ -217,8 +117,9 @@ async def fake_get_user(request: Request): return await return_edited_response(upstream_response, data_override) -@app.post("/api/v2/users/signin") -async def fake_user_signin(request: Request): +@app.post("/api/v2/users/signin", tags=[Hosts.CLIENTS.value, Hosts.PLEX.value], name="Fake signin") +@app.get("/api/v2/user.json", tags=[Hosts.PLEX.value], name="Fake user.json", description="Used by Plexamp") +async def fake_user_json_or_signin(request: Request, _=host_required([Hosts.CLIENTS, Hosts.PLEX])): upstream_response = await call_official(request, request.url.path.lstrip("/")) data_override = None @@ -268,59 +169,8 @@ async def fake_user_signin(request: Request): 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, - "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.post("/api/claim/exchange") -async def fake_claim_exchange(request: Request): +@app.post("/api/claim/exchange", tags=[Hosts.PLEX.value]) +async def fake_claim_exchange(request: Request, _=host_required([Hosts.PLEX])): upstream_response = await call_official(request, request.url.path.lstrip("/")) data_override = None @@ -356,13 +206,16 @@ async def fake_claim_exchange(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.get("/favicon.ico") +async def favicon(): + return FileResponse("favicon.ico") @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]) async def catch_all(request: Request, path: str): + if path == "": + return {"status": "PROXY ONLINE"} + upstream_response = await call_official(request, path) return await return_edited_response(upstream_response) diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..1f31d12 --- /dev/null +++ b/utils.py @@ -0,0 +1,100 @@ +import httpx +from fastapi import Request, Depends, HTTPException +from fastapi.responses import Response +from starlette.responses import JSONResponse + +from const import Hosts, HOP_BY_HOP_HEADERS +from http_client_static_dns import AsyncCustomHost, NameSolver + +# Configure upstream client +official_client = httpx.AsyncClient( + verify=True, + timeout=httpx.Timeout(30.0, connect=10.0), + limits=httpx.Limits(max_keepalive_connections=50, max_connections=100), + transport=AsyncCustomHost(NameSolver()) +) + + +def host_required(allowed_hosts: list[Hosts]): + async def dependency(request: Request): + host = request.url.hostname + if host not in [h.value for h in allowed_hosts]: + raise HTTPException(status_code=403, detail=f"Host '{host}' not allowed") + + return Depends(dependency) + + +async def call_official(request: Request, path: str) -> httpx.Response: + """Forward the incoming request to the official API and return the response.""" + print(f"Processing request to {str(request.url)}") + + # Copy request body + body = await request.body() + + # Copy headers, filtering hop-by-hop + req_headers = { + k: v + for k, v in request.headers.items() + if k.lower() not in HOP_BY_HOP_HEADERS + } + + # Rewrite Host header to match upstream + req_headers["host"] = request.url.hostname + + # Forward request upstream + upstream_response = await official_client.request( + method=request.method, + url=f"{str(request.base_url).rstrip('/').replace("http://", "https://")}/{path}", + headers=req_headers, + content=body, + params=request.query_params, + cookies=request.cookies, + ) + + # Filter response headers hop-by-hop + resp_headers = { + k: v + for k, v in upstream_response.headers.items() + if k.lower() not in HOP_BY_HOP_HEADERS + } + upstream_response.headers = resp_headers + + return upstream_response + + +async def return_edited_response( + response: httpx.Response, + new_content: dict | bytes | None = None +) -> Response: + """ + Return possibly modified response. + + If new_content is a dict and the response is JSON, it will merge/override keys. + If new_content and response data are lists, new_content will replace the entire response data. + If new_content is bytes or str, it will replace the response body entirely. + """ + + content_type = response.headers.get("content-type", "") + response.headers.pop("content-encoding", None) # body is already decoded + + if content_type.startswith("application/json"): + data = response.json() + + if isinstance(new_content, dict): # allow overrides + data = {**data, **new_content} + if isinstance(data, list) and isinstance(new_content, list): + data = new_content # replace entire list + + return JSONResponse( + content=data, + status_code=response.status_code, + headers=response.headers + ) + else: + content = new_content if isinstance(new_content, (bytes, str)) else response.content + return Response( + content=content, + status_code=response.status_code, + headers=response.headers, + media_type=content_type + )