Batch Download ProtonVPN Wireguard Configs (Python)

For many this is a TL:DR / not applicable, so, don’t fret if it’s not for you !

I’m a privacy advocate, and, have been using VPNs for quite a number of years now. One solution I implemented was what I call a “VPN Router”, that is it’s connected to ISP’s router, but, automatically within the (2nd) router, connects to the VPN. This helps, to not risk exposing one’s real IP address, allows to “Double/Triple/Quad VPN”, whereby the “VPN Router” has a VPN connection, and, my laptop, “tunnels” through this connection, for more privacy. Also, VPN’ed connection can be shared with others over wifi.

I also developed scripts to set random MAC addresses for wifi (MAC address is broadcast on the air continuously, so anybody listening can see it), to help with not being wifi triangulated.

And, the reason I’m posting this python code, is another script within the VPN router to change the VPN connection on a schedule, based on “random” or “closest/fastest” – which needs .conf Wireguard files.

This tech, “VPN Router”, I could make for others, but, it’s a very small niche I think, so, it would be relatively expensive and bespoke (and router has to support OpenWRT) – if you’re still interested, and enthusiastic, contact me here, let me know your requirements.

ProtonVPN Wireguard .conf Files Batch Downloader

I started this little side project, as, downloading these .conf files for the VPN router, well, it’s a real pain “manually”, only 20 at a time, then I think a 20 minute cooling off period. I have several hundred on these on one of my VPN routers.

So kudos to FuseTim for doing most of the heavy lifting here, although it lacked some features, and, digging around for “headers” and “cookies” I wasn’t interested in, so I modified it to do these things automatically. I, these days make heavy use of AI/LLMs for any coding – it’s just faster, and, often comes up with ideas I wouldn’t have thought of – and, I’m pretty time poor at the moment, so why not use new tools, for good purposes ?

Basic operation :

  1. Use selenium / selenium-wire to get cookie/header data from ProtonVPN.
  2. Download all servers info
  3. Generate .conf files as per constants specified at top of script.

Here’s the code, tested to best-efforts, feel free to post any fixes/bugs in comments – they’d be helpful actually, I’ll try keep it up to date, within reason. Should work on Windows/Linux/MAC, or anything else with reasonable spec hardware and Python. FOSS basically, all we’d ask is you keep the licensing same, because it protects from liability, and, credit us under (c) section – the license allows for changing, modifying, etc, I believe.

It’s name is “ProtonVPNConfigGenerator.py” :


import http.client
import http.cookies
import json
import base64
import hashlib
import os
import time
from seleniumwire import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException
from datetime import date, timedelta


"""
ProtonVPN WireGuard Configuration Generator

Enhanced with robust login handling and error recovery

Based on script provided by FuseTim, added :
1) Selenium login to fetch cookie + header information (with selenium-wire proxy for capture), 
2) plus code to handle Proton's rate limiting (I think it's 20 server's then 20 minute "cool off"), etc 
3) simplied cryptography
4) with some guesswork on ProtonVPN api, as did I think FuseTim - so, provided as is, tested to best-efforts.

"""

"""
Copyright - FuseTim 2024 (original script)
+ DonCharisma.org / DonCharisma.com 2025

This code is dual-licensed under both the MIT License and the Apache License 2.0.
You may choose either license to govern your use of this code.
MIT License:
https://opensource.org/licenses/MIT
Apache License 2.0:
https://www.apache.org/licenses/LICENSE-2.0
By contributing to this project, you agree that your contributions will be licensed under 
both the MIT License and the Apache License 2.0.
# https://gist.github.com/fusetim/1a1ee1bdf821a45361f346e9c7f41e5a

# So, basically use/change etc, but, no liability whatsoever on creators.

# Other notable pages
# https://github.com/ProtonMail/WebClients/blob/8b5035d6f848b76d005814fca260bb616e83a4b2/packages/components/containers/vpn/WireGuardConfigurationSection/feature.ts#L53

# https://gist.github.com/tiimk/56e88a6e5d47157dedf40e2761683cf1
"""

