rdp-pipe 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.
rdp_pipe/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Desktop RDP client package built on aardwolf and PyQt6."""
@@ -0,0 +1,19 @@
1
+ """Touch a filepath when remote framebuffer updates arrive."""
2
+
3
+ import logging
4
+
5
+ _LOGGER = logging.getLogger(__name__)
6
+
7
+
8
+ def touch_activity_stamp_file(activity_stamp_filepath: str) -> None:
9
+ """Create or update mtime on the activity stamp file."""
10
+
11
+ try:
12
+ with open(activity_stamp_filepath, "a"):
13
+ pass
14
+
15
+ except OSError as error:
16
+ _LOGGER.warning(
17
+ "activity stamp touch failed filepath={activity_stamp_filepath} error={error}".format(
18
+ activity_stamp_filepath=activity_stamp_filepath,
19
+ error=error))
@@ -0,0 +1,414 @@
1
+ """Unix domain socket JSON-line command server for RDP automation."""
2
+
3
+ import asyncio
4
+ import datetime
5
+ import json
6
+ import logging
7
+ import os
8
+ import socket
9
+ import sys
10
+ import threading
11
+ import time
12
+ import traceback
13
+
14
+ import rdp_pipe.rdp_input
15
+ import rdp_pipe.rdp_session_core
16
+ import rdp_pipe.runtime_paths
17
+
18
+
19
+ _LOGGER = logging.getLogger(__name__)
20
+
21
+ COMMAND_HANDLER_TIMEOUT_SECONDS = 30.0
22
+
23
+ SUPPORTED_COMMANDS = (
24
+ "receive_screenshot",
25
+ "send_click",
26
+ "receive_geometry",
27
+ "send_geometry",
28
+ "send_key",
29
+ )
30
+
31
+
32
+ def build_success_response(result: dict) -> str:
33
+ """Serialize a successful command response."""
34
+
35
+ response_body = {
36
+ "ok": True,
37
+ "result": result,
38
+ }
39
+
40
+ return json.dumps(response_body, separators=(",", ":"))
41
+
42
+
43
+ def build_error_response(error_message: str) -> str:
44
+ """Serialize a failed command response."""
45
+
46
+ response_body = {
47
+ "ok": False,
48
+ "error": error_message,
49
+ }
50
+
51
+ return json.dumps(response_body, separators=(",", ":"))
52
+
53
+
54
+ def parse_command_request(request_line: str) -> dict:
55
+ """Parse one JSON-line command request."""
56
+
57
+ if request_line == "":
58
+ raise ValueError("empty request line")
59
+
60
+ request_body = json.loads(request_line)
61
+
62
+ if "command" not in request_body:
63
+ raise ValueError("request missing command field")
64
+
65
+ return request_body
66
+
67
+
68
+ def extract_command_name_from_request_line(request_line: str) -> str:
69
+ """Return the command field from a request line, or unknown when not parseable."""
70
+
71
+ if request_line == "":
72
+ return "unknown"
73
+
74
+ try:
75
+ request_body = json.loads(request_line)
76
+
77
+ except json.JSONDecodeError:
78
+ return "unknown"
79
+
80
+ try:
81
+ return request_body["command"]
82
+
83
+ except KeyError:
84
+ return "unknown"
85
+
86
+
87
+ def build_command_transaction_log_line(
88
+ timestamp_text: str,
89
+ command_name: str,
90
+ request_size: int,
91
+ response_size: int,
92
+ response_success: bool,
93
+ transaction_duration_seconds: float) -> str:
94
+ """Serialize one headless command transaction log entry as a JSON line."""
95
+
96
+ log_body = {
97
+ "timestamp": timestamp_text,
98
+ "command": command_name,
99
+ "request_size": request_size,
100
+ "response_size": response_size,
101
+ "response_success": response_success,
102
+ "transaction_duration_seconds": round(transaction_duration_seconds, 2),
103
+ }
104
+
105
+ log_line = json.dumps(log_body, separators=(",", ":"))
106
+ log_line = log_line + "\n"
107
+
108
+ return log_line
109
+
110
+
111
+ def write_command_transaction_log_line(log_line: str):
112
+ """Write one transaction log JSON line to stdout for headless operators."""
113
+
114
+ sys.stdout.write(log_line)
115
+ sys.stdout.flush()
116
+
117
+
118
+ DEFAULT_COMMAND_SOCKET_PATH = \
119
+ rdp_pipe.runtime_paths.build_default_command_socket_filepath()
120
+
121
+
122
+ class CommandSocketClientError(Exception):
123
+ """Remote command socket returned ok false or an invalid response envelope."""
124
+
125
+
126
+ def build_command_request_body(command_name: str, **fields) -> dict:
127
+ """Build one wire-protocol command request object."""
128
+
129
+ request_body = {"command": command_name}
130
+
131
+ for field_name in fields.keys():
132
+ request_body[field_name] = fields[field_name]
133
+
134
+ return request_body
135
+
136
+
137
+ def send_command_request(socket_path: str, request_body: dict) -> dict:
138
+ """Send one JSON-line command and return the parsed response envelope."""
139
+
140
+ request_line = json.dumps(request_body, separators=(",", ":"))
141
+ request_line = request_line + "\n"
142
+
143
+ client_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
144
+ client_socket.connect(socket_path)
145
+
146
+ try:
147
+ client_socket.sendall(request_line.encode("utf-8"))
148
+
149
+ response_buffer = b""
150
+
151
+ while b"\n" not in response_buffer:
152
+ received_chunk = client_socket.recv(4096)
153
+
154
+ if received_chunk == b"":
155
+ raise ConnectionError(
156
+ "command socket closed before response line for command {command_name}".format(
157
+ command_name=request_body["command"]))
158
+
159
+ response_buffer = response_buffer + received_chunk
160
+
161
+ response_line = response_buffer.split(b"\n", 1)[0].decode("utf-8")
162
+ response_body = json.loads(response_line)
163
+
164
+ finally:
165
+ client_socket.close()
166
+
167
+ if "ok" not in response_body:
168
+ raise CommandSocketClientError(
169
+ "command socket response missing ok field for command {command_name}".format(
170
+ command_name=request_body["command"]))
171
+
172
+ if response_body["ok"] is False:
173
+ error_message = response_body["error"]
174
+ raise CommandSocketClientError(error_message)
175
+
176
+ return response_body
177
+
178
+
179
+ class CommandSocketServer:
180
+ """Serve newline-delimited JSON commands over a Unix domain socket."""
181
+
182
+ def __init__(
183
+ self,
184
+ socket_path: str,
185
+ session: rdp_pipe.rdp_session_core.RdpAsyncSession,
186
+ *,
187
+ log_command_transactions: bool = False):
188
+ """Bind session handlers to a Unix socket at socket_path."""
189
+
190
+ self._socket_path = socket_path
191
+ self._session = session
192
+ self._log_command_transactions = log_command_transactions
193
+ self._stop_event = threading.Event()
194
+ self._server_thread = None
195
+ self._listen_socket = None
196
+
197
+ def start(self):
198
+ """Start accepting command socket clients on a background thread."""
199
+
200
+ self._server_thread = threading.Thread(
201
+ target=self._server_thread_main,
202
+ name="rdp-command-socket")
203
+
204
+ self._server_thread.start()
205
+
206
+ def set_session(self, session: rdp_pipe.rdp_session_core.RdpAsyncSession):
207
+ """Point automation commands at a new session after reconnect."""
208
+
209
+ self._session = session
210
+
211
+ def stop(self):
212
+ """Stop the accept loop and close the listening socket."""
213
+
214
+ self._stop_event.set()
215
+
216
+ if self._listen_socket is not None:
217
+ try:
218
+ self._listen_socket.close()
219
+
220
+ except OSError:
221
+ pass
222
+
223
+ if self._server_thread is not None:
224
+ self._server_thread.join(timeout=2.0)
225
+
226
+ def _server_thread_main(self):
227
+ """Accept one client at a time and process JSON-line commands."""
228
+
229
+ if os.path.exists(self._socket_path):
230
+ os.unlink(self._socket_path)
231
+
232
+ self._listen_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
233
+ self._listen_socket.bind(self._socket_path)
234
+ self._listen_socket.listen(1)
235
+
236
+ while not self._stop_event.is_set():
237
+ try:
238
+ self._listen_socket.settimeout(1.0)
239
+ client_socket, _client_address = self._listen_socket.accept()
240
+
241
+ except (TimeoutError, socket.timeout):
242
+ continue
243
+
244
+ except OSError:
245
+ if self._stop_event.is_set():
246
+ break
247
+
248
+ raise
249
+
250
+ self._serve_client(client_socket)
251
+ client_socket.close()
252
+
253
+ def _serve_client(self, client_socket: socket.socket):
254
+ """Read requests from one client until disconnect."""
255
+
256
+ input_file = client_socket.makefile(mode="r", encoding="utf-8")
257
+ output_file = client_socket.makefile(mode="w", encoding="utf-8")
258
+
259
+ try:
260
+ while not self._stop_event.is_set():
261
+ request_line = input_file.readline()
262
+
263
+ if request_line == "":
264
+ return
265
+
266
+ request_payload = request_line.rstrip("\n")
267
+ request_size = len(request_payload.encode("utf-8"))
268
+ transaction_started_at = time.perf_counter()
269
+ response_line = self._dispatch_request_line(request_payload)
270
+ transaction_duration_seconds = \
271
+ time.perf_counter() - transaction_started_at
272
+
273
+ output_file.write(response_line)
274
+ output_file.write("\n")
275
+ output_file.flush()
276
+
277
+ if self._log_command_transactions:
278
+ response_size = len(response_line.encode("utf-8"))
279
+ response_body = json.loads(response_line)
280
+ response_success = response_body["ok"]
281
+ command_name = extract_command_name_from_request_line(request_payload)
282
+ timestamp_text = \
283
+ datetime.datetime.now(datetime.timezone.utc).astimezone().isoformat(
284
+ timespec="milliseconds")
285
+ log_line = build_command_transaction_log_line(
286
+ timestamp_text,
287
+ command_name,
288
+ request_size,
289
+ response_size,
290
+ response_success,
291
+ transaction_duration_seconds)
292
+
293
+ write_command_transaction_log_line(log_line)
294
+
295
+ finally:
296
+ input_file.close()
297
+ output_file.close()
298
+
299
+ def _dispatch_request_line(self, request_line: str) -> str:
300
+ """Parse and execute one command request."""
301
+
302
+ try:
303
+ request_body = parse_command_request(request_line)
304
+
305
+ except ValueError as error:
306
+ return build_error_response(str(error))
307
+
308
+ except json.JSONDecodeError as error:
309
+ return build_error_response(
310
+ "invalid JSON: {message}".format(message=str(error)))
311
+
312
+ command_name = request_body["command"]
313
+
314
+ if command_name not in SUPPORTED_COMMANDS:
315
+ return build_error_response(
316
+ "unknown command {command_name}".format(command_name=command_name))
317
+
318
+ try:
319
+ result = self._execute_command_on_session_loop(request_body)
320
+
321
+ return build_success_response(result)
322
+
323
+ except rdp_pipe.rdp_input.RdpInputError as error:
324
+ return build_error_response(str(error))
325
+
326
+ except rdp_pipe.rdp_session_core.RdpSessionError as error:
327
+ return build_error_response(str(error))
328
+
329
+ except Exception as error:
330
+ traceback.print_exc()
331
+
332
+ return build_error_response(
333
+ "command failed: {message}".format(message=str(error)))
334
+
335
+ def _execute_command_on_session_loop(self, request_body: dict) -> dict:
336
+ """Marshal command execution onto the RDP asyncio loop."""
337
+
338
+ event_loop = self._session.event_loop
339
+ if event_loop is None:
340
+ raise rdp_pipe.rdp_session_core.RdpSessionError(
341
+ "RDP session is reconnecting")
342
+
343
+ command_future = asyncio.run_coroutine_threadsafe(
344
+ self._handle_command(request_body),
345
+ event_loop)
346
+
347
+ return command_future.result(timeout=COMMAND_HANDLER_TIMEOUT_SECONDS)
348
+
349
+ async def _handle_command(self, request_body: dict) -> dict:
350
+ """Dispatch a parsed command to the session."""
351
+
352
+ command_name = request_body["command"]
353
+
354
+ if command_name == "receive_screenshot":
355
+ image_format = "png"
356
+ if "format" in request_body:
357
+ image_format = request_body["format"]
358
+
359
+ quality = 9
360
+ if "quality" in request_body:
361
+ quality = int(request_body["quality"])
362
+
363
+ return await self._session.handle_receive_screenshot(
364
+ image_format,
365
+ quality)
366
+
367
+ if command_name == "receive_geometry":
368
+ return await self._session.handle_receive_geometry()
369
+
370
+ if command_name == "send_geometry":
371
+ width_value = None
372
+ height_value = None
373
+
374
+ try:
375
+ width_value = int(request_body["width"])
376
+ except KeyError:
377
+ pass
378
+
379
+ try:
380
+ height_value = int(request_body["height"])
381
+ except KeyError:
382
+ pass
383
+
384
+ return await self._session.handle_send_geometry(
385
+ width_value,
386
+ height_value)
387
+
388
+ if command_name == "send_click":
389
+ if "x" not in request_body or "y" not in request_body:
390
+ raise ValueError("send_click requires x and y")
391
+
392
+ button = "left"
393
+ if "button" in request_body:
394
+ button = request_body["button"]
395
+
396
+ return await self._session.handle_send_click(
397
+ int(request_body["x"]),
398
+ int(request_body["y"]),
399
+ button)
400
+
401
+ if command_name == "send_key":
402
+ keys_value = None
403
+ key_value = None
404
+
405
+ if "keys" in request_body:
406
+ keys_value = request_body["keys"]
407
+
408
+ if "key" in request_body:
409
+ key_value = request_body["key"]
410
+
411
+ return await self._session.handle_send_key(keys_value, key_value)
412
+
413
+ raise ValueError(
414
+ "unsupported command {command_name}".format(command_name=command_name))
@@ -0,0 +1 @@
1
+ """Configuration mirrors for rdp_pipe."""
@@ -0,0 +1 @@
1
+ """Entrypoint config mirrors for rdp_pipe operator CLIs."""
@@ -0,0 +1,3 @@
1
+ """Config mirrors for the rdpr CLI entrypoint."""
2
+
3
+ PROG_NAME = "rdpr"
@@ -0,0 +1,126 @@
1
+ """Classify RDP connect failures and format operator-facing stderr messages."""
2
+
3
+ import asyncio
4
+ import errno
5
+ import ssl
6
+ import urllib.parse
7
+
8
+ import rdp_pipe.rdp_session_core
9
+ import rdp_pipe.trust_store
10
+
11
+ DEFAULT_RDP_PORT = 3389
12
+ _SERVER_ERROR_PREFIX = "Server replied with error"
13
+
14
+
15
+ def parse_connection_endpoint(connection_url: str) -> str:
16
+ """Return host:port for operator messages parsed from a connection URL."""
17
+
18
+ parsed_url = urllib.parse.urlparse(connection_url)
19
+ hostname = parsed_url.hostname
20
+
21
+ if parsed_url.port is not None:
22
+ port = parsed_url.port
23
+ else:
24
+ port = DEFAULT_RDP_PORT
25
+
26
+ return "{hostname}:{port}".format(hostname=hostname, port=port)
27
+
28
+
29
+ def describe_connection_failure(error: BaseException) -> str:
30
+ """Map a connect exception to a short English reason without a traceback."""
31
+
32
+ if isinstance(error, rdp_pipe.trust_store.CertificateTrustMismatchError):
33
+ return str(error)
34
+
35
+ if isinstance(error, asyncio.TimeoutError):
36
+ return "connection timed out"
37
+
38
+ if isinstance(error, ConnectionResetError):
39
+ return "connection lost"
40
+
41
+ if isinstance(error, BrokenPipeError):
42
+ return "connection lost"
43
+
44
+ if isinstance(error, OSError):
45
+ if error.errno == errno.ECONNREFUSED:
46
+ return "connection refused"
47
+
48
+ if error.errno == errno.EHOSTUNREACH:
49
+ return "no route to host"
50
+
51
+ if error.errno == errno.ENETUNREACH:
52
+ return "network unreachable"
53
+
54
+ if error.errno == errno.ETIMEDOUT:
55
+ return "connection timed out"
56
+
57
+ if error.strerror is not None and error.strerror != "":
58
+ return error.strerror
59
+
60
+ return str(error)
61
+
62
+ if isinstance(error, ssl.SSLError):
63
+ ssl_detail = str(error)
64
+
65
+ if ssl_detail != "":
66
+ return "TLS handshake failed: {detail}".format(detail=ssl_detail)
67
+
68
+ return "TLS handshake failed"
69
+
70
+ if isinstance(error, rdp_pipe.rdp_session_core.RdpSessionError):
71
+ return str(error)
72
+
73
+ error_message = str(error)
74
+
75
+ if _SERVER_ERROR_PREFIX in error_message:
76
+ return error_message
77
+
78
+ return "connection failed"
79
+
80
+
81
+ def format_connection_failure_stderr(
82
+ connection_url: str,
83
+ error: BaseException) -> str:
84
+ """Return a managed stderr block for a failed connect attempt."""
85
+
86
+ if isinstance(error, rdp_pipe.trust_store.CertificateTrustMismatchError):
87
+ return rdp_pipe.trust_store.format_certificate_trust_mismatch_stderr(error)
88
+
89
+ endpoint = parse_connection_endpoint(connection_url)
90
+ failure_reason = describe_connection_failure(error)
91
+
92
+ return "error: could not connect to {endpoint} ({failure_reason})\n".format(
93
+ endpoint=endpoint,
94
+ failure_reason=failure_reason)
95
+
96
+
97
+ def format_session_ended_stderr(error: BaseException) -> str:
98
+ """Return a managed stderr line when the session ends after connect."""
99
+
100
+ failure_reason = describe_connection_failure(error)
101
+
102
+ return "error: RDP session ended unexpectedly ({failure_reason})\n".format(
103
+ failure_reason=failure_reason)
104
+
105
+
106
+ def format_session_disconnected_reconnecting_stderr(connection_url: str) -> str:
107
+ """Return a managed stderr line when a live session drops and reconnect begins."""
108
+
109
+ endpoint = parse_connection_endpoint(connection_url)
110
+
111
+ return "error: RDP session to {endpoint} disconnected; reconnecting...\n".format(
112
+ endpoint=endpoint)
113
+
114
+
115
+ def format_session_disconnected_with_reason_stderr(
116
+ connection_url: str,
117
+ errinfo_name: str) -> str:
118
+ """Return stderr when the server sent SET_ERROR_INFO before the session dropped."""
119
+
120
+ endpoint = parse_connection_endpoint(connection_url)
121
+
122
+ return (
123
+ "error: RDP session to {endpoint} ended ({errinfo_name}); "
124
+ "awaiting reconnect choice or reconnecting...\n".format(
125
+ endpoint=endpoint,
126
+ errinfo_name=errinfo_name))
@@ -0,0 +1,53 @@
1
+ """Connection progress step identifiers and display labels for the GUI overlay."""
2
+
3
+ CONNECTION_STEP_RECONNECTING = "reconnecting"
4
+ CONNECTION_STEP_PREPARING = "preparing"
5
+ CONNECTION_STEP_CONNECTING = "connecting"
6
+ CONNECTION_STEP_VERIFYING_CERTIFICATE = "verifying_certificate"
7
+ CONNECTION_STEP_AUTHENTICATING = "authenticating"
8
+ CONNECTION_STEP_CONFIGURING_DISPLAY = "configuring_display"
9
+ CONNECTION_STEP_READY = "ready"
10
+
11
+ ORDERED_CONNECTION_STEPS = [
12
+ CONNECTION_STEP_RECONNECTING,
13
+ CONNECTION_STEP_PREPARING,
14
+ CONNECTION_STEP_CONNECTING,
15
+ CONNECTION_STEP_VERIFYING_CERTIFICATE,
16
+ CONNECTION_STEP_AUTHENTICATING,
17
+ CONNECTION_STEP_CONFIGURING_DISPLAY,
18
+ CONNECTION_STEP_READY,
19
+ ]
20
+
21
+ CONNECTION_STEP_LABELS = {
22
+ CONNECTION_STEP_RECONNECTING: "Reconnecting to server",
23
+ CONNECTION_STEP_PREPARING: "Preparing connection",
24
+ CONNECTION_STEP_CONNECTING: "Connecting to server",
25
+ CONNECTION_STEP_VERIFYING_CERTIFICATE: "Verifying server certificate",
26
+ CONNECTION_STEP_AUTHENTICATING: "Authenticating",
27
+ CONNECTION_STEP_CONFIGURING_DISPLAY: "Configuring display",
28
+ CONNECTION_STEP_READY: "Ready",
29
+ }
30
+
31
+
32
+ def get_connection_step_label(step_identifier: str) -> str:
33
+ """Return the operator-facing label for a connection progress step."""
34
+
35
+ try:
36
+ return CONNECTION_STEP_LABELS[step_identifier]
37
+
38
+ except KeyError:
39
+ raise ValueError(
40
+ "unknown connection progress step {step_identifier}".format(
41
+ step_identifier=step_identifier))
42
+
43
+
44
+ def get_connection_step_index(step_identifier: str) -> int:
45
+ """Return the zero-based index of a step in the connect sequence."""
46
+
47
+ try:
48
+ return ORDERED_CONNECTION_STEPS.index(step_identifier)
49
+
50
+ except ValueError:
51
+ raise ValueError(
52
+ "unknown connection progress step {step_identifier}".format(
53
+ step_identifier=step_identifier))