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.
- nord_config_generator/main.py +233 -172
- nord_config_generator/ui.py +12 -4
- {nord_config_generator-1.0.0.dist-info → nord_config_generator-1.0.2.dist-info}/METADATA +19 -9
- nord_config_generator-1.0.2.dist-info/RECORD +8 -0
- nord_config_generator-1.0.2.dist-info/entry_points.txt +2 -0
- nord_config_generator-1.0.0.dist-info/RECORD +0 -8
- nord_config_generator-1.0.0.dist-info/entry_points.txt +0 -2
- {nord_config_generator-1.0.0.dist-info → nord_config_generator-1.0.2.dist-info}/WHEEL +0 -0
- {nord_config_generator-1.0.0.dist-info → nord_config_generator-1.0.2.dist-info}/top_level.txt +0 -0
nord_config_generator/main.py
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
self.
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
|
97
|
-
if
|
|
98
|
-
|
|
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.
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
for
|
|
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'],
|
|
128
|
-
load=int(server_data.get('load', 0)),
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
285
|
+
preferences = self._collect_user_preferences()
|
|
175
286
|
|
|
176
|
-
self.
|
|
177
|
-
servers_info, best_servers_by_location = {}, {}
|
|
287
|
+
self._console.clear()
|
|
178
288
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
|
226
|
-
|
|
299
|
+
def _collect_user_preferences(self) -> UserPreferences:
|
|
300
|
+
defaults = UserPreferences()
|
|
301
|
+
user_input = self._console.get_preferences(defaults)
|
|
227
302
|
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
233
|
-
console.clear()
|
|
234
|
-
console.print_title()
|
|
306
|
+
use_ip = user_input.get("endpoint_type", "").lower() == 'y'
|
|
235
307
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
nord_config_generator/ui.py
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
14
|
Description-Content-Type: text/markdown
|
|
15
|
-
Requires-Dist: aiohttp
|
|
16
|
-
Requires-Dist: aiofiles
|
|
17
|
-
Requires-Dist: rich
|
|
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
|
|
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.
|
|
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
|
-
|
|
56
|
+
### Generate Configurations (Default Action)
|
|
57
|
+
|
|
58
|
+
Execute the application without any arguments. This is the primary function.
|
|
57
59
|
|
|
58
60
|
```bash
|
|
59
|
-
|
|
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,,
|
|
@@ -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,,
|
|
File without changes
|
{nord_config_generator-1.0.0.dist-info → nord_config_generator-1.0.2.dist-info}/top_level.txt
RENAMED
|
File without changes
|