refactor(native-pairing): extract shared on-demand arming state
Groundwork for web-UI-driven native (punktfunk/1) pairing. Replaces m3's fixed startup PIN + local paired store with a shared `NativePairing` (new module): arm-on-demand with a fresh, time-limited PIN (`arm(ttl)`), `current_pin()` read per ceremony so a lapsed window stops pairing, plus the trust store (list/add/ remove/is_paired) and a `status()` snapshot. The management API (next commit) and the QUIC accept loop share one handle. CLI `--allow-pairing`/`--require-pairing` still arm at startup (no expiry, PIN logged) — back-compat. m3 pairing ceremony + gate and the C-ABI roundtrip stay green; new unit tests for arm/expire/pair. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -67,64 +67,13 @@ pub struct M3Options {
|
|||||||
pub paired_store: Option<std::path::PathBuf>,
|
pub paired_store: Option<std::path::PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The host's paired punktfunk/1 clients: `~/.config/punktfunk/punktfunk1-paired.json`.
|
/// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API.
|
||||||
/// (Separate from GameStream pairing, which has its own store and ceremony.)
|
use crate::native_pairing::NativePairing;
|
||||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
|
||||||
struct PairedClients {
|
|
||||||
clients: Vec<PairedClient>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
|
||||||
struct PairedClient {
|
|
||||||
name: String,
|
|
||||||
/// Hex SHA-256 of the client's certificate.
|
|
||||||
fingerprint: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The store plus where it persists (the path is injectable for tests).
|
|
||||||
struct PairedState {
|
|
||||||
path: std::path::PathBuf,
|
|
||||||
clients: PairedClients,
|
|
||||||
}
|
|
||||||
|
|
||||||
type PairedStore = Arc<std::sync::Mutex<PairedState>>;
|
|
||||||
|
|
||||||
fn paired_path() -> Result<std::path::PathBuf> {
|
|
||||||
let home = std::env::var("HOME").context("HOME unset")?;
|
|
||||||
Ok(std::path::PathBuf::from(home).join(".config/punktfunk/punktfunk1-paired.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_paired(path: &std::path::Path) -> PairedClients {
|
|
||||||
std::fs::read(path)
|
|
||||||
.ok()
|
|
||||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_paired(state: &PairedState) -> Result<()> {
|
|
||||||
if let Some(dir) = state.path.parent() {
|
|
||||||
std::fs::create_dir_all(dir)?;
|
|
||||||
}
|
|
||||||
// Atomic replace: a crash/full-disk mid-write must not truncate the trust store (which
|
|
||||||
// would silently lock out every paired client on a --require-pairing host). Write a
|
|
||||||
// temp beside the target, then rename.
|
|
||||||
let tmp = state.path.with_extension("json.tmp");
|
|
||||||
std::fs::write(&tmp, serde_json::to_vec_pretty(&state.clients)?)?;
|
|
||||||
std::fs::rename(&tmp, &state.path)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimum spacing between accepted pairing ceremonies (bounds online PIN guessing — with
|
/// Minimum spacing between accepted pairing ceremonies (bounds online PIN guessing — with
|
||||||
/// SPAKE2 an attacker already gets only one guess per ceremony; this caps the rate).
|
/// SPAKE2 an attacker already gets only one guess per ceremony; this caps the rate).
|
||||||
const PAIRING_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(2);
|
const PAIRING_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
|
||||||
impl PairedClients {
|
|
||||||
fn contains(&self, fp: &[u8; 32]) -> bool {
|
|
||||||
let hex = fingerprint_hex(fp);
|
|
||||||
self.clients.iter().any(|c| c.fingerprint == hex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deterministic test frame: `u32 LE index` then `data[i] = idx + i` (wrapping).
|
/// Deterministic test frame: `u32 LE index` then `data[i] = idx + i` (wrapping).
|
||||||
pub fn test_frame(idx: u32, len: usize) -> Vec<u8> {
|
pub fn test_frame(idx: u32, len: usize) -> Vec<u8> {
|
||||||
let mut d = vec![0u8; len];
|
let mut d = vec![0u8; len];
|
||||||
@@ -148,7 +97,14 @@ pub fn run(opts: M3Options) -> Result<()> {
|
|||||||
.enable_all()
|
.enable_all()
|
||||||
.build()
|
.build()
|
||||||
.context("tokio runtime")?;
|
.context("tokio runtime")?;
|
||||||
rt.block_on(serve(opts))
|
// Standalone CLI: arm at startup iff --allow-pairing/--require-pairing (back-compat — the PIN
|
||||||
|
// is logged). The unified `serve --native` path instead arms on demand via the management API.
|
||||||
|
let np = Arc::new(NativePairing::load_with(
|
||||||
|
opts.paired_store.clone(),
|
||||||
|
opts.pairing_pin.clone(),
|
||||||
|
opts.allow_pairing || opts.require_pairing,
|
||||||
|
)?);
|
||||||
|
rt.block_on(serve(opts, np))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fingerprint_hex(fp: &[u8; 32]) -> String {
|
fn fingerprint_hex(fp: &[u8; 32]) -> String {
|
||||||
@@ -159,7 +115,7 @@ fn fingerprint_hex(fp: &[u8; 32]) -> String {
|
|||||||
/// served one at a time (the virtual output + NVENC are single-tenant); a client that
|
/// served one at a time (the virtual output + NVENC are single-tenant); a client that
|
||||||
/// connects mid-session waits in the accept queue. A failed session logs and the loop
|
/// connects mid-session waits in the accept queue. A failed session logs and the loop
|
||||||
/// keeps serving — only endpoint-level failures are fatal.
|
/// keeps serving — only endpoint-level failures are fatal.
|
||||||
async fn serve(opts: M3Options) -> Result<()> {
|
async fn serve(opts: M3Options, np: Arc<NativePairing>) -> Result<()> {
|
||||||
let identity = crate::gamestream::cert::ServerIdentity::load_or_create()
|
let identity = crate::gamestream::cert::ServerIdentity::load_or_create()
|
||||||
.context("load host identity (~/.config/punktfunk)")?;
|
.context("load host identity (~/.config/punktfunk)")?;
|
||||||
let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem)
|
let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem)
|
||||||
@@ -188,32 +144,17 @@ async fn serve(opts: M3Options) -> Result<()> {
|
|||||||
// One virtual microphone for the whole host lifetime (see MicService): the client's mic uplink
|
// One virtual microphone for the whole host lifetime (see MicService): the client's mic uplink
|
||||||
// (0xCB) is Opus-decoded and fed into a persistent PipeWire Audio/Source host apps record from.
|
// (0xCB) is Opus-decoded and fed into a persistent PipeWire Audio/Source host apps record from.
|
||||||
let mic_service = MicService::start();
|
let mic_service = MicService::start();
|
||||||
let paired_at = match &opts.paired_store {
|
// Pairing state (arming PIN + trust store) is shared with the management API. If it was armed
|
||||||
Some(p) => p.clone(),
|
// at startup (the CLI flags), surface the PIN the headless operator reads from the log; the
|
||||||
None => paired_path()?,
|
// web console arms it on demand instead (a fresh, time-limited PIN).
|
||||||
};
|
let st = np.status();
|
||||||
let paired: PairedStore = Arc::new(std::sync::Mutex::new(PairedState {
|
if let Some(pin) = &st.pin {
|
||||||
clients: load_paired(&paired_at),
|
|
||||||
path: paired_at,
|
|
||||||
}));
|
|
||||||
// The arming PIN: one PIN for the whole pairing window (NOT per-ceremony), because the
|
|
||||||
// SPAKE2 client must know the PIN to build its first message — so the user has to read
|
|
||||||
// the PIN before connecting. Generated once when pairing is armed, displayed here.
|
|
||||||
let arming_pin = if opts.allow_pairing || opts.require_pairing {
|
|
||||||
let pin = opts.pairing_pin.clone().unwrap_or_else(|| {
|
|
||||||
use rand::Rng;
|
|
||||||
format!("{:04}", rand::thread_rng().gen_range(0..10_000u32))
|
|
||||||
});
|
|
||||||
let n = paired.lock().unwrap().clients.clients.len();
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
paired = n,
|
paired = st.paired_clients,
|
||||||
require = opts.require_pairing,
|
require = opts.require_pairing,
|
||||||
"PAIRING ARMED — enter this PIN on the client to pair: {pin}"
|
"PAIRING ARMED — enter this PIN on the client to pair: {pin}"
|
||||||
);
|
);
|
||||||
Some(pin)
|
}
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let last_pairing = std::sync::Mutex::new(None::<std::time::Instant>);
|
let last_pairing = std::sync::Mutex::new(None::<std::time::Instant>);
|
||||||
|
|
||||||
let mut served = 0u32;
|
let mut served = 0u32;
|
||||||
@@ -238,9 +179,8 @@ async fn serve(opts: M3Options) -> Result<()> {
|
|||||||
injector.sender(),
|
injector.sender(),
|
||||||
mic_service.sender(),
|
mic_service.sender(),
|
||||||
&fingerprint,
|
&fingerprint,
|
||||||
&paired,
|
&np,
|
||||||
&last_pairing,
|
&last_pairing,
|
||||||
arming_pin.as_deref(),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -281,7 +221,7 @@ async fn pair_ceremony(
|
|||||||
mut recv: quinn::RecvStream,
|
mut recv: quinn::RecvStream,
|
||||||
req: PairRequest,
|
req: PairRequest,
|
||||||
host_fp: &[u8; 32],
|
host_fp: &[u8; 32],
|
||||||
paired: &PairedStore,
|
np: &NativePairing,
|
||||||
pin: &str,
|
pin: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use punktfunk_core::quic::pake;
|
use punktfunk_core::quic::pake;
|
||||||
@@ -318,14 +258,7 @@ async fn pair_ceremony(
|
|||||||
let ok = pake::verify(&confirms.client, &proof.confirm);
|
let ok = pake::verify(&confirms.client, &proof.confirm);
|
||||||
|
|
||||||
if ok {
|
if ok {
|
||||||
let mut store = paired.lock().unwrap();
|
if let Err(e) = np.add(&req.name, &fingerprint_hex(&client_fp)) {
|
||||||
let hex = fingerprint_hex(&client_fp);
|
|
||||||
store.clients.clients.retain(|c| c.fingerprint != hex); // re-pair updates the name
|
|
||||||
store.clients.clients.push(PairedClient {
|
|
||||||
name: req.name.clone(),
|
|
||||||
fingerprint: hex,
|
|
||||||
});
|
|
||||||
if let Err(e) = save_paired(&store) {
|
|
||||||
tracing::error!(error = %format!("{e:#}"), "could not persist paired clients");
|
tracing::error!(error = %format!("{e:#}"), "could not persist paired clients");
|
||||||
}
|
}
|
||||||
tracing::info!(name = %req.name, "pairing complete — client trusted");
|
tracing::info!(name = %req.name, "pairing complete — client trusted");
|
||||||
@@ -356,9 +289,8 @@ async fn serve_session(
|
|||||||
inj_tx: std::sync::mpsc::Sender<InputEvent>,
|
inj_tx: std::sync::mpsc::Sender<InputEvent>,
|
||||||
mic_tx: std::sync::mpsc::Sender<Vec<u8>>,
|
mic_tx: std::sync::mpsc::Sender<Vec<u8>>,
|
||||||
host_fp: &[u8; 32],
|
host_fp: &[u8; 32],
|
||||||
paired: &PairedStore,
|
np: &NativePairing,
|
||||||
last_pairing: &std::sync::Mutex<Option<std::time::Instant>>,
|
last_pairing: &std::sync::Mutex<Option<std::time::Instant>>,
|
||||||
arming_pin: Option<&str>,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let peer = conn.remote_address();
|
let peer = conn.remote_address();
|
||||||
|
|
||||||
@@ -371,7 +303,10 @@ async fn serve_session(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow!("first message timeout"))??;
|
.map_err(|_| anyhow!("first message timeout"))??;
|
||||||
if let Ok(req) = PairRequest::decode(&first) {
|
if let Ok(req) = PairRequest::decode(&first) {
|
||||||
let pin = arming_pin.context("pairing not armed (start with --allow-pairing)")?;
|
// Read the live arming PIN per attempt, so a window that lapsed no longer pairs.
|
||||||
|
let pin = np
|
||||||
|
.current_pin()
|
||||||
|
.context("pairing not armed (arm it in the console, or start with --allow-pairing)")?;
|
||||||
{
|
{
|
||||||
let mut last = last_pairing.lock().unwrap();
|
let mut last = last_pairing.lock().unwrap();
|
||||||
if let Some(t) = *last {
|
if let Some(t) = *last {
|
||||||
@@ -382,7 +317,7 @@ async fn serve_session(
|
|||||||
}
|
}
|
||||||
*last = Some(std::time::Instant::now());
|
*last = Some(std::time::Instant::now());
|
||||||
}
|
}
|
||||||
return pair_ceremony(&conn, send, recv, req, host_fp, paired, pin).await;
|
return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let source = opts.source;
|
let source = opts.source;
|
||||||
@@ -397,7 +332,7 @@ async fn serve_session(
|
|||||||
);
|
);
|
||||||
if opts.require_pairing {
|
if opts.require_pairing {
|
||||||
let known = endpoint::peer_fingerprint(&conn)
|
let known = endpoint::peer_fingerprint(&conn)
|
||||||
.map(|fp| paired.lock().unwrap().clients.contains(&fp))
|
.map(|fp| np.is_paired(&fingerprint_hex(&fp)))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
known,
|
known,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ mod inject;
|
|||||||
mod m0;
|
mod m0;
|
||||||
mod m3;
|
mod m3;
|
||||||
mod mgmt;
|
mod mgmt;
|
||||||
|
mod native_pairing;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
mod pwinit;
|
mod pwinit;
|
||||||
mod vdisplay;
|
mod vdisplay;
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
//! Shared native (`punktfunk/1`) pairing state — the on-demand arming PIN (with expiry) plus the
|
||||||
|
//! persistent paired-clients store. One [`NativePairing`] handle is shared by the punktfunk/1 QUIC
|
||||||
|
//! accept loop ([`crate::m3`]) and the management API ([`crate::mgmt`]), so an operator can **arm
|
||||||
|
//! pairing and read the PIN from the web console** instead of the service log.
|
||||||
|
//!
|
||||||
|
//! The PIN direction is inherent to the SPAKE2 ceremony: the *host* mints the PIN and the *client*
|
||||||
|
//! enters it (the client needs it to build its first message). So the UI **displays** the PIN —
|
||||||
|
//! armed on demand for a short window — rather than accepting one.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// The host's paired punktfunk/1 clients: `~/.config/punktfunk/punktfunk1-paired.json`.
|
||||||
|
/// (Separate from GameStream pairing, which has its own store and ceremony.)
|
||||||
|
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct PairedClients {
|
||||||
|
pub clients: Vec<PairedClient>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct PairedClient {
|
||||||
|
pub name: String,
|
||||||
|
/// Hex SHA-256 of the client's certificate.
|
||||||
|
pub fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PairedClients {
|
||||||
|
fn contains(&self, fp_hex: &str) -> bool {
|
||||||
|
self.clients
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.fingerprint.eq_ignore_ascii_case(fp_hex))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PairedState {
|
||||||
|
path: PathBuf,
|
||||||
|
clients: PairedClients,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current arming window. `pin == None` ⇒ disarmed. `expires_at == None` ⇒ armed with no
|
||||||
|
/// expiry (the CLI `--allow-pairing` flag); `Some(t)` ⇒ a web-armed window that auto-disarms.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Armed {
|
||||||
|
pin: Option<String>,
|
||||||
|
expires_at: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared native-pairing state: the arming PIN window + the persistent trust store.
|
||||||
|
pub struct NativePairing {
|
||||||
|
arm: Mutex<Armed>,
|
||||||
|
paired: Mutex<PairedState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A snapshot for the management API / web console.
|
||||||
|
pub struct NativePairingStatus {
|
||||||
|
pub armed: bool,
|
||||||
|
/// The PIN to display while armed (the operator reads it; the user enters it on the client).
|
||||||
|
pub pin: Option<String>,
|
||||||
|
/// Seconds left in a timed window (`None` = armed with no expiry, e.g. the CLI flag).
|
||||||
|
pub expires_in_secs: Option<u64>,
|
||||||
|
pub paired_clients: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_path() -> Result<PathBuf> {
|
||||||
|
let home = std::env::var("HOME").context("HOME unset")?;
|
||||||
|
Ok(PathBuf::from(home).join(".config/punktfunk/punktfunk1-paired.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load(path: &std::path::Path) -> PairedClients {
|
||||||
|
std::fs::read(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(state: &PairedState) -> Result<()> {
|
||||||
|
if let Some(dir) = state.path.parent() {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
// Atomic replace: a crash/full-disk mid-write must not truncate the trust store (which would
|
||||||
|
// silently lock out every paired client on a --require-pairing host). Temp + rename.
|
||||||
|
let tmp = state.path.with_extension("json.tmp");
|
||||||
|
std::fs::write(&tmp, serde_json::to_vec_pretty(&state.clients)?)?;
|
||||||
|
std::fs::rename(&tmp, &state.path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_pin() -> String {
|
||||||
|
use rand::Rng;
|
||||||
|
format!("{:04}", rand::thread_rng().gen_range(0..10_000u32))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativePairing {
|
||||||
|
/// 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`
|
||||||
|
/// (or a fresh random PIN) and **no expiry** — back-compat with the headless CLI flow.
|
||||||
|
pub fn load_with(
|
||||||
|
store_path: Option<PathBuf>,
|
||||||
|
fixed_pin: Option<String>,
|
||||||
|
arm_at_start: bool,
|
||||||
|
) -> Result<NativePairing> {
|
||||||
|
let path = match store_path {
|
||||||
|
Some(p) => p,
|
||||||
|
None => default_path()?,
|
||||||
|
};
|
||||||
|
let clients = load(&path);
|
||||||
|
let arm = if arm_at_start {
|
||||||
|
Armed {
|
||||||
|
pin: Some(fixed_pin.unwrap_or_else(random_pin)),
|
||||||
|
expires_at: None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Armed::default()
|
||||||
|
};
|
||||||
|
Ok(NativePairing {
|
||||||
|
arm: Mutex::new(arm),
|
||||||
|
paired: Mutex::new(PairedState { path, clients }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arm pairing with a fresh random PIN, valid for `ttl`. Returns the PIN to display.
|
||||||
|
pub fn arm(&self, ttl: Duration) -> String {
|
||||||
|
let pin = random_pin();
|
||||||
|
*self.arm.lock().unwrap() = Armed {
|
||||||
|
pin: Some(pin.clone()),
|
||||||
|
expires_at: Some(Instant::now() + ttl),
|
||||||
|
};
|
||||||
|
pin
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disarm pairing (no new ceremonies accepted).
|
||||||
|
pub fn disarm(&self) {
|
||||||
|
*self.arm.lock().unwrap() = Armed::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expire a timed window if its deadline passed (called under the lock before any read).
|
||||||
|
fn expire(arm: &mut Armed) {
|
||||||
|
if let Some(t) = arm.expires_at {
|
||||||
|
if Instant::now() >= t {
|
||||||
|
*arm = Armed::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current valid PIN, or `None` if disarmed/expired. The QUIC ceremony reads this
|
||||||
|
/// per-attempt, so a window that lapsed mid-connection no longer pairs.
|
||||||
|
pub fn current_pin(&self) -> Option<String> {
|
||||||
|
let mut arm = self.arm.lock().unwrap();
|
||||||
|
Self::expire(&mut arm);
|
||||||
|
arm.pin.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A snapshot for the management API.
|
||||||
|
pub fn status(&self) -> NativePairingStatus {
|
||||||
|
let mut arm = self.arm.lock().unwrap();
|
||||||
|
Self::expire(&mut arm);
|
||||||
|
let expires_in_secs = arm
|
||||||
|
.expires_at
|
||||||
|
.map(|t| t.saturating_duration_since(Instant::now()).as_secs());
|
||||||
|
NativePairingStatus {
|
||||||
|
armed: arm.pin.is_some(),
|
||||||
|
pin: arm.pin.clone(),
|
||||||
|
expires_in_secs,
|
||||||
|
paired_clients: self.paired.lock().unwrap().clients.clients.len() as u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is this client (hex SHA-256 fingerprint) in the paired set?
|
||||||
|
pub fn is_paired(&self, fp_hex: &str) -> bool {
|
||||||
|
self.paired.lock().unwrap().clients.contains(fp_hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a successful pairing (re-pairing the same fingerprint just updates the name).
|
||||||
|
pub fn add(&self, name: &str, fp_hex: &str) -> Result<()> {
|
||||||
|
let mut p = self.paired.lock().unwrap();
|
||||||
|
p.clients.clients.retain(|c| c.fingerprint != fp_hex);
|
||||||
|
p.clients.clients.push(PairedClient {
|
||||||
|
name: name.to_string(),
|
||||||
|
fingerprint: fp_hex.to_string(),
|
||||||
|
});
|
||||||
|
save(&p)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The paired clients (for the management API's device list).
|
||||||
|
pub fn list(&self) -> Vec<PairedClient> {
|
||||||
|
self.paired.lock().unwrap().clients.clients.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a paired client by fingerprint. Returns whether one was removed.
|
||||||
|
pub fn remove(&self, fp_hex: &str) -> Result<bool> {
|
||||||
|
let mut p = self.paired.lock().unwrap();
|
||||||
|
let before = p.clients.clients.len();
|
||||||
|
p.clients
|
||||||
|
.clients
|
||||||
|
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
|
||||||
|
let removed = p.clients.clients.len() != before;
|
||||||
|
if removed {
|
||||||
|
save(&p)?;
|
||||||
|
}
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn temp() -> PathBuf {
|
||||||
|
// A unique-ish temp path without Date/rand-in-test fuss: pid + addr of a local.
|
||||||
|
let x = 0u8;
|
||||||
|
std::env::temp_dir().join(format!(
|
||||||
|
"pf-native-pair-{}-{}.json",
|
||||||
|
std::process::id(),
|
||||||
|
&x as *const _ as usize
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arm_expire_and_pair() {
|
||||||
|
let p = temp();
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
|
||||||
|
// Disarmed by default.
|
||||||
|
assert!(np.current_pin().is_none());
|
||||||
|
assert!(!np.status().armed);
|
||||||
|
|
||||||
|
// Arm with a tiny TTL → a PIN appears, then expires.
|
||||||
|
let pin = np.arm(Duration::from_millis(40));
|
||||||
|
assert_eq!(pin.len(), 4);
|
||||||
|
assert_eq!(np.current_pin().as_deref(), Some(pin.as_str()));
|
||||||
|
assert!(np.status().armed);
|
||||||
|
std::thread::sleep(Duration::from_millis(60));
|
||||||
|
assert!(np.current_pin().is_none(), "window should have expired");
|
||||||
|
assert!(!np.status().armed);
|
||||||
|
|
||||||
|
// Pair / list / unpair.
|
||||||
|
assert!(!np.is_paired("ab12"));
|
||||||
|
np.add("Living Room", "AB12").unwrap();
|
||||||
|
assert!(
|
||||||
|
np.is_paired("ab12"),
|
||||||
|
"fingerprint match is case-insensitive"
|
||||||
|
);
|
||||||
|
assert_eq!(np.list().len(), 1);
|
||||||
|
assert_eq!(np.status().paired_clients, 1);
|
||||||
|
assert!(np.remove("ab12").unwrap());
|
||||||
|
assert!(!np.remove("ab12").unwrap());
|
||||||
|
assert!(np.list().is_empty());
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_flag_arms_with_no_expiry() {
|
||||||
|
let p = temp();
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
let np = NativePairing::load_with(Some(p.clone()), Some("1234".into()), true).unwrap();
|
||||||
|
assert_eq!(np.current_pin().as_deref(), Some("1234"));
|
||||||
|
let s = np.status();
|
||||||
|
assert!(s.armed);
|
||||||
|
assert_eq!(s.expires_in_secs, None, "CLI arming has no expiry");
|
||||||
|
np.disarm();
|
||||||
|
assert!(np.current_pin().is_none());
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user