mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 02:00:19 +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
@@ -147,13 +147,19 @@ computer.
|
|||||||
Connection**, which finds the headset, creates an SSH key, and asks for that
|
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
|
password once in a terminal window. If it can't find the Frame, type the
|
||||||
IP address from the Frame's Quick Settings.
|
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
|
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).
|
same network. For anywhere else, see [Tailscale](docs/tailscale.md).
|
||||||
|
|
||||||
**What it changes:** only what you click. Installs go to your user account on
|
**What it changes:** only what you click. Installs go to your user account on
|
||||||
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads), and nothing
|
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads), and nothing
|
||||||
needs `sudo` except the power buttons. On your computer it adds a `Host frame`
|
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
|
## Feedback
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -36,9 +36,11 @@ ssh frame # passwordless from now on
|
|||||||
`connect.sh` does four things:
|
`connect.sh` does four things:
|
||||||
|
|
||||||
- finds the headset (`frame.local`, then `frame`, or the IP/host you pass in)
|
- 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`
|
- 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
|
Run `./scripts/connect.sh --harden` later if you want to turn off SSH password
|
||||||
logins.
|
logins.
|
||||||
|
|||||||
+34
-1
@@ -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
|
- **Verified on device (2026-09-25):** `avahi-daemon` is running on the Frame
|
||||||
and `frame.local` resolves from the Mac over mDNS.
|
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
|
Once you have a working address, the `Host frame` alias means you just type
|
||||||
`ssh frame`.
|
`ssh frame`.
|
||||||
- To check discovery yourself: `dns-sd -G v4 frame.local` (Ctrl-C to stop), or
|
- To check discovery yourself: `dns-sd -G v4 frame.local` (Ctrl-C to stop), or
|
||||||
@@ -55,10 +56,42 @@ Host frame
|
|||||||
HostName frame.local
|
HostName frame.local
|
||||||
User steamos
|
User steamos
|
||||||
IdentityFile ~/.ssh/id_ed25519_frame
|
IdentityFile ~/.ssh/id_ed25519_frame
|
||||||
|
IdentityFile ~/.ssh/id_rsa_frame_devkit
|
||||||
IdentitiesOnly yes
|
IdentitiesOnly yes
|
||||||
ServerAliveInterval 30
|
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
|
`~/.ssh/authorized_keys` lives under `/home`, which SteamOS keeps across OS
|
||||||
updates (inferred from Deck; the Frame uses the same A/B image scheme).
|
updates (inferred from Deck; the Frame uses the same A/B image scheme).
|
||||||
|
|
||||||
|
|||||||
+177
-42
@@ -1,8 +1,10 @@
|
|||||||
#!/usr/bin/env zsh
|
#!/usr/bin/env zsh
|
||||||
# Mac-side: find the Steam Frame, create a key, add a `Host frame` alias to
|
# Mac-side: find the Steam Frame, create keys, add a `Host frame` alias to
|
||||||
# ~/.ssh/config, copy the key, and optionally disable SSH password logins.
|
# ~/.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:
|
# Usage:
|
||||||
# scripts/connect.sh [HOST_OR_IP] # set up key + alias
|
# scripts/connect.sh [HOST_OR_IP] # set up key + alias
|
||||||
@@ -11,93 +13,226 @@
|
|||||||
# Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame).
|
# Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
user_from_env=${+FRAME_USER}
|
||||||
FRAME_USER=${FRAME_USER:-steamos}
|
FRAME_USER=${FRAME_USER:-steamos}
|
||||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||||
KEY="$HOME/.ssh/id_ed25519_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"
|
CONFIG="$HOME/.ssh/config"
|
||||||
BEGIN_MARK="# >>> steam-frame ($FRAME_ALIAS) >>>"
|
BEGIN_MARK="# >>> steam-frame ($FRAME_ALIAS) >>>"
|
||||||
END_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
|
harden=0
|
||||||
host_arg=""
|
host_arg=""
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--harden) harden=1 ;;
|
--harden) harden=1 ;;
|
||||||
-h|--help) sed -n '2,11p' "$0"; exit 0 ;;
|
-h|--help) sed -n '2,13p' "$0"; exit 0 ;;
|
||||||
*) host_arg="$arg" ;;
|
*) host_arg="$arg" ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
port_open() {
|
port_open() {
|
||||||
# nc resolves through the system resolver (including mDNS for .local).
|
# 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() {
|
pick_host() {
|
||||||
local candidates=()
|
local candidates=()
|
||||||
[[ -n "$host_arg" ]] && candidates+=("$host_arg")
|
[[ -n "$host_arg" ]] && candidates+=("$host_arg")
|
||||||
candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS")
|
[[ -z "$host_arg" ]] && candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS")
|
||||||
local h
|
local h
|
||||||
for h in "${candidates[@]}"; do
|
for h in "${candidates[@]}"; do
|
||||||
if port_open "$h"; then
|
if reachable "$h"; then
|
||||||
print -r -- "$h"; return 0
|
print -r -- "$h"; return 0
|
||||||
fi
|
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
|
done
|
||||||
return 1
|
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"
|
print "==> Looking for the Steam Frame"
|
||||||
if ! HOST=$(pick_host); then
|
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 "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"
|
print -u2 "Then re-run with the IP from Quick Settings: scripts/connect.sh 192.168.x.y"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
print " found: $HOST"
|
print " found: $HOST"
|
||||||
|
|
||||||
print "==> SSH key"
|
print "==> SSH keys"
|
||||||
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
|
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
|
||||||
if [[ ! -f "$KEY" ]]; then
|
make_key "$KEY" ed25519 "mac->steam-frame"
|
||||||
ssh-keygen -q -t ed25519 -N '' -C "mac->steam-frame" -f "$KEY"
|
make_key "$DEVKIT_KEY" rsa "frame-control@$(hostname -s | tr -cs 'A-Za-z0-9._-' '-' | sed 's/^[-.]*//; s/[-.]*$//')" -b 3072
|
||||||
print " created $KEY"
|
|
||||||
else
|
|
||||||
print " exists: $KEY"
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
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"
|
print "==> ~/.ssh/config alias '$FRAME_ALIAS' -> $HOST"
|
||||||
touch "$CONFIG" && chmod 600 "$CONFIG"
|
write_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"
|
|
||||||
|
|
||||||
print "==> Checking key login"
|
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"
|
print " key login already works"
|
||||||
|
elif pair_with_devkit; then
|
||||||
|
print " paired; key login OK"
|
||||||
else
|
else
|
||||||
|
print " $devkit_why; falling back to the password"
|
||||||
print " copying key (enter the Developer Mode password once)"
|
print " copying key (enter the Developer Mode password once)"
|
||||||
ssh-copy-id -i "$KEY.pub" -o IdentitiesOnly=yes "$FRAME_USER@$HOST"
|
ssh-copy-id -i "$KEY.pub" -o IdentitiesOnly=yes "$FRAME_USER@$HOST"
|
||||||
ssh -o BatchMode=yes -o ConnectTimeout=5 "$FRAME_ALIAS" true \
|
key_login_works || { print -u2 "Key login still failing after ssh-copy-id."; exit 1; }
|
||||||
|| { print -u2 "Key login still failing after ssh-copy-id."; exit 1; }
|
|
||||||
print " key login OK"
|
print " key login OK"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
+262
-28
@@ -1,29 +1,41 @@
|
|||||||
"""Connect this computer to the Steam Frame: find it, create a key, add a `Host frame`
|
"""Connect this computer to the Steam Frame: find it, create keys, add a `Host frame`
|
||||||
alias to ~/.ssh/config and copy the key over, asking for the Developer Mode
|
alias to ~/.ssh/config and get a key onto the headset. It first asks Valve's
|
||||||
password once. The Linux and Windows twin of scripts/connect.sh (which the Mac
|
SteamOS devkit service (port 32000) to pair, which needs only a tap on the
|
||||||
app uses); same config block, so either can re-run over the other. Idempotent.
|
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]]
|
Usage: python3 ui/frame_connect.py [HOST_OR_IP[:PORT]]
|
||||||
Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame)
|
Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame)
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
FRAME_USER = os.environ.get("FRAME_USER", "steamos")
|
FRAME_USER = os.environ.get("FRAME_USER", "steamos")
|
||||||
|
USER_FROM_ENV = "FRAME_USER" in os.environ
|
||||||
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
|
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
|
||||||
SSH_DIR = Path.home() / ".ssh"
|
SSH_DIR = Path.home() / ".ssh"
|
||||||
KEY = SSH_DIR / "id_ed25519_frame"
|
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"
|
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.
|
# 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)):
|
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}")
|
sys.exit(f"{_name} must be a plain name, not {_value!r}")
|
||||||
BEGIN = f"# >>> steam-frame ({FRAME_ALIAS}) >>>"
|
BEGIN = f"# >>> steam-frame ({FRAME_ALIAS}) >>>"
|
||||||
END = f"# <<< steam-frame ({FRAME_ALIAS}) <<<"
|
END = f"# <<< steam-frame ({FRAME_ALIAS}) <<<"
|
||||||
@@ -42,6 +54,98 @@ def say(msg):
|
|||||||
print(msg, flush=True)
|
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):
|
def split_port(arg):
|
||||||
""""host:2222" -> ("host", 2222); anything else (IPv6 too) keeps port 22."""
|
""""host:2222" -> ("host", 2222); anything else (IPv6 too) keeps port 22."""
|
||||||
host, sep, port = arg.rpartition(":")
|
host, sep, port = arg.rpartition(":")
|
||||||
@@ -58,6 +162,70 @@ def port_open(host, port=22):
|
|||||||
return False
|
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.:%-]*")
|
HOST_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9.:%-]*")
|
||||||
|
|
||||||
|
|
||||||
@@ -67,9 +235,16 @@ def pick_host(arg):
|
|||||||
return None
|
return None
|
||||||
for cand in [arg] if arg else [f"{FRAME_ALIAS}.local", FRAME_ALIAS]:
|
for cand in [arg] if arg else [f"{FRAME_ALIAS}.local", FRAME_ALIAS]:
|
||||||
host, port = split_port(cand)
|
host, port = split_port(cand)
|
||||||
if port_open(host, port):
|
if reachable(host, port):
|
||||||
return 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
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -82,7 +257,23 @@ def make_ssh_dir():
|
|||||||
SSH_DIR.mkdir(mode=0o700, exist_ok=True)
|
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
|
"""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."""
|
option. The trailing "Host *" returns the rest of the file to global scope."""
|
||||||
make_ssh_dir()
|
make_ssh_dir()
|
||||||
@@ -95,10 +286,7 @@ def write_config(host, port=22):
|
|||||||
skip = False
|
skip = False
|
||||||
elif not skip:
|
elif not skip:
|
||||||
kept.append(line)
|
kept.append(line)
|
||||||
block = [BEGIN, f"Host {FRAME_ALIAS}", f" HostName {host}", *([f" Port {port}"] if port != 22 else []),
|
block = config_block(host, port, user)
|
||||||
f" User {FRAME_USER}",
|
|
||||||
" IdentityFile ~/.ssh/id_ed25519_frame", " IdentitiesOnly yes",
|
|
||||||
" ServerAliveInterval 30", "Host *", END]
|
|
||||||
tmp = CONFIG.with_name("config.frame-control.tmp")
|
tmp = CONFIG.with_name("config.frame-control.tmp")
|
||||||
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
|
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
|
||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
@@ -125,6 +313,50 @@ def key_login_works():
|
|||||||
capture_output=True).returncode == 0
|
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):
|
def main(argv):
|
||||||
if argv and argv[0] in ("-h", "--help"):
|
if argv and argv[0] in ("-h", "--help"):
|
||||||
sys.exit(__doc__)
|
sys.exit(__doc__)
|
||||||
@@ -143,30 +375,32 @@ def main(argv):
|
|||||||
host, port = found
|
host, port = found
|
||||||
say(f" found: {host}" + (f" port {port}" if port != 22 else ""))
|
say(f" found: {host}" + (f" port {port}" if port != 22 else ""))
|
||||||
|
|
||||||
say("==> SSH key")
|
say("==> SSH keys")
|
||||||
make_ssh_dir()
|
make_ssh_dir()
|
||||||
if KEY.exists():
|
make_key(KEY, "ed25519", f"{platform.node() or 'computer'}->steam-frame")
|
||||||
say(f" exists: {KEY}")
|
make_key(DEVKIT_KEY, "rsa", key_comment(platform.node()))
|
||||||
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}")
|
|
||||||
|
|
||||||
|
user = FRAME_USER if USER_FROM_ENV else (configured_user() or FRAME_USER)
|
||||||
say(f"==> ~/.ssh/config alias '{FRAME_ALIAS}' -> {host}")
|
say(f"==> ~/.ssh/config alias '{FRAME_ALIAS}' -> {host}")
|
||||||
write_config(host, port)
|
write_config(host, port, user)
|
||||||
|
|
||||||
say("==> Checking key login")
|
say("==> Checking key login")
|
||||||
if key_login_works():
|
if key_login_works():
|
||||||
say(" key login already works")
|
say(" key login already works")
|
||||||
else:
|
else:
|
||||||
say(" copying the key: enter the Developer Mode password when asked")
|
user, reason = pair_with_devkit(host, port, user)
|
||||||
pub = KEY.with_suffix(".pub").read_text(encoding="utf-8").strip()
|
if not reason:
|
||||||
r = subprocess.run(["ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", "PubkeyAuthentication=no",
|
say(" paired; key login OK")
|
||||||
"-p", str(port), f"{FRAME_USER}@{host}", ADD_KEY_CMD], input=pub + "\n", text=True)
|
else:
|
||||||
if r.returncode != 0 or not key_login_works():
|
say(f" {reason}; falling back to the password")
|
||||||
say("Key login still isn't working. Check the password and run this again.")
|
say(" copying the key: enter the Developer Mode password when asked")
|
||||||
return 1
|
pub = KEY.with_suffix(".pub").read_text(encoding="utf-8").strip()
|
||||||
say(" key login OK")
|
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}")
|
say(f"\nDone. Frame Control can reach the Frame now. In a terminal: ssh {FRAME_ALIAS}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user