mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 08:00:32 +02:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e63dc43c2f | ||
|
|
80f433a2d3 | ||
|
|
07f44f9082 | ||
|
|
c305daae15 | ||
|
|
a4031db052 | ||
|
|
28073e212a | ||
|
|
63c1a9ff55 | ||
|
|
e8db571a2c | ||
|
|
d9cd40c035 | ||
|
|
865e8dc17f | ||
|
|
34a334fa27 | ||
|
|
3f273ca37a | ||
|
|
72ffec45c2 | ||
|
|
7308ac09b1 |
No files matched your search
@@ -88,8 +88,13 @@ counts them while they run.
|
|||||||
name your networks, and switch headsets. See [devices.md](devices.md).
|
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
|
- **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
|
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
|
Linux). Remote desktop first checks that the Frame's xrdp answers on port
|
||||||
asks for the sudo password over SSH.
|
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. Accept the warning about the Frame's own certificate, then
|
||||||
|
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
|
## How it works
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,20 @@ The confidence labels are the same as in [ssh.md](ssh.md).
|
|||||||
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
|
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
|
||||||
Mac with keyboard, mouse, and clipboard.
|
Mac with keyboard, mouse, and clipboard.
|
||||||
|
|
||||||
|
**Verified 2026-09-30** (Frame BUILD_ID 20260925.6191901, Windows 11 25H2,
|
||||||
|
Remote Desktop Connection): signing in to xrdp as `steamos` with the Developer
|
||||||
|
Mode password opens a Plasma (X11) desktop within about 6 seconds.
|
||||||
|
|
||||||
|
- xrdp has no NLA, so the client shows a certificate warning (xrdp's own
|
||||||
|
`www.xrdp.org` certificate) and then xrdp's own login box. Frame Control
|
||||||
|
fills in `steamos` there on Windows, Remmina and FreeRDP.
|
||||||
|
- The desktop is a separate login session (Xorg on display `:10`), not the
|
||||||
|
headset's view. It uses about 1.3 GB of the Frame's memory.
|
||||||
|
- Closing the client leaves the session running, and the next login
|
||||||
|
reconnects to it. To end it over SSH, find it with `loginctl list-sessions`
|
||||||
|
and run `loginctl terminate-session <id>`. That doesn't touch the headset's
|
||||||
|
gamescope or SteamVR session.
|
||||||
|
|
||||||
## B. Show the Mac's desktop inside the Frame
|
## B. Show the Mac's desktop inside the Frame
|
||||||
|
|
||||||
The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had
|
The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parent.parent
|
|||||||
sys.path.insert(0, str(ROOT / "ui"))
|
sys.path.insert(0, str(ROOT / "ui"))
|
||||||
|
|
||||||
import frame_devices as fd # noqa: E402
|
import frame_devices as fd # noqa: E402
|
||||||
|
import frame_host # noqa: E402
|
||||||
|
|
||||||
CONFIG = """Host lxso1
|
CONFIG = """Host lxso1
|
||||||
HostName 192.168.1.109
|
HostName 192.168.1.109
|
||||||
@@ -274,7 +275,8 @@ class Pins(Base):
|
|||||||
def test_hashed_and_non_default_port_entries(self):
|
def test_hashed_and_non_default_port_entries(self):
|
||||||
kh = self.ssh / "known_hosts"
|
kh = self.ssh / "known_hosts"
|
||||||
kh.write_text(f"[frame.local]:2222 {KEY}\n")
|
kh.write_text(f"[frame.local]:2222 {KEY}\n")
|
||||||
subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True)
|
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True,
|
||||||
|
stdin=subprocess.DEVNULL, check=True, timeout=10)
|
||||||
self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
|
self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
|
||||||
self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
|
self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
|
||||||
self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text())
|
self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text())
|
||||||
@@ -283,7 +285,8 @@ class Pins(Base):
|
|||||||
target = fd.known_hosts("d4")
|
target = fd.known_hosts("d4")
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
target.write_text(f"frame-control-d4 {KEY}\n")
|
target.write_text(f"frame-control-d4 {KEY}\n")
|
||||||
subprocess.run(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, check=True)
|
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(target)], capture_output=True,
|
||||||
|
stdin=subprocess.DEVNULL, check=True, timeout=10)
|
||||||
self.assertNotIn("frame-control-d4", target.read_text())
|
self.assertNotIn("frame-control-d4", target.read_text())
|
||||||
self.assertTrue(fd.pinned("d4"))
|
self.assertTrue(fd.pinned("d4"))
|
||||||
self.assertTrue(fd.forget_pin("d4"))
|
self.assertTrue(fd.forget_pin("d4"))
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class ObbTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(android.FrameError, 'start this app'):
|
with self.assertRaisesRegex(android.FrameError, 'start this app'):
|
||||||
data.install_obb(PKG, [path])
|
data.install_obb(PKG, [path])
|
||||||
stream.assert_not_called()
|
stream.assert_not_called()
|
||||||
with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
|
with patch.object(data.frame_host, 'run_ssh', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
|
||||||
with self.assertRaisesRegex(android.FrameError, 'bad hash'):
|
with self.assertRaisesRegex(android.FrameError, 'bad hash'):
|
||||||
data._stream('command')
|
data._stream('command')
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Captured OpenSSH output keeps working on Windows and POSIX hosts."""
|
||||||
|
import sandbox # noqa: F401
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ui"))
|
||||||
|
import frame_host
|
||||||
|
|
||||||
|
|
||||||
|
class CapturedSSH(unittest.TestCase):
|
||||||
|
def run_command(self, source, **kwargs):
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", True):
|
||||||
|
return frame_host.run_ssh([sys.executable, "-c", source], timeout=5, **kwargs)
|
||||||
|
|
||||||
|
def test_binary_output_and_input(self):
|
||||||
|
result = self.run_command("import sys; sys.stdout.buffer.write(sys.stdin.buffer.read()); "
|
||||||
|
"sys.stderr.buffer.write(b'error\\r\\n')",
|
||||||
|
capture_output=True, input=b"data\x00\xff")
|
||||||
|
self.assertEqual(result.stdout, b"data\x00\xff")
|
||||||
|
self.assertEqual(result.stderr, b"error\r\n")
|
||||||
|
|
||||||
|
def test_text_output_normalizes_newlines(self):
|
||||||
|
result = self.run_command("import sys; sys.stdout.write(sys.stdin.read()); "
|
||||||
|
"sys.stderr.buffer.write(b'first\\r\\nsecond\\rthird\\n')",
|
||||||
|
capture_output=True, input="hello\n", text=True)
|
||||||
|
self.assertEqual(result.stdout, "hello\n")
|
||||||
|
self.assertEqual(result.stderr, "first\nsecond\nthird\n")
|
||||||
|
|
||||||
|
def test_explicit_encoding_and_errors(self):
|
||||||
|
result = self.run_command("import sys; sys.stderr.buffer.write(b'\\xe9\\xff')",
|
||||||
|
capture_output=True, encoding="ascii", errors="replace")
|
||||||
|
self.assertEqual(result.stderr, "\ufffd\ufffd")
|
||||||
|
|
||||||
|
def test_check_preserves_error_output(self):
|
||||||
|
with self.assertRaises(subprocess.CalledProcessError) as caught:
|
||||||
|
self.run_command("import sys; print('out'); print('err', file=sys.stderr); sys.exit(7)",
|
||||||
|
capture_output=True, text=True, check=True)
|
||||||
|
self.assertEqual(caught.exception.returncode, 7)
|
||||||
|
self.assertEqual(caught.exception.stdout, "out\n")
|
||||||
|
self.assertEqual(caught.exception.stderr, "err\n")
|
||||||
|
|
||||||
|
def test_timeout_preserves_partial_stderr(self):
|
||||||
|
with self.assertRaises(subprocess.TimeoutExpired) as caught:
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", True):
|
||||||
|
frame_host.run_ssh([sys.executable, "-c", "import sys, time; "
|
||||||
|
"sys.stderr.write('waiting'); sys.stderr.flush(); time.sleep(10)"],
|
||||||
|
capture_output=True, text=True, timeout=1)
|
||||||
|
self.assertEqual(caught.exception.stderr, b"waiting")
|
||||||
|
|
||||||
|
def test_streamed_stdout_is_kept_separate(self):
|
||||||
|
with tempfile.TemporaryFile() as output:
|
||||||
|
result = self.run_command("import sys; sys.stdout.buffer.write(b'file'); "
|
||||||
|
"sys.stderr.buffer.write(b'error')",
|
||||||
|
stdout=output, stderr=subprocess.PIPE)
|
||||||
|
output.seek(0)
|
||||||
|
self.assertEqual(output.read(), b"file")
|
||||||
|
self.assertIsNone(result.stdout)
|
||||||
|
self.assertEqual(result.stderr, b"error")
|
||||||
|
|
||||||
|
def test_uncaptured_windows_call_is_unchanged(self):
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(subprocess, "run") as run:
|
||||||
|
frame_host.run_ssh(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
|
||||||
|
run.assert_called_once_with(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
|
||||||
|
|
||||||
|
def test_posix_call_is_unchanged(self):
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", False), mock.patch.object(subprocess, "run") as run:
|
||||||
|
frame_host.run_ssh(["ssh", "-V"], capture_output=True, check=True, timeout=5)
|
||||||
|
run.assert_called_once_with(["ssh", "-V"], capture_output=True, check=True, timeout=5)
|
||||||
|
|
||||||
|
def test_capture_rejects_explicit_streams(self):
|
||||||
|
for stream in ("stdout", "stderr"):
|
||||||
|
with self.subTest(stream=stream), self.assertRaises(ValueError):
|
||||||
|
self.run_command("", capture_output=True, **{stream: subprocess.DEVNULL})
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("ssh"), "needs OpenSSH")
|
||||||
|
def test_real_ssh_failure_returns_stderr_without_hanging(self):
|
||||||
|
result = frame_host.run_ssh(["ssh", "-F", os.devnull, "-o", "BatchMode=yes",
|
||||||
|
"-o", "ConnectTimeout=2", "frame-control-test.invalid", "true"],
|
||||||
|
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5)
|
||||||
|
self.assertEqual(result.returncode, 255)
|
||||||
|
self.assertIn("Could not resolve hostname", result.stderr)
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""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)
|
||||||
|
self.assertIn("certificate", message)
|
||||||
|
self.assertIn("Connect", 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.Unreachable) 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} refused", str(cm.exception))
|
||||||
|
self.assertEqual(self.spawned, [])
|
||||||
|
|
||||||
|
def test_says_which_way_it_failed(self):
|
||||||
|
# Only a refused port says xrdp is off; a wrong address or a silent network say so instead.
|
||||||
|
for error, says in ((socket.gaierror(8, "nodename nor servname provided"), "Devices tab"),
|
||||||
|
(socket.timeout("timed out"), "didn't answer"),
|
||||||
|
(OSError(65, "No route to host"), "didn't answer")):
|
||||||
|
with self.subTest(says), mock.patch.object(frame_host.socket, "create_connection", side_effect=error), \
|
||||||
|
platform("windows"), self.assertRaises(frame_host.Unreachable) as cm:
|
||||||
|
frame_host.open_rdp("frame", "frame.local")
|
||||||
|
self.assertIn(says, str(cm.exception))
|
||||||
|
self.assertNotIn("refused", str(cm.exception))
|
||||||
|
self.assertEqual(self.spawned, [])
|
||||||
|
|
||||||
|
def test_server_says_it_as_the_persons_to_fix(self):
|
||||||
|
# A 400 with the message, not a 500 filed as an error diagnostic.
|
||||||
|
self.xrdp.close()
|
||||||
|
with mock.patch.multiple(server, LOCAL=False, LINK=None, HOST_OPTS=["-o", "HostName=127.0.0.1"]), \
|
||||||
|
self.assertRaises(server.Failure) as cm:
|
||||||
|
server.open_thing({"what": "rdp"})
|
||||||
|
self.assertEqual(cm.exception.status, 400)
|
||||||
|
self.assertIn("Developer Mode", str(cm.exception))
|
||||||
|
|
||||||
|
def test_one_file_per_address(self):
|
||||||
|
with platform("windows"):
|
||||||
|
a, b = frame_host.rdp_file("192.168.1.5"), frame_host.rdp_file("fe80::1%eth0")
|
||||||
|
c, d = frame_host.rdp_file("fe80::1%2"), frame_host.rdp_file("fe80::1:2")
|
||||||
|
self.assertEqual(len({a, b, c, d}), 4)
|
||||||
|
self.assertIn(b"full address:s:192.168.1.5\r\n", a.read_bytes())
|
||||||
|
self.assertIn(b"full address:s:fe80::1%eth0\r\n", b.read_bytes())
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Windows-only paths, faked on any OS: ~/.ssh/config's ACL and link-local IPv6 zones.
|
||||||
|
|
||||||
|
Run: python3 -m unittest discover -s tests
|
||||||
|
"""
|
||||||
|
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
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 frame_devices as fd # noqa: E402
|
||||||
|
|
||||||
|
REFUSED = ("Bad permissions. Try removing permissions for user: UNKNOWN\\UNKNOWN (S-1-5-21-1-2-3-1000) "
|
||||||
|
"on file C:/Users/bob/.ssh/config.\r\nBad owner or permissions on C:\\Users\\bob/.ssh/config\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
def ran(*results):
|
||||||
|
"""subprocess.run stand-in answering whoami, then icacls."""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def run(argv, **kw):
|
||||||
|
calls.append(argv)
|
||||||
|
return results[len(calls) - 1]
|
||||||
|
return run, calls
|
||||||
|
|
||||||
|
|
||||||
|
class MakePrivate(unittest.TestCase):
|
||||||
|
def test_windows_sets_owner_only_acl_by_sid(self):
|
||||||
|
run, calls = ran(subprocess.CompletedProcess([], 0, '"desktop\\björn","S-1-5-21-9-8-7-1001"\r\n'.encode("cp850")),
|
||||||
|
subprocess.CompletedProcess([], 0))
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run):
|
||||||
|
self.assertTrue(frame_host.make_private(Path("C:/x/config")))
|
||||||
|
self.assertEqual(calls[1][1:], [str(Path("C:/x/config")), "/inheritance:r", "/grant:r",
|
||||||
|
"*S-1-5-21-9-8-7-1001:F", "*S-1-5-18:F", "*S-1-5-32-544:F"])
|
||||||
|
|
||||||
|
def test_windows_falls_back_to_username_and_reports_failure(self):
|
||||||
|
run, calls = ran(subprocess.CompletedProcess([], 1, b""), subprocess.CompletedProcess([], 5))
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run), \
|
||||||
|
mock.patch.dict(os.environ, {"USERNAME": "bob"}):
|
||||||
|
self.assertFalse(frame_host.make_private(Path("config")))
|
||||||
|
self.assertIn("bob:F", calls[1])
|
||||||
|
|
||||||
|
@unittest.skipIf(os.name == "nt", "POSIX modes")
|
||||||
|
def test_posix_chmods_600(self):
|
||||||
|
with tempfile.NamedTemporaryFile() as f:
|
||||||
|
os.chmod(f.name, 0o644)
|
||||||
|
self.assertTrue(frame_host.make_private(f.name))
|
||||||
|
self.assertEqual(os.stat(f.name).st_mode & 0o777, 0o600)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigWrites(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
|
||||||
|
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
|
||||||
|
self.config = self.ssh / "config"
|
||||||
|
|
||||||
|
def test_devices_and_connect_writes_make_the_file_private(self):
|
||||||
|
import frame_connect as fc
|
||||||
|
self.config.write_text("Host other\n User me\n", encoding="utf-8")
|
||||||
|
with mock.patch.object(frame_host, "make_private", return_value=True) as private, \
|
||||||
|
mock.patch.object(fc, "SSH_DIR", self.ssh), mock.patch.object(fc, "CONFIG", self.config):
|
||||||
|
fc.write_config("10.0.0.5")
|
||||||
|
self.assertTrue(fd.repair_permissions(self.config))
|
||||||
|
fd.rewrite_block("frame", path=self.config, user="deck")
|
||||||
|
self.assertEqual(private.call_count, 3)
|
||||||
|
self.assertIn("User deck", self.config.read_text(encoding="utf-8"))
|
||||||
|
self.assertTrue(all(Path(c.args[0]).parent == self.ssh for c in private.call_args_list))
|
||||||
|
self.assertIn("Host other", self.config.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def test_setup_runs_isolated_as_the_app_starts_it(self):
|
||||||
|
r = subprocess.run([sys.executable, "-I", "-B", str(ROOT / "ui" / "frame_connect.py"), "--help"],
|
||||||
|
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=30)
|
||||||
|
self.assertNotIn("ModuleNotFoundError", r.stderr)
|
||||||
|
self.assertIn("frame_connect.py", r.stdout + r.stderr)
|
||||||
|
|
||||||
|
def test_repair_keeps_the_bytes_and_skips_a_missing_file(self):
|
||||||
|
self.assertFalse(fd.repair_permissions(self.config))
|
||||||
|
data = "# caf\xe9 (ANSI, not UTF-8)\r\nHost a\r\n".encode("cp1252")
|
||||||
|
self.config.write_bytes(data)
|
||||||
|
with mock.patch.object(frame_host, "make_private", return_value=True):
|
||||||
|
self.assertTrue(fd.repair_permissions(self.config))
|
||||||
|
self.assertEqual(self.config.read_bytes(), data)
|
||||||
|
|
||||||
|
def test_repair_fails_without_the_acl_and_leaves_the_file(self):
|
||||||
|
self.config.write_bytes(b"Host a\n")
|
||||||
|
before = self.config.stat().st_ino
|
||||||
|
with mock.patch.object(frame_host, "make_private", return_value=False):
|
||||||
|
self.assertFalse(fd.repair_permissions(self.config))
|
||||||
|
self.assertEqual((self.config.read_bytes(), self.config.stat().st_ino), (b"Host a\n", before))
|
||||||
|
self.assertEqual(sorted(f.name for f in self.ssh.iterdir()), ["config", fd.LOCK_NAME])
|
||||||
|
|
||||||
|
|
||||||
|
class ServerRepair(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
import server
|
||||||
|
cls.server = server
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
|
||||||
|
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
|
||||||
|
(self.ssh / "config").write_text("Host a\n", encoding="utf-8")
|
||||||
|
patches = [mock.patch.dict(os.environ, {"FRAME_CONTROL_SSH_DIR": str(self.ssh)}),
|
||||||
|
mock.patch.object(frame_host, "WINDOWS", True),
|
||||||
|
mock.patch.object(self.server, "_config_repaired", False)]
|
||||||
|
for p in patches:
|
||||||
|
p.start()
|
||||||
|
self.addCleanup(p.stop)
|
||||||
|
|
||||||
|
def test_repairs_the_refused_config_once(self):
|
||||||
|
with mock.patch.object(fd, "repair_permissions", return_value=True) as repair:
|
||||||
|
self.assertTrue(self.server.repair_ssh_config(REFUSED))
|
||||||
|
self.assertFalse(self.server.repair_ssh_config(REFUSED))
|
||||||
|
repair.assert_called_once()
|
||||||
|
|
||||||
|
def test_leaves_other_files_and_errors_alone(self):
|
||||||
|
key = REFUSED.replace(".ssh/config", ".ssh/id_ed25519_frame")
|
||||||
|
with mock.patch.object(fd, "repair_permissions") as repair:
|
||||||
|
self.assertFalse(self.server.repair_ssh_config(key))
|
||||||
|
self.assertFalse(self.server.repair_ssh_config("ssh: connect to host frame port 22: timed out"))
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", False):
|
||||||
|
self.assertFalse(self.server.repair_ssh_config(REFUSED))
|
||||||
|
repair.assert_not_called()
|
||||||
|
|
||||||
|
def test_ssh_retries_after_repairing(self):
|
||||||
|
results = iter([subprocess.CompletedProcess([], 255, "", REFUSED), subprocess.CompletedProcess([], 0, "ok", "")])
|
||||||
|
with mock.patch.object(frame_host, "run_ssh", lambda *a, **k: next(results)), \
|
||||||
|
mock.patch.object(fd, "repair_permissions", return_value=True), \
|
||||||
|
mock.patch.object(self.server, "LINK", None):
|
||||||
|
self.assertEqual(self.server.ssh("true"), "ok")
|
||||||
|
|
||||||
|
|
||||||
|
class LinkLocalZone(unittest.TestCase):
|
||||||
|
"""A .local name answering on fe80::: Windows' ssh needs fe80::1%12, not %wireless_32768."""
|
||||||
|
|
||||||
|
def probe(self, windows):
|
||||||
|
import frame_link as fl
|
||||||
|
info = [(fl.socket.AF_INET6, fl.socket.SOCK_STREAM, 6, "", ("fe80::1", 22, 0, 12))]
|
||||||
|
sock = mock.MagicMock()
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", windows), \
|
||||||
|
mock.patch.object(fl.socket, "getaddrinfo", return_value=info), \
|
||||||
|
mock.patch.object(fl.socket, "socket", return_value=sock), \
|
||||||
|
mock.patch.object(fl.socket, "if_indextoname", return_value="wireless_32768", create=True):
|
||||||
|
return fl.probe("frame.local", 22)["ip"]
|
||||||
|
|
||||||
|
def test_windows_uses_the_numeric_zone(self):
|
||||||
|
self.assertEqual(self.probe(True), "fe80::1%12")
|
||||||
|
|
||||||
|
def test_elsewhere_uses_the_interface_name(self):
|
||||||
|
self.assertEqual(self.probe(False), "fe80::1%wireless_32768")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+3
-3
@@ -47,8 +47,8 @@ def ssh(cmd, input=None, timeout=120):
|
|||||||
try:
|
try:
|
||||||
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
|
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
|
||||||
feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL}
|
feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL}
|
||||||
p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
|
p = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
|
||||||
timeout=timeout, text=isinstance(input, str) or input is None)
|
timeout=timeout, text=isinstance(input, str) or input is None)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise FrameError(f'timed out talking to {FRAME}')
|
raise FrameError(f'timed out talking to {FRAME}')
|
||||||
if p.returncode != 0:
|
if p.returncode != 0:
|
||||||
@@ -120,7 +120,7 @@ def _copy(src, dest, executable=False, timeout=600):
|
|||||||
else:
|
else:
|
||||||
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
|
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
|
frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise FrameError(f'copying {name} to the Frame timed out')
|
raise FrameError(f'copying {name} to the Frame timed out')
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
|
|||||||
@@ -11,16 +11,17 @@ import tempfile
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import frame_android as android
|
import frame_android as android
|
||||||
|
import frame_host
|
||||||
|
|
||||||
REMOTE = Path(android.ROOT) / 'frame/android/app-data.py'
|
REMOTE = Path(android.ROOT) / 'frame/android/app-data.py'
|
||||||
|
|
||||||
|
|
||||||
def _stream(command, src=None, dst=None):
|
def _stream(command, src=None, dst=None):
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(['ssh', *android.SSH_OPTS, android.FRAME, command],
|
result = frame_host.run_ssh(['ssh', *android.SSH_OPTS, android.FRAME, command],
|
||||||
stdin=src if src else subprocess.DEVNULL,
|
stdin=src if src else subprocess.DEVNULL,
|
||||||
stdout=dst if dst else subprocess.PIPE,
|
stdout=dst if dst else subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE, timeout=1800)
|
stderr=subprocess.PIPE, timeout=1800)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise android.FrameError('app-data transfer timed out')
|
raise android.FrameError('app-data transfer timed out')
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
|
|||||||
+10
-5
@@ -24,6 +24,10 @@ import urllib.error
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# The app runs this with python -I, which leaves the script's folder off sys.path.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import frame_host # noqa: E402
|
||||||
|
|
||||||
FRAME_USER = os.environ.get("FRAME_USER", "steamos")
|
FRAME_USER = os.environ.get("FRAME_USER", "steamos")
|
||||||
USER_FROM_ENV = "FRAME_USER" in os.environ
|
USER_FROM_ENV = "FRAME_USER" in os.environ
|
||||||
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
|
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
|
||||||
@@ -330,8 +334,9 @@ def _write_config(host, port, user):
|
|||||||
block = config_block(host, port, user)
|
block = config_block(host, port, user)
|
||||||
tmp = CONFIG.with_name(f"config.frame-control.{os.getpid()}.tmp")
|
tmp = CONFIG.with_name(f"config.frame-control.{os.getpid()}.tmp")
|
||||||
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
|
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
|
||||||
if os.name != "nt":
|
if not frame_host.make_private(tmp):
|
||||||
tmp.chmod(0o600)
|
say(" couldn't make ~/.ssh/config private; if ssh says \"Bad owner or permissions\", "
|
||||||
|
"Frame Control repairs it when it next connects")
|
||||||
# On Windows a running ssh.exe (Frame Control's own, say) keeps the config open
|
# On Windows a running ssh.exe (Frame Control's own, say) keeps the config open
|
||||||
# and locked, so the swap can fail for a moment; keep trying for a while.
|
# and locked, so the swap can fail for a moment; keep trying for a while.
|
||||||
for attempt in range(60):
|
for attempt in range(60):
|
||||||
@@ -349,9 +354,9 @@ def _write_config(host, port, user):
|
|||||||
|
|
||||||
def key_login_works():
|
def key_login_works():
|
||||||
# accept-new: trust a first-seen host key (as the copy step does); a changed one still fails.
|
# accept-new: trust a first-seen host key (as the copy step does); a changed one still fails.
|
||||||
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
return frame_host.run_ssh(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
||||||
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
|
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
|
||||||
capture_output=True).returncode == 0
|
capture_output=True).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
def configured_user():
|
def configured_user():
|
||||||
|
|||||||
+36
-6
@@ -241,8 +241,7 @@ def _write_config(path, text, expected):
|
|||||||
try:
|
try:
|
||||||
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
|
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
|
||||||
fh.write(text)
|
fh.write(text)
|
||||||
if not frame_host.WINDOWS:
|
frame_host.make_private(tmp) # best effort: an edit still beats none (repair_permissions insists)
|
||||||
tmp.chmod(0o600)
|
|
||||||
for attempt in range(20): # Windows: a running ssh.exe can hold the file for a moment
|
for attempt in range(20): # Windows: a running ssh.exe can hold the file for a moment
|
||||||
if read_config(path) != expected:
|
if read_config(path) != expected:
|
||||||
return False
|
return False
|
||||||
@@ -271,6 +270,37 @@ def _edit_config(path, change):
|
|||||||
raise OSError(f"{path} kept changing while Frame Control tried to update it")
|
raise OSError(f"{path} kept changing while Frame Control tried to update it")
|
||||||
|
|
||||||
|
|
||||||
|
def repair_permissions(path=None):
|
||||||
|
"""Give ~/.ssh/config make_private's ACL by swapping in a byte-for-byte copy: for a
|
||||||
|
file Windows' OpenSSH refuses ("Bad owner or permissions"). -> True only if the copy
|
||||||
|
got that ACL and replaced the file."""
|
||||||
|
path = Path(path or ssh_config())
|
||||||
|
with _config_lock, file_lock(path.with_name(LOCK_NAME)):
|
||||||
|
try:
|
||||||
|
data = path.read_bytes()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
fd_, tmp = tempfile.mkstemp(prefix="config.frame-control.", dir=str(path.parent))
|
||||||
|
tmp = Path(tmp)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd_, "wb") as fh:
|
||||||
|
fh.write(data)
|
||||||
|
if not frame_host.make_private(tmp):
|
||||||
|
return False
|
||||||
|
for attempt in range(20): # a running ssh.exe can hold the file for a moment
|
||||||
|
if path.read_bytes() != data:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
os.replace(tmp, path)
|
||||||
|
return True
|
||||||
|
except PermissionError:
|
||||||
|
time.sleep(0.25)
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
if tmp.exists():
|
||||||
|
tmp.unlink()
|
||||||
|
|
||||||
|
|
||||||
def rewrite_block(alias, path=None, hostname=None, user=None, port=None, expect=None):
|
def rewrite_block(alias, path=None, hostname=None, user=None, port=None, expect=None):
|
||||||
"""Change HostName, User or Port inside ALIAS's managed block, leaving the rest of the
|
"""Change HostName, User or Port inside ALIAS's managed block, leaving the rest of the
|
||||||
file alone. -> True if the file changed. Does nothing if there's no such block, or
|
file alone. -> True if the file changed. Does nothing if there's no such block, or
|
||||||
@@ -333,8 +363,8 @@ def remove_block(alias, path=None):
|
|||||||
def effective_port(alias, config):
|
def effective_port(alias, config):
|
||||||
"""The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22."""
|
"""The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22."""
|
||||||
try:
|
try:
|
||||||
out = subprocess.run(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
|
out = frame_host.run_ssh(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
|
||||||
stdin=subprocess.DEVNULL, timeout=10).stdout
|
stdin=subprocess.DEVNULL, timeout=10).stdout
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return 22
|
return 22
|
||||||
m = re.search(r"^port (\d+)$", out, re.M)
|
m = re.search(r"^port (\d+)$", out, re.M)
|
||||||
@@ -346,8 +376,8 @@ def effective_port(alias, config):
|
|||||||
|
|
||||||
def _keygen(*args):
|
def _keygen(*args):
|
||||||
try:
|
try:
|
||||||
return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
return frame_host.run_ssh(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||||
timeout=10)
|
timeout=10)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
+128
-8
@@ -5,12 +5,16 @@ Everything here runs on your computer, not the Frame. Python stdlib only.
|
|||||||
CLI (used by the Electron app, so terminal handling lives in one place):
|
CLI (used by the Electron app, so terminal handling lives in one place):
|
||||||
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
|
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
MAC = sys.platform == "darwin"
|
MAC = sys.platform == "darwin"
|
||||||
@@ -32,6 +36,45 @@ class HostError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Unreachable(HostError):
|
||||||
|
"""The Frame, or a service on it, didn't answer: the person's to sort out, not a fault here."""
|
||||||
|
|
||||||
|
|
||||||
|
def run_ssh(argv, **kwargs):
|
||||||
|
"""Run an OpenSSH tool without Windows' redirected-stderr pipe hang.
|
||||||
|
|
||||||
|
A real temporary file avoids OpenSSH's blocked asynchronous stderr writes,
|
||||||
|
while keeping subprocess.run's captured output, text, check and timeout API.
|
||||||
|
"""
|
||||||
|
if not WINDOWS:
|
||||||
|
return subprocess.run(argv, **kwargs)
|
||||||
|
if kwargs.pop("capture_output", False):
|
||||||
|
if kwargs.get("stdout") is not None or kwargs.get("stderr") is not None:
|
||||||
|
raise ValueError("stdout and stderr arguments may not be used with capture_output")
|
||||||
|
kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
if kwargs.get("stderr") != subprocess.PIPE:
|
||||||
|
return subprocess.run(argv, **kwargs)
|
||||||
|
check = kwargs.pop("check", False)
|
||||||
|
text = any(kwargs.get(key) for key in ("text", "universal_newlines", "encoding", "errors"))
|
||||||
|
with tempfile.TemporaryFile() as stderr:
|
||||||
|
kwargs["stderr"] = stderr
|
||||||
|
try:
|
||||||
|
result = subprocess.run(argv, **kwargs)
|
||||||
|
except subprocess.TimeoutExpired as error:
|
||||||
|
stderr.seek(0)
|
||||||
|
error.stderr = stderr.read()
|
||||||
|
raise
|
||||||
|
stderr.seek(0)
|
||||||
|
if text:
|
||||||
|
with io.TextIOWrapper(stderr, encoding=kwargs.get("encoding"), errors=kwargs.get("errors")) as reader:
|
||||||
|
result.stderr = reader.read()
|
||||||
|
else:
|
||||||
|
result.stderr = stderr.read()
|
||||||
|
if check:
|
||||||
|
result.check_returncode()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def data_dir(*parts):
|
def data_dir(*parts):
|
||||||
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME
|
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME
|
||||||
(or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory)."""
|
(or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory)."""
|
||||||
@@ -241,10 +284,46 @@ def clipboard_text():
|
|||||||
raise HostError("Can't read the clipboard")
|
raise HostError("Can't read the clipboard")
|
||||||
|
|
||||||
|
|
||||||
|
# What Windows' OpenSSH says when it refuses ~/.ssh/config (or a key) for its ACL.
|
||||||
|
BAD_PERMISSIONS = "Bad owner or permissions on "
|
||||||
|
|
||||||
|
|
||||||
|
def make_private(path):
|
||||||
|
"""Leave only this user able to open PATH, as ssh insists for ~/.ssh/config.
|
||||||
|
Windows: an ACL of just this user, SYSTEM and Administrators, inherited nothing.
|
||||||
|
A file written into ~/.ssh otherwise takes the folder's ACL, and Windows' OpenSSH
|
||||||
|
refuses it if that grants anyone else, even an account deleted long ago
|
||||||
|
("Bad owner or permissions"). Best effort: -> False if it couldn't."""
|
||||||
|
if not WINDOWS:
|
||||||
|
try:
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
me = os.environ.get("USERNAME", "")
|
||||||
|
try: # "desktop\me","S-1-5-21-..."
|
||||||
|
# Bytes: the account name is in the console's code page, the SID is ASCII.
|
||||||
|
out = subprocess.run(["whoami", "/user", "/fo", "csv", "/nh"], capture_output=True,
|
||||||
|
stdin=subprocess.DEVNULL, timeout=10).stdout
|
||||||
|
sid = out.decode("ascii", "replace").strip().rsplit(",", 1)[-1].strip('"')
|
||||||
|
if sid.startswith("S-1-"):
|
||||||
|
me = "*" + sid
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
if not me:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return subprocess.run(["icacls", str(path), "/inheritance:r", "/grant:r", f"{me}:F",
|
||||||
|
"*S-1-5-18:F", "*S-1-5-32-544:F"], capture_output=True,
|
||||||
|
stdin=subprocess.DEVNULL, timeout=10).returncode == 0
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def ssh_hostname(alias):
|
def ssh_hostname(alias):
|
||||||
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
|
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
|
||||||
try:
|
try:
|
||||||
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
|
out = run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return alias
|
return alias
|
||||||
for line in out.splitlines():
|
for line in out.splitlines():
|
||||||
@@ -277,24 +356,65 @@ def open_steam_link():
|
|||||||
return "Steam Link isn't installed; opened its download page"
|
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
|
||||||
|
# xrdp's certificate is its own, so every client warns about it first.
|
||||||
|
RDP_LOGIN = (f"accept the warning about the Frame's certificate, then sign in as {RDP_USER} "
|
||||||
|
"with your Developer Mode password")
|
||||||
|
|
||||||
|
|
||||||
|
def check_rdp(host, timeout=3):
|
||||||
|
"""Raise Unreachable, saying why, unless the Frame's RDP port takes a connection."""
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, RDP_PORT), timeout=timeout):
|
||||||
|
return
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
raise Unreachable(f"The Frame at {host} is on but isn't accepting remote desktop (port {RDP_PORT} "
|
||||||
|
"refused). Turn on Developer Mode in Steam Settings > System on the headset, "
|
||||||
|
"then restart it and try again.") from None
|
||||||
|
except socket.gaierror:
|
||||||
|
raise Unreachable(f"Can't find {host} on the network for remote desktop. Check the headset's "
|
||||||
|
"address on the Devices tab.") from None
|
||||||
|
except OSError as e:
|
||||||
|
raise Unreachable(f"The Frame didn't answer remote desktop at {host} ({e}). It may be asleep, "
|
||||||
|
"switched off or on another network; if it's on, check Developer Mode is on "
|
||||||
|
"in Steam Settings > System.") from None
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
# One file per address, so two launches close together can't swap headsets.
|
||||||
|
path = cache_dir(f"frame-{hashlib.sha256(host.encode()).hexdigest()[:16]}.rdp")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(path, "w", encoding="utf-8", newline="\r\n") as f: # Path.write_text(newline=) is 3.10+
|
||||||
|
f.write(f"full address:s:{host}\nusername:s:{RDP_USER}\n")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def open_rdp(alias, host=None):
|
def open_rdp(alias, host=None):
|
||||||
"""Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points."""
|
"""Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points."""
|
||||||
host = host or ssh_hostname(alias)
|
host = host or ssh_hostname(alias)
|
||||||
|
# The client would open either way and then fail on its own, with nothing said here.
|
||||||
|
check_rdp(host)
|
||||||
if MAC:
|
if MAC:
|
||||||
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
|
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")
|
open_url("https://apps.apple.com/app/windows-app/id1295203466")
|
||||||
return "Windows App isn't installed; opened its App Store page"
|
return "Windows App isn't installed; opened its App Store page"
|
||||||
if WINDOWS:
|
if WINDOWS:
|
||||||
_spawn(["mstsc.exe", f"/v:{host}"])
|
_spawn(["mstsc.exe", str(rdp_file(host))])
|
||||||
return f"Opened Remote Desktop to {host}"
|
# Windows asks about the unsigned connection file first.
|
||||||
|
return f"Opened Remote Desktop to {host}: choose Connect, {RDP_LOGIN}"
|
||||||
if which("remmina"):
|
if which("remmina"):
|
||||||
_spawn(["remmina", "-c", f"rdp://steamos@{host}"])
|
_spawn(["remmina", "-c", f"rdp://{RDP_USER}@{host}"])
|
||||||
return f"Opened Remmina to {host}"
|
return f"Opened Remmina to {host}: {RDP_LOGIN}"
|
||||||
for name in ("xfreerdp3", "xfreerdp"):
|
for name in ("xfreerdp3", "xfreerdp"):
|
||||||
if which(name):
|
if which(name):
|
||||||
_spawn([name, f"/v:{host}", "/u:steamos", "/dynamic-resolution"])
|
_spawn([name, f"/v:{host}", f"/u:{RDP_USER}", "/dynamic-resolution"])
|
||||||
return f"Opened FreeRDP to {host}"
|
return f"Opened FreeRDP to {host}: {RDP_LOGIN}"
|
||||||
raise HostError("No RDP client found: install Remmina or FreeRDP")
|
raise HostError("No RDP client found: install Remmina or FreeRDP")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+10
-9
@@ -74,8 +74,8 @@ def ssh_g(alias):
|
|||||||
"""(hostname, port, user, proxied) from `ssh -G ALIAS`, for a headset that's only an
|
"""(hostname, port, user, proxied) from `ssh -G ALIAS`, for a headset that's only an
|
||||||
ssh alias. proxied: it goes through ProxyJump or ProxyCommand, so only ssh can reach it."""
|
ssh alias. proxied: it goes through ProxyJump or ProxyCommand, so only ssh can reach it."""
|
||||||
try:
|
try:
|
||||||
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
out = frame_host.run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||||
timeout=10).stdout
|
timeout=10).stdout
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
out = ""
|
out = ""
|
||||||
got = {}
|
got = {}
|
||||||
@@ -108,7 +108,8 @@ def probe(host, port, timeout=PROBE_TIMEOUT, update=None):
|
|||||||
ip = addr[0]
|
ip = addr[0]
|
||||||
if family == socket.AF_INET6 and len(addr) > 3 and addr[3] and "%" not in ip:
|
if family == socket.AF_INET6 and len(addr) > 3 and addr[3] and "%" not in ip:
|
||||||
try: # a link-local IPv6 address only works with its interface
|
try: # a link-local IPv6 address only works with its interface
|
||||||
ip = f"{ip}%{socket.if_indextoname(addr[3])}"
|
# Windows' ssh takes only the number: its names ("wireless_32768") don't resolve.
|
||||||
|
ip = f"{ip}%{addr[3] if frame_host.WINDOWS else socket.if_indextoname(addr[3])}"
|
||||||
except (OSError, AttributeError):
|
except (OSError, AttributeError):
|
||||||
pass
|
pass
|
||||||
left = deadline - now()
|
left = deadline - now()
|
||||||
@@ -765,8 +766,8 @@ class Link:
|
|||||||
if not self.control:
|
if not self.control:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
return subprocess.run([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True,
|
return frame_host.run_ssh([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True,
|
||||||
stdin=subprocess.DEVNULL, timeout=5).returncode == 0
|
stdin=subprocess.DEVNULL, timeout=5).returncode == 0
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -777,8 +778,8 @@ class Link:
|
|||||||
pending.kill()
|
pending.kill()
|
||||||
if self.control and self.alias:
|
if self.control and self.alias:
|
||||||
try:
|
try:
|
||||||
subprocess.run([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True,
|
frame_host.run_ssh([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True,
|
||||||
stdin=subprocess.DEVNULL, timeout=5)
|
stdin=subprocess.DEVNULL, timeout=5)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
pass
|
pass
|
||||||
if proc and proc.poll() is None:
|
if proc and proc.poll() is None:
|
||||||
@@ -983,8 +984,8 @@ class Link:
|
|||||||
*self.host_opts(device, ssh_target(a["host"], res.get("ip"))),
|
*self.host_opts(device, ssh_target(a["host"], res.get("ip"))),
|
||||||
"-o", "StrictHostKeyChecking=yes", device["alias"], "true"]
|
"-o", "StrictHostKeyChecking=yes", device["alias"], "true"]
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
r = frame_host.run_ssh(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||||
errors="replace", timeout=20)
|
errors="replace", timeout=20)
|
||||||
err = r.stderr.strip()
|
err = r.stderr.strip()
|
||||||
if r.returncode == 0:
|
if r.returncode == 0:
|
||||||
rows[i].update(ssh="ok", detail=f"{lead} · SSH works")
|
rows[i].update(ssh="ok", detail=f"{lead} · SSH works")
|
||||||
|
|||||||
+53
-7
@@ -333,12 +333,14 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
|
|||||||
# Never let ssh inherit our stdin: under the app it's the pipe held open for
|
# Never let ssh inherit our stdin: under the app it's the pipe held open for
|
||||||
# --exit-on-eof, and Windows' ssh.exe waits on it forever.
|
# --exit-on-eof, and Windows' ssh.exe waits on it forever.
|
||||||
feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
|
feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
|
||||||
r = subprocess.run([*SSH, FRAME, remote], capture_output=True, **feed,
|
r = frame_host.run_ssh([*SSH, FRAME, remote], capture_output=True, **feed,
|
||||||
text=text, errors="replace" if text else None, timeout=timeout)
|
text=text, errors="replace" if text else None, timeout=timeout)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise Failure(f"Timed out talking to {FRAME}")
|
raise Failure(f"Timed out talking to {FRAME}")
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
|
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
|
||||||
|
if r.returncode == 255 and repair_ssh_config(err):
|
||||||
|
return ssh(remote, stdin=stdin, timeout=timeout, text=text)
|
||||||
if r.returncode == 255 and LINK and unreachable(err):
|
if r.returncode == 255 and LINK and unreachable(err):
|
||||||
LINK.lost(err, route_gen) # ssh itself failed: the connector reconnects
|
LINK.lost(err, route_gen) # ssh itself failed: the connector reconnects
|
||||||
failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
|
failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
|
||||||
@@ -347,6 +349,30 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
|
|||||||
return r.stdout
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
|
_config_repaired = False
|
||||||
|
|
||||||
|
|
||||||
|
def repair_ssh_config(err):
|
||||||
|
"""Windows' OpenSSH refused ~/.ssh/config for its ACL: give the file a private one,
|
||||||
|
once per run. -> True if it did, so the command is worth retrying."""
|
||||||
|
global _config_repaired
|
||||||
|
if _config_repaired or not frame_host.WINDOWS or frame_host.BAD_PERMISSIONS not in err:
|
||||||
|
return False
|
||||||
|
# ssh doubles the backslashes: "C:\\Users\\me/.ssh/config"
|
||||||
|
named = re.sub(r"[\\/]+", "/", err.split(frame_host.BAD_PERMISSIONS, 1)[1].splitlines()[0].strip())
|
||||||
|
config = frame_devices.ssh_config()
|
||||||
|
if not named.lower().endswith("/" + config.name.lower()):
|
||||||
|
return False # a key or another file: not ours to rewrite
|
||||||
|
_config_repaired = True
|
||||||
|
try:
|
||||||
|
if frame_devices.repair_permissions(config):
|
||||||
|
print(f"Gave {config} a private ACL: ssh refused it ({named})", file=sys.stderr)
|
||||||
|
return True
|
||||||
|
except OSError as e:
|
||||||
|
print(f"Couldn't repair {config}'s permissions: {e}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def strip_ansi(s):
|
def strip_ansi(s):
|
||||||
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
|
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
|
||||||
|
|
||||||
@@ -504,8 +530,8 @@ def save_shots(body):
|
|||||||
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
|
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
|
r = frame_host.run_ssh(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
|
||||||
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
|
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise Failure("Copying screenshots timed out")
|
raise Failure("Copying screenshots timed out")
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
@@ -1199,6 +1225,8 @@ def open_thing(body):
|
|||||||
raise Failure("That screenshot isn't saved on this computer yet", 404)
|
raise Failure("That screenshot isn't saved on this computer yet", 404)
|
||||||
frame_host.reveal_path(saved)
|
frame_host.reveal_path(saved)
|
||||||
return {"message": f"Showed {saved.name} in {frame_host.FILE_MANAGER}"}
|
return {"message": f"Showed {saved.name} in {frame_host.FILE_MANAGER}"}
|
||||||
|
except frame_host.Unreachable as e:
|
||||||
|
raise Failure(str(e), 400) # theirs to turn on; nothing failed here
|
||||||
except frame_host.HostError as e:
|
except frame_host.HostError as e:
|
||||||
raise Failure(str(e), 500)
|
raise Failure(str(e), 500)
|
||||||
raise Failure("unknown target", 400)
|
raise Failure("unknown target", 400)
|
||||||
@@ -2254,7 +2282,7 @@ def push_file(path, dest="Downloads/"):
|
|||||||
else:
|
else:
|
||||||
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
|
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
|
||||||
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
|
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
|
||||||
r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
|
r = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise Failure(f"Copying {name} timed out")
|
raise Failure(f"Copying {name} timed out")
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
@@ -2262,6 +2290,11 @@ def push_file(path, dest="Downloads/"):
|
|||||||
return f"Sent {name} to ~/{dest}"
|
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):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
server_version = "FrameControl/1"
|
server_version = "FrameControl/1"
|
||||||
timeout = 60 # per socket operation, so a stalled client can't hold a thread
|
timeout = 60 # per socket operation, so a stalled client can't hold a thread
|
||||||
@@ -2294,8 +2327,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
# Nobody may frame the UI (clickjacking).
|
# Nobody may frame the UI (clickjacking).
|
||||||
self.send_header("X-Frame-Options", "DENY")
|
self.send_header("X-Frame-Options", "DENY")
|
||||||
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
|
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
|
||||||
self.end_headers()
|
try:
|
||||||
self.wfile.write(data)
|
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):
|
def send_json(self, obj, status=200):
|
||||||
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
|
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
|
||||||
@@ -2338,6 +2374,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
from apk_sources import _images
|
from apk_sources import _images
|
||||||
try:
|
try:
|
||||||
self.send_bytes(*_images.image(path.rsplit("/", 1)[-1]))
|
self.send_bytes(*_images.image(path.rsplit("/", 1)[-1]))
|
||||||
|
except ClientGone:
|
||||||
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
self.send_json({"error": "Artwork unavailable"}, 404)
|
self.send_json({"error": "Artwork unavailable"}, 404)
|
||||||
elif path == "/api/sources/details":
|
elif path == "/api/sources/details":
|
||||||
@@ -2415,6 +2453,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
headers=[("X-Capture-Source", "gamescope")])
|
headers=[("X-Capture-Source", "gamescope")])
|
||||||
else:
|
else:
|
||||||
self.send_json({"error": "not found"}, 404)
|
self.send_json({"error": "not found"}, 404)
|
||||||
|
except ClientGone:
|
||||||
|
raise
|
||||||
except Failure as e:
|
except Failure as e:
|
||||||
self.send_error_json(str(e), e.status, e.apk)
|
self.send_error_json(str(e), e.status, e.apk)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -2449,6 +2489,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
with (contextlib.nullcontext() if path == "/api/devices" else working(meant)):
|
with (contextlib.nullcontext() if path == "/api/devices" else working(meant)):
|
||||||
result = handler(body)
|
result = handler(body)
|
||||||
self.send_json(result)
|
self.send_json(result)
|
||||||
|
except ClientGone:
|
||||||
|
raise
|
||||||
except Failure as e:
|
except Failure as e:
|
||||||
if e.status >= 500:
|
if e.status >= 500:
|
||||||
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
||||||
@@ -2624,6 +2666,10 @@ class LoopbackServer(ThreadingHTTPServer):
|
|||||||
socketserver.TCPServer.server_bind(self)
|
socketserver.TCPServer.server_bind(self)
|
||||||
self.server_name, self.server_port = "127.0.0.1", self.server_address[1]
|
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
|
_ONE_SERVER = None
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user