plato-sdk-v2 2.6.2__py3-none-any.whl → 2.7.0__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.
- plato/_generated/__init__.py +1 -1
- plato/_generated/api/v2/__init__.py +2 -1
- plato/_generated/api/v2/networks/__init__.py +23 -0
- plato/_generated/api/v2/networks/add_member.py +75 -0
- plato/_generated/api/v2/networks/create_network.py +70 -0
- plato/_generated/api/v2/networks/delete_network.py +68 -0
- plato/_generated/api/v2/networks/get_network.py +69 -0
- plato/_generated/api/v2/networks/list_members.py +69 -0
- plato/_generated/api/v2/networks/list_networks.py +74 -0
- plato/_generated/api/v2/networks/remove_member.py +73 -0
- plato/_generated/api/v2/networks/update_member.py +80 -0
- plato/_generated/api/v2/sessions/__init__.py +4 -0
- plato/_generated/api/v2/sessions/add_ssh_key.py +81 -0
- plato/_generated/api/v2/sessions/connect_network.py +89 -0
- plato/_generated/models/__init__.py +145 -24
- plato/v1/cli/agent.py +45 -52
- plato/v1/cli/chronos.py +46 -58
- plato/v1/cli/main.py +14 -25
- plato/v1/cli/pm.py +37 -92
- plato/v1/cli/proxy.py +343 -0
- plato/v1/cli/sandbox.py +305 -385
- plato/v1/cli/ssh.py +12 -167
- plato/v1/cli/verify.py +79 -55
- plato/v1/cli/world.py +13 -12
- plato/v2/async_/client.py +24 -2
- plato/v2/async_/session.py +48 -0
- plato/v2/sync/client.py +24 -2
- plato/v2/sync/session.py +48 -0
- {plato_sdk_v2-2.6.2.dist-info → plato_sdk_v2-2.7.0.dist-info}/METADATA +1 -1
- {plato_sdk_v2-2.6.2.dist-info → plato_sdk_v2-2.7.0.dist-info}/RECORD +32 -20
- {plato_sdk_v2-2.6.2.dist-info → plato_sdk_v2-2.7.0.dist-info}/WHEEL +0 -0
- {plato_sdk_v2-2.6.2.dist-info → plato_sdk_v2-2.7.0.dist-info}/entry_points.txt +0 -0
plato/v1/cli/proxy.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Gateway commands for connecting to VMs through the WireGuard gateway.
|
|
2
|
+
|
|
3
|
+
This module provides CLI commands to connect to VMs via TLS + SNI routing
|
|
4
|
+
through an HAProxy gateway with WireGuard backend connectivity.
|
|
5
|
+
|
|
6
|
+
Commands:
|
|
7
|
+
plato sandbox ssh <job_id> - SSH to a VM through the gateway
|
|
8
|
+
plato sandbox tunnel <job_id> <port> - Open a local port forwarding tunnel to a VM
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import socket
|
|
13
|
+
import ssl
|
|
14
|
+
import subprocess
|
|
15
|
+
import threading
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
from plato._generated.api.v2.sessions import add_ssh_key as sessions_add_ssh_key
|
|
21
|
+
from plato._generated.models import AddSSHKeyRequest
|
|
22
|
+
from plato.v1.cli.ssh import generate_ssh_key_pair
|
|
23
|
+
from plato.v1.cli.utils import console, get_http_client, get_sandbox_state, require_api_key, save_sandbox_state
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(help="Gateway commands for connecting to VMs.")
|
|
26
|
+
|
|
27
|
+
# Default gateway configuration
|
|
28
|
+
DEFAULT_GATEWAY_HOST = "gateway.plato.so"
|
|
29
|
+
DEFAULT_GATEWAY_PORT = 443
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_gateway_config() -> tuple[str, int]:
|
|
33
|
+
"""Get gateway host and port from environment or defaults.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Tuple of (host, port) for the gateway.
|
|
37
|
+
"""
|
|
38
|
+
host = os.environ.get("PLATO_GATEWAY_HOST", DEFAULT_GATEWAY_HOST)
|
|
39
|
+
port = int(os.environ.get("PLATO_GATEWAY_PORT", str(DEFAULT_GATEWAY_PORT)))
|
|
40
|
+
return host, port
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_tls_connection(
|
|
44
|
+
gateway_host: str,
|
|
45
|
+
gateway_port: int,
|
|
46
|
+
sni: str,
|
|
47
|
+
verify_ssl: bool = True,
|
|
48
|
+
) -> ssl.SSLSocket:
|
|
49
|
+
"""Create a TLS connection to the gateway with the specified SNI.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
gateway_host: The gateway hostname.
|
|
53
|
+
gateway_port: The gateway port.
|
|
54
|
+
sni: The SNI (Server Name Indication) for routing.
|
|
55
|
+
verify_ssl: Whether to verify SSL certificates.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
An SSL socket connected to the gateway.
|
|
59
|
+
"""
|
|
60
|
+
# Create SSL context
|
|
61
|
+
context = ssl.create_default_context()
|
|
62
|
+
if not verify_ssl:
|
|
63
|
+
context.check_hostname = False
|
|
64
|
+
context.verify_mode = ssl.CERT_NONE
|
|
65
|
+
|
|
66
|
+
# Create socket and wrap with TLS
|
|
67
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
68
|
+
sock.settimeout(30)
|
|
69
|
+
|
|
70
|
+
# Wrap with TLS, using SNI for routing
|
|
71
|
+
ssl_sock = context.wrap_socket(sock, server_hostname=sni)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
ssl_sock.connect((gateway_host, gateway_port))
|
|
75
|
+
except Exception as e:
|
|
76
|
+
ssl_sock.close()
|
|
77
|
+
raise ConnectionError(f"Failed to connect to gateway: {e}") from e
|
|
78
|
+
|
|
79
|
+
return ssl_sock
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def forward_data(src: socket.socket, dst: socket.socket, name: str = "") -> None:
|
|
83
|
+
"""Forward data between two sockets until one closes.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
src: Source socket to read from.
|
|
87
|
+
dst: Destination socket to write to.
|
|
88
|
+
name: Optional name for debugging.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
while True:
|
|
92
|
+
data = src.recv(4096)
|
|
93
|
+
if not data:
|
|
94
|
+
break
|
|
95
|
+
dst.sendall(data)
|
|
96
|
+
except (ConnectionResetError, BrokenPipeError, OSError):
|
|
97
|
+
pass
|
|
98
|
+
finally:
|
|
99
|
+
try:
|
|
100
|
+
dst.shutdown(socket.SHUT_WR)
|
|
101
|
+
except OSError:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command()
|
|
106
|
+
def ssh(
|
|
107
|
+
job_id: str | None = typer.Argument(None, help="The job ID to connect to (uses .sandbox.yaml if not provided)"),
|
|
108
|
+
user: str = typer.Option("root", "--user", "-u", help="SSH username"),
|
|
109
|
+
port: int = typer.Option(22, "--port", "-p", help="SSH port on the VM"),
|
|
110
|
+
identity_file: str | None = typer.Option(None, "--identity", "-i", help="Path to SSH identity file"),
|
|
111
|
+
no_verify: bool = typer.Option(False, "--no-verify", help="Skip SSL certificate verification"),
|
|
112
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show verbose output including SSH command"),
|
|
113
|
+
extra_args: list[str] | None = typer.Argument(None, help="Additional arguments to pass to SSH"),
|
|
114
|
+
) -> None:
|
|
115
|
+
"""SSH to a VM through the gateway.
|
|
116
|
+
|
|
117
|
+
Connects to the VM's SSH port via TLS + SNI routing through HAProxy, then
|
|
118
|
+
over WireGuard. If no job_id is provided, reads from .sandbox.yaml.
|
|
119
|
+
|
|
120
|
+
Arguments:
|
|
121
|
+
job_id: Job ID to connect to (optional - uses .sandbox.yaml if not provided)
|
|
122
|
+
extra_args: Additional SSH arguments (pass after '--')
|
|
123
|
+
|
|
124
|
+
Options:
|
|
125
|
+
-u, --user: SSH username (default: root)
|
|
126
|
+
-p, --port: SSH port on the VM (default: 22)
|
|
127
|
+
-i, --identity: Path to SSH private key file (auto-loaded from .sandbox.yaml)
|
|
128
|
+
--no-verify: Skip SSL certificate verification
|
|
129
|
+
-v, --verbose: Show the SSH command being executed
|
|
130
|
+
"""
|
|
131
|
+
# Try to load from .sandbox.yaml if job_id or identity_file not provided
|
|
132
|
+
state = get_sandbox_state()
|
|
133
|
+
|
|
134
|
+
if job_id is None:
|
|
135
|
+
if state and state.get("job_id"):
|
|
136
|
+
job_id = state["job_id"]
|
|
137
|
+
else:
|
|
138
|
+
console.print("[red]Error: No job_id provided and no .sandbox.yaml found[/red]")
|
|
139
|
+
console.print("[dim]Run 'plato sandbox start' first or provide a job_id[/dim]")
|
|
140
|
+
raise typer.Exit(1)
|
|
141
|
+
|
|
142
|
+
# Auto-load identity file from state if not explicitly provided
|
|
143
|
+
if identity_file is None and state:
|
|
144
|
+
saved_key = state.get("ssh_private_key_path")
|
|
145
|
+
if saved_key and Path(saved_key).exists():
|
|
146
|
+
identity_file = saved_key
|
|
147
|
+
console.print(f"[dim]Using SSH key: {saved_key}[/dim]")
|
|
148
|
+
|
|
149
|
+
# If still no identity file, generate one and add to VM
|
|
150
|
+
if identity_file is None:
|
|
151
|
+
session_id = state.get("session_id") if state else None
|
|
152
|
+
if session_id:
|
|
153
|
+
console.print("[cyan]No SSH key found, generating and adding to VM...[/cyan]")
|
|
154
|
+
try:
|
|
155
|
+
api_key = require_api_key()
|
|
156
|
+
|
|
157
|
+
# Generate key pair
|
|
158
|
+
public_key, private_key_path = generate_ssh_key_pair(job_id[:8])
|
|
159
|
+
identity_file = private_key_path
|
|
160
|
+
|
|
161
|
+
# Add to VM via API
|
|
162
|
+
add_key_request = AddSSHKeyRequest(
|
|
163
|
+
public_key=public_key,
|
|
164
|
+
username=user,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
with get_http_client() as client:
|
|
168
|
+
response = sessions_add_ssh_key.sync(
|
|
169
|
+
client=client,
|
|
170
|
+
session_id=session_id,
|
|
171
|
+
body=add_key_request,
|
|
172
|
+
x_api_key=api_key,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if response.success:
|
|
176
|
+
console.print("[green]SSH key added successfully[/green]")
|
|
177
|
+
# Update state with new key path
|
|
178
|
+
if state:
|
|
179
|
+
state["ssh_private_key_path"] = private_key_path
|
|
180
|
+
save_sandbox_state(state)
|
|
181
|
+
else:
|
|
182
|
+
for jid, result in response.results.items():
|
|
183
|
+
if not result.success:
|
|
184
|
+
console.print(f"[red]Failed to add key to {jid[:8]}...: {result.error}[/red]")
|
|
185
|
+
raise typer.Exit(1)
|
|
186
|
+
|
|
187
|
+
except Exception as e:
|
|
188
|
+
console.print(f"[red]Failed to setup SSH key: {e}[/red]")
|
|
189
|
+
raise typer.Exit(1)
|
|
190
|
+
else:
|
|
191
|
+
console.print("[red]Error: No SSH key and no session_id to add one[/red]")
|
|
192
|
+
console.print("[dim]Provide -i <key_path> or run from a directory with .sandbox.yaml[/dim]")
|
|
193
|
+
raise typer.Exit(1)
|
|
194
|
+
|
|
195
|
+
gateway_host, gateway_port = get_gateway_config()
|
|
196
|
+
sni = f"{job_id}--{port}.{gateway_host}"
|
|
197
|
+
|
|
198
|
+
# Build ProxyCommand using openssl s_client
|
|
199
|
+
verify_flag = ""
|
|
200
|
+
if no_verify:
|
|
201
|
+
verify_flag = "-verify_quiet"
|
|
202
|
+
|
|
203
|
+
proxy_cmd = (
|
|
204
|
+
f"openssl s_client -quiet -connect {gateway_host}:{gateway_port} -servername {sni} {verify_flag} 2>/dev/null"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Build SSH command
|
|
208
|
+
ssh_cmd = ["ssh", "-o", f"ProxyCommand={proxy_cmd}"]
|
|
209
|
+
|
|
210
|
+
# Add identity file if available
|
|
211
|
+
if identity_file:
|
|
212
|
+
ssh_cmd.extend(["-i", identity_file])
|
|
213
|
+
|
|
214
|
+
# Disable strict host key checking for gateway connections
|
|
215
|
+
ssh_cmd.extend(
|
|
216
|
+
[
|
|
217
|
+
"-o",
|
|
218
|
+
"StrictHostKeyChecking=no",
|
|
219
|
+
"-o",
|
|
220
|
+
"UserKnownHostsFile=/dev/null",
|
|
221
|
+
]
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Add any extra SSH arguments
|
|
225
|
+
if extra_args:
|
|
226
|
+
ssh_cmd.extend(extra_args)
|
|
227
|
+
|
|
228
|
+
# Add target
|
|
229
|
+
ssh_cmd.append(f"{user}@{job_id}")
|
|
230
|
+
|
|
231
|
+
console.print(f"[dim]Connecting to {job_id} via {gateway_host}...[/dim]")
|
|
232
|
+
|
|
233
|
+
if verbose:
|
|
234
|
+
console.print(f"[dim]SSH command: {' '.join(ssh_cmd)}[/dim]")
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
# Execute SSH with inherited stdin/stdout/stderr
|
|
238
|
+
result = subprocess.run(ssh_cmd)
|
|
239
|
+
raise typer.Exit(result.returncode)
|
|
240
|
+
except FileNotFoundError:
|
|
241
|
+
console.print("[red]Error: ssh command not found[/red]")
|
|
242
|
+
raise typer.Exit(1) from None
|
|
243
|
+
except KeyboardInterrupt:
|
|
244
|
+
console.print("\n[yellow]Connection interrupted[/yellow]")
|
|
245
|
+
raise typer.Exit(130) from None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@app.command()
|
|
249
|
+
def tunnel(
|
|
250
|
+
job_id: str = typer.Argument(..., help="The job ID to connect to"),
|
|
251
|
+
remote_port: int = typer.Argument(..., help="Remote port on the VM to forward"),
|
|
252
|
+
local_port: int | None = typer.Argument(None, help="Local port to listen on (defaults to remote_port)"),
|
|
253
|
+
bind_address: str = typer.Option("127.0.0.1", "--bind", "-b", help="Local address to bind to"),
|
|
254
|
+
no_verify: bool = typer.Option(False, "--no-verify", help="Skip SSL certificate verification"),
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Open a local port forwarding tunnel to a VM.
|
|
257
|
+
|
|
258
|
+
Creates a local TCP listener that forwards connections through the TLS
|
|
259
|
+
gateway to the specified port on the remote VM.
|
|
260
|
+
|
|
261
|
+
Arguments:
|
|
262
|
+
job_id: Job ID of the VM to connect to
|
|
263
|
+
remote_port: Port on the VM to forward to
|
|
264
|
+
local_port: Local port to listen on (default: same as remote_port)
|
|
265
|
+
|
|
266
|
+
Options:
|
|
267
|
+
-b, --bind: Local address to bind to (default: 127.0.0.1)
|
|
268
|
+
--no-verify: Skip SSL certificate verification
|
|
269
|
+
"""
|
|
270
|
+
gateway_host, gateway_port = get_gateway_config()
|
|
271
|
+
local = local_port or remote_port
|
|
272
|
+
sni = f"{job_id}--{remote_port}.{gateway_host}"
|
|
273
|
+
|
|
274
|
+
# Create local listener
|
|
275
|
+
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
276
|
+
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
server.bind((bind_address, local))
|
|
280
|
+
server.listen(5)
|
|
281
|
+
except OSError as e:
|
|
282
|
+
console.print(f"[red]Error: Could not bind to {bind_address}:{local}: {e}[/red]")
|
|
283
|
+
raise typer.Exit(1) from None
|
|
284
|
+
|
|
285
|
+
console.print(f"[green]Tunnel open:[/green] {bind_address}:{local} -> {job_id}:{remote_port}")
|
|
286
|
+
console.print("[dim]Press Ctrl+C to stop[/dim]")
|
|
287
|
+
|
|
288
|
+
def handle_client(client_sock: socket.socket, client_addr: tuple) -> None:
|
|
289
|
+
"""Handle a single client connection by forwarding to the VM."""
|
|
290
|
+
try:
|
|
291
|
+
# Connect to gateway via TLS
|
|
292
|
+
gateway_sock = create_tls_connection(gateway_host, gateway_port, sni, verify_ssl=not no_verify)
|
|
293
|
+
|
|
294
|
+
# Create bidirectional forwarding threads
|
|
295
|
+
t1 = threading.Thread(
|
|
296
|
+
target=forward_data,
|
|
297
|
+
args=(client_sock, gateway_sock, "client->gateway"),
|
|
298
|
+
daemon=True,
|
|
299
|
+
)
|
|
300
|
+
t2 = threading.Thread(
|
|
301
|
+
target=forward_data,
|
|
302
|
+
args=(gateway_sock, client_sock, "gateway->client"),
|
|
303
|
+
daemon=True,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
t1.start()
|
|
307
|
+
t2.start()
|
|
308
|
+
|
|
309
|
+
# Wait for both directions to complete
|
|
310
|
+
t1.join()
|
|
311
|
+
t2.join()
|
|
312
|
+
|
|
313
|
+
except Exception as e:
|
|
314
|
+
console.print(f"[red]Connection error: {e}[/red]")
|
|
315
|
+
finally:
|
|
316
|
+
try:
|
|
317
|
+
client_sock.close()
|
|
318
|
+
except OSError:
|
|
319
|
+
pass
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
while True:
|
|
323
|
+
# Accept connections
|
|
324
|
+
client_sock, client_addr = server.accept()
|
|
325
|
+
console.print(f"[dim]Connection from {client_addr[0]}:{client_addr[1]}[/dim]")
|
|
326
|
+
|
|
327
|
+
# Handle in a new thread
|
|
328
|
+
thread = threading.Thread(
|
|
329
|
+
target=handle_client,
|
|
330
|
+
args=(client_sock, client_addr),
|
|
331
|
+
daemon=True,
|
|
332
|
+
)
|
|
333
|
+
thread.start()
|
|
334
|
+
|
|
335
|
+
except KeyboardInterrupt:
|
|
336
|
+
console.print("\n[yellow]Tunnel closed[/yellow]")
|
|
337
|
+
finally:
|
|
338
|
+
server.close()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# Also expose ssh and tunnel as top-level commands (will be registered in main.py)
|
|
342
|
+
ssh_command = ssh
|
|
343
|
+
tunnel_command = tunnel
|