nord-config-generator 1.0.4__tar.gz → 1.0.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. {nord_config_generator-1.0.4 → nord_config_generator-1.0.5}/PKG-INFO +24 -2
  2. nord_config_generator-1.0.5/README.md +21 -0
  3. {nord_config_generator-1.0.4 → nord_config_generator-1.0.5}/pyproject.toml +31 -32
  4. nord_config_generator-1.0.5/src/nord_config_generator/client.py +53 -0
  5. nord_config_generator-1.0.5/src/nord_config_generator/generator.py +212 -0
  6. nord_config_generator-1.0.5/src/nord_config_generator/main.py +101 -0
  7. nord_config_generator-1.0.5/src/nord_config_generator/models.py +39 -0
  8. nord_config_generator-1.0.5/src/nord_config_generator/ui.py +118 -0
  9. {nord_config_generator-1.0.4 → nord_config_generator-1.0.5}/src/nord_config_generator.egg-info/PKG-INFO +24 -2
  10. {nord_config_generator-1.0.4 → nord_config_generator-1.0.5}/src/nord_config_generator.egg-info/SOURCES.txt +4 -0
  11. nord_config_generator-1.0.4/src/nord_config_generator/main.py +0 -384
  12. nord_config_generator-1.0.4/src/nord_config_generator/ui.py +0 -73
  13. {nord_config_generator-1.0.4 → nord_config_generator-1.0.5}/setup.cfg +0 -0
  14. {nord_config_generator-1.0.4 → nord_config_generator-1.0.5}/src/nord_config_generator/__init__.py +0 -0
  15. {nord_config_generator-1.0.4 → nord_config_generator-1.0.5}/src/nord_config_generator.egg-info/dependency_links.txt +0 -0
  16. {nord_config_generator-1.0.4 → nord_config_generator-1.0.5}/src/nord_config_generator.egg-info/entry_points.txt +0 -0
  17. {nord_config_generator-1.0.4 → nord_config_generator-1.0.5}/src/nord_config_generator.egg-info/requires.txt +0 -0
  18. {nord_config_generator-1.0.4 → nord_config_generator-1.0.5}/src/nord_config_generator.egg-info/top_level.txt +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nord-config-generator
3
- Version: 1.0.4
3
+ Version: 1.0.5
4
4
  Summary: A command-line tool for generating optimized NordVPN WireGuard configurations.
5
5
  Author-email: Ahmed Touhami <mustafachyi272@gmail.com>
6
+ License-Expression: GPL-3.0-or-later
6
7
  Project-URL: Homepage, https://github.com/mustafachyi/NordVPN-WireGuard-Config-Generator
7
8
  Project-URL: Bug Tracker, https://github.com/mustafachyi/NordVPN-WireGuard-Config-Generator/issues
8
9
  Classifier: Programming Language :: Python :: 3
9
- Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
10
10
  Classifier: Operating System :: OS Independent
11
11
  Classifier: Topic :: System :: Networking
12
12
  Classifier: Environment :: Console
@@ -15,3 +15,25 @@ Description-Content-Type: text/markdown
15
15
  Requires-Dist: aiohttp<4.0,>=3.12.14
16
16
  Requires-Dist: aiofiles<25.0,>=24.1.0
17
17
  Requires-Dist: rich<15.0,>=14.0.0
18
+
19
+ # NordVPN WireGuard Config Generator
20
+
21
+ A command-line tool for generating optimized NordVPN WireGuard configurations.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install nord-config-generator
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ Generate configurations:
32
+ ```bash
33
+ nordgen
34
+ ```
35
+
36
+ Retrieve Private Key:
37
+ ```bash
38
+ nordgen get-key
39
+ ```
@@ -0,0 +1,21 @@
1
+ # NordVPN WireGuard Config Generator
2
+
3
+ A command-line tool for generating optimized NordVPN WireGuard configurations.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install nord-config-generator
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ Generate configurations:
14
+ ```bash
15
+ nordgen
16
+ ```
17
+
18
+ Retrieve Private Key:
19
+ ```bash
20
+ nordgen get-key
21
+ ```
@@ -1,33 +1,32 @@
1
- [build-system]
2
- requires = ["setuptools>=61.0"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "nord-config-generator"
7
- version = "1.0.4"
8
- authors = [
9
- { name="Ahmed Touhami", email="mustafachyi272@gmail.com" },
10
- ]
11
- description = "A command-line tool for generating optimized NordVPN WireGuard configurations."
12
- readme = "README.md"
13
- license = { file="LICENSE" }
14
- requires-python = ">=3.9"
15
- classifiers = [
16
- "Programming Language :: Python :: 3",
17
- "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
18
- "Operating System :: OS Independent",
19
- "Topic :: System :: Networking",
20
- "Environment :: Console",
21
- ]
22
- dependencies = [
23
- "aiohttp>=3.12.14, <4.0",
24
- "aiofiles>=24.1.0, <25.0",
25
- "rich>=14.0.0, <15.0",
26
- ]
27
-
28
- [project.urls]
29
- "Homepage" = "https://github.com/mustafachyi/NordVPN-WireGuard-Config-Generator"
30
- "Bug Tracker" = "https://github.com/mustafachyi/NordVPN-WireGuard-Config-Generator/issues"
31
-
32
- [project.scripts]
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "nord-config-generator"
7
+ version = "1.0.5"
8
+ authors = [
9
+ { name="Ahmed Touhami", email="mustafachyi272@gmail.com" },
10
+ ]
11
+ description = "A command-line tool for generating optimized NordVPN WireGuard configurations."
12
+ readme = "README.md"
13
+ license = "GPL-3.0-or-later"
14
+ requires-python = ">=3.9"
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: System :: Networking",
19
+ "Environment :: Console",
20
+ ]
21
+ dependencies = [
22
+ "aiohttp>=3.12.14, <4.0",
23
+ "aiofiles>=24.1.0, <25.0",
24
+ "rich>=14.0.0, <15.0",
25
+ ]
26
+
27
+ [project.urls]
28
+ "Homepage" = "https://github.com/mustafachyi/NordVPN-WireGuard-Config-Generator"
29
+ "Bug Tracker" = "https://github.com/mustafachyi/NordVPN-WireGuard-Config-Generator/issues"
30
+
31
+ [project.scripts]
33
32
  nordgen = "nord_config_generator.main:cli_entry_point"
@@ -0,0 +1,53 @@
1
+ import aiohttp
2
+ import json
3
+ import base64
4
+ from typing import Optional, List, Dict, Tuple
5
+
6
+ class NordClient:
7
+ BASE_URL = "https://api.nordvpn.com/v1"
8
+ GEO_URL = "https://api.nordvpn.com/v1/helpers/ips/insights"
9
+
10
+ def __init__(self):
11
+ self._session: Optional[aiohttp.ClientSession] = None
12
+
13
+ async def __aenter__(self):
14
+ timeout = aiohttp.ClientTimeout(total=20)
15
+ connector = aiohttp.TCPConnector(limit=10, ttl_dns_cache=300)
16
+ self._session = aiohttp.ClientSession(timeout=timeout, connector=connector)
17
+ return self
18
+
19
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
20
+ if self._session:
21
+ await self._session.close()
22
+
23
+ async def get_key(self, token: str) -> Optional[str]:
24
+ if not self._session: return None
25
+ try:
26
+ auth = base64.b64encode(f'token:{token}'.encode()).decode()
27
+ headers = {'Authorization': f'Basic {auth}'}
28
+ async with self._session.get(f"{self.BASE_URL}/users/services/credentials", headers=headers) as resp:
29
+ if resp.status != 200: return None
30
+ data = await resp.json()
31
+ return data.get('nordlynx_private_key')
32
+ except (aiohttp.ClientError, json.JSONDecodeError):
33
+ return None
34
+
35
+ async def get_servers(self) -> List[Dict]:
36
+ if not self._session: return []
37
+ try:
38
+ params = {'limit': '16384', 'filters[servers_technologies][identifier]': 'wireguard_udp'}
39
+ async with self._session.get(f"{self.BASE_URL}/servers", params=params) as resp:
40
+ if resp.status != 200: return []
41
+ return await resp.json()
42
+ except (aiohttp.ClientError, json.JSONDecodeError):
43
+ return []
44
+
45
+ async def get_geo(self) -> Tuple[float, float]:
46
+ if not self._session: return 0.0, 0.0
47
+ try:
48
+ async with self._session.get(self.GEO_URL) as resp:
49
+ if resp.status != 200: return 0.0, 0.0
50
+ data = await resp.json()
51
+ return float(data.get('latitude', 0)), float(data.get('longitude', 0))
52
+ except (aiohttp.ClientError, json.JSONDecodeError, ValueError):
53
+ return 0.0, 0.0
@@ -0,0 +1,212 @@
1
+ import asyncio
2
+ import os
3
+ import aiofiles
4
+ from typing import List, Dict, Optional, Set
5
+ from math import radians, sin, cos, asin, sqrt
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from rich.progress import TaskID
10
+
11
+ from .client import NordClient
12
+ from .ui import ConsoleManager
13
+ from .models import Server, UserPreferences, Stats
14
+
15
+ class Generator:
16
+ MIN_VER_MAJOR = 2
17
+ MIN_VER_MINOR = 1
18
+
19
+ def __init__(self, client: NordClient, ui: ConsoleManager):
20
+ self.client = client
21
+ self.ui = ui
22
+ self.stats = Stats()
23
+ self.dir_cache: Set[str] = set()
24
+ self.out_dir = Path(f'nordvpn_configs_{datetime.now().strftime("%Y%m%d_%H%M%S")}')
25
+ self.path_sanitizer = str.maketrans('', '', '<>:"/\\|?*\0')
26
+
27
+ async def process(self, key: str, prefs: UserPreferences):
28
+ self.ui.spin("Fetching data...")
29
+
30
+ geo_task = asyncio.create_task(self.client.get_geo())
31
+ srv_task = asyncio.create_task(self.client.get_servers())
32
+
33
+ lat, lon = await geo_task
34
+ raw_servers = await srv_task
35
+
36
+ if not raw_servers:
37
+ self.ui.fail("Failed to fetch server data")
38
+ return
39
+
40
+ self.ui.success("Data fetched")
41
+ self.ui.spin("Processing dataset...")
42
+
43
+ loop = asyncio.get_running_loop()
44
+ with ThreadPoolExecutor(max_workers=os.cpu_count() or 4) as pool:
45
+ processed = await loop.run_in_executor(pool, self._parse_batch, raw_servers, lat, lon)
46
+
47
+ unique = {s.name: s for s in processed}
48
+ final_list = list(unique.values())
49
+ final_list.sort(key=lambda x: (x.load, x.distance))
50
+
51
+ self.stats.rejected = len(raw_servers) - len(final_list)
52
+ self.stats.total = len(final_list)
53
+
54
+ best_map = {}
55
+ for s in final_list:
56
+ k = (s.country, s.city)
57
+ if k not in best_map or s.load < best_map[k].load:
58
+ best_map[k] = s
59
+
60
+ self.stats.best = len(best_map)
61
+ best_list = list(best_map.values())
62
+
63
+ self.out_dir.mkdir(exist_ok=True)
64
+ self.dir_cache.add(str(self.out_dir))
65
+
66
+ self.ui.start_progress()
67
+ t1 = self.ui.add_task("Standard Configs", self.stats.total)
68
+ t2 = self.ui.add_task("Optimized Configs", self.stats.best)
69
+
70
+ await asyncio.gather(
71
+ self._write_batch(final_list, "configs", t1, key, prefs),
72
+ self._write_batch(best_list, "best_configs", t2, key, prefs)
73
+ )
74
+ self.ui.stop_progress()
75
+
76
+ return str(self.out_dir)
77
+
78
+ def _parse_batch(self, raw: List[Dict], lat: float, lon: float) -> List[Server]:
79
+ results = []
80
+ for d in raw:
81
+ s = self._parse_one(d, lat, lon)
82
+ if s:
83
+ results.append(s)
84
+ return results
85
+
86
+ def _parse_one(self, data: Dict, lat: float, lon: float) -> Optional[Server]:
87
+ try:
88
+ locs = data.get('locations')
89
+ if not locs: return None
90
+
91
+ ver_str = "0.0.0"
92
+ for s in data.get('specifications', []):
93
+ if s.get('identifier') == 'version':
94
+ vals = s.get('values')
95
+ if vals: ver_str = vals[0].get('value', "0.0.0")
96
+ break
97
+
98
+ if not self._check_version(ver_str):
99
+ return None
100
+
101
+ pk = ""
102
+ for t in data.get('technologies', []):
103
+ if t.get('identifier') == 'wireguard_udp':
104
+ for m in t.get('metadata', []):
105
+ if m.get('name') == 'public_key':
106
+ pk = m.get('value')
107
+ break
108
+ if not pk: return None
109
+
110
+ loc = locs[0]
111
+ d = self._haversine(lat, lon, loc['latitude'], loc['longitude'])
112
+
113
+ return Server(
114
+ name=data['name'],
115
+ hostname=data['hostname'],
116
+ station=data['station'],
117
+ load=int(data.get('load', 0)),
118
+ country=loc['country']['name'],
119
+ country_code=loc['country']['code'].lower(),
120
+ city=loc['country']['city']['name'],
121
+ latitude=loc['latitude'],
122
+ longitude=loc['longitude'],
123
+ public_key=pk,
124
+ distance=d
125
+ )
126
+ except (KeyError, IndexError, ValueError, TypeError):
127
+ return None
128
+
129
+ def _check_version(self, v: str) -> bool:
130
+ if len(v) < 3: return False
131
+ try:
132
+ parts = v.split('.')
133
+ if len(parts) < 2: return False
134
+ maj = int(parts[0])
135
+ if maj > 2: return True
136
+ return maj == 2 and int(parts[1]) >= 1
137
+ except ValueError:
138
+ return False
139
+
140
+ def _haversine(self, lat1: float, lon1: float, lat2: float, lon2: float) -> float:
141
+ R = 6371
142
+ dlat = radians(lat2 - lat1)
143
+ dlon = radians(lon2 - lon1)
144
+ a = sin(dlat/2)**2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon/2)**2
145
+ c = 2 * asin(sqrt(a))
146
+ return R * c
147
+
148
+ async def _write_batch(self, servers: List[Server], sub: str, task_id: TaskID, key: str, prefs: UserPreferences):
149
+ sem = asyncio.Semaphore(200)
150
+ path_counts: Dict[str, int] = {}
151
+
152
+ async def _write(s: Server):
153
+ async with sem:
154
+ country = self._sanitize(s.country)
155
+ city = self._sanitize(s.city)
156
+ base = self._basename(s)
157
+ rel = f"{sub}/{country}/{city}/{base}"
158
+
159
+ count = path_counts.get(rel, 0)
160
+ path_counts[rel] = count + 1
161
+
162
+ fname = base
163
+ if count > 0:
164
+ raw_base = base[:-5]
165
+ fname = f"{raw_base}_{count}.conf"
166
+
167
+ full_dir = self.out_dir / sub / country / city
168
+ self._ensure_dir(full_dir)
169
+
170
+ ep = s.station if prefs.use_ip else s.hostname
171
+ cfg = (
172
+ f"[Interface]\nPrivateKey = {key}\nAddress = 10.5.0.2/16\nDNS = {prefs.dns}\n\n"
173
+ f"[Peer]\nPublicKey = {s.public_key}\nAllowedIPs = 0.0.0.0/0, ::/0\n"
174
+ f"Endpoint = {ep}:51820\nPersistentKeepalive = {prefs.keepalive}"
175
+ )
176
+
177
+ async with aiofiles.open(full_dir / fname, 'w') as f:
178
+ await f.write(cfg)
179
+ self.ui.update_progress(task_id)
180
+
181
+ tasks = [_write(s) for s in servers]
182
+ await asyncio.gather(*tasks)
183
+
184
+ def _ensure_dir(self, path: Path):
185
+ s_path = str(path)
186
+ if s_path in self.dir_cache: return
187
+ try:
188
+ path.mkdir(parents=True, exist_ok=True)
189
+ self.dir_cache.add(s_path)
190
+ except OSError:
191
+ pass
192
+
193
+ def _sanitize(self, s: str) -> str:
194
+ return s.lower().replace(' ', '_').translate(self.path_sanitizer)
195
+
196
+ def _basename(self, s: Server) -> str:
197
+ name = s.name
198
+ num = ""
199
+ for i in range(len(name)-1, -1, -1):
200
+ if name[i].isdigit():
201
+ start = i
202
+ while start >= 0 and name[start].isdigit():
203
+ start -= 1
204
+ num = name[start+1:i+1]
205
+ break
206
+
207
+ if not num:
208
+ fallback = f"wg{s.station.replace('.', '')}"
209
+ return f"{fallback[:15]}.conf"
210
+
211
+ base = f"{s.country_code}{num}"
212
+ return f"{base[:15]}.conf"
@@ -0,0 +1,101 @@
1
+ import sys
2
+ import asyncio
3
+ import argparse
4
+ import time
5
+
6
+ from .ui import ConsoleManager
7
+ from .client import NordClient
8
+ from .generator import Generator
9
+ from .models import UserPreferences
10
+
11
+ async def main():
12
+ ui = ConsoleManager()
13
+ async with NordClient() as client:
14
+ if len(sys.argv) > 1 and sys.argv[1] == "get-key":
15
+ await run_get_key(ui, client)
16
+ else:
17
+ await run_generate(ui, client)
18
+
19
+ async def run_get_key(ui: ConsoleManager, client: NordClient):
20
+ parser = argparse.ArgumentParser(prog="nordgen get-key", description="Retrieve Private Key")
21
+ parser.add_argument('-t', '--token', help='NordVPN Access Token')
22
+
23
+ args = parser.parse_args(sys.argv[2:])
24
+
25
+ ui.clear()
26
+ ui.header()
27
+
28
+ key = await resolve_key(ui, client, args.token)
29
+ if key:
30
+ ui.show_key(key)
31
+ ui.wait()
32
+
33
+ async def run_generate(ui: ConsoleManager, client: NordClient):
34
+ parser = argparse.ArgumentParser(prog="nordgen", description="NordVPN WireGuard Config Generator")
35
+ parser.add_argument('-t', '--token', help='NordVPN Access Token')
36
+ parser.add_argument('-d', '--dns', default='103.86.96.100', help='DNS Server')
37
+ parser.add_argument('-i', '--ip', action='store_true', help='Use IP Endpoint')
38
+ parser.add_argument('-k', '--keepalive', type=int, default=25, help='Persistent Keepalive')
39
+
40
+ args = parser.parse_args(sys.argv[1:])
41
+
42
+ ui.clear()
43
+ ui.header()
44
+
45
+ prefs = UserPreferences(args.dns, args.ip, args.keepalive)
46
+ key = ""
47
+
48
+ if not args.token:
49
+ key = await resolve_key(ui, client, "")
50
+ if not key:
51
+ ui.wait()
52
+ return
53
+
54
+ ui.clear()
55
+ ui.header()
56
+ prefs = ui.prompt_prefs(prefs)
57
+ else:
58
+ key = await resolve_key(ui, client, args.token)
59
+ if not key:
60
+ ui.wait()
61
+ return
62
+
63
+ ui.clear()
64
+ ui.header()
65
+
66
+ gen = Generator(client, ui)
67
+ start = time.time()
68
+ out = await gen.process(key, prefs)
69
+
70
+ if out:
71
+ ui.clear()
72
+ ui.header()
73
+ ui.summary(out, gen.stats, time.time() - start)
74
+
75
+ ui.wait()
76
+
77
+ async def resolve_key(ui: ConsoleManager, client: NordClient, token: str) -> str:
78
+ if not token:
79
+ token = ui.prompt_secret("Please enter your NordVPN access token: ")
80
+
81
+ if len(token) != 64:
82
+ ui.error("Invalid token format")
83
+ return ""
84
+
85
+ ui.spin("Validating token...")
86
+ key = await client.get_key(token)
87
+ if not key:
88
+ ui.fail("Token invalid")
89
+ return ""
90
+
91
+ ui.success("Token validated")
92
+ return key
93
+
94
+ def cli_entry_point():
95
+ try:
96
+ asyncio.run(main())
97
+ except KeyboardInterrupt:
98
+ pass
99
+
100
+ if __name__ == "__main__":
101
+ cli_entry_point()
@@ -0,0 +1,39 @@
1
+ class Server:
2
+ __slots__ = (
3
+ 'name', 'hostname', 'station', 'load', 'country',
4
+ 'country_code', 'city', 'latitude', 'longitude',
5
+ 'public_key', 'distance'
6
+ )
7
+
8
+ def __init__(
9
+ self, name: str, hostname: str, station: str, load: int,
10
+ country: str, country_code: str, city: str,
11
+ latitude: float, longitude: float, public_key: str, distance: float
12
+ ):
13
+ self.name = name
14
+ self.hostname = hostname
15
+ self.station = station
16
+ self.load = load
17
+ self.country = country
18
+ self.country_code = country_code
19
+ self.city = city
20
+ self.latitude = latitude
21
+ self.longitude = longitude
22
+ self.public_key = public_key
23
+ self.distance = distance
24
+
25
+ class UserPreferences:
26
+ __slots__ = ('dns', 'use_ip', 'keepalive')
27
+
28
+ def __init__(self, dns: str = "103.86.96.100", use_ip: bool = False, keepalive: int = 25):
29
+ self.dns = dns
30
+ self.use_ip = use_ip
31
+ self.keepalive = keepalive
32
+
33
+ class Stats:
34
+ __slots__ = ('total', 'best', 'rejected')
35
+
36
+ def __init__(self):
37
+ self.total = 0
38
+ self.best = 0
39
+ self.rejected = 0
@@ -0,0 +1,118 @@
1
+ import os
2
+ from typing import TYPE_CHECKING
3
+ from rich.console import Console
4
+ from rich.panel import Panel
5
+ from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TaskID
6
+ from rich.theme import Theme
7
+ from rich.table import Table
8
+
9
+ if TYPE_CHECKING:
10
+ from .models import UserPreferences, Stats
11
+
12
+ class ConsoleManager:
13
+ def __init__(self):
14
+ theme = Theme({
15
+ "info": "cyan",
16
+ "success": "bold green",
17
+ "warning": "yellow",
18
+ "error": "bold red",
19
+ "title": "bold magenta",
20
+ })
21
+ self.console = Console(theme=theme)
22
+ self.progress = None
23
+ self.task_ids = {}
24
+
25
+ def clear(self):
26
+ os.system('cls' if os.name == 'nt' else 'clear')
27
+
28
+ def header(self):
29
+ self.console.print(Panel(
30
+ "[title]NordVPN Configuration Generator[/title]",
31
+ expand=False,
32
+ border_style="cyan",
33
+ padding=(0, 2)
34
+ ))
35
+
36
+ def prompt_secret(self, msg: str) -> str:
37
+ return self.console.input(f"[cyan]{msg}[/cyan]", password=True).strip()
38
+
39
+ def prompt_prefs(self, defaults: "UserPreferences") -> "UserPreferences":
40
+ from .models import UserPreferences
41
+ self.console.print("[info]Configuration Options (Enter for default)[/info]")
42
+
43
+ d_in = self.console.input(f"DNS IP (default: {defaults.dns}): ").strip()
44
+ dns = d_in if d_in else defaults.dns
45
+
46
+ i_in = self.console.input("Use IP for endpoints? (y/N): ").strip().lower()
47
+ use_ip = i_in == 'y'
48
+
49
+ k_in = self.console.input(f"PersistentKeepalive (default: {defaults.keepalive}): ").strip()
50
+ ka = int(k_in) if k_in.isdigit() else defaults.keepalive
51
+
52
+ return UserPreferences(dns, use_ip, ka)
53
+
54
+ def spin(self, msg: str):
55
+ with self.console.status(f"[cyan]{msg}"):
56
+ pass
57
+
58
+ def success(self, msg: str):
59
+ self.console.print(f"[success]{msg}[/success]")
60
+
61
+ def fail(self, msg: str):
62
+ self.console.print(f"[error]{msg}[/error]")
63
+
64
+ def error(self, msg: str):
65
+ self.console.print(f"[error]{msg}[/error]")
66
+
67
+ def show_key(self, key: str):
68
+ self.console.print(Panel(
69
+ f"[green]{key}[/green]",
70
+ title="NordLynx Private Key",
71
+ border_style="green",
72
+ expand=False
73
+ ))
74
+
75
+ def summary(self, path: str, stats: "Stats", sec: float):
76
+ grid = Table.grid(padding=(0, 2))
77
+ grid.add_column(style="cyan")
78
+ grid.add_column()
79
+ grid.add_row("Output Directory:", path)
80
+ grid.add_row("Standard Configs:", str(stats.total))
81
+ grid.add_row("Optimized Configs:", str(stats.best))
82
+ grid.add_row("Incompatible:", f"[yellow]{stats.rejected}[/yellow]")
83
+ grid.add_row("Duration:", f"{sec:.2f}s")
84
+
85
+ self.console.print(Panel(
86
+ grid,
87
+ title="Complete",
88
+ border_style="green",
89
+ expand=False
90
+ ))
91
+
92
+ def start_progress(self):
93
+ self.progress = Progress(
94
+ SpinnerColumn(),
95
+ TextColumn("[progress.description]{task.description}"),
96
+ BarColumn(),
97
+ TextColumn("{task.completed}/{task.total}"),
98
+ console=self.console,
99
+ transient=False
100
+ )
101
+ self.progress.start()
102
+
103
+ def add_task(self, name: str, total: int) -> TaskID:
104
+ if self.progress:
105
+ return self.progress.add_task(name, total=total)
106
+ return TaskID(0)
107
+
108
+ def update_progress(self, task_id: TaskID):
109
+ if self.progress:
110
+ self.progress.update(task_id, advance=1)
111
+
112
+ def stop_progress(self):
113
+ if self.progress:
114
+ self.progress.stop()
115
+
116
+ def wait(self):
117
+ self.console.print()
118
+ self.console.input("[info]Press Enter to exit...[/info]")
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nord-config-generator
3
- Version: 1.0.4
3
+ Version: 1.0.5
4
4
  Summary: A command-line tool for generating optimized NordVPN WireGuard configurations.
