#!/usr/bin/env python3 """ NetDesk Host - unattended remote desktop agent for Windows (full access). Modes: python netdesk_host.py --server wss://VPS/ws [--listen 8901] # simple in-process mode python netdesk_host.py --install # install+start boot-time service broker (run as admin once) python netdesk_host.py --uninstall python netdesk_host.py --broker # service broker (normally started by the scheduled task) python netdesk_host.py --session-agent --broker ws://127.0.0.1:PORT/agent --token TOK # per-desktop worker, spawned by the broker Service architecture (like TeamViewer/RustDesk): broker (SYSTEM, session 0, starts at boot) - owns the outbound connection to the NetDesk server - spawns a desktop agent into the active console session as SYSTEM -> agent can capture and inject on the user desktop, the logon screen and UAC prompts (full access, before/without any user being logged on) - relays traffic server <-> agent (agent holds all E2E keys, broker is a dumb pipe) Features: H.264 capture/encode, SendInput keyboard+mouse (desktop-following), clipboard sync, file manager with folder upload, chat, remote power, process manager, privacy mode (monitor off), lock-on-disconnect, optional system audio (PyAudioWPatch), optional TOTP 2FA, wake display on connect. """ import argparse import asyncio import base64 import hashlib import hmac import json import logging import mss import numpy as np import os import queue import secrets import shutil import string import subprocess import sys import threading import time import uuid from fractions import Fraction from pathlib import Path import av import aiohttp from aiohttp import web, WSMsgType from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives import serialization # WebRTC (aiortc): optional P2P/UDP fast path. When it's missing or the NAT # won't allow it, everything keeps working over the WSS relay exactly as before. try: from aiortc import (RTCPeerConnection, RTCSessionDescription, RTCConfiguration, RTCIceServer) RTC_AVAILABLE = True except Exception: RTC_AVAILABLE = False from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives import hashes # DXGI Desktop Duplication (dxcam): optional, Windows-only fast-capture path. # ~10x faster than mss GDI readback (4ms vs ~45ms/frame at 1080p) with built-in # damage detection (grab() returns None when the screen is unchanged). When it's # missing or fails (secure desktop, RDP, odd GPU), capture falls back to mss. try: import dxcam as _dxcam _DX_AVAILABLE = (sys.platform == "win32") except Exception: _dxcam = None _DX_AVAILABLE = False log = logging.getLogger("netdesk.host") VERSION = "1.5.0-netdesk" BUILTIN_SERVER = "wss://netdesk.space/ws" IS_WIN = sys.platform == "win32" CREATE_NO_WINDOW = 0x08000000 if IS_WIN else 0 def normalize_server(addr: str) -> str: """Accept what users type - 'netdesk.space', 'https://x', 'x:8443', 'wss://x/ws' - and return the websocket URL of the server.""" a = (addr or "").strip().rstrip("/") if not a: return "" if a.startswith("https://"): a = "wss://" + a[8:] elif a.startswith("http://"): a = "ws://" + a[7:] elif not a.startswith(("ws://", "wss://")): host = a.split("/")[0] is_local = (host.split(":")[0] in ("localhost", "127.0.0.1") or host.startswith("192.168.") or host.startswith("10.")) a = ("ws://" if is_local else "wss://") + a _, rest = a.split("://", 1) if "/" not in rest: a += "/ws" return a def exe_name_config() -> dict: """Configuration baked into the file name (RustDesk-style), e.g. 'NetDeskHost-host=desk.example.com,key=SECRET.exe' -> preset server + key. Lets you hand out a pre-configured exe with no manual setup.""" if not getattr(sys, "frozen", False): return {} name = os.path.splitext(os.path.basename(sys.executable))[0] out = {} for part in name.replace("--", ",").split(","): for key, field in (("host=", "server"), ("key=", "reg_key")): idx = part.find(key) if idx != -1: out[field] = part[idx + len(key):].strip() if out.get("server"): out["server"] = normalize_server(out["server"]) return out DEFAULT_SERVER = exe_name_config().get("server") or BUILTIN_SERVER QUALITY_PRESETS = { "low": dict(fps=15, bitrate=2000, scale=0.75), "balanced": dict(fps=30, bitrate=6000, scale=1.0), "high": dict(fps=30, bitrate=12000, scale=1.0), "extreme": dict(fps=60, bitrate=20000, scale=1.0), } # ================================================================ config def default_config_dir(): """Config location. Frozen (exe) builds are portable: settings and logs live next to the executable, so the folder can be carried on a USB stick and moved freely. If that folder is not writable (network share, read-only mount, Program Files), fall back to the user profile so the app still works. Source (non-frozen) runs always use the user profile folder.""" if getattr(sys, "frozen", False): exe_dir = Path(sys.executable).resolve().parent try: probe = exe_dir / ".nd_write_test" probe.write_bytes(b"") probe.unlink() return exe_dir except Exception: pass if IS_WIN: d = Path(os.environ.get("APPDATA", Path.home())) / "NetDesk" else: d = Path.home() / ".netdesk" d.mkdir(parents=True, exist_ok=True) return d def programdata_dir(): d = Path(os.environ.get("ProgramData", r"C:\ProgramData")) / "NetDesk" return d _CONFIG_DIR = None def appdata_dir(): global _CONFIG_DIR d = _CONFIG_DIR or default_config_dir() d.mkdir(parents=True, exist_ok=True) return d def make_verifier(password: str, salt: bytes = None, iters: int = 200000): salt = salt or secrets.token_bytes(16) kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=iters) verifier = kdf.derive(password.encode("utf-8")) return salt, iters, verifier # ---------------------------------------------------------------- TOTP (RFC 6238, stdlib only) def totp_now(secret: str, t: float = None, step: int = 30, digits: int = 6) -> str: if t is None: t = time.time() key = secret.upper().rstrip("=") key += "=" * ((8 - len(key) % 8) % 8) key = base64.b32decode(key) counter = int(t // step).to_bytes(8, "big") digest = hmac.new(key, counter, hashlib.sha1).digest() off = digest[-1] & 0x0F code = ((digest[off] & 0x7F) << 24 | digest[off + 1] << 16 | digest[off + 2] << 8 | digest[off + 3]) % (10 ** digits) return str(code).zfill(digits) def totp_verify(secret: str, code: str, window: int = 1) -> bool: code = (code or "").strip() if not code.isdigit(): return False now = time.time() return any(hmac.compare_digest(totp_now(secret, now + i * 30), code) for i in range(-window, window + 1)) def gen_password(n: int = 8) -> str: alphabet = "abcdefghijkmnpqrstuvwxyz23456789" # no look-alikes (l/1, o/0) return "".join(secrets.choice(alphabet) for _ in range(n)) class Config: def __init__(self, path: Path): self.path = path self.data = {} if path.exists(): try: self.data = json.loads(path.read_text("utf-8")) except Exception: log.warning("config unreadable, regenerating") if "id" not in self.data: self.data["id"] = str(secrets.randbelow(900000000) + 100000000) if "verifier" not in self.data: pw = gen_password() salt, iters, ver = make_verifier(pw) self.data.update(salt=salt.hex(), iters=iters, verifier=ver.hex(), pw_plain=pw) self._new_password = pw else: self._new_password = None baked = exe_name_config() if baked.get("server") and not self.data.get("server_locked_by_user"): self.data["server"] = baked["server"] if baked.get("reg_key") and not self.data.get("reg_key"): self.data["reg_key"] = baked["reg_key"] self.data.setdefault("server", DEFAULT_SERVER) self.data.setdefault("name", os.environ.get("COMPUTERNAME", "host")) self.data.setdefault("perms", {"input": True, "clipboard": True, "files": True, "audio": True}) self.data.setdefault("lock_on_end", False) self.data.setdefault("privacy_on_session", False) self.data.setdefault("allow_display_power", False) # optional viewer IP allowlist: comma/space separated IPs or CIDRs self.data.setdefault("allow_ips", "") self.save() def save(self): self.path.parent.mkdir(parents=True, exist_ok=True) tmp = self.path.with_suffix(".tmp") tmp.write_text(json.dumps(self.data, indent=2), "utf-8") os.replace(tmp, self.path) def reload(self): """Pick up changes made by the desktop app (password, account, server) without restarting the engine or service.""" try: fresh = json.loads(self.path.read_text("utf-8")) if isinstance(fresh, dict) and fresh.get("id") and fresh.get("verifier"): self.data = fresh except Exception: pass @property def id(self): return self.data["id"] def set_password(self, pw: str): salt, iters, ver = make_verifier(pw) self.data.update(salt=salt.hex(), iters=iters, verifier=ver.hex(), pw_plain=pw) self.save() def set_totp(self, secret: str): self.data["totp"] = secret self.save() def check_proof(self, nonce: str, proof: str) -> bool: want = hashlib.sha256((nonce + self.data["verifier"]).encode()).hexdigest() return secrets.compare_digest(want, proof.lower()) def check_proof_bound(self, nonce: str, host_pub_b64: str, proof: str) -> bool: """Channel-bound proof (MITM hardening): hex(SHA256(nonce+verifier+host_pub)). Binds the host's ephemeral ECDH pubkey so a relay that swapped it is detected (it can't produce a matching proof without the verifier).""" want = hashlib.sha256( (nonce + self.data["verifier"] + host_pub_b64).encode()).hexdigest() return secrets.compare_digest(want, proof.lower()) # ================================================================ Windows layer if IS_WIN: import ctypes from ctypes import wintypes from ctypes.wintypes import DWORD, WORD, LONG ULONG_PTR = ctypes.c_size_t user32 = ctypes.windll.user32 kernel32 = ctypes.windll.kernel32 advapi32 = ctypes.windll.advapi32 def _set_dpi_awareness(): """Make the process PER-MONITOR-DPI-AWARE (v2) BEFORE mss/capture/SendInput run. Without this, screen-capture geometry, monitor origins and injected mouse coordinates disagree on scaled or mixed-DPI multi-monitor setups, so the remote cursor lands in the wrong place. RustDesk does the same.""" try: user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4)) # PER_MONITOR_AWARE_V2 return except Exception: pass try: ctypes.windll.shcore.SetProcessDpiAwareness(2) # PER_MONITOR_AWARE return except Exception: pass try: user32.SetProcessDPIAware() # system-DPI-aware (last resort) except Exception: pass _set_dpi_awareness() class MOUSEINPUT(ctypes.Structure): _fields_ = [("dx", LONG), ("dy", LONG), ("mouseData", DWORD), ("dwFlags", DWORD), ("time", DWORD), ("dwExtraInfo", ULONG_PTR)] class KEYBDINPUT(ctypes.Structure): _fields_ = [("wVk", WORD), ("wScan", WORD), ("dwFlags", DWORD), ("time", DWORD), ("dwExtraInfo", ULONG_PTR)] class _U(ctypes.Union): # NOTE: do NOT add a c_byte padding member here. An oversized union makes # sizeof(INPUT) 48 instead of the 40 Windows x64 requires, and SendInput # then rejects EVERY event (returns 0) - no mouse, no keyboard at all. _fields_ = [("mi", MOUSEINPUT), ("ki", KEYBDINPUT)] class INPUT(ctypes.Structure): _fields_ = [("type", DWORD), ("u", _U)] class PROCESS_INFORMATION(ctypes.Structure): _fields_ = [("hProcess", wintypes.HANDLE), ("hThread", wintypes.HANDLE), ("dwProcessId", DWORD), ("dwThreadId", DWORD)] class STARTUPINFOW(ctypes.Structure): _fields_ = [("cb", DWORD), ("lpReserved", wintypes.LPWSTR), ("lpDesktop", wintypes.LPWSTR), ("lpTitle", wintypes.LPWSTR), ("dwX", DWORD), ("dwY", DWORD), ("dwXSize", DWORD), ("dwYSize", DWORD), ("dwXCountChars", DWORD), ("dwYCountChars", DWORD), ("dwFillAttribute", DWORD), ("dwFlags", DWORD), ("wShowWindow", WORD), ("cbReserved2", WORD), ("lpReserved2", wintypes.LPBYTE), ("hStdInput", wintypes.HANDLE), ("hStdOutput", wintypes.HANDLE), ("hStdError", wintypes.HANDLE)] INPUT_MOUSE, INPUT_KEYBOARD = 0, 1 KEYEVENTF_KEYUP, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_SCANCODE = 0x0002, 0x0001, 0x0008 MOUSEEVENTF_MOVE, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_VIRTUALDESKTOP = 0x0001, 0x8000, 0x4000 MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP = 0x0002, 0x0004 MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP = 0x0008, 0x0010 MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP = 0x0020, 0x0040 MOUSEEVENTF_WHEEL, MOUSEEVENTF_HWHEEL = 0x0800, 0x1000 CF_UNICODETEXT, GMEM_MOVEABLE = 13, 0x0002 # 64-bit safety: handle-returning APIs must be declared, else ctypes # truncates pointers to 32 bits and random access violations follow. user32.GetClipboardData.restype = ctypes.c_void_p user32.GetClipboardData.argtypes = [wintypes.UINT] user32.SetClipboardData.restype = ctypes.c_void_p user32.SetClipboardData.argtypes = [wintypes.UINT, wintypes.HGLOBAL] kernel32.GlobalAlloc.restype = wintypes.HGLOBAL kernel32.GlobalAlloc.argtypes = [wintypes.UINT, ctypes.c_size_t] kernel32.GlobalLock.restype = ctypes.c_void_p kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL] kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL] user32.MapVirtualKeyW.restype = wintypes.UINT user32.MapVirtualKeyW.argtypes = [wintypes.UINT, wintypes.UINT] user32.SendInput.argtypes = [wintypes.UINT, ctypes.POINTER(INPUT), ctypes.c_int] # --- desktop following (logon screen / UAC secure desktop) --- DESKTOP_ACCESS = 0x1 | 0x2 | 0x8 | 0x80 | 0x100 # read|createwin|hook|write|switch user32.OpenInputDesktop.restype = wintypes.HANDLE user32.OpenInputDesktop.argtypes = [DWORD, wintypes.BOOL, DWORD] user32.CloseDesktop.argtypes = [wintypes.HANDLE] user32.SetThreadDesktop.restype = wintypes.BOOL user32.SetThreadDesktop.argtypes = [wintypes.HANDLE] user32.GetUserObjectInformationW.argtypes = [wintypes.HANDLE, wintypes.INT, ctypes.c_void_p, DWORD, ctypes.POINTER(DWORD)] user32.GetThreadDesktop.restype = wintypes.HANDLE user32.GetThreadDesktop.argtypes = [DWORD] kernel32.GetCurrentThreadId.restype = DWORD user32.LockWorkStation.restype = wintypes.BOOL _desktop_tls = threading.local() def _desktop_name(hdesk): if not hdesk: return None buf = ctypes.create_unicode_buffer(64) need = DWORD() if user32.GetUserObjectInformationW(hdesk, 2, buf, 64, ctypes.byref(need)): # UOI_NAME return buf.value return None def attach_desktop(): """Follow the active input desktop so injection reaches the logon screen and UAC secure desktop (service/SYSTEM mode). CRITICAL: only call SetThreadDesktop when the input desktop is DIFFERENT from the desktop this thread is already on. Switching to a fresh handle of the *same* desktop silently breaks SendInput (mouse/keyboard stop working entirely) - which is exactly what happens in normal user mode, where the thread already owns the visible 'Default' desktop.""" if getattr(_desktop_tls, "name", None) is None: # where this thread starts (usually 'Default' in a user session) _desktop_tls.name = _desktop_name( user32.GetThreadDesktop(kernel32.GetCurrentThreadId())) h = user32.OpenInputDesktop(0, True, DESKTOP_ACCESS) if not h: return _desktop_tls.name name = _desktop_name(h) if not name or name == _desktop_tls.name: user32.CloseDesktop(h) # already on the input desktop: do NOT switch return _desktop_tls.name # input desktop changed (user switch / logon screen / UAC): follow it if user32.SetThreadDesktop(h): old = getattr(_desktop_tls, "hdesk", None) if old: user32.CloseDesktop(old) _desktop_tls.hdesk = h _desktop_tls.name = name else: user32.CloseDesktop(h) return _desktop_tls.name def desktop_name(): return getattr(_desktop_tls, "name", None) # --- input --- VK = { **{f"Key{c}": 0x41 + i for i, c in enumerate("ABCDEFGHIJKLMNOPQRSTUVWXYZ")}, **{f"Digit{c}": 0x30 + i for i, c in enumerate("0123456789")}, **{f"Numpad{c}": 0x60 + i for i, c in enumerate("0123456789")}, **{f"F{n}": 0x70 + n - 1 for n in range(1, 25)}, "Backquote": 0xC0, "Minus": 0xBD, "Equal": 0xBB, "BracketLeft": 0xDB, "BracketRight": 0xDD, "Backslash": 0xDC, "Semicolon": 0xBA, "Quote": 0xDE, "Comma": 0xBC, "Period": 0xBE, "Slash": 0xBF, "IntlBackslash": 0xE2, "CapsLock": 0x14, "Space": 0x20, "Enter": 0x0D, "NumpadEnter": 0x0D, "Backspace": 0x08, "Tab": 0x09, "Escape": 0x1B, "Delete": 0x2E, "Insert": 0x2D, "Home": 0x24, "End": 0x23, "PageUp": 0x21, "PageDown": 0x22, "ArrowLeft": 0x25, "ArrowUp": 0x26, "ArrowRight": 0x27, "ArrowDown": 0x28, "ControlLeft": 0xA2, "ControlRight": 0xA3, "AltLeft": 0xA4, "AltRight": 0xA5, "ShiftLeft": 0xA0, "ShiftRight": 0xA1, "MetaLeft": 0x5B, "MetaRight": 0x5C, "ContextMenu": 0x5D, "PrintScreen": 0x2C, "ScrollLock": 0x91, "Pause": 0x13, "NumLock": 0x90, "NumpadMultiply": 0x6A, "NumpadAdd": 0x6B, "NumpadSubtract": 0x6D, "NumpadDecimal": 0x6E, "NumpadDivide": 0x6F, } EXTENDED_CODES = { "ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", "Insert", "Delete", "NumLock", "PrintScreen", "Pause", "ControlRight", "AltRight", "MetaLeft", "MetaRight", "ContextMenu", "NumpadEnter", "NumpadDivide", "ScrollLock", } user32.SendInput.restype = wintypes.UINT def send_inputs(*inputs): arr = (INPUT * len(inputs))(*inputs) n = user32.SendInput(len(inputs), arr, ctypes.sizeof(INPUT)) if n != len(inputs): # 0 here almost always means a bad cbSize/struct layout (see the _U # union note) or input blocked by UIPI/secure desktop - make it loud log.warning("SendInput injected %d/%d (sizeof(INPUT)=%d, err=%d)", n, len(inputs), ctypes.sizeof(INPUT), ctypes.get_last_error()) def _mi(flags, data=0, dx=0, dy=0): return INPUT(INPUT_MOUSE, _U(mi=MOUSEINPUT(dx, dy, data & 0xFFFFFFFF, flags, 0, 0))) # SM_*VIRTUALSCREEN indices - the whole multi-monitor bounding box, read # cheaply (no mss allocation per mouse move) SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN = 76, 77 SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN = 78, 79 MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP = 0x0080, 0x0100 XBUTTON1, XBUTTON2 = 0x0001, 0x0002 def virtual_desktop_box(): return {"left": user32.GetSystemMetrics(SM_XVIRTUALSCREEN), "top": user32.GetSystemMetrics(SM_YVIRTUALSCREEN), "w": user32.GetSystemMetrics(SM_CXVIRTUALSCREEN), "h": user32.GetSystemMetrics(SM_CYVIRTUALSCREEN)} def inject_mouse_move(x, y, union_box=None): u = union_box or virtual_desktop_box() nx = int(round((x - u["left"]) * 65535 / max(1, u["w"] - 1))) ny = int(round((y - u["top"]) * 65535 / max(1, u["h"] - 1))) send_inputs(_mi(MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESKTOP, dx=nx, dy=ny)) def inject_mouse_buttons(new_b, old_b): evts = [] for mask, down, up in ( (1, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP), (2, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP), (4, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP), ): if new_b & mask and not old_b & mask: evts.append(_mi(down)) if not new_b & mask and old_b & mask: evts.append(_mi(up)) # side buttons (back/forward): bit 8 = X1, bit 16 = X2 for mask, xb in ((8, XBUTTON1), (16, XBUTTON2)): if new_b & mask and not old_b & mask: evts.append(_mi(MOUSEEVENTF_XDOWN, data=xb)) if not new_b & mask and old_b & mask: evts.append(_mi(MOUSEEVENTF_XUP, data=xb)) if evts: send_inputs(*evts) def inject_wheel(v, h): evts = [] if v: evts.append(_mi(MOUSEEVENTF_WHEEL, int(v * 120))) if h: evts.append(_mi(MOUSEEVENTF_HWHEEL, int(h * 120))) if evts: send_inputs(*evts) _pressed_keys = set() def inject_key(code, down): vk = VK.get(code) if not vk: return scan = user32.MapVirtualKeyW(vk, 0) flags = KEYEVENTF_SCANCODE | (KEYEVENTF_EXTENDEDKEY if code in EXTENDED_CODES else 0) if not down: flags |= KEYEVENTF_KEYUP if down: _pressed_keys.add(code) else: _pressed_keys.discard(code) if scan: send_inputs(INPUT(INPUT_KEYBOARD, _U(ki=KEYBDINPUT(0, scan, flags, 0, 0)))) else: send_inputs(INPUT(INPUT_KEYBOARD, _U(ki=KEYBDINPUT(vk, 0, flags, 0, 0)))) def release_all_keys(): for code in list(_pressed_keys): inject_key(code, False) _pressed_keys.clear() class InputPump(threading.Thread): """Dedicated input thread that follows the active desktop before each injection (works on logon screen / UAC when running as SYSTEM).""" def __init__(self, host): super().__init__(daemon=True) self.host = host self.q = queue.Queue(maxsize=2048) self.buttons = 0 self._last_attach = 0.0 def put(self, item): try: self.q.put_nowait(item) except queue.Full: # never drop a button/key/wheel event (a lost "up" = a stuck # key or button). Only mouse *moves* are safe to drop under load; # evict the oldest queued move to make room for this event. if item[0] != "move": try: for _ in range(len(self.q.queue)): old = self.q.get_nowait() if old[0] == "move": break except Exception: pass try: self.q.put_nowait(item) except Exception: pass def run(self): while True: item = self.q.get() try: # latest-wins for motion: if moves piled up (slow link burst, # injector stalled), only the newest position matters - never # skip past a button/key/wheel event though if item[0] == "move": while True: try: nxt = self.q.queue[0] except IndexError: break if nxt[0] != "move": break item = self.q.get_nowait() # follow the active input desktop (logon screen / UAC) but not # on every single event - and INSIDE the try so a transient # OpenInputDesktop failure can never kill the input thread tnow = time.time() if item[0] == "key" or tnow - self._last_attach > 0.2: attach_desktop() self._last_attach = tnow if item[0] == "move": # cheap virtual-desktop bounds (no per-move mss allocation) inject_mouse_move(item[1], item[2], virtual_desktop_box()) elif item[0] == "buttons": inject_mouse_buttons(item[1], self.buttons) self.buttons = item[1] elif item[0] == "wheel": inject_wheel(item[1], item[2]) elif item[0] == "key": if self.host.cfg.data["perms"].get("input", True): inject_key(item[1], item[2]) elif item[0] == "wake": send_inputs(_mi(MOUSEEVENTF_MOVE, dx=1, dy=0), _mi(MOUSEEVENTF_MOVE, dx=-1, dy=0)) except Exception as e: log.debug("input pump error: %s", e) # --- remote cursor shape (RustDesk-style overlay: sent separately from video # so idle screens still update the cursor and no re-encode is forced) --- gdi32 = ctypes.windll.gdi32 class CURSORINFO(ctypes.Structure): _fields_ = [("cbSize", DWORD), ("flags", DWORD), ("hCursor", wintypes.HANDLE), ("ptScreenPos", wintypes.POINT)] class ICONINFO(ctypes.Structure): _fields_ = [("fIcon", wintypes.BOOL), ("xHotspot", DWORD), ("yHotspot", DWORD), ("hbmMask", wintypes.HANDLE), ("hbmColor", wintypes.HANDLE)] class BITMAP(ctypes.Structure): _fields_ = [("bmType", LONG), ("bmWidth", LONG), ("bmHeight", LONG), ("bmWidthBytes", LONG), ("bmPlanes", WORD), ("bmBitsPixel", WORD), ("bmBits", ctypes.c_void_p)] class BITMAPINFOHEADER(ctypes.Structure): _fields_ = [("biSize", DWORD), ("biWidth", LONG), ("biHeight", LONG), ("biPlanes", WORD), ("biBitCount", WORD), ("biCompression", DWORD), ("biSizeImage", DWORD), ("biXPelsPerMeter", LONG), ("biYPelsPerMeter", LONG), ("biClrUsed", DWORD), ("biClrImportant", DWORD)] user32.GetCursorInfo.argtypes = [ctypes.POINTER(CURSORINFO)] user32.GetIconInfo.argtypes = [wintypes.HANDLE, ctypes.POINTER(ICONINFO)] CURSOR_SHOWING = 0x0001 _cursor_png_cache = {} # shared system cursors -> CSS name. The viewer then uses the OS-native pointer # (crisp at any DPI), and it still works when GetIconInfo refuses a handle that # belongs to another session (engine running as SYSTEM in session 0). _STD_CURSORS = {} user32.LoadCursorW.restype = ctypes.c_void_p for _idc, _css in ((32512, "default"), (32513, "text"), (32514, "wait"), (32515, "crosshair"), (32516, "default"), (32642, "nwse-resize"), (32643, "nesw-resize"), (32644, "ew-resize"), (32645, "ns-resize"), (32646, "move"), (32648, "not-allowed"), (32649, "pointer"), (32650, "progress"), (32651, "help")): try: _h = user32.LoadCursorW(None, ctypes.c_void_p(_idc)) # MAKEINTRESOURCE if _h: _STD_CURSORS[int(_h)] = _css except Exception: pass def cursor_state(): """(hCursor, global_x, global_y) or None. hCursor==0 means hidden.""" ci = CURSORINFO() ci.cbSize = ctypes.sizeof(CURSORINFO) if not user32.GetCursorInfo(ctypes.byref(ci)): return None showing = bool(ci.flags & CURSOR_SHOWING) and ci.hCursor return (int(ci.hCursor) if showing else 0, ci.ptScreenPos.x, ci.ptScreenPos.y) def cursor_png(hcursor): """RGBA PNG (base64) + hotspot for a cursor handle, cached by handle.""" if hcursor in _cursor_png_cache: return _cursor_png_cache[hcursor] import numpy as _np from PIL import Image as _Img import io as _io ii = ICONINFO() if not user32.GetIconInfo(hcursor, ctypes.byref(ii)): return None try: bm = BITMAP() src = ii.hbmColor if ii.hbmColor else ii.hbmMask gdi32.GetObjectW(src, ctypes.sizeof(BITMAP), ctypes.byref(bm)) w, h = bm.bmWidth, bm.bmHeight if not ii.hbmColor: h //= 2 hdc = user32.GetDC(0) bi = BITMAPINFOHEADER() bi.biSize = ctypes.sizeof(BITMAPINFOHEADER) bi.biWidth = w; bi.biPlanes = 1; bi.biBitCount = 32; bi.biCompression = 0 def dib(hbm, rows): bi.biHeight = -abs(rows) buf = (ctypes.c_char * (w * abs(rows) * 4))() gdi32.GetDIBits(hdc, hbm, 0, abs(rows), buf, ctypes.byref(bi), 0) return _np.frombuffer(bytes(buf), dtype=_np.uint8).reshape(abs(rows), w, 4) if ii.hbmColor: col = dib(ii.hbmColor, h) if col[:, :, 3].any(): rgba = col[:, :, [2, 1, 0, 3]].copy() else: andm = dib(ii.hbmMask, h)[:, :, 0] alpha = _np.where(andm == 0, 255, 0).astype(_np.uint8) rgba = _np.dstack([col[:, :, 2], col[:, :, 1], col[:, :, 0], alpha]) else: both = dib(ii.hbmMask, h * 2)[:, :, 0] AND, XOR = both[:h], both[h:] alpha = _np.where(AND == 0, 255, 0).astype(_np.uint8) lum = _np.where((AND == 0) & (XOR > 0), 255, 0).astype(_np.uint8) rgba = _np.dstack([lum, lum, lum, alpha]) user32.ReleaseDC(0, hdc) b = _io.BytesIO() _Img.fromarray(rgba, "RGBA").save(b, "PNG") out = {"w": int(w), "h": int(h), "hx": int(ii.xHotspot), "hy": int(ii.yHotspot), "png": base64.b64encode(b.getvalue()).decode()} if len(_cursor_png_cache) < 64: _cursor_png_cache[hcursor] = out return out except Exception: return None finally: try: if ii.hbmColor: gdi32.DeleteObject(ii.hbmColor) if ii.hbmMask: gdi32.DeleteObject(ii.hbmMask) except Exception: pass # --- clipboard --- def clip_get(): if not user32.OpenClipboard(None): return None try: h = user32.GetClipboardData(CF_UNICODETEXT) if not h: return None ptr = kernel32.GlobalLock(h) if not ptr: return None try: return ctypes.wstring_at(ptr) finally: kernel32.GlobalUnlock(h) finally: user32.CloseClipboard() def clip_set(text): data = text.encode("utf-16-le") + b"\x00\x00" if not user32.OpenClipboard(None): return False try: user32.EmptyClipboard() h = kernel32.GlobalAlloc(GMEM_MOVEABLE, len(data)) if not h: return False ptr = kernel32.GlobalLock(h) if not ptr: return False ctypes.memmove(ptr, data, len(data)) kernel32.GlobalUnlock(h) user32.SetClipboardData(CF_UNICODETEXT, h) return True finally: user32.CloseClipboard() # --- display power (privacy mode) --- HWND_BROADCAST, WM_SYSCOMMAND, SC_MONITORPOWER = 0xFFFF, 0x0112, 0xF170 SMTO_ABORTIFHUNG = 0x0002 def monitor_power(state): # -1 on, 2 off res = DWORD() user32.SendMessageTimeoutW(HWND_BROADCAST, WM_SYSCOMMAND, SC_MONITORPOWER, state, SMTO_ABORTIFHUNG, 500, ctypes.byref(res)) # --- process manager --- def process_list(): out = subprocess.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True, text=True, creationflags=0x08000000, timeout=15).stdout items = [] for line in out.splitlines(): parts = [p.strip('"') for p in line.split('","')] if len(parts) >= 5: try: mem = int(parts[4].replace("K", "").replace(",", "").strip()) except Exception: mem = 0 items.append({"name": parts[0], "pid": int(parts[1]), "mem": mem}) items.sort(key=lambda i: -i["mem"]) return items[:300] def process_kill(pid): r = subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, text=True, creationflags=0x08000000, timeout=10) return r.returncode == 0, (r.stdout or r.stderr).strip() def print_file(path): """Print a document on this machine's default printer (print verb).""" if not os.path.isfile(path): return False, "file not found" try: r = ctypes.windll.shell32.ShellExecuteW(0, "print", path, None, None, 0) if r > 32: return True, "sent to default printer" return False, f"no print association (code {r})" except Exception as e: return False, str(e) # --- power / commands --- def sys_command(action, cmd=None): CREATE_NO_WINDOW = 0x08000000 try: if action == "lock": user32.LockWorkStation() return True, "locked" if action == "logoff": subprocess.Popen("shutdown /l", shell=True, creationflags=CREATE_NO_WINDOW) return True, "logging off" if action == "reboot": subprocess.Popen("shutdown /r /t 3", shell=True, creationflags=CREATE_NO_WINDOW) return True, "rebooting" if action == "shutdown": subprocess.Popen("shutdown /s /t 3", shell=True, creationflags=CREATE_NO_WINDOW) return True, "shutting down" if action == "run" and cmd: subprocess.Popen(cmd, shell=True, creationflags=CREATE_NO_WINDOW) return True, "started" return False, "unknown command" except Exception as e: return False, str(e) # --- session/process helpers for the service broker --- kernel32.WTSGetActiveConsoleSessionId.restype = DWORD kernel32.WTSGetActiveConsoleSessionId.argtypes = [] kernel32.ProcessIdToSessionId.restype = wintypes.BOOL kernel32.ProcessIdToSessionId.argtypes = [DWORD, ctypes.POINTER(DWORD)] kernel32.GetCurrentProcess.restype = wintypes.HANDLE kernel32.GetCurrentProcess.argtypes = [] kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT] TOKEN_ALL_ACCESS = 0xF01FF TokenSessionId = 12 SecurityImpersonation, TokenPrimary = 2, 1 DETACHED_PROCESS, CREATE_NEW_PROCESS_GROUP = 0x00000008, 0x00000200 CREATE_UNICODE_ENVIRONMENT, CREATE_BREAKAWAY_FROM_JOB = 0x00000400, 0x01000000 advapi32.OpenProcessToken.argtypes = [wintypes.HANDLE, DWORD, ctypes.POINTER(wintypes.HANDLE)] advapi32.DuplicateTokenEx.argtypes = [wintypes.HANDLE, DWORD, wintypes.LPVOID, wintypes.INT, wintypes.INT, ctypes.POINTER(wintypes.HANDLE)] advapi32.SetTokenInformation.argtypes = [wintypes.HANDLE, wintypes.INT, wintypes.LPVOID, DWORD] advapi32.CreateProcessAsUserW.argtypes = [wintypes.HANDLE, wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.LPVOID, wintypes.LPVOID, wintypes.BOOL, DWORD, wintypes.LPVOID, wintypes.LPCWSTR, ctypes.POINTER(STARTUPINFOW), ctypes.POINTER(PROCESS_INFORMATION)] userenv = ctypes.windll.userenv userenv.CreateEnvironmentBlock.argtypes = [ctypes.POINTER(wintypes.LPVOID), wintypes.HANDLE, wintypes.BOOL] userenv.DestroyEnvironmentBlock.argtypes = [wintypes.LPVOID] def console_session_id(): return kernel32.WTSGetActiveConsoleSessionId() def my_session_id(): sid = DWORD(0) kernel32.ProcessIdToSessionId(kernel32.GetCurrentProcessId(), ctypes.byref(sid)) return sid.value def spawn_in_console_session(cmdline, session_id, desktop="Winsta0\\Default"): """Spawn a detached SYSTEM process inside the given console session (needs SeTcbPrivilege, i.e. the caller runs as SYSTEM).""" htok = wintypes.HANDLE() if not advapi32.OpenProcessToken(kernel32.GetCurrentProcess(), TOKEN_ALL_ACCESS, ctypes.byref(htok)): raise OSError("OpenProcessToken failed") hdup = wintypes.HANDLE() if not advapi32.DuplicateTokenEx(htok, TOKEN_ALL_ACCESS, None, SecurityImpersonation, TokenPrimary, ctypes.byref(hdup)): raise OSError("DuplicateTokenEx failed") sid = DWORD(session_id) if not advapi32.SetTokenInformation(hdup, TokenSessionId, ctypes.byref(sid), ctypes.sizeof(sid)): raise OSError("SetTokenInformation(TokenSessionId) failed") env = wintypes.LPVOID() have_env = userenv.CreateEnvironmentBlock(ctypes.byref(env), hdup, False) si = STARTUPINFOW() si.cb = ctypes.sizeof(si) si.lpDesktop = desktop pi = PROCESS_INFORMATION() flags = DETACHED_PROCESS | CREATE_UNICODE_ENVIRONMENT | CREATE_BREAKAWAY_FROM_JOB cmd = ctypes.create_unicode_buffer(cmdline) if cmdline.startswith('"'): app = cmdline[1:cmdline.index('"', 1)] else: app = cmdline.split(" ", 1)[0] ok = advapi32.CreateProcessAsUserW( hdup, app, cmd, None, None, False, flags, env.value if have_env else None, None, ctypes.byref(si), ctypes.byref(pi)) if have_env: userenv.DestroyEnvironmentBlock(env) if not ok: raise OSError(f"CreateProcessAsUser failed: {ctypes.GetLastError()}") return pi.hProcess, pi.dwProcessId def is_elevated(): try: return bool(ctypes.windll.shell32.IsUserAnAdmin()) except Exception: return False elif sys.platform == "darwin": # ---------------- macOS (Apple Silicon native, pure ctypes) ---------------- import ctypes.util import platform _cg = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreGraphics")) _cf = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreFoundation")) class CGPoint(ctypes.Structure): _fields_ = [("x", ctypes.c_double), ("y", ctypes.c_double)] class _Size(ctypes.Structure): _fields_ = [("width", ctypes.c_double), ("height", ctypes.c_double)] class CGRect2(ctypes.Structure): _fields_ = [("origin", CGPoint), ("size", _Size)] _cg.CGEventCreateMouseEvent.restype = ctypes.c_void_p _cg.CGEventCreateMouseEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint32, CGPoint, ctypes.c_uint32] _cg.CGEventCreateScrollWheelEvent.restype = ctypes.c_void_p _cg.CGEventCreateScrollWheelEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_int32] _cg.CGEventCreateKeyboardEvent.restype = ctypes.c_void_p _cg.CGEventCreateKeyboardEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint16, ctypes.c_bool] _cg.CGEventPost.argtypes = [ctypes.c_uint32, ctypes.c_void_p] _cg.CGEventSetLocation.argtypes = [ctypes.c_void_p, CGPoint] _cg.CFRelease.argtypes = [ctypes.c_void_p] _cg.CGMainDisplayID.restype = ctypes.c_void_p _cg.CGDisplayBounds.restype = CGRect2 _cg.CGDisplayBounds.argtypes = [ctypes.c_void_p] _cg.CGDisplayPixelsWide.restype = ctypes.c_uint _cg.CGDisplayPixelsWide.argtypes = [ctypes.c_void_p] kCGHIDEventTap = 0 kCGEventMouseMoved, kCGEventLeftDown, kCGEventLeftUp = 5, 1, 2 kCGEventRightDown, kCGEventRightUp = 3, 4 kCGEventOtherDown, kCGEventOtherUp = 25, 26 kCGScrollEventUnitLine = 1 # US-ANSI virtual keycodes for our JS KeyboardEvent.code names VK_MAC = { **{f"Key{c}": v for c, v in zip( "ABCDEFGHIJKLMNOPQRSTUVWXYZ", [0x00, 0x0B, 0x08, 0x02, 0x0E, 0x03, 0x05, 0x04, 0x22, 0x26, 0x28, 0x25, 0x2E, 0x2D, 0x1F, 0x23, 0x0C, 0x0F, 0x01, 0x11, 0x20, 0x09, 0x0D, 0x07, 0x10, 0x06])}, **{f"Digit{c}": v for c, v in zip( "1234567890", [0x12, 0x13, 0x14, 0x15, 0x17, 0x16, 0x1A, 0x1C, 0x19, 0x1D])}, **{f"F{n}": v for n, v in zip(range(1, 13), [0x7A, 0x78, 0x63, 0x76, 0x60, 0x61, 0x62, 0x64, 0x65, 0x6D, 0x67, 0x6F])}, "Backquote": 0x32, "Minus": 0x1B, "Equal": 0x18, "BracketLeft": 0x21, "BracketRight": 0x1E, "Backslash": 0x2A, "Semicolon": 0x29, "Quote": 0x27, "Comma": 0x2B, "Period": 0x2F, "Slash": 0x2C, "CapsLock": 0x39, "Space": 0x31, "Enter": 0x24, "NumpadEnter": 0x24, "Backspace": 0x33, "Tab": 0x30, "Escape": 0x35, "Delete": 0x75, "Insert": 0, "Home": 0x73, "End": 0x77, "PageUp": 0x74, "PageDown": 0x79, "ArrowLeft": 0x7B, "ArrowUp": 0x7E, "ArrowRight": 0x7C, "ArrowDown": 0x7D, "ControlLeft": 0x3B, "ControlRight": 0x3E, "AltLeft": 0x3A, "AltRight": 0x3D, "ShiftLeft": 0x38, "ShiftRight": 0x3C, "MetaLeft": 0x37, "MetaRight": 0x37, "ContextMenu": 0, "Numpad0": 0x52, "Numpad1": 0x53, "Numpad2": 0x54, "Numpad3": 0x55, "Numpad4": 0x56, "Numpad5": 0x57, "Numpad6": 0x58, "Numpad7": 0x59, "Numpad8": 0x5B, "Numpad9": 0x5C, } _mac_tls = threading.local() def _display_info(): """[(scale, origin_x, origin_y, point_w, point_h)] per active display.""" if getattr(_mac_tls, "displays", None): return _mac_tls.displays infos = [] count = 32 arr = (ctypes.c_void_p * count)() actual = ctypes.c_uint32() if _cg.CGGetActiveDisplayList(count, arr, ctypes.byref(actual)) == 0: for i in range(actual.value): did = arr[i] bounds = _cg.CGDisplayBounds(did) pw = _cg.CGDisplayPixelsWide(did) scale = (pw / bounds.size.width) if bounds.size.width else 1.0 infos.append((scale, bounds.origin.x, bounds.origin.y, bounds.size.width, bounds.size.height)) _mac_tls.displays = infos or [(1.0, 0.0, 0.0, 1920.0, 1080.0)] return _mac_tls.displays def pixel_to_global(x, y): """Map a physical-pixel coordinate on the virtual desktop to global points.""" for scale, ox, oy, pw, ph in _display_info(): if x < pw * scale and y < ph * scale: return CGPoint(ox + x / scale, oy + y / scale) scale, ox, oy, pw, ph = _display_info()[0] return CGPoint(ox + x / scale, oy + y / scale) def attach_desktop(): return "Default" def desktop_name(): return "Default" def _post_event(ev): _cg.CGEventPost(kCGHIDEventTap, ev) _cg.CFRelease(ev) def _mouse_event(etype, button, x, y): return _cg.CGEventCreateMouseEvent(None, etype, pixel_to_global(x, y), button) def send_inputs(*inputs): # parity stub (mac uses discrete events) pass class InputPump(threading.Thread): def __init__(self, host): super().__init__(daemon=True) self.host = host self.q = queue.Queue(maxsize=512) self.buttons = 0 def put(self, item): try: self.q.put_nowait(item) except Exception: pass def run(self): while True: item = self.q.get() try: if item[0] == "move": _post_event(_mouse_event(kCGEventMouseMoved, 0, item[1], item[2])) elif item[0] == "buttons": b = item[1] x, y = self._pos() for mask, (down_t, up_t, btn) in ( (1, (kCGEventLeftDown, kCGEventLeftUp, 0)), (2, (kCGEventRightDown, kCGEventRightUp, 1)), (4, (kCGEventOtherDown, kCGEventOtherUp, 2))): if b & mask and not self.buttons & mask: _post_event(_mouse_event(down_t, btn, x, y)) if not b & mask and self.buttons & mask: _post_event(_mouse_event(up_t, btn, x, y)) self.buttons = b elif item[0] == "wheel": v, h = item[1], item[2] ev = _cg.CGEventCreateScrollWheelEvent(None, kCGScrollEventUnitLine, 2, int(v), int(h)) _post_event(ev) elif item[0] == "key": if self.host.cfg.data["perms"].get("input", True): kc = VK_MAC.get(item[1]) if kc: _post_event(_cg.CGEventCreateKeyboardEvent(None, kc, item[2])) elif item[0] == "wake": x, y = self._pos() _post_event(_mouse_event(kCGEventMouseMoved, 0, x + 1, y)) except Exception as e: log.debug("mac input error: %s", e) def _pos(self): return getattr(self.host, "_last_xy", (0, 0)) def clip_get(): r = subprocess.run(["/usr/bin/pbpaste"], capture_output=True, text=True, timeout=5) return r.stdout if r.returncode == 0 else None def clip_set(text): r = subprocess.run(["/usr/bin/pbcopy"], input=text, text=True, timeout=5) return r.returncode == 0 def monitor_power(state): # -1 ignored; 2 = display sleep if state == 2: subprocess.Popen(["/usr/bin/pmset", "displaysleepnow"]) def process_list(): r = subprocess.run(["ps", "-axo", "pid=,rss=,comm="], capture_output=True, text=True, timeout=15) items = [] for line in r.stdout.splitlines(): parts = line.strip().split(None, 2) if len(parts) == 3 and parts[0].isdigit(): items.append({"pid": int(parts[0]), "mem": int(parts[1]) * 1024, "name": os.path.basename(parts[2])}) items.sort(key=lambda i: -i["mem"]) return items[:300] def process_kill(pid): try: os.kill(pid, 9) return True, "killed" except Exception as e: return False, str(e) def print_file(path): if not os.path.isfile(path): return False, "file not found" r = subprocess.run(["/usr/bin/lp", path], capture_output=True, text=True, timeout=10) return r.returncode == 0, (r.stdout or r.stderr).strip() or "queued" def sys_command(action, cmd=None): try: if action == "lock": subprocess.run(["osascript", "-e", 'tell application "System Events" to keystroke "q" using {command down, control down}'], timeout=5) return True, "locked" if action == "logoff": return False, "use lock or reboot" if action == "reboot": subprocess.Popen(["osascript", "-e", 'tell app "System Events" to restart']) return True, "rebooting" if action == "shutdown": subprocess.Popen(["osascript", "-e", 'tell app "System Events" to shut down']) return True, "shutting down" if action == "run" and cmd: subprocess.Popen(cmd, shell=True) return True, "started" return False, "unknown command" except Exception as e: return False, str(e) def console_session_id(): return 1 def my_session_id(): return 1 def spawn_in_console_session(cmdline, sid, desktop=None): raise OSError("macOS uses LaunchAgent, not a session broker") def is_elevated(): return os.geteuid() == 0 else: # ---- other non-Windows stubs ---- def attach_desktop(): return "Default" def desktop_name(): return "Default" def clip_get(): return None def clip_set(t): return False def release_all_keys(): pass def monitor_power(state): pass def process_list(): return [] def process_kill(pid): return False, "unsupported" def print_file(path): return False, "unsupported" def sys_command(a, c=None): return False, "Windows only" def console_session_id(): return 0 def my_session_id(): return 0 def spawn_in_console_session(cmdline, sid, desktop=None): raise OSError("Windows only") def is_elevated(): return os.geteuid() == 0 # ================================================================ session / host class Session: def __init__(self, host, sid): self.host = host self.sid = sid self.active = False self.e2e = None self.worker = None self.audio = None self.stop_evt = threading.Event() self.mon = 1 self.fps = 30 self.bitrate = 6000 self.scale = 1.0 self.apply_evt = threading.Event() self.uploads = {} self.monitors = [] class Host: """The desktop worker. Talks over exactly one of: link='server' direct outbound websocket to the NetDesk server (simple mode) link='broker' websocket to the local service broker (service mode) link='direct' a direct viewer websocket (LAN / port-forward)""" def __init__(self, cfg: Config, args, link="server"): self.cfg = cfg self.args = args self.link = link self.loop = None self.session: Session | None = None self._current_sid = None # WebRTC P2P fast path (video+input peer-to-peer; relay = fallback) self.rtc_pc = None self.rtc_media = None self.rtc_ctl = None self.rtc_active = False self.ws_server = None # server link socket (simple mode) self.ws_broker = None # broker socket (service mode) self.ws_direct = None # direct viewer socket self._pending = None self._mouse_b = 0 self.clip_last_sent = None self.clip_thread = None self.pump = None self.privacy = False self.reg_error = "" self.account = "" self._tunnels = {} # tid -> (reader, writer) self.audio_available = False try: import pyaudiowpatch # noqa: F401 self.audio_available = (not getattr(args, "no_audio", False) and cfg.data["perms"].get("audio", True)) except Exception: log.info("PyAudioWPatch not installed - audio disabled (pip install PyAudioWPatch)") if IS_WIN: self.pump = InputPump(self) self.pump.start() # ---------------- outgoing (thread-safe, non-blocking) ---------------- def _post(self, coro): def _drain(fut): try: fut.exception() except Exception: pass try: asyncio.run_coroutine_threadsafe(coro, self.loop).add_done_callback(_drain) except RuntimeError: pass def _sock(self): if self.link == "direct": return self.ws_direct if self.link == "broker": return self.ws_broker return self.ws_server async def _send_str(self, ws, data): if ws is not None and not ws.closed: await ws.send_str(data) # ---------------- WebRTC P2P (video + input take the direct UDP path) ---- async def rtc_answer(self, m): """Viewer sent an SDP offer over the relay: answer it. On success the media/ctl DataChannels open and video+input flow peer-to-peer.""" try: await self.rtc_close() # ICE servers come with the offer (server-minted, incl. the nearest # TURN relay) so the host never needs TURN credentials itself. ice = [] for srv in (m.get("ice") or []): try: urls = srv.get("urls") urls = urls if isinstance(urls, list) else [urls] # aiortc uses ONE turn url: prefer UDP (lowest latency) urls.sort(key=lambda u: (0 if "transport=udp" in u else 1)) ice.append(RTCIceServer(urls=urls, username=srv.get("username"), credential=srv.get("credential"))) except Exception: pass if not ice: ice = [RTCIceServer(urls=["stun:stun.cloudflare.com:3478"])] pc = RTCPeerConnection(RTCConfiguration(iceServers=ice)) self.rtc_pc = pc self._rtc_turn = any("turn" in str(s.urls) for s in ice) @pc.on("datachannel") def on_dc(ch): if ch.label == "media": self.rtc_media = ch elif ch.label in ("ctl", "move"): # "ctl" = ordered (clicks/keys); "move" = unordered+unreliable # mouse motion so a lost packet never stalls the pointer if ch.label == "ctl": self.rtc_ctl = ch else: # tell the viewer we understand the motion channel; older # engines stay silent and the viewer keeps motion on "ctl" try: ch.send(json.dumps({"t": "move.ok"})) except Exception: pass @ch.on("message") def on_ctl(msg): # input JSON arrives here instead of the relay socket try: if isinstance(msg, str): self.on_message(json.loads(msg)) except Exception: pass @ch.on("close") def on_close(): if self.rtc_active: log.info("rtc: channel closed - back to relay") self.rtc_active = False self._rtc_check_open() @ch.on("open") def on_open(): self._rtc_check_open() @pc.on("connectionstatechange") async def on_state(): if pc.connectionState in ("failed", "closed", "disconnected"): if self.rtc_active: log.info("rtc: %s - back to relay", pc.connectionState) self.rtc_active = False await pc.setRemoteDescription( RTCSessionDescription(sdp=m.get("sdp", ""), type="offer")) answer = await pc.createAnswer() await pc.setLocalDescription(answer) # gathers ICE (non-trickle) sid = self.session.sid if self.session else None self.send_json({"t": "rtc.answer", "sid": sid, "sdp": pc.localDescription.sdp}) log.info("rtc: sent answer, waiting for P2P channels") except Exception as e: log.warning("rtc setup failed: %s", e) try: self.send_json({"t": "rtc.unavailable", "sid": self.session.sid if self.session else None, "msg": str(e)[:120]}) except Exception: pass def _rtc_check_open(self): mch, cch = self.rtc_media, self.rtc_ctl if (mch is not None and cch is not None and mch.readyState == "open" and cch.readyState == "open" and not self.rtc_active): self.rtc_active = True route = "unknown" try: # which tier won: direct P2P or the TURN relay? pair = self.rtc_pc.sctp.transport.transport._connection._nominated for p in pair.values(): route = ("TURN relay" if p.local_candidate.type == "relay" or p.remote_candidate.type == "relay" else "DIRECT P2P") except Exception: pass log.info("rtc: ACTIVE via %s - video and input bypass the WSS relay", route) async def rtc_close(self): self.rtc_active = False pc, self.rtc_pc = self.rtc_pc, None self.rtc_media = self.rtc_ctl = None if pc is not None: try: await pc.close() except Exception: pass def send_json(self, m: dict): if self._current_sid and "sid" not in m: m["sid"] = self._current_sid self._post(self._send_str(self._sock(), json.dumps(m))) def send_binary(self, payload: bytes): sess = self.session if not sess: return self._post(self._send_bin(sess, payload)) def send_raw(self, payload: bytes): """Send without session gating/E2E (used by TCP tunnels: the payload is TLS-protected to the server, which relays it to the viewer).""" ws = self._sock() async def _go(): if ws is not None and not ws.closed: await ws.send_bytes(payload) self._post(_go()) async def _send_bin(self, sess: Session, payload: bytes): if sess.e2e: nonce = secrets.token_bytes(12) payload = nonce + sess.e2e.encrypt(nonce, payload, None) # P2P fast path: same encrypted bytes, direct UDP instead of the relay. # If the channel is congested we DROP the frame (fresh beats late); # if it's gone we fall through to the relay socket seamlessly. if self.rtc_active: ch = self.rtc_media try: if ch is not None and ch.readyState == "open": if ch.bufferedAmount < 262144: ch.send(payload) return except Exception: self.rtc_active = False ws = self._sock() if ws is not None and not ws.closed: await ws.send_bytes(payload) # ---------------- auth ---------------- def make_challenge(self, sid): nonce = secrets.token_hex(16) self._current_sid = sid self._pending = {"sid": sid, "nonce": nonce, "priv": ec.generate_private_key(ec.SECP256R1())} pub_b = self._pending["priv"].public_key().public_bytes( serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint) pub_b64 = base64.b64encode(pub_b).decode() self._pending["pub_b64"] = pub_b64 # for channel-bound proof verification return { "t": "session.challenge", "sid": sid, "salt": base64.b64encode(bytes.fromhex(self.cfg.data["salt"])).decode(), "iters": self.cfg.data["iters"], "nonce": nonce, "e2e": True, "totp": bool(self.cfg.data.get("totp")), "pub": pub_b64, } def finish_auth(self, m): p = getattr(self, "_pending", None) sid = m.get("sid") if not p or p["sid"] != sid: self.send_json({"t": "session.deny", "reason": "no pending session"}) return totp_secret = self.cfg.data.get("totp") if totp_secret and not totp_verify(totp_secret, m.get("totp", "")): log.warning("bad 2FA code (sid=%s)", sid) time.sleep(0.4) self.send_json({"t": "session.deny", "reason": "bad 2FA code"}) self._pending = None self._current_sid = None return # Channel-bound proof (MITM hardening): if the viewer sent proof_bind, # it must verify against OUR ephemeral pubkey — a relay that swapped the # pub in the challenge can't satisfy it. Absent → legacy viewer, fall # back to the plain proof (no regression). pbind = m.get("proof_bind") if pbind and not self.cfg.check_proof_bound(p["nonce"], p.get("pub_b64", ""), pbind): log.warning("channel-bound proof MISMATCH — possible relay key-swap (sid=%s)", sid) time.sleep(0.4) self.send_json({"t": "session.deny", "reason": "key verification failed"}) self._pending = None self._current_sid = None return if not self.cfg.check_proof(p["nonce"], m.get("proof", "")): log.warning("bad password attempt (sid=%s)", sid) time.sleep(0.4) self.send_json({"t": "session.deny", "reason": "wrong password"}) self._pending = None self._current_sid = None return aesgcm = None viewer_pub_b = m.get("pub") if viewer_pub_b: try: peer = ec.EllipticCurvePublicKey.from_encoded_point( ec.SECP256R1(), base64.b64decode(viewer_pub_b)) shared = p["priv"].exchange(ec.ECDH(), peer) aesgcm = AESGCM(hashlib.sha256(shared + b"e2e").digest()) except Exception as e: log.warning("E2E negotiation failed (%s); transport-only", e) self._pending = None sess = Session(self, sid) sess.e2e = aesgcm try: with mss.mss() as sct: sess.monitors = [ {"i": i, "l": mon["left"], "t": mon["top"], "w": mon["width"], "h": mon["height"]} for i, mon in enumerate(sct.monitors[1:], start=1) ] except Exception: sess.monitors = [] mon = next((mm for mm in sess.monitors if mm["i"] == sess.mon), None) info = { "mon": sess.mon, "monitors": sess.monitors, "w": mon["w"] if mon else 0, "h": mon["h"] if mon else 0, "fps": sess.fps, "bitrate": sess.bitrate, "perms": self.cfg.data["perms"], "lock_on_end": self.cfg.data.get("lock_on_end", False), "e2e": aesgcm is not None, "audio": self.audio_available, "host_name": self.cfg.data["name"], "version": VERSION, "os": sys.platform, "logon_screen": (desktop_name() or "").lower().startswith("winlogon"), } self.session = sess sess.active = True if self.cfg.data.get("privacy_on_session") and self.cfg.data.get("allow_display_power"): # silent/privacy session: blank the physical monitor instead of # waking the displays - nothing appears on the host's screen self.privacy = True self._display_power(2) elif self.pump: self.pump.put(("wake",)) # wake the displays for the viewer info["privacy_active"] = self.privacy self.send_json({"t": "session.accept", "info": info}) sess.worker = threading.Thread(target=capture_worker, args=(sess,), daemon=True) sess.worker.start() if self.audio_available: sess.audio = threading.Thread(target=audio_worker, args=(sess,), daemon=True) sess.audio.start() if self.clip_thread is None or not self.clip_thread.is_alive(): self.clip_thread = threading.Thread(target=clipboard_worker, args=(self,), daemon=True) self.clip_thread.start() log.info("session %s accepted (e2e=%s)", sid, aesgcm is not None) def end_session(self, reason): sess = self.session if not sess: return sess.active = False sess.stop_evt.set() for fh in sess.uploads.values(): try: fh.close() except Exception: pass sess.uploads.clear() release_all_keys() if RTC_AVAILABLE and self.rtc_pc is not None: try: if self.loop is not None: asyncio.run_coroutine_threadsafe(self.rtc_close(), self.loop) except Exception: pass if self.privacy: monitor_power(-1) self.privacy = False if getattr(self, "_wp_hidden", False): # never leave the desk without its wallpaper try: wallpaper_set(getattr(self, "_wp_orig", "") or "") except Exception: pass self._wp_hidden = False if getattr(self, "_input_blocked", False): try: ctypes.windll.user32.BlockInput(False) except Exception: pass self._input_blocked = False mr = getattr(self, "_mode_restore", None) if mr: # never leave the remote PC in a resolution the viewer picked try: set_display_mode(mr["name"], mr["w"], mr["h"]) except Exception: pass self._mode_restore = None if self.cfg.data.get("lock_on_end"): try: sys_command("lock") except Exception: pass try: self.send_json({"t": "session.end", "sid": sess.sid, "reason": reason}) except Exception: pass self.session = None self._current_sid = None log.info("session ended: %s", reason) def _display_power(self, state): """Hardware display control. Sleeping the display requires the user's explicit opt-in (allow_display_power); waking it is always permitted.""" if state == 2 and not self.cfg.data.get("allow_display_power"): log.info("display sleep requested but allow_display_power is off - ignored") return threading.Thread(target=monitor_power, args=(state,), daemon=True).start() if state == 2: self._start_privacy_watchdog() def _privacy_watchdog(self): """Safety net: if privacy (monitor off) is ever engaged with no active session - crash, race, stale config - force the monitor back on.""" while self.privacy: time.sleep(2) sess = self.session if self.privacy and (sess is None or not sess.active): self.privacy = False monitor_power(-1) log.info("privacy watchdog: restored monitor (no active session)") def _start_privacy_watchdog(self): threading.Thread(target=self._privacy_watchdog, daemon=True).start() # ---------------- messages ---------------- def on_message(self, m: dict): t = m.get("t") sess = self.session if t == "wol": # another computer on this account asked us to wake a LAN neighbour threading.Thread(target=send_wol, args=(str(m.get("mac", "")),), daemon=True).start() return if t == "session.begin": if sess and sess.active: self.send_json({"t": "session.deny", "sid": m.get("sid"), "reason": "host busy"}) return self.cfg.reload() # password / 2FA changed in the app apply immediately allow = (self.cfg.data.get("allow_ips") or "").strip() peer = str(m.get("peer") or "") if allow and peer and not ip_allowed(peer, allow): log.warning("session denied: viewer %s is not on the IP allowlist", peer) self.send_json({"t": "session.deny", "sid": m.get("sid"), "reason": "this computer only accepts connections from allowed IPs"}) return self.send_json(self.make_challenge(m.get("sid") or uuid.uuid4().hex)) return if t == "session.auth": self.finish_auth(m) return # TCP tunnels live outside the session (server-gated, portal-authed) if t == "tunnel.open": self.tunnel_open(int(m.get("tid", 0)), m.get("thost", "127.0.0.1"), int(m.get("port", 0))) return if t == "tunnel.close": self.tunnel_close(int(m.get("tid", 0))) return if not sess or m.get("sid") != sess.sid: return if t == "session.end": self.end_session("viewer left") elif t == "ping": self.send_json({"t": "pong", "c": m.get("c")}) elif t == "set.quality": sess.fps = int(m.get("fps", 30)) sess.bitrate = int(m.get("bitrate", 6000)) sess.scale = float(m.get("scale", 1.0)) sess.apply_evt.set() elif t == "monitor.switch": try: sess.mon = int(m.get("mon", 1)) sess.apply_evt.set() except Exception: pass elif t == "keyframe": sess.force_idr = True elif t == "rtc.offer": if RTC_AVAILABLE: asyncio.ensure_future(self.rtc_answer(m)) else: self.send_json({"t": "rtc.unavailable", "sid": sess.sid}) elif t == "i.mouse": self.do_mouse(m) elif t == "i.key": if self.pump: self.pump.put(("key", m.get("c", ""), bool(m.get("d")))) elif t == "clip.set": if self.cfg.data["perms"].get("clipboard", True) and m.get("origin") == "viewer": clip_set(m.get("text", "")[:1024 * 1024]) self.clip_last_sent = m.get("text", "") elif t == "chat": log.info("chat from viewer: %s", m.get("text", "")[:200]) elif t == "set.cfg": for key in ("lock_on_end", "privacy_on_session", "allow_display_power"): if key in m: self.cfg.data[key] = bool(m[key]) self.cfg.save() self.send_json({"t": "sys.res", "ok": True, "msg": "config saved"}) elif t == "file.roots": self.file_roots() elif t == "file.list": self.file_list(m.get("path", "")) elif t == "file.get": self.file_get(m.get("path"), int(m.get("fid", 0))) elif t == "file.put": self.file_put_begin(m.get("path"), int(m.get("fid", 0)), int(m.get("size", 0))) elif t == "file.mkdir": self.file_simple(m.get("path"), lambda p: os.makedirs(p, exist_ok=True)) elif t == "file.del": self.file_simple(m.get("path"), (lambda p: shutil.rmtree(p)) if m.get("dir") else (lambda p: os.remove(p))) elif t == "file.ren": self.file_rename(m.get("src"), m.get("dst")) elif t == "sys.cmd": self.do_sys(m) def _session_device(self): """GDI display device of the monitor the current session is viewing. Uses the geometry the capture worker already cached on the session - NEVER calls mss here (a second mss in the event-loop thread crashes the process on Windows via a GDI conflict with the capture thread).""" sess = self.session mi = sess.mon if sess else 1 geo = None for mm in (getattr(sess, "monitors", None) or []): if mm["i"] == mi: geo = {"left": mm["l"], "top": mm["t"]} break if geo is None: # fall back to the primary display device devs = _display_devices() if not devs: raise RuntimeError("no display device found") return devs[0] dev = _device_for_monitor(geo) if not dev: raise RuntimeError("no display device found") return dev def do_sys(self, m): # A system command must NEVER take down the engine or the session. try: self._do_sys(m) except Exception as e: log.warning("sys command %r failed: %s", m.get("a"), e) try: self.send_json({"t": "sys.res", "ok": False, "kind": m.get("a"), "msg": str(e)[:200]}) except Exception: pass def _do_sys(self, m): a = m.get("a", "") if a == "processes": try: self.send_json({"t": "sys.res", "ok": True, "kind": "processes", "items": process_list()}) except Exception as e: self.send_json({"t": "sys.res", "ok": False, "msg": str(e)}) elif a == "kill": ok, msg = process_kill(int(m.get("pid", 0))) self.send_json({"t": "sys.res", "ok": ok, "msg": msg}) elif a == "privacy": if not self.cfg.data.get("allow_display_power"): self.send_json({"t": "sys.res", "ok": False, "kind": "privacy", "msg": "display control disabled (enable in the NetDesk app)"}) return self.privacy = not self.privacy self._display_power(2 if self.privacy else -1) self.send_json({"t": "sys.res", "ok": True, "kind": "privacy", "on": self.privacy, "msg": "monitor off" if self.privacy else "monitor on"}) elif a == "run": ok, msg = sys_command("run", m.get("cmd")) self.send_json({"t": "sys.res", "ok": ok, "msg": msg}) elif a == "print": path = m.get("path", "") ok, msg = print_file(path) self.send_json({"t": "sys.res", "ok": ok, "msg": msg}) elif a == "modes": try: dev = self._session_device() self.send_json({"t": "sys.res", "ok": True, "kind": "modes", "items": display_modes(dev["name"]), "current": [dev["w"], dev["h"]]}) except Exception as e: self.send_json({"t": "sys.res", "ok": False, "kind": "modes", "msg": str(e)[:200]}) elif a == "setmode": try: dev = self._session_device() # remember the ORIGINAL resolution so we restore it at # session end - never leave the remote PC in a changed mode if not getattr(self, "_mode_restore", None): self._mode_restore = {"name": dev["name"], "w": dev["w"], "h": dev["h"]} ok, msg = set_display_mode(dev["name"], int(m.get("w", 0)), int(m.get("h", 0))) if ok and self.session: self.session.force_idr = True self.send_json({"t": "sys.res", "ok": ok, "kind": "setmode", "msg": msg}) except Exception as e: self.send_json({"t": "sys.res", "ok": False, "kind": "setmode", "msg": str(e)[:200]}) elif a == "wallpaper": try: if getattr(self, "_wp_hidden", False): wallpaper_set(getattr(self, "_wp_orig", "") or "") self._wp_hidden = False else: self._wp_orig = wallpaper_get() wallpaper_set("") self._wp_hidden = True self.send_json({"t": "sys.res", "ok": True, "kind": "wallpaper", "on": self._wp_hidden, "msg": "wallpaper hidden" if self._wp_hidden else "wallpaper restored"}) except Exception as e: self.send_json({"t": "sys.res", "ok": False, "kind": "wallpaper", "msg": str(e)[:200]}) elif a == "cad": ok, msg = send_cad() self.send_json({"t": "sys.res", "ok": ok, "kind": "cad", "msg": msg}) elif a == "block_input": # RustDesk parity: freeze the remote user's own mouse/keyboard while # you control. Needs an elevated agent; auto-releases at session end # (and Windows releases it if the process ever dies). want = not getattr(self, "_input_blocked", False) ok = bool(ctypes.windll.user32.BlockInput(want)) if ok: self._input_blocked = want msg = "remote user's input BLOCKED" if want else "remote user's input released" else: msg = ("blocking needs the elevated service agent " "(install the boot-time service)") self.send_json({"t": "sys.res", "ok": ok, "kind": "block_input", "on": getattr(self, "_input_blocked", False), "msg": msg}) else: ok, msg = sys_command(a) self.send_json({"t": "sys.res", "ok": ok, "msg": msg}) def on_binary(self, data: bytes): if data and data[0] == 0xF3 and len(data) >= 5: # tunnel payload from the server (not session-E2E encrypted) tid = int.from_bytes(data[1:5], "little") self.on_tunnel_data(tid, data[5:]) return sess = self.session if not sess: return if sess.e2e: try: data = sess.e2e.decrypt(data[:12], data[12:], None) except Exception: return if not data or data[0] != 0xF2: return fid = int.from_bytes(data[1:5], "little") off = int.from_bytes(data[5:13], "little") chunk = data[13:] fh = sess.uploads.get(fid) if fh: fh.seek(off) fh.write(chunk) left = getattr(fh, "_nd_left", None) if left is not None: fh._nd_left = left - len(chunk) if fh._nd_left <= 0: final = fh._nd_final fh.close() sess.uploads.pop(fid, None) try: os.replace(final + ".netdesk.part", final) self.send_json({"t": "file.ok", "msg": "upload complete", "refresh": True}) except Exception as e: self.send_json({"t": "file.err", "msg": str(e)}) # ---------------- input ---------------- def do_mouse(self, m): if not self.cfg.data["perms"].get("input", True) or not self.pump: return x, y = int(m.get("x", 0)), int(m.get("y", 0)) # viewer sends coords in the ENCODED image space; convert to physical scale = getattr(self.session, "scale", 1.0) if self.session else 1.0 if scale and abs(scale - 1.0) > 0.01: x = int(x / scale) y = int(y / scale) # viewer coords are relative to the selected monitor; the injector uses # virtual-desktop absolute coords, so add the monitor's origin sess = self.session if sess and sess.monitors: mon = next((mm for mm in sess.monitors if mm["i"] == sess.mon), None) if mon: x += mon["l"] y += mon["t"] b = int(m.get("b", 0)) w, hw = float(m.get("w", 0) or 0), float(m.get("hw", 0) or 0) if (x, y) != getattr(self, "_last_xy", None): self._last_xy = (x, y) self.pump.put(("move", x, y)) if b != self._mouse_b: self.pump.put(("buttons", b)) self._mouse_b = b if w or hw: self.pump.put(("wheel", w, hw)) # fractional notches preserved # ---------------- TCP tunnels ---------------- def tunnel_open(self, tid: int, thost: str, port: int): if tid in self._tunnels: return async def _go(): try: r, w = await asyncio.wait_for(asyncio.open_connection(thost, port), 6) except Exception as e: self.send_json({"t": "tunnel.err", "tid": tid, "msg": str(e)}) return self._tunnels[tid] = (r, w) self.send_json({"t": "tunnel.ok", "tid": tid}) try: while True: data = await r.read(65536) if not data: break self.send_raw(b"\xf3" + tid.to_bytes(4, "little") + data) except Exception: pass finally: self.tunnel_close(tid) self.send_json({"t": "tunnel.closed", "tid": tid}) self._post(_go()) def tunnel_close(self, tid: int): conn = self._tunnels.pop(tid, None) if conn: try: conn[1].close() except Exception: pass def on_tunnel_data(self, tid: int, payload: bytes): conn = self._tunnels.get(tid) if conn: try: conn[1].write(payload) except Exception: self.tunnel_close(tid) # ---------------- files ---------------- def file_roots(self): items = [] if IS_WIN: for letter in string.ascii_uppercase: drv = f"{letter}:\\" if os.path.exists(drv): items.append({"n": drv, "t": "d", "s": 0, "m": 0}) else: items.append({"n": "/", "t": "d", "s": 0, "m": 0}) self.send_json({"t": "file.roots.res", "path": "", "items": items}) def file_list(self, path): try: if not path: return self.file_roots() items = [] with os.scandir(path) as it: for e in it: try: st = e.stat() items.append({"n": e.name, "t": "d" if e.is_dir() else "f", "s": 0 if e.is_dir() else st.st_size, "m": int(st.st_mtime)}) except Exception: continue items.sort(key=lambda i: (i["t"] != "d", i["n"].lower())) self.send_json({"t": "file.list.res", "path": path, "items": items[:2000]}) except Exception as e: self.send_json({"t": "file.err", "msg": f"cannot list {path}: {e}"}) def file_get(self, path, fid): def worker(): try: sent = 0 with open(path, "rb") as f: while True: chunk = f.read(128 * 1024) if not chunk: break self.send_binary(0xF1.to_bytes(1, "little") + fid.to_bytes(4, "little") + sent.to_bytes(8, "little") + chunk) sent += len(chunk) self.send_json({"t": "file.done", "fid": fid, "path": path, "size": sent}) except Exception as e: self.send_json({"t": "file.err", "msg": str(e)}) threading.Thread(target=worker, daemon=True).start() @staticmethod def _desktop_dir(): """The console user's real Desktop (handles OneDrive redirection).""" try: buf = ctypes.c_wchar_p() # SHGetKnownFolderPath(FOLDERID_Desktop) import uuid as _u fid = (ctypes.c_ubyte * 16).from_buffer_copy( _u.UUID("B4BFCC3A-DB2C-424C-B029-7FE99A87C641").bytes_le) if ctypes.windll.shell32.SHGetKnownFolderPath( ctypes.byref(fid), 0, None, ctypes.byref(buf)) == 0: p = buf.value ctypes.windll.ole32.CoTaskMemFree(buf) if p and os.path.isdir(p): return p except Exception: pass p = os.path.join(os.path.expanduser("~"), "Desktop") return p if os.path.isdir(p) else os.path.expanduser("~") def file_put_begin(self, path, fid, size): sess = self.session if not sess or not self.cfg.data["perms"].get("files", True): return try: # TeamViewer-style: a drop on the screen (relative path, no target # folder chosen) lands on the remote DESKTOP, never in our CWD. if not os.path.isabs(path): rel = os.path.normpath(path).replace("/", "\\") if rel.startswith("..") or ":" in rel: rel = os.path.basename(rel) path = os.path.join(self._desktop_dir(), rel) if fid in sess.uploads: sess.uploads[fid].close() os.makedirs(os.path.dirname(path) or ".", exist_ok=True) fh = open(path + ".netdesk.part", "wb") fh._nd_final = path fh._nd_left = size sess.uploads[fid] = fh except Exception as e: self.send_json({"t": "file.err", "msg": str(e)}) def file_simple(self, path, fn): try: fn(path) self.send_json({"t": "file.ok", "msg": "ok", "refresh": True}) except Exception as e: self.send_json({"t": "file.err", "msg": str(e)}) def file_rename(self, src, dst): self.file_simple(src, lambda p: os.replace(p, dst)) # ================================================================ capture + encode # H.264 encoder selection. Prefer a HARDWARE encoder (near-zero CPU, constant # low latency regardless of screen complexity) and fall back to software # libx264, so NetDesk runs smoothly on ANY PC + GPU: # NVIDIA -> h264_nvenc, Intel/QuickSync -> h264_qsv, AMD -> h264_amf, # macOS -> videotoolbox, Linux -> vaapi, else -> libx264 (software). # The winner is probed ONCE (opening a HW encoder with no capable GPU raises) # and cached; if a driver rejects the tuned options we retry the same encoder # with bare options before moving on, so quirky hardware still gets accel. _VIDEO_ENCODER = None # cached ffmpeg encoder name after the first successful probe def _encoder_chain(pref="auto"): """Ranked encoder candidates for a user preference. "auto" prefers a DISCRETE-GPU hardware encoder (NVENC/AMF - genuinely lower latency and offloads the CPU) and otherwise uses software libx264, which on a capable CPU is actually faster than Intel iGPU QuickSync for live screen content (measured) - so QSV is only chosen when explicitly requested. Any HW encoder stays available via "hardware" or a specific name ("qsv"/"nvenc"/"amf").""" pref = (pref or "auto").lower() if pref == "software": return ["libx264"] if pref in ("nvenc", "qsv", "amf", "mf"): return [f"h264_{pref}", "libx264"] if IS_WIN: if pref == "hardware": return ["h264_nvenc", "h264_qsv", "h264_amf", "libx264"] return ["h264_nvenc", "h264_amf", "libx264"] # auto if IS_MAC: return ["libx264"] if pref == "software" else ["h264_videotoolbox", "libx264"] return ["h264_vaapi", "libx264"] def _encoder_opts(name, fps, bitrate_kbps): """Return (input_pix_fmt, options dict) tuned for low latency. HW encoders take nv12; libx264 takes yuv420p. `forced_idr`/`forced-idr` makes pict_type=I produce a real IDR on demand (monitor switch / recovery).""" maxrate = bitrate_kbps bufsize = max(bitrate_kbps * 2 // max(fps, 1), 150) # ~250ms VBV if name == "libx264": # TCP smoothness: a raw keyframe is a 10-30x burst that stalls the pipe # for ~an RTT on long links. Cap instantaneous rate with a tight VBV, # stretch keyint and kill scene-cut bursts; force_idr still gives # on-demand keyframes for monitor switches / decoder recovery. return "yuv420p", { "preset": "ultrafast", "tune": "zerolatency", "x264opts": (f"vbv-maxrate={maxrate}:vbv-bufsize={bufsize}:" f"keyint-min={fps * 4}:scenecut=0"), } if name == "h264_nvenc": return "nv12", {"preset": "p1", "tune": "ull", "rc": "cbr", "forced-idr": "1"} if name == "h264_qsv": return "nv12", {"preset": "veryfast", "low_power": "1", "async_depth": "1", "look_ahead": "0", "forced_idr": "1"} if name == "h264_amf": return "nv12", {"usage": "ultralowlatency", "rc": "cbr", "quality": "speed", "forced_idr": "1"} if name == "h264_videotoolbox": return "nv12", {"realtime": "1"} if name == "h264_vaapi": return "nv12", {} return "yuv420p", {} def make_encoder(w, h, fps, bitrate_kbps, pref="auto"): """Build the best available H.264 encoder for this machine, honoring the `video_encoder` preference ("auto"/"software"/"hardware"/name). Returns (CodecContext, input_pix_fmt).""" global _VIDEO_ENCODER order = [] if _VIDEO_ENCODER: order.append(_VIDEO_ENCODER) for n in _encoder_chain(pref): if n not in order: order.append(n) err = None for name in order: fmt, opts = _encoder_opts(name, fps, bitrate_kbps) # tuned options first, then bare options (for quirky HW drivers) attempts = [opts] if name == "libx264" else [opts, {}] for att in attempts: try: c = av.CodecContext.create(name, "w") c.width, c.height, c.pix_fmt = w, h, fmt c.time_base = Fraction(1, 1000) c.framerate = Fraction(fps, 1) c.bit_rate = bitrate_kbps * 1000 c.gop_size = fps * 4 c.max_b_frames = 0 if att: c.options = att c.open() if _VIDEO_ENCODER != name: _VIDEO_ENCODER = name log.info("video encoder: %s (%s)", name, "hardware" if name != "libx264" else "software") return c, fmt except Exception as e: err = e if _VIDEO_ENCODER == name: # cached winner stopped working - re-probe _VIDEO_ENCODER = None raise RuntimeError(f"no usable H.264 encoder: {err}") def _enum_monitor_positions(): """Map each Windows display device name -> (left, top, width, height, primary) via EnumDisplayMonitors. Used to pair a dxcam output (which knows its own \\\\.\\DISPLAYn name) with the mss monitor at the same position, because dxcam's output order does NOT match mss's monitor order.""" out = {} if not IS_WIN: return out try: from ctypes import wintypes # noqa class _RECT(ctypes.Structure): _fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long), ("right", ctypes.c_long), ("bottom", ctypes.c_long)] class _MI(ctypes.Structure): _fields_ = [("cbSize", ctypes.c_ulong), ("rcMonitor", _RECT), ("rcWork", _RECT), ("dwFlags", ctypes.c_ulong), ("szDevice", ctypes.c_wchar * 32)] user32 = ctypes.windll.user32 proc = ctypes.WINFUNCTYPE(ctypes.c_int, ctypes.c_ulong, ctypes.c_ulong, ctypes.POINTER(_RECT), ctypes.c_double) def _cb(hmon, hdc, lprc, data): mi = _MI() mi.cbSize = ctypes.sizeof(_MI) if user32.GetMonitorInfoW(hmon, ctypes.byref(mi)): r = mi.rcMonitor out[mi.szDevice] = (r.left, r.top, r.right - r.left, r.bottom - r.top, bool(mi.dwFlags & 1)) return 1 user32.EnumDisplayMonitors(0, 0, proc(_cb), 0) except Exception: pass return out def _dxcam_output_count(): try: return max(1, _dxcam.output_info().count("Output[")) except Exception: return 1 def capture_worker(sess: Session): host = sess.host w = h = None enc = None enc_fmt = "yuv420p" # pixel format the chosen encoder wants (yuv420p / nv12) last_frame = None idx = 0 force_idr = True last_desktop = None sct = None cam = None # dxcam camera (DXGI fast path), or None -> mss cam_mon = None # the sess.mon the current camera was built for cam_first = None # the camera's priming frame, sent as the first paint last_cur_h = None last_cur_pos = None cur_mon_seen = None # detect monitor switches to force a keyframe enc_pref = host.cfg.data.get("video_encoder", "auto") use_dx = _DX_AVAILABLE and str( host.cfg.data.get("capture_backend", "auto")).lower() != "mss" log.info("capture worker started (dxgi=%s encoder=%s)", use_dx, enc_pref) def release_cam(): nonlocal cam, cam_mon, cam_first try: if cam is not None: cam.release() except Exception: pass cam = None cam_mon = None cam_first = None def ensure_cam(mon_index, mon_geo): """(Re)create the dxcam camera for the mss monitor at mon_geo, mapping by device position (dxcam output order != mss order). Returns True when the DXGI fast path is live, False to use mss. Any failure -> mss.""" nonlocal cam, cam_mon, cam_first if not use_dx or mon_index < 1: release_cam() return False if cam is not None and cam_mon == mon_index: return True release_cam() target = (mon_geo["left"], mon_geo["top"]) names = _enum_monitor_positions() target_name = next((nm for nm, g in names.items() if (g[0], g[1]) == target), None) for idx_o in range(_dxcam_output_count()): try: c = _dxcam.create(output_idx=idx_o, output_color="BGRA") except Exception: continue if c is None: continue dn = getattr(getattr(c, "_output", None), "devicename", "") if target_name is not None and dn != target_name: try: c.release() except Exception: pass continue probe = c.grab() for _ in range(4): if probe is not None: break time.sleep(0.01) probe = c.grab() if (probe is not None and probe.shape[1] == mon_geo["width"] and probe.shape[0] == mon_geo["height"]): cam = c cam_mon = mon_index cam_first = probe log.info("dxcam output %d (%s) -> monitor %d (%dx%d)", idx_o, dn, mon_index, probe.shape[1], probe.shape[0]) return True try: c.release() except Exception: pass log.info("dxcam unavailable for monitor %d; using mss", mon_index) return False def update_cursor(mon_geo, scale): nonlocal last_cur_h, last_cur_pos if not IS_WIN: return try: cs = cursor_state() if cs is None: return hcur, gx, gy = cs cx = int((gx - mon_geo["left"]) * scale) cy = int((gy - mon_geo["top"]) * scale) if hcur != last_cur_h: last_cur_h = hcur if hcur == 0: host.send_json({"t": "cursor", "hidden": True}) else: shp = cursor_png(hcur) css = _STD_CURSORS.get(hcur) if shp or css: msg = {"t": "cursor.shape", "id": hcur} if shp: msg.update(shp) if css: msg["css"] = css host.send_json(msg) if hcur != 0 and (cx, cy) != last_cur_pos: last_cur_pos = (cx, cy) host.send_json({"t": "cursor", "id": hcur, "x": cx, "y": cy}) except Exception: pass def rebuild(): nonlocal sct, enc, last_frame, w, h, force_idr try: if sct: sct.close() except Exception: pass sct = mss.mss() release_cam() try: if enc is not None: enc.close() except Exception: pass enc = None last_frame = None w = h = None force_idr = True try: while sess.active and not sess.stop_evt.is_set(): t0 = time.time() # follow the active desktop (logon screen / UAC switches) name = attach_desktop() if name != last_desktop: log.info("input desktop switched to %s", name) last_desktop = name rebuild() host.send_json({"t": "session.info", "info": { "mon": sess.mon, "monitors": sess.monitors, "w": w or 0, "h": h or 0, "fps": sess.fps, "bitrate": sess.bitrate, "logon_screen": bool(name and name.lower().startswith("winlogon")), "host_name": host.cfg.data["name"], "e2e": sess.e2e is not None, "audio": host.audio_available, "perms": host.cfg.data["perms"], "lock_on_end": host.cfg.data.get("lock_on_end", False), "privacy_active": host.privacy}}) if not name: time.sleep(0.25) continue mon = sct.monitors[min(sess.mon, len(sct.monitors) - 1)] # A monitor switch (even between same-size displays) invalidates the # encoder's reference frames - force a keyframe so the new screen is # not predicted from the old one. if cur_mon_seen is not None and cur_mon_seen != sess.mon: force_idr = True last_frame = None cur_mon_seen = sess.mon ow, oh = mon["width"] & ~1, mon["height"] & ~1 scale = max(0.25, min(sess.scale, 1.0)) tw = max(2, int(ow * scale) & ~1) th = max(2, int(oh * scale) & ~1) if enc is None or (w, h) != (tw, th) or sess.apply_evt.is_set(): sess.apply_evt.clear() if (w, h) != (tw, th): w, h = tw, th force_idr = True last_frame = None if enc is not None: try: enc.close() except Exception: pass enc, enc_fmt = make_encoder(w, h, sess.fps, sess.bitrate, enc_pref) host.send_json({"t": "session.info", "info": { "mon": sess.mon, "monitors": sess.monitors, "w": w, "h": h, "fps": sess.fps, "bitrate": sess.bitrate, "logon_screen": str(last_desktop or "").lower().startswith("winlogon"), "host_name": host.cfg.data["name"], "e2e": sess.e2e is not None, "audio": host.audio_available, "perms": host.cfg.data["perms"], "lock_on_end": host.cfg.data.get("lock_on_end", False), "privacy_active": host.privacy}}) need_kf = bool(force_idr or getattr(sess, "force_idr", False)) dx_active = ensure_cam(sess.mon, mon) if dx_active: # DXGI Desktop Duplication fast path (~4ms vs ~45ms for mss). if cam_first is not None: full, cam_first = cam_first, None # priming frame else: try: full = cam.grab() # None == unchanged except Exception as e: log.debug("dxcam grab failed (%s); using mss", e) release_cam() continue if full is None: # Screen unchanged: keep the cursor live; only re-encode the # last frame when a keyframe is actually due. update_cursor(mon, scale) if not need_kf or last_frame is None: time.sleep(max(0.0, 1.0 / sess.fps - (time.time() - t0))) continue arr = last_frame else: arr = full[:oh, :ow, :] last_frame = arr update_cursor(mon, scale) else: # Fallback: mss GDI capture + full-frame change detection. try: img = sct.grab(mon) except Exception as e: log.debug("grab failed (%s)", e) time.sleep(0.3) rebuild() continue arr = np.frombuffer(img.raw, dtype=np.uint8).reshape( img.size.height, img.size.width, 4) arr = arr[:oh, :ow, :] update_cursor(mon, scale) unchanged = (last_frame is not None and arr.shape == last_frame.shape and np.array_equal(arr, last_frame) and not need_kf) if unchanged: time.sleep(max(0.0, 1.0 / sess.fps - (time.time() - t0))) continue last_frame = arr frame = av.VideoFrame.from_ndarray(arr, format="bgra") frame = frame.reformat(width=w, height=h, format=enc_fmt) frame.pts = idx * 1000 // max(1, sess.fps) frame.time_base = Fraction(1, 1000) idx += 1 if force_idr or getattr(sess, "force_idr", False): try: frame.pict_type = av.video.frame.PictureType.I except Exception: pass sess.force_idr = False force_idr = False for pkt in enc.encode(frame): host.send_binary(b"\x01" + bytes([1 if pkt.is_keyframe else 0]) + bytes(pkt)) delay = 1.0 / sess.fps - (time.time() - t0) if delay > 0: time.sleep(delay) except Exception as e: log.exception("capture worker crashed: %s", e) finally: release_cam() try: if enc is not None: enc.close() except Exception: pass try: if sct is not None: sct.close() except Exception: pass log.info("capture worker stopped") # ================================================================ audio / clipboard workers def audio_worker(sess: Session): host = sess.host try: import pyaudiowpatch as pyaudio import numpy as _np except Exception: return ctx = None stream = None p = None try: p = pyaudio.PyAudio() try: dev = p.get_default_wasapi_loopback() except Exception: return stream = p.open(format=pyaudio.paInt16, channels=2, rate=48000, input=True, frames_per_buffer=960, input_device_index=dev["index"]) ctx = av.CodecContext.create("libopus", "w") ctx.sample_rate = 48000 ctx.layout = "stereo" ctx.format = "s16" ctx.bit_rate = 96000 ctx.open() log.info("audio capture running") while sess.active and not sess.stop_evt.is_set(): data = stream.read(960, exception_on_overflow=False) samples = _np.frombuffer(data, dtype=_np.int16) if not samples.any(): continue frame = av.AudioFrame.from_ndarray(samples.reshape(1, -1), format="s16", layout="stereo") frame.sample_rate = 48000 frame.pts = None for pkt in ctx.encode(frame): host.send_binary(b"\xa1" + bytes(pkt)) except Exception as e: log.info("audio disabled: %s", e) finally: try: if stream is not None: stream.stop_stream() stream.close() except Exception: pass try: if p is not None: p.terminate() except Exception: pass try: if ctx is not None: ctx.close() except Exception: pass def clipboard_worker(host: Host): while True: time.sleep(0.5) sess = host.session if not sess or not sess.active: continue if not host.cfg.data["perms"].get("clipboard", True): continue txt = clip_get() if txt is not None and txt != host.clip_last_sent: host.clip_last_sent = txt host.send_json({"t": "clip.set", "text": txt[:1024 * 1024], "origin": "host"}) # ================================================================ transports (host side) from contextlib import asynccontextmanager @asynccontextmanager async def ws_connect_tolerant(http, url, **kw): """Async-CM ws_connect that falls back to unverified TLS for self-signed certificates. Acceptable for personal deployments with a self-signed cert; use a real certificate in production.""" try: async with http.ws_connect(url, **kw) as ws: yield ws except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError) as e: log.warning("self-signed certificate on %s - accepting (INSECURE)", url) async with http.ws_connect(url, ssl=False, **kw) as ws: yield ws # ---------------- display modes / wallpaper / SAS (TeamViewer-style View & Actions menus) class _DEVMODEW(ctypes.Structure): _fields_ = [ ("dmDeviceName", ctypes.c_wchar * 32), ("dmSpecVersion", ctypes.c_ushort), ("dmDriverVersion", ctypes.c_ushort), ("dmSize", ctypes.c_ushort), ("dmDriverExtra", ctypes.c_ushort), ("dmFields", ctypes.c_ulong), ("dmPositionX", ctypes.c_long), ("dmPositionY", ctypes.c_long), ("dmDisplayOrientation", ctypes.c_ulong), ("dmDisplayFixedOutput", ctypes.c_ulong), ("dmColor", ctypes.c_short), ("dmDuplex", ctypes.c_short), ("dmYResolution", ctypes.c_short), ("dmTTOption", ctypes.c_short), ("dmCollate", ctypes.c_short), ("dmFormName", ctypes.c_wchar * 32), ("dmLogPixels", ctypes.c_ushort), ("dmBitsPerPel", ctypes.c_ulong), ("dmPelsWidth", ctypes.c_ulong), ("dmPelsHeight", ctypes.c_ulong), ("dmDisplayFlags", ctypes.c_ulong), ("dmDisplayFrequency", ctypes.c_ulong), ("dmICMMethod", ctypes.c_ulong), ("dmICMIntent", ctypes.c_ulong), ("dmMediaType", ctypes.c_ulong), ("dmDitherType", ctypes.c_ulong), ("dmReserved1", ctypes.c_ulong), ("dmReserved2", ctypes.c_ulong), ("dmPanningWidth", ctypes.c_ulong), ("dmPanningHeight", ctypes.c_ulong), ] class _DISPLAY_DEVICEW(ctypes.Structure): _fields_ = [("cb", ctypes.c_ulong), ("DeviceName", ctypes.c_wchar * 32), ("DeviceString", ctypes.c_wchar * 128), ("StateFlags", ctypes.c_ulong), ("DeviceID", ctypes.c_wchar * 128), ("DeviceKey", ctypes.c_wchar * 128)] def _display_devices(): """Attached display devices with their current desktop position + size.""" u = ctypes.windll.user32 out, i = [], 0 while True: dd = _DISPLAY_DEVICEW() dd.cb = ctypes.sizeof(dd) if not u.EnumDisplayDevicesW(None, i, ctypes.byref(dd), 0): break i += 1 if not (dd.StateFlags & 0x1): # DISPLAY_DEVICE_ATTACHED_TO_DESKTOP continue dm = _DEVMODEW() dm.dmSize = ctypes.sizeof(dm) if u.EnumDisplaySettingsW(dd.DeviceName, -1, ctypes.byref(dm)): # current out.append({"name": dd.DeviceName, "x": dm.dmPositionX, "y": dm.dmPositionY, "w": int(dm.dmPelsWidth), "h": int(dm.dmPelsHeight)}) return out def _device_for_monitor(mon_geo): """Match an mss monitor dict (left/top) to its GDI display device.""" devs = _display_devices() for d in devs: if d["x"] == mon_geo.get("left") and d["y"] == mon_geo.get("top"): return d return devs[0] if devs else None def display_modes(device_name): """All (w, h) modes the device supports, largest first.""" u = ctypes.windll.user32 modes, i = set(), 0 while True: dm = _DEVMODEW() dm.dmSize = ctypes.sizeof(dm) if not u.EnumDisplaySettingsW(device_name, i, ctypes.byref(dm)): break i += 1 if dm.dmPelsWidth >= 800 and dm.dmPelsHeight >= 600: modes.add((int(dm.dmPelsWidth), int(dm.dmPelsHeight))) return sorted(modes, reverse=True) def set_display_mode(device_name, w, h): """Temporarily change the display resolution (not persisted to registry).""" u = ctypes.windll.user32 dm = _DEVMODEW() dm.dmSize = ctypes.sizeof(dm) if not u.EnumDisplaySettingsW(device_name, -1, ctypes.byref(dm)): return False, "cannot read current mode" dm.dmPelsWidth, dm.dmPelsHeight = int(w), int(h) dm.dmFields = 0x80000 | 0x100000 # DM_PELSWIDTH | DM_PELSHEIGHT r = u.ChangeDisplaySettingsExW(device_name, ctypes.byref(dm), None, 0, None) return (r == 0), ("resolution changed" if r == 0 else f"display rejected the mode (code {r})") def wallpaper_get(): buf = ctypes.create_unicode_buffer(520) ctypes.windll.user32.SystemParametersInfoW(0x73, 512, buf, 0) # SPI_GETDESKWALLPAPER return buf.value def wallpaper_set(path): # SPI_SETDESKWALLPAPER with SPIF_UPDATEINIFILE | SPIF_SENDCHANGE return bool(ctypes.windll.user32.SystemParametersInfoW(0x14, 0, path or "", 3)) def send_cad(): """Secure Attention Sequence (Ctrl+Alt+Del). Only takes effect when the agent runs as SYSTEM (boot-time service) and Windows SAS policy allows it.""" try: sas = ctypes.WinDLL("sas.dll") sas.SendSAS(0) # as service sas.SendSAS(1) # as user (whichever the policy permits) return True, "Ctrl+Alt+Del sent (takes effect when SAS policy allows it)" except Exception as e: return False, f"Ctrl+Alt+Del unavailable: {str(e)[:120]}" def _mac_str() -> str: """Primary NIC MAC for Wake-on-LAN (empty if only a random/virtual one).""" try: n = uuid.getnode() if (n >> 40) & 0x02: # locally-administered bit: random, not a real NIC return "" return ":".join(f"{(n >> s) & 0xFF:02x}" for s in range(40, -1, -8)) except Exception: return "" def send_wol(mac: str): """Broadcast a Wake-on-LAN magic packet on the local network.""" import socket as _sk clean = "".join(ch for ch in mac if ch in "0123456789abcdefABCDEF") if len(clean) != 12: return payload = b"\xff" * 6 + bytes.fromhex(clean) * 16 try: s = _sk.socket(_sk.AF_INET, _sk.SOCK_DGRAM) s.setsockopt(_sk.SOL_SOCKET, _sk.SO_BROADCAST, 1) for port in (9, 7): s.sendto(payload, ("255.255.255.255", port)) s.close() log.info("WoL magic packet broadcast for %s", mac) except Exception as e: log.warning("WoL send failed: %s", e) def ip_allowed(ip: str, allow_csv: str) -> bool: """True if ip matches any entry (IP or CIDR) in the allowlist string.""" import ipaddress try: a = ipaddress.ip_address(ip) except ValueError: return True # unparseable/missing peer info: never lock the owner out for part in allow_csv.replace(",", " ").split(): try: if a in ipaddress.ip_network(part, strict=False): return True except ValueError: continue return False def reg_message(cfg: Config, args, takeover=False) -> dict: return {"t": "reg.host", "id": cfg.id, "key": getattr(args, "reg_key", None) or cfg.data.get("reg_key", ""), "name": cfg.data.get("name", "host"), "ver": VERSION, # desktop-app account token: binds this computer to "My computers" "token": cfg.data.get("account_token", ""), # MAC lets another computer on the account wake this one (WoL) "mac": _mac_str(), # on our first registration only, take over any idle stuck engine # holding this ID (never displaces an active session server-side) "takeover": bool(takeover)} def write_status(cfg: Config, state: str, server: str, in_session=False, account=""): """Heartbeat file the desktop app reads (written by engine and broker). A stale duplicate engine (rejected with "already connected elsewhere") must never stomp the status of the engine that actually holds the ID - that ping-pong made the app flicker Online/Stopped every few seconds.""" try: if state != "online": try: d = json.loads((cfg.path.parent / "gui_status.json").read_text("utf-8")) if (d.get("state") == "online" and d.get("pid") != os.getpid() and time.time() - d.get("ts", 0) < 8): return # another live engine of ours is online - keep its status except Exception: pass (cfg.path.parent / "gui_status.json").write_text(json.dumps({ "state": state, "id": cfg.id, "name": cfg.data.get("name", ""), "server": server, "in_session": bool(in_session), "account": account, "ts": int(time.time()), "pid": os.getpid()}), "utf-8") except Exception: pass async def pump_socket_to_host(host: Host, ws, label): """Feed one inbound websocket into the host (used by simple/direct/agent modes).""" async for msg in ws: if msg.type == WSMsgType.TEXT: try: m = json.loads(msg.data) except Exception: continue if m.get("t") == "host_status": if not m.get("ok"): log.error("server rejected registration: %s", m.get("msg")) host.reg_error = m.get("msg") or "rejected" else: host.reg_error = "" host.account = m.get("account", "") continue host.on_message(m) elif msg.type == WSMsgType.BINARY: host.on_binary(msg.data) elif msg.type == WSMsgType.ERROR: break async def run_simple_mode(host: Host, args): """Original mode: this process talks to the server itself + optional direct listener.""" async def direct_listen(): if not args.listen: return async def handler(req): ws = web.WebSocketResponse(heartbeat=20.0, max_msg_size=64 * 1024 * 1024) await ws.prepare(req) pending_sid = None try: async for msg in ws: if msg.type == WSMsgType.TEXT: try: m = json.loads(msg.data) except Exception: continue t = m.get("t") if t == "session.begin": if host.session and host.session.active: await ws.send_json({"t": "session.deny", "sid": None, "reason": "host busy"}) continue pending_sid = uuid.uuid4().hex host.link = "direct" host.ws_direct = ws host.on_message({"t": "session.begin", "sid": pending_sid, "peer": req.remote or ""}) else: if pending_sid and not m.get("sid"): m["sid"] = pending_sid host.on_message(m) elif msg.type == WSMsgType.BINARY: host.on_binary(msg.data) finally: if host.link == "direct": host.end_session("direct viewer left") host.link = "server" host.ws_direct = None return ws app = web.Application() app.router.add_get("/ws", handler) runner = web.AppRunner(app, access_log=None) await runner.setup() await web.TCPSite(runner, "0.0.0.0", args.listen).start() log.info("direct listener on port %d", args.listen) if args.listen: await direct_listen() url = args.server or host.cfg.data.get("server", "") or DEFAULT_SERVER if not url: log.info("no --server given; direct mode only") return async def gui_status_writer(): """Heartbeat for the desktop GUI (gui_status.json next to the config).""" while True: online = host.ws_server is not None and not host.ws_server.closed err = getattr(host, "reg_error", "") state = ("error: " + err) if err else ("online" if online else "connecting") write_status(host.cfg, state, url, bool(host.session), getattr(host, "account", "")) await asyncio.sleep(3) status_task = asyncio.ensure_future(gui_status_writer()) host._status_task = status_task backoff = 1 while True: try: timeout = aiohttp.ClientTimeout(total=None, connect=15) async with aiohttp.ClientSession(timeout=timeout) as http: async with ws_connect_tolerant(http, url, heartbeat=20, max_msg_size=64 * 1024 * 1024) as ws: host.ws_server = ws host.link = "server" backoff = 1 host.cfg.reload() # take over on our first attempt, AND whenever the last try # was bounced because a STALE idle engine still holds the ID # (server never lets takeover kill an active session, so this # only ever reclaims a dead/ghost holder - the real fix for # "reconnect says the ID is already connected elsewhere") prev_err = (getattr(host, "reg_error", "") or "").lower() want_takeover = (not getattr(host, "_did_register", False) or "already connected" in prev_err) host._did_register = True await ws.send_json(reg_message(host.cfg, args, takeover=want_takeover)) log.info("connected to server %s as ID %s (takeover=%s)", url, host.cfg.id, want_takeover) await pump_socket_to_host(host, ws, "server") except Exception as e: log.warning("server link lost (%s); retrying in %ss", e, backoff) host.ws_server = None if host.session and host.link == "server": host.end_session("server link lost") await asyncio.sleep(backoff) backoff = min(backoff * 2, 5) # ================================================================ service broker class Broker: """Boot-time SYSTEM service core: holds the server link, spawns a desktop agent into the console session, and relays traffic. Runs in session 0.""" def __init__(self, cfg: Config, args): self.cfg = cfg self.args = args self.token = secrets.token_hex(16) self.port = 0 self.agent_ws = None self.agent_proc = None # (handle|None, pid, session) self.server_ws = None self.direct_ws = None # direct viewer socket self.direct_sid = None self.session_origin = "server" self.reg_error = "" self.account = "" # ---------- spawn / supervise agent ---------- def agent_cmdline(self): if getattr(sys, "frozen", False): prog = f'"{sys.executable}"' else: prog = f'"{sys.executable}" "{os.path.abspath(__file__)}"' return (f'{prog} --session-agent --config "{self.cfg.path.parent}" ' f'--broker-url ws://127.0.0.1:{self.port}/agent --token {self.token}') def spawn_agent(self, session_id): cmd = self.agent_cmdline() try: handle, pid = spawn_in_console_session(cmd, session_id) log.info("spawned SYSTEM agent pid=%s session=%s", pid, session_id) return (handle, pid, None) except Exception as e: log.warning("SYSTEM spawn failed (%s); spawning plain child", e) proc = subprocess.Popen(cmd, creationflags=0x00000008 | 0x00000200) # DETACHED|NEW_GROUP return (None, proc.pid, proc) def kill_agent(self): if self.agent_ws is not None: self.kill_agent_ws_only() info = self.agent_proc if info: handle, pid, popen = info if popen is not None: popen.kill() elif handle: kernel32.TerminateProcess(handle, 1) kernel32.CloseHandle(handle) else: subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, creationflags=0x08000000) self.agent_proc = None async def lifecycle(self): while True: try: con = console_session_id() info = self.agent_proc alive = False if info: handle, pid, popen = info if popen is not None: alive = popen.poll() is None elif handle: alive = kernel32.WaitForSingleObject(handle, 0) == 0x102 # STILL_ACTIVE if alive: sid = DWORD(0) if kernel32.ProcessIdToSessionId(pid, ctypes.byref(sid)) and sid.value != con: alive = False # console session changed (user switch) if not alive: if info: log.info("respawning agent (old pid=%s)", info[1]) self.kill_agent() self.agent_proc = self.spawn_agent(con) except Exception as e: log.debug("lifecycle: %s", e) await asyncio.sleep(2) # ---------- sockets ---------- async def server_link(self): url = self.args.server or self.cfg.data.get("server", "") or DEFAULT_SERVER if not url: log.warning("broker: no server URL configured (--server)") return backoff = 1 while True: try: timeout = aiohttp.ClientTimeout(total=None, connect=15) async with aiohttp.ClientSession(timeout=timeout) as http: async with ws_connect_tolerant(http, url, heartbeat=20, max_msg_size=64 * 1024 * 1024) as ws: self.server_ws = ws backoff = 1 self.cfg.reload() prev_err = (getattr(self, "reg_error", "") or "").lower() want_takeover = (not getattr(self, "_did_register", False) or "already connected" in prev_err) self._did_register = True await ws.send_json(reg_message(self.cfg, self.args, takeover=want_takeover)) log.info("broker connected to %s as ID %s (takeover=%s)", url, self.cfg.id, want_takeover) async for msg in ws: if msg.type == WSMsgType.TEXT: m = json.loads(msg.data) if msg.data.startswith("{") else {} if m.get("t") == "host_status": if not m.get("ok"): log.error("registration rejected: %s", m.get("msg")) self.reg_error = m.get("msg") or "rejected" else: self.reg_error = "" self.account = m.get("account", "") continue if m.get("t") == "session.begin" and self.agent_ws is None: await ws.send_json({"t": "session.deny", "sid": m.get("sid"), "reason": "host starting, try again shortly"}) continue if self.agent_ws and not self.agent_ws.closed: await self.agent_ws.send_str(msg.data) elif msg.type == WSMsgType.BINARY: if self.agent_ws and not self.agent_ws.closed: await self.agent_ws.send_bytes(msg.data) except Exception as e: log.warning("broker server link lost (%s); retry in %ss", e, backoff) self.server_ws = None await asyncio.sleep(backoff) backoff = min(backoff * 2, 5) def route_from_agent(self, data: str): """Agent output goes to whoever started the session (direct client or server).""" if self.session_origin == "direct" and self.direct_ws and not self.direct_ws.closed: asyncio.ensure_future(self.direct_ws.send_str(data)) elif self.server_ws and not self.server_ws.closed: asyncio.ensure_future(self.server_ws.send_str(data)) async def agent_handler(self, req): if req.query.get("token") != self.token: return web.Response(status=403, text="bad token") ws = web.WebSocketResponse(heartbeat=15.0, max_msg_size=64 * 1024 * 1024) await ws.prepare(req) self.kill_agent_ws_only() self.agent_ws = ws log.info("desktop agent attached (session %s)", my_session_id()) try: async for msg in ws: if msg.type == WSMsgType.TEXT: self.route_from_agent(msg.data) elif msg.type == WSMsgType.BINARY: if self.session_origin == "direct" and self.direct_ws and not self.direct_ws.closed: asyncio.ensure_future(self.direct_ws.send_bytes(msg.data)) elif self.server_ws and not self.server_ws.closed: asyncio.ensure_future(self.server_ws.send_bytes(msg.data)) finally: if self.agent_ws is ws: self.agent_ws = None log.info("desktop agent detached") return ws def kill_agent_ws_only(self): if self.agent_ws is not None: try: asyncio.ensure_future(self.agent_ws.close()) except Exception: pass self.agent_ws = None async def direct_handler(self, req): ws = web.WebSocketResponse(heartbeat=20.0, max_msg_size=64 * 1024 * 1024) await ws.prepare(req) old = self.direct_ws if old is not None and not old.closed: try: await old.close() except Exception: pass self.direct_ws = ws try: async for msg in ws: if msg.type == WSMsgType.TEXT: m = json.loads(msg.data) if msg.data.startswith("{") else {} if m.get("t") == "session.begin": self.direct_sid = uuid.uuid4().hex m["sid"] = self.direct_sid self.session_origin = "direct" if self.agent_ws and not self.agent_ws.closed: await self.agent_ws.send_str(json.dumps(m)) continue if self.agent_ws and not self.agent_ws.closed: await self.agent_ws.send_str(msg.data) elif msg.type == WSMsgType.BINARY: if self.agent_ws and not self.agent_ws.closed: await self.agent_ws.send_bytes(msg.data) finally: if self.direct_ws is ws: self.direct_ws = None self.session_origin = "server" return ws async def run(self): # agent socket app = web.Application() app.router.add_get("/agent", self.agent_handler) runner = web.AppRunner(app, access_log=None) await runner.setup() site = web.TCPSite(runner, "127.0.0.1", 0) await site.start() self.port = site._server.sockets[0].getsockname()[1] (self.cfg.path.parent / "broker.json").write_text( json.dumps({"port": self.port, "token": self.token}), "utf-8") log.info("broker socket on 127.0.0.1:%d", self.port) # optional direct listener if self.args.listen: dapp = web.Application() dapp.router.add_get("/ws", self.direct_handler) drunner = web.AppRunner(dapp, access_log=None) await drunner.setup() await web.TCPSite(drunner, "0.0.0.0", self.args.listen).start() log.info("direct listener on port %d", self.args.listen) async def status(): url = self.args.server or self.cfg.data.get("server", "") or DEFAULT_SERVER while True: up = self.server_ws is not None and not self.server_ws.closed err = getattr(self, "reg_error", "") state = ("error: " + err) if err else ("online" if up else "connecting") write_status(self.cfg, state, url, False, getattr(self, "account", "")) await asyncio.sleep(3) await asyncio.gather(self.server_link(), self.lifecycle(), status()) def kill_other_engines(): """Windows: make sure only THIS engine instance runs (frozen exe).""" if not IS_WIN or not getattr(sys, "frozen", False): return me = os.getpid() subprocess.run( ["powershell", "-NoProfile", "-Command", f"Get-CimInstance Win32_Process -Filter \"Name='{os.path.basename(sys.executable)}'\" | " f"Where-Object {{ $_.ProcessId -ne {me} -and $_.CommandLine -like '*--engine*' }} | " "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"], capture_output=True, creationflags=CREATE_NO_WINDOW) async def run_agent_mode(args): """Desktop worker spawned by the broker (runs inside the console session).""" cfg = Config(Path(args.config) / "config.json" if args.config else appdata_dir() / "config.json") host = Host(cfg, args, link="broker") host.loop = asyncio.get_running_loop() kill_other_engines() backoff = 1 timeout = aiohttp.ClientTimeout(total=None, connect=5, sock_read=30) while True: try: async with aiohttp.ClientSession(timeout=timeout) as http: async with http.ws_connect(f"{args.broker_url}?token={args.token}", heartbeat=30, max_msg_size=64 * 1024 * 1024) as ws: host.ws_broker = ws backoff = 1 log.info("agent linked to broker") await pump_socket_to_host(host, ws, "broker") except Exception as e: log.warning("agent/broker link error: %s", e) host.ws_broker = None if host.session: host.end_session("broker link lost") await asyncio.sleep(backoff) backoff = min(backoff * 2, 10) # exit if we are no longer in the console session (the broker respawns us) if IS_WIN and my_session_id() != console_session_id(): log.info("session changed; agent exiting") return # ================================================================ install / uninstall SERVICE_TASK = "NetDesk" def install_service(args): if not IS_WIN: print("service install is Windows-only") return if not is_elevated(): print("Please run --install from an elevated (Administrator) prompt once.") return cfg_dir = appdata_dir() # the portable folder (or APPDATA) the APP writes to cfg = Config(cfg_dir / "config.json") # Point the SYSTEM broker at the SAME config file the app writes, so any # password / 2FA / settings change made in the app applies to the boot # service automatically (cfg.reload() at each connection) - no re-install, # no extra elevation. Also mirror it into ProgramData as a fallback the # service can always read even if the portable folder becomes unavailable. pd = programdata_dir() pd.mkdir(parents=True, exist_ok=True) (pd / "config.json").write_text(json.dumps(cfg.data, indent=2), "utf-8") svc_cfg = cfg_dir if os.access(cfg_dir, os.W_OK) else pd if getattr(sys, "frozen", False): # packaged exe: run it directly with --broker (no python, no script path) tr = f'"{sys.executable}" --broker --config "{svc_cfg}"' else: py = Path(sys.executable).with_name("pythonw.exe") if not py.exists(): py = Path(sys.executable) tr = f'"{py}" "{os.path.abspath(__file__)}" --broker --config "{svc_cfg}"' server = args.server or cfg.data.get("server", "") if server: tr += f' --server "{server}"' reg = args.reg_key or cfg.data.get("reg_key", "") if reg: tr += f' --reg-key "{reg}"' if args.listen: tr += f" --listen {args.listen}" r = subprocess.run(["schtasks", "/Create", "/TN", SERVICE_TASK, "/SC", "ONSTART", "/RU", "SYSTEM", "/RL", "HIGHEST", "/TR", tr, "/F"], capture_output=True, text=True) result_path = programdata_dir() / "install_result.json" if r.returncode != 0: result_path.write_text(json.dumps( {"ok": False, "error": (r.stderr or r.stdout or "schtasks failed").strip()}), "utf-8") print("schtasks failed:", r.stderr or r.stdout) return # handover: stop only the user-run ENGINE so the service owns the connection. # Do NOT blanket-kill NetDeskHost.exe - that also kills the GUI (which is # polling for success) and this very installer, so the install looked like it # "never finished" and the toggle stayed off. me = os.getpid() subprocess.run( ["powershell", "-NoProfile", "-Command", "Get-CimInstance Win32_Process | Where-Object { " "($_.Name -eq 'NetDeskHost.exe' -or $_.CommandLine -like '*netdesk_host.py*') " f"-and $_.CommandLine -like '*--engine*' -and $_.ProcessId -ne {me} }} | " "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"], capture_output=True, creationflags=0x08000000) subprocess.run(["schtasks", "/Run", "/TN", SERVICE_TASK], capture_output=True) result_path.write_text(json.dumps({"ok": True, "id": cfg.id}), "utf-8") print("=" * 62) print(" NetDesk service installed and started (boot-time, SYSTEM).") print(f" Host ID : {cfg.id} (service reads config from {svc_cfg})") print(" Reachable at the logon screen, across reboots, no auto-logon.") print(f" Remove: {Path(__file__).name} --uninstall") print("=" * 62) def uninstall_service(): if not IS_WIN: return subprocess.run(["schtasks", "/End", "/TN", SERVICE_TASK], capture_output=True) r = subprocess.run(["schtasks", "/Delete", "/TN", SERVICE_TASK, "/F"], capture_output=True, text=True) if r.returncode != 0: print("schtasks delete failed:", r.stderr or r.stdout) return # stop the broker/agent processes this service started (targeted, by command line) subprocess.run( ["powershell", "-NoProfile", "-Command", "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*--broker*' " "-or $_.CommandLine -like '*--session-agent*' } | " "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"], capture_output=True, creationflags=0x08000000) print("NetDesk service removed.") # ================================================================ main def main(): # frozen/CLI behavior: double-click (no args) opens the desktop app, # explicit flags keep the classic engine/service CLI. gui_wanted = ("--gui" in sys.argv) or ( len(sys.argv) == 1 and getattr(sys, "frozen", False)) if "--gui-selftest" in sys.argv: import netdesk_gui netdesk_gui.selftest() return if gui_wanted: try: import netdesk_gui except Exception as e: print("GUI unavailable (%s); engine CLI only." % e) import traceback; traceback.print_exc() return netdesk_gui.run() return ap = argparse.ArgumentParser(description="NetDesk host agent") ap.add_argument("--server", help="NetDesk server ws(s):// URL") ap.add_argument("--reg-key", help="server registration key") ap.add_argument("--id", help="override host ID (9 digits)") ap.add_argument("--set-password", dest="newpw", help="set a new permanent password") ap.add_argument("--set-totp", dest="newtotp", nargs="?", const="auto", help="enable TOTP 2FA (optional secret, base32; 'auto' generates one)") ap.add_argument("--name", help="friendly name shown to viewers") ap.add_argument("--listen", type=int, metavar="PORT", help="direct-access listener port") ap.add_argument("--no-audio", action="store_true") ap.add_argument("--config", help="config directory (service mode)") ap.add_argument("--broker", action="store_true", help=argparse.SUPPRESS) ap.add_argument("--broker-url", dest="broker_url", help=argparse.SUPPRESS) ap.add_argument("--session-agent", action="store_true", help=argparse.SUPPRESS) ap.add_argument("--token", help=argparse.SUPPRESS) ap.add_argument("--engine", action="store_true", help=argparse.SUPPRESS) ap.add_argument("--gui", action="store_true", help="open the desktop control panel") ap.add_argument("--install", action="store_true", help="install boot-time service (admin once)") ap.add_argument("--uninstall", action="store_true", help="remove the service") ap.add_argument("--verbose", action="store_true") args = ap.parse_args() global _CONFIG_DIR if args.config: _CONFIG_DIR = Path(args.config) logging.basicConfig( level=logging.DEBUG if args.verbose else logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s", handlers=[logging.StreamHandler(), logging.FileHandler(appdata_dir() / "host.log", encoding="utf-8")], ) if args.install: install_service(args) return if args.uninstall: uninstall_service() return cfg = Config(appdata_dir() / "config.json") if args.id and args.id.isdigit(): cfg.data["id"] = args.id if args.name: cfg.data["name"] = args.name if args.server: cfg.data["server"] = args.server cfg.save() if args.newpw: cfg.set_password(args.newpw) print("[netdesk] permanent password updated.") return if args.newtotp is not None: if args.newtotp == "auto": secret = "".join(secrets.choice(string.ascii_uppercase + "234567") for _ in range(32)) else: secret = args.newtotp cfg.set_totp(secret) print("[netdesk] TOTP 2FA ENABLED.") print(f" Secret (base32, add to your authenticator): {secret}") print(" Viewer will ask for the 6-digit code at connect.") return if args.broker: if not IS_WIN: print("[netdesk] the session broker is Windows-only; on macOS use a LaunchAgent (see scripts/deploy)") return broker = Broker(cfg, args) print(f"[netdesk] broker starting (ID {cfg.id}, session {my_session_id()})") try: asyncio.run(broker.run()) except KeyboardInterrupt: pass return if args.session_agent: if not args.broker_url or not args.token: print("--session-agent needs --broker-url and --token") return print(f"[netdesk] desktop agent starting (ID {cfg.id}, session {my_session_id()})") try: asyncio.run(run_agent_mode(args)) except KeyboardInterrupt: pass return if not args.engine: print("=" * 60) print(f" NetDesk host v{VERSION}") print(f" Host ID : {cfg.id}") print(f" Name : {cfg.data['name']}") print(f" Server : {args.server or cfg.data.get('server') or '(none - direct mode only)'}") print(f" Config : {cfg.path}") if cfg.data.get("totp"): print(" 2FA : TOTP enabled") if cfg._new_password and not args.engine: print(f" NEW PASSWORD (save it): {cfg._new_password}") if not args.engine: print("[netdesk] running - press Ctrl+C to stop") print("=" * 60) host = Host(cfg, args, link="server") async def amain(): host.loop = asyncio.get_running_loop() await run_simple_mode(host, args) try: asyncio.run(amain()) except KeyboardInterrupt: host.end_session("host stopping") print("\r[netdesk] stopped") if __name__ == "__main__": main()