diff --git a/docs/guide/proxies-and-vpn.md b/docs/guide/proxies-and-vpn.md index 0935df88..3fca325b 100644 --- a/docs/guide/proxies-and-vpn.md +++ b/docs/guide/proxies-and-vpn.md @@ -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 diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 360539fd..c9d60020 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -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 ) diff --git a/unshackle/core/proxies/resolve.py b/unshackle/core/proxies/resolve.py index 5ebe3400..4b49e42c 100644 --- a/unshackle/core/proxies/resolve.py +++ b/unshackle/core/proxies/resolve.py @@ -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 diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 48b54089..bb06d0b7 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -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"):