unitlab 2.3.3__tar.gz → 2.3.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: unitlab
3
- Version: 2.3.3
3
+ Version: 2.3.4
4
4
  Home-page: https://github.com/teamunitlab/unitlab-sdk
5
5
  Author: Unitlab Inc.
6
6
  Author-email: team@unitlab.ai
@@ -23,3 +23,4 @@ Requires-Dist: typer
23
23
  Requires-Dist: validators
24
24
  Requires-Dist: psutil
25
25
  Requires-Dist: pyyaml
26
+ Requires-Dist: jupyter
@@ -2,7 +2,7 @@ from setuptools import find_packages, setup
2
2
 
3
3
  setup(
4
4
  name="unitlab",
5
- version="2.3.3",
5
+ version="2.3.4",
6
6
  license="MIT",
7
7
  author="Unitlab Inc.",
8
8
  author_email="team@unitlab.ai",
@@ -31,6 +31,8 @@ setup(
31
31
  "validators",
32
32
  'psutil',
33
33
  'pyyaml',
34
+ 'jupyter',
35
+
34
36
  ],
35
37
  entry_points={
36
38
  "console_scripts": ["unitlab=unitlab.main:app"],
@@ -0,0 +1,199 @@
1
+ """
2
+ Cloudflare Tunnel Configuration using Service Token
3
+ No user login required - uses pre-generated service token
4
+ """
5
+
6
+ import os
7
+ import subprocess
8
+ import socket
9
+ import time
10
+ import logging
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class CloudflareTunnel:
16
+ def __init__(self, base_domain, device_id): # base_domain kept for compatibility
17
+ """
18
+ Initialize tunnel with service token
19
+ No login or credential files needed
20
+ """
21
+ # Configuration
22
+ self.base_domain = "1scan.uz" # Hardcoded domain
23
+ self.device_id = device_id
24
+ self.hostname = socket.gethostname()
25
+
26
+ # Service token - replace with your actual token from cloudflared tunnel token command
27
+ # This token can ONLY run the tunnel, cannot modify or delete it
28
+ # To generate: cloudflared tunnel token unitlab-shared
29
+ self.service_token = os.getenv(
30
+ "CLOUDFLARE_TUNNEL_TOKEN",
31
+ "eyJhIjoiYzkxMTkyYWUyMGE1ZDQzZjY1ZTA4NzU1MGQ4ZGM4OWIiLCJzIjoiZmdnSHowbFJFRnBHa05TZzIzV3JKMVBiaDVROGVUd0oyYWtJWThXdjhtTT0iLCJ0IjoiYjMzZGFhOGYtMmNjMy00Y2FkLWEyMjgtOTdlMDYwNzBlNjAwIn0=" # Replace this with your actual token from step 1
32
+ )
33
+
34
+ if self.service_token == "YOUR_SERVICE_TOKEN_HERE":
35
+ logger.warning(
36
+ "⚠️ No service token configured. "
37
+ "Set CLOUDFLARE_TUNNEL_TOKEN env var or update the token in tunnel_config.py"
38
+ )
39
+
40
+ # Subdomain names
41
+ self.jupyter_subdomain = f"jupyter-{device_id}"
42
+ self.ssh_subdomain = f"ssh-{device_id}"
43
+
44
+ # Full URLs
45
+ self.jupyter_url = f"https://{self.jupyter_subdomain}.{self.base_domain}"
46
+ self.ssh_url = f"https://{self.ssh_subdomain}.{self.base_domain}"
47
+
48
+ self.tunnel_process = None
49
+
50
+ def check_cloudflared_installed(self):
51
+ """Check if cloudflared is installed"""
52
+ try:
53
+ result = subprocess.run(
54
+ ["cloudflared", "--version"],
55
+ capture_output=True,
56
+ text=True
57
+ )
58
+ return result.returncode == 0
59
+ except FileNotFoundError:
60
+ return False
61
+
62
+ def install_cloudflared(self):
63
+ """Auto-install cloudflared if not present"""
64
+ import platform
65
+
66
+ system = platform.system().lower()
67
+ machine = platform.machine().lower()
68
+
69
+ print("📦 Installing cloudflared...")
70
+
71
+ try:
72
+ if system == "linux":
73
+ # Determine architecture
74
+ if machine in ["x86_64", "amd64"]:
75
+ arch = "amd64"
76
+ elif machine in ["aarch64", "arm64"]:
77
+ arch = "arm64"
78
+ else:
79
+ arch = "386"
80
+
81
+ # Download and install
82
+ url = f"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-{arch}"
83
+
84
+ commands = [
85
+ f"curl -L {url} -o /tmp/cloudflared",
86
+ "chmod +x /tmp/cloudflared",
87
+ "sudo mv /tmp/cloudflared /usr/local/bin/cloudflared 2>/dev/null || "
88
+ "mkdir -p ~/.local/bin && mv /tmp/cloudflared ~/.local/bin/cloudflared"
89
+ ]
90
+
91
+ for cmd in commands:
92
+ subprocess.run(cmd, shell=True, check=False)
93
+
94
+ # Add ~/.local/bin to PATH if needed
95
+ local_bin = os.path.expanduser("~/.local/bin")
96
+ if local_bin not in os.environ.get("PATH", ""):
97
+ os.environ["PATH"] = f"{local_bin}:{os.environ['PATH']}"
98
+
99
+ return self.check_cloudflared_installed()
100
+
101
+ elif system == "darwin":
102
+ # Try homebrew first
103
+ result = subprocess.run(["brew", "install", "cloudflared"], capture_output=True)
104
+ if result.returncode != 0:
105
+ # Fallback to direct download
106
+ url = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz"
107
+ subprocess.run(f"curl -L {url} | tar xz", shell=True)
108
+ subprocess.run("sudo mv cloudflared /usr/local/bin/", shell=True)
109
+
110
+ return self.check_cloudflared_installed()
111
+
112
+ elif system == "windows":
113
+ print("⚠️ Please install cloudflared manually on Windows")
114
+ print(" Download from: https://github.com/cloudflare/cloudflared/releases")
115
+ return False
116
+
117
+ except Exception as e:
118
+ logger.error(f"Failed to install cloudflared: {e}")
119
+ return False
120
+
121
+ def setup(self, jupyter_port): # jupyter_port kept for compatibility
122
+ """
123
+ Setup and start tunnel with service token
124
+ No login required!
125
+ """
126
+ print("🚀 Setting up Cloudflare tunnel with service token...")
127
+
128
+ # Check if cloudflared is installed
129
+ if not self.check_cloudflared_installed():
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
135
+
136
+ # Start tunnel with service token
137
+ return self.start_tunnel_with_token()
138
+
139
+ def start_tunnel_with_token(self):
140
+ """
141
+ Start the tunnel using service token
142
+ This is much simpler than the credential file approach
143
+ """
144
+ try:
145
+ print("🚀 Starting Cloudflare tunnel...")
146
+
147
+ # Simple command with service token
148
+ cmd = [
149
+ "cloudflared",
150
+ "tunnel",
151
+ "--no-autoupdate", # Prevent auto-updates during run
152
+ "run",
153
+ "--token",
154
+ self.service_token
155
+ ]
156
+
157
+ # Start the tunnel process
158
+ self.tunnel_process = subprocess.Popen(
159
+ cmd,
160
+ stdout=subprocess.PIPE,
161
+ stderr=subprocess.STDOUT,
162
+ text=True,
163
+ bufsize=1
164
+ )
165
+
166
+ # Wait for tunnel to establish
167
+ print("⏳ Waiting for tunnel to connect...")
168
+ time.sleep(5)
169
+
170
+ # Check if process is still running
171
+ if self.tunnel_process.poll() is None:
172
+ print("✅ Tunnel is running!")
173
+ print(f"📌 Jupyter URL: {self.jupyter_url}")
174
+ print(f"📌 SSH URL: {self.ssh_url}")
175
+ return self.tunnel_process
176
+ else:
177
+ # Read any error output
178
+ output = self.tunnel_process.stdout.read()
179
+ print("❌ Tunnel failed to start")
180
+ print(f"Error output: {output}")
181
+ return None
182
+
183
+ except Exception as e:
184
+ print(f"❌ Error starting tunnel: {e}")
185
+ return None
186
+
187
+ def stop(self):
188
+ """Stop the tunnel if running"""
189
+ if self.tunnel_process and self.tunnel_process.poll() is None:
190
+ print("Stopping tunnel...")
191
+ self.tunnel_process.terminate()
192
+ self.tunnel_process.wait(timeout=5)
193
+ print("Tunnel stopped")
194
+
195
+ # Removed all the old methods that are no longer needed:
196
+ # - login() - not needed with service token
197
+ # - create_tunnel() - tunnel already exists
198
+ # - configure_dns() - already configured
199
+ # - 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
3
+ Version: 2.3.4
4
4
  Home-page: https://github.com/teamunitlab/unitlab-sdk
5
5
  Author: Unitlab Inc.
6
6
  Author-email: team@unitlab.ai
@@ -23,3 +23,4 @@ Requires-Dist: typer
23
23
  Requires-Dist: validators
24
24
  Requires-Dist: psutil
25
25
  Requires-Dist: pyyaml
26
+ Requires-Dist: jupyter
@@ -8,6 +8,7 @@ src/unitlab/client.py
8
8
  src/unitlab/exceptions.py
9
9
  src/unitlab/main.py
10
10
  src/unitlab/tunnel_config.py
11
+ src/unitlab/tunnel_service_token.py
11
12
  src/unitlab/utils.py
12
13
  src/unitlab.egg-info/PKG-INFO
13
14
  src/unitlab.egg-info/SOURCES.txt
@@ -6,3 +6,4 @@ typer
6
6
  validators
7
7
  psutil
8
8
  pyyaml
9
+ jupyter
@@ -1,238 +0,0 @@
1
- """
2
- Cloudflare Tunnel Configuration for persistent subdomains
3
- """
4
-
5
- import json
6
- import subprocess
7
- import socket
8
- import time
9
- import yaml
10
- from pathlib import Path
11
-
12
-
13
- class CloudflareTunnel:
14
- def __init__(self, base_domain, device_id):
15
- # Hardcode the base domain here
16
- self.base_domain = "1scan.uz" # HARDCODED - ignore the passed base_domain
17
- self.device_id = device_id
18
- 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
-
23
- # Subdomain names
24
- self.jupyter_subdomain = f"jupyter-{device_id}"
25
- self.ssh_subdomain = f"ssh-{device_id}"
26
-
27
- # Full URLs - using hardcoded base_domain
28
- self.jupyter_url = f"https://{self.jupyter_subdomain}.{self.base_domain}"
29
- self.ssh_url = f"https://{self.ssh_subdomain}.{self.base_domain}"
30
-
31
- self.tunnel_uuid = None
32
- self.credentials_file = None
33
-
34
- def login(self):
35
- """Login to Cloudflare (one-time setup)"""
36
- try:
37
- print("🔐 Checking Cloudflare authentication...")
38
- result = subprocess.run(
39
- ["cloudflared", "tunnel", "login"],
40
- capture_output=True,
41
- text=True
42
- )
43
- if result.returncode == 0:
44
- print("✅ Cloudflare authentication successful")
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}")
51
- return False
52
-
53
- def create_tunnel(self):
54
- """Create a named tunnel"""
55
- try:
56
- print(f"🚇 Creating tunnel: {self.tunnel_name}")
57
-
58
- # Check if tunnel already exists
59
- list_result = subprocess.run(
60
- ["cloudflared", "tunnel", "list", "--output", "json"],
61
- capture_output=True,
62
- text=True
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
86
-
87
- if not self.tunnel_uuid:
88
- list_result = subprocess.run(
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
99
-
100
- if self.tunnel_uuid:
101
- self.credentials_file = self.config_dir / f"{self.tunnel_uuid}.json"
102
- print(f"✅ Tunnel created with ID: {self.tunnel_uuid}")
103
- return True
104
-
105
- print(f"❌ Failed to create tunnel: {result.stderr}")
106
- return False
107
-
108
- except Exception as e:
109
- print(f"❌ Error creating tunnel: {e}")
110
- return False
111
-
112
- def configure_dns(self):
113
- """Configure DNS routes for the tunnel"""
114
- try:
115
- print("🌐 Configuring DNS routes...")
116
-
117
- # Route for Jupyter
118
- jupyter_result = subprocess.run(
119
- ["cloudflared", "tunnel", "route", "dns",
120
- self.tunnel_name, f"{self.jupyter_subdomain}.{self.base_domain}"],
121
- capture_output=True,
122
- text=True
123
- )
124
-
125
- if jupyter_result.returncode == 0:
126
- print(f"✅ Jupyter route configured: {self.jupyter_url}")
127
- else:
128
- print(f"⚠️ Jupyter route may already exist or failed: {jupyter_result.stderr}")
129
-
130
- # Route for SSH
131
- ssh_result = subprocess.run(
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}")
142
-
143
- return True
144
-
145
- except Exception as e:
146
- print(f"❌ Error configuring DNS: {e}")
147
- return False
148
-
149
- def create_config_file(self, jupyter_port):
150
- """Create tunnel configuration file"""
151
- config = {
152
- "tunnel": self.tunnel_uuid,
153
- "credentials-file": str(self.credentials_file),
154
- "ingress": [
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
- }
174
-
175
- config_file = self.config_dir / f"config-{self.device_id}.yml"
176
- with open(config_file, 'w') as f:
177
- yaml.dump(config, f, default_flow_style=False)
178
-
179
- print(f"📝 Configuration saved to: {config_file}")
180
- return config_file
181
-
182
- def start_tunnel(self, config_file):
183
- """Start the tunnel with the configuration"""
184
- try:
185
- print("🚀 Starting Cloudflare tunnel...")
186
-
187
- cmd = ["cloudflared", "tunnel", "--config", str(config_file), "run"]
188
-
189
- process = subprocess.Popen(
190
- cmd,
191
- stdout=subprocess.PIPE,
192
- stderr=subprocess.STDOUT,
193
- text=True,
194
- bufsize=1
195
- )
196
-
197
- # Wait for tunnel to establish
198
- time.sleep(5)
199
-
200
- if process.poll() is None:
201
- print("✅ Tunnel is running")
202
- return process
203
- else:
204
- print("❌ Tunnel failed to start")
205
- return None
206
-
207
- except Exception as e:
208
- print(f"❌ Error starting tunnel: {e}")
209
- return None
210
-
211
- def setup(self, jupyter_port):
212
- """Complete setup process"""
213
- # Check if we need to login
214
- if not (self.config_dir / "cert.pem").exists():
215
- if not self.login():
216
- return None
217
-
218
- # Create tunnel
219
- if not self.create_tunnel():
220
- return None
221
-
222
- # Configure DNS
223
- if not self.configure_dns():
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes