unitlab 2.3.33__py3-none-any.whl → 2.3.34__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.
@@ -1,653 +0,0 @@
1
- """
2
- Cloudflare API-based Tunnel Configuration
3
- Uses API to dynamically manage DNS and routes
4
- """
5
-
6
- import os
7
- import requests
8
- import subprocess
9
- import time
10
- import logging
11
- from pathlib import Path
12
- from .binary_manager import CloudflaredBinaryManager
13
-
14
- # Try to load .env file if it exists
15
- try:
16
- from dotenv import load_dotenv
17
- env_path = Path(__file__).parent.parent.parent / '.env'
18
- if env_path.exists():
19
- load_dotenv(env_path)
20
- except ImportError:
21
- pass # dotenv not installed, use system env vars only
22
-
23
- logger = logging.getLogger(__name__)
24
-
25
-
26
- class CloudflareAPITunnel:
27
- def __init__(self, base_domain, device_id):
28
- """
29
- Initialize API-based tunnel manager
30
- """
31
- self.base_domain = "1scan.uz"
32
- self.device_id = device_id
33
-
34
- # Clean device ID for subdomain
35
- self.clean_device_id = device_id.replace('-', '').replace('_', '').lower()[:20]
36
-
37
- # Cloudflare IDs - hardcoded for zero-config experience
38
- self.zone_id = "78182c3883adad79d8f1026851a68176"
39
- self.account_id = "c91192ae20a5d43f65e087550d8dc89b"
40
- self.tunnel_id = "0777fc10-49c4-472d-8661-f60d80d6184d" # unitlab-agent tunnel
41
-
42
- # API token - hardcoded for zero-config experience
43
- # This token only has DNS edit permissions for 1scan.uz - limited scope for safety
44
- self.api_token = "LJLe6QMOtpN0MeuLQ05_zUKKxVm4vEibkC8lxSJd"
45
-
46
- if not self.api_token:
47
- logger.warning("Using fallback tunnel configuration without API management.")
48
-
49
- # API setup
50
- self.api_base = "https://api.cloudflare.com/client/v4"
51
- self.headers = {
52
- "Authorization": f"Bearer {self.api_token}",
53
- "Content-Type": "application/json"
54
- } if self.api_token else {}
55
-
56
- # URLs for services - simplified for Jupyter only
57
- self.jupyter_subdomain = f"j{self.clean_device_id}"
58
- self.jupyter_url = f"https://{self.jupyter_subdomain}.{self.base_domain}"
59
-
60
- # Keep SSH URLs for compatibility but they won't work yet
61
- self.ssh_subdomain = f"s{self.clean_device_id}"
62
- self.ssh_hostname = f"{self.ssh_subdomain}.{self.base_domain}"
63
- self.ssh_url = self.ssh_hostname
64
-
65
- self.tunnel_process = None
66
- self.created_dns_records = []
67
- self.tunnel_config_file = None
68
-
69
- # Try to initialize binary manager, but don't fail if it doesn't work
70
- try:
71
- self.binary_manager = CloudflaredBinaryManager()
72
- except Exception as e:
73
- logger.warning(f"Binary manager initialization failed: {e}")
74
- self.binary_manager = None
75
-
76
- def create_dns_records(self):
77
- """
78
- Create DNS CNAME records for this device
79
- """
80
- if not self.api_token:
81
- print("⚠️ No API token configured. Skipping DNS creation.")
82
- print(" Assuming DNS records already exist or will be created manually.")
83
- return True
84
-
85
- print(f"📡 Creating DNS records for device {self.device_id}...")
86
-
87
- records = [
88
- {"name": self.jupyter_subdomain, "comment": f"Jupyter for {self.device_id}"},
89
- {"name": self.ssh_subdomain, "comment": f"SSH for {self.device_id}"}
90
- ]
91
-
92
- for record in records:
93
- try:
94
- # Check if record exists
95
- check_url = f"{self.api_base}/zones/{self.zone_id}/dns_records"
96
- params = {"name": f"{record['name']}.{self.base_domain}", "type": "CNAME"}
97
-
98
- response = requests.get(check_url, headers=self.headers, params=params)
99
- existing = response.json()
100
-
101
- if existing.get("result") and len(existing["result"]) > 0:
102
- # Record exists
103
- print(f" ✓ DNS record {record['name']}.{self.base_domain} already exists")
104
- continue
105
-
106
- # Create new record
107
- data = {
108
- "type": "CNAME",
109
- "name": record["name"],
110
- "content": f"{self.tunnel_id}.cfargotunnel.com",
111
- "ttl": 1, # Auto
112
- "proxied": True,
113
- "comment": record["comment"]
114
- }
115
-
116
- response = requests.post(check_url, headers=self.headers, json=data)
117
-
118
- if response.status_code == 200:
119
- result = response.json()
120
- if result.get("success"):
121
- print(f" ✅ Created DNS: {record['name']}.{self.base_domain}")
122
- self.created_dns_records.append(result["result"]["id"])
123
- else:
124
- print(f" ⚠️ Failed to create {record['name']}: {result.get('errors')}")
125
- else:
126
- print(f" ❌ HTTP error {response.status_code} for {record['name']}")
127
-
128
- except Exception as e:
129
- print(f" ❌ Error creating DNS record: {e}")
130
- continue
131
-
132
- return True
133
-
134
- def update_tunnel_config(self, jupyter_port=8888, ssh_port=22):
135
- """
136
- Update tunnel configuration via API
137
- """
138
- if not self.api_token:
139
- print("⚠️ No API token. Tunnel will use existing configuration.")
140
- return True
141
-
142
- print(f"🔧 Configuring tunnel routes...")
143
-
144
- # Get current tunnel config first
145
- get_url = f"{self.api_base}/accounts/{self.account_id}/tunnels/{self.tunnel_id}/configurations"
146
-
147
- try:
148
- # Get existing config
149
- response = requests.get(get_url, headers=self.headers)
150
- current_config = response.json() if response.status_code == 200 else {}
151
-
152
- # Build new ingress rules
153
- new_ingress = [
154
- {
155
- "hostname": f"{self.jupyter_subdomain}.{self.base_domain}",
156
- "service": f"http://localhost:{jupyter_port}",
157
- "originRequest": {
158
- "noTLSVerify": True
159
- }
160
- },
161
- {
162
- "hostname": f"{self.ssh_subdomain}.{self.base_domain}",
163
- "service": f"ssh://localhost:{ssh_port}"
164
- }
165
- ]
166
-
167
- # Merge with existing ingress if any
168
- if current_config.get("success") and current_config.get("result"):
169
- existing_ingress = current_config["result"].get("config", {}).get("ingress", [])
170
-
171
- # Filter out our hostnames from existing
172
- filtered_ingress = [
173
- rule for rule in existing_ingress
174
- if rule.get("hostname") not in [
175
- f"{self.jupyter_subdomain}.{self.base_domain}",
176
- f"{self.ssh_subdomain}.{self.base_domain}"
177
- ] and rule.get("service") != "http_status:404"
178
- ]
179
-
180
- # Combine
181
- new_ingress = new_ingress + filtered_ingress
182
-
183
- # Add catch-all at the end
184
- new_ingress.append({"service": "http_status:404"})
185
-
186
- # Update configuration
187
- config_data = {
188
- "config": {
189
- "ingress": new_ingress
190
- }
191
- }
192
-
193
- put_url = f"{self.api_base}/accounts/{self.account_id}/tunnels/{self.tunnel_id}/configurations"
194
- response = requests.put(put_url, headers=self.headers, json=config_data)
195
-
196
- if response.status_code == 200:
197
- print(f" ✅ Tunnel routes configured")
198
- return True
199
- else:
200
- print(f" ⚠️ Route configuration status: {response.status_code}")
201
- # Continue anyway - routes might be configured manually
202
- return True
203
-
204
- except Exception as e:
205
- print(f" ⚠️ Could not update routes via API: {e}")
206
- print(" Assuming routes are configured in dashboard.")
207
- return True
208
-
209
- def create_device_tunnel(self):
210
- """
211
- Create a unique tunnel for this device
212
- Each tunnel gets a unique UUID to avoid conflicts
213
- """
214
- import uuid
215
- # Always use a unique name with UUID
216
- unique_id = str(uuid.uuid4())[:8]
217
- tunnel_name = f"device-{self.clean_device_id}-{unique_id}"
218
- # Since we use UUID, no need to check - just create new tunnel
219
- create_url = f"{self.api_base}/accounts/{self.account_id}/tunnels"
220
- # Generate secret first
221
- tunnel_secret = os.urandom(32).hex()
222
-
223
- create_data = {
224
- "name": tunnel_name,
225
- "tunnel_secret": tunnel_secret
226
- }
227
-
228
- create_response = requests.post(create_url, headers=self.headers, json=create_data)
229
-
230
- if create_response.status_code in [200, 201]:
231
- tunnel = create_response.json()['result']
232
- print(f"✅ Created tunnel: {tunnel_name}")
233
-
234
- # Add the secret to the tunnel info (API doesn't return it)
235
- tunnel['tunnel_secret'] = tunnel_secret
236
-
237
- # Save credentials for this tunnel
238
- self._save_tunnel_credentials(tunnel)
239
-
240
- # Configure tunnel routes
241
- self._configure_tunnel_routes(tunnel['id'])
242
-
243
- # Create DNS records for this device
244
- self.create_dns_records()
245
-
246
- return tunnel
247
- else:
248
- print(f"❌ Failed to create tunnel: {create_response.text}")
249
- return None
250
-
251
- def _configure_tunnel_routes(self, tunnel_id):
252
- """
253
- Configure ingress routes for the device tunnel
254
- The tunnel needs to be configured with a config file, not via API
255
- So we'll create a config file for it
256
- """
257
- import yaml
258
-
259
- # Create config file for this tunnel
260
- config_dir = Path.home() / '.cloudflared'
261
- config_dir.mkdir(exist_ok=True)
262
- config_file = config_dir / f'config-{tunnel_id}.yml'
263
-
264
- config = {
265
- "tunnel": tunnel_id,
266
- "credentials-file": str(config_dir / f"{tunnel_id}.json"),
267
- "ingress": [
268
- {
269
- "hostname": f"{self.jupyter_subdomain}.{self.base_domain}",
270
- "service": "http://localhost:8888",
271
- "originRequest": {
272
- "noTLSVerify": True
273
- }
274
- },
275
- {
276
- "hostname": f"{self.ssh_subdomain}.{self.base_domain}",
277
- "service": "ssh://localhost:22"
278
- },
279
- {
280
- "service": "http_status:404"
281
- }
282
- ]
283
- }
284
-
285
- with open(config_file, 'w') as f:
286
- yaml.dump(config, f)
287
-
288
- print(f"✅ Created tunnel config: {config_file}")
289
- self.tunnel_config_file = config_file
290
-
291
- def _save_tunnel_credentials(self, tunnel_info):
292
- """
293
- Save tunnel credentials locally for this device
294
- Credentials must be base64 encoded for cloudflared
295
- """
296
- import base64
297
- import json
298
-
299
- creds_dir = Path.home() / '.cloudflared'
300
- creds_dir.mkdir(exist_ok=True)
301
-
302
- creds_file = creds_dir / f"{tunnel_info['id']}.json"
303
-
304
- # Get the secret - it should be hex string
305
- secret_hex = tunnel_info.get('tunnel_secret') or tunnel_info.get('secret')
306
- if secret_hex:
307
- # Convert hex to bytes then to base64
308
- secret_bytes = bytes.fromhex(secret_hex)
309
- secret_b64 = base64.b64encode(secret_bytes).decode('ascii')
310
- else:
311
- print("⚠️ No tunnel secret found")
312
- return None
313
-
314
- credentials = {
315
- "AccountTag": self.account_id,
316
- "TunnelSecret": secret_b64, # Must be base64!
317
- "TunnelID": tunnel_info['id']
318
- }
319
-
320
- with open(creds_file, 'w') as f:
321
- json.dump(credentials, f)
322
-
323
- print(f"💾 Saved credentials to: {creds_file}")
324
- return creds_file
325
-
326
- def start_tunnel_with_token(self):
327
- """
328
- Start tunnel using the existing service token
329
- """
330
- try:
331
- print("🚀 Starting Cloudflare tunnel...")
332
-
333
- # Ensure cloudflared is available
334
- cloudflared_path = self._ensure_cloudflared()
335
- if not cloudflared_path:
336
- raise RuntimeError("Failed to obtain cloudflared binary")
337
-
338
- # Always create a new unique tunnel with UUID
339
- device_tunnel = self.create_device_tunnel()
340
-
341
- # Now set up DNS and routes via API after tunnel is created/found
342
- if self.api_token and device_tunnel:
343
- self.tunnel_id = device_tunnel['id'] # Ensure tunnel_id is set
344
- self.create_dns_records()
345
- self.update_tunnel_config()
346
-
347
- if not device_tunnel:
348
- print("❌ Could not create/find device tunnel")
349
- # Fallback to shared tunnel if API fails
350
- print("⚠️ Falling back to shared tunnel...")
351
- service_token = "eyJhIjoiYzkxMTkyYWUyMGE1ZDQzZjY1ZTA4NzU1MGQ4ZGM4OWIiLCJ0IjoiMDc3N2ZjMTAtNDljNC00NzJkLTg2NjEtZjYwZDgwZDYxODRkIiwicyI6Ik9XRTNaak5tTVdVdE1tWTRaUzAwTmpoakLTazBmalF0WXpjek1tSm1ZVGt4WlRRMCJ9"
352
- cmd = [
353
- cloudflared_path,
354
- "tunnel",
355
- "--no-autoupdate",
356
- "run",
357
- "--token",
358
- service_token
359
- ]
360
- else:
361
- tunnel_id = device_tunnel['id']
362
- tunnel_name = device_tunnel['name']
363
-
364
- print(f"🚇 Starting tunnel: {tunnel_name} ({tunnel_id})")
365
-
366
- # Check if credentials file exists
367
- creds_file = Path.home() / '.cloudflared' / f"{tunnel_id}.json"
368
- config_file = Path.home() / '.cloudflared' / f'config-{tunnel_id}.yml'
369
- cmd = None # Initialize cmd
370
-
371
- # Since we always create new tunnels, credentials should exist
372
- if not creds_file.exists():
373
- print("❌ Error: No credentials file found for newly created tunnel")
374
- return None
375
-
376
- # Use credentials to run tunnel
377
- result = token_response.json()
378
- if 'result' in result:
379
- token = result['result']
380
- print(f" ✅ Got tunnel token")
381
- else:
382
- print(f" ❌ No token in response: {result}")
383
- return None
384
-
385
- # Check if config file exists with ingress rules
386
- if config_file.exists():
387
- # Use token with config file for ingress rules
388
- print(f" Using token with config file: {config_file}")
389
- cmd = [
390
- cloudflared_path,
391
- "tunnel",
392
- "--no-autoupdate",
393
- "--config", str(config_file),
394
- "run",
395
- "--token", token
396
- ]
397
- else:
398
- # Use token directly
399
- print(f" Using token directly (no config file)")
400
- cmd = [
401
- cloudflared_path,
402
- "tunnel",
403
- "--no-autoupdate",
404
- "run",
405
- "--token", token
406
- ]
407
- else:
408
- print(f"❌ Could not get tunnel token: {token_response.status_code}")
409
- if token_response.text:
410
- print(f" Error: {token_response.text}")
411
-
412
- # If 404, tunnel doesn't exist or we don't have access
413
- if token_response.status_code == 404:
414
- print(f"🔄 Tunnel not accessible (404), need to recreate...")
415
- # Delete the old tunnel immediately
416
- delete_url = f"{self.api_base}/accounts/{self.account_id}/tunnels/{tunnel_id}"
417
- delete_response = requests.delete(delete_url, headers=self.headers)
418
- if delete_response.status_code in [200, 204, 404]:
419
- print(f" ✅ Deleted old tunnel")
420
-
421
- # Create a new tunnel with timestamp
422
- import uuid
423
- # Use a random suffix to ensure uniqueness
424
- random_suffix = str(uuid.uuid4())[:8]
425
- self.clean_device_id = f"{self.device_id.replace(' ', '').replace('-', '').replace('.', '').replace('_', '')[:16]}-{random_suffix}"
426
- print(f" Creating new tunnel with ID: {self.clean_device_id}")
427
-
428
- new_tunnel = self.create_device_tunnel(force_new=False)
429
-
430
- if new_tunnel and new_tunnel.get('tunnel_secret'):
431
- print(f" ✅ Created new tunnel: {new_tunnel['name']}")
432
- # Use the new tunnel directly - update references
433
- device_tunnel = new_tunnel
434
- tunnel_id = new_tunnel['id']
435
- tunnel_name = new_tunnel['name']
436
-
437
- # Check if credentials were saved
438
- creds_file = Path.home() / '.cloudflared' / f"{tunnel_id}.json"
439
- if creds_file.exists():
440
- print(f" ✅ Credentials saved for new tunnel")
441
- # Set up command to run with credentials
442
- config_file = Path.home() / '.cloudflared' / f'config-{tunnel_id}.yml'
443
- if config_file.exists():
444
- cmd = [
445
- cloudflared_path,
446
- "tunnel",
447
- "--no-autoupdate",
448
- "--config", str(config_file),
449
- "run"
450
- ]
451
- else:
452
- cmd = [
453
- cloudflared_path,
454
- "tunnel",
455
- "--no-autoupdate",
456
- "--credentials-file", str(creds_file),
457
- "run",
458
- tunnel_id
459
- ]
460
- else:
461
- print(f" ❌ No credentials for new tunnel, cannot proceed")
462
- return None
463
- else:
464
- print(f" ❌ Failed to create replacement tunnel")
465
- return None
466
- else:
467
- print(f" ℹ️ Token endpoint returned {token_response.status_code}")
468
- print(f" This might mean the tunnel needs to be recreated manually")
469
- return None
470
-
471
- # Only use credentials if we haven't already set cmd with token
472
- if cmd is None and creds_file.exists():
473
- # Check if config file exists
474
- if config_file.exists():
475
- # Run tunnel with config file (includes routes)
476
- cmd = [
477
- cloudflared_path,
478
- "tunnel",
479
- "--no-autoupdate",
480
- "--config", str(config_file),
481
- "run"
482
- ]
483
- else:
484
- # Fallback to credentials file only
485
- cmd = [
486
- cloudflared_path,
487
- "tunnel",
488
- "--no-autoupdate",
489
- "--credentials-file", str(creds_file),
490
- "run",
491
- tunnel_id
492
- ]
493
-
494
- # Only start the tunnel if we have a valid command
495
- if cmd:
496
- self.tunnel_process = subprocess.Popen(
497
- cmd,
498
- stdout=subprocess.PIPE,
499
- stderr=subprocess.STDOUT,
500
- text=True,
501
- bufsize=1
502
- )
503
-
504
- print("⏳ Waiting for tunnel to connect...")
505
- time.sleep(5)
506
-
507
- if self.tunnel_process.poll() is None:
508
- print("✅ Tunnel is running!")
509
- print(f"📌 Device ID: {self.clean_device_id}")
510
- print(f"📌 Jupyter URL: {self.jupyter_url}")
511
- print(f"📌 SSH hostname: {self.ssh_hostname}")
512
- print(f"📌 SSH command: ssh -o ProxyCommand='cloudflared access ssh --hostname {self.ssh_hostname}' user@localhost")
513
- return self.tunnel_process
514
- else:
515
- output = self.tunnel_process.stdout.read() if self.tunnel_process.stdout else ""
516
- print(f"❌ Tunnel failed to start: {output}")
517
- return None
518
- else:
519
- # If no cmd, something went wrong
520
- print("❌ No valid command to start tunnel")
521
- return None
522
-
523
- except Exception as e:
524
- print(f"❌ Error starting tunnel: {e}")
525
- return None
526
-
527
- def setup(self, jupyter_port=8888):
528
- """
529
- Setup and start tunnel (maintains compatibility)
530
- """
531
- return self.start_tunnel_with_token()
532
-
533
- def stop(self):
534
- """
535
- Stop the tunnel if running
536
- Note: We keep the tunnel configuration for next run
537
- """
538
- if self.tunnel_process and self.tunnel_process.poll() is None:
539
- print("Stopping tunnel...")
540
- self.tunnel_process.terminate()
541
- try:
542
- self.tunnel_process.wait(timeout=5)
543
- except subprocess.TimeoutExpired:
544
- self.tunnel_process.kill()
545
- print("Tunnel stopped")
546
- print("ℹ️ Tunnel configuration preserved for next run")
547
-
548
- def _ensure_cloudflared(self):
549
- """
550
- Ensure cloudflared binary is available
551
- Downloads it if necessary
552
- """
553
- print("🔍 Checking for cloudflared binary...")
554
-
555
- # Try binary manager first
556
- if self.binary_manager:
557
- try:
558
- path = self.binary_manager.get_binary_path()
559
- print(f"✅ Using cloudflared from binary manager: {path}")
560
- return path
561
- except Exception as e:
562
- logger.warning(f"Binary manager failed, will download directly: {e}")
563
-
564
- # Direct download fallback - simplified version
565
- import platform
566
- import urllib.request
567
- import ssl
568
-
569
- # Create SSL context that handles certificate issues
570
- ssl_context = ssl.create_default_context()
571
- ssl_context.check_hostname = False
572
- ssl_context.verify_mode = ssl.CERT_NONE
573
-
574
- cache_dir = Path.home() / '.unitlab' / 'bin'
575
- cache_dir.mkdir(parents=True, exist_ok=True)
576
-
577
- cloudflared_path = cache_dir / 'cloudflared'
578
- if platform.system() == 'Windows':
579
- cloudflared_path = cache_dir / 'cloudflared.exe'
580
-
581
- # If already exists, use it
582
- if cloudflared_path.exists():
583
- print(f"✅ Using cached cloudflared: {cloudflared_path}")
584
- return str(cloudflared_path)
585
-
586
- # Download based on platform
587
- system = platform.system().lower()
588
- machine = platform.machine().lower()
589
-
590
- print(f"📥 Downloading cloudflared for {system}/{machine}...")
591
-
592
- if system == 'linux':
593
- if machine in ['x86_64', 'amd64']:
594
- url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64'
595
- elif machine in ['aarch64', 'arm64']:
596
- url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64'
597
- else:
598
- url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-386'
599
- elif system == 'darwin':
600
- url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz'
601
- elif system == 'windows':
602
- url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe'
603
- else:
604
- raise RuntimeError(f"Unsupported platform: {system}")
605
-
606
- try:
607
- # Download the file with SSL context
608
- req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
609
-
610
- # Special handling for macOS .tgz files
611
- if system == 'darwin':
612
- import tarfile
613
- import io
614
-
615
- with urllib.request.urlopen(req, context=ssl_context) as response:
616
- data = response.read()
617
-
618
- # Extract from tar.gz
619
- with tarfile.open(fileobj=io.BytesIO(data), mode='r:gz') as tar:
620
- tar.extract('cloudflared', cache_dir)
621
- else:
622
- # Direct binary download for Linux/Windows
623
- with urllib.request.urlopen(req, context=ssl_context) as response:
624
- with open(cloudflared_path, 'wb') as out_file:
625
- out_file.write(response.read())
626
-
627
- # Make executable on Unix
628
- if system != 'windows':
629
- import stat
630
- cloudflared_path.chmod(cloudflared_path.stat().st_mode | stat.S_IEXEC)
631
-
632
- print(f"✅ Downloaded cloudflared to: {cloudflared_path}")
633
- return str(cloudflared_path)
634
-
635
- except Exception as e:
636
- print(f"❌ Failed to download cloudflared: {e}")
637
- raise RuntimeError(f"Could not download cloudflared: {e}")
638
-
639
- def cleanup_dns(self):
640
- """
641
- Remove created DNS records (optional cleanup)
642
- """
643
- if not self.api_token or not self.created_dns_records:
644
- return
645
-
646
- print("🧹 Cleaning up DNS records...")
647
- for record_id in self.created_dns_records:
648
- try:
649
- url = f"{self.api_base}/zones/{self.zone_id}/dns_records/{record_id}"
650
- requests.delete(url, headers=self.headers)
651
- print(f" Deleted record {record_id}")
652
- except:
653
- pass