fix(dl): reject local proxies when --remote targets another machine

Gluetun and loopback proxy URIs only listen on this machine. Stop with an error instead of forwarding them, unless the server is on this machine too.
This commit is contained in:
imSp4rky
2026-09-05 10:50:35 -06:00
parent bae71c0194
commit b0efa6c8d8
4 changed files with 45 additions and 0 deletions
+7
View File
@@ -173,6 +173,13 @@ This means you get access to the **50+ VPN providers** that Gluetun supports, us
VPN subscription, through a single uniform interface, with no per-provider integration
in unshackle itself.
!!! warning "Not available with `--remote`"
Gluetun listens on your machine, so a server on another machine cannot reach it. With
`--remote`, unshackle stops with an error for `--proxy gluetun:...`, and for any proxy that
resolves to `localhost` or a loopback address, such as a region query that Gluetun answers.
Give `--remote` a proxy the server can reach, or run the service locally. A server on this
machine (a `remote_services` URL on `127.0.0.1`) can reach a local proxy, so it is exempt.
!!! warning "Docker is required"
Gluetun needs a working [Docker](https://docs.docker.com/get-docker/) installation on
your `PATH`. If Docker is not found, the proxy provider fails to load with an error linking
+19
View File
@@ -53,6 +53,7 @@ from unshackle.core.events import events
from unshackle.core.providers.anilist import parse_anilist_ref
from unshackle.core.providers.tvdb import SEASON_TYPES, parse_int
from unshackle.core.proxies import Basic, ExpressVPN, Gluetun, Hola, NordVPN, ProtonVPN, SurfsharkVPN, WindscribeVPN
from unshackle.core.proxies.resolve import is_loopback
from unshackle.core.service import Service, grow_session_pool
from unshackle.core.services import Services
from unshackle.core.temp import with_task_temp
@@ -203,6 +204,16 @@ def title_wanted(candidate: Any, wanted: Collection[str]) -> bool:
return bool(candidate.matches_wanted(wanted))
def server_url(server_name: Optional[str]) -> str:
"""The configured URL of a remote server, or an empty string when the config cannot be read."""
from unshackle.core.remote_service import resolve_server
try:
return resolve_server(server_name)[0]
except Exception:
return ""
def post_script_group(candidate: Any) -> Any:
"""Group a title by the folder its outputs share, for post-scripts in season mode."""
if isinstance(candidate, Episode):
@@ -1312,10 +1323,15 @@ class dl:
if cdm_info:
log_event("load_cdm", level="INFO", service=self.service, context={"cdm": cdm_info})
# A server on this machine can reach a local proxy, so only a server elsewhere is guarded
self.remote_needs_public_proxy = self.is_remote and not is_loopback(server_url(ctx.params.get("server")))
self.proxy_providers = []
if no_proxy:
ctx.params["proxy"] = None
else:
if self.remote_needs_public_proxy and proxy and proxy.lower().startswith("gluetun:"):
raise click.UsageError("Gluetun runs on your machine, so --remote cannot use it.")
if proxy_providers is not None:
self.proxy_providers = list(proxy_providers)
else:
@@ -1434,6 +1450,9 @@ class dl:
ctx.params["proxy_query"] = None
ctx.params["proxy_provider"] = None
if self.remote_needs_public_proxy and ctx.params.get("proxy") and is_loopback(ctx.params["proxy"]):
raise click.UsageError("That proxy is on your machine, so --remote cannot use it.")
ctx.obj = ContextData(
config=self.service_config, cdm=self.cdm, proxy_providers=self.proxy_providers, profile=self.profile
)
+14
View File
@@ -5,9 +5,11 @@ Used by both the REST API handlers and the remote service client.
from __future__ import annotations
import ipaddress
import logging
import re
from typing import Any, List, Optional
from urllib.parse import urlparse
from unshackle.core.utils.redact import mask_proxy
@@ -111,3 +113,15 @@ def resolve_proxy(proxy: str, proxy_providers: List[Any]) -> Optional[str]:
return proxy_uri
raise ValueError(f"No proxy provider had a proxy for {proxy}")
def is_loopback(uri: str) -> bool:
"""Whether a proxy or server URI points at this machine."""
parsed = urlparse(uri if "//" in uri else f"//{uri}")
host = parsed.hostname or ""
if host == "localhost" or host.endswith(".localhost"):
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
+5
View File
@@ -31,6 +31,7 @@ from unshackle.core.config import config
from unshackle.core.console import console, prompt_user
from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential
from unshackle.core.proxies.resolve import is_loopback
from unshackle.core.titles import Title_T, Titles_T, remap_titles
from unshackle.core.titles.episode import Episode, Series
from unshackle.core.titles.movie import Movie, Movies
@@ -866,6 +867,10 @@ class RemoteService:
country = re.match(r"^(?:[a-z]+:)?([a-z]{2})\d*$", query, re.IGNORECASE)
if country:
create_data["proxy_region"] = country.group(1).lower()
if create_data.get("proxy") and is_loopback(create_data["proxy"]) and not is_loopback(self.client.server_url):
# the local CLI resolves the proxy, so a local-only URI is useless to a server elsewhere
raise click.ClickException("That proxy is on your machine, so --remote cannot use it.")
if not no_proxy and not proxy and client_region:
create_data["client_region"] = client_region
elif not no_proxy and not proxy and self._server_accounts and not self._server_accounts.get("global"):