runtime-sdk 0.4.49__py3-none-win_amd64.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.
runtime_sdk/proxy.py ADDED
@@ -0,0 +1,600 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import json
5
+ import os
6
+ import shlex
7
+ import signal
8
+ import socket
9
+ import subprocess
10
+ import sys
11
+ import threading
12
+ import time
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any, Callable
16
+
17
+ from websockets.sync.client import connect as websocket_connect
18
+
19
+ from runtime_sdk import computers, output as cli_output, terminal
20
+ from runtime_sdk.auth import authenticated_client
21
+ from runtime_sdk.client import DEFAULT_WARMUP_TIMEOUT, RuntimeAPIError
22
+ from runtime_sdk.config import RuntimeConfig
23
+
24
+
25
+ def _runtime_state_dir() -> Path:
26
+ root = os.environ.get("XDG_STATE_HOME")
27
+ if root:
28
+ return Path(root) / "runtime"
29
+ if sys.platform == "win32":
30
+ root = os.environ.get("LOCALAPPDATA")
31
+ if root:
32
+ return Path(root) / "Runtime"
33
+ return Path.home() / ".local" / "state" / "runtime"
34
+
35
+
36
+ def _proxy_dir() -> Path:
37
+ return _runtime_state_dir() / "proxies"
38
+
39
+
40
+ def _proxy_state_path(local_port: int) -> Path:
41
+ return _proxy_dir() / f"proxy-{local_port}.json"
42
+
43
+
44
+ def _proxy_log_path(local_port: int) -> Path:
45
+ return _proxy_dir() / f"proxy-{local_port}.log"
46
+
47
+
48
+ def _pid_running(pid: int) -> bool:
49
+ if pid <= 0:
50
+ return False
51
+ if sys.platform == "win32":
52
+ return _windows_process_started_at(pid) is not None
53
+ try:
54
+ os.kill(pid, 0)
55
+ return True
56
+ except OSError:
57
+ return False
58
+
59
+
60
+ def _pid_command(pid: int) -> str:
61
+ if pid <= 0:
62
+ return ""
63
+ try:
64
+ return subprocess.check_output(
65
+ ["ps", "-o", "command=", "-p", str(pid)],
66
+ text=True,
67
+ stderr=subprocess.DEVNULL,
68
+ ).strip()
69
+ except (OSError, subprocess.SubprocessError):
70
+ return ""
71
+
72
+
73
+ def _proxy_pid_matches(
74
+ pid: int, local_port: int, process_started_at: int | None = None
75
+ ) -> bool:
76
+ if sys.platform == "win32":
77
+ current_started_at = _windows_process_started_at(pid)
78
+ return current_started_at is not None and current_started_at == process_started_at
79
+ if not _pid_running(pid):
80
+ return False
81
+ command = _pid_command(pid)
82
+ if not command:
83
+ return False
84
+ with contextlib.suppress(ValueError):
85
+ argv = shlex.split(command)
86
+ if "_proxy_agent" not in argv:
87
+ return False
88
+ if "--local-port" not in argv:
89
+ return False
90
+ index = argv.index("--local-port")
91
+ if index + 1 >= len(argv) or argv[index + 1] != str(local_port):
92
+ return False
93
+ return "runtime_sdk.cli" in argv or any(
94
+ part.endswith("runtime_sdk.cli") for part in argv
95
+ )
96
+ return False
97
+
98
+
99
+ def _load_proxy_records(*, clean_stale: bool = False) -> list[dict[str, Any]]:
100
+ records: list[dict[str, Any]] = []
101
+ directory = _proxy_dir()
102
+ if not directory.exists():
103
+ return records
104
+ for path in sorted(directory.glob("proxy-*.json")):
105
+ try:
106
+ record = json.loads(path.read_text())
107
+ except (OSError, ValueError):
108
+ continue
109
+ if not isinstance(record, dict):
110
+ continue
111
+ record["state_path"] = str(path)
112
+ pid = int(record.get("pid") or 0)
113
+ local_port = int(record.get("local_port") or 0)
114
+ process_started_at = int(record.get("process_started_at") or 0) or None
115
+ record["status"] = (
116
+ "running"
117
+ if _proxy_pid_matches(pid, local_port, process_started_at)
118
+ else "stopped"
119
+ )
120
+ if clean_stale and record["status"] != "running":
121
+ with contextlib.suppress(OSError):
122
+ path.unlink()
123
+ continue
124
+ records.append(record)
125
+ return records
126
+
127
+
128
+ def _parse_proxy_port_spec(spec: str) -> tuple[int, int]:
129
+ text = str(spec or "").strip()
130
+ if not text:
131
+ raise RuntimeAPIError("port mapping is required")
132
+ if ":" in text:
133
+ local_text, remote_text = text.split(":", 1)
134
+ else:
135
+ local_text, remote_text = text, text
136
+ try:
137
+ local_port = int(local_text)
138
+ remote_port = int(remote_text)
139
+ except ValueError as exc:
140
+ raise RuntimeAPIError(f"invalid port mapping: {text}") from exc
141
+ for port in (local_port, remote_port):
142
+ if port <= 0 or port > 65535:
143
+ raise RuntimeAPIError("port must be between 1 and 65535")
144
+ return local_port, remote_port
145
+
146
+
147
+ def _spawn_proxy_agent(
148
+ base_url: str,
149
+ computer_id: str,
150
+ display_name: str,
151
+ local_port: int,
152
+ remote_port: int,
153
+ ) -> int:
154
+ proxy_dir = _proxy_dir()
155
+ proxy_dir.mkdir(parents=True, exist_ok=True)
156
+ log_path = _proxy_log_path(local_port)
157
+ log_file = log_path.open("a", encoding="utf-8")
158
+ try:
159
+ popen_options: dict[str, Any] = {
160
+ "stdin": subprocess.DEVNULL,
161
+ "stdout": log_file,
162
+ "stderr": log_file,
163
+ "close_fds": True,
164
+ "env": os.environ.copy(),
165
+ }
166
+ if sys.platform == "win32":
167
+ popen_options["creationflags"] = (
168
+ subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
169
+ )
170
+ else:
171
+ popen_options["start_new_session"] = True
172
+ proc = subprocess.Popen(
173
+ [
174
+ sys.executable,
175
+ "-m",
176
+ "runtime_sdk.cli",
177
+ "_proxy_agent",
178
+ "--base-url",
179
+ base_url,
180
+ "--computer-id",
181
+ computer_id,
182
+ "--display-name",
183
+ display_name,
184
+ "--local-port",
185
+ str(local_port),
186
+ "--remote-port",
187
+ str(remote_port),
188
+ ],
189
+ **popen_options,
190
+ )
191
+ finally:
192
+ log_file.close()
193
+
194
+ deadline = time.time() + max(4.0, DEFAULT_WARMUP_TIMEOUT + 2.0)
195
+ state_path = _proxy_state_path(local_port)
196
+ while time.time() < deadline:
197
+ if state_path.exists():
198
+ return proc.pid
199
+ code = proc.poll()
200
+ if code is not None:
201
+ break
202
+ time.sleep(0.1)
203
+
204
+ message = f"proxy failed to start on localhost:{local_port}"
205
+ with contextlib.suppress(OSError):
206
+ tail = log_path.read_text(encoding="utf-8").strip()
207
+ if tail:
208
+ message = tail.splitlines()[-1]
209
+ raise RuntimeAPIError(message)
210
+
211
+
212
+ def _remove_proxy_state(local_port: int) -> None:
213
+ with contextlib.suppress(OSError):
214
+ _proxy_state_path(local_port).unlink()
215
+
216
+
217
+ def _stop_proxy_ports(
218
+ local_ports: list[int], *, stop_all: bool = False
219
+ ) -> list[dict[str, Any]]:
220
+ records = _load_proxy_records(clean_stale=False)
221
+ stopped: list[dict[str, Any]] = []
222
+ wanted = set(local_ports)
223
+ for record in records:
224
+ port = int(record.get("local_port") or 0)
225
+ if not stop_all and port not in wanted:
226
+ continue
227
+ pid = int(record.get("pid") or 0)
228
+ process_started_at = int(record.get("process_started_at") or 0) or None
229
+ if _proxy_pid_matches(pid, port, process_started_at):
230
+ with contextlib.suppress(OSError):
231
+ _terminate_process(pid)
232
+ _remove_proxy_state(port)
233
+ record["status"] = "stopped"
234
+ stopped.append(record)
235
+ return stopped
236
+
237
+
238
+ def start_temporary_proxy(
239
+ config: RuntimeConfig, computer_id: str, remote_port: int
240
+ ) -> tuple[int, Callable[[], None]]:
241
+ _wait_for_proxy_preflight(config, computer_id, remote_port)
242
+
243
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
244
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
245
+ try:
246
+ listener.bind(("127.0.0.1", 0))
247
+ listener.listen()
248
+ listener.settimeout(1.0)
249
+ except OSError as exc:
250
+ with contextlib.suppress(OSError):
251
+ listener.close()
252
+ raise RuntimeAPIError(f"proxy listen failed: {exc}") from exc
253
+
254
+ local_port = int(listener.getsockname()[1])
255
+ stop = threading.Event()
256
+
257
+ def serve() -> None:
258
+ try:
259
+ while not stop.is_set():
260
+ try:
261
+ conn, _ = listener.accept()
262
+ except socket.timeout:
263
+ continue
264
+ except OSError:
265
+ if stop.is_set():
266
+ break
267
+ continue
268
+ thread = threading.Thread(
269
+ target=_handle_proxy_client,
270
+ args=(config, computer_id, remote_port, conn),
271
+ daemon=True,
272
+ )
273
+ thread.start()
274
+ finally:
275
+ with contextlib.suppress(OSError):
276
+ listener.close()
277
+
278
+ thread = threading.Thread(target=serve, daemon=True)
279
+ thread.start()
280
+
281
+ def cleanup() -> None:
282
+ stop.set()
283
+ with contextlib.suppress(OSError):
284
+ listener.close()
285
+ thread.join(timeout=2)
286
+
287
+ return local_port, cleanup
288
+
289
+
290
+ def _wait_for_proxy_preflight(
291
+ config: RuntimeConfig, computer_id: str, remote_port: int
292
+ ) -> None:
293
+ deadline = time.monotonic() + 20
294
+ last_error: Exception | None = None
295
+ while time.monotonic() < deadline:
296
+ try:
297
+ _proxy_preflight(config, computer_id, remote_port)
298
+ return
299
+ except Exception as exc:
300
+ last_error = exc
301
+ time.sleep(0.5)
302
+ raise RuntimeAPIError(
303
+ f"proxy preflight failed for {computer_id}:{remote_port}: {last_error}"
304
+ )
305
+
306
+
307
+ def handle_proxy_start(
308
+ config: RuntimeConfig, computer_id: str | None, specs: list[str] | None
309
+ ) -> int:
310
+ if (err := cli_output.require_auth(config)) is not None:
311
+ return err
312
+ if not computer_id:
313
+ return cli_output.report_error("computer id is required")
314
+ if not specs:
315
+ return cli_output.report_error("at least one port mapping is required")
316
+
317
+ client = authenticated_client(config)
318
+ resolved_id = computers.resolve_computer_ref(client, computer_id)
319
+ existing_records = _load_proxy_records(clean_stale=True)
320
+ existing_ports = {int(r.get("local_port") or 0) for r in existing_records}
321
+ mappings: list[tuple[int, int]] = []
322
+ seen_ports: set[int] = set()
323
+ for spec in specs:
324
+ local_port, remote_port = _parse_proxy_port_spec(spec)
325
+ if local_port in existing_ports:
326
+ return cli_output.report_error(
327
+ f"local port {local_port} is already in use by a runtime proxy"
328
+ )
329
+ if local_port in seen_ports:
330
+ return cli_output.report_error(
331
+ f"local port {local_port} was specified more than once"
332
+ )
333
+ seen_ports.add(local_port)
334
+ mappings.append((local_port, remote_port))
335
+
336
+ started: list[dict[str, Any]] = []
337
+ started_ports: list[int] = []
338
+ for local_port, remote_port in mappings:
339
+ try:
340
+ _spawn_proxy_agent(
341
+ config.base_url,
342
+ resolved_id,
343
+ computer_id,
344
+ local_port,
345
+ remote_port,
346
+ )
347
+ except RuntimeAPIError as exc:
348
+ if started_ports:
349
+ _stop_proxy_ports(started_ports, stop_all=False)
350
+ return cli_output.report_error(str(exc), status_code=exc.status_code)
351
+ except Exception as exc:
352
+ if started_ports:
353
+ _stop_proxy_ports(started_ports, stop_all=False)
354
+ return cli_output.report_error(str(exc))
355
+ started_ports.append(local_port)
356
+ started.append(
357
+ {
358
+ "computer_id": resolved_id,
359
+ "computer": computer_id,
360
+ "local_port": local_port,
361
+ "remote_port": remote_port,
362
+ }
363
+ )
364
+
365
+ return cli_output.report_success({"proxies": started})
366
+
367
+
368
+ def handle_proxy_ls(config: RuntimeConfig) -> int:
369
+ proxies = _load_proxy_records(clean_stale=True)
370
+ return cli_output.report_success({"proxies": proxies})
371
+
372
+
373
+ def handle_proxy_stop(
374
+ config: RuntimeConfig, local_ports: list[int] | None, *, stop_all: bool = False
375
+ ) -> int:
376
+ if not stop_all and not local_ports:
377
+ return cli_output.report_error("pass one or more local ports, or use --all")
378
+
379
+ stopped = _stop_proxy_ports(local_ports or [], stop_all=stop_all)
380
+
381
+ return cli_output.report_success({"proxies": stopped})
382
+
383
+
384
+ def _proxy_preflight(
385
+ config: RuntimeConfig, computer_id: str, remote_port: int
386
+ ) -> None:
387
+ client = authenticated_client(config)
388
+ ticket_payload = client.create_proxy_ticket(computer_id, remote_port)
389
+ ws_url = terminal.resolve_ws_url(config.base_url, ticket_payload.get("ws_url"))
390
+ if not ws_url:
391
+ raise RuntimeAPIError("proxy endpoint did not return a websocket url")
392
+ with websocket_connect(
393
+ ws_url, open_timeout=DEFAULT_WARMUP_TIMEOUT, close_timeout=1, max_size=None
394
+ ):
395
+ return
396
+
397
+
398
+ def run_proxy_agent(
399
+ config: RuntimeConfig,
400
+ computer_id: str,
401
+ display_name: str,
402
+ local_port: int,
403
+ remote_port: int,
404
+ ) -> int:
405
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
406
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
407
+ try:
408
+ listener.bind(("127.0.0.1", local_port))
409
+ listener.listen()
410
+ listener.settimeout(1.0)
411
+ except OSError as exc:
412
+ print(f"proxy listen failed on localhost:{local_port}: {exc}", file=sys.stderr)
413
+ return 1
414
+
415
+ try:
416
+ _proxy_preflight(config, computer_id, remote_port)
417
+ except Exception as exc:
418
+ with contextlib.suppress(OSError):
419
+ listener.close()
420
+ print(
421
+ f"proxy preflight failed for localhost:{local_port} -> {computer_id}:{remote_port}: {exc}",
422
+ file=sys.stderr,
423
+ )
424
+ return 1
425
+
426
+ proxy_dir = _proxy_dir()
427
+ proxy_dir.mkdir(parents=True, exist_ok=True)
428
+ state = {
429
+ "pid": os.getpid(),
430
+ "computer_id": computer_id,
431
+ "display_name": display_name,
432
+ "local_port": local_port,
433
+ "remote_port": remote_port,
434
+ "started_at": datetime.now(timezone.utc).isoformat(),
435
+ }
436
+ if sys.platform == "win32":
437
+ state["process_started_at"] = _windows_process_started_at(os.getpid())
438
+ _proxy_state_path(local_port).write_text(
439
+ json.dumps(state, indent=2) + "\n", encoding="utf-8"
440
+ )
441
+
442
+ stop_event = threading.Event()
443
+
444
+ def handle_signal(_signum: int, _frame: Any) -> None:
445
+ stop_event.set()
446
+ with contextlib.suppress(OSError):
447
+ listener.close()
448
+
449
+ signal.signal(signal.SIGTERM, handle_signal)
450
+ signal.signal(signal.SIGINT, handle_signal)
451
+
452
+ try:
453
+ while not stop_event.is_set():
454
+ try:
455
+ conn, _ = listener.accept()
456
+ except socket.timeout:
457
+ continue
458
+ except OSError:
459
+ if stop_event.is_set():
460
+ break
461
+ continue
462
+ thread = threading.Thread(
463
+ target=_handle_proxy_client,
464
+ args=(config, computer_id, remote_port, conn),
465
+ daemon=True,
466
+ )
467
+ thread.start()
468
+ finally:
469
+ with contextlib.suppress(OSError):
470
+ listener.close()
471
+ _remove_proxy_state(local_port)
472
+ return 0
473
+
474
+
475
+ def _terminate_process(pid: int) -> None:
476
+ if sys.platform != "win32":
477
+ os.kill(pid, signal.SIGTERM)
478
+ return
479
+
480
+ import ctypes
481
+ from ctypes import wintypes
482
+
483
+ process_terminate = 0x0001
484
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
485
+ kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
486
+ kernel32.OpenProcess.restype = wintypes.HANDLE
487
+ kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT]
488
+ kernel32.TerminateProcess.restype = wintypes.BOOL
489
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
490
+ kernel32.CloseHandle.restype = wintypes.BOOL
491
+ handle = kernel32.OpenProcess(process_terminate, False, pid)
492
+ if not handle:
493
+ raise OSError(ctypes.get_last_error(), "failed to open process")
494
+ try:
495
+ if not kernel32.TerminateProcess(handle, 0):
496
+ raise OSError(ctypes.get_last_error(), "failed to terminate process")
497
+ finally:
498
+ kernel32.CloseHandle(handle)
499
+
500
+
501
+ def _windows_process_started_at(pid: int) -> int | None:
502
+ import ctypes
503
+ from ctypes import wintypes
504
+
505
+ process_query_limited_information = 0x1000
506
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
507
+ kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
508
+ kernel32.OpenProcess.restype = wintypes.HANDLE
509
+ kernel32.GetProcessTimes.argtypes = [
510
+ wintypes.HANDLE,
511
+ ctypes.POINTER(wintypes.FILETIME),
512
+ ctypes.POINTER(wintypes.FILETIME),
513
+ ctypes.POINTER(wintypes.FILETIME),
514
+ ctypes.POINTER(wintypes.FILETIME),
515
+ ]
516
+ kernel32.GetProcessTimes.restype = wintypes.BOOL
517
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
518
+ kernel32.CloseHandle.restype = wintypes.BOOL
519
+ handle = kernel32.OpenProcess(process_query_limited_information, False, pid)
520
+ if not handle:
521
+ return None
522
+ creation = wintypes.FILETIME()
523
+ exit_time = wintypes.FILETIME()
524
+ kernel_time = wintypes.FILETIME()
525
+ user_time = wintypes.FILETIME()
526
+ try:
527
+ if not kernel32.GetProcessTimes(
528
+ handle,
529
+ ctypes.byref(creation),
530
+ ctypes.byref(exit_time),
531
+ ctypes.byref(kernel_time),
532
+ ctypes.byref(user_time),
533
+ ):
534
+ return None
535
+ return (int(creation.dwHighDateTime) << 32) | int(creation.dwLowDateTime)
536
+ finally:
537
+ kernel32.CloseHandle(handle)
538
+
539
+
540
+ def _handle_proxy_client(
541
+ config: RuntimeConfig, computer_id: str, remote_port: int, conn: socket.socket
542
+ ) -> None:
543
+ client = authenticated_client(config)
544
+ try:
545
+ ticket_payload = client.create_proxy_ticket(computer_id, remote_port)
546
+ ws_url = terminal.resolve_ws_url(config.base_url, ticket_payload.get("ws_url"))
547
+ if not ws_url:
548
+ raise RuntimeAPIError("proxy endpoint did not return a websocket url")
549
+ with websocket_connect(
550
+ ws_url, open_timeout=DEFAULT_WARMUP_TIMEOUT, close_timeout=1, max_size=None
551
+ ) as ws:
552
+ stop = threading.Event()
553
+
554
+ def local_to_ws() -> None:
555
+ try:
556
+ while not stop.is_set():
557
+ data = conn.recv(65536)
558
+ if not data:
559
+ break
560
+ ws.send(data)
561
+ except Exception:
562
+ pass
563
+ finally:
564
+ stop.set()
565
+ with contextlib.suppress(Exception):
566
+ ws.close()
567
+
568
+ def ws_to_local() -> None:
569
+ try:
570
+ while not stop.is_set():
571
+ data = ws.recv()
572
+ if data is None:
573
+ break
574
+ if isinstance(data, str):
575
+ chunk = data.encode()
576
+ else:
577
+ chunk = data
578
+ if chunk:
579
+ conn.sendall(chunk)
580
+ except Exception:
581
+ pass
582
+ finally:
583
+ stop.set()
584
+ with contextlib.suppress(OSError):
585
+ conn.shutdown(socket.SHUT_RDWR)
586
+
587
+ t1 = threading.Thread(target=local_to_ws, daemon=True)
588
+ t2 = threading.Thread(target=ws_to_local, daemon=True)
589
+ t1.start()
590
+ t2.start()
591
+ t1.join()
592
+ t2.join()
593
+ except Exception as exc:
594
+ print(
595
+ f"proxy connection failed for localhost:{conn.getsockname()[1]} -> {computer_id}:{remote_port}: {exc}",
596
+ file=sys.stderr,
597
+ )
598
+ finally:
599
+ with contextlib.suppress(OSError):
600
+ conn.close()
Binary file