diff --git a/docs/guide/proxies-and-vpn.md b/docs/guide/proxies-and-vpn.md index 542cd2cf..ba42f418 100644 --- a/docs/guide/proxies-and-vpn.md +++ b/docs/guide/proxies-and-vpn.md @@ -288,7 +288,8 @@ run. Give `profile` and `resolver` only to use a profile that you made yourself [How unshackle chooses a profile](#how-unshackle-chooses-a-profile)). The returned proxy is HTTP on loopback, -`http://127.0.0.1:{port}`. The locations themselves are read from Control D at startup +`http://127.0.0.1:{port}`. The log shows the location and a masked resolver ID, for example +`Using ControlD Proxy (YUL - Montreal): abcd***@dns.controld.com`. The locations themselves are read from Control D at startup (107 of them across 68 countries, at the time of writing), so there is nothing to list: | Query | Meaning | diff --git a/tests/core/test_controld_proxy.py b/tests/core/test_controld_proxy.py index ff4a834d..56751e9d 100644 --- a/tests/core/test_controld_proxy.py +++ b/tests/core/test_controld_proxy.py @@ -267,12 +267,14 @@ def account(catalogue: None, monkeypatch: pytest.MonkeyPatch) -> Iterator[Accoun monkeypatch.setattr("unshackle.core.proxies.controld.requests.request", made.request) controld.FORWARDERS.clear() controld.REGIONS.clear() + controld.LOCATIONS.clear() yield made for server in controld.FORWARDERS.values(): server.shutdown() server.server_close() controld.FORWARDERS.clear() controld.REGIONS.clear() + controld.LOCATIONS.clear() def test_locations_come_from_control_d(account: Account) -> None: @@ -378,6 +380,13 @@ def test_a_server_runs_a_forwarder_for_a_resolver_a_client_sends(account: Accoun local_proxy("controld://evil.example/x@dns.controld.com") +def test_the_log_names_the_location_and_masks_the_resolver(account: Account) -> None: + provider = ControlD(token="secret", profile="abcd123", resolver="efgh456") + provider.get_proxy("yul") + provider.get_proxy("yul") # a profile already on the region keeps its location + assert provider.last_connection_display() == "(YUL - Montreal): efgh***@dns.controld.com" + + def test_remote_sends_the_resolver_and_a_bare_region_skips_control_d( account: Account, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -391,6 +400,15 @@ def test_remote_sends_the_resolver_and_a_bare_region_skips_control_d( remote_service.resolve_remote_proxy_arg("us") # a loopback forwarder is useless to a server +def test_a_server_gets_the_typed_proxy_only_when_this_machine_resolved_it_to_loopback() -> None: + from unshackle.core.remote_service import server_proxy_arg + + assert server_proxy_arg({"proxy": "http://127.0.0.1:4000", "proxy_arg": "controld:ca"}) == "controld:ca" + assert server_proxy_arg({"proxy": "https://u:p@ca1.example:89", "proxy_arg": "nordvpn:ca"}) == ( + "https://u:p@ca1.example:89" + ) + + @pytest.mark.parametrize("query", ["ca", "us:seattle", "us-ny", "uk1", "yul", "jfk", "res_yyz", "nordvpn:ca"]) def test_a_region_query_matches(query: str) -> None: assert re.fullmatch(rf"(?:[a-z]+:)?{REGION}", query, re.IGNORECASE) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index df31d2db..1037e9eb 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -1613,6 +1613,7 @@ class dl: with console.status("Loading Proxy Providers...", spinner="dots"): self.proxy_providers = load_proxy_providers() + ctx.params["proxy_arg"] = proxy if proxy and proxy.startswith("controld://"): proxy = ctx.params["proxy"] = resolve_proxy(proxy, []) if proxy: @@ -1647,9 +1648,6 @@ 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, diff --git a/unshackle/commands/search.py b/unshackle/commands/search.py index d475aaa1..58052a2e 100644 --- a/unshackle/commands/search.py +++ b/unshackle/commands/search.py @@ -88,6 +88,7 @@ def search( with console.status("Loading Proxy Providers...", spinner="dots"): proxy_providers = load_proxy_providers() + ctx.params["proxy_arg"] = proxy if proxy: try: requested_provider, query = split_proxy_query(proxy, proxy_providers) diff --git a/unshackle/core/proxies/controld.py b/unshackle/core/proxies/controld.py index b88d95d1..c699140e 100644 --- a/unshackle/core/proxies/controld.py +++ b/unshackle/core/proxies/controld.py @@ -16,6 +16,7 @@ import requests from requests.adapters import HTTPAdapter from unshackle.core.proxies.proxy import Proxy +from unshackle.core.utils.redact import REDACTED log = logging.getLogger("ControlD") @@ -261,6 +262,7 @@ class LocalProxy(socketserver.ThreadingTCPServer): # process-wide across provider instances; separate processes share only the account's unshackle- names FORWARDERS: dict[str, LocalProxy] = {} REGIONS: dict[str, str] = {} # profile -> region, oldest claim first +LOCATIONS: dict[str, str] = {} # profile -> the location code it redirects through STATE = threading.Lock() @@ -337,6 +339,7 @@ class ControlD(Proxy): self.configured = {x["profile"]: resolver_id(x["resolver"]) for x in pairs} self.max_profiles = max_profiles self.resolvers = dict(self.configured) + self.last_profile: Optional[str] = None self.catalogue = self.get_locations() @@ -459,6 +462,8 @@ class ControlD(Proxy): server.resolver.cache.clear() REGIONS.pop(profile, None) REGIONS[profile] = query + LOCATIONS[profile] = location + self.last_profile = profile return resolver def get_proxy(self, query: str) -> Optional[str]: @@ -467,6 +472,15 @@ class ControlD(Proxy): resolver = self.claim(query.lower()) return forwarder(resolver).uri if resolver else None + def last_connection_display(self) -> Optional[str]: + """Name the location of the last claim, with the resolver ID masked, in place of the loopback URI.""" + location = LOCATIONS.get(self.last_profile or "") + if not location: + return None + city = next((x.get("city") for x in self.catalogue if x["PK"] == location), None) + label = f"{location} - {city}" if city else location + return f"({label}): {self.resolvers[self.last_profile][:4]}{REDACTED}@dns.controld.com" + def get_remote_proxy(self, query: str) -> Optional[str]: """Get the `controld://` proxy URI for this region, for a server on another machine.""" with STATE: diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 823a3a0d..5c771110 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -920,6 +920,12 @@ def resolve_remote_proxy_arg(proxy_arg: str, providers: Optional[list] = None) - raise click.ClickException(str(e)) +def server_proxy_arg(params: Dict[str, Any]) -> str: + """The --proxy value to resolve for a server: the one typed when it resolved to a proxy on this machine.""" + proxy = params["proxy"] + return (params.get("proxy_arg") or proxy) if is_loopback(proxy) else proxy + + _CACHE_HEX_ID_RE = re.compile(r"[0-9a-f]{32,}", re.IGNORECASE) @@ -1159,7 +1165,8 @@ class RemoteService: if params.get("no_proxy"): data["no_proxy"] = True elif params.get("proxy"): - data["proxy"] = resolve_remote_proxy_arg(params["proxy"], getattr(self.ctx.obj, "proxy_providers", None)) + providers = getattr(self.ctx.obj, "proxy_providers", None) + data["proxy"] = resolve_remote_proxy_arg(server_proxy_arg(params), providers) self.require_reachable_proxy(data["proxy"]) response = self.client.post("/api/search", data) self.save_returned_cache(response.get("cache") or {}, profile) @@ -1226,7 +1233,7 @@ class RemoteService: "rejects the remote session" ) elif not no_proxy and proxy: - resolved_proxy = resolve_remote_proxy_arg(proxy, providers) + resolved_proxy = resolve_remote_proxy_arg(server_proxy_arg(self.ctx.parent.params), providers) if resolved_proxy: create_data["proxy"] = resolved_proxy query = (self.ctx.parent.params.get("proxy_query") if self.ctx.parent else None) or proxy