diff --git a/docs/guide/proxies-and-vpn.md b/docs/guide/proxies-and-vpn.md index 7d2ccb7d..72932980 100644 --- a/docs/guide/proxies-and-vpn.md +++ b/docs/guide/proxies-and-vpn.md @@ -193,6 +193,23 @@ the mapped cluster. A query that already names one server, such as `nordvpn:us12 does not accept an `@` part. An IP selector needs no whois lookup. On NordVPN it searches every server in the country, not only the recommended ones, so a pinned server still works when it is busy. +To keep a selector for every run, put it in `server_map`. A value that starts with `-` or `@` applies +to the region of its config key, and a value with `@` after a server name is a whole query. A selector in +`--proxy` wins over one in `server_map`. + +```yaml title="unshackle.yaml" +proxy_providers: + nordvpn: + server_map: + ca: -datacamp # --proxy nordvpn:ca means nordvpn:ca@-datacamp + windscribevpn: + server_map: + fr:paris: "@m247" # quote a value that starts with @ + surfsharkvpn: + server_map: + fr: fr-bod@195.86.24.103 # always this one server +``` + !!! note "Not every proxy provider" Only NordVPN, Surfshark and Windscribe read the `@` part. With a bare query such as `--proxy fr@datacamp`, the other proxy providers have no proxy for it, so prefix @@ -789,7 +806,8 @@ combined username and password must be exactly 48 alphanumeric characters (case- the username must not contain an `@`. If validation fails, you have almost certainly used your login credentials instead of your service credentials. -`server_map` is optional and maps a region (or `code:city`) to a pinned NordVPN server ID. +`server_map` is optional and maps a region (or `code:city`) to a pinned NordVPN server ID, or to a +selector (see [Choose a server network](#choose-a-server-network)). Its main use is to tune performance: for a given streaming service one specific server often performs noticeably better than NordVPN's recommended pick. So you can hard-code that server for a day or two as a workaround, until it degrades and you swap in another. @@ -930,7 +948,8 @@ proxy_providers: **Required keys:** `username`, `password`, validated with the same 48-character rule as NordVPN (combined username+password must be 48 alphanumeric characters, case-insensitive, no `@`). -`server_map` optionally pins a server per region. Give the server name from Surfshark's +`server_map` optionally pins a server per region, or holds a selector (see +[Choose a server network](#choose-a-server-network)). Give the server name from Surfshark's server list, for example `us-dal`. A full hostname such as `us-dal.prod.surfshark.com` also works. Surfshark does not use numeric server IDs. unshackle ignores a numeric or empty value and logs a warning. A `us:seattle` entry applies only to that city query, and it does not @@ -982,7 +1001,8 @@ proxy_providers: us:seattle: us-seattle.totallyacdn.com ``` -**Required keys:** `username`, `password`. `server_map` optionally maps a region (or +**Required keys:** `username`, `password`. `server_map` optionally takes a selector (see +[Choose a server network](#choose-a-server-network)), or maps a region (or `region:city`) directly to a hostname. **Query forms:** diff --git a/tests/core/test_proxy_network_selector.py b/tests/core/test_proxy_network_selector.py index 54fa5064..b1552f5c 100644 --- a/tests/core/test_proxy_network_selector.py +++ b/tests/core/test_proxy_network_selector.py @@ -57,6 +57,25 @@ def test_region_grammar_takes_the_selector_but_not_a_uri(value: str, is_query: b assert bool(re.fullmatch(rf"(?:[a-z]+:)?{REGION}", value, re.IGNORECASE)) is is_query +@pytest.mark.parametrize( + ("region", "value", "expected"), + [ + ("fr", "-datacamp", "fr@-datacamp"), + ("fr", "@DataCamp", "fr@datacamp"), + ("us:seattle", "-m247", "us:seattle@-m247"), + ("fr", "fr-bod@195.86.24.103", "fr-bod@195.86.24.103"), + ("fr", "fr-bod", None), + ("fr", "fr-028.totallyacdn.com", None), + ("fr", 1154, None), + ("fr", "@", None), + ("fr", "fr@", None), + ("fr", "-", None), + ], +) +def test_mapped_query(region: str, value: object, expected: str | None) -> None: + assert asn.mapped_query(region, value) == expected + + def test_remote_sends_a_surfshark_selector_unresolved() -> None: assert resolve_remote_proxy_arg("surfsharkvpn:fr-bod@datacamp", []) == "surfsharkvpn:fr-bod@datacamp" @@ -164,6 +183,13 @@ def test_nordvpn_network_selector_skips_the_server_map(nordvpn: NordVPN, query: assert nordvpn.last_hostname == f"{host}.proxy.nordvpn.com" +@pytest.mark.parametrize(("value", "host"), [("-datacamp", "ca2"), ("ca@10.0.0.9", "ca3")]) +def test_nordvpn_server_map_takes_a_selector(nordvpn: NordVPN, value: str, host: str) -> None: + nordvpn.server_map = {"ca": value} + uri = nordvpn.get_proxy("ca") + assert uri is not None and uri.endswith(f"@{host}.proxy.nordvpn.com:89") + + def test_nordvpn_selector_with_no_match_raises(nordvpn: NordVPN) -> None: with pytest.raises(ValueError, match="No NordVPN server in ca"): nordvpn.get_proxy("ca@m247") @@ -206,6 +232,24 @@ def test_windscribe_network_selector(windscribe: WindscribeVPN, query: str, host assert uri is not None and uri.rsplit("@", 1)[1].removesuffix(":443") in hosts +@pytest.mark.parametrize( + ("server_map", "query", "hosts"), + [ + ({"fr": "-datacamp"}, "fr", {"fr-001.example", "fr-002.example"}), + ({"fr": "-datacamp"}, "fr:paris", {"fr-001.example", "fr-002.example"}), + ({"fr:paris": "@-10.0.0.2"}, "fr:paris", {"fr-002.example"}), + ({"fr": "fr@10.0.0.1"}, "fr", {"fr-003.example"}), + ({"fr": "-datacamp"}, "fr@datacamp", {"fr-003.example"}), + ], +) +def test_windscribe_server_map_takes_a_selector( + windscribe: WindscribeVPN, server_map: dict, query: str, hosts: set[str] +) -> None: + windscribe.server_map = server_map + uri = windscribe.get_proxy(query) + assert uri is not None and uri.rsplit("@", 1)[1].removesuffix(":443") in hosts + + def test_windscribe_selector_with_no_match_raises(windscribe: WindscribeVPN) -> None: with pytest.raises(ValueError, match="No WindscribeVPN server in fr"): windscribe.get_proxy("fr:paris@datacamp") @@ -236,6 +280,22 @@ def test_surfshark_selector_pins_a_server_ip(surfshark: SurfsharkVPN, query: str assert surfshark.last_server is not None and surfshark.last_server[0] == "fr-bod.prod.surfshark.com" +@pytest.mark.parametrize( + ("value", "query", "ports"), + [ + ("-datacamp", "fr", {4002, 4003}), + ("fr-bod@10.0.0.1", "fr", {4001}), + ("-datacamp", "fr@datacamp", {4001}), + ], +) +def test_surfshark_server_map_takes_a_selector( + surfshark: SurfsharkVPN, value: str, query: str, ports: set[int] +) -> None: + surfshark.server_map = {"fr": value} + uri = surfshark.get_proxy(query) + assert uri is not None and int(uri.rsplit(":", 1)[1]) in ports + + def test_surfshark_without_selector_is_unchanged(surfshark: SurfsharkVPN) -> None: assert surfshark.get_proxy("fr") == f"https://{'a' * 24}:{'b' * 24}@fr-bod.prod.surfshark.com:443" assert surfshark.last_connection_display() is None diff --git a/unshackle/core/proxies/asn.py b/unshackle/core/proxies/asn.py index af09601f..b5d406a9 100644 --- a/unshackle/core/proxies/asn.py +++ b/unshackle/core/proxies/asn.py @@ -22,6 +22,19 @@ def split_selector(query: str) -> tuple[str, Optional[str]]: return query, selector or None +def mapped_query(region: str, value: object) -> Optional[str]: + """ + Get the query that a server_map value stands for, or None for a plain server value. + + A value that starts with "-" or "@" is a selector for the region of its config key ("fr: -datacamp" means + "fr@-datacamp"); a value with "@" after a server is a whole query ("fr: fr-bod@195.86.24.103"). + """ + value = str(value).strip().lower() + query = f"{region}@{value.removeprefix('@')}" if value.startswith(("-", "@")) else value + # an empty selector ("@", "fr@") would hit the same map entry again and recurse forever + return query if split_selector(query)[1] not in (None, "-") else None + + def lookup(ips: Iterable[str]) -> dict[str, str]: """ Get the network (AS) name of each IP from Team Cymru's bulk whois. An IP it does not know is left out. diff --git a/unshackle/core/proxies/nordvpn.py b/unshackle/core/proxies/nordvpn.py index d74c4186..8fedbf18 100644 --- a/unshackle/core/proxies/nordvpn.py +++ b/unshackle/core/proxies/nordvpn.py @@ -1,17 +1,17 @@ import json import random import re -from typing import Optional +from typing import Optional, Union import requests -from unshackle.core.proxies.asn import describe, is_ip, select, split_selector +from unshackle.core.proxies.asn import describe, is_ip, mapped_query, select, split_selector from unshackle.core.proxies.proxy import Proxy from unshackle.core.utilities import COUNTRY_CODE_ALIASES class NordVPN(Proxy): - def __init__(self, username: str, password: str, server_map: Optional[dict[str, int]] = None): + def __init__(self, username: str, password: str, server_map: Optional[dict[str, Union[int, str]]] = None): """ Proxy provider that uses NordVPN Service Credentials. @@ -89,6 +89,8 @@ class NordVPN(Proxy): ) if server_mapping and not selector: + if mapped := mapped_query(server_map_key, server_mapping): + return self.get_proxy(mapped) hostname = f"{country['code'].lower()}{server_mapping}.proxy.nordvpn.com" else: city_id = None diff --git a/unshackle/core/proxies/surfsharkvpn.py b/unshackle/core/proxies/surfsharkvpn.py index 6ba284be..735db466 100644 --- a/unshackle/core/proxies/surfsharkvpn.py +++ b/unshackle/core/proxies/surfsharkvpn.py @@ -12,7 +12,7 @@ from typing import Optional import certifi import requests -from unshackle.core.proxies.asn import describe, fold, is_ip, select, split_selector +from unshackle.core.proxies.asn import describe, fold, is_ip, mapped_query, select, split_selector from unshackle.core.proxies.controld import build_query, parse_a_records from unshackle.core.proxies.proxy import Proxy from unshackle.core.utilities import COUNTRY_CODE_ALIASES @@ -155,8 +155,12 @@ class SurfsharkVPN(Proxy): query = COUNTRY_CODE_ALIASES.get(query, query) server_map_key = f"{query}:{city}" if city else query - if server_map_key in self.server_map: - servers = [self.server_map[server_map_key]] + mapped = self.server_map.get(server_map_key) + as_query = mapped_query(server_map_key, mapped) if mapped else None + if as_query and not selector: + return self.get_proxy(as_query) + if mapped and not as_query: + servers = [mapped] elif not city and any(x["connectionName"].split(".", 1)[0] == query for x in self.countries): servers = [query] elif re.fullmatch(r"[a-z]{2}", query): diff --git a/unshackle/core/proxies/windscribevpn.py b/unshackle/core/proxies/windscribevpn.py index ca023378..7329538b 100644 --- a/unshackle/core/proxies/windscribevpn.py +++ b/unshackle/core/proxies/windscribevpn.py @@ -5,7 +5,7 @@ from typing import Iterator, Optional import requests -from unshackle.core.proxies.asn import describe, resolve_all, select, split_selector +from unshackle.core.proxies.asn import describe, mapped_query, resolve_all, select, split_selector from unshackle.core.proxies.proxy import Proxy @@ -67,10 +67,10 @@ class WindscribeVPN(Proxy): if not re.fullmatch(r"[a-z]+", query): raise ValueError(f"'{query}' is already one WindscribeVPN server, so it takes no '@' network selector.") hostname: Optional[str] = self.get_random_server(query, city, selector) - elif server_map_key in self.server_map: - hostname = self.server_map[server_map_key] - elif query in self.server_map: - hostname = self.server_map[query] + elif mapping := self.server_map.get(server_map_key) or self.server_map.get(query): + if mapped := mapped_query(server_map_key, mapping): + return self.get_proxy(mapped) + hostname = mapping else: server_match = re.fullmatch(r"([a-z]{2})(\d+)", query) if server_match: