vpn-tool-cli 1.0.0__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.
vpn_tool.py
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import hashlib
|
|
5
|
+
import getpass
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import threading
|
|
9
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import httpx
|
|
12
|
+
import orjson
|
|
13
|
+
|
|
14
|
+
VERSION = "1.0.0"
|
|
15
|
+
API_BASE = "https://vpn-gateway-manager--jjajajjajahsh.replit.app"
|
|
16
|
+
DEFAULT_HOST = "localhost"
|
|
17
|
+
DEFAULT_PORT = 8765
|
|
18
|
+
|
|
19
|
+
def get_config_dir() -> Path:
|
|
20
|
+
return Path.home() / ".vpn-tool"
|
|
21
|
+
|
|
22
|
+
def get_auth_file() -> Path:
|
|
23
|
+
return get_config_dir() / "auth.json"
|
|
24
|
+
|
|
25
|
+
def get_session_file() -> Path:
|
|
26
|
+
return get_config_dir() / "session"
|
|
27
|
+
|
|
28
|
+
def get_server_pid_file() -> Path:
|
|
29
|
+
return get_config_dir() / "server.pid"
|
|
30
|
+
|
|
31
|
+
def get_server_host_file() -> Path:
|
|
32
|
+
return get_config_dir() / "server.host"
|
|
33
|
+
|
|
34
|
+
def get_server_port_file() -> Path:
|
|
35
|
+
return get_config_dir() / "server.port"
|
|
36
|
+
|
|
37
|
+
def ensure_config():
|
|
38
|
+
try:
|
|
39
|
+
get_config_dir().mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
print(f"Error creating config directory: {e}", file=sys.stderr)
|
|
42
|
+
sys.exit(1)
|
|
43
|
+
|
|
44
|
+
def hash_pin(pin: str) -> str:
|
|
45
|
+
return hashlib.sha256(pin.encode("utf-8")).hexdigest()
|
|
46
|
+
|
|
47
|
+
def load_store() -> dict | None:
|
|
48
|
+
file = get_auth_file()
|
|
49
|
+
if not file.exists():
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
data = file.read_bytes()
|
|
53
|
+
return orjson.loads(data)
|
|
54
|
+
except Exception:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def save_store(store: dict):
|
|
58
|
+
ensure_config()
|
|
59
|
+
try:
|
|
60
|
+
data = orjson.dumps(store, option=orjson.OPT_INDENT_2)
|
|
61
|
+
auth_file = get_auth_file()
|
|
62
|
+
auth_file.write_bytes(data)
|
|
63
|
+
os.chmod(auth_file, 0o600)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
print(f"Error saving auth store: {e}", file=sys.stderr)
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
|
|
68
|
+
def registered() -> bool:
|
|
69
|
+
store = load_store()
|
|
70
|
+
return store is not None and len(store.get("profiles", [])) > 0
|
|
71
|
+
|
|
72
|
+
def logged_in() -> bool:
|
|
73
|
+
file = get_session_file()
|
|
74
|
+
if not file.exists():
|
|
75
|
+
return False
|
|
76
|
+
try:
|
|
77
|
+
return file.read_text(encoding="utf-8").strip() == "logged_in"
|
|
78
|
+
except Exception:
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
def set_logged_in(status: bool):
|
|
82
|
+
ensure_config()
|
|
83
|
+
val = "logged_in" if status else "logged_out"
|
|
84
|
+
session_file = get_session_file()
|
|
85
|
+
try:
|
|
86
|
+
session_file.write_text(val, encoding="utf-8")
|
|
87
|
+
os.chmod(session_file, 0o600)
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
def prompt(prompt_str: str, secure: bool = False) -> str:
|
|
92
|
+
if secure:
|
|
93
|
+
try:
|
|
94
|
+
return getpass.getpass(prompt_str).strip()
|
|
95
|
+
except Exception:
|
|
96
|
+
return ""
|
|
97
|
+
try:
|
|
98
|
+
return input(prompt_str).strip()
|
|
99
|
+
except (EOFError, KeyboardInterrupt):
|
|
100
|
+
print()
|
|
101
|
+
return ""
|
|
102
|
+
|
|
103
|
+
def require_login():
|
|
104
|
+
if not registered():
|
|
105
|
+
print("Not registered. Run: vpn-tool auth register", file=sys.stderr)
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
if not logged_in():
|
|
108
|
+
print("Not logged in. Run: vpn-tool auth login", file=sys.stderr)
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
|
|
111
|
+
def get_server_endpoint() -> tuple[str, int]:
|
|
112
|
+
host = DEFAULT_HOST
|
|
113
|
+
port = DEFAULT_PORT
|
|
114
|
+
host_file = get_server_host_file()
|
|
115
|
+
port_file = get_server_port_file()
|
|
116
|
+
if host_file.exists():
|
|
117
|
+
try:
|
|
118
|
+
host = host_file.read_text(encoding="utf-8").strip()
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
if port_file.exists():
|
|
122
|
+
try:
|
|
123
|
+
port = int(port_file.read_text(encoding="utf-8").strip())
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
return host, port
|
|
127
|
+
|
|
128
|
+
def check_server() -> bool:
|
|
129
|
+
host, port = get_server_endpoint()
|
|
130
|
+
try:
|
|
131
|
+
with httpx.Client(timeout=2.0) as client:
|
|
132
|
+
client.get(f"http://{host}:{port}/api/")
|
|
133
|
+
return True
|
|
134
|
+
except Exception:
|
|
135
|
+
print("Error: could not connect to vpn-tool server, run 'vpn-tool serve' to start it", file=sys.stderr)
|
|
136
|
+
sys.exit(1)
|
|
137
|
+
|
|
138
|
+
def api_request(method: str, endpoint: str):
|
|
139
|
+
check_server()
|
|
140
|
+
host, port = get_server_endpoint()
|
|
141
|
+
proxy_base = f"http://{host}:{port}"
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
with httpx.Client() as client:
|
|
145
|
+
response = client.request(
|
|
146
|
+
method,
|
|
147
|
+
proxy_base + endpoint,
|
|
148
|
+
headers={"Accept": "application/json"},
|
|
149
|
+
timeout=30.0
|
|
150
|
+
)
|
|
151
|
+
try:
|
|
152
|
+
body_json = response.json()
|
|
153
|
+
except Exception:
|
|
154
|
+
body_json = response.text
|
|
155
|
+
|
|
156
|
+
output = {
|
|
157
|
+
"status": response.status_code,
|
|
158
|
+
"body": body_json
|
|
159
|
+
}
|
|
160
|
+
print(orjson.dumps(output, option=orjson.OPT_INDENT_2).decode("utf-8"))
|
|
161
|
+
|
|
162
|
+
if response.status_code < 200 or response.status_code >= 300:
|
|
163
|
+
sys.exit(1)
|
|
164
|
+
except httpx.RequestError:
|
|
165
|
+
print("Error: could not connect to vpn-tool server, run 'vpn-tool serve' to start it", file=sys.stderr)
|
|
166
|
+
sys.exit(1)
|
|
167
|
+
|
|
168
|
+
def print_error(status: int, msg: str):
|
|
169
|
+
output = {
|
|
170
|
+
"status": status,
|
|
171
|
+
"body": {"error": msg}
|
|
172
|
+
}
|
|
173
|
+
print(orjson.dumps(output, option=orjson.OPT_INDENT_2).decode("utf-8"))
|
|
174
|
+
sys.exit(1)
|
|
175
|
+
|
|
176
|
+
# HTTP Proxy Server Implementation
|
|
177
|
+
class ProxyHandler(BaseHTTPRequestHandler):
|
|
178
|
+
def do_request(self, method):
|
|
179
|
+
if not self.path.startswith("/api/"):
|
|
180
|
+
self.send_response(404)
|
|
181
|
+
self.end_headers()
|
|
182
|
+
self.wfile.write(b"Not Found")
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
target_url = API_BASE + self.path
|
|
186
|
+
headers = {key: val for key, val in self.headers.items() if key.lower() != 'host'}
|
|
187
|
+
|
|
188
|
+
content_length = int(self.headers.get('Content-Length', 0))
|
|
189
|
+
body = self.rfile.read(content_length) if content_length > 0 else None
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
with httpx.Client() as client:
|
|
193
|
+
resp = client.request(
|
|
194
|
+
method=method,
|
|
195
|
+
url=target_url,
|
|
196
|
+
headers=headers,
|
|
197
|
+
content=body,
|
|
198
|
+
timeout=30.0,
|
|
199
|
+
follow_redirects=True
|
|
200
|
+
)
|
|
201
|
+
self.send_response(resp.status_code)
|
|
202
|
+
for key, val in resp.headers.items():
|
|
203
|
+
if key.lower() in ['content-encoding', 'transfer-encoding', 'content-length']:
|
|
204
|
+
continue
|
|
205
|
+
self.send_header(key, val)
|
|
206
|
+
self.send_header('Content-Length', str(len(resp.content)))
|
|
207
|
+
self.end_headers()
|
|
208
|
+
self.wfile.write(resp.content)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
self.send_response(502)
|
|
211
|
+
self.end_headers()
|
|
212
|
+
err_body = orjson.dumps({"error": str(e)})
|
|
213
|
+
self.wfile.write(err_body)
|
|
214
|
+
|
|
215
|
+
def do_GET(self):
|
|
216
|
+
self.do_request("GET")
|
|
217
|
+
|
|
218
|
+
def do_POST(self):
|
|
219
|
+
self.do_request("POST")
|
|
220
|
+
|
|
221
|
+
def do_PUT(self):
|
|
222
|
+
self.do_request("PUT")
|
|
223
|
+
|
|
224
|
+
def do_DELETE(self):
|
|
225
|
+
self.do_request("DELETE")
|
|
226
|
+
|
|
227
|
+
def log_message(self, format, *args):
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
def main():
|
|
231
|
+
import argparse
|
|
232
|
+
|
|
233
|
+
parser = argparse.ArgumentParser(
|
|
234
|
+
description="VPN Gateway Manager CLI",
|
|
235
|
+
prog="vpn-tool"
|
|
236
|
+
)
|
|
237
|
+
parser.add_argument(
|
|
238
|
+
"--version",
|
|
239
|
+
action="version",
|
|
240
|
+
version=f"vpn-tool version {VERSION}"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
244
|
+
|
|
245
|
+
# Auth Command
|
|
246
|
+
auth_parser = subparsers.add_parser("auth", help="Authentication commands")
|
|
247
|
+
auth_subparsers = auth_parser.add_subparsers(dest="auth_command", required=True)
|
|
248
|
+
|
|
249
|
+
def handle_register(args):
|
|
250
|
+
store = load_store()
|
|
251
|
+
if store is None:
|
|
252
|
+
store = {"version": 1, "active_profile": "", "profiles": []}
|
|
253
|
+
|
|
254
|
+
pin = prompt("Enter new PIN: ", secure=True)
|
|
255
|
+
if not pin:
|
|
256
|
+
print("PIN cannot be empty.", file=sys.stderr)
|
|
257
|
+
sys.exit(1)
|
|
258
|
+
|
|
259
|
+
confirm = prompt("Confirm new PIN: ", secure=True)
|
|
260
|
+
if pin != confirm:
|
|
261
|
+
print("PINs do not match.", file=sys.stderr)
|
|
262
|
+
sys.exit(1)
|
|
263
|
+
|
|
264
|
+
label = prompt("Enter profile label (optional): ", secure=False)
|
|
265
|
+
if not label:
|
|
266
|
+
label = f"profile-{len(store['profiles']) + 1}"
|
|
267
|
+
|
|
268
|
+
profile_id = f"id-{len(store['profiles']) + 1}"
|
|
269
|
+
new_profile = {
|
|
270
|
+
"id": profile_id,
|
|
271
|
+
"label": label,
|
|
272
|
+
"pin_hash": hash_pin(pin),
|
|
273
|
+
"created_at": str(os.getpid())
|
|
274
|
+
}
|
|
275
|
+
store["profiles"].append(new_profile)
|
|
276
|
+
store["active_profile"] = profile_id
|
|
277
|
+
save_store(store)
|
|
278
|
+
set_logged_in(False)
|
|
279
|
+
print(f"PIN registered successfully with profile '{label}' ({profile_id}).")
|
|
280
|
+
print("Run: vpn-tool auth login")
|
|
281
|
+
|
|
282
|
+
reg_cmd = auth_subparsers.add_parser("register", help="Register a new PIN profile")
|
|
283
|
+
reg_cmd.set_defaults(func=handle_register)
|
|
284
|
+
|
|
285
|
+
def handle_login(args):
|
|
286
|
+
store = load_store()
|
|
287
|
+
if store is None or not store.get("profiles"):
|
|
288
|
+
print("No PIN registered. Run: vpn-tool auth register", file=sys.stderr)
|
|
289
|
+
sys.exit(1)
|
|
290
|
+
|
|
291
|
+
pin = prompt("Enter PIN: ", secure=True)
|
|
292
|
+
hashed = hash_pin(pin)
|
|
293
|
+
matched = None
|
|
294
|
+
for p in store["profiles"]:
|
|
295
|
+
if p["pin_hash"] == hashed:
|
|
296
|
+
matched = p
|
|
297
|
+
break
|
|
298
|
+
|
|
299
|
+
if not matched:
|
|
300
|
+
print("Invalid PIN.", file=sys.stderr)
|
|
301
|
+
sys.exit(1)
|
|
302
|
+
|
|
303
|
+
store["active_profile"] = matched["id"]
|
|
304
|
+
save_store(store)
|
|
305
|
+
set_logged_in(True)
|
|
306
|
+
print(f"Login successful as profile '{matched['label']}'.")
|
|
307
|
+
|
|
308
|
+
login_cmd = auth_subparsers.add_parser("login", help="Login with PIN")
|
|
309
|
+
login_cmd.set_defaults(func=handle_login)
|
|
310
|
+
|
|
311
|
+
def handle_logout(args):
|
|
312
|
+
if not registered():
|
|
313
|
+
print("No PIN registered.", file=sys.stderr)
|
|
314
|
+
sys.exit(1)
|
|
315
|
+
store = load_store()
|
|
316
|
+
pin = prompt("Enter PIN: ", secure=True)
|
|
317
|
+
hashed = hash_pin(pin)
|
|
318
|
+
matched = False
|
|
319
|
+
for p in store.get("profiles", []):
|
|
320
|
+
if p["id"] == store.get("active_profile") and p["pin_hash"] == hashed:
|
|
321
|
+
matched = True
|
|
322
|
+
break
|
|
323
|
+
if not matched:
|
|
324
|
+
print("Invalid PIN or active profile mismatch.", file=sys.stderr)
|
|
325
|
+
sys.exit(1)
|
|
326
|
+
set_logged_in(False)
|
|
327
|
+
print("Logout successful.")
|
|
328
|
+
|
|
329
|
+
logout_cmd = auth_subparsers.add_parser("logout", help="Logout")
|
|
330
|
+
logout_cmd.set_defaults(func=handle_logout)
|
|
331
|
+
|
|
332
|
+
def handle_auth_status(args):
|
|
333
|
+
reg_str = "yes" if registered() else "no"
|
|
334
|
+
log_str = "yes" if logged_in() else "no"
|
|
335
|
+
print(f"Registered: {reg_str}")
|
|
336
|
+
print(f"Logged in: {log_str}")
|
|
337
|
+
|
|
338
|
+
astatus_cmd = auth_subparsers.add_parser("status", help="Show authentication status")
|
|
339
|
+
astatus_cmd.set_defaults(func=handle_auth_status)
|
|
340
|
+
|
|
341
|
+
def handle_auth_list(args):
|
|
342
|
+
store = load_store()
|
|
343
|
+
if store is None or not store.get("profiles"):
|
|
344
|
+
print("No profiles registered.")
|
|
345
|
+
return
|
|
346
|
+
print("Registered Auth Profiles:")
|
|
347
|
+
for p in store["profiles"]:
|
|
348
|
+
active_marker = "*" if p["id"] == store.get("active_profile") else " "
|
|
349
|
+
print(f"{active_marker} [{p['id']}] Label: {p['label']}")
|
|
350
|
+
|
|
351
|
+
alist_cmd = auth_subparsers.add_parser("list", help="List all registered auth profiles")
|
|
352
|
+
alist_cmd.set_defaults(func=handle_auth_list)
|
|
353
|
+
|
|
354
|
+
def handle_whoami(args):
|
|
355
|
+
store = load_store()
|
|
356
|
+
active_id = store.get("active_profile") if store else None
|
|
357
|
+
if not store or not active_id:
|
|
358
|
+
print("No active profile.")
|
|
359
|
+
return
|
|
360
|
+
for p in store.get("profiles", []):
|
|
361
|
+
if p["id"] == active_id:
|
|
362
|
+
print(orjson.dumps(p, option=orjson.OPT_INDENT_2).decode("utf-8"))
|
|
363
|
+
return
|
|
364
|
+
print("Active profile not found in store.")
|
|
365
|
+
|
|
366
|
+
whoami_cmd = auth_subparsers.add_parser("whoami", help="Show current active auth profile metadata")
|
|
367
|
+
whoami_cmd.set_defaults(func=handle_whoami)
|
|
368
|
+
|
|
369
|
+
def handle_switch(args):
|
|
370
|
+
target_id = args.profile_id
|
|
371
|
+
store = load_store()
|
|
372
|
+
if not store:
|
|
373
|
+
print("No profiles registered.", file=sys.stderr)
|
|
374
|
+
sys.exit(1)
|
|
375
|
+
found = False
|
|
376
|
+
for p in store.get("profiles", []):
|
|
377
|
+
if p["id"] == target_id or p["label"] == target_id:
|
|
378
|
+
store["active_profile"] = p["id"]
|
|
379
|
+
found = True
|
|
380
|
+
break
|
|
381
|
+
if not found:
|
|
382
|
+
print(f"Profile '{target_id}' not found.", file=sys.stderr)
|
|
383
|
+
sys.exit(1)
|
|
384
|
+
save_store(store)
|
|
385
|
+
set_logged_in(False)
|
|
386
|
+
print(f"Switched active profile to {target_id}. Please login.")
|
|
387
|
+
|
|
388
|
+
switch_cmd = auth_subparsers.add_parser("switch", help="Switch active auth profile")
|
|
389
|
+
switch_cmd.add_argument("profile_id")
|
|
390
|
+
switch_cmd.set_defaults(func=handle_switch)
|
|
391
|
+
|
|
392
|
+
def handle_reset(args):
|
|
393
|
+
store = load_store()
|
|
394
|
+
if not store or not store.get("profiles"):
|
|
395
|
+
print("No PIN registered.", file=sys.stderr)
|
|
396
|
+
sys.exit(1)
|
|
397
|
+
current = prompt("Enter current PIN: ", secure=True)
|
|
398
|
+
hashed = hash_pin(current)
|
|
399
|
+
index = -1
|
|
400
|
+
for i, p in enumerate(store["profiles"]):
|
|
401
|
+
if p["id"] == store.get("active_profile") and p["pin_hash"] == hashed:
|
|
402
|
+
index = i
|
|
403
|
+
break
|
|
404
|
+
if index == -1:
|
|
405
|
+
print("Invalid current PIN.", file=sys.stderr)
|
|
406
|
+
sys.exit(1)
|
|
407
|
+
new_pin = prompt("Enter new PIN: ", secure=True)
|
|
408
|
+
if not new_pin:
|
|
409
|
+
print("New PIN cannot be empty.", file=sys.stderr)
|
|
410
|
+
sys.exit(1)
|
|
411
|
+
confirm = prompt("Confirm new PIN: ", secure=True)
|
|
412
|
+
if new_pin != confirm:
|
|
413
|
+
print("PINs do not match.", file=sys.stderr)
|
|
414
|
+
sys.exit(1)
|
|
415
|
+
store["profiles"][index]["pin_hash"] = hash_pin(new_pin)
|
|
416
|
+
save_store(store)
|
|
417
|
+
set_logged_in(False)
|
|
418
|
+
print("PIN reset successfully.")
|
|
419
|
+
|
|
420
|
+
reset_cmd = auth_subparsers.add_parser("reset", help="Reset PIN for active profile")
|
|
421
|
+
reset_cmd.set_defaults(func=handle_reset)
|
|
422
|
+
|
|
423
|
+
def handle_remove(args):
|
|
424
|
+
if not registered():
|
|
425
|
+
print("No PIN registered.", file=sys.stderr)
|
|
426
|
+
sys.exit(1)
|
|
427
|
+
try:
|
|
428
|
+
get_auth_file().unlink(missing_ok=True)
|
|
429
|
+
get_session_file().unlink(missing_ok=True)
|
|
430
|
+
except Exception:
|
|
431
|
+
pass
|
|
432
|
+
print("Authentication removed.")
|
|
433
|
+
|
|
434
|
+
remove_cmd = auth_subparsers.add_parser("remove", help="Remove authentication configuration")
|
|
435
|
+
remove_cmd.set_defaults(func=handle_remove)
|
|
436
|
+
|
|
437
|
+
# Server Subcommands
|
|
438
|
+
def handle_serve(args):
|
|
439
|
+
ensure_config()
|
|
440
|
+
host = args.address
|
|
441
|
+
port = int(args.port)
|
|
442
|
+
server_address = (host, port)
|
|
443
|
+
try:
|
|
444
|
+
httpd = HTTPServer(server_address, ProxyHandler)
|
|
445
|
+
except Exception as e:
|
|
446
|
+
print(f"Error starting server on {host}:{port}: {e}", file=sys.stderr)
|
|
447
|
+
sys.exit(1)
|
|
448
|
+
|
|
449
|
+
get_server_pid_file().write_text(str(os.getpid()), encoding="utf-8")
|
|
450
|
+
get_server_host_file().write_text(host, encoding="utf-8")
|
|
451
|
+
get_server_port_file().write_text(str(port), encoding="utf-8")
|
|
452
|
+
print(f"vpn-tool server started on http://{host}:{port} ( proxying {API_BASE} )")
|
|
453
|
+
try:
|
|
454
|
+
httpd.serve_forever()
|
|
455
|
+
except KeyboardInterrupt:
|
|
456
|
+
pass
|
|
457
|
+
finally:
|
|
458
|
+
get_server_pid_file().unlink(missing_ok=True)
|
|
459
|
+
get_server_host_file().unlink(missing_ok=True)
|
|
460
|
+
get_server_port_file().unlink(missing_ok=True)
|
|
461
|
+
httpd.server_close()
|
|
462
|
+
|
|
463
|
+
serve_cmd = subparsers.add_parser("serve", help="Start VPN tool HTTP proxy server")
|
|
464
|
+
serve_cmd.add_argument("--address", default=DEFAULT_HOST, help="Address to bind the server to (default: localhost)")
|
|
465
|
+
serve_cmd.add_argument("--port", default=str(DEFAULT_PORT), help="Port to run the server on (default: 8765)")
|
|
466
|
+
serve_cmd.set_defaults(func=handle_serve)
|
|
467
|
+
|
|
468
|
+
def handle_server_status(args):
|
|
469
|
+
pid_file = get_server_pid_file()
|
|
470
|
+
if not pid_file.exists():
|
|
471
|
+
print("vpn-tool server is NOT running.")
|
|
472
|
+
sys.exit(1)
|
|
473
|
+
|
|
474
|
+
pid = pid_file.read_text(encoding="utf-8").strip()
|
|
475
|
+
host, port = get_server_endpoint()
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
os.kill(int(pid), 0)
|
|
479
|
+
print(f"vpn-tool server is running (PID: {pid}, Address: {host}:{port})")
|
|
480
|
+
except OSError:
|
|
481
|
+
print("vpn-tool server PID file exists, but the process is dead.")
|
|
482
|
+
sys.exit(1)
|
|
483
|
+
|
|
484
|
+
server_status_cmd = subparsers.add_parser("server-status", help="Check VPN tool server status")
|
|
485
|
+
server_status_cmd.set_defaults(func=handle_server_status)
|
|
486
|
+
|
|
487
|
+
def handle_server_stop(args):
|
|
488
|
+
pid_file = get_server_pid_file()
|
|
489
|
+
if not pid_file.exists():
|
|
490
|
+
print("vpn-tool server is not running.")
|
|
491
|
+
return
|
|
492
|
+
try:
|
|
493
|
+
pid = int(pid_file.read_text(encoding="utf-8").strip())
|
|
494
|
+
os.kill(pid, 15)
|
|
495
|
+
print(f"Stopped vpn-tool server (PID: {pid})")
|
|
496
|
+
except Exception as e:
|
|
497
|
+
print(f"Error stopping server: {e}", file=sys.stderr)
|
|
498
|
+
finally:
|
|
499
|
+
get_server_pid_file().unlink(missing_ok=True)
|
|
500
|
+
get_server_host_file().unlink(missing_ok=True)
|
|
501
|
+
get_server_port_file().unlink(missing_ok=True)
|
|
502
|
+
|
|
503
|
+
server_stop_cmd = subparsers.add_parser("server-stop", help="Stop VPN tool server")
|
|
504
|
+
server_stop_cmd.set_defaults(func=handle_server_stop)
|
|
505
|
+
|
|
506
|
+
# Info Command
|
|
507
|
+
def handle_info(args):
|
|
508
|
+
info_data = {
|
|
509
|
+
"tool": "vpn-tool",
|
|
510
|
+
"version": VERSION,
|
|
511
|
+
"api_base": API_BASE
|
|
512
|
+
}
|
|
513
|
+
print(orjson.dumps({"status": 200, "body": info_data}, option=orjson.OPT_INDENT_2).decode("utf-8"))
|
|
514
|
+
|
|
515
|
+
info_cmd = subparsers.add_parser("info", help="Show client version info")
|
|
516
|
+
info_cmd.set_defaults(func=handle_info)
|
|
517
|
+
|
|
518
|
+
# API Commands
|
|
519
|
+
healthz_cmd = subparsers.add_parser("healthz", help="Check API health")
|
|
520
|
+
healthz_cmd.set_defaults(func=lambda args: (require_login(), api_request("GET", "/api/healthz")))
|
|
521
|
+
|
|
522
|
+
countries_cmd = subparsers.add_parser("countries", help="List VPN countries")
|
|
523
|
+
countries_cmd.set_defaults(func=lambda args: (require_login(), api_request("GET", "/api/vpn/countries")))
|
|
524
|
+
|
|
525
|
+
for action in ["create", "connect", "disconnect"]:
|
|
526
|
+
c = subparsers.add_parser(action, help=f"{action.capitalize()} VPN")
|
|
527
|
+
c.add_argument("--country", required=True, help="Country code (required)")
|
|
528
|
+
def make_action_handler(act):
|
|
529
|
+
return lambda args: (require_login(), api_request("POST", f"/api/vpn/{act}?countryCode={args.country}"))
|
|
530
|
+
c.set_defaults(func=make_action_handler(action))
|
|
531
|
+
|
|
532
|
+
status_cmd = subparsers.add_parser("status", help="Get VPN status")
|
|
533
|
+
status_cmd.add_argument("--country", required=True, help="Country code (required)")
|
|
534
|
+
status_cmd.set_defaults(func=lambda args: (require_login(), api_request("GET", f"/api/vpn/status?countryCode={args.country}")))
|
|
535
|
+
|
|
536
|
+
test_cmd = subparsers.add_parser("test", help="Test API delay")
|
|
537
|
+
test_cmd.add_argument("--ms", default="100", help="Milliseconds")
|
|
538
|
+
test_cmd.set_defaults(func=lambda args: (require_login(), api_request("GET", f"/api/vpn/test/sleep?ms={args.ms}")))
|
|
539
|
+
|
|
540
|
+
ip_cmd = subparsers.add_parser("ip", help="Get VPN IP addresses")
|
|
541
|
+
ip_cmd.add_argument("--country", required=True, help="Country code (required)")
|
|
542
|
+
ip_cmd.add_argument("--count", default="10", help="Number of IPs")
|
|
543
|
+
ip_cmd.set_defaults(func=lambda args: (require_login(), api_request("POST", f"/api/vpn/ip?countryCode={args.country}&count={args.count}")))
|
|
544
|
+
|
|
545
|
+
parsed_args = parser.parse_args()
|
|
546
|
+
if hasattr(parsed_args, "func"):
|
|
547
|
+
parsed_args.func(parsed_args)
|
|
548
|
+
else:
|
|
549
|
+
parser.print_help()
|
|
550
|
+
|
|
551
|
+
if __name__ == "__main__":
|
|
552
|
+
main()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vpn-tool-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: VPN Gateway Manager CLI
|
|
5
|
+
Author-email: getyu565-cyber <your-email@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/getyu565-cyber/vpn-tool
|
|
8
|
+
Project-URL: Repository, https://github.com/getyu565-cyber/vpn-tool.git
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: httpx>=0.20.0
|
|
15
|
+
Requires-Dist: orjson>=3.0.0
|
|
16
|
+
|
|
17
|
+
# VPN Tool CLI (`vpn-tool`)
|
|
18
|
+
|
|
19
|
+
A robust command-line interface (CLI) tool and local proxy server to manage and connect to your VPN Gateway manager securely using PIN authentication.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
- **Secure PIN Authentication**: Register, login, switch, and manage multiple secure PIN profiles stored locally in `~/.vpn-tool/auth.json`.
|
|
26
|
+
- **Local Proxy Server**: Built-in background server (`vpn-tool serve`) that acts as a secure proxy to the remote VPN gateway API.
|
|
27
|
+
- **Full CLI Command Suite**: Easily check API health, list countries, create VPN tunnels, connect, disconnect, test latency, and query IP addresses.
|
|
28
|
+
- **High Performance**: Powered by Python, utilizing `httpx` for reliable requests and `orjson` for fast, formatted JSON outputs.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
### Prerequisites
|
|
35
|
+
Make sure you have Python 3.8+ installed.
|
|
36
|
+
|
|
37
|
+
### Install from Source / Repository
|
|
38
|
+
1. Clone the repository:
|
|
39
|
+
```bash
|
|
40
|
+
git clone [https://github.com/getyu565-cyber/vpn-tool.git](https://github.com/getyu565-cyber/vpn-tool.git)
|
|
41
|
+
cd vpn-tool
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
vpn_tool.py,sha256=bBBqykMhLSFKIDVMOqEOvPjZB8qy9A810tcroT3y5tg,19650
|
|
2
|
+
vpn_tool_cli-1.0.0.dist-info/METADATA,sha256=SYtxMWhMQiasJY9iV3rTWl2mMmCNPTKJmd0nFOkypH8,1595
|
|
3
|
+
vpn_tool_cli-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
4
|
+
vpn_tool_cli-1.0.0.dist-info/entry_points.txt,sha256=19GVGkOHI6Xz2tZkCXSSAUitQk9aGM7sNXuc_jDpzVg,43
|
|
5
|
+
vpn_tool_cli-1.0.0.dist-info/top_level.txt,sha256=GpxJbN2f2kTR2r-6gBXYNgoezNVefo0fSGnQF5Q_l8M,9
|
|
6
|
+
vpn_tool_cli-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vpn_tool
|