rdp-pipe 0.1.0__tar.gz

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.
Files changed (62) hide show
  1. rdp_pipe-0.1.0/LICENSE +21 -0
  2. rdp_pipe-0.1.0/PKG-INFO +264 -0
  3. rdp_pipe-0.1.0/README.md +245 -0
  4. rdp_pipe-0.1.0/pyproject.toml +44 -0
  5. rdp_pipe-0.1.0/setup.cfg +4 -0
  6. rdp_pipe-0.1.0/src/rdp_pipe/__init__.py +1 -0
  7. rdp_pipe-0.1.0/src/rdp_pipe/activity_stamp.py +19 -0
  8. rdp_pipe-0.1.0/src/rdp_pipe/command_socket.py +414 -0
  9. rdp_pipe-0.1.0/src/rdp_pipe/config/__init__.py +1 -0
  10. rdp_pipe-0.1.0/src/rdp_pipe/config/entrypoint/__init__.py +1 -0
  11. rdp_pipe-0.1.0/src/rdp_pipe/config/entrypoint/rdpr.py +3 -0
  12. rdp_pipe-0.1.0/src/rdp_pipe/connection_error.py +126 -0
  13. rdp_pipe-0.1.0/src/rdp_pipe/connection_progress.py +53 -0
  14. rdp_pipe-0.1.0/src/rdp_pipe/connection_url.py +187 -0
  15. rdp_pipe-0.1.0/src/rdp_pipe/display_control.py +256 -0
  16. rdp_pipe-0.1.0/src/rdp_pipe/entrypoint/__init__.py +1 -0
  17. rdp_pipe-0.1.0/src/rdp_pipe/entrypoint/rdp.py +319 -0
  18. rdp_pipe-0.1.0/src/rdp_pipe/entrypoint/rdpr.py +303 -0
  19. rdp_pipe-0.1.0/src/rdp_pipe/fastpath_input.py +80 -0
  20. rdp_pipe-0.1.0/src/rdp_pipe/keyboard_debug.py +20 -0
  21. rdp_pipe-0.1.0/src/rdp_pipe/mouse_debug.py +20 -0
  22. rdp_pipe-0.1.0/src/rdp_pipe/pointer_debug.py +48 -0
  23. rdp_pipe-0.1.0/src/rdp_pipe/pointer_update.py +684 -0
  24. rdp_pipe-0.1.0/src/rdp_pipe/qt_session_mapping.py +28 -0
  25. rdp_pipe-0.1.0/src/rdp_pipe/qt_session_window.py +1722 -0
  26. rdp_pipe-0.1.0/src/rdp_pipe/rdp_connection.py +1305 -0
  27. rdp_pipe-0.1.0/src/rdp_pipe/rdp_input.py +111 -0
  28. rdp_pipe-0.1.0/src/rdp_pipe/rdp_session_core.py +468 -0
  29. rdp_pipe-0.1.0/src/rdp_pipe/rdp_session_thread.py +578 -0
  30. rdp_pipe-0.1.0/src/rdp_pipe/remote_session_end.py +57 -0
  31. rdp_pipe-0.1.0/src/rdp_pipe/runtime_paths.py +30 -0
  32. rdp_pipe-0.1.0/src/rdp_pipe/trust_store.py +446 -0
  33. rdp_pipe-0.1.0/src/rdp_pipe.egg-info/PKG-INFO +264 -0
  34. rdp_pipe-0.1.0/src/rdp_pipe.egg-info/SOURCES.txt +60 -0
  35. rdp_pipe-0.1.0/src/rdp_pipe.egg-info/dependency_links.txt +1 -0
  36. rdp_pipe-0.1.0/src/rdp_pipe.egg-info/entry_points.txt +3 -0
  37. rdp_pipe-0.1.0/src/rdp_pipe.egg-info/requires.txt +12 -0
  38. rdp_pipe-0.1.0/src/rdp_pipe.egg-info/top_level.txt +1 -0
  39. rdp_pipe-0.1.0/tests/test_activity_stamp.py +33 -0
  40. rdp_pipe-0.1.0/tests/test_command_socket.py +270 -0
  41. rdp_pipe-0.1.0/tests/test_command_socket_server.py +264 -0
  42. rdp_pipe-0.1.0/tests/test_connection_error.py +130 -0
  43. rdp_pipe-0.1.0/tests/test_connection_progress.py +47 -0
  44. rdp_pipe-0.1.0/tests/test_connection_url.py +159 -0
  45. rdp_pipe-0.1.0/tests/test_display_control_pdu.py +139 -0
  46. rdp_pipe-0.1.0/tests/test_fastpath_input.py +68 -0
  47. rdp_pipe-0.1.0/tests/test_pointer_update.py +442 -0
  48. rdp_pipe-0.1.0/tests/test_qt_session_geometry.py +134 -0
  49. rdp_pipe-0.1.0/tests/test_qt_session_keyboard.py +225 -0
  50. rdp_pipe-0.1.0/tests/test_qt_session_mapping.py +59 -0
  51. rdp_pipe-0.1.0/tests/test_qt_session_mouse.py +265 -0
  52. rdp_pipe-0.1.0/tests/test_rdp_cli.py +225 -0
  53. rdp_pipe-0.1.0/tests/test_rdp_connection_factory.py +287 -0
  54. rdp_pipe-0.1.0/tests/test_rdp_session_core.py +251 -0
  55. rdp_pipe-0.1.0/tests/test_rdp_session_reconnect.py +206 -0
  56. rdp_pipe-0.1.0/tests/test_rdp_session_shutdown.py +732 -0
  57. rdp_pipe-0.1.0/tests/test_rdpeclip_clipboard.py +84 -0
  58. rdp_pipe-0.1.0/tests/test_rdpr_cli.py +211 -0
  59. rdp_pipe-0.1.0/tests/test_remote_session_end.py +67 -0
  60. rdp_pipe-0.1.0/tests/test_runtime_paths.py +28 -0
  61. rdp_pipe-0.1.0/tests/test_send_geometry.py +111 -0
  62. rdp_pipe-0.1.0/tests/test_trust_store.py +230 -0
rdp_pipe-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dustin Oprea
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,264 @@
1
+ Metadata-Version: 2.4
2
+ Name: rdp_pipe
3
+ Version: 0.1.0
4
+ Summary: PyQt6 desktop RDP client with seamless resize (MS-RDPEDISP)
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: aardwolf
9
+ Requires-Dist: PyQt6
10
+ Requires-Dist: Pillow
11
+ Provides-Extra: dev
12
+ Requires-Dist: build; extra == "dev"
13
+ Requires-Dist: pytest; extra == "dev"
14
+ Requires-Dist: pytest-asyncio; extra == "dev"
15
+ Requires-Dist: twine; extra == "dev"
16
+ Provides-Extra: mcp
17
+ Requires-Dist: mcp; extra == "mcp"
18
+ Dynamic: license-file
19
+
20
+ # rdp_pipe
21
+
22
+ Python desktop RDP client for Linux operators connecting to remote hosts over RDP with NLA (CredSSP) and NTLM password authentication. The UI is a native PyQt6 window with pointer-gated keyboard input and seamless resize via MS-RDPEDISP when the server supports it.
23
+
24
+ ## Prerequisites
25
+
26
+ - **pyenv** with the Python version named in **`.python-version`** installed locally (the file is required in every clone; install that interpreter with pyenv before pip)
27
+ - An X11 or Wayland desktop session for PyQt6
28
+ - Network reachability to the target host on TCP 3389 (or the port in the URL)
29
+
30
+ ## Install
31
+
32
+ From the repo root with pyenv active for **`.python-version`**:
33
+
34
+ ```bash
35
+ pip install -e .
36
+ ```
37
+
38
+ Optional wrapper: **`./script/install.sh`** (runs the same command).
39
+
40
+ This project does not use a repo **`.venv`**. Console scripts (`rdp`, `rdpr`) land on your pyenv shim path after editable install.
41
+
42
+ If imports fail after moving the repo, remove any leftover **`.venv`** directory (it may still point at an old checkout path) and run **`pip install -e .`** again. The installable Python package is **`rdp_pipe`**, not `rdp`.
43
+
44
+ ## Connect
45
+
46
+ ```bash
47
+ rdp [-h] [--headless] [--pipe] [--no-autoresize] URL
48
+ ```
49
+
50
+ GUI mode (default) opens a PyQt6 window. `--headless` runs without a display and **requires** `--pipe` for automation on the command socket under the system temp directory (`rdp.sock`).
51
+
52
+ Press **Ctrl+C in the terminal** where you launched `rdp` to disconnect and exit cleanly (this does not affect keyboard input forwarded inside the session window).
53
+
54
+ `URL` is an aardwolf-style connection string or bare host shorthand:
55
+
56
+ ```bash
57
+ rdp rdp://DOMAIN\\Administrator@10.0.0.5:3389
58
+ rdp rdp+ntlm-password://DOMAIN\\Administrator@10.0.0.5:3389
59
+ rdp 10.0.0.5
60
+ ```
61
+
62
+ Generic `rdp://` URLs prefer NTLM password authentication; the server selects NLA (CredSSP), TLS-only, or legacy RDP during X.224 negotiation.
63
+
64
+
65
+ ### Server certificate trust
66
+
67
+ On NLA connect, the client stores the server TLS certificate under `~/.config/rdpipe/`:
68
+
69
+ - `clients/<ip>` — one-line file mapping the remote IP to a certificate fingerprint
70
+ - `certificates/<fingerprint>/` — `certificate` (PEM), `ip`, and `metadata` (JSON)
71
+
72
+ The first connection to an IP is accepted automatically. If the server later presents a different certificate for the same IP, the client refuses to connect and prints paths to remove, for example:
73
+
74
+ ```text
75
+ ~/.config/rdpipe/clients/10.0.0.5
76
+ ~/.config/rdpipe/certificates/<old-fingerprint>/
77
+ ```
78
+
79
+ Remove the `clients/<ip>` file to trust a new certificate on the next connect. IPv6 addresses use underscores instead of colons in client filenames (e.g. `2001_db8__1`).
80
+
81
+
82
+ ### Password resolution
83
+
84
+ Passwords are resolved in order (first match wins):
85
+
86
+ 1. Embedded in URL userinfo (`user:password@host`)
87
+ 2. `RDP_PASSWORD` environment variable
88
+ 3. Standard input — `getpass` on a TTY, otherwise one line from a pipe
89
+
90
+ If no password is available after these steps, the CLI exits with a non-zero status and an error on stderr.
91
+
92
+ Examples:
93
+
94
+ ```bash
95
+ export RDP_PASSWORD='your-password'
96
+ rdp 'rdp+ntlm-password://DOMAIN\Administrator@10.0.0.5'
97
+ ```
98
+
99
+ ```bash
100
+ echo 'your-password' | rdp 'rdp+ntlm-password://Administrator@10.0.0.5'
101
+ ```
102
+
103
+ ### Headless automation
104
+
105
+ ```bash
106
+ export RDP_PASSWORD='your-password'
107
+ rdp --headless --pipe '10.0.0.5'
108
+ ```
109
+
110
+ Headless mode uses a fixed 1280×800 session geometry (no window resize). The process listens on the Unix socket for newline-delimited JSON commands. Use `--color-depth 24` (or `16`) when you need a specific bpp for screen analysis via `receive_screenshot`.
111
+
112
+ Full wire format, command parameters, response shapes, and client examples: **[REMOTE_COMMAND_PROTOCOL.md](REMOTE_COMMAND_PROTOCOL.md)**.
113
+
114
+ Quick probe with `rdpr`:
115
+
116
+ ```bash
117
+ rdpr command_receive_geometry
118
+ rdpr command_send_geometry 1920 1080
119
+ rdpr command_send_click 640 400 --button left
120
+ rdpr command_receive_screenshot --format png
121
+ ```
122
+
123
+ Or with `nc`:
124
+
125
+ ```bash
126
+ printf '%s\n' '{"command":"receive_geometry"}' | nc -U "${TMPDIR:-/tmp}/rdp.sock"
127
+ ```
128
+
129
+ `--pipe` is optional in GUI mode (automation socket at `{system temp}/rdp.sock` alongside the window). Every session also touches `{system temp}/rdp_activity.stamp` on remote framebuffer updates.
130
+
131
+ ### MCP automation
132
+
133
+ A standard MCP host config is provided at [`mcp/mcp.json`](mcp/mcp.json). It launches [`mcp/run_server.sh`](mcp/run_server.sh), which selects the pyenv interpreter from `.python-version`, sets `RDPR_COMMAND` to that environment’s `rdpr`, and runs `mcp/server.py`. Install the optional MCP extra into that interpreter, then copy the file or merge its `rdp_pipe` entry into your host’s MCP settings:
134
+
135
+ ```bash
136
+ pip install -e ".[mcp]"
137
+ ```
138
+
139
+ Socket default: `{system temp}/rdp.sock` (honors `$TMPDIR`; typically `/tmp` on Linux).
140
+
141
+ For Cursor, copy or symlink into `.cursor/mcp.json` (that directory is gitignored):
142
+
143
+ ```bash
144
+ mkdir -p .cursor
145
+ cp mcp/mcp.json .cursor/mcp.json
146
+ ```
147
+
148
+ Typical LLM flow with an active `rdp --pipe` session:
149
+
150
+ 1. `command_receive_geometry` — learn coordinate bounds
151
+ 2. `command_receive_screenshot` — capture state (inline image; server always passes `--no-write` to `rdpr`)
152
+ 3. `command_send_click` / `command_send_key` — act on the remote desktop
153
+
154
+ #### MCP test prompt
155
+
156
+ Paste this to an agent with **`rdp_pipe` connected** and a live **`rdp --headless --pipe`** (or GUI + `--pipe`) session:
157
+
158
+ ```text
159
+ In RDP:
160
+ 1. take screenshot
161
+ 2. open calculator
162
+ 3. take screenshot
163
+ 4. close calculator
164
+ 5. take screenshot
165
+ 6. open command prompt
166
+ 7. take screenshot
167
+ 8. list directory
168
+ 9. take screenshot
169
+ ```
170
+
171
+ Take a screenshot after steps **1**, **3**, **5**, **7**, and **9** only. After step **4**, confirm step **5** shows Calculator gone before opening Command Prompt.
172
+
173
+ On Windows Server with legacy Calculator (`win32calc.exe`), search-box automation works for launch; close with `taskkill /F /IM win32calc.exe` from search (title-bar **X** clicks are unreliable over RDP). Open Command Prompt with search → `cmd` → Enter; run `dir` in the CMD window.
174
+
175
+ #### MCP reference screenshots
176
+
177
+ Committed captures from a successful run (1280×800 session) live under [`asset/mcp/screenshot/`](asset/mcp/screenshot/):
178
+
179
+ | Step | Expected state | File |
180
+ |------|----------------|------|
181
+ | 1 | Clean desktop | [`01.png`](asset/mcp/screenshot/01.png) |
182
+ | 3 | Calculator open | [`03.png`](asset/mcp/screenshot/03.png) |
183
+ | 5 | Calculator closed | [`05.png`](asset/mcp/screenshot/05.png) |
184
+ | 7 | Command Prompt open | [`07.png`](asset/mcp/screenshot/07.png) |
185
+ | 9 | `dir` in `C:\Users\Administrator` | [`09.png`](asset/mcp/screenshot/09.png) |
186
+
187
+ Step 1 — initial desktop:
188
+
189
+ ![MCP test step 1 — initial desktop](asset/mcp/screenshot/01.png)
190
+
191
+ Step 3 — Calculator open:
192
+
193
+ ![MCP test step 3 — Calculator open](asset/mcp/screenshot/03.png)
194
+
195
+ Step 5 — Calculator closed:
196
+
197
+ ![MCP test step 5 — Calculator closed](asset/mcp/screenshot/05.png)
198
+
199
+ Step 7 — Command Prompt open:
200
+
201
+ ![MCP test step 7 — Command Prompt open](asset/mcp/screenshot/07.png)
202
+
203
+ Step 9 — directory listing:
204
+
205
+ ![MCP test step 9 — dir output](asset/mcp/screenshot/09.png)
206
+
207
+ Wire protocol details: **[REMOTE_COMMAND_PROTOCOL.md](REMOTE_COMMAND_PROTOCOL.md)**.
208
+
209
+ ## Monkey-patching
210
+
211
+ This client patches **aardwolf** at runtime instead of vendoring or forking the library. Patches live in production code under `src/rdp_pipe/` (not in tests). Each patch replaces a symbol on import; subclass overrides on `RdpDesktopConnection` are normal inheritance and are not listed here.
212
+
213
+ | Target | Replacement | Why |
214
+ |--------|-------------|-----|
215
+ | `aardwolf.protocol.T124.userdata.clientcoredata.TS_UD_CS_CORE.to_bytes` | `_patched_ts_ud_cs_core_to_bytes` in [`rdp_connection.py`](src/rdp_pipe/rdp_connection.py) | During desktop connect, advertise `SUPPORT_MONITOR_LAYOUT_PDU` and (when `--color-depth` is 32) `WANT_32BPP_SESSION` / 24-bpp high color in Client Core Data. Active only while `RdpDesktopConnection.connect()` runs (`_CONNECTING_DESKTOP_CONNECTION` gate). |
216
+ | `aardwolf.protocol.pdu.capabilities.pointer.TS_POINTER_CAPABILITYSET.__init__` | `_patched_pointer_capabilityset_init` in [`rdp_connection.py`](src/rdp_pipe/rdp_connection.py) | Advertise `colorPointerFlag=True` so the server sends color/cached pointer updates for hover cursor shapes. |
217
+ | `aardwolf.connection.RDPConnection.handle_out_data` | `_patched_handle_out_data` in [`rdp_connection.py`](src/rdp_pipe/rdp_connection.py) | Before every Confirm Active (connect and mid-session reactivation), set `desktopResizeFlag=True` and add LARGE_POINTER / UNICODE / MOUSE_HWHEEL that aardwolf omits. |
218
+ | `RdpDesktopConnection._RDPConnection__process_fastpath` | `_rdp_desktop_process_fastpath` in [`rdp_connection.py`](src/rdp_pipe/rdp_connection.py) | Stock aardwolf handles fast-path `BITMAP` only. Our handler also forwards bitmap tiles without inferring resolution from tile size, and emits pointer updates (`COLOR`, `POINTER`, `CACHED`, etc.) for remote cursor mirroring. |
219
+ | `aardwolf.extensions.RDPECLIP.channel.RDPECLIPChannel._handle_format_data_request` | `_patched_rdpeclip_handle_format_data_request` in [`rdp_connection.py`](src/rdp_pipe/rdp_connection.py) | aardwolf dereferences `clipboard.data.datatype` without checking for `None`. After connect we advertise standard clipboard formats even when the local clipboard is empty; a remote `CB_FORMAT_DATA_REQUEST` then crashed the x224 reader. Reply with `CB_RESPONSE_FAIL` when `clipboard.data` is unset. |
220
+ | `aardwolf.connection.RDPConnection.terminate` | `_patched_aardwolf_terminate` in [`rdp_connection.py`](src/rdp_pipe/rdp_connection.py) | Stock aardwolf always calls `send_disconnect()` and logs `Error while requesting shutdown` when the MCS channel is missing (failed connect) or the TCP socket is already reset. Skip graceful shutdown when MCS/server connect data are absent; suppress the warning for expected transport errors during reconnect teardown. |
221
+ | `aardwolf.connection.RDPConnection.check_logon_status` | `_patched_check_logon_status` in [`rdp_connection.py`](src/rdp_pipe/rdp_connection.py) | After aardwolf records `SET_ERROR_INFO`, copy `SERVER_SHUTDOWN` / `SERVER_REBOOT` onto `RdpDesktopConnection.pending_remote_session_end_reason` so the GUI can prompt for reconnect vs exit. |
222
+
223
+ When adding or changing a runtime monkey-patch, update this section in the same change set.
224
+
225
+ ## Release (PyPI)
226
+
227
+ Operator workflow mirrors a static frontend **build → package → deploy**, but artifacts are Python wheels/sdists and **deploy** uploads with **twine** (not S3).
228
+
229
+ Activate pyenv for **`.python-version`**, install dev tools once (`pip install -e ".[dev]"`), then from the repo root:
230
+
231
+ ```bash
232
+ ./script/build.sh
233
+ ARCHIVE="$(./script/package.sh patch)"
234
+ ./script/deploy.sh "${ARCHIVE}"
235
+ ```
236
+
237
+ | Script | Role |
238
+ |--------|------|
239
+ | **`script/build.sh`** | `python -m build` → **`dist/`** (wheel + sdist). No version bump. |
240
+ | **`script/package.sh`** | **`patch` \| `minor` \| `major`**: clean tree, bump **`pyproject.toml`**, commit, annotated **`vX.Y.Z`**, run **`build.sh`**, pack **`build/archives/vX.Y.Z_rdp_pipe.tar.gz`**. Stdout = archive path. |
241
+ | **`script/deploy.sh`** | Positional archive path: extract, **`twine check`**, **`twine upload`**. Writes **`releases/pypi/X.Y.Z.md`** when the tree is clean. |
242
+
243
+ PyPI credentials: **`~/.pypirc`** or **`TWINE_USERNAME`** / **`TWINE_PASSWORD`**. For TestPyPI, set **`TWINE_REPOSITORY_URL=https://test.pypi.org/legacy/`** before **`deploy.sh`**.
244
+
245
+ After **`package.sh`**, push the release commit and tag when ready: **`git push origin HEAD vX.Y.Z`**.
246
+
247
+ ## Test
248
+
249
+ ```bash
250
+ pip install -e ".[dev]"
251
+ python -m pytest -q
252
+ ```
253
+
254
+ MCP server tests need the **`mcp`** extra: `pip install -e ".[dev,mcp]"`.
255
+
256
+ Offline unit tests cover URL/password handling, RDPDISP PDU encoding, pointer mask decoding, command socket protocol, and CLI validation.
257
+
258
+ ## Manual smoke
259
+
260
+ Connect to an RDP host with NLA enabled. Resize the client window and confirm the remote display resolution tracks when the server supports RDPDISP. Servers without RDPDISP keep a fixed session resolution; the local window may letterbox until disconnect.
261
+
262
+ To trace remote pointer mirroring (PDU types, apply/skip decisions, forwarded hover coordinates), run with `RDP_POINTER_DEBUG=1` and watch stderr while moving the mouse over window borders and text fields.
263
+
264
+ To trace keyboard capture after idle (Qt key events, pointer-inside gating, enqueue, forwarder dequeue), run with `RDP_KEYBOARD_DEBUG=1`. If GUI keys show no `keyPressEvent` lines but `rdpr send_key` still works, focus was stolen locally; if both fail, check stderr for `RDP deactivation-reactivation failed`.
@@ -0,0 +1,245 @@
1
+ # rdp_pipe
2
+
3
+ Python desktop RDP client for Linux operators connecting to remote hosts over RDP with NLA (CredSSP) and NTLM password authentication. The UI is a native PyQt6 window with pointer-gated keyboard input and seamless resize via MS-RDPEDISP when the server supports it.
4
+
5
+ ## Prerequisites
6
+
7
+ - **pyenv** with the Python version named in **`.python-version`** installed locally (the file is required in every clone; install that interpreter with pyenv before pip)
8
+ - An X11 or Wayland desktop session for PyQt6
9
+ - Network reachability to the target host on TCP 3389 (or the port in the URL)
10
+
11
+ ## Install
12
+
13
+ From the repo root with pyenv active for **`.python-version`**:
14
+
15
+ ```bash
16
+ pip install -e .
17
+ ```
18
+
19
+ Optional wrapper: **`./script/install.sh`** (runs the same command).
20
+
21
+ This project does not use a repo **`.venv`**. Console scripts (`rdp`, `rdpr`) land on your pyenv shim path after editable install.
22
+
23
+ If imports fail after moving the repo, remove any leftover **`.venv`** directory (it may still point at an old checkout path) and run **`pip install -e .`** again. The installable Python package is **`rdp_pipe`**, not `rdp`.
24
+
25
+ ## Connect
26
+
27
+ ```bash
28
+ rdp [-h] [--headless] [--pipe] [--no-autoresize] URL
29
+ ```
30
+
31
+ GUI mode (default) opens a PyQt6 window. `--headless` runs without a display and **requires** `--pipe` for automation on the command socket under the system temp directory (`rdp.sock`).
32
+
33
+ Press **Ctrl+C in the terminal** where you launched `rdp` to disconnect and exit cleanly (this does not affect keyboard input forwarded inside the session window).
34
+
35
+ `URL` is an aardwolf-style connection string or bare host shorthand:
36
+
37
+ ```bash
38
+ rdp rdp://DOMAIN\\Administrator@10.0.0.5:3389
39
+ rdp rdp+ntlm-password://DOMAIN\\Administrator@10.0.0.5:3389
40
+ rdp 10.0.0.5
41
+ ```
42
+
43
+ Generic `rdp://` URLs prefer NTLM password authentication; the server selects NLA (CredSSP), TLS-only, or legacy RDP during X.224 negotiation.
44
+
45
+
46
+ ### Server certificate trust
47
+
48
+ On NLA connect, the client stores the server TLS certificate under `~/.config/rdpipe/`:
49
+
50
+ - `clients/<ip>` — one-line file mapping the remote IP to a certificate fingerprint
51
+ - `certificates/<fingerprint>/` — `certificate` (PEM), `ip`, and `metadata` (JSON)
52
+
53
+ The first connection to an IP is accepted automatically. If the server later presents a different certificate for the same IP, the client refuses to connect and prints paths to remove, for example:
54
+
55
+ ```text
56
+ ~/.config/rdpipe/clients/10.0.0.5
57
+ ~/.config/rdpipe/certificates/<old-fingerprint>/
58
+ ```
59
+
60
+ Remove the `clients/<ip>` file to trust a new certificate on the next connect. IPv6 addresses use underscores instead of colons in client filenames (e.g. `2001_db8__1`).
61
+
62
+
63
+ ### Password resolution
64
+
65
+ Passwords are resolved in order (first match wins):
66
+
67
+ 1. Embedded in URL userinfo (`user:password@host`)
68
+ 2. `RDP_PASSWORD` environment variable
69
+ 3. Standard input — `getpass` on a TTY, otherwise one line from a pipe
70
+
71
+ If no password is available after these steps, the CLI exits with a non-zero status and an error on stderr.
72
+
73
+ Examples:
74
+
75
+ ```bash
76
+ export RDP_PASSWORD='your-password'
77
+ rdp 'rdp+ntlm-password://DOMAIN\Administrator@10.0.0.5'
78
+ ```
79
+
80
+ ```bash
81
+ echo 'your-password' | rdp 'rdp+ntlm-password://Administrator@10.0.0.5'
82
+ ```
83
+
84
+ ### Headless automation
85
+
86
+ ```bash
87
+ export RDP_PASSWORD='your-password'
88
+ rdp --headless --pipe '10.0.0.5'
89
+ ```
90
+
91
+ Headless mode uses a fixed 1280×800 session geometry (no window resize). The process listens on the Unix socket for newline-delimited JSON commands. Use `--color-depth 24` (or `16`) when you need a specific bpp for screen analysis via `receive_screenshot`.
92
+
93
+ Full wire format, command parameters, response shapes, and client examples: **[REMOTE_COMMAND_PROTOCOL.md](REMOTE_COMMAND_PROTOCOL.md)**.
94
+
95
+ Quick probe with `rdpr`:
96
+
97
+ ```bash
98
+ rdpr command_receive_geometry
99
+ rdpr command_send_geometry 1920 1080
100
+ rdpr command_send_click 640 400 --button left
101
+ rdpr command_receive_screenshot --format png
102
+ ```
103
+
104
+ Or with `nc`:
105
+
106
+ ```bash
107
+ printf '%s\n' '{"command":"receive_geometry"}' | nc -U "${TMPDIR:-/tmp}/rdp.sock"
108
+ ```
109
+
110
+ `--pipe` is optional in GUI mode (automation socket at `{system temp}/rdp.sock` alongside the window). Every session also touches `{system temp}/rdp_activity.stamp` on remote framebuffer updates.
111
+
112
+ ### MCP automation
113
+
114
+ A standard MCP host config is provided at [`mcp/mcp.json`](mcp/mcp.json). It launches [`mcp/run_server.sh`](mcp/run_server.sh), which selects the pyenv interpreter from `.python-version`, sets `RDPR_COMMAND` to that environment’s `rdpr`, and runs `mcp/server.py`. Install the optional MCP extra into that interpreter, then copy the file or merge its `rdp_pipe` entry into your host’s MCP settings:
115
+
116
+ ```bash
117
+ pip install -e ".[mcp]"
118
+ ```
119
+
120
+ Socket default: `{system temp}/rdp.sock` (honors `$TMPDIR`; typically `/tmp` on Linux).
121
+
122
+ For Cursor, copy or symlink into `.cursor/mcp.json` (that directory is gitignored):
123
+
124
+ ```bash
125
+ mkdir -p .cursor
126
+ cp mcp/mcp.json .cursor/mcp.json
127
+ ```
128
+
129
+ Typical LLM flow with an active `rdp --pipe` session:
130
+
131
+ 1. `command_receive_geometry` — learn coordinate bounds
132
+ 2. `command_receive_screenshot` — capture state (inline image; server always passes `--no-write` to `rdpr`)
133
+ 3. `command_send_click` / `command_send_key` — act on the remote desktop
134
+
135
+ #### MCP test prompt
136
+
137
+ Paste this to an agent with **`rdp_pipe` connected** and a live **`rdp --headless --pipe`** (or GUI + `--pipe`) session:
138
+
139
+ ```text
140
+ In RDP:
141
+ 1. take screenshot
142
+ 2. open calculator
143
+ 3. take screenshot
144
+ 4. close calculator
145
+ 5. take screenshot
146
+ 6. open command prompt
147
+ 7. take screenshot
148
+ 8. list directory
149
+ 9. take screenshot
150
+ ```
151
+
152
+ Take a screenshot after steps **1**, **3**, **5**, **7**, and **9** only. After step **4**, confirm step **5** shows Calculator gone before opening Command Prompt.
153
+
154
+ On Windows Server with legacy Calculator (`win32calc.exe`), search-box automation works for launch; close with `taskkill /F /IM win32calc.exe` from search (title-bar **X** clicks are unreliable over RDP). Open Command Prompt with search → `cmd` → Enter; run `dir` in the CMD window.
155
+
156
+ #### MCP reference screenshots
157
+
158
+ Committed captures from a successful run (1280×800 session) live under [`asset/mcp/screenshot/`](asset/mcp/screenshot/):
159
+
160
+ | Step | Expected state | File |
161
+ |------|----------------|------|
162
+ | 1 | Clean desktop | [`01.png`](asset/mcp/screenshot/01.png) |
163
+ | 3 | Calculator open | [`03.png`](asset/mcp/screenshot/03.png) |
164
+ | 5 | Calculator closed | [`05.png`](asset/mcp/screenshot/05.png) |
165
+ | 7 | Command Prompt open | [`07.png`](asset/mcp/screenshot/07.png) |
166
+ | 9 | `dir` in `C:\Users\Administrator` | [`09.png`](asset/mcp/screenshot/09.png) |
167
+
168
+ Step 1 — initial desktop:
169
+
170
+ ![MCP test step 1 — initial desktop](asset/mcp/screenshot/01.png)
171
+
172
+ Step 3 — Calculator open:
173
+
174
+ ![MCP test step 3 — Calculator open](asset/mcp/screenshot/03.png)
175
+
176
+ Step 5 — Calculator closed:
177
+
178
+ ![MCP test step 5 — Calculator closed](asset/mcp/screenshot/05.png)
179
+
180
+ Step 7 — Command Prompt open:
181
+
182
+ ![MCP test step 7 — Command Prompt open](asset/mcp/screenshot/07.png)
183
+
184
+ Step 9 — directory listing:
185
+
186
+ ![MCP test step 9 — dir output](asset/mcp/screenshot/09.png)
187
+
188
+ Wire protocol details: **[REMOTE_COMMAND_PROTOCOL.md](REMOTE_COMMAND_PROTOCOL.md)**.
189
+
190
+ ## Monkey-patching
191
+
192
+ This client patches **aardwolf** at runtime instead of vendoring or forking the library. Patches live in production code under `src/rdp_pipe/` (not in tests). Each patch replaces a symbol on import; subclass overrides on `RdpDesktopConnection` are normal inheritance and are not listed here.
193
+
194
+ | Target | Replacement | Why |
195
+ |--------|-------------|-----|
196
+ | `aardwolf.protocol.T124.userdata.clientcoredata.TS_UD_CS_CORE.to_bytes` | `_patched_ts_ud_cs_core_to_bytes` in [`rdp_connection.py`](src/rdp_pipe/rdp_connection.py) | During desktop connect, advertise `SUPPORT_MONITOR_LAYOUT_PDU` and (when `--color-depth` is 32) `WANT_32BPP_SESSION` / 24-bpp high color in Client Core Data. Active only while `RdpDesktopConnection.connect()` runs (`_CONNECTING_DESKTOP_CONNECTION` gate). |
197
+ | `aardwolf.protocol.pdu.capabilities.pointer.TS_POINTER_CAPABILITYSET.__init__` | `_patched_pointer_capabilityset_init` in [`rdp_connection.py`](src/rdp_pipe/rdp_connection.py) | Advertise `colorPointerFlag=True` so the server sends color/cached pointer updates for hover cursor shapes. |
198
+ | `aardwolf.connection.RDPConnection.handle_out_data` | `_patched_handle_out_data` in [`rdp_connection.py`](src/rdp_pipe/rdp_connection.py) | Before every Confirm Active (connect and mid-session reactivation), set `desktopResizeFlag=True` and add LARGE_POINTER / UNICODE / MOUSE_HWHEEL that aardwolf omits. |
199
+ | `RdpDesktopConnection._RDPConnection__process_fastpath` | `_rdp_desktop_process_fastpath` in [`rdp_connection.py`](src/rdp_pipe/rdp_connection.py) | Stock aardwolf handles fast-path `BITMAP` only. Our handler also forwards bitmap tiles without inferring resolution from tile size, and emits pointer updates (`COLOR`, `POINTER`, `CACHED`, etc.) for remote cursor mirroring. |
200
+ | `aardwolf.extensions.RDPECLIP.channel.RDPECLIPChannel._handle_format_data_request` | `_patched_rdpeclip_handle_format_data_request` in [`rdp_connection.py`](src/rdp_pipe/rdp_connection.py) | aardwolf dereferences `clipboard.data.datatype` without checking for `None`. After connect we advertise standard clipboard formats even when the local clipboard is empty; a remote `CB_FORMAT_DATA_REQUEST` then crashed the x224 reader. Reply with `CB_RESPONSE_FAIL` when `clipboard.data` is unset. |
201
+ | `aardwolf.connection.RDPConnection.terminate` | `_patched_aardwolf_terminate` in [`rdp_connection.py`](src/rdp_pipe/rdp_connection.py) | Stock aardwolf always calls `send_disconnect()` and logs `Error while requesting shutdown` when the MCS channel is missing (failed connect) or the TCP socket is already reset. Skip graceful shutdown when MCS/server connect data are absent; suppress the warning for expected transport errors during reconnect teardown. |
202
+ | `aardwolf.connection.RDPConnection.check_logon_status` | `_patched_check_logon_status` in [`rdp_connection.py`](src/rdp_pipe/rdp_connection.py) | After aardwolf records `SET_ERROR_INFO`, copy `SERVER_SHUTDOWN` / `SERVER_REBOOT` onto `RdpDesktopConnection.pending_remote_session_end_reason` so the GUI can prompt for reconnect vs exit. |
203
+
204
+ When adding or changing a runtime monkey-patch, update this section in the same change set.
205
+
206
+ ## Release (PyPI)
207
+
208
+ Operator workflow mirrors a static frontend **build → package → deploy**, but artifacts are Python wheels/sdists and **deploy** uploads with **twine** (not S3).
209
+
210
+ Activate pyenv for **`.python-version`**, install dev tools once (`pip install -e ".[dev]"`), then from the repo root:
211
+
212
+ ```bash
213
+ ./script/build.sh
214
+ ARCHIVE="$(./script/package.sh patch)"
215
+ ./script/deploy.sh "${ARCHIVE}"
216
+ ```
217
+
218
+ | Script | Role |
219
+ |--------|------|
220
+ | **`script/build.sh`** | `python -m build` → **`dist/`** (wheel + sdist). No version bump. |
221
+ | **`script/package.sh`** | **`patch` \| `minor` \| `major`**: clean tree, bump **`pyproject.toml`**, commit, annotated **`vX.Y.Z`**, run **`build.sh`**, pack **`build/archives/vX.Y.Z_rdp_pipe.tar.gz`**. Stdout = archive path. |
222
+ | **`script/deploy.sh`** | Positional archive path: extract, **`twine check`**, **`twine upload`**. Writes **`releases/pypi/X.Y.Z.md`** when the tree is clean. |
223
+
224
+ PyPI credentials: **`~/.pypirc`** or **`TWINE_USERNAME`** / **`TWINE_PASSWORD`**. For TestPyPI, set **`TWINE_REPOSITORY_URL=https://test.pypi.org/legacy/`** before **`deploy.sh`**.
225
+
226
+ After **`package.sh`**, push the release commit and tag when ready: **`git push origin HEAD vX.Y.Z`**.
227
+
228
+ ## Test
229
+
230
+ ```bash
231
+ pip install -e ".[dev]"
232
+ python -m pytest -q
233
+ ```
234
+
235
+ MCP server tests need the **`mcp`** extra: `pip install -e ".[dev,mcp]"`.
236
+
237
+ Offline unit tests cover URL/password handling, RDPDISP PDU encoding, pointer mask decoding, command socket protocol, and CLI validation.
238
+
239
+ ## Manual smoke
240
+
241
+ Connect to an RDP host with NLA enabled. Resize the client window and confirm the remote display resolution tracks when the server supports RDPDISP. Servers without RDPDISP keep a fixed session resolution; the local window may letterbox until disconnect.
242
+
243
+ To trace remote pointer mirroring (PDU types, apply/skip decisions, forwarded hover coordinates), run with `RDP_POINTER_DEBUG=1` and watch stderr while moving the mouse over window borders and text fields.
244
+
245
+ To trace keyboard capture after idle (Qt key events, pointer-inside gating, enqueue, forwarder dequeue), run with `RDP_KEYBOARD_DEBUG=1`. If GUI keys show no `keyPressEvent` lines but `rdpr send_key` still works, focus was stolen locally; if both fail, check stderr for `RDP deactivation-reactivation failed`.
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rdp_pipe"
7
+ version = "0.1.0"
8
+ description = "PyQt6 desktop RDP client with seamless resize (MS-RDPEDISP)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "aardwolf",
13
+ "PyQt6",
14
+ "Pillow",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = [
19
+ "build",
20
+ "pytest",
21
+ "pytest-asyncio",
22
+ "twine",
23
+ ]
24
+ mcp = [
25
+ "mcp",
26
+ ]
27
+
28
+ [project.scripts]
29
+ rdp = "rdp_pipe.entrypoint.rdp:main"
30
+ rdpr = "rdp_pipe.entrypoint.rdpr:main"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
37
+ asyncio_mode = "auto"
38
+ pythonpath = ["src"]
39
+
40
+ [tool.pyright]
41
+ include = ["src", "tests", "mcp"]
42
+ extraPaths = ["src"]
43
+ pythonVersion = "3.14"
44
+ typeCheckingMode = "basic"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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))