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/__init__.py +18 -0
- runtime_sdk/account.py +243 -0
- runtime_sdk/arguments.py +10 -0
- runtime_sdk/auth.py +175 -0
- runtime_sdk/cli.py +985 -0
- runtime_sdk/client.py +616 -0
- runtime_sdk/completion.py +348 -0
- runtime_sdk/computers.py +578 -0
- runtime_sdk/config.py +201 -0
- runtime_sdk/console.py +118 -0
- runtime_sdk/files.py +233 -0
- runtime_sdk/github.py +229 -0
- runtime_sdk/goals.py +368 -0
- runtime_sdk/output.py +69 -0
- runtime_sdk/providers.py +422 -0
- runtime_sdk/proxy.py +600 -0
- runtime_sdk/runtime-tui.exe +0 -0
- runtime_sdk/secrets.py +155 -0
- runtime_sdk/services.py +173 -0
- runtime_sdk/ssh.py +197 -0
- runtime_sdk/terminal.py +330 -0
- runtime_sdk-0.4.49.dist-info/METADATA +273 -0
- runtime_sdk-0.4.49.dist-info/RECORD +26 -0
- runtime_sdk-0.4.49.dist-info/WHEEL +5 -0
- runtime_sdk-0.4.49.dist-info/entry_points.txt +2 -0
- runtime_sdk-0.4.49.dist-info/top_level.txt +1 -0
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
|