unitlab 2.3.4__py3-none-any.whl → 2.3.5__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,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
+
unitlab/client.py CHANGED
@@ -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
unitlab/main.py CHANGED
@@ -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:
unitlab/tunnel_config.py CHANGED
@@ -8,6 +8,7 @@ import subprocess
8
8
  import socket
9
9
  import time
10
10
  import logging
11
+ from .binary_manager import CloudflaredBinaryManager
11
12
 
12
13
  logger = logging.getLogger(__name__)
13
14
 
@@ -23,12 +24,15 @@ class CloudflareTunnel:
23
24
  self.device_id = device_id
24
25
  self.hostname = socket.gethostname()
25
26
 
27
+ # Initialize binary manager to handle cloudflared
28
+ self.binary_manager = CloudflaredBinaryManager()
29
+
26
30
  # Service token - replace with your actual token from cloudflared tunnel token command
27
31
  # This token can ONLY run the tunnel, cannot modify or delete it
28
- # To generate: cloudflared tunnel token unitlab-shared
32
+ # To generate: cloudflared tunnel token [tunnel-name]
29
33
  self.service_token = os.getenv(
30
34
  "CLOUDFLARE_TUNNEL_TOKEN",
31
- "eyJhIjoiYzkxMTkyYWUyMGE1ZDQzZjY1ZTA4NzU1MGQ4ZGM4OWIiLCJzIjoiZmdnSHowbFJFRnBHa05TZzIzV3JKMVBiaDVROGVUd0oyYWtJWThXdjhtTT0iLCJ0IjoiYjMzZGFhOGYtMmNjMy00Y2FkLWEyMjgtOTdlMDYwNzBlNjAwIn0=" # Replace this with your actual token from step 1
35
+ "eyJhIjoiYzkxMTkyYWUyMGE1ZDQzZjY1ZTA4NzU1MGQ4ZGM4OWIiLCJ0IjoiMDc3N2ZjMTAtNDljNC00NzJkLTg2NjEtZjYwZDgwZDYxODRkIiwicyI6Ik9XRTNaak5tTVdVdE1tWTRaUzAwTmpoakxUazBaalF0WXpjek1tSm1ZVGt4WlRRMCJ9" # TODO: Replace with your new tunnel's token
32
36
  )
33
37
 
34
38
  if self.service_token == "YOUR_SERVICE_TOKEN_HERE":
@@ -37,13 +41,14 @@ class CloudflareTunnel:
37
41
  "Set CLOUDFLARE_TUNNEL_TOKEN env var or update the token in tunnel_config.py"
38
42
  )
39
43
 
40
- # Subdomain names
41
- self.jupyter_subdomain = f"jupyter-{device_id}"
42
- self.ssh_subdomain = f"ssh-{device_id}"
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]}"
43
47
 
44
- # Full URLs
45
- self.jupyter_url = f"https://{self.jupyter_subdomain}.{self.base_domain}"
46
- self.ssh_url = f"https://{self.ssh_subdomain}.{self.base_domain}"
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
47
52
 
48
53
  self.tunnel_process = None
49
54
 
@@ -121,17 +126,12 @@ class CloudflareTunnel:
121
126
  def setup(self, jupyter_port): # jupyter_port kept for compatibility
122
127
  """
123
128
  Setup and start tunnel with service token
124
- No login required!
129
+ No login required! Binary is automatically downloaded if needed.
125
130
  """
126
131
  print("🚀 Setting up Cloudflare tunnel with service token...")
127
132
 
128
- # Check if cloudflared is installed
129
- if not self.check_cloudflared_installed():
130
- print("cloudflared not found, attempting to install...")
131
- if not self.install_cloudflared():
132
- print("❌ Failed to install cloudflared. Please install it manually:")
133
- print(" https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/")
134
- return None
133
+ # Binary manager will automatically download cloudflared if needed
134
+ # No manual installation required!
135
135
 
136
136
  # Start tunnel with service token
137
137
  return self.start_tunnel_with_token()
@@ -139,14 +139,18 @@ class CloudflareTunnel:
139
139
  def start_tunnel_with_token(self):
140
140
  """
141
141
  Start the tunnel using service token
142
- This is much simpler than the credential file approach
142
+ Binary is automatically downloaded if needed
143
143
  """
144
144
  try:
145
145
  print("🚀 Starting Cloudflare tunnel...")
146
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
+
147
151
  # Simple command with service token
