unitlab 2.3.33__py3-none-any.whl → 2.3.34__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 +79 -64
- unitlab/main.py +17 -32
- unitlab/persistent_tunnel.py +141 -50
- unitlab/utils.py +2 -0
- {unitlab-2.3.33.dist-info → unitlab-2.3.34.dist-info}/METADATA +12 -3
- unitlab-2.3.34.dist-info/RECORD +13 -0
- {unitlab-2.3.33.dist-info → unitlab-2.3.34.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.34.dist-info}/entry_points.txt +0 -0
- {unitlab-2.3.33.dist-info → unitlab-2.3.34.dist-info/licenses}/LICENSE.md +0 -0
- {unitlab-2.3.33.dist-info → unitlab-2.3.34.dist-info}/top_level.txt +0 -0
unitlab/client.py
CHANGED
@@ -12,12 +12,8 @@ import subprocess
|
|
12
12
|
import signal
|
13
13
|
import re
|
14
14
|
import time
|
15
|
-
import threading
|
16
15
|
import psutil
|
17
16
|
from datetime import datetime, timezone
|
18
|
-
from .tunnel_config import CloudflareTunnel
|
19
|
-
from .cloudflare_api_tunnel import CloudflareAPITunnel
|
20
|
-
from .simple_tunnel import SimpleTunnel
|
21
17
|
from .utils import get_api_url, handle_exceptions
|
22
18
|
from pathlib import Path
|
23
19
|
|
@@ -29,15 +25,6 @@ except ImportError:
|
|
29
25
|
HAS_GPU = False
|
30
26
|
|
31
27
|
|
32
|
-
try:
|
33
|
-
from dotenv import load_dotenv
|
34
|
-
env_path = Path(__file__).parent.parent.parent / '.env'
|
35
|
-
if env_path.exists():
|
36
|
-
load_dotenv(env_path)
|
37
|
-
except ImportError:
|
38
|
-
pass # dotenv not installed, use system env vars only
|
39
|
-
|
40
|
-
|
41
28
|
logger = logging.getLogger(__name__)
|
42
29
|
|
43
30
|
class UnitlabClient:
|
@@ -88,6 +75,7 @@ class UnitlabClient:
|
|
88
75
|
self.hostname = socket.gethostname()
|
89
76
|
self.tunnel_manager = None
|
90
77
|
self.jupyter_url = None
|
78
|
+
self.api_expose_url = None
|
91
79
|
self.ssh_url = None
|
92
80
|
self.jupyter_proc = None
|
93
81
|
self.tunnel_proc = None
|
@@ -289,6 +277,8 @@ class UnitlabClient:
|
|
289
277
|
# Don't call run() here - it has infinite loop. Call start() in setup_tunnels()
|
290
278
|
self.jupyter_url = None
|
291
279
|
self.ssh_url = None
|
280
|
+
self.api_url = None
|
281
|
+
|
292
282
|
|
293
283
|
except ImportError as e:
|
294
284
|
logger.warning(f"Could not import PersistentTunnel: {e}")
|
@@ -399,26 +389,34 @@ class UnitlabClient:
|
|
399
389
|
|
400
390
|
logger.info("Setting up Cloudflare tunnel...")
|
401
391
|
|
402
|
-
|
392
|
+
|
403
393
|
if self.tunnel_manager.start():
|
404
|
-
|
394
|
+
|
405
395
|
self.jupyter_proc = self.tunnel_manager.jupyter_process
|
406
|
-
|
396
|
+
|
407
397
|
self.jupyter_url = self.tunnel_manager.jupyter_url
|
408
|
-
self.
|
398
|
+
self.api_expose_url = self.tunnel_manager.api_expose_url
|
409
399
|
logger.info(f"Tunnel started successfully at {self.jupyter_url}")
|
410
400
|
self.tunnel_proc = self.tunnel_manager.tunnel_process
|
411
401
|
self.jupyter_port = "8888" # Both use fixed port
|
412
402
|
|
413
|
-
|
403
|
+
|
404
|
+
if hasattr(self.tunnel_manager, 'ssh_url'):
|
405
|
+
self.ssh_url = self.tunnel_manager.ssh_url
|
406
|
+
else:
|
407
|
+
|
408
|
+
self.ssh_url = self.jupyter_url
|
409
|
+
|
410
|
+
|
414
411
|
if hasattr(self.tunnel_manager, 'tunnel_url') and self.tunnel_manager.tunnel_url:
|
415
412
|
self.jupyter_url = self.tunnel_manager.tunnel_url
|
416
|
-
|
413
|
+
if not hasattr(self.tunnel_manager, 'ssh_url'):
|
414
|
+
self.ssh_url = self.tunnel_manager.tunnel_url
|
417
415
|
elif hasattr(self.tunnel_manager, 'jupyter_url'):
|
418
416
|
self.jupyter_url = self.tunnel_manager.jupyter_url
|
419
|
-
|
417
|
+
if not hasattr(self.tunnel_manager, 'ssh_url'):
|
418
|
+
self.ssh_url = self.tunnel_manager.jupyter_url
|
420
419
|
|
421
|
-
# The tunnel is now running
|
422
420
|
logger.info("✅ Tunnel and Jupyter established")
|
423
421
|
logger.info("URL: {}".format(self.jupyter_url))
|
424
422
|
self.report_services()
|
@@ -470,12 +468,15 @@ class UnitlabClient:
|
|
470
468
|
|
471
469
|
logger.info(f"Reporting Jupyter service with URL: {self.jupyter_url}")
|
472
470
|
logger.debug(f"API key present: {bool(self.api_key)}")
|
471
|
+
|
473
472
|
if self.api_key:
|
474
473
|
logger.debug(f"API key value: {self.api_key[:8]}...")
|
474
|
+
|
475
475
|
jupyter_response = self._post_device(
|
476
476
|
f"/api/tunnel/agent/jupyter/{self.device_id}/",
|
477
477
|
jupyter_data
|
478
478
|
)
|
479
|
+
|
479
480
|
logger.info(f"Reported Jupyter service: {jupyter_response.status_code if hasattr(jupyter_response, 'status_code') else jupyter_response}")
|
480
481
|
|
481
482
|
# Report SSH service (always report, even if SSH is not running locally)
|
@@ -506,7 +507,22 @@ class UnitlabClient:
|
|
506
507
|
ssh_data
|
507
508
|
)
|
508
509
|
logger.info(f"Reported SSH service: {ssh_response.status_code if hasattr(ssh_response, 'status_code') else ssh_response}")
|
510
|
+
logger.info("Reporting API endpoint:")
|
509
511
|
|
512
|
+
api_expose_data = {
|
513
|
+
"service_type": "api",
|
514
|
+
"service_name": f"api-{self.device_id}",
|
515
|
+
'local_port': None,
|
516
|
+
'tunnel_url': self.api_expose_url,
|
517
|
+
'status': 'online'
|
518
|
+
}
|
519
|
+
|
520
|
+
api_expose_response = self._post_device(
|
521
|
+
f"/api/tunnel/agent/api-url/{self.device_id}/",
|
522
|
+
api_expose_data
|
523
|
+
)
|
524
|
+
logger.info(f"Reported Api service: {api_expose_response.status_code if hasattr(api_expose_response, 'status_code') else api_expose_response}")
|
525
|
+
|
510
526
|
except Exception as e:
|
511
527
|
logger.error(f"Failed to report services: {e}", exc_info=True)
|
512
528
|
|
@@ -550,55 +566,55 @@ class UnitlabClient:
|
|
550
566
|
|
551
567
|
return metrics
|
552
568
|
|
553
|
-
def send_metrics(self):
|
554
|
-
|
555
|
-
|
556
|
-
|
569
|
+
# def send_metrics(self):
|
570
|
+
# """Send metrics to server"""
|
571
|
+
# try:
|
572
|
+
# metrics = self.collect_metrics()
|
557
573
|
|
558
|
-
|
559
|
-
|
560
|
-
|
574
|
+
# # Send CPU metrics
|
575
|
+
# if 'cpu' in metrics:
|
576
|
+
# self._post_device(f"/api/tunnel/agent/cpu/{self.device_id}/", metrics['cpu'])
|
561
577
|
|
562
|
-
|
563
|
-
|
564
|
-
|
578
|
+
# # Send RAM metrics
|
579
|
+
# if 'ram' in metrics:
|
580
|
+
# self._post_device(f"/api/tunnel/agent/ram/{self.device_id}/", metrics['ram'])
|
565
581
|
|
566
|
-
|
567
|
-
|
568
|
-
|
582
|
+
# # Send GPU metrics if available
|
583
|
+
# if 'gpu' in metrics and metrics['gpu']:
|
584
|
+
# self._post_device(f"/api/tunnel/agent/gpu/{self.device_id}/", metrics['gpu'])
|
569
585
|
|
570
|
-
|
586
|
+
# logger.debug(f"Metrics sent - CPU: {metrics['cpu']['percent']:.1f}%, RAM: {metrics['ram']['percent']:.1f}%")
|
571
587
|
|
572
|
-
|
573
|
-
|
588
|
+
# except Exception as e:
|
589
|
+
# logger.error(f"Failed to send metrics: {e}")
|
574
590
|
|
575
|
-
def metrics_loop(self):
|
576
|
-
|
577
|
-
|
591
|
+
# def metrics_loop(self):
|
592
|
+
# """Background thread for sending metrics"""
|
593
|
+
# logger.info("Starting metrics thread")
|
578
594
|
|
579
|
-
|
580
|
-
|
581
|
-
|
595
|
+
# while self.running:
|
596
|
+
# try:
|
597
|
+
# self.send_metrics()
|
582
598
|
|
583
|
-
|
584
|
-
|
585
|
-
|
586
|
-
|
599
|
+
# # Check if processes are still running
|
600
|
+
# if self.jupyter_proc and self.jupyter_proc.poll() is not None:
|
601
|
+
# logger.warning("Jupyter process died")
|
602
|
+
# self.jupyter_proc = None
|
587
603
|
|
588
|
-
|
589
|
-
|
590
|
-
|
604
|
+
# if self.tunnel_proc and self.tunnel_proc.poll() is not None:
|
605
|
+
# logger.warning("Tunnel process died")
|
606
|
+
# self.tunnel_proc = None
|
591
607
|
|
592
|
-
|
593
|
-
|
608
|
+
# except Exception as e:
|
609
|
+
# logger.error(f"Metrics loop error: {e}")
|
594
610
|
|
595
|
-
|
596
|
-
|
597
|
-
|
598
|
-
|
599
|
-
|
611
|
+
# # Wait for next interval (default 5 seconds)
|
612
|
+
# for _ in range(3):
|
613
|
+
# if not self.running:
|
614
|
+
# break
|
615
|
+
# time.sleep(1)
|
600
616
|
|
601
|
-
|
617
|
+
# logger.info("Metrics thread stopped")
|
602
618
|
|
603
619
|
def run_device_agent(self):
|
604
620
|
"""Main run method for device agent"""
|
@@ -612,17 +628,16 @@ class UnitlabClient:
|
|
612
628
|
# Check SSH
|
613
629
|
self.check_ssh()
|
614
630
|
|
615
|
-
|
616
|
-
# Just setup the tunnels which will also start Jupyter
|
631
|
+
|
617
632
|
logger.info("Starting integrated Jupyter and tunnel...")
|
618
633
|
|
619
|
-
|
634
|
+
|
620
635
|
if not self.setup_tunnels():
|
621
636
|
logger.error("Failed to setup tunnels")
|
622
637
|
self.cleanup_device_agent()
|
623
638
|
return
|
624
639
|
|
625
|
-
|
640
|
+
|
626
641
|
logger.info("=" * 50)
|
627
642
|
logger.info("🎉 All services started successfully!")
|
628
643
|
logger.info(f"📔 Jupyter: {self.jupyter_url}")
|
@@ -635,8 +650,8 @@ class UnitlabClient:
|
|
635
650
|
logger.info("=" * 50)
|
636
651
|
|
637
652
|
# Start metrics thread
|
638
|
-
self.metrics_thread = threading.Thread(target=self.metrics_loop, daemon=True)
|
639
|
-
self.metrics_thread.start()
|
653
|
+
# self.metrics_thread = threading.Thread(target=self.metrics_loop, daemon=True)
|
654
|
+
# self.metrics_thread.start()
|
640
655
|
|
641
656
|
# Main loop
|
642
657
|
try:
|
unitlab/main.py
CHANGED
@@ -2,16 +2,21 @@ from enum import Enum
|
|
2
2
|
from pathlib import Path
|
3
3
|
from uuid import UUID
|
4
4
|
import logging
|
5
|
-
import
|
5
|
+
import threading
|
6
6
|
|
7
7
|
import typer
|
8
8
|
import validators
|
9
9
|
from typing_extensions import Annotated
|
10
|
+
import psutil
|
11
|
+
import uvicorn
|
12
|
+
from fastapi import FastAPI,Response
|
13
|
+
|
10
14
|
|
11
15
|
from . import utils
|
12
16
|
from .client import UnitlabClient
|
13
17
|
|
14
18
|
|
19
|
+
|
15
20
|
app = typer.Typer()
|
16
21
|
project_app = typer.Typer()
|
17
22
|
dataset_app = typer.Typer()
|
@@ -45,6 +50,8 @@ class AnnotationType(str, Enum):
|
|
45
50
|
IMG_POINT = "img_point"
|
46
51
|
|
47
52
|
|
53
|
+
|
54
|
+
|
48
55
|
@app.command(help="Configure the credentials")
|
49
56
|
def configure(
|
50
57
|
api_key: Annotated[str, typer.Option(help="The api-key obtained from unitlab.ai")],
|
@@ -111,18 +118,6 @@ def dataset_download(
|
|
111
118
|
get_client(api_key).dataset_download_files(pk)
|
112
119
|
|
113
120
|
|
114
|
-
def send_metrics_to_server(server_url: str, device_id: str, metrics: dict):
|
115
|
-
"""Standalone function to send metrics to server using client"""
|
116
|
-
client = UnitlabClient(api_key="dummy") # API key not needed for metrics
|
117
|
-
return client.send_metrics_to_server(server_url, device_id, metrics)
|
118
|
-
|
119
|
-
|
120
|
-
def send_metrics_into_server():
|
121
|
-
"""Standalone function to collect system metrics using client"""
|
122
|
-
client = UnitlabClient(api_key="dummy") # API key not needed for metrics
|
123
|
-
return client.collect_system_metrics()
|
124
|
-
|
125
|
-
|
126
121
|
@agent_app.command(name="run", help="Run the device agent with Jupyter, SSH tunnels and metrics")
|
127
122
|
def run_agent(
|
128
123
|
api_key: API_KEY,
|
@@ -130,42 +125,32 @@ def run_agent(
|
|
130
125
|
base_domain: Annotated[str, typer.Option(help="Base domain for tunnels")] = "1scan.uz",
|
131
126
|
|
132
127
|
):
|
133
|
-
|
134
|
-
|
135
|
-
# Setup logging
|
128
|
+
|
136
129
|
logging.basicConfig(
|
137
130
|
level=logging.INFO,
|
138
131
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
139
132
|
handlers=[logging.StreamHandler()]
|
140
133
|
)
|
141
134
|
|
142
|
-
|
143
|
-
server_url = 'https://api-dev.unitlab.ai/'
|
135
|
+
server_url = 'http://localhost:8000/'
|
144
136
|
|
145
|
-
# Generate unique device ID if not provided
|
146
137
|
if not device_id:
|
147
138
|
import uuid
|
148
139
|
import platform
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
device_id =
|
153
|
-
|
154
|
-
# Always generate a unique device ID (no saving/reusing)
|
155
|
-
hostname = platform.node().replace('.', '-').replace(' ', '-')[:20]
|
156
|
-
random_suffix = str(uuid.uuid4())[:8]
|
157
|
-
device_id = f"{hostname}-{random_suffix}"
|
158
|
-
print(f"📝 Generated unique device ID: {device_id}")
|
140
|
+
|
141
|
+
hostname = platform.node().replace('.', '-').replace(' ', '-')[:20]
|
142
|
+
random_suffix = str(uuid.uuid4())[:8]
|
143
|
+
device_id = f"{hostname}-{random_suffix}"
|
144
|
+
print(f"📝 Generated unique device ID: {device_id}")
|
159
145
|
|
160
|
-
|
161
|
-
# Create client and initialize device agent
|
146
|
+
|
162
147
|
client = UnitlabClient(api_key=api_key)
|
163
148
|
client.initialize_device_agent(
|
164
149
|
server_url=server_url,
|
165
150
|
device_id=device_id,
|
166
151
|
base_domain=base_domain
|
167
152
|
)
|
168
|
-
|
153
|
+
|
169
154
|
try:
|
170
155
|
client.run_device_agent()
|
171
156
|
except Exception as e:
|
unitlab/persistent_tunnel.py
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
2
|
"""
|
3
|
-
Persistent Tunnel - Each device gets deviceid.
|
3
|
+
Persistent Tunnel - Each device gets deviceid.unitlab-ai.com
|
4
4
|
Uses Cloudflare API to create named tunnels
|
5
5
|
"""
|
6
6
|
|
@@ -10,18 +10,32 @@ import json
|
|
10
10
|
import time
|
11
11
|
import os
|
12
12
|
import base64
|
13
|
+
from fastapi import FastAPI
|
14
|
+
import uvicorn
|
15
|
+
import threading
|
16
|
+
import psutil
|
17
|
+
|
18
|
+
|
19
|
+
api = FastAPI()
|
20
|
+
|
21
|
+
@api.get("/api-agent/")
|
22
|
+
def get_cpu_info():
|
23
|
+
cpu_usage_percent = psutil.cpu_percent(interval=1)
|
24
|
+
ram = psutil.virtual_memory()
|
25
|
+
return {"cpu_percentage": cpu_usage_percent, 'cpu_count': psutil.cpu_count(), 'ram_usage': ram.used }
|
26
|
+
|
13
27
|
|
14
28
|
class PersistentTunnel:
|
15
29
|
def __init__(self, device_id=None):
|
16
30
|
"""Initialize with device ID"""
|
17
31
|
|
18
32
|
# Cloudflare credentials (hardcoded for simplicity)
|
19
|
-
|
20
|
-
self.cf_api_key = "
|
33
|
+
|
34
|
+
self.cf_api_key = "RoIAn1t9rMqcGK7_Xja216pxbRTyFafC1jeRKIO3"
|
21
35
|
|
22
36
|
# Account and Zone IDs
|
23
|
-
self.cf_account_id = "
|
24
|
-
self.cf_zone_id = "
|
37
|
+
self.cf_account_id = "29df28cf48a30be3b1aa344b840400e6" # Your account ID
|
38
|
+
self.cf_zone_id = "eae80a730730b3b218a80dace996535a" # Zone ID for unitlab-ai.com
|
25
39
|
|
26
40
|
# Clean device ID for subdomain
|
27
41
|
if device_id:
|
@@ -32,16 +46,19 @@ class PersistentTunnel:
|
|
32
46
|
|
33
47
|
self.tunnel_name = "agent-{}".format(self.device_id)
|
34
48
|
self.subdomain = self.device_id
|
35
|
-
self.domain = "
|
49
|
+
self.domain = "unitlab-ai.com"
|
36
50
|
self.jupyter_url = "https://{}.{}".format(self.subdomain, self.domain)
|
37
|
-
|
51
|
+
self.api_expose_url = "https://{}.{}/api-agent/".format(self.subdomain, self.domain)
|
52
|
+
self.ssh_subdomain = "s{}".format(self.device_id) # Shorter SSH subdomain to avoid length issues
|
53
|
+
self.ssh_url = "{}.{}".format(self.ssh_subdomain, self.domain) # SSH on s{deviceid}.unitlab-ai.com
|
54
|
+
|
38
55
|
self.tunnel_id = None
|
39
56
|
self.tunnel_credentials = None
|
40
57
|
self.jupyter_process = None
|
41
58
|
self.tunnel_process = None
|
42
59
|
|
43
60
|
def get_zone_id(self):
|
44
|
-
"""Get Zone ID for
|
61
|
+
"""Get Zone ID for unitlab-ai.com"""
|
45
62
|
print("🔍 Getting Zone ID for {}...".format(self.domain))
|
46
63
|
|
47
64
|
url = "https://api.cloudflare.com/client/v4/zones"
|
@@ -61,11 +78,12 @@ class PersistentTunnel:
|
|
61
78
|
|
62
79
|
def _get_headers(self):
|
63
80
|
"""Get API headers for Global API Key"""
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
"
|
68
|
-
|
81
|
+
|
82
|
+
|
83
|
+
return {
|
84
|
+
"Authorization": f"Bearer {self.cf_api_key}",
|
85
|
+
"Content-Type": "application/json"
|
86
|
+
}
|
69
87
|
|
70
88
|
def get_or_create_tunnel(self):
|
71
89
|
"""Get existing tunnel or create a new one"""
|
@@ -133,15 +151,15 @@ class PersistentTunnel:
|
|
133
151
|
print("✅ Tunnel created: {}".format(self.tunnel_id))
|
134
152
|
return cred_file
|
135
153
|
else:
|
136
|
-
print("❌ Failed to create tunnel: {}".format(response.text
|
154
|
+
print("❌ Failed to create tunnel: {}".format(response.text))
|
137
155
|
return None
|
138
156
|
|
139
157
|
def create_dns_record(self):
|
140
|
-
"""Create DNS CNAME
|
158
|
+
"""Create DNS CNAME records for main domain and SSH subdomain"""
|
141
159
|
if not self.tunnel_id:
|
142
160
|
return False
|
143
161
|
|
144
|
-
print("🔧 Creating DNS
|
162
|
+
print("🔧 Creating DNS records...")
|
145
163
|
|
146
164
|
# Get zone ID if we don't have it
|
147
165
|
if self.cf_zone_id == "NEED_ZONE_ID_FOR_1SCAN_UZ":
|
@@ -150,6 +168,7 @@ class PersistentTunnel:
|
|
150
168
|
url = "https://api.cloudflare.com/client/v4/zones/{}/dns_records".format(self.cf_zone_id)
|
151
169
|
headers = self._get_headers()
|
152
170
|
|
171
|
+
# Create main subdomain record for Jupyter and API
|
153
172
|
data = {
|
154
173
|
"type": "CNAME",
|
155
174
|
"name": self.subdomain,
|
@@ -161,14 +180,33 @@ class PersistentTunnel:
|
|
161
180
|
response = requests.post(url, headers=headers, json=data)
|
162
181
|
|
163
182
|
if response.status_code in [200, 201]:
|
164
|
-
print("✅ DNS record created")
|
165
|
-
return True
|
183
|
+
print("✅ Main DNS record created: {}.{}".format(self.subdomain, self.domain))
|
166
184
|
elif "already exists" in response.text:
|
167
|
-
print("⚠️ DNS record already exists")
|
168
|
-
return True
|
185
|
+
print("⚠️ Main DNS record already exists: {}.{}".format(self.subdomain, self.domain))
|
169
186
|
else:
|
170
|
-
print("❌ Failed to create DNS: {}".format(response.text[:200]))
|
187
|
+
print("❌ Failed to create main DNS: {}".format(response.text[:200]))
|
171
188
|
return False
|
189
|
+
|
190
|
+
# Create SSH subdomain record (s{deviceid}.unitlab-ai.com)
|
191
|
+
ssh_data = {
|
192
|
+
"type": "CNAME",
|
193
|
+
"name": self.ssh_subdomain,
|
194
|
+
"content": "{}.cfargotunnel.com".format(self.tunnel_id),
|
195
|
+
"proxied": True,
|
196
|
+
"ttl": 1
|
197
|
+
}
|
198
|
+
|
199
|
+
ssh_response = requests.post(url, headers=headers, json=ssh_data)
|
200
|
+
|
201
|
+
if ssh_response.status_code in [200, 201]:
|
202
|
+
print("✅ SSH DNS record created: {}.{}".format(self.ssh_subdomain, self.domain))
|
203
|
+
elif "already exists" in ssh_response.text:
|
204
|
+
print("⚠️ SSH DNS record already exists: {}.{}".format(self.ssh_subdomain, self.domain))
|
205
|
+
else:
|
206
|
+
print("⚠️ Could not create SSH DNS: {}".format(ssh_response.text[:200]))
|
207
|
+
# SSH is optional, so we continue even if SSH DNS fails
|
208
|
+
|
209
|
+
return True
|
172
210
|
|
173
211
|
def create_tunnel_config(self, cred_file):
|
174
212
|
"""Create tunnel config file"""
|
@@ -177,11 +215,24 @@ class PersistentTunnel:
|
|
177
215
|
f.write("tunnel: {}\n".format(self.tunnel_id))
|
178
216
|
f.write("credentials-file: {}\n\n".format(cred_file))
|
179
217
|
f.write("ingress:\n")
|
218
|
+
|
219
|
+
# SSH service on dedicated subdomain (s{deviceid}.unitlab-ai.com)
|
220
|
+
f.write(" - hostname: {}.{}\n".format(self.ssh_subdomain, self.domain))
|
221
|
+
f.write(" service: ssh://localhost:22\n")
|
222
|
+
|
223
|
+
# API (more specific path goes first)
|
224
|
+
f.write(" - hostname: {}.{}\n".format(self.subdomain, self.domain))
|
225
|
+
f.write(" path: /api-agent/*\n")
|
226
|
+
f.write(" service: http://localhost:8001\n")
|
227
|
+
|
228
|
+
# Jupyter (general hostname for HTTP)
|
180
229
|
f.write(" - hostname: {}.{}\n".format(self.subdomain, self.domain))
|
181
230
|
f.write(" service: http://localhost:8888\n")
|
231
|
+
|
232
|
+
# Catch-all 404 (MUST be last!)
|
182
233
|
f.write(" - service: http_status:404\n")
|
183
|
-
|
184
|
-
|
234
|
+
return config_file
|
235
|
+
|
185
236
|
|
186
237
|
def get_cloudflared_path(self):
|
187
238
|
"""Get or download cloudflared for any platform"""
|
@@ -255,6 +306,9 @@ class PersistentTunnel:
|
|
255
306
|
print("✅ cloudflared downloaded successfully")
|
256
307
|
return local_bin
|
257
308
|
|
309
|
+
|
310
|
+
|
311
|
+
|
258
312
|
def start_jupyter(self):
|
259
313
|
"""Start Jupyter"""
|
260
314
|
print("🚀 Starting Jupyter...")
|
@@ -274,31 +328,59 @@ class PersistentTunnel:
|
|
274
328
|
self.jupyter_process = subprocess.Popen(
|
275
329
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
276
330
|
)
|
277
|
-
|
331
|
+
|
278
332
|
time.sleep(3)
|
279
333
|
print("✅ Jupyter started")
|
280
334
|
return True
|
281
335
|
|
282
|
-
def
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
)
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
336
|
+
def start_api(self):
|
337
|
+
def run_api():
|
338
|
+
uvicorn.run(
|
339
|
+
api,
|
340
|
+
port=8001
|
341
|
+
)
|
342
|
+
|
343
|
+
api_thread = threading.Thread(target=run_api, daemon=True)
|
344
|
+
api_thread.start()
|
345
|
+
print('API is started')
|
346
|
+
|
347
|
+
def start_tunnel(self, config_file):
|
348
|
+
"""Start tunnel with config"""
|
349
|
+
print("🔧 Starting tunnel...")
|
350
|
+
|
351
|
+
cloudflared = self.get_cloudflared_path()
|
352
|
+
|
353
|
+
cmd = [
|
354
|
+
cloudflared,
|
355
|
+
"tunnel",
|
356
|
+
"--config", config_file,
|
357
|
+
"run"
|
358
|
+
]
|
359
|
+
|
360
|
+
self.tunnel_process = subprocess.Popen(
|
361
|
+
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
362
|
+
)
|
363
|
+
|
364
|
+
time.sleep(2)
|
365
|
+
|
366
|
+
# Check if process is still running
|
367
|
+
if self.tunnel_process.poll() is not None:
|
368
|
+
print("❌ Tunnel process died!")
|
369
|
+
# Try to read error output
|
370
|
+
try:
|
371
|
+
stdout, stderr = self.tunnel_process.communicate(timeout=1)
|
372
|
+
if stderr:
|
373
|
+
print(f"Error: {stderr.decode()}")
|
374
|
+
if stdout:
|
375
|
+
print(f"Output: {stdout.decode()}")
|
376
|
+
except:
|
377
|
+
pass
|
378
|
+
return False
|
379
|
+
|
380
|
+
print("✅ Tunnel running at {}".format(self.jupyter_url))
|
381
|
+
print("✅ API running at {}".format(self.api_expose_url))
|
382
|
+
print("✅ SSH available at {}".format(self.ssh_url))
|
383
|
+
return True
|
302
384
|
|
303
385
|
def start(self):
|
304
386
|
"""Main entry point"""
|
@@ -313,9 +395,7 @@ class PersistentTunnel:
|
|
313
395
|
|
314
396
|
# 1. Get existing or create new tunnel via API
|
315
397
|
cred_file = self.get_or_create_tunnel()
|
316
|
-
|
317
|
-
print("⚠️ Falling back to quick tunnel")
|
318
|
-
return self.start_quick_tunnel()
|
398
|
+
|
319
399
|
|
320
400
|
# 2. Create DNS record
|
321
401
|
self.create_dns_record()
|
@@ -325,13 +405,24 @@ class PersistentTunnel:
|
|
325
405
|
|
326
406
|
# 4. Start services
|
327
407
|
self.start_jupyter()
|
408
|
+
self.start_api()
|
328
409
|
self.start_tunnel(config_file)
|
329
410
|
|
330
411
|
print("\n" + "="*50)
|
331
|
-
print("🎉 SUCCESS! Persistent
|
332
|
-
print(" {}".format(self.jupyter_url))
|
333
|
-
print("
|
412
|
+
print("🎉 SUCCESS! Persistent URLs created:")
|
413
|
+
print("📔 Jupyter: {}".format(self.jupyter_url))
|
414
|
+
print("🔧 API: {}".format(self.api_expose_url))
|
415
|
+
print("🔐 SSH: {}".format(self.ssh_url))
|
416
|
+
print("")
|
417
|
+
print("SSH Connection Command:")
|
418
|
+
import getpass
|
419
|
+
current_user = getpass.getuser()
|
420
|
+
print("ssh -o ProxyCommand='cloudflared access ssh --hostname {}' {}@{}".format(
|
421
|
+
self.ssh_url, current_user, self.ssh_url))
|
422
|
+
print("")
|
423
|
+
print("Tunnel ID: {}".format(self.tunnel_id))
|
334
424
|
print("="*50)
|
425
|
+
|
335
426
|
|
336
427
|
return True
|
337
428
|
|