5
5
  Author-email: Ahmed Touhami <mustafachyi272@gmail.com>
6
+ License-Expression: GPL-3.0-or-later
6
7
  Project-URL: Homepage, https://github.com/mustafachyi/NordVPN-WireGuard-Config-Generator
7
8
  Project-URL: Bug Tracker, https://github.com/mustafachyi/NordVPN-WireGuard-Config-Generator/issues
8
9
  Classifier: Programming Language :: Python :: 3
9
- Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
10
10
  Classifier: Operating System :: OS Independent
11
11
  Classifier: Topic :: System :: Networking
12
12
  Classifier: Environment :: Console
@@ -15,3 +15,25 @@ Description-Content-Type: text/markdown
15
15
  Requires-Dist: aiohttp<4.0,>=3.12.14
16
16
  Requires-Dist: aiofiles<25.0,>=24.1.0
17
17
  Requires-Dist: rich<15.0,>=14.0.0
18
+
19
+ # NordVPN WireGuard Config Generator
20
+
21
+ A command-line tool for generating optimized NordVPN WireGuard configurations.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install nord-config-generator
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ Generate configurations:
32
+ ```bash
33
+ nordgen
34
+ ```
35
+
36
+ Retrieve Private Key:
37
+ ```bash
38
+ nordgen get-key
39
+ ```
@@ -1,6 +1,10 @@
1
+ README.md
1
2
  pyproject.toml
