mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
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:
@@ -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:**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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:
|
||||||
|
|||||||
Reference in New Issue
Block a user