nps-ctl 0.1.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.
- nps_ctl/__init__.py +35 -0
- nps_ctl/api.py +31 -0
- nps_ctl/base.py +326 -0
- nps_ctl/cli/__init__.py +219 -0
- nps_ctl/cli/cmd_clients.py +78 -0
- nps_ctl/cli/cmd_deploy.py +293 -0
- nps_ctl/cli/cmd_hosts.py +143 -0
- nps_ctl/cli/cmd_npc.py +973 -0
- nps_ctl/cli/cmd_status.py +49 -0
- nps_ctl/cli/cmd_sync.py +155 -0
- nps_ctl/cli/cmd_tunnels.py +142 -0
- nps_ctl/cli/cmd_utils.py +19 -0
- nps_ctl/cli/helpers.py +176 -0
- nps_ctl/cli/parser.py +655 -0
- nps_ctl/client_mgmt.py +164 -0
- nps_ctl/cluster.py +1425 -0
- nps_ctl/deploy.py +514 -0
- nps_ctl/exceptions.py +21 -0
- nps_ctl/host.py +160 -0
- nps_ctl/logging.py +515 -0
- nps_ctl/py.typed +0 -0
- nps_ctl/ssh_proxy.py +258 -0
- nps_ctl/templates/nps.conf.template +70 -0
- nps_ctl/tunnel.py +286 -0
- nps_ctl/types.py +99 -0
- nps_ctl/utils.py +35 -0
- nps_ctl-0.1.0.dist-info/METADATA +175 -0
- nps_ctl-0.1.0.dist-info/RECORD +32 -0
- nps_ctl-0.1.0.dist-info/WHEEL +5 -0
- nps_ctl-0.1.0.dist-info/entry_points.txt +2 -0
- nps_ctl-0.1.0.dist-info/licenses/LICENSE +674 -0
- nps_ctl-0.1.0.dist-info/top_level.txt +1 -0
nps_ctl/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""nps-ctl: A Python library and CLI tool for managing NPS servers.
|
|
2
|
+
|
|
3
|
+
This package provides:
|
|
4
|
+
- NPSClient: API client for a single NPS server
|
|
5
|
+
- NPSCluster: Manager for multiple NPS servers
|
|
6
|
+
- Modular API functions: client_mgmt, tunnel, host
|
|
7
|
+
- CLI tool for command-line management
|
|
8
|
+
- Enhanced logging with configure_logging
|
|
9
|
+
|
|
10
|
+
Modules:
|
|
11
|
+
base: NPSClient class (authentication and HTTP requests)
|
|
12
|
+
cluster: NPSCluster class (multi-server management)
|
|
13
|
+
client_mgmt: NPC client management functions
|
|
14
|
+
tunnel: Tunnel management functions
|
|
15
|
+
host: Host (domain) management functions
|
|
16
|
+
types: Type definitions (ClientInfo, TunnelInfo, HostInfo, EdgeConfig)
|
|
17
|
+
exceptions: Exception classes (NPSError, NPSAuthError, NPSAPIError)
|
|
18
|
+
deploy: Deployment functions (install, uninstall via SSH)
|
|
19
|
+
utils: Utility functions (auth key generation)
|
|
20
|
+
logging: Logging configuration and utilities
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
from .base import NPSClient
|
|
26
|
+
from .cluster import NPSCluster
|
|
27
|
+
from .logging import configure_logging, set_log_level
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"NPSClient",
|
|
31
|
+
"NPSCluster",
|
|
32
|
+
"__version__",
|
|
33
|
+
"configure_logging",
|
|
34
|
+
"set_log_level",
|
|
35
|
+
]
|
nps_ctl/api.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""NPS API client for managing NPS servers.
|
|
2
|
+
|
|
3
|
+
This module re-exports all public API components for backward compatibility.
|
|
4
|
+
New code should import directly from the specific modules:
|
|
5
|
+
|
|
6
|
+
- ``nps_ctl.base`` - NPSClient (API client for a single NPS server)
|
|
7
|
+
- ``nps_ctl.cluster`` - NPSCluster (multi-server management)
|
|
8
|
+
- ``nps_ctl.client_mgmt`` - NPC client management functions
|
|
9
|
+
- ``nps_ctl.tunnel`` - Tunnel management functions
|
|
10
|
+
- ``nps_ctl.host`` - Host (domain) management functions
|
|
11
|
+
- ``nps_ctl.types`` - Type definitions (ClientInfo, TunnelInfo, HostInfo, EdgeConfig)
|
|
12
|
+
- ``nps_ctl.exceptions`` - Exception classes (NPSError, NPSAuthError, NPSAPIError)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# Re-export everything for backward compatibility
|
|
16
|
+
from .base import NPSClient
|
|
17
|
+
from .cluster import NPSCluster
|
|
18
|
+
from .exceptions import NPSAPIError, NPSAuthError, NPSError
|
|
19
|
+
from .types import ClientInfo, EdgeConfig, HostInfo, TunnelInfo
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ClientInfo",
|
|
23
|
+
"EdgeConfig",
|
|
24
|
+
"HostInfo",
|
|
25
|
+
"NPSAPIError",
|
|
26
|
+
"NPSAuthError",
|
|
27
|
+
"NPSClient",
|
|
28
|
+
"NPSCluster",
|
|
29
|
+
"NPSError",
|
|
30
|
+
"TunnelInfo",
|
|
31
|
+
]
|
nps_ctl/base.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Base NPS API client with authentication and request infrastructure.
|
|
2
|
+
|
|
3
|
+
This module provides the core NPSClient class that handles authentication,
|
|
4
|
+
HTTP requests, and SSL configuration for communicating with NPS servers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import socket
|
|
11
|
+
import ssl
|
|
12
|
+
import time
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import socks
|
|
21
|
+
|
|
22
|
+
HAS_SOCKS = True
|
|
23
|
+
except ImportError:
|
|
24
|
+
HAS_SOCKS = False
|
|
25
|
+
|
|
26
|
+
from .exceptions import NPSAPIError, NPSAuthError
|
|
27
|
+
from .logging import get_operation_logger
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
op_logger = get_operation_logger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class NPSClient:
|
|
35
|
+
"""API client for a single NPS server.
|
|
36
|
+
|
|
37
|
+
Provides authenticated HTTP communication with an NPS server's Web API.
|
|
38
|
+
Domain-specific operations (client management, tunnel management, host
|
|
39
|
+
management) are provided by separate modules that operate on an
|
|
40
|
+
NPSClient instance.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
base_url: The base URL of the NPS server (e.g., "https://nps.example.com").
|
|
44
|
+
auth_key: The authentication key configured in nps.conf.
|
|
45
|
+
timeout: Request timeout in seconds.
|
|
46
|
+
verify_ssl: Whether to verify SSL certificates.
|
|
47
|
+
proxy: HTTP/HTTPS proxy URL (e.g., "http://127.0.0.1:7890").
|
|
48
|
+
socks_proxy: SOCKS5 proxy address (e.g., "localhost:1080" for SSH tunnel).
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
>>> from nps_ctl.base import NPSClient
|
|
52
|
+
>>> from nps_ctl.client_mgmt import list_clients
|
|
53
|
+
>>> nps = NPSClient("https://nps.example.com", "your_auth_key")
|
|
54
|
+
>>> clients = list_clients(nps)
|
|
55
|
+
>>> for c in clients:
|
|
56
|
+
... print(f"{c['Id']}: {c['Remark']}")
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
base_url: str
|
|
60
|
+
auth_key: str
|
|
61
|
+
timeout: int = 30
|
|
62
|
+
verify_ssl: bool = True
|
|
63
|
+
max_retries: int = 3
|
|
64
|
+
retry_backoff: float = 1.0
|
|
65
|
+
proxy: str | None = None
|
|
66
|
+
socks_proxy: str | None = None
|
|
67
|
+
_ssl_context: ssl.SSLContext | None = field(default=None, init=False, repr=False)
|
|
68
|
+
_opener: urllib.request.OpenerDirector | None = field(
|
|
69
|
+
default=None, init=False, repr=False
|
|
70
|
+
)
|
|
71
|
+
_original_socket: type | None = field(default=None, init=False, repr=False)
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
"""Initialize SSL context and proxy handler."""
|
|
75
|
+
self.base_url = self.base_url.rstrip("/")
|
|
76
|
+
|
|
77
|
+
# Setup SSL context
|
|
78
|
+
if not self.verify_ssl:
|
|
79
|
+
self._ssl_context = ssl.create_default_context()
|
|
80
|
+
self._ssl_context.check_hostname = False
|
|
81
|
+
self._ssl_context.verify_mode = ssl.CERT_NONE
|
|
82
|
+
logger.debug(f"SSL verification disabled for {self.base_url}")
|
|
83
|
+
else:
|
|
84
|
+
self._ssl_context = ssl.create_default_context()
|
|
85
|
+
|
|
86
|
+
# Setup SOCKS proxy if specified (takes precedence over HTTP proxy)
|
|
87
|
+
if self.socks_proxy:
|
|
88
|
+
if not HAS_SOCKS:
|
|
89
|
+
raise ImportError(
|
|
90
|
+
"PySocks is required for SOCKS proxy support. "
|
|
91
|
+
"Install it with: pip install PySocks"
|
|
92
|
+
)
|
|
93
|
+
# Parse socks_proxy address (format: host:port)
|
|
94
|
+
if ":" in self.socks_proxy:
|
|
95
|
+
socks_host, socks_port_str = self.socks_proxy.rsplit(":", 1)
|
|
96
|
+
socks_port = int(socks_port_str)
|
|
97
|
+
else:
|
|
98
|
+
socks_host = self.socks_proxy
|
|
99
|
+
socks_port = 1080 # Default SOCKS port
|
|
100
|
+
|
|
101
|
+
op_logger.connection_attempt(
|
|
102
|
+
self.base_url,
|
|
103
|
+
proxy=f"socks5://{socks_host}:{socks_port}",
|
|
104
|
+
verify_ssl=self.verify_ssl,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Store original socket class for potential cleanup
|
|
108
|
+
self._original_socket = socket.socket
|
|
109
|
+
|
|
110
|
+
# Set default SOCKS proxy globally for this client
|
|
111
|
+
socks.set_default_proxy(socks.SOCKS5, socks_host, socks_port)
|
|
112
|
+
socket.socket = socks.socksocket # type: ignore[assignment] # runtime monkey-patch for SOCKS proxy
|
|
113
|
+
|
|
114
|
+
logger.info(
|
|
115
|
+
f"SOCKS5 proxy enabled: {socks_host}:{socks_port} for {self.base_url}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Setup HTTP proxy if specified (only if SOCKS proxy is not set)
|
|
119
|
+
elif self.proxy:
|
|
120
|
+
op_logger.connection_attempt(
|
|
121
|
+
self.base_url, proxy=self.proxy, verify_ssl=self.verify_ssl
|
|
122
|
+
)
|
|
123
|
+
proxy_handler = urllib.request.ProxyHandler(
|
|
124
|
+
{"http": self.proxy, "https": self.proxy}
|
|
125
|
+
)
|
|
126
|
+
https_handler = urllib.request.HTTPSHandler(context=self._ssl_context)
|
|
127
|
+
self._opener = urllib.request.build_opener(proxy_handler, https_handler)
|
|
128
|
+
else:
|
|
129
|
+
op_logger.connection_attempt(
|
|
130
|
+
self.base_url, verify_ssl=self.verify_ssl, timeout=self.timeout
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
logger.info(f"NPSClient initialized for {self.base_url}")
|
|
134
|
+
|
|
135
|
+
def _request_with_retry(
|
|
136
|
+
self,
|
|
137
|
+
req: urllib.request.Request,
|
|
138
|
+
*,
|
|
139
|
+
error_prefix: str = "Request failed",
|
|
140
|
+
error_cls: type[Exception] = NPSAPIError,
|
|
141
|
+
) -> bytes:
|
|
142
|
+
"""Execute an HTTP request with retry and exponential backoff.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
req: The prepared urllib Request object.
|
|
146
|
+
error_prefix: Prefix for error messages.
|
|
147
|
+
error_cls: Exception class to raise on final failure.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Raw response bytes.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
error_cls: If all retries are exhausted.
|
|
154
|
+
"""
|
|
155
|
+
last_error: Exception | None = None
|
|
156
|
+
url = req.full_url
|
|
157
|
+
method = req.get_method()
|
|
158
|
+
start_time = time.perf_counter()
|
|
159
|
+
|
|
160
|
+
logger.debug(f"Request: {method} {url}")
|
|
161
|
+
|
|
162
|
+
for attempt in range(self.max_retries):
|
|
163
|
+
attempt_start = time.perf_counter()
|
|
164
|
+
try:
|
|
165
|
+
if self._opener:
|
|
166
|
+
with self._opener.open(req, timeout=self.timeout) as response:
|
|
167
|
+
data = response.read()
|
|
168
|
+
elapsed_ms = (time.perf_counter() - attempt_start) * 1000
|
|
169
|
+
op_logger.connection_success(url, response_time_ms=elapsed_ms)
|
|
170
|
+
logger.debug(
|
|
171
|
+
f"Response: {response.status} ({len(data)} bytes, "
|
|
172
|
+
f"{elapsed_ms:.1f}ms)"
|
|
173
|
+
)
|
|
174
|
+
return data
|
|
175
|
+
else:
|
|
176
|
+
with urllib.request.urlopen(
|
|
177
|
+
req, timeout=self.timeout, context=self._ssl_context
|
|
178
|
+
) as response:
|
|
179
|
+
data = response.read()
|
|
180
|
+
elapsed_ms = (time.perf_counter() - attempt_start) * 1000
|
|
181
|
+
op_logger.connection_success(url, response_time_ms=elapsed_ms)
|
|
182
|
+
logger.debug(
|
|
183
|
+
f"Response: {response.status} ({len(data)} bytes, "
|
|
184
|
+
f"{elapsed_ms:.1f}ms)"
|
|
185
|
+
)
|
|
186
|
+
return data
|
|
187
|
+
except urllib.error.HTTPError as e:
|
|
188
|
+
last_error = e
|
|
189
|
+
op_logger.connection_failed(
|
|
190
|
+
url, f"HTTP {e.code} {e.reason}", attempt=attempt + 1
|
|
191
|
+
)
|
|
192
|
+
if attempt < self.max_retries - 1:
|
|
193
|
+
wait = self.retry_backoff * (2**attempt)
|
|
194
|
+
logger.info(f"Retrying in {wait:.1f}s...")
|
|
195
|
+
time.sleep(wait)
|
|
196
|
+
except urllib.error.URLError as e:
|
|
197
|
+
last_error = e
|
|
198
|
+
op_logger.connection_failed(url, str(e.reason), attempt=attempt + 1)
|
|
199
|
+
if attempt < self.max_retries - 1:
|
|
200
|
+
wait = self.retry_backoff * (2**attempt)
|
|
201
|
+
logger.info(f"Retrying in {wait:.1f}s...")
|
|
202
|
+
time.sleep(wait)
|
|
203
|
+
except (TimeoutError, OSError) as e:
|
|
204
|
+
# Handle socket timeout and other OS-level network errors
|
|
205
|
+
last_error = e
|
|
206
|
+
error_msg = "Timeout" if isinstance(e, TimeoutError) else str(e)
|
|
207
|
+
op_logger.connection_failed(url, error_msg, attempt=attempt + 1)
|
|
208
|
+
if attempt < self.max_retries - 1:
|
|
209
|
+
wait = self.retry_backoff * (2**attempt)
|
|
210
|
+
logger.info(f"Retrying in {wait:.1f}s...")
|
|
211
|
+
time.sleep(wait)
|
|
212
|
+
|
|
213
|
+
# All retries exhausted
|
|
214
|
+
total_elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
215
|
+
logger.error(
|
|
216
|
+
f"{error_prefix} after {self.max_retries} attempts "
|
|
217
|
+
f"({total_elapsed_ms:.1f}ms total): {last_error}"
|
|
218
|
+
)
|
|
219
|
+
raise error_cls(f"{error_prefix}: {last_error}") from last_error
|
|
220
|
+
|
|
221
|
+
def _get_server_time(self) -> int:
|
|
222
|
+
"""Get the server timestamp for authentication.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Server timestamp as integer.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
NPSAuthError: If failed to get server time.
|
|
229
|
+
"""
|
|
230
|
+
url = f"{self.base_url}/auth/gettime"
|
|
231
|
+
logger.debug(f"Getting server time from {url}")
|
|
232
|
+
req = urllib.request.Request(url)
|
|
233
|
+
try:
|
|
234
|
+
raw = self._request_with_retry(
|
|
235
|
+
req,
|
|
236
|
+
error_prefix="Failed to get server time",
|
|
237
|
+
error_cls=NPSAuthError,
|
|
238
|
+
)
|
|
239
|
+
data = json.loads(raw.decode("utf-8"))
|
|
240
|
+
return int(data.get("time", 0))
|
|
241
|
+
except NPSAuthError:
|
|
242
|
+
raise
|
|
243
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
244
|
+
raise NPSAuthError(f"Invalid server time response: {e}") from e
|
|
245
|
+
|
|
246
|
+
def _generate_auth_key(self, timestamp: int) -> str:
|
|
247
|
+
"""Generate authentication key using MD5.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
timestamp: Server timestamp.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
MD5 hash of auth_key + timestamp.
|
|
254
|
+
"""
|
|
255
|
+
raw = f"{self.auth_key}{timestamp}"
|
|
256
|
+
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
|
257
|
+
|
|
258
|
+
def request(
|
|
259
|
+
self,
|
|
260
|
+
endpoint: str,
|
|
261
|
+
method: str = "GET",
|
|
262
|
+
data: dict[str, Any] | None = None,
|
|
263
|
+
) -> dict[str, Any]:
|
|
264
|
+
"""Make an authenticated API request.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
endpoint: API endpoint (e.g., "/client/list").
|
|
268
|
+
method: HTTP method.
|
|
269
|
+
data: Request data for POST requests.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
JSON response as dictionary.
|
|
273
|
+
|
|
274
|
+
Raises:
|
|
275
|
+
NPSAPIError: If the request fails.
|
|
276
|
+
"""
|
|
277
|
+
start_time = time.perf_counter()
|
|
278
|
+
op_logger.request_start(method, endpoint, data)
|
|
279
|
+
|
|
280
|
+
timestamp = self._get_server_time()
|
|
281
|
+
auth_key = self._generate_auth_key(timestamp)
|
|
282
|
+
|
|
283
|
+
# Auth params must be included in POST body, not URL query string
|
|
284
|
+
auth_params = {"auth_key": auth_key, "timestamp": str(timestamp)}
|
|
285
|
+
|
|
286
|
+
if method == "POST":
|
|
287
|
+
# For POST requests, include auth params in the body
|
|
288
|
+
post_params = {**auth_params}
|
|
289
|
+
if data:
|
|
290
|
+
post_params.update({k: str(v) for k, v in data.items()})
|
|
291
|
+
post_data = urllib.parse.urlencode(post_params).encode("utf-8")
|
|
292
|
+
url = f"{self.base_url}{endpoint}"
|
|
293
|
+
req = urllib.request.Request(url, data=post_data, method="POST")
|
|
294
|
+
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
|
295
|
+
else:
|
|
296
|
+
# For GET requests, include auth params in URL
|
|
297
|
+
params = {**auth_params}
|
|
298
|
+
if data:
|
|
299
|
+
params.update({k: str(v) for k, v in data.items()})
|
|
300
|
+
url = f"{self.base_url}{endpoint}?{urllib.parse.urlencode(params)}"
|
|
301
|
+
req = urllib.request.Request(url, method=method)
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
raw = self._request_with_retry(
|
|
305
|
+
req,
|
|
306
|
+
error_prefix=f"API request {endpoint} failed",
|
|
307
|
+
)
|
|
308
|
+
response_data = raw.decode("utf-8")
|
|
309
|
+
result = json.loads(response_data)
|
|
310
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
311
|
+
api_status = result.get("status")
|
|
312
|
+
op_logger.request_success(
|
|
313
|
+
method, endpoint, status=api_status, response_time_ms=elapsed_ms
|
|
314
|
+
)
|
|
315
|
+
return result
|
|
316
|
+
|
|
317
|
+
except NPSAPIError as e:
|
|
318
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
319
|
+
op_logger.request_failed(
|
|
320
|
+
method, endpoint, str(e), status_code=getattr(e, "status_code", None)
|
|
321
|
+
)
|
|
322
|
+
raise
|
|
323
|
+
except json.JSONDecodeError as e:
|
|
324
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
325
|
+
op_logger.request_failed(method, endpoint, f"Invalid JSON: {e}")
|
|
326
|
+
raise NPSAPIError(f"Invalid JSON response: {e}") from e
|
nps_ctl/cli/__init__.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Command-line interface for NPS management.
|
|
2
|
+
|
|
3
|
+
This package provides the `nps-ctl` command for managing NPS servers.
|
|
4
|
+
|
|
5
|
+
Submodules:
|
|
6
|
+
parser: Argument parser definition
|
|
7
|
+
helpers: Shared helper utilities (table formatting, config path)
|
|
8
|
+
cmd_status: Status command
|
|
9
|
+
cmd_clients: Client management commands
|
|
10
|
+
cmd_tunnels: Tunnel management commands
|
|
11
|
+
cmd_hosts: Host management commands
|
|
12
|
+
cmd_sync: Sync and export commands
|
|
13
|
+
cmd_deploy: Install and uninstall commands
|
|
14
|
+
cmd_npc: NPC deployment commands
|
|
15
|
+
cmd_utils: Utility commands (auth key generation)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
|
|
21
|
+
from ..logging import configure_logging, flush_output
|
|
22
|
+
from ..ssh_proxy import SSHProxy
|
|
23
|
+
from .helpers import console, get_default_config_path
|
|
24
|
+
from .parser import create_parser
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def setup_logging(verbose: bool = False, debug: bool = False) -> None:
|
|
28
|
+
"""Configure logging based on verbosity level.
|
|
29
|
+
|
|
30
|
+
Log levels:
|
|
31
|
+
- Default (no flags): NOTICE - shows key phase information
|
|
32
|
+
- -v/--verbose: INFO - shows all request/response details
|
|
33
|
+
- --debug: DEBUG - shows everything including internal details
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
verbose: Enable INFO level logging.
|
|
37
|
+
debug: Enable DEBUG level logging (overrides verbose).
|
|
38
|
+
"""
|
|
39
|
+
if debug:
|
|
40
|
+
level = "DEBUG"
|
|
41
|
+
elif verbose:
|
|
42
|
+
level = "INFO"
|
|
43
|
+
else:
|
|
44
|
+
level = "NOTICE" # Custom level (25) - shows key phases only
|
|
45
|
+
|
|
46
|
+
configure_logging(level=level, use_colors=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _dispatch_client_list(args) -> int:
|
|
50
|
+
"""Dispatch client list command with special handling.
|
|
51
|
+
|
|
52
|
+
If --update or --dry-run is specified, fetches client info from NPS API
|
|
53
|
+
and updates clients.toml. Otherwise, displays the API client list.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
args: Parsed command line arguments.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Exit code.
|
|
60
|
+
"""
|
|
61
|
+
update = getattr(args, "update", False)
|
|
62
|
+
dry_run = getattr(args, "dry_run", False)
|
|
63
|
+
|
|
64
|
+
if update or dry_run:
|
|
65
|
+
from ..cluster import NPSCluster
|
|
66
|
+
|
|
67
|
+
from .cmd_npc import handle_npc_list
|
|
68
|
+
|
|
69
|
+
cluster = NPSCluster(args.config)
|
|
70
|
+
handle_npc_list(args, cluster)
|
|
71
|
+
return 0
|
|
72
|
+
else:
|
|
73
|
+
from .cmd_clients import cmd_clients
|
|
74
|
+
|
|
75
|
+
return cmd_clients(args)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _dispatch_client_push(args) -> int:
|
|
79
|
+
"""Dispatch client push command.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
args: Parsed command line arguments.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Exit code.
|
|
86
|
+
"""
|
|
87
|
+
from ..cluster import NPSCluster
|
|
88
|
+
from .cmd_npc import handle_client_push
|
|
89
|
+
|
|
90
|
+
cluster = NPSCluster(args.config)
|
|
91
|
+
handle_client_push(args, cluster)
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main() -> int:
|
|
96
|
+
"""Main entry point for nps-ctl CLI."""
|
|
97
|
+
parser = create_parser()
|
|
98
|
+
args = parser.parse_args()
|
|
99
|
+
|
|
100
|
+
# No command specified -> print top-level help
|
|
101
|
+
if args.command is None:
|
|
102
|
+
parser.print_help()
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
# Command specified but no subcommand -> print command group help
|
|
106
|
+
subcommand = getattr(args, "subcommand", None)
|
|
107
|
+
if subcommand is None:
|
|
108
|
+
# Re-parse to get the subparser and print its help
|
|
109
|
+
parser.parse_args([args.command, "--help"])
|
|
110
|
+
return 0 # pragma: no cover (--help exits)
|
|
111
|
+
|
|
112
|
+
# Setup logging
|
|
113
|
+
verbose = getattr(args, "verbose", False)
|
|
114
|
+
debug = getattr(args, "debug", False)
|
|
115
|
+
setup_logging(verbose=verbose, debug=debug)
|
|
116
|
+
|
|
117
|
+
# Check if this command requires config
|
|
118
|
+
requires_config = getattr(args, "requires_config", True)
|
|
119
|
+
|
|
120
|
+
# Find config file if not specified and required
|
|
121
|
+
if requires_config and args.config is None:
|
|
122
|
+
try:
|
|
123
|
+
args.config = get_default_config_path()
|
|
124
|
+
except FileNotFoundError as e:
|
|
125
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
126
|
+
return 1
|
|
127
|
+
|
|
128
|
+
# Handle --auto-proxy option
|
|
129
|
+
auto_proxy = getattr(args, "auto_proxy", None)
|
|
130
|
+
ssh_proxy: SSHProxy | None = None
|
|
131
|
+
|
|
132
|
+
if auto_proxy:
|
|
133
|
+
# Create and start SSH SOCKS proxy
|
|
134
|
+
try:
|
|
135
|
+
console.print(f"[blue]Creating SSH SOCKS proxy via {auto_proxy}...[/blue]")
|
|
136
|
+
flush_output()
|
|
137
|
+
ssh_proxy = SSHProxy(ssh_host=auto_proxy)
|
|
138
|
+
ssh_proxy.start()
|
|
139
|
+
# Set socks_proxy for the command to use
|
|
140
|
+
args.socks_proxy = ssh_proxy.address
|
|
141
|
+
console.print(f"[green]✓ SSH proxy ready on {ssh_proxy.address}[/green]")
|
|
142
|
+
flush_output()
|
|
143
|
+
except Exception as e:
|
|
144
|
+
console.print(f"[red]Failed to create SSH proxy: {e}[/red]")
|
|
145
|
+
flush_output()
|
|
146
|
+
return 1
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
return _dispatch(args)
|
|
150
|
+
finally:
|
|
151
|
+
# Clean up SSH proxy if we created one
|
|
152
|
+
if ssh_proxy:
|
|
153
|
+
ssh_proxy.stop()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _dispatch(args) -> int:
|
|
157
|
+
"""Dispatch to the appropriate handler based on (command, subcommand).
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
args: Parsed command line arguments with command and subcommand set.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Exit code.
|
|
164
|
+
"""
|
|
165
|
+
from .cmd_deploy import cmd_install, cmd_uninstall
|
|
166
|
+
from .cmd_hosts import cmd_add_host, cmd_hosts
|
|
167
|
+
from .cmd_npc import (
|
|
168
|
+
cmd_client_add,
|
|
169
|
+
cmd_npc_install,
|
|
170
|
+
cmd_npc_restart,
|
|
171
|
+
cmd_npc_status,
|
|
172
|
+
cmd_npc_uninstall,
|
|
173
|
+
)
|
|
174
|
+
from .cmd_status import cmd_status
|
|
175
|
+
from .cmd_sync import cmd_export, cmd_sync
|
|
176
|
+
from .cmd_tunnels import cmd_add_tunnel, cmd_tunnels
|
|
177
|
+
from .cmd_utils import cmd_generate_auth_key
|
|
178
|
+
|
|
179
|
+
# Command dispatch table: (command, subcommand) -> handler
|
|
180
|
+
dispatch_table: dict[tuple[str, str], Callable[..., int]] = {
|
|
181
|
+
# client commands
|
|
182
|
+
("client", "list"): _dispatch_client_list,
|
|
183
|
+
("client", "add"): cmd_client_add,
|
|
184
|
+
("client", "push"): _dispatch_client_push,
|
|
185
|
+
("client", "install"): cmd_npc_install,
|
|
186
|
+
("client", "uninstall"): cmd_npc_uninstall,
|
|
187
|
+
("client", "status"): cmd_npc_status,
|
|
188
|
+
("client", "restart"): cmd_npc_restart,
|
|
189
|
+
# edge commands
|
|
190
|
+
("edge", "status"): cmd_status,
|
|
191
|
+
("edge", "install"): cmd_install,
|
|
192
|
+
("edge", "uninstall"): cmd_uninstall,
|
|
193
|
+
("edge", "sync"): cmd_sync,
|
|
194
|
+
("edge", "export"): cmd_export,
|
|
195
|
+
# tunnel commands
|
|
196
|
+
("tunnel", "list"): cmd_tunnels,
|
|
197
|
+
("tunnel", "add"): cmd_add_tunnel,
|
|
198
|
+
# host commands
|
|
199
|
+
("host", "list"): cmd_hosts,
|
|
200
|
+
("host", "add"): cmd_add_host,
|
|
201
|
+
# util commands
|
|
202
|
+
("util", "generate-auth-key"): cmd_generate_auth_key,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
key = (args.command, args.subcommand)
|
|
206
|
+
handler = dispatch_table.get(key)
|
|
207
|
+
|
|
208
|
+
if handler is None:
|
|
209
|
+
print(
|
|
210
|
+
f"Error: Unknown command '{args.command} {args.subcommand}'",
|
|
211
|
+
file=sys.stderr,
|
|
212
|
+
)
|
|
213
|
+
return 1
|
|
214
|
+
|
|
215
|
+
return handler(args)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__":
|
|
219
|
+
sys.exit(main())
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""CLI command: clients - List and manage NPC clients."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from .. import client_mgmt
|
|
6
|
+
from ..cluster import NPSCluster
|
|
7
|
+
from ..exceptions import NPSError
|
|
8
|
+
from ..types import ClientInfo
|
|
9
|
+
from .helpers import console, create_table, print_error
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cmd_clients(args: argparse.Namespace) -> int:
|
|
13
|
+
"""List clients on edge nodes."""
|
|
14
|
+
try:
|
|
15
|
+
cluster = NPSCluster(args.config)
|
|
16
|
+
except FileNotFoundError as e:
|
|
17
|
+
print_error(str(e))
|
|
18
|
+
return 1
|
|
19
|
+
|
|
20
|
+
if args.all:
|
|
21
|
+
# Show clients from all edges
|
|
22
|
+
all_clients = cluster.get_all_clients()
|
|
23
|
+
for edge_name, clients in all_clients.items():
|
|
24
|
+
edge = cluster.get_edge(edge_name)
|
|
25
|
+
region = edge.region if edge else ""
|
|
26
|
+
console.print(f"\n[bold cyan]=== {edge_name} ({region}) ===[/bold cyan]")
|
|
27
|
+
_print_clients(clients)
|
|
28
|
+
else:
|
|
29
|
+
# Show clients from specific edge
|
|
30
|
+
edge_name = args.edge
|
|
31
|
+
if not edge_name:
|
|
32
|
+
# Use first edge as default
|
|
33
|
+
edge_name = cluster.edge_names[0] if cluster.edge_names else None
|
|
34
|
+
if not edge_name:
|
|
35
|
+
print_error("No edges configured")
|
|
36
|
+
return 1
|
|
37
|
+
|
|
38
|
+
nps = cluster.get_client(edge_name)
|
|
39
|
+
if not nps:
|
|
40
|
+
print_error(f"Edge '{edge_name}' not found")
|
|
41
|
+
return 1
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
clients = client_mgmt.list_clients(nps)
|
|
45
|
+
_print_clients(clients)
|
|
46
|
+
except NPSError as e:
|
|
47
|
+
print_error(str(e))
|
|
48
|
+
return 1
|
|
49
|
+
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _print_clients(clients: list[ClientInfo]) -> None:
|
|
54
|
+
"""Print client list as table."""
|
|
55
|
+
table = create_table()
|
|
56
|
+
table.add_column("ID", style="dim")
|
|
57
|
+
table.add_column("Remark", style="bold")
|
|
58
|
+
table.add_column("VKey", style="dim")
|
|
59
|
+
table.add_column("Status")
|
|
60
|
+
table.add_column("Conn", justify="right")
|
|
61
|
+
|
|
62
|
+
for c in clients:
|
|
63
|
+
is_connected = c.get("IsConnect", False)
|
|
64
|
+
status = (
|
|
65
|
+
"[green]Connected[/green]" if is_connected else "[red]Disconnected[/red]"
|
|
66
|
+
)
|
|
67
|
+
vkey = c.get("VerifyKey", "")
|
|
68
|
+
vkey_display = vkey[:20] + "..." if len(vkey) > 20 else vkey
|
|
69
|
+
|
|
70
|
+
table.add_row(
|
|
71
|
+
str(c.get("Id", "")),
|
|
72
|
+
c.get("Remark", ""),
|
|
73
|
+
vkey_display,
|
|
74
|
+
status,
|
|
75
|
+
str(c.get("NowConn", 0)),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
console.print(table)
|