# Configuration - Set these first!
USERNAME = "your_protonvpn_username" # ProtonVPN username
PASSWORD = "your_protonvpn_password" # ProtonVPN password
PLATFORM = "Linux" # Linux, Router = LF ... Windows, etc, CRLF
CONFIG_PREFIX = "wg" # filename is same as ProtonVPN provides with "wg"
OUTPUT_DIR = "./configs"
SELECTED_COUNTRIES = [] # eg "CH" ... or "CH", "US" ... or empty list will process all countries
SELECTED_TIER = 2 # 1 = Free, 2 = Paid
SELECTED_FEATURES = ["P2P"] # options  "Vanilla" or "SecureCore", "TOR", "P2P", "XOR", "IPv6" 
                            # XOR unsure of use case
SERVER_FEATURE_MASK = {
    "Vanilla": 0,
    "SecureCore": 1,
    "TOR": 2,
    "P2P": 4,
    "XOR": 8,
    "IPv6": 16
}
MAX_SERVERS = 999 # maximum configs to download
LISTING_ONLY = False # If you actually want to create .conf files or not

# SafeMode I'm unsure of function, in the ProtonVPN created .conf files there's also "Bouncing", which again unsure about, if that can be specified here.
# ie, as we don't have docs for api, these are best guess.
CONFIG_FEATURES = {
    "SafeMode": False, # Non-standard ports - https://protonvpn.com/support/non-standard-ports (dead link, even on WayBackMachine)
    "SplitTCP": True, # VPN Accelerator - True/False
    "PortForwarding": True, # NAT-PMP (Port Forwarding) - True/False
    "RandomNAT": False, # Moderate NAT - True/False
    "NetShieldLevel": 0, # NetShield 0 = off, 1 = block malware, 2 = block malware + ads + trackers
}

# Global set to track downloaded DeviceNames
downloaded_devices = set()

