unitlab 2.3.33__py3-none-any.whl → 2.3.35__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/client.py +78 -64
- unitlab/main.py +17 -32
- unitlab/persistent_tunnel.py +196 -51
- unitlab/utils.py +2 -0
- {unitlab-2.3.33.dist-info → unitlab-2.3.35.dist-info}/METADATA +12 -3
- unitlab-2.3.35.dist-info/RECORD +13 -0
- {unitlab-2.3.33.dist-info → unitlab-2.3.35.dist-info}/WHEEL +1 -1
- unitlab/api_tunnel.py +0 -238
- unitlab/auto_tunnel.py +0 -174
- unitlab/binary_manager.py +0 -154
- unitlab/cloudflare_api_tunnel.py +0 -379
- unitlab/cloudflare_api_tunnel_backup.py +0 -653
- unitlab/dynamic_tunnel.py +0 -272
- unitlab/easy_tunnel.py +0 -210
- unitlab/simple_tunnel.py +0 -205
- unitlab/tunnel_config.py +0 -204
- unitlab/tunnel_service_token.py +0 -104
- unitlab-2.3.33.dist-info/RECORD +0 -23
- {unitlab-2.3.33.dist-info → unitlab-2.3.35.dist-info}/entry_points.txt +0 -0
- {unitlab-2.3.33.dist-info → unitlab-2.3.35.dist-info/licenses}/LICENSE.md +0 -0
- {unitlab-2.3.33.dist-info → unitlab-2.3.35.dist-info}/top_level.txt +0 -0
@@ -1,653 +0,0 @@
|
|
1
|
-
"""
|
2
|
-
Cloudflare API-based Tunnel Configuration
|
3
|
-
Uses API to dynamically manage DNS and routes
|
4
|
-
"""
|
5
|
-
|
6
|
-
import os
|
7
|
-
import requests
|
8
|
-
import subprocess
|
9
|
-
import time
|
10
|
-
import logging
|
11
|
-
from pathlib import Path
|
12
|
-
from .binary_manager import CloudflaredBinaryManager
|
13
|
-
|
14
|
-
# Try to load .env file if it exists
|
15
|
-
try:
|
16
|
-
from dotenv import load_dotenv
|
17
|
-
env_path = Path(__file__).parent.parent.parent / '.env'
|
18
|
-
if env_path.exists():
|
19
|
-
load_dotenv(env_path)
|
20
|
-
except ImportError:
|
21
|
-
pass # dotenv not installed, use system env vars only
|
22
|
-
|
23
|
-
logger = logging.getLogger(__name__)
|
24
|
-
|
25
|
-
|
26
|
-
class CloudflareAPITunnel:
|
27
|
-
def __init__(self, base_domain, device_id):
|
28
|
-
"""
|
29
|
-
Initialize API-based tunnel manager
|
30
|
-
"""
|
31
|
-
self.base_domain = "1scan.uz"
|
32
|
-
self.device_id = device_id
|
33
|
-
|
34
|
-
# Clean device ID for subdomain
|
35
|
-
self.clean_device_id = device_id.replace('-', '').replace('_', '').lower()[:20]
|
36
|
-
|
37
|
-
# Cloudflare IDs - hardcoded for 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
|
41
|
-
|
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"
|
45
|
-
|
46
|
-
if not self.api_token:
|
47
|
-
logger.warning("Using fallback tunnel configuration without API management.")
|
48
|
-
|
49
|
-
# API setup
|
50
|
-
self.api_base = "https://api.cloudflare.com/client/v4"
|
51
|
-
self.headers = {
|
52
|
-
"Authorization": f"Bearer {self.api_token}",
|
53
|
-
"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
|
64
|
-
|
65
|
-
self.tunnel_process = None
|
66
|
-
self.created_dns_records = []
|
67
|
-
self.tunnel_config_file = None
|
68
|
-
|
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
|
-
|
76
|
-
def create_dns_records(self):
|
77
|
-
"""
|
78
|
-
Create DNS CNAME records for this device
|
79
|
-
"""
|
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
|
-
|
85
|
-
print(f"📡 Creating DNS records for device {self.device_id}...")
|
86
|
-
|
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}"}
|
90
|
-
]
|
91
|
-
|
92
|
-
for record in records:
|
93
|
-
try:
|
94
|
-
# Check if record exists
|
95
|
-
check_url = f"{self.api_base}/zones/{self.zone_id}/dns_records"
|
96
|
-
params = {"name": f"{record['name']}.{self.base_domain}", "type": "CNAME"}
|
97
|
-
|
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
|
105
|
-
|
106
|
-
# Create new record
|
107
|
-
data = {
|
108
|
-
"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"]
|
114
|
-
}
|
115
|
-
|
116
|
-
response = requests.post(check_url, headers=self.headers, json=data)
|
117
|
-
|
118
|
-
if response.status_code == 200:
|
119
|
-
result = response.json()
|
120
|
-
if result.get("success"):
|
121
|
-
print(f" ✅ Created DNS: {record['name']}.{self.base_domain}")
|
122
|
-
self.created_dns_records.append(result["result"]["id"])
|
123
|
-
else:
|
124
|
-
print(f" ⚠️ Failed to create {record['name']}: {result.get('errors')}")
|
125
|
-
else:
|
126
|
-
print(f" ❌ HTTP error {response.status_code} for {record['name']}")
|
127
|
-
|
128
|
-
except Exception as e:
|
129
|
-
print(f" ❌ Error creating DNS record: {e}")
|
130
|
-
continue
|
131
|
-
|
132
|
-
return True
|
133
|
-
|
134
|
-
def update_tunnel_config(self, jupyter_port=8888, ssh_port=22):
|
135
|
-
"""
|
136
|
-
Update tunnel configuration via API
|
137
|
-
"""
|
138
|
-
if not self.api_token:
|
139
|
-
print("⚠️ No API token. Tunnel will use existing configuration.")
|
140
|
-
return True
|
141
|
-
|
142
|
-
print(f"🔧 Configuring tunnel routes...")
|
143
|
-
|
144
|
-
# Get current tunnel config first
|
145
|
-
get_url = f"{self.api_base}/accounts/{self.account_id}/tunnels/{self.tunnel_id}/configurations"
|
146
|
-
|
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"})
|
185
|
-
|
186
|
-
# Update configuration
|
187
|
-
config_data = {
|
188
|
-
"config": {
|
189
|
-
"ingress": new_ingress
|
190
|
-
}
|
191
|
-
}
|
192
|
-
|
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)
|
195
|
-
|
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):
|
210
|
-
"""
|
211
|
-
Create a unique tunnel for this device
|
212
|
-
Each tunnel gets a unique UUID to avoid conflicts
|
213
|
-
"""
|
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
|
-
}
|
227
|
-
|
228
|
-
create_response = requests.post(create_url, headers=self.headers, json=create_data)
|
229
|
-
|
230
|
-
if create_response.status_code in [200, 201]:
|
231
|
-
tunnel = create_response.json()['result']
|
232
|
-
print(f"✅ Created tunnel: {tunnel_name}")
|
233
|
-
|
234
|
-
# Add the secret to the tunnel info (API doesn't return it)
|
235
|
-
tunnel['tunnel_secret'] = tunnel_secret
|
236
|
-
|
237
|
-
# Save credentials for this tunnel
|
238
|
-
self._save_tunnel_credentials(tunnel)
|
239
|
-
|
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
|
250
|
-
|
251
|
-
def _configure_tunnel_routes(self, tunnel_id):
|
252
|
-
"""
|
253
|
-
Configure ingress routes for the device tunnel
|
254
|
-
The tunnel needs to be configured with a config file, not via API
|
255
|
-
So we'll create a config file for it
|
256
|
-
"""
|
257
|
-
import yaml
|
258
|
-
|
259
|
-
# Create config file for this tunnel
|
260
|
-
config_dir = Path.home() / '.cloudflared'
|
261
|
-
config_dir.mkdir(exist_ok=True)
|
262
|
-
config_file = config_dir / f'config-{tunnel_id}.yml'
|
263
|
-
|
264
|
-
config = {
|
265
|
-
"tunnel": tunnel_id,
|
266
|
-
"credentials-file": str(config_dir / f"{tunnel_id}.json"),
|
267
|
-
"ingress": [
|
268
|
-
{
|
269
|
-
"hostname": f"{self.jupyter_subdomain}.{self.base_domain}",
|
270
|
-
"service": "http://localhost:8888",
|
271
|
-
"originRequest": {
|
272
|
-
"noTLSVerify": True
|
273
|
-
}
|
274
|
-
},
|
275
|
-
{
|
276
|
-
"hostname": f"{self.ssh_subdomain}.{self.base_domain}",
|
277
|
-
"service": "ssh://localhost:22"
|
278
|
-
},
|
279
|
-
{
|
280
|
-
"service": "http_status:404"
|
281
|
-
}
|
282
|
-
]
|
283
|
-
}
|
284
|
-
|
285
|
-
with open(config_file, 'w') as f:
|
286
|
-
yaml.dump(config, f)
|
287
|
-
|
288
|
-
print(f"✅ Created tunnel config: {config_file}")
|
289
|
-
self.tunnel_config_file = config_file
|
290
|
-
|
291
|
-
def _save_tunnel_credentials(self, tunnel_info):
|
292
|
-
"""
|
293
|
-
Save tunnel credentials locally for this device
|
294
|
-
Credentials must be base64 encoded for cloudflared
|
295
|
-
"""
|
296
|
-
import base64
|
297
|
-
import json
|
298
|
-
|
299
|
-
creds_dir = Path.home() / '.cloudflared'
|
300
|
-
creds_dir.mkdir(exist_ok=True)
|
301
|
-
|
302
|
-
creds_file = creds_dir / f"{tunnel_info['id']}.json"
|
303
|
-
|
304
|
-
# Get the secret - it should be hex string
|
305
|
-
secret_hex = tunnel_info.get('tunnel_secret') or tunnel_info.get('secret')
|
306
|
-
if secret_hex:
|
307
|
-
# Convert hex to bytes then to base64
|
308
|
-
secret_bytes = bytes.fromhex(secret_hex)
|
309
|
-
secret_b64 = base64.b64encode(secret_bytes).decode('ascii')
|
310
|
-
else:
|
311
|
-
print("⚠️ No tunnel secret found")
|
312
|
-
return None
|
313
|
-
|
314
|
-
credentials = {
|
315
|
-
"AccountTag": self.account_id,
|
316
|
-
"TunnelSecret": secret_b64, # Must be base64!
|
317
|
-
"TunnelID": tunnel_info['id']
|
318
|
-
}
|
319
|
-
|
320
|
-
with open(creds_file, 'w') as f:
|
321
|
-
json.dump(credentials, f)
|
322
|
-
|
323
|
-
print(f"💾 Saved credentials to: {creds_file}")
|
324
|
-
return creds_file
|
325
|
-
|
326
|
-
def start_tunnel_with_token(self):
|
327
|
-
"""
|
328
|
-
Start tunnel using the existing service token
|
329
|
-
"""
|
330
|
-
try:
|
331
|
-
print("🚀 Starting Cloudflare tunnel...")
|
332
|
-
|
333
|
-
# Ensure cloudflared is available
|
334
|
-
cloudflared_path = self._ensure_cloudflared()
|
335
|
-
if not cloudflared_path:
|
336
|
-
raise RuntimeError("Failed to obtain cloudflared binary")
|
337
|
-
|
338
|
-
# Always create a new unique tunnel with UUID
|
339
|
-
device_tunnel = self.create_device_tunnel()
|
340
|
-
|
341
|
-
# Now set up DNS and routes via API after tunnel is created/found
|
342
|
-
if self.api_token and device_tunnel:
|
343
|
-
self.tunnel_id = device_tunnel['id'] # Ensure tunnel_id is set
|
344
|
-
self.create_dns_records()
|
345
|
-
self.update_tunnel_config()
|
346
|
-
|
347
|
-
if not device_tunnel:
|
348
|
-
print("❌ Could not create/find device tunnel")
|
349
|
-
# Fallback to shared tunnel if API fails
|
350
|
-
print("⚠️ Falling back to shared tunnel...")
|
351
|
-
service_token = "eyJhIjoiYzkxMTkyYWUyMGE1ZDQzZjY1ZTA4NzU1MGQ4ZGM4OWIiLCJ0IjoiMDc3N2ZjMTAtNDljNC00NzJkLTg2NjEtZjYwZDgwZDYxODRkIiwicyI6Ik9XRTNaak5tTVdVdE1tWTRaUzAwTmpoakLTazBmalF0WXpjek1tSm1ZVGt4WlRRMCJ9"
|
352
|
-
cmd = [
|
353
|
-
cloudflared_path,
|
354
|
-
"tunnel",
|
355
|
-
"--no-autoupdate",
|
356
|
-
"run",
|
357
|
-
"--token",
|
358
|
-
service_token
|
359
|
-
]
|
360
|
-
else:
|
361
|
-
tunnel_id = device_tunnel['id']
|
362
|
-
tunnel_name = device_tunnel['name']
|
363
|
-
|
364
|
-
print(f"🚇 Starting tunnel: {tunnel_name} ({tunnel_id})")
|
365
|
-
|
366
|
-
# Check if credentials file exists
|
367
|
-
creds_file = Path.home() / '.cloudflared' / f"{tunnel_id}.json"
|
368
|
-
config_file = Path.home() / '.cloudflared' / f'config-{tunnel_id}.yml'
|
369
|
-
cmd = None # Initialize cmd
|
370
|
-
|
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
|
377
|
-
result = token_response.json()
|
378
|
-
if 'result' in result:
|
379
|
-
token = result['result']
|
380
|
-
print(f" ✅ Got tunnel token")
|
381
|
-
else:
|
382
|
-
print(f" ❌ No token in response: {result}")
|
383
|
-
return None
|
384
|
-
|
385
|
-
# Check if config file exists with ingress rules
|
386
|
-
if config_file.exists():
|
387
|
-
# Use token with config file for ingress rules
|
388
|
-
print(f" Using token with config file: {config_file}")
|
389
|
-
cmd = [
|
390
|
-
cloudflared_path,
|
391
|
-
"tunnel",
|
392
|
-
"--no-autoupdate",
|
393
|
-
"--config", str(config_file),
|
394
|
-
"run",
|
395
|
-
"--token", token
|
396
|
-
]
|
397
|
-
else:
|
398
|
-
# Use token directly
|
399
|
-
print(f" Using token directly (no config file)")
|
400
|
-
cmd = [
|
401
|
-
cloudflared_path,
|
402
|
-
"tunnel",
|
403
|
-
"--no-autoupdate",
|
404
|
-
"run",
|
405
|
-
"--token", token
|
406
|
-
]
|
407
|
-
else:
|
408
|
-
print(f"❌ Could not get tunnel token: {token_response.status_code}")
|
409
|
-
if token_response.text:
|
410
|
-
print(f" Error: {token_response.text}")
|
411
|
-
|
412
|
-
# If 404, tunnel doesn't exist or we don't have access
|
413
|
-
if token_response.status_code == 404:
|
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
|
466
|
-
else:
|
467
|
-
print(f" ℹ️ Token endpoint returned {token_response.status_code}")
|
468
|
-
print(f" This might mean the tunnel needs to be recreated manually")
|
469
|
-
return None
|
470
|
-
|
471
|
-
# Only use credentials if we haven't already set cmd with token
|
472
|
-
if cmd is None and creds_file.exists():
|
473
|
-
# Check if config file exists
|
474
|
-
if config_file.exists():
|
475
|
-
# Run tunnel with config file (includes routes)
|
476
|
-
cmd = [
|
477
|
-
cloudflared_path,
|
478
|
-
"tunnel",
|
479
|
-
"--no-autoupdate",
|
480
|
-
"--config", str(config_file),
|
481
|
-
"run"
|
482
|
-
]
|
483
|
-
else:
|
484
|
-
# Fallback to credentials file only
|
485
|
-
cmd = [
|
486
|
-
cloudflared_path,
|
487
|
-
"tunnel",
|
488
|
-
"--no-autoupdate",
|
489
|
-
"--credentials-file", str(creds_file),
|
490
|
-
"run",
|
491
|
-
tunnel_id
|
492
|
-
]
|
493
|
-
|
494
|
-
# Only start the tunnel if we have a valid command
|
495
|
-
if cmd:
|
496
|
-
self.tunnel_process = subprocess.Popen(
|
497
|
-
cmd,
|
498
|
-
stdout=subprocess.PIPE,
|
499
|
-
stderr=subprocess.STDOUT,
|
500
|
-
text=True,
|
501
|
-
bufsize=1
|
502
|
-
)
|
503
|
-
|
504
|
-
print("⏳ Waiting for tunnel to connect...")
|
505
|
-
time.sleep(5)
|
506
|
-
|
507
|
-
if self.tunnel_process.poll() is None:
|
508
|
-
print("✅ Tunnel is running!")
|
509
|
-
print(f"📌 Device ID: {self.clean_device_id}")
|
510
|
-
print(f"📌 Jupyter URL: {self.jupyter_url}")
|
511
|
-
print(f"📌 SSH hostname: {self.ssh_hostname}")
|
512
|
-
print(f"📌 SSH command: ssh -o ProxyCommand='cloudflared access ssh --hostname {self.ssh_hostname}' user@localhost")
|
513
|
-
return self.tunnel_process
|
514
|
-
else:
|
515
|
-
output = self.tunnel_process.stdout.read() if self.tunnel_process.stdout else ""
|
516
|
-
print(f"❌ Tunnel failed to start: {output}")
|
517
|
-
return None
|
518
|
-
else:
|
519
|
-
# If no cmd, something went wrong
|
520
|
-
print("❌ No valid command to start tunnel")
|
521
|
-
return None
|
522
|
-
|
523
|
-
except Exception as e:
|
524
|
-
print(f"❌ Error starting tunnel: {e}")
|
525
|
-
return None
|
526
|
-
|
527
|
-
def setup(self, jupyter_port=8888):
|
528
|
-
"""
|
529
|
-
Setup and start tunnel (maintains compatibility)
|
530
|
-
"""
|
531
|
-
return self.start_tunnel_with_token()
|
532
|
-
|
533
|
-
def stop(self):
|
534
|
-
"""
|
535
|
-
Stop the tunnel if running
|
536
|
-
Note: We keep the tunnel configuration for next run
|
537
|
-
"""
|
538
|
-
if self.tunnel_process and self.tunnel_process.poll() is None:
|
539
|
-
print("Stopping tunnel...")
|
540
|
-
self.tunnel_process.terminate()
|
541
|
-
try:
|
542
|
-
self.tunnel_process.wait(timeout=5)
|
543
|
-
except subprocess.TimeoutExpired:
|
544
|
-
self.tunnel_process.kill()
|
545
|
-
print("Tunnel stopped")
|
546
|
-
print("ℹ️ Tunnel configuration preserved for next run")
|
547
|
-
|
548
|
-
def _ensure_cloudflared(self):
|
549
|
-
"""
|
550
|
-
Ensure cloudflared binary is available
|
551
|
-
Downloads it if necessary
|
552
|
-
"""
|
553
|
-
print("🔍 Checking for cloudflared binary...")
|
554
|
-
|
555
|
-
# Try binary manager first
|
556
|
-
if self.binary_manager:
|
557
|
-
try:
|
558
|
-
path = self.binary_manager.get_binary_path()
|
559
|
-
print(f"✅ Using cloudflared from binary manager: {path}")
|
560
|
-
return path
|
561
|
-
except Exception as e:
|
562
|
-
logger.warning(f"Binary manager failed, will download directly: {e}")
|
563
|
-
|
564
|
-
# Direct download fallback - simplified version
|
565
|
-
import platform
|
566
|
-
import urllib.request
|
567
|
-
import ssl
|
568
|
-
|
569
|
-
# Create SSL context that handles certificate issues
|
570
|
-
ssl_context = ssl.create_default_context()
|
571
|
-
ssl_context.check_hostname = False
|
572
|
-
ssl_context.verify_mode = ssl.CERT_NONE
|
573
|
-
|
574
|
-
cache_dir = Path.home() / '.unitlab' / 'bin'
|
575
|
-
cache_dir.mkdir(parents=True, exist_ok=True)
|
576
|
-
|
577
|
-
cloudflared_path = cache_dir / 'cloudflared'
|
578
|
-
if platform.system() == 'Windows':
|
579
|
-
cloudflared_path = cache_dir / 'cloudflared.exe'
|
580
|
-
|
581
|
-
# If already exists, use it
|
582
|
-
if cloudflared_path.exists():
|
583
|
-
print(f"✅ Using cached cloudflared: {cloudflared_path}")
|
584
|
-
return str(cloudflared_path)
|
585
|
-
|
586
|
-
# Download based on platform
|
587
|
-
system = platform.system().lower()
|
588
|
-
machine = platform.machine().lower()
|
589
|
-
|
590
|
-
print(f"📥 Downloading cloudflared for {system}/{machine}...")
|
591
|
-
|
592
|
-
if system == 'linux':
|
593
|
-
if machine in ['x86_64', 'amd64']:
|
594
|
-
url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64'
|
595
|
-
elif machine in ['aarch64', 'arm64']:
|
596
|
-
url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64'
|
597
|
-
else:
|
598
|
-
url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-386'
|
599
|
-
elif system == 'darwin':
|
600
|
-
url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz'
|
601
|
-
elif system == 'windows':
|
602
|
-
url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe'
|
603
|
-
else:
|
604
|
-
raise RuntimeError(f"Unsupported platform: {system}")
|
605
|
-
|
606
|
-
try:
|
607
|
-
# Download the file with SSL context
|
608
|
-
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
609
|
-
|
610
|
-
# Special handling for macOS .tgz files
|
611
|
-
if system == 'darwin':
|
612
|
-
import tarfile
|
613
|
-
import io
|
614
|
-
|
615
|
-
with urllib.request.urlopen(req, context=ssl_context) as response:
|
616
|
-
data = response.read()
|
617
|
-
|
618
|
-
# Extract from tar.gz
|
619
|
-
with tarfile.open(fileobj=io.BytesIO(data), mode='r:gz') as tar:
|
620
|
-
tar.extract('cloudflared', cache_dir)
|
621
|
-
else:
|
622
|
-
# Direct binary download for Linux/Windows
|
623
|
-
with urllib.request.urlopen(req, context=ssl_context) as response:
|
624
|
-
with open(cloudflared_path, 'wb') as out_file:
|
625
|
-
out_file.write(response.read())
|
626
|
-
|
627
|
-
# Make executable on Unix
|
628
|
-
if system != 'windows':
|
629
|
-
import stat
|
630
|
-
cloudflared_path.chmod(cloudflared_path.stat().st_mode | stat.S_IEXEC)
|
631
|
-
|
632
|
-
print(f"✅ Downloaded cloudflared to: {cloudflared_path}")
|
633
|
-
return str(cloudflared_path)
|
634
|
-
|
635
|
-
except Exception as e:
|
636
|
-
print(f"❌ Failed to download cloudflared: {e}")
|
637
|
-
raise RuntimeError(f"Could not download cloudflared: {e}")
|
638
|
-
|
639
|
-
def cleanup_dns(self):
|
640
|
-
"""
|
641
|
-
Remove created DNS records (optional cleanup)
|
642
|
-
"""
|
643
|
-
if not self.api_token or not self.created_dns_records:
|
644
|
-
return
|
645
|
-
|
646
|
-
print("🧹 Cleaning up DNS records...")
|
647
|
-
for record_id in self.created_dns_records:
|
648
|
-
try:
|
649
|
-
url = f"{self.api_base}/zones/{self.zone_id}/dns_records/{record_id}"
|
650
|
-
requests.delete(url, headers=self.headers)
|
651
|
-
print(f" Deleted record {record_id}")
|
652
|
-
except:
|
653
|
-
pass
|