elliot.science

 elliot.science

Building a Lightweight WireGuard Manager for Mullvad

2026-03-21
/projects#python#mullvad#wireguard#gentoo

Preface

This script was designed to provide a lightweight, scriptable alternative to the official mullvad client. The script only requires python3, wireguard-tools (for wg and wg-quick) and then doas/sudo (required for system operations). Most systems come with python3 and sudo usually, only requiring WireGuard to be installed.

This was originally singular monolithic script written in bash, but quite quickly went past what you should in bash. The original was made as an alternative for no UI and no OpenVPN, since this time Mullvad has offically dropped OpenVPN in their official client, link to article.

Server Configuration

The setup script, mullvad-setup.py, automates the process of fetching the relay list, generating keys, and writing configuration files to /etc/wireguard.

The script fetches the latest server list from the Mullvad API:

API_RELAYS_URL = "https://api.mullvad.net/public/relays/wireguard/v1/"

def fetch_relays():
    """Fetches the list of WireGuard relays."""
    print(f"{CYAN}[+] Fetching server list from Mullvad...{NC}")
    try:
        with urllib.request.urlopen(API_RELAYS_URL) as response:
            return json.loads(response.read().decode('utf-8'))
    except Exception as e:
        error_exit(f"Unable to fetch relay list: {e}")

It then iterates through the returned JSON to generate individual configuration files for each relay, assigning the user’s specific IP address and DNS settings:

def write_configs(relays_data: dict, private_key: str, address: str):
    """Generates configuration files for all relays."""
    # ...
    config_content = f"""[Interface]
PrivateKey = {private_key}
Address = {address}
DNS = {DNS_SERVER}

[Peer]
PublicKey = {pubkey}
Endpoint = {ipv4}:51820
AllowedIPs = 0.0.0.0/0, ::/0
"""

Manager Script

The core manager (src/mullvad.py) acts as a wrapper around wg-quick and the Mullvad verification API. It automatically handles privilege escalation via doas or sudo when required.

The connection logic verifies the server exists in the local cache before attempting to bring up the interface:

def connect(target_server: str) -> bool:
    """Connects to the specified server."""
    servers = get_server_list()

    if servers and target_server not in servers:
        print(f"{RED}Error: Server '{target_server}' not found in server list.{NC}")
        return False
    # ...
    result = run_command(["wg-quick", "up", config_path])

Verification

The tool includes a verification command that checks the connection against Mullvad’s API to ensure the tunnel is active:

def verify() -> int:
    """Checks connection status via Mullvad API."""
    try:
        response = urllib.request.urlopen('https://am.i.mullvad.net/json', timeout=5)
        data = json.loads(response.read().decode('utf-8'))
        
        mullvad_exit_ip = data.get('mullvad_exit_ip', False)
        # ...
        if mullvad_exit_ip:
            print(f"{GREEN}Connection Verified!{NC}")
            return 0
    except Exception as e:
        print(f"{RED}Verification failed: {e}{NC}")
        return 1

Usage

# List available servers
mullvad list

# Connect to a specific server
mullvad connect se-sto-wg-011

# Connect to a random server in a specific region (e.g., Sweden)
mullvad random se

# Verify the connection
mullvad verify
1

Transition to Official Client: I have since transitioned from this custom Python script to the official Mullvad client. While the custom script served its purpose, the official client offers significant advantages for a production Gentoo environment.

Comparison Summary:

FeatureCustom Python ScriptOfficial Mullvad Client
Setup & Configmullvad-setup.py fetches list; generates /etc/wireguard/*.conf.mullvad-vpn handles config generation and updates automatically.
Connection HandlingWrapper around wg-quick (wg-quick up/down).Dedicated daemon handling interface bring-up/down. With the upcoming addition of Mullvad’s transition to gotatun.
FirewallingRelies on system firewall (iptables/nftables) configured separately.Built-in firewall rules to prevent traffic leaks before tunnel is active.
Kill SwitchNone (requires external script).Native “Disconnect Killswitch” feature.
Relay SelectionManual or simple random command based on cached list.Robust selection logic (country, city, ISP, exit country, etc.).
VerificationChecks IP via am.i.mullvad.net/json (manual).Built-in verification tool mullvad status.
MaintenanceRequires manual script updates and config cache refreshing.Managed via portage (Gentoo ebuilds) with dependency tracking.

Conclusion: The shift to the official client eliminates the need to maintain the Python script and the setup utility. It also ensures that security patches and relay updates are handled by the package manager, rather than relying on a local cache file. The native firewall integration provides better protection against accidental leaks during connection drops, which was a limitation of the wg-quick based approach.