class ProtonVPNConfigGenerator:
    def __init__(self):
        self.auth_data = None
        self.connection = None
        self.headers = None
        self.driver = None

    def initialize_selenium(self):
        """Configure Selenium with enhanced options"""
        options = webdriver.FirefoxOptions()
        options.headless = True
        seleniumwire_options = {'disable_encoding': True}
        return webdriver.Firefox(options=options, seleniumwire_options=seleniumwire_options)

    def get_auth_credentials(self):
        """Handle two-stage login and extract auth tokens"""
        self.driver = self.initialize_selenium()
        
        try:
            self.driver.get("https://account.protonvpn.com/login")
            
            # Stage 1: Username
            WebDriverWait(self.driver, 30).until(
                EC.presence_of_element_located((By.ID, "username"))
            ).send_keys(USERNAME)
            
            self.driver.find_element(By.XPATH, "//button[contains(text(),'Continue')]").click()
            
            # Stage 2: Password
            WebDriverWait(self.driver, 30).until(
                EC.presence_of_element_located((By.ID, "password"))
            ).send_keys(PASSWORD)
            
            self.driver.find_element(By.XPATH, "//button[contains(text(),'Sign in')]").click()
            
            # Wait for Dashboard
            WebDriverWait(self.driver, 45).until(
                EC.presence_of_element_located((By.XPATH, "//h1[contains(text(),'Subscription')]"))
            )
            
            # Extract auth tokens from network traffic
            return self.extract_auth_tokens(self.driver)
        
        except TimeoutException as e:
            print("Login timeout - Check credentials or server response")
            raise e
        
        #finally:
            # driver.quit()  # keep proton window open (and cookies, active)

    def extract_auth_tokens(self, driver):
        """Extract authentication tokens from network requests"""
        auth_data = {}
        
        for request in self.driver.requests:
            if request.response and "/api" in request.url:
                # Debugging: Print request details
                print(f"Request URL: {request.url}")
                print(f"Response Status: {request.response.status_code}")
                print(f"Headers: {request.headers}")
                
                # Extract cookies
                cookies = request.headers.get("cookie", "")
                if "AUTH-" in cookies:
                    auth_parts = cookies.split("AUTH-")[1].split(";")[0].split("=")
                    auth_data["auth_server"] = auth_parts[0].split("-")[0]
                    # Debugging: Print extracted details
                    print(f"\nauth_server : {auth_data['auth_server']}")
                    auth_data["auth_token"] = auth_parts[1]
                    # Debugging: Print extracted details
                    print(f"\nauth_token : {auth_data['auth_token']}")
                if "Session-Id=" in cookies:
                    auth_data["session_id"] = cookies.split("Session-Id=")[1].split(";")[0]
                    # Debugging: Print extracted details
                    print(f"\nsession_id : {auth_data['session_id']}")
                
                # Extract app version
                if "x-pm-appversion" in request.headers:
                    auth_data["x-pm-appversion"] = request.headers["x-pm-appversion"]
                    # Debugging: Print extracted details
                    print(f"\nx-pm-appversion : {auth_data['x-pm-appversion']}")
                
                
        
        if not all(k in auth_data for k in ["auth_token", "session_id", "x-pm-appversion"]):
            raise ValueError("Failed to extract all required authentication tokens")
        
        return auth_data

    def setup_connection(self):
        """Configure HTTP connection with extracted auth tokens"""
        self.connection = http.client.HTTPSConnection("account.protonvpn.com")
        
        cookies = http.cookies.SimpleCookie()
        cookies[f"AUTH-{self.auth_data['auth_server']}"] = self.auth_data["auth_token"]
        cookies["Session-Id"] = self.auth_data["session_id"]

        self.headers = {
            "x-pm-appversion": self.auth_data["x-pm-appversion"],
            "x-pm-uid": self.auth_data["auth_server"],
            "Accept": "application/vnd.protonmail.v1+json",
            "Cookie": cookies.output(attrs=[], header="", sep="; ")
        }

    def generate_keys(self):
        """Generate X25519 keys through ProtonVPN API"""
        self.connection.request("GET", "/api/vpn/v1/certificate/key/EC", headers=self.headers)
        response = self.connection.getresponse()
        
        if response.status != 200:
            raise ConnectionError(f"Key generation failed: {response.status}")
            
        resp = json.loads(response.read().decode())
        return [resp["PrivateKey"], resp["PublicKey"].split("\n")[1], resp["PrivateKey"].split("\n")[1]]

    def register_config(self, server, keys):
        """Register WireGuard configuration"""
        headers = self.headers.copy()
        headers["Content-Type"] = "application/json"

        body = {
            "ClientPublicKey": keys[1],
            "Mode": "persistent",
            "DeviceName": f"{CONFIG_PREFIX}-{server['Name']}",
            "Features": {
                "peerName": server["Name"],
                "peerIp": server["Servers"][0]["EntryIP"],
                "peerPublicKey": server["Servers"][0]["X25519PublicKey"],
                "platform": PLATFORM,
                **CONFIG_FEATURES
            }
        }
        
        self.connection.request("POST", "/api/vpn/v1/certificate", body=json.dumps(body), headers=headers)
        response = self.connection.getresponse()
        
        if response.status != 200:
            raise ConnectionError(f"Registration failed: {response.status}")
            
        return json.loads(response.read().decode())

    def generate_config(self, keys, registration):
        """Generate WireGuard configuration file"""
        """Issued certs expire in 1 year"""
        return f"""[Interface]
# Expires {(date.today() + timedelta(days=365)):%d-%b-%Y}
# Bouncing = 0 # (not sure)
# SafeMode = {CONFIG_FEATURES['SafeMode']}
# NetShield = {CONFIG_FEATURES['NetShieldLevel']}
# Moderate NAT = {CONFIG_FEATURES['RandomNAT']}
# NAT-PMP (Port Forwarding) = {CONFIG_FEATURES['PortForwarding']}
# VPN Accelerator = {CONFIG_FEATURES['SplitTCP']}
PrivateKey = {self.get_priv_x25519(keys)}
Address = 10.2.0.2/32
DNS = 10.2.0.1

[Peer]
PublicKey = {registration['Features']['peerPublicKey']}
AllowedIPs = 0.0.0.0/0
Endpoint = {registration['Features']['peerIp']}:51820"""

    def get_priv_x25519(self, priv):
        """Convert private key to WireGuard format"""
        hash_ = hashlib.sha512(base64.b64decode(priv[2])[-32:]).digest()[:32]
        hash_ = bytearray(hash_)
        hash_[0] &= 0xf8
        hash_[31] = (hash_[31] & 0x7f) | 0x40
        return base64.b64encode(bytes(hash_)).decode()

    def write_config(self, name, config):
        """Save configuration securely"""
        os.makedirs(OUTPUT_DIR, exist_ok=True)
        path = os.path.join(OUTPUT_DIR, f"{name}.conf")
        
        if PLATFORM in ["Linux", "Router"]:
        # Open in binary mode and write with explicit newline characters
            with open(path, "wb") as f:
                f.write(config.encode('utf-8').replace(b'\r\n', b'\n'))
        else:
            with open(path, "w") as f:
                f.write(config)
        
        os.chmod(path, 0o600)
        
    # Check SELECTED_FEATURES for a server
    def get_feature_status(self, features):
        return [feature for feature in SELECTED_FEATURES if features & SERVER_FEATURE_MASK[feature] != 0]


    def run(self):
        """Main execution flow"""
        
        loop_continue = True
        
        while loop_continue:  # Loop to restart the process after waiting
            try:
                # Step 1: Authenticate and extract tokens
                self.auth_data = self.get_auth_credentials()

                # Step 2: Setup API connection
                self.setup_connection()

                # Step 3: Fetch server listings and generate configs
                self.connection.request("GET", "/api/vpn/logicals", headers=self.headers)
                response = self.connection.getresponse()

                if response.status != 200:
                    raise ConnectionError(f"API Error: {response.status} - {response.reason}")

                servers = json.loads(response.read().decode())["LogicalServers"]
                generated_count = 0

                for server in servers:
                    if generated_count >= MAX_SERVERS:
                        break

                    if SELECTED_COUNTRIES:
                        if server["EntryCountry"] not in SELECTED_COUNTRIES:
                            continue
                            
                    if server["Tier"] != SELECTED_TIER:
                        continue

                    if not self.get_feature_status(server["Features"]):
                        continue
                        
                    # Skip Configs that are already downloaded
                    if server['Name'] in downloaded_cfgs:
                        print(f"Skipping already downloaded Config for : {server['Name']}")
                        continue

                    if LISTING_ONLY:
                        print(f"Server: {server['Name']} (Tier {server['Tier']})")
                        continue


                    try:
                        keys = self.generate_keys()
                        registration = self.register_config(server, keys)

                        config_content = self.generate_config(keys, registration)
                        self.write_config(registration["DeviceName"].replace("#", "-"), config_content)

                        generated_count += 1
                        print(f"Generated config for {server['Name']}")

                    except Exception as e:
                        print(f"Error processing server {server['Name']}: {str(e)}. Waiting for 20 minutes before restarting...")
                        self.driver.quit()
                        time.sleep(1200)  # Wait for 20 minutes before restarting
                        break
                    else:
                        # Track successfully downloaded Configs
                        downloaded_cfgs.add(server["Name"])
                        # Add a 2-second delay after processing each server
                        time.sleep(2)
                        
                # to break above while loop, all server's extracted, for loop completed
                else:
                    self.driver.quit()
                    loop_continue = False

            except Exception as e:
                print(f"Critical failure: {str(e)}.") # Waiting for 20 minutes before restarting...")
            #    time.sleep(1200)  # Wait for 20 minutes before restarting the main flow


