feat(pairing): delegated approval (§8b-1) — approve an unpaired device from the console
ci / web (push) Failing after 40s
ci / rust (push) Successful in 1m6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
apple / swift (push) Successful in 1m20s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
ci / docs-site (push) Failing after 46s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
docker / deploy-docs (push) Successful in 16s

An identified-but-unpaired device that knocks on a pairing-required host is now
held as a pending request the operator approves from the web console — pairing it
with no PIN fetched out of band — instead of a flat reject.

- core: Hello gains an optional trailing device name (len u8 || UTF-8, ≤64,
  same trailing-back-compat pattern as compositor/gamepad/bitrate). client-rs
  --name sends it; the connector sends None (fingerprint-derived label).
- native_pairing: in-memory pending queue (note_pending dedups by fingerprint,
  evicts the least-recently-active past a 32 cap, 10-min TTL); approve_pending
  pins the fingerprint, deny drops it. Names are sanitized (strip control/ANSI/
  bidi — untrusted wire input); add()/remove() roll back in-memory on a persist
  failure; pairing clears any stale pending knock.
- m3: the require_pairing gate records the knock (sanitized label) before
  rejecting; anonymous (certless) clients record nothing.
- mgmt: GET /native/pending, POST /native/pending/{id}/approve (optional {name})
  and /deny; OpenAPI + tests; docs/api/openapi.json regenerated.
- web: a "Waiting for approval" section on the Pairing page (live-poll, Approve/
  Deny, error-surfaced via QueryState); en+de strings.
- Also completes an in-progress NativeClient Sync refactor (receivers behind
  per-plane mutexes) that was left half-applied in the tree.

