50e25205a0
- library.rs + ui_library.rs: the host's unified game library over the management API (the Apple LibraryClient/LibraryView ported) — mTLS with the paired identity, host verified by its pinned cert fingerprint (ureq + rustls, unified with the workspace rustls 0.23); posters load async with monogram placeholders, and picking a title starts a session that asks the host to launch it (the library id rides the Hello). - app.rs (~800 lines lighter) splits into cli.rs (argv/headless pairing/--connect/screenshot scenes), launch.rs (mode resolve + session worker + event stream into the UI) and ui_trust.rs (TOFU / SPAKE2 PIN / delegated-approval dialogs); ui_hosts/ui_stream reworked around the split. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
313 lines
12 KiB
Rust
313 lines
12 KiB
Rust
//! Game-library client for the host's management REST API (the Apple `LibraryClient`
|
||
//! ported): `GET https://<host>:<mgmt>/api/v1/library` plus the per-title art proxy.
|
||
//! Authentication is **mTLS** — this client presents its persistent identity (the same
|
||
//! cert the host paired over QUIC) and the host authorizes paired certificates for the
|
||
//! read-only library routes, no bearer token. The host's self-signed certificate is
|
||
//! verified by its pinned SHA-256 fingerprint (`KnownHost::fp_hex`), not a CA chain.
|
||
|
||
use serde::Deserialize;
|
||
use std::io::Read;
|
||
use std::sync::Arc;
|
||
use std::time::Duration;
|
||
|
||
/// The management API's default port — matches `mgmt::DEFAULT_PORT` on the host. A
|
||
/// discovered host may override it via its mDNS `mgmt` TXT (`DiscoveredHost::mgmt_port`);
|
||
/// saved-but-not-advertising hosts fall back here (Apple parity).
|
||
pub const DEFAULT_MGMT_PORT: u16 = 47990;
|
||
|
||
/// Cover-art URLs, mirroring the host's `library::Artwork`: absolute CDN URLs for custom
|
||
/// entries, host-relative proxy paths (`/api/v1/library/art/...`) for Steam titles. The
|
||
/// wire shape also carries a `logo` (a transparent title logo) — not a poster kind, so
|
||
/// serde just skips it here.
|
||
#[derive(Clone, Debug, Default, Deserialize)]
|
||
pub struct Artwork {
|
||
#[serde(default)]
|
||
pub portrait: Option<String>,
|
||
#[serde(default)]
|
||
pub hero: Option<String>,
|
||
#[serde(default)]
|
||
pub header: Option<String>,
|
||
}
|
||
|
||
impl Artwork {
|
||
/// Poster candidates in the Apple client's fallback order — portrait (the 600×900
|
||
/// capsule) → header (near-universal) → hero — with host-relative paths resolved
|
||
/// against `base` so the loader only ever sees absolute URLs.
|
||
pub fn poster_candidates(&self, base: &str) -> Vec<String> {
|
||
[&self.portrait, &self.header, &self.hero]
|
||
.into_iter()
|
||
.flatten()
|
||
.map(|u| {
|
||
if u.starts_with('/') {
|
||
format!("{base}{u}")
|
||
} else {
|
||
u.clone()
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
/// One title in the host's unified library. `id` is store-qualified (`steam:<appid>`,
|
||
/// `custom:<id>`) and is also the launch handle the Hello carries when a session is
|
||
/// started from the library. The host's `launch` spec field is deliberately not
|
||
/// deserialized — launching goes by id, the host resolves the spec itself.
|
||
#[derive(Clone, Debug, Deserialize)]
|
||
pub struct GameEntry {
|
||
pub id: String,
|
||
/// Which store surfaced it (`"steam"`, `"custom"`, future `"heroic"`/`"gog"`/…) —
|
||
/// drives the poster's store badge.
|
||
pub store: String,
|
||
pub title: String,
|
||
#[serde(default)]
|
||
pub art: Artwork,
|
||
}
|
||
|
||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||
#[derive(Debug)]
|
||
pub enum LibraryError {
|
||
/// The host rejected our certificate — this device isn't on its paired list.
|
||
NotPaired,
|
||
/// The host's certificate didn't hash to the pinned fingerprint (impostor/rotated cert).
|
||
PinMismatch,
|
||
Http(u16),
|
||
Unreachable(String),
|
||
}
|
||
|
||
impl std::fmt::Display for LibraryError {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
LibraryError::NotPaired => f.write_str(
|
||
"The host didn't recognize this device. Pair with the host first — the \
|
||
library is authorized by this device's certificate (no token needed).",
|
||
),
|
||
LibraryError::PinMismatch => f.write_str(
|
||
"The host's certificate doesn't match the pinned fingerprint. \
|
||
Re-pair with a PIN to re-establish trust.",
|
||
),
|
||
LibraryError::Http(code) => {
|
||
write!(f, "The management API returned HTTP {code}.")
|
||
}
|
||
LibraryError::Unreachable(why) => write!(
|
||
f,
|
||
"Couldn't reach the host's management API: {why}. Check the host is \
|
||
updated and reachable (a host pinned to --mgmt-bind 127.0.0.1 is \
|
||
loopback-only and can't be browsed remotely)."
|
||
),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `https://addr:port`, IPv6 literals bracketed.
|
||
pub fn base_url(addr: &str, mgmt_port: u16) -> String {
|
||
if addr.contains(':') {
|
||
format!("https://[{addr}]:{mgmt_port}")
|
||
} else {
|
||
format!("https://{addr}:{mgmt_port}")
|
||
}
|
||
}
|
||
|
||
/// An HTTPS agent presenting `identity` via TLS client auth and verifying the server by
|
||
/// `pin` (`None` = accept any cert, the TOFU special case — same semantics as the QUIC
|
||
/// connect). Reused across a whole grid's worth of poster loads.
|
||
pub fn agent(
|
||
identity: &(String, String),
|
||
pin: Option<[u8; 32]>,
|
||
) -> Result<ureq::Agent, LibraryError> {
|
||
use rustls::pki_types::pem::PemObject;
|
||
let bad =
|
||
|what: &str, e: &dyn std::fmt::Display| LibraryError::Unreachable(format!("{what}: {e}"));
|
||
// The ring provider, explicitly — the same one core's QUIC endpoints install, so the
|
||
// process never mixes rustls crypto providers.
|
||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||
let builder = rustls::ClientConfig::builder_with_provider(provider)
|
||
.with_safe_default_protocol_versions()
|
||
.map_err(|e| bad("tls config", &e))?
|
||
.dangerous()
|
||
.with_custom_certificate_verifier(Arc::new(PinVerify { pin }));
|
||
let cert = rustls::pki_types::CertificateDer::from_pem_slice(identity.0.as_bytes())
|
||
.map_err(|e| bad("client cert pem", &e))?;
|
||
let key = rustls::pki_types::PrivateKeyDer::from_pem_slice(identity.1.as_bytes())
|
||
.map_err(|e| bad("client key pem", &e))?;
|
||
let cfg = builder
|
||
.with_client_auth_cert(vec![cert], key)
|
||
.map_err(|e| bad("client auth", &e))?;
|
||
Ok(ureq::AgentBuilder::new()
|
||
.tls_config(Arc::new(cfg))
|
||
.timeout_connect(Duration::from_secs(5))
|
||
.timeout(Duration::from_secs(10))
|
||
.build())
|
||
}
|
||
|
||
/// Fetch the host's unified library. Errors are pre-classified for the UI (401/403 →
|
||
/// [`LibraryError::NotPaired`], a pin-verifier rejection → [`LibraryError::PinMismatch`]).
|
||
pub fn fetch_games(
|
||
addr: &str,
|
||
mgmt_port: u16,
|
||
identity: &(String, String),
|
||
pin: Option<[u8; 32]>,
|
||
) -> Result<Vec<GameEntry>, LibraryError> {
|
||
let agent = agent(identity, pin)?;
|
||
let url = format!("{}/api/v1/library", base_url(addr, mgmt_port));
|
||
let body = match agent.get(&url).call() {
|
||
Ok(resp) => resp
|
||
.into_string()
|
||
.map_err(|e| LibraryError::Unreachable(format!("read body: {e}")))?,
|
||
Err(e) => return Err(classify(e)),
|
||
};
|
||
serde_json::from_str(&body).map_err(|e| LibraryError::Unreachable(format!("bad JSON: {e}")))
|
||
}
|
||
|
||
/// Poster-art byte fetch cap — largest Steam hero assets run a few MB; anything bigger is
|
||
/// not an image we want to hand to the texture decoder.
|
||
const ART_MAX_BYTES: u64 = 16 * 1024 * 1024;
|
||
|
||
/// Fetch one cover-art image. URLs on the host itself (under `base`) go through the
|
||
/// pinned mTLS agent (the host's art proxy requires the paired cert); any other origin —
|
||
/// a public CDN URL on a custom entry — uses ureq's default agent with normal webpki
|
||
/// trust and no client cert (Apple's `LibraryTLSDelegate` does the same split).
|
||
pub fn fetch_art(pinned: &ureq::Agent, base: &str, url: &str) -> Result<Vec<u8>, LibraryError> {
|
||
let resp = if url.starts_with(base) {
|
||
pinned.get(url).call()
|
||
} else {
|
||
ureq::get(url).timeout(Duration::from_secs(10)).call()
|
||
}
|
||
.map_err(classify)?;
|
||
let mut bytes = Vec::new();
|
||
resp.into_reader()
|
||
.take(ART_MAX_BYTES)
|
||
.read_to_end(&mut bytes)
|
||
.map_err(|e| LibraryError::Unreachable(format!("read image: {e}")))?;
|
||
Ok(bytes)
|
||
}
|
||
|
||
fn classify(e: ureq::Error) -> LibraryError {
|
||
match e {
|
||
ureq::Error::Status(401 | 403, _) => LibraryError::NotPaired,
|
||
ureq::Error::Status(code, _) => LibraryError::Http(code),
|
||
ureq::Error::Transport(t) => {
|
||
// A pin rejection surfaces as a TLS alert wrapped in a transport error; the
|
||
// verifier's error kind survives in the message.
|
||
let msg = t.to_string();
|
||
if msg.contains("ApplicationVerificationFailure") || msg.contains("InvalidCertificate")
|
||
{
|
||
LibraryError::PinMismatch
|
||
} else {
|
||
LibraryError::Unreachable(msg)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Fingerprint-pinning verifier — the client-HTTP twin of core's (private) QUIC
|
||
/// `PinVerify`: trust is the SHA-256 of the host's self-signed leaf cert. The handshake
|
||
/// signatures MUST still be verified for real: CertificateVerify is what proves the peer
|
||
/// *holds the pinned cert's private key* — skip it and an active MITM can replay the
|
||
/// host's (public) certificate, match the pin, and complete the handshake with its own key.
|
||
#[derive(Debug)]
|
||
struct PinVerify {
|
||
pin: Option<[u8; 32]>,
|
||
}
|
||
|
||
impl rustls::client::danger::ServerCertVerifier for PinVerify {
|
||
fn verify_server_cert(
|
||
&self,
|
||
end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||
_ocsp: &[u8],
|
||
_now: rustls::pki_types::UnixTime,
|
||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||
if let Some(expected) = self.pin {
|
||
let fp = punktfunk_core::quic::endpoint::cert_fingerprint(end_entity.as_ref());
|
||
if fp != expected {
|
||
return Err(rustls::Error::InvalidCertificate(
|
||
rustls::CertificateError::ApplicationVerificationFailure,
|
||
));
|
||
}
|
||
}
|
||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||
}
|
||
|
||
fn verify_tls12_signature(
|
||
&self,
|
||
message: &[u8],
|
||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||
dss: &rustls::DigitallySignedStruct,
|
||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||
rustls::crypto::verify_tls12_signature(
|
||
message,
|
||
cert,
|
||
dss,
|
||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||
)
|
||
}
|
||
|
||
fn verify_tls13_signature(
|
||
&self,
|
||
message: &[u8],
|
||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||
dss: &rustls::DigitallySignedStruct,
|
||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||
rustls::crypto::verify_tls13_signature(
|
||
message,
|
||
cert,
|
||
dss,
|
||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||
)
|
||
}
|
||
|
||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||
rustls::crypto::ring::default_provider()
|
||
.signature_verification_algorithms
|
||
.supported_schemes()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn poster_candidates_order_and_resolution() {
|
||
// Fallback order is portrait → header → hero, host-relative paths resolved.
|
||
let art = Artwork {
|
||
portrait: Some("/api/v1/library/art/steam:570/portrait".into()),
|
||
hero: Some("https://cdn.example/hero.jpg".into()),
|
||
header: Some("/api/v1/library/art/steam:570/header".into()),
|
||
};
|
||
assert_eq!(
|
||
art.poster_candidates("https://192.168.1.42:47990"),
|
||
vec![
|
||
"https://192.168.1.42:47990/api/v1/library/art/steam:570/portrait",
|
||
"https://192.168.1.42:47990/api/v1/library/art/steam:570/header",
|
||
"https://cdn.example/hero.jpg",
|
||
]
|
||
);
|
||
assert!(Artwork::default()
|
||
.poster_candidates("https://h:47990")
|
||
.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn game_entry_decodes_the_wire_shape() {
|
||
// The exact shape mgmt.rs serializes (optional art fields omitted, launch ignored).
|
||
let json = r#"[
|
||
{"id":"steam:570","store":"steam","title":"Dota 2",
|
||
"art":{"portrait":"/api/v1/library/art/steam:570/portrait"},
|
||
"launch":{"kind":"steam_appid","value":"570"}},
|
||
{"id":"custom:abc","store":"custom","title":"My Emu","art":{}}
|
||
]"#;
|
||
let games: Vec<GameEntry> = serde_json::from_str(json).unwrap();
|
||
assert_eq!(games.len(), 2);
|
||
assert_eq!(games[0].id, "steam:570");
|
||
assert!(games[1].art.portrait.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn ipv6_base_url_is_bracketed() {
|
||
assert_eq!(base_url("fe80::1", 47990), "https://[fe80::1]:47990");
|
||
assert_eq!(base_url("192.168.1.42", 1234), "https://192.168.1.42:1234");
|
||
}
|
||
}
|