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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user