cpolar-connect 0.1.0.dev0__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.
- cpolar_connect/__init__.py +16 -0
- cpolar_connect/auth.py +271 -0
- cpolar_connect/cli.py +311 -0
- cpolar_connect/config.py +305 -0
- cpolar_connect/doctor.py +459 -0
- cpolar_connect/exceptions.py +27 -0
- cpolar_connect/i18n.py +532 -0
- cpolar_connect/ssh.py +367 -0
- cpolar_connect/tunnel.py +258 -0
- cpolar_connect-0.1.0.dev0.dist-info/METADATA +325 -0
- cpolar_connect-0.1.0.dev0.dist-info/RECORD +14 -0
- cpolar_connect-0.1.0.dev0.dist-info/WHEEL +4 -0
- cpolar_connect-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- cpolar_connect-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cpolar Connect - Easy-to-use CLI tool for cpolar tunnel management and SSH connections
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
__version__ = "0.0.1"
|
|
6
|
+
__author__ = "Your Name"
|
|
7
|
+
__email__ = "your.email@example.com"
|
|
8
|
+
|
|
9
|
+
from .config import ConfigManager, CpolarConfig, ConfigError
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ConfigManager",
|
|
13
|
+
"CpolarConfig",
|
|
14
|
+
"ConfigError",
|
|
15
|
+
"__version__",
|
|
16
|
+
]
|
cpolar_connect/auth.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication module for Cpolar Connect
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import requests
|
|
7
|
+
from typing import Optional, Tuple
|
|
8
|
+
from bs4 import BeautifulSoup
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from .config import ConfigManager
|
|
12
|
+
from .exceptions import AuthenticationError, NetworkError
|
|
13
|
+
from .i18n import _
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CpolarAuth:
|
|
20
|
+
"""Handle cpolar authentication"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config_manager: ConfigManager):
|
|
23
|
+
"""Initialize authentication with config manager"""
|
|
24
|
+
self.config_manager = config_manager
|
|
25
|
+
self.config = config_manager.get_config()
|
|
26
|
+
self.session = requests.Session()
|
|
27
|
+
self.session.headers.update({
|
|
28
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
|
29
|
+
})
|
|
30
|
+
self.base_url = self.config.base_url
|
|
31
|
+
self.login_url = f"{self.base_url}/login"
|
|
32
|
+
self.status_url = f"{self.base_url}/status"
|
|
33
|
+
self.authenticated = False
|
|
34
|
+
|
|
35
|
+
def get_csrf_token(self) -> str:
|
|
36
|
+
"""Get CSRF token from login page"""
|
|
37
|
+
try:
|
|
38
|
+
response = self.session.get(self.login_url, timeout=10)
|
|
39
|
+
response.raise_for_status()
|
|
40
|
+
|
|
41
|
+
soup = BeautifulSoup(response.text, 'html.parser')
|
|
42
|
+
csrf_input = soup.find("input", {"name": "csrf_token"})
|
|
43
|
+
|
|
44
|
+
if not csrf_input:
|
|
45
|
+
# Try alternative methods
|
|
46
|
+
# Sometimes the token might be in meta tag
|
|
47
|
+
meta_csrf = soup.find("meta", {"name": "csrf-token"})
|
|
48
|
+
if meta_csrf:
|
|
49
|
+
return meta_csrf.get("content", "")
|
|
50
|
+
|
|
51
|
+
logger.error("CSRF token not found in login page")
|
|
52
|
+
raise AuthenticationError(_('error.csrf_token_not_found'))
|
|
53
|
+
|
|
54
|
+
csrf_token = csrf_input.get("value", "")
|
|
55
|
+
if not csrf_token:
|
|
56
|
+
raise AuthenticationError(_('error.csrf_token_empty'))
|
|
57
|
+
|
|
58
|
+
logger.debug("Successfully obtained CSRF token")
|
|
59
|
+
return csrf_token
|
|
60
|
+
|
|
61
|
+
except requests.RequestException as e:
|
|
62
|
+
logger.error(f"Network error while getting login page: {e}")
|
|
63
|
+
raise NetworkError(_('error.network', error=e))
|
|
64
|
+
except Exception as e:
|
|
65
|
+
logger.error(f"Error getting CSRF token: {e}")
|
|
66
|
+
raise AuthenticationError(_('error.auth', error=e))
|
|
67
|
+
|
|
68
|
+
def login(self, username: Optional[str] = None, password: Optional[str] = None) -> requests.Session:
|
|
69
|
+
"""
|
|
70
|
+
Login to cpolar and return authenticated session
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
username: Override config username if provided
|
|
74
|
+
password: Override config password if provided
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Authenticated requests.Session object
|
|
78
|
+
"""
|
|
79
|
+
# Get credentials
|
|
80
|
+
if not username:
|
|
81
|
+
username = self.config.username
|
|
82
|
+
|
|
83
|
+
if not password:
|
|
84
|
+
password = self.config_manager.get_password(username)
|
|
85
|
+
if not password:
|
|
86
|
+
raise AuthenticationError(_('auth.password_required'))
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
# Step 1: Get CSRF token
|
|
90
|
+
console.print(f"[dim]{_('auth.csrf_token')}[/dim]")
|
|
91
|
+
csrf_token = self.get_csrf_token()
|
|
92
|
+
|
|
93
|
+
# Step 2: Submit login form
|
|
94
|
+
console.print(f"[dim]{_('auth.logging_in', username=username)}[/dim]")
|
|
95
|
+
|
|
96
|
+
# Try different field names based on what cpolar might expect
|
|
97
|
+
login_data = {
|
|
98
|
+
'login': username, # or could be 'username' or 'email'
|
|
99
|
+
'password': password,
|
|
100
|
+
'csrf_token': csrf_token
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Debug: log the form fields we're sending (without password)
|
|
104
|
+
logger.debug(f"Login form fields: {list(login_data.keys())}")
|
|
105
|
+
logger.debug(f"Login URL: {self.login_url}")
|
|
106
|
+
|
|
107
|
+
response = self.session.post(
|
|
108
|
+
self.login_url,
|
|
109
|
+
data=login_data,
|
|
110
|
+
timeout=10,
|
|
111
|
+
allow_redirects=False # Handle redirects manually
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Check for redirect (successful login typically redirects)
|
|
115
|
+
if response.status_code in [302, 303]:
|
|
116
|
+
redirect_url = response.headers.get('Location', '')
|
|
117
|
+
logger.debug(f"Login redirected to: {redirect_url}")
|
|
118
|
+
|
|
119
|
+
# Follow the redirect
|
|
120
|
+
response = self.session.get(
|
|
121
|
+
redirect_url if redirect_url.startswith('http') else f"{self.base_url}{redirect_url}",
|
|
122
|
+
timeout=10
|
|
123
|
+
)
|
|
124
|
+
response.raise_for_status()
|
|
125
|
+
else:
|
|
126
|
+
response.raise_for_status()
|
|
127
|
+
|
|
128
|
+
# Step 3: Verify login success
|
|
129
|
+
if not self._verify_authentication(response):
|
|
130
|
+
logger.error("Login verification failed")
|
|
131
|
+
raise AuthenticationError(_('auth.login_failed'))
|
|
132
|
+
|
|
133
|
+
self.authenticated = True
|
|
134
|
+
console.print(f"[green]{_('auth.login_success')}[/green]")
|
|
135
|
+
logger.info(f"Successfully authenticated as {username}")
|
|
136
|
+
|
|
137
|
+
return self.session
|
|
138
|
+
|
|
139
|
+
except requests.RequestException as e:
|
|
140
|
+
logger.error(f"Network error during login: {e}")
|
|
141
|
+
raise NetworkError(_('error.network', error=e))
|
|
142
|
+
except AuthenticationError:
|
|
143
|
+
raise
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.error(f"Unexpected error during login: {e}")
|
|
146
|
+
raise AuthenticationError(_('error.auth', error=e))
|
|
147
|
+
|
|
148
|
+
def _verify_authentication(self, response: requests.Response) -> bool:
|
|
149
|
+
"""
|
|
150
|
+
Verify that authentication was successful
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
response: Response from login POST request
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
True if authentication successful, False otherwise
|
|
157
|
+
"""
|
|
158
|
+
response_text = response.text.lower()
|
|
159
|
+
|
|
160
|
+
# Check for success indicators
|
|
161
|
+
success_indicators = [
|
|
162
|
+
'logout',
|
|
163
|
+
'status',
|
|
164
|
+
'dashboard',
|
|
165
|
+
'tunnel',
|
|
166
|
+
'隧道', # Chinese for tunnel
|
|
167
|
+
'get-started' # Cpolar redirect page after login
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
# Check for failure indicators (be more specific)
|
|
171
|
+
failure_indicators = [
|
|
172
|
+
'login failed',
|
|
173
|
+
'invalid credentials',
|
|
174
|
+
'incorrect password',
|
|
175
|
+
'authentication failed',
|
|
176
|
+
'登录失败', # Chinese for login failed
|
|
177
|
+
'密码错误' # Chinese for wrong password
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
# If we find failure indicators, authentication failed
|
|
181
|
+
for indicator in failure_indicators:
|
|
182
|
+
if indicator in response_text:
|
|
183
|
+
logger.debug(f"Found failure indicator: {indicator}")
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
# If we find success indicators, authentication succeeded
|
|
187
|
+
for indicator in success_indicators:
|
|
188
|
+
if indicator in response_text:
|
|
189
|
+
logger.debug(f"Found success indicator: {indicator}")
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
# Check if we're still on login page
|
|
193
|
+
if '/login' in response.url:
|
|
194
|
+
logger.debug("Still on login page after POST")
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
# If redirected to status, dashboard, or get-started, consider it success
|
|
198
|
+
if any(path in response.url for path in ['/status', '/dashboard', '/get-started']):
|
|
199
|
+
logger.debug(f"Redirected to {response.url}")
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
# Default to checking for specific text
|
|
203
|
+
return 'logout' in response_text or 'status' in response_text
|
|
204
|
+
|
|
205
|
+
def logout(self) -> None:
|
|
206
|
+
"""Logout from cpolar"""
|
|
207
|
+
if not self.authenticated:
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
logout_url = f"{self.base_url}/logout"
|
|
212
|
+
self.session.get(logout_url, timeout=5)
|
|
213
|
+
self.authenticated = False
|
|
214
|
+
logger.info("Logged out from cpolar")
|
|
215
|
+
except Exception as e:
|
|
216
|
+
# Logout errors are not critical
|
|
217
|
+
logger.debug(f"Error during logout: {e}")
|
|
218
|
+
|
|
219
|
+
def test_connection(self) -> bool:
|
|
220
|
+
"""Test connection to cpolar"""
|
|
221
|
+
try:
|
|
222
|
+
response = self.session.get(self.base_url, timeout=10)
|
|
223
|
+
return response.status_code == 200
|
|
224
|
+
except Exception as e:
|
|
225
|
+
logger.error(f"Connection test failed: {e}")
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
def is_authenticated(self) -> bool:
|
|
229
|
+
"""Check if currently authenticated"""
|
|
230
|
+
if not self.authenticated:
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
# Try to access status page to verify session is still valid
|
|
234
|
+
try:
|
|
235
|
+
response = self.session.get(self.status_url, timeout=10, allow_redirects=True)
|
|
236
|
+
|
|
237
|
+
# Check if redirected to login page
|
|
238
|
+
if '/login' in response.url.lower():
|
|
239
|
+
self.authenticated = False
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
# Check for authentication indicators in response
|
|
243
|
+
response_text = response.text.lower()
|
|
244
|
+
if response.status_code == 200:
|
|
245
|
+
# Look for indicators we're logged in
|
|
246
|
+
if any(indicator in response_text for indicator in ['logout', 'status', 'tunnel', '隧道']):
|
|
247
|
+
return True
|
|
248
|
+
# If we see login form elements, we're not authenticated
|
|
249
|
+
if any(indicator in response_text for indicator in ['password', 'login', 'sign in']):
|
|
250
|
+
self.authenticated = False
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
return response.status_code == 200
|
|
254
|
+
except Exception as e:
|
|
255
|
+
logger.debug(f"Authentication check failed: {e}")
|
|
256
|
+
self.authenticated = False
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
def get_session(self) -> requests.Session:
|
|
260
|
+
"""Get the current session, login if necessary"""
|
|
261
|
+
if not self.is_authenticated():
|
|
262
|
+
self.login()
|
|
263
|
+
return self.session
|
|
264
|
+
|
|
265
|
+
def __enter__(self):
|
|
266
|
+
"""Context manager entry - login"""
|
|
267
|
+
return self.get_session()
|
|
268
|
+
|
|
269
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
270
|
+
"""Context manager exit - logout"""
|
|
271
|
+
self.logout()
|
cpolar_connect/cli.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Cpolar Connect - CLI Entry Point
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.prompt import Prompt, Confirm
|
|
10
|
+
import sys
|
|
11
|
+
import os
|
|
12
|
+
from getpass import getpass
|
|
13
|
+
|
|
14
|
+
from .config import ConfigManager, ConfigError
|
|
15
|
+
from .exceptions import AuthenticationError, TunnelError, SSHError, NetworkError
|
|
16
|
+
from .auth import CpolarAuth
|
|
17
|
+
from .tunnel import TunnelManager
|
|
18
|
+
from .ssh import SSHManager
|
|
19
|
+
from .i18n import _, get_i18n, Language
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
@click.group(invoke_without_command=True)
|
|
24
|
+
@click.version_option(version="0.0.1")
|
|
25
|
+
@click.pass_context
|
|
26
|
+
def cli(ctx):
|
|
27
|
+
"""🚀 Cpolar Connect - Easy cpolar tunnel management and SSH connections"""
|
|
28
|
+
ctx.ensure_object(dict)
|
|
29
|
+
ctx.obj['config_manager'] = ConfigManager()
|
|
30
|
+
|
|
31
|
+
# If no command is provided, run the default action (connect)
|
|
32
|
+
if ctx.invoked_subcommand is None:
|
|
33
|
+
# Default behavior: update and connect
|
|
34
|
+
config_manager = ctx.obj['config_manager']
|
|
35
|
+
|
|
36
|
+
if not config_manager.config_exists():
|
|
37
|
+
console.print(f"[yellow]⚠️ {_('cli.no_config')}[/yellow]")
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
config = config_manager.get_config()
|
|
42
|
+
console.print(f"[cyan]🔗 {_('cli.connecting_server')}[/cyan]")
|
|
43
|
+
|
|
44
|
+
# Check password availability
|
|
45
|
+
password = config_manager.get_password(config.username)
|
|
46
|
+
if not password:
|
|
47
|
+
console.print(f"[yellow]⚠️ {_('auth.password_required')}[/yellow]")
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
|
|
50
|
+
# Execute full connection flow
|
|
51
|
+
with console.status("[bold green]Connecting to cpolar tunnel...") as status:
|
|
52
|
+
try:
|
|
53
|
+
# 1. Authenticate with cpolar
|
|
54
|
+
status.update("[yellow]Authenticating with cpolar...[/yellow]")
|
|
55
|
+
auth = CpolarAuth(config_manager)
|
|
56
|
+
session = auth.login()
|
|
57
|
+
|
|
58
|
+
# 2. Get tunnel information
|
|
59
|
+
status.update("[yellow]Fetching tunnel information...[/yellow]")
|
|
60
|
+
tunnel_manager = TunnelManager(session, config.base_url)
|
|
61
|
+
tunnel_info = tunnel_manager.get_tunnel_info()
|
|
62
|
+
|
|
63
|
+
# 3. Setup SSH and connect
|
|
64
|
+
status.update("[yellow]Setting up SSH connection...[/yellow]")
|
|
65
|
+
ssh_manager = SSHManager(config)
|
|
66
|
+
|
|
67
|
+
# Get server password if needed (for first-time key upload)
|
|
68
|
+
server_password = None
|
|
69
|
+
if not ssh_manager.test_ssh_connection(tunnel_info.hostname, tunnel_info.port):
|
|
70
|
+
console.print(f"\n[yellow]{_('warning.first_connection')}[/yellow]")
|
|
71
|
+
server_password = getpass(f"Enter password for {config.server_user}@{tunnel_info.hostname}: ")
|
|
72
|
+
|
|
73
|
+
# Setup and connect
|
|
74
|
+
ssh_manager.setup_and_connect(tunnel_info, server_password)
|
|
75
|
+
|
|
76
|
+
except (AuthenticationError, TunnelError, SSHError, NetworkError) as e:
|
|
77
|
+
console.print(f"[red]❌ {_('error.connection_failed', error=e)}[/red]")
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
finally:
|
|
80
|
+
# Clean logout
|
|
81
|
+
if 'auth' in locals():
|
|
82
|
+
auth.logout()
|
|
83
|
+
|
|
84
|
+
except ConfigError as e:
|
|
85
|
+
console.print(f"[red]❌ {_('error.config', error=e)}[/red]")
|
|
86
|
+
sys.exit(1)
|
|
87
|
+
|
|
88
|
+
@cli.command()
|
|
89
|
+
@click.option('--force', '-f', is_flag=True, help='Overwrite existing configuration')
|
|
90
|
+
@click.pass_context
|
|
91
|
+
def init(ctx, force):
|
|
92
|
+
"""🔧 Initialize configuration file"""
|
|
93
|
+
config_manager = ctx.obj['config_manager']
|
|
94
|
+
|
|
95
|
+
if config_manager.config_exists() and not force:
|
|
96
|
+
if not Confirm.ask(_('warning.config_exists')):
|
|
97
|
+
console.print(f"[yellow]{_('warning.config_cancelled')}[/yellow]")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
console.print(Panel.fit("🔧 [bold]Cpolar Connect Setup[/bold]", border_style="blue"))
|
|
101
|
+
|
|
102
|
+
# Collect basic configuration
|
|
103
|
+
console.print("\n[bold cyan]Basic Configuration[/bold cyan]")
|
|
104
|
+
|
|
105
|
+
username = Prompt.ask(_('cli.enter_username'))
|
|
106
|
+
server_user = Prompt.ask(_('cli.enter_server_user'))
|
|
107
|
+
|
|
108
|
+
# Parse ports
|
|
109
|
+
ports_input = Prompt.ask(_('cli.enter_ports'), default="8888,6666")
|
|
110
|
+
try:
|
|
111
|
+
ports = [int(p.strip()) for p in ports_input.split(',')]
|
|
112
|
+
except ValueError:
|
|
113
|
+
console.print(f"[red]❌ {_('warning.invalid_port_format')}[/red]")
|
|
114
|
+
sys.exit(1)
|
|
115
|
+
|
|
116
|
+
auto_connect = Confirm.ask("Auto-connect after update?", default=True)
|
|
117
|
+
|
|
118
|
+
# Create configuration
|
|
119
|
+
config_data = {
|
|
120
|
+
'username': username,
|
|
121
|
+
'server_user': server_user,
|
|
122
|
+
'ports': ports,
|
|
123
|
+
'auto_connect': auto_connect
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
config_manager.create_config(config_data)
|
|
128
|
+
console.print(f"\n[green]{_('cli.config_created')}[/green]")
|
|
129
|
+
console.print(f"📁 Configuration saved to: {config_manager.config_path}")
|
|
130
|
+
|
|
131
|
+
# Prompt for password storage
|
|
132
|
+
store_password = Confirm.ask(_('cli.store_password'), default=True)
|
|
133
|
+
if store_password:
|
|
134
|
+
password = getpass(f"{_('cli.enter_password')}: ")
|
|
135
|
+
if password:
|
|
136
|
+
config_manager.set_password(username, password)
|
|
137
|
+
|
|
138
|
+
console.print(f"\n[yellow]💡 {_('info.env_password_tip')}[/yellow]")
|
|
139
|
+
console.print(f"[yellow]💡 {_('info.config_show_tip')}[/yellow]")
|
|
140
|
+
|
|
141
|
+
except ConfigError as e:
|
|
142
|
+
console.print(f"[red]❌ {_('error.config_create_failed', error=e)}[/red]")
|
|
143
|
+
sys.exit(1)
|
|
144
|
+
|
|
145
|
+
# TODO: Implement after creating auth, tunnel, and ssh modules
|
|
146
|
+
# @cli.command()
|
|
147
|
+
# def connect():
|
|
148
|
+
# """🔗 Connect to server via SSH"""
|
|
149
|
+
# pass
|
|
150
|
+
|
|
151
|
+
# @cli.command()
|
|
152
|
+
# def update():
|
|
153
|
+
# """🔄 Update tunnel information and SSH configuration"""
|
|
154
|
+
# pass
|
|
155
|
+
|
|
156
|
+
# @cli.command()
|
|
157
|
+
# def status():
|
|
158
|
+
# """📊 Show current tunnel status"""
|
|
159
|
+
# pass
|
|
160
|
+
|
|
161
|
+
@cli.group()
|
|
162
|
+
def config():
|
|
163
|
+
"""⚙️ Configuration management"""
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
@config.command('get')
|
|
167
|
+
@click.argument('key')
|
|
168
|
+
@click.pass_context
|
|
169
|
+
def config_get(ctx, key):
|
|
170
|
+
"""Get configuration value"""
|
|
171
|
+
config_manager = ctx.obj['config_manager']
|
|
172
|
+
try:
|
|
173
|
+
value = config_manager.get(key)
|
|
174
|
+
console.print(f"[cyan]{key}[/cyan]: [white]{value}[/white]")
|
|
175
|
+
except KeyError:
|
|
176
|
+
console.print(f"[red]❌ {_('error.config_key_not_found', key=key)}[/red]")
|
|
177
|
+
sys.exit(1)
|
|
178
|
+
except ConfigError as e:
|
|
179
|
+
console.print(f"[red]❌ {_('error.config', error=e)}[/red]")
|
|
180
|
+
sys.exit(1)
|
|
181
|
+
|
|
182
|
+
@config.command('set')
|
|
183
|
+
@click.argument('key')
|
|
184
|
+
@click.argument('value')
|
|
185
|
+
@click.pass_context
|
|
186
|
+
def config_set(ctx, key, value):
|
|
187
|
+
"""Set configuration value"""
|
|
188
|
+
config_manager = ctx.obj['config_manager']
|
|
189
|
+
try:
|
|
190
|
+
config_manager.set(key, value)
|
|
191
|
+
console.print(f"[green]{_('cli.config_updated', key=key, value=value)}[/green]")
|
|
192
|
+
except ConfigError as e:
|
|
193
|
+
console.print(f"[red]❌ {_('error.config', error=e)}[/red]")
|
|
194
|
+
sys.exit(1)
|
|
195
|
+
|
|
196
|
+
@config.command('edit')
|
|
197
|
+
@click.pass_context
|
|
198
|
+
def config_edit(ctx):
|
|
199
|
+
"""Edit configuration file in default editor"""
|
|
200
|
+
config_manager = ctx.obj['config_manager']
|
|
201
|
+
try:
|
|
202
|
+
config_manager.edit()
|
|
203
|
+
console.print(f"[green]✅ {_('info.config_opened')}[/green]")
|
|
204
|
+
except ConfigError as e:
|
|
205
|
+
console.print(f"[red]❌ {_('error.config_edit_failed', error=e)}[/red]")
|
|
206
|
+
sys.exit(1)
|
|
207
|
+
|
|
208
|
+
@config.command('show')
|
|
209
|
+
@click.pass_context
|
|
210
|
+
def config_show(ctx):
|
|
211
|
+
"""Show current configuration"""
|
|
212
|
+
config_manager = ctx.obj['config_manager']
|
|
213
|
+
try:
|
|
214
|
+
config_manager.display()
|
|
215
|
+
except ConfigError as e:
|
|
216
|
+
console.print(f"[red]❌ {_('error.config', error=e)}[/red]")
|
|
217
|
+
console.print(f"[yellow]💡 {_('info.run_init')}[/yellow]")
|
|
218
|
+
sys.exit(1)
|
|
219
|
+
|
|
220
|
+
@config.command('path')
|
|
221
|
+
@click.pass_context
|
|
222
|
+
def config_path(ctx):
|
|
223
|
+
"""Show configuration file path"""
|
|
224
|
+
config_manager = ctx.obj['config_manager']
|
|
225
|
+
console.print(f"📁 Config file: [cyan]{config_manager.config_path}[/cyan]")
|
|
226
|
+
console.print(f"📁 Logs directory: [cyan]{config_manager.logs_path}[/cyan]")
|
|
227
|
+
|
|
228
|
+
@config.command('clear-password')
|
|
229
|
+
@click.pass_context
|
|
230
|
+
def config_clear_password(ctx):
|
|
231
|
+
"""Clear stored password from keyring"""
|
|
232
|
+
config_manager = ctx.obj['config_manager']
|
|
233
|
+
try:
|
|
234
|
+
config = config_manager.get_config()
|
|
235
|
+
config_manager.clear_password(config.username)
|
|
236
|
+
except ConfigError as e:
|
|
237
|
+
console.print(f"[red]❌ {_('error.config', error=e)}[/red]")
|
|
238
|
+
sys.exit(1)
|
|
239
|
+
|
|
240
|
+
@cli.command('language')
|
|
241
|
+
@click.argument('lang', type=click.Choice(['zh', 'en', 'chinese', 'english']))
|
|
242
|
+
@click.pass_context
|
|
243
|
+
def set_language(ctx, lang):
|
|
244
|
+
"""Set interface language / 设置界面语言
|
|
245
|
+
|
|
246
|
+
Examples:
|
|
247
|
+
cpolar-connect language zh # 设置中文
|
|
248
|
+
cpolar-connect language en # Set English
|
|
249
|
+
"""
|
|
250
|
+
config_manager = ctx.obj['config_manager']
|
|
251
|
+
|
|
252
|
+
# Normalize language code
|
|
253
|
+
if lang in ['chinese', 'zh']:
|
|
254
|
+
lang_code = 'zh'
|
|
255
|
+
lang_name = '中文'
|
|
256
|
+
else:
|
|
257
|
+
lang_code = 'en'
|
|
258
|
+
lang_name = 'English'
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
# Load config
|
|
262
|
+
config = config_manager.get_config()
|
|
263
|
+
|
|
264
|
+
# Update language
|
|
265
|
+
config.language = lang_code
|
|
266
|
+
config_manager.save_config(config)
|
|
267
|
+
|
|
268
|
+
# Apply immediately
|
|
269
|
+
from .i18n import set_language, Language
|
|
270
|
+
set_language(Language.ZH if lang_code == 'zh' else Language.EN)
|
|
271
|
+
|
|
272
|
+
# Show success message in new language
|
|
273
|
+
if lang_code == 'zh':
|
|
274
|
+
console.print(f"[green]✅ 界面语言已设置为 {lang_name}[/green]")
|
|
275
|
+
console.print("[dim]重新运行命令以使用新语言[/dim]")
|
|
276
|
+
else:
|
|
277
|
+
console.print(f"[green]✅ Interface language set to {lang_name}[/green]")
|
|
278
|
+
console.print("[dim]Restart the command to use the new language[/dim]")
|
|
279
|
+
|
|
280
|
+
except ConfigError as e:
|
|
281
|
+
console.print(f"[red]❌ {_('error.config', error=e)}[/red]")
|
|
282
|
+
sys.exit(1)
|
|
283
|
+
|
|
284
|
+
@cli.command('doctor')
|
|
285
|
+
@click.pass_context
|
|
286
|
+
def doctor_cmd(ctx):
|
|
287
|
+
"""🏥 Diagnose connection problems / 诊断连接问题
|
|
288
|
+
|
|
289
|
+
This command checks:
|
|
290
|
+
- Configuration validity
|
|
291
|
+
- Network connectivity
|
|
292
|
+
- Cpolar authentication
|
|
293
|
+
- SSH setup
|
|
294
|
+
- Active tunnels
|
|
295
|
+
|
|
296
|
+
Use this when you have connection issues.
|
|
297
|
+
"""
|
|
298
|
+
from .doctor import Doctor
|
|
299
|
+
|
|
300
|
+
doctor = Doctor()
|
|
301
|
+
success = doctor.run()
|
|
302
|
+
|
|
303
|
+
if not success:
|
|
304
|
+
sys.exit(1)
|
|
305
|
+
|
|
306
|
+
def main():
|
|
307
|
+
"""Entry point for the CLI"""
|
|
308
|
+
cli()
|
|
309
|
+
|
|
310
|
+
if __name__ == '__main__':
|
|
311
|
+
main()
|