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