feat(proxy): accept an @ selector as a server_map value

A value starting with - or @ applies to its region (fr: -datacamp); a value with @ after a server is a whole query (fr: fr-bod@195.86.24.103). A selector in --proxy still wins.
This commit is contained in:
imSp4rky
2026-10-22 14:03:25 -06:00
parent 4753fcd7bc
commit 1ec236f61b
6 changed files with 113 additions and 14 deletions
+23 -3
View File
@@ -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 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. 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" !!! note "Not every proxy provider"
Only NordVPN, Surfshark and Windscribe read the `@` part. With a bare query such as 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 `--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 the username must not contain an `@`. If validation fails, you have almost certainly used
your login credentials instead of your service credentials. 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 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 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. 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 **Required keys:** `username`, `password`, validated with the same 48-character rule as
NordVPN (combined username+password must be 48 alphanumeric characters, case-insensitive, no `@`). 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 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 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 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 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. `region:city`) directly to a hostname.
**Query forms:** **Query forms:**
+60
View File
@@ -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 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: def test_remote_sends_a_surfshark_selector_unresolved() -> None:
assert resolve_remote_proxy_arg("surfsharkvpn:fr-bod@datacamp", []) == "surfsharkvpn:fr-bod@datacamp" 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" 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: def test_nordvpn_selector_with_no_match_raises(nordvpn: NordVPN) -> None:
with pytest.raises(ValueError, match="No NordVPN server in ca"): with pytest.raises(ValueError, match="No NordVPN server in ca"):
nordvpn.get_proxy("ca@m247") 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 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: def test_windscribe_selector_with_no_match_raises(windscribe: WindscribeVPN) -> None:
with pytest.raises(ValueError, match="No WindscribeVPN server in fr"): with pytest.raises(ValueError, match="No WindscribeVPN server in fr"):
windscribe.get_proxy("fr:paris@datacamp") 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" 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: 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.get_proxy("fr") == f"https://{'a' * 24}:{'b' * 24}@fr-bod.prod.surfshark.com:443"
assert surfshark.last_connection_display() is None assert surfshark.last_connection_display() is None
+13
View File
@@ -22,6 +22,19 @@ def split_selector(query: str) -> tuple[str, Optional[str]]:
return query, selector or None 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]: 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. Get the network (AS) name of each IP from Team Cymru's bulk whois. An IP it does not know is left out.
+5 -3
View File
@@ -1,17 +1,17 @@
import json import json
import random import random
import re import re
from typing import Optional from typing import Optional, Union
import requests 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.proxies.proxy import Proxy
from unshackle.core.utilities import COUNTRY_CODE_ALIASES from unshackle.core.utilities import COUNTRY_CODE_ALIASES
class NordVPN(Proxy): 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. Proxy provider that uses NordVPN Service Credentials.
@@ -89,6 +89,8 @@ class NordVPN(Proxy):
) )
if server_mapping and not selector: 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" hostname = f"{country['code'].lower()}{server_mapping}.proxy.nordvpn.com"
else: else:
city_id = None city_id = None
+7 -3
View File
@@ -12,7 +12,7 @@ from typing import Optional
import certifi import certifi
import requests 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.controld import build_query, parse_a_records
from unshackle.core.proxies.proxy import Proxy from unshackle.core.proxies.proxy import Proxy
from unshackle.core.utilities import COUNTRY_CODE_ALIASES from unshackle.core.utilities import COUNTRY_CODE_ALIASES
@@ -155,8 +155,12 @@ class SurfsharkVPN(Proxy):
query = COUNTRY_CODE_ALIASES.get(query, query) query = COUNTRY_CODE_ALIASES.get(query, query)
server_map_key = f"{query}:{city}" if city else query server_map_key = f"{query}:{city}" if city else query
if server_map_key in self.server_map: mapped = self.server_map.get(server_map_key)
servers = [self.server_map[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): elif not city and any(x["connectionName"].split(".", 1)[0] == query for x in self.countries):
servers = [query] servers = [query]
elif re.fullmatch(r"[a-z]{2}", query): elif re.fullmatch(r"[a-z]{2}", query):
+5 -5
View File
@@ -5,7 +5,7 @@ from typing import Iterator, Optional
import requests 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 from unshackle.core.proxies.proxy import Proxy
@@ -67,10 +67,10 @@ class WindscribeVPN(Proxy):
if not re.fullmatch(r"[a-z]+", query): if not re.fullmatch(r"[a-z]+", query):
raise ValueError(f"'{query}' is already one WindscribeVPN server, so it takes no '@' network selector.") 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) hostname: Optional[str] = self.get_random_server(query, city, selector)
elif server_map_key in self.server_map: elif mapping := self.server_map.get(server_map_key) or self.server_map.get(query):
hostname = self.server_map[server_map_key] if mapped := mapped_query(server_map_key, mapping):
elif query in self.server_map: return self.get_proxy(mapped)
hostname = self.server_map[query] hostname = mapping
else: else:
server_match = re.fullmatch(r"([a-z]{2})(\d+)", query) server_match = re.fullmatch(r"([a-z]{2})(\d+)", query)
if server_match: if server_match: