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.
Files changed (32) hide show
  1. plato/_generated/__init__.py +1 -1
  2. plato/_generated/api/v2/__init__.py +2 -1
  3. plato/_generated/api/v2/networks/__init__.py +23 -0
  4. plato/_generated/api/v2/networks/add_member.py +75 -0
  5. plato/_generated/api/v2/networks/create_network.py +70 -0
  6. plato/_generated/api/v2/networks/delete_network.py +68 -0
  7. plato/_generated/api/v2/networks/get_network.py +69 -0
  8. plato/_generated/api/v2/networks/list_members.py +69 -0
  9. plato/_generated/api/v2/networks/list_networks.py +74 -0
  10. plato/_generated/api/v2/networks/remove_member.py +73 -0
  11. plato/_generated/api/v2/networks/update_member.py +80 -0
  12. plato/_generated/api/v2/sessions/__init__.py +4 -0
  13. plato/_generated/api/v2/sessions/add_ssh_key.py +81 -0
  14. plato/_generated/api/v2/sessions/connect_network.py +89 -0
  15. plato/_generated/models/__init__.py +145 -24
  16. plato/v1/cli/agent.py +45 -52
  17. plato/v1/cli/chronos.py +46 -58
  18. plato/v1/cli/main.py +14 -25
  19. plato/v1/cli/pm.py +37 -92
  20. plato/v1/cli/proxy.py +343 -0
  21. plato/v1/cli/sandbox.py +305 -385
  22. plato/v1/cli/ssh.py +12 -167
  23. plato/v1/cli/verify.py +79 -55
  24. plato/v1/cli/world.py +13 -12
  25. plato/v2/async_/client.py +24 -2
  26. plato/v2/async_/session.py +48 -0
  27. plato/v2/sync/client.py +24 -2
  28. plato/v2/sync/session.py +48 -0
  29. {plato_sdk_v2-2.6.2.dist-info → plato_sdk_v2-2.7.0.dist-info}/METADATA +1 -1
  30. {plato_sdk_v2-2.6.2.dist-info → plato_sdk_v2-2.7.0.dist-info}/RECORD +32 -20
  31. {plato_sdk_v2-2.6.2.dist-info → plato_sdk_v2-2.7.0.dist-info}/WHEEL +0 -0
  32. {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