nord-config-generator 1.0.0__py3-none-any.whl → 1.0.2__py3-none-any.whl

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.
@@ -18,10 +18,6 @@ import aiofiles
18
18
 
19
19
  from .ui import ConsoleManager
20
20
 
21
- NORD_API_BASE_URL = "https://api.nordvpn.com/v1"
22
- LOCATION_API_URL = "https://ipinfo.io/json"
23
- CONCURRENT_LIMIT = 200
24
-
25
21
  @dataclass
26
22
  class Server:
27
23
  name: str
@@ -29,6 +25,7 @@ class Server:
29
25
  station: str
30
26
  load: int
31
27
  country: str
28
+ country_code: str
32
29
  city: str
33
30
  latitude: float
34
31
  longitude: float
@@ -41,49 +38,49 @@ class UserPreferences:
41
38
  use_ip_for_endpoint: bool = False
42
39
  persistent_keepalive: int = 25
43
40
 
44
- def update_from_input(self, user_input: dict):
45
- dns_input = user_input.get("dns")
46
- if dns_input and re.match(r'^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$', dns_input):
47
- self.dns = dns_input
48
-
49
- self.use_ip_for_endpoint = user_input.get("endpoint_type", "").lower() == 'y'
50
-
51
- keepalive_input = user_input.get("keepalive")
52
- if keepalive_input and keepalive_input.isdigit() and 15 <= int(keepalive_input) <= 120:
53
- self.persistent_keepalive = int(keepalive_input)
41
+ @dataclass
42
+ class GenerationStats:
43
+ total_configs: int = 0
44
+ best_configs: int = 0
54
45
 
55
46
  class NordVpnApiClient:
47
+ NORD_API_BASE_URL = "https://api.nordvpn.com/v1"
48
+ LOCATION_API_URL = "https://ipinfo.io/json"
49
+ COUNTRIES_API_URL = f"{NORD_API_BASE_URL}/servers/countries"
50
+
56
51
  def __init__(self, console_manager: ConsoleManager):
57
- self._session = aiohttp.ClientSession()
58
52
  self._console = console_manager
53
+ self._session: Optional[aiohttp.ClientSession] = None
59
54
 
60
- async def _get(self, url: str, **kwargs) -> Optional[Any]:
61
- try:
62
- async with self._session.get(url, **kwargs) as response:
63
- response.raise_for_status()
64
- return await response.json()
65
- except (aiohttp.ClientError, json.JSONDecodeError) as e:
66
- self._console.print_message("error", f"API request failed for {url}: {e}")
67
- return None
55
+ async def __aenter__(self):
56
+ self._session = aiohttp.ClientSession()
57
+ return self
58
+
59
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
60
+ if self._session:
61
+ await self._session.close()
68
62
 
69
63
  async def get_private_key(self, token: str) -> Optional[str]:
70
64
  auth_header = base64.b64encode(f'token:{token}'.encode()).decode()
71
- url = f"{NORD_API_BASE_URL}/users/services/credentials"
72
- data = await self._get(url, headers={'Authorization': f'Basic {auth_header}'})
65
+ url = f"{self.NORD_API_BASE_URL}/users/services/credentials"
66
+ headers = {'Authorization': f'Basic {auth_header}'}
67
+ data = await self._get(url, headers=headers)
73
68
  if isinstance(data, dict):
74
69
  return data.get('nordlynx_private_key')
75
70
  return None
76
71
 
77
72
  async def get_all_servers(self) -> List[Dict[str, Any]]:
78
- url = f"{NORD_API_BASE_URL}/servers"
73
+ url = f"{self.NORD_API_BASE_URL}/servers"
79
74
  params = {'limit': 9000, 'filters[servers_technologies][identifier]': 'wireguard_udp'}
80
75
  data = await self._get(url, params=params)
