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 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
@@ -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)