From 7308ac09b17bd3403bf74a831f2c311647374333 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:11:57 +1000 Subject: [PATCH] Remote desktop from Windows: sign in as steamos, and say when xrdp isn't there A Windows user reported "RDP not working". Frame Control ran `mstsc /v:HOST`, which offers the Windows account; the Frame's xrdp (TLS, no NLA) only accepts steamos with the Developer Mode password. The app also said "Opened Remote Desktop" without checking that anything answered on port 3389. - open_rdp checks port 3389 first and explains how to turn xrdp on - On Windows, launch mstsc with a .rdp file naming user steamos (CRLF) - Every platform's message says to sign in as steamos with the Developer Mode password - The server no longer logs a page closing mid-reply (WinError 10053 on Windows) as a 500 with an error diagnostic Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/frame-control.md | 8 ++- tests/test_rdp.py | 130 ++++++++++++++++++++++++++++++++++++++++++ ui/frame_host.py | 45 ++++++++++++--- ui/server.py | 20 ++++++- 4 files changed, 192 insertions(+), 11 deletions(-) create mode 100644 tests/test_rdp.py diff --git a/docs/frame-control.md b/docs/frame-control.md index 3ec3b4c..8b9838a 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -88,8 +88,12 @@ counts them while they run. name your networks, and switch headsets. See [devices.md](devices.md). - **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on - Linux). Sleep, restart and shut down open a terminal window because SteamOS - asks for the sudo password over SSH. + Linux). Remote desktop first checks that the Frame's xrdp answers on port + 3389 (Developer Mode turns it on). On Windows it opens a connection file for + user `steamos`, because `mstsc /v:` alone offers your Windows account, which + xrdp turns away. Sign in with the Developer Mode password. Sleep, restart and + shut down open a terminal window because SteamOS asks for the sudo password + over SSH. ## How it works diff --git a/tests/test_rdp.py b/tests/test_rdp.py new file mode 100644 index 0000000..4c2f806 --- /dev/null +++ b/tests/test_rdp.py @@ -0,0 +1,130 @@ +"""Remote desktop to the Frame (frame_host.open_rdp) on each computer, with the client +launch stubbed and a real socket standing in for the Frame's xrdp. Also the server +staying quiet when the page goes away mid-reply, which on Windows is +ConnectionAbortedError (WinError 10053). + +Run: python3 -m unittest discover -s tests +""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) +import email.message +import io +import socket +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "ui")) + +import frame_host # noqa: E402 +import server # noqa: E402 + + +def platform(name): + """Patches frame_host to behave as on `name` ("mac", "windows" or "linux").""" + return mock.patch.multiple(frame_host, MAC=name == "mac", WINDOWS=name == "windows", + LINUX=name == "linux") + + +class OpenRdp(unittest.TestCase): + def setUp(self): + self.xrdp = socket.socket() + self.xrdp.bind(("127.0.0.1", 0)) + self.xrdp.listen(4) + self.addCleanup(self.xrdp.close) + port = mock.patch.object(frame_host, "RDP_PORT", self.xrdp.getsockname()[1]) + port.start() + self.addCleanup(port.stop) + self.spawned = [] + spawn = mock.patch.object(frame_host, "_spawn", self.spawned.append) + spawn.start() + self.addCleanup(spawn.stop) + cache = tempfile.TemporaryDirectory() + self.addCleanup(cache.cleanup) + self.cache = Path(cache.name) + where = mock.patch.object(frame_host, "cache_dir", lambda *p: self.cache.joinpath(*p)) + where.start() + self.addCleanup(where.stop) + + def test_windows_signs_in_as_steamos(self): + # The report: mstsc /v:HOST alone offers the Windows account, which xrdp rejects. + with platform("windows"): + message = frame_host.open_rdp("frame", "127.0.0.1") + self.assertEqual(len(self.spawned), 1) + argv = self.spawned[0] + self.assertEqual(argv[0], "mstsc.exe") + self.assertNotIn("/v:127.0.0.1", argv) + rdp = Path(argv[1]) + self.assertEqual(rdp.suffix, ".rdp") + data = rdp.read_bytes() # CRLF lines, as mstsc writes them, however this OS ends lines + self.assertNotIn(b"\r\r", data) + lines = data.decode("utf-8").split("\r\n") + self.assertIn("full address:s:127.0.0.1", lines) + self.assertIn("username:s:steamos", lines) + self.assertIn("steamos", message) + self.assertIn("Developer Mode password", message) + + def test_nothing_listening_says_why_and_opens_nothing(self): + self.xrdp.close() + for name in ("windows", "mac", "linux"): + with self.subTest(name), platform(name), self.assertRaises(frame_host.HostError) as cm: + frame_host.open_rdp("frame", "127.0.0.1") + self.assertIn("Developer Mode", str(cm.exception)) + self.assertIn(f"port {frame_host.RDP_PORT}", str(cm.exception)) + self.assertEqual(self.spawned, []) + + def test_address_cant_add_lines_to_the_file(self): + with platform("windows"), self.assertRaises(frame_host.HostError): + frame_host.rdp_file("frame\r\nusername:s:root") + self.assertEqual(list(self.cache.iterdir()), []) + + def test_linux_clients_get_the_user(self): + with platform("linux"), mock.patch.object(frame_host, "which", + lambda n, *e: "/usr/bin/xfreerdp" if n == "xfreerdp" else None): + message = frame_host.open_rdp("frame", "127.0.0.1") + self.assertEqual(self.spawned, [["xfreerdp", "/v:127.0.0.1", "/u:steamos", "/dynamic-resolution"]]) + self.assertIn("steamos", message) + + +class PageGoneAway(unittest.TestCase): + """The report's server log: the page closed while index.html was being sent, and the + server logged it as a 500, tried to answer anyway, and filed an error diagnostic.""" + + def handler(self, path="/"): + h = server.Handler.__new__(server.Handler) + h.command, h.path, h.request_version = "GET", path, "HTTP/1.1" + h.requestline, h.client_address = f"GET {path} HTTP/1.1", ("127.0.0.1", 1) + h.headers = email.message.Message() + h.headers["Host"] = "127.0.0.1:1" + h.wfile = mock.Mock(write=mock.Mock(side_effect=ConnectionAbortedError(10053, "aborted"))) + h.close_connection = True + return h + + def test_not_a_server_error(self): + h = self.handler() + with mock.patch.object(server.frame_telemetry, "diagnostic") as diagnostic, \ + mock.patch.object(sys, "stderr", io.StringIO()), self.assertRaises(server.ClientGone): + h.do_GET() + diagnostic.assert_not_called() + self.assertEqual(h.wfile.write.call_count, 1) # no second, 500 reply + + def test_server_logs_nothing(self): + srv = server.LoopbackServer.__new__(server.LoopbackServer) + err = io.StringIO() + with mock.patch.object(sys, "stderr", err): + try: + raise server.ClientGone() + except server.ClientGone: + srv.handle_error(None, ("127.0.0.1", 1)) + self.assertEqual(err.getvalue(), "") + try: + raise RuntimeError("real") + except RuntimeError: + srv.handle_error(None, ("127.0.0.1", 1)) + self.assertIn("RuntimeError: real", err.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/frame_host.py b/ui/frame_host.py index e5c1914..202d600 100644 --- a/ui/frame_host.py +++ b/ui/frame_host.py @@ -8,6 +8,7 @@ CLI (used by the Electron app, so terminal handling lives in one place): import os import shlex import shutil +import socket import ssl import subprocess import sys @@ -264,24 +265,54 @@ def open_steam_link(): return "Steam Link isn't installed; opened its download page" +RDP_PORT = 3389 +RDP_USER = "steamos" # xrdp signs in with the Developer Mode password, not this computer's +RDP_LOGIN = f"sign in as {RDP_USER} with your Developer Mode password" + + +def rdp_reachable(host, timeout=3): + """Whether anything answers on the Frame's RDP port.""" + try: + with socket.create_connection((host, RDP_PORT), timeout=timeout): + return True + except OSError: + return False + + +def rdp_file(host): + """A Remote Desktop connection file for the Frame. mstsc /v: alone offers this + computer's Windows account, which xrdp turns away; the file names steamos instead.""" + if any(c in host for c in "\r\n"): + raise HostError("That headset address can't be used for remote desktop") + path = cache_dir("frame.rdp") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"full address:s:{host}\nusername:s:{RDP_USER}\n", encoding="utf-8", newline="\r\n") + return path + + def open_rdp(alias, host=None): """Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points.""" host = host or ssh_hostname(alias) + # The client would open either way and then fail on its own, with nothing said here. + if not rdp_reachable(host): + raise HostError(f"The Frame isn't accepting remote desktop at {host} (nothing answered on port " + f"{RDP_PORT}). Turn on Developer Mode in Steam Settings → System on the headset, " + "then restart it and try again.") if MAC: if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0: - return "Opened Windows App" + return f"Opened Windows App: connect to {host} and {RDP_LOGIN}" open_url("https://apps.apple.com/app/windows-app/id1295203466") return "Windows App isn't installed; opened its App Store page" if WINDOWS: - _spawn(["mstsc.exe", f"/v:{host}"]) - return f"Opened Remote Desktop to {host}" + _spawn(["mstsc.exe", str(rdp_file(host))]) + return f"Opened Remote Desktop to {host}: {RDP_LOGIN}" if which("remmina"): - _spawn(["remmina", "-c", f"rdp://steamos@{host}"]) - return f"Opened Remmina to {host}" + _spawn(["remmina", "-c", f"rdp://{RDP_USER}@{host}"]) + return f"Opened Remmina to {host}: {RDP_LOGIN}" for name in ("xfreerdp3", "xfreerdp"): if which(name): - _spawn([name, f"/v:{host}", "/u:steamos", "/dynamic-resolution"]) - return f"Opened FreeRDP to {host}" + _spawn([name, f"/v:{host}", f"/u:{RDP_USER}", "/dynamic-resolution"]) + return f"Opened FreeRDP to {host}: {RDP_LOGIN}" raise HostError("No RDP client found: install Remmina or FreeRDP") diff --git a/ui/server.py b/ui/server.py index 7771b72..52bc1e5 100755 --- a/ui/server.py +++ b/ui/server.py @@ -2254,6 +2254,11 @@ def push_file(path, dest="Downloads/"): return f"Sent {name} to ~/{dest}" +class ClientGone(Exception): + """The page went away (a reload, the app quitting) before its reply was written: + nobody to answer, and nothing went wrong here.""" + + class Handler(BaseHTTPRequestHandler): server_version = "FrameControl/1" timeout = 60 # per socket operation, so a stalled client can't hold a thread @@ -2286,8 +2291,11 @@ class Handler(BaseHTTPRequestHandler): # Nobody may frame the UI (clickjacking). self.send_header("X-Frame-Options", "DENY") self.send_header("Content-Security-Policy", "frame-ancestors 'none'") - self.end_headers() - self.wfile.write(data) + try: + self.end_headers() + self.wfile.write(data) + except ConnectionError as e: # Windows says ConnectionAbortedError, others BrokenPipeError + raise ClientGone() from e def send_json(self, obj, status=200): self.send_bytes(json.dumps(obj).encode(), "application/json", status) @@ -2405,6 +2413,8 @@ class Handler(BaseHTTPRequestHandler): headers=[("X-Capture-Source", "gamescope")]) else: self.send_json({"error": "not found"}, 404) + except ClientGone: + raise except Failure as e: self.send_error_json(str(e), e.status, e.apk) except ValueError as e: @@ -2439,6 +2449,8 @@ class Handler(BaseHTTPRequestHandler): with (contextlib.nullcontext() if path == "/api/devices" else working(meant)): result = handler(body) self.send_json(result) + except ClientGone: + raise except Failure as e: if e.status >= 500: frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e) @@ -2614,6 +2626,10 @@ class LoopbackServer(ThreadingHTTPServer): socketserver.TCPServer.server_bind(self) self.server_name, self.server_port = "127.0.0.1", self.server_address[1] + def handle_error(self, request, client_address): + if not isinstance(sys.exc_info()[1], ClientGone): + super().handle_error(request, client_address) + _ONE_SERVER = None