81
- if isinstance(data, list):
82
- return data
83
- return []
76
+ return data if isinstance(data, list) else []
77
+
78
+ async def get_countries(self) -> List[Dict[str, Any]]:
79
+ data = await self._get(self.COUNTRIES_API_URL)
80
+ return data if isinstance(data, list) else []
84
81
 
85
82
  async def get_user_geolocation(self) -> Optional[Tuple[float, float]]:
86
- data = await self._get(LOCATION_API_URL)
83
+ data = await self._get(self.LOCATION_API_URL)
87
84
  if not isinstance(data, dict):
88
85
  return None
89
86
  try:
@@ -93,190 +90,254 @@ class NordVpnApiClient:
93
90
  self._console.print_message("error", "Could not parse location data.")
94
91
  return None
95
92
 
96
- async def close(self):
97
- if self._session and not self._session.closed:
98
- await self._session.close()
93
+ async def _get(self, url: str, **kwargs) -> Optional[Any]:
94
+ if not self._session:
95
+ return None
96
+ try:
97
+ async with self._session.get(url, **kwargs) as response:
98
+ response.raise_for_status()
99
+ return await response.json()
100
+ except (aiohttp.ClientError, json.JSONDecodeError) as e:
101
+ self._console.print_message("error", f"API request failed for {url}: {e}")
102
+ return None
99
103
 
100
104
  class ConfigurationOrchestrator:
105
+ CONCURRENT_LIMIT = 200
106
+
101
107
  def __init__(self, private_key: str, preferences: UserPreferences, console_manager: ConsoleManager, api_client: NordVpnApiClient):
102
- self._api_client = api_client
103
108
  self._private_key = private_key
104
109
  self._preferences = preferences
105
110
  self._console = console_manager
111
+ self._api_client = api_client
106
112
  self._output_dir = Path(f'nordvpn_configs_{datetime.now().strftime("%Y%m%d_%H%M%S")}')
