mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +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)
|
||||
|
||||
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
|
||||
the `AUTH-<UID>` and `REFRESH-<UID>` cookies — and save them at `<cookies>/vpn/protonvpn.txt`. **No yaml is
|
||||
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.
|
||||
There are two ways to obtain that session — TV login or cookie export. You only need one. They can also coexist: a
|
||||
cached or cookie session is always tried first, and TV login is only used as a fallback when no session is present.
|
||||
|
||||
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
|
||||
proxy_providers:
|
||||
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
|
||||
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:
|
||||
stream-us: us:ny # optional alias -> country / country:city / countryNN
|
||||
```
|
||||
|
||||
@@ -5,12 +5,16 @@ import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from rich.text import Text
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.console import console
|
||||
from unshackle.core.proxies.proxy import Proxy
|
||||
|
||||
log = logging.getLogger("proxies.proton")
|
||||
@@ -20,8 +24,9 @@ class ProtonVPN(Proxy):
|
||||
"""
|
||||
Proton VPN HTTPS proxy provider.
|
||||
|
||||
Reuses an exported account.proton.me session (the AUTH-<UID>/REFRESH-<UID> cookies) to call
|
||||
the VPN API, resolves a server by country/city, mints short-lived proxy credentials, and
|
||||
Authenticates via TV login (a self-sustaining session cached at <cache>/vpn/protonvpn.json, the
|
||||
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).
|
||||
|
||||
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_HOSTS = ("account.proton.me", "account.protonvpn.com")
|
||||
APP_VERSION = "browser-vpn@1.3.5"
|
||||
PROXY_PORT = 4443
|
||||
SECURE_CORE_PORT = 443
|
||||
@@ -42,15 +48,25 @@ class ProtonVPN(Proxy):
|
||||
TOKEN_DURATION = 1200 # free tier caps the granted lifetime lower
|
||||
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__(
|
||||
self,
|
||||
server_map: Optional[dict[str, str]] = None,
|
||||
cookie_path: Optional[str] = None,
|
||||
cache_path: Optional[str] = None,
|
||||
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.timeout = timeout
|
||||
self.enable = enable
|
||||
self.api_base = self.API_BASE
|
||||
|
||||
self.uid: Optional[str] = None
|
||||
self.access_token: Optional[str] = None
|
||||
@@ -66,7 +82,11 @@ class ProtonVPN(Proxy):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
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:
|
||||
servers = self.logicals or []
|
||||
if not servers:
|
||||
@@ -77,6 +97,7 @@ class ProtonVPN(Proxy):
|
||||
)
|
||||
|
||||
def get_proxy(self, query: str) -> Optional[str]:
|
||||
self.ensure_session()
|
||||
server = self.resolve_server(query.strip().lower())
|
||||
if not server:
|
||||
return None
|
||||
@@ -195,15 +216,22 @@ class ProtonVPN(Proxy):
|
||||
if not self.access_token:
|
||||
self.load_session()
|
||||
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
|
||||
|
||||
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():
|
||||
continue
|
||||
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
|
||||
if not response.ok:
|
||||
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:
|
||||
return False
|
||||
response = requests.post(
|
||||
f"{self.API_BASE}/auth/refresh",
|
||||
f"{self.api_base}/auth/refresh",
|
||||
json={
|
||||
"UID": self.uid,
|
||||
"ResponseType": "token",
|
||||
@@ -245,34 +273,95 @@ class ProtonVPN(Proxy):
|
||||
def load_session(self) -> None:
|
||||
cached = self.load_cache()
|
||||
|
||||
api_host = self.API_BASE.split("://", 1)[-1].split("/", 1)[0]
|
||||
cookie_uid = cookie_access = cookie_refresh = None
|
||||
cookie_uid = cookie_access = cookie_host = None
|
||||
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
|
||||
if name.startswith("AUTH-"):
|
||||
cookie_uid, cookie_access = name[len("AUTH-") :], value
|
||||
elif name.startswith("REFRESH-"):
|
||||
cookie_refresh = value
|
||||
cookie_uid, cookie_access, cookie_host = name[len("AUTH-") :], value, host
|
||||
|
||||
# Cached tokens win, but only for the same account and not older than a re-exported cookie file
|
||||
same_account = cookie_uid is None or cached.get("uid") == cookie_uid
|
||||
# The TV cache is the only refreshable session
|
||||
cookie_newer = (
|
||||
self.cookie_path.is_file()
|
||||
and self.cache_path.is_file()
|
||||
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.access_token = cached["access_token"]
|
||||
self.refresh_token = cached.get("refresh_token")
|
||||
self.api_base = cached.get("api_base") or self.api_base
|
||||
return
|
||||
|
||||
self.uid, self.access_token = cookie_uid, cookie_access
|
||||
# A cookie-only re-export may lack REFRESH-; reuse the cache's refresh token if it's the same account
|
||||
self.refresh_token = cookie_refresh or (cached.get("refresh_token") if same_account else None)
|
||||
if self.uid and self.access_token:
|
||||
self.save_session()
|
||||
# Cookie session: access token only. Browser sessions can't refresh headlessly
|
||||
self.uid, self.access_token, self.refresh_token = cookie_uid, cookie_access, None
|
||||
if cookie_host:
|
||||
self.api_base = f"https://{cookie_host}/api"
|
||||
|
||||
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]]:
|
||||
"""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 {}
|
||||
|
||||
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:
|
||||
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))
|
||||
@@ -331,7 +425,7 @@ class ProtonVPN(Proxy):
|
||||
return cookies_dir / "vpn" / "protonvpn.txt"
|
||||
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user