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