diff --git a/app/main.js b/app/main.js index b943a82..4497136 100644 --- a/app/main.js +++ b/app/main.js @@ -159,10 +159,16 @@ async function startServer() { // Closing stdin lets server.py close its SSH connections and exit (the only clean // way on Windows); SIGTERM does the same elsewhere. +// Resolves once it has exited (or after 20 s), so a replacement can take the server +// lock: server.py allows one per user. function endServer(child) { + const gone = child.exitCode !== null || child.signalCode !== null ? Promise.resolve() + : new Promise((resolve) => child.once("exit", resolve)); try { child.stdin.end(); } catch {} if (!IS_WIN) child.kill("SIGTERM"); - setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref(); + // server.py ignores a second SIGTERM while it shuts down, so the fallback is a hard kill. + setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, 12000).unref(); + return Promise.race([gone, new Promise((resolve) => setTimeout(resolve, 20000).unref())]); } function stopServer() { @@ -183,27 +189,37 @@ function serverDied(why) { if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`)); } -async function restartServer() { - const old = server; - server = null; - url = null; - if (old) endServer(old); - await load(); +// Restarts that overlap share one: two could each start a server, and the one +// that lost the lock would leave the app pointing at nothing. +let restarting = null; +function restartServer() { + if (!restarting) { + restarting = (async () => { + const old = server; + server = null; + url = null; + if (old) await endServer(old); + if (starting) await starting.catch(() => {}); // a start it cut short: then start afresh + await load(); + })().finally(() => { restarting = null; }); + } + return restarting; } // On macOS the page's sticky header becomes the title bar, clear of the traffic lights. const CHROME_CSS = IS_MAC && ` header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; } - header a, header button, header input, header .chip { -webkit-app-region: no-drag; } + header a, header button, header input, header select, header .chip { -webkit-app-region: no-drag; } `; // Restart Server can start a new load while an older one is still waiting for // its server; only the newest load may touch the window. let loadGen = 0; +let starting = null; // loads that overlap share one server start async function load() { const gen = ++loadGen; try { - if (!url) await startServer(); + if (!url) await (starting ||= startServer().finally(() => { starting = null; })); if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); } } catch (e) { if (gen === loadGen && win) await win.loadURL(errorPage(e.message)); @@ -254,6 +270,46 @@ ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null); ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null); ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); }); +// 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; + +// SSH through the server, so it goes to the headset and address the app is using +// (with its own pinned identity), and refuses when there's none. +function openSsh() { + if (!url) return dialog.showErrorBox("Couldn't open SSH", "Frame Control's server isn't running."); + const body = JSON.stringify({ what: "terminal" }); + const req = http.request(new URL("/api/open", url), { + method: "POST", timeout: 15000, + headers: { "Content-Type": "application/json", "X-Frame-UI": "1", "Content-Length": Buffer.byteLength(body) }, + }, (res) => { + let data = ""; + res.on("data", (c) => { data += c; }); + res.on("end", () => { + if (res.statusCode === 200) return; + let why = `HTTP ${res.statusCode}`; + try { why = JSON.parse(data).error || why; } catch {} + dialog.showErrorBox("Couldn't open SSH", why); + }); + }); + req.on("error", (e) => dialog.showErrorBox("Couldn't open SSH", e.message)); + req.on("timeout", () => req.destroy(new Error("the server didn't answer"))); + req.end(body); +} +function showDevices() { + if (win && url) win.webContents.executeJavaScript('location.hash = "devices"').catch(() => {}); +} + ipcMain.handle("comfort:notify", (e, message) => { if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification"); if (!Notification.isSupported()) throw new Error("System notifications are unavailable"); @@ -425,13 +481,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() { @@ -446,8 +503,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: openSsh }, + { 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 41b2232..8ab5b8f 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. // And it passes update state both ways: see app/updater.js. @@ -12,6 +13,12 @@ contextBridge.exposeInMainWorld("frameApp", { notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request), 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))); + }, // While the keyboard-and-trackpad panel holds the keyboard, ⌘W, ⌘R and the rest go to the Frame. captureKeys: (on) => ipcRenderer.send("keys:capture", !!on), pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } }, diff --git a/docs/devices.md b/docs/devices.md new file mode 100644 index 0000000..1ed3200 --- /dev/null +++ b/docs/devices.md @@ -0,0 +1,180 @@ +# 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 headset's own + known_hosts file, `~/.ssh/frame-control-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. From that moment no command goes to the previous headset, even + if the new one never answers. It waits while an install is running, since an + install reads the SSH settings step by step. +- **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-hosts/ -o HashKnownHosts=no -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. Edits to `~/.ssh/config` take a lock file +(`~/.ssh/config.frame-control.lock`) that Set Up Connection takes too, and never +write over a change someone else made since the app last read the file. + +**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 keeps one known_hosts +file per headset instead, so saving or forgetting one headset's key never touches +another's: 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. + +## One server at a time + +Only one Frame Control server runs per user (a lock file, `server.lock`, in the app's +data folder). Two would each connect, reconnect and edit the headsets on their own, and +one could move the other's install to a different headset. A second one, say +`scripts/frame-ui.sh` while the app is open, exits with "Frame Control is already +running". `FRAME_CONTROL_DATA_DIR` gives a separate one, with its own headsets. + +## 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 2637c10..3ec3b4c 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, @@ -78,6 +81,11 @@ counts them while they run. an SSH tunnel; see [mac-in-headset.md](mac-in-headset.md). - **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 @@ -101,7 +109,9 @@ Frame for the keyboard and trackpad. 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/scripts/connect.sh b/scripts/connect.sh index 00bcb41..477020b 100755 --- a/scripts/connect.sh +++ b/scripts/connect.sh @@ -101,10 +101,22 @@ make_key() { # path type comment [extra ssh-keygen args] fi } -# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off. +# Takes the lock Frame Control uses to edit ~/.ssh/config (ui/frame_devices.py), so a +# running app and this script never write over each other's change. write_config() { + local lockfd="" rc + zmodload zsh/system 2>/dev/null + touch "$CONFIG.frame-control.lock" 2>/dev/null + zsystem flock -t 30 -f lockfd "$CONFIG.frame-control.lock" 2>/dev/null || lockfd="" + write_config_locked; rc=$? + [[ -n "$lockfd" ]] && zsystem flock -u "$lockfd" + return $rc +} + +# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off. +write_config_locked() { touch "$CONFIG" && chmod 600 "$CONFIG" || return 1 - local tmp + local tmp new="$CONFIG.frame-control.$$" tmp=$(mktemp) || return 1 # Drop any previous managed block, then PREPEND a fresh one: ssh uses the first # value it sees per option, so this block must precede any other "Host frame" @@ -126,7 +138,8 @@ write_config() { print -r -- "Host *" print -r -- "$END_MARK" cat "$tmp" - } > "$CONFIG" || { print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; } + } > "$new" && chmod 600 "$new" && mv -f "$new" "$CONFIG" \ + || { rm -f "$new"; print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; } rm -f "$tmp" } diff --git a/scripts/keep-awake.sh b/scripts/keep-awake.sh index c4a3301..f2c6815 100755 --- a/scripts/keep-awake.sh +++ b/scripts/keep-awake.sh @@ -15,11 +15,13 @@ set -euo pipefail FRAME_ALIAS=${FRAME_ALIAS:-frame} +# Frame Control passes the headset it has chosen: its address and pinned identity. +ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}}) HERE=${0:A:h} cmd=${1:-status} case $cmd in on|off|status) ;; *) echo "usage: keep-awake.sh on|off|status" >&2; exit 2 ;; esac -ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \ +ssh "${ssh_opts[@]}" -o ConnectTimeout=8 "$FRAME_ALIAS" \ 'mkdir -p ~/.cache/frame-control && cat > ~/.cache/frame-control/frame_steam.py' < "$HERE/../ui/frame_steam.py" # Runs on the Frame. Verified 2026-09-28 (BUILD_ID 20260925.6191901): the @@ -27,7 +29,7 @@ ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \ # written the way Steam's settings page does (steamui module exporting the # SetSetting wrapper). logind refuses an inhibitor from an SSH session # ("Interactive authentication required") but allows one from a user unit. -ssh "$FRAME_ALIAS" python3 - "$cmd" <<'EOF' +ssh "${ssh_opts[@]}" "$FRAME_ALIAS" python3 - "$cmd" <<'EOF' import json, os, subprocess, sys sys.path.insert(0, os.path.expanduser("~/.cache/frame-control")) from frame_steam import Page diff --git a/scripts/panel-on-frame.sh b/scripts/panel-on-frame.sh index 781767a..2ce218c 100755 --- a/scripts/panel-on-frame.sh +++ b/scripts/panel-on-frame.sh @@ -21,6 +21,8 @@ set -euo pipefail FRAME_ALIAS=${FRAME_ALIAS:-frame} +# Frame Control passes the headset it has chosen: its address and pinned identity. +ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}}) REMMINA_PROFILE="~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina" id="" name="" @@ -109,4 +111,4 @@ EOF ) b64=$(print -rn -- "$remote" | base64) -ssh "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}" +ssh "${ssh_opts[@]}" "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}" diff --git a/tests/fakessh/ssh b/tests/fakessh/ssh new file mode 100755 index 0000000..45b2b17 --- /dev/null +++ b/tests/fakessh/ssh @@ -0,0 +1,79 @@ +#!/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), "denied" or +"slow" (hangs after connecting); +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.") +if what == "slow": + time.sleep(30) +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/test_agent.py b/tests/test_agent.py index 46ec1db..931eccb 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -213,8 +213,9 @@ class ManagedBackend(unittest.TestCase): with mock.patch.object(server.frame_host, 'MUX', True), \ mock.patch.object(server.frame_host.os, 'getuid', return_value=501, create=True), \ mock.patch.object(server.frame_host.os, 'getpid', return_value=123): - self.assertEqual(server.frame_host.control_path(), '/tmp/frame-ui-501-%C') - self.assertEqual(server.frame_host.control_path(private=True), '/tmp/frame-ui-501-123-%C') + # Per headset (its tag), and per process for a private server. + self.assertEqual(server.frame_host.control_path('a1b2', private=False), '/tmp/frame-ui-501-a1b2-%C') + self.assertEqual(server.frame_host.control_path('a1b2', private=True), '/tmp/frame-ui-501-123-a1b2-%C') class ComputerState(unittest.TestCase): @@ -251,3 +252,23 @@ class ComputerState(unittest.TestCase): if __name__ == '__main__': unittest.main() + + +class ScriptsFollowTheHeadset(unittest.TestCase): + """keep_awake and panel tools run scripts that ssh on their own: they must reach the + headset the server is routed to, not whatever `frame` means in ~/.ssh/config.""" + + @unittest.skipUnless(shutil.which('zsh'), 'needs zsh') + def test_scripts_get_the_routed_alias_and_options(self): + import shlex + fake = mock.Mock(FRAME='frame-2', LOCAL=False, HERE=Path(__file__).resolve().parent.parent / 'ui', + SSH=['ssh', '-o', 'BatchMode=yes', '-o', 'HostName=192.0.2.2', '-o', 'HostKeyAlias=frame-control-ab']) + with mock.patch.object(agent.subprocess, 'run', return_value=mock.Mock(returncode=0, stdout='ok', stderr='')) as run: + agent.run_script(fake, 'keep-awake.sh', ['status']) + env = run.call_args.kwargs['env'] + self.assertEqual(env['FRAME_ALIAS'], 'frame-2') + self.assertEqual(shlex.split(env['FRAME_SSH_OPTS']), fake.SSH[1:]) + # and the script turns that back into the same argv + out = subprocess.run(['zsh', '-c', 'ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}}); print -l -- $ssh_opts'], + env={**os.environ, 'FRAME_SSH_OPTS': env['FRAME_SSH_OPTS']}, capture_output=True, text=True) + self.assertEqual(out.stdout.splitlines(), fake.SSH[1:]) diff --git a/tests/test_devices.py b/tests/test_devices.py new file mode 100644 index 0000000..31f3c61 --- /dev/null +++ b/tests/test_devices.py @@ -0,0 +1,400 @@ +"""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::1234:5678", "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_first_import_keeps_using_frame(self): + # Set Up Connection puts each new block first; the app used `frame` before. + blocks = CONFIG.split("# >>> steam-frame (frame-2) >>>") + head, first = blocks[0].split("# >>> steam-frame (frame) >>>") + second, tail = blocks[1].split("# <<< steam-frame (frame-2) <<<") + (self.ssh / "config").write_text(head + "# >>> steam-frame (frame-2) >>>" + second + "# <<< steam-frame (frame-2) <<<\n" + + "# >>> steam-frame (frame) >>>" + first + tail) + self.reg.sync_from_config(seed=False) + self.assertEqual([d["alias"] for d in self.reg.devices()], ["frame-2", "frame"]) + self.assertEqual(self.reg.active(), self.reg.by_alias("frame")["id"]) + + @unittest.skipUnless(shutil.which("ssh"), "needs ssh") + def test_a_port_inherited_from_another_host_entry_is_kept(self): + (self.ssh / "config").write_text(CONFIG.replace("Host *\n ServerAliveInterval 60", "Host *\n Port 2222")) + self.reg.sync_from_config(seed=False) + self.assertEqual(self.reg.by_alias("frame")["port"], 2222) # what ssh itself would use + self.assertEqual(self.reg.by_alias("frame-2")["port"], 2222) # its own Port line + # Saving 22 must then say so in the block, or ssh would go on inheriting 2222. + self.assertTrue(fd.rewrite_block("frame", port=22)) + self.assertEqual(fd.effective_port("frame", self.ssh / "config"), 22) + self.assertFalse(fd.rewrite_block("frame", port=22)) # and only once + + 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_setup_changing_the_login_updates_the_headset(self): + self.reg.sync_from_config(seed=False) + (self.ssh / "config").write_text(CONFIG.replace(" User steamos\n", " User deck\n Port 2200\n", 1)) + self.assertTrue(self.reg.sync_from_config(seed=False)) + d = self.reg.by_alias("frame") + self.assertEqual((d["user"], d["port"]), ("deck", 2200)) + + 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 SharedFile(Base): + """The desktop app and a standalone server can share devices.json.""" + + def test_one_server_never_saves_over_anothers_change(self): + self.reg.sync_from_config(seed=False) + other = fd.Registry(self.dir / "devices.json") # a second server, loaded now + d = self.reg.by_alias("frame") + self.reg.add_address(d["id"], "100.101.1.2", "tailscale") + other.record_success(d["id"], d["addresses"][0]["host"], "net-1", 12) # works from its older copy + hosts = [a["host"] for a in fd.Registry(self.dir / "devices.json").get(d["id"])["addresses"]] + self.assertIn("100.101.1.2", hosts) + self.assertIn("100.101.1.2", [a["host"] for a in other.get(d["id"])["addresses"]]) # and it sees it + + def test_another_servers_choice_of_headset_doesnt_move_this_one(self): + self.reg.sync_from_config(seed=False) + other = fd.Registry(self.dir / "devices.json") + mine, theirs = self.reg.by_alias("frame")["id"], self.reg.by_alias("frame-2")["id"] + self.reg.set_active(mine) + other.set_active(theirs) + self.assertEqual(self.reg.active(), mine) # after a refresh + self.reg.add_address(mine, "192.0.2.9") # and after a change reloads the file + self.assertEqual(self.reg.active(), mine) + self.assertEqual(fd.Registry(self.dir / "devices.json").active(), mine) # last to save: next start + +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: said explicitly + self.assertEqual(fd.parse_blocks(cfg.read_text())[1]["port"], 22) + self.assertIn(" 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_concurrent_edits_all_land(self): + import threading + def edit(alias, prefix): + for n in range(15): + fd.rewrite_block(alias, hostname=f"{prefix}.{n}") + threads = [threading.Thread(target=edit, args=("frame", "10.0.0")), + threading.Thread(target=edit, args=("frame-2", "10.0.1"))] + for t in threads: + t.start() + for t in threads: + t.join() + blocks = fd.parse_blocks((self.ssh / "config").read_text()) + self.assertEqual([b["hostname"] for b in blocks], ["10.0.0.14", "10.0.1.14"]) + self.assertEqual([p.name for p in self.ssh.iterdir() if "frame-control." in p.name and not p.name.endswith(".lock")], []) # no temp files left + + def test_learning_skips_a_block_someone_changed(self): + # The connector learned an address, but Set Up Connection moved the block meanwhile. + self.assertFalse(fd.rewrite_block("frame", hostname="10.0.0.9", + expect={"hostname": "old.example", "user": None, "port": None})) + self.assertIn("HostName frame.tail1234.ts.net", (self.ssh / "config").read_text()) + self.assertTrue(fd.rewrite_block("frame", hostname="10.0.0.9", + expect={"hostname": "frame.tail1234.ts.net", "user": "steamos", "port": 22})) + + 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("d1").read_text(), f"frame-control-d1 {KEY}\n") + self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"])) # idempotent + self.assertEqual(fd.known_hosts("d1").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")) + self.assertFalse(fd.forget_pin("d1")) + + def test_each_headset_has_its_own_file(self): + (self.ssh / "known_hosts").write_text(f"a.local {KEY}\nb.local {KEY}\n") + fd.seed_pin("da", ["a.local"]) + fd.seed_pin("db", ["b.local"]) + fd.forget_pin("da") + self.assertTrue(fd.pinned("db")) # forgetting one can't touch another + self.assertNotEqual(fd.known_hosts("da"), fd.known_hosts("db")) + + 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("d3").read_text()) + + def test_hashed_pins_are_found_and_forgotten(self): + target = fd.known_hosts("d4") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(f"frame-control-d4 {KEY}\n") + subprocess.run(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, check=True) + self.assertNotIn("frame-control-d4", target.read_text()) + self.assertTrue(fd.pinned("d4")) + self.assertTrue(fd.forget_pin("d4")) + self.assertFalse(fd.pinned("d4")) + + def test_known_hosts_option_uses_the_override(self): + self.assertEqual(fd.known_hosts_opt("d5"), str(self.ssh / "frame-control-hosts" / "d5")) + os.environ.pop("FRAME_CONTROL_SSH_DIR") + self.assertEqual(fd.known_hosts_opt("d5"), "~/.ssh/frame-control-hosts/d5") # no spaces to split on + + +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 + with self.assertRaises(fd.DeviceError): + self.reg.update_address(d["id"], "192.168.1.40", new_host="192.168.1.41", label="bad\nlabel") + self.assertEqual(self.reg.get(d["id"])["addresses"][1]["networks"], ["n-home"]) # rejected: unchanged + 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") + with self.assertRaises(fd.DeviceError): + self.reg.update_device(b["id"], user="steam", port="bad") + self.assertEqual(self.reg.get(b["id"])["user"], "deck") # a rejected edit changes nothing + self.reg.remove_device(b["id"]) + self.assertEqual(self.reg.active(), a["id"]) + self.assertFalse(self.reg.emptied()) + self.reg.remove_device(a["id"]) + self.assertTrue(self.reg.emptied()) # the connector then uses no headset at all + self.reg.add_device("frame-4") + self.assertFalse(self.reg.emptied()) + 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() + + +class RoutingLongLivedSsh(unittest.TestCase): + """The keyboard agent and the Mac view keep their own ssh open: switching headset + must end or retarget them, or input would go on reaching the old headset.""" + + def test_switching_headset_stops_input_and_retargets_the_mac_view(self): + import server + from unittest import mock + before = (server.FRAME, list(server.HOST_OPTS)) + self.addCleanup(lambda: server.route(*before)) + with mock.patch.object(server._input, "stop") as stop, \ + mock.patch.object(server.macview, "retarget") as retarget: + server.route(server.FRAME, ["-o", "HostName=192.0.2.9"]) # same headset, new address + stop.assert_not_called() + server.route("frame-2", ["-o", "HostName=192.0.2.2"]) + stop.assert_called_once() + retarget.assert_called_with("frame-2", ["-o", "HostName=192.0.2.2"]) diff --git a/tests/test_link.py b/tests/test_link.py new file mode 100644 index 0000000..782306e --- /dev/null +++ b/tests/test_link.py @@ -0,0 +1,695 @@ +"""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"]) + # Closed now. Windows retries a refused connect for about 2 s before saying so. + self.assertEqual(fl.probe("127.0.0.1", port, timeout=6 if os.name == "nt" else 2)["state"], "refused") + 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 test_start_routes_to_the_saved_headset_before_serving(self): + a = self.reg.add_device("frame", hosts=["192.0.2.1"]) + b = self.reg.add_device("frame-2", hosts=["192.0.2.2"]) + self.reg.set_active(b["id"]) + with mock.patch.object(self.link, "run", lambda: None): # no connector: only what start() applies + self.link.start() + self.assertEqual(self.routes[0][0], "frame-2") + self.assertIn("HostName=192.0.2.2", self.routes[0][1]) + self.assertNotEqual(a["id"], b["id"]) + + def test_headset_removed_elsewhere_mid_install_reaches_nothing(self): + a = self.reg.add_device("frame", hosts=["192.0.2.1"]) + self.reg.add_device("frame-2", hosts=["192.0.2.2"]) + self.reg.set_active(a["id"]) + other = fd.Registry(self.dir / "devices.json") # another Frame Control server + other.remove_device(a["id"]) + self.link.work = lambda: 1 + self.assertTrue(self.link.active_device().get("none")) + self.link.work = lambda: 0 + self.assertEqual(self.link.active_device()["alias"], "frame-2") # idle: move on + + def test_nothing_elsewhere_moves_a_running_install(self): + """A bare alias in use, then another server sets up and picks a headset.""" + self.link.override = None + self.hosts({"localhost": "ok"}) + with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)): + self.link.connect(["start"]) + started = self.routes[-1] + other = fd.Registry(self.dir / "devices.json") + other.set_active(other.add_device("frame-2", hosts=["192.0.2.2"])["id"]) + self.link.work = lambda: 1 + with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)): + self.link.close_master() + self.link.connect(["dropped"]) + self.assertEqual(self.routes[-1][0], started[0]) + self.assertTrue(self.link.deferred) + + def listen6(self): + """A "different device": the same port on IPv6 loopback.""" + try: + six = socket.socket(socket.AF_INET6) + self.addCleanup(six.close) + six.bind(("::1", self.port)) + six.listen(4) + except OSError: + self.skipTest("no IPv6 loopback") + + def pin(self, device_id): + fd.known_hosts(device_id).parent.mkdir(parents=True, exist_ok=True) + fd.known_hosts(device_id).write_text(f"frame-control-{device_id} ssh-ed25519 AAAA\n") + + def hosts(self, mapping): + # An IPv4 answer is what ssh is pointed at, so localhost arrives as 127.0.0.1. + if "localhost" in mapping: + mapping = dict({"127.0.0.1": mapping["localhost"]}, **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. + self.listen6() + d = self.device("nothing.invalid", "::1", "127.0.0.1") + self.hosts({"::1": "wrong", "127.0.0.1": "ok"}) + self.link.connect(["start"]) + s = self.link.snapshot() + self.assertEqual(s["phase"], "connected", s["error"]) + self.assertEqual(s["via"]["host"], "127.0.0.1") + 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["::1"]["state"], "sshfailed") + self.assertIn("different headset", rows["::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("HostName=127.0.0.1", 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 + # ssh takes an option's first value: accept-new must come before the commands' own "yes". + self.assertLess(master.index("StrictHostKeyChecking=accept-new"), master.index("StrictHostKeyChecking=yes")) + self.assertIn("StrictHostKeyChecking=yes", opts) # every other command checks the pinned key + self.assertTrue(fd.known_hosts(d["id"]).parent.is_dir()) # where ssh saves the key it accepts + # It learned: 127.0.0.1 works on this network. + learned = {a["host"]: a for a in self.reg.get(d["id"])["addresses"]} + self.assertEqual(learned["127.0.0.1"]["networks"], ["n-test"]) + self.assertEqual(learned["::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.pin(d["id"]) + 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_ssh_goes_to_the_ipv4_address_that_answered(self): + self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + s = self.link.snapshot() + self.assertEqual((s["phase"], s["via"]["host"], s["via"]["ip"]), ("connected", "localhost", "127.0.0.1")) + self.assertIn("HostName=127.0.0.1", self.routes[-1][1]) + + def test_no_headset_after_removing_them_all(self): + d = self.device("localhost") + self.reg.remove_device(d["id"]) + self.link.connect(["switch"]) + s = self.link.snapshot() + self.assertEqual((s["phase"], s["device"]["id"], s["retry_at"]), ("failed", "none", None)) + self.assertIn("No headset", s["error"]["message"]) + self.assertEqual(self.routes[-1], ("frame-control-no-headset", ["-o", "HostName=no-headset.invalid"])) + + def test_a_headset_without_addresses_reaches_nothing(self): + d = self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + fl.devices_action(self.link, {"action": "address-remove", "id": d["id"], "host": "localhost"}, None) + self.assertIn("HostName=no-address.invalid", self.routes[-1][1]) # at once, not after a retry + self.link.connect(["switch"]) + s = self.link.snapshot() + self.assertEqual((s["phase"], s["retry_at"]), ("failed", None)) + self.assertIn("no addresses", s["error"]["message"]) + + def test_switching_back_to_the_frame_alias_the_server_started_with(self): + self.link.override = self.link.session_alias = "frame-bare" + other = self.device("localhost") + ids = [d["id"] for d in fl.devices_view(self.link)["devices"]] + self.assertEqual(ids, ["alias-frame-bare", other["id"]]) + fl.devices_action(self.link, {"action": "use", "id": other["id"]}, None) + self.assertIn("alias-frame-bare", [d["id"] for d in fl.devices_view(self.link)["devices"]]) # still there + fl.devices_action(self.link, {"action": "use", "id": "alias-frame-bare"}, None) + self.assertEqual(self.link.active_device()["alias"], "frame-bare") + self.assertEqual(self.routes[-1][0], "frame-bare") + + def test_removing_the_headset_frame_alias_named_doesnt_bring_it_back_bare(self): + d = self.device("localhost") + self.link.override = self.link.session_alias = "frame-t" + fl.devices_action(self.link, {"action": "remove", "id": d["id"], "config": True}, None) + self.assertNotIn("alias-frame-t", [x["id"] for x in fl.devices_view(self.link)["devices"]]) + + def test_setup_changing_the_login_waits_for_installs(self): + d = self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + cfg = self.dir / "ssh" / "config" + cfg.write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n User steamos\n" + f" Port {self.port}\nHost *\n# <<< steam-frame (frame-t) <<<\n") + self.link.watch_config() # the block's login is recorded + routes = len(self.routes) + cfg.write_text(cfg.read_text().replace("User steamos", "User deck")) + running = [1] + self.link.work = lambda: running[0] + self.link.config_mtime = None + self.link.watch_config() + self.assertEqual(len(self.routes), routes) # an install is running: not yet + self.link.connect(["dropped"]) # a reconnect meanwhile keeps the login it started with + self.assertIn("User=steamos", self.routes[-1][1]) + running[0] = 0 + self.link.watch_config() + self.assertIn("User=deck", self.routes[-1][1]) + + def test_a_rename_leaves_the_login_in_the_config_alone(self): + d = self.device("localhost") + cfg = self.dir / "ssh" / "config" + cfg.write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n User deck\n" + "Host *\n# <<< steam-frame (frame-t) <<<\n") # setup wrote a new user, not yet imported + fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None) + self.assertIn("User deck", cfg.read_text()) + out = fl.devices_action(self.link, {"action": "update", "id": d["id"], "port": 2200}, None) + self.assertIn("User deck", cfg.read_text()) # changed meanwhile: left as it is + self.assertIn("left as it is", out["message"]) + + def test_a_late_failure_from_the_last_headset_is_ignored(self): + self.link.state["phase"] = "connected" + self.link.gen = 3 + self.link.lost("ssh: connect to host a port 22: Operation timed out", 2) # sent before the switch + self.assertEqual(self.link.kicks, []) + self.link.lost("ssh: connect to host b port 22: Operation timed out", 3) + self.assertEqual(len(self.link.kicks), 1) + + def test_a_jump_hosts_login_isnt_the_headsets(self): + opts = ["-o", "HostName=10.0.0.5"] + self.assertFalse(fl.Link.is_target('Authenticated to bastion ([1.2.3.4]:22) using "publickey".', opts, "frame")) + self.assertTrue(fl.Link.is_target('Authenticated to 10.0.0.5 ([10.0.0.5]:22) using "publickey".', opts, "frame")) + self.assertTrue(fl.Link.is_target('Authenticated to frame.local ([10.0.0.5]:22) using "publickey".', + ["-o", "HostName=FRAME.LOCAL"], "frame")) + + def test_a_forward_the_jump_host_couldnt_open_tries_the_next_address(self): + said = ["Authenticated to bastion ([1.2.3.4]:22) using \"publickey\".", + "channel 0: open failed: connect failed: Connection refused", "stdio forwarding failed"] + self.link.state["stages"] = [{"id": i, "state": "pending", "started": None, "ended": None, "detail": ""} + for i, _ in fl.STAGES] + self.assertEqual(self.link.failed("login", said, False, "frame"), "next") + self.assertEqual(self.link.failed("login", ["steamos@frame: Permission denied (publickey)."], False, "frame"), + "stop") + + def test_a_reconnect_being_started_isnt_a_live_connection(self): + self.link.state["phase"] = "connected" + self.assertTrue(self.link.alive()) + self.link.busy = True # the loop took a Retry off the queue and is about to reconnect + self.assertFalse(self.link.alive()) # so ensure() waits instead of starting work on it + + def test_probes_from_an_earlier_attempt_leave_the_new_rows_alone(self): + self.link.state.update(attempt=2, probes=[{"host": "b", "state": "waiting"}]) + self.link.probe_update(0, 1, state="answered", ip="10.0.0.2") + self.assertEqual(self.link.state["probes"][0], {"host": "b", "state": "waiting"}) + + def test_a_bare_alias_lets_ssh_config_decide(self): + self.link.override = "frame-bare" + self.hosts({"localhost": "ok"}) + with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)): + self.link.connect(["start"]) + s = self.link.snapshot() + self.assertEqual(s["phase"], "connected", s["error"]) + self.assertTrue(s["device"]["transient"]) + # Where ~/.ssh/config sends it, pinned down for the connection (ssh's own known_hosts). + alias, opts = self.routes[-1] + self.assertEqual((alias, opts[2:]), ("frame-bare", ["-o", "HostName=localhost", "-o", f"Port={self.port}", + "-o", "User=tester"])) + self.assertEqual(opts[:2], ["-o", "ControlPath=" + fl.frame_host.control_path(fl.Link.control_tag(s["device"]))]) + + def test_each_headset_has_its_own_shared_connection(self): + """ssh's %C hashes only address, user and port: two headsets at one address + (one moved) must still never share a ControlMaster.""" + a, b = (dict(fl.Link.bare("x"), id=i, transient=False, user="steamos", port=22) for i in ("aaaa1111", "bbbb2222")) + pa, pb = (next(o for o in self.link.host_opts(d, "192.0.2.5") if o.startswith("ControlPath=")) for d in (a, b)) + self.assertNotEqual(pa, pb) + self.assertIn("aaaa1111", pa) + long_alias = fl.Link.bare("x" * 64) + path = next(o for o in self.link.host_opts(long_alias, None) if o.startswith("ControlPath=")) + self.assertLess(len(path) - len("ControlPath=") - len("%C") + 40 + 17, 104) # fits a macOS socket path + + def test_a_bare_alias_keeps_its_pinned_route_for_reconnects_and_terminals(self): + self.link.override = "frame-bare" + self.hosts({"localhost": "ok"}) + with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)): + self.link.connect(["start"]) + pinned = self.routes[-1][1] + # ~/.ssh/config now sends the alias elsewhere, but an install is running. + self.link.work = lambda: 1 + with mock.patch.object(fl, "ssh_g", return_value=("elsewhere.invalid", 2222, "other", False)): + self.link.close_master() + self.link.connect(["dropped"]) + self.assertEqual(self.link.snapshot()["probes"][0]["host"], "localhost") # probed where commands go + self.assertEqual(self.link.snapshot()["phase"], "connected") + self.assertEqual(self.link.named_route(), ("frame-bare", pinned)) # terminals go there too + + def test_a_set_up_headset_behind_a_jump_host_is_left_to_ssh(self): + d = self.device("10.99.99.98", "10.99.99.99") # neither answers directly + self.hosts({"10.99.99.98": "wrong", "10.99.99.99": "ok"}) + with mock.patch.object(fl, "ssh_g", return_value=("frame-t", 22, "steamos", True)): + self.link.connect(["start"]) + s = self.link.snapshot() + self.assertEqual((s["phase"], s["via"]["host"]), ("connected", "10.99.99.99"), s["error"]) + self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", self.routes[-1][1]) # still pinned per headset + + def test_a_bare_alias_behind_a_jump_host_is_left_to_ssh(self): + self.link.override = "frame-jump" + self.hosts({"10.99.99.99": "ok"}) # ssh's ProxyJump would get there + with mock.patch.object(fl, "ssh_g", return_value=("10.99.99.99", 22, "tester", True)): + self.link.connect(["start"]) + s = self.link.snapshot() + self.assertEqual(s["phase"], "connected", s["error"]) + self.assertEqual(s["via"]["why"], "through a jump host") + + def test_changing_the_port_reroutes_even_if_the_attempt_fails(self): + d = self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + self.reg.update_device(d["id"], port=1) # nothing listens there + self.link.connect(["switch"]) + self.assertEqual(self.link.snapshot()["phase"], "failed") + self.assertIn("Port=1", self.routes[-1][1]) + + def test_test_now_checks_every_address_without_touching_the_connection(self): + self.listen6() + d = self.device("::1", "127.0.0.1", "nothing.invalid") + self.pin(d["id"]) + self.hosts({"::1": "wrong", "127.0.0.1": "ok"}) + self.link.test(d["id"]) + rows = {r["host"]: r for r in self.link.snapshot()["tests"][d["id"]]["rows"]} + self.assertEqual(rows["127.0.0.1"]["ssh"], "ok") + self.assertEqual(rows["::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() if "-G" not in c)) + + def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self): + self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + other = self.reg.add_device("frame-other", port=self.port) + self.reg.add_address(other["id"], "nothing.invalid") + self.link.use(other["id"]) + self.assertEqual(self.routes[-1][0], "frame-other") # at once, before any attempt + self.assertEqual(self.link.snapshot()["phase"], "connecting") + self.assertFalse(self.link.alive()) # so ensure() waits instead of using the old master + self.link.connect(["switch"]) + self.assertEqual(self.link.snapshot()["phase"], "failed") + alias, opts = self.routes[-1] + self.assertEqual(alias, "frame-other") + self.assertIn("HostName=nothing.invalid", opts) + self.assertIn(f"HostKeyAlias=frame-control-{other['id']}", opts) + + def test_an_attempt_overtaken_by_a_switch_routes_nothing_back(self): + self.device("localhost") + self.hosts({"localhost": "ok"}) + other = self.reg.add_device("frame-other", port=self.port) + self.reg.add_address(other["id"], "nothing.invalid") + pick = fl.Link.pick + + def switch_then_pick(*args): + if not getattr(self, "switched", False): + self.switched = True + self.link.use(other["id"]) # the user switches while A is being found + return pick(*args) + with mock.patch.object(fl.Link, "pick", staticmethod(switch_then_pick)): + self.link.connect(["start"]) + self.assertEqual(self.routes[-1][0], "frame-other") + self.assertEqual(self.link.snapshot()["phase"], "connecting") + self.assertFalse(self.link.alive()) + self.assertIsNone(self.link.master) + + def test_no_switching_while_something_is_installing(self): + d = self.device("localhost") + other = self.reg.add_device("frame-other") + for body in ({"action": "use", "id": other["id"]}, {"action": "remove", "id": d["id"]}, + {"action": "update", "id": d["id"], "port": 2222}, + {"action": "address-remove", "id": d["id"], "host": "localhost"}, + {"action": "address-update", "id": d["id"], "host": "localhost", "newHost": "127.0.0.1"}, + {"action": "forget-identity", "id": d["id"]}): + with self.assertRaises(fd.DeviceError, msg=body): + fl.devices_action(self.link, body, open_setup=None, busy=lambda: 1) + # Renaming, or changing another headset, is fine. + fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None, busy=lambda: 1) + fl.devices_action(self.link, {"action": "update", "id": other["id"], "port": 2222}, None, busy=lambda: 1) + self.assertEqual(self.reg.get(d["id"])["name"], "Desk") + + def test_no_reconnecting_under_a_running_install(self): + self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + with self.assertRaises(fd.DeviceError): + fl.devices_action(self.link, {"action": "retry"}, None, busy=lambda: 1) + fl.devices_action(self.link, {"action": "retry"}, None) # fine when nothing runs + + def test_terminals_get_the_address_by_name(self): + d = self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + alias, opts = self.link.named_route() + self.assertEqual(alias, "frame-t") + self.assertIn("HostName=localhost", opts) + self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", opts) + + def test_renaming_during_an_install_is_fine(self): + d = self.device("localhost") + # The page sends the user and port along with the name, unchanged. + fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk", "user": "steamos", + "port": str(self.port)}, None, busy=lambda: 1) + self.assertEqual(self.reg.get(d["id"])["name"], "Desk") + + def test_a_rename_shows_at_once(self): + d = self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None) + self.assertEqual(self.link.snapshot()["device"]["name"], "Desk") + + def test_stopping_mid_handshake_leaves_no_ssh_behind(self): + self.device("localhost") + self.hosts({"localhost": "slow"}) + t = threading.Thread(target=self.link.connect, args=(["start"],), daemon=True) + t.start() + for _ in range(100): + if self.link.pending: + break + time.sleep(0.05) + proc = self.link.pending + self.assertIsNotNone(proc) + self.link.stop() + t.join(10) + self.assertFalse(t.is_alive()) + self.assertIsNotNone(proc.poll()) + self.assertIsNone(self.link.master) + + def test_test_now_goes_through_a_jump_host(self): + d = self.device("10.99.99.99") + self.pin(d["id"]) + self.hosts({"10.99.99.99": "ok"}) + with mock.patch.object(fl, "ssh_g", return_value=("frame-t", 22, "steamos", True)): + self.link.test(d["id"]) + row = self.link.snapshot()["tests"][d["id"]]["rows"][0] + self.assertEqual(row["ssh"], "ok", row) + self.assertIn("jump host", row["detail"]) + + 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")) + # Removing every headset leaves none in use, rather than falling back to the `frame` alias. + spare = self.reg.add_device("frame-spare") + fl.devices_action(self.link, {"action": "remove", "id": spare["id"]}, None) + # The only headset, whose ssh alias would stay: not without removing that too. + (self.dir / "ssh" / "config").write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n" + "Host *\n# <<< steam-frame (frame-t) <<<\n") + with self.assertRaises(fd.DeviceError): + fl.devices_action(self.link, {"action": "remove", "id": d["id"]}, None) + 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") + (self.dir / "ssh" / "config").write_text("Host frame lab-*\n HostName 10.0.0.7\n") # someone's own `frame` + self.assertEqual(fl.next_alias(self.link), "frame-2") + + +@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", "127.0.0.1": "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_changes_meant_for_another_headset_are_refused(self): + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20) + conn.request("POST", "/api/launch", body=b'{"appid": "620"}', + headers={"X-Frame-UI": "1", "Content-Type": "application/json", "X-Frame-Device": "someoneelse"}) + r = conn.getresponse() + self.assertEqual(r.status, 409) + self.assertIn("switched headsets", json.loads(r.read())["error"]) + 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_macview.py b/tests/test_macview.py index 9739add..e636927 100644 --- a/tests/test_macview.py +++ b/tests/test_macview.py @@ -10,6 +10,7 @@ Run: python3 -m unittest discover -s tests import base64 import http.client import json +import io import os import shutil import socket @@ -42,6 +43,43 @@ class Helpers(unittest.TestCase): self.assertEqual(h, 1080) self.assertAlmostEqual(w / h, 0.5, places=2) + def test_the_tunnel_follows_the_headset(self): + mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame") + mv.retarget("frame", ["-o", "ControlPath=/tmp/x-%C", "-o", "HostName=192.0.2.1", "-o", "HostKeyAlias=frame-control-a"]) + self.assertEqual(mv.host_opts, ["-o", "HostName=192.0.2.1", "-o", "HostKeyAlias=frame-control-a"]) + + class Tunnel: + ended = False + def poll(self): return None + def terminate(self): Tunnel.ended = True + mv.tunnel, mv.remote_port = Tunnel(), 47999 + mv.retarget("frame", ["-o", "HostName=192.0.2.9"]) # another address, same headset: keep it + self.assertFalse(Tunnel.ended) + mv.retarget("frame-2", ["-o", "HostName=192.0.2.2"]) # another headset: never the old one's tunnel + self.assertTrue(Tunnel.ended) + self.assertIsNone(mv.tunnel) + self.assertEqual(mv.frame, "frame-2") + + def test_a_switch_just_before_publishing_drops_the_old_headsets_tunnel(self): + mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame") + mv.port = 47000 + ended = [] + + class Proc: + def poll(self): return None + def terminate(self): ended.append(self) + def wait(self): return 0 + stderr = io.StringIO("") + + def probe(port): + mv.retarget("frame-2", ["-o", "HostName=192.0.2.2"]) # the app switches right now + return True + with mock.patch.object(frame_macview.subprocess, "Popen", return_value=Proc()), \ + mock.patch.object(frame_macview.time, "sleep"), mock.patch.object(mv, "_probe", probe): + self.assertFalse(mv._open_tunnel([], [47001])) + self.assertIsNone(mv.tunnel) + self.assertEqual(len(ended), 1) # the tunnel to the old headset was closed + @unittest.skipUnless(shutil.which("bash"), "needs bash") @unittest.skipIf(os.name == "nt", "Windows' bash.exe is WSL's launcher, and runners have no distribution") def test_launch_script_parses(self): diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100644 index 0000000..b808dd8 --- /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.101.102.103", "fd7a:115c:a1e0::1234:5678"]}, + "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.101.102.103": "tailscale", "fd7a:115c:a1e0::1234:5678": "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 35c2852..9805806 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -34,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) @@ -238,6 +240,46 @@ class ServerGuards(unittest.TestCase): self.assertEqual(self.post("/api/nope", {})[0], 404) +class OneServer(unittest.TestCase): + """Two servers for one user would each connect and edit headsets on their own.""" + + def start(self, env): + proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + self.addCleanup(lambda: (proc.terminate(), proc.wait(10), proc.stdout.close())) + return proc + + def test_a_second_server_is_refused_until_the_first_exits(self): + data = tempfile.mkdtemp(prefix="frame-one-server-") + env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid", + "FRAME_CONTROL_SERVER_WAIT": "1"} + first = self.start(env) + self.assertIn("Frame Control on", first.stdout.readline()) + second = subprocess.run([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env, + capture_output=True, text=True, timeout=60) + self.assertEqual(second.returncode, 1) + self.assertIn("already running", second.stderr) + first.terminate() + first.wait(10) + self.assertIn("Frame Control on", self.start(env).stdout.readline()) + + def test_a_private_server_runs_alongside_but_cant_change_headsets(self): + """The MCP adapter starts its own server (FRAME_PRIVATE_SSH=1) while the app runs.""" + data = tempfile.mkdtemp(prefix="frame-one-server-") + env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid", + "FRAME_CONTROL_SERVER_WAIT": "1"} + self.assertIn("Frame Control on", self.start(env).stdout.readline()) + private = self.start({**env, "FRAME_PRIVATE_SSH": "1"}) + line = private.stdout.readline() + self.assertIn("Frame Control on", line) + port = int(line.split("http://127.0.0.1:")[1].split()[0]) + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + conn.request("POST", "/api/devices", body=json.dumps({"action": "use", "id": "x"}), + headers={"Content-Type": "application/json", "X-Frame-UI": "1", "Host": f"127.0.0.1:{port}"}) + r = conn.getresponse() + self.assertEqual(r.status, 403, r.read()) + + class ArtworkSettings(unittest.TestCase): """The settings panel's endpoints, with and without the page's X-Frame-UI key.""" diff --git a/ui/frame_agent.py b/ui/frame_agent.py index ae5a07c..b8a3dbe 100644 --- a/ui/frame_agent.py +++ b/ui/frame_agent.py @@ -1,7 +1,9 @@ """Agent actions and one-use human approvals. No model SDK or network calls here.""" import hashlib +import os from pathlib import Path import secrets +import shlex import shutil import subprocess import threading @@ -129,7 +131,10 @@ def run_script(server, name, args): script = server.HERE.parent / 'scripts' / name if not script.exists() or not shutil.which('zsh') or server.LOCAL: raise ValueError(name + ' requires a computer with zsh and the matching script installed') - result = subprocess.run(['zsh', str(script), *args], capture_output=True, text=True, timeout=60) + # The headset the server is routed to, not whatever `frame` means in ~/.ssh/config. + env = {**os.environ, 'FRAME_ALIAS': server.FRAME, + 'FRAME_SSH_OPTS': shlex.join(server.SSH[1:])} + result = subprocess.run(['zsh', str(script), *args], capture_output=True, text=True, timeout=60, env=env) if result.returncode: raise ValueError(result.stderr.strip() or 'Script failed') return {'message': result.stdout.strip()} diff --git a/ui/frame_connect.py b/ui/frame_connect.py index 9b75ded..a459a32 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 @@ -283,10 +284,40 @@ def config_block(host, port=22, user=FRAME_USER): " IdentitiesOnly yes", " ServerAliveInterval 30", "Host *", END] +class config_lock: + """The lock Frame Control takes to edit ~/.ssh/config (frame_devices.file_lock), so a + running app and this setup never write over each other's change.""" + + def __enter__(self): + self.fh = open(SSH_DIR / "config.frame-control.lock", "a+") + for _ in range(300): + try: + if os.name == "nt": + import msvcrt + self.fh.seek(0) + msvcrt.locking(self.fh.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.lockf(self.fh, fcntl.LOCK_EX | fcntl.LOCK_NB) + return self + except OSError: + time.sleep(0.1) + return self # 30 s: go ahead rather than fail the setup + + def __exit__(self, *exc): + self.fh.close() # closing releases the lock + return False + + def write_config(host, port=22, user=FRAME_USER): + make_ssh_dir() + with config_lock(): + _write_config(host, port, user) + + +def _write_config(host, port, user): """Replace our managed block and put it first: ssh uses the first value it sees per option. The trailing "Host *" returns the rest of the file to global scope.""" - make_ssh_dir() old = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else "" kept, skip = [], False for line in old.splitlines(): @@ -297,7 +328,7 @@ def write_config(host, port=22, user=FRAME_USER): elif not skip: kept.append(line) block = config_block(host, port, user) - tmp = CONFIG.with_name("config.frame-control.tmp") + tmp = CONFIG.with_name(f"config.frame-control.{os.getpid()}.tmp") tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8") if os.name != "nt": tmp.chmod(0o600) @@ -367,9 +398,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..8345302 --- /dev/null +++ b/ui/frame_devices.py @@ -0,0 +1,802 @@ +"""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 the headset's own +(~/.ssh/frame-control-hosts/), so a different device answering at a +remembered IP is caught. + +Python stdlib only. +""" +import contextlib +import copy +import json +import os +import re +import secrets +import subprocess +import tempfile +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" + + +PIN_DIR = "frame-control-hosts" + + +def known_hosts(device_id): + """The headset's own known_hosts file: one per headset, so saving or forgetting one + headset's key (by ssh or by the app) can never touch another's.""" + return ssh_dir() / PIN_DIR / device_id + + +def known_hosts_opt(device_id): + """How ssh is told about it. `~` 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.""" + if os.environ.get("FRAME_CONTROL_SSH_DIR"): + return str(known_hosts(device_id)) + return f"~/.ssh/{PIN_DIR}/{device_id}" + + +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, "port_set": False, + "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"], cur["port_set"] = int(value), True + 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 "" + + +# One edit of ~/.ssh/config at a time: between this app's threads (_config_lock) and +# with Set Up Connection (frame_connect.py and scripts/connect.sh take the same lock +# file). _edit_config also notices any other program writing in between. +_config_lock = threading.Lock() +LOCK_NAME = "config.frame-control.lock" + + +@contextlib.contextmanager +def file_lock(path, timeout=30): + """An exclusive lock on `path` (created if need be) shared with other processes: + POSIX record locks (what zsh's `zsystem flock` takes), or msvcrt on Windows.""" + path.parent.mkdir(parents=True, exist_ok=True) + fh = open(path, "a+") + try: + deadline = time.monotonic() + timeout + while True: + try: + if frame_host.WINDOWS: + import msvcrt + fh.seek(0) + msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.lockf(fh, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except OSError: + if time.monotonic() > deadline: + raise OSError(f"{path} stayed locked (is Set Up Connection running?)") + time.sleep(0.1) + yield + finally: + try: + if frame_host.WINDOWS: + import msvcrt + fh.seek(0) + msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + fcntl.lockf(fh, fcntl.LOCK_UN) + except OSError: + pass + fh.close() + + +def _write_config(path, text, expected): + """Swap the file in whole (as frame_connect.write_config does), keeping it private. + Returns False, writing nothing, if the file no longer holds `expected`.""" + fd_, tmp = tempfile.mkstemp(prefix="config.frame-control.", dir=str(path.parent)) + tmp = Path(tmp) + try: + with os.fdopen(fd_, "w", encoding="utf-8") as fh: + fh.write(text) + 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 + if read_config(path) != expected: + return False + try: + os.replace(tmp, path) + return True + except PermissionError: + time.sleep(0.25) + raise OSError(f"{path} stayed locked by another program") + finally: + if tmp.exists(): + tmp.unlink() + + +def _edit_config(path, change): + """Apply change(lines) -> new lines or None to the file, retrying if another program + wrote it meanwhile. -> True if the file changed.""" + with _config_lock, file_lock(path.with_name(LOCK_NAME)): + for _ in range(5): + text = read_config(path) + new = change(text.splitlines()) + if new is None: + return False + if _write_config(path, "\n".join(new) + "\n", text): + return True + raise OSError(f"{path} kept changing while Frame Control tried to update it") + + +def rewrite_block(alias, path=None, hostname=None, user=None, port=None, expect=None): + """Change HostName, User or Port inside ALIAS's managed block, leaving the rest of the + file alone. -> True if the file changed. Does nothing if there's no such block, or + if `expect` ({"hostname", "user", "port"}; None values match anything) no longer + describes the block, checked under the lock: someone else changed it meanwhile.""" + def change(lines): + if expect: + block = next((b for b in parse_blocks("\n".join(lines)) if b["alias"] == alias), None) + # A port the block doesn't set is inherited from elsewhere in the file: not compared. + if not block or any(v is not None and block[k] != v and (k != "port" or block["port_set"]) + for k, v in expect.items()): + return None + block = next((b for b in parse_blocks("\n".join(lines)) if b["alias"] == alias), None) + # Port 22 needs no line, unless the block would otherwise inherit another port + # from a later Host entry (which ssh would use). + force = bool(port) and block is not None and not block["port_set"] and \ + effective_port(alias, config_path) != int(port) + return _rewritten(lines, alias, hostname, user, port, force) + config_path = Path(path or ssh_config()) + return _edit_config(config_path, change) + + +def _rewritten(lines, alias, hostname, user, port, force_port=False): + begin, end = begin_mark(alias), end_mark(alias) + if begin not in lines or end not in lines: + return None + i, j = lines.index(begin), lines.index(end) + if j < i: + return None + 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) + # An existing Port line is kept, even for 22: dropping it could let a later + # `Host *` Port apply to Terminal but not to the app. + out.append(f" {f[0]} {want[key]}") + else: + out.append(line) + if want["port"] and (want["port"] != "22" or force_port) 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:] + return None if new == lines else new + + +def remove_block(alias, path=None): + def change(lines): + begin, end = begin_mark(alias), end_mark(alias) + if begin not in lines or end not in lines or lines.index(end) < lines.index(begin): + return None + return lines[:lines.index(begin)] + lines[lines.index(end) + 1:] + return _edit_config(Path(path or ssh_config()), change) + + +def effective_port(alias, config): + """The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22.""" + try: + out = subprocess.run(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True, + stdin=subprocess.DEVNULL, timeout=10).stdout + except (OSError, subprocess.TimeoutExpired): + return 22 + m = re.search(r"^port (\d+)$", out, re.M) + port = int(m.group(1)) if m else 22 + return port if 1 <= port <= 65535 else 22 + + +# ---- pinned host keys ------------------------------------------------------------- + +def _keygen(*args): + try: + return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True, + timeout=10) + except (OSError, subprocess.TimeoutExpired): + return None + + +def pinned(device_id): + """Whether a key is saved for the headset. Entries are plain text (ssh gets + HashKnownHosts=no), but ask ssh-keygen too in case one was hashed.""" + target = known_hosts(device_id) + try: + lines = target.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + return False + name = host_key_alias(device_id) + if any(line.split(None, 1)[0].split(",").count(name) for line in lines + if line.strip() and not line.startswith("#")): + return True + r = _keygen("-F", name, "-f", str(target)) + return bool(r and r.returncode == 0 and r.stdout.strip()) + + +def seed_pin(device_id, hosts, port=22, sources=None): + """Copy the host keys ssh already trusts for one of `hosts` into the headset's file + under its alias, so moving to per-headset pinning asks nobody to trust anything again. + -> True if a key is pinned.""" + if pinned(device_id): + 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 + r = _keygen("-F", wanted, "-f", str(src)) + for line in (r.stdout if r else "").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 = known_hosts(device_id) + target.parent.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True) + fd_, tmp = tempfile.mkstemp(prefix=".seed-", dir=str(target.parent)) + with os.fdopen(fd_, "w", encoding="utf-8") as fh: + fh.write("\n".join(dict.fromkeys(keys)) + "\n") + os.replace(tmp, target) # whole file at once: ssh never sees half of it + return True + return False + + +def forget_pin(device_id): + """Drop a headset's saved key, e.g. after SteamOS was reinstalled. The next connection + trusts whatever key the headset shows, as a first connection does.""" + try: + known_hosts(device_id).unlink() + return True + except FileNotFoundError: + 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._depth = 0 # nested _changing() calls + self._mtime = None # devices.json as last loaded or saved + self.data = {"version": VERSION, "active": None, "devices": [], "networks": {}} + self.load() + + # -- storage -- + def load(self): + with self.lock: + try: + self._mtime = self.path.stat().st_mtime_ns + 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)] + # The headset in use is this server's own choice: another server picking a + # different one mustn't move commands (an install, say) under it. The file's + # choice is only where a server starts. + # Kept even if another server removed it, so the connector can see that + # (and not quietly move to another headset in the middle of an install). + mine = self.data.get("active") + if mine: + data["active"] = mine + 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) + self._mtime = self.path.stat().st_mtime_ns + + def _refresh(self): + """Pick up what another Frame Control server saved (the app and a standalone + server can share devices.json).""" + with self.lock: + try: + if self.path.stat().st_mtime_ns != self._mtime: + self.load() + except OSError: + pass + + @contextlib.contextmanager + def _changing(self): + """Every change holds this registry's lock and a lock file shared with other + processes, and starts from what's on disk, so no server saves over another's + change. Nested calls (sync_from_config adding a device) share the outer one.""" + with self.lock: + if self._depth: + self._depth += 1 + try: + yield + finally: + self._depth -= 1 + return + with file_lock(self.path.with_name(self.path.name + ".lock")): + self.load() + self._depth = 1 + try: + yield + finally: + self._depth = 0 + + def snapshot(self): + self._refresh() + with self.lock: + return copy.deepcopy(self.data) + + # -- lookups -- + def devices(self): + self._refresh() + 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): + self._refresh() + with self.lock: + return copy.deepcopy(self._find(device_id)) + + def by_alias(self, alias): + self._refresh() + with self.lock: + return next((copy.deepcopy(d) for d in self.data["devices"] if d["alias"] == alias), None) + + def active(self): + self._refresh() + with self.lock: + return self.data.get("active") + + def emptied(self): + self._refresh() + with self.lock: + return bool(self.data.get("emptied")) and not self.data["devices"] + + def set_active(self, device_id): + with self._changing(): + 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._changing(): + 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) + self.data.pop("emptied", None) + 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._changing(): + d = self._find(device_id) + # Check everything first: a rejected edit changes nothing. + name = None if name is None else (check_text(name, "name") or d["alias"]) + user = None if user is None else check_user(user) + port = None if port is None else check_port(port) + d.update({k: v for k, v in (("name", name), ("user", user), ("port", port)) if v is not None}) + 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._changing(): + d = self._find(device_id) + self.data["devices"].remove(d) + self.data.setdefault("dismissed", {})[d["alias"]] = d.get("config_host") or "" + if not self.data["devices"]: + self.data["emptied"] = True # removed on purpose: don't fall back to the `frame` alias + 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._changing(): + 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._changing(): + d = self._find(device_id) + a = self._addr(d, host) + # Check everything first: a rejected edit changes nothing. + moved = new_host is not None and new_host != host + if moved: + 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") + kind = None if kind is None else check_kind(kind) + label = None if label is None else check_text(label, "label") + if moved: + 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"] = kind + if label is not None: + a["label"] = label + self.save() + return copy.deepcopy(a) + + def remove_address(self, device_id, host): + with self._changing(): + d = self._find(device_id) + d["addresses"].remove(self._addr(d, host)) + self.save() + + def move_address(self, device_id, host, delta): + with self._changing(): + 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._changing(): + 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._changing(): + if self.data.get("dismissed", {}).pop(alias, None) is not None: + self.save() + + def set_config_host(self, device_id, host): + with self._changing(): + 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._changing(): + 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._changing(): + 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" + self._refresh() + 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)) + for b in blocks: + if not b["port_set"]: + # No Port in the block: another Host entry may give one (ssh uses the first). + b["port"] = effective_port(b["alias"], self.config or ssh_config()) + changed = False + with self._changing(): + first = not self.data["devices"] and not self.data.get("active") + 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.get("config_login") != [user, b["port"]]: + # Set Up Connection (or an edit) changed who to log in as, or the port. + if d.get("config_login") is not None and [d["user"], d["port"]] != [user, b["port"]]: + d["user"], d["port"] = user, b["port"] if 1 <= b["port"] <= 65535 else d["port"] + d["config_login"] = [user, 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 first: + # First import: the headset the app used before is `frame`, even if Set Up + # Connection put another block above it. + frame = next((d for d in self.data["devices"] if d["alias"] == "frame"), None) + if frame and self.data.get("active") != frame["id"]: + self.data["active"] = frame["id"] + changed = True + if changed: + self.save() + return changed diff --git a/ui/frame_host.py b/ui/frame_host.py index 0229111..e5c1914 100644 --- a/ui/frame_host.py +++ b/ui/frame_host.py @@ -56,13 +56,19 @@ def cache_dir(*parts): return base.joinpath(*parts) -def control_path(*, private=False): +def control_path(tag="x", *, private=None): """ssh ControlPath for the shared connection, or None where it isn't supported. + `tag` names the headset: ssh's %C hashes only the address, user and port, so two + headsets reached at the same address (one of them moved) would otherwise share a + connection, and one's commands would run on the other. /tmp, not $TMPDIR: macOS's per-user temp path overflows the unix socket path limit. """ + # A private server (the MCP adapter's) keeps its own masters: FRAME_PRIVATE_SSH=1. + if private is None: + private = os.environ.get("FRAME_PRIVATE_SSH") == "1" suffix = f"-{os.getpid()}" if private else "" - return f"/tmp/frame-ui-{os.getuid()}{suffix}-%C" if MUX else None + return f"/tmp/frame-ui-{os.getuid()}{suffix}-{tag}-%C" if MUX else None def which(name, *extra): @@ -258,9 +264,9 @@ def open_steam_link(): return "Steam Link isn't installed; opened its download page" -def open_rdp(alias): - """Remote desktop to the Frame's xrdp (user steamos).""" - host = ssh_hostname(alias) +def open_rdp(alias, host=None): + """Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points.""" + host = host or ssh_hostname(alias) if MAC: if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0: return "Opened Windows App" diff --git a/ui/frame_link.py b/ui/frame_link.py new file mode 100644 index 0000000..51979c6 --- /dev/null +++ b/ui/frame_link.py @@ -0,0 +1,1232 @@ +"""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 hashlib +import ipaddress +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 +RESOLVE_GRACE = 6 # ...after however long the name lookup took, up to this much +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, proxied) from `ssh -G ALIAS`, for a headset that's only an + ssh alias. proxied: it goes through ProxyJump or ProxyCommand, so only ssh can reach it.""" + 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", "proxyjump", "proxycommand") and k not in got: + got[k] = v.strip() + port = int(got["port"]) if got.get("port", "").isdigit() else 22 + proxied = any(got.get(k) not in (None, "", "none") for k in ("proxyjump", "proxycommand")) + return got.get("hostname") or alias, port, got.get("user"), proxied + + +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") + 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)} + # The time out is for connecting: macOS can take 5 s to look up a .local name + # (it waits for an IPv6 answer that never comes), which says nothing about the headset. + deadline = now() + timeout + last = None + infos = infos[:4] + for n, (family, kind, proto, _, addr) in enumerate(infos): + ip = addr[0] + if family == socket.AF_INET6 and len(addr) > 3 and addr[3] and "%" not in ip: + try: # a link-local IPv6 address only works with its interface + ip = f"{ip}%{socket.if_indextoname(addr[3])}" + except (OSError, AttributeError): + pass + left = deadline - now() + if left <= 0: + break + update(state="trying", detail=f"Trying {ip}", ip=ip) + s = socket.socket(family, kind, proto) + # Share the time out, so one address that never answers (a dead IPv6 route, + # say) leaves the others their turn. + s.settimeout(left / (len(infos) - n)) + 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 ssh_target(host, ip): + """Where ssh should go for an address whose probe answered from `ip`: that IP, so ssh + doesn't look the name up again and try an address that didn't answer.""" + try: + ipaddress.ip_address((ip or "").split("%")[0]) + return ip + except ValueError: + return host + + +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.work_lock = threading.Lock() # the server's: held, no install starts (see server.working) + self.work = lambda: 0 # how many installs are running + self.deferred = False # a login change from ~/.ssh/config waiting for them + self.session_alias = env_alias # ...and stays selectable after switching away + 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 + self.attempt_gen = 0 + self.pending = None # an ssh handshake still running + self.gen = 0 # bumped when the headset or its login changes: older attempts are void + self.route_lock = threading.Lock() + self.routed = None # the device id every ssh command points at + self.routed_device = 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, attempt=None, **fields): + with self.cond: + if attempt is not None and attempt != self.state["attempt"]: + return # a probe from an earlier attempt, still finishing: not this one's row + 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): + # Route to the saved headset before the server takes requests: until the connector + # has run, commands (an upload by scp, say) would otherwise go to the default alias. + device = self.active_device() + with self.route_lock: + self.apply(device["alias"], self.first_route(device)) + 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() + if self.thread: + self.thread.join(5) # an attempt in progress notices `stopped` and ends + self.close_master() + + def alive(self): + if self.state["phase"] != "connected" or self.kicks or self.busy: + return False # a reconnect is queued or starting: don't begin anything on this connection + 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: one from the registry, or back to FRAME_ALIAS.""" + if self.session_alias and device_id == self.bare(self.session_alias)["id"] \ + and not self.reg.by_alias(self.session_alias): + self.override = self.session_alias + else: + self.reg.set_active(device_id) + self.override = None + self.invalidate() + + def invalidate(self): + """The headset in use, or how to log in to it, changed: from now on commands go to + the one now selected (never the last one), any attempt still running is void, + and ensure() waits for the connector to reach it.""" + device = self.active_device() + with self.route_lock: + self.gen += 1 + self.apply(device["alias"], self.first_route(device)) + self.routed = None # the next attempt routes again + with self.cond: + # Say so at once: the page clears the old headset's panels when the device changes. + self.state.update(phase="connecting", device=self.public_device(device), via=None, error=None, + retry_at=None, probes=[], stages=[]) + self.kicks.append("switch") + self.version += 1 + self.cond.notify_all() + self.devices_changed() + + def named_route(self): + """(alias, options) for a terminal window: like every command's, but with the + address by name, not the IP it answered from. A zone's % can't be passed through + Windows' console, and ssh resolves the name itself.""" + device = self.active_device() + routed = self.routed_device + if self.routed is not None and routed and routed["alias"] == device["alias"]: + device = routed # as every command has it now (a frozen route, a login change deferred) + via = self.state.get("via") if self.state.get("phase") == "connected" else None + host = via["host"] if via else (device["addresses"][0]["host"] if device.get("addresses") else None) + return device["alias"], self.host_opts(device, host) + + def first_route(self, device): + """Where commands go before any address has answered: the first one, with the + headset's own pinned identity, so nothing reaches another device meanwhile.""" + return self.host_opts(device, device["addresses"][0]["host"] if device["addresses"] else None) + + @staticmethod + def route_key(device): + return device["id"], device.get("user"), device.get("port"), tuple(device.get("frozen") or ()) + + def lost(self, message, gen=None): + """A command couldn't reach the headset (Windows has no master to watch). `gen`: + the route it was sent on; one to a headset since switched away from says nothing + about this one.""" + if gen is not None and gen != self.gen: + return + 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: + # Removed (by another server). Mid-install, fail closed: the rest of the + # install, or its clean-up, mustn't land on whichever headset comes next. + if self.work(): + return self.NONE + devices = self.reg.devices() + if devices: + return devices[0] + if self.reg.emptied(): + return self.NONE # every headset was removed: reach nothing until one is added + return self.bare("frame") # never set up here, or set up before the registry existed + + NONE = {"id": "none", "name": "No headset", "alias": "frame-control-no-headset", "user": None, "port": None, + "addresses": [], "transient": True, "none": True, "identity_files": []} + + @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": []} + + @staticmethod + def control_tag(device): + """A short, path-safe name for the headset's ControlPath: its id, or for a bare + alias a hash of it (an alias can be too long for a socket path).""" + if device.get("transient"): + return "a" + hashlib.sha1(device["alias"].encode()).hexdigest()[:8] + return device["id"] + + def host_opts(self, device, host): + """What every ssh command adds to reach DEVICE at HOST.""" + if device.get("none"): + return ["-o", "HostName=no-headset.invalid"] # fails at once, with ssh's own "can't resolve" + # Each headset its own shared connection (see frame_host.control_path). + mux = ["-o", f"ControlPath={frame_host.control_path(self.control_tag(device))}"] if self.control else [] + if device.get("transient"): + return [*mux, *(device.get("frozen") or [])] # what ~/.ssh/config said when it was routed + if not host: # a headset with no addresses: reach nothing, not whatever ~/.ssh/config says + return ["-o", "HostName=no-address.invalid"] + # StrictHostKeyChecking=yes: whatever ~/.ssh/config says for Host *, every command + # checks the headset's pinned key (only the connector's first handshake may save one). + return [*mux, "-o", "StrictHostKeyChecking=yes", "-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(device['id'])}", "-o", "HashKnownHosts=no", + "-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 + before = self.active_device() + if before.get("transient") and not before.get("none") and self.routed is not None: + # A bare alias is in use: a headset set up now doesn't take over by itself. + self.override = self.override or before["alias"] + self.session_alias = self.session_alias or before["alias"] # and stays on the list + if self.reg.sync_from_config(): + self.devices_changed() + after = self.active_device() + if (before.get("user"), before.get("port")) != (after.get("user"), after.get("port")): + self.deferred = True # Set Up Connection changed the active headset's login + if self.deferred: + with self.work_lock: # not while an install runs: it reads the route step by step + if not self.work(): + self.deferred = False + self.invalidate() + + 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() + with self.work_lock, self.route_lock: + gen = self.attempt_gen = self.gen + device = self.active_device() + if device.get("transient") and not device.get("none") and "frozen" not in device: + # A bare alias: pin down where ~/.ssh/config sends it now, so an edit to that + # file can't move the commands of an install that's running. + h, p, u, proxied = ssh_g(device["alias"]) + device = dict(device, user=device.get("user") or u, port=p, frozen_host=h, proxied=proxied, frozen=[ + "-o", f"HostName={frame_devices.ssh_host(h)}", "-o", f"Port={p}", + *(["-o", f"User={u}"] if u else [])]) + if self.routed and self.routed_device and self.route_key(device) != self.routed and self.work(): + # While an install runs, nothing moves it: not Set Up Connection changing this + # headset in ~/.ssh/config, nor another Frame Control server adding, choosing + # or removing headsets in devices.json. (Switching here is refused meanwhile.) + # Reconnect as it started; the change applies once it's done (see watch_config). + device = self.routed_device + self.deferred = True + if self.route_key(device) != self.routed: + # Another headset, or a new user or port: nothing may go on using the old + # route, even if this attempt fails. + self.alias, self.opts = device["alias"], self.first_route(device) + self.apply(self.alias, self.opts) + self.routed = self.route_key(device) + self.routed_device = 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: + self.finish(gen, ok, device) + + def finish(self, gen, ok, device): + """Publish how an attempt ended (connected, or failed with a retry time).""" + with self.cond: + if gen != self.gen: + # The headset changed meanwhile: this attempt's result is about the + # old one. Leave "connecting"; the queued switch starts the next. + self.state["phase"] = "connecting" + self.version += 1 + self.cond.notify_all() + ok = None + if ok is None: + self.close_master() + return + 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=None if device.get("none") or not (device.get("transient") or device["addresses"]) else + 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): + if device.get("none"): + self.fail("find", "No headset is set up. Add one on the Devices tab.") + return False + if not device.get("transient") and not device["addresses"]: + self.fail("find", f"{device['name']} has no addresses. Add one on the Devices tab.") + return False + # 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"): + # Where the route was pinned (connect), so the probe checks what commands use. + if "frozen_host" in device: + host, port, user, proxied = device["frozen_host"], device["port"], device.get("user"), device["proxied"] + else: + host, port, user, proxied = ssh_g(device["alias"]) + if user and not device.get("user"): + device["user"] = user + a = {"host": host, "kind": frame_network.guess_kind(host), "label": "from ~/.ssh/config"} + if proxied: + # Reached through a jump host: only ssh itself can find it. + with self.cond: + self.state["probes"] = [dict(a, why="through a jump host", state="answered", ip=None, rtt_ms=None, + detail="ssh's ProxyJump or ProxyCommand connects")] + self.stage("find", "done", f"{device['alias']} goes through a jump host; ssh finds it") + return self.handshake(device, a, {"ip": None, "rtt_ms": None}, device.get("user") or user) == "ok" \ + and self.finish_bare(a, net) + ranked = [(a, "from ~/.ssh/config")] + else: + ranked = frame_devices.order_addresses(device["addresses"], net.get("id"), bool(ts.get("up"))) + if ssh_g(device["alias"])[3]: + # ~/.ssh/config sends this alias through a jump host: a direct probe says + # nothing, so let ssh (through the jump host) try each address in turn. + with self.cond: + self.state["probes"] = [dict(a, why=why, state="waiting", detail="Through a jump host", ip=None, + rtt_ms=None, label=a.get("label") or "") for a, why in ranked] + self.stage("find", "done", f"{device['alias']} goes through a jump host; ssh finds it") + for i, (a, why) in enumerate(ranked): + if i: + for sid in ("ssh", "identity", "login"): + self.stage(sid, "pending", "") + outcome = self.handshake(device, a, {"ip": None, "rtt_ms": None}, device.get("user")) + if outcome == "ok": + self.probe_update(i, state="answered", detail="Reached through the jump host") + self.publish(via={"host": a["host"], "kind": a["kind"], "ip": None, "rtt_ms": None, + "why": "through a jump host", "network": net.get("id"), + "network_name": net["name"]}) + self.learn(device, a["host"], net, None) + return True + with self.cond: + why_not = (self.state["error"] or {}).get("message") or "SSH failed" + self.probe_update(i, state="sshfailed", detail=why_not) + if outcome != "next": + return False + return False + 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() + + attempt_no = self.state["attempt"] + + def run_probe(i, host): + res = probe(host, port, update=lambda **f: self.probe_update(i, attempt_no, **f)) + res.setdefault("ip", None) + res.setdefault("rtt_ms", None) + self.probe_update(i, attempt_no, **{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 + RESOLVE_GRACE + 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 + + def finish_bare(self, a, net): + self.publish(via={"host": a["host"], "kind": a["kind"], "ip": None, "rtt_ms": None, + "why": "through a jump host", "network": net.get("id"), "network_name": net["name"]}) + return True + + @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 + RESOLVE_GRACE + 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) + # Set Up Connection may have changed the block while this attempt ran: take that + # in (and reconnect) rather than writing this attempt's older settings over it. + self.watch_config() + try: + now_dev = self.reg.get(device["id"]) + except frame_devices.DeviceError: + return + if self.route_key(now_dev) != self.route_key(device): + return + # Terminal's `ssh ALIAS` and the helper scripts use ~/.ssh/config: point it here too, + # unless the block changed since this attempt began (checked under the file lock). + login = device.get("config_login") or [None, None] + expect = {"hostname": device.get("config_host"), "user": login[0], "port": login[1]} + try: + if frame_devices.rewrite_block(device["alias"], hostname=host, user=device["user"], + port=device["port"], expect=expect): + self.config_mtime = frame_devices.ssh_config().stat().st_mtime + self.reg.set_config_host(device["id"], host) + self.reg.sync_from_config() # records the login it now holds + 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 + pending, self.pending = self.pending, None + if pending and pending.poll() is None: + pending.kill() + 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".""" + # The IP that answered (with a link-local IPv6 address's zone), so ssh doesn't + # look the name up again and stall on an address that didn't answer. + opts = self.host_opts(device, ssh_target(a["host"], found.get("ip"))) + alias = device["alias"] + with self.route_lock: + if self.attempt_gen != self.gen: + return "stop" # the headset changed: don't route anything back to this one + 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. ssh won't create + # the folder its known_hosts file goes in. + extra = ["-o", "StrictHostKeyChecking=accept-new"] + pins = frame_devices.known_hosts(device["id"]).parent + pins.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True) + if self.control: + # No ConnectTimeout: with it, OpenSSH's master takes ~5s to open its socket. + # `extra` first: ssh takes the first value of an option, and it may say accept-new. + argv = [*self.mux_base, *extra, *opts, "-v", "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5", + "-o", "ServerAliveCountMax=2", "-N", alias] + else: + argv = [*self.mux_base, *extra, *opts, "-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" + self.pending = proc # so stop() can end it mid-handshake + 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 self.stopped: + proc.kill() + return "stop" + 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" # silence is about this address (or a jump host's forward to it) + 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) and self.is_target(line, opts, alias): + 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 + self.pending = None + if self.stopped: + proc.kill() + return "stop" + 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" + + @staticmethod + def is_target(line, opts, alias): + """Whether an "Authenticated to X" line is about the headset, not a jump host + (ssh -v passes its verbosity on to ProxyJump's own ssh).""" + host = next((o.split("=", 1)[1].replace("%%", "%") for o in opts if o.startswith("HostName=")), alias) + m = re.search(r"Authenticated to (\S+)", line) + # OpenSSH lower-cases host names (FRAME.LOCAL logs as frame.local). + return not m or m.group(1).lower() in (host.lower(), alias.lower()) or "Authentication succeeded" in line + + 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" + # A refused key is the same at every address: stop. (Judged by ssh's own words, not + # the step: a jump host's progress lines look like the headset's.) A forward that + # a jump host couldn't open is about this address only: try the next. + forward = re.search(r"open failed|forwarding failed|Connection refused|Connection closed|timed out", text) + if re.search(r"Permission denied", text) and not forward: + 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 "next" + 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 {} + + proxied = ssh_g(device["alias"])[3] + + def one(i, a): + if proxied: # through a jump host: a direct probe says nothing, ssh itself is the test + res = {"state": "answered", "detail": "Through a jump host", "ip": None, "rtt_ms": None} + else: + 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 + lead = f"Answered in {res['rtt_ms']:g} ms" if res.get("rtt_ms") is not None else "Through the jump host" + rows[i]["ssh"] = "checking" + put() + argv = [*self.mux_base[:3], "-o", "ControlPath=none", "-o", "ConnectTimeout=8", + *self.host_opts(device, ssh_target(a["host"], res.get("ip"))), + "-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"{lead} · 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"{lead} · 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 = [] + bare = link.bare(link.session_alias) if link.session_alias and not link.reg.by_alias(link.session_alias) else None + for extra in ([active] if active.get("transient") and not active.get("none") else []) + \ + ([bare] if bare and bare["id"] != active["id"] else []): + devices.append(dict(link.public_device(extra), active=extra["id"] == active["id"], 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 login_change(reg, did, body): + """Whether an update asks for another user or port (the page sends both every time).""" + try: + d = reg.get(did) + except frame_devices.DeviceError: + return False + user, port = body.get("user"), body.get("port") + try: + port = int(port) if port is not None else None + except (TypeError, ValueError): + return True # it will be refused anyway + return (user is not None and user != d["user"]) or (port is not None and port != d["port"]) + + +def devices_action(link, body, open_setup, busy=lambda: 0): + """POST /api/devices {"action": ..., "id": device id, ...}. -> {"message", ...devices_view}. + busy() counts installs in progress: nothing may move them to another headset.""" + reg = link.reg + action = body.get("action") + did = body.get("id") + active = link.active_device() + is_active = did == active["id"] + moves = action == "use" or (action == "retry" and link.alive()) or (is_active and ( + # (a retry while connected would cut the install's connection) + action in ("remove", "address-remove", "forget-identity") + or (action == "update" and login_change(reg, did, body)) + or (action == "address-update" and body.get("newHost") not in (None, body.get("host"))))) + if moves and busy(): + raise frame_devices.DeviceError( + f"Wait for what's running on {active['name']} to finish (see the activity bar), then try again") + if action == "use": + sa = link.session_alias + d = link.bare(sa) if sa and did == link.bare(sa)["id"] and not reg.by_alias(sa) else reg.get(did) + link.use(did) + msg = f"Switched to {d['name']}" + elif action == "update": + before = reg.get(did) + d = reg.update_device(did, name=body.get("name"), user=body.get("user"), port=body.get("port")) + login_changed = (d["user"], d["port"]) != (before["user"], before["port"]) + if is_active and login_changed: + link.invalidate() # before anything else can fail: the old login mustn't stay in use + msg = f"Saved {d['name']}" + if is_active and not login_changed: + link.publish(device=link.public_device(link.active_device())) # a new name shows at once + if login_changed: + # Only what changed, and only if the block still says what it did: Set Up + # Connection may have written a new login meanwhile, which then stands. + try: + if not frame_devices.rewrite_block(d["alias"], user=d["user"], port=d["port"], + expect={"user": before["user"], "port": before["port"]}) \ + and any(b["alias"] == d["alias"] and (b["user"], b["port"]) != (d["user"], d["port"]) + for b in frame_devices.parse_blocks(frame_devices.read_config())): + msg += "; ~/.ssh/config changed meanwhile, so it was left as it is" + except OSError as e: + raise frame_devices.DeviceError(f"Saved, but couldn't update ~/.ssh/config: {e}") + elif action == "remove": + if not body.get("config") and len(reg.devices()) == 1 and reg.get(did)["alias"] in { + b["alias"] for b in frame_devices.parse_blocks(frame_devices.read_config())}: + # Its ssh alias would stay, and the app would go on using it as a bare alias. + raise frame_devices.DeviceError("This is your only headset. To remove it completely, also remove its " + "entry from ~/.ssh/config (the box below)") + d = reg.remove_device(did) + if d["alias"] == link.session_alias: + link.session_alias = None # removed on purpose: not back as a bare alias + if is_active: + link.override = None + link.invalidate() + 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}") + 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")) + if is_active and a["host"] != body.get("host"): + link.invalidate() # the address in use may have moved + msg = f"Saved {a['host']}" + elif action == "address-remove": + reg.remove_address(did, body.get("host")) + if is_active: + link.invalidate() # it may be the address in use: stop using it now + 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): + """A free alias for a new headset: not one Frame Control knows, nor any `Host` name + already in ~/.ssh/config (Set Up Connection's block would shadow it).""" + text = frame_devices.read_config() + taken = {d["alias"] for d in link.reg.devices()} | {b["alias"] for b in frame_devices.parse_blocks(text)} + taken |= {a for a in (link.session_alias, link.override, link.active_device().get("alias")) if a} + for line in text.splitlines(): + f = line.split() + if f and f[0].lower() == "host": + taken |= {name for name in f[1:] if not any(c in name for c in "*?!")} + 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_macview.py b/ui/frame_macview.py index b89cf88..f6b346e 100644 --- a/ui/frame_macview.py +++ b/ui/frame_macview.py @@ -120,6 +120,10 @@ class MacView: self.tunnel_ssh = list(tunnel_ssh) self.run = run self.frame = frame + self.host_opts = [] # the headset in use and how to reach it (see retarget) + # Short, never held across ssh: retarget() and publishing a new tunnel check and + # change the headset together (self.lock is held while a tunnel is being opened). + self.route_lock = threading.Lock() self.track = track or (lambda proc: None) # the server ends these on exit self.lock = threading.Lock() self.token = secrets.token_urlsafe(24) @@ -239,13 +243,37 @@ class MacView: last = self._last_tunnel_error raise MacViewError(f"Couldn't open a tunnel from {self.frame} to this Mac: {last or 'no answer through it'}") + def retarget(self, alias, host_opts): + """The server now reaches the headset as `alias` with `host_opts` (another address, + or another headset). Only the short route_lock, never self.lock: this runs while + the server routes, which a tunnel being opened may be waiting on.""" + # host_opts is "-o", "Name=value" pairs; the tunnel keeps its own connection, + # not the shared master. + opts = [x for flag, value in zip(host_opts[::2], host_opts[1::2]) + if not value.startswith("ControlPath=") for x in (flag, value)] + with self.route_lock: + moved = alias != self.frame + self.frame, self.host_opts = alias, opts + tunnel = self.tunnel + if moved and tunnel is not None: + # Another headset: its viewers can't be the old one's. The supervisor + # reopens a tunnel to the new one if anything is being shown. + self.tunnel, self.remote_port = None, None + if moved and tunnel is not None and tunnel.poll() is None: + tunnel.terminate() + def _open_tunnel(self, via, ports): """Tries the ports on one route; True once the tunnel answers. With self.lock held.""" last = "" for port in ports: - proc = subprocess.Popen([*self.tunnel_ssh, *via, "-o", "ExitOnForwardFailure=yes", + with self.route_lock: + target = (self.frame, self.host_opts) # retarget() may change these meanwhile + # `via` first: ssh keeps the first value of an option, so USB-C's HostName wins + # while the headset's pinned identity (in host_opts) still checks it. + proc = subprocess.Popen([*self.tunnel_ssh, "-o", "ControlPath=none", *via, *target[1], + "-o", "ExitOnForwardFailure=yes", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=3", "-N", - "-R", f"127.0.0.1:{port}:127.0.0.1:{self.port}", self.frame], + "-R", f"127.0.0.1:{port}:127.0.0.1:{self.port}", target[0]], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) # A taken port makes ssh exit once it's connected; a working @@ -259,7 +287,11 @@ class MacView: ok = True break if ok: - self.tunnel, self.remote_port = proc, port + with self.route_lock: # checked and published together, so a switch can't slip between + ok = target[0] == self.frame # else the app switched headset while this connected + if ok: + self.tunnel, self.remote_port = proc, port + if ok: self.track(proc) self._supervise() return True @@ -294,6 +326,8 @@ class MacView: socket.create_connection((ip, 22), timeout=1).close() except OSError: return [] # not plugged into this Mac + if any(o.startswith("HostKeyAlias=") for o in self.host_opts): + return ["-o", f"HostName={ip}"] # checked against the headset's own pinned key alias = self.frame try: cfg = subprocess.run(["ssh", "-G", self.frame], capture_output=True, text=True, timeout=5).stdout 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 7a69418..0d7a686 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 ---- */ #media select { min-width: 0; max-width: 100%; flex: 1; } .dropzone { position: fixed; inset: 0; z-index: 40; display: grid; place-items: center; pointer-events: none; @@ -591,9 +643,11 @@ Games2 Android3 Tools4 + Devices5
- Connecting… + + — @@ -1079,6 +1134,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.
+
+