107
- self._semaphore = asyncio.Semaphore(CONCURRENT_LIMIT)
108
- self._thread_pool = ThreadPoolExecutor(max_workers=min(32, (os.cpu_count() or 1) + 4))
109
- self.generation_succeeded = False
110
- self.stats = {"total": 0, "best": 0}
113
+ self._semaphore = asyncio.Semaphore(self.CONCURRENT_LIMIT)
114
+ self.stats = GenerationStats()
115
+
116
+ async def generate(self) -> Optional[Path]:
117
+ user_location, all_servers_data, countries_data = await self._fetch_remote_data()
118
+ if not user_location or not all_servers_data or not countries_data:
119
+ return None
120
+
121
+ country_code_map = {country['name']: country['code'].lower() for country in countries_data}
122
+ processed_servers = await self._process_server_data(all_servers_data, user_location, country_code_map)
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]], List[Dict[str, Any]]]:
134
+ with self._console.create_progress_bar() as progress:
135
+ task = progress.add_task("Fetching remote data...", total=3)
136
+ user_location, all_servers_data, countries_data = await asyncio.gather(
137
+ self._api_client.get_user_geolocation(),
138
+ self._api_client.get_all_servers(),
139
+ self._api_client.get_countries()
140
+ )
141
+ progress.update(task, advance=3)
142
+ return user_location, all_servers_data, countries_data
143
+
144
+ async def _process_server_data(self, all_servers_data: List[Dict[str, Any]], user_location: Tuple[float, float], country_code_map: Dict[str, str]) -> List[Server]:
145
+ loop = asyncio.get_running_loop()
146
+ parse_func = partial(self._parse_server_data, user_location=user_location, country_code_map=country_code_map)
147
+ with ThreadPoolExecutor(max_workers=min(32, (os.cpu_count() or 1) + 4)) as executor:
148
+ tasks = [loop.run_in_executor(executor, parse_func, s) for s in all_servers_data]
149
+ processed_servers = await asyncio.gather(*tasks)
150
+ return [server for server in processed_servers if server]
151
+
152
+ def _get_best_servers(self, sorted_servers: List[Server]) -> Dict[Tuple[str, str], Server]:
153
+ best = {}
154
+ for server in sorted_servers:
155
+ key = (server.country, server.city)
156
+ if key not in best or server.load < best[key].load:
157
+ best[key] = server
158
+ return best
159
+
160
+ def _build_servers_info(self, sorted_servers: List[Server]) -> Dict:
161
+ info = {}
162
+ for server in sorted_servers:
163
+ country_info = info.setdefault(server.country, {})
164
+ city_info = country_info.setdefault(server.city, {"distance": int(server.distance), "servers": []})
165
+ city_info["servers"].append((server.name, server.load))
166
+ return info
167
+
168
+ async def _save_all_configurations(self, sorted_servers: List[Server], best_servers: Dict, servers_info: Dict):
169
+ with self._console.create_progress_bar(transient=False) as progress:
170
+ self.stats.total_configs = len(sorted_servers)
171
+ self.stats.best_configs = len(best_servers)
172
+
173
+ task_all = progress.add_task("Generating standard configs...", total=self.stats.total_configs)
174
+ task_best = progress.add_task("Generating optimized configs...", total=self.stats.best_configs)
175
+
176
+ save_tasks = [self._create_save_task(s, 'configs', progress, task_all) for s in sorted_servers]
177
+ save_tasks.extend([self._create_save_task(s, 'best_configs', progress, task_best) for s in best_servers.values()])
178
+
179
+ await asyncio.gather(*save_tasks)
180
+ async with aiofiles.open(self._output_dir / 'servers.json', 'w') as f:
181
+ await f.write(json.dumps(servers_info, indent=2, separators=(',', ':'), ensure_ascii=False))
182
+
183
+ def _create_save_task(self, server: Server, subfolder: str, progress, task_id):
184
+ config_str = self._generate_wireguard_config_string(server, self._preferences, self._private_key)
185
+ path = self._output_dir / subfolder / self._sanitize_path_part(server.country) / self._sanitize_path_part(server.city)
186
+ filename = self._generate_compliant_filename(server)
187
+ return self._save_config_file(config_str, path, filename, progress, task_id)
188
+
189
+ async def _save_config_file(self, config_string: str, path: Path, filename: str, progress, task_id):
190
+ path.mkdir(parents=True, exist_ok=True)
191
+ async with self._semaphore:
192
+ async with aiofiles.open(path / filename, 'w') as f:
193
+ await f.write(config_string)
194
+ progress.update(task_id, advance=1)
111
195
 
112
196
  @staticmethod
113
- def _calculate_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
114
- lon1_rad, lat1_rad, lon2_rad, lat2_rad = map(radians, [lon1, lat1, lon2, lat2])
115
- a = sin((lat2_rad - lat1_rad) / 2)**2 + cos(lat1_rad) * cos(lat2_rad) * sin((lon2_rad - lon1_rad) / 2)**2
116
- return 2 * asin(sqrt(a)) * 6371
197
+ def _generate_compliant_filename(server: Server) -> str:
198
+ server_number_match = re.search(r'\d+$', server.name)
199
+ if not server_number_match:
200
+ fallback_name = f"wg{server.station.replace('.', '')}"
201
+ return f"{fallback_name[:15]}.conf"
202
+
203
+ server_number = server_number_match.group(0)
204
+ base_name = f"{server.country_code}{server_number}"
205
+ return f"{base_name[:15]}.conf"
206
+
207
+ @staticmethod
208
+ def _generate_wireguard_config_string(server: Server, preferences: UserPreferences, private_key: str) -> str:
209
+ endpoint = server.station if preferences.use_ip_for_endpoint else server.hostname
210
+ 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}"
117
211
 
118
- def _parse_server_data(self, server_data: Dict[str, Any], user_location: Tuple[float, float]) -> Optional[Server]:
212
+ @staticmethod
213
+ def _parse_server_data(server_data: Dict[str, Any], user_location: Tuple[float, float], country_code_map: Dict[str, str]) -> Optional[Server]:
119
214
  try:
