unitlab 2.3.20__tar.gz → 2.3.23__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.
Files changed (22) hide show
  1. {unitlab-2.3.20/src/unitlab.egg-info → unitlab-2.3.23}/PKG-INFO +1 -1
  2. {unitlab-2.3.20 → unitlab-2.3.23}/setup.py +1 -1
  3. unitlab-2.3.23/src/unitlab/cloudflare_api_tunnel.py +367 -0
  4. unitlab-2.3.20/src/unitlab/cloudflare_api_tunnel.py → unitlab-2.3.23/src/unitlab/cloudflare_api_tunnel_backup.py +95 -104
  5. {unitlab-2.3.20 → unitlab-2.3.23/src/unitlab.egg-info}/PKG-INFO +1 -1
  6. {unitlab-2.3.20 → unitlab-2.3.23}/src/unitlab.egg-info/SOURCES.txt +1 -0
  7. {unitlab-2.3.20 → unitlab-2.3.23}/LICENSE.md +0 -0
  8. {unitlab-2.3.20 → unitlab-2.3.23}/README.md +0 -0
  9. {unitlab-2.3.20 → unitlab-2.3.23}/setup.cfg +0 -0
  10. {unitlab-2.3.20 → unitlab-2.3.23}/src/unitlab/__init__.py +0 -0
  11. {unitlab-2.3.20 → unitlab-2.3.23}/src/unitlab/__main__.py +0 -0
  12. {unitlab-2.3.20 → unitlab-2.3.23}/src/unitlab/binary_manager.py +0 -0
  13. {unitlab-2.3.20 → unitlab-2.3.23}/src/unitlab/client.py +0 -0
  14. {unitlab-2.3.20 → unitlab-2.3.23}/src/unitlab/exceptions.py +0 -0
  15. {unitlab-2.3.20 → unitlab-2.3.23}/src/unitlab/main.py +0 -0
  16. {unitlab-2.3.20 → unitlab-2.3.23}/src/unitlab/tunnel_config.py +0 -0
  17. {unitlab-2.3.20 → unitlab-2.3.23}/src/unitlab/tunnel_service_token.py +0 -0
  18. {unitlab-2.3.20 → unitlab-2.3.23}/src/unitlab/utils.py +0 -0
  19. {unitlab-2.3.20 → unitlab-2.3.23}/src/unitlab.egg-info/dependency_links.txt +0 -0
  20. {unitlab-2.3.20 → unitlab-2.3.23}/src/unitlab.egg-info/entry_points.txt +0 -0
  21. {unitlab-2.3.20 → unitlab-2.3.23}/src/unitlab.egg-info/requires.txt +0 -0
  22. {unitlab-2.3.20 → unitlab-2.3.23}/src/unitlab.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: unitlab
3
- Version: 2.3.20
3
+ Version: 2.3.23
4
4
  Home-page: https://github.com/teamunitlab/unitlab-sdk
5
5
  Author: Unitlab Inc.
6
6
  Author-email: team@unitlab.ai
@@ -2,7 +2,7 @@ from setuptools import find_packages, setup
2
2
 