148
152
  cmd = [
149
- "cloudflared",
153
+ cloudflared_path,
150
154
  "tunnel",
151
155
  "--no-autoupdate", # Prevent auto-updates during run
152
156
  "run",
@@ -170,8 +174,9 @@ class CloudflareTunnel:
170
174
  # Check if process is still running
171
175
  if self.tunnel_process.poll() is None:
172
176
  print("✅ Tunnel is running!")
177
+ print(f"📌 Device subdomain: {self.device_subdomain}.{self.base_domain}")
173
178
  print(f"📌 Jupyter URL: {self.jupyter_url}")
174
- print(f"📌 SSH URL: {self.ssh_url}")
179
+ print(f"📌 SSH access: ssh -o ProxyCommand='cloudflared access ssh --hostname {self.ssh_hostname}' user@localhost")
175
180
  return self.tunnel_process
176
181
  else:
177
182
  # Read any error output
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: unitlab
3
- Version: 2.3.4
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
@@ -0,0 +1,16 @@
1
+ unitlab/__init__.py,sha256=Wtk5kQ_MTlxtd3mxJIn2qHVK5URrVcasMMPjD3BtrVM,214
2
+ unitlab/__main__.py,sha256=6Hs2PV7EYc5Tid4g4OtcLXhqVHiNYTGzSBdoOnW2HXA,29
3
+ unitlab/binary_manager.py,sha256=bZmT07K2InCrZp1KYXgLntEqnQxPFLqPBDf-LcG25Yo,4544
4
+ unitlab/client.py,sha256=G4qDgqhLK4wMvQaLbYSpMUZQgaJ_0kjpBTF-_Qjc2oA,24252
5
+ unitlab/cloudflare_api_tunnel.py,sha256=9HLr4ebwYVv5gsM6gKRyDUSTUoeULt48hmz6ABoP_FA,11093
6
+ unitlab/exceptions.py,sha256=68Tr6LreEzjQ3Vns8HAaWdtewtkNUJOvPazbf6NSnXU,950
7
+ unitlab/main.py,sha256=4EM2A3cHVOBe_Bp31zRE-0-WCrqxpIf8mtF5fP-gADw,5252
8
+ unitlab/tunnel_config.py,sha256=7CiAqasfg26YQfJYXapCBQPSoqw4jIx6yR64saybLLo,8312
9
+ unitlab/tunnel_service_token.py,sha256=ji96a4s4W2cFJrHZle0zBD85Ac_T862-gCKzBUomrxM,3125
10
+ unitlab/utils.py,sha256=83ekAxxfXecFTg76Z62BGDybC_skKJHYoLyawCD9wGM,1920
11
+ unitlab-2.3.5.dist-info/LICENSE.md,sha256=Gn7RRvByorAcAaM-WbyUpsgi5ED1-bKFFshbWfYYz2Y,1069
12
+ unitlab-2.3.5.dist-info/METADATA,sha256=fj1zH_aN8EHYrIl8QFCvl5_e41JD2HdKHlW5fwpTp3Y,814
13
+ unitlab-2.3.5.dist-info/WHEEL,sha256=iAkIy5fosb7FzIOwONchHf19Qu7_1wCWyFNR5gu9nU0,91
14
+ unitlab-2.3.5.dist-info/entry_points.txt,sha256=ig-PjKEqSCj3UTdyANgEi4tsAU84DyXdaOJ02NHX4bY,45
15
+ unitlab-2.3.5.dist-info/top_level.txt,sha256=Al4ZlTYE3fTJK2o6YLCDMH5_DjuQkffRBMxgmWbKaqQ,8
16
+ unitlab-2.3.5.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- unitlab/__init__.py,sha256=Wtk5kQ_MTlxtd3mxJIn2qHVK5URrVcasMMPjD3BtrVM,214
2
- unitlab/__main__.py,sha256=6Hs2PV7EYc5Tid4g4OtcLXhqVHiNYTGzSBdoOnW2HXA,29
3
- unitlab/client.py,sha256=LGbjto3jisF5LojlYjJyLKRc55wxfZTO_tkoipZBKd4,22910
4
- unitlab/exceptions.py,sha256=68Tr6LreEzjQ3Vns8HAaWdtewtkNUJOvPazbf6NSnXU,950
5
- unitlab/main.py,sha256=XD8PF_EJ1gAKqHcRT_bWy62LWrODGWMx2roKoaQaoY4,5253
6
- unitlab/tunnel_config.py,sha256=WhxjuOjvOXz16KbMAgpvbCpRD0Y5y53y9jVizQ7CxR8,7841
7
- unitlab/tunnel_service_token.py,sha256=ji96a4s4W2cFJrHZle0zBD85Ac_T862-gCKzBUomrxM,3125
8
- unitlab/utils.py,sha256=83ekAxxfXecFTg76Z62BGDybC_skKJHYoLyawCD9wGM,1920
9
- unitlab-2.3.4.dist-info/LICENSE.md,sha256=Gn7RRvByorAcAaM-WbyUpsgi5ED1-bKFFshbWfYYz2Y,1069
10
- unitlab-2.3.4.dist-info/METADATA,sha256=4GHYHBRrtcbbHw06gcZ5mfucnjJvOGFC4Y8DvRqw-28,814
11
- unitlab-2.3.4.dist-info/WHEEL,sha256=iAkIy5fosb7FzIOwONchHf19Qu7_1wCWyFNR5gu9nU0,91
12
- unitlab-2.3.4.dist-info/entry_points.txt,sha256=ig-PjKEqSCj3UTdyANgEi4tsAU84DyXdaOJ02NHX4bY,45
13
- unitlab-2.3.4.dist-info/top_level.txt,sha256=Al4ZlTYE3fTJK2o6YLCDMH5_DjuQkffRBMxgmWbKaqQ,8
14
- unitlab-2.3.4.dist-info/RECORD,,