120
215
  location = server_data['locations'][0]
216
+ country_name = location['country']['name']
217
+ country_code = country_code_map.get(country_name)
218
+ if not country_code:
219
+ return None
220
+
121
221
  public_key = next(
122
- tech_meta['value']
123
- for tech in server_data['technologies'] if tech['identifier'] == 'wireguard_udp'
124
- for tech_meta in tech['metadata'] if tech_meta['name'] == 'public_key'
222
+ m['value'] for t in server_data['technologies']
223
+ if t['identifier'] == 'wireguard_udp'
224
+ for m in t['metadata'] if m['name'] == 'public_key'
225
+ )
226
+ distance = ConfigurationOrchestrator._calculate_distance(
227
+ user_location[0], user_location[1], location['latitude'], location['longitude']
125
228
  )
126
229
  return Server(
127
- name=server_data['name'], hostname=server_data['hostname'], station=server_data['station'],
128
- load=int(server_data.get('load', 0)), country=location['country']['name'],
129
- city=location['country'].get('city', {}).get('name', 'Unknown'), latitude=location['latitude'],
130
- longitude=location['longitude'], public_key=public_key,
131
- distance=self._calculate_distance(user_location[0], user_location[1], location['latitude'], location['longitude'])
230
+ name=server_data['name'], hostname=server_data['hostname'],
231
+ station=server_data['station'], load=int(server_data.get('load', 0)),
232
+ country=country_name, country_code=country_code,
233
+ city=location['country'].get('city', {}).get('name', 'Unknown'),
234
+ latitude=location['latitude'], longitude=location['longitude'],
235
+ public_key=public_key, distance=distance
132
236
  )
133
237
  except (KeyError, IndexError, StopIteration):
134
238
  return None
135
239
 
136
- def _generate_wireguard_config_string(self, server: Server) -> str:
137
- endpoint = server.station if self._preferences.use_ip_for_endpoint else server.hostname
138
- return f"[Interface]\nPrivateKey = {self._private_key}\nAddress = 10.5.0.2/16\nDNS = {self._preferences.dns}\n\n[Peer]\nPublicKey = {server.public_key}\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = {endpoint}:51820\nPersistentKeepalive = {self._preferences.persistent_keepalive}"
240
+ @staticmethod
241
+ def _calculate_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
242
+ lon1_rad, lat1_rad, lon2_rad, lat2_rad = map(radians, [lon1, lat1, lon2, lat2])
243
+ dlon = lon2_rad - lon1_rad
244
+ dlat = lat2_rad - lat1_rad
245
+ a = sin(dlat / 2)**2 + cos(lat1_rad) * cos(lat2_rad) * sin(dlon / 2)**2
246
+ c = 2 * asin(sqrt(a))
247
+ return c * 6371
139
248
 
140
249
  @staticmethod
141
250
  def _sanitize_path_part(part: str) -> str:
142
251
  return re.sub(r'[<>:"/\\|?*\0]', '', part.lower().replace(' ', '_')).replace('#', '')
143
252
 
