diff --git a/app/main.js b/app/main.js index bf6ff66..aac8d35 100644 --- a/app/main.js +++ b/app/main.js @@ -245,6 +245,23 @@ function fromUi(e) { ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : ""); ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); }); +// The page reports the headsets it knows (the server's Devices tab), so the Frame +// menu can switch between them. Only plain names and ids go into the menu. +const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +let devices = []; +ipcMain.on("devices:changed", (e, list) => { + if (!fromUi(e) || !Array.isArray(list)) return; + const next = list.slice(0, 20).filter(d => d && typeof d.id === "string" && ALIAS_RE.test(d.alias || "")) + .map(d => ({ id: d.id.slice(0, 80), name: String(d.name || d.alias).slice(0, 60), alias: d.alias, active: !!d.active })); + if (JSON.stringify(next) === JSON.stringify(devices)) return; + devices = next; + buildMenu(); +}); +const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME; +function showDevices() { + if (win && url) win.webContents.executeJavaScript('location.hash = "devices"').catch(() => {}); +} + // frame-control://install links from websites (docs/web-install.md). They can // arrive before the window or server exists (macOS open-url on a cold launch), // so they wait here until the page asks for them. The page checks the link with @@ -326,13 +343,14 @@ async function runInTerminal(argv) { } } -async function setUpConnection() { - const alias = `FRAME_ALIAS=${FRAME}`; +// Set Up Connection for the headset in use (or another alias, from the Devices tab). +async function setUpConnection(name = activeAlias()) { + if (!ALIAS_RE.test(name)) return; + const alias = `FRAME_ALIAS=${name}`; if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]); const py = python || await findPython({ ...process.env, PATH: await loginPath() }); - const setup = [py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py")]; - // A new console inherits our environment on Windows; Linux terminals may not. - runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]); + // --alias, since a new console on Windows (and some Linux terminals) doesn't get our environment. + runInTerminal([py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py"), "--alias", name]); } function buildMenu() { @@ -343,8 +361,15 @@ function buildMenu() { { label: "Frame", submenu: [ - { label: "Set Up Connection…", click: setUpConnection }, - { label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) }, + { label: "Set Up Connection…", click: () => setUpConnection() }, + { label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", activeAlias()]) }, + { type: "separator" }, + ...(devices.length > 1 ? [{ + label: "Headset", + submenu: devices.map(d => ({ label: d.name, type: "radio", checked: d.active, + click: () => { if (win) win.webContents.send("use-device", d.id); } })), + }] : []), + { label: "Devices…", accelerator: "CmdOrCtrl+5", click: showDevices }, { type: "separator" }, { label: "Open in Browser", click: () => url && shell.openExternal(url) }, { label: "Restart Server", click: () => win ? restartServer() : createWindow() }, diff --git a/app/preload.js b/app/preload.js index 43bc34d..9ff2567 100644 --- a/app/preload.js +++ b/app/preload.js @@ -2,7 +2,8 @@ // to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells // the page where a dropped file or folder lives, so a folder can be sideloaded // as a title without zipping it (the local server reads it from there). -// It can open Set Up Connection when the headset can't be reached. +// It can open Set Up Connection when the headset can't be reached, and keeps the +// Frame menu's list of headsets up to date. // It also receives frame-control://install links (docs/web-install.md): only // what the link asked for, never an install; the page asks the user first. const { contextBridge, ipcRenderer, webUtils } = require("electron"); @@ -10,6 +11,12 @@ const { contextBridge, ipcRenderer, webUtils } = require("electron"); contextBridge.exposeInMainWorld("frameApp", { readClipboard: () => ipcRenderer.invoke("clipboard:read"), setUpConnection: () => ipcRenderer.invoke("connection:setup"), + // The Frame menu's headset switcher: the page tells it the headsets, and hears picks. + devicesChanged: (list) => ipcRenderer.send("devices:changed", list), + onUseDevice: (cb) => { + ipcRenderer.removeAllListeners("use-device"); + ipcRenderer.on("use-device", (_e, id) => cb(String(id))); + }, pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } }, onInstallLink: (cb) => { ipcRenderer.removeAllListeners("install-link"); diff --git a/docs/devices.md b/docs/devices.md new file mode 100644 index 0000000..4f01b69 --- /dev/null +++ b/docs/devices.md @@ -0,0 +1,167 @@ +# Headsets, addresses and the connection + +Frame Control can manage more than one Steam Frame, and each headset can be +reached at more than one address: a LAN IP at home, another at the office, its +mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices** +tab (key 5) lists them, and the connection pill in the header shows what the +app is doing to reach the one in use, step by step, as it happens. + +The code is in three modules, all stdlib-only Python on your computer: + +| Module | What it does | +|---|---| +| `ui/frame_devices.py` | The registry: headsets, their addresses, networks; importing and updating `~/.ssh/config`; pinned host keys | +| `ui/frame_network.py` | Which network this computer is on, and Tailscale's state | +| `ui/frame_link.py` | The connector: finds the headset, keeps the SSH connection, publishes each stage; the Devices API | + +## Headsets + +Each headset keeps its own SSH alias, as Set Up Connection has always written +it: the first is `frame`, the next `frame-2`, and so on. Terminal's +`ssh frame-2` and the helper scripts (`FRAME_ALIAS=frame-2 scripts/push.sh …`) +work for each one. + +- **Nothing to migrate by hand.** On first start, the app imports every + `# >>> steam-frame (ALIAS) >>>` block in `~/.ssh/config` as a headset, with + the block's HostName as its first address. It also copies the host key your + `known_hosts` already trusts for that address into the app's own + `~/.ssh/frame-control_known_hosts`, so nobody is asked to trust it again. +- **Add a headset** runs Set Up Connection (`scripts/connect.sh` on macOS, + `ui/frame_connect.py --alias NAME` elsewhere) in a terminal with a new alias. + When it writes its block, the app picks the headset up by itself. If Set Up + Connection runs again and finds a headset somewhere new, that address is added + at the top of its list. +- **Use this headset** (or the switcher in the header, or the app's + **Frame → Headset** menu) moves the whole app to another headset; every panel + reloads from it. +- **Remove** forgets a headset. Its `~/.ssh/config` block stays unless you tick + the box; either way it isn't imported again unless Set Up Connection changes it. +- A plain `FRAME_ALIAS` that Set Up Connection never configured still works: the + app shows it as not set up and lets ssh's own config decide where it goes. + +## Addresses + +Each address has a kind (LAN, mDNS, Tailscale or Other, guessed from the address +and changeable), an optional label, the networks it has worked on, and when it +last worked with its round-trip time. + +When connecting, the app **tries all addresses at once** (TCP to the SSH port) +and ranks them: + +1. addresses that worked on the network this computer is on now; +2. mDNS names; +3. Tailscale addresses, if Tailscale is running here; +4. addresses not tried on this network yet; +5. addresses that only ever worked on other networks; +6. Tailscale addresses while Tailscale is off. + +Your order on the Devices tab breaks ties. The best-ranked address that answers +wins; one that answers first waits up to 0.35 s for a better-ranked one that is +still trying. If SSH to the winner fails in a way another address could fix +(a different device answered there, or the link dropped), the next one that +answered is tried. Every success records the network on that address, so next +time on that network it's tried first. + +**Test now** probes every address and tries SSH on each one that answers, without +disturbing the connection in use: "SSH works", "answered as a different +headset", "refused this computer's key", or why it didn't answer. **Find on +Tailscale** lists your tailnet's devices (likely headsets first, from `tailscale +status --json`, including the Mac app's own CLI) with buttons to add their +MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit +services and checks `ALIAS.local` and `frame.local`. + +## Networks + +A network is told apart by its default gateway: the router's IP address plus its +hardware (MAC) address, read with `route`/`arp` (macOS), `ip route`/`ip neigh` +(Linux) or `route print`/`arp -a` (Windows). That works on wired networks, and +on macOS 14 and later, which hides the Wi-Fi name from apps without Location +permission. Where the system does share the Wi-Fi name, it's shown, and you can +name any network yourself ("Home Wi-Fi") on the Devices tab. + +The app rereads the gateway every 5 seconds and Tailscale's state every +30 seconds. Changing networks reconnects. + +## The connection, stage by stage + +The connector runs in the server (`frame_link.Link`) and moves through: + +1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale. +2. **Finding the headset**: each address resolving, trying, answered in N ms, + no answer, refused, or can't be found. +3. **Opening SSH** to the address that answered. +4. **Checking the headset's identity**: the host key must match the one pinned + for this headset. +5. **Logging in** as the headset's user. +6. **Connected** via network N, address A, round trip T; or **failed** at a stage + with the reason in plain words and a countdown to the next try (5, 10, 20, + then every 30 seconds). Retry now skips the wait. + +Stages 3 to 5 come from following `ssh -v` as it runs. On macOS and Linux the +connection is an SSH ControlMaster that every command shares; when it dies (the +headset slept or left the network) the connector notices and starts again. On +Windows, where OpenSSH can't share a connection, the same handshake runs once +and each command then connects on its own; a command that can't reach the +headset makes the connector start again. + +Once connected, every `ssh`, `scp` and `rsync` the app runs gets +`-o HostName=
-o HostKeyAlias=frame-control- +-o UserKnownHostsFile=~/.ssh/frame-control_known_hosts -o User=… -o Port=…`. The +alias's block in `~/.ssh/config` is also updated to the last address that +worked (and to the user and port you set), so Terminal's `ssh frame` and the +scripts follow. + +**Host keys are pinned per headset, not per address.** Your own `known_hosts` +is keyed by address, so a different device answering at a remembered IP (a DHCP +lease that moved) would look like a new host there. The app keys its own +known_hosts by headset instead: a different device answering at one of its +addresses is refused, and the pill says so. A headset's first connection trusts +the key it shows, as Set Up Connection does. After reinstalling SteamOS the +headset has a new key; **Forget identity** on the Devices tab lets the next +connection save the new one. + +## API + +All under the usual `/api/` guards (loopback `Host`, `X-Frame-UI` header). + +| Request | Returns | +|---|---| +| `GET /api/connection` | The connection state: `phase` (connecting, connected, failed), `device`, `network`, `stages`, `probes`, `via`, `error`, `retry_at`, `tests`, `version` | +| `GET /api/connection/events` | The same as server-sent events, one each time it changes (the page reads it with `fetch`, since `EventSource` can't send the header) | +| `GET /api/devices` | Headsets, the current network, known networks, the next free alias | +| `GET /api/devices/tailscale?id=` | Tailscale peers, likely headsets first | +| `GET /api/devices/mdns?id=` | Headsets found on this network | +| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `forget-identity`, `name-network`, `setup` (alias, optional host), `retry` | + +Every host, alias and user is checked against strict patterns before it's +stored, because they end up in ssh arguments and `~/.ssh/config`; nothing goes +through a shell. + +## The registry file + +`devices.json` in the app's data folder (`~/Library/Application Support/Frame +Control` on macOS, `%APPDATA%\Frame Control` on Windows, +`~/.local/share/frame-control` on Linux). It's plain JSON so the iPhone app can +share the format later (it still connects to one host; see +[iphone.md](iphone.md)): + +```json +{"version": 1, "active": "f67f8b7e", + "devices": [{"id": "f67f8b7e", "name": "Steam Frame", "alias": "frame", "user": "steamos", "port": 22, + "identity_files": ["~/.ssh/id_ed25519_frame"], + "addresses": [{"host": "frame.local", "kind": "mdns", "label": "", + "networks": ["n-e0998baa61"], "last_ok": 1790593550.4, "last_rtt_ms": 0.9}]}], + "networks": {"n-e0998baa61": {"name": "Home Wi-Fi", "ssid": null, "gateway": "192.168.1.1", + "gateway_mac": "b4:fb:e4:b5:67:55", "wifi": true, "last_seen": 1790593550.0}}} +``` + +A network id is `n-` and the first 10 hex digits of SHA-1 of `gateway|mac`. + +## Tests + +`tests/test_devices.py`, `tests/test_network.py` and `tests/test_link.py` run +with the other unit tests. They use a stand-in `ssh` (`tests/fakessh/ssh`) that +prints what `ssh -v` prints and plays a ControlMaster, real sockets on this +computer for the addresses, and temporary folders for `~/.ssh` +(`FRAME_CONTROL_SSH_DIR`) and the app data (`FRAME_CONTROL_DATA_DIR`), so they +never touch yours. diff --git a/docs/frame-control.md b/docs/frame-control.md index 299c57c..f73c484 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -17,13 +17,16 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810 ## Features -The window has four tabs: **Home** (headset view, status, screenshots), +The window has five tabs: **Home** (headset view, status, screenshots), **Games** (installed games, sideloaded titles, getting games), **Android** (apps, -the catalogue, display settings, reports) and **Tools** (sending files and text, -Flatpaks, remote and power). Keys 1–4 switch between them. Files can be dropped -anywhere in the window. When the Frame can't be reached, one banner says why in -plain words and the app retries every few seconds, filling everything in once it -answers. Flatpak and Android installs run in the background; the bottom bar +the catalogue, display settings, reports), **Tools** (sending files and text, +Flatpaks, remote and power) and **Devices** (your headsets and their addresses). +Keys 1–5 switch between them. Files can be dropped anywhere in the window. A +connection pill in the header always shows which headset, which network this +computer is on, the address in use or being tried, and each step of connecting +as it happens; click it for the whole timeline. When the Frame can't be +reached, a banner says why in plain words, what was tried, and counts down to +the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar counts them while they run. - **Headset view**: what the lenses show, as SteamVR composites it (the room, @@ -72,6 +75,11 @@ counts them while they run. Frame clipboard. - **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC, Remmina). +- **Devices**: several headsets, each with several addresses (LAN IPs per + network, its `.local` mDNS name, its Tailscale IP or MagicDNS name). The app + tries them all at once and learns which worked on which network. Add, edit, + reorder and test addresses, find a headset on Tailscale or on this network, + 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 @@ -92,7 +100,9 @@ trusts root certificates already in the Windows store. The server is Python stdlib only and listens on 127.0.0.1. It rejects requests with a non-local `Host` header, and any `/api/` request without a custom header, so other websites can't drive it or read captures. Everything reaches -the headset through the `frame` SSH alias. On macOS and Linux it keeps one +the headset through its SSH alias (`frame` for the first one), pointed at the +address that answered with `-o HostName=` (`ui/frame_link.py`, described in +[devices.md](devices.md)). On macOS and Linux it keeps one multiplexed SSH connection open, so status and each capture take about 0.3 s. Windows' OpenSSH can't share a connection, so there each request connects on its own and the app is a little slower. What differs between the three diff --git a/tests/fakessh/ssh b/tests/fakessh/ssh new file mode 100755 index 0000000..af0ebb4 --- /dev/null +++ b/tests/fakessh/ssh @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""A stand-in for OpenSSH's ssh, for tests/test_link.py: prints what `ssh -v` prints at +each step of a connection, and plays a ControlMaster. What each HostName does comes +from $FAKESSH_HOSTS (JSON: host -> "ok", "wrong" (a different host key) or "denied"); +every call is appended to $FAKESSH_LOG as a JSON line. POSIX only.""" +import json +import os +import signal +import sys +import time + +args = sys.argv[1:] +with open(os.environ["FAKESSH_LOG"], "a") as f: + f.write(json.dumps(args) + "\n") +hosts = json.loads(os.environ.get("FAKESSH_HOSTS", "{}")) +opts = {} +i = 0 +while i < len(args) and args[i].startswith("-"): + if args[i] in ("-o", "-O", "-p", "-l"): + key = args[i] + val = args[i + 1] + if key == "-o": + k, _, v = val.partition("=") + opts[k.lower()] = v + else: + opts[key] = val + i += 2 + else: + opts[args[i]] = True + i += 1 +alias = args[i] if i < len(args) else "" +host = opts.get("hostname", alias).replace("%%", "%") +marker = os.path.join(os.environ["FAKESSH_DIR"], "master-" + host.replace("/", "_")) +say = lambda s: (sys.stderr.write(s + "\n"), sys.stderr.flush()) + +if "-G" in opts: + print(f"hostname {alias}\nport 22\nuser tester") + sys.exit(0) +if opts.get("-O") == "check": + sys.exit(0 if os.path.exists(marker) else 255) +if opts.get("-O") == "exit": + if os.path.exists(marker): + os.unlink(marker) + sys.exit(0) + +what = hosts.get(host) +if what is None: + say(f"ssh: Could not resolve hostname {host}: nodename nor servname provided, or not known") + sys.exit(255) +say(f"debug1: Connecting to {host} [127.0.0.1] port {opts.get('port', 22)}.") +say("debug1: Connection established.") +say(f"debug1: Authenticating to {host}:22 as '{opts.get('user', 'tester')}'") +say("debug1: Server host key: ssh-ed25519 SHA256:fakefakefakefakefakefakefakefakefakefakefak") +if what == "wrong": + say("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@") + say("@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @") + say("Host key verification failed.") + sys.exit(255) +say(f"debug1: Host '{opts.get('hostkeyalias', host)}' is known and matches the ED25519 host key.") +say("debug1: Next authentication method: publickey") +if what == "denied": + say(f"tester@{host}: Permission denied (publickey).") + sys.exit(255) +say(f'Authenticated to {host} ([127.0.0.1]:22) using "publickey".') +if opts.get("controlmaster") == "yes": + open(marker, "w").close() + def bye(*_): + if os.path.exists(marker): + os.unlink(marker) + sys.exit(0) + signal.signal(signal.SIGTERM, bye) + while True: + time.sleep(0.2) + if not os.path.exists(marker): + sys.exit(0) +sys.exit(0) diff --git a/tests/sandbox.py b/tests/sandbox.py new file mode 100644 index 0000000..5d2a3f0 --- /dev/null +++ b/tests/sandbox.py @@ -0,0 +1,16 @@ +"""Imported first by every test module: nothing a test does reaches this person's +app data, their telemetry, or the shared compatibility database. + +Must run before any ui module is imported, since those read these at import time. +""" +import atexit +import os +import shutil +import tempfile + +_dir = tempfile.mkdtemp(prefix="frame-control-tests-") +atexit.register(shutil.rmtree, _dir, ignore_errors=True) +os.environ["FRAME_CONTROL_DATA_DIR"] = _dir +os.environ["FRAME_CONTROL_TELEMETRY"] = "0" +# A maintainer's machine holds the database key; send anything that slips through nowhere. +os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9" diff --git a/tests/test_compat_db.py b/tests/test_compat_db.py index 7081d79..37208d5 100644 --- a/tests/test_compat_db.py +++ b/tests/test_compat_db.py @@ -2,6 +2,7 @@ Run: python3 -m unittest discover -s tests """ +import sandbox # noqa: F401 (first: keeps tests off real data and services) import os import sys import tempfile diff --git a/tests/test_connect.py b/tests/test_connect.py index b3366bd..0e8972a 100644 --- a/tests/test_connect.py +++ b/tests/test_connect.py @@ -3,6 +3,7 @@ steamos-devkit-service, the ~/.ssh/config block, and the mDNS output parsers. Run: python3 -m unittest discover -s tests """ +import sandbox # noqa: F401 (first: keeps tests off real data and services) import json import socket import sys diff --git a/tests/test_devices.py b/tests/test_devices.py new file mode 100644 index 0000000..83c187c --- /dev/null +++ b/tests/test_devices.py @@ -0,0 +1,273 @@ +"""frame_devices: the headset registry, importing ~/.ssh/config, address order, pinned +host keys and input checks. Everything works in temporary folders. + +Run: python3 -m unittest discover -s tests +""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "ui")) + +import frame_devices as fd # noqa: E402 + +CONFIG = """Host lxso1 + HostName 192.168.1.109 + +# >>> steam-frame (frame) >>> +Host frame + HostName frame.tail1234.ts.net + User steamos + IdentityFile ~/.ssh/id_ed25519_frame + IdentityFile ~/.ssh/id_rsa_frame_devkit + IdentitiesOnly yes + ServerAliveInterval 30 +Host * +# <<< steam-frame (frame) <<< +# >>> steam-frame (frame-2) >>> +Host frame-2 + HostName 192.168.1.60 + Port 2222 + User deck + IdentityFile ~/.ssh/id_ed25519_frame +Host * +# <<< steam-frame (frame-2) <<< +Host * + ServerAliveInterval 60 +""" +KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIID6kdLfZZmdTqS1snKfTESTKEYTESTKEYTESTKEYTESTKE" + + +class Base(unittest.TestCase): + def setUp(self): + self.dir = Path(tempfile.mkdtemp(prefix="frame-devices-")) + self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True) + self.ssh = self.dir / "ssh" + self.ssh.mkdir() + (self.ssh / "config").write_text(CONFIG) + old = os.environ.get("FRAME_CONTROL_SSH_DIR") + os.environ["FRAME_CONTROL_SSH_DIR"] = str(self.ssh) + self.addCleanup(lambda: os.environ.__setitem__("FRAME_CONTROL_SSH_DIR", old) if old + else os.environ.pop("FRAME_CONTROL_SSH_DIR", None)) + self.reg = fd.Registry(self.dir / "devices.json") + + +class Validation(unittest.TestCase): + def test_hosts(self): + for good in ("frame.local", "192.168.1.40", "fd7a:115c:a1e0::5928:ae55", "fe80::1%en0", "frame-2.tail1234.ts.net"): + self.assertEqual(fd.check_host(good), good) + for bad in ("", " ", "-oProxyCommand=sh", "a b", "frame;id", "frame\nHost *", "frame..local", "$(id)", + "frame%en0", "x" * 300, None, 5, "frame/../x"): + with self.assertRaises(fd.DeviceError, msg=repr(bad)): + fd.check_host(bad) + + def test_names(self): + self.assertEqual(fd.check_alias("frame-2"), "frame-2") + for bad in ("", "-F", "frame 2", "frame\n", "a" * 65, None): + with self.assertRaises(fd.DeviceError): + fd.check_alias(bad) + with self.assertRaises(fd.DeviceError): + fd.check_user(bad) + for bad in ("0", "65536", "x", None, "22; id"): + with self.assertRaises(fd.DeviceError): + fd.check_port(bad) + self.assertEqual(fd.check_port("2222"), 2222) + with self.assertRaises(fd.DeviceError): + fd.check_text("line\nbreak", "label") + with self.assertRaises(fd.DeviceError): + fd.check_kind("wifi") + + def test_ipv6_zone_is_escaped_for_ssh(self): + self.assertEqual(fd.ssh_host("fe80::1%en0"), "fe80::1%%en0") + + +class Migration(Base): + def test_blocks_are_parsed(self): + blocks = fd.parse_blocks(CONFIG) + self.assertEqual([b["alias"] for b in blocks], ["frame", "frame-2"]) + self.assertEqual(blocks[0]["hostname"], "frame.tail1234.ts.net") + self.assertEqual(blocks[0]["identity_files"], ["~/.ssh/id_ed25519_frame", "~/.ssh/id_rsa_frame_devkit"]) + self.assertEqual((blocks[1]["port"], blocks[1]["user"]), (2222, "deck")) + + def test_existing_headsets_are_imported_once(self): + self.assertTrue(self.reg.sync_from_config(seed=False)) + devices = self.reg.devices() + self.assertEqual([d["alias"] for d in devices], ["frame", "frame-2"]) + frame, second = devices + self.assertEqual(frame["name"], "Steam Frame") + self.assertEqual(frame["addresses"][0]["host"], "frame.tail1234.ts.net") + self.assertEqual(frame["addresses"][0]["kind"], "tailscale") + self.assertEqual((second["user"], second["port"]), ("deck", 2222)) + self.assertEqual(self.reg.active(), frame["id"]) + self.assertFalse(self.reg.sync_from_config(seed=False)) # nothing new + # It's all on disk, in the documented shape. + data = json.loads((self.dir / "devices.json").read_text()) + self.assertEqual(data["version"], 1) + self.assertEqual(len(data["devices"]), 2) + self.assertEqual(fd.Registry(self.dir / "devices.json").devices(), self.reg.devices()) + + def test_setup_finding_a_new_address_adds_it(self): + self.reg.sync_from_config(seed=False) + (self.ssh / "config").write_text(CONFIG.replace("HostName frame.tail1234.ts.net", "HostName 192.168.1.237")) + self.assertTrue(self.reg.sync_from_config(seed=False)) + hosts = [a["host"] for a in self.reg.by_alias("frame")["addresses"]] + self.assertEqual(hosts, ["192.168.1.237", "frame.tail1234.ts.net"]) # the new one first + + def test_removed_headset_stays_removed_until_setup_changes_it(self): + self.reg.sync_from_config(seed=False) + second = self.reg.by_alias("frame-2") + self.reg.remove_device(second["id"]) + self.reg.sync_from_config(seed=False) + self.assertIsNone(self.reg.by_alias("frame-2")) + self.reg.undismiss("frame-2") # Set Up Connection run for it from the Devices tab + self.reg.sync_from_config(seed=False) + self.assertIsNotNone(self.reg.by_alias("frame-2")) + + def test_corrupt_registry_is_ignored(self): + (self.dir / "bad.json").write_text("{not json") + self.assertEqual(fd.Registry(self.dir / "bad.json").devices(), []) + (self.dir / "evil.json").write_text(json.dumps({"devices": [ + {"id": "x1", "alias": "-oProxyCommand=id", "addresses": []}, + {"id": "x2", "alias": "ok", "addresses": [{"host": "a b", "kind": "lan"}, {"host": "frame.local", "kind": "mdns"}]}]})) + devices = fd.Registry(self.dir / "evil.json").devices() + self.assertEqual([d["alias"] for d in devices], ["ok"]) + self.assertEqual([a["host"] for a in devices[0]["addresses"]], ["frame.local"]) + + +class ConfigRewrite(Base): + def test_hostname_user_and_port_change_only_inside_the_block(self): + cfg = self.ssh / "config" + self.assertTrue(fd.rewrite_block("frame", hostname="192.168.1.237")) + text = cfg.read_text() + self.assertIn(" HostName 192.168.1.237\n", text) + self.assertEqual(text.replace("192.168.1.237", "frame.tail1234.ts.net"), CONFIG) # nothing else moved + self.assertFalse(fd.rewrite_block("frame", hostname="192.168.1.237")) # no change, no write + self.assertTrue(fd.rewrite_block("frame", port=2200, user="deck")) + block = fd.parse_blocks(cfg.read_text())[0] + self.assertEqual((block["port"], block["user"], block["hostname"]), (2200, "deck", "192.168.1.237")) + self.assertTrue(fd.rewrite_block("frame-2", port=22)) # back to the default: the line goes + self.assertEqual(fd.parse_blocks(cfg.read_text())[1]["port"], 22) + self.assertNotIn("Port 22\n", cfg.read_text()) + self.assertIn("HostName 192.168.1.109", cfg.read_text()) # other hosts untouched + if os.name != "nt": + self.assertEqual(cfg.stat().st_mode & 0o777, 0o600) + + def test_zone_is_escaped_and_read_back(self): + fd.rewrite_block("frame", hostname="fe80::1%en0") + self.assertIn("HostName fe80::1%%en0", (self.ssh / "config").read_text()) + self.assertEqual(fd.parse_blocks((self.ssh / "config").read_text())[0]["hostname"], "fe80::1%en0") + + def test_missing_block_is_left_alone(self): + self.assertFalse(fd.rewrite_block("frame-9", hostname="10.0.0.1")) + self.assertFalse(fd.remove_block("frame-9")) + self.assertTrue(fd.remove_block("frame-2")) + self.assertEqual([b["alias"] for b in fd.parse_blocks((self.ssh / "config").read_text())], ["frame"]) + + +@unittest.skipUnless(shutil.which("ssh-keygen"), "needs ssh-keygen") +class Pins(Base): + def test_seed_copies_the_trusted_key_under_the_device_alias(self): + (self.ssh / "known_hosts").write_text(f"frame.tail1234.ts.net {KEY}\nother.example {KEY}X\n") + self.assertFalse(fd.pinned("d1")) + self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"])) + self.assertTrue(fd.pinned("d1")) + self.assertEqual(fd.known_hosts().read_text(), f"frame-control-d1 {KEY}\n") + self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"])) # idempotent + self.assertEqual(fd.known_hosts().read_text().count("\n"), 1) + self.assertFalse(fd.seed_pin("d2", ["never-seen.example"])) + self.assertTrue(fd.forget_pin("d1")) + self.assertFalse(fd.pinned("d1")) + + def test_hashed_and_non_default_port_entries(self): + kh = self.ssh / "known_hosts" + kh.write_text(f"[frame.local]:2222 {KEY}\n") + subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True) + self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry + self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222)) + self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts().read_text()) + + def test_known_hosts_option_uses_the_override(self): + self.assertEqual(fd.known_hosts_opt(), str(self.ssh / "frame-control_known_hosts")) + + +class Registry(Base): + def test_address_editing(self): + d = self.reg.add_device("frame-3", hosts=["192.168.1.40"]) + a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS") + self.assertEqual(a["kind"], "mdns") + self.reg.add_address(d["id"], "100.100.1.1", kind="tailscale", label="Tailscale") + with self.assertRaises(fd.DeviceError): + self.reg.add_address(d["id"], "frame-3.local") # already there + with self.assertRaises(fd.DeviceError): + self.reg.add_address(d["id"], "frame-3.local; id") + self.reg.move_address(d["id"], "100.100.1.1", -1) + self.reg.move_address(d["id"], "100.100.1.1", -1) + self.reg.move_address(d["id"], "100.100.1.1", -1) # already first: stays + hosts = lambda: [x["host"] for x in self.reg.get(d["id"])["addresses"]] + self.assertEqual(hosts(), ["100.100.1.1", "192.168.1.40", "frame-3.local"]) + self.reg.record_success(d["id"], "192.168.1.40", "n-home", 3.2) + self.reg.update_address(d["id"], "192.168.1.40", label="Home") + self.assertEqual(self.reg.get(d["id"])["addresses"][1]["networks"], ["n-home"]) # a label keeps what it learned + self.reg.update_address(d["id"], "192.168.1.40", new_host="192.168.1.41") + moved = self.reg.get(d["id"])["addresses"][1] + self.assertEqual((moved["host"], moved["networks"], moved["last_ok"]), ("192.168.1.41", [], None)) + self.reg.remove_address(d["id"], "192.168.1.41") + self.assertEqual(hosts(), ["100.100.1.1", "frame-3.local"]) + with self.assertRaises(fd.DeviceError): + self.reg.remove_address(d["id"], "nope") + + def test_devices(self): + a = self.reg.add_device("frame") + b = self.reg.add_device("frame-2", name="Office") + self.assertEqual(self.reg.active(), a["id"]) + with self.assertRaises(fd.DeviceError): + self.reg.add_device("frame") + self.reg.set_active(b["id"]) + self.assertEqual(self.reg.update_device(b["id"], name="Desk", user="deck", port="2222")["port"], 2222) + with self.assertRaises(fd.DeviceError): + self.reg.update_device(b["id"], user="bad user") + self.reg.remove_device(b["id"]) + self.assertEqual(self.reg.active(), a["id"]) + with self.assertRaises(fd.DeviceError): + self.reg.get(b["id"]) + + def test_networks_get_names(self): + net = {"id": "n-1", "gateway": "192.168.1.1", "gateway_mac": "aa:bb:cc:dd:ee:ff", "ssid": None, "wifi": True} + self.assertEqual(self.reg.network_name(net), "Wi-Fi via 192.168.1.1") + self.reg.record_network(net) + self.reg.name_network("n-1", "Home Wi-Fi") + self.assertEqual(self.reg.network_name(net), "Home Wi-Fi") + self.assertEqual(self.reg.network_name(dict(net, id="n-2", ssid="Cafe")), "Cafe") + self.assertEqual(self.reg.network_name(None), "No network") + with self.assertRaises(fd.DeviceError): + self.reg.name_network("n-unknown", "x") + + +class Order(unittest.TestCase): + def addr(self, host, kind, networks=()): + return {"host": host, "kind": kind, "networks": list(networks)} + + def test_known_here_then_mdns_then_tailscale_then_the_rest(self): + addrs = [self.addr("10.1.1.5", "lan", ["n-office"]), self.addr("192.168.1.40", "lan"), + self.addr("100.64.1.2", "tailscale"), self.addr("frame.local", "mdns"), + self.addr("192.168.1.237", "lan", ["n-home"])] + order = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)] + self.assertEqual(order, ["192.168.1.237", "frame.local", "100.64.1.2", "192.168.1.40", "10.1.1.5"]) + # Tailscale off: its addresses go last. + order = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", False)] + self.assertEqual(order[-1], "100.64.1.2") + # On an unknown network nothing has worked yet; the user's order breaks ties. + ranked = fd.order_addresses(addrs, None, True) + self.assertEqual([a["host"] for a, _ in ranked], ["frame.local", "100.64.1.2", "192.168.1.40", "10.1.1.5", "192.168.1.237"]) + self.assertEqual(ranked[0][1], "mDNS name") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_frame_apk.py b/tests/test_frame_apk.py index 1af14f9..ebe7b0b 100644 --- a/tests/test_frame_apk.py +++ b/tests/test_frame_apk.py @@ -1,4 +1,5 @@ """frame_apk against a small APK built here: binary manifest plus resource table.""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) import io import os import struct diff --git a/tests/test_frame_apk_versions.py b/tests/test_frame_apk_versions.py index 8cf7701..ae19175 100644 --- a/tests/test_frame_apk_versions.py +++ b/tests/test_frame_apk_versions.py @@ -1,4 +1,5 @@ """Offline version lookup with small index-v2 fixtures.""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) import io import json import os diff --git a/tests/test_frame_titles.py b/tests/test_frame_titles.py index 55d080d..acb9454 100644 --- a/tests/test_frame_titles.py +++ b/tests/test_frame_titles.py @@ -1,4 +1,5 @@ """frame_titles without a headset: executable headers, launch targets, zips, runtimes.""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) import json import os import shutil diff --git a/tests/test_link.py b/tests/test_link.py new file mode 100644 index 0000000..a2575b4 --- /dev/null +++ b/tests/test_link.py @@ -0,0 +1,337 @@ +"""frame_link: finding a headset among its addresses and following each stage of +connecting, with a stand-in ssh (tests/fakessh/ssh) and real sockets on this computer. +Also the server's /api/connection, its event stream, and /api/devices. + +Run: python3 -m unittest discover -s tests +""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) +import http.client +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import threading +import time +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_devices as fd # noqa: E402 +import frame_link as fl # noqa: E402 +import frame_network as fn # noqa: E402 + +FAKESSH = ROOT / "tests" / "fakessh" +NET = {"id": "n-test", "gateway": "192.168.1.1", "gateway_mac": "aa:bb:cc:dd:ee:ff", "interface": "en0", + "ssid": None, "wifi": True, "local_ip": "192.168.1.9", "tailscale": {"up": False, "installed": False}} + + +def explain(msg): + """A cut-down server.unreachable, so this needs no server import.""" + if "Could not resolve" in msg: + return "Can't find the Frame on the network." + if "refused" in msg: + return "The Frame refused the connection." + if "timed out" in msg.lower(): + return "The Frame isn't answering." + if "Permission denied" in msg: + return "The Frame didn't accept this computer's SSH key." + return None + + +class Probe(unittest.TestCase): + def test_answers_refusals_and_unknown_names(self): + with socket.socket() as srv: + srv.bind(("127.0.0.1", 0)) + srv.listen(4) + port = srv.getsockname()[1] + seen = [] + res = fl.probe("127.0.0.1", port, update=lambda **f: seen.append(f["state"])) + self.assertEqual(res["state"], "answered") + self.assertEqual(res["ip"], "127.0.0.1") + self.assertIsInstance(res["rtt_ms"], float) + self.assertEqual(seen, ["resolving", "trying"]) + self.assertEqual(fl.probe("127.0.0.1", port, timeout=2)["state"], "refused") # closed now + self.assertEqual(fl.probe("frame-control-test.invalid", 22, timeout=2)["state"], "unresolved") + + def test_failed_probes_read_like_ssh(self): + # So the server's UNREACHABLE table words them like any other ssh failure. + self.assertIn("Could not resolve hostname x", fl.probe_raw("x", 22, {"state": "unresolved"})) + self.assertIn("port 22: Connection refused", fl.probe_raw("x", 22, {"state": "refused"})) + self.assertIn("Operation timed out", fl.probe_raw("x", 22, {"state": "timeout"})) + + +class Pick(unittest.TestCase): + def pick(self, results, tried=()): + return fl.Link.pick(results, set(tried), threading.Condition(), time.monotonic() + 5) + + def test_best_ranked_answer_wins(self): + now = time.monotonic() + ok = lambda t=now: {"state": "answered", "t": t} + no = {"state": "timeout", "t": now} + self.assertEqual(self.pick([no, ok(), ok()]), 1) + self.assertEqual(self.pick([no, ok(), ok()], tried=[1]), 2) + self.assertIsNone(self.pick([no, no])) + # A worse-ranked answer waits PREFER for a better one still trying, then goes. + t0 = time.monotonic() + self.assertEqual(self.pick([None, ok(time.monotonic())]), 1) + self.assertGreaterEqual(time.monotonic() - t0, fl.PREFER - 0.05) + + def test_gives_up_on_slow_lookups_at_the_deadline(self): + t0 = time.monotonic() + results = [None] + self.assertIsNone(fl.Link.pick(results, set(), threading.Condition(), time.monotonic() + 0.3)) + self.assertLess(time.monotonic() - t0, 2) + self.assertEqual(results[0]["state"], "timeout") + + +@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script") +class Connecting(unittest.TestCase): + def setUp(self): + self.dir = Path(tempfile.mkdtemp(prefix="frame-link-")) + self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True) + (self.dir / "ssh").mkdir() + self.log = self.dir / "calls.jsonl" + env = {"FRAME_CONTROL_SSH_DIR": str(self.dir / "ssh"), "FAKESSH_LOG": str(self.log), + "FAKESSH_DIR": str(self.dir), "PATH": f"{FAKESSH}{os.pathsep}{os.environ['PATH']}"} + patcher = mock.patch.dict(os.environ, env) + patcher.start() + self.addCleanup(patcher.stop) + for name, value in (("current_network", lambda *a, **k: dict(NET)), ("fingerprint", lambda: ("192.168.1.1", "en0", "aa:bb:cc:dd:ee:ff"))): + p = mock.patch.object(fn, name, value) + p.start() + self.addCleanup(p.stop) + self.srv = socket.socket() + self.srv.bind(("127.0.0.1", 0)) + self.srv.listen(16) + self.addCleanup(self.srv.close) + self.port = self.srv.getsockname()[1] + self.reg = fd.Registry(self.dir / "devices.json") + self.routes = [] + self.link = fl.Link(self.reg, env_alias=None, mux_base=["ssh", "-o", "BatchMode=yes", "-o", "ControlPath=x"], + control="x", apply=lambda alias, opts: self.routes.append((alias, list(opts))), + explain=explain) + self.addCleanup(self.link.stop) + + def hosts(self, mapping): + os.environ["FAKESSH_HOSTS"] = json.dumps(mapping) + + def calls(self): + return [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else [] + + def device(self, *hosts): + d = self.reg.add_device("frame-t", port=self.port, hosts=[]) + for h in hosts: + self.reg.add_address(d["id"], h, kind="lan") + return d + + def test_falls_through_to_the_address_that_is_really_the_headset(self): + # Tried in this order: a name that doesn't resolve, a different device, the headset. + d = self.device("nothing.invalid", "127.0.0.1", "localhost") + self.hosts({"127.0.0.1": "wrong", "localhost": "ok"}) + self.link.connect(["start"]) + s = self.link.snapshot() + self.assertEqual(s["phase"], "connected", s["error"]) + self.assertEqual(s["via"]["host"], "localhost") + self.assertEqual([st["state"] for st in s["stages"]], ["done"] * 5) + rows = {p["host"]: p for p in s["probes"]} + self.assertEqual(rows["nothing.invalid"]["state"], "unresolved") + self.assertEqual(rows["127.0.0.1"]["state"], "sshfailed") + self.assertIn("different headset", rows["127.0.0.1"]["detail"]) + # Every ssh command was pointed at the winner, with the host key pinned per device. + alias, opts = self.routes[-1] + self.assertEqual(alias, "frame-t") + self.assertIn(f"HostName=localhost", opts) + self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", opts) + self.assertIn(f"Port={self.port}", opts) + master = [c for c in self.calls() if "ControlMaster=yes" in c][-1] + self.assertIn("StrictHostKeyChecking=accept-new", master) # first connection: nothing pinned yet + # It learned: localhost works on this network. + learned = {a["host"]: a for a in self.reg.get(d["id"])["addresses"]} + self.assertEqual(learned["localhost"]["networks"], ["n-test"]) + self.assertEqual(learned["127.0.0.1"]["networks"], []) + self.assertTrue(self.link.alive()) + self.link.close_master() + self.assertFalse(any(p.name.startswith("master-") for p in self.dir.iterdir())) + + def test_stages_are_published_as_they_happen(self): + self.device("localhost") + self.hosts({"localhost": "ok"}) + steps, versions = [], [] + real = self.link.stage + + def stage(sid, state, detail=None): + real(sid, state, detail) + steps.append((sid, state)) + versions.append(self.link.snapshot()["version"]) + self.link.stage = stage + before = self.link.snapshot()["version"] + self.assertIsNone(self.link.wait(before, 0.05)) # nothing new yet + self.link.connect(["start"]) + started = [sid for sid, state in steps if state == "active"] + self.assertEqual(list(dict.fromkeys(started)), ["network", "find", "ssh", "identity", "login"]) + self.assertEqual([sid for sid, state in steps if state == "done"][-3:], ["ssh", "identity", "login"]) + self.assertEqual(versions, sorted(versions)) # every step is a new version for the page + self.assertEqual(self.link.wait(before, 1)["phase"], "connected") + + def test_nothing_answers(self): + self.device("nothing.invalid", "also-nothing.invalid") + self.link.connect(["start"]) + s = self.link.snapshot() + self.assertEqual(s["phase"], "failed") + self.assertEqual(s["error"]["stage"], "find") + self.assertEqual(s["error"]["message"], "Can't find the Frame on the network.") + self.assertGreater(s["retry_at"], time.time()) + self.assertEqual([st["state"] for st in s["stages"]][:2], ["done", "failed"]) + + def test_refused_key_stops_at_login(self): + self.device("localhost") + self.hosts({"localhost": "denied"}) + self.link.connect(["start"]) + s = self.link.snapshot() + self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "login")) + self.assertIn("SSH key", s["error"]["message"]) + + def test_pinned_identity_is_checked_strictly(self): + d = self.device("localhost") + (self.dir / "ssh" / "frame-control_known_hosts").write_text(f"frame-control-{d['id']} ssh-ed25519 AAAA\n") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + master = [c for c in self.calls() if "ControlMaster=yes" in c][-1] + self.assertIn("StrictHostKeyChecking=yes", master) + + def test_a_bare_alias_lets_ssh_config_decide(self): + self.link.override = "frame-bare" + self.hosts({"frame-bare": "ok"}) # the stand-in ssh has no config: the alias is the host + with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester")): + self.link.connect(["start"]) + s = self.link.snapshot() + self.assertEqual(s["phase"], "connected", s["error"]) + self.assertTrue(s["device"]["transient"]) + self.assertEqual(self.routes[-1], ("frame-bare", [])) # no HostName override, ssh's own known_hosts + + def test_test_now_checks_every_address_without_touching_the_connection(self): + d = self.device("127.0.0.1", "localhost", "nothing.invalid") + (self.dir / "ssh" / "frame-control_known_hosts").write_text(f"frame-control-{d['id']} ssh-ed25519 AAAA\n") + self.hosts({"127.0.0.1": "wrong", "localhost": "ok"}) + self.link.test(d["id"]) + rows = {r["host"]: r for r in self.link.snapshot()["tests"][d["id"]]["rows"]} + self.assertEqual(rows["localhost"]["ssh"], "ok") + self.assertEqual(rows["127.0.0.1"]["ssh"], "wrong") + self.assertEqual(rows["nothing.invalid"]["state"], "unresolved") + self.assertEqual(self.routes, []) + self.assertTrue(all("ControlPath=none" in c for c in self.calls())) + + def test_devices_api_checks_everything(self): + d = self.device("localhost") + bad = [{"action": "address-add", "id": d["id"], "host": "-oProxyCommand=touch /tmp/x"}, + {"action": "address-add", "id": d["id"], "host": "a\nHost *"}, + {"action": "address-add", "id": d["id"], "host": "frame.local", "kind": "wifi"}, + {"action": "update", "id": d["id"], "user": "root; id"}, + {"action": "update", "id": d["id"], "port": 0}, + {"action": "address-move", "id": d["id"], "host": "localhost", "delta": 5}, + {"action": "setup", "alias": "-F/etc/passwd"}, + {"action": "setup", "alias": "frame-9", "host": "$(id)"}, + {"action": "use", "id": "nope"}, + {"action": "explode"}] + for body in bad: + with self.assertRaises(fd.DeviceError, msg=body): + fl.devices_action(self.link, body, open_setup=lambda *a: self.fail("setup ran")) + opened = [] + out = fl.devices_action(self.link, {"action": "setup", "alias": "frame-9", "host": "192.168.1.50"}, + open_setup=lambda alias, host: opened.append((alias, host)) or "a terminal") + self.assertEqual(opened, [("frame-9", "192.168.1.50")]) + self.assertIn("frame-9", out["message"]) + self.assertEqual(out["active"], d["id"]) + self.assertEqual(fl.next_alias(self.link), "frame") + + +@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script") +class ServerConnection(unittest.TestCase): + """The real server, a Set Up Connection block in a stand-in ~/.ssh, and the stand-in ssh.""" + + @classmethod + def setUpClass(cls): + cls.dir = Path(tempfile.mkdtemp(prefix="frame-link-server-")) + ssh_dir = cls.dir / "ssh" + ssh_dir.mkdir() + cls.srv = socket.socket() # the "headset's" port 22 + cls.srv.bind(("127.0.0.1", 0)) + cls.srv.listen(16) + (ssh_dir / "config").write_text("# >>> steam-frame (frame) >>>\nHost frame\n HostName localhost\n" + f" Port {cls.srv.getsockname()[1]}\n" + " User steamos\nHost *\n# <<< steam-frame (frame) <<<\n") + env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1", "FRAME_CONTROL_SSH_DIR": str(ssh_dir), + "FRAME_CONTROL_DATA_DIR": str(cls.dir / "data"), "FAKESSH_LOG": str(cls.dir / "calls.jsonl"), + "FAKESSH_DIR": str(cls.dir), "FAKESSH_HOSTS": json.dumps({"localhost": "ok"}), + "PATH": f"{FAKESSH}{os.pathsep}{os.environ['PATH']}"} + env.pop("FRAME_ALIAS", None) + cls.log = tempfile.TemporaryFile() + cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env, + stdout=subprocess.PIPE, stderr=cls.log, text=True) + cls.port = int(cls.proc.stdout.readline().split("127.0.0.1:")[1].split()[0]) + + @classmethod + def tearDownClass(cls): + cls.proc.terminate() + cls.proc.wait(timeout=15) + cls.proc.stdout.close() + cls.log.close() + cls.srv.close() + shutil.rmtree(cls.dir, ignore_errors=True) + + def request(self, method, path, body=None, key="1"): + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20) + conn.request(method, path, body=json.dumps(body).encode() if body is not None else None, + headers={"X-Frame-UI": key, "Content-Type": "application/json"}) + r = conn.getresponse() + data = json.loads(r.read() or b"{}") + conn.close() + return r.status, data + + def wait_connected(self): + for _ in range(100): + status, s = self.request("GET", "/api/connection") + if s.get("phase") in ("connected", "failed"): + return s + time.sleep(0.1) + self.fail(f"never connected: {s}") + + def test_imports_the_headset_and_connects_through_its_port(self): + s = self.wait_connected() + self.assertEqual(s["phase"], "connected", s["error"]) + self.assertEqual(s["via"]["host"], "localhost") + self.assertEqual(s["device"]["alias"], "frame") + self.assertEqual(s["device"]["name"], "Steam Frame") + self.assertEqual(s["probes"][0]["host"], "localhost") + status, devices = self.request("GET", "/api/devices") + self.assertEqual(status, 200) + self.assertEqual([d["alias"] for d in devices["devices"]], ["frame"]) + self.assertEqual(devices["nextAlias"], "frame-2") + + def test_events_stream_the_state(self): + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20) + conn.request("GET", "/api/connection/events", headers={"X-Frame-UI": "1"}) + r = conn.getresponse() + self.assertEqual(r.status, 200) + self.assertEqual(r.getheader("Content-Type"), "text/event-stream") + line = r.fp.readline() + self.assertTrue(line.startswith(b"data: "), line) + self.assertIn("stages", json.loads(line[6:])) + conn.close() + + def test_guards_and_validation(self): + self.assertEqual(self.request("GET", "/api/connection", key="")[0], 403) + self.assertEqual(self.request("GET", "/api/devices", key="nope")[0], 403) + self.assertEqual(self.request("POST", "/api/devices", {"action": "address-add", "id": "x", "host": "a;b"})[0], 400) + self.assertEqual(self.request("POST", "/api/devices", {"action": "setup", "alias": "-oProxyCommand=x"})[0], 400) + self.assertEqual(self.request("POST", "/api/devices", {"action": "nope"})[0], 400) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100644 index 0000000..7a14419 --- /dev/null +++ b/tests/test_network.py @@ -0,0 +1,147 @@ +"""frame_network's parsers, with what macOS, Linux and Windows print. + +Run: python3 -m unittest discover -s tests +""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) +import json +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "ui")) + +import frame_network as fn # noqa: E402 + +MAC_ROUTE = """ route to: default +destination: default + mask: default + gateway: 192.168.1.1 + interface: en0 + flags: +""" +MAC_ARP = "? (192.168.1.1) at b4:fb:e4:1:87:3f on en0 ifscope [ethernet]\n" +MAC_ARP_INCOMPLETE = "? (192.168.1.1) at (incomplete) on en0 ifscope [ethernet]\n" +MAC_SUMMARY = """ { + BSSID : + ConnectionID : 1 + InterfaceType : WiFi + LinkStatusActive : TRUE + NetworkID : + SSID : + Security : WPA2_PSK +}""" +MAC_SUMMARY_NAMED = MAC_SUMMARY.replace("SSID : \n Security", "SSID : Home Net\n Security") +MAC_SUMMARY_WIRED = " {\n InterfaceType : Ethernet\n LinkStatusActive : TRUE\n}" + +LINUX_ROUTE = """default via 10.0.0.1 dev wlp2s0 proto dhcp src 10.0.0.23 metric 600 +default via 192.168.50.1 dev enp3s0 proto dhcp src 192.168.50.9 metric 100 +""" +LINUX_NEIGH = "192.168.50.1 dev enp3s0 lladdr 00:11:22:aa:bb:cc REACHABLE\n" +NMCLI = "no:Neighbour\nyes:Cafe\\: upstairs\nno:\n" + +WIN_ROUTE = """=========================================================================== +Interface List + 12...00 15 5d 01 02 03 ......Intel(R) Wi-Fi 6 AX201 160MHz +=========================================================================== + +IPv4 Route Table +=========================================================================== +Active Routes: +Network Destination Netmask Gateway Interface Metric + 0.0.0.0 0.0.0.0 192.168.0.254 192.168.0.40 50 + 0.0.0.0 0.0.0.0 192.168.1.1 192.168.1.50 35 +=========================================================================== +Persistent Routes: + None +""" +WIN_ARP = """ +Interface: 192.168.1.50 --- 0xc + Internet Address Physical Address Type + 192.168.1.1 b4-fb-e4-b5-67-55 dynamic +""" +NETSH = """ +There is 1 interface on the system: + + Name : Wi-Fi + Description : Intel(R) Wi-Fi 6 AX201 160MHz + State : connected + SSID : Office 5G + BSSID : 12:34:56:78:9a:bc + Network type : Infrastructure +""" +NETSH_OFF = NETSH.replace("State : connected", "State : disconnected") + +TAILSCALE = json.dumps({ + "BackendState": "Running", + "CurrentTailnet": {"Name": "example.github"}, + "Self": {"HostName": "laptop", "DNSName": "laptop.tail1234.ts.net.", "TailscaleIPs": ["fd7a:115c:a1e0::1", "100.101.102.103"]}, + "Peer": {"nodekey:1": {"HostName": "frame", "DNSName": "frame.tail1234.ts.net.", "OS": "linux", "Online": True, + "TailscaleIPs": ["100.113.174.84", "fd7a:115c:a1e0::5928:ae55"]}, + "nodekey:2": {"HostName": "phone", "DNSName": "phone.tail1234.ts.net.", "OS": "iOS", "Online": False, + "TailscaleIPs": ["100.77.1.2"]}}, +}) + + +class Parsers(unittest.TestCase): + def test_macos(self): + self.assertEqual(fn.parse_route_macos(MAC_ROUTE), ("192.168.1.1", "en0")) + self.assertEqual(fn.parse_route_macos("route: writing to routing socket: not in table\n"), (None, None)) + self.assertEqual(fn.parse_arp_macos(MAC_ARP, "192.168.1.1"), "b4:fb:e4:01:87:3f") # padded + self.assertIsNone(fn.parse_arp_macos(MAC_ARP_INCOMPLETE, "192.168.1.1")) + self.assertIsNone(fn.parse_arp_macos(MAC_ARP, "192.168.1.10")) + self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY), (None, True)) # no Location permission + self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY_NAMED), ("Home Net", True)) + self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY_WIRED), (None, False)) + + def test_linux(self): + self.assertEqual(fn.parse_route_linux(LINUX_ROUTE), ("192.168.50.1", "enp3s0")) # lowest metric + self.assertEqual(fn.parse_route_linux(""), (None, None)) + self.assertEqual(fn.parse_neigh_linux(LINUX_NEIGH, "192.168.50.1"), "00:11:22:aa:bb:cc") + self.assertIsNone(fn.parse_neigh_linux("192.168.50.1 dev enp3s0 FAILED\n", "192.168.50.1")) + self.assertEqual(fn.parse_nmcli(NMCLI), "Cafe: upstairs") + self.assertIsNone(fn.parse_nmcli("no:Neighbour\n")) + + def test_windows(self): + self.assertEqual(fn.parse_route_windows(WIN_ROUTE), ("192.168.1.1", "192.168.1.50")) + self.assertEqual(fn.parse_arp_windows(WIN_ARP, "192.168.1.1"), "b4:fb:e4:b5:67:55") + self.assertEqual(fn.parse_netsh(NETSH), "Office 5G") # not the BSSID + self.assertIsNone(fn.parse_netsh(NETSH_OFF)) + + def test_mac_addresses(self): + self.assertEqual(fn.norm_mac("B4-FB-E4-B5-67-55"), "b4:fb:e4:b5:67:55") + for bad in ("", "(incomplete)", "ff:ff:ff:ff:ff:ff", "00:00:00:00:00:00", "b4:fb:e4:b5:67", "zz:fb:e4:b5:67:55"): + self.assertIsNone(fn.norm_mac(bad), bad) + + def test_network_id_is_stable_and_needs_both_parts(self): + a = fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:55") + self.assertEqual(a, fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:55")) + self.assertTrue(a.startswith("n-")) + self.assertNotEqual(a, fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:56")) # same IP, another router + self.assertIsNone(fn.network_id("192.168.1.1", None)) + self.assertIsNone(fn.network_id(None, "b4:fb:e4:b5:67:55")) + + def test_tailscale(self): + ts = fn.parse_tailscale(TAILSCALE) + self.assertTrue(ts["up"]) + self.assertEqual(ts["ip"], "100.101.102.103") + self.assertEqual(ts["name"], "laptop.tail1234.ts.net") + self.assertEqual(ts["tailnet"], "example.github") + frame = ts["peers"][0] + self.assertEqual((frame["name"], frame["dns"], frame["os"], frame["online"]), + ("frame", "frame.tail1234.ts.net", "linux", True)) + self.assertFalse(fn.parse_tailscale(json.dumps({"BackendState": "Stopped", "Self": {}}))["up"]) + self.assertEqual(fn.parse_tailscale("not json"), {"up": False, "peers": []}) + self.assertEqual(fn.parse_tailscale("[]"), {"up": False, "peers": []}) + + def test_address_kinds(self): + cases = {"frame.local": "mdns", "frame.local.": "mdns", "frame.tail1234.ts.net": "tailscale", + "100.113.174.84": "tailscale", "fd7a:115c:a1e0::5928:ae55": "tailscale", + "192.168.1.40": "lan", "10.0.0.5": "lan", "fe80::1%en0": "lan", + "frame.example.com": "manual", "8.8.8.8": "manual"} + for host, kind in cases.items(): + self.assertEqual(fn.guess_kind(host), kind, host) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_server.py b/tests/test_server.py index ec389bd..31167d3 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -5,6 +5,7 @@ request guards and input validation, which all run before any SSH call. Run: python3 -m unittest discover -s tests """ +import sandbox # noqa: F401 (first: keeps tests off real data and services) import http.client import io import json @@ -33,7 +34,9 @@ class ServerGuards(unittest.TestCase): @classmethod def setUpClass(cls): cls.port = free_port() - env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1"} + cls.ssh_dir = tempfile.mkdtemp(prefix="frame-control-ssh-") # an empty ~/.ssh: no headsets set up + env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1", + "FRAME_CONTROL_SSH_DIR": cls.ssh_dir} cls.log = tempfile.TemporaryFile() cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(cls.port)], env=env, stdout=cls.log, stderr=subprocess.STDOUT) diff --git a/tests/test_steam.py b/tests/test_steam.py index d29d975..615bf1e 100644 --- a/tests/test_steam.py +++ b/tests/test_steam.py @@ -2,6 +2,7 @@ Run: python3 -m unittest discover -s tests """ +import sandbox # noqa: F401 (first: keeps tests off real data and services) import json import subprocess import sys diff --git a/tests/test_webinstall.py b/tests/test_webinstall.py index 1858297..e197b49 100644 --- a/tests/test_webinstall.py +++ b/tests/test_webinstall.py @@ -4,6 +4,7 @@ the localhost-testing rule allows. Run: python3 -m unittest discover -s tests """ +import sandbox # noqa: F401 (first: keeps tests off real data and services) import hashlib import json import os diff --git a/ui/frame_connect.py b/ui/frame_connect.py index 9b75ded..6aefc4b 100644 --- a/ui/frame_connect.py +++ b/ui/frame_connect.py @@ -6,8 +6,9 @@ asking for the Developer Mode password once. The Linux and Windows twin of scripts/connect.sh (which the Mac app uses); same config block, so either can re-run over the other. Idempotent. -Usage: python3 ui/frame_connect.py [HOST_OR_IP[:PORT]] -Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame) +Usage: python3 ui/frame_connect.py [--alias NAME] [HOST_OR_IP[:PORT]] +Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame; --alias wins, for + terminals that don't pass the environment on, like Windows' `start`) """ import base64 import json @@ -367,9 +368,22 @@ def pair_with_devkit(host, port, user): return chosen[0], "paired, but key login still fails" +def use_alias(alias): + """--alias: set up another headset under its own ~/.ssh/config alias (Devices tab).""" + global FRAME_ALIAS, BEGIN, END + if not NAME_RE.fullmatch(alias): + sys.exit(f"--alias must be a plain name, not {alias!r}") + FRAME_ALIAS = alias + BEGIN = f"# >>> steam-frame ({FRAME_ALIAS}) >>>" + END = f"# <<< steam-frame ({FRAME_ALIAS}) <<<" + + def main(argv): if argv and argv[0] in ("-h", "--help"): sys.exit(__doc__) + if len(argv) >= 2 and argv[0] == "--alias": + use_alias(argv[1]) + argv = argv[2:] say("==> Looking for the Steam Frame") found = pick_host(argv[0] if argv else None) while not found: diff --git a/ui/frame_devices.py b/ui/frame_devices.py new file mode 100644 index 0000000..3b5a517 --- /dev/null +++ b/ui/frame_devices.py @@ -0,0 +1,620 @@ +"""The headsets Frame Control knows, and the addresses each can be reached at. + +One headset can answer at several addresses: a LAN IP at home, another in the +office, its mDNS name (frame.local), its Tailscale IP or MagicDNS name. The +registry keeps them all, learns which worked on which network, and hands the +connector (frame_link.py) an order to try them in. + +Stored as JSON in frame_host.data_dir("devices.json"). The format is plain so the +iPhone app can share it later; docs/devices.md describes it: + + {"version": 1, "active": "", + "devices": [{"id", "name", "alias", "user", "port", "identity_files", + "addresses": [{"host", "kind": lan|mdns|tailscale|manual, "label", + "networks": [network ids it worked on], "last_ok", "last_rtt_ms"}]}], + "networks": {"": {"name", "ssid", "gateway", "gateway_mac", "last_seen"}}} + +Headsets set up before this existed live only in ~/.ssh/config, in the managed +`# >>> steam-frame (ALIAS) >>>` blocks that scripts/connect.sh and +ui/frame_connect.py write; they're imported from there, so nobody has to add +them again. Each device keeps its alias: Terminal's `ssh frame` and the helper +scripts go on working, and the connector rewrites the block's HostName to the +last address that worked, so they follow it. + +Host keys are pinned per headset, not per address: ssh gets +`-o HostKeyAlias=frame-control-` and a known_hosts file of our own, so a +different device answering at a remembered IP is caught. + +Python stdlib only. +""" +import copy +import json +import os +import re +import secrets +import subprocess +import threading +import time +from pathlib import Path + +import frame_host +import frame_network + +VERSION = 1 +# Everything here can end up in ssh arguments or ~/.ssh/config, so nothing that +# could start an option, add a line, or carry a directive. +NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}") +HOST_RE = re.compile(r"[A-Za-z0-9:][A-Za-z0-9.:-]{0,252}(%[A-Za-z0-9._-]{1,32})?") +TEXT_MAX = 60 +KINDS = ("lan", "mdns", "tailscale", "manual") +KIND_LABEL = {"lan": "Local network", "mdns": "mDNS (.local)", "tailscale": "Tailscale", "manual": "Other"} +DEFAULT_USER = "steamos" + + +class DeviceError(ValueError): + """Bad input from the page; the server answers 400 with the message.""" + + +def ssh_dir(): + """~/.ssh, or $FRAME_CONTROL_SSH_DIR in tests so they never touch the real one.""" + return Path(os.environ.get("FRAME_CONTROL_SSH_DIR") or Path.home() / ".ssh") + + +def ssh_config(): + return ssh_dir() / "config" + + +def known_hosts(): + return ssh_dir() / "frame-control_known_hosts" + + +def known_hosts_opt(): + """How ssh is told about our known_hosts file. `~` rather than the full path when it's + the usual place, so a home folder with a space in its name can't split the option.""" + return "~/.ssh/frame-control_known_hosts" if not os.environ.get("FRAME_CONTROL_SSH_DIR") else str(known_hosts()) + + +def host_key_alias(device_id): + return f"frame-control-{device_id}" + + +# ---- validation ---------------------------------------------------------------- + +def check_alias(alias): + if not isinstance(alias, str) or not NAME_RE.fullmatch(alias): + raise DeviceError("The SSH alias must be a plain name: letters, digits, dot, dash or underscore") + return alias + + +def check_user(user): + if not isinstance(user, str) or not NAME_RE.fullmatch(user): + raise DeviceError("The user name must be letters, digits, dot, dash or underscore") + return user + + +def check_host(host): + host = host.strip() if isinstance(host, str) else host + if (not isinstance(host, str) or not HOST_RE.fullmatch(host) or ".." in host + or ("%" in host and ":" not in host.split("%")[0])): # a zone only follows an IPv6 address + raise DeviceError(f"{host!r} isn't a host name or IP address") + return host + + +def check_port(port): + try: + port = int(port) + except (TypeError, ValueError): + raise DeviceError("The port must be a number") from None + if not 1 <= port <= 65535: + raise DeviceError("The port must be between 1 and 65535") + return port + + +def check_text(text, what): + text = (text or "").strip() if isinstance(text, (str, type(None))) else None + if text is None or len(text) > TEXT_MAX or re.search(r"[\x00-\x1f\x7f]", text): + raise DeviceError(f"The {what} must be plain text of at most {TEXT_MAX} characters") + return text + + +def check_kind(kind): + if kind not in KINDS: + raise DeviceError(f"The kind must be one of {', '.join(KINDS)}") + return kind + + +def ssh_host(host): + """A host for ssh's HostName, which expands %-tokens: an IPv6 zone's % is doubled.""" + return host.replace("%", "%%") + + +# ---- ~/.ssh/config's managed blocks --------------------------------------------- + +BLOCK_RE = re.compile(r"# >>> steam-frame \((" + NAME_RE.pattern + r")\) >>>") + + +def begin_mark(alias): + return f"# >>> steam-frame ({alias}) >>>" + + +def end_mark(alias): + return f"# <<< steam-frame ({alias}) <<<" + + +def parse_blocks(text): + """The managed blocks: [{"alias", "hostname", "user", "port", "identity_files"}].""" + blocks, cur = [], None + for line in text.splitlines(): + m = BLOCK_RE.fullmatch(line.strip()) + if m: + cur = {"alias": m.group(1), "hostname": None, "user": None, "port": 22, "identity_files": []} + continue + if cur is None: + continue + if line.strip() == end_mark(cur["alias"]): + blocks.append(cur) + cur = None + continue + f = line.split(None, 1) + if len(f) != 2: + continue + key, value = f[0].lower(), f[1].strip() + if key == "hostname" and cur["hostname"] is None: + cur["hostname"] = value.replace("%%", "%") + elif key == "user" and cur["user"] is None: + cur["user"] = value + elif key == "port" and value.isdigit(): + cur["port"] = int(value) + elif key == "identityfile": + cur["identity_files"].append(value) + return blocks + + +def read_config(path=None): + path = Path(path or ssh_config()) + try: + return path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return "" + + +def _write_config(path, text): + """Swap the file in whole (same as frame_connect.write_config), keeping it private.""" + tmp = path.with_name("config.frame-control.tmp") + tmp.write_text(text, encoding="utf-8") + if not frame_host.WINDOWS: + tmp.chmod(0o600) + for attempt in range(20): # Windows: a running ssh.exe can hold the file for a moment + try: + os.replace(tmp, path) + return + except PermissionError: + time.sleep(0.25) + tmp.unlink() + raise OSError(f"{path} stayed locked by another program") + + +def rewrite_block(alias, path=None, hostname=None, user=None, port=None): + """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.""" + path = Path(path or ssh_config()) + text = read_config(path) + lines = text.splitlines() + begin, end = begin_mark(alias), end_mark(alias) + if begin not in lines or end not in lines: + return False + i, j = lines.index(begin), lines.index(end) + if j < i: + return False + block = lines[i:j] + want = {"hostname": ssh_host(hostname) if hostname else None, "user": user, + "port": str(port) if port else None} + out, seen = [], set() + for line in block: + f = line.split(None, 1) + key = f[0].lower() if f else "" + if key in want and want[key] is not None and key not in seen: + seen.add(key) + if key == "port" and want[key] == "22": + continue # the default; connect.sh leaves it out + out.append(f" {f[0]} {want[key]}") + else: + out.append(line) + if want["port"] and want["port"] != "22" and "port" not in seen: + at = next((n + 1 for n, line in enumerate(out) if line.split(None, 1)[:1] == ["HostName"]), 2) + out.insert(at, f" Port {want['port']}") + new = lines[:i] + out + lines[j:] + if new == lines: + return False + _write_config(path, "\n".join(new) + "\n") + return True + + +def remove_block(alias, path=None): + path = Path(path or ssh_config()) + lines = read_config(path).splitlines() + begin, end = begin_mark(alias), end_mark(alias) + if begin not in lines or end not in lines: + return False + i, j = lines.index(begin), lines.index(end) + if j < i: + return False + _write_config(path, "\n".join(lines[:i] + lines[j + 1:]) + "\n") + return True + + +# ---- pinned host keys ------------------------------------------------------------- + +def _pin_lines(path=None): + try: + return Path(path or known_hosts()).read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + return [] + + +def pinned(device_id, path=None): + name = host_key_alias(device_id) + return any(line.split(None, 1)[0].split(",").count(name) for line in _pin_lines(path) + if line.strip() and not line.startswith("#")) + + +def seed_pin(device_id, hosts, port=22, sources=None, path=None): + """Copy the host keys ssh already trusts for one of `hosts` into our file under the + device's alias, so moving to per-device pinning asks nobody to trust anything again. + -> True if a key was pinned.""" + if pinned(device_id, path): + return True + sources = sources or [ssh_dir() / "known_hosts", ssh_dir() / "known_hosts2"] + name = host_key_alias(device_id) + for host in hosts: + wanted = host if port == 22 else f"[{host}]:{port}" + keys = [] + for src in sources: + if not Path(src).is_file(): + continue + try: + out = subprocess.run(["ssh-keygen", "-F", wanted, "-f", str(src)], capture_output=True, + stdin=subprocess.DEVNULL, text=True, timeout=10).stdout + except (OSError, subprocess.TimeoutExpired): + continue + for line in out.splitlines(): + f = line.split() + if len(f) >= 3 and not line.startswith("#") and not f[0].startswith("@"): + keys.append(f"{name} {f[1]} {f[2]}") + if keys: + target = Path(path or known_hosts()) + target.parent.mkdir(parents=True, exist_ok=True) + with open(target, "a", encoding="utf-8") as fh: + fh.write("\n".join(dict.fromkeys(keys)) + "\n") + if not frame_host.WINDOWS: + target.chmod(0o600) + return True + return False + + +def forget_pin(device_id, path=None): + """Drop a device's pinned keys, e.g. after SteamOS was reinstalled. The next connection + trusts whatever key the headset shows, as a first connection does.""" + target = Path(path or known_hosts()) + name = host_key_alias(device_id) + lines = _pin_lines(target) + kept = [line for line in lines if not (line.strip() and name in line.split(None, 1)[0].split(","))] + if kept != lines: + target.write_text("".join(line + "\n" for line in kept), encoding="utf-8") + return True + return False + + +# ---- address order -------------------------------------------------------------------- + +def order_addresses(addresses, network_id, tailscale_up): + """The order to try a device's addresses in, each with why it's there: + known to work on this network, then mDNS, then Tailscale if it's up, then the rest + (addresses that only ever worked elsewhere last). The user's order breaks ties.""" + def group(a): + nets = a.get("networks") or [] + if network_id and network_id in nets: + return 0, "worked on this network before" + if a["kind"] == "mdns": + return 1, "mDNS name" + if a["kind"] == "tailscale": + return (2, "Tailscale") if tailscale_up else (5, "Tailscale isn't running") + if nets: + return 4, "worked on another network" + return 3, "not tried on this network yet" + ranked = sorted(enumerate(addresses), key=lambda p: (group(p[1])[0], p[0])) + return [(a, group(a)[1]) for _, a in ranked] + + +# ---- the registry ------------------------------------------------------------------------ + +def new_address(host, kind=None, label=""): + host = check_host(host) + return {"host": host, "kind": check_kind(kind) if kind else frame_network.guess_kind(host), + "label": check_text(label, "label"), "networks": [], "last_ok": None, "last_rtt_ms": None} + + +class Registry: + """devices.json, loaded once and saved on every change. Thread-safe.""" + + def __init__(self, path=None, config=None): + self.path = Path(path or frame_host.data_dir("devices.json")) + self.config = Path(config) if config else None # None: ssh_config() at call time + self.lock = threading.RLock() + self.data = {"version": VERSION, "active": None, "devices": [], "networks": {}} + self.load() + + # -- storage -- + def load(self): + with self.lock: + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return + if isinstance(data, dict) and isinstance(data.get("devices"), list): + data.setdefault("networks", {}) + data.setdefault("active", None) + data["devices"] = [d for d in data["devices"] if self._sane(d)] + self.data = data + + @staticmethod + def _sane(d): + try: + check_alias(d["alias"]) + d["addresses"] = [a for a in d.get("addresses") or [] if isinstance(a, dict) and HOST_RE.fullmatch(a.get("host", "")) + and a.get("kind") in KINDS] + for a in d["addresses"]: + a.setdefault("networks", []) + a.setdefault("label", "") + return NAME_RE.fullmatch(d.get("id", "")) is not None + except (KeyError, TypeError, DeviceError): + return False + + def save(self): + with self.lock: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_name(self.path.name + ".tmp") + tmp.write_text(json.dumps(self.data, indent=1), encoding="utf-8") + os.replace(tmp, self.path) + + def snapshot(self): + with self.lock: + return copy.deepcopy(self.data) + + # -- lookups -- + def devices(self): + with self.lock: + return copy.deepcopy(self.data["devices"]) + + def _find(self, device_id): + for d in self.data["devices"]: + if d["id"] == device_id: + return d + raise DeviceError("No such headset (it may have been removed)") + + def get(self, device_id): + with self.lock: + return copy.deepcopy(self._find(device_id)) + + def by_alias(self, alias): + with self.lock: + return next((copy.deepcopy(d) for d in self.data["devices"] if d["alias"] == alias), None) + + def active(self): + with self.lock: + return self.data.get("active") + + def set_active(self, device_id): + with self.lock: + self._find(device_id) + self.data["active"] = device_id + self.save() + + # -- devices -- + def add_device(self, alias, name=None, user=DEFAULT_USER, port=22, hosts=(), identity_files=()): + with self.lock: + check_alias(alias) + if any(d["alias"] == alias for d in self.data["devices"]): + raise DeviceError(f"There's already a headset with the alias {alias}") + ids = {d["id"] for d in self.data["devices"]} + device_id = secrets.token_hex(4) + while device_id in ids: + device_id = secrets.token_hex(4) + d = {"id": device_id, "name": check_text(name or ("Steam Frame" if alias == "frame" else alias), "name"), + "alias": alias, "user": check_user(user or DEFAULT_USER), "port": check_port(port), + "identity_files": [str(f) for f in identity_files][:8], "addresses": [], "config_host": None, + "added": time.time()} + for host in hosts: + if host and not any(a["host"] == host for a in d["addresses"]): + d["addresses"].append(new_address(host)) + self.data["devices"].append(d) + if not self.data.get("active"): + self.data["active"] = device_id + self.save() + return copy.deepcopy(d) + + def update_device(self, device_id, name=None, user=None, port=None): + """-> the device after the change. The caller mirrors user and port into ~/.ssh/config.""" + with self.lock: + d = self._find(device_id) + if name is not None: + d["name"] = check_text(name, "name") or d["alias"] + if user is not None: + d["user"] = check_user(user) + if port is not None: + d["port"] = check_port(port) + self.save() + return copy.deepcopy(d) + + def remove_device(self, device_id): + """Forget a headset. Its ~/.ssh/config block (if kept) isn't imported again + unless Set Up Connection changes it.""" + with self.lock: + d = self._find(device_id) + self.data["devices"].remove(d) + self.data.setdefault("dismissed", {})[d["alias"]] = d.get("config_host") or "" + if self.data.get("active") == device_id: + self.data["active"] = self.data["devices"][0]["id"] if self.data["devices"] else None + self.save() + return d + + # -- addresses -- + def _addr(self, d, host): + for a in d["addresses"]: + if a["host"] == host: + return a + raise DeviceError(f"{host} isn't one of this headset's addresses") + + def add_address(self, device_id, host, kind=None, label=""): + with self.lock: + d = self._find(device_id) + a = new_address(host, kind, label) + if any(x["host"] == a["host"] for x in d["addresses"]): + raise DeviceError(f"{a['host']} is already on the list") + if len(d["addresses"]) >= 32: + raise DeviceError("That's enough addresses for one headset") + d["addresses"].append(a) + self.save() + return copy.deepcopy(a) + + def update_address(self, device_id, host, new_host=None, kind=None, label=None): + with self.lock: + d = self._find(device_id) + a = self._addr(d, host) + if new_host is not None and new_host != host: + new_host = check_host(new_host) + if any(x["host"] == new_host for x in d["addresses"]): + raise DeviceError(f"{new_host} is already on the list") + a.update(host=new_host, networks=[], last_ok=None, last_rtt_ms=None) # a new place: learn again + if kind is not None: + a["kind"] = check_kind(kind) + if label is not None: + a["label"] = check_text(label, "label") + self.save() + return copy.deepcopy(a) + + def remove_address(self, device_id, host): + with self.lock: + d = self._find(device_id) + d["addresses"].remove(self._addr(d, host)) + self.save() + + def move_address(self, device_id, host, delta): + with self.lock: + d = self._find(device_id) + a = self._addr(d, host) + i = d["addresses"].index(a) + j = max(0, min(len(d["addresses"]) - 1, i + int(delta))) + d["addresses"].insert(j, d["addresses"].pop(i)) + self.save() + + def record_success(self, device_id, host, network_id, rtt_ms): + """Learn: this address worked on this network.""" + with self.lock: + try: + a = self._addr(self._find(device_id), host) + except DeviceError: + return + if network_id and network_id not in a["networks"]: + a["networks"] = (a["networks"] + [network_id])[-16:] + a["last_ok"] = time.time() + a["last_rtt_ms"] = rtt_ms + self.save() + + def undismiss(self, alias): + """Set Up Connection is about to run for this alias: import its block again.""" + with self.lock: + if self.data.get("dismissed", {}).pop(alias, None) is not None: + self.save() + + def set_config_host(self, device_id, host): + with self.lock: + try: + self._find(device_id)["config_host"] = host + except DeviceError: + return + self.save() + + # -- networks -- + def record_network(self, net): + """Remember a network we've seen (for naming it), keeping its user-given name.""" + if not net or not net.get("id"): + return + with self.lock: + known = self.data["networks"].get(net["id"]) or {"name": ""} + changed = (known.get("ssid") != (net.get("ssid") or known.get("ssid")) or + time.time() - (known.get("last_seen") or 0) > 3600 or "gateway" not in known) + known.update(ssid=net.get("ssid") or known.get("ssid"), gateway=net.get("gateway"), wifi=net.get("wifi"), + gateway_mac=net.get("gateway_mac"), last_seen=time.time()) + self.data["networks"][net["id"]] = known + if changed: + self.save() + + def name_network(self, network_id, name): + with self.lock: + if network_id not in self.data["networks"]: + raise DeviceError("That network hasn't been seen") + self.data["networks"][network_id]["name"] = check_text(name, "network name") + self.save() + + def network_name(self, net): + """What to call a network: the name given to it, its Wi-Fi name, or its router.""" + if not net: + return "No network" + with self.lock: + known = self.data["networks"].get(net.get("id") or "") or {} + if known.get("name"): + return known["name"] + ssid = net.get("ssid") or known.get("ssid") + if ssid: + return ssid + if net.get("gateway"): + return f"{'Wi-Fi' if net.get('wifi') else 'Network'} via {net['gateway']}" + return "No network" + + # -- ~/.ssh/config -- + def sync_from_config(self, seed=True): + """Import managed blocks we don't know yet, and pick up a HostName that Set Up + Connection changed since we last looked. -> True if anything changed.""" + blocks = parse_blocks(read_config(self.config)) + changed = False + with self.lock: + for b in blocks: + host = b["hostname"] if b["hostname"] and HOST_RE.fullmatch(b["hostname"]) else None + user = b["user"] if b["user"] and NAME_RE.fullmatch(b["user"]) else DEFAULT_USER + d = next((x for x in self.data["devices"] if x["alias"] == b["alias"]), None) + dismissed = self.data.get("dismissed", {}) + if d is None and b["alias"] in dismissed: + if dismissed[b["alias"]] == (host or ""): + continue # removed on the Devices tab; unchanged since + del dismissed[b["alias"]] + if d is None: + try: + d = self._find(self.add_device(b["alias"], user=user, port=b["port"], + identity_files=b["identity_files"])["id"]) + except DeviceError: + continue + if host: + d["addresses"].append(dict(new_address(host), label="From Set Up Connection")) + d["config_host"] = host + changed = True + if seed and host: + seed_pin(d["id"], [host], b["port"]) + elif host and host != d.get("config_host"): + # Set Up Connection ran again and found the headset somewhere new. + d["config_host"] = host + if not any(a["host"] == host for a in d["addresses"]): + d["addresses"].insert(0, dict(new_address(host), label="From Set Up Connection")) + if seed: + seed_pin(d["id"], [host], b["port"]) + changed = True + if d["identity_files"] != b["identity_files"] and b["identity_files"]: + d["identity_files"] = b["identity_files"][:8] + changed = True + d["managed"] = True + aliases = {b["alias"] for b in blocks} + for d in self.data["devices"]: + d["managed"] = d["alias"] in aliases + if changed: + self.save() + return changed diff --git a/ui/frame_host.py b/ui/frame_host.py index a60b82c..a3842f3 100644 --- a/ui/frame_host.py +++ b/ui/frame_host.py @@ -33,8 +33,11 @@ class HostError(RuntimeError): def data_dir(*parts): - """Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME.""" - if MAC: + """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).""" + if os.environ.get("FRAME_CONTROL_DATA_DIR"): + base = Path(os.environ["FRAME_CONTROL_DATA_DIR"]) + elif MAC: base = Path.home() / "Library" / "Application Support" / "Frame Control" elif WINDOWS: base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") / "Frame Control" diff --git a/ui/frame_link.py b/ui/frame_link.py new file mode 100644 index 0000000..79bc722 --- /dev/null +++ b/ui/frame_link.py @@ -0,0 +1,905 @@ +"""The connection to the active headset: which address to use, and every step of getting there. + +A background thread (Link) keeps one SSH connection to the active headset open +and publishes what it's doing, stage by stage, for the page's connection pill: + + 1. network checking this computer's network (gateway, Wi-Fi, Tailscale) + 2. find finding the headset: every address probed on port 22 at once + 3. ssh opening SSH to the address that answered + 4. identity checking the headset's identity (its pinned host key) + 5. login logging in as the device's user + then connected (network, address, round trip), or failed at a stage with a + plain reason and a countdown to the next try. + +Addresses go in the order frame_devices.order_addresses gives. All are probed +at once; the best-ranked one that answers wins, waiting a moment (PREFER) for a +better-ranked address that's still trying, happy-eyeballs style. If SSH to the +winner fails in a way another address could fix (a different device answered, +the link dropped), the next one that answered is tried. + +The server hands in `apply(alias, host_opts)`, which points every ssh, scp and +rsync it runs at the alias with `-o HostName=
` and friends, so they all +follow. Where ssh can share one connection (not Windows), the master connection +lives here; it reconnects when it dies, when this computer changes networks, and +when the page asks. + +Python stdlib only. Runs on this computer, never on the Frame. +""" +import copy +import queue +import re +import socket +import subprocess +import threading +import time + +import frame_devices +import frame_host +import frame_network + +PROBE_TIMEOUT = 4 # seconds for a TCP answer on port 22 +PREFER = 0.35 # how long an answer waits for a better-ranked address still trying +HANDSHAKE_TIMEOUT = 25 +TICK = 2 # the loop's heartbeat +NETWORK_EVERY = 5 # how often the network fingerprint is read +TAILSCALE_EVERY = 30 +RETRY = (5, 10, 20, 30) # seconds before automatic retries after a failure +REQUEST_GAP = 5 # a request may start a new attempt this long after the last one + +STAGES = [("network", "Checking this computer's network"), ("find", "Finding the headset"), + ("ssh", "Opening SSH"), ("identity", "Checking the headset's identity"), + ("login", "Logging in")] + +# What ssh -v prints at each step (OpenSSH on macOS, Linux and Windows). +CONNECTING = re.compile(r"Connecting to (\S+) \[([^\]]+)\] port (\d+)") +ESTABLISHED = re.compile(r"Connection established") +HOSTKEY = re.compile(r"Server host key: (\S+) (\S+)") +KNOWN = re.compile(r"is known and matches") +ADDED = re.compile(r"Permanently added") +CHANGED = re.compile(r"REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed") +UNKNOWN = re.compile(r"No \S+ host key is known for") +AUTH_START = re.compile(r"Authentications that can continue|Next authentication method") +AUTHED = re.compile(r"Authenticated to |Authentication succeeded") +DENIED = re.compile(r"Permission denied") + + +def now(): + return time.time() + + +def ssh_g(alias): + """(hostname, port, user) from `ssh -G ALIAS`, for a headset that's only an ssh alias.""" + try: + out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, + timeout=10).stdout + except (OSError, subprocess.TimeoutExpired): + out = "" + got = {} + for line in out.splitlines(): + k, _, v = line.partition(" ") + if k in ("hostname", "port", "user") and k not in got: + got[k] = v.strip() + port = int(got["port"]) if got.get("port", "").isdigit() else 22 + return got.get("hostname") or alias, port, got.get("user") + + +def probe(host, port, timeout=PROBE_TIMEOUT, update=None): + """Try a TCP connection to host:port. -> {"state", "detail", "ip", "rtt_ms"}. + + state: answered, unresolved, timeout, refused, unreachable or error. update(fields) + reports progress (resolving, trying) as it happens.""" + update = update or (lambda **_: None) + update(state="resolving", detail="Looking up the name") + deadline = now() + timeout + try: + infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + except (socket.gaierror, UnicodeError, OSError) as e: + return {"state": "unresolved", "detail": "Can't find this name on the network", "error": str(e)} + last = None + for family, kind, proto, _, addr in infos[:4]: + ip = addr[0] + left = deadline - now() + if left <= 0: + break + update(state="trying", detail=f"Trying {ip}", ip=ip) + s = socket.socket(family, kind, proto) + s.settimeout(left) + t0 = time.monotonic() + try: + s.connect(addr) + rtt = round((time.monotonic() - t0) * 1000, 1) + return {"state": "answered", "detail": f"Answered in {rtt:g} ms", "ip": ip, "rtt_ms": rtt} + except socket.timeout: + last = {"state": "timeout", "detail": "No answer", "ip": ip} + except ConnectionRefusedError: + last = {"state": "refused", "detail": "Refused: SSH isn't on at this address", "ip": ip} + except OSError as e: + last = {"state": "unreachable", "detail": f"Can't get there ({e.strerror or e})", "ip": ip} + finally: + s.close() + return last or {"state": "timeout", "detail": "No answer"} + + +def probe_raw(host, port, result): + """ssh's own wording for a failed probe, so the server's UNREACHABLE table explains it.""" + return {"unresolved": f"ssh: Could not resolve hostname {host}: not found", + "refused": f"ssh: connect to host {host} port {port}: Connection refused", + "unreachable": f"ssh: connect to host {host} port {port}: No route to host", + }.get(result["state"], f"ssh: connect to host {host} port {port}: Operation timed out") + + +class Link: + def __init__(self, registry, *, env_alias, mux_base, control, apply, explain): + self.reg = registry + self.override = env_alias # FRAME_ALIAS, if set: the headset this server starts on + self.mux_base = list(mux_base) # ["ssh", "-o", "BatchMode=yes", ControlPath...] + self.control = control # ControlPath, or None where ssh can't share connections + self.apply = apply # apply(alias, host_opts): point every ssh command at the headset + self.explain = explain # ssh error text -> plain reason, or None + self.cond = threading.Condition() + self.version = 0 + self.stopped = False + self.kicks = [] # reasons someone asked for a (re)connect + self.busy = False # the loop is handling kicks + self.state = {"phase": "idle", "reason": None, "device": None, "network": None, "stages": [], + "probes": [], "via": None, "error": None, "retry_at": None, "attempt": 0, + "started": None, "finished": None, "tests": {}, "devices_rev": 0} + self.master = None # the ssh ControlMaster process, if we started it + self.opts = [] # host options of the current connection + self.alias = None + self.fails = 0 + self.last_fp = None + self.last_attempt = 0 + self.config_mtime = None + self.thread = None + + # ---- publishing ---- + def publish(self, **fields): + with self.cond: + self.state.update(fields) + self.version += 1 + self.cond.notify_all() + + def snapshot(self): + with self.cond: + snap = copy.deepcopy(self.state) + snap["version"] = self.version + snap["now"] = now() + return snap + + def wait(self, version, timeout): + """The state once its version passes `version`, or None after `timeout` seconds.""" + with self.cond: + if not self.cond.wait_for(lambda: self.version > version or self.stopped, timeout): + return None + return self.snapshot() + + def stage(self, sid, state, detail=None): + """Move one stage along (pending -> active -> done or failed) and publish.""" + with self.cond: + for s in self.state["stages"]: + if s["id"] == sid: + if state == "active" and s["state"] != "active": + s["started"] = now() + if state in ("done", "failed", "skipped"): + s["ended"] = now() + s["started"] = s["started"] or s["ended"] + s["state"] = state + if detail is not None: + s["detail"] = detail + self.version += 1 + self.cond.notify_all() + + def probe_update(self, index, **fields): + with self.cond: + if index < len(self.state["probes"]): + self.state["probes"][index].update(fields) + self.version += 1 + self.cond.notify_all() + + def devices_changed(self): + with self.cond: + self.state["devices_rev"] += 1 + self.version += 1 + self.cond.notify_all() + + # ---- control from the server ---- + def start(self): + self.thread = threading.Thread(target=self.run, name="frame-link", daemon=True) + self.thread.start() + + def kick(self, reason): + with self.cond: + self.kicks.append(reason) + self.cond.notify_all() + + def stop(self): + with self.cond: + self.stopped = True + self.cond.notify_all() + self.close_master() + + def alive(self): + if self.state["phase"] != "connected": + return False + if not self.control: + return True + return self.master is None or self.master.poll() is None + + def ensure(self, wait=20): + """Called before a command: make sure a connection is up, or being tried. + + Waits (up to `wait` s) for an attempt already running, or starts one if the + last ended a while ago. Never raises: if the headset can't be reached, the + command runs anyway and fails with ssh's own error, as it always has.""" + with self.cond: + if self.stopped or self.alive(): + return + if self.state["phase"] != "connecting" and not self.kicks and now() - self.last_attempt > REQUEST_GAP: + self.kicks.append("request") + self.cond.wait_for(lambda: self.stopped or (not self.kicks and not self.busy and + self.state["phase"] != "connecting"), wait) + + def use(self, device_id): + """Switch to another headset.""" + self.reg.set_active(device_id) + self.override = None + self.devices_changed() + self.kick("switch") + + def lost(self, message): + """A command couldn't reach the headset (Windows has no master to watch).""" + if self.state["phase"] == "connected": + self.kick(f"lost: {message}") + + # ---- the device this server talks to ---- + def active_device(self): + """The active headset from the registry, or a stand-in for a bare ssh alias.""" + if self.override: + return self.reg.by_alias(self.override) or self.bare(self.override) + want = self.reg.active() + if want: + try: + return self.reg.get(want) + except frame_devices.DeviceError: + pass + devices = self.reg.devices() + return devices[0] if devices else self.bare("frame") + + @staticmethod + def bare(alias): + """A headset that's only an ssh alias (no Set Up Connection block): ssh's config decides.""" + return {"id": f"alias-{alias}", "name": alias, "alias": alias, "user": None, "port": None, + "addresses": [], "transient": True, "identity_files": []} + + def host_opts(self, device, host): + """What every ssh command adds to reach DEVICE at HOST.""" + if device.get("transient") or not host: + return [] + return ["-o", f"HostName={frame_devices.ssh_host(host)}", + "-o", f"HostKeyAlias={frame_devices.host_key_alias(device['id'])}", + "-o", f"UserKnownHostsFile={frame_devices.known_hosts_opt()}", + "-o", f"User={device['user']}", "-o", f"Port={device['port']}"] + + def public_device(self, d): + return {k: d.get(k) for k in ("id", "name", "alias", "user", "port", "transient")} + + # ---- the loop ---- + def run(self): + self.kick("start") + last_net = last_ts = 0 + while True: + with self.cond: + self.cond.wait_for(lambda: self.stopped or self.kicks, TICK) + if self.stopped: + return + reasons, self.kicks = self.kicks, [] + self.busy = bool(reasons) + try: + t = now() + if t - last_net >= NETWORK_EVERY: + last_net = t + fp = frame_network.fingerprint() + if self.last_fp is not None and fp[::2] != self.last_fp[::2]: + reasons.append("network") + self.last_fp = fp + self.watch_config() + if self.state["phase"] == "connected" and self.control and self.master is None \ + and not self.check(self.opts): + reasons.append("dropped") # a master we found open, not one we started + if t - last_ts >= TAILSCALE_EVERY and self.state["network"] and not reasons: + last_ts = t + self.refresh_network() + phase = self.state["phase"] + if phase == "connected" and not self.alive(): + reasons.append("dropped") + if phase == "failed" and self.state["retry_at"] and now() >= self.state["retry_at"]: + reasons.append("retry") + if reasons: + self.connect(reasons) + except Exception as e: # keep the loop alive whatever happens; say what went wrong + self.publish(phase="failed", error={"stage": "network", "message": f"{type(e).__name__}: {e}", + "raw": str(e)}, retry_at=now() + RETRY[-1]) + finally: + with self.cond: + self.busy = False + self.cond.notify_all() + + def watch_config(self): + """Set Up Connection may have added a headset or found a new address: pick it up.""" + try: + mtime = frame_devices.ssh_config().stat().st_mtime + except OSError: + mtime = None + if mtime != self.config_mtime: + self.config_mtime = mtime + if self.reg.sync_from_config(): + self.devices_changed() + + def refresh_network(self): + net = frame_network.current_network(self.last_fp) + self.reg.record_network(net) + net["name"] = self.reg.network_name(net) + self.publish(network=net) + + # ---- one attempt ---- + def connect(self, reasons): + why = self.describe(reasons) + self.last_attempt = now() + self.close_master() + device = self.active_device() + with self.cond: + self.state.update(phase="connecting", reason=why, device=self.public_device(device), via=None, + error=None, retry_at=None, attempt=self.state["attempt"] + 1, started=now(), + finished=None, probes=[], + stages=[{"id": i, "label": label, "state": "pending", "detail": "", + "started": None, "ended": None} for i, label in STAGES]) + self.version += 1 + self.cond.notify_all() + ok = False + try: + ok = self.attempt(device) + finally: + with self.cond: + self.state["finished"] = now() + if ok: + self.fails = 0 + self.state.update(phase="connected", retry_at=None, error=None) + else: + self.fails += 1 + self.state.update(phase="failed", + retry_at=now() + RETRY[min(self.fails, len(RETRY)) - 1]) + if not self.state["error"]: + self.state["error"] = {"stage": "find", "message": "Couldn't connect", "raw": ""} + self.version += 1 + self.cond.notify_all() + + @staticmethod + def describe(reasons): + for r in reasons: + if r == "network": + return "This computer changed networks" + if r == "dropped" or r.startswith("lost"): + return "The connection dropped" + if r == "switch": + return "Switched headset" + if "retry" in reasons: + return "Trying again" + if "start" in reasons: + return "Starting up" + return "Connecting" + + def fail(self, sid, message, raw=""): + self.stage(sid, "failed", message) + with self.cond: + self.state["error"] = {"stage": sid, "message": message, "raw": raw} + + def attempt(self, device): + # 1. this computer's network + self.stage("network", "active") + net = frame_network.current_network(self.last_fp) + self.reg.record_network(net) + net["name"] = self.reg.network_name(net) + ts = net.get("tailscale") or {} + self.publish(network=net) + bits = [net["name"]] + if net.get("local_ip"): + bits.append(f"this computer is {net['local_ip']}") + bits.append("Tailscale on" if ts.get("up") else "Tailscale off" if ts.get("installed") else "no Tailscale") + self.stage("network", "done" if net.get("gateway") or ts.get("up") else "failed", " · ".join(bits)) + if not net.get("gateway") and not ts.get("up"): + with self.cond: + self.state["error"] = {"stage": "network", "raw": "", + "message": "This computer isn't connected to a network."} + # Keep going anyway: a headset on a direct link or loopback could still answer. + + # 2. find the headset + self.stage("find", "active") + port = device.get("port") or 22 + if device.get("transient") or not device["addresses"]: + host, port, user = ssh_g(device["alias"]) + if user and not device.get("user"): + device["user"] = user + ranked = [({"host": host, "kind": frame_network.guess_kind(host), "label": "from ~/.ssh/config"}, + "from ~/.ssh/config")] + else: + ranked = frame_devices.order_addresses(device["addresses"], net.get("id"), bool(ts.get("up"))) + with self.cond: + self.state["probes"] = [{"host": a["host"], "kind": a["kind"], "label": a.get("label") or "", + "why": why, "state": "waiting", "detail": "Waiting", "ip": None, + "rtt_ms": None} for a, why in ranked] + self.stage("find", "active", f"Trying {len(ranked)} address{'es' * (len(ranked) != 1)} at once") + results = [None] * len(ranked) + done = threading.Condition() + + def run_probe(i, host): + res = probe(host, port, update=lambda **f: self.probe_update(i, **f)) + res.setdefault("ip", None) + res.setdefault("rtt_ms", None) + self.probe_update(i, **{k: res[k] for k in ("state", "detail", "ip", "rtt_ms")}) + with done: + if results[i] is None: # not already given up on + results[i] = dict(res, t=time.monotonic()) + done.notify_all() + + for i, (a, _) in enumerate(ranked): + threading.Thread(target=run_probe, args=(i, a["host"]), daemon=True).start() + + tried = set() + user = device.get("user") or "the headset's user" + deadline = time.monotonic() + PROBE_TIMEOUT + 1 + while True: + pick = self.pick(results, tried, done, deadline) + if pick is None: + break + if tried: # another address may do better (a different device answered, or it dropped) + for sid in ("ssh", "identity", "login"): + self.stage(sid, "pending", "") + tried.add(pick) + a = ranked[pick][0] + self.stage("find", "done", f"{a['host']} answered in {results[pick]['rtt_ms']:g} ms") + outcome = self.handshake(device, a, results[pick], user) + if outcome == "ok": + via = {"host": a["host"], "kind": a["kind"], "ip": results[pick]["ip"], + "rtt_ms": results[pick]["rtt_ms"], "why": ranked[pick][1], "network": net.get("id"), + "network_name": net["name"]} + self.publish(via=via) + self.learn(device, a["host"], net, results[pick]["rtt_ms"]) + return True + with self.cond: + why_not = (self.state["error"] or {}).get("message") or "SSH failed" + self.probe_update(pick, state="sshfailed", detail=why_not) + if outcome != "next": + return False + if tried: + return False # the last handshake already said why + # Nothing answered: explain with the most useful failure. + with self.cond: + for row in self.state["probes"]: + if row["state"] in ("waiting", "resolving", "trying"): + row.update(state="timeout", detail="No answer in time") + states = [r["state"] for r in results if r] + worst = next((s for s in ("refused", "timeout", "unreachable", "unresolved") if s in states), "timeout") + i = states.index(worst) if worst in states else 0 + raw = probe_raw(ranked[i][0]["host"], port, results[i] or {"state": worst}) + message = self.explain(raw) or "The Frame isn't answering." + self.fail("find", message, raw) + return False + + @staticmethod + def pick(results, tried, done, deadline=None): + """The next address to use: the best-ranked answer once every better-ranked + address has failed, or once it has waited PREFER seconds for them. None when + nothing (else) answered. Probes still going at `deadline` (a name lookup can + take longer than the connect timeout) count as no answer.""" + deadline = deadline or time.monotonic() + PROBE_TIMEOUT + 1 + with done: + while True: + if time.monotonic() >= deadline: + for i, r in enumerate(results): + if r is None: + results[i] = {"state": "timeout", "detail": "No answer", "ip": None, "rtt_ms": None, + "t": time.monotonic()} + answered = [i for i, r in enumerate(results) if r and r["state"] == "answered" and i not in tried] + pending = [i for i, r in enumerate(results) if r is None] + if answered: + best = answered[0] + better = [i for i in pending if i < best] + waited = time.monotonic() - results[best]["t"] + if not better or waited >= PREFER: + return best + done.wait(PREFER - waited) + elif not pending: + return None + else: + done.wait(min(0.5, max(0.01, deadline - time.monotonic()))) + + def learn(self, device, host, net, rtt): + if device.get("transient"): + return + self.reg.record_success(device["id"], host, net.get("id"), rtt) + # Terminal's `ssh ALIAS` and the helper scripts use ~/.ssh/config: point it here too. + try: + if frame_devices.rewrite_block(device["alias"], hostname=host, user=device["user"], + port=device["port"]): + self.config_mtime = frame_devices.ssh_config().stat().st_mtime + self.reg.set_config_host(device["id"], host) + except OSError: + pass # not fatal: the app itself doesn't need the file + self.devices_changed() + + # ---- SSH ---- + def check(self, opts, alias=None): + """Is a master connection up for these options? (`ssh -O check`)""" + if not self.control: + return False + try: + return subprocess.run([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True, + stdin=subprocess.DEVNULL, timeout=5).returncode == 0 + except (OSError, subprocess.TimeoutExpired): + return False + + def close_master(self): + proc, self.master = self.master, None + if self.control and self.alias: + try: + subprocess.run([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True, + stdin=subprocess.DEVNULL, timeout=5) + except (OSError, subprocess.TimeoutExpired): + pass + if proc and proc.poll() is None: + proc.terminate() + try: + proc.wait(5) + except subprocess.TimeoutExpired: + proc.kill() + + def handshake(self, device, a, found, user): + """SSH to one address, following ssh -v through stages 3-5. + -> "ok", "next" (try another address) or "stop".""" + opts = self.host_opts(device, a["host"]) + alias = device["alias"] + self.alias, self.opts = alias, opts + self.apply(alias, opts) + target = f"{a['host']}" + (f" ({found['ip']})" if found.get("ip") and found["ip"] != a["host"] else "") + self.stage("ssh", "active", f"Opening SSH to {target}") + if self.control and self.check(opts, alias): + for sid in ("ssh", "identity", "login"): + self.stage(sid, "done", "Reusing the SSH connection that's already open") + return "ok" + extra = [] + if not device.get("transient"): + if frame_devices.pinned(device["id"]): + extra = ["-o", "StrictHostKeyChecking=yes"] + else: + # First connection since this headset was added: trust what it shows + # (as Set Up Connection does), and pin it from now on. + extra = ["-o", "StrictHostKeyChecking=accept-new"] + if self.control: + # No ConnectTimeout: with it, OpenSSH's master takes ~5s to open its socket. + argv = [*self.mux_base, *opts, *extra, "-v", "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5", + "-o", "ServerAliveCountMax=2", "-N", alias] + else: + argv = [*self.mux_base, *opts, *extra, "-v", "-o", "ConnectTimeout=10", alias, "true"] + try: + proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, **frame_host.DETACHED) + except OSError as e: + self.fail("ssh", f"Couldn't run ssh: {e}", str(e)) + return "stop" + lines = queue.Queue() + collecting = [True] + + def read(): + for raw in iter(proc.stderr.readline, b""): + if collecting[0]: + lines.put(raw.decode("utf-8", "replace").rstrip()) + lines.put(None) + proc.stderr.close() + threading.Thread(target=read, daemon=True).start() + + step, said, authed = "ssh", [], False + deadline = time.monotonic() + HANDSHAKE_TIMEOUT + mismatch = False + while True: + left = deadline - time.monotonic() + if left <= 0: + proc.kill() + self.fail(step, self.explain(f"Timed out talking to {alias}") or "The headset took too long to answer.", + f"Timed out talking to {alias}") + return "next" if step in ("ssh", "identity") else "stop" + try: + line = lines.get(timeout=min(left, 0.25)) + except queue.Empty: + line = "" + if authed and self.control and self.check(opts, alias): + break + if proc.poll() is not None and lines.empty(): + line = None + else: + continue + if line is None: # ssh exited + proc.wait() + if not self.control and proc.returncode == 0: + break + if authed and self.control and self.check(opts, alias): + break + return self.failed(step, said, mismatch, alias) + if not line.startswith("debug"): + said.append(line) + m = CONNECTING.search(line) + if m: + self.stage("ssh", "active", f"Opening SSH to {a['host']}" + + (f" ({m.group(2)})" if m.group(2) != a["host"] else "") + f", port {m.group(3)}") + elif ESTABLISHED.search(line): + self.stage("ssh", "done", f"Connected to {target}") + step = "identity" + self.stage("identity", "active", "Waiting for the headset's host key") + elif HOSTKEY.search(line): + m = HOSTKEY.search(line) + self.stage("identity", "active", f"It shows {m.group(1)} key {m.group(2)[:20]}…") + elif KNOWN.search(line): + self.stage("identity", "done", "Matches the identity saved for this headset") + step = "login" + self.stage("login", "active", f"Logging in as {user}") + elif ADDED.search(line): + self.stage("identity", "done", "First connection: saved this headset's identity") + step = "login" + self.stage("login", "active", f"Logging in as {user}") + elif (CHANGED.search(line) or UNKNOWN.search(line)) and not device.get("transient"): + mismatch = True # a bare alias keeps ssh's per-address check, and its wording + elif AUTH_START.search(line) and step != "login": + self.stage("identity", "done") + step = "login" + self.stage("login", "active", f"Logging in as {user}") + elif AUTHED.search(line): + authed = True + if step != "login": + self.stage("identity", "done") + self.stage("login", "done", f"Logged in as {user}") + step = "connected" + collecting[0] = False # the master keeps printing mux debug lines: drop them + for sid in ("ssh", "identity", "login"): + with self.cond: + pending = any(s["id"] == sid and s["state"] != "done" for s in self.state["stages"]) + if pending: + self.stage(sid, "done") + if self.control: + self.master = proc + return "ok" + + def failed(self, step, said, mismatch, alias): + text = "\n".join(said).strip() + if mismatch: + self.fail("identity", "This address answered as a different headset (its SSH identity doesn't match). " + "If SteamOS was reinstalled, use Forget Identity on the Devices tab.", text) + return "next" + if step == "login" or re.search(r"Permission denied", text): + self.fail("login", self.explain(text) or "The headset didn't accept this computer's key.", text) + return "stop" + if step == "connected": + self.fail("login", "Logged in, but the shared SSH connection didn't start.", text) + return "stop" + self.fail(step, self.explain(text) or (text.splitlines()[-1] if text else "ssh stopped"), text) + return "next" + + # ---- Test now ---- + def test(self, device_id): + """Probe every address of a headset and check SSH on the ones that answer, + without touching the live connection. Results stream into state["tests"].""" + device = self.reg.get(device_id) + started = now() + rows = [{"host": a["host"], "kind": a["kind"], "state": "waiting", "detail": "Waiting", "ip": None, + "rtt_ms": None, "ssh": None} for a in device["addresses"]] + + def put(**fields): + with self.cond: + self.state["tests"][device_id] = dict({"started": started, "done": False, "rows": rows}, **fields) + self.version += 1 + self.cond.notify_all() + + put() + net = self.state["network"] or {} + + def one(i, a): + res = probe(a["host"], device["port"], update=lambda **f: (rows[i].update(f), put())) + rows[i].update({k: res.get(k) for k in ("state", "detail", "ip", "rtt_ms")}) + put() + if res["state"] != "answered": + return + rows[i]["ssh"] = "checking" + put() + argv = [*self.mux_base[:3], "-o", "ControlPath=none", "-o", "ConnectTimeout=8", + *self.host_opts(device, a["host"]), "-o", "StrictHostKeyChecking=yes", device["alias"], "true"] + try: + r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True, + errors="replace", timeout=20) + err = r.stderr.strip() + if r.returncode == 0: + rows[i].update(ssh="ok", detail=f"Answered in {res['rtt_ms']:g} ms · SSH works") + self.reg.record_success(device_id, a["host"], net.get("id"), res["rtt_ms"]) + elif UNKNOWN.search(err): + rows[i].update(ssh="unpinned", detail=f"Answered in {res['rtt_ms']:g} ms · identity not saved yet") + elif CHANGED.search(err): + rows[i].update(ssh="wrong", detail="Answered as a different headset") + elif DENIED.search(err): + rows[i].update(ssh="denied", detail="Answered, but refused this computer's key") + else: + rows[i].update(ssh="failed", detail=self.explain(err) or (err.splitlines() or ["SSH failed"])[-1]) + except (OSError, subprocess.TimeoutExpired): + rows[i].update(ssh="failed", detail="SSH took too long") + put() + + threads = [threading.Thread(target=one, args=(i, a), daemon=True) for i, a in enumerate(device["addresses"])] + for t in threads: + t.start() + for t in threads: + t.join(40) + put(done=True, finished=now()) + self.devices_changed() + + +# ---- the page's API: /api/devices ------------------------------------------------- + +def devices_view(link): + """Every headset with its addresses, the networks they worked on, and the current network.""" + snap = link.reg.snapshot() + active = link.active_device() + names = {nid: link.reg.network_name(dict(n, id=nid)) for nid, n in snap["networks"].items()} + devices = [] + if active.get("transient"): + devices.append(dict(link.public_device(active), active=True, addresses=[], managed=False, pinned=False)) + for d in snap["devices"]: + view = {k: v for k, v in d.items() if k not in ("config_host", "addresses")} + view["active"] = d["id"] == active["id"] + view["pinned"] = frame_devices.pinned(d["id"]) + view["addresses"] = [dict(a, network_names=[names.get(n, "an unnamed network") for n in a["networks"]]) + for a in d["addresses"]] + devices.append(view) + return {"devices": devices, "active": active["id"], "network": link.state["network"], + "networks": [dict(n, id=nid, display=names[nid]) for nid, n in snap["networks"].items()], + "kinds": frame_devices.KIND_LABEL} + + +def devices_action(link, body, open_setup): + """POST /api/devices {"action": ..., "id": device id, ...}. -> {"message", ...devices_view}.""" + reg = link.reg + action = body.get("action") + did = body.get("id") + active = link.active_device() + is_active = did == active["id"] + if action == "use": + d = reg.get(did) + link.use(did) + msg = f"Switched to {d['name']}" + elif action == "update": + d = reg.update_device(did, name=body.get("name"), user=body.get("user"), port=body.get("port")) + try: + frame_devices.rewrite_block(d["alias"], user=d["user"], port=d["port"]) + except OSError as e: + raise frame_devices.DeviceError(f"Saved, but couldn't update ~/.ssh/config: {e}") + if is_active: + link.kick("switch") + msg = f"Saved {d['name']}" + elif action == "remove": + d = reg.remove_device(did) + frame_devices.forget_pin(did) + removed = False + if body.get("config"): + try: + removed = frame_devices.remove_block(d["alias"]) + except OSError as e: + raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}") + if is_active: + link.override = None + link.kick("switch") + msg = f"Removed {d['name']}" + (f" and its '{d['alias']}' entry in ~/.ssh/config" if removed else "") + elif action == "address-add": + a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "") + if is_active and link.state["phase"] == "failed": + link.kick("retry") + msg = f"Added {a['host']}" + elif action == "address-update": + a = reg.update_address(did, body.get("host"), new_host=body.get("newHost"), kind=body.get("kind"), + label=body.get("label")) + msg = f"Saved {a['host']}" + elif action == "address-remove": + reg.remove_address(did, body.get("host")) + msg = f"Removed {body.get('host')}" + elif action == "address-move": + delta = body.get("delta") + if delta not in (-1, 1): + raise frame_devices.DeviceError("delta must be -1 or 1") + reg.move_address(did, body.get("host"), delta) + msg = "Moved" + elif action == "test": + d = reg.get(did) + if not d["addresses"]: + raise frame_devices.DeviceError("This headset has no addresses to test yet") + threading.Thread(target=link.test, args=(did,), daemon=True).start() + msg = f"Testing {len(d['addresses'])} address{'es' * (len(d['addresses']) != 1)}" + elif action == "forget-identity": + d = reg.get(did) + frame_devices.forget_pin(did) + if is_active: + link.kick("switch") + msg = f"Forgot {d['name']}'s SSH identity; the next connection saves the one it shows" + elif action == "name-network": + reg.name_network(body.get("network"), body.get("name")) + if link.state["network"]: + link.refresh_network() + msg = "Saved the network's name" + elif action == "setup": + alias = frame_devices.check_alias(body.get("alias")) + host = frame_devices.check_host(body["host"]) if body.get("host") else None + link.reg.undismiss(alias) + where = open_setup(alias, host) + msg = f"Opened Set Up Connection for '{alias}' in {where}" + elif action == "retry": + link.kick("retry") + msg = "Connecting…" + else: + raise frame_devices.DeviceError("unknown action") + link.devices_changed() + return dict(devices_view(link), message=msg) + + +def next_alias(link): + taken = {d["alias"] for d in link.reg.devices()} | {b["alias"] for b in frame_devices.parse_blocks( + frame_devices.read_config())} + if "frame" not in taken: + return "frame" + n = 2 + while f"frame-{n}" in taken: + n += 1 + return f"frame-{n}" + + +LIKELY = re.compile(r"frame|steam", re.I) + + +def tailscale_find(link, device_id=None): + """Tailscale peers that could be a headset, likely ones first, for "Find on Tailscale".""" + ts = frame_network.tailscale_status() + device = link.reg.get(device_id) if device_id else link.active_device() + known = {a["host"].rstrip(".").lower() for a in device.get("addresses") or []} + if not ts.get("installed"): + return {"up": False, "peers": [], "message": "Tailscale isn't installed on this computer."} + if not ts.get("up"): + return {"up": False, "peers": [], "message": "Tailscale isn't running on this computer. Start it, then look again."} + peers = [] + for p in ts["peers"]: + ip = next((i for i in p["ips"] if "." in i), p["ips"][0] if p["ips"] else None) + likely = p["os"] == "linux" and (LIKELY.search(p["name"]) or p["name"].lower() in ( + device["alias"].lower(), (device.get("name") or "").lower())) + peers.append({"name": p["name"], "dns": p["dns"], "ip": ip, "os": p["os"], "online": p["online"], + "likely": bool(likely), "added": bool({p["dns"].lower(), (ip or "").lower()} & known)}) + peers.sort(key=lambda p: (not p["likely"], p["os"] != "linux", not p["online"], p["name"].lower())) + return {"up": True, "peers": peers, "tailnet": ts.get("tailnet"), "message": None} + + +def mdns_find(link, device_id=None): + """Headsets on this network: SteamOS devkit services (mDNS) and .local.""" + device = link.reg.get(device_id) if device_id else link.active_device() + known = {a["host"].rstrip(".").lower() for a in device.get("addresses") or []} + try: + import frame_connect + found = frame_connect.discover_devkit() + except (ImportError, SystemExit, OSError): + found = [] + names = list(dict.fromkeys([h.rstrip(".") for h in found] + [f"{device['alias']}.local", "frame.local"])) + rows = [None] * len(names) + + def check(i, host): + res = probe(host, 22, timeout=3) + rows[i] = {"host": host, "state": res["state"], "ip": res.get("ip"), "rtt_ms": res.get("rtt_ms"), + "detail": res["detail"], "advertised": host in [h.rstrip(".") for h in found], + "added": host.lower() in known or (res.get("ip") or "").lower() in known} + threads = [threading.Thread(target=check, args=(i, h), daemon=True) for i, h in enumerate(names) + if frame_devices.HOST_RE.fullmatch(h)] + for t in threads: + t.start() + for t in threads: + t.join(8) + hosts = [r for r in rows if r and (r["advertised"] or r["state"] in ("answered", "refused"))] + return {"hosts": hosts, "tool": bool(frame_host.which("dns-sd") or frame_host.which("avahi-browse"))} diff --git a/ui/frame_network.py b/ui/frame_network.py new file mode 100644 index 0000000..252be4f --- /dev/null +++ b/ui/frame_network.py @@ -0,0 +1,310 @@ +"""Which network this computer is on, and whether Tailscale is up. + +Frame Control remembers which of a headset's addresses worked on which network, +so it needs a stable name for "this network". The Wi-Fi name (SSID) is the +friendly one, but macOS 14+ hides it from apps without Location permission, and +wired networks have none. So every network is identified by a fingerprint of +its default gateway: the router's IP and MAC address, which stay the same for a +given home or office network. The user can give a fingerprint a name. + +Runs on this computer (macOS, Linux, Windows). Python stdlib only; every probe +is a short command with a timeout, and each parser has fixtures in +tests/test_network.py. +""" +import hashlib +import ipaddress +import json +import os +import re +import socket +import subprocess +import time + +import frame_host + +TIMEOUT = 3 + + +def run(argv, timeout=TIMEOUT): + """A command's stdout, or "" if it's missing, fails or takes too long.""" + try: + r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, timeout=timeout, + **({"creationflags": subprocess.CREATE_NO_WINDOW} if frame_host.WINDOWS else {})) + except (OSError, subprocess.TimeoutExpired): + return "" + return r.stdout.decode("utf-8", "replace") if r.returncode == 0 else "" + + +def valid_ip(text): + try: + ipaddress.ip_address(text) + return True + except ValueError: + return False + + +def norm_mac(text): + """"b4:fb:e4:1:87:3f" or "B4-FB-E4-01-87-3F" -> "b4:fb:e4:01:87:3f"; None if it isn't a MAC.""" + parts = re.split(r"[:-]", (text or "").strip()) + if len(parts) != 6 or not all(re.fullmatch(r"[0-9A-Fa-f]{1,2}", p) for p in parts): + return None + mac = ":".join(p.lower().zfill(2) for p in parts) + return None if mac in ("00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff") else mac + + +# ---- default gateway ------------------------------------------------------- + +def parse_route_macos(text): + """`route -n get default` -> (gateway, interface).""" + gw = re.search(r"^\s*gateway:\s*(\S+)", text, re.M) + iface = re.search(r"^\s*interface:\s*(\S+)", text, re.M) + gateway = gw.group(1) if gw and valid_ip(gw.group(1)) else None + return gateway, iface.group(1) if iface else None + + +def parse_route_linux(text): + """`ip -4 route show default` -> (gateway, interface) of the lowest-metric route.""" + best = None + for line in text.splitlines(): + m = re.search(r"^default via (\S+) dev (\S+)", line.strip()) + if not m or not valid_ip(m.group(1)): + continue + metric = re.search(r"\bmetric (\d+)", line) + key = int(metric.group(1)) if metric else 0 + if best is None or key < best[0]: + best = (key, m.group(1), m.group(2)) + return (best[1], best[2]) if best else (None, None) + + +def parse_route_windows(text): + """`route print -4 0.0.0.0` -> (gateway, local IP of the interface), lowest metric wins.""" + best = None + for line in text.splitlines(): + f = line.split() + if len(f) == 5 and f[0] == "0.0.0.0" and f[1] == "0.0.0.0" and valid_ip(f[2]) and f[4].isdigit(): + if best is None or int(f[4]) < best[0]: + best = (int(f[4]), f[2], f[3]) + return (best[1], best[2]) if best else (None, None) + + +# ---- the gateway's MAC address ---------------------------------------------- + +def parse_arp_macos(text, ip): + """`arp -n IP` -> MAC ("? (192.168.1.1) at b4:fb:e4:b5:67:55 on en0 ifscope [ethernet]").""" + m = re.search(r"\(" + re.escape(ip) + r"\) at (\S+)", text) + return norm_mac(m.group(1)) if m else None + + +def parse_neigh_linux(text, ip): + """`ip neigh show IP` -> MAC ("192.168.1.1 dev wlan0 lladdr b4:fb:... REACHABLE").""" + for line in text.splitlines(): + f = line.split() + if f and f[0] == ip and "lladdr" in f: + return norm_mac(f[f.index("lladdr") + 1]) if f.index("lladdr") + 1 < len(f) else None + return None + + +def parse_arp_windows(text, ip): + """`arp -a IP` -> MAC (" 192.168.1.1 b4-fb-e4-b5-67-55 dynamic").""" + for line in text.splitlines(): + f = line.split() + if len(f) >= 2 and f[0] == ip: + return norm_mac(f[1]) + return None + + +# ---- Wi-Fi name -------------------------------------------------------------- + +def parse_summary_macos(text): + """`ipconfig getsummary IFACE` -> (ssid, is_wifi). macOS prints "" without Location permission.""" + kind = re.search(r"^\s*InterfaceType\s*:\s*(\S+)", text, re.M) + ssid = re.search(r"^\s*SSID\s*:\s*(.+?)\s*$", text, re.M) + name = ssid.group(1) if ssid else None + if name in ("", ""): + name = None + return name, (kind.group(1).lower() == "wifi") if kind else None + + +def parse_nmcli(text): + """`nmcli -t -f active,ssid dev wifi` -> the active SSID (colons in names come escaped as \\:).""" + for line in text.splitlines(): + if line.startswith("yes:"): + return line[4:].replace("\\:", ":") or None + return None + + +def parse_netsh(text): + """`netsh wlan show interfaces` -> the connected SSID (not the BSSID line).""" + state = re.search(r"^\s*State\s*:\s*(\S+)", text, re.M) + ssid = re.search(r"^\s*SSID\s*:\s*(.+?)\s*$", text, re.M) + if not ssid or (state and state.group(1).lower() != "connected"): + return None + return ssid.group(1) + + +# ---- Tailscale ----------------------------------------------------------------- + +def tailscale_cli(): + extra = [] + if frame_host.MAC: + extra.append("/Applications/Tailscale.app/Contents/MacOS/Tailscale") + elif frame_host.WINDOWS: + for base in (os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)")): + if base: + extra.append(os.path.join(base, "Tailscale", "tailscale.exe")) + return frame_host.which("tailscale", *extra) + + +def parse_tailscale(text): + """`tailscale status --json` -> {"up", "ip", "name", "tailnet", "peers": [...]}. + + Each peer: {"name", "dns" (MagicDNS name, no trailing dot), "ips", "os", "online"}. + """ + try: + data = json.loads(text) + except ValueError: + return {"up": False, "peers": []} + if not isinstance(data, dict): + return {"up": False, "peers": []} + me = data.get("Self") or {} + tailnet = (data.get("CurrentTailnet") or {}).get("Name") if isinstance(data.get("CurrentTailnet"), dict) else None + out = {"up": data.get("BackendState") == "Running", + "ip": next((ip for ip in me.get("TailscaleIPs") or [] if "." in ip), None), + "name": (me.get("DNSName") or "").rstrip(".") or None, + "tailnet": tailnet, "peers": []} + for p in (data.get("Peer") or {}).values(): + if not isinstance(p, dict): + continue + out["peers"].append({"name": p.get("HostName") or "", "dns": (p.get("DNSName") or "").rstrip("."), + "ips": [ip for ip in p.get("TailscaleIPs") or [] if isinstance(ip, str)], + "os": p.get("OS") or "", "online": bool(p.get("Online"))}) + return out + + +def tailscale_status(): + cli = tailscale_cli() + if not cli: + return {"up": False, "installed": False, "peers": []} + out = parse_tailscale(run([cli, "status", "--json"], timeout=4) or "{}") + out["installed"] = True + return out + + +TAILNET_V4 = ipaddress.ip_network("100.64.0.0/10") +TAILNET_V6 = ipaddress.ip_network("fd7a:115c:a1e0::/48") + + +def is_tailscale(host): + host = host.lower().rstrip(".") + if host.endswith(".ts.net"): + return True + try: + ip = ipaddress.ip_address(host) + except ValueError: + return False + return ip in (TAILNET_V4 if ip.version == 4 else TAILNET_V6) + + +def guess_kind(host): + """What sort of address a host is: mdns, tailscale, lan or manual.""" + h = host.lower().rstrip(".") + if h.endswith(".local"): + return "mdns" + if is_tailscale(h): + return "tailscale" + try: + ip = ipaddress.ip_address(h.split("%")[0]) + if ip.is_private or ip.is_link_local: + return "lan" + except ValueError: + pass + return "manual" + + +# ---- putting it together ---------------------------------------------------------- + +def network_id(gateway, mac): + """A short, stable id for a network: its gateway's IP and MAC. None until both are known.""" + if not gateway or not mac: + return None + return "n-" + hashlib.sha1(f"{gateway}|{mac}".encode()).hexdigest()[:10] + + +def local_ip(towards="192.0.2.1"): + """This computer's address on the default route (UDP connect sends nothing).""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect((towards, 9)) + return s.getsockname()[0] + except OSError: + return None + + +def gateway(): + """(gateway IP, interface) of the default route.""" + if frame_host.MAC: + return parse_route_macos(run(["route", "-n", "get", "default"])) + if frame_host.WINDOWS: + return parse_route_windows(run(["route", "print", "-4", "0.0.0.0"])) + return parse_route_linux(run(["ip", "-4", "route", "show", "default"])) + + +def gateway_mac(ip): + if frame_host.MAC: + return parse_arp_macos(run(["arp", "-n", ip]), ip) + if frame_host.WINDOWS: + return parse_arp_windows(run(["arp", "-a", ip]), ip) + return parse_neigh_linux(run(["ip", "neigh", "show", ip]), ip) + + +def poke(ip): + """Make the system look up the gateway's MAC (an ARP entry can expire).""" + try: + with socket.create_connection((ip, 53), timeout=0.3): + pass + except OSError: + pass + + +def wifi(interface): + """(ssid or None, is_wifi or None) for the default route's interface.""" + if frame_host.MAC: + if interface: + ssid, is_wifi = parse_summary_macos(run(["ipconfig", "getsummary", interface])) + if ssid or is_wifi is False: + return ssid, is_wifi + m = re.search(r"Current Wi-Fi Network: (.+)", run(["networksetup", "-getairportnetwork", interface])) + return (m.group(1).strip() if m else None), is_wifi + return None, None + if frame_host.WINDOWS: + ssid = parse_netsh(run(["netsh", "wlan", "show", "interfaces"])) + return ssid, True if ssid else None + if frame_host.which("nmcli"): + ssid = parse_nmcli(run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"])) + else: + ssid = run(["iwgetid", "-r"]).strip() or None + return ssid, True if ssid else (interface.startswith(("wl", "wlan")) if interface else None) + + +def fingerprint(): + """The cheap part, polled every few seconds: (gateway, interface, gateway MAC).""" + gw, iface = gateway() + mac = None + if gw: + mac = gateway_mac(gw) + if not mac: + poke(gw) + mac = gateway_mac(gw) + return gw, iface, mac + + +def current_network(fp=None, with_tailscale=True): + """Everything the connection status shows about this computer's network.""" + gw, iface, mac = fp or fingerprint() + ssid, is_wifi = wifi(iface) if gw else (None, None) + net = {"id": network_id(gw, mac), "gateway": gw, "gateway_mac": mac, "interface": iface, + "ssid": ssid, "wifi": is_wifi, "local_ip": local_ip(gw) if gw else None, "checked": time.time()} + if with_tailscale: + ts = tailscale_status() + net["tailscale"] = {k: ts.get(k) for k in ("up", "installed", "ip", "name", "tailnet")} + return net diff --git a/ui/index.html b/ui/index.html index 8ac1265..e5b977c 100644 --- a/ui/index.html +++ b/ui/index.html @@ -86,6 +86,58 @@ .wait { color: var(--muted); font-size: 13px; display: flex; align-items: center; gap: 8px; } .wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; } + /* ---- connection pill, its details dialog, and the Devices tab (frame_link.py) ---- */ + .pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 420px; min-width: 190px; flex: 0 1 auto; background: var(--btn); } + .pill .dot { flex: none; } + .pill .dot.wait { background: var(--warn); box-shadow: 0 0 6px rgba(217,162,58,.7); animation: pulse 1s ease-in-out infinite; } + @keyframes pulse { 50% { opacity: .35; } } + .pill .pt { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; line-height: 1.2; text-align: left; } + .pill .pt b { font-weight: 500; font-size: 13px; color: var(--bright); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .pill .pt span { font-size: 11.5px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; } + .dlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px; + padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); } + .dlg::backdrop { background: rgba(0,0,0,.55); } + .dlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); } + .dlg h3, [data-page=devices] h3 { margin: 16px 0 6px; font-size: 11px; letter-spacing: 1.3px; text-transform: uppercase; color: var(--muted); font-weight: 700; } + .dlg label, .dev-form label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; } + .dlg label input, .dev-form label input { margin-top: 5px; } + .facts { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; margin: 0; font-size: 13px; } + .facts dt { color: var(--muted); } .facts dd { margin: 0; color: var(--bright); overflow-wrap: anywhere; } + .stages { list-style: none; margin: 0; padding: 0; } + .stages li { display: grid; grid-template-columns: 22px 1fr auto; gap: 2px 8px; padding: 6px 0; border-top: 1px solid rgba(255,255,255,.05); } + .stages li:first-child { border-top: 0; } + .stages .ic { width: 16px; height: 16px; border-radius: 50%; margin-top: 2px; display: grid; place-items: center; font-size: 11px; font-weight: 700; } + .stages .done .ic { background: rgba(89,191,64,.2); color: var(--green-hi); } + .stages .failed .ic { background: rgba(217,65,38,.25); color: #ff8a73; } + .stages .pending .ic { border: 1.5px solid var(--dim); } + .stages .active .ic { border: 2px solid rgba(255,255,255,.15); border-top-color: var(--blue); animation: spin .8s linear infinite; } + .stages .lb { color: var(--bright); font-size: 13.5px; } .stages .pending .lb { color: var(--muted); } + .stages .dt { grid-column: 2 / 4; color: var(--muted); font-size: 12.5px; overflow-wrap: anywhere; } + .stages .failed .dt { color: #ff8a73; } + .stages .tm { color: var(--dim); font-size: 12px; font-variant-numeric: tabular-nums; } + .probes { width: 100%; border-collapse: collapse; font-size: 12.5px; } + .probes td { padding: 5px 8px 5px 0; border-top: 1px solid rgba(255,255,255,.05); vertical-align: top; } + .probes td:first-child { color: var(--bright); min-width: 170px; overflow-wrap: anywhere; } + .res-answered, .res-ok { color: var(--green-hi); } .res-refused, .res-sshfailed, .res-wrong, .res-denied { color: #ff8a73; } + .res-timeout, .res-unresolved, .res-unreachable, .res-unpinned { color: var(--warn); } + .res-trying, .res-resolving, .res-waiting, .res-checking { color: var(--link); } + .dev-grid { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(0, 2.2fr); gap: 22px; align-items: start; } + @media (max-width: 1000px) { .dev-grid { grid-template-columns: 1fr; } } + .dev-item { cursor: pointer; border-radius: 3px; padding: 10px !important; margin: 0 -10px; } + .dev-item:hover { background: rgba(255,255,255,.04); } + .dev-item.sel { background: rgba(26,159,255,.12); } + .dev-form { display: grid; grid-template-columns: 2fr 1.3fr 1fr .8fr; gap: 0 10px; align-items: end; } + .dev-form input[readonly] { color: var(--muted); } + .dev-panel select, .dlg select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; + padding: 8px 8px; font: inherit; font-size: 13px; } + .addr .t { overflow-wrap: anywhere; } + .addr .s { white-space: normal; } + .addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; } + .addr-add { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto; gap: 8px; margin-top: 12px; } + @media (max-width: 700px) { .dev-form, .addr-edit, .addr-add { grid-template-columns: 1fr; } } + .found { margin-top: 10px; } + /* ---- drop anywhere ---- */ .dropzone { position: fixed; inset: 0; z-index: 40; display: grid; place-items: center; pointer-events: none; background: rgba(14,20,27,.82); backdrop-filter: blur(3px); } @@ -379,9 +431,11 @@ Games2 Android3 Tools4 + Devices5
- Connecting… + + — @@ -644,6 +699,25 @@ +
+
+
+

Headsets

+
+
Loading…
+
Frame Control talks to one headset at a time. Each can be reached at several addresses; + it tries them all at once and uses the best one that answers on the network you're on.
+

This computer's network

+
Checking…
+
+ +
+
Networks are told apart by their router (its IP and hardware address), so this works on wired + networks and when your computer won't share the Wi-Fi name.
+
+
Pick a headset.
+
+