plato-sdk-v2 2.7.6__py3-none-any.whl → 2.7.7__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/cli/proxy.py ADDED
@@ -0,0 +1,222 @@
1
+ """SSH and gateway utilities for Plato CLI.
2
+
3
+ This module provides:
4
+ - SSH key generation and config file management
5
+ - TLS connection utilities for port forwarding via TLS + SNI gateway
6
+
7
+ The CLI commands (ssh, tunnel) are in sandbox.py.
8
+ """
9
+
10
+ import os
11
+ import socket
12
+ import ssl
13
+ import subprocess
14
+ from pathlib import Path
15
+
16
+ from cryptography.hazmat.primitives import serialization
17
+ from cryptography.hazmat.primitives.asymmetric import ed25519
18
+
19
+ # Default gateway configuration
20
+ DEFAULT_GATEWAY_HOST = "gateway.plato.so"
21
+ DEFAULT_GATEWAY_PORT = 443
22
+
23
+
24
+ def get_gateway_config() -> tuple[str, int]:
25
+ """Get gateway host and port from environment or defaults."""
26
+ host = os.environ.get("PLATO_GATEWAY_HOST", DEFAULT_GATEWAY_HOST)
27
+ port = int(os.environ.get("PLATO_GATEWAY_PORT", str(DEFAULT_GATEWAY_PORT)))
28
+ return host, port
29
+
30
+
31
+ def get_plato_dir(working_dir: Path | str | None = None) -> Path:
32
+ """Get the .plato directory for config/SSH files.
33
+
34
+ Args:
35
+ working_dir: If provided, returns working_dir/.plato.
36
+ If None, returns cwd/.plato (workspace-based).
37
+ """
38
+ if working_dir is not None:
39
+ return Path(working_dir) / ".plato"
40
+ return Path.cwd() / ".plato"
41
+
42
+
43
+ def generate_ssh_key_pair(identifier: str, working_dir: Path | str | None = None) -> tuple[str, str]:
44
+ """Generate a new ed25519 SSH key pair.
45
+
46
+ Args:
47
+ identifier: A unique identifier for naming the key files (e.g., job_id prefix)
48
+ working_dir: Optional working directory for the .plato folder
49
+
50
+ Returns:
51
+ Tuple of (public_key_str, private_key_path)
52
+ """
53
+ plato_dir = get_plato_dir(working_dir)
54
+ plato_dir.mkdir(mode=0o700, exist_ok=True)
55
+
56
+ private_key_path = plato_dir / f"ssh_{identifier}_key"
57
+ public_key_path = plato_dir / f"ssh_{identifier}_key.pub"
58
+
59
+ # Remove existing keys if they exist
60
+ private_key_path.unlink(missing_ok=True)
61
+ public_key_path.unlink(missing_ok=True)
62
+
63
+ # Generate ed25519 key pair
64
+ private_key = ed25519.Ed25519PrivateKey.generate()
65
+ public_key = private_key.public_key()
66
+
67
+ # Serialize private key in OpenSSH format
68
+ private_key_bytes = private_key.private_bytes(
69
+ encoding=serialization.Encoding.PEM,
70
+ format=serialization.PrivateFormat.OpenSSH,
71
+ encryption_algorithm=serialization.NoEncryption(),
72
+ )
73
+
74
+ # Serialize public key in OpenSSH format
75
+ public_key_bytes = public_key.public_bytes(
76
+ encoding=serialization.Encoding.OpenSSH,
77
+ format=serialization.PublicFormat.OpenSSH,
78
+ )
79
+
80
+ # Add comment to public key
81
+ comment = f"plato-sandbox-{identifier}"
82
+ public_key_str = f"{public_key_bytes.decode('utf-8')} {comment}"
83
+
84
+ # Write private key with 0600 permissions
85
+ private_key_path.write_bytes(private_key_bytes)
86
+ private_key_path.chmod(0o600)
87
+
88
+ # Write public key with 0644 permissions
89
+ public_key_path.write_text(public_key_str + "\n")
90
+ public_key_path.chmod(0o644)
91
+
92
+ return public_key_str, str(private_key_path)
93
+
94
+
95
+ def generate_ssh_config(
96
+ job_id: str,
97
+ private_key_path: str,
98
+ gateway_host: str | None = None,
99
+ gateway_port: int | None = None,
100
+ working_dir: Path | str | None = None,
101
+ ) -> str:
102
+ """Generate .plato/ssh_config file for easy SSH access.
103
+
104
+ Args:
105
+ job_id: The job ID for the sandbox VM.
106
+ private_key_path: Path to the SSH private key file.
107
+ gateway_host: Gateway hostname (default: from env or gateway.plato.so).
108
+ gateway_port: Gateway port (default: from env or 443).
109
+ working_dir: Working directory for .plato folder.
110
+
111
+ Returns:
112
+ Absolute path to the generated ssh_config file.
113
+ """
114
+ if gateway_host is None:
115
+ gateway_host = os.environ.get("PLATO_GATEWAY_HOST", DEFAULT_GATEWAY_HOST)
116
+ if gateway_port is None:
117
+ gateway_port = int(os.environ.get("PLATO_GATEWAY_PORT", str(DEFAULT_GATEWAY_PORT)))
118
+
119
+ plato_dir = get_plato_dir(working_dir)
120
+ plato_dir.mkdir(mode=0o700, exist_ok=True)
121
+
122
+ ssh_config_path = plato_dir / "ssh_config"
123
+
124
+ config_content = f"""# Plato SSH Config
125
+ # Usage: ssh -F .plato/ssh_config sandbox
126
+
127
+ Host *.plato
128
+ User root
129
+ StrictHostKeyChecking no
130
+ UserKnownHostsFile /dev/null
131
+ ProxyCommand openssl s_client -quiet -connect {gateway_host}:{gateway_port} -servername %h--22.{gateway_host} 2>/dev/null
132
+
133
+ Host sandbox
134
+ HostName {job_id}.plato
135
+ IdentityFile {private_key_path}
136
+ """
137
+
138
+ ssh_config_path.write_text(config_content)
139
+ ssh_config_path.chmod(0o600)
140
+
141
+ return str(ssh_config_path)
142
+
143
+
144
+ def run_ssh_command(ssh_config_path: str, ssh_host: str, command: str) -> tuple[int, str, str]:
145
+ """Run a command on the remote VM via SSH.
146
+
147
+ Args:
148
+ ssh_config_path: Path to SSH config file.
149
+ ssh_host: SSH host alias (e.g., "sandbox").
150
+ command: Shell command to execute.
151
+
152
+ Returns:
153
+ Tuple of (returncode, stdout, stderr).
154
+ """
155
+ result = subprocess.run(
156
+ ["ssh", "-F", ssh_config_path, ssh_host, command],
157
+ capture_output=True,
158
+ text=True,
159
+ )
160
+ return result.returncode, result.stdout, result.stderr
161
+
162
+
163
+ def create_tls_connection(
164
+ gateway_host: str,
165
+ gateway_port: int,
166
+ sni: str,
167
+ verify_ssl: bool = True,
168
+ ) -> ssl.SSLSocket:
169
+ """Create a TLS connection to the gateway with the specified SNI.
170
+
171
+ Args:
172
+ gateway_host: The gateway hostname.
173
+ gateway_port: The gateway port.
174
+ sni: The SNI (Server Name Indication) for routing.
175
+ verify_ssl: Whether to verify SSL certificates.
176
+
177
+ Returns:
178
+ An SSL socket connected to the gateway.
179
+ """
180
+ # Create SSL context
181
+ context = ssl.create_default_context()
182
+ if not verify_ssl:
183
+ context.check_hostname = False
184
+ context.verify_mode = ssl.CERT_NONE
185
+
186
+ # Create socket and wrap with TLS
187
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
188
+ sock.settimeout(30)
189
+
190
+ # Wrap with TLS, using SNI for routing
191
+ ssl_sock = context.wrap_socket(sock, server_hostname=sni)
192
+
193
+ try:
194
+ ssl_sock.connect((gateway_host, gateway_port))
195
+ except Exception as e:
196
+ ssl_sock.close()
197
+ raise ConnectionError(f"Failed to connect to gateway: {e}") from e
198
+
199
+ return ssl_sock
200
+
201
+
202
+ def forward_data(src: socket.socket, dst: socket.socket, name: str = "") -> None:
203
+ """Forward data between two sockets until one closes.
204
+
205
+ Args:
206
+ src: Source socket to read from.
207
+ dst: Destination socket to write to.
208
+ name: Optional name for debugging.
209
+ """
210
+ try:
211
+ while True:
212
+ data = src.recv(4096)
213
+ if not data:
214
+ break
215
+ dst.sendall(data)
216
+ except (ConnectionResetError, BrokenPipeError, OSError):
217
+ pass
218
+ finally:
219
+ try:
220
+ dst.shutdown(socket.SHUT_WR)
221
+ except OSError:
222
+ pass