mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-15 05:25:12 +00:00
feat(proxies): add ProtonVPN TV login, drop cookie refresh
Self-sustaining TV-code auth; cookies are now access-only and re-exported on expiry.
This commit is contained in:
+53
-8
@@ -58,21 +58,66 @@ Note that `gb` is used instead of `uk` to be more consistent across regional sys
|
|||||||
### protonvpn (dict)
|
### protonvpn (dict)
|
||||||
|
|
||||||
Enable Proton VPN as a proxy provider. Proton mints short-lived HTTPS proxy credentials per session, so
|
Enable Proton VPN as a proxy provider. Proton mints short-lived HTTPS proxy credentials per session, so
|
||||||
authentication reuses an exported `account.proton.me` browser session rather than static credentials.
|
authentication reuses a Proton account session (obtained via TV login or an exported browser session) rather than
|
||||||
|
static credentials. **Free Proton accounts work**, limited to the free-tier exit countries.
|
||||||
|
|
||||||
Export the cookies for `account.proton.me` (Netscape `cookies.txt` or a JSON cookie list) — they must include
|
There are two ways to obtain that session — TV login or cookie export. You only need one. They can also coexist: a
|
||||||
the `AUTH-<UID>` and `REFRESH-<UID>` cookies — and save them at `<cookies>/vpn/protonvpn.txt`. **No yaml is
|
cached or cookie session is always tried first, and TV login is only used as a fallback when no session is present.
|
||||||
required**: the provider auto-loads when that file exists. Exporting straight from the logged-in web page is
|
|
||||||
fine; cookies for other Proton apps (e.g. `mail.proton.me`) are ignored automatically. The session refreshes on
|
|
||||||
its own when the access token expires, so you only re-export if it is fully revoked. **Free Proton accounts
|
|
||||||
work**, limited to the free-tier exit countries.
|
|
||||||
|
|
||||||
Configuration is optional — only add a block to override defaults:
|
#### TV login (self-sustaining, recommended)
|
||||||
|
|
||||||
|
Set `enable: true` and run any command that uses the proxy. On first use, when no session is cached, unshackle
|
||||||
|
prints a code and pauses:
|
||||||
|
|
||||||
|
1. Open `https://account.proton.me/vpn/tv/code` in a browser where you are signed in to Proton.
|
||||||
|
2. Enter the code shown in the terminal and approve it.
|
||||||
|
3. Return to the terminal and **press Enter** to continue.
|
||||||
|
|
||||||
|
The minted refresh token is cached at `<cache>/vpn/protonvpn.json` and **refreshes on its own forever after**, so
|
||||||
|
this is genuinely a one-time step — later runs load straight from the cache without prompting. The prompt is **off
|
||||||
|
by default** (`enable: false`) so nobody is prompted unexpectedly, and it is skipped automatically when there is no
|
||||||
|
interactive terminal (e.g. under `serve`, cron, or piped input) — in those contexts, use a cookie export or run an
|
||||||
|
interactive command once to seed the cache first.
|
||||||
|
|
||||||
|
#### Cookie export (easiest to collect, but lapses)
|
||||||
|
|
||||||
|
Export the cookies for `account.proton.me` **or** `account.protonvpn.com` (Netscape `cookies.txt` or a JSON cookie
|
||||||
|
list) and save them at `<cookies>/vpn/protonvpn.txt`. The provider auto-loads when that file exists (no YAML
|
||||||
|
required); cookies for other Proton apps (e.g. `mail.proton.me`) are ignored automatically.
|
||||||
|
|
||||||
|
- The export **must include the `AUTH-<UID>` cookie**. It is **HttpOnly** — many one-click cookie-export
|
||||||
|
extensions skip HttpOnly cookies by default, which is the most common reason an export "doesn't work". Use an
|
||||||
|
exporter that includes them.
|
||||||
|
- A browser session **cannot be refreshed headlessly** — Proton rotates browser refresh tokens and ties them to
|
||||||
|
the live browser. unshackle therefore uses the cookie's access token only (it never tries to refresh it) and the
|
||||||
|
session works until that token expires (Proton's TTL, typically ~24h); after that you must re-export. For a
|
||||||
|
set-and-forget setup, prefer TV login.
|
||||||
|
- Cookies are read fresh from the file on every run and are **never written to the cache**. If a TV-login cache
|
||||||
|
also exists, it is preferred unless the cookie file is newer — so to force the cookie session, just re-save
|
||||||
|
(re-export) the cookie file.
|
||||||
|
|
||||||
|
#### Resetting
|
||||||
|
|
||||||
|
To start clean, delete both `<cookies>/vpn/protonvpn.txt` and `<cache>/vpn/protonvpn.json`.
|
||||||
|
|
||||||
|
#### Troubleshooting
|
||||||
|
|
||||||
|
- **`Proton: no session available …`** — no cookies and no cache were found. Either export cookies, or set
|
||||||
|
`enable: true` and run from an interactive terminal (the prompt is skipped on non-tty stdin).
|
||||||
|
- **`Proton: session expired; re-export … cookies`** — a cookie session's access token lapsed and cannot be
|
||||||
|
refreshed (expected for cookie auth). Re-export the cookies, or switch to TV login.
|
||||||
|
|
||||||
|
#### Configuration
|
||||||
|
|
||||||
|
Configuration is optional — only add a block to enable TV login or override defaults:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
proxy_providers:
|
proxy_providers:
|
||||||
protonvpn:
|
protonvpn:
|
||||||
|
enable: true # prompt the one-time TV login when no session is cached (default false)
|
||||||
cookie_path: /path/to/protonvpn.txt # optional; defaults to <cookies>/vpn/protonvpn.txt
|
cookie_path: /path/to/protonvpn.txt # optional; defaults to <cookies>/vpn/protonvpn.txt
|
||||||
|
cache_path: /path/to/protonvpn.json # optional; defaults to <cache>/vpn/protonvpn.json
|
||||||
|
timeout: 10 # optional; per-request timeout in seconds (default 10)
|
||||||
server_map:
|
server_map:
|
||||||
stream-us: us:ny # optional alias -> country / country:city / countryNN
|
stream-us: us:ny # optional alias -> country / country:city / countryNN
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
from unshackle.core.config import config
|
from unshackle.core.config import config
|
||||||
|
from unshackle.core.console import console
|
||||||
from unshackle.core.proxies.proxy import Proxy
|
from unshackle.core.proxies.proxy import Proxy
|
||||||
|
|
||||||
log = logging.getLogger("proxies.proton")
|
log = logging.getLogger("proxies.proton")
|
||||||
@@ -20,8 +24,9 @@ class ProtonVPN(Proxy):
|
|||||||
"""
|
"""
|
||||||
Proton VPN HTTPS proxy provider.
|
Proton VPN HTTPS proxy provider.
|
||||||
|
|
||||||
Reuses an exported account.proton.me session (the AUTH-<UID>/REFRESH-<UID> cookies) to call
|
Authenticates via TV login (a self-sustaining session cached at <cache>/vpn/protonvpn.json, the
|
||||||
the VPN API, resolves a server by country/city, mints short-lived proxy credentials, and
|
only refreshable kind) or an exported account.proton.me cookie session (AUTH-<UID>, access only —
|
||||||
|
re-export on expiry). Resolves a server by country/city, mints short-lived proxy credentials, and
|
||||||
returns an authenticated HTTPS proxy URL (https://user:pass@server:4443).
|
returns an authenticated HTTPS proxy URL (https://user:pass@server:4443).
|
||||||
|
|
||||||
Query format (after the provider prefix, e.g. "protonvpn:us"):
|
Query format (after the provider prefix, e.g. "protonvpn:us"):
|
||||||
@@ -33,6 +38,7 @@ class ProtonVPN(Proxy):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
API_BASE = "https://account.proton.me/api"
|
API_BASE = "https://account.proton.me/api"
|
||||||
|
API_HOSTS = ("account.proton.me", "account.protonvpn.com")
|
||||||
APP_VERSION = "browser-vpn@1.3.5"
|
APP_VERSION = "browser-vpn@1.3.5"
|
||||||
PROXY_PORT = 4443
|
PROXY_PORT = 4443
|
||||||
SECURE_CORE_PORT = 443
|
SECURE_CORE_PORT = 443
|
||||||
@@ -42,15 +48,25 @@ class ProtonVPN(Proxy):
|
|||||||
TOKEN_DURATION = 1200 # free tier caps the granted lifetime lower
|
TOKEN_DURATION = 1200 # free tier caps the granted lifetime lower
|
||||||
COUNTRY_ALIASES = {"gb": "uk"} # Proton labels the United Kingdom "UK", not the ISO "GB"
|
COUNTRY_ALIASES = {"gb": "uk"} # Proton labels the United Kingdom "UK", not the ISO "GB"
|
||||||
|
|
||||||
|
TV_API_BASE = "https://vpn-api.proton.me"
|
||||||
|
TV_APP_VERSION = "android_tv-vpn@5.18.75.1+play"
|
||||||
|
TV_USER_AGENT = "ProtonVPN/5.18.75.1 (Android 11; NVIDIA SHIELD Android TV)"
|
||||||
|
TV_ACCEPT = "application/vnd.protonmail.v1+json"
|
||||||
|
POLL_TIMEOUT = 600.0
|
||||||
|
POLL_INTERVAL = 5.0
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
server_map: Optional[dict[str, str]] = None,
|
server_map: Optional[dict[str, str]] = None,
|
||||||
cookie_path: Optional[str] = None,
|
cookie_path: Optional[str] = None,
|
||||||
cache_path: Optional[str] = None,
|
cache_path: Optional[str] = None,
|
||||||
timeout: float = 10.0,
|
timeout: float = 10.0,
|
||||||
|
enable: bool = False,
|
||||||
):
|
):
|
||||||
self.server_map = {str(k).lower().strip(): str(v).lower().strip() for k, v in (server_map or {}).items()}
|
self.server_map = {str(k).lower().strip(): str(v).lower().strip() for k, v in (server_map or {}).items()}
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
self.enable = enable
|
||||||
|
self.api_base = self.API_BASE
|
||||||
|
|
||||||
self.uid: Optional[str] = None
|
self.uid: Optional[str] = None
|
||||||
self.access_token: Optional[str] = None
|
self.access_token: Optional[str] = None
|
||||||
@@ -66,7 +82,11 @@ class ProtonVPN(Proxy):
|
|||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
try:
|
try:
|
||||||
servers = self.get_logicals() # eager so the "Loaded ProtonVPN: …" line shows the catalog size
|
# only fetch the catalog if a session exists; never start TV login here (that belongs in get_proxy)
|
||||||
|
if self.access_token or self.cookie_path.is_file() or self.cache_path.is_file():
|
||||||
|
servers = self.get_logicals()
|
||||||
|
else:
|
||||||
|
servers = self.logicals or []
|
||||||
except Exception:
|
except Exception:
|
||||||
servers = self.logicals or []
|
servers = self.logicals or []
|
||||||
if not servers:
|
if not servers:
|
||||||
@@ -77,6 +97,7 @@ class ProtonVPN(Proxy):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_proxy(self, query: str) -> Optional[str]:
|
def get_proxy(self, query: str) -> Optional[str]:
|
||||||
|
self.ensure_session()
|
||||||
server = self.resolve_server(query.strip().lower())
|
server = self.resolve_server(query.strip().lower())
|
||||||
if not server:
|
if not server:
|
||||||
return None
|
return None
|
||||||
@@ -195,15 +216,22 @@ class ProtonVPN(Proxy):
|
|||||||
if not self.access_token:
|
if not self.access_token:
|
||||||
self.load_session()
|
self.load_session()
|
||||||
if not self.access_token:
|
if not self.access_token:
|
||||||
log.error("Proton: no session available; export account.proton.me cookies to %s", self.cookie_path)
|
log.error(
|
||||||
|
"Proton: no session available; export account.proton.me cookies to %s "
|
||||||
|
"or set proxy_providers.protonvpn.enable: true for the one-time TV login",
|
||||||
|
self.cookie_path,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
for attempt in range(2):
|
for attempt in range(2):
|
||||||
response = requests.request(method, f"{self.API_BASE}{path}", headers=self.headers(), timeout=self.timeout)
|
response = requests.request(method, f"{self.api_base}{path}", headers=self.headers(), timeout=self.timeout)
|
||||||
if response.status_code == 401 and attempt == 0 and self.refresh_session():
|
if response.status_code == 401 and attempt == 0 and self.refresh_session():
|
||||||
continue
|
continue
|
||||||
if response.status_code == 401:
|
if response.status_code == 401:
|
||||||
log.error("Proton: session expired; re-export account.proton.me cookies to %s", self.cookie_path)
|
log.error(
|
||||||
|
"Proton: session expired; re-export cookies to %s or use TV login",
|
||||||
|
self.cookie_path,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
raise ValueError(f"Proton request failed with HTTP {response.status_code}: {path}")
|
raise ValueError(f"Proton request failed with HTTP {response.status_code}: {path}")
|
||||||
@@ -222,7 +250,7 @@ class ProtonVPN(Proxy):
|
|||||||
if not self.refresh_token or not self.uid:
|
if not self.refresh_token or not self.uid:
|
||||||
return False
|
return False
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{self.API_BASE}/auth/refresh",
|
f"{self.api_base}/auth/refresh",
|
||||||
json={
|
json={
|
||||||
"UID": self.uid,
|
"UID": self.uid,
|
||||||
"ResponseType": "token",
|
"ResponseType": "token",
|
||||||
@@ -245,34 +273,95 @@ class ProtonVPN(Proxy):
|
|||||||
def load_session(self) -> None:
|
def load_session(self) -> None:
|
||||||
cached = self.load_cache()
|
cached = self.load_cache()
|
||||||
|
|
||||||
api_host = self.API_BASE.split("://", 1)[-1].split("/", 1)[0]
|
cookie_uid = cookie_access = cookie_host = None
|
||||||
cookie_uid = cookie_access = cookie_refresh = None
|
|
||||||
for name, value, domain in self.load_cookies():
|
for name, value, domain in self.load_cookies():
|
||||||
if domain and domain.lstrip(".") != api_host:
|
host = domain.lstrip(".") if domain else ""
|
||||||
|
if host and host not in self.API_HOSTS:
|
||||||
continue
|
continue
|
||||||
if name.startswith("AUTH-"):
|
if name.startswith("AUTH-"):
|
||||||
cookie_uid, cookie_access = name[len("AUTH-") :], value
|
cookie_uid, cookie_access, cookie_host = name[len("AUTH-") :], value, host
|
||||||
elif name.startswith("REFRESH-"):
|
|
||||||
cookie_refresh = value
|
|
||||||
|
|
||||||
# Cached tokens win, but only for the same account and not older than a re-exported cookie file
|
# The TV cache is the only refreshable session
|
||||||
same_account = cookie_uid is None or cached.get("uid") == cookie_uid
|
|
||||||
cookie_newer = (
|
cookie_newer = (
|
||||||
self.cookie_path.is_file()
|
self.cookie_path.is_file()
|
||||||
and self.cache_path.is_file()
|
and self.cache_path.is_file()
|
||||||
and self.cookie_path.stat().st_mtime > self.cache_path.stat().st_mtime
|
and self.cookie_path.stat().st_mtime > self.cache_path.stat().st_mtime
|
||||||
)
|
)
|
||||||
if cached.get("access_token") and same_account and not cookie_newer:
|
if cached.get("access_token") and not cookie_newer:
|
||||||
self.uid = cached["uid"]
|
self.uid = cached["uid"]
|
||||||
self.access_token = cached["access_token"]
|
self.access_token = cached["access_token"]
|
||||||
self.refresh_token = cached.get("refresh_token")
|
self.refresh_token = cached.get("refresh_token")
|
||||||
|
self.api_base = cached.get("api_base") or self.api_base
|
||||||
return
|
return
|
||||||
|
|
||||||
self.uid, self.access_token = cookie_uid, cookie_access
|
# Cookie session: access token only. Browser sessions can't refresh headlessly
|
||||||
# A cookie-only re-export may lack REFRESH-; reuse the cache's refresh token if it's the same account
|
self.uid, self.access_token, self.refresh_token = cookie_uid, cookie_access, None
|
||||||
self.refresh_token = cookie_refresh or (cached.get("refresh_token") if same_account else None)
|
if cookie_host:
|
||||||
if self.uid and self.access_token:
|
self.api_base = f"https://{cookie_host}/api"
|
||||||
self.save_session()
|
|
||||||
|
def ensure_session(self) -> None:
|
||||||
|
if not self.access_token:
|
||||||
|
self.load_session()
|
||||||
|
if not self.access_token and self.enable and sys.stdin.isatty():
|
||||||
|
self.tv_login()
|
||||||
|
|
||||||
|
def tv_login(self) -> bool:
|
||||||
|
"""Fork a TV session and have the user approve the code at proton.me/tv; tokens refresh like the cookie path."""
|
||||||
|
headers = {
|
||||||
|
"x-pm-appversion": self.TV_APP_VERSION,
|
||||||
|
"x-pm-locale": "en",
|
||||||
|
"User-Agent": self.TV_USER_AGENT,
|
||||||
|
"Accept": self.TV_ACCEPT,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{self.TV_API_BASE}/auth/v4/sessions/forks", headers=headers, timeout=self.timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
fork = response.json()
|
||||||
|
except (requests.RequestException, ValueError) as error:
|
||||||
|
log.error("Proton: failed to start TV login: %s", error)
|
||||||
|
return False
|
||||||
|
|
||||||
|
selector, user_code = fork.get("Selector"), fork.get("UserCode")
|
||||||
|
if not selector or not user_code:
|
||||||
|
log.error("Proton: unexpected TV login response")
|
||||||
|
return False
|
||||||
|
|
||||||
|
indent = " " * 5
|
||||||
|
prompt = (
|
||||||
|
"Proton: open https://account.proton.me/vpn/tv/code\n"
|
||||||
|
f"Enter code {user_code}, then press Enter to continue..."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
console.input(Text(indent + prompt.replace("\n", "\n" + indent), style="text"))
|
||||||
|
except EOFError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
poll_url = f"{self.TV_API_BASE}/auth/v4/sessions/forks/{selector}"
|
||||||
|
deadline = time.monotonic() + self.POLL_TIMEOUT
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
response = requests.get(poll_url, headers=headers, timeout=self.timeout)
|
||||||
|
if response.status_code == 422: # not approved yet
|
||||||
|
time.sleep(self.POLL_INTERVAL)
|
||||||
|
continue
|
||||||
|
if not response.ok:
|
||||||
|
log.error("Proton: TV login poll failed with HTTP %s", response.status_code)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
tokens = response.json()
|
||||||
|
except ValueError:
|
||||||
|
log.error("Proton: unexpected TV login token response")
|
||||||
|
return False
|
||||||
|
uid, access, refresh = tokens.get("UID"), tokens.get("AccessToken"), tokens.get("RefreshToken")
|
||||||
|
if uid and access and refresh:
|
||||||
|
self.uid, self.access_token, self.refresh_token = uid, access, refresh
|
||||||
|
self.save_session()
|
||||||
|
log.info("Proton: TV login successful")
|
||||||
|
return True
|
||||||
|
log.error("Proton: TV login response missing tokens")
|
||||||
|
return False
|
||||||
|
|
||||||
|
log.error("Proton: TV login not approved in time; run the command again to retry")
|
||||||
|
return False
|
||||||
|
|
||||||
def load_cookies(self) -> list[tuple[str, str, str]]:
|
def load_cookies(self) -> list[tuple[str, str, str]]:
|
||||||
"""Return [(name, value, domain), ...] from a Netscape cookies.txt or JSON cookie list."""
|
"""Return [(name, value, domain), ...] from a Netscape cookies.txt or JSON cookie list."""
|
||||||
@@ -315,7 +404,12 @@ class ProtonVPN(Proxy):
|
|||||||
return data if isinstance(data, dict) else {}
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
def save_session(self) -> None:
|
def save_session(self) -> None:
|
||||||
tokens = {"uid": self.uid, "access_token": self.access_token, "refresh_token": self.refresh_token}
|
tokens = {
|
||||||
|
"uid": self.uid,
|
||||||
|
"access_token": self.access_token,
|
||||||
|
"refresh_token": self.refresh_token,
|
||||||
|
"api_base": self.api_base,
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
self.cache_path.parent.mkdir(parents=True, exist_ok=True)
|
self.cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
write_private(self.cache_path, json.dumps({k: v for k, v in tokens.items() if v}, indent=2))
|
write_private(self.cache_path, json.dumps({k: v for k, v in tokens.items() if v}, indent=2))
|
||||||
@@ -331,7 +425,7 @@ class ProtonVPN(Proxy):
|
|||||||
return cookies_dir / "vpn" / "protonvpn.txt"
|
return cookies_dir / "vpn" / "protonvpn.txt"
|
||||||
|
|
||||||
def default_cache_path(self) -> Path:
|
def default_cache_path(self) -> Path:
|
||||||
return Path(config.directories.cache) / "global" / "proton_tokens.json"
|
return Path(config.directories.cache) / "vpn" / "protonvpn.json"
|
||||||
|
|
||||||
|
|
||||||
def slugify(value: str) -> str:
|
def slugify(value: str) -> str:
|
||||||
|
|||||||
Reference in New Issue
Block a user