144
- async def _save_config_file(self, config_string: str, path: Path, filename: str, progress, task):
145
- path.mkdir(parents=True, exist_ok=True)
146
- async with self._semaphore:
147
- async with aiofiles.open(path / filename, 'w') as f:
148
- await f.write(config_string)
149
- progress.update(task, advance=1)
150
-
151
- async def generate(self) -> Optional[Path]:
152
- progress = self._console.create_progress_bar()
153
- with progress:
154
- task_data = progress.add_task("Fetching remote data...", total=2)
155
-
156
- progress.update(task_data, description="Fetching user location...")
157
- user_location, all_servers_data = await asyncio.gather(
158
- self._api_client.get_user_geolocation(),
159
- self._api_client.get_all_servers()
160
- )
161
-
162
- if not user_location or not all_servers_data:
163
- return None
164
-
165
- progress.update(task_data, advance=2, description="Processing servers...")
166
-
167
- loop = asyncio.get_running_loop()
168
- parse_func = partial(self._parse_server_data, user_location=user_location)
169
- parse_tasks = [loop.run_in_executor(self._thread_pool, parse_func, s) for s in all_servers_data]
170
-
171
- processed_servers = [server for server in await asyncio.gather(*parse_tasks) if server]
172
- self._thread_pool.shutdown(wait=False, cancel_futures=True)
253
+ class Application:
254
+ def __init__(self):
255
+ self._console = ConsoleManager()
256
+
257
+ async def run(self, args: List[str]):
258
+ async with NordVpnApiClient(self._console) as api_client:
259
+ try:
260
+ if not args:
261
+ await self._run_generate_command(api_client)
262
+ elif args[0] == "get-key" and len(args) == 1:
263
+ await self._run_get_key_command(api_client)
264
+ else:
265
+ command = " ".join(args)
266
+ self._console.print_message("error", f"Unknown command or invalid arguments: '{command}'.")
267
+ self._console.print_message("info", "Usage: nordgen | nordgen get-key")
268
+ except Exception as e:
269
+ self._console.print_message("error", f"An unrecoverable error occurred: {e}")
270
+
271
+ async def _run_get_key_command(self, api_client: NordVpnApiClient):
272
+ self._console.clear()
273
+ self._console.print_title()
274
+ private_key = await self._get_validated_private_key(api_client)
275
+ if private_key:
276
+ self._console.display_key(private_key)
277
+
278
+ async def _run_generate_command(self, api_client: NordVpnApiClient):
279
+ self._console.clear()
280
+ self._console.print_title()
281
+ private_key = await self._get_validated_private_key(api_client)
282
+ if not private_key:
283
+ return
173
284
 
174
- sorted_servers = sorted(processed_servers, key=lambda s: (s.load, s.distance))
285
+ preferences = self._collect_user_preferences()
175
286
 
176
- self._output_dir.mkdir(exist_ok=True)
177
- servers_info, best_servers_by_location = {}, {}
287
+ self._console.clear()
178
288
 
179
- config_progress = self._console.create_progress_bar(transient=False)
180
- with config_progress:
181
- total_configs = len(sorted_servers)
182
- best_configs = 0
183
-
184
- save_tasks = []
185
- task_save_all = config_progress.add_task("Generating configs...", total=total_configs)
186
- for server in sorted_servers:
187
- country_sanitized = self._sanitize_path_part(server.country)
188
- city_sanitized = self._sanitize_path_part(server.city)
189
- config_str = self._generate_wireguard_config_string(server)
190
- path = self._output_dir / 'configs' / country_sanitized / city_sanitized
191
- filename = f"{self._sanitize_path_part(server.name)}.conf"
192
- save_tasks.append(self._save_config_file(config_str, path, filename, config_progress, task_save_all))
193
-
194
- location_key = (server.country, server.city)
195
- if location_key not in best_servers_by_location or server.load < best_servers_by_location[location_key].load:
196
- best_servers_by_location[location_key] = server
197
-
198
- country_info = servers_info.setdefault(server.country, {})
199
- city_info = country_info.setdefault(server.city, {"distance": int(server.distance), "servers": []})
200
- city_info["servers"].append((server.name, server.load))
201
-
202
- self.stats["total"] = total_configs
203
- await asyncio.gather(*save_tasks)
204
-
205
- best_save_tasks = []
206
- best_configs = len(best_servers_by_location)
207
- task_save_best = config_progress.add_task("Generating optimized configs...", total=best_configs)
208
- for server in best_servers_by_location.values():
209
- country_sanitized = self._sanitize_path_part(server.country)
210
- city_sanitized = self._sanitize_path_part(server.city)
211
- config_str = self._generate_wireguard_config_string(server)
212
- path = self._output_dir / 'best_configs' / country_sanitized / city_sanitized
213
- filename = f"{self._sanitize_path_part(server.name)}.conf"
214
- best_save_tasks.append(self._save_config_file(config_str, path, filename, config_progress, task_save_best))
215
-
216
- self.stats["best"] = best_configs
217
- await asyncio.gather(*best_save_tasks)
218
-
219
- async with aiofiles.open(self._output_dir / 'servers.json', 'w') as f:
220
- await f.write(json.dumps(servers_info, indent=2, separators=(',', ':'), ensure_ascii=False))
289
+ start_time = time.time()
290
+ orchestrator = ConfigurationOrchestrator(private_key, preferences, self._console, api_client)
291
+ output_dir = await orchestrator.generate()
292
+ elapsed_time = time.time() - start_time
221
293
 