# Example usage
if __name__ == "__main__":
    generator = ProtonVPNConfigGenerator()
    generator.run()

(Edit 20250403 – fixed a bug for iterating over more than 20 configs
Edit 20250404 – fixed so empty list of countries, includes all countries)

Why post it here and not github etc ? Well, ProtonVPN api documentation isn’t public, so better to tuck it away here, then we’re more likely to have the script run fine for a longer period.

Enjoy !

Don Charisma


Our new sponsor –

Spartan.ist Brand: Quality and Customer Focused E-Commerce Retailer


Help us out by using our referral codesTransferwiseDropboxpCloudBitChuteDigitalOcean $100 Free CreditGemini Crypto Exchange We Both Get $10 Bitcoin – Binance –Bybit – Bitget – Remitly –


Disclaimer

Disclaimer – This is creative writing, for the purposes of freedom of expression and shared connection, in the realm of the divine via communication, you know, art. If you take offense to anything herein, then I suggest you may be the intolerant, bigoted, hateful, ideologically possessed, sinful, undiverse, uninclusive, extreme, misinformed, uninformed, propagandised one, not I. But who knows I could be wrong, I have been before, and will be again.

“to err is human; to forgive, divine” – Alexander Pope


Resources & Sources

Unless otherwise stated everything here is (c) DonCharisma.org, all rights are reserved – Please contact us if you want to buy digital assets from this website, we’d be happy to help. Sometimes we use photos from stock sites (or public domain) which are un-watermarked (Watermarked photos are normally our own). Where content is included from elsewhere on the internet (for example news articles, or parts of articles, images or YouTube videos) – we have included under fair use, that is for “satire or discussion” (or any of the other fair uses), we will usually cite the source (where known or disclosed to us) and that copyright remains with copyright holder. Each and every post on this website has an open comments section, so it’s usually a discussion, but sometimes satire appears in comments, or in the post.


