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:
imSp4rky
2026-06-28 18:42:11 -06:00
parent f9ab349e9c
commit 39f5bc2a9e
2 changed files with 170 additions and 31 deletions
+53 -8
View File
@@ -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
``` ```
+117 -23
View File
@@ -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: