mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 07:00:37 +02:00
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:
1 parent
1a0e54d8bd
commit
a90ffeda5f
6 files changed
+676
-74
No files matched your search
+262
-28
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user