unitlab 2.3.20__py3-none-any.whl → 2.3.23__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.
- unitlab/cloudflare_api_tunnel.py +150 -445
- unitlab/cloudflare_api_tunnel_backup.py +653 -0
- {unitlab-2.3.20.dist-info → unitlab-2.3.23.dist-info}/METADATA +1 -1
- {unitlab-2.3.20.dist-info → unitlab-2.3.23.dist-info}/RECORD +8 -7
- {unitlab-2.3.20.dist-info → unitlab-2.3.23.dist-info}/LICENSE.md +0 -0
- {unitlab-2.3.20.dist-info → unitlab-2.3.23.dist-info}/WHEEL +0 -0
- {unitlab-2.3.20.dist-info → unitlab-2.3.23.dist-info}/entry_points.txt +0 -0
- {unitlab-2.3.20.dist-info → unitlab-2.3.23.dist-info}/top_level.txt +0 -0
unitlab/cloudflare_api_tunnel.py
CHANGED
@@ -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
|
22
|
-
|
23
|
-
logger = logging.getLogger(__name__)
|
24
|
-
|
23
|
+
pass
|
25
24
|
|
26
25
|
class CloudflareAPITunnel:
|
27
|
-
def __init__(self,
|
26
|
+
def __init__(self, device_id, base_domain="1scan.uz"):
|
28
27
|
"""
|
29
|
-
Initialize
|
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
|
35
|
-
self.clean_device_id = device_id.replace('-', '').replace('
|
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
|
-
#
|
38
|
-
self.
|
39
|
-
self.
|
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
|
-
#
|
43
|
-
|
44
|
-
self.
|
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
|
-
|
47
|
-
|
46
|
+
# Hardcoded Cloudflare credentials
|
47
|
+
self.account_id = "c91192ae20a5d43f65e087550d8dc89b"
|
48
|
+
self.api_token = "xkfCGvxHeiU7xvz0OhfYUICZvJ7rM4NmgdMSB5jy"
|
49
|
+
self.zone_id = "f17ca0e9cf056e87afb019c88f936ac9"
|
48
50
|
|
49
|
-
# API
|
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
|
-
}
|
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
|
-
#
|
70
|
-
|
71
|
-
|
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
|
69
|
+
Create DNS records pointing to the tunnel
|
79
70
|
"""
|
80
|
-
if not self.api_token:
|
81
|
-
print("⚠️
|
82
|
-
|
83
|
-
|
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
|
-
|
88
|
-
{"name": self.jupyter_subdomain, "
|
89
|
-
{"name": self.ssh_subdomain, "
|
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
|
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
|
-
|
97
|
-
|
98
|
-
response = requests.get(check_url, headers=self.headers, params=params)
|
99
|
-
existing = response.json()
|
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)
|
100
88
|
|
101
|
-
if
|
102
|
-
|
103
|
-
|
104
|
-
|
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[
|
110
|
-
"content":
|
111
|
-
"ttl": 1,
|
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,58 @@ class CloudflareAPITunnel:
|
|
130
118
|
continue
|
131
119
|
|
132
120
|
return True
|
133
|
-
|
134
|
-
def
|
121
|
+
|
122
|
+
def create_device_tunnel(self):
|
135
123
|
"""
|
136
|
-
|
124
|
+
Create a unique tunnel for this device
|
125
|
+
Each tunnel gets a unique UUID to avoid conflicts
|
137
126
|
"""
|
138
|
-
|
139
|
-
|
140
|
-
|
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"
|
131
|
+
print(f"📦 Creating new tunnel: {tunnel_name}")
|
143
132
|
|
144
|
-
|
145
|
-
get_url = f"{self.api_base}/accounts/{self.account_id}/tunnels/{self.tunnel_id}/configurations"
|
133
|
+
create_url = f"{self.api_base}/accounts/{self.account_id}/tunnels"
|
146
134
|
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
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
|
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}")
|
182
148
|
|
183
|
-
# Add
|
184
|
-
|
149
|
+
# Add the secret to the tunnel info (API doesn't return it)
|
150
|
+
tunnel['tunnel_secret'] = tunnel_secret
|
185
151
|
|
186
|
-
#
|
187
|
-
|
188
|
-
"config": {
|
189
|
-
"ingress": new_ingress
|
190
|
-
}
|
191
|
-
}
|
152
|
+
# Save credentials for this tunnel
|
153
|
+
self._save_tunnel_credentials(tunnel)
|
192
154
|
|
193
|
-
|
194
|
-
|
155
|
+
# Configure tunnel routes
|
156
|
+
self._configure_tunnel_routes(tunnel['id'])
|
195
157
|
|
196
|
-
|
197
|
-
|
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
|
158
|
+
# Store tunnel ID for DNS creation
|
159
|
+
self.tunnel_id = tunnel['id']
|
223
160
|
|
224
|
-
for
|
225
|
-
|
226
|
-
existing_tunnel = tunnel
|
227
|
-
print(f"✅ Found existing tunnel: {tunnel_name}")
|
228
|
-
break
|
161
|
+
# Create DNS records for this device
|
162
|
+
self.create_dns_records()
|
229
163
|
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
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()
|
274
|
-
|
275
|
-
return existing_tunnel
|
276
|
-
|
277
|
-
return None
|
164
|
+
return tunnel
|
165
|
+
else:
|
166
|
+
print(f"❌ Failed to create tunnel: {create_response.text}")
|
167
|
+
return None
|
278
168
|
|
279
169
|
def _configure_tunnel_routes(self, tunnel_id):
|
280
170
|
"""
|
281
171
|
Configure ingress routes for the device tunnel
|
282
|
-
|
283
|
-
So we'll create a config file for it
|
172
|
+
Creates a config file for cloudflared
|
284
173
|
"""
|
285
174
|
import yaml
|
286
175
|
|
@@ -346,14 +235,14 @@ class CloudflareAPITunnel:
|
|
346
235
|
}
|
347
236
|
|
348
237
|
with open(creds_file, 'w') as f:
|
349
|
-
json.dump(credentials, f)
|
238
|
+
json.dump(credentials, f, indent=2)
|
350
239
|
|
351
|
-
print(f"
|
240
|
+
print(f"✅ Saved tunnel credentials: {creds_file}")
|
352
241
|
return creds_file
|
353
242
|
|
354
243
|
def start_tunnel_with_token(self):
|
355
244
|
"""
|
356
|
-
Start tunnel using
|
245
|
+
Start tunnel using API-created tunnel with UUID
|
357
246
|
"""
|
358
247
|
try:
|
359
248
|
print("🚀 Starting Cloudflare tunnel...")
|
@@ -363,186 +252,82 @@ class CloudflareAPITunnel:
|
|
363
252
|
if not cloudflared_path:
|
364
253
|
raise RuntimeError("Failed to obtain cloudflared binary")
|
365
254
|
|
366
|
-
# Create
|
255
|
+
# Create a new unique tunnel
|
367
256
|
device_tunnel = self.create_device_tunnel()
|
368
257
|
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
self.create_dns_records()
|
373
|
-
self.update_tunnel_config()
|
258
|
+
if not device_tunnel:
|
259
|
+
print("❌ Could not create device tunnel")
|
260
|
+
return None
|
374
261
|
|
375
|
-
|
376
|
-
|
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")
|
262
|
+
tunnel_id = device_tunnel['id']
|
263
|
+
tunnel_name = device_tunnel['name']
|
389
264
|
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
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():
|
395
277
|
cmd = [
|
396
278
|
cloudflared_path,
|
397
279
|
"tunnel",
|
398
280
|
"--no-autoupdate",
|
399
|
-
"
|
400
|
-
"
|
401
|
-
service_token
|
281
|
+
"--config", str(config_file),
|
282
|
+
"run"
|
402
283
|
]
|
403
284
|
else:
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
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
|
-
]
|
285
|
+
cmd = [
|
286
|
+
cloudflared_path,
|
287
|
+
"tunnel",
|
288
|
+
"--no-autoupdate",
|
289
|
+
"--credentials-file", str(creds_file),
|
290
|
+
"run",
|
291
|
+
tunnel_id
|
292
|
+
]
|
498
293
|
|
499
|
-
#
|
500
|
-
|
501
|
-
|
502
|
-
|
503
|
-
|
504
|
-
|
505
|
-
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
|
510
|
-
|
511
|
-
|
512
|
-
|
513
|
-
|
514
|
-
|
515
|
-
|
516
|
-
|
517
|
-
|
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
|
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
|
523
313
|
else:
|
524
|
-
|
525
|
-
|
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")
|
314
|
+
output = self.tunnel_process.stdout.read() if self.tunnel_process.stdout else ""
|
315
|
+
print(f"❌ Tunnel failed to start: {output}")
|
530
316
|
return None
|
531
317
|
|
532
318
|
except Exception as e:
|
533
319
|
print(f"❌ Error starting tunnel: {e}")
|
534
320
|
return None
|
535
|
-
|
321
|
+
|
536
322
|
def setup(self, jupyter_port=8888):
|
537
323
|
"""
|
538
324
|
Setup and start tunnel (maintains compatibility)
|
539
325
|
"""
|
540
326
|
return self.start_tunnel_with_token()
|
541
|
-
|
327
|
+
|
542
328
|
def stop(self):
|
543
329
|
"""
|
544
330
|
Stop the tunnel if running
|
545
|
-
Note: We keep the tunnel configuration for next run
|
546
331
|
"""
|
547
332
|
if self.tunnel_process and self.tunnel_process.poll() is None:
|
548
333
|
print("Stopping tunnel...")
|
@@ -552,12 +337,10 @@ class CloudflareAPITunnel:
|
|
552
337
|
except subprocess.TimeoutExpired:
|
553
338
|
self.tunnel_process.kill()
|
554
339
|
print("Tunnel stopped")
|
555
|
-
|
556
|
-
|
340
|
+
|
557
341
|
def _ensure_cloudflared(self):
|
558
342
|
"""
|
559
343
|
Ensure cloudflared binary is available
|
560
|
-
Downloads it if necessary
|
561
344
|
"""
|
562
345
|
print("🔍 Checking for cloudflared binary...")
|
563
346
|
|
@@ -568,95 +351,17 @@ class CloudflareAPITunnel:
|
|
568
351
|
print(f"✅ Using cloudflared from binary manager: {path}")
|
569
352
|
return path
|
570
353
|
except Exception as e:
|
571
|
-
|
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}")
|
354
|
+
print(f"⚠️ Binary manager failed: {e}")
|
614
355
|
|
356
|
+
# Fallback to system cloudflared
|
615
357
|
try:
|
616
|
-
|
617
|
-
|
618
|
-
|
619
|
-
|
620
|
-
|
621
|
-
|
622
|
-
|
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
|
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
|
654
365
|
|
655
|
-
print("
|
656
|
-
|
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
|
366
|
+
print("❌ cloudflared not found")
|
367
|
+
return None
|