diff --git a/unshackle/core/proxies/proton.py b/unshackle/core/proxies/proton.py index 98c1bd0e..d5b8e9e1 100644 --- a/unshackle/core/proxies/proton.py +++ b/unshackle/core/proxies/proton.py @@ -25,13 +25,14 @@ class ProtonVPN(Proxy): Authenticates through TV login (a self-sustaining login session cached at /vpn/protonvpn.json, the only refreshable kind) or an exported account.proton.me cookie session (AUTH-, access only, - re-export on expiry). Resolves a server by country/city, mints short-lived proxy credentials, and + re-export on expiry). Resolves a server by country/city/node, mints short-lived proxy credentials, and returns an authenticated HTTPS proxy URL (https://user:pass@server:4443). Query format (after the proxy provider prefix, for example "protonvpn:us"): - us random server in the country (paid preferred over free) - us12 Proton server #12 (the number shown in the connection log) - us:ny city, NordVPN-style + us random server in the country (paid preferred over free) + us12 / us-12 / us#12 Proton server #12 (the number shown in the connection log) + us:ny city, NordVPN-style + node-pl-24 specific physical server node by domain Free Proton accounts work, limited to the free-tier exit countries. """ @@ -122,19 +123,25 @@ class ProtonVPN(Proxy): query = self.server_map.get(query, query) country, city, server_num = self.parse_query(query) + max_tier = self.get_max_tier() + eligible = [ + s + for s in self.get_logicals() + if s.get("Status") == 1 + and int(s.get("Tier") or 0) <= max_tier + and not int(s.get("Features") or 0) & self.EXCLUDE_FEATURES + ] + pool = [] if country: - max_tier = self.get_max_tier() # only servers the account can actually connect to - pool = [ - s - for s in self.get_logicals() - if str(s.get("ExitCountry") or "").lower() == country - and s.get("Status") == 1 - and int(s.get("Tier") or 0) <= max_tier - and not int(s.get("Features") or 0) & self.EXCLUDE_FEATURES - ] + pool = [s for s in eligible if str(s.get("ExitCountry") or "").lower() == country] if city: pool = self.match_city(pool, city) + else: + domain_query = query.removesuffix(".protonvpn.net") + pool = [ + s for s in eligible if str(s.get("Domain") or "").lower().removesuffix(".protonvpn.net") == domain_query + ] if not pool: log.warning("Proton: no server matched query '%s'", query) @@ -142,13 +149,13 @@ class ProtonVPN(Proxy): return self.select(pool, server_num) def parse_query(self, query: str) -> tuple[Optional[str], Optional[str], Optional[int]]: - """Parse "us", "us12"/"us-12" (server #12), and "us:ny" (city).""" + """Parse "us", "us12"/"us-12"/"us#12" (server #12), and "us:ny" (city).""" city = None if ":" in query: query, city = query.split(":", 1) city = city.strip() or None - match = re.match(r"^([a-z]{2})-?(\d+)?$", query.strip()) + match = re.match(r"^([a-z]{2})[-#]?(\d+)?$", query.strip()) if not match: return None, None, None country = self.COUNTRY_ALIASES.get(match.group(1), match.group(1))