Adversarially reviewed (4 lenses + 3-vote verify); the confirmed findings are
fixed here. Validated live on the GNOME box: knock (with a wire name, and a
malicious ANSI/bidi name that got neutralized) → pending → approve → the same
identity streams real video. Full workspace tests + clippy + fmt green; web tsc
clean. Roadmap §8b-1 marked done; §8b-2 (peer-push approval) is the client
follow-up. See docs-site pairing page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 19:14:05 +00:00
parent 9758751a4d
commit 99b4de32ee
14 changed files with 1250 additions and 109 deletions
+3
View File
@@ -371,6 +371,9 @@ async fn session(args: Args) -> Result<()> {
compositor: args.compositor, compositor: args.compositor,
gamepad: args.gamepad, gamepad: args.gamepad,
bitrate_kbps: args.bitrate_kbps, bitrate_kbps: args.bitrate_kbps,
// `--name` (also the pairing label) — shown in the host's pending-approval list when
// this client knocks on a pairing-required host.
name: Some(args.name.clone()),
} }
.encode(), .encode(),
) )
+19 -12
View File
@@ -108,11 +108,15 @@ pub struct AudioPacket {
} }
pub struct NativeClient { pub struct NativeClient {
frames: Receiver<Frame>, // Each plane's receiver sits behind its own mutex so `NativeClient` is `Sync` and Rust
audio: Receiver<AudioPacket>, // embedders can share one `Arc<NativeClient>` across their plane threads (the same
rumble: Receiver<(u16, u16, u16)>, // one-thread-per-plane contract the C ABI documents — the lock is uncontended there,
// and two threads racing one plane now serialize instead of being undefined).
frames: Mutex<Receiver<Frame>>,
audio: Mutex<Receiver<AudioPacket>>,
rumble: Mutex<Receiver<(u16, u16, u16)>>,
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams. /// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
hidout: Receiver<HidOutput>, hidout: Mutex<Receiver<HidOutput>>,
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>, input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
/// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker. /// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker.
mic_tx: tokio::sync::mpsc::UnboundedSender<(u32, u64, Vec<u8>)>, mic_tx: tokio::sync::mpsc::UnboundedSender<(u32, u64, Vec<u8>)>,
@@ -234,10 +238,10 @@ impl NativeClient {
}; };
*mode_slot.lock().unwrap() = negotiated; *mode_slot.lock().unwrap() = negotiated;
Ok(NativeClient { Ok(NativeClient {
frames: frame_rx, frames: Mutex::new(frame_rx),
audio: audio_rx, audio: Mutex::new(audio_rx),
rumble: rumble_rx, rumble: Mutex::new(rumble_rx),
hidout: hidout_rx, hidout: Mutex::new(hidout_rx),
input_tx, input_tx,
mic_tx, mic_tx,
rich_input_tx, rich_input_tx,
@@ -419,7 +423,7 @@ impl NativeClient {
/// (`&self` here supports the cross-plane sharing; a plane's queue is still /// (`&self` here supports the cross-plane sharing; a plane's queue is still
/// single-consumer by contract). /// single-consumer by contract).
pub fn next_frame(&self, timeout: Duration) -> Result<Frame> { pub fn next_frame(&self, timeout: Duration) -> Result<Frame> {
match self.frames.recv_timeout(timeout) { match self.frames.lock().unwrap().recv_timeout(timeout) {
Ok(f) => Ok(f), Ok(f) => Ok(f),
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame), Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed), Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
@@ -430,7 +434,7 @@ impl NativeClient {
/// [`PunktfunkError::Closed`] once the session ended. Drain on a dedicated audio thread — /// [`PunktfunkError::Closed`] once the session ended. Drain on a dedicated audio thread —
/// packets arrive every 5 ms. /// packets arrive every 5 ms.
pub fn next_audio(&self, timeout: Duration) -> Result<AudioPacket> { pub fn next_audio(&self, timeout: Duration) -> Result<AudioPacket> {
match self.audio.recv_timeout(timeout) { match self.audio.lock().unwrap().recv_timeout(timeout) {
Ok(p) => Ok(p), Ok(p) => Ok(p),
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame), Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed), Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
@@ -440,7 +444,7 @@ impl NativeClient {
/// Pull the next rumble update `(pad, low, high)`; same semantics as /// Pull the next rumble update `(pad, low, high)`; same semantics as
/// [`NativeClient::next_audio`]. Amplitudes are 0..0xFFFF, `(0, 0)` = stop. /// [`NativeClient::next_audio`]. Amplitudes are 0..0xFFFF, `(0, 0)` = stop.
pub fn next_rumble(&self, timeout: Duration) -> Result<(u16, u16, u16)> { pub fn next_rumble(&self, timeout: Duration) -> Result<(u16, u16, u16)> {
match self.rumble.recv_timeout(timeout) { match self.rumble.lock().unwrap().recv_timeout(timeout) {
Ok(r) => Ok(r), Ok(r) => Ok(r),
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame), Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed), Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
@@ -452,7 +456,7 @@ impl NativeClient {
/// [`NativeClient::next_rumble`]. Replay it on a real DualSense (e.g. via the platform's /// [`NativeClient::next_rumble`]. Replay it on a real DualSense (e.g. via the platform's
/// `GCDualSenseAdaptiveTrigger` API). Only the DualSense host backend emits these. /// `GCDualSenseAdaptiveTrigger` API). Only the DualSense host backend emits these.
pub fn next_hidout(&self, timeout: Duration) -> Result<HidOutput> { pub fn next_hidout(&self, timeout: Duration) -> Result<HidOutput> {
match self.hidout.recv_timeout(timeout) { match self.hidout.lock().unwrap().recv_timeout(timeout) {
Ok(h) => Ok(h), Ok(h) => Ok(h),
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame), Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed), Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
@@ -579,6 +583,9 @@ async fn worker_main(args: WorkerArgs) {
compositor, compositor,
gamepad, gamepad,
bitrate_kbps, bitrate_kbps,
// No device name yet: the connect ABI has no name parameter (pairing does). The
// host falls back to a fingerprint-derived label in its pending-approval list.
name: None,
} }
.encode(), .encode(),
) )
+85 -1
View File
@@ -38,7 +38,7 @@ pub const CTL_MAGIC: &[u8; 4] = b"PKFc";
/// `client → host`: open the session, requesting a display mode (the host creates its /// `client → host`: open the session, requesting a display mode (the host creates its
/// virtual output at exactly this size/refresh — native resolution end to end). /// virtual output at exactly this size/refresh — native resolution end to end).
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct Hello { pub struct Hello {
pub abi_version: u32, pub abi_version: u32,
pub mode: Mode, pub mode: Mode,
@@ -57,8 +57,17 @@ pub struct Hello {
/// the value it actually configured in [`Welcome::bitrate_kbps`]. Appended to the wire form — /// the value it actually configured in [`Welcome::bitrate_kbps`]. Appended to the wire form —
/// omitted by older clients (decodes to `0`, i.e. host default). /// omitted by older clients (decodes to `0`, i.e. host default).
pub bitrate_kbps: u32, pub bitrate_kbps: u32,
/// Human-readable device name ("Enrico's MacBook"), shown by the host when this device knocks
/// on a pairing-required host (the delegated-approval pending list) and stored on approval.
/// Appended to the wire form as `len u8 || UTF-8` (≤ [`HELLO_NAME_MAX`] bytes) — omitted by
/// older clients (decodes to `None`; the host falls back to a fingerprint-derived label).
pub name: Option<String>,
} }
/// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on
/// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway).
pub const HELLO_NAME_MAX: usize = 64;
/// `host → client`: the complete session offer. /// `host → client`: the complete session offer.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Welcome { pub struct Welcome {
@@ -463,6 +472,22 @@ impl Hello {
b.push(self.compositor.to_u8()); // appended at offset 20 — older hosts read [0..20] and skip it b.push(self.compositor.to_u8()); // appended at offset 20 — older hosts read [0..20] and skip it
b.push(self.gamepad.to_u8()); // appended at offset 21 — same back-compat discipline b.push(self.gamepad.to_u8()); // appended at offset 21 — same back-compat discipline
b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); // appended at offset 22..26 b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); // appended at offset 22..26
if let Some(name) = &self.name {
// Appended at offset 26: len u8 || UTF-8. This is the LAST trailing field — `None`
// emits nothing (so a no-name Hello is byte-identical to the bitrate-era form), which
// means a *future* field can't simply follow `name` at a fixed offset; it would need
// its own presence flag. Truncate to a char boundary within HELLO_NAME_MAX.
let mut n = name.as_str();
while n.len() > HELLO_NAME_MAX {
let mut cut = HELLO_NAME_MAX;
while !n.is_char_boundary(cut) {
cut -= 1;
}
n = &n[..cut];
}
b.push(n.len() as u8);
b.extend_from_slice(n.as_bytes());
}
b b
} }
@@ -492,6 +517,17 @@ impl Hello {
.get(22..26) .get(22..26)
.map(|s| u32::from_le_bytes(s.try_into().unwrap())) .map(|s| u32::from_le_bytes(s.try_into().unwrap()))
.unwrap_or(0), .unwrap_or(0),
// Optional trailing device name: len u8 || UTF-8. Absent / oversized / non-UTF-8 →
// `None` (never fail the handshake over a label).
name: b.get(26).and_then(|&len| {
let len = len as usize;
if len == 0 || len > HELLO_NAME_MAX {
return None;
}
b.get(27..27 + len)
.and_then(|s| std::str::from_utf8(s).ok())
.map(String::from)
}),
}) })
} }
} }
@@ -1406,6 +1442,7 @@ mod tests {
compositor: CompositorPref::Kwin, compositor: CompositorPref::Kwin,
gamepad: GamepadPref::DualSense, gamepad: GamepadPref::DualSense,
bitrate_kbps: 25_000, bitrate_kbps: 25_000,
name: Some("Test Device".into()),
}; };
assert_eq!(Hello::decode(&h.encode()).unwrap(), h); assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
let s = Start { let s = Start {
@@ -1470,6 +1507,7 @@ mod tests {
compositor: CompositorPref::Mutter, compositor: CompositorPref::Mutter,
gamepad: GamepadPref::DualSense, gamepad: GamepadPref::DualSense,
bitrate_kbps: 80_000, bitrate_kbps: 80_000,
name: None,
}; };
let enc = h.encode(); let enc = h.encode();
assert_eq!(enc.len(), 26); assert_eq!(enc.len(), 26);
@@ -1526,6 +1564,51 @@ mod tests {
assert_eq!(Welcome::decode(&wenc).unwrap().bitrate_kbps, 120_000); assert_eq!(Welcome::decode(&wenc).unwrap().bitrate_kbps, 120_000);
} }
#[test]
fn hello_name_roundtrip_and_back_compat() {
let base = Hello {
abi_version: 2,
mode: Mode {
width: 1280,
height: 720,
refresh_hz: 60,
},
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
name: Some("Enrico's MacBook".into()),
};
let enc = base.encode();
assert_eq!(
Hello::decode(&enc).unwrap().name.as_deref(),
Some("Enrico's MacBook")
);
// A bitrate-era (26-byte) peer reading a named Hello ignores the trailing name; a named
// host reading a bitrate-era Hello decodes name = None.
assert_eq!(Hello::decode(&enc[..26]).unwrap().name, None);
// No name → wire form is byte-identical to the bitrate-era message (26 bytes).
let unnamed = Hello {
name: None,
..base.clone()
};
assert_eq!(unnamed.encode().len(), 26);
// Over-long names truncate to a char boundary within HELLO_NAME_MAX on encode.
let long = Hello {
name: Some(format!("{}ü", "x".repeat(HELLO_NAME_MAX - 1))), // ü straddles the cap
..base.clone()
};
let dec = Hello::decode(&long.encode()).unwrap();
let n = dec.name.expect("truncated name still present");
assert!(n.len() <= HELLO_NAME_MAX && n.starts_with('x'));
// A corrupt length byte (longer than the buffer) or bad UTF-8 degrades to None, never Err.
let mut bad_len = unnamed.encode();
bad_len.push(40); // claims 40 name bytes, none follow
assert_eq!(Hello::decode(&bad_len).unwrap().name, None);
let mut bad_utf8 = unnamed.encode();
bad_utf8.extend_from_slice(&[2, 0xFF, 0xFE]);
assert_eq!(Hello::decode(&bad_utf8).unwrap().name, None);
}
#[test] #[test]
fn reconfigure_roundtrip() { fn reconfigure_roundtrip() {
let rq = Reconfigure { let rq = Reconfigure {
@@ -1632,6 +1715,7 @@ mod tests {
compositor: CompositorPref::Auto, compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto, gamepad: GamepadPref::Auto,
bitrate_kbps: 0, bitrate_kbps: 0,
name: None,
} }
.encode(); .encode();
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair"); assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
+120 -5
View File
@@ -454,13 +454,34 @@ async fn serve_session(
punktfunk_core::ABI_VERSION punktfunk_core::ABI_VERSION
); );
if opts.require_pairing { if opts.require_pairing {
let known = endpoint::peer_fingerprint(&conn) let fp = endpoint::peer_fingerprint(&conn);
.map(|fp| np.is_paired(&fingerprint_hex(&fp))) let known = fp
.as_ref()
.map(|fp| np.is_paired(&fingerprint_hex(fp)))
.unwrap_or(false); .unwrap_or(false);
anyhow::ensure!( if !known {
known, // Delegated approval (§8b-1): an identified-but-unpaired knock becomes a pending
"unpaired client rejected (this host requires pairing — run the PIN ceremony first)" // request the operator can approve from the console — no PIN fetched out of band.
// The label is the client's Hello name, else fingerprint-derived. An anonymous
// client (no certificate) has no identity to approve, so nothing is recorded.
if let Some(fp) = &fp {
let fp_hex = fingerprint_hex(fp);
// Sanitize the wire-supplied name before it reaches the log (untrusted: an
// unpaired device could embed terminal escapes / bidi overrides); note_pending
// stores the same sanitized form and derives a fingerprint label when empty.
let label = crate::native_pairing::sanitize_device_name(
hello.name.as_deref().unwrap_or(""),
&fp_hex,
); );
tracing::info!(name = %label, fingerprint = %fp_hex,
"unpaired device knocked — held for approval in the console");
np.note_pending(&label, &fp_hex);
}
anyhow::bail!(
"unpaired client rejected (this host requires pairing — approve the device \
in the console, or run the PIN ceremony)"
);
}
} }
crate::encode::validate_dimensions( crate::encode::validate_dimensions(
crate::encode::Codec::H265, crate::encode::Codec::H265,
@@ -2206,6 +2227,100 @@ mod tests {
std::env::temp_dir().join(format!("punktfunk-paired-test-{}.json", std::process::id())) std::env::temp_dir().join(format!("punktfunk-paired-test-{}.json", std::process::id()))
} }
/// Delegated approval (§8b-1) end to end in-process: an identified-but-unpaired client's
/// knock on a pairing-required host is held as a pending request (fingerprint-derived label —
/// the connector sends no Hello name); approving it pairs the fingerprint, and the same
/// identity then gets a session with no PIN ceremony.
#[test]
fn delegated_approval_admits_after_knock() {
use punktfunk_core::client::NativeClient;
use punktfunk_core::quic::endpoint;
let store =
std::env::temp_dir().join(format!("pf-approval-test-{}.json", std::process::id()));
let _ = std::fs::remove_file(&store);
let np = Arc::new(NativePairing::load_with(Some(store.clone()), None, false).unwrap());
let np_host = np.clone();
let host = std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.unwrap();
rt.block_on(serve(
M3Options {
port: 19779,
source: M3Source::Synthetic,
seconds: 0,
frames: 25,
max_sessions: 2, // the knock + the post-approval session
max_concurrent: 1,
require_pairing: true,
allow_pairing: false,
pairing_pin: None,
paired_store: None, // unused: the shared `np` IS the store handle
},
np_host,
))
});
std::thread::sleep(std::time::Duration::from_millis(500));
let timeout = std::time::Duration::from_secs(10);
let (cert, key) = endpoint::generate_identity().unwrap();
let mode = punktfunk_core::Mode {
width: 1280,
height: 720,
refresh_hz: 60,
};
// 1: the knock — an identified-but-unpaired connect is rejected, but lands in pending.
assert!(
NativeClient::connect(
"127.0.0.1",
19779,
mode,
CompositorPref::Auto,
GamepadPref::Auto,
0,
None,
Some((cert.clone(), key.clone())),
timeout
)
.is_err(),
"unpaired knock must still be rejected"
);
let expected_fp = fingerprint_hex(&endpoint::fingerprint_of_pem(&cert).unwrap());
let pend = np.pending();
assert_eq!(pend.len(), 1, "the knock must be held for approval");
assert_eq!(pend[0].fingerprint, expected_fp);
assert!(
pend[0].name.starts_with("device "),
"no Hello name → fingerprint-derived label, got {:?}",
pend[0].name
);
// 2: approve (with an operator label) → the same identity now gets a session, no PIN.
let approved = np
.approve_pending(pend[0].id, Some("Approved Device"))
.unwrap()
.expect("pending id must approve");
assert_eq!(approved.fingerprint, expected_fp);
let client = NativeClient::connect(
"127.0.0.1",
19779,
mode,
CompositorPref::Auto,
GamepadPref::Auto,
0,
None,
Some((cert, key)),
timeout,
)
.expect("approved identity gets a session");
drop(client);
let _ = std::fs::remove_file(&store);
host.join().unwrap().unwrap();
}
/// The PIN pairing ceremony + the --require-pairing gate, end to end in-process: /// The PIN pairing ceremony + the --require-pairing gate, end to end in-process:
/// wrong PIN rejected; right PIN pairs and returns the host fingerprint; a paired /// wrong PIN rejected; right PIN pairs and returns the host fingerprint; a paired
/// identity gets a session on a pairing-required host; an anonymous client does not. /// identity gets a session on a pairing-required host; an anonymous client does not.
+224
View File
@@ -145,6 +145,9 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(disarm_native_pairing)) .routes(routes!(disarm_native_pairing))
.routes(routes!(list_native_clients)) .routes(routes!(list_native_clients))
.routes(routes!(unpair_native_client)) .routes(routes!(unpair_native_client))
.routes(routes!(list_pending_devices))
.routes(routes!(approve_pending_device))
.routes(routes!(deny_pending_device))
.routes(routes!(stop_session)) .routes(routes!(stop_session))
.routes(routes!(request_idr)), .routes(routes!(request_idr)),
) )
@@ -379,6 +382,29 @@ struct NativeClient {
fingerprint: String, fingerprint: String,
} }
/// An unpaired device that tried to connect while the host requires pairing — awaiting
/// **delegated approval** (approve it here instead of fetching the host PIN out of band).
#[derive(Serialize, ToSchema)]
struct PendingDevice {
/// Id to address approve/deny (per-process; entries expire after ~10 minutes).
id: u32,
/// Best-effort device label (the client's own name, else fingerprint-derived).
#[schema(example = "Enrico's MacBook")]
name: String,
/// Hex SHA-256 of the device's certificate — what approval pins.
fingerprint: String,
/// Seconds since the device last knocked.
age_secs: u64,
}
/// Approve-pending-device request body. Send `{}` to keep the device's own name.
#[derive(Deserialize, ToSchema)]
struct ApprovePending {
/// Operator-chosen label for the device (defaults to the name it knocked with).
#[schema(example = "Living Room TV")]
name: Option<String>,
}
/// Error envelope for every non-2xx response. /// Error envelope for every non-2xx response.
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
struct ApiError { struct ApiError {
@@ -885,6 +911,116 @@ async fn unpair_native_client(
} }
} }
/// List devices awaiting pairing approval
///
/// Unpaired devices that tried to connect while the host requires pairing. Approve one to pair
/// it without a PIN (delegated approval); entries expire after ~10 minutes.
#[utoipa::path(
get,
path = "/native/pending",
tag = "native",
operation_id = "listPendingDevices",
responses(
(status = OK, description = "Devices awaiting approval (empty when none, or when the \
native host is not enabled)", body = Vec<PendingDevice>),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn list_pending_devices(State(st): State<Arc<MgmtState>>) -> Json<Vec<PendingDevice>> {
let pending = st
.native
.as_ref()
.map(|np| np.pending())
.unwrap_or_default();
Json(
pending
.into_iter()
.map(|p| PendingDevice {
id: p.id,
name: p.name,
fingerprint: p.fingerprint,
age_secs: p.age_secs,
})
.collect(),
)
}
/// Approve a pending device
///
/// Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally
/// relabel it via the body; send `{}` to keep the name it knocked with.
#[utoipa::path(
post,
path = "/native/pending/{id}/approve",
tag = "native",
operation_id = "approvePendingDevice",
params(("id" = u32, Path, description = "Pending-request id from the pending list")),
request_body = ApprovePending,
responses(
(status = OK, description = "Device paired", body = NativeClient),
(status = NOT_FOUND, description = "No pending request with that id (expired?)", body = ApiError),
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the trust store", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn approve_pending_device(
State(st): State<Arc<MgmtState>>,
Path(id): Path<u32>,
ApiJson(req): ApiJson<ApprovePending>,
) -> Response {
let Some(np) = &st.native else {
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
};
match np.approve_pending(id, req.name.as_deref()) {
Ok(Some(client)) => {
tracing::info!(name = %client.name, fingerprint = %client.fingerprint,
"management API: pending device approved (delegated pairing)");
Json(NativeClient {
name: client.name,
fingerprint: client.fingerprint,
})
.into_response()
}
Ok(None) => api_error(
StatusCode::NOT_FOUND,
"no pending request with that id (it may have expired — have the device retry)",
),
Err(e) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("could not persist trust store: {e}"),
),
}
}
/// Deny a pending device
///
/// Drops the request. Not a blocklist — the device's next attempt knocks again.
#[utoipa::path(
post,
path = "/native/pending/{id}/deny",
tag = "native",
operation_id = "denyPendingDevice",
params(("id" = u32, Path, description = "Pending-request id from the pending list")),
responses(
(status = NO_CONTENT, description = "Request dropped"),
(status = NOT_FOUND, description = "No pending request with that id", body = ApiError),
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn deny_pending_device(State(st): State<Arc<MgmtState>>, Path(id): Path<u32>) -> Response {
let Some(np) = &st.native else {
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
};
if np.deny_pending(id) {
tracing::info!(id, "management API: pending device denied");
StatusCode::NO_CONTENT.into_response()
} else {
api_error(StatusCode::NOT_FOUND, "no pending request with that id")
}
}
/// Stop the active session /// Stop the active session
/// ///
/// Kicks the connected client: stops the video/audio stream threads and clears the launch /// Kicks the connected client: stops the video/audio stream threads and clears the launch
@@ -1344,6 +1480,77 @@ mod tests {
assert_eq!(b["armed"], false); assert_eq!(b["armed"], false);
} }
#[tokio::test]
async fn pending_devices_approve_and_deny() {
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(
std::env::temp_dir()
.join(format!("pf-mgmt-pending-{}.json", std::process::id())),
),
None,
false,
)
.unwrap(),
);
let app = test_app_native(test_state(), np.clone());
// Empty queue.
let (s, b) = send(&app, get_req("/api/v1/native/pending")).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b.as_array().unwrap().len(), 0);
// Two devices knock (what the QUIC gate records); they appear in the list.
np.note_pending("Enrico's MacBook", "aa11");
np.note_pending("device bb22cc33", "bb22");
let (_, b) = send(&app, get_req("/api/v1/native/pending")).await;
assert_eq!(b.as_array().unwrap().len(), 2);
assert_eq!(b[0]["name"], "Enrico's MacBook");
let approve_id = b[0]["id"].as_u64().unwrap();
let deny_id = b[1]["id"].as_u64().unwrap();
// Approve the first with an operator label → paired under that name, gone from pending.
let (s, b) = send(
&app,
post_json(
&format!("/api/v1/native/pending/{approve_id}/approve"),
serde_json::json!({"name": "Office MacBook"}),
),
)
.await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b["name"], "Office MacBook");
assert_eq!(b["fingerprint"], "aa11");
assert!(np.is_paired("AA11"), "approval pins the fingerprint");
// Deny the second → dropped, not paired; a re-deny is 404.
let deny = post_json(
&format!("/api/v1/native/pending/{deny_id}/deny"),
serde_json::json!({}),
);
assert_eq!(send(&app, deny).await.0, StatusCode::NO_CONTENT);
assert!(!np.is_paired("bb22"));
let (s, _) = send(
&app,
post_json(
&format!("/api/v1/native/pending/{deny_id}/deny"),
serde_json::json!({}),
),
)
.await;
assert_eq!(s, StatusCode::NOT_FOUND);
// Queue is empty again; approving a stale id is 404 (keep `{}` = device's own name).
let (_, b) = send(&app, get_req("/api/v1/native/pending")).await;
assert_eq!(b.as_array().unwrap().len(), 0);
let (s, _) = send(
&app,
post_json("/api/v1/native/pending/123/approve", serde_json::json!({})),
)
.await;
assert_eq!(s, StatusCode::NOT_FOUND);
}
#[tokio::test] #[tokio::test]
async fn native_endpoints_report_disabled_without_native_host() { async fn native_endpoints_report_disabled_without_native_host() {
let app = test_app(test_state(), None); let app = test_app(test_state(), None);
@@ -1357,5 +1564,22 @@ mod tests {
) )
.await; .await;
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE); assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
// Pending list reads as an empty array (like /native/clients), not a 503.
let (s, b) = send(&app, get_req("/api/v1/native/pending")).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b.as_array().unwrap().len(), 0);
// Approve/deny without a native host are 503.
let (s, _) = send(
&app,
post_json("/api/v1/native/pending/0/approve", serde_json::json!({})),
)
.await;
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
let (s, _) = send(
&app,
post_json("/api/v1/native/pending/0/deny", serde_json::json!({})),
)
.await;
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
} }
} }
+303 -7
View File
@@ -47,10 +47,47 @@ struct Armed {
expires_at: Option<Instant>, expires_at: Option<Instant>,
} }
/// Shared native-pairing state: the arming PIN window + the persistent trust store. /// An unpaired (but identified) device that knocked on a pairing-required host — held for
/// **delegated approval** from the management console (roadmap §8b-1) instead of being silently
/// forgotten. In-memory only: pending knocks don't survive a restart (the device just knocks
/// again), and they expire after [`PENDING_TTL`].
struct Pending {
id: u32,
name: String,
fp_hex: String,
requested_at: Instant,
}
#[derive(Default)]
struct PendingState {
next_id: u32,
items: Vec<Pending>,
}
/// A pending-approval snapshot for the management API / web console.
pub struct PendingRequest {
/// Per-process id used to address approve/deny (stable for the entry's lifetime).
pub id: u32,
/// Best-effort device label (the client's `Hello` name, else fingerprint-derived).
pub name: String,
/// Hex SHA-256 of the knocking client's certificate — what approval pins.
pub fingerprint: String,
/// Seconds since the (most recent) knock.
pub age_secs: u64,
}
/// Pending knocks older than this are dropped (the device retries; a stale entry shouldn't be
/// approvable days later when the operator no longer remembers the context).
const PENDING_TTL: Duration = Duration::from_secs(10 * 60);
/// Cap on the pending list — a LAN scanner must not grow it unboundedly. Oldest entries drop.
const PENDING_CAP: usize = 32;
/// Shared native-pairing state: the arming PIN window + the persistent trust store + the
/// pending-approval queue.
pub struct NativePairing { pub struct NativePairing {
arm: Mutex<Armed>, arm: Mutex<Armed>,
paired: Mutex<PairedState>, paired: Mutex<PairedState>,
pending: Mutex<PendingState>,
} }
/// A snapshot for the management API / web console. /// A snapshot for the management API / web console.
@@ -92,6 +129,48 @@ fn random_pin() -> String {
format!("{:04}", rand::thread_rng().gen_range(0..10_000u32)) format!("{:04}", rand::thread_rng().gen_range(0..10_000u32))
} }
/// Sanitize a client-supplied device name before it's stored, listed, or logged. The name comes
/// straight off the wire (the `Hello`/`PairRequest` of an *unpaired* device), so it's untrusted: a
/// hostile LAN device could embed terminal escapes / control characters (log + console injection) or
/// bidi overrides (`U+202E` etc.) to make a malicious device *look* like a trusted one in the
/// approval UI. Strip C0/C1 controls and Unicode bidi/format controls, collapse whitespace, trim, and
/// cap the length; an empty/all-control name falls back to a fingerprint-derived label.
pub(crate) fn sanitize_device_name(name: &str, fp_hex: &str) -> String {
let cleaned: String = name
.chars()
.map(|c| if c == '\t' || c == '\n' { ' ' } else { c })
.filter(|&c| {
!c.is_control()
// Bidi/format controls that could spoof or reorder the displayed name.
&& !('\u{202A}'..='\u{202E}').contains(&c) // LRE..RLO/PDF
&& !('\u{2066}'..='\u{2069}').contains(&c) // LRI..PDI
&& c != '\u{200E}' // LRM
&& c != '\u{200F}' // RLM
&& c != '\u{061C}' // ALM
&& c != '\u{FEFF}' // BOM / zero-width no-break space
})
.collect();
// Collapse internal whitespace runs, trim, cap at the wire limit.
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
let mut trimmed = collapsed.as_str();
while trimmed.len() > NAME_MAX {
let mut cut = NAME_MAX;
while !trimmed.is_char_boundary(cut) {
cut -= 1;
}
trimmed = &trimmed[..cut];
}
let trimmed = trimmed.trim();
if trimmed.is_empty() {
format!("device {}", &fp_hex[..8.min(fp_hex.len())])
} else {
trimmed.to_string()
}
}
/// Max stored device-name length (matches the `Hello` wire cap, `quic::HELLO_NAME_MAX`).
const NAME_MAX: usize = 64;
impl NativePairing { impl NativePairing {
/// Load the trust store. `store_path = None` uses the default config path. If `arm_at_start` /// Load the trust store. `store_path = None` uses the default config path. If `arm_at_start`
/// (the CLI `--allow-pairing`/`--require-pairing` flags), arm immediately with `fixed_pin` /// (the CLI `--allow-pairing`/`--require-pairing` flags), arm immediately with `fixed_pin`
@@ -117,6 +196,7 @@ impl NativePairing {
Ok(NativePairing { Ok(NativePairing {
arm: Mutex::new(arm), arm: Mutex::new(arm),
paired: Mutex::new(PairedState { path, clients }), paired: Mutex::new(PairedState { path, clients }),
pending: Mutex::new(PendingState::default()),
}) })
} }
@@ -172,15 +252,33 @@ impl NativePairing {
self.paired.lock().unwrap().clients.contains(fp_hex) self.paired.lock().unwrap().clients.contains(fp_hex)
} }
/// Record a successful pairing (re-pairing the same fingerprint just updates the name). /// Record a successful pairing (re-pairing the same fingerprint just updates the name
/// matched case-insensitively, like every other fingerprint comparison here). The name is
/// sanitized (untrusted). On a persist failure the in-memory store is rolled back so it never
/// diverges from disk. Also clears any pending knock for this fingerprint (it's now paired).
pub fn add(&self, name: &str, fp_hex: &str) -> Result<()> { pub fn add(&self, name: &str, fp_hex: &str) -> Result<()> {
let name = sanitize_device_name(name, fp_hex);
{
let mut p = self.paired.lock().unwrap(); let mut p = self.paired.lock().unwrap();
p.clients.clients.retain(|c| c.fingerprint != fp_hex); let snapshot = p.clients.clients.clone(); // restore on a failed save
p.clients
.clients
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
p.clients.clients.push(PairedClient { p.clients.clients.push(PairedClient {
name: name.to_string(), name,
fingerprint: fp_hex.to_string(), fingerprint: fp_hex.to_string(),
}); });
save(&p) if let Err(e) = save(&p) {
p.clients.clients = snapshot;
return Err(e);
}
}
// A device that knocked and is now paired shouldn't linger in the approval list.
let mut pending = self.pending.lock().unwrap();
pending
.items
.retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex));
Ok(())
} }
/// The paired clients (for the management API's device list). /// The paired clients (for the management API's device list).
@@ -188,19 +286,122 @@ impl NativePairing {
self.paired.lock().unwrap().clients.clients.clone() self.paired.lock().unwrap().clients.clients.clone()
} }
/// Remove a paired client by fingerprint. Returns whether one was removed. /// Remove a paired client by fingerprint. Returns whether one was removed. On a persist
/// failure the in-memory store is rolled back (it never diverges from disk).
pub fn remove(&self, fp_hex: &str) -> Result<bool> { pub fn remove(&self, fp_hex: &str) -> Result<bool> {
let mut p = self.paired.lock().unwrap(); let mut p = self.paired.lock().unwrap();
let before = p.clients.clients.len(); let before = p.clients.clients.len();
let snapshot = p.clients.clients.clone();
p.clients p.clients
.clients .clients
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex)); .retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
let removed = p.clients.clients.len() != before; let removed = p.clients.clients.len() != before;
if removed { if removed {
save(&p)?; if let Err(e) = save(&p) {
p.clients.clients = snapshot;
return Err(e);
}
} }
Ok(removed) Ok(removed)
} }
// -- Delegated approval (roadmap §8b-1) --------------------------------
/// Drop expired pending knocks (called under the lock, mirroring [`Self::expire`]).
fn expire_pending(pending: &mut PendingState) {
pending
.items
.retain(|p| p.requested_at.elapsed() < PENDING_TTL);
}
/// Record an unpaired device's knock for delegated approval. Re-knocks from the same
/// fingerprint refresh the existing entry in place (same id; a connect-retry loop must not spam
/// the list); a fresh fingerprint gets a new id, evicting the **least-recently-active** entry
/// past [`PENDING_CAP`]. The name is sanitized (untrusted; see [`sanitize_device_name`]).
pub fn note_pending(&self, name: &str, fp_hex: &str) {
let name = sanitize_device_name(name, fp_hex);
let mut pending = self.pending.lock().unwrap();
Self::expire_pending(&mut pending);
if let Some(p) = pending
.items
.iter_mut()
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
{
p.requested_at = Instant::now();
p.name = name;
return;
}
if pending.items.len() >= PENDING_CAP {
// Evict the least-recently-active entry. NOT index 0: the in-place refresh above means
// Vec order no longer tracks recency, so pick the minimum `requested_at` explicitly.
if let Some(at) = pending
.items
.iter()
.enumerate()
.min_by_key(|(_, p)| p.requested_at)
.map(|(i, _)| i)
{
pending.items.remove(at);
}
}
let id = pending.next_id;
pending.next_id = pending.next_id.wrapping_add(1);
pending.items.push(Pending {
id,
name,
fp_hex: fp_hex.to_string(),
requested_at: Instant::now(),
});
}
/// The devices currently awaiting approval (for the management API).
pub fn pending(&self) -> Vec<PendingRequest> {
let mut pending = self.pending.lock().unwrap();
Self::expire_pending(&mut pending);
pending
.items
.iter()
.map(|p| PendingRequest {
id: p.id,
name: p.name.clone(),
fingerprint: p.fp_hex.clone(),
age_secs: p.requested_at.elapsed().as_secs(),
})
.collect()
}
/// Approve a pending knock: pair its fingerprint (under `name_override` if the operator
/// labeled it, else the knock's own name) and drop it from the queue. `Ok(None)` = no such
/// (or expired) id.
pub fn approve_pending(
&self,
id: u32,
name_override: Option<&str>,
) -> Result<Option<PairedClient>> {
let entry = {
let mut pending = self.pending.lock().unwrap();
Self::expire_pending(&mut pending);
let Some(at) = pending.items.iter().position(|p| p.id == id) else {
return Ok(None);
};
pending.items.remove(at)
}; // pending lock released — add() takes the paired lock
let name = name_override.unwrap_or(&entry.name);
self.add(name, &entry.fp_hex)?;
Ok(Some(PairedClient {
name: name.to_string(),
fingerprint: entry.fp_hex,
}))
}
/// Deny (drop) a pending knock. Returns whether one was removed. The device's next knock
/// re-creates an entry — deny is "not now", not a blocklist.
pub fn deny_pending(&self, id: u32) -> bool {
let mut pending = self.pending.lock().unwrap();
let before = pending.items.len();
pending.items.retain(|p| p.id != id);
pending.items.len() != before
}
} }
#[cfg(test)] #[cfg(test)]
@@ -250,6 +451,101 @@ mod tests {
let _ = std::fs::remove_file(&p); let _ = std::fs::remove_file(&p);
} }
#[test]
fn pending_knock_approve_and_deny() {
let p = temp();
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
assert!(np.pending().is_empty());
// A knock appears; a re-knock from the same fingerprint refreshes (same id, new name)
// instead of duplicating.
np.note_pending("device aa11", "AA11");
np.note_pending("Bedroom TV", "aa11");
let pend = np.pending();
assert_eq!(pend.len(), 1, "re-knock dedups by fingerprint");
assert_eq!(pend[0].name, "Bedroom TV");
let id = pend[0].id;
// Deny drops it without pairing; the next knock gets a fresh id.
assert!(np.deny_pending(id));
assert!(!np.deny_pending(id));
assert!(np.pending().is_empty());
assert!(!np.is_paired("aa11"));
// Approve pairs the fingerprint (operator label wins) and clears the entry.
np.note_pending("device bb22", "BB22");
let id = np.pending()[0].id;
assert!(
np.approve_pending(9999, None).unwrap().is_none(),
"unknown id"
);
let client = np
.approve_pending(id, Some("Living Room"))
.unwrap()
.unwrap();
assert_eq!(client.name, "Living Room");
assert!(np.is_paired("bb22"), "approval pins the fingerprint");
assert!(np.pending().is_empty());
assert_eq!(np.list()[0].name, "Living Room");
// The cap evicts the oldest knock.
for i in 0..(PENDING_CAP + 3) {
np.note_pending("flood", &format!("f{i:03}"));
}
let pend = np.pending();
assert_eq!(pend.len(), PENDING_CAP);
assert_eq!(pend[0].fingerprint, "f003", "oldest entries evicted first");
let _ = std::fs::remove_file(&p);
}
#[test]
fn sanitize_strips_control_and_bidi() {
// ANSI escape + newline + a bidi override that could spoof the displayed name.
let dirty = "\u{1b}]0;evil\u{07}Good\nDevice\u{202E}xfp";
let clean = sanitize_device_name(dirty, "deadbeef00");
assert!(!clean.contains('\u{1b}') && !clean.contains('\n') && !clean.contains('\u{202E}'));
// ESC dropped (']' survives), BEL dropped, '\n'→space (Good Device), RLO dropped (no space).
assert_eq!(clean, "]0;evilGood Devicexfp");
// All-control / empty → fingerprint-derived fallback.
assert_eq!(
sanitize_device_name("\u{1b}\u{07}", "deadbeef00"),
"device deadbeef"
);
assert_eq!(sanitize_device_name(" ", "abc"), "device abc");
// Over-long names cap at a char boundary.
assert!(sanitize_device_name(&"x".repeat(200), "ab").len() <= 64);
}
#[test]
fn pairing_clears_a_pending_knock() {
let p = temp();
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
np.note_pending("Knocker", "cc44");
assert_eq!(np.pending().len(), 1);
// Pairing the same fingerprint (e.g. via the PIN ceremony) drops the stale pending entry.
np.add("Knocker", "CC44").unwrap();
assert!(
np.pending().is_empty(),
"a now-paired device must leave the approval list"
);
assert!(np.is_paired("cc44"));
let _ = std::fs::remove_file(&p);
}
#[test]
fn add_replaces_case_insensitively() {
let p = temp();
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
np.add("First", "AB12").unwrap();
np.add("Second", "ab12").unwrap(); // same device, different hex case
assert_eq!(np.list().len(), 1, "re-add must replace, not duplicate");
assert_eq!(np.list()[0].name, "Second");
let _ = std::fs::remove_file(&p);
}
#[test] #[test]
fn cli_flag_arms_with_no_expiry() { fn cli_flag_arms_with_no_expiry() {
let p = temp(); let p = temp();
+22 -4
View File
@@ -3,9 +3,10 @@ title: Pairing & Trust
description: How a client and host establish trust — PIN pairing once, pinned reconnects after. description: How a client and host establish trust — PIN pairing once, pinned reconnects after.
--- ---
punktfunk has no accounts and no cloud. Trust is established directly between a client and a host, on punktfunk has no accounts and no cloud. Trust is established directly between a client and a host,
your network, with a one-time **PIN pairing**. After that, the device reconnects automatically on a on your network, with a one-time pairing — either an **approval click in the host's console** or a
pinned cryptographic identity. **PIN ceremony**. After that, the device reconnects automatically on a pinned cryptographic
identity.
## How it works ## How it works
@@ -17,7 +18,24 @@ pinned cryptographic identity.
- After pairing, the host stores the client's identity in its allow-list, and the client stores the - After pairing, the host stores the client's identity in its allow-list, and the client stores the
host's fingerprint. Reconnects are automatic — no PIN. host's fingerprint. Reconnects are automatic — no PIN.
## Arming pairing on the host ## Approving a device from the console (no PIN)
The fastest way to admit a new device: just **try to connect** from it. On a pairing-required host,
the attempt shows up in the web console's Pairing page under **Waiting for approval** — with the
device's name and identity fingerprint. Click **Approve** (and optionally give it a label like
"Living Room TV"), and the device is paired on the spot: its next connect goes straight through. No
PIN to read or type.
**Deny** just dismisses the request (the device can knock again later — it's "not now", not a
blocklist). Requests expire on their own after a few minutes.
This works because approval happens on the host's authenticated management surface — only someone
with console access can admit a device.
## Pairing with a PIN
The PIN ceremony is the other path — useful for the *first* device (before the console has admitted
anything) or when you're at the client and the console isn't handy.
Pairing has to be **armed** on the host before a client can pair (so a random device can't pair Pairing has to be **armed** on the host before a client can pair (so a random device can't pair
itself). Two ways: itself). Two ways:
+15 -16
View File
@@ -144,7 +144,7 @@ mostly-mechanical port. Recommended start: **Phase 0** — capture an existing m
stack end to end; **Phase 1** wires SudoVDA for the native-resolution output. Deferred only because stack end to end; **Phase 1** wires SudoVDA for the native-resolution output. Deferred only because
it's unbuildable on the Linux dev box; the trait boundaries are already in the right places. it's unbuildable on the Linux dev box; the trait boundaries are already in the right places.
## 8. Pairing & trust hardening *(next)* ## 8. Pairing & trust hardening *(§8a + §8b-1 done; §8b-2 next)*
The unified host + web-console pairing (arm a window → display the host PIN → user enters it on the The unified host + web-console pairing (arm a window → display the host PIN → user enters it on the
client) is built and live. Two changes harden it from "works" to "secure by default": client) is built and live. Two changes harden it from "works" to "secure by default":
@@ -154,24 +154,23 @@ client) is built and live. Two changes harden it from "works" to "secure by defa
is via the SPAKE2 PIN ceremony (one online guess, no offline attack) armed from the web console. is via the SPAKE2 PIN ceremony (one online guess, no offline attack) armed from the web console.
Validated live: unpaired → "this host requires pairing", then web-armed PIN → "client trusted". Validated live: unpaired → "this host requires pairing", then web-armed PIN → "client trusted".
Deployed to the dev box + Bazzite. Deployed to the dev box + Bazzite.
- **Delegated pairing approval** *(next — the ergonomic enabler for "mandatory": pair a device - ✅ **§8b-1 Delegated approval via the console — done (2026-06-12)** *(the ergonomic enabler for
without fetching the host PIN out of band).* Target flow: "mandatory": pair a device without fetching the host PIN out of band).* An identified-but-unpaired
1. Device A is already paired (authenticated) to Host X. device that knocks on a pairing-required host is held as a **pending request** in `NativePairing`
2. The user tries to connect Device B to Host X. (in-memory, deduped by fingerprint, 32-entry cap, 10-min expiry — a LAN scanner can't grow it,
3. Host X surfaces a request: *"Allow Device B to pair with Host X?"* and an anonymous client with no certificate records nothing). The mgmt API gains
4. The user approves/denies; on approve, Host X admits Device B — binding B's certificate `GET /native/pending` + `POST /native/pending/{id}/approve` (optional `{name}` to relabel) +
fingerprint — with no PIN typed. `POST /native/pending/{id}/deny`; the web console's Pairing page shows a **Waiting for approval**
section (live-polling) with Approve/Deny — approve pins the fingerprint on the spot, no PIN.
Two buildable layers: The `Hello` carries an optional trailing **device name** (same back-compat pattern as
- **§8b-1 (host + web — achievable now):** an unpaired B that connects to an approval-enabled host compositor/gamepad/bitrate; `client-rs --name` sends it, fingerprint-derived label otherwise) so
is held as a **pending request** `{id, name, fingerprint, requested_at}` in `NativePairing` the pending list is human-readable. End-to-end tested (knock → pending → approve → same identity
instead of a flat reject; mgmt gains `GET /native/pending` + `POST /native/pending/{id}/{approve, streams) + unit/mgmt tests.
deny}`; the web console lists pending requests with Approve/Deny. The **operator approves from
the console** — delegated approval via the management surface.
- **§8b-2 (peer push — needs the client):** the host also pushes the pending request over a paired - **§8b-2 (peer push — needs the client):** the host also pushes the pending request over a paired
**Device A**'s live QUIC connection (a new control-plane message); A's app renders the prompt and **Device A**'s live QUIC connection (a new control-plane message); A's app renders the prompt and
replies approve/deny — the user's exact "Device A gets a notification" flow. The native/Apple UI replies approve/deny — the user's exact "Device A gets a notification" flow. The native/Apple UI
is a client-agent task. is a client-agent task. The Apple connector should also start sending the `Hello` device name
(needs a connect-ABI name parameter; the wire field is already live).
PIN pairing (§8a) stays the bootstrap — the first device, or when no approver is online. PIN pairing (§8a) stays the bootstrap — the first device, or when no approver is online.
+7
View File
@@ -29,6 +29,13 @@ All three appliances advertise over mDNS (`_punktfunk._udp`) and require PIN pai
## Progress log ## Progress log
### 2026-06-12 ### 2026-06-12
- **Delegated pairing approval (§8b-1)** — an unpaired device that tries to connect to a
pairing-required host now shows up as a **pending request** in the web console's Pairing page;
one click approves it (optionally relabeling) and pairs its certificate fingerprint — no PIN
fetched out of band. New mgmt endpoints (`/native/pending` + approve/deny), an in-memory pending
queue in `NativePairing` (fp-deduped, capped, 10-min expiry), and an optional **device name** in
the `Hello` (back-compat trailing field; `client-rs --name` sends it). End-to-end tested.
§8b-2 (approve from a paired device's own app) is the client-side follow-up.
- **CI + deployment landed** (see the [CI & Docker](/docs/ci) guide). Gitea Actions, three - **CI + deployment landed** (see the [CI & Docker](/docs/ci) guide). Gitea Actions, three
workflows: Rust workspace checks inside the new `punktfunk-rust-ci` builder image (Ubuntu 26.04, workflows: Rust workspace checks inside the new `punktfunk-rust-ci` builder image (Ubuntu 26.04,
full link-dep stack incl. a libcuda stub — 141/141 tests green in-container), web + docs-site full link-dep stack incl. a libcuda stub — 141/141 tests green in-container), web + docs-site
+225
View File
@@ -401,6 +401,184 @@
} }
} }
}, },
"/api/v1/native/pending": {
"get": {
"tags": [
"native"
],
"summary": "List devices awaiting pairing approval",
"description": "Unpaired devices that tried to connect while the host requires pairing. Approve one to pair\nit without a PIN (delegated approval); entries expire after ~10 minutes.",
"operationId": "listPendingDevices",
"responses": {
"200": {
"description": "Devices awaiting approval (empty when none, or when the native host is not enabled)",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PendingDevice"
}
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/native/pending/{id}/approve": {
"post": {
"tags": [
"native"
],
"summary": "Approve a pending device",
"description": "Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally\nrelabel it via the body; send `{}` to keep the name it knocked with.",
"operationId": "approvePendingDevice",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Pending-request id from the pending list",
"required": true,
"schema": {
"type": "integer",
"format": "int32",
"minimum": 0
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApprovePending"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Device paired",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NativeClient"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No pending request with that id (expired?)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the trust store",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Native host not enabled",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/native/pending/{id}/deny": {
"post": {
"tags": [
"native"
],
"summary": "Deny a pending device",
"description": "Drops the request. Not a blocklist — the device's next attempt knocks again.",
"operationId": "denyPendingDevice",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Pending-request id from the pending list",
"required": true,
"schema": {
"type": "integer",
"format": "int32",
"minimum": 0
}
}
],
"responses": {
"204": {
"description": "Request dropped"
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No pending request with that id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Native host not enabled",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/pair": { "/api/v1/pair": {
"get": { "get": {
"tags": [ "tags": [
@@ -623,6 +801,20 @@
} }
} }
}, },
"ApprovePending": {
"type": "object",
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name.",
"properties": {
"name": {
"type": [
"string",
"null"
],
"description": "Operator-chosen label for the device (defaults to the name it knocked with).",
"example": "Living Room TV"
}
}
},
"ArmNativePairing": { "ArmNativePairing": {
"type": "object", "type": "object",
"description": "Arm-native-pairing request body.", "description": "Arm-native-pairing request body.",
@@ -860,6 +1052,39 @@
} }
} }
}, },
"PendingDevice": {
"type": "object",
"description": "An unpaired device that tried to connect while the host requires pairing — awaiting\n**delegated approval** (approve it here instead of fetching the host PIN out of band).",
"required": [
"id",
"name",
"fingerprint",
"age_secs"
],
"properties": {
"age_secs": {
"type": "integer",
"format": "int64",
"description": "Seconds since the device last knocked.",
"minimum": 0
},
"fingerprint": {
"type": "string",
"description": "Hex SHA-256 of the device's certificate — what approval pins."
},
"id": {
"type": "integer",
"format": "int32",
"description": "Id to address approve/deny (per-process; entries expire after ~10 minutes).",
"minimum": 0
},
"name": {
"type": "string",
"description": "Best-effort device label (the client's own name, else fingerprint-derived).",
"example": "Enrico's MacBook"
}
}
},
"PortMap": { "PortMap": {
"type": "object", "type": "object",
"description": "Every port a client integration may need (Moonlight derives the stream ports from the\nHTTP base; a control pane should not have to).", "description": "Every port a client integration may need (Moonlight derives the stream ports from the\nHTTP base; a control pane should not have to).",
+6
View File
@@ -146,6 +146,12 @@
// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`. // `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
#define MAX_DATAGRAM_BYTES 2048 #define MAX_DATAGRAM_BYTES 2048
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on
// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway).
#define HELLO_NAME_MAX 64
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC) #if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`Reconfigure`] (first byte after the magic). // Type byte of [`Reconfigure`] (first byte after the magic).
#define MSG_RECONFIGURE 1 #define MSG_RECONFIGURE 1
+8
View File
@@ -58,6 +58,14 @@
"pairing_native_devices": "Gekoppelte Geräte", "pairing_native_devices": "Gekoppelte Geräte",
"pairing_native_empty": "Noch keine Geräte gekoppelt.", "pairing_native_empty": "Noch keine Geräte gekoppelt.",
"pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.", "pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
"pairing_pending_title": "Warten auf Freigabe",
"pairing_pending_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.",
"pairing_pending_approve": "Freigeben",
"pairing_pending_deny": "Ablehnen",
"pairing_pending_name_prompt": "Gerät benennen:",
"pairing_pending_age_just_now": "gerade eben",
"pairing_pending_age_secs": "vor {s}s",
"pairing_pending_age_mins": "vor {min} min",
"pairing_moonlight_title": "Moonlight-Kopplung (GameStream)", "pairing_moonlight_title": "Moonlight-Kopplung (GameStream)",
"settings_title": "Einstellungen", "settings_title": "Einstellungen",
"settings_token_label": "API-Token", "settings_token_label": "API-Token",
+8
View File
@@ -58,6 +58,14 @@
"pairing_native_devices": "Paired devices", "pairing_native_devices": "Paired devices",
"pairing_native_empty": "No devices paired yet.", "pairing_native_empty": "No devices paired yet.",
"pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.", "pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.",
"pairing_pending_title": "Waiting for approval",
"pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.",
"pairing_pending_approve": "Approve",
"pairing_pending_deny": "Deny",
"pairing_pending_name_prompt": "Name this device:",
"pairing_pending_age_just_now": "just now",
"pairing_pending_age_secs": "{s}s ago",
"pairing_pending_age_mins": "{min} min ago",
"pairing_moonlight_title": "Moonlight (GameStream) pairing", "pairing_moonlight_title": "Moonlight (GameStream) pairing",
"settings_title": "Settings", "settings_title": "Settings",
"settings_token_label": "API token", "settings_token_label": "API token",
+197 -56
View File
@@ -1,21 +1,33 @@
import { useState } from 'react' import { useState } from "react";
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from "@tanstack/react-query";
import { KeyRound, CheckCircle2, Smartphone, Timer, Trash2 } from 'lucide-react' import {
KeyRound,
CheckCircle2,
Smartphone,
Timer,
Trash2,
UserPlus,
X,
} from "lucide-react";
import { import {
useGetNativePairing, useGetNativePairing,
useArmNativePairing, useArmNativePairing,
useDisarmNativePairing, useDisarmNativePairing,
useListNativeClients, useListNativeClients,
useUnpairNativeClient, useUnpairNativeClient,
useListPendingDevices,
useApprovePendingDevice,
useDenyPendingDevice,
getGetNativePairingQueryKey, getGetNativePairingQueryKey,
getListNativeClientsQueryKey, getListNativeClientsQueryKey,
} from '@/api/gen/native/native' getListPendingDevicesQueryKey,
} from "@/api/gen/native/native";
import { import {
useGetPairingStatus, useGetPairingStatus,
useSubmitPairingPin, useSubmitPairingPin,
getGetPairingStatusQueryKey, getGetPairingStatusQueryKey,
} from '@/api/gen/pairing/pairing' } from "@/api/gen/pairing/pairing";
import { import {
Table, Table,
TableBody, TableBody,
@@ -23,49 +35,151 @@ import {
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { Label } from '@/components/ui/label' import { Label } from "@/components/ui/label";
import { QueryState } from '@/components/query-state' import { QueryState } from "@/components/query-state";
import { m } from '@/paraglide/messages' import { m } from "@/paraglide/messages";
import { useLocale } from '@/lib/i18n' import { useLocale } from "@/lib/i18n";
export const Route = createFileRoute('/pairing')({ component: PairingPage }) export const Route = createFileRoute("/pairing")({ component: PairingPage });
/** Seconds → `m:ss`. */ /** Seconds → `m:ss`. */
function fmtTime(secs: number): string { function fmtTime(secs: number): string {
const s = Math.max(0, Math.floor(secs)) const s = Math.max(0, Math.floor(secs));
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}` return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
} }
function PairingPage() { function PairingPage() {
useLocale() useLocale();
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-2xl font-semibold">{m.pairing_title()}</h1> <h1 className="text-2xl font-semibold">{m.pairing_title()}</h1>
<PendingDevices />
<NativePairingCard /> <NativePairingCard />
<NativeDevices /> <NativeDevices />
<MoonlightPairingCard /> <MoonlightPairingCard />
</div> </div>
) );
}
/** Seconds since a knock → a short relative label. */
function fmtAge(secs: number): string {
if (secs < 10) return m.pairing_pending_age_just_now();
if (secs < 60) return m.pairing_pending_age_secs({ s: Math.floor(secs) });
return m.pairing_pending_age_mins({ min: Math.floor(secs / 60) });
}
/**
* Devices awaiting delegated approval: an unpaired device that tried to connect shows up here,
* and Approve pairs it on the spot — no PIN fetched out of band. Renders nothing while empty
* (the common case); polls so a knock appears while the operator is looking at the page.
*/
function PendingDevices() {
const qc = useQueryClient();
const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } });
const approve = useApprovePendingDevice();
const deny = useDenyPendingDevice();
const rows = pending.data ?? [];
// Stay out of the way when there's nothing pending and the fetch is healthy — but DON'T swallow
// a real error (a 500 etc.); fall through to QueryState below so it surfaces like every other
// section. (A 401 is handled globally by the fetcher's redirect-to-login.)
if (rows.length === 0 && !pending.error) return null;
const refresh = () => {
qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() });
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
};
const onApprove = (id: number, currentName: string) => {
const name = prompt(m.pairing_pending_name_prompt(), currentName);
if (name == null) return; // operator cancelled
approve.mutate(
{ id, data: { name: name.trim() ? name.trim() : null } },
{ onSuccess: refresh },
);
};
return (
<div className="space-y-2">
<h2 className="flex items-center gap-2 text-lg font-medium">
<UserPlus className="size-4" />
{m.pairing_pending_title()}
</h2>
<p className="text-sm text-muted-foreground">
{m.pairing_pending_desc()}
</p>
<QueryState
isLoading={pending.isLoading}
error={pending.error}
refetch={pending.refetch}
>
<Card>
<CardContent className="p-0">
<Table>
<TableBody>
{rows.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{p.fingerprint.slice(0, 16)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{fmtAge(p.age_secs)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
disabled={approve.isPending || deny.isPending}
onClick={() => onApprove(p.id, p.name)}
>
{m.pairing_pending_approve()}
</Button>
<Button
size="sm"
variant="ghost"
aria-label={m.pairing_pending_deny()}
disabled={approve.isPending || deny.isPending}
onClick={() =>
deny.mutate({ id: p.id }, { onSuccess: refresh })
}
>
<X className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</QueryState>
</div>
);
} }
/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */ /** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */
function NativePairingCard() { function NativePairingCard() {
const qc = useQueryClient() const qc = useQueryClient();
// Poll fast while armed (live countdown), slow otherwise. // Poll fast while armed (live countdown), slow otherwise.
const status = useGetNativePairing({ const status = useGetNativePairing({
query: { refetchInterval: (q) => (q.state.data?.armed ? 1_000 : 4_000) }, query: { refetchInterval: (q) => (q.state.data?.armed ? 1_000 : 4_000) },
}) });
const arm = useArmNativePairing() const arm = useArmNativePairing();
const disarm = useDisarmNativePairing() const disarm = useDisarmNativePairing();
const d = status.data const d = status.data;
const refresh = () => qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() }) const refresh = () =>
qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() });
return ( return (
<QueryState isLoading={status.isLoading} error={status.error} refetch={status.refetch}> <QueryState
isLoading={status.isLoading}
error={status.error}
refetch={status.refetch}
>
<Card className="max-w-md"> <Card className="max-w-md">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -75,7 +189,9 @@ function NativePairingCard() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{!d?.enabled ? ( {!d?.enabled ? (
<p className="text-sm text-muted-foreground">{m.pairing_native_disabled()}</p> <p className="text-sm text-muted-foreground">
{m.pairing_native_disabled()}
</p>
) : d.armed && d.pin ? ( ) : d.armed && d.pin ? (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm">{m.pairing_native_enter()}</p> <p className="text-sm">{m.pairing_native_enter()}</p>
@@ -99,10 +215,17 @@ function NativePairingCard() {
</div> </div>
) : ( ) : (
<> <>
<p className="text-sm text-muted-foreground">{m.pairing_native_desc()}</p> <p className="text-sm text-muted-foreground">
{m.pairing_native_desc()}
</p>
<Button <Button
disabled={arm.isPending} disabled={arm.isPending}
onClick={() => arm.mutate({ data: { ttl_secs: 120 } }, { onSuccess: refresh })} onClick={() =>
arm.mutate(
{ data: { ttl_secs: 120 } },
{ onSuccess: refresh },
)
}
> >
<KeyRound className="size-4" /> <KeyRound className="size-4" />
{m.pairing_native_arm()} {m.pairing_native_arm()}
@@ -112,28 +235,35 @@ function NativePairingCard() {
</CardContent> </CardContent>
</Card> </Card>
</QueryState> </QueryState>
) );
} }
/** The paired native (punktfunk/1) devices, with unpair. */ /** The paired native (punktfunk/1) devices, with unpair. */
function NativeDevices() { function NativeDevices() {
const qc = useQueryClient() const qc = useQueryClient();
const clients = useListNativeClients() const clients = useListNativeClients();
const unpair = useUnpairNativeClient() const unpair = useUnpairNativeClient();
const rows = clients.data ?? [] const rows = clients.data ?? [];
const onUnpair = (fingerprint: string) => { const onUnpair = (fingerprint: string) => {
if (!confirm(m.pairing_native_unpair_confirm())) return if (!confirm(m.pairing_native_unpair_confirm())) return;
unpair.mutate( unpair.mutate(
{ fingerprint }, { fingerprint },
{ onSuccess: () => qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }) }, {
) onSuccess: () =>
} qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }),
},
);
};
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2> <h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
<QueryState isLoading={clients.isLoading} error={clients.error} refetch={clients.refetch}> <QueryState
isLoading={clients.isLoading}
error={clients.error}
refetch={clients.refetch}
>
{rows.length === 0 ? ( {rows.length === 0 ? (
<Card> <Card>
<CardContent className="p-6 text-center text-sm text-muted-foreground"> <CardContent className="p-6 text-center text-sm text-muted-foreground">
@@ -154,7 +284,9 @@ function NativeDevices() {
<TableBody> <TableBody>
{rows.map((c) => ( {rows.map((c) => (
<TableRow key={c.fingerprint}> <TableRow key={c.fingerprint}>
<TableCell className="font-medium">{c.name || '—'}</TableCell> <TableCell className="font-medium">
{c.name || "—"}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground"> <TableCell className="font-mono text-xs text-muted-foreground">
{c.fingerprint.slice(0, 16)} {c.fingerprint.slice(0, 16)}
</TableCell> </TableCell>
@@ -178,32 +310,36 @@ function NativeDevices() {
)} )}
</QueryState> </QueryState>
</div> </div>
) );
} }
/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */ /** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */
function MoonlightPairingCard() { function MoonlightPairingCard() {
const qc = useQueryClient() const qc = useQueryClient();
const [pin, setPin] = useState('') const [pin, setPin] = useState("");
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } }) const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } });
const submit = useSubmitPairingPin() const submit = useSubmitPairingPin();
const pending = pairing.data?.pin_pending ?? false const pending = pairing.data?.pin_pending ?? false;
const onSubmit = (e: React.FormEvent) => { const onSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault();
submit.mutate( submit.mutate(
{ data: { pin } }, { data: { pin } },
{ {
onSuccess: () => { onSuccess: () => {
setPin('') setPin("");
qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() }) qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() });
}, },
}, },
) );
} };
return ( return (
<QueryState isLoading={pairing.isLoading} error={pairing.error} refetch={pairing.refetch}> <QueryState
isLoading={pairing.isLoading}
error={pairing.error}
refetch={pairing.refetch}
>
<Card className="max-w-md"> <Card className="max-w-md">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -225,12 +361,15 @@ function MoonlightPairingCard() {
autoComplete="off" autoComplete="off"
maxLength={8} maxLength={8}
value={pin} value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, ''))} onChange={(e) => setPin(e.target.value.replace(/\D/g, ""))}
placeholder="0000" placeholder="0000"
className="font-mono text-lg tracking-widest" className="font-mono text-lg tracking-widest"
/> />
</div> </div>
<Button type="submit" disabled={pin.length < 4 || submit.isPending}> <Button
type="submit"
disabled={pin.length < 4 || submit.isPending}
>
{m.pairing_submit()} {m.pairing_submit()}
</Button> </Button>
{submit.isSuccess && ( {submit.isSuccess && (
@@ -239,11 +378,13 @@ function MoonlightPairingCard() {
{m.pairing_success()} {m.pairing_success()}
</p> </p>
)} )}
{submit.isError && <p className="text-sm text-destructive">{m.pairing_failed()}</p>} {submit.isError && (
<p className="text-sm text-destructive">{m.pairing_failed()}</p>
)}
</form> </form>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</QueryState> </QueryState>
) );
} }