unitlab 2.3.4__tar.gz → 2.3.7__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.
- {unitlab-2.3.4/src/unitlab.egg-info → unitlab-2.3.7}/PKG-INFO +1 -1
- {unitlab-2.3.4 → unitlab-2.3.7}/setup.py +1 -1
- unitlab-2.3.7/src/unitlab/binary_manager.py +137 -0
- {unitlab-2.3.4 → unitlab-2.3.7}/src/unitlab/client.py +38 -4
- unitlab-2.3.7/src/unitlab/cloudflare_api_tunnel.py +286 -0
- {unitlab-2.3.4 → unitlab-2.3.7}/src/unitlab/main.py +1 -1
- {unitlab-2.3.4 → unitlab-2.3.7}/src/unitlab/tunnel_config.py +24 -19
- {unitlab-2.3.4 → unitlab-2.3.7/src/unitlab.egg-info}/PKG-INFO +1 -1
- {unitlab-2.3.4 → unitlab-2.3.7}/src/unitlab.egg-info/SOURCES.txt +2 -0
- {unitlab-2.3.4 → unitlab-2.3.7}/LICENSE.md +0 -0
- {unitlab-2.3.4 → unitlab-2.3.7}/README.md +0 -0
- {unitlab-2.3.4 → unitlab-2.3.7}/setup.cfg +0 -0
- {unitlab-2.3.4 → unitlab-2.3.7}/src/unitlab/__init__.py +0 -0
- {unitlab-2.3.4 → unitlab-2.3.7}/src/unitlab/__main__.py +0 -0
- {unitlab-2.3.4 → unitlab-2.3.7}/src/unitlab/exceptions.py +0 -0
- {unitlab-2.3.4 → unitlab-2.3.7}/src/unitlab/tunnel_service_token.py +0 -0
- {unitlab-2.3.4 → unitlab-2.3.7}/src/unitlab/utils.py +0 -0
- {unitlab-2.3.4 → unitlab-2.3.7}/src/unitlab.egg-info/dependency_links.txt +0 -0
- {unitlab-2.3.4 → unitlab-2.3.7}/src/unitlab.egg-info/entry_points.txt +0 -0
- {unitlab-2.3.4 → unitlab-2.3.7}/src/unitlab.egg-info/requires.txt +0 -0
- {unitlab-2.3.4 → unitlab-2.3.7}/src/unitlab.egg-info/top_level.txt +0 -0
@@ -0,0 +1,137 @@
|
|
1
|
+
import os
|
2
|
+
import platform
|
3
|
+
import hashlib
|
4
|
+
import urllib.request
|
5
|
+
from pathlib import Path
|
6
|
+
import stat
|
7
|
+
import json
|
8
|
+
|
9
|
+
class CloudflaredBinaryManager:
|
10
|
+
"""
|
11
|
+
Manages cloudflared binary automatically
|
12
|
+
- Downloads on first use
|
13
|
+
- Caches for future use
|
14
|
+
- Verifies integrity
|
15
|
+
- Zero user configuration
|
16
|
+
"""
|
17
|
+
|
18
|
+
# Binary URLs and checksums
|
19
|
+
BINARIES = {
|
20
|
+
'linux-amd64': {
|
21
|
+
'url': 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64',
|
22
|
+
'checksum': 'sha256:...', # Add real checksums
|
23
|
+
'filename': 'cloudflared'
|
24
|
+
},
|
25
|
+
'linux-arm64': {
|
26
|
+
'url': 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64',
|
27
|
+
'checksum': 'sha256:...',
|
28
|
+
'filename': 'cloudflared'
|
29
|
+
},
|
30
|
+
'darwin-amd64': {
|
31
|
+
'url': 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz',
|
32
|
+
'checksum': 'sha256:...',
|
33
|
+
'filename': 'cloudflared',
|
34
|
+
'compressed': True
|
35
|
+
},
|
36
|
+
'windows-amd64': {
|
37
|
+
'url': 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe',
|
38
|
+
'checksum': 'sha256:...',
|
39
|
+
'filename': 'cloudflared.exe'
|
40
|
+
}
|
41
|
+
}
|
42
|
+
|
43
|
+
def __init__(self):
|
44
|
+
# User's home directory - works on all platforms
|
45
|
+
self.cache_dir = Path.home() / '.unitlab' / 'bin'
|
46
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
47
|
+
|
48
|
+
# Detect platform once
|
49
|
+
self.platform_key = self._detect_platform()
|
50
|
+
|
51
|
+
def _detect_platform(self):
|
52
|
+
"""Detect OS and architecture"""
|
53
|
+
system = platform.system().lower()
|
54
|
+
machine = platform.machine().lower()
|
55
|
+
|
56
|
+
if system == 'linux':
|
57
|
+
if machine in ['x86_64', 'amd64']:
|
58
|
+
return 'linux-amd64'
|
59
|
+
elif machine in ['aarch64', 'arm64']:
|
60
|
+
return 'linux-arm64'
|
61
|
+
|
62
|
+
elif system == 'darwin': # macOS
|
63
|
+
# Check if ARM (M1/M2) or Intel
|
64
|
+
if machine == 'arm64':
|
65
|
+
return 'darwin-arm64'
|
66
|
+
return 'darwin-amd64'
|
67
|
+
|
68
|
+
elif system == 'windows':
|
69
|
+
return 'windows-amd64'
|
70
|
+
|
71
|
+
raise RuntimeError(f"Unsupported platform: {system} {machine}")
|
72
|
+
|
73
|
+
def get_binary_path(self):
|
74
|
+
"""Get path to cloudflared binary, downloading if needed"""
|
75
|
+
|
76
|
+
binary_info = self.BINARIES[self.platform_key]
|
77
|
+
binary_path = self.cache_dir / binary_info['filename']
|
78
|
+
|
79
|
+
# Check if already downloaded
|
80
|
+
if binary_path.exists():
|
81
|
+
print("✓ Using cached cloudflared")
|
82
|
+
return str(binary_path)
|
83
|
+
|
84
|
+
# Download for first time
|
85
|
+
print("🔄 First time setup - downloading cloudflared...")
|
86
|
+
self._download_binary(binary_info, binary_path)
|
87
|
+
|
88
|
+
return str(binary_path)
|
89
|
+
|
90
|
+
def _download_binary(self, info, target_path):
|
91
|
+
"""Download and verify binary"""
|
92
|
+
|
93
|
+
# Download with progress bar
|
94
|
+
def download_progress(block_num, block_size, total_size):
|
95
|
+
downloaded = block_num * block_size
|
96
|
+
if total_size > 0:
|
97
|
+
percent = min(downloaded * 100 / total_size, 100)
|
98
|
+
print(f"Downloading: {percent:.0f}%", end='\r')
|
99
|
+
else:
|
100
|
+
print(f"Downloading: {downloaded} bytes", end='\r')
|
101
|
+
|
102
|
+
temp_file = target_path.with_suffix('.tmp')
|
103
|
+
|
104
|
+
try:
|
105
|
+
# Download file
|
106
|
+
urllib.request.urlretrieve(
|
107
|
+
info['url'],
|
108
|
+
temp_file,
|
109
|
+
reporthook=download_progress
|
110
|
+
)
|
111
|
+
print("\n✓ Download complete")
|
112
|
+
|
113
|
+
# Handle compressed files (macOS .tgz)
|
114
|
+
if info.get('compressed'):
|
115
|
+
import tarfile
|
116
|
+
with tarfile.open(temp_file, 'r:gz') as tar:
|
117
|
+
# Extract just the cloudflared binary
|
118
|
+
tar.extract('cloudflared', self.cache_dir)
|
119
|
+
temp_file.unlink()
|
120
|
+
else:
|
121
|
+
# Move to final location
|
122
|
+
temp_file.rename(target_path)
|
123
|
+
|
124
|
+
# Make executable on Unix systems
|
125
|
+
if platform.system() != 'Windows':
|
126
|
+
target_path.chmod(target_path.stat().st_mode | stat.S_IEXEC)
|
127
|
+
|
128
|
+
print("✓ Cloudflared ready!")
|
129
|
+
|
130
|
+
except Exception as e:
|
131
|
+
print(f"❌ Download failed: {e}")
|
132
|
+
if temp_file.exists():
|
133
|
+
temp_file.unlink()
|
134
|
+
raise
|
135
|
+
|
136
|
+
|
137
|
+
|
@@ -16,7 +16,9 @@ import threading
|
|
16
16
|
import psutil
|
17
17
|
from datetime import datetime, timezone
|
18
18
|
from .tunnel_config import CloudflareTunnel
|
19
|
+
from .cloudflare_api_tunnel import CloudflareAPITunnel
|
19
20
|
from .utils import get_api_url, handle_exceptions
|
21
|
+
from pathlib import Path
|
20
22
|
|
21
23
|
|
22
24
|
try:
|
@@ -26,6 +28,18 @@ except ImportError:
|
|
26
28
|
HAS_GPU = False
|
27
29
|
|
28
30
|
|
31
|
+
try:
|
32
|
+
from dotenv import load_dotenv
|
33
|
+
env_path = Path(__file__).parent.parent.parent / '.env'
|
34
|
+
if env_path.exists():
|
35
|
+
load_dotenv(env_path)
|
36
|
+
except ImportError:
|
37
|
+
pass # dotenv not installed, use system env vars only
|
38
|
+
|
39
|
+
|
40
|
+
|
41
|
+
|
42
|
+
|
29
43
|
logger = logging.getLogger(__name__)
|
30
44
|
|
31
45
|
class UnitlabClient:
|
@@ -270,7 +284,17 @@ class UnitlabClient:
|
|
270
284
|
self.base_domain = base_domain
|
271
285
|
|
272
286
|
# Initialize tunnel manager if available
|
273
|
-
if
|
287
|
+
# Use API-based tunnel if API token is available
|
288
|
+
print(os.getenv('CLOUDFLARE_API_TOKEN'), 'api tokennn')
|
289
|
+
|
290
|
+
if os.getenv("CLOUDFLARE_API_TOKEN"):
|
291
|
+
logger.info("Using API-based Cloudflare tunnel management")
|
292
|
+
|
293
|
+
self.tunnel_manager = CloudflareAPITunnel(base_domain, device_id)
|
294
|
+
self.jupyter_url = self.tunnel_manager.jupyter_url
|
295
|
+
self.ssh_url = self.tunnel_manager.ssh_url
|
296
|
+
elif CloudflareTunnel:
|
297
|
+
logger.info("Using service token Cloudflare tunnel")
|
274
298
|
self.tunnel_manager = CloudflareTunnel(base_domain, device_id)
|
275
299
|
self.jupyter_url = self.tunnel_manager.jupyter_url
|
276
300
|
self.ssh_url = self.tunnel_manager.ssh_url
|
@@ -299,19 +323,25 @@ class UnitlabClient:
|
|
299
323
|
# Add API key if provided
|
300
324
|
if self.api_key:
|
301
325
|
headers['Authorization'] = f'Api-Key {self.api_key}'
|
326
|
+
logger.debug(f"Added API key to headers: Api-Key {self.api_key[:8]}...")
|
327
|
+
else:
|
328
|
+
logger.warning("No API key found for device agent request")
|
302
329
|
|
303
330
|
return headers
|
304
331
|
|
305
332
|
def _post_device(self, endpoint, data=None):
|
306
333
|
"""Make authenticated POST request for device agent"""
|
307
334
|
full_url = urllib.parse.urljoin(self.server_url, endpoint)
|
308
|
-
|
335
|
+
headers = self._get_device_headers()
|
336
|
+
logger.debug(f"Posting to {full_url}")
|
337
|
+
logger.debug(f"Headers: {headers}")
|
338
|
+
logger.debug(f"Data: {data}")
|
309
339
|
|
310
340
|
try:
|
311
341
|
response = self.api_session.post(
|
312
342
|
full_url,
|
313
343
|
json=data or {},
|
314
|
-
headers=
|
344
|
+
headers=headers,
|
315
345
|
)
|
316
346
|
logger.debug(f"Response status: {response.status_code}, Response: {response.text}")
|
317
347
|
response.raise_for_status()
|
@@ -331,7 +361,8 @@ class UnitlabClient:
|
|
331
361
|
"--ServerApp.token=''",
|
332
362
|
"--ServerApp.password=''",
|
333
363
|
"--ServerApp.allow_origin='*'",
|
334
|
-
"--ServerApp.ip='0.0.0.0'"
|
364
|
+
"--ServerApp.ip='0.0.0.0'",
|
365
|
+
"--ServerApp.port=8888" # Explicitly use 8888 to match Cloudflare config
|
335
366
|
]
|
336
367
|
|
337
368
|
self.jupyter_proc = subprocess.Popen(
|
@@ -427,6 +458,9 @@ class UnitlabClient:
|
|
427
458
|
}
|
428
459
|
|
429
460
|
logger.info(f"Reporting Jupyter service with URL: {self.jupyter_url}")
|
461
|
+
logger.debug(f"API key present: {bool(self.api_key)}")
|
462
|
+
if self.api_key:
|
463
|
+
logger.debug(f"API key value: {self.api_key[:8]}...")
|
430
464
|
jupyter_response = self._post_device(
|
431
465
|
f"/api/tunnel/agent/jupyter/{self.device_id}/",
|
432
466
|
jupyter_data
|
@@ -0,0 +1,286 @@
|
|
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 now, can move to env vars)
|
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 from environment
|
43
|
+
self.api_token = os.getenv("CLOUDFLARE_API_TOKEN")
|
44
|
+
if not self.api_token:
|
45
|
+
logger.warning("CLOUDFLARE_API_TOKEN not set. API features will be disabled.")
|
46
|
+
|
47
|
+
# API setup
|
48
|
+
self.api_base = "https://api.cloudflare.com/client/v4"
|
49
|
+
self.headers = {
|
50
|
+
"Authorization": f"Bearer {self.api_token}",
|
51
|
+
"Content-Type": "application/json"
|
52
|
+
} if self.api_token else {}
|
53
|
+
|
54
|
+
# URLs for services
|
55
|
+
self.jupyter_subdomain = f"j{self.clean_device_id}"
|
56
|
+
self.ssh_subdomain = f"s{self.clean_device_id}"
|
57
|
+
self.jupyter_url = f"https://{self.jupyter_subdomain}.{self.base_domain}"
|
58
|
+
self.ssh_hostname = f"{self.ssh_subdomain}.{self.base_domain}"
|
59
|
+
self.ssh_url = self.ssh_hostname # For backward compatibility
|
60
|
+
|
61
|
+
self.tunnel_process = None
|
62
|
+
self.created_dns_records = []
|
63
|
+
self.binary_manager = CloudflaredBinaryManager()
|
64
|
+
|
65
|
+
def create_dns_records(self):
|
66
|
+
"""
|
67
|
+
Create DNS CNAME records for this device
|
68
|
+
"""
|
69
|
+
if not self.api_token:
|
70
|
+
print("⚠️ No API token configured. Skipping DNS creation.")
|
71
|
+
print(" Assuming DNS records already exist or will be created manually.")
|
72
|
+
return True
|
73
|
+
|
74
|
+
print(f"📡 Creating DNS records for device {self.device_id}...")
|
75
|
+
|
76
|
+
records = [
|
77
|
+
{"name": self.jupyter_subdomain, "comment": f"Jupyter for {self.device_id}"},
|
78
|
+
{"name": self.ssh_subdomain, "comment": f"SSH for {self.device_id}"}
|
79
|
+
]
|
80
|
+
|
81
|
+
for record in records:
|
82
|
+
try:
|
83
|
+
# Check if record exists
|
84
|
+
check_url = f"{self.api_base}/zones/{self.zone_id}/dns_records"
|
85
|
+
params = {"name": f"{record['name']}.{self.base_domain}", "type": "CNAME"}
|
86
|
+
|
87
|
+
response = requests.get(check_url, headers=self.headers, params=params)
|
88
|
+
existing = response.json()
|
89
|
+
|
90
|
+
if existing.get("result") and len(existing["result"]) > 0:
|
91
|
+
# Record exists
|
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": f"{self.tunnel_id}.cfargotunnel.com",
|
100
|
+
"ttl": 1, # Auto
|
101
|
+
"proxied": True,
|
102
|
+
"comment": record["comment"]
|
103
|
+
}
|
104
|
+
|
105
|
+
response = requests.post(check_url, headers=self.headers, json=data)
|
106
|
+
|
107
|
+
if response.status_code == 200:
|
108
|
+
result = response.json()
|
109
|
+
if result.get("success"):
|
110
|
+
print(f" ✅ Created DNS: {record['name']}.{self.base_domain}")
|
111
|
+
self.created_dns_records.append(result["result"]["id"])
|
112
|
+
else:
|
113
|
+
print(f" ⚠️ Failed to create {record['name']}: {result.get('errors')}")
|
114
|
+
else:
|
115
|
+
print(f" ❌ HTTP error {response.status_code} for {record['name']}")
|
116
|
+
|
117
|
+
except Exception as e:
|
118
|
+
print(f" ❌ Error creating DNS record: {e}")
|
119
|
+
continue
|
120
|
+
|
121
|
+
return True
|
122
|
+
|
123
|
+
def update_tunnel_config(self, jupyter_port=8888, ssh_port=22):
|
124
|
+
"""
|
125
|
+
Update tunnel configuration via API
|
126
|
+
"""
|
127
|
+
if not self.api_token:
|
128
|
+
print("⚠️ No API token. Tunnel will use existing configuration.")
|
129
|
+
return True
|
130
|
+
|
131
|
+
print(f"🔧 Configuring tunnel routes...")
|
132
|
+
|
133
|
+
# Get current tunnel config first
|
134
|
+
get_url = f"{self.api_base}/accounts/{self.account_id}/cfd_tunnel/{self.tunnel_id}/configurations"
|
135
|
+
|
136
|
+
try:
|
137
|
+
# Get existing config
|
138
|
+
response = requests.get(get_url, headers=self.headers)
|
139
|
+
current_config = response.json()
|
140
|
+
|
141
|
+
# Build new ingress rules
|
142
|
+
new_ingress = [
|
143
|
+
{
|
144
|
+
"hostname": f"{self.jupyter_subdomain}.{self.base_domain}",
|
145
|
+
"service": f"http://localhost:{jupyter_port}",
|
146
|
+
"originRequest": {
|
147
|
+
"noTLSVerify": True
|
148
|
+
}
|
149
|
+
},
|
150
|
+
{
|
151
|
+
"hostname": f"{self.ssh_subdomain}.{self.base_domain}",
|
152
|
+
"service": f"ssh://localhost:{ssh_port}"
|
153
|
+
}
|
154
|
+
]
|
155
|
+
|
156
|
+
# Merge with existing ingress if any
|
157
|
+
if current_config.get("success") and current_config.get("result"):
|
158
|
+
existing_ingress = current_config["result"].get("config", {}).get("ingress", [])
|
159
|
+
|
160
|
+
# Filter out our hostnames from existing
|
161
|
+
filtered_ingress = [
|
162
|
+
rule for rule in existing_ingress
|
163
|
+
if rule.get("hostname") not in [
|
164
|
+
f"{self.jupyter_subdomain}.{self.base_domain}",
|
165
|
+
f"{self.ssh_subdomain}.{self.base_domain}"
|
166
|
+
] and rule.get("service") != "http_status:404"
|
167
|
+
]
|
168
|
+
|
169
|
+
# Combine
|
170
|
+
new_ingress = new_ingress + filtered_ingress
|
171
|
+
|
172
|
+
# Add catch-all at the end
|
173
|
+
new_ingress.append({"service": "http_status:404"})
|
174
|
+
|
175
|
+
# Update configuration
|
176
|
+
config_data = {
|
177
|
+
"config": {
|
178
|
+
"ingress": new_ingress
|
179
|
+
}
|
180
|
+
}
|
181
|
+
|
182
|
+
put_url = f"{self.api_base}/accounts/{self.account_id}/cfd_tunnel/{self.tunnel_id}/configurations"
|
183
|
+
response = requests.put(put_url, headers=self.headers, json=config_data)
|
184
|
+
|
185
|
+
if response.status_code == 200:
|
186
|
+
print(f" ✅ Tunnel routes configured")
|
187
|
+
return True
|
188
|
+
else:
|
189
|
+
print(f" ⚠️ Route configuration status: {response.status_code}")
|
190
|
+
# Continue anyway - routes might be configured manually
|
191
|
+
return True
|
192
|
+
|
193
|
+
except Exception as e:
|
194
|
+
print(f" ⚠️ Could not update routes via API: {e}")
|
195
|
+
print(" Assuming routes are configured in dashboard.")
|
196
|
+
return True
|
197
|
+
|
198
|
+
def start_tunnel_with_token(self):
|
199
|
+
"""
|
200
|
+
Start tunnel using the existing service token
|
201
|
+
"""
|
202
|
+
try:
|
203
|
+
print("🚀 Starting Cloudflare tunnel...")
|
204
|
+
|
205
|
+
# First, try to set up DNS and routes via API
|
206
|
+
if self.api_token:
|
207
|
+
self.create_dns_records()
|
208
|
+
self.update_tunnel_config()
|
209
|
+
|
210
|
+
# Get cloudflared binary
|
211
|
+
cloudflared_path = self.binary_manager.get_binary_path()
|
212
|
+
|
213
|
+
# Use the existing service token from environment
|
214
|
+
service_token = os.getenv(
|
215
|
+
"CLOUDFLARE_TUNNEL_TOKEN",
|
216
|
+
"eyJhIjoiYzkxMTkyYWUyMGE1ZDQzZjY1ZTA4NzU1MGQ4ZGM4OWIiLCJ0IjoiMDc3N2ZjMTAtNDljNC00NzJkLTg2NjEtZjYwZDgwZDYxODRkIiwicyI6Ik9XRTNaak5tTVdVdE1tWTRaUzAwTmpoakxUazBaalF0WXpjek1tSm1ZVGt4WlRRMCJ9"
|
217
|
+
)
|
218
|
+
|
219
|
+
# Start tunnel with service token
|
220
|
+
cmd = [
|
221
|
+
cloudflared_path,
|
222
|
+
"tunnel",
|
223
|
+
"--no-autoupdate",
|
224
|
+
"run",
|
225
|
+
"--token",
|
226
|
+
service_token
|
227
|
+
]
|
228
|
+
|
229
|
+
self.tunnel_process = subprocess.Popen(
|
230
|
+
cmd,
|
231
|
+
stdout=subprocess.PIPE,
|
232
|
+
stderr=subprocess.STDOUT,
|
233
|
+
text=True,
|
234
|
+
bufsize=1
|
235
|
+
)
|
236
|
+
|
237
|
+
print("⏳ Waiting for tunnel to connect...")
|
238
|
+
time.sleep(5)
|
239
|
+
|
240
|
+
if self.tunnel_process.poll() is None:
|
241
|
+
print("✅ Tunnel is running!")
|
242
|
+
print(f"📌 Device ID: {self.clean_device_id}")
|
243
|
+
print(f"📌 Jupyter URL: {self.jupyter_url}")
|
244
|
+
print(f"📌 SSH hostname: {self.ssh_hostname}")
|
245
|
+
print(f"📌 SSH command: ssh -o ProxyCommand='cloudflared access ssh --hostname {self.ssh_hostname}' user@localhost")
|
246
|
+
return self.tunnel_process
|
247
|
+
else:
|
248
|
+
output = self.tunnel_process.stdout.read() if self.tunnel_process.stdout else ""
|
249
|
+
print(f"❌ Tunnel failed to start: {output}")
|
250
|
+
return None
|
251
|
+
|
252
|
+
except Exception as e:
|
253
|
+
print(f"❌ Error starting tunnel: {e}")
|
254
|
+
return None
|
255
|
+
|
256
|
+
def setup(self, jupyter_port=8888):
|
257
|
+
"""
|
258
|
+
Setup and start tunnel (maintains compatibility)
|
259
|
+
"""
|
260
|
+
return self.start_tunnel_with_token()
|
261
|
+
|
262
|
+
def stop(self):
|
263
|
+
"""
|
264
|
+
Stop the tunnel if running
|
265
|
+
"""
|
266
|
+
if self.tunnel_process and self.tunnel_process.poll() is None:
|
267
|
+
print("Stopping tunnel...")
|
268
|
+
self.tunnel_process.terminate()
|
269
|
+
self.tunnel_process.wait(timeout=5)
|
270
|
+
print("Tunnel stopped")
|
271
|
+
|
272
|
+
def cleanup_dns(self):
|
273
|
+
"""
|
274
|
+
Remove created DNS records (optional cleanup)
|
275
|
+
"""
|
276
|
+
if not self.api_token or not self.created_dns_records:
|
277
|
+
return
|
278
|
+
|
279
|
+
print("🧹 Cleaning up DNS records...")
|
280
|
+
for record_id in self.created_dns_records:
|
281
|
+
try:
|
282
|
+
url = f"{self.api_base}/zones/{self.zone_id}/dns_records/{record_id}"
|
283
|
+
requests.delete(url, headers=self.headers)
|
284
|
+
print(f" Deleted record {record_id}")
|
285
|
+
except:
|
286
|
+
pass
|
@@ -125,7 +125,7 @@ def send_metrics_into_server():
|
|
125
125
|
|
126
126
|
@agent_app.command(name="run", help="Run the device agent with Jupyter, SSH tunnels and metrics")
|
127
127
|
def run_agent(
|
128
|
-
api_key:
|
128
|
+
api_key: API_KEY,
|
129
129
|
device_id: Annotated[str, typer.Option(help="Device ID")] = None,
|
130
130
|
base_domain: Annotated[str, typer.Option(help="Base domain for tunnels")] = "1scan.uz",
|
131
131
|
|
@@ -8,6 +8,7 @@ import subprocess
|
|
8
8
|
import socket
|
9
9
|
import time
|
10
10
|
import logging
|
11
|
+
from .binary_manager import CloudflaredBinaryManager
|
11
12
|
|
12
13
|
logger = logging.getLogger(__name__)
|
13
14
|
|
@@ -23,12 +24,15 @@ class CloudflareTunnel:
|
|
23
24
|
self.device_id = device_id
|
24
25
|
self.hostname = socket.gethostname()
|
25
26
|
|
27
|
+
# Initialize binary manager to handle cloudflared
|
28
|
+
self.binary_manager = CloudflaredBinaryManager()
|
29
|
+
|
26
30
|
# Service token - replace with your actual token from cloudflared tunnel token command
|
27
31
|
# This token can ONLY run the tunnel, cannot modify or delete it
|
28
|
-
# To generate: cloudflared tunnel token
|
32
|
+
# To generate: cloudflared tunnel token [tunnel-name]
|
29
33
|
self.service_token = os.getenv(
|
30
34
|
"CLOUDFLARE_TUNNEL_TOKEN",
|
31
|
-
"
|
35
|
+
"eyJhIjoiYzkxMTkyYWUyMGE1ZDQzZjY1ZTA4NzU1MGQ4ZGM4OWIiLCJ0IjoiMDc3N2ZjMTAtNDljNC00NzJkLTg2NjEtZjYwZDgwZDYxODRkIiwicyI6Ik9XRTNaak5tTVdVdE1tWTRaUzAwTmpoakxUazBaalF0WXpjek1tSm1ZVGt4WlRRMCJ9" # TODO: Replace with your new tunnel's token
|
32
36
|
)
|
33
37
|
|
34
38
|
if self.service_token == "YOUR_SERVICE_TOKEN_HERE":
|
@@ -37,13 +41,14 @@ class CloudflareTunnel:
|
|
37
41
|
"Set CLOUDFLARE_TUNNEL_TOKEN env var or update the token in tunnel_config.py"
|
38
42
|
)
|
39
43
|
|
40
|
-
#
|
41
|
-
|
42
|
-
self.
|
44
|
+
# Use single subdomain per device with service differentiation
|
45
|
+
# This works with *.1scan.uz wildcard certificate
|
46
|
+
self.device_subdomain = f"{device_id.replace('-', '').replace('_', '').lower()[:20]}"
|
43
47
|
|
44
|
-
#
|
45
|
-
self.jupyter_url = f"https://{self.
|
46
|
-
self.
|
48
|
+
# Both services use same subdomain
|
49
|
+
self.jupyter_url = f"https://{self.device_subdomain}.{self.base_domain}"
|
50
|
+
self.ssh_hostname = f"{self.device_subdomain}.{self.base_domain}" # For SSH ProxyCommand
|
51
|
+
self.ssh_url = self.ssh_hostname # Keep for backward compatibility
|
47
52
|
|
48
53
|
self.tunnel_process = None
|
49
54
|
|
@@ -121,17 +126,12 @@ class CloudflareTunnel:
|
|
121
126
|
def setup(self, jupyter_port): # jupyter_port kept for compatibility
|
122
127
|
"""
|
123
128
|
Setup and start tunnel with service token
|
124
|
-
No login required!
|
129
|
+
No login required! Binary is automatically downloaded if needed.
|
125
130
|
"""
|
126
131
|
print("🚀 Setting up Cloudflare tunnel with service token...")
|
127
132
|
|
128
|
-
#
|
129
|
-
|
130
|
-
print("cloudflared not found, attempting to install...")
|
131
|
-
if not self.install_cloudflared():
|
132
|
-
print("❌ Failed to install cloudflared. Please install it manually:")
|
133
|
-
print(" https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/")
|
134
|
-
return None
|
133
|
+
# Binary manager will automatically download cloudflared if needed
|
134
|
+
# No manual installation required!
|
135
135
|
|
136
136
|
# Start tunnel with service token
|
137
137
|
return self.start_tunnel_with_token()
|
@@ -139,14 +139,18 @@ class CloudflareTunnel:
|
|
139
139
|
def start_tunnel_with_token(self):
|
140
140
|
"""
|
141
141
|
Start the tunnel using service token
|
142
|
-
|
142
|
+
Binary is automatically downloaded if needed
|
143
143
|
"""
|
144
144
|
try:
|
145
145
|
print("🚀 Starting Cloudflare tunnel...")
|
146
146
|
|
147
|
+
# Get cloudflared binary path (downloads if needed)
|
148
|
+
cloudflared_path = self.binary_manager.get_binary_path()
|
149
|
+
print(f"📍 Using cloudflared at: {cloudflared_path}")
|
150
|
+
|
147
151
|
# Simple command with service token
|
148
152
|
cmd = [
|
149
|
-
|
153
|
+
cloudflared_path,
|
150
154
|
"tunnel",
|
151
155
|
"--no-autoupdate", # Prevent auto-updates during run
|
152
156
|
"run",
|
@@ -170,8 +174,9 @@ class CloudflareTunnel:
|
|
170
174
|
# Check if process is still running
|
171
175
|
if self.tunnel_process.poll() is None:
|
172
176
|
print("✅ Tunnel is running!")
|
177
|
+
print(f"📌 Device subdomain: {self.device_subdomain}.{self.base_domain}")
|
173
178
|
print(f"📌 Jupyter URL: {self.jupyter_url}")
|
174
|
-
print(f"📌 SSH
|
179
|
+
print(f"📌 SSH access: ssh -o ProxyCommand='cloudflared access ssh --hostname {self.ssh_hostname}' user@localhost")
|
175
180
|
return self.tunnel_process
|
176
181
|
else:
|
177
182
|
# Read any error output
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|