222
- self.generation_succeeded = True
223
- return self._output_dir
294
+ if output_dir:
295
+ self._console.display_summary(output_dir, orchestrator.stats, elapsed_time)
296
+ else:
297
+ self._console.print_message("error", "Process failed. Check logs for details.")
224
298
 
225
- def is_valid_token_format(token: str) -> bool:
226
- return bool(re.match(r'^[a-fA-F0-9]{64}$', token))
299
+ def _collect_user_preferences(self) -> UserPreferences:
300
+ defaults = UserPreferences()
301
+ user_input = self._console.get_preferences(defaults)
227
302
 
228
- async def main_async():
229
- console = ConsoleManager()
230
- api_client = NordVpnApiClient(console)
303
+ dns_input = user_input.get("dns")
304
+ dns = dns_input if dns_input and re.match(r'^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$', dns_input) else defaults.dns
231
305
 
232
- try:
233
- console.clear()
234
- console.print_title()
306
+ use_ip = user_input.get("endpoint_type", "").lower() == 'y'
235
307
 
236
- token = console.get_user_input("Please enter your NordVPN access token: ", is_secret=True)
237
- if not is_valid_token_format(token):
238
- console.print_message("error", "Invalid token format.")
239
- return
308
+ keepalive = defaults.persistent_keepalive
309
+ keepalive_input = user_input.get("keepalive")
310
+ if keepalive_input and keepalive_input.isdigit():
311
+ keepalive_val = int(keepalive_input)
312
+ if 15 <= keepalive_val <= 120:
313
+ keepalive = keepalive_val
314
+
315
+ return UserPreferences(dns=dns, use_ip_for_endpoint=use_ip, persistent_keepalive=keepalive)
240
316
 
241
- private_key = None
242
- with console.create_progress_bar() as progress:
317
+ async def _get_validated_private_key(self, api_client: NordVpnApiClient) -> Optional[str]:
318
+ token = self._console.get_user_input("Please enter your NordVPN access token: ", is_secret=True)
319
+ if not re.match(r'^[a-fA-F0-9]{64}$', token):
320
+ self._console.print_message("error", "Invalid token format.")
321
+ return None
322
+
323
+ with self._console.create_progress_bar() as progress:
243
324
  task = progress.add_task("Validating token...", total=1)
244
325
  private_key = await api_client.get_private_key(token)
245
326
  progress.update(task, advance=1)
246
327
 
247
- if not private_key:
248
- console.print_message("error", "Token is invalid or could not be verified. Please check the token and try again.")
249
- return
250
-
251
- console.print_message("success", "Token validated successfully.")
252
-
253
- preferences = UserPreferences()
254
- user_input = console.get_preferences(preferences)
255
- preferences.update_from_input(user_input)
256
-
257
- console.clear()
258
-
259
- start_time = time.time()
260
- orchestrator = ConfigurationOrchestrator(private_key, preferences, console, api_client)
261
-
262
- output_directory = await orchestrator.generate()
263
- elapsed_time = time.time() - start_time
264
-
265
- if orchestrator.generation_succeeded and output_directory:
266
- console.print_summary(output_directory, orchestrator.stats["total"], orchestrator.stats["best"], elapsed_time)
328
+ if private_key:
329
+ self._console.print_message("success", "Token validated successfully.")
330
+ return private_key
267
331
  else:
