Merge branch 'worktree-agent-a64afd16c052d0281' into frame-sideload-features

This commit is contained in:
saphid committed 2026-09-26 20:38:41 +10:00
commit 031ab6aa12
6 files changed
+669 -67

No files matched your search

+7 -1
View File
@@ -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
+4 -2
View File
@@ -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.
+34 -1
View File
@@ -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 <key> <comment> 900b919520e4cf601998a71eec318fec`
(a fixed token from Valve's client) shows an approve prompt inside the
headset naming the comment (`frame-control@<your computer>`). 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).
+177 -42
View File
@@ -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 <key> <comment> <magic>" 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
+192
View File
@@ -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()
+255 -21
View File
@@ -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 <key> <comment> <magic>" 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@<short host name>" 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,26 +375,28 @@ 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:
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"{FRAME_USER}@{host}", ADD_KEY_CMD], input=pub + "\n", text=True)
"-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