3
3
  setup(
4
4
  name="unitlab",
5
- version="2.3.20",
5
+ version="2.3.23",
6
6
  license="MIT",
7
7
  author="Unitlab Inc.",
8
8
  author_email="team@unitlab.ai",
@@ -0,0 +1,367 @@
1
+ """
2
+ Cloudflare API-based Tunnel Configuration
3
+ Uses API to dynamically manage DNS and routes
4
+ SIMPLIFIED VERSION with unique tunnel names
5
+ """
6
+
7
+ import os
8
+ import requests
9
+ import subprocess
10
+ import time
11
+ import logging
12
+ import uuid
13
+ from pathlib import Path
14
+ from .binary_manager import CloudflaredBinaryManager
15
+
16
+ # Try to load .env file if it exists
17
+ try:
18
+ from dotenv import load_dotenv
19
+ env_path = Path(__file__).parent.parent.parent / '.env'
20
+ if env_path.exists():
21
+ load_dotenv(env_path)
22
+ except ImportError:
23
+ pass
24
+
25
+ class CloudflareAPITunnel:
26
+ def __init__(self, device_id, base_domain="1scan.uz"):
27
+ """
28
+ Initialize Cloudflare tunnel with API configuration
29
+ Each device gets a unique tunnel with UUID
30
+ """
31
+ self.device_id = device_id
32
+ self.base_domain = base_domain
33
+
34
+ # Clean device ID for use in hostnames (remove special chars)
35
+ self.clean_device_id = device_id.replace(' ', '').replace('-', '').replace('.', '').replace('_', '')[:24]
36
+
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}"
40
+
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
+
46
+ # Hardcoded Cloudflare credentials
47
+ self.account_id = "c91192ae20a5d43f65e087550d8dc89b"
48
+ self.api_token = "xkfCGvxHeiU7xvz0OhfYUICZvJ7rM4NmgdMSB5jy"
49
+ self.zone_id = "f17ca0e9cf056e87afb019c88f936ac9"
50
+
51
+ # API configuration
52
+ self.api_base = "https://api.cloudflare.com/client/v4"
53
+ self.headers = {
54
+ "Authorization": f"Bearer {self.api_token}",
55
+ "Content-Type": "application/json"
56
+ }
57
+
58
+ # Track created resources for cleanup
59
+ self.tunnel_id = None
60
+ self.tunnel_process = None
61
+ self.created_dns_records = []
62
+ self.tunnel_config_file = None
63
+
64
+ # Binary manager for cloudflared
65
+ self.binary_manager = CloudflaredBinaryManager()
66
+
67
+ def create_dns_records(self):
68
+ """
69
+ Create DNS records pointing to the tunnel
70
+ """
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
+
75
+ print(f"📡 Creating DNS records for device {self.device_id}...")
76
+
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"}
80
+ ]
81
+
82
+ for record in dns_records:
83
+ try:
84
+ # Check if record exists
85
+ check_url = f"{self.api_base}/zones/{self.zone_id}/dns_records"
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)
88
+
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
94
+
95
+ # Create new record
96
+ data = {
97
+ "type": "CNAME",
98
+ "name": record['name'],
99
+ "content": record['content'],
100
+ "ttl": 1,
101
+ "proxied": True
102
+ }
103
+
104
+ response = requests.post(check_url, headers=self.headers, json=data)
105
+
106
+ if response.status_code == 200:
107
+ result = response.json()
108
+ if result.get("success"):
109
+ print(f" ✅ Created DNS: {record['name']}.{self.base_domain}")
110
+ self.created_dns_records.append(result["result"]["id"])
111
+ else:
112
+ print(f" ⚠️ Failed to create {record['name']}: {result.get('errors')}")
113
+ else:
114
+ print(f" ❌ HTTP error {response.status_code} for {record['name']}")
115
+
116
+ except Exception as e:
117
+ print(f" ❌ Error creating DNS record: {e}")
118
+ continue
119
+
120
+ return True
121
+
122
+ def create_device_tunnel(self):
123
+ """
124
+ Create a unique tunnel for this device
125
+ Each tunnel gets a unique UUID to avoid conflicts
126
+ """
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}"
130
+
131
+ print(f"📦 Creating new tunnel: {tunnel_name}")
132
+
133
+ create_url = f"{self.api_base}/accounts/{self.account_id}/tunnels"
134
+
135
+ # Generate secret
136
+ tunnel_secret = os.urandom(32).hex()
137
+
138
+ create_data = {
139
+ "name": tunnel_name,
140
+ "tunnel_secret": tunnel_secret
141
+ }
142
+
143
+ create_response = requests.post(create_url, headers=self.headers, json=create_data)
144
+
145
+ if create_response.status_code in [200, 201]:
146
+ tunnel = create_response.json()['result']
147
+ print(f"✅ Created tunnel: {tunnel_name}")
148
+
149
+ # Add the secret to the tunnel info (API doesn't return it)
150
+ tunnel['tunnel_secret'] = tunnel_secret
151
+
152
+ # Save credentials for this tunnel
153
+ self._save_tunnel_credentials(tunnel)
154
+
155
+ # Configure tunnel routes
156
+ self._configure_tunnel_routes(tunnel['id'])
157
+
158
+ # Store tunnel ID for DNS creation
159
+ self.tunnel_id = tunnel['id']
160
+
161
+ # Create DNS records for this device
162
+ self.create_dns_records()
163
+
164
+ return tunnel
165
+ else:
166
+ print(f"❌ Failed to create tunnel: {create_response.text}")
167
+ return None
168
+
169
+ def _configure_tunnel_routes(self, tunnel_id):
170
+ """
171
+ Configure ingress routes for the device tunnel
172
+ Creates a config file for cloudflared
173
+ """
174
+ import yaml
175
+
176
+ # Create config file for this tunnel
177
+ config_dir = Path.home() / '.cloudflared'
178
+ config_dir.mkdir(exist_ok=True)
179
+ config_file = config_dir / f'config-{tunnel_id}.yml'
180
+
181
+ config = {
182
+ "tunnel": tunnel_id,
183
+ "credentials-file": str(config_dir / f"{tunnel_id}.json"),
184
+ "ingress": [
185
+ {
186
+ "hostname": f"{self.jupyter_subdomain}.{self.base_domain}",
187
+ "service": "http://localhost:8888",
188
+ "originRequest": {
189
+ "noTLSVerify": True
190
+ }
191
+ },
192
+ {
193
+ "hostname": f"{self.ssh_subdomain}.{self.base_domain}",
194
+ "service": "ssh://localhost:22"
195
+ },
196
+ {
197
+ "service": "http_status:404"
198
+ }
199
+ ]
200
+ }
201
+
202
+ with open(config_file, 'w') as f:
203
+ yaml.dump(config, f)
204
+
205
+ print(f"✅ Created tunnel config: {config_file}")
206
+ self.tunnel_config_file = config_file
207
+
208
+ def _save_tunnel_credentials(self, tunnel_info):
209
+ """
210
+ Save tunnel credentials locally for this device
211
+ Credentials must be base64 encoded for cloudflared
212
+ """
213
+ import base64
214
+ import json
215
+
216
+ creds_dir = Path.home() / '.cloudflared'
217
+ creds_dir.mkdir(exist_ok=True)
218
+
219
+ creds_file = creds_dir / f"{tunnel_info['id']}.json"
220
+
221
+ # Get the secret - it should be hex string
222
+ secret_hex = tunnel_info.get('tunnel_secret') or tunnel_info.get('secret')
223
+ if secret_hex:
224
+ # Convert hex to bytes then to base64
225
+ secret_bytes = bytes.fromhex(secret_hex)
226
+ secret_b64 = base64.b64encode(secret_bytes).decode('ascii')
227
+ else:
228
+ print("⚠️ No tunnel secret found")
229
+ return None
230
+
231
+ credentials = {
232
+ "AccountTag": self.account_id,
233
+ "TunnelSecret": secret_b64, # Must be base64!
234
+ "TunnelID": tunnel_info['id']
235
+ }
236
+
237
+ with open(creds_file, 'w') as f:
238
+ json.dump(credentials, f, indent=2)
239
+
240
+ print(f"✅ Saved tunnel credentials: {creds_file}")
241
+ return creds_file
242
+
243
+ def start_tunnel_with_token(self):
244
+ """
245
+ Start tunnel using API-created tunnel with UUID
246
+ """
247
+ try:
248
+ print("🚀 Starting Cloudflare tunnel...")
249
+
250
+ # Ensure cloudflared is available
251
+ cloudflared_path = self._ensure_cloudflared()
252
+ if not cloudflared_path:
253
+ raise RuntimeError("Failed to obtain cloudflared binary")
254
+
255
+ # Create a new unique tunnel
256
+ device_tunnel = self.create_device_tunnel()
257
+
258
+ if not device_tunnel:
259
+ print("❌ Could not create device tunnel")
260
+ return None
261
+
262
+ tunnel_id = device_tunnel['id']
263
+ tunnel_name = device_tunnel['name']
264
+
265
+ print(f"🚇 Starting tunnel: {tunnel_name} ({tunnel_id})")
266
+
267
+ # Check credentials file exists
268
+ creds_file = Path.home() / '.cloudflared' / f"{tunnel_id}.json"
269
+ config_file = Path.home() / '.cloudflared' / f'config-{tunnel_id}.yml'
270
+
271
+ if not creds_file.exists():
272
+ print("❌ Error: No credentials file found for newly created tunnel")
273
+ return None
274
+
275
+ # Use config file if it exists, otherwise use credentials file
276
+ if config_file.exists():
277
+ cmd = [
278
+ cloudflared_path,
279
+ "tunnel",
280
+ "--no-autoupdate",
281
+ "--config", str(config_file),
282
+ "run"
283
+ ]
284
+ else:
285
+ cmd = [
286
+ cloudflared_path,
287
+ "tunnel",
288
+ "--no-autoupdate",
289
+ "--credentials-file", str(creds_file),
290
+ "run",
291
+ tunnel_id
292
+ ]
293
+
294
+ # Start tunnel process
295
+ self.tunnel_process = subprocess.Popen(
296
+ cmd,
297
+ stdout=subprocess.PIPE,
298
+ stderr=subprocess.STDOUT,
299
+ text=True,
300
+ bufsize=1
301
+ )
302
+
303
+ print("⏳ Waiting for tunnel to connect...")
304
+ time.sleep(5)
305
+
306
+ if self.tunnel_process.poll() is None:
307
+ print("✅ Tunnel is running!")
308
+ print(f"📌 Device ID: {self.clean_device_id}")
309
+ print(f"📌 Jupyter URL: {self.jupyter_url}")
310
+ print(f"📌 SSH hostname: {self.ssh_hostname}")
311
+ print(f"📌 SSH command: ssh -o ProxyCommand='cloudflared access ssh --hostname {self.ssh_hostname}' user@localhost")
312
+ return self.tunnel_process
313
+ else:
314
+ output = self.tunnel_process.stdout.read() if self.tunnel_process.stdout else ""
315
+ print(f"❌ Tunnel failed to start: {output}")
316
+ return None
317
+
318
+ except Exception as e:
319
+ print(f"❌ Error starting tunnel: {e}")
320
+ return None
321
+
322
+ def setup(self, jupyter_port=8888):
323
+ """
324
+ Setup and start tunnel (maintains compatibility)
325
+ """
326
+ return self.start_tunnel_with_token()
327
+
328
+ def stop(self):
329
+ """
330
+ Stop the tunnel if running
331
+ """
332
+ if self.tunnel_process and self.tunnel_process.poll() is None:
333
+ print("Stopping tunnel...")
334
+ self.tunnel_process.terminate()
335
+ try:
336
+ self.tunnel_process.wait(timeout=5)
337
+ except subprocess.TimeoutExpired:
338
+ self.tunnel_process.kill()
339
+ print("Tunnel stopped")
340
+
341
+ def _ensure_cloudflared(self):
342
+ """
343
+ Ensure cloudflared binary is available
344
+ """
345
+ print("🔍 Checking for cloudflared binary...")
346
+
347
+ # Try binary manager first
348
+ if self.binary_manager:
349
+ try:
350
+ path = self.binary_manager.get_binary_path()
351
+ print(f"✅ Using cloudflared from binary manager: {path}")
352
+ return path
353
+ except Exception as e:
354
+ print(f"⚠️ Binary manager failed: {e}")
355
+
356
+ # Fallback to system cloudflared
357
+ try:
358
+ result = subprocess.run(['which', 'cloudflared'], capture_output=True, text=True)
359
+ if result.returncode == 0:
360
+ path = result.stdout.strip()
361
+ print(f"✅ Found system cloudflared: {path}")
362
+ return path
363
+ except:
364
+ pass
365
+
366
+ print("❌ cloudflared not found")
367
+ return None
@@ -206,75 +206,47 @@ class CloudflareAPITunnel:
206
206
  print(" Assuming routes are configured in dashboard.")
