unitlab 2.3.3__tar.gz → 2.3.5__tar.gz

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,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: unitlab
3
- Version: 2.3.3
3
+ Version: 2.3.5
4
4
  Home-page: https://github.com/teamunitlab/unitlab-sdk
5
5
  Author: Unitlab Inc.
6
6
  Author-email: team@unitlab.ai
@@ -23,3 +23,4 @@ Requires-Dist: typer
23
23
  Requires-Dist: validators
24
24
  Requires-Dist: psutil
25
25
  Requires-Dist: pyyaml
26
+ Requires-Dist: jupyter
@@ -2,7 +2,7 @@ from setuptools import find_packages, setup
2
2
 
3
3
  setup(
4
4
  name="unitlab",
5
- version="2.3.3",
5
+ version="2.3.5",
6
6
  license="MIT",
7
7
  author="Unitlab Inc.",
8
8
  author_email="team@unitlab.ai",
@@ -31,6 +31,8 @@ setup(
31
31
  "validators",
32
32
  'psutil',
33
33
  'pyyaml',
34
+ 'jupyter',
35
+
34
36
  ],
35
37
  entry_points={
36
38
  "console_scripts": ["unitlab=unitlab.main:app"],
@@ -0,0 +1,137 @@
1
+ import os
2
+ import platform
3
+ import hashlib
4
+ import urllib.request
5
+ from pathlib import Path
6
+ import stat
7
+ import json
8
+
9
+ class CloudflaredBinaryManager:
10
+ """
11
+ Manages cloudflared binary automatically
12
+ - Downloads on first use
13
+ - Caches for future use
14
+ - Verifies integrity
15
+ - Zero user configuration
16
+ """
17
+
18
+ # Binary URLs and checksums
19
+ BINARIES = {
20
+ 'linux-amd64': {
21
+ 'url': 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64',
22
+ 'checksum': 'sha256:...', # Add real checksums
23
+ 'filename': 'cloudflared'
24
+ },
25
+ 'linux-arm64': {
26
+ 'url': 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64',
27
+ 'checksum': 'sha256:...',
28
+ 'filename': 'cloudflared'
29
+ },
30
+ 'darwin-amd64': {
31
+ 'url': 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz',
32
+ 'checksum': 'sha256:...',
33
+ 'filename': 'cloudflared',
34
+ 'compressed': True
35
+ },
36
+ 'windows-amd64': {
37
+ 'url': 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe',
38
+ 'checksum': 'sha256:...',
39
+ 'filename': 'cloudflared.exe'
40
+ }
41
+ }
42
+
43
+ def __init__(self):
44
+ # User's home directory - works on all platforms
45
+ self.cache_dir = Path.home() / '.unitlab' / 'bin'
46
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
47
+
48
+ # Detect platform once
49
+ self.platform_key = self._detect_platform()
50
+
51
+ def _detect_platform(self):
52
+ """Detect OS and architecture"""
53
+ system = platform.system().lower()
54
+ machine = platform.machine().lower()
55
+
56
+ if system == 'linux':
57
+ if machine in ['x86_64', 'amd64']:
58
+ return 'linux-amd64'
59
+ elif machine in ['aarch64', 'arm64']:
60
+ return 'linux-arm64'
61
+
62
+ elif system == 'darwin': # macOS
63
+ # Check if ARM (M1/M2) or Intel
64
+ if machine == 'arm64':
65
+ return 'darwin-arm64'
66
+ return 'darwin-amd64'
67
+
68
+ elif system == 'windows':
69
+ return 'windows-amd64'
70
+
71
+ raise RuntimeError(f"Unsupported platform: {system} {machine}")
72
+
73
+ def get_binary_path(self):
74
+ """Get path to cloudflared binary, downloading if needed"""
75
+
76
+ binary_info = self.BINARIES[self.platform_key]
77
+ binary_path = self.cache_dir / binary_info['filename']
78
+
79
+ # Check if already downloaded
80
+ if binary_path.exists():
81
+ print("✓ Using cached cloudflared")
82
+ return str(binary_path)
83
+
84
+ # Download for first time
85
+ print("🔄 First time setup - downloading cloudflared...")
86
+ self._download_binary(binary_info, binary_path)
87
+
88
+ return str(binary_path)
89
+
90
+ def _download_binary(self, info, target_path):
91
+ """Download and verify binary"""
92
+
93
+ # Download with progress bar
94
+ def download_progress(block_num, block_size, total_size):
95
+ downloaded = block_num * block_size
96
+ if total_size > 0:
97
+ percent = min(downloaded * 100 / total_size, 100)
98
+ print(f"Downloading: {percent:.0f}%", end='\r')
99
+ else:
100
+ print(f"Downloading: {downloaded} bytes", end='\r')
101
+
102
+ temp_file = target_path.with_suffix('.tmp')
103
+
104
+ try:
105
+ # Download file
106
+ urllib.request.urlretrieve(
107
+ info['url'],
108
+ temp_file,
109
+ reporthook=download_progress
110
+ )
111
+ print("\n✓ Download complete")
112
+
113
+ # Handle compressed files (macOS .tgz)
114
+ if info.get('compressed'):
115
+ import tarfile
116
+ with tarfile.open(temp_file, 'r:gz') as tar:
117
+ # Extract just the cloudflared binary
118
+ tar.extract('cloudflared', self.cache_dir)
119
+ temp_file.unlink()
120
+ else:
121
+ # Move to final location
122
+ temp_file.rename(target_path)
123
+
124
+ # Make executable on Unix systems
125
+ if platform.system() != 'Windows':
126
+ target_path.chmod(target_path.stat().st_mode | stat.S_IEXEC)
127
+
128
+ print("✓ Cloudflared ready!")
129
+
130
+ except Exception as e:
131
+ print(f"❌ Download failed: {e}")
132
+ if temp_file.exists():
133
+ temp_file.unlink()
134
+ raise
135
+
136
+
137
+
@@ -16,7 +16,9 @@ import threading
16
16
  import psutil
17
17
  from datetime import datetime, timezone
18
18
  from .tunnel_config import CloudflareTunnel
19
+ from .cloudflare_api_tunnel import CloudflareAPITunnel
19
20
  from .utils import get_api_url, handle_exceptions
21
+ from pathlib import Path
20
22
 
21
23
 
22
24
  try:
@@ -26,6 +28,18 @@ except ImportError:
26
28
  HAS_GPU = False
27
29
 
28
30
 
31
+ try:
32
+ from dotenv import load_dotenv
33
+ env_path = Path(__file__).parent.parent.parent / '.env'
34
+ if env_path.exists():
35
+ load_dotenv(env_path)
36
+ except ImportError:
37
+ pass # dotenv not installed, use system env vars only
38
+
39
+
40
+
41
+
42
+
29
43
  logger = logging.getLogger(__name__)
30
44
 
31
45
  class UnitlabClient:
@@ -270,7 +284,17 @@ class UnitlabClient:
270
284
  self.base_domain = base_domain
271
285
 
272
286
  # Initialize tunnel manager if available
273
- if CloudflareTunnel:
287
+ # Use API-based tunnel if API token is available
288
+ print(os.getenv('CLOUDFLARE_API_TOKEN'), 'api tokennn')
289
+
290
+ if os.getenv("CLOUDFLARE_API_TOKEN"):
291
+ logger.info("Using API-based Cloudflare tunnel management")
292
+
293
+ self.tunnel_manager = CloudflareAPITunnel(base_domain, device_id)
294
+ self.jupyter_url = self.tunnel_manager.jupyter_url
295
+ self.ssh_url = self.tunnel_manager.ssh_url
296
+ elif CloudflareTunnel:
297
+ logger.info("Using service token Cloudflare tunnel")
274
298
  self.tunnel_manager = CloudflareTunnel(base_domain, device_id)
275
299
  self.jupyter_url = self.tunnel_manager.jupyter_url
276
300
  self.ssh_url = self.tunnel_manager.ssh_url
@@ -299,19 +323,25 @@ class UnitlabClient:
299
323
  # Add API key if provided
300
324
  if self.api_key:
301
325
  headers['Authorization'] = f'Api-Key {self.api_key}'
326
+ logger.debug(f"Added API key to headers: Api-Key {self.api_key[:8]}...")
327
+ else:
328
+ logger.warning("No API key found for device agent request")
302
329
 
303
330
  return headers
304
331
 
305
332
  def _post_device(self, endpoint, data=None):
306
333
  """Make authenticated POST request for device agent"""
307
334
  full_url = urllib.parse.urljoin(self.server_url, endpoint)
308
- logger.debug(f"Posting to {full_url} with data: {data}")
335
+ headers = self._get_device_headers()
336
+ logger.debug(f"Posting to {full_url}")
337
+ logger.debug(f"Headers: {headers}")
338
+ logger.debug(f"Data: {data}")
309
339
 
310
340
  try:
311
341
  response = self.api_session.post(
312
342
  full_url,
313
343
  json=data or {},
314
- headers=self._get_device_headers(),
344
+ headers=headers,
315
345
  )
316
346
  logger.debug(f"Response status: {response.status_code}, Response: {response.text}")
317
347
  response.raise_for_status()
@@ -331,7 +361,8 @@ class UnitlabClient:
331
361
  "--ServerApp.token=''",
332
362
  "--ServerApp.password=''",
333
363
  "--ServerApp.allow_origin='*'",
334
- "--ServerApp.ip='0.0.0.0'"
364
+ "--ServerApp.ip='0.0.0.0'",
365
+ "--ServerApp.port=8888" # Explicitly use 8888 to match Cloudflare config
335
366
  ]
336
367
 
337
368
  self.jupyter_proc = subprocess.Popen(
@@ -427,6 +458,9 @@ class UnitlabClient:
427
458
  }
428
459
 
429
460
  logger.info(f"Reporting Jupyter service with URL: {self.jupyter_url}")
461
+ logger.debug(f"API key present: {bool(self.api_key)}")
462
+ if self.api_key:
463
+ logger.debug(f"API key value: {self.api_key[:8]}...")
430
464
  jupyter_response = self._post_device(
431
465
  f"/api/tunnel/agent/jupyter/{self.device_id}/",
432
466
  jupyter_data
@@ -0,0 +1,286 @@
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 now, can move to env vars)
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 from environment
43
+ self.api_token = os.getenv("CLOUDFLARE_API_TOKEN")
44
+ if not self.api_token:
45
+ logger.warning("CLOUDFLARE_API_TOKEN not set. API features will be disabled.")
46
+
47
+ # API setup
48
+ self.api_base = "https://api.cloudflare.com/client/v4"
49
+ self.headers = {
50
+ "Authorization": f"Bearer {self.api_token}",
51
+ "Content-Type": "application/json"
52
+ } if self.api_token else {}
53
+
54
+ # URLs for services
55
+ self.jupyter_subdomain = f"j{self.clean_device_id}"
56
+ self.ssh_subdomain = f"s{self.clean_device_id}"
57
+ self.jupyter_url = f"https://{self.jupyter_subdomain}.{self.base_domain}"
58
+ self.ssh_hostname = f"{self.ssh_subdomain}.{self.base_domain}"
59
+ self.ssh_url = self.ssh_hostname # For backward compatibility
60
+
61
+ self.tunnel_process = None
62
+ self.created_dns_records = []
63
+ self.binary_manager = CloudflaredBinaryManager()
64
+
65
+ def create_dns_records(self):
66
+ """
67
+ Create DNS CNAME records for this device
68
+ """
69
+ if not self.api_token:
70
+ print("⚠️ No API token configured. Skipping DNS creation.")
71
+ print(" Assuming DNS records already exist or will be created manually.")
72
+ return True
73
+
74
+ print(f"📡 Creating DNS records for device {self.device_id}...")
75
+
76
+ records = [
77
+ {"name": self.jupyter_subdomain, "comment": f"Jupyter for {self.device_id}"},
78
+ {"name": self.ssh_subdomain, "comment": f"SSH for {self.device_id}"}
79
+ ]
80
+
81
+ for record in records:
82
+ try:
83
+ # Check if record exists
84
+ check_url = f"{self.api_base}/zones/{self.zone_id}/dns_records"
85
+ params = {"name": f"{record['name']}.{self.base_domain}", "type": "CNAME"}
86
+
87
+ response = requests.get(check_url, headers=self.headers, params=params)
88
+ existing = response.json()
89
+
90
+ if existing.get("result") and len(existing["result"]) > 0:
91
+ # Record exists
92
+ print(f" ✓ DNS record {record['name']}.{self.base_domain} already exists")
93
+ continue
94
+
95
+ # Create new record
96
+ data = {
97
+ "type": "CNAME",
98
+ "name": record["name"],
99
+ "content": f"{self.tunnel_id}.cfargotunnel.com",
100
+ "ttl": 1, # Auto
101
+ "proxied": True,
102
+ "comment": record["comment"]
103
+ }
104
+
105
+ response = requests.post(check_url, headers=self.headers, json=data)
106
+
107
+ if response.status_code == 200:
108
+ result = response.json()
109
+ if result.get("success"):
110
+ print(f" ✅ Created DNS: {record['name']}.{self.base_domain}")
111
+ self.created_dns_records.append(result["result"]["id"])
112
+ else:
113
+ print(f" ⚠️ Failed to create {record['name']}: {result.get('errors')}")
114
+ else:
115
+ print(f" ❌ HTTP error {response.status_code} for {record['name']}")
116
+
117
+ except Exception as e:
118
+ print(f" ❌ Error creating DNS record: {e}")
119
+ continue
120
+
121
+ return True
122
+
123
+ def update_tunnel_config(self, jupyter_port=8888, ssh_port=22):
124
+ """
125
+ Update tunnel configuration via API
126
+ """
127
+ if not self.api_token:
128
+ print("⚠️ No API token. Tunnel will use existing configuration.")
129
+ return True
130
+
131
+ print(f"🔧 Configuring tunnel routes...")
132
+
133
+ # Get current tunnel config first
134
+ get_url = f"{self.api_base}/accounts/{self.account_id}/cfd_tunnel/{self.tunnel_id}/configurations"
135
+
136
+ try:
137
+ # Get existing config
138
+ response = requests.get(get_url, headers=self.headers)
139
+ current_config = response.json()
140
+
141
+ # Build new ingress rules
142
+ new_ingress = [
143
+ {
144
+ "hostname": f"{self.jupyter_subdomain}.{self.base_domain}",
145
+ "service": f"http://localhost:{jupyter_port}",
146
+ "originRequest": {
147
+ "noTLSVerify": True
148
+ }
149
+ },
150
+ {
151
+ "hostname": f"{self.ssh_subdomain}.{self.base_domain}",
152
+ "service": f"ssh://localhost:{ssh_port}"
153
+ }
154
+ ]
155
+
156
+ # Merge with existing ingress if any
157
+ if current_config.get("success") and current_config.get("result"):
158
+ existing_ingress = current_config["result"].get("config", {}).get("ingress", [])
159
+
160
+ # Filter out our hostnames from existing
161
+ filtered_ingress = [
162
+ rule for rule in existing_ingress
163
+ if rule.get("hostname") not in [
164
+ f"{self.jupyter_subdomain}.{self.base_domain}",
165
+ f"{self.ssh_subdomain}.{self.base_domain}"
166
+ ] and rule.get("service") != "http_status:404"
167
+ ]
168
+
169
+ # Combine
170
+ new_ingress = new_ingress + filtered_ingress
171
+
172
+ # Add catch-all at the end
173
+ new_ingress.append({"service": "http_status:404"})
174
+
175
+ # Update configuration
176
+ config_data = {
177
+ "config": {
178
+ "ingress": new_ingress
179
+ }
180
+ }
181
+
182
+ put_url = f"{self.api_base}/accounts/{self.account_id}/cfd_tunnel/{self.tunnel_id}/configurations"
183
+ response = requests.put(put_url, headers=self.headers, json=config_data)
184
+
185
+ if response.status_code == 200:
186
+ print(f" ✅ Tunnel routes configured")
187
+ return True
188
+ else:
189
+ print(f" ⚠️ Route configuration status: {response.status_code}")
190
+ # Continue anyway - routes might be configured manually
191
+ return True
192
+
193
+ except Exception as e:
194
+ print(f" ⚠️ Could not update routes via API: {e}")
195
+ print(" Assuming routes are configured in dashboard.")
196
+ return True
197
+
198
+ def start_tunnel_with_token(self):
199
+ """
200
+ Start tunnel using the existing service token
201
+ """
202
+ try:
203
+ print("🚀 Starting Cloudflare tunnel...")
204
+
205
+ # First, try to set up DNS and routes via API
206
+ if self.api_token:
207
+ self.create_dns_records()
208
+ self.update_tunnel_config()
209
+
210
+ # Get cloudflared binary
211
+ cloudflared_path = self.binary_manager.get_binary_path()
212
+
213
+ # Use the existing service token from environment
214
+ service_token = os.getenv(
215
+ "CLOUDFLARE_TUNNEL_TOKEN",
216
+ "eyJhIjoiYzkxMTkyYWUyMGE1ZDQzZjY1ZTA4NzU1MGQ4ZGM4OWIiLCJ0IjoiMDc3N2ZjMTAtNDljNC00NzJkLTg2NjEtZjYwZDgwZDYxODRkIiwicyI6Ik9XRTNaak5tTVdVdE1tWTRaUzAwTmpoakxUazBaalF0WXpjek1tSm1ZVGt4WlRRMCJ9"
217
+ )
218
+
219
+ # Start tunnel with service token
220
+ cmd = [
221
+ cloudflared_path,
222
+ "tunnel",
223
+ "--no-autoupdate",
224
+ "run",
225
+ "--token",
226
+ service_token
227
+ ]
228
+
229
+ self.tunnel_process = subprocess.Popen(
230
+ cmd,
231
+ stdout=subprocess.PIPE,
232
+ stderr=subprocess.STDOUT,
233
+ text=True,
234
+ bufsize=1
235
+ )
236
+
237
+ print("⏳ Waiting for tunnel to connect...")
238
+ time.sleep(5)
239
+
240
+ if self.tunnel_process.poll() is None:
241
+ print("✅ Tunnel is running!")
242
+ print(f"📌 Device ID: {self.clean_device_id}")
243
+ print(f"📌 Jupyter URL: {self.jupyter_url}")
244
+ print(f"📌 SSH hostname: {self.ssh_hostname}")
245
+ print(f"📌 SSH command: ssh -o ProxyCommand='cloudflared access ssh --hostname {self.ssh_hostname}' user@localhost")
246
+ return self.tunnel_process
247
+ else:
248
+ output = self.tunnel_process.stdout.read() if self.tunnel_process.stdout else ""
249
+ print(f"❌ Tunnel failed to start: {output}")
250
+ return None
251
+
252
+ except Exception as e:
253
+ print(f"❌ Error starting tunnel: {e}")
254
+ return None
255
+
256
+ def setup(self, jupyter_port=8888):
257
+ """
258
+ Setup and start tunnel (maintains compatibility)
259
+ """
260
+ return self.start_tunnel_with_token()
261
+
262
+ def stop(self):
263
+ """
264
+ Stop the tunnel if running
265
+ """
266
+ if self.tunnel_process and self.tunnel_process.poll() is None:
267
+ print("Stopping tunnel...")
268
+ self.tunnel_process.terminate()
269
+ self.tunnel_process.wait(timeout=5)
270
+ print("Tunnel stopped")
271
+
272
+ def cleanup_dns(self):
273
+ """
274
+ Remove created DNS records (optional cleanup)
275
+ """
276
+ if not self.api_token or not self.created_dns_records:
277
+ return
278
+
279
+ print("🧹 Cleaning up DNS records...")
280
+ for record_id in self.created_dns_records:
281
+ try:
282
+ url = f"{self.api_base}/zones/{self.zone_id}/dns_records/{record_id}"
283
+ requests.delete(url, headers=self.headers)
284
+ print(f" Deleted record {record_id}")
285
+ except:
286
+ pass
@@ -125,7 +125,7 @@ def send_metrics_into_server():
125
125
 
126
126
  @agent_app.command(name="run", help="Run the device agent with Jupyter, SSH tunnels and metrics")
127
127
  def run_agent(
128
- api_key: str,
128
+ api_key: API_KEY,
129
129
  device_id: Annotated[str, typer.Option(help="Device ID")] = None,
130
130
  base_domain: Annotated[str, typer.Option(help="Base domain for tunnels")] = "1scan.uz",
131
131
 
@@ -140,7 +140,7 @@ def run_agent(
140
140
  )
141
141
 
142
142
  # Get server URL from environment or use default
143
- server_url = 'https://api-dev.unitlab.ai/'
143
+ server_url = 'http://localhost:8000/'
144
144
 
145
145
  # Generate unique device ID if not provided
146
146
  if not device_id:
@@ -0,0 +1,204 @@
1
+ """
2
+ Cloudflare Tunnel Configuration using Service Token
3
+ No user login required - uses pre-generated service token
4
+ """
5
+
6
+ import os
7
+ import subprocess
8
+ import socket
9
+ import time
10
+ import logging
11
+ from .binary_manager import CloudflaredBinaryManager
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class CloudflareTunnel:
17
+ def __init__(self, base_domain, device_id): # base_domain kept for compatibility
18
+ """
19
+ Initialize tunnel with service token
20
+ No login or credential files needed
21
+ """
22
+ # Configuration
23
+ self.base_domain = "1scan.uz" # Hardcoded domain
24
+ self.device_id = device_id
25
+ self.hostname = socket.gethostname()
26
+
27
+ # Initialize binary manager to handle cloudflared
28
+ self.binary_manager = CloudflaredBinaryManager()
29
+
30
+ # Service token - replace with your actual token from cloudflared tunnel token command
31
+ # This token can ONLY run the tunnel, cannot modify or delete it
32
+ # To generate: cloudflared tunnel token [tunnel-name]
33
+ self.service_token = os.getenv(
34
+ "CLOUDFLARE_TUNNEL_TOKEN",
35
+ "eyJhIjoiYzkxMTkyYWUyMGE1ZDQzZjY1ZTA4NzU1MGQ4ZGM4OWIiLCJ0IjoiMDc3N2ZjMTAtNDljNC00NzJkLTg2NjEtZjYwZDgwZDYxODRkIiwicyI6Ik9XRTNaak5tTVdVdE1tWTRaUzAwTmpoakxUazBaalF0WXpjek1tSm1ZVGt4WlRRMCJ9" # TODO: Replace with your new tunnel's token
36
+ )
37
+
38
+ if self.service_token == "YOUR_SERVICE_TOKEN_HERE":
39
+ logger.warning(
40
+ "⚠️ No service token configured. "
41
+ "Set CLOUDFLARE_TUNNEL_TOKEN env var or update the token in tunnel_config.py"
42
+ )
43
+
44
+ # Use single subdomain per device with service differentiation
45
+ # This works with *.1scan.uz wildcard certificate
46
+ self.device_subdomain = f"{device_id.replace('-', '').replace('_', '').lower()[:20]}"
47
+
48
+ # Both services use same subdomain
49
+ self.jupyter_url = f"https://{self.device_subdomain}.{self.base_domain}"
50
+ self.ssh_hostname = f"{self.device_subdomain}.{self.base_domain}" # For SSH ProxyCommand
51
+ self.ssh_url = self.ssh_hostname # Keep for backward compatibility
52
+
53
+ self.tunnel_process = None
54
+
55
+ def check_cloudflared_installed(self):
56
+ """Check if cloudflared is installed"""
57
+ try:
58
+ result = subprocess.run(
59
+ ["cloudflared", "--version"],
60
+ capture_output=True,
61
+ text=True
62
+ )
63
+ return result.returncode == 0
64
+ except FileNotFoundError:
65
+ return False
66
+
67
+ def install_cloudflared(self):
68
+ """Auto-install cloudflared if not present"""
69
+ import platform
70
+
71
+ system = platform.system().lower()
72
+ machine = platform.machine().lower()
73
+
74
+ print("📦 Installing cloudflared...")
75
+
76
+ try:
77
+ if system == "linux":
78
+ # Determine architecture
79
+ if machine in ["x86_64", "amd64"]:
80
+ arch = "amd64"
81
+ elif machine in ["aarch64", "arm64"]:
82
+ arch = "arm64"
83
+ else:
84
+ arch = "386"
85
+
86
+ # Download and install
87
+ url = f"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-{arch}"
88
+
89
+ commands = [
90
+ f"curl -L {url} -o /tmp/cloudflared",
91
+ "chmod +x /tmp/cloudflared",
92
+ "sudo mv /tmp/cloudflared /usr/local/bin/cloudflared 2>/dev/null || "
93
+ "mkdir -p ~/.local/bin && mv /tmp/cloudflared ~/.local/bin/cloudflared"
94
+ ]
95
+
96
+ for cmd in commands:
97
+ subprocess.run(cmd, shell=True, check=False)
98
+
99
+ # Add ~/.local/bin to PATH if needed
100
+ local_bin = os.path.expanduser("~/.local/bin")
101
+ if local_bin not in os.environ.get("PATH", ""):
102
+ os.environ["PATH"] = f"{local_bin}:{os.environ['PATH']}"
103
+
104
+ return self.check_cloudflared_installed()
105
+
106
+ elif system == "darwin":
107
+ # Try homebrew first
108
+ result = subprocess.run(["brew", "install", "cloudflared"], capture_output=True)
109
+ if result.returncode != 0:
110
+ # Fallback to direct download
111
+ url = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz"
112
+ subprocess.run(f"curl -L {url} | tar xz", shell=True)
113
+ subprocess.run("sudo mv cloudflared /usr/local/bin/", shell=True)
114
+
115
+ return self.check_cloudflared_installed()
116
+
117
+ elif system == "windows":
118
+ print("⚠️ Please install cloudflared manually on Windows")
119
+ print(" Download from: https://github.com/cloudflare/cloudflared/releases")
120
+ return False
121
+
122
+ except Exception as e:
123
+ logger.error(f"Failed to install cloudflared: {e}")
124
+ return False
125
+
126
+ def setup(self, jupyter_port): # jupyter_port kept for compatibility
127
+ """
128
+ Setup and start tunnel with service token
129
+ No login required! Binary is automatically downloaded if needed.
130
+ """
131
+ print("🚀 Setting up Cloudflare tunnel with service token...")
132
+
133
+ # Binary manager will automatically download cloudflared if needed
134
+ # No manual installation required!
135
+
136
+ # Start tunnel with service token
137
+ return self.start_tunnel_with_token()
138
+
139
+ def start_tunnel_with_token(self):
140
+ """
141
+ Start the tunnel using service token
142
+ Binary is automatically downloaded if needed
143
+ """
144
+ try:
145
+ print("🚀 Starting Cloudflare tunnel...")
146
+
147
+ # Get cloudflared binary path (downloads if needed)
148
+ cloudflared_path = self.binary_manager.get_binary_path()
149
+ print(f"📍 Using cloudflared at: {cloudflared_path}")
150
+
151
+ # Simple command with service token
152
+ cmd = [
153
+ cloudflared_path,
154
+ "tunnel",
155
+ "--no-autoupdate", # Prevent auto-updates during run
156
+ "run",
157
+ "--token",
158
+ self.service_token
159
+ ]
160
+
161
+ # Start the tunnel process
162
+ self.tunnel_process = subprocess.Popen(
163
+ cmd,
164
+ stdout=subprocess.PIPE,
165
+ stderr=subprocess.STDOUT,
166
+ text=True,
167
+ bufsize=1
168
+ )
169
+
170
+ # Wait for tunnel to establish
171
+ print("⏳ Waiting for tunnel to connect...")
172
+ time.sleep(5)
173
+
174
+ # Check if process is still running
175
+ if self.tunnel_process.poll() is None:
176
+ print("✅ Tunnel is running!")
177
+ print(f"📌 Device subdomain: {self.device_subdomain}.{self.base_domain}")
178
+ print(f"📌 Jupyter URL: {self.jupyter_url}")
179
+ print(f"📌 SSH access: ssh -o ProxyCommand='cloudflared access ssh --hostname {self.ssh_hostname}' user@localhost")
180
+ return self.tunnel_process
181
+ else:
182
+ # Read any error output
183
+ output = self.tunnel_process.stdout.read()
184
+ print("❌ Tunnel failed to start")
185
+ print(f"Error output: {output}")
186
+ return None
187
+
188
+ except Exception as e:
189
+ print(f"❌ Error starting tunnel: {e}")
190
+ return None
191
+
192
+ def stop(self):
193
+ """Stop the tunnel if running"""
194
+ if self.tunnel_process and self.tunnel_process.poll() is None:
195
+ print("Stopping tunnel...")
196
+ self.tunnel_process.terminate()
197
+ self.tunnel_process.wait(timeout=5)
198
+ print("Tunnel stopped")
199
+
200
+ # Removed all the old methods that are no longer needed:
201
+ # - login() - not needed with service token
202
+ # - create_tunnel() - tunnel already exists
203
+ # - configure_dns() - already configured
204
+ # - create_config_file() - not needed with service token
@@ -0,0 +1,104 @@
1
+ """
2
+ Service Token implementation for Cloudflare Tunnel
3
+ More secure than embedding full credentials
4
+ """
5
+
6
+ import os
7
+ import subprocess
8
+ import time
9
+ from pathlib import Path
10
+
11
+
12
+ class ServiceTokenTunnel:
13
+ """
14
+ Use Cloudflare Service Token instead of credentials file
15
+ This is more secure and doesn't require login
16
+ """
17
+
18
+ # Embed the service token (generated once by admin)
19
+ # This token can ONLY run the tunnel, cannot modify it
20
+ DEFAULT_SERVICE_TOKEN = "eyJhIjoiYzkxMTkyYWUyMGE1ZDQzZjY1ZTA4NzU1MGQ4ZGM4OWIiLCJzIjoiZmdnSHowbFJFRnBHa05TZzIzV3JKMVBiaDVROGVUd0oyYWtJWThXdjhtTT0iLCJ0IjoiYjMzZGFhOGYtMmNjMy00Y2FkLWEyMjgtOTdlMDYwNzBlNjAwIn0="
21
+
22
+ def __init__(self, device_id, service_token=None):
23
+ self.device_id = device_id
24
+ # Allow override via environment variable or parameter
25
+ self.service_token = (
26
+ service_token or
27
+ os.getenv("CLOUDFLARE_TUNNEL_TOKEN") or
28
+ self.DEFAULT_SERVICE_TOKEN
29
+ )
30
+
31
+ # With service token, we don't need credential files
32
+ # The token contains all necessary information
33
+
34
+ def start_tunnel_with_token(self):
35
+ """
36
+ Start tunnel using service token
37
+ No login, no credential files needed
38
+ """
39
+ print("🚀 Starting tunnel with service token...")
40
+
41
+ # Service token method - super simple!
42
+ cmd = [
43
+ "cloudflared",
44
+ "tunnel",
45
+ "run",
46
+ "--token",
47
+ self.service_token
48
+ ]
49
+
50
+ try:
51
+ # Start the tunnel
52
+ process = subprocess.Popen(
53
+ cmd,
54
+ stdout=subprocess.PIPE,
55
+ stderr=subprocess.PIPE,
56
+ text=True
57
+ )
58
+
59
+ # Wait for tunnel to establish
60
+ time.sleep(3)
61
+
62
+ if process.poll() is None:
63
+ print("✅ Tunnel running with service token")
64
+ return process
65
+ else:
66
+ print("❌ Failed to start tunnel")
67
+ return None
68
+
69
+ except Exception as e:
70
+ print(f"❌ Error: {e}")
71
+ return None
72
+
73
+ def get_tunnel_info(self):
74
+ """
75
+ Service tokens use predetermined URLs
76
+ The admin sets these up when creating the tunnel
77
+ """
78
+ # These URLs are configured when creating the tunnel
79
+ base_domain = "1scan.uz"
80
+ return {
81
+ "jupyter_url": f"https://jupyter-{self.device_id}.{base_domain}",
82
+ "ssh_url": f"https://ssh-{self.device_id}.{base_domain}"
83
+ }
84
+
85
+
86
+ # Usage example
87
+ def run_with_service_token(device_id):
88
+ """
89
+ Example of how simple it is with service token
90
+ """
91
+ tunnel = ServiceTokenTunnel(device_id)
92
+
93
+ # No login needed!
94
+ # No credential files needed!
95
+ # Just run with the token
96
+ process = tunnel.start_tunnel_with_token()
97
+
98
+ if process:
99
+ urls = tunnel.get_tunnel_info()
100
+ print(f"Jupyter: {urls['jupyter_url']}")
101
+ print(f"SSH: {urls['ssh_url']}")
102
+ return process
103
+
104
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: unitlab
3
- Version: 2.3.3
3
+ Version: 2.3.5
4
4
  Home-page: https://github.com/teamunitlab/unitlab-sdk
5
5
  Author: Unitlab Inc.
6
6
  Author-email: team@unitlab.ai
@@ -23,3 +23,4 @@ Requires-Dist: typer
23
23
  Requires-Dist: validators
24
24
  Requires-Dist: psutil
25
25
  Requires-Dist: pyyaml
26
+ Requires-Dist: jupyter
@@ -4,10 +4,13 @@ setup.cfg
4
4
  setup.py
5
5
  src/unitlab/__init__.py
6
6
  src/unitlab/__main__.py
7
+ src/unitlab/binary_manager.py
7
8
  src/unitlab/client.py
9
+ src/unitlab/cloudflare_api_tunnel.py
8
10
  src/unitlab/exceptions.py
9
11
  src/unitlab/main.py
10
12
  src/unitlab/tunnel_config.py
13
+ src/unitlab/tunnel_service_token.py
11
14
  src/unitlab/utils.py
12
15
  src/unitlab.egg-info/PKG-INFO
13
16
  src/unitlab.egg-info/SOURCES.txt
@@ -6,3 +6,4 @@ typer
6
6
  validators
7
7
  psutil
8
8
  pyyaml
9
+ jupyter
@@ -1,238 +0,0 @@
1
- """
2
- Cloudflare Tunnel Configuration for persistent subdomains
3
- """
4
-
5
- import json
6
- import subprocess
7
- import socket
8
- import time
9
- import yaml
10
- from pathlib import Path
11
-
12
-
13
- class CloudflareTunnel:
14
- def __init__(self, base_domain, device_id):
15
- # Hardcode the base domain here
16
- self.base_domain = "1scan.uz" # HARDCODED - ignore the passed base_domain
17
- self.device_id = device_id
18
- self.hostname = socket.gethostname()
19
- self.tunnel_name = f"device-{device_id}"
20
- self.config_dir = Path.home() / ".cloudflared"
21
- self.config_dir.mkdir(exist_ok=True)
22
-
23
- # Subdomain names
24
- self.jupyter_subdomain = f"jupyter-{device_id}"
25
- self.ssh_subdomain = f"ssh-{device_id}"
26
-
27
- # Full URLs - using hardcoded base_domain
28
- self.jupyter_url = f"https://{self.jupyter_subdomain}.{self.base_domain}"
29
- self.ssh_url = f"https://{self.ssh_subdomain}.{self.base_domain}"
30
-
31
- self.tunnel_uuid = None
32
- self.credentials_file = None
33
-
34
- def login(self):
35
- """Login to Cloudflare (one-time setup)"""
36
- try:
37
- print("🔐 Checking Cloudflare authentication...")
38
- result = subprocess.run(
39
- ["cloudflared", "tunnel", "login"],
40
- capture_output=True,
41
- text=True
42
- )
43
- if result.returncode == 0:
44
- print("✅ Cloudflare authentication successful")
45
- return True
46
- else:
47
- print("❌ Cloudflare authentication failed")
48
- return False
49
- except Exception as e:
50
- print(f"❌ Error during Cloudflare login: {e}")
51
- return False
52
-
53
- def create_tunnel(self):
54
- """Create a named tunnel"""
55
- try:
56
- print(f"🚇 Creating tunnel: {self.tunnel_name}")
57
-
58
- # Check if tunnel already exists
59
- list_result = subprocess.run(
60
- ["cloudflared", "tunnel", "list", "--output", "json"],
61
- capture_output=True,
62
- text=True
63
- )
64
-
65
- if list_result.returncode == 0:
66
- tunnels = json.loads(list_result.stdout)
67
- for tunnel in tunnels:
68
- if tunnel.get("name") == self.tunnel_name:
69
- self.tunnel_uuid = tunnel.get("id")
70
- print(f"✅ Tunnel already exists with ID: {self.tunnel_uuid}")
71
- self.credentials_file = self.config_dir / f"{self.tunnel_uuid}.json"
72
- return True
73
-
74
- # Create new tunnel
75
- result = subprocess.run(
76
- ["cloudflared", "tunnel", "create", self.tunnel_name],
77
- capture_output=True,
78
- text=True
79
- )
80
-
81
- if result.returncode == 0:
82
- for line in result.stdout.split('\n'):
83
- if "Created tunnel" in line and "with id" in line:
84
- self.tunnel_uuid = line.split("with id")[1].strip()
85
- break
86
-
87
- if not self.tunnel_uuid:
88
- list_result = subprocess.run(
89
- ["cloudflared", "tunnel", "list", "--output", "json"],
90
- capture_output=True,
91
- text=True
92
- )
93
- if list_result.returncode == 0:
94
- tunnels = json.loads(list_result.stdout)
95
- for tunnel in tunnels:
96
- if tunnel.get("name") == self.tunnel_name:
97
- self.tunnel_uuid = tunnel.get("id")
98
- break
99
-
100
- if self.tunnel_uuid:
101
- self.credentials_file = self.config_dir / f"{self.tunnel_uuid}.json"
102
- print(f"✅ Tunnel created with ID: {self.tunnel_uuid}")
103
- return True
104
-
105
- print(f"❌ Failed to create tunnel: {result.stderr}")
106
- return False
107
-
108
- except Exception as e:
109
- print(f"❌ Error creating tunnel: {e}")
110
- return False
111
-
112
- def configure_dns(self):
113
- """Configure DNS routes for the tunnel"""
114
- try:
115
- print("🌐 Configuring DNS routes...")
116
-
117
- # Route for Jupyter
118
- jupyter_result = subprocess.run(
119
- ["cloudflared", "tunnel", "route", "dns",
120
- self.tunnel_name, f"{self.jupyter_subdomain}.{self.base_domain}"],
121
- capture_output=True,
122
- text=True
123
- )
124
-
125
- if jupyter_result.returncode == 0:
126
- print(f"✅ Jupyter route configured: {self.jupyter_url}")
127
- else:
128
- print(f"⚠️ Jupyter route may already exist or failed: {jupyter_result.stderr}")
129
-
130
- # Route for SSH
131
- ssh_result = subprocess.run(
132
- ["cloudflared", "tunnel", "route", "dns",
133
- self.tunnel_name, f"{self.ssh_subdomain}.{self.base_domain}"],
134
- capture_output=True,
135
- text=True
136
- )
137
-
138
- if ssh_result.returncode == 0:
139
- print(f"✅ SSH route configured: {self.ssh_url}")
140
- else:
141
- print(f"⚠️ SSH route may already exist or failed: {ssh_result.stderr}")
142
-
143
- return True
144
-
145
- except Exception as e:
146
- print(f"❌ Error configuring DNS: {e}")
147
- return False
148
-
149
- def create_config_file(self, jupyter_port):
150
- """Create tunnel configuration file"""
151
- config = {
152
- "tunnel": self.tunnel_uuid,
153
- "credentials-file": str(self.credentials_file),
154
- "ingress": [
155
- {
156
- "hostname": f"{self.jupyter_subdomain}.{self.base_domain}",
157
- "service": f"http://localhost:{jupyter_port}",
158
- "originRequest": {
159
- "noTLSVerify": True
160
- }
161
- },
162
- {
163
- "hostname": f"{self.ssh_subdomain}.{self.base_domain}",
164
- "service": "ssh://localhost:22",
165
- "originRequest": {
166
- "noTLSVerify": True
167
- }
168
- },
169
- {
170
- "service": "http_status:404"
171
- }
172
- ]
173
- }
174
-
175
- config_file = self.config_dir / f"config-{self.device_id}.yml"
176
- with open(config_file, 'w') as f:
177
- yaml.dump(config, f, default_flow_style=False)
178
-
179
- print(f"📝 Configuration saved to: {config_file}")
180
- return config_file
181
-
182
- def start_tunnel(self, config_file):
183
- """Start the tunnel with the configuration"""
184
- try:
185
- print("🚀 Starting Cloudflare tunnel...")
186
-
187
- cmd = ["cloudflared", "tunnel", "--config", str(config_file), "run"]
188
-
189
- process = subprocess.Popen(
190
- cmd,
191
- stdout=subprocess.PIPE,
192
- stderr=subprocess.STDOUT,
193
- text=True,
194
- bufsize=1
195
- )
196
-
197
- # Wait for tunnel to establish
198
- time.sleep(5)
199
-
200
- if process.poll() is None:
201
- print("✅ Tunnel is running")
202
- return process
203
- else:
204
- print("❌ Tunnel failed to start")
205
- return None
206
-
207
- except Exception as e:
208
- print(f"❌ Error starting tunnel: {e}")
209
- return None
210
-
211
- def setup(self, jupyter_port):
212
- """Complete setup process"""
213
- # Check if we need to login
214
- if not (self.config_dir / "cert.pem").exists():
215
- if not self.login():
216
- return None
217
-
218
- # Create tunnel
219
- if not self.create_tunnel():
220
- return None
221
-
222
- # Configure DNS
223
- if not self.configure_dns():
224
- return None
225
-
226
- # Create config file
227
- config_file = self.create_config_file(jupyter_port)
228
-
229
- # Start tunnel
230
- tunnel_process = self.start_tunnel(config_file)
231
-
232
- if tunnel_process:
233
- print("\n✅ Tunnel setup complete!")
234
- print(f"📌 Jupyter URL: {self.jupyter_url}")
235
- print(f"📌 SSH URL: {self.ssh_url}")
236
- return tunnel_process
237
-
238
- return None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes