unitlab 2.3.3__py3-none-any.whl → 2.3.5__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/binary_manager.py +137 -0
- unitlab/client.py +38 -4
- unitlab/cloudflare_api_tunnel.py +286 -0
- unitlab/main.py +2 -2
- unitlab/tunnel_config.py +150 -184
- unitlab/tunnel_service_token.py +104 -0
- {unitlab-2.3.3.dist-info → unitlab-2.3.5.dist-info}/METADATA +2 -1
- unitlab-2.3.5.dist-info/RECORD +16 -0
- unitlab-2.3.3.dist-info/RECORD +0 -13
- {unitlab-2.3.3.dist-info → unitlab-2.3.5.dist-info}/LICENSE.md +0 -0
- {unitlab-2.3.3.dist-info → unitlab-2.3.5.dist-info}/WHEEL +0 -0
- {unitlab-2.3.3.dist-info → unitlab-2.3.5.dist-info}/entry_points.txt +0 -0
- {unitlab-2.3.3.dist-info → unitlab-2.3.5.dist-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
|
+
|
unitlab/client.py
CHANGED
@@ -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
|
unitlab/main.py
CHANGED
@@ -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
|
|
@@ -140,7 +140,7 @@ def run_agent(
|
|
140
140
|
)
|
141
141
|
|
142
142
|
# Get server URL from environment or use default
|
143
|
-
server_url = '
|
143
|
+
server_url = 'http://localhost:8000/'
|
144
144
|
|
145
145
|
# Generate unique device ID if not provided
|
146
146
|
if not device_id:
|
unitlab/tunnel_config.py
CHANGED
@@ -1,192 +1,165 @@
|
|
1
1
|
"""
|
2
|
-
Cloudflare Tunnel Configuration
|
2
|
+
Cloudflare Tunnel Configuration using Service Token
|
3
|
+
No user login required - uses pre-generated service token
|
3
4
|
"""
|
4
5
|
|
5
|
-
import
|
6
|
+
import os
|
6
7
|
import subprocess
|
7
8
|
import socket
|
8
9
|
import time
|
9
|
-
import
|
10
|
-
from
|
10
|
+
import logging
|
11
|
+
from .binary_manager import CloudflaredBinaryManager
|
12
|
+
|
13
|
+
logger = logging.getLogger(__name__)
|
11
14
|
|
12
15
|
|
13
16
|
class CloudflareTunnel:
|
14
|
-
def __init__(self, base_domain, device_id):
|
15
|
-
|
16
|
-
|
17
|
+
def __init__(self, base_domain, device_id): # base_domain kept for compatibility
|
18
|
+
"""
|
19
|
+
Initialize tunnel with service token
|
20
|
+
No login or credential files needed
|
21
|
+
"""
|
22
|
+
# Configuration
|
23
|
+
self.base_domain = "1scan.uz" # Hardcoded domain
|
17
24
|
self.device_id = device_id
|
18
25
|
self.hostname = socket.gethostname()
|
19
|
-
self.tunnel_name = f"device-{device_id}"
|
20
|
-
self.config_dir = Path.home() / ".cloudflared"
|
21
|
-
self.config_dir.mkdir(exist_ok=True)
|
22
26
|
|
23
|
-
#
|
24
|
-
self.
|
25
|
-
|
27
|
+
# Initialize binary manager to handle cloudflared
|
28
|
+
self.binary_manager = CloudflaredBinaryManager()
|
29
|
+
|
30
|
+
# Service token - replace with your actual token from cloudflared tunnel token command
|
31
|
+
# This token can ONLY run the tunnel, cannot modify or delete it
|
32
|
+
# To generate: cloudflared tunnel token [tunnel-name]
|
33
|
+
self.service_token = os.getenv(
|
34
|
+
"CLOUDFLARE_TUNNEL_TOKEN",
|
35
|
+
"eyJhIjoiYzkxMTkyYWUyMGE1ZDQzZjY1ZTA4NzU1MGQ4ZGM4OWIiLCJ0IjoiMDc3N2ZjMTAtNDljNC00NzJkLTg2NjEtZjYwZDgwZDYxODRkIiwicyI6Ik9XRTNaak5tTVdVdE1tWTRaUzAwTmpoakxUazBaalF0WXpjek1tSm1ZVGt4WlRRMCJ9" # TODO: Replace with your new tunnel's token
|
36
|
+
)
|
37
|
+
|
38
|
+
if self.service_token == "YOUR_SERVICE_TOKEN_HERE":
|
39
|
+
logger.warning(
|
40
|
+
"⚠️ No service token configured. "
|
41
|
+
"Set CLOUDFLARE_TUNNEL_TOKEN env var or update the token in tunnel_config.py"
|
42
|
+
)
|
43
|
+
|
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]}"
|
26
47
|
|
27
|
-
#
|
28
|
-
self.jupyter_url = f"https://{self.
|
29
|
-
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
|
30
52
|
|
31
|
-
self.
|
32
|
-
self.credentials_file = None
|
53
|
+
self.tunnel_process = None
|
33
54
|
|
34
|
-
def
|
35
|
-
"""
|
55
|
+
def check_cloudflared_installed(self):
|
56
|
+
"""Check if cloudflared is installed"""
|
36
57
|
try:
|
37
|
-
print("🔐 Checking Cloudflare authentication...")
|
38
58
|
result = subprocess.run(
|
39
|
-
["cloudflared", "
|
59
|
+
["cloudflared", "--version"],
|
40
60
|
capture_output=True,
|
41
61
|
text=True
|
42
62
|
)
|
43
|
-
|
44
|
-
|
45
|
-
return True
|
46
|
-
else:
|
47
|
-
print("❌ Cloudflare authentication failed")
|
48
|
-
return False
|
49
|
-
except Exception as e:
|
50
|
-
print(f"❌ Error during Cloudflare login: {e}")
|
63
|
+
return result.returncode == 0
|
64
|
+
except FileNotFoundError:
|
51
65
|
return False
|
52
66
|
|
53
|
-
def
|
54
|
-
"""
|
67
|
+
def install_cloudflared(self):
|
68
|
+
"""Auto-install cloudflared if not present"""
|
69
|
+
import platform
|
70
|
+
|
71
|
+
system = platform.system().lower()
|
72
|
+
machine = platform.machine().lower()
|
73
|
+
|
74
|
+
print("📦 Installing cloudflared...")
|
75
|
+
|
55
76
|
try:
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
if list_result.returncode == 0:
|
66
|
-
tunnels = json.loads(list_result.stdout)
|
67
|
-
for tunnel in tunnels:
|
68
|
-
if tunnel.get("name") == self.tunnel_name:
|
69
|
-
self.tunnel_uuid = tunnel.get("id")
|
70
|
-
print(f"✅ Tunnel already exists with ID: {self.tunnel_uuid}")
|
71
|
-
self.credentials_file = self.config_dir / f"{self.tunnel_uuid}.json"
|
72
|
-
return True
|
73
|
-
|
74
|
-
# Create new tunnel
|
75
|
-
result = subprocess.run(
|
76
|
-
["cloudflared", "tunnel", "create", self.tunnel_name],
|
77
|
-
capture_output=True,
|
78
|
-
text=True
|
79
|
-
)
|
80
|
-
|
81
|
-
if result.returncode == 0:
|
82
|
-
for line in result.stdout.split('\n'):
|
83
|
-
if "Created tunnel" in line and "with id" in line:
|
84
|
-
self.tunnel_uuid = line.split("with id")[1].strip()
|
85
|
-
break
|
77
|
+
if system == "linux":
|
78
|
+
# Determine architecture
|
79
|
+
if machine in ["x86_64", "amd64"]:
|
80
|
+
arch = "amd64"
|
81
|
+
elif machine in ["aarch64", "arm64"]:
|
82
|
+
arch = "arm64"
|
83
|
+
else:
|
84
|
+
arch = "386"
|
86
85
|
|
87
|
-
|
88
|
-
|
89
|
-
["cloudflared", "tunnel", "list", "--output", "json"],
|
90
|
-
capture_output=True,
|
91
|
-
text=True
|
92
|
-
)
|
93
|
-
if list_result.returncode == 0:
|
94
|
-
tunnels = json.loads(list_result.stdout)
|
95
|
-
for tunnel in tunnels:
|
96
|
-
if tunnel.get("name") == self.tunnel_name:
|
97
|
-
self.tunnel_uuid = tunnel.get("id")
|
98
|
-
break
|
86
|
+
# Download and install
|
87
|
+
url = f"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-{arch}"
|
99
88
|
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
["
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
["cloudflared", "tunnel", "route", "dns",
|
133
|
-
self.tunnel_name, f"{self.ssh_subdomain}.{self.base_domain}"],
|
134
|
-
capture_output=True,
|
135
|
-
text=True
|
136
|
-
)
|
137
|
-
|
138
|
-
if ssh_result.returncode == 0:
|
139
|
-
print(f"✅ SSH route configured: {self.ssh_url}")
|
140
|
-
else:
|
141
|
-
print(f"⚠️ SSH route may already exist or failed: {ssh_result.stderr}")
|
89
|
+
commands = [
|
90
|
+
f"curl -L {url} -o /tmp/cloudflared",
|
91
|
+
"chmod +x /tmp/cloudflared",
|
92
|
+
"sudo mv /tmp/cloudflared /usr/local/bin/cloudflared 2>/dev/null || "
|
93
|
+
"mkdir -p ~/.local/bin && mv /tmp/cloudflared ~/.local/bin/cloudflared"
|
94
|
+
]
|
95
|
+
|
96
|
+
for cmd in commands:
|
97
|
+
subprocess.run(cmd, shell=True, check=False)
|
98
|
+
|
99
|
+
# Add ~/.local/bin to PATH if needed
|
100
|
+
local_bin = os.path.expanduser("~/.local/bin")
|
101
|
+
if local_bin not in os.environ.get("PATH", ""):
|
102
|
+
os.environ["PATH"] = f"{local_bin}:{os.environ['PATH']}"
|
103
|
+
|
104
|
+
return self.check_cloudflared_installed()
|
105
|
+
|
106
|
+
elif system == "darwin":
|
107
|
+
# Try homebrew first
|
108
|
+
result = subprocess.run(["brew", "install", "cloudflared"], capture_output=True)
|
109
|
+
if result.returncode != 0:
|
110
|
+
# Fallback to direct download
|
111
|
+
url = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz"
|
112
|
+
subprocess.run(f"curl -L {url} | tar xz", shell=True)
|
113
|
+
subprocess.run("sudo mv cloudflared /usr/local/bin/", shell=True)
|
114
|
+
|
115
|
+
return self.check_cloudflared_installed()
|
116
|
+
|
117
|
+
elif system == "windows":
|
118
|
+
print("⚠️ Please install cloudflared manually on Windows")
|
119
|
+
print(" Download from: https://github.com/cloudflare/cloudflared/releases")
|
120
|
+
return False
|
142
121
|
|
143
|
-
return True
|
144
|
-
|
145
122
|
except Exception as e:
|
146
|
-
|
123
|
+
logger.error(f"Failed to install cloudflared: {e}")
|
147
124
|
return False
|
148
125
|
|
149
|
-
def
|
150
|
-
"""
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
{
|
156
|
-
"hostname": f"{self.jupyter_subdomain}.{self.base_domain}",
|
157
|
-
"service": f"http://localhost:{jupyter_port}",
|
158
|
-
"originRequest": {
|
159
|
-
"noTLSVerify": True
|
160
|
-
}
|
161
|
-
},
|
162
|
-
{
|
163
|
-
"hostname": f"{self.ssh_subdomain}.{self.base_domain}",
|
164
|
-
"service": "ssh://localhost:22",
|
165
|
-
"originRequest": {
|
166
|
-
"noTLSVerify": True
|
167
|
-
}
|
168
|
-
},
|
169
|
-
{
|
170
|
-
"service": "http_status:404"
|
171
|
-
}
|
172
|
-
]
|
173
|
-
}
|
126
|
+
def setup(self, jupyter_port): # jupyter_port kept for compatibility
|
127
|
+
"""
|
128
|
+
Setup and start tunnel with service token
|
129
|
+
No login required! Binary is automatically downloaded if needed.
|
130
|
+
"""
|
131
|
+
print("🚀 Setting up Cloudflare tunnel with service token...")
|
174
132
|
|
175
|
-
|
176
|
-
|
177
|
-
yaml.dump(config, f, default_flow_style=False)
|
133
|
+
# Binary manager will automatically download cloudflared if needed
|
134
|
+
# No manual installation required!
|
178
135
|
|
179
|
-
|
180
|
-
return
|
136
|
+
# Start tunnel with service token
|
137
|
+
return self.start_tunnel_with_token()
|
181
138
|
|
182
|
-
def
|
183
|
-
"""
|
139
|
+
def start_tunnel_with_token(self):
|
140
|
+
"""
|
141
|
+
Start the tunnel using service token
|
142
|
+
Binary is automatically downloaded if needed
|
143
|
+
"""
|
184
144
|
try:
|
185
145
|
print("🚀 Starting Cloudflare tunnel...")
|
186
146
|
|
187
|
-
|
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
|
+
|
151
|
+
# Simple command with service token
|
152
|
+
cmd = [
|
153
|
+
cloudflared_path,
|
154
|
+
"tunnel",
|
155
|
+
"--no-autoupdate", # Prevent auto-updates during run
|
156
|
+
"run",
|
157
|
+
"--token",
|
158
|
+
self.service_token
|
159
|
+
]
|
188
160
|
|
189
|
-
|
161
|
+
# Start the tunnel process
|
162
|
+
self.tunnel_process = subprocess.Popen(
|
190
163
|
cmd,
|
191
164
|
stdout=subprocess.PIPE,
|
192
165
|
stderr=subprocess.STDOUT,
|
@@ -195,44 +168,37 @@ class CloudflareTunnel:
|
|
195
168
|
)
|
196
169
|
|
197
170
|
# Wait for tunnel to establish
|
171
|
+
print("⏳ Waiting for tunnel to connect...")
|
198
172
|
time.sleep(5)
|
199
173
|
|
200
|
-
if process
|
201
|
-
|
202
|
-
|
174
|
+
# Check if process is still running
|
175
|
+
if self.tunnel_process.poll() is None:
|
176
|
+
print("✅ Tunnel is running!")
|
177
|
+
print(f"📌 Device subdomain: {self.device_subdomain}.{self.base_domain}")
|
178
|
+
print(f"📌 Jupyter URL: {self.jupyter_url}")
|
179
|
+
print(f"📌 SSH access: ssh -o ProxyCommand='cloudflared access ssh --hostname {self.ssh_hostname}' user@localhost")
|
180
|
+
return self.tunnel_process
|
203
181
|
else:
|
182
|
+
# Read any error output
|
183
|
+
output = self.tunnel_process.stdout.read()
|
204
184
|
print("❌ Tunnel failed to start")
|
185
|
+
print(f"Error output: {output}")
|
205
186
|
return None
|
206
187
|
|
207
188
|
except Exception as e:
|
208
189
|
print(f"❌ Error starting tunnel: {e}")
|
209
190
|
return None
|
210
191
|
|
211
|
-
def
|
212
|
-
"""
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
return None
|
225
|
-
|
226
|
-
# Create config file
|
227
|
-
config_file = self.create_config_file(jupyter_port)
|
228
|
-
|
229
|
-
# Start tunnel
|
230
|
-
tunnel_process = self.start_tunnel(config_file)
|
231
|
-
|
232
|
-
if tunnel_process:
|
233
|
-
print("\n✅ Tunnel setup complete!")
|
234
|
-
print(f"📌 Jupyter URL: {self.jupyter_url}")
|
235
|
-
print(f"📌 SSH URL: {self.ssh_url}")
|
236
|
-
return tunnel_process
|
237
|
-
|
238
|
-
return None
|
192
|
+
def stop(self):
|
193
|
+
"""Stop the tunnel if running"""
|
194
|
+
if self.tunnel_process and self.tunnel_process.poll() is None:
|
195
|
+
print("Stopping tunnel...")
|
196
|
+
self.tunnel_process.terminate()
|
197
|
+
self.tunnel_process.wait(timeout=5)
|
198
|
+
print("Tunnel stopped")
|
199
|
+
|
200
|
+
# Removed all the old methods that are no longer needed:
|
201
|
+
# - login() - not needed with service token
|
202
|
+
# - create_tunnel() - tunnel already exists
|
203
|
+
# - configure_dns() - already configured
|
204
|
+
# - create_config_file() - not needed with service token
|
@@ -0,0 +1,104 @@
|
|
1
|
+
"""
|
2
|
+
Service Token implementation for Cloudflare Tunnel
|
3
|
+
More secure than embedding full credentials
|
4
|
+
"""
|
5
|
+
|
6
|
+
import os
|
7
|
+
import subprocess
|
8
|
+
import time
|
9
|
+
from pathlib import Path
|
10
|
+
|
11
|
+
|
12
|
+
class ServiceTokenTunnel:
|
13
|
+
"""
|
14
|
+
Use Cloudflare Service Token instead of credentials file
|
15
|
+
This is more secure and doesn't require login
|
16
|
+
"""
|
17
|
+
|
18
|
+
# Embed the service token (generated once by admin)
|
19
|
+
# This token can ONLY run the tunnel, cannot modify it
|
20
|
+
DEFAULT_SERVICE_TOKEN = "eyJhIjoiYzkxMTkyYWUyMGE1ZDQzZjY1ZTA4NzU1MGQ4ZGM4OWIiLCJzIjoiZmdnSHowbFJFRnBHa05TZzIzV3JKMVBiaDVROGVUd0oyYWtJWThXdjhtTT0iLCJ0IjoiYjMzZGFhOGYtMmNjMy00Y2FkLWEyMjgtOTdlMDYwNzBlNjAwIn0="
|
21
|
+
|
22
|
+
def __init__(self, device_id, service_token=None):
|
23
|
+
self.device_id = device_id
|
24
|
+
# Allow override via environment variable or parameter
|
25
|
+
self.service_token = (
|
26
|
+
service_token or
|
27
|
+
os.getenv("CLOUDFLARE_TUNNEL_TOKEN") or
|
28
|
+
self.DEFAULT_SERVICE_TOKEN
|
29
|
+
)
|
30
|
+
|
31
|
+
# With service token, we don't need credential files
|
32
|
+
# The token contains all necessary information
|
33
|
+
|
34
|
+
def start_tunnel_with_token(self):
|
35
|
+
"""
|
36
|
+
Start tunnel using service token
|
37
|
+
No login, no credential files needed
|
38
|
+
"""
|
39
|
+
print("🚀 Starting tunnel with service token...")
|
40
|
+
|
41
|
+
# Service token method - super simple!
|
42
|
+
cmd = [
|
43
|
+
"cloudflared",
|
44
|
+
"tunnel",
|
45
|
+
"run",
|
46
|
+
"--token",
|
47
|
+
self.service_token
|
48
|
+
]
|
49
|
+
|
50
|
+
try:
|
51
|
+
# Start the tunnel
|
52
|
+
process = subprocess.Popen(
|
53
|
+
cmd,
|
54
|
+
stdout=subprocess.PIPE,
|
55
|
+
stderr=subprocess.PIPE,
|
56
|
+
text=True
|
57
|
+
)
|
58
|
+
|
59
|
+
# Wait for tunnel to establish
|
60
|
+
time.sleep(3)
|
61
|
+
|
62
|
+
if process.poll() is None:
|
63
|
+
print("✅ Tunnel running with service token")
|
64
|
+
return process
|
65
|
+
else:
|
66
|
+
print("❌ Failed to start tunnel")
|
67
|
+
return None
|
68
|
+
|
69
|
+
except Exception as e:
|
70
|
+
print(f"❌ Error: {e}")
|
71
|
+
return None
|
72
|
+
|
73
|
+
def get_tunnel_info(self):
|
74
|
+
"""
|
75
|
+
Service tokens use predetermined URLs
|
76
|
+
The admin sets these up when creating the tunnel
|
77
|
+
"""
|
78
|
+
# These URLs are configured when creating the tunnel
|
79
|
+
base_domain = "1scan.uz"
|
80
|
+
return {
|
81
|
+
"jupyter_url": f"https://jupyter-{self.device_id}.{base_domain}",
|
82
|
+
"ssh_url": f"https://ssh-{self.device_id}.{base_domain}"
|
83
|
+
}
|
84
|
+
|
85
|
+
|
86
|
+
# Usage example
|
87
|
+
def run_with_service_token(device_id):
|
88
|
+
"""
|
89
|
+
Example of how simple it is with service token
|
90
|
+
"""
|
91
|
+
tunnel = ServiceTokenTunnel(device_id)
|
92
|
+
|
93
|
+
# No login needed!
|
94
|
+
# No credential files needed!
|
95
|
+
# Just run with the token
|
96
|
+
process = tunnel.start_tunnel_with_token()
|
97
|
+
|
98
|
+
if process:
|
99
|
+
urls = tunnel.get_tunnel_info()
|
100
|
+
print(f"Jupyter: {urls['jupyter_url']}")
|
101
|
+
print(f"SSH: {urls['ssh_url']}")
|
102
|
+
return process
|
103
|
+
|
104
|
+
return None
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: unitlab
|
3
|
-
Version: 2.3.
|
3
|
+
Version: 2.3.5
|
4
4
|
Home-page: https://github.com/teamunitlab/unitlab-sdk
|
5
5
|
Author: Unitlab Inc.
|
6
6
|
Author-email: team@unitlab.ai
|
@@ -23,4 +23,5 @@ Requires-Dist: typer
|
|
23
23
|
Requires-Dist: validators
|
24
24
|
Requires-Dist: psutil
|
25
25
|
Requires-Dist: pyyaml
|
26
|
+
Requires-Dist: jupyter
|
26
27
|
|
@@ -0,0 +1,16 @@
|
|
1
|
+
unitlab/__init__.py,sha256=Wtk5kQ_MTlxtd3mxJIn2qHVK5URrVcasMMPjD3BtrVM,214
|
2
|
+
unitlab/__main__.py,sha256=6Hs2PV7EYc5Tid4g4OtcLXhqVHiNYTGzSBdoOnW2HXA,29
|
3
|
+
unitlab/binary_manager.py,sha256=bZmT07K2InCrZp1KYXgLntEqnQxPFLqPBDf-LcG25Yo,4544
|
4
|
+
unitlab/client.py,sha256=G4qDgqhLK4wMvQaLbYSpMUZQgaJ_0kjpBTF-_Qjc2oA,24252
|
5
|
+
unitlab/cloudflare_api_tunnel.py,sha256=9HLr4ebwYVv5gsM6gKRyDUSTUoeULt48hmz6ABoP_FA,11093
|
6
|
+
unitlab/exceptions.py,sha256=68Tr6LreEzjQ3Vns8HAaWdtewtkNUJOvPazbf6NSnXU,950
|
7
|
+
unitlab/main.py,sha256=4EM2A3cHVOBe_Bp31zRE-0-WCrqxpIf8mtF5fP-gADw,5252
|
8
|
+
unitlab/tunnel_config.py,sha256=7CiAqasfg26YQfJYXapCBQPSoqw4jIx6yR64saybLLo,8312
|
9
|
+
unitlab/tunnel_service_token.py,sha256=ji96a4s4W2cFJrHZle0zBD85Ac_T862-gCKzBUomrxM,3125
|
10
|
+
unitlab/utils.py,sha256=83ekAxxfXecFTg76Z62BGDybC_skKJHYoLyawCD9wGM,1920
|
11
|
+
unitlab-2.3.5.dist-info/LICENSE.md,sha256=Gn7RRvByorAcAaM-WbyUpsgi5ED1-bKFFshbWfYYz2Y,1069
|
12
|
+
unitlab-2.3.5.dist-info/METADATA,sha256=fj1zH_aN8EHYrIl8QFCvl5_e41JD2HdKHlW5fwpTp3Y,814
|
13
|
+
unitlab-2.3.5.dist-info/WHEEL,sha256=iAkIy5fosb7FzIOwONchHf19Qu7_1wCWyFNR5gu9nU0,91
|
14
|
+
unitlab-2.3.5.dist-info/entry_points.txt,sha256=ig-PjKEqSCj3UTdyANgEi4tsAU84DyXdaOJ02NHX4bY,45
|
15
|
+
unitlab-2.3.5.dist-info/top_level.txt,sha256=Al4ZlTYE3fTJK2o6YLCDMH5_DjuQkffRBMxgmWbKaqQ,8
|
16
|
+
unitlab-2.3.5.dist-info/RECORD,,
|
unitlab-2.3.3.dist-info/RECORD
DELETED
@@ -1,13 +0,0 @@
|
|
1
|
-
unitlab/__init__.py,sha256=Wtk5kQ_MTlxtd3mxJIn2qHVK5URrVcasMMPjD3BtrVM,214
|
2
|
-
unitlab/__main__.py,sha256=6Hs2PV7EYc5Tid4g4OtcLXhqVHiNYTGzSBdoOnW2HXA,29
|
3
|
-
unitlab/client.py,sha256=LGbjto3jisF5LojlYjJyLKRc55wxfZTO_tkoipZBKd4,22910
|
4
|
-
unitlab/exceptions.py,sha256=68Tr6LreEzjQ3Vns8HAaWdtewtkNUJOvPazbf6NSnXU,950
|
5
|
-
unitlab/main.py,sha256=XD8PF_EJ1gAKqHcRT_bWy62LWrODGWMx2roKoaQaoY4,5253
|
6
|
-
unitlab/tunnel_config.py,sha256=lfWXKTPKlhHOxECUA0_HhynrKfOZ345nLo0qA7XtfbE,8571
|
7
|
-
unitlab/utils.py,sha256=83ekAxxfXecFTg76Z62BGDybC_skKJHYoLyawCD9wGM,1920
|
8
|
-
unitlab-2.3.3.dist-info/LICENSE.md,sha256=Gn7RRvByorAcAaM-WbyUpsgi5ED1-bKFFshbWfYYz2Y,1069
|
9
|
-
unitlab-2.3.3.dist-info/METADATA,sha256=pO2UGvjS3FoVgAXZKlHUeBONoWAnLgVkZ2I55UyiSGs,791
|
10
|
-
unitlab-2.3.3.dist-info/WHEEL,sha256=iAkIy5fosb7FzIOwONchHf19Qu7_1wCWyFNR5gu9nU0,91
|
11
|
-
unitlab-2.3.3.dist-info/entry_points.txt,sha256=ig-PjKEqSCj3UTdyANgEi4tsAU84DyXdaOJ02NHX4bY,45
|
12
|
-
unitlab-2.3.3.dist-info/top_level.txt,sha256=Al4ZlTYE3fTJK2o6YLCDMH5_DjuQkffRBMxgmWbKaqQ,8
|
13
|
-
unitlab-2.3.3.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|