207
207
  return True
208
208
 
209
- def create_device_tunnel(self, retry_on_fail=True):
209
+ def create_device_tunnel(self):
210
210
  """
211
- Create a unique tunnel for this device if it doesn't exist
211
+ Create a unique tunnel for this device
212
+ Each tunnel gets a unique UUID to avoid conflicts
212
213
  """
213
- tunnel_name = f"device-{self.clean_device_id}"
214
- print(f"🔍 Checking for tunnel: {tunnel_name}")
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
+ }
215
227
 
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)
228
+ create_response = requests.post(create_url, headers=self.headers, json=create_data)
219
229
 
220
- if response.status_code == 200:
221
- tunnels = response.json().get('result', [])
222
- existing_tunnel = None
230
+ if create_response.status_code in [200, 201]:
231
+ tunnel = create_response.json()['result']
232
+ print(f"✅ Created tunnel: {tunnel_name}")
223
233
 
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
234
+ # Add the secret to the tunnel info (API doesn't return it)
235
+ tunnel['tunnel_secret'] = tunnel_secret
229
236
 
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()
237
+ # Save credentials for this tunnel
238
+ self._save_tunnel_credentials(tunnel)
274
239
 
275
- return existing_tunnel
276
-
277
- return None
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
278
250
 
279
251
  def _configure_tunnel_routes(self, tunnel_id):
