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.
- unitlab/cloudflare_api_tunnel.py +162 -445
- unitlab/cloudflare_api_tunnel_backup.py +653 -0
- {unitlab-2.3.20.dist-info → unitlab-2.3.25.dist-info}/METADATA +1 -1
- {unitlab-2.3.20.dist-info → unitlab-2.3.25.dist-info}/RECORD +8 -7
- {unitlab-2.3.20.dist-info → unitlab-2.3.25.dist-info}/LICENSE.md +0 -0
- {unitlab-2.3.20.dist-info → unitlab-2.3.25.dist-info}/WHEEL +0 -0
- {unitlab-2.3.20.dist-info → unitlab-2.3.25.dist-info}/entry_points.txt +0 -0
- {unitlab-2.3.20.dist-info → unitlab-2.3.25.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 = "LJLe6QMOtpN0MeuLQ05_zUKKxVm4vEibkC8lxSJd"
|
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
|
-
|
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
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
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[
|
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,70 @@ 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
|
-
|
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
|
-
#
|
145
|
-
|
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
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
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
|
-
#
|
187
|
-
|
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
|
-
|
194
|
-
|
164
|
+
# Save credentials for this tunnel
|
165
|
+
self._save_tunnel_credentials(tunnel)
|
195
166
|
|
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
|
167
|
+
# Configure tunnel routes
|
168
|
+
self._configure_tunnel_routes(tunnel['id'])
|
223
169
|
|
224
|
-
|
225
|
-
|
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
|
-
|
231
|
-
|
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
|
276
|
-
|
277
|
-
|
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
|
-
|
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"
|
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
|
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
|
267
|
+
# Create a new unique tunnel
|
367
268
|
device_tunnel = self.create_device_tunnel()
|
368
269
|
|
369
|
-
|
370
|
-
|
371
|
-
|
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
|
-
|
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")
|
274
|
+
tunnel_id = device_tunnel['id']
|
275
|
+
tunnel_name = device_tunnel['name']
|
389
276
|
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
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
|
-
"
|
400
|
-
"
|
401
|
-
service_token
|
293
|
+
"--config", str(config_file),
|
294
|
+
"run"
|
402
295
|
]
|
403
296
|
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
|
-
]
|
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
|
-
#
|
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
|
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
|
-
|
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")
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
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("
|
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
|
378
|
+
print("❌ cloudflared not found")
|
379
|
+
return None
|