2
3
  src/nord_config_generator/__init__.py
4
+ src/nord_config_generator/client.py
5
+ src/nord_config_generator/generator.py
3
6
  src/nord_config_generator/main.py
7
+ src/nord_config_generator/models.py
4
8
  src/nord_config_generator/ui.py
5
9
  src/nord_config_generator.egg-info/PKG-INFO
6
10
  src/nord_config_generator.egg-info/SOURCES.txt
@@ -1,384 +0,0 @@
1
- import sys
2
- import os
3
- import asyncio
4
- import json
5
- import base64
6
- import time
7
- from typing import List, Tuple, Optional, Dict, Any
8
- from dataclasses import dataclass
9
- from pathlib import Path
10
- from math import radians, sin, cos, asin, sqrt
11
- from functools import partial
12
- from concurrent.futures import ThreadPoolExecutor
13
- from datetime import datetime
14
-
15
- import aiohttp
16
- import aiofiles
17
-
18
- from .ui import ConsoleManager
19
-
20
- @dataclass
21
- class Server:
22
- name: str
23
- hostname: str
24
- station: str
25
- load: int
26
- country: str
27
- country_code: str
28
- city: str
29
- latitude: float
30
- longitude: float
31
- public_key: str
32
- distance: float = 0.0
33
-
34
- @dataclass
35
- class UserPreferences:
36
- dns: str = "103.86.96.100"
37
- use_ip_for_endpoint: bool = False
38
- persistent_keepalive: int = 25
39
-
40
- @dataclass
41
- class GenerationStats:
42
- total_configs: int = 0
43
- best_configs: int = 0
44
-
45
- class NordVpnApiClient:
46
- NORD_API_BASE_URL = "https://api.nordvpn.com/v1"
47
- LOCATION_API_URL = "https://ipinfo.io/json"
48
-
49
- def __init__(self, console_manager: ConsoleManager):
50
- self._console = console_manager
51
- self._session: Optional[aiohttp.ClientSession] = None
52
-
53
- async def __aenter__(self):
54
- self._session = aiohttp.ClientSession()
55
- return self
56
-
57
- async def __aexit__(self, exc_type, exc_val, exc_tb):
58
- if self._session:
59
- await self._session.close()
60
-
61
- async def get_private_key(self, token: str) -> Optional[str]:
62
- auth_header = base64.b64encode(f'token:{token}'.encode()).decode()
63
- url = f"{self.NORD_API_BASE_URL}/users/services/credentials"
64
- headers = {'Authorization': f'Basic {auth_header}'}
65
- data = await self._get(url, headers=headers)
66
- if isinstance(data, dict):
67
- return data.get('nordlynx_private_key')
68
- return None
69
-
70
- async def get_all_servers(self) -> List[Dict[str, Any]]:
71
- url = f"{self.NORD_API_BASE_URL}/servers"
72
- params = {'limit': 16384, 'filters[servers_technologies][identifier]': 'wireguard_udp'}
73
- data = await self._get(url, params=params)
74
- return data if isinstance(data, list) else []
75
-
76
- async def get_user_geolocation(self) -> Optional[Tuple[float, float]]:
77
- data = await self._get(self.LOCATION_API_URL)
78
- if not isinstance(data, dict):
79
- return None
80
- try:
81
- lat, lon = data.get('loc', '').split(',')
82
- return float(lat), float(lon)
83
- except (ValueError, IndexError):
84
- self._console.print_message("error", "Could not parse location data.")
85
- return None
86
-
87
- async def _get(self, url: str, **kwargs) -> Optional[Any]:
88
- if not self._session:
89
- return None
90
- try:
91
- async with self._session.get(url, **kwargs) as response:
92
- response.raise_for_status()
93
- return await response.json()
94
- except (aiohttp.ClientError, json.JSONDecodeError) as e:
95
- self._console.print_message("error", f"API request failed for {url}: {e}")
96
- return None
97
-
98
- class ConfigurationOrchestrator:
99
- CONCURRENT_LIMIT = 200
100
- _path_sanitizer = str.maketrans('', '', '<>:"/\\|?*\0')
101
-
102
- def __init__(self, private_key: str, preferences: UserPreferences, console_manager: ConsoleManager, api_client: NordVpnApiClient):
103
- self._private_key = private_key
104
- self._preferences = preferences
105
- self._console = console_manager
106
- self._api_client = api_client
107
- self._output_dir = Path(f'nordvpn_configs_{datetime.now().strftime("%Y%m%d_%H%M%S")}')
108
- self._semaphore = asyncio.Semaphore(self.CONCURRENT_LIMIT)
109
- self.stats = GenerationStats()
110
-
111
- async def generate(self) -> Optional[Path]:
112
- user_location, all_servers_data = await self._fetch_remote_data()
113
- if not user_location or not all_servers_data:
114
- return None
115
-
116
- processed_servers = await self._process_server_data(all_servers_data, user_location)
117
-
118
- unique_servers = {}
119
- for s in processed_servers:
120
- if s.name not in unique_servers:
121
- unique_servers[s.name] = s
122
- processed_servers = list(unique_servers.values())
123
-
124
- sorted_servers = sorted(processed_servers, key=lambda s: (s.load, s.distance))
125
- best_servers_by_location = self._get_best_servers(sorted_servers)
126
-
127
- self._output_dir.mkdir(exist_ok=True)
128
- servers_info = self._build_servers_info(sorted_servers)
129
-
130
- await self._save_all_configurations(sorted_servers, best_servers_by_location, servers_info)
131
- return self._output_dir
132
-
133
- async def _fetch_remote_data(self) -> Tuple[Optional[Tuple[float, float]], List[Dict[str, Any]]]:
134
- with self._console.create_progress_bar() as progress:
135
- task = progress.add_task("Fetching remote data...", total=2)
136
- user_location, all_servers_data = await asyncio.gather(
137
- self._api_client.get_user_geolocation(),
138
- self._api_client.get_all_servers()
139
- )
140
- progress.update(task, advance=2)
141
- return user_location, all_servers_data
142
-
143
- async def _process_server_data(self, all_servers_data: List[Dict[str, Any]], user_location: Tuple[float, float]) -> List[Server]:
144
- loop = asyncio.get_running_loop()
145
- parse_func = partial(self._parse_server_data, user_location=user_location)
146
- with ThreadPoolExecutor(max_workers=min(32, (os.cpu_count() or 1) + 4)) as executor:
147
- tasks = [loop.run_in_executor(executor, parse_func, s) for s in all_servers_data]
148
- processed_servers = await asyncio.gather(*tasks)
149
- return [server for server in processed_servers if server]
150
-
151
- def _get_best_servers(self, sorted_servers: List[Server]) -> Dict[Tuple[str, str], Server]:
152
- best = {}
153
- for server in sorted_servers:
154
- key = (server.country, server.city)
155
- if key not in best or server.load < best[key].load:
156
- best[key] = server
157
- return best
158
-
159
- def _build_servers_info(self, sorted_servers: List[Server]) -> Dict:
160
- info = {}
161
- for server in sorted_servers:
162
- country_info = info.setdefault(server.country, {})
163
- city_info = country_info.setdefault(server.city, {"distance": int(server.distance), "servers": []})
164
- city_info["servers"].append((server.name, server.load))
165
- return info
166
-
167
- async def _save_all_configurations(self, sorted_servers: List[Server], best_servers: Dict, servers_info: Dict):
168
- used_paths: Dict[str, int] = {}
169
-
170
- with self._console.create_progress_bar(transient=False) as progress:
171
- self.stats.total_configs = len(sorted_servers)
172
- self.stats.best_configs = len(best_servers)
173
-
174
- task_all = progress.add_task("Generating standard configs...", total=self.stats.total_configs)
175
- task_best = progress.add_task("Generating optimized configs...", total=self.stats.best_configs)
176
-
177
- save_tasks = []
178
- save_tasks.extend(self._create_batch_save_tasks(sorted_servers, 'configs', progress, task_all, used_paths))
179
- save_tasks.extend(self._create_batch_save_tasks(list(best_servers.values()), 'best_configs', progress, task_best, used_paths))
180
-
181
- await asyncio.gather(*save_tasks)
182
- async with aiofiles.open(self._output_dir / 'servers.json', 'w') as f:
183
- await f.write(json.dumps(servers_info, indent=2, separators=(',', ':'), ensure_ascii=False))
184
-
185
- def _create_batch_save_tasks(self, servers: List[Server], subfolder: str, progress, task_id, used_paths: Dict[str, int]):
186
- tasks = []
187
- for server in servers:
188
- country_clean = self._sanitize_path_part(server.country)
189
- city_clean = self._sanitize_path_part(server.city)
190
- dir_path = self._output_dir / subfolder / country_clean / city_clean
191
-
192
- base_filename = self._extract_base_filename(server)
193
- rel_path = f"{subfolder}/{country_clean}/{city_clean}/{base_filename}"
194
-
195
- if rel_path in used_paths:
196
- idx = used_paths[rel_path]
197
- if idx == 0: idx = 1
198
-
199
- base_path_no_ext = rel_path[:-5]
200
- base_name_no_ext = base_filename[:-5]
201
-
202
- while True:
203
- new_rel = f"{base_path_no_ext}_{idx}.conf"
204
- if new_rel not in used_paths:
205
- used_paths[rel_path] = idx + 1
206
- used_paths[new_rel] = 0
207
- filename = f"{base_name_no_ext}_{idx}.conf"
208
- break
209
- idx += 1
210
- else:
211
- filename = base_filename
212
- used_paths[rel_path] = 0
213
-
214
- config_str = self._generate_wireguard_config_string(server, self._preferences, self._private_key)
215
- tasks.append(self._save_config_file(config_str, dir_path, filename, progress, task_id))
216
- return tasks
217
-
218
- async def _save_config_file(self, config_string: str, path: Path, filename: str, progress, task_id):
219
- path.mkdir(parents=True, exist_ok=True)
220
- async with self._semaphore:
221
- async with aiofiles.open(path / filename, 'w') as f:
222
- await f.write(config_string)
223
- progress.update(task_id, advance=1)
224
-
225
- @staticmethod
226
- def _extract_base_filename(server: Server) -> str:
227
- s = server.name
228
- num = ""
229
- for i in range(len(s) - 1, -1, -1):
230
- if s[i].isdigit():
231
- start = i
232
- while start >= 0 and s[start].isdigit():
233
- start -= 1
234
- num = s[start+1 : i+1]
235
- break
236
-
237
- if not num:
238
- fallback = f"wg{server.station.replace('.', '')}"
239
- return f"{fallback[:15]}.conf"
240
-
241
- base = f"{server.country_code}{num}"
242
- return f"{base[:15]}.conf"
243
-
244
- @staticmethod
245
- def _generate_wireguard_config_string(server: Server, preferences: UserPreferences, private_key: str) -> str:
246
- endpoint = server.station if preferences.use_ip_for_endpoint else server.hostname
247
- return f"[Interface]\nPrivateKey = {private_key}\nAddress = 10.5.0.2/16\nDNS = {preferences.dns}\n\n[Peer]\nPublicKey = {server.public_key}\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = {endpoint}:51820\nPersistentKeepalive = {preferences.persistent_keepalive}"
248
-
249
- @staticmethod
250
- def _parse_server_data(server_data: Dict[str, Any], user_location: Tuple[float, float]) -> Optional[Server]:
251
- try:
252
- location = server_data['locations'][0]
253
- country_info = location['country']
254
-
255
- public_key = next(
256
- m['value'] for t in server_data['technologies']
257
- if t['identifier'] == 'wireguard_udp'
258
- for m in t['metadata'] if m['name'] == 'public_key'
259
- )
260
- distance = ConfigurationOrchestrator._calculate_distance(
261
- user_location[0], user_location[1], location['latitude'], location['longitude']
262
- )
263
- return Server(
264
- name=server_data['name'], hostname=server_data['hostname'],
265
- station=server_data['station'], load=int(server_data.get('load', 0)),
266
- country=country_info['name'], country_code=country_info['code'].lower(),
267
- city=country_info.get('city', {}).get('name', 'Unknown'),
268
- latitude=location['latitude'], longitude=location['longitude'],
269
- public_key=public_key, distance=distance
270
- )
271
- except (KeyError, IndexError, StopIteration):
272
- return None
273
-
274
- @staticmethod
275
- def _calculate_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
276
- lon1_rad, lat1_rad, lon2_rad, lat2_rad = map(radians, [lon1, lat1, lon2, lat2])
277
- dlon = lon2_rad - lon1_rad
278
- dlat = lat2_rad - lat1_rad
279
- a = sin(dlat / 2)**2 + cos(lat1_rad) * cos(lat2_rad) * sin(dlon / 2)**2
280
- c = 2 * asin(sqrt(a))
281
- return c * 6371
282
-
283
- @classmethod
284
- def _sanitize_path_part(cls, part: str) -> str:
285
- return part.lower().replace(' ', '_').replace('#', '').translate(cls._path_sanitizer)
286
-
287
- class Application:
288
- def __init__(self):
289
- self._console = ConsoleManager()
290
-
291
- async def run(self, args: List[str]):
292
- async with NordVpnApiClient(self._console) as api_client:
293
- try:
294
- if not args:
295
- await self._run_generate_command(api_client)
296
- elif args[0] == "get-key" and len(args) == 1:
297
- await self._run_get_key_command(api_client)
298
- else:
299
- command = " ".join(args)
300
- self._console.print_message("error", f"Unknown command or invalid arguments: '{command}'.")
301
- self._console.print_message("info", "Usage: nordgen | nordgen get-key")
302
- except Exception as e:
303
- self._console.print_message("error", f"An unrecoverable error occurred: {e}")
304
-
305
- async def _run_get_key_command(self, api_client: NordVpnApiClient):
306
- self._console.clear()
307
- self._console.print_title()
308
- private_key = await self._get_validated_private_key(api_client)
309
- if private_key:
310
- self._console.display_key(private_key)
311
-
312
- async def _run_generate_command(self, api_client: NordVpnApiClient):
313
- self._console.clear()
314
- self._console.print_title()
315
- private_key = await self._get_validated_private_key(api_client)
316
- if not private_key:
317
- return
318
-
319
- preferences = self._collect_user_preferences()
320
-
321
- self._console.clear()
322
-
323
- start_time = time.time()
324
- orchestrator = ConfigurationOrchestrator(private_key, preferences, self._console, api_client)
325
- output_dir = await orchestrator.generate()
326
- elapsed_time = time.time() - start_time
327
-
328
- if output_dir:
329
- self._console.display_summary(output_dir, orchestrator.stats, elapsed_time)
330
- else:
331
- self._console.print_message("error", "Process failed. Check logs for details.")
332
-
333
- def _collect_user_preferences(self) -> UserPreferences:
334
- defaults = UserPreferences()
335
- user_input = self._console.get_preferences(defaults)
336
-
337
- dns_input = user_input.get("dns")
338
- if dns_input:
339
- parts = dns_input.split('.')
340
- if len(parts) == 4 and all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
341
- defaults.dns = dns_input
342
-
343
- use_ip = user_input.get("endpoint_type", "").lower() == 'y'
344
-
345
- keepalive_input = user_input.get("keepalive")
346
- if keepalive_input and keepalive_input.isdigit():
347
- keepalive_val = int(keepalive_input)
348
- if 15 <= keepalive_val <= 120:
349
- defaults.persistent_keepalive = keepalive_val
350
-
351
- return UserPreferences(
352
- dns=defaults.dns,
353
- use_ip_for_endpoint=use_ip,
354
- persistent_keepalive=defaults.persistent_keepalive
355
- )
356
-
357
- async def _get_validated_private_key(self, api_client: NordVpnApiClient) -> Optional[str]:
358
- token = self._console.get_user_input("Please enter your NordVPN access token: ", is_secret=True)
359
- is_hex = len(token) == 64 and all(c in '0123456789abcdefABCDEF' for c in token)
360
- if not is_hex:
361
- self._console.print_message("error", "Invalid token format.")
362
- return None
363
-
364
- with self._console.create_progress_bar() as progress:
365
- task = progress.add_task("Validating token...", total=1)
366
- private_key = await api_client.get_private_key(token)
367
- progress.update(task, advance=1)
368
-
369
- if private_key:
370
- self._console.print_message("success", "Token validated successfully.")
371
- return private_key
372
- else:
373
- self._console.print_message("error", "Token is invalid or could not be verified.")
374
- return None
375
-
376
- def cli_entry_point():
377
- try:
378
- app = Application()
379
- asyncio.run(app.run(sys.argv[1:]))
380
- except KeyboardInterrupt:
381
- print("\nProcess interrupted by user.")
382
-
383
- if __name__ == "__main__":
384
- cli_entry_point()
@@ -1,73 +0,0 @@
1
- from rich.console import Console
2
- from rich.panel import Panel
3
- from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn
4
- from rich.theme import Theme
5
- from rich.table import Table
6
- from pathlib import Path
7
- from typing import TYPE_CHECKING
8
- import os
9
-
10
- if TYPE_CHECKING:
11
- from .main import UserPreferences, GenerationStats
12
-
13
- class ConsoleManager:
14
- def __init__(self):
15
- custom_theme = Theme({
16
- "info": "cyan",
17
- "success": "bold green",
18
- "warning": "yellow",
19
- "error": "bold red",
20
- "title": "bold magenta",
21
- "path": "underline bright_blue"
22
- })
23
- self.console = Console(theme=custom_theme)
24
-
25
- def clear(self):
26
- os.system('cls' if os.name == 'nt' else 'clear')
27
-
28
- def print_title(self):
29
- self.console.print(Panel("[title]NordVPN Configuration Generator[/title]", expand=False, border_style="info"))
30
-
31
- def get_user_input(self, prompt: str, is_secret: bool = False) -> str:
32
- return self.console.input(f"[info]{prompt}[/info]", password=is_secret).strip()
33
-
34
- def get_preferences(self, defaults: "UserPreferences") -> dict:
35
- self.console.print("\n[info]Configuration Options (press Enter to use defaults)[/info]")
36
- dns = self.get_user_input(f"Enter DNS server IP (default: {defaults.dns}): ")
37
- endpoint_type = self.get_user_input("Use IP instead of hostname for endpoints? (y/N): ")
38
- keepalive = self.get_user_input(f"Enter PersistentKeepalive value (default: {defaults.persistent_keepalive}): ")
39
- return {"dns": dns, "endpoint_type": endpoint_type, "keepalive": keepalive}
40
-
41
- def print_message(self, style: str, message: str):
42
- self.console.print(f"[{style}]{message}[/{style}]")
43
-
44
- def create_progress_bar(self, transient: bool = True) -> Progress:
45
- return Progress(
46
- SpinnerColumn(),
47
- TextColumn("[progress.description]{task.description}"),
48
- BarColumn(),
49
- TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
50
- TimeElapsedColumn(),
51
- console=self.console,
52
- transient=transient
53
- )
54
-
55
- def display_key(self, key: str):
56
- key_panel = Panel(key, title="NordLynx Private Key", border_style="success", expand=False)
57
- self.console.print(key_panel)
58
-
59
- def display_summary(self, output_dir: Path, stats: "GenerationStats", elapsed_time: float):
60
- summary_table = Table.grid(padding=(0, 2))
61
- summary_table.add_column(style="info")
62
- summary_table.add_column()
63
- summary_table.add_row("Output Directory:", f"[path]{output_dir}[/path]")
64
- summary_table.add_row("Standard Configs:", f"{stats.total_configs}")
65
- summary_table.add_row("Optimized Configs:", f"{stats.best_configs}")
66
- summary_table.add_row("Time Taken:", f"{elapsed_time:.2f} seconds")
67
-
68
- self.console.print(Panel(
69
- summary_table,
70
- title="[success]Generation Complete[/success]",
71
- border_style="success",
72
- expand=False
73
- ))