268
- console.print_message("error", "Process failed. Check the logs for details.")
269
-
270
- except Exception as e:
271
- console.print_message("error", f"An unrecoverable error occurred: {e}")
272
- finally:
273
- await api_client.close()
332
+ self._console.print_message("error", "Token is invalid or could not be verified.")
333
+ return None
274
334
 
275
335
  def cli_entry_point():
276
336
  try:
277
- asyncio.run(main_async())
337
+ app = Application()
338
+ asyncio.run(app.run(sys.argv[1:]))
278
339
  except KeyboardInterrupt:
279
340
  print("\nProcess interrupted by user.")
280
341
 
281
342
  if __name__ == "__main__":
282
- cli_entry_point()
343
+ cli_entry_point()
@@ -4,8 +4,12 @@ from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeEl
4
4
  from rich.theme import Theme
5
5
  from rich.table import Table
6
6
  from pathlib import Path
7
+ from typing import TYPE_CHECKING
7
8
  import os
8
9
 
10
+ if TYPE_CHECKING:
11
+ from .main import UserPreferences, GenerationStats
12
+
9
13
  class ConsoleManager:
10
14
  def __init__(self):
11
15
  custom_theme = Theme({
@@ -27,7 +31,7 @@ class ConsoleManager:
27
31
  def get_user_input(self, prompt: str, is_secret: bool = False) -> str:
28
32
  return self.console.input(f"[info]{prompt}[/info]", password=is_secret).strip()
29
33
 
30
- def get_preferences(self, defaults) -> dict:
34
+ def get_preferences(self, defaults: "UserPreferences") -> dict:
31
35
  self.console.print("\n[info]Configuration Options (press Enter to use defaults)[/info]")
32
36
  dns = self.get_user_input(f"Enter DNS server IP (default: {defaults.dns}): ")
33
37
  endpoint_type = self.get_user_input("Use IP instead of hostname for endpoints? (y/N): ")
@@ -48,13 +52,17 @@ class ConsoleManager:
48
52
  transient=transient
49
53
  )
50
54
 
51
- def print_summary(self, output_dir: Path, total_configs: int, best_configs: int, elapsed_time: float):
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):
52
60
  summary_table = Table.grid(padding=(0, 2))
53
61
  summary_table.add_column(style="info")
54
62
  summary_table.add_column()
55
63
  summary_table.add_row("Output Directory:", f"[path]{output_dir}[/path]")
56
- summary_table.add_row("Standard Configs:", f"{total_configs}")
57
- summary_table.add_row("Optimized Configs:", f"{best_configs}")
64
+ summary_table.add_row("Standard Configs:", f"{stats.total_configs}")
65
+ summary_table.add_row("Optimized Configs:", f"{stats.best_configs}")
58
66
  summary_table.add_row("Time Taken:", f"{elapsed_time:.2f} seconds")
59
67
 
