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) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-26 20:03:48 +10:00
1 parent 1a0e54d8bd
commit a90ffeda5f
6 files changed
+676 -74

No files matched your search

+262 -28
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,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