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