60
68
  self.console.print(Panel(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nord-config-generator
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: A command-line tool for generating optimized NordVPN WireGuard configurations.
5
5
  Author-email: Ahmed Touhami <mustafachyi272@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/mustafachyi/NordVPN-WireGuard-Config-Generator
@@ -10,11 +10,11 @@ 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
13
- Requires-Python: >=3.8
13
+ Requires-Python: >=3.9
14
14
  Description-Content-Type: text/markdown
15
- Requires-Dist: aiohttp>=3.8.0
16
- Requires-Dist: aiofiles>=0.8.0
17
- Requires-Dist: rich>=12.0.0
15
+ Requires-Dist: aiohttp<4.0,>=3.12.14
16
+ Requires-Dist: aiofiles<25.0,>=24.1.0
17
+ Requires-Dist: rich<15.0,>=14.0.0
18
18
 
19
19
  # NordVPN WireGuard Configuration Generator
20
20
 
@@ -39,11 +39,11 @@ This consolidated effort ensures a higher standard of quality and a more reliabl
39
39
  * **Performance:** Asynchronous architecture processes the entire NordVPN server list in seconds.
40
40
  * **Optimization:** Intelligently sorts servers by current load and geographic proximity to the user, generating configurations for the most performant connections.
41
41
  * **Structured Output:** Automatically creates a clean directory structure containing standard configurations, a `best_configs` folder for optimal servers per location, and a `servers.json` file with detailed metadata for analysis.
42
- * **Interactive and Non-Interactive:** A guided TUI for interactive use. The core logic is structured to be scriptable.
42
+ * **Interactive and Non-Interactive:** A guided rich-CLI for interactive use. The core logic is structured to be scriptable.
43
43
 
44
44
  ## Installation
45
45
 
46
- Prerequisites: Python 3.8+
46
+ Prerequisites: Python 3.9+
47
47
 
48
48
  Install the package using `pip`:
49
49
 
@@ -53,14 +53,24 @@ pip install nord-config-generator
53
53
 
54
54
  ## Usage
55
55
 
56
- Execute the installed application:
56
+ ### Generate Configurations (Default Action)
57
+
58
+ Execute the application without any arguments. This is the primary function.
57
59
 
58
60
  ```bash
59
- nord-generator
61
+ nordgen
60
62
  ```
61
63
 
62
64
  The application will prompt for the required access token and configuration preferences.
63
65
 
66
+ ### Retrieve Private Key
67
+
68
+ To retrieve and display your NordLynx private key without generating configurations, use the `get-key` command:
69
+
70
+ ```bash
71
+ nordgen get-key
72
+ ```
73
+
64
74
  ## Web Version
65
75
 
66
76
  A graphical alternative is available for direct use in a web browser.
@@ -0,0 +1,8 @@
1
+ nord_config_generator/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ nord_config_generator/main.py,sha256=0eoOR9rbwqI_BK445-wvRSDNuYj6jBwC04WSR4TT3AI,15604
3
+ nord_config_generator/ui.py,sha256=r9ulCF3t8h9WlepXM4_LAdDLi8TFM9HufXrO95cMOOM,3148
4
+ nord_config_generator-1.0.2.dist-info/METADATA,sha256=10zzx4oPvftR-M5v5bIwP3p2dp_RrIkTOy5_zisZP2w,3876
5
+ nord_config_generator-1.0.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ nord_config_generator-1.0.2.dist-info/entry_points.txt,sha256=TEZSrG2vKDWS4hcLAy-s65yjdxzcXAGbExHwWSYqd04,71
7
+ nord_config_generator-1.0.2.dist-info/top_level.txt,sha256=wGNt1MATKpHqIKJ3G0ByTkE2thqRe6xPZWrIU0FstSo,22
8
+ nord_config_generator-1.0.2.dist-info/RECORD,,
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nordgen = nord_config_generator.main:cli_entry_point
@@ -1,8 +0,0 @@
1
- nord_config_generator/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- nord_config_generator/main.py,sha256=P_xPIeXwUn4FJNPjR7aEun0EfL5vbHko3IOu4bnxfdI,12740
3
- nord_config_generator/ui.py,sha256=YEnSy9ltwul-3KzmEKLA3pynD29eEEf7hikzqRuI_l4,2838
4
- nord_config_generator-1.0.0.dist-info/METADATA,sha256=m1GQxkfmHG63UuylocH7M4leju66BObnmTirtlF-A10,3592
5
- nord_config_generator-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
- nord_config_generator-1.0.0.dist-info/entry_points.txt,sha256=NEVrNutXaNQAPTdWa9R5iZfdpVKZ_RYCYqR8hWJBv2Q,78
7
- nord_config_generator-1.0.0.dist-info/top_level.txt,sha256=wGNt1MATKpHqIKJ3G0ByTkE2thqRe6xPZWrIU0FstSo,22
8
- nord_config_generator-1.0.0.dist-info/RECORD,,
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- nord-generator = nord_config_generator.main:cli_entry_point