From a90ffeda5feb22e1144e16990f3e6ba4a3eed7e7 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:03:48 +1000 Subject: [PATCH] Pair through the SteamOS devkit service before asking for a password connect.sh and frame_connect.py now try Valve's steamos-devkit-service first: GET /properties.json for the login user, then POST /register with a new RSA key (~/.ssh/id_rsa_frame_devkit, the only type it accepts), so the user approves a prompt in the headset instead of typing a password. Port 32000 closed, a timeout or a 403 falls back to the existing password copy. The Host frame block lists both keys; a host counts as found if port 22 or 32000 answers; with no host given, dns-sd or avahi-browse look for _steamos-devkit._tcp. Inferred from Valve's source, not yet verified on a Frame. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 8 +- docs/scripts.md | 6 +- docs/ssh.md | 35 ++++- scripts/connect.sh | 219 +++++++++++++++++++++++++------ tests/test_connect.py | 192 ++++++++++++++++++++++++++++ ui/frame_connect.py | 290 ++++++++++++++++++++++++++++++++++++++---- 6 files changed, 676 insertions(+), 74 deletions(-) create mode 100644 tests/test_connect.py diff --git a/README.md b/README.md index 573e1ae..4756d45 100644 --- a/README.md +++ b/README.md @@ -147,13 +147,19 @@ computer. Connection**, which finds the headset, creates an SSH key, and asks for that password once in a terminal window. If it can't find the Frame, type the IP address from the Frame's Quick Settings. + + Before asking for the password it tries Valve's SteamOS devkit pairing: if + the headset shows a pairing request, approve it and no password is needed. + (**Inferred from Valve's source** ([steamos-devkit-service](https://gitlab.steamos.cloud/devkit/steamos-devkit-service)), + not yet verified on a Frame; see [SSH](docs/ssh.md#password-free-pairing-steamos-devkit-service).) 3. That's it. The app now reaches the headset whenever it's awake and on the same network. For anywhere else, see [Tailscale](docs/tailscale.md). **What it changes:** only what you click. Installs go to your user account on the Frame (`--user` Flatpaks, Lepton instances, Steam downloads), and nothing needs `sudo` except the power buttons. On your computer it adds a `Host frame` -entry to `~/.ssh/config` and a key at `~/.ssh/id_ed25519_frame`. +entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and +`~/.ssh/id_rsa_frame_devkit` (the pairing service only takes RSA keys). ## Feedback diff --git a/docs/scripts.md b/docs/scripts.md index a1a1e0f..464c5a6 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -36,9 +36,11 @@ ssh frame # passwordless from now on `connect.sh` does four things: - finds the headset (`frame.local`, then `frame`, or the IP/host you pass in) -- creates a dedicated key (`~/.ssh/id_ed25519_frame`) +- creates dedicated keys (`~/.ssh/id_ed25519_frame`, plus `~/.ssh/id_rsa_frame_devkit` for pairing) - adds a `Host frame` block to `~/.ssh/config` -- runs `ssh-copy-id`, which asks for the Developer Mode password once +- tries SteamOS devkit pairing (approve on the headset, no password; **inferred**, + see [SSH](ssh.md#password-free-pairing-steamos-devkit-service)), else runs + `ssh-copy-id`, which asks for the Developer Mode password once Run `./scripts/connect.sh --harden` later if you want to turn off SSH password logins. diff --git a/docs/ssh.md b/docs/ssh.md index 0b25cce..aa11407 100644 --- a/docs/ssh.md +++ b/docs/ssh.md @@ -33,7 +33,8 @@ unless your router's DNS registers DHCP client names. - **Verified on device (2026-09-25):** `avahi-daemon` is running on the Frame and `frame.local` resolves from the Mac over mDNS. -- `scripts/connect.sh` tries `frame.local`, then `frame`. If neither works, it tells you to re-run it with the IP. +- `scripts/connect.sh` tries `frame.local`, then `frame`, then an mDNS browse for + the devkit service (below). If none works, it tells you to re-run it with the IP. Once you have a working address, the `Host frame` alias means you just type `ssh frame`. - To check discovery yourself: `dns-sd -G v4 frame.local` (Ctrl-C to stop), or @@ -55,10 +56,42 @@ Host frame HostName frame.local User steamos IdentityFile ~/.ssh/id_ed25519_frame + IdentityFile ~/.ssh/id_rsa_frame_devkit IdentitiesOnly yes ServerAliveInterval 30 ``` +The script only asks for the password if the pairing below doesn't work. + +## Password-free pairing (SteamOS devkit service) + +**Inferred from Valve's source ([steamos-devkit-service](https://gitlab.steamos.cloud/devkit/steamos-devkit-service), +[steamos-devkit](https://gitlab.steamos.cloud/devkit/steamos-devkit) client); not yet +verified on a Frame.** SteamOS's devkit service is what Valve's Devkit Client +uses to pair. `scripts/connect.sh` and `ui/frame_connect.py` try it first: + +- The headset serves HTTP on port **32000** and advertises mDNS + `_steamos-devkit._tcp`. `GET /properties.json` gives the `login` user; the + script uses it as `User` (unless you set `FRAME_USER`, or it says `root`), + for the password fallback too, and keeps it on re-runs. +- `POST /register` with `ssh-rsa 900b919520e4cf601998a71eec318fec` + (a fixed token from Valve's client) shows an approve prompt inside the + headset naming the comment (`frame-control@`). It waits 30 s, + then installs the key for the device user and turns `sshd` on. The reply is + `200 Registered`, or `403` with `{"error": ...}` (declined, timed out, Steam + not running). +- It only accepts **RSA** keys, hence the second key, + `~/.ssh/id_rsa_frame_devkit` (3072-bit). +- A host counts as found if port 22 **or** 32000 answers. With no host given, + and `frame.local`/`frame` unreachable, it browses `_steamos-devkit._tcp` with + `dns-sd` (macOS) or `avahi-browse` (Linux) for a few seconds if installed. +- Port 32000 closed, a timeout, or an error: the script says why and falls back + to copying the ed25519 key with the Developer Mode password, as before. + +Anyone on your network can send the request, so only approve a prompt you +started. Whether the Frame runs this service with Developer Mode on is the +unverified part: `curl http://frame.local:32000/properties.json` answers the question. + `~/.ssh/authorized_keys` lives under `/home`, which SteamOS keeps across OS updates (inferred from Deck; the Frame uses the same A/B image scheme). diff --git a/scripts/connect.sh b/scripts/connect.sh index 9ce6cd4..0be75a5 100755 --- a/scripts/connect.sh +++ b/scripts/connect.sh @@ -1,8 +1,10 @@ #!/usr/bin/env zsh -# Mac-side: find the Steam Frame, create a key, add a `Host frame` alias to -# ~/.ssh/config, copy the key, and optionally disable SSH password logins. +# Mac-side: find the Steam Frame, create keys, add a `Host frame` alias to +# ~/.ssh/config, get a key onto the headset, and optionally disable SSH password +# logins. It first pairs through Valve's SteamOS devkit service (port 32000: +# approve on the headset, no password), else copies the key with the password. # -# Verified on a Frame 2026-09-25 (except --harden). Idempotent: safe to re-run. +# Verified on a Frame 2026-09-25 (except --harden and devkit pairing). Idempotent. # # Usage: # scripts/connect.sh [HOST_OR_IP] # set up key + alias @@ -11,93 +13,226 @@ # Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame). set -euo pipefail +user_from_env=${+FRAME_USER} FRAME_USER=${FRAME_USER:-steamos} FRAME_ALIAS=${FRAME_ALIAS:-frame} KEY="$HOME/.ssh/id_ed25519_frame" +# The devkit service only accepts ssh-rsa keys, so pairing uses a second key. +DEVKIT_KEY="$HOME/.ssh/id_rsa_frame_devkit" CONFIG="$HOME/.ssh/config" BEGIN_MARK="# >>> steam-frame ($FRAME_ALIAS) >>>" END_MARK="# <<< steam-frame ($FRAME_ALIAS) <<<" +DEVKIT_PORT=32000 +DEVKIT_SERVICE=_steamos-devkit._tcp +MAGIC_PHRASE=900b919520e4cf601998a71eec318fec # fixed token Valve's client appends +NAME_RE='^[A-Za-z0-9][A-Za-z0-9._-]*$' +HOST_RE='^[A-Za-z0-9][A-Za-z0-9.:%-]*$' harden=0 host_arg="" for arg in "$@"; do case "$arg" in --harden) harden=1 ;; - -h|--help) sed -n '2,11p' "$0"; exit 0 ;; + -h|--help) sed -n '2,13p' "$0"; exit 0 ;; *) host_arg="$arg" ;; esac done port_open() { # nc resolves through the system resolver (including mDNS for .local). - nc -z -G 3 "$1" 22 >/dev/null 2>&1 + nc -z -G 3 "$1" "$2" >/dev/null 2>&1 +} + +# sshd, or the devkit service, which turns sshd on once a pairing is approved. +reachable() { + port_open "$1" 22 || port_open "$1" $DEVKIT_PORT +} + +# What a command printed within $1 seconds; dns-sd never exits by itself. +run_for() { + local secs=$1; shift + "$@" 2>/dev/null & + local pid=$! + sleep "$secs" + kill $pid 2>/dev/null || true + wait $pid 2>/dev/null || true +} + +# Hosts advertising the devkit service over mDNS (dns-sd -B, then -L each). +discover_devkit() { + local name target + run_for 3 dns-sd -B $DEVKIT_SERVICE local. \ + | sed -n "s/.* Add .*${DEVKIT_SERVICE//./\\.}\\.[[:space:]]*//p" | awk '!seen[$0]++' | head -n 4 \ + | while IFS= read -r name; do + target=$(run_for 2 dns-sd -L "$name" $DEVKIT_SERVICE local. \ + | sed -n 's/.* can be reached at \([^ :]*\):[0-9].*/\1/p' | head -n 1) + [[ -n "$target" ]] && print -r -- "${target%.}" + done | awk '!seen[$0]++' } pick_host() { local candidates=() [[ -n "$host_arg" ]] && candidates+=("$host_arg") - candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS") + [[ -z "$host_arg" ]] && candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS") local h for h in "${candidates[@]}"; do - if port_open "$h"; then + if reachable "$h"; then print -r -- "$h"; return 0 fi - print -u2 " - $h: not resolvable or port 22 closed" + print -u2 " - $h: not resolvable, or ports 22 and $DEVKIT_PORT closed" + done + [[ -n "$host_arg" ]] && return 1 + print -u2 " - asking mDNS for $DEVKIT_SERVICE" + for h in ${(f)"$(discover_devkit)"}; do + if [[ "$h" =~ $HOST_RE ]] && reachable "$h"; then + print -r -- "$h"; return 0 + fi + print -u2 " - $h: advertised, but not reachable" done return 1 } +make_key() { # path type comment [extra ssh-keygen args] + if [[ ! -f "$1" ]]; then + ssh-keygen -q -t "$2" "${@:4}" -N '' -C "$3" -f "$1" + print " created $1" + else + print " exists: $1" + fi +} + +# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off. +write_config() { + touch "$CONFIG" && chmod 600 "$CONFIG" || return 1 + local tmp + 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" + # or "Host *". The trailing "Host *" returns the rest of the file to global scope. + awk -v b="$BEGIN_MARK" -v e="$END_MARK" ' + $0==b {skip=1; next} + $0==e {skip=0; next} + !skip {print} + ' "$CONFIG" > "$tmp" || { rm -f "$tmp"; return 1; } + { + print -r -- "$BEGIN_MARK" + print -r -- "Host $FRAME_ALIAS" + print -r -- " HostName $HOST" + print -r -- " User $FRAME_USER" + print -r -- " IdentityFile $KEY" + print -r -- " IdentityFile $DEVKIT_KEY" + print -r -- " IdentitiesOnly yes" + print -r -- " ServerAliveInterval 30" + print -r -- "Host *" + print -r -- "$END_MARK" + cat "$tmp" + } > "$CONFIG" || { print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; } + rm -f "$tmp" +} + +# accept-new: after pairing, this is the first contact, so trust a first-seen host +# key (as ssh-copy-id's prompt would); a changed one still fails. +key_login_works() { + ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new "$FRAME_ALIAS" true 2>/dev/null +} + +# The User in our managed block, so a re-run keeps one the headset named earlier. +configured_user() { + [[ -f "$CONFIG" ]] || return 0 + awk -v b="$BEGIN_MARK" -v e="$END_MARK" ' + $0==b {inside=1; next} + $0==e {exit} + inside && $1=="User" {print $2; exit} + ' "$CONFIG" +} + +devkit_url() { + if [[ "$HOST" == *:* ]]; then print -r -- "http://[$HOST]:$DEVKIT_PORT$1" + else print -r -- "http://$HOST:$DEVKIT_PORT$1"; fi +} + +# Valve's steamos-devkit-service: GET /properties.json names the login user; POST +# /register with "ssh-rsa " shows an approve prompt in the +# headset (the comment is what it displays, 30 s to answer), then installs the key +# and turns sshd on. Returns non-zero with the reason in $devkit_why to fall back. +devkit_why="" +pair_with_devkit() { + local props login comment body resp code text err + print "==> Pairing through the headset's SteamOS devkit service (no password)" + if [[ ! -r "$DEVKIT_KEY.pub" ]]; then + devkit_why="can't read the pairing key $DEVKIT_KEY.pub"; return 1 + fi + if ! props=$(curl -fsS --noproxy '*' -m 5 "$(devkit_url /properties.json)" 2>&1); then + devkit_why="devkit service not reachable on port $DEVKIT_PORT: ${${props##*curl: }%%$'\n'*}"; return 1 + fi + # properties.json is Valve's json.dumps(indent=2): "login" sits on its own line. + login=$(print -r -- "$props" | sed -n 's/.*"login"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) + [[ "$login" =~ $NAME_RE && "$login" != root ]] || login="" + # Before the prompt, so the password fallback uses this user too. + if [[ -n "$login" && "$login" != "$FRAME_USER" ]]; then + if (( user_from_env )); then + print " the headset logs in as '$login'; keeping FRAME_USER=$FRAME_USER" + else + FRAME_USER=$login + print " the headset logs in as '$FRAME_USER'" + write_config || { print -u2 "Could not rewrite $CONFIG."; exit 1; } + fi + fi + # One word: the headset splits the body on spaces and shows the third field. + comment="frame-control@$(hostname -s | tr -cs 'A-Za-z0-9._-' '-' | sed 's/^[-.]*//; s/[-.]*$//')" + [[ "$comment" == "frame-control@" ]] && comment="frame-control@computer" + body="ssh-rsa $(awk '{print $2}' "$DEVKIT_KEY.pub") $comment $MAGIC_PHRASE" + print " Approve the pairing request in the headset (it waits about 30 seconds)" + if ! resp=$(print -r -- "$body" | curl -sS --noproxy '*' -m 60 -H 'Content-Type: text/plain' \ + --data-binary @- -w '\n%{http_code}' "$(devkit_url /register)" 2>&1); then + devkit_why="devkit pairing failed: no answer (${${resp##*curl: }%%$'\n'*})"; return 1 + fi + code=${resp##*$'\n'} + text=${resp%$'\n'*} + if [[ "$code" != 2* ]]; then + err=$(print -r -- "$text" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) + devkit_why="devkit pairing failed: ${err:-${text:-HTTP $code}}"; return 1 + fi + # The approval is what turns sshd on, so it may take a moment to answer. + local i + for i in {1..10}; do + key_login_works && return 0 + sleep 1 + done + devkit_why="paired, but key login still fails"; return 1 +} + print "==> Looking for the Steam Frame" if ! HOST=$(pick_host); then - print -u2 "Could not reach the Frame on port 22." + print -u2 "Could not reach the Frame on port 22 or $DEVKIT_PORT." print -u2 "Check: Developer Mode on + user password set; same Wi-Fi; no client isolation." print -u2 "Then re-run with the IP from Quick Settings: scripts/connect.sh 192.168.x.y" exit 1 fi print " found: $HOST" -print "==> SSH key" +print "==> SSH keys" mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh" -if [[ ! -f "$KEY" ]]; then - ssh-keygen -q -t ed25519 -N '' -C "mac->steam-frame" -f "$KEY" - print " created $KEY" -else - print " exists: $KEY" -fi +make_key "$KEY" ed25519 "mac->steam-frame" +make_key "$DEVKIT_KEY" rsa "frame-control@$(hostname -s | tr -cs 'A-Za-z0-9._-' '-' | sed 's/^[-.]*//; s/[-.]*$//')" -b 3072 +if (( ! user_from_env )); then + prev_user=$(configured_user) + if [[ "$prev_user" =~ $NAME_RE ]]; then FRAME_USER=$prev_user; fi +fi print "==> ~/.ssh/config alias '$FRAME_ALIAS' -> $HOST" -touch "$CONFIG" && chmod 600 "$CONFIG" -tmp=$(mktemp) -# 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" -# or "Host *". The trailing "Host *" returns the rest of the file to global scope. -awk -v b="$BEGIN_MARK" -v e="$END_MARK" ' - $0==b {skip=1; next} - $0==e {skip=0; next} - !skip {print} -' "$CONFIG" > "$tmp" -{ - print -r -- "$BEGIN_MARK" - print -r -- "Host $FRAME_ALIAS" - print -r -- " HostName $HOST" - print -r -- " User $FRAME_USER" - print -r -- " IdentityFile $KEY" - print -r -- " IdentitiesOnly yes" - print -r -- " ServerAliveInterval 30" - print -r -- "Host *" - print -r -- "$END_MARK" - cat "$tmp" -} > "$CONFIG" -rm -f "$tmp" +write_config print "==> Checking key login" -if ssh -o BatchMode=yes -o ConnectTimeout=5 "$FRAME_ALIAS" true 2>/dev/null; then +if key_login_works; then print " key login already works" +elif pair_with_devkit; then + print " paired; key login OK" else + print " $devkit_why; falling back to the password" print " copying key (enter the Developer Mode password once)" ssh-copy-id -i "$KEY.pub" -o IdentitiesOnly=yes "$FRAME_USER@$HOST" - ssh -o BatchMode=yes -o ConnectTimeout=5 "$FRAME_ALIAS" true \ - || { print -u2 "Key login still failing after ssh-copy-id."; exit 1; } + key_login_works || { print -u2 "Key login still failing after ssh-copy-id."; exit 1; } print " key login OK" fi diff --git a/tests/test_connect.py b/tests/test_connect.py new file mode 100644 index 0000000..d2b8187 --- /dev/null +++ b/tests/test_connect.py @@ -0,0 +1,192 @@ +"""Setup-script checks that need no headset: devkit pairing against a stub of Valve's +steamos-devkit-service, the ~/.ssh/config block, and the mDNS output parsers. + +Run: python3 -m unittest discover -s tests +""" +import json +import socket +import sys +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "ui")) + +import frame_connect as fc # noqa: E402 + +PUB = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC+/x= frame-control@old\n" + + +class StubDevkit(BaseHTTPRequestHandler): + """Answers like steamos-devkit-service; `reply` picks the /register outcome.""" + reply = (200, b"Registered\n") + properties = {"txtvers": 1, "login": "steamos", "settings": "{}", "devkit1": ["devkit-1"]} + bodies = [] + + def log_message(self, *args): + pass + + def do_GET(self): + if self.path == "/properties.json": + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(self.properties).encode()) + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + body = self.rfile.read(int(self.headers["Content-Length"])) + StubDevkit.bodies.append((self.path, self.headers["Content-Type"], body)) + code, text = self.reply + self.send_response(code) + self.send_header("Content-type", "text/plain") + self.end_headers() + self.wfile.write(text) + + +class DevkitPairing(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.server = ThreadingHTTPServer(("127.0.0.1", 0), StubDevkit) + cls.port = cls.server.server_address[1] + threading.Thread(target=cls.server.serve_forever, daemon=True).start() + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + + def setUp(self): + StubDevkit.reply = (200, b"Registered\n") + StubDevkit.bodies = [] + self.said = [] + self._say, fc.say = fc.say, self.said.append + + def tearDown(self): + fc.say = self._say + + def test_register_body(self): + body = fc.register_body(PUB, "frame-control@mac") + self.assertEqual(body, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC+/x= frame-control@mac " + "900b919520e4cf601998a71eec318fec\n") + # approve-ssh-key shows split(' ')[2] as the key name. + self.assertEqual(body.split(" ")[2], "frame-control@mac") + with self.assertRaises(ValueError): + fc.register_body("ssh-ed25519 AAAAC3Nz x", "c") + + def test_key_comment_is_one_word(self): + self.assertEqual(fc.key_comment("Alex's MacBook Pro.local"), "frame-control@Alex-s-MacBook-Pro") + self.assertEqual(fc.key_comment(""), "frame-control@computer") + self.assertNotIn(" ", fc.key_comment(" a b\nc ")) + + def test_parse_login(self): + self.assertEqual(fc.parse_login(b'{"login": "steamos", "txtvers": 1}'), "steamos") + for raw in (b'{"txtvers": 1}', b'{"login": "root"}', b'{"login": "x\\nHost *"}', b'{"login": 5}'): + self.assertIsNone(fc.parse_login(raw), raw) + for raw in (b"not json", b"[1]"): + with self.assertRaises(ValueError): + fc.parse_login(raw) + + def test_devkit_error(self): + self.assertEqual(fc.devkit_error(403, b'{"error": "Steam is not running"}\n'), "Steam is not running") + self.assertEqual(fc.devkit_error(500, b"install-ssh-key:\nboom"), "install-ssh-key:\nboom") + self.assertEqual(fc.devkit_error(403, b""), "HTTP 403") + + def test_pair_ok(self): + logins = [] + reason = fc.devkit_pair("127.0.0.1", PUB, "frame-control@test", self.port, logins.append) + self.assertIsNone(reason) + self.assertEqual(logins, ["steamos"]) + path, ctype, body = StubDevkit.bodies[0] + self.assertEqual((path, ctype), ("/register", "text/plain")) + self.assertEqual(body.decode(), fc.register_body(PUB, "frame-control@test")) + self.assertTrue(any("Approve the pairing request" in s for s in self.said)) + + def test_pair_refused_falls_back(self): + StubDevkit.reply = (403, b'{"error": "timeout - Steam did not respond to the pairing request"}') + logins = [] + reason = fc.devkit_pair("127.0.0.1", PUB, "c", self.port, logins.append) + self.assertIn("timeout - Steam did not respond", reason) + # The login is still reported, so the password fallback uses the right user. + self.assertEqual(logins, ["steamos"]) + + def test_pair_without_service_falls_back(self): + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + closed = s.getsockname()[1] + logins = [] + reason = fc.devkit_pair("127.0.0.1", PUB, "c", closed, logins.append) + self.assertIn("not reachable", reason) + self.assertEqual((logins, StubDevkit.bodies), ([], [])) + + def test_pair_times_out(self): + # Accepts the connection but never answers, like a prompt nobody taps. + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + s.listen() + ok, msg = fc.register("127.0.0.1", "x", s.getsockname()[1], timeout=0.5) + self.assertFalse(ok) + self.assertIn("no answer", msg) + + +class ConfigBlock(unittest.TestCase): + def test_both_keys(self): + block = fc.config_block("frame.local", 22, "steamos") + self.assertEqual(block[0], fc.BEGIN) + self.assertEqual(block[-1], fc.END) + self.assertIn(" User steamos", block) + self.assertNotIn(" Port 22", block) + files = [line for line in block if line.startswith(" IdentityFile")] + self.assertEqual(files, [" IdentityFile ~/.ssh/id_ed25519_frame", " IdentityFile ~/.ssh/id_rsa_frame_devkit"]) + self.assertIn(" IdentitiesOnly yes", block) + self.assertEqual(block[-2], "Host *") + self.assertIn(" Port 2222", fc.config_block("10.0.0.5", 2222)) + + def test_write_config_replaces_block(self): + import tempfile + with tempfile.TemporaryDirectory() as d: + saved = fc.SSH_DIR, fc.CONFIG + fc.SSH_DIR, fc.CONFIG = Path(d), Path(d) / "config" + try: + fc.CONFIG.write_text("Host other\n User me\n", encoding="utf-8") + fc.write_config("frame.local") + self.assertEqual(fc.configured_user(), "steamos") + fc.write_config("10.0.0.5", 22, "deck") + text = fc.CONFIG.read_text(encoding="utf-8") + self.assertEqual(fc.configured_user(), "deck") # not "me" from Host other + finally: + fc.SSH_DIR, fc.CONFIG = saved + self.assertEqual(text.count(fc.BEGIN), 1) + self.assertIn("HostName 10.0.0.5", text) + self.assertIn("User deck", text) + self.assertNotIn("frame.local", text) + self.assertTrue(text.endswith("Host other\n User me\n")) + + +class MdnsParsers(unittest.TestCase): + def test_dns_sd(self): + browse = ("Browsing for _steamos-devkit._tcp\n" + "Timestamp A/R Flags if Domain Service Type Instance Name\n" + "19:34:35.419 Add 3 15 local. _steamos-devkit._tcp. frame\n" + "19:34:35.611 Add 2 1 local. _steamos-devkit._tcp. frame\n" + "19:34:35.700 Add 2 15 local. _steamos-devkit._tcp. My Frame\n" + "19:34:36.000 Rmv 0 15 local. _steamos-devkit._tcp. gone\n") + self.assertEqual(fc.parse_dns_sd_browse(browse), ["frame", "My Frame"]) + resolve = ("Lookup frame._steamos-devkit._tcp.local.\n" + "19:34:44.601 frame._steamos-devkit._tcp.local. can be reached at frame.local.:32000 (interface 15)\n") + self.assertEqual(fc.parse_dns_sd_resolve(resolve), "frame.local") + self.assertIsNone(fc.parse_dns_sd_resolve("Lookup frame\n")) + + def test_avahi(self): + out = ('+;wlan0;IPv4;frame;_steamos-devkit._tcp;local\n' + '=;wlan0;IPv6;frame;_steamos-devkit._tcp;local;frame.local;fe80::1;32000;"login=steamos"\n' + '=;wlan0;IPv4;frame;_steamos-devkit._tcp;local;frame.local;192.168.1.50;32000;"login=steamos"\n') + self.assertEqual(fc.parse_avahi(out), ["frame.local", "192.168.1.50"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/frame_connect.py b/ui/frame_connect.py index bdf93ec..bc3be68 100644 --- a/ui/frame_connect.py +++ b/ui/frame_connect.py @@ -1,29 +1,41 @@ -"""Connect this computer to the Steam Frame: find it, create a key, add a `Host frame` -alias to ~/.ssh/config and copy the key over, 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. +"""Connect this computer to the Steam Frame: find it, create keys, add a `Host frame` +alias to ~/.ssh/config and get a key onto the headset. It first asks Valve's +SteamOS devkit service (port 32000) to pair, which needs only a tap on the +headset; if that service isn't there or says no, it copies the key over SSH, +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) """ import base64 +import json import os import platform import re +import shutil import socket import subprocess import sys import time +import urllib.error +import urllib.request from pathlib import Path FRAME_USER = os.environ.get("FRAME_USER", "steamos") +USER_FROM_ENV = "FRAME_USER" in os.environ FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame") SSH_DIR = Path.home() / ".ssh" KEY = SSH_DIR / "id_ed25519_frame" +# The devkit service only accepts ssh-rsa keys (write_key in Valve's +# steamos-devkit-service), so pairing uses a second key next to the ed25519 one. +DEVKIT_KEY = SSH_DIR / "id_rsa_frame_devkit" CONFIG = SSH_DIR / "config" +NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") # Both go into ~/.ssh/config, so nothing that could add a line or a directive. for _name, _value in (("FRAME_ALIAS", FRAME_ALIAS), ("FRAME_USER", FRAME_USER)): - if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", _value): + if not NAME_RE.fullmatch(_value): sys.exit(f"{_name} must be a plain name, not {_value!r}") BEGIN = f"# >>> steam-frame ({FRAME_ALIAS}) >>>" END = f"# <<< steam-frame ({FRAME_ALIAS}) <<<" @@ -42,6 +54,98 @@ def say(msg): print(msg, flush=True) +# --- Valve's SteamOS devkit pairing (steamos-devkit-service on the headset). HTTP on +# port 32000: GET /properties.json names the user to log in as; POST /register with +# "ssh-rsa " shows an approve prompt in the headset (the +# comment is what it displays, 30 s to answer), then installs the key and turns sshd on. + +DEVKIT_PORT = 32000 +DEVKIT_SERVICE = "_steamos-devkit._tcp" +MAGIC_PHRASE = "900b919520e4cf601998a71eec318fec" # fixed token Valve's client appends +REGISTER_TIMEOUT = 60 +# A LAN host: never go through an HTTP(S)_PROXY from the environment. +_opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def key_comment(node): + """"frame-control@" as one word: the headset splits the body on spaces.""" + name = re.sub(r"[^A-Za-z0-9._-]+", "-", (node or "").split(".")[0]).strip("-.") or "computer" + return f"frame-control@{name}" + + +def register_body(pub, comment): + fields = pub.split() + if len(fields) < 2 or fields[0] != "ssh-rsa": + raise ValueError("the devkit service only takes ssh-rsa keys") + return f"ssh-rsa {fields[1]} {comment} {MAGIC_PHRASE}\n" + + +def parse_login(raw): + """The `login` from /properties.json if it's a plain user name, else None. "root" + means several users are configured and Valve's client switches between them; we + can't, so it counts as no answer.""" + props = json.loads(raw) + if not isinstance(props, dict): + raise ValueError("properties.json isn't a JSON object") + login = props.get("login") + if isinstance(login, str) and NAME_RE.fullmatch(login) and login != "root": + return login + return None + + +def devkit_error(status, raw): + """A readable reason from a failed /register: its {"error": ...} JSON, or the text.""" + text = raw.decode("utf-8", "replace").strip() + try: + err = json.loads(text).get("error") + except (ValueError, AttributeError): + err = None + return str(err or text or f"HTTP {status}")[:300] + + +def devkit_url(host, port, path): + return f"http://[{host}]:{port}{path}" if ":" in host else f"http://{host}:{port}{path}" + + +def why(e): + return str(getattr(e, "reason", None) or e) + + +def fetch_login(host, port=DEVKIT_PORT, timeout=5): + """GET /properties.json. Raises OSError (HTTP errors included) or ValueError.""" + with _opener.open(devkit_url(host, port, "/properties.json"), timeout=timeout) as r: + return parse_login(r.read()) + + +def register(host, body, port=DEVKIT_PORT, timeout=REGISTER_TIMEOUT): + """POST /register, which waits while someone answers the prompt. -> (ok, message)""" + req = urllib.request.Request(devkit_url(host, port, "/register"), data=body.encode("ascii"), + headers={"Content-Type": "text/plain"}, method="POST") + try: + with _opener.open(req, timeout=timeout) as r: + return True, r.read().decode("utf-8", "replace").strip() + except urllib.error.HTTPError as e: + with e: + return False, devkit_error(e.code, e.read()) + except OSError as e: + return False, f"no answer ({why(e)})" + + +def devkit_pair(host, pub, comment, port=DEVKIT_PORT, on_login=None): + """The password-free route. -> None once paired, else the reason, which means: fall + back to copying the key with the password. on_login(user) runs before the prompt + with the login properties.json names, so the fallback uses that user too.""" + try: + login = fetch_login(host, port) + except (OSError, ValueError) as e: + return f"devkit service not reachable on port {port}: {why(e)}" + if login and on_login: + on_login(login) + say(" Approve the pairing request in the headset (it waits about 30 seconds)") + ok, msg = register(host, register_body(pub, comment), port) + return None if ok else f"devkit pairing failed: {msg}" + + def split_port(arg): """"host:2222" -> ("host", 2222); anything else (IPv6 too) keeps port 22.""" host, sep, port = arg.rpartition(":") @@ -58,6 +162,70 @@ def port_open(host, port=22): return False +def reachable(host, port): + """sshd, or the devkit service, which turns sshd on once a pairing is approved.""" + try: + socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + except OSError: + return False + return port_open(host, port) or port_open(host, DEVKIT_PORT) + + +# --- mDNS. There's no stdlib client, so this borrows dns-sd (macOS; Bonjour for +# Windows) or avahi-browse (Linux) when present, with short timeouts. + +def run_for(args, seconds): + """What a command printed within `seconds`; dns-sd never exits by itself.""" + try: + out = subprocess.run(args, capture_output=True, timeout=seconds).stdout + except subprocess.TimeoutExpired as e: + out = e.stdout + except OSError: + out = b"" + return (out or b"").decode("utf-8", "replace") + + +def parse_dns_sd_browse(text): + """Instance names from `dns-sd -B _steamos-devkit._tcp`, deduplicated, in order.""" + pat = re.compile(r"\sAdd\s+\d+\s+\d+\s+\S+\s+" + re.escape(DEVKIT_SERVICE) + r"\.\s+(.+?)\s*$") + names = [] + for line in text.splitlines(): + m = pat.search(line) + if m and m.group(1) not in names: + names.append(m.group(1)) + return names + + +def parse_dns_sd_resolve(text): + """The target host from `dns-sd -L` ("... can be reached at frame.local.:32000").""" + m = re.search(r"can be reached at (\S+?)\.?:\d+", text) + return m.group(1) if m else None + + +def parse_avahi(text): + """Host names, then IPv4 addresses, from `avahi-browse -rpt` resolved ("=") lines.""" + names, addrs = [], [] + for line in text.splitlines(): + f = line.split(";") + if len(f) >= 9 and f[0] == "=" and f[2] == "IPv4": + names.append(f[6]) + addrs.append(f[7]) + return list(dict.fromkeys(names + addrs)) + + +def discover_devkit(): + if shutil.which("dns-sd"): + hosts = [] + for name in parse_dns_sd_browse(run_for(["dns-sd", "-B", DEVKIT_SERVICE, "local."], 3))[:4]: + host = parse_dns_sd_resolve(run_for(["dns-sd", "-L", name, DEVKIT_SERVICE, "local."], 2)) + if host and host not in hosts: + hosts.append(host) + return hosts + if shutil.which("avahi-browse"): + return parse_avahi(run_for(["avahi-browse", "-rpt", DEVKIT_SERVICE], 5)) + return [] + + HOST_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9.:%-]*") @@ -67,9 +235,16 @@ def pick_host(arg): return None for cand in [arg] if arg else [f"{FRAME_ALIAS}.local", FRAME_ALIAS]: host, port = split_port(cand) - if port_open(host, port): + if reachable(host, port): return host, port - say(f" - {cand}: not resolvable or port {port} closed") + say(f" - {cand}: not resolvable, or ports {port} and {DEVKIT_PORT} closed") + if arg: + return None + say(f" - asking mDNS for {DEVKIT_SERVICE}") + for host in discover_devkit(): + if HOST_RE.fullmatch(host) and reachable(host, 22): + return host, 22 + say(f" - {host}: advertised, but not reachable") return None @@ -82,7 +257,23 @@ def make_ssh_dir(): SSH_DIR.mkdir(mode=0o700, exist_ok=True) -def write_config(host, port=22): +def make_key(path, kind, comment): + if path.exists(): + say(f" exists: {path}") + return + bits = ["-b", "3072"] if kind == "rsa" else [] + subprocess.run(["ssh-keygen", "-q", "-t", kind, *bits, "-N", "", "-C", comment, "-f", str(path)], check=True) + say(f" created {path}") + + +def config_block(host, port=22, user=FRAME_USER): + return [BEGIN, f"Host {FRAME_ALIAS}", f" HostName {host}", *([f" Port {port}"] if port != 22 else []), + f" User {user}", + " IdentityFile ~/.ssh/id_ed25519_frame", " IdentityFile ~/.ssh/id_rsa_frame_devkit", + " IdentitiesOnly yes", " ServerAliveInterval 30", "Host *", END] + + +def write_config(host, port=22, user=FRAME_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() @@ -95,10 +286,7 @@ def write_config(host, port=22): skip = False elif not skip: kept.append(line) - block = [BEGIN, f"Host {FRAME_ALIAS}", f" HostName {host}", *([f" Port {port}"] if port != 22 else []), - f" User {FRAME_USER}", - " IdentityFile ~/.ssh/id_ed25519_frame", " IdentitiesOnly yes", - " ServerAliveInterval 30", "Host *", END] + block = config_block(host, port, user) tmp = CONFIG.with_name("config.frame-control.tmp") tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8") if os.name != "nt": @@ -125,6 +313,50 @@ def key_login_works(): capture_output=True).returncode == 0 +def configured_user(): + """The User in our managed block, so a re-run keeps one the headset named earlier.""" + if not CONFIG.exists(): + return None + inside = False + for line in CONFIG.read_text(encoding="utf-8").splitlines(): + if line in (BEGIN, END): + inside = line == BEGIN + elif inside and line.startswith(" User "): + name = line[7:].strip() + return name if NAME_RE.fullmatch(name) else None + return None + + +def pair_with_devkit(host, port, user): + """Try devkit pairing and confirm key login. -> (user, None) or (user, reason to fall back).""" + say("==> Pairing through the headset's SteamOS devkit service (no password)") + chosen = [user] + + def use_login(login): + if login == chosen[0]: + return + if USER_FROM_ENV: + say(f" the headset logs in as '{login}'; keeping FRAME_USER={user}") + else: + chosen[0] = login + say(f" the headset logs in as '{login}'") + write_config(host, port, login) + + try: + pub = DEVKIT_KEY.with_suffix(".pub").read_text(encoding="utf-8") + except OSError as e: + return user, f"can't read the pairing key: {e}" + reason = devkit_pair(host, pub, key_comment(platform.node()), on_login=use_login) + if reason: + return chosen[0], reason + # The approval is what turns sshd on, so it may take a moment to answer. + for _ in range(10): + if key_login_works(): + return chosen[0], None + time.sleep(1) + return chosen[0], "paired, but key login still fails" + + def main(argv): if argv and argv[0] in ("-h", "--help"): sys.exit(__doc__) @@ -143,30 +375,32 @@ def main(argv): host, port = found say(f" found: {host}" + (f" port {port}" if port != 22 else "")) - say("==> SSH key") + say("==> SSH keys") make_ssh_dir() - if KEY.exists(): - say(f" exists: {KEY}") - else: - subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", - f"{platform.node() or 'computer'}->steam-frame", "-f", str(KEY)], check=True) - say(f" created {KEY}") + make_key(KEY, "ed25519", f"{platform.node() or 'computer'}->steam-frame") + make_key(DEVKIT_KEY, "rsa", key_comment(platform.node())) + user = FRAME_USER if USER_FROM_ENV else (configured_user() or FRAME_USER) say(f"==> ~/.ssh/config alias '{FRAME_ALIAS}' -> {host}") - write_config(host, port) + write_config(host, port, user) say("==> Checking key login") if key_login_works(): say(" key login already works") else: - say(" copying the key: enter the Developer Mode password when asked") - pub = KEY.with_suffix(".pub").read_text(encoding="utf-8").strip() - r = subprocess.run(["ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", "PubkeyAuthentication=no", - "-p", str(port), f"{FRAME_USER}@{host}", ADD_KEY_CMD], input=pub + "\n", text=True) - if r.returncode != 0 or not key_login_works(): - say("Key login still isn't working. Check the password and run this again.") - return 1 - say(" key login OK") + user, reason = pair_with_devkit(host, port, user) + if not reason: + say(" paired; key login OK") + else: + say(f" {reason}; falling back to the password") + say(" copying the key: enter the Developer Mode password when asked") + pub = KEY.with_suffix(".pub").read_text(encoding="utf-8").strip() + r = subprocess.run(["ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", "PubkeyAuthentication=no", + "-p", str(port), f"{user}@{host}", ADD_KEY_CMD], input=pub + "\n", text=True) + if r.returncode != 0 or not key_login_works(): + say("Key login still isn't working. Check the password and run this again.") + return 1 + say(" key login OK") say(f"\nDone. Frame Control can reach the Frame now. In a terminal: ssh {FRAME_ALIAS}") return 0