DonCharisma.com-logo-4

*Shameless self-promotion* – Sometimes we work – Our commercial site :

DonCharisma.com – you dream it we built it … because – “anything is possible with Charisma”


Comments

Comments are very often welcomed, provided you can string a legible, relevant and polite sentence together. In other cases probably best shared with your therapist, or kept to yourself.



8 thoughts on “Batch Download ProtonVPN Wireguard Configs (Python)

    1. A lot of “crime” moved online, so, just have to take care out there.

      Maybe 10 years ago or so, I had my credit card ripped off, 5000GBP had been taken by the time I realised – the card had been cloned and was being used in SE Asia, where they were still doing signatures to draw cash. Credit card company paid me back, insurance probably.

      I still don’t know exactly where they got the number, might have been a waiter in a cafe for instance, ATM with a scanning device on it, online somewhere.

      The card companies made the situation even worse with RFID, now, criminals can potentially steal card details from a distance – but there are RFID blocking solutions for cards with “tap to pay” “feature”.

      1. I have an RFID blocking wallet. And I gave my husband a similar one for his cards. Still, clever thieves will figure it out.

      1. Well. it turns out an old friend sent me the books but no gift notice was enclosed. She just emailed me to ask if I’d gotten them! SO no harm no foul!

  1. Okay, this post is not for this digital idiot but it sounds good. My son would understand it.

    Right now I am trying to figure out the mystery of the books – how someone sent a total of five books to me from Amazon, using my Amazon Chase card but the purchases did not show up on my account. I cancelled the card since I surmised I’d been hacked and maybe whoever had my card number was testing it out.

    1. Hi Noelle,

      Yes, it’s more for geeks and the like, to solve a problem very few actually have, so, all good 🙂

      If purchases not showing up on your Amazon account, then, sounds like someone has your card details – Amazon to be fair, might accept a return of the books – and, also Credit Card fraudulent purchases you can usually get the money back, card issuer can do a chargeback. Sounds like an Amazon problem, and they are supposed to be very helpful customer service wise.

      Best practice to change your passwords, where you’ve used that card. Also, RFID solutions might be necessary.

      I’d suggest be careful with passwords, I use something called KeepassXC with it’s browser extension for managing passwords – don’t trust the cloud password managers, the one I used to use got hacked (after I stopped using it) !

      Saw email, sorry, just been hectic.

      TC

      DC

Comments are closed.