diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index 6b477734..5b11f0fd 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -3,7 +3,7 @@ //! scenes. use crate::app::AppModel; -use crate::trust::{KnownHost, KnownHosts}; +use crate::trust::{forget_placeholder, KnownHost, KnownHosts}; use crate::ui_hosts::{ConnectRequest, HostsMsg}; use gtk::glib; use gtk::prelude::*; @@ -252,18 +252,6 @@ fn probe_all(hosts: &[KnownHost]) -> Vec { ) } -/// 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 /// 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 diff --git a/clients/session/README.md b/clients/session/README.md index e937722b..4bc44e12 100644 --- a/clients/session/README.md +++ b/clients/session/README.md @@ -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 --browse host[:port] [--mgmt PORT] [--fullscreen] +punktfunk-session --pair --connect host[:port] [--name LABEL] ``` `--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= --connect host[:port]` runs the SPAKE2 ceremony with no window and no +toolkit, prints `paired : fp=`, 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, `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 diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index 227eb321..e4894761 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -105,7 +105,7 @@ pub fn run(target: Option<&str>) -> u8 { }; let opts = ConsoleOptions { - device_name: device_name(), + device_name: trust::device_name(), deck: is_steam_deck(), }; 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 { if name.trim().is_empty() { addr.to_string() diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index 043f1787..a254fbbc 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -5,8 +5,9 @@ //! One stream session per invocation: `--connect host[:port]` (+ `--fp HEX`, //! `--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 -//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing there (or -//! via the shell's headless `--pair`) makes this binary connect silently. +//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing on either side +//! makes the other connect silently. `--pair --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 //! 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()?)) } + /// `--pair --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. pub(crate) fn parse_host_port(target: &str) -> (String, u16) { match target.rsplit_once(':') { @@ -312,6 +361,13 @@ mod session_main { }; } + // `--pair `: 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 // decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV). // Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally. @@ -369,12 +425,14 @@ mod session_main { eprintln!( "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 --pair --connect host[:port] [--name LABEL]\n\ \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\ 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\ - pair in the console or via `punktfunk-client --pair --connect …`." + enrol with --pair (no display needed), in the console, or from the desktop\n\ + client." ); return EXIT_CONNECT_FAILED; }; @@ -406,7 +464,7 @@ mod session_main { "error", &format!( "no pinned fingerprint for {addr}:{port} — pair first \ - (punktfunk-client --pair --connect {addr}:{port}) or pass --fp HEX" + (punktfunk-session --pair --connect {addr}:{port}) or pass --fp HEX" ), Some(true), ); diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 3a939436..0476a424 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -212,6 +212,40 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo 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 /// 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.