feat(session): headless --pair, so a box with only SSH can enrol

`punktfunk-session --pair <PIN> --connect host[:port]` runs the SPAKE2
ceremony with no window and no toolkit, prints the same
`paired <addr>:<port> fp=<hex>` line as `punktfunk-client --pair`, and
exits. Until now the PIN ceremony lived only in the GTK shell or the Skia
console, so enrolling an embedded/kiosk client meant installing a desktop
on it — or copying the identity store by hand.

Dispatches above every graphics call (the machine may have no display) and
is in the `--no-default-features` build: enrolling must never be the reason
a minimal image pulls in Skia. `--name` defaults to the hostname instead of
the desktop path's hardcoded "Steam Deck".

`forget_placeholder` and `device_name` move into pf_client_core::trust so
the two binaries share one implementation rather than the session
re-deriving them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 14:32:45 +02:00
co-authored by Claude Opus 5
parent b7cea71bbd
commit 915fc3ef9e
5 changed files with 107 additions and 35 deletions
+1 -13
View File
@@ -3,7 +3,7 @@
//! scenes. //! scenes.
use crate::app::AppModel; use crate::app::AppModel;
use crate::trust::{KnownHost, KnownHosts}; use crate::trust::{forget_placeholder, KnownHost, KnownHosts};
use crate::ui_hosts::{ConnectRequest, HostsMsg}; use crate::ui_hosts::{ConnectRequest, HostsMsg};
use gtk::glib; use gtk::glib;
use gtk::prelude::*; use gtk::prelude::*;
@@ -252,18 +252,6 @@ fn probe_all(hosts: &[KnownHost]) -> Vec<bool> {
) )
} }
/// Drop an fp-less placeholder for `addr:port` (see `headless_pair`). No-op when none exists.
fn forget_placeholder(addr: &str, port: u16) {
let mut known = KnownHosts::load();
let before = known.hosts.len();
known
.hosts
.retain(|h| !(h.fp_hex.is_empty() && h.addr == addr && h.port == port));
if known.hosts.len() != before {
let _ = known.save();
}
}
/// `--list-hosts [--probe]` — the saved known-hosts store as JSON (the store the Decky plugin /// `--list-hosts [--probe]` — the saved known-hosts store as JSON (the store the Decky plugin
/// renders). With `--probe`, each host carries an `online` bool from a live reachability probe /// renders). With `--probe`, each host carries an `online` bool from a live reachability probe
/// (mDNS-independent); without it, `online` is `null` (unknown — the caller falls back to its /// (mDNS-independent); without it, `online` is `null` (unknown — the caller falls back to its
+9 -1
View File
@@ -8,6 +8,7 @@ presenter of the Linux client re-architecture (punktfunk-planning:
``` ```
punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen] [--stats] punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen] [--stats]
punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen] punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen]
punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]
``` ```
`--browse` opens the console game library (the Skia coverflow over the animated aurora) `--browse` opens the console game library (the Skia coverflow over the animated aurora)
@@ -17,9 +18,16 @@ pairing is the desktop client / Decky plugin's job. `PUNKTFUNK_FAKE_LIBRARY=<fil
feeds canned entries with no host (portrait paths starting with `/` load from disk). feeds canned entries with no host (portrait paths starting with `/` load from disk).
Reads the same identity / known-hosts / settings stores as the desktop client Reads the same identity / known-hosts / settings stores as the desktop client
(`punktfunk-client`) — pair there (or via its headless `--pair`) first; this binary never (`punktfunk-client`), so enrolling on either side makes the other work; this binary never
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store). connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
`--pair <PIN> --connect host[:port]` runs the SPAKE2 ceremony with no window and no
toolkit, prints `paired <addr>:<port> fp=<hex>`, and exits — the route for a machine that
has only SSH (an embedded/kiosk client, an image being provisioned). `--name` sets the
label the host files this client under, defaulting to the hostname. It is in the
`--no-default-features` build too: enrolling must never be the reason a minimal image has
to pull in Skia.
Stdout is the machine interface: `{"ready":true}` after the first presented frame, Stdout is the machine interface: `{"ready":true}` after the first presented frame,
`stats: …` once per second while the overlay tier isn't Off (always the full detailed `stats: …` once per second while the overlay tier isn't Off (always the full detailed
text, whatever the OSD shows; `--stats` forces the overlay on), one text, whatever the OSD shows; `--stats` forces the overlay on), one
+1 -17
View File
@@ -105,7 +105,7 @@ pub fn run(target: Option<&str>) -> u8 {
}; };
let opts = ConsoleOptions { let opts = ConsoleOptions {
device_name: device_name(), device_name: trust::device_name(),
deck: is_steam_deck(), deck: is_steam_deck(),
}; };
let (overlay, handles) = match SkiaOverlay::console(opts, entry) { let (overlay, handles) = match SkiaOverlay::console(opts, entry) {
@@ -251,22 +251,6 @@ pub fn run(target: Option<&str>) -> u8 {
} }
} }
/// The machine's name — what the host lists this client as after pairing.
fn device_name() -> String {
#[cfg(target_os = "linux")]
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
let s = s.trim();
if !s.is_empty() {
return s.to_string();
}
}
std::env::var("COMPUTERNAME")
.or_else(|_| std::env::var("HOSTNAME"))
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "This device".into())
}
fn host_display_name(name: &str, addr: &str) -> String { fn host_display_name(name: &str, addr: &str) -> String {
if name.trim().is_empty() { if name.trim().is_empty() {
addr.to_string() addr.to_string()
+62 -4
View File
@@ -5,8 +5,9 @@
//! One stream session per invocation: `--connect host[:port]` (+ `--fp HEX`, //! One stream session per invocation: `--connect host[:port]` (+ `--fp HEX`,
//! `--launch id`, `--fullscreen`), exits when the session ends. Reads the same identity //! `--launch id`, `--fullscreen`), exits when the session ends. Reads the same identity
//! / known-hosts / settings stores as the desktop shell on each OS — the GTK client //! / known-hosts / settings stores as the desktop shell on each OS — the GTK client
//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing there (or //! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing on either side
//! via the shell's headless `--pair`) makes this binary connect silently. //! makes the other connect silently. `--pair <PIN> --connect host` runs the ceremony here,
//! with no window and no toolkit, for machines that have only a shell.
//! //!
//! Stdout is the machine interface (the shell↔session contract): `{"ready":true}` after //! Stdout is the machine interface (the shell↔session contract): `{"ready":true}` after
//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` / //! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` /
@@ -61,6 +62,54 @@ mod session_main {
Some((x.trim().parse().ok()?, y.trim().parse().ok()?)) Some((x.trim().parse().ok()?, y.trim().parse().ok()?))
} }
/// `--pair <PIN> --connect host[:port]` — the SPAKE2 PIN ceremony with no window, no GTK
/// and no console UI, so a machine that has only SSH can be enrolled: an embedded/kiosk
/// client, a headless box, an image being provisioned. Writes the verified host into the
/// same known-hosts store `--connect` reads, so pairing here is exactly what makes the
/// later stream connect silently.
///
/// Deliberately identical in shape and output to `punktfunk-client --pair` (which stays
/// the desktop route) — the difference is only that this binary carries no toolkit, so it
/// is the one a minimal image installs. Present in the `--no-default-features` build too:
/// enrolment must not be the reason an embedded image has to pull in Skia.
fn headless_pair(pin: &str) -> u8 {
let Some(target) = arg_value("--connect") else {
eprintln!("--pair requires --connect host[:port]");
return EXIT_CONNECT_FAILED;
};
let (addr, port) = parse_host_port(&target);
// The label the HOST files this client under. A headless box has nobody to ask, so
// the hostname is the only name that will mean anything in the paired-devices list.
let name = arg_value("--name").unwrap_or_else(trust::device_name);
let identity = match trust::load_or_create_identity() {
Ok(i) => i,
Err(e) => {
eprintln!("client identity: {e:#}");
return EXIT_CONNECT_FAILED;
}
};
match trust::pair_with_host(&addr, port, &identity, pin, &name) {
Ok(fp) => {
let fp_hex = trust::hex(&fp);
trust::persist_host(
&arg_value("--host-label").unwrap_or_else(|| addr.clone()),
&addr,
port,
&fp_hex,
true,
);
trust::forget_placeholder(&addr, port);
println!("paired {addr}:{port} fp={fp_hex}");
0
}
Err(e) => {
eprintln!("pairing failed: {} ({e:?})", trust::pair_error_message(&e));
EXIT_TRUST_REJECTED
}
}
}
/// `host[:port]`, port defaulting to the native 9777. /// `host[:port]`, port defaulting to the native 9777.
pub(crate) fn parse_host_port(target: &str) -> (String, u16) { pub(crate) fn parse_host_port(target: &str) -> (String, u16) {
match target.rsplit_once(':') { match target.rsplit_once(':') {
@@ -312,6 +361,13 @@ mod session_main {
}; };
} }
// `--pair <PIN>`: enrol this machine against a host and exit. Sits with the other
// non-streaming subcommands, above every graphics call — the box doing this may have
// no display at all.
if let Some(pin) = arg_value("--pair") {
return headless_pair(&pin);
}
// Before any Vulkan call: make RADV expose its video-decode queue + extensions so the // Before any Vulkan call: make RADV expose its video-decode queue + extensions so the
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV). // decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally. // Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
@@ -369,12 +425,14 @@ mod session_main {
eprintln!( eprintln!(
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen]\n\ "usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen]\n\
\x20 punktfunk-session --browse [host[:port]] [--mgmt PORT] [--fullscreen] [--json-status]\n\ \x20 punktfunk-session --browse [host[:port]] [--mgmt PORT] [--fullscreen] [--json-status]\n\
\x20 punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]\n\
\n\ \n\
Streams from a paired punktfunk host in a Vulkan window. --browse opens the\n\ Streams from a paired punktfunk host in a Vulkan window. --browse opens the\n\
gamepad console instead: bare --browse is the host list (discovery, PIN\n\ gamepad console instead: bare --browse is the host list (discovery, PIN\n\
pairing, settings, wake-on-LAN); with a target it opens that host's game\n\ pairing, settings, wake-on-LAN); with a target it opens that host's game\n\
library. --connect never dials a host it has no pinned fingerprint for —\n\ library. --connect never dials a host it has no pinned fingerprint for —\n\
pair in the console or via `punktfunk-client --pair <PIN> --connect …`." enrol with --pair (no display needed), in the console, or from the desktop\n\
client."
); );
return EXIT_CONNECT_FAILED; return EXIT_CONNECT_FAILED;
}; };
@@ -406,7 +464,7 @@ mod session_main {
"error", "error",
&format!( &format!(
"no pinned fingerprint for {addr}:{port} — pair first \ "no pinned fingerprint for {addr}:{port} — pair first \
(punktfunk-client --pair <PIN> --connect {addr}:{port}) or pass --fp HEX" (punktfunk-session --pair <PIN> --connect {addr}:{port}) or pass --fp HEX"
), ),
Some(true), Some(true),
); );
+34
View File
@@ -212,6 +212,40 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo
let _ = known.save(); let _ = known.save();
} }
/// This machine's name — the label a host files this client under in its paired-devices list.
/// `/etc/hostname` first (the answer on any Linux box, and the only one available in a minimal
/// build with no GTK to ask), then the usual environment fallbacks.
pub fn device_name() -> String {
#[cfg(target_os = "linux")]
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
let s = s.trim();
if !s.is_empty() {
return s.to_string();
}
}
std::env::var("COMPUTERNAME")
.or_else(|_| std::env::var("HOSTNAME"))
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "This device".into())
}
/// Drop an fp-less placeholder entry for `addr:port`. A host added by address before any
/// ceremony (`--add-host` with no `--fp`) is stored keyed by address with an empty fingerprint;
/// once pairing yields the real one, [`persist_host`] writes a second, fp-keyed entry — so the
/// placeholder has to go or the host list shows the same box twice. No-op (and no disk write)
/// when there is none, which is the usual case.
pub fn forget_placeholder(addr: &str, port: u16) {
let mut known = KnownHosts::load();
let before = known.hosts.len();
known
.hosts
.retain(|h| !(h.fp_hex.is_empty() && h.addr == addr && h.port == port));
if known.hosts.len() != before {
let _ = known.save();
}
}
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host /// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host
/// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so /// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so
/// the hosts page can call it on every discovery tick without churning the store. /// the hosts page can call it on every discovery tick without churning the store.