version 1.1
authorkalken <redacted>
Tue, 11 Feb 2025 10:09:12 +0000 (11:09 +0100)
committerkalken <redacted>
Mon, 24 Feb 2025 16:05:26 +0000 (17:05 +0100)
README.md
wg-mullvad.py

index 5f1462d9158637d97d0430b84d6ad6f984c4ebd9..83fe7ef378a07deb801a5533ef8050bd8026bc7c 100644 (file)
--- a/README.md
+++ b/README.md
@@ -2,8 +2,43 @@
 
 This repository contains a python script that generates WireGuard®[^1] configuration files for all Mullvad relays.
 
-Use --help to see how to use it.
-
 The script can generate, save and reuse a private key when generating configurations.
 
+## User guide:
+There are some builtin functions to help the user: To see all options use **--help**.
+
+### Settings file
+If the **--settings-file** does not exist it will be created, and a private key for WireGuard will be generated.
+Everytime the program is executed, it will login to the account and check that the device exists. If not it will try to create it. Any file in ini format that contains a "privatekey"-entry is a valid settings file. Even an existing configuration file can be used as a settings file as long as it contains the private key.
+
+### Filter
+**--filter** is an option to limit the number of files that will be edited or created. The filter search will match any hostname that contains the given term. **--filter se-got** would generate files for all relays in Gothenburg Sweden. Standard name scheme for relays are: **\<country-city-type-identifier>**.
+
+### Multihop
+**--multihop-server** has a built in search. If the specified term does not respond to exactly 1 server, it will show the servers matching the search term. Example: **--multihop-server se-got** will show all the possible multihop-servers in Gothenburg Sweden. **--multihop-server se-got-wg-001** would set this server as multihop server in all configurations.
+
+### Files and Folders
+Files will only be edited or added, never removed. If you want to start over, just remove the folder with configuration files created, e.g. **(~/.config/mullvad/wg0)**. Its also possible to run the program many times to add different setups.
+
+## Examples
+
+    # Create WireGuard files for all relays in Gothenburg:
+    ./wg-mullvad.py --account <myaccountnumber> --filter se-got
+    
+    # Create files for all relays in Stockholm
+    ./wg-mullvad.py --account <myaccountnumber> --filter se-sto
+    
+    # Create files for all servers via se-got-wg-001
+    ./wg-mullvad.py --account <myaccountnumber> --multihop-server se-got-wg-001
+    
+    # Create multihop files for servers in Stockholm via se-got-wg-003
+    ./wg-mullvad.py --account <myaccountnumber> --multihop-server se-got-wg-003 --filter se-sto
+    
+## Multihop options
+ There are 3 different ways to do multihop with mullvad.
+ 1. Multihop by using special port in WireGuard config (described above).
+ 2. Using [Socks](https://mullvad.net/en/help/different-entryexit-node-using-wireguard-and-socks5-proxy).
+ 3. Using a WireGuard tunnel in a WireGuard tunnel (requires more complex routing setup and outside the scope of this guide).
+
 [^1]: "WireGuard" is a registered trademark of Jason A. Donenfeld.
\ No newline at end of file
index e58ea5eeadfb656f932c1ba48b4bf265497b8a67..a8917732254014d6ef3bf2588d4facedebc768ef 100755 (executable)
@@ -8,11 +8,12 @@ import json
 import sys
 import ipaddress
 import base64
+import gzip
 from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
 from cryptography.hazmat.primitives import serialization
 
 
-_version = '1.0'
+_version = '1.1'
 
 
 def generate_publickey(privatekey):
@@ -47,12 +48,28 @@ class Mullvad:
         self._wg_relay_ipv6 = args.wg_relay_ipv6
         self._wg_dns = args.wg_dns
         self._wg_hijack_dns = args.wg_hijack_dns
+        self._wg_multihop_server = args.wg_multihop_server
         self._webtoken = None
+        self._filter = args.filter
 
         self._config = configparser.ConfigParser()
         self._settings_file = pathlib.Path(self._settings_file).expanduser()
 
     def run(self):
+        multihop_server = False
+        if self._wg_multihop_server:
+            multihop_servers = self.filter(self.get_multihop_info(), self._wg_multihop_server)
+            if len(multihop_servers) == 1:
+                multihop_server = multihop_servers[0]
+            elif len(multihop_servers) >= 1:
+                print('Select one of the following multihop servers:')
+                for server in multihop_servers:
+                    print(f'{server["hostname"]}')
+                sys.exit(1)
+            else:
+                print(f'No multihop-server matching hostname: {self._wg_multihop_server}')
+                sys.exit(1)
+
         if self._settings_file.is_file():
             privatekey = self.get_privatekey()
         else:
@@ -62,7 +79,7 @@ class Mullvad:
         publickey = generate_publickey(privatekey)
         device = self.get_device(publickey) or self.create_device(publickey)
         if device:
-            self.create_wg_configs(device, privatekey)
+            self.create_wg_configs(device, privatekey, multihop_server)
 
     def get_privatekey(self):
         print(f'Reading settings from: {self._settings_file}')
@@ -102,12 +119,21 @@ class Mullvad:
         webtoken = self.get_webtoken()
         req = urllib.request.Request(url)
         req.add_header('Authorization', f'Bearer {webtoken}')
+        req.add_header('Accept-Encoding', 'gzip')
+
         if body:
             req.add_header('Content-Type', 'application/json')
             response = urllib.request.urlopen(req, json.dumps(body).encode())
         else:
             response = urllib.request.urlopen(req)
-        data = json.loads(response.read())
+
+        return self.get_response(response)
+
+    def get_response(self, response):
+        if response.headers.get('Content-Encoding') == 'gzip':
+            data = json.loads(gzip.decompress(response.read()))
+        else:
+            data = json.loads(response.read())
         return data
 
     def get_device(self, publickey):
@@ -138,6 +164,7 @@ class Mullvad:
         body['hijack_dns'] = self._wg_hijack_dns
         try:
             response = self.api('https://api.mullvad.net/accounts/v1/devices', body)
+            print(f'Device created: ({response['name']}) {response['pubkey']}')
             return response
         except urllib.error.HTTPError as e:
             error_message = json.loads(e.read())
@@ -153,44 +180,91 @@ class Mullvad:
 
     def get_wireguard_info(self):
         try:
-            response = urllib.request.urlopen('https://api.mullvad.net/public/relays/wireguard/v2/')
-            data = json.loads(response.read())
+            req = urllib.request.Request('https://api.mullvad.net/public/relays/wireguard/v2/')
+            req.add_header('Accept-Encoding', 'gzip')
+            response = urllib.request.urlopen(req)
+            data = self.get_response(response)
             return data['wireguard']
         except urllib.error.HTTPError as e:
-            error_message = json.loads(e.read())
+            error_message = self.get_response(e)
+            print(error_message)
+            sys.exit(1)
+
+    def get_multihop_info(self):
+        try:
+            req = urllib.request.Request('https://api.mullvad.net/www/relays/all')
+            req.add_header('Accept-Encoding', 'gzip')
+            response = urllib.request.urlopen(req)
+            data = self.get_response(response)
+            return [i for i in data if i['type'] == 'wireguard']
+        except urllib.error.HTTPError as e:
+            error_message = self.get_response(e)
             print(error_message)
             sys.exit(1)
 
-    def create_wg_configs(self, device, privatekey):
+    def filter(self, data, _filter):
+        if _filter:
+            return [d for d in data if _filter in d['hostname']]
+        return data
+
+    def create_wg_configs(self, device, privatekey, multihop_server):
         wg = self.get_wireguard_info()
         output_dir = pathlib.Path(self._output_dir).expanduser()
         output_dir.mkdir(exist_ok=True, parents=True)
-        print(f'Creating files in: {output_dir}')
-        for relay in wg['relays']:
-            _hostname = relay['hostname']
-            _filepath = pathlib.Path.joinpath(output_dir, f'{_hostname}.conf')
-            _filepath.touch(mode=0o600, exist_ok=True)
-            with _filepath.open('w') as _file:
-                config = configparser.ConfigParser()
-                config.add_section('Interface')
-                config.set('Interface', '#device', device['name'])
-                config.set('Interface', 'privateKey', privatekey)
-                config.set('Interface', 'address',  ','.join([device['ipv4_address'], device['ipv6_address']]))
-                if self._wg_dns:
-                    config.set('Interface', 'dns', ','.join([str(x) for x in self._wg_dns]))
-                else:
-                    config.set('Interface', 'dns', ','.join([wg['ipv4_gateway'], wg['ipv6_gateway']]))
-                config.add_section('Peer')
-                config.set('Peer', '#owned', str(relay['owned']))
-                config.set('Peer', '#provider', relay['provider'])
-                config.set('Peer', 'publickey', relay['public_key'])
-                config.set('Peer', 'allowedips', '0.0.0.0/0,::/0')
-                if self._wg_relay_ipv6:
-                    wg_relay_address = relay['ipv6_addr_in']
-                else:
-                    wg_relay_address = relay['ipv4_addr_in']
-                config.set('Peer', 'endpoint', f'{wg_relay_address}:{self._wg_relay_port}')
-                config.write(_file)
+        config = configparser.ConfigParser()
+        config.add_section('Interface')
+        config.set('Interface', '#device', device['name'])
+        config.set('Interface', 'privateKey', privatekey)
+        config.set('Interface', 'address',  ','.join([device['ipv4_address'], device['ipv6_address']]))
+        if self._wg_dns:
+            config.set('Interface', 'dns', ','.join([str(x) for x in self._wg_dns]))
+        else:
+            config.set('Interface', 'dns', ','.join([wg['ipv4_gateway'], wg['ipv6_gateway']]))
+        config.add_section('Peer')
+
+        if multihop_server:
+            _servername = multihop_server["hostname"]
+            relays = self.filter(self.get_multihop_info(), self._filter)
+            if relays:
+                print(f'Creating files in: {output_dir}')
+                for relay in relays:
+                    _hostname = relay['hostname']
+                    _filepath = pathlib.Path.joinpath(output_dir, f'{_hostname}-via-{_servername}.conf')
+                    _filepath.touch(mode=0o600, exist_ok=True)
+                    with _filepath.open('w') as _file:
+                        config.set('Peer', '#owned', str(relay['owned']))
+                        config.set('Peer', '#provider', relay['provider'])
+                        config.set('Peer', 'publickey', relay['pubkey'])
+                        config.set('Peer', 'allowedips', '0.0.0.0/0,::/0')
+                        if self._wg_relay_ipv6:
+                            wg_relay_address = multihop_server['ipv6_addr_in']
+                        else:
+                            wg_relay_address = multihop_server['ipv4_addr_in']
+                        config.set('Peer', 'endpoint', f'{wg_relay_address}:{relay["multihop_port"]}')
+                        config.write(_file)
+            else:
+                print(f'No relays matching filter: {self._filter}')
+        else:
+            relays = self.filter(wg['relays'], self._filter)
+            if relays:
+                print(f'Creating files in: {output_dir}')
+                for relay in relays:
+                    _hostname = relay['hostname']
+                    _filepath = pathlib.Path.joinpath(output_dir, f'{_hostname}.conf')
+                    _filepath.touch(mode=0o600, exist_ok=True)
+                    with _filepath.open('w') as _file:
+                        config.set('Peer', '#owned', str(relay['owned']))
+                        config.set('Peer', '#provider', relay['provider'])
+                        config.set('Peer', 'publickey', relay['public_key'])
+                        config.set('Peer', 'allowedips', '0.0.0.0/0,::/0')
+                        if self._wg_relay_ipv6:
+                            wg_relay_address = relay['ipv6_addr_in']
+                        else:
+                            wg_relay_address = relay['ipv4_addr_in']
+                        config.set('Peer', 'endpoint', f'{wg_relay_address}:{self._wg_relay_port}')
+                        config.write(_file)
+            else:
+                print(f'No relays matching filter: {self._filter}')
 
 
 def validate_account(value):
@@ -237,12 +311,21 @@ def main():
     parser.add_argument(
         '--ipv6', dest='wg_relay_ipv6', help='use ipv6 address for relays in wireguard configs', action='store_true')
     parser.add_argument(
-            '--version', help='show version information', action='version', version=f'%(prog)s-{_version}')
+        '--multihop-server', dest='wg_multihop_server', action='store', default=None, help='use multihop server')
+    parser.add_argument(
+        '--filter', action='store', default=None,
+        help='filter relay list before creating configuration files')
+    parser.add_argument(
+        '--version', help='show version information', action='version', version=f'%(prog)s-{_version}')
 
     args = parser.parse_args()
 
-    mullvad = Mullvad(args)
-    mullvad.run()
+    try:
+        mullvad = Mullvad(args)
+        mullvad.run()
+    except Exception as e:
+        print('Error:', e)
+        sys.exit(1)
 
 
 if __name__ == '__main__':
git clone https://git.99rst.org/PROJECT