elsewindow 0.1.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.
- elsewindow/__init__.py +33 -0
- elsewindow/__main__.py +8 -0
- elsewindow/cli.py +204 -0
- elsewindow/config.py +152 -0
- elsewindow/live-cli.yml +94 -0
- elsewindow/live_config.py +429 -0
- elsewindow/profiles.yml +41 -0
- elsewindow/py.typed +1 -0
- elsewindow/session.py +788 -0
- elsewindow-0.1.0.dist-info/METADATA +176 -0
- elsewindow-0.1.0.dist-info/RECORD +15 -0
- elsewindow-0.1.0.dist-info/WHEEL +5 -0
- elsewindow-0.1.0.dist-info/entry_points.txt +2 -0
- elsewindow-0.1.0.dist-info/licenses/LICENSE +21 -0
- elsewindow-0.1.0.dist-info/top_level.txt +1 -0
elsewindow/session.py
ADDED
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
# Copyright (c) 2026 kogeler
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Owned Xpra server, client, and SSH lifecycle orchestration."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import secrets
|
|
14
|
+
import shlex
|
|
15
|
+
import signal
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from ssh_wrapper.connection import OpenSSHMaster, SSHMasterSettings
|
|
19
|
+
from ssh_wrapper.errors import SSHError
|
|
20
|
+
from ssh_wrapper.remote_process import BoundedTail, OwnedRemoteProcess
|
|
21
|
+
|
|
22
|
+
from .config import XpraConfig
|
|
23
|
+
from .live_config import (
|
|
24
|
+
command_cli_options,
|
|
25
|
+
network_profile,
|
|
26
|
+
production_encoding,
|
|
27
|
+
production_transport_options,
|
|
28
|
+
static_cli_options,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
LOCAL_PROCESS_STOP_TIMEOUT = 5.0
|
|
32
|
+
CAPABILITY_TIMEOUT = 15.0
|
|
33
|
+
OPENGL_CAPABILITY_TIMEOUT = 45.0
|
|
34
|
+
CLIENT_DIAGNOSTIC_LINES = 8
|
|
35
|
+
CLIENT_DIAGNOSTIC_CHARACTERS = 2 * 1024
|
|
36
|
+
CLIENT_DIAGNOSTIC_MARKERS = (
|
|
37
|
+
"drm",
|
|
38
|
+
"error",
|
|
39
|
+
"failed",
|
|
40
|
+
"failure",
|
|
41
|
+
"connection",
|
|
42
|
+
"disconnect",
|
|
43
|
+
"handshake",
|
|
44
|
+
"invalid",
|
|
45
|
+
"metadata",
|
|
46
|
+
"protocol",
|
|
47
|
+
"renderer",
|
|
48
|
+
"segmentation",
|
|
49
|
+
"socket",
|
|
50
|
+
"timeout",
|
|
51
|
+
"unsupported",
|
|
52
|
+
"warning",
|
|
53
|
+
"wayland",
|
|
54
|
+
)
|
|
55
|
+
ANSI_ESCAPE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|[@-_])")
|
|
56
|
+
DISPLAY_PATTERN = re.compile(r"wayland-[0-9]+")
|
|
57
|
+
REMOTE_SESSIONS_DIR = "$XDG_RUNTIME_DIR/xpra"
|
|
58
|
+
REMOTE_SOCKET_DIR = "$XDG_RUNTIME_DIR/xpra"
|
|
59
|
+
SERVER_RUNTIME_PATHS = {
|
|
60
|
+
"--socket-dir": REMOTE_SOCKET_DIR,
|
|
61
|
+
"--socket-dirs": REMOTE_SOCKET_DIR,
|
|
62
|
+
"--sessions-dir": REMOTE_SESSIONS_DIR,
|
|
63
|
+
}
|
|
64
|
+
SERVER_SECURITY_OPTIONS = (
|
|
65
|
+
"--start-new-commands=no",
|
|
66
|
+
"--dbus=no",
|
|
67
|
+
"--mdns=no",
|
|
68
|
+
"--ssh-upgrade=no",
|
|
69
|
+
"--audio=no",
|
|
70
|
+
"--webcam=no",
|
|
71
|
+
"--printing=no",
|
|
72
|
+
"--file-transfer=no",
|
|
73
|
+
"--open-files=no",
|
|
74
|
+
"--open-url=no",
|
|
75
|
+
"--notifications=no",
|
|
76
|
+
)
|
|
77
|
+
CLIENT_SECURITY_OPTIONS = (
|
|
78
|
+
"--title=@title@",
|
|
79
|
+
"--clipboard=no",
|
|
80
|
+
"--remote-logging=no",
|
|
81
|
+
"--tray=no",
|
|
82
|
+
"--splash=no",
|
|
83
|
+
"--audio=no",
|
|
84
|
+
"--webcam=no",
|
|
85
|
+
"--printing=no",
|
|
86
|
+
"--file-transfer=no",
|
|
87
|
+
"--open-files=no",
|
|
88
|
+
"--open-url=no",
|
|
89
|
+
"--notifications=no",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _translate_server_runtime_options(
|
|
94
|
+
options: tuple[str, ...],
|
|
95
|
+
) -> tuple[tuple[str, ...], frozenset[str]]:
|
|
96
|
+
"""Translate canonical container-private paths without copying their values."""
|
|
97
|
+
translated: list[str] = []
|
|
98
|
+
replaced: set[str] = set()
|
|
99
|
+
for option in options:
|
|
100
|
+
name, separator, _value = option.partition("=")
|
|
101
|
+
replacement = SERVER_RUNTIME_PATHS.get(name)
|
|
102
|
+
if replacement is None:
|
|
103
|
+
translated.append(option)
|
|
104
|
+
continue
|
|
105
|
+
if not separator or name in replaced:
|
|
106
|
+
raise RuntimeError("the mirrored server runtime-path contract changed")
|
|
107
|
+
translated.append(f"{name}={replacement}")
|
|
108
|
+
replaced.add(name)
|
|
109
|
+
return tuple(translated), frozenset(replaced)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _production_server_base_options() -> tuple[str, ...]:
|
|
113
|
+
"""Translate only the fork's container-private paths to the owned runtime."""
|
|
114
|
+
translated, replaced = _translate_server_runtime_options(
|
|
115
|
+
static_cli_options("server", "base")
|
|
116
|
+
)
|
|
117
|
+
if replaced != set(SERVER_RUNTIME_PATHS):
|
|
118
|
+
raise RuntimeError("the mirrored server runtime-path contract changed")
|
|
119
|
+
return translated
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _production_server_info_options(
|
|
123
|
+
display: str, options: tuple[str, ...]
|
|
124
|
+
) -> tuple[str, ...]:
|
|
125
|
+
"""Translate the canonical server-info target to the owned live session."""
|
|
126
|
+
if DISPLAY_PATTERN.fullmatch(display) is None:
|
|
127
|
+
raise RuntimeError("the remote Xpra display is invalid")
|
|
128
|
+
translated_options, replaced = _translate_server_runtime_options(options)
|
|
129
|
+
positional = tuple(
|
|
130
|
+
index
|
|
131
|
+
for index, option in enumerate(translated_options)
|
|
132
|
+
if not option.startswith("-")
|
|
133
|
+
)
|
|
134
|
+
if len(positional) != 1 or not replaced:
|
|
135
|
+
raise RuntimeError("the mirrored server info command contract changed")
|
|
136
|
+
translated = list(translated_options)
|
|
137
|
+
translated[positional[0]] = display
|
|
138
|
+
return tuple(translated)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def build_xpra_command_argv(
|
|
142
|
+
role: str,
|
|
143
|
+
command: str,
|
|
144
|
+
xpra_path: str | Path,
|
|
145
|
+
*,
|
|
146
|
+
display: str | None = None,
|
|
147
|
+
uri: str | None = None,
|
|
148
|
+
ssh_wrapper: str | Path | None = None,
|
|
149
|
+
) -> tuple[str, ...]:
|
|
150
|
+
"""Build one canonical auxiliary Xpra command for production or live use."""
|
|
151
|
+
options = command_cli_options(role, command)
|
|
152
|
+
if command == "version" and display is None and uri is None and ssh_wrapper is None:
|
|
153
|
+
return (str(xpra_path), *options)
|
|
154
|
+
if (
|
|
155
|
+
role == "server"
|
|
156
|
+
and command == "info"
|
|
157
|
+
and display is not None
|
|
158
|
+
and uri is None
|
|
159
|
+
and ssh_wrapper is None
|
|
160
|
+
):
|
|
161
|
+
return (
|
|
162
|
+
str(xpra_path),
|
|
163
|
+
command,
|
|
164
|
+
*_production_server_info_options(display, options),
|
|
165
|
+
)
|
|
166
|
+
if (
|
|
167
|
+
role == "client"
|
|
168
|
+
and command == "detach"
|
|
169
|
+
and display is None
|
|
170
|
+
and uri is not None
|
|
171
|
+
and ssh_wrapper is not None
|
|
172
|
+
):
|
|
173
|
+
return (
|
|
174
|
+
str(xpra_path),
|
|
175
|
+
command,
|
|
176
|
+
uri,
|
|
177
|
+
f"--ssh={ssh_wrapper}",
|
|
178
|
+
*options,
|
|
179
|
+
)
|
|
180
|
+
raise RuntimeError("the mirrored auxiliary Xpra command context is invalid")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _gpu_mode(profile: str) -> str:
|
|
184
|
+
return "yes" if production_encoding(profile) == "h264" else "auto"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _option_names(*blocks: tuple[str, ...]) -> tuple[str, ...]:
|
|
188
|
+
"""Return unique long-option names in source order, without copying tables."""
|
|
189
|
+
names: list[str] = []
|
|
190
|
+
for option in (value for block in blocks for value in block):
|
|
191
|
+
if not option.startswith("--"):
|
|
192
|
+
continue
|
|
193
|
+
name = option.partition("=")[0].replace("_", "-")
|
|
194
|
+
if name not in names:
|
|
195
|
+
names.append(name)
|
|
196
|
+
return tuple(names)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def build_server_argv(
|
|
200
|
+
application: tuple[str, ...],
|
|
201
|
+
session_name: str,
|
|
202
|
+
encoding_profile: str,
|
|
203
|
+
) -> tuple[str, ...]:
|
|
204
|
+
"""Build one production Wayland server command from the mirrored profile."""
|
|
205
|
+
return (
|
|
206
|
+
"env",
|
|
207
|
+
f"XPRA_WAYLAND_GPU={_gpu_mode(encoding_profile)}",
|
|
208
|
+
"xpra",
|
|
209
|
+
"seamless",
|
|
210
|
+
*_production_server_base_options(),
|
|
211
|
+
f"--session-name={session_name}",
|
|
212
|
+
f"--start-child-after-connect={shlex.join(application)}",
|
|
213
|
+
*static_cli_options("server", "lifecycle"),
|
|
214
|
+
*production_transport_options("server", encoding_profile),
|
|
215
|
+
*SERVER_SECURITY_OPTIONS,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
SESSION_METADATA_PROBE = r"""\
|
|
220
|
+
import hashlib
|
|
221
|
+
import json
|
|
222
|
+
import os
|
|
223
|
+
import stat
|
|
224
|
+
import sys
|
|
225
|
+
|
|
226
|
+
NOT_FOUND = 1
|
|
227
|
+
MISMATCH = 2
|
|
228
|
+
INVALID_RUNTIME = 3
|
|
229
|
+
MAX_FILE_SIZE = 65536
|
|
230
|
+
PROC_ROOT = "/proc"
|
|
231
|
+
|
|
232
|
+
def fail(status, reason):
|
|
233
|
+
if status != NOT_FOUND:
|
|
234
|
+
print(f"metadata-{reason}", file=sys.stderr)
|
|
235
|
+
raise SystemExit(status)
|
|
236
|
+
|
|
237
|
+
def owned_stat(path, kind):
|
|
238
|
+
try:
|
|
239
|
+
info = os.lstat(path)
|
|
240
|
+
except FileNotFoundError:
|
|
241
|
+
return None
|
|
242
|
+
except OSError:
|
|
243
|
+
fail(MISMATCH, "stat-failed")
|
|
244
|
+
if info.st_uid != os.getuid() or info.st_mode & 0o022:
|
|
245
|
+
fail(MISMATCH, "unsafe-owner-or-mode")
|
|
246
|
+
if kind == "directory" and not stat.S_ISDIR(info.st_mode):
|
|
247
|
+
fail(MISMATCH, "not-directory")
|
|
248
|
+
if kind == "file" and not stat.S_ISREG(info.st_mode):
|
|
249
|
+
fail(MISMATCH, "not-file")
|
|
250
|
+
if kind == "socket" and not stat.S_ISSOCK(info.st_mode):
|
|
251
|
+
fail(MISMATCH, "not-socket")
|
|
252
|
+
return info
|
|
253
|
+
|
|
254
|
+
def read_file(path):
|
|
255
|
+
if owned_stat(path, "file") is None:
|
|
256
|
+
return None
|
|
257
|
+
try:
|
|
258
|
+
with open(path, "rb") as source:
|
|
259
|
+
value = source.read(MAX_FILE_SIZE + 1)
|
|
260
|
+
except OSError:
|
|
261
|
+
fail(MISMATCH, "read-failed")
|
|
262
|
+
if len(value) > MAX_FILE_SIZE or b"\0" in value:
|
|
263
|
+
fail(MISMATCH, "invalid-file-content")
|
|
264
|
+
try:
|
|
265
|
+
return value.decode("utf-8", errors="strict")
|
|
266
|
+
except UnicodeDecodeError:
|
|
267
|
+
fail(MISMATCH, "invalid-file-encoding")
|
|
268
|
+
|
|
269
|
+
def load_session(directory):
|
|
270
|
+
if owned_stat(directory, "directory") is None:
|
|
271
|
+
return None
|
|
272
|
+
config_text = read_file(os.path.join(directory, "config"))
|
|
273
|
+
command_text = read_file(os.path.join(directory, "cmdline"))
|
|
274
|
+
pid_text = read_file(os.path.join(directory, "server.pid"))
|
|
275
|
+
socket_info = owned_stat(os.path.join(directory, "socket"), "socket")
|
|
276
|
+
if None in (config_text, command_text, pid_text, socket_info):
|
|
277
|
+
return None
|
|
278
|
+
try:
|
|
279
|
+
pid = int(pid_text.strip())
|
|
280
|
+
except ValueError:
|
|
281
|
+
fail(MISMATCH, "invalid-pid")
|
|
282
|
+
if pid <= 0:
|
|
283
|
+
fail(MISMATCH, "invalid-pid")
|
|
284
|
+
process_dir = os.path.join(PROC_ROOT, str(pid))
|
|
285
|
+
try:
|
|
286
|
+
process_info = os.stat(process_dir)
|
|
287
|
+
with open(os.path.join(process_dir, "cmdline"), "rb") as source:
|
|
288
|
+
process_command = source.read(MAX_FILE_SIZE + 1)
|
|
289
|
+
except (FileNotFoundError, ProcessLookupError):
|
|
290
|
+
return None
|
|
291
|
+
except OSError:
|
|
292
|
+
fail(MISMATCH, "process-read-failed")
|
|
293
|
+
if process_info.st_uid != os.getuid() or len(process_command) > MAX_FILE_SIZE:
|
|
294
|
+
fail(MISMATCH, "process-owner-or-size")
|
|
295
|
+
recorded_argv = tuple(command_text.splitlines())
|
|
296
|
+
try:
|
|
297
|
+
process_argv = tuple(
|
|
298
|
+
value.decode("utf-8", errors="strict")
|
|
299
|
+
for value in process_command.rstrip(b"\0").split(b"\0")
|
|
300
|
+
)
|
|
301
|
+
except UnicodeDecodeError:
|
|
302
|
+
fail(MISMATCH, "invalid-process-encoding")
|
|
303
|
+
interpreter_prefix = (
|
|
304
|
+
len(process_argv) == len(recorded_argv) + 1
|
|
305
|
+
and os.path.basename(process_argv[0]).startswith("python")
|
|
306
|
+
and process_argv[1:] == recorded_argv
|
|
307
|
+
)
|
|
308
|
+
same_process = (
|
|
309
|
+
recorded_argv == process_argv
|
|
310
|
+
or process_argv == (" ".join(recorded_argv),)
|
|
311
|
+
or interpreter_prefix
|
|
312
|
+
)
|
|
313
|
+
if not same_process or len(recorded_argv) < 2:
|
|
314
|
+
fail(MISMATCH, "process-arguments")
|
|
315
|
+
if os.path.basename(recorded_argv[0]) != "xpra":
|
|
316
|
+
fail(MISMATCH, "unexpected-program")
|
|
317
|
+
if recorded_argv[1] != "seamless":
|
|
318
|
+
fail(MISMATCH, "unexpected-mode-argument")
|
|
319
|
+
fields = {}
|
|
320
|
+
identity_fields = {"mode", "session-name", "backend"}
|
|
321
|
+
for line in config_text.splitlines():
|
|
322
|
+
key, separator, value = line.partition("=")
|
|
323
|
+
if separator:
|
|
324
|
+
key = key.strip().replace("_", "-")
|
|
325
|
+
if key in fields and key in identity_fields:
|
|
326
|
+
fail(MISMATCH, "duplicate-config-field")
|
|
327
|
+
fields[key] = value.strip()
|
|
328
|
+
if fields.get("mode") != "seamless":
|
|
329
|
+
fail(MISMATCH, "unexpected-config-mode")
|
|
330
|
+
return fields, recorded_argv
|
|
331
|
+
|
|
332
|
+
def sessions_root():
|
|
333
|
+
runtime = os.environ.get("XDG_RUNTIME_DIR", "")
|
|
334
|
+
if not runtime:
|
|
335
|
+
runtime = f"/run/user/{os.getuid()}"
|
|
336
|
+
if not os.path.isabs(runtime):
|
|
337
|
+
fail(INVALID_RUNTIME, "relative-runtime")
|
|
338
|
+
try:
|
|
339
|
+
runtime_info = os.lstat(runtime)
|
|
340
|
+
except FileNotFoundError:
|
|
341
|
+
fail(INVALID_RUNTIME, "missing-runtime")
|
|
342
|
+
except OSError:
|
|
343
|
+
fail(INVALID_RUNTIME, "runtime-stat-failed")
|
|
344
|
+
if not stat.S_ISDIR(runtime_info.st_mode) or runtime_info.st_uid != os.getuid():
|
|
345
|
+
fail(INVALID_RUNTIME, "unsafe-runtime")
|
|
346
|
+
return os.path.join(runtime, "xpra")
|
|
347
|
+
|
|
348
|
+
operation = sys.argv[1]
|
|
349
|
+
root = sessions_root()
|
|
350
|
+
if operation == "ready" and len(sys.argv) == 5:
|
|
351
|
+
actual_display, session_name, expected_argv_sha256 = sys.argv[2:]
|
|
352
|
+
component = actual_display
|
|
353
|
+
if (
|
|
354
|
+
not component.startswith("wayland-")
|
|
355
|
+
or not component[8:].isdigit()
|
|
356
|
+
or "/" in component
|
|
357
|
+
):
|
|
358
|
+
fail(MISMATCH, "invalid-display")
|
|
359
|
+
session = load_session(os.path.join(root, component))
|
|
360
|
+
if session is None:
|
|
361
|
+
fail(NOT_FOUND, "session-not-ready")
|
|
362
|
+
fields, argv = session
|
|
363
|
+
positionals = tuple(value for value in argv[2:] if not value.startswith("--"))
|
|
364
|
+
if positionals:
|
|
365
|
+
fail(MISMATCH, "unexpected-display-argument")
|
|
366
|
+
normalized_argv = (
|
|
367
|
+
"xpra",
|
|
368
|
+
*argv[1:],
|
|
369
|
+
)
|
|
370
|
+
argv_sha256 = hashlib.sha256(
|
|
371
|
+
json.dumps(
|
|
372
|
+
list(normalized_argv),
|
|
373
|
+
ensure_ascii=False,
|
|
374
|
+
separators=(",", ":"),
|
|
375
|
+
).encode("utf-8")
|
|
376
|
+
).hexdigest()
|
|
377
|
+
if argv_sha256 != expected_argv_sha256:
|
|
378
|
+
fail(MISMATCH, "arguments")
|
|
379
|
+
if fields.get("session-name") != session_name:
|
|
380
|
+
fail(MISMATCH, "session-name")
|
|
381
|
+
if fields.get("backend") != "wayland":
|
|
382
|
+
fail(MISMATCH, "backend")
|
|
383
|
+
raise SystemExit(0)
|
|
384
|
+
fail(MISMATCH, "invalid-operation")
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
async def _terminate_local(process: asyncio.subprocess.Process | None) -> None:
|
|
389
|
+
if process is None or process.returncode is not None:
|
|
390
|
+
return
|
|
391
|
+
try:
|
|
392
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
393
|
+
except ProcessLookupError:
|
|
394
|
+
return
|
|
395
|
+
try:
|
|
396
|
+
await asyncio.wait_for(process.wait(), timeout=LOCAL_PROCESS_STOP_TIMEOUT)
|
|
397
|
+
except TimeoutError:
|
|
398
|
+
try:
|
|
399
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
400
|
+
except ProcessLookupError:
|
|
401
|
+
pass
|
|
402
|
+
await process.wait()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class XpraSession:
|
|
406
|
+
"""Own one new remote Xpra session and one local attaching client."""
|
|
407
|
+
|
|
408
|
+
def __init__(self, config: XpraConfig) -> None:
|
|
409
|
+
self.config = config
|
|
410
|
+
self.session_id = secrets.token_hex(16)
|
|
411
|
+
executable_name = config.application[0].rsplit("/", maxsplit=1)[-1]
|
|
412
|
+
safe_name = re.sub(r"[^A-Za-z0-9._-]+", "-", executable_name).strip(".-")
|
|
413
|
+
self.application_name = (safe_name[:64] or "application").lower()
|
|
414
|
+
self.session_name = self.application_name
|
|
415
|
+
self.master = OpenSSHMaster(
|
|
416
|
+
SSHMasterSettings(
|
|
417
|
+
ssh_path=config.ssh_path,
|
|
418
|
+
false_path=config.false_path,
|
|
419
|
+
connect_timeout=config.connect_timeout,
|
|
420
|
+
runtime_prefix="elsewindow",
|
|
421
|
+
),
|
|
422
|
+
config.connection,
|
|
423
|
+
)
|
|
424
|
+
self.remote: OwnedRemoteProcess | None = None
|
|
425
|
+
self.client: asyncio.subprocess.Process | None = None
|
|
426
|
+
self.client_stdout = BoundedTail()
|
|
427
|
+
self.client_stderr = BoundedTail()
|
|
428
|
+
self._client_drains: tuple[asyncio.Task[None], ...] = ()
|
|
429
|
+
self._wrapper: Path | None = None
|
|
430
|
+
self._remote_display: str | None = None
|
|
431
|
+
|
|
432
|
+
@staticmethod
|
|
433
|
+
def _require_options(output: bytes, options: tuple[str, ...], side: str) -> None:
|
|
434
|
+
normalized = output.decode("utf-8", errors="replace").replace("_", "-")
|
|
435
|
+
missing = [option for option in options if option not in normalized]
|
|
436
|
+
if missing:
|
|
437
|
+
raise SSHError(
|
|
438
|
+
"xpra_incompatible",
|
|
439
|
+
f"{side} Xpra does not expose the required lifecycle options",
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def _local_required_options(self) -> tuple[str, ...]:
|
|
443
|
+
return _option_names(
|
|
444
|
+
("--ssh=owned",),
|
|
445
|
+
static_cli_options("client", "base"),
|
|
446
|
+
network_profile(self.config.network_profile).client_options(),
|
|
447
|
+
production_transport_options("client", self.config.encoding_profile),
|
|
448
|
+
CLIENT_SECURITY_OPTIONS,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
def _remote_required_options(self) -> tuple[str, ...]:
|
|
452
|
+
return _option_names(
|
|
453
|
+
_production_server_base_options(),
|
|
454
|
+
("--session-name=owned", "--start-child-after-connect=owned"),
|
|
455
|
+
static_cli_options("server", "lifecycle"),
|
|
456
|
+
production_transport_options("server", self.config.encoding_profile),
|
|
457
|
+
SERVER_SECURITY_OPTIONS,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
async def _run_local(
|
|
461
|
+
self, argv: list[str], timeout: float
|
|
462
|
+
) -> tuple[int, bytes, bytes]:
|
|
463
|
+
try:
|
|
464
|
+
process = await asyncio.create_subprocess_exec(
|
|
465
|
+
*argv,
|
|
466
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
467
|
+
stdout=asyncio.subprocess.PIPE,
|
|
468
|
+
stderr=asyncio.subprocess.PIPE,
|
|
469
|
+
start_new_session=True,
|
|
470
|
+
)
|
|
471
|
+
except OSError as error:
|
|
472
|
+
raise SSHError(
|
|
473
|
+
"xpra_start_failed", "cannot start the local Xpra client"
|
|
474
|
+
) from error
|
|
475
|
+
try:
|
|
476
|
+
stdout, stderr = await asyncio.wait_for(
|
|
477
|
+
process.communicate(), timeout=timeout
|
|
478
|
+
)
|
|
479
|
+
except TimeoutError:
|
|
480
|
+
await _terminate_local(process)
|
|
481
|
+
return 124, b"", b""
|
|
482
|
+
return process.returncode or 0, stdout, stderr
|
|
483
|
+
|
|
484
|
+
async def _run_mux(
|
|
485
|
+
self, remote_program: str, timeout: float = CAPABILITY_TIMEOUT
|
|
486
|
+
) -> tuple[int, bytes, bytes]:
|
|
487
|
+
await self.master.ensure_ready()
|
|
488
|
+
process = await asyncio.create_subprocess_exec(
|
|
489
|
+
*self.master.command_argv(remote_program),
|
|
490
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
491
|
+
stdout=asyncio.subprocess.PIPE,
|
|
492
|
+
stderr=asyncio.subprocess.PIPE,
|
|
493
|
+
start_new_session=True,
|
|
494
|
+
)
|
|
495
|
+
try:
|
|
496
|
+
stdout, stderr = await asyncio.wait_for(
|
|
497
|
+
process.communicate(), timeout=timeout
|
|
498
|
+
)
|
|
499
|
+
except TimeoutError:
|
|
500
|
+
await _terminate_local(process)
|
|
501
|
+
raise SSHError(
|
|
502
|
+
"xpra_probe_timeout", "remote Xpra probe timed out"
|
|
503
|
+
) from None
|
|
504
|
+
return process.returncode or 0, stdout, stderr
|
|
505
|
+
|
|
506
|
+
async def _check_local_capabilities(self) -> None:
|
|
507
|
+
help_status, stdout, stderr = await self._run_local(
|
|
508
|
+
[str(self.config.xpra_path), "attach", "--help"], CAPABILITY_TIMEOUT
|
|
509
|
+
)
|
|
510
|
+
if help_status != 0:
|
|
511
|
+
raise SSHError("xpra_incompatible", "local Xpra capability probe failed")
|
|
512
|
+
self._require_options(stdout + stderr, self._local_required_options(), "local")
|
|
513
|
+
if production_encoding(self.config.encoding_profile) == "h264":
|
|
514
|
+
opengl_status, stdout, _stderr = await self._run_local(
|
|
515
|
+
[str(self.config.xpra_path), "opengl"],
|
|
516
|
+
OPENGL_CAPABILITY_TIMEOUT,
|
|
517
|
+
)
|
|
518
|
+
properties = {}
|
|
519
|
+
for line in stdout.decode("utf-8", errors="replace").splitlines():
|
|
520
|
+
key, separator, value = line.partition("=")
|
|
521
|
+
if separator:
|
|
522
|
+
properties[key.strip()] = value.strip().lower()
|
|
523
|
+
if (
|
|
524
|
+
opengl_status != 0
|
|
525
|
+
or properties.get("success") != "true"
|
|
526
|
+
or properties.get("safe") != "true"
|
|
527
|
+
):
|
|
528
|
+
raise SSHError(
|
|
529
|
+
"xpra_incompatible",
|
|
530
|
+
"local Xpra OpenGL renderer is unavailable or unsafe",
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
async def _check_remote_capabilities(self) -> None:
|
|
534
|
+
status, stdout, stderr = await self._run_mux(
|
|
535
|
+
"command -v python3 >/dev/null && xpra seamless --help"
|
|
536
|
+
)
|
|
537
|
+
if status != 0:
|
|
538
|
+
raise SSHError("xpra_incompatible", "remote Xpra capability probe failed")
|
|
539
|
+
self._require_options(
|
|
540
|
+
stdout + stderr, self._remote_required_options(), "remote"
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
def server_argv(self) -> tuple[str, ...]:
|
|
544
|
+
"""Return the exact foreground remote Xpra server argv."""
|
|
545
|
+
return build_server_argv(
|
|
546
|
+
self.config.application,
|
|
547
|
+
self.session_name,
|
|
548
|
+
self.config.encoding_profile,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
def _capture_remote_display(self) -> None:
|
|
552
|
+
"""Capture the selected display published by Xpra."""
|
|
553
|
+
if self._remote_display is not None:
|
|
554
|
+
return
|
|
555
|
+
remote = self.remote
|
|
556
|
+
if remote is None:
|
|
557
|
+
return
|
|
558
|
+
raw = getattr(remote, "stdout_head", b"") or remote.stdout_tail.data
|
|
559
|
+
for line in raw.decode("utf-8", errors="replace").splitlines():
|
|
560
|
+
candidate = line.strip()
|
|
561
|
+
if DISPLAY_PATTERN.fullmatch(candidate):
|
|
562
|
+
self._remote_display = candidate
|
|
563
|
+
return
|
|
564
|
+
|
|
565
|
+
def _uri(self) -> str:
|
|
566
|
+
"""Return the owned session URI after its actual display is known."""
|
|
567
|
+
display = self._remote_display
|
|
568
|
+
if display is None:
|
|
569
|
+
raise RuntimeError("remote Xpra display is not initialized")
|
|
570
|
+
return f"{self.config.authority_uri}/{display}"
|
|
571
|
+
|
|
572
|
+
def readiness_probe(self) -> str:
|
|
573
|
+
"""Return the non-connecting remote session-readiness probe."""
|
|
574
|
+
display = self._remote_display
|
|
575
|
+
if display is None:
|
|
576
|
+
raise RuntimeError("remote Xpra display is not initialized")
|
|
577
|
+
server = self.server_argv()
|
|
578
|
+
normalized_argv = ("xpra", *server[3:])
|
|
579
|
+
argv_sha256 = hashlib.sha256(
|
|
580
|
+
json.dumps(
|
|
581
|
+
list(normalized_argv),
|
|
582
|
+
ensure_ascii=False,
|
|
583
|
+
separators=(",", ":"),
|
|
584
|
+
).encode("utf-8")
|
|
585
|
+
).hexdigest()
|
|
586
|
+
return shlex.join(
|
|
587
|
+
(
|
|
588
|
+
"python3",
|
|
589
|
+
"-c",
|
|
590
|
+
SESSION_METADATA_PROBE,
|
|
591
|
+
"ready",
|
|
592
|
+
display,
|
|
593
|
+
self.session_name,
|
|
594
|
+
argv_sha256,
|
|
595
|
+
)
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
def attach_argv(self) -> list[str]:
|
|
599
|
+
"""Return the exact mirrored production attach profile."""
|
|
600
|
+
if self._wrapper is None:
|
|
601
|
+
raise RuntimeError("SSH wrapper is not initialized")
|
|
602
|
+
return [
|
|
603
|
+
str(self.config.xpra_path),
|
|
604
|
+
"attach",
|
|
605
|
+
self._uri(),
|
|
606
|
+
f"--ssh={self._wrapper}",
|
|
607
|
+
*static_cli_options("client", "base"),
|
|
608
|
+
*network_profile(self.config.network_profile).client_options(),
|
|
609
|
+
*production_transport_options("client", self.config.encoding_profile),
|
|
610
|
+
*CLIENT_SECURITY_OPTIONS,
|
|
611
|
+
]
|
|
612
|
+
|
|
613
|
+
async def _wait_ready(self) -> None:
|
|
614
|
+
deadline = asyncio.get_running_loop().time() + self.config.ready_timeout
|
|
615
|
+
while asyncio.get_running_loop().time() < deadline:
|
|
616
|
+
remote = self.remote
|
|
617
|
+
if remote is None or remote.returncode is not None:
|
|
618
|
+
diagnostic = self._remote_diagnostic()
|
|
619
|
+
message = "remote Xpra exited before becoming ready"
|
|
620
|
+
if diagnostic:
|
|
621
|
+
message = f"{message}: {diagnostic}"
|
|
622
|
+
raise SSHError(
|
|
623
|
+
"xpra_start_failed",
|
|
624
|
+
message,
|
|
625
|
+
)
|
|
626
|
+
self._capture_remote_display()
|
|
627
|
+
if self._remote_display is None:
|
|
628
|
+
await asyncio.sleep(self.config.poll_interval)
|
|
629
|
+
continue
|
|
630
|
+
status, _stdout, stderr = await self._run_mux(
|
|
631
|
+
self.readiness_probe(), self.config.probe_timeout
|
|
632
|
+
)
|
|
633
|
+
if status == 0:
|
|
634
|
+
return
|
|
635
|
+
if status != 1:
|
|
636
|
+
detail = self._diagnostic(stderr.decode("utf-8", errors="replace"))
|
|
637
|
+
message = "remote Xpra session metadata does not match the owned server"
|
|
638
|
+
if detail:
|
|
639
|
+
message = f"{message}: {detail}"
|
|
640
|
+
raise SSHError(
|
|
641
|
+
"xpra_identity_mismatch",
|
|
642
|
+
message,
|
|
643
|
+
)
|
|
644
|
+
await asyncio.sleep(self.config.poll_interval)
|
|
645
|
+
raise SSHError(
|
|
646
|
+
"xpra_ready_timeout", "remote Xpra did not become ready before the deadline"
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
async def _drain_client(
|
|
650
|
+
self, stream: asyncio.StreamReader, destination: BoundedTail
|
|
651
|
+
) -> None:
|
|
652
|
+
while chunk := await stream.read(4096):
|
|
653
|
+
destination.append(chunk)
|
|
654
|
+
|
|
655
|
+
async def _start_client(self) -> None:
|
|
656
|
+
try:
|
|
657
|
+
self.client = await asyncio.create_subprocess_exec(
|
|
658
|
+
*self.attach_argv(),
|
|
659
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
660
|
+
stdout=asyncio.subprocess.PIPE,
|
|
661
|
+
stderr=asyncio.subprocess.PIPE,
|
|
662
|
+
start_new_session=True,
|
|
663
|
+
)
|
|
664
|
+
except OSError as error:
|
|
665
|
+
raise SSHError(
|
|
666
|
+
"xpra_start_failed", "cannot attach the local Xpra client"
|
|
667
|
+
) from error
|
|
668
|
+
assert self.client.stdout is not None and self.client.stderr is not None
|
|
669
|
+
self._client_drains = (
|
|
670
|
+
asyncio.create_task(
|
|
671
|
+
self._drain_client(self.client.stdout, self.client_stdout)
|
|
672
|
+
),
|
|
673
|
+
asyncio.create_task(
|
|
674
|
+
self._drain_client(self.client.stderr, self.client_stderr)
|
|
675
|
+
),
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
async def _finish_client_drains(self) -> None:
|
|
679
|
+
if not self._client_drains:
|
|
680
|
+
return
|
|
681
|
+
drains, self._client_drains = self._client_drains, ()
|
|
682
|
+
await asyncio.gather(*drains, return_exceptions=True)
|
|
683
|
+
|
|
684
|
+
def _diagnostic(self, raw: str) -> str:
|
|
685
|
+
"""Bound one diagnostic line and remove owned private local paths."""
|
|
686
|
+
for path in (
|
|
687
|
+
self._wrapper,
|
|
688
|
+
self.master.control_path,
|
|
689
|
+
self.master.runtime_dir,
|
|
690
|
+
):
|
|
691
|
+
if path is not None:
|
|
692
|
+
raw = raw.replace(str(path), "<private-path>")
|
|
693
|
+
|
|
694
|
+
selected: list[str] = []
|
|
695
|
+
for raw_line in raw.splitlines():
|
|
696
|
+
line = ANSI_ESCAPE.sub("", raw_line)
|
|
697
|
+
line = "".join(character for character in line if character.isprintable())
|
|
698
|
+
line = " ".join(line.split())
|
|
699
|
+
lowered = line.lower()
|
|
700
|
+
if line and any(marker in lowered for marker in CLIENT_DIAGNOSTIC_MARKERS):
|
|
701
|
+
selected.append(line)
|
|
702
|
+
diagnostic = " | ".join(selected[-CLIENT_DIAGNOSTIC_LINES:])
|
|
703
|
+
return diagnostic[-CLIENT_DIAGNOSTIC_CHARACTERS:]
|
|
704
|
+
|
|
705
|
+
def _client_diagnostic(self) -> str:
|
|
706
|
+
raw = self.client_stderr.text() or self.client_stdout.text()
|
|
707
|
+
return self._diagnostic(raw)
|
|
708
|
+
|
|
709
|
+
def _remote_diagnostic(self) -> str:
|
|
710
|
+
remote = self.remote
|
|
711
|
+
if remote is None:
|
|
712
|
+
return ""
|
|
713
|
+
stderr = getattr(remote, "stderr_tail", None)
|
|
714
|
+
stdout = getattr(remote, "stdout_tail", None)
|
|
715
|
+
raw = stderr.text() if stderr is not None else ""
|
|
716
|
+
if not raw and stdout is not None:
|
|
717
|
+
raw = stdout.text()
|
|
718
|
+
return self._diagnostic(raw)
|
|
719
|
+
|
|
720
|
+
async def _wait_lifecycle(self) -> int:
|
|
721
|
+
remote = self.remote
|
|
722
|
+
client = self.client
|
|
723
|
+
master_process = self.master.process
|
|
724
|
+
assert remote is not None and client is not None and master_process is not None
|
|
725
|
+
client_wait = asyncio.create_task(client.wait())
|
|
726
|
+
remote_wait = asyncio.create_task(remote.wait())
|
|
727
|
+
master_wait = asyncio.create_task(master_process.wait())
|
|
728
|
+
waits = {client_wait, remote_wait, master_wait}
|
|
729
|
+
try:
|
|
730
|
+
done, _pending = await asyncio.wait(
|
|
731
|
+
waits, return_when=asyncio.FIRST_COMPLETED
|
|
732
|
+
)
|
|
733
|
+
if client_wait in done and client_wait.result() == 0:
|
|
734
|
+
return 0
|
|
735
|
+
if master_wait in done:
|
|
736
|
+
raise SSHError("connection_lost", "SSH master was lost")
|
|
737
|
+
if remote_wait in done:
|
|
738
|
+
status = remote_wait.result()
|
|
739
|
+
if status == 0:
|
|
740
|
+
return 0
|
|
741
|
+
diagnostic = self._remote_diagnostic()
|
|
742
|
+
message = f"remote Xpra exited with status {status}"
|
|
743
|
+
if diagnostic:
|
|
744
|
+
message = f"{message}: {diagnostic}"
|
|
745
|
+
raise SSHError(
|
|
746
|
+
"xpra_server_exited",
|
|
747
|
+
message,
|
|
748
|
+
)
|
|
749
|
+
status = client_wait.result()
|
|
750
|
+
await self._finish_client_drains()
|
|
751
|
+
diagnostic = self._client_diagnostic()
|
|
752
|
+
message = f"local Xpra client exited with status {status}"
|
|
753
|
+
if diagnostic:
|
|
754
|
+
message = f"{message}: {diagnostic}"
|
|
755
|
+
raise SSHError(
|
|
756
|
+
"xpra_client_failed",
|
|
757
|
+
message,
|
|
758
|
+
)
|
|
759
|
+
finally:
|
|
760
|
+
for task in waits:
|
|
761
|
+
if not task.done():
|
|
762
|
+
task.cancel()
|
|
763
|
+
await asyncio.gather(*waits, return_exceptions=True)
|
|
764
|
+
|
|
765
|
+
async def run(self) -> int:
|
|
766
|
+
"""Start, attach, wait, and clean every owned process in order."""
|
|
767
|
+
try:
|
|
768
|
+
await self._check_local_capabilities()
|
|
769
|
+
await self.master.start()
|
|
770
|
+
self._wrapper = self.master.create_mux_wrapper("xpra-ssh")
|
|
771
|
+
await self._check_remote_capabilities()
|
|
772
|
+
self.remote = OwnedRemoteProcess(
|
|
773
|
+
self.master,
|
|
774
|
+
self.server_argv(),
|
|
775
|
+
heartbeat_interval=self.config.heartbeat_interval,
|
|
776
|
+
lease_timeout=self.config.lease_timeout,
|
|
777
|
+
grace_timeout=self.config.grace_timeout,
|
|
778
|
+
)
|
|
779
|
+
await self.remote.start()
|
|
780
|
+
await self._wait_ready()
|
|
781
|
+
await self._start_client()
|
|
782
|
+
return await self._wait_lifecycle()
|
|
783
|
+
finally:
|
|
784
|
+
await _terminate_local(self.client)
|
|
785
|
+
await self._finish_client_drains()
|
|
786
|
+
if self.remote is not None:
|
|
787
|
+
await self.remote.close()
|
|
788
|
+
await self.master.close()
|