280
252
  """
@@ -363,7 +335,7 @@ class CloudflareAPITunnel:
363
335
  if not cloudflared_path:
364
336
  raise RuntimeError("Failed to obtain cloudflared binary")
365
337
 
366
- # Create or get existing tunnel for this device
338
+ # Always create a new unique tunnel with UUID
367
339
  device_tunnel = self.create_device_tunnel()
368
340
 
369
341
  # Now set up DNS and routes via API after tunnel is created/found
@@ -372,21 +344,6 @@ class CloudflareAPITunnel:
372
344
  self.create_dns_records()
373
345
  self.update_tunnel_config()
374
346
 
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")
389
-
390
347
  if not device_tunnel:
391
348
  print("❌ Could not create/find device tunnel")
392
349
  # Fallback to shared tunnel if API fails
@@ -411,21 +368,12 @@ class CloudflareAPITunnel:
411
368
  config_file = Path.home() / '.cloudflared' / f'config-{tunnel_id}.yml'
412
369
  cmd = None # Initialize cmd
413
370
 
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:
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
429
377
  result = token_response.json()
430
378
  if 'result' in result:
431
379
  token = result['result']
@@ -463,11 +411,58 @@ class CloudflareAPITunnel:
463
411
 
464
412
  # If 404, tunnel doesn't exist or we don't have access
465
413
  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
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
471
466
  else:
472
467
  print(f" ℹ️ Token endpoint returned {token_response.status_code}")
473
468
  print(f" This might mean the tunnel needs to be recreated manually")
@@ -521,12 +516,8 @@ class CloudflareAPITunnel:
521
516
  print(f"❌ Tunnel failed to start: {output}")
522
517
  return None
523
518
  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")
519
+ # If no cmd, something went wrong
520
+ print("❌ No valid command to start tunnel")
530
521
  return None
531
522
 
532
523
  except Exception as e:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: unitlab
3
- Version: 2.3.20
3
+ Version: 2.3.23
4
4
  Home-page: https://github.com/teamunitlab/unitlab-sdk
5
5
  Author: Unitlab Inc.
6
6
  Author-email: team@unitlab.ai
@@ -7,6 +7,7 @@ src/unitlab/__main__.py
7
7
  src/unitlab/binary_manager.py
8
8
  src/unitlab/client.py
9
9
  src/unitlab/cloudflare_api_tunnel.py
10
+ src/unitlab/cloudflare_api_tunnel_backup.py
10
11
  src/unitlab/exceptions.py
11
12
  src/unitlab/main.py
12
13
  src/unitlab/tunnel_config.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes