VirtualDesktop 1.0.1__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.
- VirtualDesktop/__init__.py +121 -0
- VirtualDesktop/__main__.py +10 -0
- VirtualDesktop/_bundle.py +369 -0
- VirtualDesktop/_compat.py +576 -0
- VirtualDesktop/_devices.py +335 -0
- VirtualDesktop/_download.py +462 -0
- VirtualDesktop/_helper.py +31 -0
- VirtualDesktop/_log.py +85 -0
- VirtualDesktop/_process.py +912 -0
- VirtualDesktop/_sigcheck.ps1 +32 -0
- VirtualDesktop/_sigcheck.py +321 -0
- VirtualDesktop/_winapi.py +714 -0
- VirtualDesktop/capture.py +671 -0
- VirtualDesktop/cli.py +673 -0
- VirtualDesktop/config.py +255 -0
- VirtualDesktop/desktop.py +231 -0
- VirtualDesktop/desktop_capture.py +159 -0
- VirtualDesktop/desktop_session.py +533 -0
- VirtualDesktop/display_setup.py +790 -0
- VirtualDesktop/driver.py +831 -0
- VirtualDesktop/driver_manifest.json +118 -0
- VirtualDesktop/errors.py +211 -0
- VirtualDesktop/manifest.py +310 -0
- VirtualDesktop/session.py +514 -0
- VirtualDesktop/window.py +869 -0
- virtualdesktop-1.0.1.dist-info/METADATA +307 -0
- virtualdesktop-1.0.1.dist-info/RECORD +31 -0
- virtualdesktop-1.0.1.dist-info/WHEEL +5 -0
- virtualdesktop-1.0.1.dist-info/entry_points.txt +2 -0
- virtualdesktop-1.0.1.dist-info/licenses/LICENSE +21 -0
- virtualdesktop-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
"""Silent subprocess execution, ACL-restricted work directories, and UAC elevation.
|
|
2
|
+
|
|
3
|
+
Everything in this module exists for one reason: the driver install must happen in an
|
|
4
|
+
**elevated child process** while the caller keeps running unelevated. Two consequences
|
|
5
|
+
drive the design:
|
|
6
|
+
|
|
7
|
+
* The caller must receive the child's exact outcome, so the child reports back over a
|
|
8
|
+
named pipe created by the caller (not over stdout, which ``ShellExecuteW`` cannot
|
|
9
|
+
connect, and not over a world-writable temp file, which any local process could forge).
|
|
10
|
+
* The elevated child must not be able to run attacker-controlled code, so it is launched
|
|
11
|
+
with ``-E -s`` (ignore ``PYTHON*`` environment variables and the per-user site
|
|
12
|
+
directory) and only ever consumes files from a verified, digest-pinned cache.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ctypes
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from ctypes import wintypes
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
30
|
+
|
|
31
|
+
from ._log import get_logger
|
|
32
|
+
from .errors import ConfigError, ElevationDenied, ElevationRequired, OffscreenMountError
|
|
33
|
+
from ._winapi import IS_WINDOWS, format_hresult, require_windows
|
|
34
|
+
|
|
35
|
+
log = get_logger("process")
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"CREATE_NO_WINDOW",
|
|
39
|
+
"CommandResult",
|
|
40
|
+
"run_command",
|
|
41
|
+
"secure_mkdir",
|
|
42
|
+
"secure_write_json",
|
|
43
|
+
"ResponsePipe",
|
|
44
|
+
"ElevationResult",
|
|
45
|
+
"elevate_and_run_helper",
|
|
46
|
+
"helper_command",
|
|
47
|
+
"import_roots",
|
|
48
|
+
"last_getlasterror",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
CREATE_NO_WINDOW = 0x08000000
|
|
52
|
+
SW_HIDE = 0
|
|
53
|
+
SW_SHOWNORMAL = 1
|
|
54
|
+
_STILL_ACTIVE = 259
|
|
55
|
+
_SEE_MASK_NOCLOSEPROCESS = 0x00000040
|
|
56
|
+
_SEE_MASK_NOASYNC = 0x00000100
|
|
57
|
+
_DEFAULT_CHUNK = 4096
|
|
58
|
+
|
|
59
|
+
if IS_WINDOWS: # pragma: no branch
|
|
60
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
61
|
+
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
|
|
62
|
+
advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
|
|
63
|
+
else: # pragma: no cover
|
|
64
|
+
kernel32 = shell32 = advapi32 = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def last_getlasterror() -> int:
|
|
68
|
+
if not IS_WINDOWS:
|
|
69
|
+
return 0
|
|
70
|
+
return ctypes.get_last_error()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# --------------------------------------------------------------------------------------
|
|
74
|
+
# Hardened directories
|
|
75
|
+
# --------------------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _SECURITY_ATTRIBUTES(ctypes.Structure):
|
|
79
|
+
_fields_ = [
|
|
80
|
+
("nLength", wintypes.DWORD),
|
|
81
|
+
("lpSecurityDescriptor", ctypes.c_void_p),
|
|
82
|
+
("bInheritHandle", wintypes.BOOL),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _SHELLEXECUTEINFOW(ctypes.Structure):
|
|
87
|
+
"""``SHELLEXECUTEINFOW`` - needed to obtain the elevated child's process handle."""
|
|
88
|
+
|
|
89
|
+
_fields_ = [
|
|
90
|
+
("cbSize", wintypes.DWORD),
|
|
91
|
+
("fMask", ctypes.c_ulong),
|
|
92
|
+
("hwnd", wintypes.HWND),
|
|
93
|
+
("lpVerb", ctypes.c_wchar_p),
|
|
94
|
+
("lpFile", ctypes.c_wchar_p),
|
|
95
|
+
("lpParameters", ctypes.c_wchar_p),
|
|
96
|
+
("lpDirectory", ctypes.c_wchar_p),
|
|
97
|
+
("nShow", ctypes.c_int),
|
|
98
|
+
("hInstApp", wintypes.HINSTANCE),
|
|
99
|
+
("lpIDList", ctypes.c_void_p),
|
|
100
|
+
("lpClass", ctypes.c_wchar_p),
|
|
101
|
+
("hkeyClass", wintypes.HKEY),
|
|
102
|
+
("dwHotKey", wintypes.DWORD),
|
|
103
|
+
("hIcon", wintypes.HANDLE),
|
|
104
|
+
("hProcess", wintypes.HANDLE),
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_SDDL_REVISION_1 = 1
|
|
109
|
+
_SE_FILE_OBJECT = 1
|
|
110
|
+
_DACL_SECURITY_INFORMATION = 0x00000004
|
|
111
|
+
_PROTECTED_DACL_SECURITY_INFORMATION = 0x80000000
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _owner_only_sd(access: Tuple[str, ...] = ("FA",)) -> Optional[ctypes.c_void_p]:
|
|
115
|
+
"""Build a protected DACL: full control for SYSTEM/Administrators/owner.
|
|
116
|
+
|
|
117
|
+
``access`` overrides the permission mask granted to the owner (``OW``), which is how
|
|
118
|
+
the response pipe grants ``GW`` (generic write) instead of full control.
|
|
119
|
+
"""
|
|
120
|
+
if not IS_WINDOWS:
|
|
121
|
+
return None
|
|
122
|
+
owner_mask = "".join(access)
|
|
123
|
+
sddl = f"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;{owner_mask};;;OW)"
|
|
124
|
+
descriptor = ctypes.c_void_p()
|
|
125
|
+
ok = advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
|
126
|
+
ctypes.c_wchar_p(sddl),
|
|
127
|
+
ctypes.c_uint(_SDDL_REVISION_1),
|
|
128
|
+
ctypes.byref(descriptor),
|
|
129
|
+
None,
|
|
130
|
+
)
|
|
131
|
+
if not ok:
|
|
132
|
+
log.debug("could not build security descriptor (%s): %s", sddl, last_getlasterror())
|
|
133
|
+
return None
|
|
134
|
+
return descriptor
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _owner_sid_string() -> Optional[str]:
|
|
138
|
+
"""The current process token's user SID in SDDL form (``S-1-5-21-...``)."""
|
|
139
|
+
if not IS_WINDOWS:
|
|
140
|
+
return None
|
|
141
|
+
TOKEN_QUERY = 0x0008
|
|
142
|
+
TokenUser = 1
|
|
143
|
+
token = wintypes.HANDLE()
|
|
144
|
+
if not advapi32.OpenProcessToken(
|
|
145
|
+
kernel32.GetCurrentProcess(), TOKEN_QUERY, ctypes.byref(token)
|
|
146
|
+
):
|
|
147
|
+
return None
|
|
148
|
+
try:
|
|
149
|
+
needed = wintypes.DWORD()
|
|
150
|
+
advapi32.GetTokenInformation(token, TokenUser, None, 0, ctypes.byref(needed))
|
|
151
|
+
if not needed.value:
|
|
152
|
+
return None
|
|
153
|
+
buffer = ctypes.create_string_buffer(needed.value)
|
|
154
|
+
if not advapi32.GetTokenInformation(
|
|
155
|
+
token, TokenUser, buffer, needed.value, ctypes.byref(needed)
|
|
156
|
+
):
|
|
157
|
+
return None
|
|
158
|
+
# TOKEN_USER is a SID_AND_ATTRIBUTES struct: the first pointer-sized field is the SID.
|
|
159
|
+
sid = ctypes.cast(buffer, ctypes.POINTER(ctypes.c_void_p))[0]
|
|
160
|
+
text = ctypes.c_wchar_p()
|
|
161
|
+
if not advapi32.ConvertSidToStringSidW(ctypes.c_void_p(sid), ctypes.byref(text)):
|
|
162
|
+
return None
|
|
163
|
+
try:
|
|
164
|
+
return text.value
|
|
165
|
+
finally:
|
|
166
|
+
kernel32.LocalFree(text)
|
|
167
|
+
finally:
|
|
168
|
+
kernel32.CloseHandle(token)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
#: FILE_GENERIC_WRITE, spelled numerically on purpose. The SDDL abbreviation ``GW`` maps to
|
|
172
|
+
#: a mask that also includes DELETE-related bits, which a named pipe cannot satisfy, so the
|
|
173
|
+
#: client's ``GENERIC_WRITE`` open failed the access check with ERROR_ACCESS_DENIED
|
|
174
|
+
#: (measured: ``GW`` = FAIL(5), ``0x12019b`` = OK). The numeric mask is the exact
|
|
175
|
+
#: *mappable* subset and is honoured on every Windows version tested.
|
|
176
|
+
FILE_GENERIC_WRITE = 0x0012019B
|
|
177
|
+
|
|
178
|
+
#: Directory equivalent: SDDL can safely use ``FA`` for directories because the object is
|
|
179
|
+
#: created with the caller as owner, so generic bits resolve normally.
|
|
180
|
+
FILE_ALL_ACCESS_SDDL = "FA"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _security_descriptor(
|
|
184
|
+
user_access: str = FILE_ALL_ACCESS_SDDL,
|
|
185
|
+
owner_sid: Optional[str] = None,
|
|
186
|
+
) -> Optional[ctypes.c_void_p]:
|
|
187
|
+
"""Build a *protected* DACL: SYSTEM + Administrators + this user's SID.
|
|
188
|
+
|
|
189
|
+
``user_access`` is the access mask granted to the current user, either an SDDL
|
|
190
|
+
abbreviation (``FA``) or a numeric mask (``0x0012019B``). The current user's SID is
|
|
191
|
+
written out explicitly instead of relying on the ``OW`` ("owner rights") SID: ``OW``
|
|
192
|
+
only applies to objects that actually have an owner, which a freshly created **named
|
|
193
|
+
pipe** does not (``ConvertStringSecurityDescriptorToSecurityDescriptor`` leaves the
|
|
194
|
+
owner unset), so an ``OW``-only ACE silently grants nothing and the client gets
|
|
195
|
+
ERROR_ACCESS_DENIED.
|
|
196
|
+
"""
|
|
197
|
+
if not IS_WINDOWS:
|
|
198
|
+
return None
|
|
199
|
+
sid = owner_sid or _owner_sid_string()
|
|
200
|
+
user_ace = f"(A;OICI;{user_access};;;{sid})" if sid else ""
|
|
201
|
+
sddl = f"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA){user_ace}"
|
|
202
|
+
descriptor = ctypes.c_void_p()
|
|
203
|
+
ok = advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
|
204
|
+
ctypes.c_wchar_p(sddl),
|
|
205
|
+
ctypes.c_uint(_SDDL_REVISION_1),
|
|
206
|
+
ctypes.byref(descriptor),
|
|
207
|
+
None,
|
|
208
|
+
)
|
|
209
|
+
if not ok:
|
|
210
|
+
log.debug("could not build security descriptor (%s): %s", sddl, last_getlasterror())
|
|
211
|
+
return None
|
|
212
|
+
return descriptor
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _local_free(descriptor: Optional[ctypes.c_void_p]) -> None:
|
|
216
|
+
if descriptor:
|
|
217
|
+
kernel32.LocalFree(descriptor)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if IS_WINDOWS: # pragma: no branch
|
|
221
|
+
advapi32.OpenProcessToken.argtypes = [
|
|
222
|
+
wintypes.HANDLE,
|
|
223
|
+
wintypes.DWORD,
|
|
224
|
+
ctypes.POINTER(wintypes.HANDLE),
|
|
225
|
+
]
|
|
226
|
+
advapi32.OpenProcessToken.restype = wintypes.BOOL
|
|
227
|
+
advapi32.GetTokenInformation.argtypes = [
|
|
228
|
+
wintypes.HANDLE,
|
|
229
|
+
ctypes.c_int,
|
|
230
|
+
ctypes.c_void_p,
|
|
231
|
+
wintypes.DWORD,
|
|
232
|
+
ctypes.POINTER(wintypes.DWORD),
|
|
233
|
+
]
|
|
234
|
+
advapi32.GetTokenInformation.restype = wintypes.BOOL
|
|
235
|
+
advapi32.ConvertSidToStringSidW.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_wchar_p)]
|
|
236
|
+
advapi32.ConvertSidToStringSidW.restype = wintypes.BOOL
|
|
237
|
+
advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = [
|
|
238
|
+
ctypes.c_wchar_p,
|
|
239
|
+
ctypes.c_uint,
|
|
240
|
+
ctypes.POINTER(ctypes.c_void_p),
|
|
241
|
+
ctypes.POINTER(wintypes.DWORD),
|
|
242
|
+
]
|
|
243
|
+
advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.restype = wintypes.BOOL
|
|
244
|
+
kernel32.CreateDirectoryW.argtypes = [
|
|
245
|
+
ctypes.c_wchar_p,
|
|
246
|
+
ctypes.POINTER(_SECURITY_ATTRIBUTES),
|
|
247
|
+
]
|
|
248
|
+
kernel32.CreateDirectoryW.restype = wintypes.BOOL
|
|
249
|
+
kernel32.GetCurrentProcess.restype = wintypes.HANDLE
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def secure_mkdir(path: os.PathLike) -> Path:
|
|
253
|
+
"""``mkdir -p`` with an ACL that excludes other local users where possible.
|
|
254
|
+
|
|
255
|
+
**Every** missing component is created with the hardened descriptor, not just the leaf:
|
|
256
|
+
creating a child inside a permissive parent leaves that parent traversable by other
|
|
257
|
+
users, and ``CreateDirectoryW`` fails with ``ERROR_PATH_NOT_FOUND`` (3) when its parent
|
|
258
|
+
does not exist — so delegating to Python's ``parents=True`` would silently skip the ACL
|
|
259
|
+
work for exactly the nested paths this library uses.
|
|
260
|
+
|
|
261
|
+
Falls back to a plain ``mkdir`` (and logs at debug level) when the security APIs are
|
|
262
|
+
unavailable; the directory then still lives under the user's own cache/profile root.
|
|
263
|
+
"""
|
|
264
|
+
target = Path(path)
|
|
265
|
+
if target.exists():
|
|
266
|
+
return target
|
|
267
|
+
missing: List[Path] = []
|
|
268
|
+
cursor = target
|
|
269
|
+
while not cursor.exists():
|
|
270
|
+
missing.append(cursor)
|
|
271
|
+
if cursor.parent == cursor: # pragma: no cover - filesystem root
|
|
272
|
+
break
|
|
273
|
+
cursor = cursor.parent
|
|
274
|
+
for directory in reversed(missing):
|
|
275
|
+
descriptor = _security_descriptor(FILE_ALL_ACCESS_SDDL)
|
|
276
|
+
if descriptor is None:
|
|
277
|
+
directory.mkdir(exist_ok=True)
|
|
278
|
+
continue
|
|
279
|
+
attributes = _SECURITY_ATTRIBUTES()
|
|
280
|
+
attributes.nLength = ctypes.sizeof(_SECURITY_ATTRIBUTES)
|
|
281
|
+
attributes.lpSecurityDescriptor = descriptor
|
|
282
|
+
attributes.bInheritHandle = False
|
|
283
|
+
try:
|
|
284
|
+
if not kernel32.CreateDirectoryW(str(directory), ctypes.byref(attributes)):
|
|
285
|
+
error = last_getlasterror()
|
|
286
|
+
if error != 183 and not directory.exists(): # ERROR_ALREADY_EXISTS
|
|
287
|
+
log.debug("hardened CreateDirectory failed (%s); falling back", error)
|
|
288
|
+
directory.mkdir(exist_ok=True)
|
|
289
|
+
finally:
|
|
290
|
+
_local_free(descriptor)
|
|
291
|
+
return target
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def secure_write_json(path: os.PathLike, payload: Dict[str, Any]) -> Path:
|
|
295
|
+
"""Atomically write JSON into a file that is not group/world accessible."""
|
|
296
|
+
target = Path(path)
|
|
297
|
+
secure_mkdir(target.parent)
|
|
298
|
+
text = json.dumps(payload, indent=2, sort_keys=True, default=str)
|
|
299
|
+
temp = target.with_suffix(target.suffix + f".{uuid.uuid4().hex[:8]}.tmp")
|
|
300
|
+
temp.write_text(text, encoding="utf-8")
|
|
301
|
+
os.replace(temp, target)
|
|
302
|
+
return target
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# --------------------------------------------------------------------------------------
|
|
306
|
+
# Subprocess execution
|
|
307
|
+
# --------------------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@dataclass
|
|
311
|
+
class CommandResult:
|
|
312
|
+
argv: Sequence[str]
|
|
313
|
+
returncode: int
|
|
314
|
+
stdout: str = ""
|
|
315
|
+
stderr: str = ""
|
|
316
|
+
log_path: Optional[str] = None
|
|
317
|
+
duration: float = 0.0
|
|
318
|
+
|
|
319
|
+
@property
|
|
320
|
+
def ok(self) -> bool:
|
|
321
|
+
return self.returncode == 0
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def requires_reboot(self) -> bool:
|
|
325
|
+
return self.returncode == 3010
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def rendered(self) -> str:
|
|
329
|
+
return " ".join(str(a) if " " not in str(a) else f'"{a}"' for a in self.argv)
|
|
330
|
+
|
|
331
|
+
def to_dict(self) -> dict:
|
|
332
|
+
return {
|
|
333
|
+
"argv": list(self.argv),
|
|
334
|
+
"returncode": self.returncode,
|
|
335
|
+
"requires_reboot": self.requires_reboot,
|
|
336
|
+
"log_path": self.log_path,
|
|
337
|
+
"duration": round(self.duration, 3),
|
|
338
|
+
"stdout_tail": self.stdout[-2000:],
|
|
339
|
+
"stderr_tail": self.stderr[-2000:],
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def run_command(
|
|
344
|
+
argv: Sequence[str],
|
|
345
|
+
*,
|
|
346
|
+
timeout: float = 300.0,
|
|
347
|
+
log_path: Optional[os.PathLike] = None,
|
|
348
|
+
env: Optional[Dict[str, str]] = None,
|
|
349
|
+
cwd: Optional[os.PathLike] = None,
|
|
350
|
+
) -> CommandResult:
|
|
351
|
+
"""Run a command with no console window, capturing output and never using a shell.
|
|
352
|
+
|
|
353
|
+
``argv`` is always passed as a list: no ``shell=True`` anywhere in this library, so
|
|
354
|
+
quotes inside a path can never turn into command injection.
|
|
355
|
+
"""
|
|
356
|
+
if not argv:
|
|
357
|
+
raise ConfigError("run_command requires a non-empty argv", code="empty-argv")
|
|
358
|
+
argv = [str(a) for a in argv]
|
|
359
|
+
started = time.monotonic()
|
|
360
|
+
try:
|
|
361
|
+
completed = subprocess.run(
|
|
362
|
+
argv,
|
|
363
|
+
capture_output=True,
|
|
364
|
+
text=True,
|
|
365
|
+
timeout=timeout,
|
|
366
|
+
cwd=str(cwd) if cwd else None,
|
|
367
|
+
env=env,
|
|
368
|
+
creationflags=CREATE_NO_WINDOW if IS_WINDOWS else 0,
|
|
369
|
+
errors="replace",
|
|
370
|
+
)
|
|
371
|
+
stdout, stderr, code = completed.stdout or "", completed.stderr or "", completed.returncode
|
|
372
|
+
except FileNotFoundError as exc:
|
|
373
|
+
stdout, stderr, code = "", str(exc), 127
|
|
374
|
+
except subprocess.TimeoutExpired as exc:
|
|
375
|
+
stdout = (exc.stdout or b"").decode("utf-8", "replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
|
|
376
|
+
stderr = f"timed out after {timeout}s"
|
|
377
|
+
code = 124
|
|
378
|
+
duration = time.monotonic() - started
|
|
379
|
+
result = CommandResult(
|
|
380
|
+
argv=argv,
|
|
381
|
+
returncode=code,
|
|
382
|
+
stdout=stdout,
|
|
383
|
+
stderr=stderr,
|
|
384
|
+
log_path=str(log_path) if log_path else None,
|
|
385
|
+
duration=duration,
|
|
386
|
+
)
|
|
387
|
+
if log_path:
|
|
388
|
+
try:
|
|
389
|
+
secure_mkdir(Path(log_path).parent)
|
|
390
|
+
with open(log_path, "a", encoding="utf-8") as handle:
|
|
391
|
+
handle.write(f"\n=== {time.strftime('%Y-%m-%d %H:%M:%S')} {result.rendered}\n")
|
|
392
|
+
handle.write(f"--- exit code: {code} ({duration:.2f}s)\n")
|
|
393
|
+
if stdout:
|
|
394
|
+
handle.write("--- stdout\n" + stdout + "\n")
|
|
395
|
+
if stderr:
|
|
396
|
+
handle.write("--- stderr\n" + stderr + "\n")
|
|
397
|
+
except OSError as exc: # pragma: no cover - logging must never break the flow
|
|
398
|
+
log.warning("could not write command log %s: %s", log_path, exc)
|
|
399
|
+
log.debug("ran %s -> %s in %.2fs", result.rendered, code, duration)
|
|
400
|
+
return result
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# --------------------------------------------------------------------------------------
|
|
404
|
+
# Response pipe (child -> parent, spoof-proof)
|
|
405
|
+
# --------------------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
_PIPE_ACCESS_OUTBOUND = 0x00000002
|
|
408
|
+
#: Duplex is required, not a preference: a client that asks for ``GENERIC_WRITE`` is
|
|
409
|
+
#: checked against the pipe's *write* right, but an outbound-only pipe grants
|
|
410
|
+
#: ``FILE_WRITE_DATA`` and the generic mapping then fails the access check with
|
|
411
|
+
#: ERROR_ACCESS_DENIED (measured: outbound=FAIL 5, duplex=OK).
|
|
412
|
+
_PIPE_ACCESS_DUPLEX = 0x00000003
|
|
413
|
+
_FILE_FLAG_FIRST_PIPE_INSTANCE = 0x00080000
|
|
414
|
+
_PIPE_TYPE_BYTE = 0x00000000
|
|
415
|
+
_PIPE_READMODE_BYTE = 0x00000000
|
|
416
|
+
_PIPE_WAIT = 0x00000000
|
|
417
|
+
#: Non-blocking mode. Used deliberately for the **server** side of the response pipe so
|
|
418
|
+
#: that a caller-supplied timeout is actually honoured: in blocking mode
|
|
419
|
+
#: ``ConnectNamedPipe`` waits for ``nDefaultTimeOut`` (we would have to pass 10 minutes to
|
|
420
|
+
#: cover a slow UAC prompt), which would make ``wait_for_message(timeout=...)`` a lie.
|
|
421
|
+
_PIPE_NOWAIT = 0x00000001
|
|
422
|
+
_INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
|
|
423
|
+
|
|
424
|
+
if IS_WINDOWS: # pragma: no branch
|
|
425
|
+
# Explicit prototypes matter here: without them ctypes assumes ``int``, which
|
|
426
|
+
# truncates 64-bit HANDLEs and silently breaks named-pipe I/O on some builds.
|
|
427
|
+
kernel32.CreateNamedPipeW.argtypes = [
|
|
428
|
+
ctypes.c_wchar_p,
|
|
429
|
+
wintypes.DWORD,
|
|
430
|
+
wintypes.DWORD,
|
|
431
|
+
wintypes.DWORD,
|
|
432
|
+
wintypes.DWORD,
|
|
433
|
+
wintypes.DWORD,
|
|
434
|
+
wintypes.DWORD,
|
|
435
|
+
ctypes.c_void_p,
|
|
436
|
+
]
|
|
437
|
+
kernel32.CreateNamedPipeW.restype = wintypes.HANDLE
|
|
438
|
+
kernel32.ConnectNamedPipe.argtypes = [wintypes.HANDLE, ctypes.c_void_p]
|
|
439
|
+
kernel32.ConnectNamedPipe.restype = wintypes.BOOL
|
|
440
|
+
kernel32.DisconnectNamedPipe.argtypes = [wintypes.HANDLE]
|
|
441
|
+
kernel32.DisconnectNamedPipe.restype = wintypes.BOOL
|
|
442
|
+
kernel32.ReadFile.argtypes = [
|
|
443
|
+
wintypes.HANDLE,
|
|
444
|
+
ctypes.c_void_p,
|
|
445
|
+
wintypes.DWORD,
|
|
446
|
+
ctypes.POINTER(wintypes.DWORD),
|
|
447
|
+
ctypes.c_void_p,
|
|
448
|
+
]
|
|
449
|
+
kernel32.ReadFile.restype = wintypes.BOOL
|
|
450
|
+
kernel32.WriteFile.argtypes = [
|
|
451
|
+
wintypes.HANDLE,
|
|
452
|
+
ctypes.c_void_p,
|
|
453
|
+
wintypes.DWORD,
|
|
454
|
+
ctypes.POINTER(wintypes.DWORD),
|
|
455
|
+
ctypes.c_void_p,
|
|
456
|
+
]
|
|
457
|
+
kernel32.WriteFile.restype = wintypes.BOOL
|
|
458
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
459
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
460
|
+
kernel32.CreateFileW.argtypes = [
|
|
461
|
+
ctypes.c_wchar_p,
|
|
462
|
+
wintypes.DWORD,
|
|
463
|
+
wintypes.DWORD,
|
|
464
|
+
ctypes.c_void_p,
|
|
465
|
+
wintypes.DWORD,
|
|
466
|
+
wintypes.DWORD,
|
|
467
|
+
wintypes.HANDLE,
|
|
468
|
+
]
|
|
469
|
+
kernel32.CreateFileW.restype = wintypes.HANDLE
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
class ResponsePipe:
|
|
473
|
+
"""A one-shot named pipe the elevated child writes its JSON result into."""
|
|
474
|
+
|
|
475
|
+
def __init__(self, name: Optional[str] = None):
|
|
476
|
+
require_windows("ResponsePipe")
|
|
477
|
+
token = os.environ.get("USERNAME", "user")
|
|
478
|
+
self.name = name or rf"\\.\pipe\VirtualDesktop-{token}-{uuid.uuid4().hex}"
|
|
479
|
+
self._handle: Optional[int] = None
|
|
480
|
+
# The default named-pipe DACL does not always include the creating user's own
|
|
481
|
+
# account (WriteDAC is refused for non-elevated creators), and an outbound-only
|
|
482
|
+
# pipe fails the client's generic-write access check outright. Grant generic write
|
|
483
|
+
# explicitly to the owner, SYSTEM and Administrators, and keep the pipe duplex: it
|
|
484
|
+
# is then reliably usable by the elevated child while still excluding every other
|
|
485
|
+
# local user.
|
|
486
|
+
self._descriptor = _security_descriptor(f"0x{FILE_GENERIC_WRITE:08X}")
|
|
487
|
+
self._attrs = _SECURITY_ATTRIBUTES()
|
|
488
|
+
self._attrs.nLength = ctypes.sizeof(_SECURITY_ATTRIBUTES)
|
|
489
|
+
self._attrs.lpSecurityDescriptor = self._descriptor
|
|
490
|
+
self._attrs.bInheritHandle = False
|
|
491
|
+
|
|
492
|
+
def create(self) -> str:
|
|
493
|
+
handle = kernel32.CreateNamedPipeW(
|
|
494
|
+
self.name,
|
|
495
|
+
_PIPE_ACCESS_DUPLEX | _FILE_FLAG_FIRST_PIPE_INSTANCE,
|
|
496
|
+
_PIPE_TYPE_BYTE | _PIPE_READMODE_BYTE | _PIPE_NOWAIT,
|
|
497
|
+
1,
|
|
498
|
+
64 * 1024,
|
|
499
|
+
64 * 1024,
|
|
500
|
+
1000 * 60 * 10,
|
|
501
|
+
ctypes.byref(self._attrs),
|
|
502
|
+
)
|
|
503
|
+
if not handle or handle == _INVALID_HANDLE_VALUE:
|
|
504
|
+
raise OffscreenMountError(
|
|
505
|
+
f"could not create the response pipe {self.name}: {last_getlasterror()}",
|
|
506
|
+
code="pipe-create-failed",
|
|
507
|
+
)
|
|
508
|
+
self._handle = handle
|
|
509
|
+
return self.name
|
|
510
|
+
|
|
511
|
+
def wait_for_message(
|
|
512
|
+
self,
|
|
513
|
+
*,
|
|
514
|
+
timeout: float = 600.0,
|
|
515
|
+
poll: float = 0.05,
|
|
516
|
+
on_listening: Optional[callable] = None,
|
|
517
|
+
) -> Optional[dict]:
|
|
518
|
+
"""Block until the child connects (or the timeout expires), then read its JSON.
|
|
519
|
+
|
|
520
|
+
``on_listening`` is invoked once, as soon as the pipe is listening, so a caller can
|
|
521
|
+
start the client *after* the server is ready (optional and purely an optimisation).
|
|
522
|
+
"""
|
|
523
|
+
if self._handle is None:
|
|
524
|
+
raise OffscreenMountError("pipe not created", code="pipe-not-created")
|
|
525
|
+
deadline = time.monotonic() + timeout
|
|
526
|
+
connected = False
|
|
527
|
+
announced = False
|
|
528
|
+
access_denied_since: Optional[float] = None
|
|
529
|
+
while time.monotonic() < deadline:
|
|
530
|
+
if kernel32.ConnectNamedPipe(self._handle, None):
|
|
531
|
+
connected = True
|
|
532
|
+
break
|
|
533
|
+
error = last_getlasterror()
|
|
534
|
+
if error == 535: # ERROR_PIPE_CONNECTED - client connected before we called
|
|
535
|
+
connected = True
|
|
536
|
+
break
|
|
537
|
+
# In PIPE_NOWAIT mode the *connected* state is reported as ERROR_NO_DATA (232)
|
|
538
|
+
# on the next ConnectNamedPipe call, so that error is the success signal here
|
|
539
|
+
# (measured: listening=536 -> client writes -> connected-but-reported-as-232).
|
|
540
|
+
if error == 232:
|
|
541
|
+
connected = True
|
|
542
|
+
break
|
|
543
|
+
# ERROR_PIPE_LISTENING (536) is the only proof that the pipe is *ready*; firing
|
|
544
|
+
# the callback any earlier would let a client race into ERROR_PIPE_BUSY.
|
|
545
|
+
if not announced and error == 536 and on_listening is not None:
|
|
546
|
+
announced = True
|
|
547
|
+
try:
|
|
548
|
+
on_listening()
|
|
549
|
+
except Exception: # pragma: no cover - callback must never break the wait
|
|
550
|
+
log.debug("on_listening callback raised", exc_info=True)
|
|
551
|
+
if error == 5: # ERROR_ACCESS_DENIED - the client could not open the pipe
|
|
552
|
+
if access_denied_since is None:
|
|
553
|
+
access_denied_since = time.monotonic()
|
|
554
|
+
elif time.monotonic() - access_denied_since > min(2.0, max(timeout / 4.0, 0.25)):
|
|
555
|
+
log.error(
|
|
556
|
+
"the helper cannot open %s (ERROR_ACCESS_DENIED); it will not be "
|
|
557
|
+
"able to report its result",
|
|
558
|
+
self.name,
|
|
559
|
+
)
|
|
560
|
+
return None
|
|
561
|
+
time.sleep(poll)
|
|
562
|
+
if not connected:
|
|
563
|
+
log.warning("no response received on %s within %.0fs", self.name, timeout)
|
|
564
|
+
return None
|
|
565
|
+
buffer = ctypes.create_string_buffer(64 * 1024)
|
|
566
|
+
read = wintypes.DWORD()
|
|
567
|
+
chunks: List[bytes] = []
|
|
568
|
+
deadline = time.monotonic() + 5.0
|
|
569
|
+
while True:
|
|
570
|
+
ok = kernel32.ReadFile(
|
|
571
|
+
self._handle, buffer, ctypes.sizeof(buffer) - 1, ctypes.byref(read), None
|
|
572
|
+
)
|
|
573
|
+
if not ok:
|
|
574
|
+
error = last_getlasterror()
|
|
575
|
+
if error == 234: # ERROR_MORE_DATA - message-mode leftovers, keep reading
|
|
576
|
+
continue
|
|
577
|
+
# In PIPE_NOWAIT mode a read with no data pending reports ERROR_NO_DATA
|
|
578
|
+
# (232); the client may simply not have written yet, so retry briefly.
|
|
579
|
+
if error == 232 and time.monotonic() < deadline:
|
|
580
|
+
time.sleep(0.02)
|
|
581
|
+
continue
|
|
582
|
+
if error not in (232, 109): # 109 = ERROR_BROKEN_PIPE
|
|
583
|
+
log.debug("ReadFile on %s failed with %s", self.name, error)
|
|
584
|
+
break
|
|
585
|
+
if read.value == 0:
|
|
586
|
+
break
|
|
587
|
+
chunks.append(buffer.raw[: read.value])
|
|
588
|
+
payload = b"".join(chunks).decode("utf-8", "replace").strip()
|
|
589
|
+
if not payload:
|
|
590
|
+
return None
|
|
591
|
+
try:
|
|
592
|
+
data = json.loads(payload)
|
|
593
|
+
except json.JSONDecodeError:
|
|
594
|
+
log.warning("unparsable helper response: %r", payload[:400])
|
|
595
|
+
return None
|
|
596
|
+
return data if isinstance(data, dict) else None
|
|
597
|
+
|
|
598
|
+
def close(self) -> None:
|
|
599
|
+
if self._handle:
|
|
600
|
+
kernel32.DisconnectNamedPipe(self._handle)
|
|
601
|
+
kernel32.CloseHandle(self._handle)
|
|
602
|
+
self._handle = None
|
|
603
|
+
if getattr(self, "_descriptor", None):
|
|
604
|
+
_local_free(self._descriptor)
|
|
605
|
+
self._descriptor = None
|
|
606
|
+
|
|
607
|
+
def __enter__(self) -> "ResponsePipe":
|
|
608
|
+
self.create()
|
|
609
|
+
return self
|
|
610
|
+
|
|
611
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
612
|
+
self.close()
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def write_response(pipe_name: str, payload: Dict[str, Any], *, timeout: float = 30.0) -> bool:
|
|
616
|
+
"""Child-side helper: open ``pipe_name`` and write the JSON payload.
|
|
617
|
+
|
|
618
|
+
Retries on ``ERROR_PIPE_BUSY`` (231), which is what a client sees if it races ahead of
|
|
619
|
+
the server's ``ConnectNamedPipe`` call. Returning silently on a busy pipe would lose the
|
|
620
|
+
child's result *and* hang the waiting parent, so this call is deliberately persistent.
|
|
621
|
+
"""
|
|
622
|
+
require_windows("write_response")
|
|
623
|
+
GENERIC_WRITE = 0x40000000
|
|
624
|
+
OPEN_EXISTING = 3
|
|
625
|
+
ERROR_PIPE_BUSY = 231
|
|
626
|
+
handle = None
|
|
627
|
+
deadline = time.monotonic() + timeout
|
|
628
|
+
while True:
|
|
629
|
+
handle = kernel32.CreateFileW(pipe_name, GENERIC_WRITE, 0, None, OPEN_EXISTING, 0, None)
|
|
630
|
+
if handle and handle != _INVALID_HANDLE_VALUE:
|
|
631
|
+
break
|
|
632
|
+
error = last_getlasterror()
|
|
633
|
+
if error == ERROR_PIPE_BUSY and time.monotonic() < deadline:
|
|
634
|
+
time.sleep(0.05)
|
|
635
|
+
continue
|
|
636
|
+
log.warning("helper could not open response pipe %s: %s", pipe_name, error)
|
|
637
|
+
return False
|
|
638
|
+
try:
|
|
639
|
+
data = json.dumps(payload, default=str).encode("utf-8")
|
|
640
|
+
written = wintypes.DWORD()
|
|
641
|
+
ok = kernel32.WriteFile(handle, data, len(data), ctypes.byref(written), None)
|
|
642
|
+
if not ok:
|
|
643
|
+
log.warning("helper could not write to %s: %s", pipe_name, last_getlasterror())
|
|
644
|
+
return bool(ok)
|
|
645
|
+
finally:
|
|
646
|
+
kernel32.CloseHandle(handle)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
# --------------------------------------------------------------------------------------
|
|
650
|
+
# Elevation
|
|
651
|
+
# --------------------------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
@dataclass
|
|
655
|
+
class ElevationResult:
|
|
656
|
+
launched: bool
|
|
657
|
+
response: Optional[dict] = None
|
|
658
|
+
shell_result: int = 0
|
|
659
|
+
timed_out: bool = False
|
|
660
|
+
|
|
661
|
+
@property
|
|
662
|
+
def helper_ok(self) -> bool:
|
|
663
|
+
return bool(self.response and self.response.get("ok"))
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def package_root() -> str:
|
|
667
|
+
"""Directory that must be importable for ``import VirtualDesktop`` to work."""
|
|
668
|
+
return str(Path(__file__).resolve().parent.parent)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def import_roots() -> List[str]:
|
|
672
|
+
"""Every directory the *current* interpreter imports ``VirtualDesktop`` from.
|
|
673
|
+
|
|
674
|
+
This matters for an installed package, and it is not the same as :func:`package_root`:
|
|
675
|
+
|
|
676
|
+
* a **source checkout** imports the package because its parent directory is on ``sys.path``;
|
|
677
|
+
* a **wheel install** imports it from ``site-packages``, and the *parent* of the package
|
|
678
|
+
directory is then ``site-packages`` while the code itself lives in
|
|
679
|
+
``site-packages/VirtualDesktop``.
|
|
680
|
+
|
|
681
|
+
``_helper.py`` adds its own directory and that directory's parent to ``sys.path``, which is
|
|
682
|
+
enough when the elevated child runs the *same* interpreter. Handing it the real site-packages
|
|
683
|
+
as well keeps the helper working even when the elevated child resolves a different Python
|
|
684
|
+
(a different venv, or a system interpreter), which would otherwise fail with
|
|
685
|
+
``ModuleNotFoundError: VirtualDesktop`` and a silent pipe.
|
|
686
|
+
"""
|
|
687
|
+
roots: List[str] = []
|
|
688
|
+
package_dir = Path(__file__).resolve().parent
|
|
689
|
+
for candidate in (package_dir.parent, package_dir):
|
|
690
|
+
text = str(candidate)
|
|
691
|
+
if text not in roots:
|
|
692
|
+
roots.append(text)
|
|
693
|
+
try:
|
|
694
|
+
import site
|
|
695
|
+
|
|
696
|
+
for candidate in list(site.getsitepackages()) + [site.getusersitepackages()]:
|
|
697
|
+
text = str(candidate)
|
|
698
|
+
if text and text not in roots and Path(text).is_dir():
|
|
699
|
+
roots.append(text)
|
|
700
|
+
except Exception: # pragma: no cover - site is always importable on CPython
|
|
701
|
+
log.debug("could not enumerate site-packages for the helper environment", exc_info=True)
|
|
702
|
+
return roots
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def helper_environment(base: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
|
706
|
+
"""A deliberately minimal environment for the elevated helper.
|
|
707
|
+
|
|
708
|
+
``-E`` (ignore ``PYTHON*`` variables) is *not* used any more, because it also discards the
|
|
709
|
+
``PYTHONPATH`` the helper needs when the library is not installed into site-packages.
|
|
710
|
+
Security is preserved by constructing the environment ourselves instead of inheriting one:
|
|
711
|
+
only the variables below are passed on, and ``PYTHONPATH`` is built from
|
|
712
|
+
:func:`import_roots` rather than taken from the caller, so neither an inherited variable nor
|
|
713
|
+
a stale one can redirect what the helper imports. ``PYTHONSTARTUP``/``PYTHONHOME``/
|
|
714
|
+
``PYTHONUSERBASE`` are explicitly removed.
|
|
715
|
+
"""
|
|
716
|
+
source = dict(os.environ if base is None else base)
|
|
717
|
+
allowed = (
|
|
718
|
+
"SystemRoot",
|
|
719
|
+
"SystemDrive",
|
|
720
|
+
"windir",
|
|
721
|
+
"ComSpec",
|
|
722
|
+
"PATH",
|
|
723
|
+
"PATHEXT",
|
|
724
|
+
"TEMP",
|
|
725
|
+
"TMP",
|
|
726
|
+
"USERPROFILE",
|
|
727
|
+
"HOMEDRIVE",
|
|
728
|
+
"HOMEPATH",
|
|
729
|
+
"APPDATA",
|
|
730
|
+
"LOCALAPPDATA",
|
|
731
|
+
"ProgramData",
|
|
732
|
+
"ProgramFiles",
|
|
733
|
+
"ProgramFiles(x86)",
|
|
734
|
+
"ProgramW6432",
|
|
735
|
+
"CommonProgramFiles",
|
|
736
|
+
"NUMBER_OF_PROCESSORS",
|
|
737
|
+
"PROCESSOR_ARCHITECTURE",
|
|
738
|
+
"PROCESSOR_ARCHITEW6432",
|
|
739
|
+
"USERNAME",
|
|
740
|
+
"USERDOMAIN",
|
|
741
|
+
"SESSIONNAME",
|
|
742
|
+
"PUBLIC",
|
|
743
|
+
"LANG",
|
|
744
|
+
"VIRTUALDESKTOP_HELPER_TRACE",
|
|
745
|
+
)
|
|
746
|
+
env = {key: source[key] for key in allowed if key in source}
|
|
747
|
+
env["PYTHONPATH"] = os.pathsep.join(import_roots())
|
|
748
|
+
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
749
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
750
|
+
for forbidden in ("PYTHONSTARTUP", "PYTHONHOME", "PYTHONUSERBASE", "PYTHONWARNINGS"):
|
|
751
|
+
env.pop(forbidden, None)
|
|
752
|
+
return env
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def helper_command(*args: str) -> List[str]:
|
|
756
|
+
"""The argv used to re-enter this library as the elevated helper.
|
|
757
|
+
|
|
758
|
+
Runs the module *by absolute path* rather than with ``-m``: ``-m`` resolves the package
|
|
759
|
+
through ``sys.path``, and the elevated child's ``sys.path`` contains only its own working
|
|
760
|
+
directory — which is not necessarily the project directory (measured: the helper died with
|
|
761
|
+
``No module named VirtualDesktop`` while the pipe stayed silent). Invoking
|
|
762
|
+
``<package>/_helper.py`` works for a source checkout and for an installed wheel alike,
|
|
763
|
+
because the script directory is always on ``sys.path``.
|
|
764
|
+
"""
|
|
765
|
+
helper = Path(__file__).resolve().with_name("_helper.py")
|
|
766
|
+
# ``--no-elevate`` tells the child it is already privileged: without it the child asks UAC
|
|
767
|
+
# for elevation *again* for an uninstall, because the token probe reports "not elevated"
|
|
768
|
+
# inside the elevated child on this machine (measured: the nested request never surfaced a
|
|
769
|
+
# prompt and the parent timed out with ``driver-uninstall-timeout``).
|
|
770
|
+
return [sys.executable, "-s", str(helper), "_elevated-install", "--no-elevate", *args]
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def elevate_and_run_helper(
|
|
774
|
+
args: Sequence[str],
|
|
775
|
+
*,
|
|
776
|
+
timeout: float = 900.0,
|
|
777
|
+
log_dir: Optional[os.PathLike] = None,
|
|
778
|
+
show_ui: bool = False,
|
|
779
|
+
extra_env: Optional[Dict[str, str]] = None,
|
|
780
|
+
) -> ElevationResult:
|
|
781
|
+
"""Re-launch this library as an elevated helper via UAC and read its result.
|
|
782
|
+
|
|
783
|
+
Uses ``ShellExecuteExW`` (not ``ShellExecuteW``) with ``SEE_MASK_NOCLOSEPROCESS`` so the
|
|
784
|
+
child's process handle is available. That matters operationally: if the helper dies
|
|
785
|
+
immediately the wait ends at once with an actionable error instead of blocking for the
|
|
786
|
+
full (10-15 minute) driver-install timeout — which is exactly how a broken helper used to
|
|
787
|
+
present itself: "no response on the pipe" and nothing else.
|
|
788
|
+
|
|
789
|
+
``ShellExecuteExW`` cannot pass an environment block, so the child is started through
|
|
790
|
+
``cmd.exe /c set ... && python ...``: the *sanitised* environment is injected that way and
|
|
791
|
+
the helper inherits nothing else.
|
|
792
|
+
"""
|
|
793
|
+
require_windows("elevate_and_run_helper")
|
|
794
|
+
answer: Optional[dict] = None
|
|
795
|
+
timed_out = False
|
|
796
|
+
shell_code = 0
|
|
797
|
+
exit_code: Optional[int] = None
|
|
798
|
+
env = helper_environment(os.environ | dict(extra_env or {}))
|
|
799
|
+
|
|
800
|
+
with ResponsePipe() as pipe:
|
|
801
|
+
argv = helper_command("--response-pipe", pipe.name, *args)
|
|
802
|
+
log.debug("elevating: %s", " ".join(argv))
|
|
803
|
+
launcher, parameters = _elevation_launcher(argv, env)
|
|
804
|
+
shell32.ShellExecuteExW.argtypes = [ctypes.POINTER(_SHELLEXECUTEINFOW)]
|
|
805
|
+
shell32.ShellExecuteExW.restype = wintypes.BOOL
|
|
806
|
+
info = _SHELLEXECUTEINFOW()
|
|
807
|
+
info.cbSize = ctypes.sizeof(_SHELLEXECUTEINFOW)
|
|
808
|
+
info.fMask = _SEE_MASK_NOCLOSEPROCESS | _SEE_MASK_NOASYNC
|
|
809
|
+
info.lpVerb = "runas"
|
|
810
|
+
info.lpFile = launcher
|
|
811
|
+
info.lpParameters = parameters
|
|
812
|
+
info.lpDirectory = str(log_dir) if log_dir else None
|
|
813
|
+
info.nShow = SW_HIDE if not show_ui else SW_SHOWNORMAL
|
|
814
|
+
ctypes.set_last_error(0)
|
|
815
|
+
if not shell32.ShellExecuteExW(ctypes.byref(info)):
|
|
816
|
+
error = ctypes.get_last_error()
|
|
817
|
+
log.warning("ShellExecuteExW(runas) failed with Win32 error %s", error)
|
|
818
|
+
raise ElevationDenied(
|
|
819
|
+
f"elevation was refused (Win32 error {error})",
|
|
820
|
+
details={"win32_error": error},
|
|
821
|
+
)
|
|
822
|
+
shell_code = int(info.hInstApp or 0)
|
|
823
|
+
handle = int(info.hProcess or 0)
|
|
824
|
+
if not handle:
|
|
825
|
+
raise ElevationDenied(
|
|
826
|
+
"elevation was accepted but no process was created",
|
|
827
|
+
details={"shell_result": shell_code},
|
|
828
|
+
)
|
|
829
|
+
try:
|
|
830
|
+
answer, timed_out, exit_code = _await_helper(pipe, handle, timeout)
|
|
831
|
+
finally:
|
|
832
|
+
kernel32.CloseHandle(wintypes.HANDLE(handle))
|
|
833
|
+
|
|
834
|
+
if answer is None and not timed_out:
|
|
835
|
+
raise OffscreenMountError(
|
|
836
|
+
f"the elevated helper exited with code {exit_code} before reporting a result",
|
|
837
|
+
code="helper-exited-early",
|
|
838
|
+
hint="Run `VirtualDesktop install-driver --dry-run` and then the printed helper "
|
|
839
|
+
"command from an elevated terminal to see its output.",
|
|
840
|
+
details={"exit_code": exit_code, "shell_result": shell_code},
|
|
841
|
+
)
|
|
842
|
+
return ElevationResult(
|
|
843
|
+
launched=True,
|
|
844
|
+
response=answer,
|
|
845
|
+
shell_result=shell_code or 32,
|
|
846
|
+
timed_out=timed_out,
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _elevation_launcher(argv: Sequence[str], env: Dict[str, str]) -> tuple:
|
|
851
|
+
"""Return ``(lpFile, lpParameters)`` for ``ShellExecuteExW``.
|
|
852
|
+
|
|
853
|
+
The helper is started **directly** (no ``cmd.exe`` wrapper). An earlier revision injected
|
|
854
|
+
the sanitised environment through ``cmd /c set "K=V" && ...``; that died with exit code 1
|
|
855
|
+
and produced no log, because the assembled command line ran into the ``cmd.exe`` length
|
|
856
|
+
limit. Direct invocation works because:
|
|
857
|
+
|
|
858
|
+
* the helper is addressed by absolute path, so its own directory lands on ``sys.path``;
|
|
859
|
+
* the elevated child inherits a normal user environment from the shell (measured: ``TEMP``,
|
|
860
|
+
``LOCALAPPDATA`` and ``SystemRoot`` are all present), so nothing has to be injected;
|
|
861
|
+
* every value the helper needs from us (cache dir, strategy, signature policy, log dir) is
|
|
862
|
+
already an explicit command-line argument.
|
|
863
|
+
|
|
864
|
+
``env`` is therefore only used by the caller that builds it (for diagnostics); it is not
|
|
865
|
+
placed on the command line.
|
|
866
|
+
"""
|
|
867
|
+
inner = subprocess.list2cmdline([str(a) for a in argv])
|
|
868
|
+
if len(inner) > 8000: # cmd/ShellExecute safety margin
|
|
869
|
+
raise ConfigError(
|
|
870
|
+
"the helper command line is too long to launch reliably",
|
|
871
|
+
code="helper-command-too-long",
|
|
872
|
+
details={"length": len(inner)},
|
|
873
|
+
)
|
|
874
|
+
return str(argv[0]), subprocess.list2cmdline([str(a) for a in argv[1:]])
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _await_helper(pipe: "ResponsePipe", handle: int, timeout: float):
|
|
878
|
+
"""Wait for the pipe message, aborting early if the helper process dies."""
|
|
879
|
+
kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
|
880
|
+
kernel32.WaitForSingleObject.restype = wintypes.DWORD
|
|
881
|
+
kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
|
|
882
|
+
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
|
|
883
|
+
|
|
884
|
+
deadline = time.monotonic() + timeout
|
|
885
|
+
while time.monotonic() < deadline:
|
|
886
|
+
answer = pipe.wait_for_message(timeout=1.0, poll=0.05)
|
|
887
|
+
if answer is not None:
|
|
888
|
+
return answer, False, None
|
|
889
|
+
code = wintypes.DWORD()
|
|
890
|
+
if kernel32.GetExitCodeProcess(wintypes.HANDLE(handle), ctypes.byref(code)):
|
|
891
|
+
if code.value != _STILL_ACTIVE:
|
|
892
|
+
# Give the pipe one last chance: the child may have written and exited.
|
|
893
|
+
return pipe.wait_for_message(timeout=2.0, poll=0.05), False, int(code.value)
|
|
894
|
+
time.sleep(0.1)
|
|
895
|
+
return None, True, None
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def default_log_dir(cache_dir: Optional[os.PathLike] = None) -> Path:
|
|
899
|
+
from ._download import default_cache_dir
|
|
900
|
+
|
|
901
|
+
root = Path(cache_dir) if cache_dir else default_cache_dir()
|
|
902
|
+
return secure_mkdir(root / "logs")
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def tail_file(path: os.PathLike, *, lines: int = 40) -> str:
|
|
906
|
+
"""Last ``lines`` lines of a file, for error messages."""
|
|
907
|
+
try:
|
|
908
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
909
|
+
content = handle.readlines()
|
|
910
|
+
except OSError:
|
|
911
|
+
return ""
|
|
912
|
+
return "".join(content[-lines:])
|