feat(pairing): seamless no-PIN delegated approval (host parks the knock, clients add "Request access")
Web-console "Approve" (delegated pairing, roadmap §8b-1) was unreachable: every client routed a fresh pair=required host straight to the SPAKE2 PIN ceremony, so no "knock" was ever recorded; and an unpaired connect was rejected+closed with no way to resume after approval. The backend + console were complete but had no client-side trigger and no post-approval admit path. Host (native_pairing.rs, punktfunk1.rs): an unpaired identified knock is now PARKED instead of rejected — it releases its NVENC session permit, awaits an operator decision (NativePairing::wait_for_decision, woken by a Notify on approve/deny), and on approval re-acquires a slot and admits the SAME connection with no reconnect. QUIC keep-alive (4s/8s) holds the parked connection warm. The pairing gate moves out of the HANDSHAKE_TIMEOUT-bounded handshake future; approve_pending is reordered read-then-add and wait_for_decision double-checks is_paired to close a "neither pending nor paired" race. New PENDING_APPROVAL_WAIT (180s). Tests: delegated_approval_admits_after_knock now approves mid-park (no reconnect) + new wait_for_decision_approve_deny_timeout unit test (108 host tests green). Clients (Linux/Apple/Windows/Android): a fresh pair=required host now offers "Request access" alongside the PIN ceremony — a plain identified connect with a ~185s handshake budget and a cancelable "waiting for approval" UI; on success the host is saved as paired, and cancel returns the UI immediately while a late- resolving connect is torn down silently via a per-attempt flag. Apple reuses the existing C-ABI timeout_ms (no ABI change); Windows adds SessionParams.connect_timeout + a RequestAccess screen; Android adds a timeoutMs arg to the nativeConnect JNI seam (both sides + both callers). Linux built + clippy + fmt clean; Apple/Windows/ Android pending their CI/on-device compiles. SPAKE2 ceremony reviewed end-to-end against the spake2 0.4 contract — correct, no changes needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -74,10 +74,31 @@ import io.unom.punktfunk.kit.security.KnownHostStore
|
|||||||
import io.unom.punktfunk.kit.security.obtainIdentity
|
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||||
import io.unom.punktfunk.models.HostStatus
|
import io.unom.punktfunk.models.HostStatus
|
||||||
import io.unom.punktfunk.models.PendingTrust
|
import io.unom.punktfunk.models.PendingTrust
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** Handshake budget for a normal connect (the prior hardcoded value, now passed explicitly). */
|
||||||
|
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handshake budget for the no-PIN "request access" connect. Must exceed the host's approval-park
|
||||||
|
* window (~180 s) so a slow operator approval still lands on this same parked connection rather than
|
||||||
|
* timing the client out first. Mirrors the Linux client's 185 s.
|
||||||
|
*/
|
||||||
|
private const val REQUEST_ACCESS_TIMEOUT_MS = 185_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A no-PIN "request access" connect in flight — the host being requested (drives the cancelable
|
||||||
|
* "Waiting for approval…" dialog) and a per-attempt flag the Cancel button trips. The connect is a
|
||||||
|
* blocking call with no abort, so Cancel returns the UI immediately and a late result checks
|
||||||
|
* [cancelled] and tears the (possibly just-approved) session down silently rather than navigating.
|
||||||
|
*/
|
||||||
|
private class RequestAccessState(val target: PendingTrust) {
|
||||||
|
val cancelled = AtomicBoolean(false)
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||||
@@ -128,8 +149,11 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
.onSuccess { identity = it }
|
.onSuccess { identity = it }
|
||||||
.onFailure { status = "Identity unavailable: ${it.message} — re-pair may be required" }
|
.onFailure { status = "Identity unavailable: ${it.message} — re-pair may be required" }
|
||||||
}
|
}
|
||||||
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing).
|
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing / the
|
||||||
|
// request-access-or-PIN choice).
|
||||||
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
||||||
|
// A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog).
|
||||||
|
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
|
||||||
// A saved host whose label is being edited (the Rename dialog).
|
// A saved host whose label is being edited (the Rename dialog).
|
||||||
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
|
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||||
|
|
||||||
@@ -163,7 +187,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
targetHost, targetPort, w, h, hz,
|
targetHost, targetPort, w, h, hz,
|
||||||
id.certPem, id.privateKeyPem, pinHex ?: "",
|
id.certPem, id.privateKeyPem, pinHex ?: "",
|
||||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||||
hdrEnabled, settings.audioChannels,
|
hdrEnabled, settings.audioChannels, CONNECT_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
connecting = false
|
connecting = false
|
||||||
@@ -182,10 +206,66 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decide pinned-reconnect vs fp-changed vs TOFU vs PIN pairing before connecting. Trust state is
|
// The no-PIN "request access" path (delegated approval): open a normal identified connect that
|
||||||
|
// the host PARKS until the operator clicks Approve in its console/web UI, showing a cancelable
|
||||||
|
// "Waiting for approval…" dialog meanwhile. The SAME connection is admitted on approval (no
|
||||||
|
// reconnect), so on success we record the host as PAIRED — the operator's approval IS the pairing.
|
||||||
|
// The connect can't be aborted, so Cancel returns the UI immediately and a late result is torn
|
||||||
|
// down silently via the per-attempt flag (mirrors the Linux client's request-access flow).
|
||||||
|
fun requestAccess(target: PendingTrust) {
|
||||||
|
val id = identity
|
||||||
|
if (id == null) {
|
||||||
|
status = "Identity not ready yet — try again in a moment"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val req = RequestAccessState(target)
|
||||||
|
awaiting = req
|
||||||
|
connecting = true
|
||||||
|
status = null
|
||||||
|
discovery.stop() // free the Wi-Fi radio before the (parked) stream session
|
||||||
|
scope.launch {
|
||||||
|
val hdrEnabled = displaySupportsHdr(context)
|
||||||
|
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||||
|
// Pin the advertised fingerprint for a discovered host (defence against an impostor while
|
||||||
|
// we wait); a manually-typed host has none, so trust-on-first-use.
|
||||||
|
val pinHex = target.advertisedFp ?: ""
|
||||||
|
val handle = withContext(Dispatchers.IO) {
|
||||||
|
NativeBridge.nativeConnect(
|
||||||
|
target.host, target.port, w, h, hz,
|
||||||
|
id.certPem, id.privateKeyPem, pinHex,
|
||||||
|
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||||
|
hdrEnabled, settings.audioChannels, REQUEST_ACCESS_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Cancelled while we were parked: tear the (possibly just-approved) session down and
|
||||||
|
// don't touch UI a fresh action may now own.
|
||||||
|
if (req.cancelled.get()) {
|
||||||
|
if (handle != 0L) withContext(Dispatchers.IO) { NativeBridge.nativeClose(handle) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
awaiting = null
|
||||||
|
connecting = false
|
||||||
|
if (handle != 0L) {
|
||||||
|
// Approved — save the host as PAIRED, pinning the fingerprint it presented, so
|
||||||
|
// future connects are silent (exactly like after a PIN ceremony).
|
||||||
|
val fp = NativeBridge.nativeHostFingerprint(handle)
|
||||||
|
if (fp.isNotEmpty()) {
|
||||||
|
knownHostStore.save(KnownHost(target.host, target.port, target.name, fp, paired = true))
|
||||||
|
savedHosts = knownHostStore.all()
|
||||||
|
}
|
||||||
|
onConnected(handle)
|
||||||
|
} else {
|
||||||
|
status = "Request timed out — approve this device in the host's console, then retry."
|
||||||
|
discovery.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide pinned-reconnect vs fp-changed vs TOFU vs pairing before connecting. Trust state is
|
||||||
// keyed by address:port, so a discovered and a manually-typed connection to the same host share
|
// keyed by address:port, so a discovered and a manually-typed connection to the same host share
|
||||||
// one record. Trust-on-first-use is permitted ONLY when the host advertised pair=optional; a
|
// one record. Trust-on-first-use is permitted ONLY when the host advertised pair=optional; a
|
||||||
// pair=required host, or a manual/unknown-policy host, must pair by PIN.
|
// pair=required host, or a manual/unknown-policy host, must pair — either by no-PIN request
|
||||||
|
// access (approve in the console) or by the SPAKE2 PIN ceremony.
|
||||||
fun connect(
|
fun connect(
|
||||||
targetHost: String,
|
targetHost: String,
|
||||||
targetPort: Int,
|
targetPort: Int,
|
||||||
@@ -208,9 +288,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
// clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null.
|
// clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null.
|
||||||
dh?.pairingRequired == false -> pendingTrust =
|
dh?.pairingRequired == false -> pendingTrust =
|
||||||
PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW)
|
PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW)
|
||||||
// pair=required, or a manual/unknown-policy host → PIN pairing is mandatory.
|
// pair=required, or a manual/unknown-policy host → offer the two ways in: a no-PIN
|
||||||
|
// "request access" (approve in the console) or the SPAKE2 PIN ceremony.
|
||||||
else -> pendingTrust =
|
else -> pendingTrust =
|
||||||
PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.PAIR)
|
PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.REQUEST_ACCESS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,6 +552,33 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
TextButton({ pendingTrust = null }) { Text("Cancel") }
|
TextButton({ pendingTrust = null }) { Text("Cancel") }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
// A fresh pair=required (or manual/unknown-policy) host: offer the two ways in. "Request
|
||||||
|
// access" is the no-PIN path — connect and wait for the operator to click Approve in the
|
||||||
|
// host's console; "Use a PIN…" switches to the SPAKE2 ceremony.
|
||||||
|
PendingTrust.Kind.REQUEST_ACCESS -> AlertDialog(
|
||||||
|
onDismissRequest = { pendingTrust = null },
|
||||||
|
title = { Text("Pairing required") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("${pt.host}:${pt.port} requires pairing before it will stream.")
|
||||||
|
Text(
|
||||||
|
"Request access and approve this device in the host's console (or web " +
|
||||||
|
"UI) — no PIN needed. Or pair with the 4-digit PIN the host displays.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton({ pendingTrust = null; requestAccess(pt) }) { Text("Request access") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
Row {
|
||||||
|
TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) {
|
||||||
|
Text("Use a PIN…")
|
||||||
|
}
|
||||||
|
TextButton({ pendingTrust = null }) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
PendingTrust.Kind.PAIR -> {
|
PendingTrust.Kind.PAIR -> {
|
||||||
var pin by remember(pt) { mutableStateOf("") }
|
var pin by remember(pt) { mutableStateOf("") }
|
||||||
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
|
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
|
||||||
@@ -537,6 +645,44 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The no-PIN "request access" wait: the connect is parked on the host until the operator
|
||||||
|
// approves this device. Cancel returns the UI immediately — it trips the per-attempt flag so a
|
||||||
|
// late approval is torn down silently (see requestAccess) and resumes discovery.
|
||||||
|
awaiting?.let { req ->
|
||||||
|
fun cancel() {
|
||||||
|
req.cancelled.set(true)
|
||||||
|
awaiting = null
|
||||||
|
connecting = false
|
||||||
|
discovery.start() // the request may still be pending on the host; keep scanning
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { cancel() },
|
||||||
|
title = { Text("Waiting for approval") },
|
||||||
|
text = {
|
||||||
|
val deviceName = Build.MODEL ?: "this device"
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||||
|
Text("Approve this device on ${req.target.name}.")
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
|
||||||
|
"automatically once you approve — no PIN needed.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { cancel() }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
|
// Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
|
||||||
// friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
|
// friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
|
||||||
renameTarget?.let { kh ->
|
renameTarget?.let { kh ->
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ enum class Tab(val label: String, val icon: ImageVector) {
|
|||||||
/**
|
/**
|
||||||
* A trust decision awaiting the user before a connect proceeds. [name] is the label to save the
|
* A trust decision awaiting the user before a connect proceeds. [name] is the label to save the
|
||||||
* host under. Trust-on-first-use ([Kind.TRUST_NEW]) is only ever offered when the host ADVERTISED
|
* host under. Trust-on-first-use ([Kind.TRUST_NEW]) is only ever offered when the host ADVERTISED
|
||||||
* pair=optional; a pair=required host or a manually-typed/unknown-policy host goes straight to PIN
|
* pair=optional; a pair=required host or a manually-typed/unknown-policy host is offered the
|
||||||
* pairing ([Kind.PAIR]), and a changed fingerprint forces re-pairing — never a silent re-trust.
|
* two ways in ([Kind.REQUEST_ACCESS]): a no-PIN "request access" connect the operator approves in
|
||||||
|
* the host's console, or the SPAKE2 PIN ceremony ([Kind.PAIR]). A changed fingerprint forces
|
||||||
|
* re-pairing by PIN ([Kind.FP_CHANGED]) — never a silent re-trust.
|
||||||
*/
|
*/
|
||||||
data class PendingTrust(
|
data class PendingTrust(
|
||||||
val host: String,
|
val host: String,
|
||||||
@@ -24,7 +26,7 @@ data class PendingTrust(
|
|||||||
val advertisedFp: String?,
|
val advertisedFp: String?,
|
||||||
val kind: Kind,
|
val kind: Kind,
|
||||||
) {
|
) {
|
||||||
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR }
|
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR, REQUEST_ACCESS }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Trust state of a host, shown as a colored pill on its card. */
|
/** Trust state of a host, shown as a colored pill on its card. */
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ object NativeBridge {
|
|||||||
* trust-on-first-use — read [nativeHostFingerprint] after; else 64-hex host SHA-256, mismatch →
|
* trust-on-first-use — read [nativeHostFingerprint] after; else 64-hex host SHA-256, mismatch →
|
||||||
* `0`). [width]/[height]/[refreshHz] are the requested virtual-output mode (the host streams at
|
* `0`). [width]/[height]/[refreshHz] are the requested virtual-output mode (the host streams at
|
||||||
* exactly this); [bitrateKbps] 0 = host default; [compositorPref]/[gamepadPref] are the
|
* exactly this); [bitrateKbps] 0 = host default; [compositorPref]/[gamepadPref] are the
|
||||||
* `CompositorPref`/`GamepadPref` wire bytes (0 = Auto). Returns an opaque session handle, or `0`
|
* `CompositorPref`/`GamepadPref` wire bytes (0 = Auto). [timeoutMs] is the handshake budget — the
|
||||||
* on failure. Pair with exactly one [nativeClose].
|
* normal path passes a short value, the no-PIN "request access" path a long one (≥ the host's
|
||||||
|
* approval-park window) so a slow operator approval lands on this same parked connection. Returns
|
||||||
|
* an opaque session handle, or `0` on failure. Pair with exactly one [nativeClose].
|
||||||
*/
|
*/
|
||||||
external fun nativeConnect(
|
external fun nativeConnect(
|
||||||
host: String,
|
host: String,
|
||||||
@@ -46,6 +48,7 @@ object NativeBridge {
|
|||||||
gamepadPref: Int,
|
gamepadPref: Int,
|
||||||
hdrEnabled: Boolean,
|
hdrEnabled: Boolean,
|
||||||
audioChannels: Int,
|
audioChannels: Int,
|
||||||
|
timeoutMs: Int,
|
||||||
): Long
|
): Long
|
||||||
|
|
||||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||||
|
|||||||
@@ -140,13 +140,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
||||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels): Long`. `certPem`/`keyPem` empty =
|
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, timeoutMs): Long`. `certPem`/`keyPem`
|
||||||
/// anonymous, else presented as the persistent identity. `pinHex` empty = TOFU (read
|
/// empty = anonymous, else presented as the persistent identity. `pinHex` empty = TOFU (read
|
||||||
/// `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0). `bitrateKbps`
|
/// `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0). `bitrateKbps`
|
||||||
/// 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref` wire bytes
|
/// 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref` wire bytes
|
||||||
/// (0 = Auto; unknown → Auto). `audioChannels` is the requested surround layout (2/6/8; normalized,
|
/// (0 = Auto; unknown → Auto). `audioChannels` is the requested surround layout (2/6/8; normalized,
|
||||||
/// anything else → stereo) — the host clamps it and the resolved count drives playback.
|
/// anything else → stereo) — the host clamps it and the resolved count drives playback. `timeoutMs`
|
||||||
/// Returns an opaque handle, or 0 on failure (logged).
|
/// is the handshake budget: the normal path passes a short value, the no-PIN "request access" path a
|
||||||
|
/// long one (≥ the host's approval-park window) so a slow operator approval lands on this same parked
|
||||||
|
/// connection rather than timing the client out first. Returns an opaque handle, or 0 on failure.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
|
||||||
@@ -165,6 +167,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
gamepad_pref: jint,
|
gamepad_pref: jint,
|
||||||
hdr_enabled: jboolean,
|
hdr_enabled: jboolean,
|
||||||
audio_channels: jint,
|
audio_channels: jint,
|
||||||
|
timeout_ms: jint,
|
||||||
) -> jlong {
|
) -> jlong {
|
||||||
let host: String = match env.get_string(&host) {
|
let host: String = match env.get_string(&host) {
|
||||||
Ok(s) => s.into(),
|
Ok(s) => s.into(),
|
||||||
@@ -224,7 +227,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
None, // launch: default app
|
None, // launch: default app
|
||||||
pin, // Some → Crypto on host-fp mismatch
|
pin, // Some → Crypto on host-fp mismatch
|
||||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||||
Duration::from_secs(10),
|
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"
|
||||||
|
// (the host parks the connection until the operator approves the device — see ConnectScreen).
|
||||||
|
Duration::from_millis(timeout_ms.max(0) as u64),
|
||||||
) {
|
) {
|
||||||
Ok(client) => {
|
Ok(client) => {
|
||||||
let handle = SessionHandle {
|
let handle = SessionHandle {
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
// (HomeView/HostCards), the trust prompt (TrustCardView), and the HUD (StreamHUDView) live in
|
// (HomeView/HostCards), the trust prompt (TrustCardView), and the HUD (StreamHUDView) live in
|
||||||
// their own files.
|
// their own files.
|
||||||
//
|
//
|
||||||
// Two ways to establish trust on first contact: the TOFU prompt (host fingerprint over the
|
// Ways to establish trust on first contact: the TOFU prompt (host fingerprint over the
|
||||||
// live-but-blurred stream, compared with the host's log) or the PIN pairing ceremony — pairing
|
// live-but-blurred stream, compared with the host's log; only for a host advertising pair=optional),
|
||||||
// verifies both sides at once and is the only way into hosts running --require-pairing. Once
|
// the PIN pairing ceremony (verifies both sides at once), or — for a host that requires pairing —
|
||||||
// pinned, reconnects are silent and a changed host identity refuses to connect.
|
// delegated approval ("Request Access": a plain identified connect the host parks until the operator
|
||||||
|
// approves this device in its console, no PIN). Once pinned, reconnects are silent and a changed
|
||||||
|
// host identity refuses to connect.
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
@@ -31,6 +33,12 @@ struct ContentView: View {
|
|||||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@State private var showAddHost = false
|
@State private var showAddHost = false
|
||||||
@State private var pairingTarget: StoredHost?
|
@State private var pairingTarget: StoredHost?
|
||||||
|
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
|
||||||
|
/// delegated approval ("Request Access") and the SPAKE2 PIN ceremony (rule 3b).
|
||||||
|
@State private var approvalChoice: ApprovalRequest?
|
||||||
|
/// A delegated-approval connect is in flight (host parks it until the operator approves):
|
||||||
|
/// drives the cancelable "Waiting for approval" prompt and the pin-as-paired on success.
|
||||||
|
@State private var awaitingApproval: ApprovalRequest?
|
||||||
@State private var speedTestTarget: StoredHost?
|
@State private var speedTestTarget: StoredHost?
|
||||||
@State private var libraryTarget: StoredHost?
|
@State private var libraryTarget: StoredHost?
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@@ -55,10 +63,27 @@ struct ContentView: View {
|
|||||||
autoConnectIfAsked()
|
autoConnectIfAsked()
|
||||||
}
|
}
|
||||||
.onChange(of: model.phase) { _, phase in
|
.onChange(of: model.phase) { _, phase in
|
||||||
|
switch phase {
|
||||||
|
case .streaming:
|
||||||
// A session actually started — remember it on the card ("Connected … ago"
|
// A session actually started — remember it on the card ("Connected … ago"
|
||||||
// plus the accent ring on the most recent host).
|
// plus the accent ring on the most recent host).
|
||||||
if case .streaming = phase, let host = model.activeHost {
|
guard let host = model.activeHost else { break }
|
||||||
store.markConnected(host.id)
|
store.markConnected(host.id)
|
||||||
|
// Delegated approval just succeeded: the operator let this device in, so pin the
|
||||||
|
// host's observed fingerprint and remember it as paired — future connects are then
|
||||||
|
// silent (rule 1), exactly like after a PIN/TOFU success. Dismisses the wait prompt.
|
||||||
|
if awaitingApproval?.host.id == host.id {
|
||||||
|
if let fp = model.connection?.hostFingerprint {
|
||||||
|
store.pin(host.id, fingerprint: fp)
|
||||||
|
}
|
||||||
|
awaitingApproval = nil
|
||||||
|
}
|
||||||
|
case .idle:
|
||||||
|
// The delegated-approval connect failed, timed out, or was cancelled — drop the
|
||||||
|
// wait prompt (SessionModel surfaces any error via `errorMessage`).
|
||||||
|
if awaitingApproval != nil { awaitingApproval = nil }
|
||||||
|
default:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more)
|
.onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more)
|
||||||
@@ -90,6 +115,47 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
// Fresh pair=required / unknown host: offer the two ways in. An action sheet (not an
|
||||||
|
// alert) so it never collides with the wait alert below. "Request Access" is the no-PIN
|
||||||
|
// delegated-approval path; "Pair with PIN…" runs the SPAKE2 ceremony. The follow-on
|
||||||
|
// presentation is deferred a tick so this dialog is fully dismissed first.
|
||||||
|
.confirmationDialog(
|
||||||
|
"Pairing required",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { approvalChoice != nil },
|
||||||
|
set: { if !$0 { approvalChoice = nil } }),
|
||||||
|
titleVisibility: .visible,
|
||||||
|
presenting: approvalChoice
|
||||||
|
) { req in
|
||||||
|
Button("Request Access") {
|
||||||
|
DispatchQueue.main.async { requestAccess(req) }
|
||||||
|
}
|
||||||
|
Button("Pair with PIN…") {
|
||||||
|
DispatchQueue.main.async { pairingTarget = req.host }
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: { req in
|
||||||
|
Text("\(req.host.displayName) requires pairing. Request access and approve this "
|
||||||
|
+ "device in the host's web console (port 3000 → Pairing) — no PIN needed. Or "
|
||||||
|
+ "pair with the 4-digit PIN it can display.")
|
||||||
|
}
|
||||||
|
// The delegated-approval wait: the host holds the connection open until the operator
|
||||||
|
// approves it. Cancel returns the UI at once; the in-flight connect is left to time out
|
||||||
|
// and its late result is discarded by SessionModel's connect guard (disconnect resets the
|
||||||
|
// phase/host it checks).
|
||||||
|
.alert(
|
||||||
|
"Waiting for approval",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { awaitingApproval != nil },
|
||||||
|
set: { if !$0 { awaitingApproval = nil } }),
|
||||||
|
presenting: awaitingApproval
|
||||||
|
) { _ in
|
||||||
|
Button("Cancel", role: .cancel) { model.disconnect() }
|
||||||
|
} message: { req in
|
||||||
|
Text("Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s web "
|
||||||
|
+ "console (port 3000 → Pairing). This device connects automatically once you "
|
||||||
|
+ "approve it — no need to reconnect.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var home: some View {
|
private var home: some View {
|
||||||
@@ -230,19 +296,32 @@ struct ContentView: View {
|
|||||||
// A pinned host connects on its stored fingerprint; an unpinned host may only TOFU when
|
// A pinned host connects on its stored fingerprint; an unpinned host may only TOFU when
|
||||||
// the host's LIVE advert says `pair=optional` (rule 3a). When the caller doesn't already
|
// the host's LIVE advert says `pair=optional` (rule 3a). When the caller doesn't already
|
||||||
// know the policy (a saved-card tap / manual entry), resolve it from the current mDNS set:
|
// know the policy (a saved-card tap / manual entry), resolve it from the current mDNS set:
|
||||||
// an unpinned host with no matching `pair=optional` advert routes to PIN pairing instead
|
// an unpinned host with no matching `pair=optional` advert routes to the approval choice
|
||||||
// of silently entering the trust prompt (rules 3b + 4). A pinned host ignores all of this.
|
// (request access / pair with PIN) instead of silently entering the trust prompt (rules
|
||||||
|
// 3b + 4). A pinned host ignores all of this.
|
||||||
if host.pinnedSHA256 == nil {
|
if host.pinnedSHA256 == nil {
|
||||||
let tofuOK = allowTofu ?? discovery.hosts.contains {
|
let tofuOK = allowTofu ?? discovery.hosts.contains {
|
||||||
host.matches($0) && $0.allowsTofu
|
host.matches($0) && $0.allowsTofu
|
||||||
}
|
}
|
||||||
if !tofuOK {
|
if !tofuOK {
|
||||||
pairingTarget = host
|
// pair=required / unknown policy / manual entry (rule 3b): never a silent
|
||||||
|
// connect — offer no-PIN delegated approval or the PIN ceremony.
|
||||||
|
approvalChoice = ApprovalRequest(
|
||||||
|
host: host, advertisedFingerprint: advertisedFingerprint(for: host))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The gamepad-type setting resolves NOW (Automatic → match the active physical
|
startSession(host, launchID: launchID, allowTofu: host.pinnedSHA256 == nil)
|
||||||
// controller): the host's virtual pad backend is fixed per session.
|
}
|
||||||
|
|
||||||
|
/// Resolve the @AppStorage stream mode + input prefs and hand off to the session model. The
|
||||||
|
/// gamepad-type setting resolves NOW (Automatic → match the active physical controller): the
|
||||||
|
/// host's virtual pad backend is fixed per session. `requestAccess` opens the no-PIN
|
||||||
|
/// delegated-approval connect (host parks it until the operator approves).
|
||||||
|
private func startSession(
|
||||||
|
_ host: StoredHost, launchID: String? = nil,
|
||||||
|
allowTofu: Bool, requestAccess: Bool = false
|
||||||
|
) {
|
||||||
model.connect(
|
model.connect(
|
||||||
to: host,
|
to: host,
|
||||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||||
@@ -255,7 +334,22 @@ struct ContentView: View {
|
|||||||
bitrateKbps: UInt32(clamping: bitrateKbps),
|
bitrateKbps: UInt32(clamping: bitrateKbps),
|
||||||
audioChannels: UInt8(clamping: audioChannels),
|
audioChannels: UInt8(clamping: audioChannels),
|
||||||
launchID: launchID,
|
launchID: launchID,
|
||||||
allowTofu: host.pinnedSHA256 == nil)
|
allowTofu: allowTofu,
|
||||||
|
requestAccess: requestAccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The no-PIN delegated-approval flow: open an identified connect the host parks until the
|
||||||
|
/// operator approves it in the console, showing the cancelable "Waiting for approval" prompt
|
||||||
|
/// meanwhile. On success the SAME connection is admitted (no reconnect) and the host is pinned
|
||||||
|
/// as paired (see the `.streaming` branch of `onChange`).
|
||||||
|
private func requestAccess(_ req: ApprovalRequest) {
|
||||||
|
guard !model.isBusy else { return }
|
||||||
|
awaitingApproval = req
|
||||||
|
// Pin the advertised certificate for a discovered host (impostor defence during the long
|
||||||
|
// wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||||||
|
var host = req.host
|
||||||
|
host.pinnedSHA256 = req.advertisedFingerprint
|
||||||
|
startSession(host, allowTofu: false, requestAccess: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
|
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
|
||||||
@@ -268,8 +362,9 @@ struct ContentView: View {
|
|||||||
/// Tap a discovered host: save it (so the session has a stored identity and the trust pin
|
/// Tap a discovered host: save it (so the session has a stored identity and the trust pin
|
||||||
/// persists), then connect or pair per the host's advertised policy. The host is the policy
|
/// persists), then connect or pair per the host's advertised policy. The host is the policy
|
||||||
/// authority — TOFU is offered ONLY when it explicitly advertised `pair=optional` (rule 3a);
|
/// authority — TOFU is offered ONLY when it explicitly advertised `pair=optional` (rule 3a);
|
||||||
/// a `pair=required` host, or one with no/unknown `pair` field, goes straight to the PIN
|
/// a `pair=required` host, or one with no/unknown `pair` field, gets the approval choice
|
||||||
/// pairing ceremony (rule 3b). (A pinned discovered host connects silently inside `connect`.)
|
/// (request access / pair with PIN) (rule 3b). (A pinned discovered host connects silently
|
||||||
|
/// inside `connect`.)
|
||||||
private func connectDiscovered(_ d: DiscoveredHost) {
|
private func connectDiscovered(_ d: DiscoveredHost) {
|
||||||
guard !model.isBusy else { return }
|
guard !model.isBusy else { return }
|
||||||
let host = StoredHost(name: d.name, address: d.host, port: d.port)
|
let host = StoredHost(name: d.name, address: d.host, port: d.port)
|
||||||
@@ -277,7 +372,9 @@ struct ContentView: View {
|
|||||||
if d.allowsTofu {
|
if d.allowsTofu {
|
||||||
connect(host, allowTofu: true)
|
connect(host, allowTofu: true)
|
||||||
} else {
|
} else {
|
||||||
pairingTarget = host
|
// pair=required / unknown policy (rule 3b): offer no-PIN delegated approval or PIN.
|
||||||
|
approvalChoice = ApprovalRequest(
|
||||||
|
host: host, advertisedFingerprint: pinFingerprint(d.fingerprintHex))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,6 +388,30 @@ struct ContentView: View {
|
|||||||
connect(pinned)
|
connect(pinned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The certificate fingerprint a live mDNS advert carries for this saved host (advisory — see
|
||||||
|
/// `HostDiscovery`), to pin during a delegated-approval wait. nil if the host isn't currently
|
||||||
|
/// advertising or advertised no/invalid `fp`.
|
||||||
|
private func advertisedFingerprint(for host: StoredHost) -> Data? {
|
||||||
|
pinFingerprint(discovery.hosts.first { host.matches($0) }?.fingerprintHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an advertised cert fingerprint (lowercase hex) into the 32-byte pin the connect
|
||||||
|
/// expects; nil unless it's exactly a 32-byte (SHA-256) value, so a malformed advert falls
|
||||||
|
/// back to trust-on-first-use rather than failing the connect closed.
|
||||||
|
private func pinFingerprint(_ hex: String?) -> Data? {
|
||||||
|
guard let hex, let data = Data(hexString: hex), data.count == 32 else { return nil }
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the host lists this device in its approval prompt (matches PairSheet's client name).
|
||||||
|
private var localDeviceName: String {
|
||||||
|
#if os(macOS)
|
||||||
|
Host.current().localizedName ?? "Mac"
|
||||||
|
#else
|
||||||
|
UIDevice.current.name
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - First-run + dev hooks
|
// MARK: - First-run + dev hooks
|
||||||
|
|
||||||
/// First run on iOS: default the stream mode to this device's native screen so the
|
/// First run on iOS: default the stream mode to this device's native screen so the
|
||||||
@@ -378,3 +499,31 @@ private struct FullscreenController: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
/// A fresh `pair=required`/unknown host pending a trust decision: drives both the "request access
|
||||||
|
/// vs. pair with PIN" choice and the subsequent approval wait. `advertisedFingerprint` is the
|
||||||
|
/// discovered host's advertised cert (nil for a manually-typed host → trust-on-first-use).
|
||||||
|
private struct ApprovalRequest {
|
||||||
|
let host: StoredHost
|
||||||
|
let advertisedFingerprint: Data?
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Data {
|
||||||
|
/// Parse an even-length hex string into bytes; nil on any non-hex character or odd length.
|
||||||
|
/// Used to turn an mDNS-advertised cert fingerprint into a connect pin.
|
||||||
|
init?(hexString: String) {
|
||||||
|
let chars = Array(hexString)
|
||||||
|
guard chars.count.isMultiple(of: 2) else { return nil }
|
||||||
|
var bytes = [UInt8]()
|
||||||
|
bytes.reserveCapacity(chars.count / 2)
|
||||||
|
var i = 0
|
||||||
|
while i < chars.count {
|
||||||
|
guard let hi = chars[i].hexDigitValue, let lo = chars[i + 1].hexDigitValue else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
bytes.append(UInt8(hi << 4 | lo))
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
self = Data(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ final class SessionModel: ObservableObject {
|
|||||||
/// field — TOFU is forbidden (rule 3b): the connect refuses rather than offering trust, and
|
/// field — TOFU is forbidden (rule 3b): the connect refuses rather than offering trust, and
|
||||||
/// the user is routed to PIN pairing by the caller. (A pinned host connects regardless: its
|
/// the user is routed to PIN pairing by the caller. (A pinned host connects regardless: its
|
||||||
/// stored fingerprint is the trust decision.)
|
/// stored fingerprint is the trust decision.)
|
||||||
|
///
|
||||||
|
/// `requestAccess` is the no-PIN delegated-approval path: open an identified connect the host
|
||||||
|
/// PARKS until the operator clicks Approve in its console, then admits the SAME connection (no
|
||||||
|
/// reconnect). The handshake budget is widened to exceed the host's park window, and a
|
||||||
|
/// successful connect streams directly (the approval IS the trust decision) — the caller pins
|
||||||
|
/// the observed fingerprint as paired. `host.pinnedSHA256`, when set, pins the advertised cert
|
||||||
|
/// for the wait; nil = trust-on-first-use.
|
||||||
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
|
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
|
||||||
compositor: PunktfunkConnection.Compositor = .auto,
|
compositor: PunktfunkConnection.Compositor = .auto,
|
||||||
gamepad: PunktfunkConnection.GamepadType = .auto,
|
gamepad: PunktfunkConnection.GamepadType = .auto,
|
||||||
@@ -103,7 +110,8 @@ final class SessionModel: ObservableObject {
|
|||||||
hdrEnabled: Bool = true,
|
hdrEnabled: Bool = true,
|
||||||
launchID: String? = nil,
|
launchID: String? = nil,
|
||||||
allowTofu: Bool = false,
|
allowTofu: Bool = false,
|
||||||
autoTrust: Bool = false) {
|
autoTrust: Bool = false,
|
||||||
|
requestAccess: Bool = false) {
|
||||||
guard phase == .idle else { return }
|
guard phase == .idle else { return }
|
||||||
phase = .connecting
|
phase = .connecting
|
||||||
activeHost = host
|
activeHost = host
|
||||||
@@ -138,7 +146,11 @@ final class SessionModel: ObservableObject {
|
|||||||
width: width, height: height, refreshHz: hz,
|
width: width, height: height, refreshHz: hz,
|
||||||
pinSHA256: pin, identity: identity, compositor: compositor,
|
pinSHA256: pin, identity: identity, compositor: compositor,
|
||||||
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
||||||
audioChannels: audioChannels, launchID: launchID) }
|
audioChannels: audioChannels, launchID: launchID,
|
||||||
|
// Delegated approval: the host holds this connect open until the operator approves
|
||||||
|
// it (~180 s) — outwait that window so a slow approval still lands here. Normal
|
||||||
|
// connects keep the snappy default.
|
||||||
|
timeoutMs: requestAccess ? 185_000 : 10_000) }
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
// The user may have abandoned this attempt (window closed, another host
|
// The user may have abandoned this attempt (window closed, another host
|
||||||
@@ -152,7 +164,9 @@ final class SessionModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let conn):
|
case .success(let conn):
|
||||||
if pin != nil || autoTrust {
|
if pin != nil || autoTrust || requestAccess {
|
||||||
|
// requestAccess: the operator approved this device on the host, so the
|
||||||
|
// session is trusted — stream directly (the caller pins it as paired).
|
||||||
self.connection = conn
|
self.connection = conn
|
||||||
self.startStatsTimer()
|
self.startStatsTimer()
|
||||||
self.beginStreaming()
|
self.beginStreaming()
|
||||||
@@ -174,6 +188,14 @@ final class SessionModel: ObservableObject {
|
|||||||
case .failure:
|
case .failure:
|
||||||
self.phase = .idle
|
self.phase = .idle
|
||||||
self.activeHost = nil
|
self.activeHost = nil
|
||||||
|
if requestAccess {
|
||||||
|
// The delegated-approval connect ended without being admitted: the
|
||||||
|
// operator didn't approve it before the host's park window elapsed (or
|
||||||
|
// the host was unreachable).
|
||||||
|
self.errorMessage = "\(host.displayName) didn't let this device in. "
|
||||||
|
+ "Approve it in the host's web console (port 3000 → Pairing), then "
|
||||||
|
+ "request access again — the request expires after a few minutes."
|
||||||
|
} else {
|
||||||
self.errorMessage = pin != nil
|
self.errorMessage = pin != nil
|
||||||
? "Could not connect to \(host.displayName) — host unreachable, "
|
? "Could not connect to \(host.displayName) — host unreachable, "
|
||||||
+ "not running, its identity no longer matches the pinned "
|
+ "not running, its identity no longer matches the pinned "
|
||||||
@@ -188,6 +210,7 @@ final class SessionModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The user confirmed the fingerprint: returns it for pinning and enters streaming.
|
/// The user confirmed the fingerprint: returns it for pinning and enters streaming.
|
||||||
func confirmTrust() -> Data? {
|
func confirmTrust() -> Data? {
|
||||||
|
|||||||
+156
-5
@@ -295,19 +295,21 @@ fn initiate_connect(app: Rc<App>, req: ConnectRequest) {
|
|||||||
// Rule 3a: the host opted into reduced-security TOFU; offer it alongside PIN.
|
// Rule 3a: the host opted into reduced-security TOFU; offer it alongside PIN.
|
||||||
tofu_dialog(app, req);
|
tofu_dialog(app, req);
|
||||||
} else {
|
} else {
|
||||||
// Rule 3b: pair=required or unknown policy — PIN pairing is mandatory.
|
// Rule 3b: pair=required or unknown policy — offer no-PIN delegated approval
|
||||||
pin_dialog(app, req);
|
// (request access → approve in the console) or the PIN ceremony.
|
||||||
|
approval_dialog(app, req);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// Manual entry (no advertised fingerprint). A known address connects silently
|
// Manual entry (no advertised fingerprint). A known address connects silently
|
||||||
// on its stored pin (rule 1); an unknown one must pair — never silent TOFU.
|
// on its stored pin (rule 1); an unknown one must pair — request access (approve in
|
||||||
|
// the console) or use a PIN; never silent TOFU.
|
||||||
match known
|
match known
|
||||||
.find_by_addr(&req.addr, req.port)
|
.find_by_addr(&req.addr, req.port)
|
||||||
.and_then(|k| crate::trust::parse_hex32(&k.fp_hex))
|
.and_then(|k| crate::trust::parse_hex32(&k.fp_hex))
|
||||||
{
|
{
|
||||||
Some(pin) => start_session(app, req, Some(pin)),
|
Some(pin) => start_session(app, req, Some(pin)),
|
||||||
None => pin_dialog(app, req), // rule 3b
|
None => approval_dialog(app, req), // rule 3b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -418,6 +420,83 @@ fn pin_dialog(app: Rc<App>, req: ConnectRequest) {
|
|||||||
dialog.present(Some(&parent));
|
dialog.present(Some(&parent));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A fresh host that requires pairing: offer the two ways in. "Request access" is the no-PIN
|
||||||
|
/// path — connect and wait for the operator to click Approve in the host's console/web UI
|
||||||
|
/// (delegated approval); "Use a PIN instead…" runs the SPAKE2 ceremony.
|
||||||
|
fn approval_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||||
|
let dialog = adw::AlertDialog::new(
|
||||||
|
Some("Pairing Required"),
|
||||||
|
Some(&format!(
|
||||||
|
"{} requires pairing.\n\nRequest access and approve this device in the host's console \
|
||||||
|
(or web UI) — no PIN needed. Or pair with the 4-digit PIN it can display.",
|
||||||
|
req.name
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
dialog.add_responses(&[
|
||||||
|
("cancel", "Cancel"),
|
||||||
|
("pin", "Use a PIN instead…"),
|
||||||
|
("request", "Request Access"),
|
||||||
|
]);
|
||||||
|
dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.set_default_response(Some("request"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
let parent = app.window.clone();
|
||||||
|
dialog.connect_response(None, move |_, response| match response {
|
||||||
|
"request" => request_access(app.clone(), req.clone()),
|
||||||
|
"pin" => pin_dialog(app.clone(), req.clone()),
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
dialog.present(Some(&parent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the
|
||||||
|
/// operator approves it in the console, showing a cancelable "waiting" dialog meanwhile. On
|
||||||
|
/// approval the same connection is admitted (no reconnect) and the host is saved as paired.
|
||||||
|
fn request_access(app: Rc<App>, req: ConnectRequest) {
|
||||||
|
// Pin the advertised certificate for a discovered host (defence against a host impostor while
|
||||||
|
// we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||||||
|
let pin = req.fp_hex.as_deref().and_then(crate::trust::parse_hex32);
|
||||||
|
let cancel = Rc::new(std::cell::Cell::new(false));
|
||||||
|
|
||||||
|
let waiting = adw::AlertDialog::new(
|
||||||
|
Some("Waiting for Approval"),
|
||||||
|
Some(&format!(
|
||||||
|
"Approve “{}” in {}’s console or web UI.\n\nThis device is waiting to be let in — it \
|
||||||
|
connects automatically once you approve it.",
|
||||||
|
glib::host_name(),
|
||||||
|
req.name
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
waiting.add_responses(&[("cancel", "Cancel")]);
|
||||||
|
waiting.set_close_response("cancel");
|
||||||
|
{
|
||||||
|
let app = app.clone();
|
||||||
|
let cancel = cancel.clone();
|
||||||
|
waiting.connect_response(Some("cancel"), move |_, _| {
|
||||||
|
// Return the UI immediately; the in-flight connect is left to time out and is torn
|
||||||
|
// down silently by the event loop (see StartOpts::cancel).
|
||||||
|
cancel.set(true);
|
||||||
|
app.busy.set(false);
|
||||||
|
app.toast("Cancelled — the request may still be pending on the host.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
waiting.present(Some(&app.window));
|
||||||
|
|
||||||
|
start_session_with(
|
||||||
|
app,
|
||||||
|
req,
|
||||||
|
pin,
|
||||||
|
StartOpts {
|
||||||
|
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator
|
||||||
|
// approval still lands on this connection rather than timing the client out first.
|
||||||
|
connect_timeout: std::time::Duration::from_secs(185),
|
||||||
|
persist_paired: true,
|
||||||
|
waiting: Some(waiting),
|
||||||
|
cancel: Some(cancel),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Measure the path to a host over the real data plane (Swift's "Test Network Speed…"):
|
/// Measure the path to a host over the real data plane (Swift's "Test Network Speed…"):
|
||||||
/// connect, have the host burst probe filler for 2 s up to its 3 Gbps ceiling, report
|
/// connect, have the host burst probe filler for 2 s up to its 3 Gbps ceiling, report
|
||||||
/// goodput · loss · a recommended bitrate (≈70 % of measured), and apply it in one tap.
|
/// goodput · loss · a recommended bitrate (≈70 % of measured), and apply it in one tap.
|
||||||
@@ -556,7 +635,42 @@ fn resolve_mode(app: &App) -> punktfunk_core::config::Mode {
|
|||||||
mode
|
mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tunables for a session start that differ between the normal connect and the "request access"
|
||||||
|
/// (delegated-approval) flow. `Default` is the normal connect.
|
||||||
|
struct StartOpts {
|
||||||
|
/// Handshake budget. The request-access flow uses a long one because the host PARKS the
|
||||||
|
/// connection until the operator clicks Approve (see the host's `PENDING_APPROVAL_WAIT`).
|
||||||
|
connect_timeout: std::time::Duration,
|
||||||
|
/// Persist the host as *paired* on a successful connect. Set for request-access, where the
|
||||||
|
/// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU
|
||||||
|
/// persists the host *unpaired* (pinned, but not PIN/approval-verified).
|
||||||
|
persist_paired: bool,
|
||||||
|
/// A "waiting for approval" dialog to dismiss on the first session event (request-access only).
|
||||||
|
waiting: Option<adw::AlertDialog>,
|
||||||
|
/// Set by the waiting dialog's Cancel button. `NativeClient::connect` is a blocking call with
|
||||||
|
/// no abort, so Cancel returns the UI immediately (clears busy, closes the dialog) and leaves
|
||||||
|
/// the in-flight connect to time out; when it finally resolves, the event loop sees this flag
|
||||||
|
/// and tears down silently (drops the connector → closes the connection) without touching the
|
||||||
|
/// UI a new session may already own.
|
||||||
|
cancel: Option<Rc<std::cell::Cell<bool>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StartOpts {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
connect_timeout: std::time::Duration::from_secs(15),
|
||||||
|
persist_paired: false,
|
||||||
|
waiting: None,
|
||||||
|
cancel: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
||||||
|
start_session_with(app, req, pin, StartOpts::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_session_with(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>, opts: StartOpts) {
|
||||||
if app.busy.replace(true) {
|
if app.busy.replace(true) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -577,10 +691,14 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
|||||||
audio_channels: s.audio_channels,
|
audio_channels: s.audio_channels,
|
||||||
pin,
|
pin,
|
||||||
identity: app.identity.clone(),
|
identity: app.identity.clone(),
|
||||||
|
connect_timeout: opts.connect_timeout,
|
||||||
};
|
};
|
||||||
let inhibit = s.inhibit_shortcuts;
|
let inhibit = s.inhibit_shortcuts;
|
||||||
drop(s);
|
drop(s);
|
||||||
let tofu = pin.is_none();
|
let tofu = pin.is_none();
|
||||||
|
let persist_paired = opts.persist_paired;
|
||||||
|
let mut waiting = opts.waiting;
|
||||||
|
let cancel = opts.cancel;
|
||||||
|
|
||||||
let mut handle = crate::session::start(params);
|
let mut handle = crate::session::start(params);
|
||||||
let frames = std::mem::replace(&mut handle.frames, async_channel::bounded(1).1);
|
let frames = std::mem::replace(&mut handle.frames, async_channel::bounded(1).1);
|
||||||
@@ -588,14 +706,41 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
|||||||
let mut frames = Some(frames);
|
let mut frames = Some(frames);
|
||||||
let mut page: Option<crate::ui_stream::StreamPage> = None;
|
let mut page: Option<crate::ui_stream::StreamPage> = None;
|
||||||
while let Ok(event) = handle.events.recv().await {
|
while let Ok(event) = handle.events.recv().await {
|
||||||
|
// A cancelled request-access connect resolved late: tear down silently. Don't touch
|
||||||
|
// app.busy — Cancel already cleared it, and a fresh session may now own it.
|
||||||
|
if cancel.as_ref().is_some_and(|c| c.get()) {
|
||||||
|
if let Some(w) = waiting.take() {
|
||||||
|
w.close();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
match event {
|
match event {
|
||||||
SessionEvent::Connected {
|
SessionEvent::Connected {
|
||||||
connector,
|
connector,
|
||||||
mode,
|
mode,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
} => {
|
} => {
|
||||||
|
// Dismiss the "waiting for approval" dialog (request-access flow), if any.
|
||||||
|
if let Some(w) = waiting.take() {
|
||||||
|
w.close();
|
||||||
|
}
|
||||||
|
if persist_paired {
|
||||||
|
// Request-access: the operator approved this device, so record the host as
|
||||||
|
// a trusted PAIRED host (pinning the fingerprint we observed) — future
|
||||||
|
// connects are then silent (rule 1), exactly like after a PIN ceremony.
|
||||||
|
let fp_hex = crate::trust::hex(&fingerprint);
|
||||||
|
let mut known = KnownHosts::load();
|
||||||
|
known.upsert(KnownHost {
|
||||||
|
name: req.name.clone(),
|
||||||
|
addr: req.addr.clone(),
|
||||||
|
port: req.port,
|
||||||
|
fp_hex,
|
||||||
|
paired: true,
|
||||||
|
});
|
||||||
|
let _ = known.save();
|
||||||
|
app.toast("Approved — connecting…");
|
||||||
|
} else if tofu {
|
||||||
// A TOFU connect just observed the real fingerprint — pin it from now on.
|
// A TOFU connect just observed the real fingerprint — pin it from now on.
|
||||||
if tofu {
|
|
||||||
let fp_hex = crate::trust::hex(&fingerprint);
|
let fp_hex = crate::trust::hex(&fingerprint);
|
||||||
let mut known = KnownHosts::load();
|
let mut known = KnownHosts::load();
|
||||||
known.upsert(KnownHost {
|
known.upsert(KnownHost {
|
||||||
@@ -644,6 +789,9 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
|||||||
msg,
|
msg,
|
||||||
trust_rejected,
|
trust_rejected,
|
||||||
} => {
|
} => {
|
||||||
|
if let Some(w) = waiting.take() {
|
||||||
|
w.close();
|
||||||
|
}
|
||||||
tracing::warn!(%msg, trust_rejected, "connect failed");
|
tracing::warn!(%msg, trust_rejected, "connect failed");
|
||||||
app.busy.set(false);
|
app.busy.set(false);
|
||||||
// A pinned connect rejected on trust grounds means the host's cert no
|
// A pinned connect rejected on trust grounds means the host's cert no
|
||||||
@@ -658,6 +806,9 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
SessionEvent::Ended(err) => {
|
SessionEvent::Ended(err) => {
|
||||||
|
if let Some(w) = waiting.take() {
|
||||||
|
w.close();
|
||||||
|
}
|
||||||
app.gamepad.detach();
|
app.gamepad.detach();
|
||||||
app.nav.pop_to_tag("hosts");
|
app.nav.pop_to_tag("hosts");
|
||||||
if let Some(e) = err {
|
if let Some(e) = err {
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ pub struct SessionParams {
|
|||||||
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
||||||
pub pin: Option<[u8; 32]>,
|
pub pin: Option<[u8; 32]>,
|
||||||
pub identity: (String, String),
|
pub identity: (String, String),
|
||||||
|
/// How long to wait for the handshake. The normal path uses a short budget; the
|
||||||
|
/// "request access" (delegated-approval) path uses a long one, because the host PARKS the
|
||||||
|
/// connection until the operator clicks Approve in its console (so this must exceed the
|
||||||
|
/// host's approval window — see `PENDING_APPROVAL_WAIT`).
|
||||||
|
pub connect_timeout: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Default)]
|
#[derive(Clone, Copy, Default)]
|
||||||
@@ -139,7 +144,7 @@ fn pump(
|
|||||||
None, // launch: the Linux client has no library picker yet
|
None, // launch: the Linux client has no library picker yet
|
||||||
params.pin,
|
params.pin,
|
||||||
Some(params.identity),
|
Some(params.identity),
|
||||||
Duration::from_secs(15),
|
params.connect_timeout,
|
||||||
) {
|
) {
|
||||||
Ok(c) => Arc::new(c),
|
Ok(c) => Arc::new(c),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
+301
-15
@@ -20,7 +20,9 @@ use crate::video::{DecodedFrame, DecoderPref};
|
|||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
use windows_reactor::*;
|
use windows_reactor::*;
|
||||||
|
|
||||||
const RESOLUTIONS: &[(u32, u32)] = &[
|
const RESOLUTIONS: &[(u32, u32)] = &[
|
||||||
@@ -43,12 +45,27 @@ const BITRATES_MBPS: &[u32] = &[0, 10, 20, 30, 50, 80, 150];
|
|||||||
/// capture; the resolved count drives the decoder + WASAPI render layout.
|
/// capture; the resolved count drives the decoder + WASAPI render layout.
|
||||||
const AUDIO_CHANNELS: &[(u8, &str)] = &[(2, "Stereo"), (6, "5.1 Surround"), (8, "7.1 Surround")];
|
const AUDIO_CHANNELS: &[(u8, &str)] = &[(2, "Stereo"), (6, "5.1 Surround"), (8, "7.1 Surround")];
|
||||||
|
|
||||||
|
/// punktfunk's own license (MIT OR Apache-2.0), shown on the Licenses screen.
|
||||||
|
const APP_LICENSE: &str = concat!(
|
||||||
|
include_str!("../../../LICENSE-MIT"),
|
||||||
|
"\n\n================================ Apache-2.0 ================================\n\n",
|
||||||
|
include_str!("../../../LICENSE-APACHE"),
|
||||||
|
);
|
||||||
|
/// Third-party software notices for the linked Rust crates (generated by
|
||||||
|
/// scripts/gen-third-party-notices.sh; the MSIX also ships this under licenses/).
|
||||||
|
const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt");
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
enum Screen {
|
enum Screen {
|
||||||
Hosts,
|
Hosts,
|
||||||
Connecting,
|
Connecting,
|
||||||
|
/// The no-PIN "request access" wait: an identified connect is in flight, parked by the host
|
||||||
|
/// until the operator approves this device in its console. Cancelable.
|
||||||
|
RequestAccess,
|
||||||
Stream,
|
Stream,
|
||||||
Settings,
|
Settings,
|
||||||
|
/// Open-source / third-party license notices (reached from Settings).
|
||||||
|
Licenses,
|
||||||
Pair,
|
Pair,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +149,11 @@ struct Shared {
|
|||||||
/// Latest stream stats, written by the session's event loop and mirrored into reactor state
|
/// Latest stream stats, written by the session's event loop and mirrored into reactor state
|
||||||
/// by the stream page's HUD poll thread to drive the overlay.
|
/// by the stream page's HUD poll thread to drive the overlay.
|
||||||
stats: Mutex<Stats>,
|
stats: Mutex<Stats>,
|
||||||
|
/// Cancel flag for the in-flight "request access" connect. A FRESH flag is installed per
|
||||||
|
/// request: the waiting screen's Cancel button reads it back from here and sets it, and that
|
||||||
|
/// request's event loop (which captured the same `Arc` at spawn) then tears down silently when
|
||||||
|
/// the parked connect finally resolves. `None` outside a request-access flow.
|
||||||
|
cancel: Mutex<Option<Arc<AtomicBool>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AppCtx {
|
pub struct AppCtx {
|
||||||
@@ -376,8 +398,13 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
.vertical_alignment(VerticalAlignment::Center)
|
.vertical_alignment(VerticalAlignment::Center)
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// request_access_page (like settings_page/Connecting) uses no hooks, so calling it inline
|
||||||
|
// is sound — it only wires a Cancel button to the shared cancel flag + navigation.
|
||||||
|
Screen::RequestAccess => request_access_page(ctx, &set_screen),
|
||||||
// settings_page uses no hooks (it never touches `cx`), so calling it inline is sound.
|
// settings_page uses no hooks (it never touches `cx`), so calling it inline is sound.
|
||||||
Screen::Settings => settings_page(ctx, &set_screen),
|
Screen::Settings => settings_page(ctx, &set_screen),
|
||||||
|
// licenses_page is a static text screen (no hooks), so inline is sound.
|
||||||
|
Screen::Licenses => licenses_page(&set_screen),
|
||||||
Screen::Pair => component(pair_page, svc),
|
Screen::Pair => component(pair_page, svc),
|
||||||
Screen::Stream => component(stream_page, StreamProps { svc, stats }),
|
Screen::Stream => component(stream_page, StreamProps { svc, stats }),
|
||||||
}
|
}
|
||||||
@@ -569,12 +596,61 @@ fn initiate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tunables that differ between the normal connect and the no-PIN "request access" flow.
|
||||||
|
/// `Default` is the normal connect: short handshake budget, persist *unpaired* on TOFU, and the
|
||||||
|
/// plain "Connecting" screen.
|
||||||
|
struct ConnectOpts {
|
||||||
|
/// Handshake budget. Request-access uses a long one because the host PARKS the connection
|
||||||
|
/// until the operator clicks Approve in its console (see the host's `PENDING_APPROVAL_WAIT`).
|
||||||
|
connect_timeout: Duration,
|
||||||
|
/// Persist the host as *paired* on a successful connect. Set for request-access, where the
|
||||||
|
/// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU
|
||||||
|
/// persists the host *unpaired* (pinned, but not PIN/approval-verified).
|
||||||
|
persist_paired: bool,
|
||||||
|
/// Show the cancelable "waiting for approval" screen instead of "Connecting" (request-access).
|
||||||
|
awaiting_approval: bool,
|
||||||
|
/// Set by the waiting screen's Cancel button. `NativeClient::connect` is blocking with no
|
||||||
|
/// abort, so Cancel returns the UI immediately and leaves the parked connect to resolve/time
|
||||||
|
/// out; this request's event loop then sees the flag and tears down silently (drops the
|
||||||
|
/// connector → closes the connection) without touching a screen a new session may already own.
|
||||||
|
cancel: Option<Arc<AtomicBool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ConnectOpts {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
connect_timeout: Duration::from_secs(15),
|
||||||
|
persist_paired: false,
|
||||||
|
awaiting_approval: false,
|
||||||
|
cancel: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn connect(
|
fn connect(
|
||||||
ctx: &Arc<AppCtx>,
|
ctx: &Arc<AppCtx>,
|
||||||
target: &Target,
|
target: &Target,
|
||||||
pin: Option<[u8; 32]>,
|
pin: Option<[u8; 32]>,
|
||||||
set_screen: &AsyncSetState<Screen>,
|
set_screen: &AsyncSetState<Screen>,
|
||||||
set_status: &AsyncSetState<String>,
|
set_status: &AsyncSetState<String>,
|
||||||
|
) {
|
||||||
|
connect_with(
|
||||||
|
ctx,
|
||||||
|
target,
|
||||||
|
pin,
|
||||||
|
set_screen,
|
||||||
|
set_status,
|
||||||
|
ConnectOpts::default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connect_with(
|
||||||
|
ctx: &Arc<AppCtx>,
|
||||||
|
target: &Target,
|
||||||
|
pin: Option<[u8; 32]>,
|
||||||
|
set_screen: &AsyncSetState<Screen>,
|
||||||
|
set_status: &AsyncSetState<String>,
|
||||||
|
opts: ConnectOpts,
|
||||||
) {
|
) {
|
||||||
let s = ctx.settings.lock().unwrap().clone();
|
let s = ctx.settings.lock().unwrap().clone();
|
||||||
let mode = if s.width != 0 && s.refresh_hz != 0 {
|
let mode = if s.width != 0 && s.refresh_hz != 0 {
|
||||||
@@ -607,29 +683,54 @@ fn connect(
|
|||||||
decoder: DecoderPref::from_name(&s.decoder),
|
decoder: DecoderPref::from_name(&s.decoder),
|
||||||
pin,
|
pin,
|
||||||
identity: ctx.identity.clone(),
|
identity: ctx.identity.clone(),
|
||||||
|
connect_timeout: opts.connect_timeout,
|
||||||
});
|
});
|
||||||
set_status.call(String::new());
|
set_status.call(String::new());
|
||||||
set_screen.call(Screen::Connecting);
|
set_screen.call(if opts.awaiting_approval {
|
||||||
|
Screen::RequestAccess
|
||||||
|
} else {
|
||||||
|
Screen::Connecting
|
||||||
|
});
|
||||||
|
|
||||||
let tofu = pin.is_none();
|
let tofu = pin.is_none();
|
||||||
|
let persist_paired = opts.persist_paired;
|
||||||
|
let cancel = opts.cancel;
|
||||||
let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
|
let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
|
||||||
let (ss, st) = (set_screen.clone(), set_status.clone());
|
let (ss, st) = (set_screen.clone(), set_status.clone());
|
||||||
let target = target.clone();
|
let target = target.clone();
|
||||||
std::thread::spawn(move || loop {
|
std::thread::spawn(move || loop {
|
||||||
match handle.events.recv_blocking() {
|
let event = match handle.events.recv_blocking() {
|
||||||
Ok(SessionEvent::Connected {
|
Ok(e) => e,
|
||||||
|
Err(_) => {
|
||||||
|
gamepad.detach();
|
||||||
|
ss.call(Screen::Hosts);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// A cancelled request-access connect that resolved late (the host approved or the park
|
||||||
|
// timed out after the user walked away): tear down silently. Cancel already returned the
|
||||||
|
// UI to the host list; dropping `event` (and with it any connector) closes the connection
|
||||||
|
// without popping a stream or a stray error over the screen a new session may own.
|
||||||
|
if cancel.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match event {
|
||||||
|
SessionEvent::Connected {
|
||||||
connector,
|
connector,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
..
|
..
|
||||||
}) => {
|
} => {
|
||||||
if tofu {
|
if persist_paired || tofu {
|
||||||
|
// Request-access: the operator approved this device, so record the host as a
|
||||||
|
// trusted PAIRED host — future connects are then silent (rule 1), exactly like
|
||||||
|
// after a PIN ceremony. A plain TOFU connect persists it *unpaired* (pinned).
|
||||||
let mut k = KnownHosts::load();
|
let mut k = KnownHosts::load();
|
||||||
k.upsert(KnownHost {
|
k.upsert(KnownHost {
|
||||||
name: target.name.clone(),
|
name: target.name.clone(),
|
||||||
addr: target.addr.clone(),
|
addr: target.addr.clone(),
|
||||||
port: target.port,
|
port: target.port,
|
||||||
fp_hex: trust::hex(&fingerprint),
|
fp_hex: trust::hex(&fingerprint),
|
||||||
paired: false,
|
paired: persist_paired,
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
}
|
}
|
||||||
@@ -638,10 +739,10 @@ fn connect(
|
|||||||
*shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone()));
|
*shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone()));
|
||||||
ss.call(Screen::Stream);
|
ss.call(Screen::Stream);
|
||||||
}
|
}
|
||||||
Ok(SessionEvent::Failed {
|
SessionEvent::Failed {
|
||||||
msg,
|
msg,
|
||||||
trust_rejected,
|
trust_rejected,
|
||||||
}) => {
|
} => {
|
||||||
st.call(msg);
|
st.call(msg);
|
||||||
gamepad.detach();
|
gamepad.detach();
|
||||||
if trust_rejected {
|
if trust_rejected {
|
||||||
@@ -653,22 +754,100 @@ fn connect(
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(SessionEvent::Ended(err)) => {
|
SessionEvent::Ended(err) => {
|
||||||
st.call(err.unwrap_or_else(|| "Session ended".into()));
|
st.call(err.unwrap_or_else(|| "Session ended".into()));
|
||||||
gamepad.detach();
|
gamepad.detach();
|
||||||
ss.call(Screen::Hosts);
|
ss.call(Screen::Hosts);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(SessionEvent::Stats(s)) => *shared.stats.lock().unwrap() = s,
|
SessionEvent::Stats(s) => *shared.stats.lock().unwrap() = s,
|
||||||
Err(_) => {
|
|
||||||
gamepad.detach();
|
|
||||||
ss.call(Screen::Hosts);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the
|
||||||
|
/// operator approves this device in its console (or web UI), showing a cancelable "waiting"
|
||||||
|
/// screen meanwhile. On approval the SAME connection is admitted (no reconnect) and the host is
|
||||||
|
/// saved as paired, so later connects are silent.
|
||||||
|
fn request_access(
|
||||||
|
ctx: &Arc<AppCtx>,
|
||||||
|
target: &Target,
|
||||||
|
set_screen: &AsyncSetState<Screen>,
|
||||||
|
set_status: &AsyncSetState<String>,
|
||||||
|
) {
|
||||||
|
// Pin the advertised certificate for a discovered host (defence against a host impostor while
|
||||||
|
// we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||||||
|
let pin = target.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||||
|
// A fresh cancel flag per request, installed where the waiting screen's Cancel button can read
|
||||||
|
// it back; this request's event loop captures the same `Arc` (via ConnectOpts) below.
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
*ctx.shared.cancel.lock().unwrap() = Some(cancel.clone());
|
||||||
|
connect_with(
|
||||||
|
ctx,
|
||||||
|
target,
|
||||||
|
pin,
|
||||||
|
set_screen,
|
||||||
|
set_status,
|
||||||
|
ConnectOpts {
|
||||||
|
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator
|
||||||
|
// approval still lands on this connection rather than timing the client out first.
|
||||||
|
connect_timeout: Duration::from_secs(185),
|
||||||
|
persist_paired: true,
|
||||||
|
awaiting_approval: true,
|
||||||
|
cancel: Some(cancel),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cancelable "waiting for approval" screen (request-access flow): a spinner + guidance while
|
||||||
|
/// the identified connect sits parked on the host, plus a Cancel that returns to the host list and
|
||||||
|
/// trips the shared cancel flag so the parked connect tears down silently if it resolves after the
|
||||||
|
/// user has walked away. Mirrors the inline `Connecting` screen; uses no hooks.
|
||||||
|
fn request_access_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
|
||||||
|
let target_name = ctx.shared.target.lock().unwrap().name.clone();
|
||||||
|
let headline = if target_name.is_empty() {
|
||||||
|
"Waiting for approval\u{2026}".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Waiting for {target_name} to approve\u{2026}")
|
||||||
|
};
|
||||||
|
let cancel_btn = {
|
||||||
|
let (ctx, ss) = (ctx.clone(), set_screen.clone());
|
||||||
|
button("Cancel")
|
||||||
|
.icon(SymbolGlyph::Cancel)
|
||||||
|
.on_click(move || {
|
||||||
|
// Return the UI immediately; the parked connect is blocking with no abort, so trip
|
||||||
|
// the flag this request's event loop captured — it then tears down silently when
|
||||||
|
// the connect finally resolves (see ConnectOpts::cancel).
|
||||||
|
if let Some(c) = ctx.shared.cancel.lock().unwrap().as_ref() {
|
||||||
|
c.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
ss.call(Screen::Hosts);
|
||||||
|
})
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Center)
|
||||||
|
};
|
||||||
|
vstack((
|
||||||
|
ProgressRing::indeterminate()
|
||||||
|
.width(48.0)
|
||||||
|
.height(48.0)
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Center),
|
||||||
|
text_block(headline)
|
||||||
|
.font_size(18.0)
|
||||||
|
.semibold()
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Center),
|
||||||
|
text_block(
|
||||||
|
"Approve this device in the host's console or web UI \u{2014} it connects automatically \
|
||||||
|
once you approve it. No PIN needed.",
|
||||||
|
)
|
||||||
|
.foreground(ThemeRef::SecondaryText)
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Center),
|
||||||
|
cancel_btn,
|
||||||
|
))
|
||||||
|
.spacing(16.0)
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Center)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
||||||
let ctx = &props.ctx;
|
let ctx = &props.ctx;
|
||||||
let set_screen = &props.set_screen;
|
let set_screen = &props.set_screen;
|
||||||
@@ -728,6 +907,20 @@ fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
|||||||
.icon(SymbolGlyph::Cancel)
|
.icon(SymbolGlyph::Cancel)
|
||||||
.on_click(move || ss.call(Screen::Hosts))
|
.on_click(move || ss.call(Screen::Hosts))
|
||||||
};
|
};
|
||||||
|
// The no-PIN alternative offered alongside the PIN ceremony: open an identified connect that
|
||||||
|
// the host parks until the operator approves this device in its console (delegated approval).
|
||||||
|
let request_btn = {
|
||||||
|
let (ctx2, ss, st, target2) = (
|
||||||
|
ctx.clone(),
|
||||||
|
set_screen.clone(),
|
||||||
|
set_status.clone(),
|
||||||
|
target.clone(),
|
||||||
|
);
|
||||||
|
button("Request access without a PIN")
|
||||||
|
.icon(SymbolGlyph::Send)
|
||||||
|
.on_click(move || request_access(&ctx2, &target2, &ss, &st))
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Stretch)
|
||||||
|
};
|
||||||
|
|
||||||
let content = card(vstack((
|
let content = card(vstack((
|
||||||
grid((
|
grid((
|
||||||
@@ -760,6 +953,13 @@ fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
|||||||
.font_size(28.0)
|
.font_size(28.0)
|
||||||
.on_changed(move |s| set_code.call(s)),
|
.on_changed(move |s| set_code.call(s)),
|
||||||
hstack((pair_btn, cancel_btn)).spacing(8.0),
|
hstack((pair_btn, cancel_btn)).spacing(8.0),
|
||||||
|
text_block(
|
||||||
|
"Don\u{2019}t have a PIN? Request access instead and approve this device on the host \
|
||||||
|
(its console or web UI) \u{2014} no PIN needed.",
|
||||||
|
)
|
||||||
|
.font_size(12.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
request_btn,
|
||||||
))
|
))
|
||||||
.spacing(16.0))
|
.spacing(16.0))
|
||||||
.max_width(480.0)
|
.max_width(480.0)
|
||||||
@@ -967,6 +1167,21 @@ fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Eleme
|
|||||||
.spacing(10.0),
|
.spacing(10.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let licenses_button = {
|
||||||
|
let ss = set_screen.clone();
|
||||||
|
button("Third-party licenses").on_click(move || ss.call(Screen::Licenses))
|
||||||
|
};
|
||||||
|
let about_card = card(
|
||||||
|
vstack((
|
||||||
|
text_block("About").font_size(15.0).semibold(),
|
||||||
|
text_block("punktfunk is licensed under MIT OR Apache-2.0.")
|
||||||
|
.font_size(12.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
licenses_button,
|
||||||
|
))
|
||||||
|
.spacing(10.0),
|
||||||
|
);
|
||||||
|
|
||||||
page(vec![
|
page(vec![
|
||||||
header.into(),
|
header.into(),
|
||||||
section("DISPLAY"),
|
section("DISPLAY"),
|
||||||
@@ -975,6 +1190,77 @@ fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Eleme
|
|||||||
video_card.into(),
|
video_card.into(),
|
||||||
section("AUDIO"),
|
section("AUDIO"),
|
||||||
audio_card.into(),
|
audio_card.into(),
|
||||||
|
section("ABOUT"),
|
||||||
|
about_card.into(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Static screen: the app's own license + the third-party software notices (reached from Settings).
|
||||||
|
fn licenses_page(set_screen: &AsyncSetState<Screen>) -> Element {
|
||||||
|
let header = grid((
|
||||||
|
text_block("Third-party licenses")
|
||||||
|
.font_size(30.0)
|
||||||
|
.bold()
|
||||||
|
.grid_column(0)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
|
button("Back")
|
||||||
|
.accent()
|
||||||
|
.icon(SymbolGlyph::Back)
|
||||||
|
.on_click({
|
||||||
|
let ss = set_screen.clone();
|
||||||
|
move || ss.call(Screen::Settings)
|
||||||
|
})
|
||||||
|
.grid_column(1)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
|
))
|
||||||
|
.columns([GridLength::Star(1.0), GridLength::Auto])
|
||||||
|
.margin(edges(0.0, 0.0, 0.0, 6.0));
|
||||||
|
|
||||||
|
let app_card = card(
|
||||||
|
vstack((
|
||||||
|
text_block("punktfunk").font_size(15.0).semibold(),
|
||||||
|
text_block("Licensed under MIT OR Apache-2.0, at your option.")
|
||||||
|
.font_size(12.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
text_block(APP_LICENSE)
|
||||||
|
.font_size(11.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
))
|
||||||
|
.spacing(8.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
let natives_card = card(
|
||||||
|
vstack((
|
||||||
|
text_block("Bundled components").font_size(15.0).semibold(),
|
||||||
|
text_block(
|
||||||
|
"FFmpeg is bundled under the LGPL v2.1+ (dynamically linked, replaceable DLLs); its \
|
||||||
|
license and notice ship in the installed licenses\\ folder. SDL 3 (Zlib) and the \
|
||||||
|
Windows App SDK (Microsoft) are also linked.",
|
||||||
|
)
|
||||||
|
.font_size(12.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
))
|
||||||
|
.spacing(8.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
let notices_card = card(
|
||||||
|
vstack((
|
||||||
|
text_block("Rust crates").font_size(15.0).semibold(),
|
||||||
|
text_block(THIRD_PARTY_NOTICES)
|
||||||
|
.font_size(11.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
))
|
||||||
|
.spacing(8.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
page(vec![
|
||||||
|
header.into(),
|
||||||
|
section("PUNKTFUNK"),
|
||||||
|
app_card.into(),
|
||||||
|
section("BUNDLED"),
|
||||||
|
natives_card.into(),
|
||||||
|
section("OPEN SOURCE"),
|
||||||
|
notices_card.into(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,9 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
|
|||||||
decoder,
|
decoder,
|
||||||
pin,
|
pin,
|
||||||
identity,
|
identity,
|
||||||
|
// Headless CLI uses the normal (short) handshake budget; the long request-access wait is a
|
||||||
|
// GUI-only flow.
|
||||||
|
connect_timeout: Duration::from_secs(15),
|
||||||
});
|
});
|
||||||
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(60);
|
let deadline = Instant::now() + Duration::from_secs(60);
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ pub struct SessionParams {
|
|||||||
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
||||||
pub pin: Option<[u8; 32]>,
|
pub pin: Option<[u8; 32]>,
|
||||||
pub identity: (String, String),
|
pub identity: (String, String),
|
||||||
|
/// How long to wait for the handshake. The normal path uses a short budget; the
|
||||||
|
/// "request access" (delegated-approval) path uses a long one, because the host PARKS the
|
||||||
|
/// connection until the operator clicks Approve in its console (so this must exceed the
|
||||||
|
/// host's approval window — see `PENDING_APPROVAL_WAIT`).
|
||||||
|
pub connect_timeout: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Default, PartialEq)]
|
#[derive(Clone, Copy, Default, PartialEq)]
|
||||||
@@ -164,7 +169,7 @@ fn pump(
|
|||||||
None, // launch: the Windows client has no library picker yet
|
None, // launch: the Windows client has no library picker yet
|
||||||
params.pin,
|
params.pin,
|
||||||
Some(params.identity),
|
Some(params.identity),
|
||||||
Duration::from_secs(15),
|
params.connect_timeout,
|
||||||
) {
|
) {
|
||||||
Ok(c) => Arc::new(c),
|
Ok(c) => Arc::new(c),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use anyhow::Result;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
/// The host's paired punktfunk/1 clients: `~/.config/punktfunk/punktfunk1-paired.json`.
|
/// The host's paired punktfunk/1 clients: `~/.config/punktfunk/punktfunk1-paired.json`.
|
||||||
/// (Separate from GameStream pairing, which has its own store and ceremony.)
|
/// (Separate from GameStream pairing, which has its own store and ceremony.)
|
||||||
@@ -76,6 +77,18 @@ pub struct PendingRequest {
|
|||||||
pub age_secs: u64,
|
pub age_secs: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The outcome of [`NativePairing::wait_for_decision`] — what an operator did with a parked,
|
||||||
|
/// unpaired knock (delegated approval, roadmap §8b-1).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum PairingDecision {
|
||||||
|
/// The operator clicked Approve (the fingerprint is now paired) — admit the session.
|
||||||
|
Approved,
|
||||||
|
/// The operator denied, or the pending entry was otherwise dropped without pairing — reject.
|
||||||
|
Denied,
|
||||||
|
/// No decision within the wait window — reject; the device can knock again.
|
||||||
|
TimedOut,
|
||||||
|
}
|
||||||
|
|
||||||
/// Pending knocks older than this are dropped (the device retries; a stale entry shouldn't be
|
/// 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).
|
/// approvable days later when the operator no longer remembers the context).
|
||||||
const PENDING_TTL: Duration = Duration::from_secs(10 * 60);
|
const PENDING_TTL: Duration = Duration::from_secs(10 * 60);
|
||||||
@@ -88,6 +101,11 @@ pub struct NativePairing {
|
|||||||
arm: Mutex<Armed>,
|
arm: Mutex<Armed>,
|
||||||
paired: Mutex<PairedState>,
|
paired: Mutex<PairedState>,
|
||||||
pending: Mutex<PendingState>,
|
pending: Mutex<PendingState>,
|
||||||
|
/// Notified whenever the trust/pending state changes (a fingerprint paired, or a pending knock
|
||||||
|
/// denied/dropped), so a QUIC connection parked in [`NativePairing::wait_for_decision`] wakes
|
||||||
|
/// the instant an operator acts in the console — the substrate for delegated approval admitting
|
||||||
|
/// a session with no client reconnect.
|
||||||
|
changed: Notify,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A snapshot for the management API / web console.
|
/// A snapshot for the management API / web console.
|
||||||
@@ -199,6 +217,7 @@ impl 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()),
|
pending: Mutex::new(PendingState::default()),
|
||||||
|
changed: Notify::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,10 +295,17 @@ impl NativePairing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A device that knocked and is now paired shouldn't linger in the approval list.
|
// A device that knocked and is now paired shouldn't linger in the approval list.
|
||||||
|
{
|
||||||
let mut pending = self.pending.lock().unwrap();
|
let mut pending = self.pending.lock().unwrap();
|
||||||
pending
|
pending
|
||||||
.items
|
.items
|
||||||
.retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex));
|
.retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex));
|
||||||
|
}
|
||||||
|
// Wake any connection parked in `wait_for_decision` for this fingerprint: pairing just
|
||||||
|
// completed (console approve or the PIN ceremony), so it can admit the session with no
|
||||||
|
// reconnect. Notified AFTER the pin AND the pending-clear so a woken waiter observes the
|
||||||
|
// fully settled state (paired = true, no longer pending) — see `wait_for_decision`.
|
||||||
|
self.changed.notify_waiters();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,6 +398,17 @@ impl NativePairing {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Is a knock for this fingerprint still awaiting approval? (Expired entries are dropped
|
||||||
|
/// first, so this also reports whether a parked knock is still live.)
|
||||||
|
pub fn pending_contains(&self, fp_hex: &str) -> bool {
|
||||||
|
let mut pending = self.pending.lock().unwrap();
|
||||||
|
Self::expire_pending(&mut pending);
|
||||||
|
pending
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
|
||||||
|
}
|
||||||
|
|
||||||
/// Approve a pending knock: pair its fingerprint (under `name_override` if the operator
|
/// 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
|
/// labeled it, else the knock's own name) and drop it from the queue. `Ok(None)` = no such
|
||||||
/// (or expired) id.
|
/// (or expired) id.
|
||||||
@@ -380,29 +417,78 @@ impl NativePairing {
|
|||||||
id: u32,
|
id: u32,
|
||||||
name_override: Option<&str>,
|
name_override: Option<&str>,
|
||||||
) -> Result<Option<PairedClient>> {
|
) -> Result<Option<PairedClient>> {
|
||||||
let entry = {
|
// Read (do NOT pre-remove) the entry: `add()` pins the fingerprint and THEN clears its
|
||||||
|
// pending entry — an order `wait_for_decision` relies on so a parked waiter never observes
|
||||||
|
// the device as "neither pending nor paired" (which would read as a denial). Removing here
|
||||||
|
// first would open exactly that window.
|
||||||
|
let (knock_name, fp_hex) = {
|
||||||
let mut pending = self.pending.lock().unwrap();
|
let mut pending = self.pending.lock().unwrap();
|
||||||
Self::expire_pending(&mut pending);
|
Self::expire_pending(&mut pending);
|
||||||
let Some(at) = pending.items.iter().position(|p| p.id == id) else {
|
match pending.items.iter().find(|p| p.id == id) {
|
||||||
return Ok(None);
|
Some(p) => (p.name.clone(), p.fp_hex.clone()),
|
||||||
};
|
None => return Ok(None),
|
||||||
pending.items.remove(at)
|
}
|
||||||
}; // pending lock released — add() takes the paired lock
|
}; // pending lock released — add() takes the paired then pending locks
|
||||||
let name = name_override.unwrap_or(&entry.name);
|
let name = name_override.unwrap_or(&knock_name).to_string();
|
||||||
self.add(name, &entry.fp_hex)?;
|
self.add(&name, &fp_hex)?; // pins, clears the pending entry, and notifies waiters
|
||||||
Ok(Some(PairedClient {
|
Ok(Some(PairedClient {
|
||||||
name: name.to_string(),
|
name,
|
||||||
fingerprint: entry.fp_hex,
|
fingerprint: fp_hex,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deny (drop) a pending knock. Returns whether one was removed. The device's next knock
|
/// 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.
|
/// re-creates an entry — deny is "not now", not a blocklist.
|
||||||
pub fn deny_pending(&self, id: u32) -> bool {
|
pub fn deny_pending(&self, id: u32) -> bool {
|
||||||
|
let removed = {
|
||||||
let mut pending = self.pending.lock().unwrap();
|
let mut pending = self.pending.lock().unwrap();
|
||||||
let before = pending.items.len();
|
let before = pending.items.len();
|
||||||
pending.items.retain(|p| p.id != id);
|
pending.items.retain(|p| p.id != id);
|
||||||
pending.items.len() != before
|
pending.items.len() != before
|
||||||
|
};
|
||||||
|
if removed {
|
||||||
|
// Wake a parked waiter so it returns `Denied` at once instead of holding the
|
||||||
|
// connection open until the approval window lapses.
|
||||||
|
self.changed.notify_waiters();
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Park (async) until an operator decides on a knock identified by `fp_hex`, up to `timeout`.
|
||||||
|
/// Returns [`PairingDecision::Approved`] the instant the fingerprint is paired (console
|
||||||
|
/// approve or a concurrent PIN ceremony), [`PairingDecision::Denied`] if its pending entry is
|
||||||
|
/// dropped without pairing, or [`PairingDecision::TimedOut`] if the window lapses. Holds no
|
||||||
|
/// lock across the await. The QUIC accept path calls this right after [`Self::note_pending`]
|
||||||
|
/// to keep the knocking connection open until a human clicks Approve — so the device pairs and
|
||||||
|
/// streams with no reconnect (delegated approval, roadmap §8b-1).
|
||||||
|
pub async fn wait_for_decision(&self, fp_hex: &str, timeout: Duration) -> PairingDecision {
|
||||||
|
let deadline = tokio::time::Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
// Arm the wakeup BEFORE re-reading state, and `enable()` it, so an approve/deny that
|
||||||
|
// lands between the state check and the await still wakes us (no lost notification).
|
||||||
|
let notified = self.changed.notified();
|
||||||
|
tokio::pin!(notified);
|
||||||
|
notified.as_mut().enable();
|
||||||
|
|
||||||
|
if self.is_paired(fp_hex) {
|
||||||
|
return PairingDecision::Approved;
|
||||||
|
}
|
||||||
|
if !self.pending_contains(fp_hex) {
|
||||||
|
// Neither pending nor paired. This is almost always a denial — but it can also be
|
||||||
|
// the tiny interval inside `add()` between pinning and clearing the pending entry.
|
||||||
|
// Re-check `is_paired` once: because `add()` pins BEFORE it clears pending, a
|
||||||
|
// cleared-pending observation that is really an approval will now read as paired.
|
||||||
|
if self.is_paired(fp_hex) {
|
||||||
|
return PairingDecision::Approved;
|
||||||
|
}
|
||||||
|
return PairingDecision::Denied;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = &mut notified => {}
|
||||||
|
_ = tokio::time::sleep_until(deadline) => return PairingDecision::TimedOut,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,4 +647,60 @@ mod tests {
|
|||||||
assert!(np.current_pin().is_none());
|
assert!(np.current_pin().is_none());
|
||||||
let _ = std::fs::remove_file(&p);
|
let _ = std::fs::remove_file(&p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn wait_for_decision_approve_deny_timeout() {
|
||||||
|
use std::sync::Arc;
|
||||||
|
let p = temp();
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
let np = Arc::new(NativePairing::load_with(Some(p.clone()), None, false).unwrap());
|
||||||
|
|
||||||
|
// TimedOut: a parked knock with no decision returns TimedOut; the entry survives.
|
||||||
|
np.note_pending("Knocker", "ab01");
|
||||||
|
let d = np
|
||||||
|
.wait_for_decision("ab01", Duration::from_millis(80))
|
||||||
|
.await;
|
||||||
|
assert_eq!(d, PairingDecision::TimedOut);
|
||||||
|
assert!(np.pending_contains("ab01"));
|
||||||
|
|
||||||
|
// Approved: approving WHILE parked wakes the waiter with Approved.
|
||||||
|
let np2 = np.clone();
|
||||||
|
let waiter =
|
||||||
|
tokio::spawn(
|
||||||
|
async move { np2.wait_for_decision("ab01", Duration::from_secs(5)).await },
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||||
|
let id = np
|
||||||
|
.pending()
|
||||||
|
.into_iter()
|
||||||
|
.find(|x| x.fingerprint == "ab01")
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
np.approve_pending(id, Some("Approved")).unwrap().unwrap();
|
||||||
|
assert_eq!(waiter.await.unwrap(), PairingDecision::Approved);
|
||||||
|
assert!(np.is_paired("ab01"));
|
||||||
|
|
||||||
|
// Denied: denying WHILE parked wakes the waiter with Denied (not held until timeout).
|
||||||
|
np.note_pending("Knock2", "cd02");
|
||||||
|
let np3 = np.clone();
|
||||||
|
let waiter =
|
||||||
|
tokio::spawn(
|
||||||
|
async move { np3.wait_for_decision("cd02", Duration::from_secs(5)).await },
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||||
|
let id = np
|
||||||
|
.pending()
|
||||||
|
.into_iter()
|
||||||
|
.find(|x| x.fingerprint == "cd02")
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
assert!(np.deny_pending(id));
|
||||||
|
assert_eq!(waiter.await.unwrap(), PairingDecision::Denied);
|
||||||
|
assert!(!np.is_paired("cd02"));
|
||||||
|
|
||||||
|
// Already paired before the call → immediate Approved (no waiting).
|
||||||
|
let d = np.wait_for_decision("ab01", Duration::from_secs(5)).await;
|
||||||
|
assert_eq!(d, PairingDecision::Approved);
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ pub struct Punktfunk1Options {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API.
|
/// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API.
|
||||||
use crate::native_pairing::NativePairing;
|
use crate::native_pairing::{NativePairing, PairingDecision};
|
||||||
/// The shared streaming-stats recorder (web-console capture/graph), shared with the management API
|
/// The shared streaming-stats recorder (web-console capture/graph), shared with the management API
|
||||||
/// and the GameStream loop; threaded into each session's `SessionContext`.
|
/// and the GameStream loop; threaded into each session's `SessionContext`.
|
||||||
use crate::stats_recorder::StatsRecorder;
|
use crate::stats_recorder::StatsRecorder;
|
||||||
@@ -290,8 +290,11 @@ pub(crate) async fn serve(
|
|||||||
let stats = stats.clone();
|
let stats = stats.clone();
|
||||||
let inj_tx = injector.sender();
|
let inj_tx = injector.sender();
|
||||||
let mic_tx = mic_service.sender();
|
let mic_tx = mic_service.sender();
|
||||||
|
// The session permit + the pool it came from are handed to serve_session, which owns the
|
||||||
|
// permit's lifetime: it's released while a knock is parked for delegated approval and
|
||||||
|
// re-acquired on approval, so the hold is no longer a simple closure-scoped binding.
|
||||||
|
let sem_session = sem.clone();
|
||||||
sessions.spawn(async move {
|
sessions.spawn(async move {
|
||||||
let _permit = permit; // held for the session's lifetime; frees a slot on completion
|
|
||||||
match serve_session(
|
match serve_session(
|
||||||
conn,
|
conn,
|
||||||
&opts,
|
&opts,
|
||||||
@@ -302,6 +305,8 @@ pub(crate) async fn serve(
|
|||||||
&np,
|
&np,
|
||||||
&last_pairing,
|
&last_pairing,
|
||||||
stats,
|
stats,
|
||||||
|
permit,
|
||||||
|
sem_session,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -410,6 +415,14 @@ type AudioCapSlot = Arc<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCaptu
|
|||||||
/// client), so its budget is far larger than the machine-speed session handshake.
|
/// client), so its budget is far larger than the machine-speed session handshake.
|
||||||
const PAIRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
const PAIRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// How long the host keeps an unpaired knock PARKED — connection held open — waiting for the
|
||||||
|
/// operator to click Approve in the console (delegated approval, roadmap §8b-1). The QUIC
|
||||||
|
/// keep-alive (4 s, under the 8 s idle timeout) holds the path warm meanwhile, so on approval the
|
||||||
|
/// device pairs and streams with NO reconnect. Bounded well under the pending entry's TTL (10 min);
|
||||||
|
/// the client uses a comparable connect timeout, and a client that gives up first closes the
|
||||||
|
/// connection (the host stops waiting at once).
|
||||||
|
const PENDING_APPROVAL_WAIT: std::time::Duration = std::time::Duration::from_secs(180);
|
||||||
|
|
||||||
/// The host side of the SPAKE2 pairing ceremony (see `punktfunk_core::quic::pake`):
|
/// The host side of the SPAKE2 pairing ceremony (see `punktfunk_core::quic::pake`):
|
||||||
/// generate + display a PIN, run SPAKE2 as B binding both cert fingerprints, verify the
|
/// generate + display a PIN, run SPAKE2 as B binding both cert fingerprints, verify the
|
||||||
/// client's key-confirmation MAC (its single online guess), and persist the client's
|
/// client's key-confirmation MAC (its single online guess), and persist the client's
|
||||||
@@ -502,6 +515,11 @@ async fn serve_session(
|
|||||||
np: &NativePairing,
|
np: &NativePairing,
|
||||||
last_pairing: &std::sync::Mutex<Option<std::time::Instant>>,
|
last_pairing: &std::sync::Mutex<Option<std::time::Instant>>,
|
||||||
stats: Arc<StatsRecorder>,
|
stats: Arc<StatsRecorder>,
|
||||||
|
// The session slot. Owned here (not just held by the spawning task) because an unpaired knock
|
||||||
|
// RELEASES it while parked for delegated approval, then RE-ACQUIRES one on approval — so a
|
||||||
|
// parked knock can't hold a streaming slot. `sem` is the pool it re-acquires from.
|
||||||
|
mut permit: tokio::sync::OwnedSemaphorePermit,
|
||||||
|
sem: Arc<tokio::sync::Semaphore>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let peer = conn.remote_address();
|
let peer = conn.remote_address();
|
||||||
|
|
||||||
@@ -531,6 +549,79 @@ async fn serve_session(
|
|||||||
return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin).await;
|
return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pairing gate for a session Hello (a PairRequest was handled above). Lifted OUT of the
|
||||||
|
// `handshake` future below for two reasons: (1) the approval wait must not be bound by the
|
||||||
|
// short HANDSHAKE_TIMEOUT — a human reads the console and clicks Approve; (2) the NVENC session
|
||||||
|
// permit is released while parked, so a knock awaiting approval can't hold a streaming slot.
|
||||||
|
// On approval the device is now paired, so the handshake proceeds and the session starts with
|
||||||
|
// NO client reconnect (delegated approval, roadmap §8b-1).
|
||||||
|
if opts.require_pairing {
|
||||||
|
// Decode just enough to gate (the Hello carries the device name for the pending label);
|
||||||
|
// the `handshake` future re-decodes for the real session — a few dozen bytes, negligible.
|
||||||
|
let gate_hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
gate_hello.abi_version == punktfunk_core::ABI_VERSION,
|
||||||
|
"ABI mismatch: client {} host {}",
|
||||||
|
gate_hello.abi_version,
|
||||||
|
punktfunk_core::ABI_VERSION
|
||||||
|
);
|
||||||
|
let fp = endpoint::peer_fingerprint(&conn);
|
||||||
|
let known = fp
|
||||||
|
.as_ref()
|
||||||
|
.map(|fp| np.is_paired(&fingerprint_hex(fp)))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !known {
|
||||||
|
// An anonymous client (no certificate) has no identity to approve — reject outright
|
||||||
|
// (the PIN ceremony is its way in). Mirrors the prior behavior for anonymous knocks.
|
||||||
|
let Some(fp) = fp else {
|
||||||
|
anyhow::bail!(
|
||||||
|
"unpaired anonymous client rejected (this host requires pairing — present a \
|
||||||
|
client identity and approve it in the console, or run the PIN ceremony)"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let fp_hex = fingerprint_hex(&fp);
|
||||||
|
// Sanitize the wire-supplied name before it reaches the log / console (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(
|
||||||
|
gate_hello.name.as_deref().unwrap_or(""),
|
||||||
|
&fp_hex,
|
||||||
|
);
|
||||||
|
tracing::info!(name = %label, fingerprint = %fp_hex,
|
||||||
|
"unpaired device knocked — parking connection for delegated approval in the console");
|
||||||
|
np.note_pending(&label, &fp_hex);
|
||||||
|
// Free the session slot while a human decides — a parked knock must not hold an NVENC
|
||||||
|
// permit (a handful of parked knocks would otherwise block every real session).
|
||||||
|
drop(permit);
|
||||||
|
let decision = tokio::select! {
|
||||||
|
d = np.wait_for_decision(&fp_hex, PENDING_APPROVAL_WAIT) => d,
|
||||||
|
// The client gave up (closed the connection) before a decision — stop waiting.
|
||||||
|
_ = conn.closed() => anyhow::bail!("client disconnected before pairing approval"),
|
||||||
|
};
|
||||||
|
match decision {
|
||||||
|
PairingDecision::Approved => {
|
||||||
|
tracing::info!(name = %label, fingerprint = %fp_hex,
|
||||||
|
"device approved in console — admitting session (no reconnect)");
|
||||||
|
}
|
||||||
|
PairingDecision::Denied => anyhow::bail!("pairing request denied in the console"),
|
||||||
|
PairingDecision::TimedOut => anyhow::bail!(
|
||||||
|
"pairing request not approved within {PENDING_APPROVAL_WAIT:?} \
|
||||||
|
— the device can knock again"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
// Re-acquire a session slot for the now-approved session (waits if all slots are busy,
|
||||||
|
// exactly like any freshly accepted client).
|
||||||
|
permit = sem
|
||||||
|
.clone()
|
||||||
|
.acquire_owned()
|
||||||
|
.await
|
||||||
|
.expect("session semaphore is never closed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Held for the rest of the session (RAII frees the slot on return). For an already-paired
|
||||||
|
// client this is the original permit; for a just-approved knock it's the re-acquired one.
|
||||||
|
let _permit = permit;
|
||||||
|
|
||||||
let source = opts.source;
|
let source = opts.source;
|
||||||
let frames = opts.frames;
|
let frames = opts.frames;
|
||||||
let handshake = async {
|
let handshake = async {
|
||||||
@@ -541,36 +632,8 @@ async fn serve_session(
|
|||||||
hello.abi_version,
|
hello.abi_version,
|
||||||
punktfunk_core::ABI_VERSION
|
punktfunk_core::ABI_VERSION
|
||||||
);
|
);
|
||||||
if opts.require_pairing {
|
// The pairing gate (require_pairing → paired? else park for delegated approval) ran above,
|
||||||
let fp = endpoint::peer_fingerprint(&conn);
|
// before this future, so a client reaching here is paired (or the host is `--open`).
|
||||||
let known = fp
|
|
||||||
.as_ref()
|
|
||||||
.map(|fp| np.is_paired(&fingerprint_hex(fp)))
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !known {
|
|
||||||
// Delegated approval (§8b-1): an identified-but-unpaired knock becomes a pending
|
|
||||||
// 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,
|
||||||
hello.mode.width,
|
hello.mode.width,
|
||||||
@@ -4110,10 +4173,11 @@ 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
|
/// Delegated approval (§8b-1) end to end in-process, the SEAMLESS flow: an
|
||||||
/// knock on a pairing-required host is held as a pending request (fingerprint-derived label —
|
/// identified-but-unpaired client's knock on a pairing-required host is PARKED (connection held
|
||||||
/// the connector sends no Hello name); approving it pairs the fingerprint, and the same
|
/// open) and shows up as a pending request (fingerprint-derived label — the connector sends no
|
||||||
/// identity then gets a session with no PIN ceremony.
|
/// Hello name); the operator approves it WHILE the client waits, and the SAME connection is
|
||||||
|
/// admitted to a session with no PIN and no reconnect.
|
||||||
#[test]
|
#[test]
|
||||||
fn delegated_approval_admits_after_knock() {
|
fn delegated_approval_admits_after_knock() {
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
@@ -4136,7 +4200,7 @@ mod tests {
|
|||||||
source: Punktfunk1Source::Synthetic,
|
source: Punktfunk1Source::Synthetic,
|
||||||
seconds: 0,
|
seconds: 0,
|
||||||
frames: 25,
|
frames: 25,
|
||||||
max_sessions: 2, // the knock + the post-approval session
|
max_sessions: 1, // the single parked-then-approved session (no reconnect)
|
||||||
max_concurrent: 1,
|
max_concurrent: 1,
|
||||||
require_pairing: true,
|
require_pairing: true,
|
||||||
allow_pairing: false,
|
allow_pairing: false,
|
||||||
@@ -4150,49 +4214,47 @@ mod tests {
|
|||||||
))
|
))
|
||||||
});
|
});
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
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 (cert, key) = endpoint::generate_identity().unwrap();
|
||||||
|
let expected_fp = fingerprint_hex(&endpoint::fingerprint_of_pem(&cert).unwrap());
|
||||||
let mode = punktfunk_core::Mode {
|
let mode = punktfunk_core::Mode {
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 720,
|
height: 720,
|
||||||
refresh_hz: 60,
|
refresh_hz: 60,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1: the knock — an identified-but-unpaired connect is rejected, but lands in pending.
|
// Approver thread: wait for the parked knock to register, assert its label, then APPROVE it
|
||||||
|
// WHILE the client is still parked — the console "click accept" flow.
|
||||||
|
let np_approve = np.clone();
|
||||||
|
let expect_fp = expected_fp.clone();
|
||||||
|
let approver = std::thread::spawn(move || {
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);
|
||||||
|
let pend = loop {
|
||||||
|
if let Some(p) = np_approve
|
||||||
|
.pending()
|
||||||
|
.into_iter()
|
||||||
|
.find(|p| p.fingerprint == expect_fp)
|
||||||
|
{
|
||||||
|
break p;
|
||||||
|
}
|
||||||
assert!(
|
assert!(
|
||||||
NativeClient::connect(
|
std::time::Instant::now() < deadline,
|
||||||
"127.0.0.1",
|
"the knock must register while the client is parked"
|
||||||
19779,
|
|
||||||
mode,
|
|
||||||
CompositorPref::Auto,
|
|
||||||
GamepadPref::Auto,
|
|
||||||
0,
|
|
||||||
0, // video_caps
|
|
||||||
2, // audio_channels (stereo)
|
|
||||||
None, // launch
|
|
||||||
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());
|
std::thread::sleep(std::time::Duration::from_millis(40));
|
||||||
let pend = np.pending();
|
};
|
||||||
assert_eq!(pend.len(), 1, "the knock must be held for approval");
|
|
||||||
assert_eq!(pend[0].fingerprint, expected_fp);
|
|
||||||
assert!(
|
assert!(
|
||||||
pend[0].name.starts_with("device "),
|
pend.name.starts_with("device "),
|
||||||
"no Hello name → fingerprint-derived label, got {:?}",
|
"no Hello name → fingerprint-derived label, got {:?}",
|
||||||
pend[0].name
|
pend.name
|
||||||
);
|
);
|
||||||
|
np_approve
|
||||||
// 2: approve (with an operator label) → the same identity now gets a session, no PIN.
|
.approve_pending(pend.id, Some("Approved Device"))
|
||||||
let approved = np
|
|
||||||
.approve_pending(pend[0].id, Some("Approved Device"))
|
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("pending id must approve");
|
.expect("pending id must approve");
|
||||||
assert_eq!(approved.fingerprint, expected_fp);
|
});
|
||||||
|
|
||||||
|
// The knock: a SINGLE connect that parks until approved, then streams — no reconnect. The
|
||||||
|
// timeout is generous (it covers the park + the approver's poll latency).
|
||||||
let client = NativeClient::connect(
|
let client = NativeClient::connect(
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
19779,
|
19779,
|
||||||
@@ -4203,11 +4265,17 @@ mod tests {
|
|||||||
0, // video_caps
|
0, // video_caps
|
||||||
2, // audio_channels (stereo)
|
2, // audio_channels (stereo)
|
||||||
None, // launch
|
None, // launch
|
||||||
None,
|
None, // pin: TOFU — the operator's approval (not a PIN) authorizes this client
|
||||||
Some((cert, key)),
|
Some((cert, key)),
|
||||||
timeout,
|
std::time::Duration::from_secs(15),
|
||||||
)
|
)
|
||||||
.expect("approved identity gets a session");
|
.expect("approved mid-park → session admitted with no reconnect");
|
||||||
|
approver.join().unwrap();
|
||||||
|
assert!(
|
||||||
|
np.is_paired(&expected_fp),
|
||||||
|
"approval must pin the knocking fingerprint"
|
||||||
|
);
|
||||||
|
assert_eq!(np.list()[0].name, "Approved Device");
|
||||||
drop(client);
|
drop(client);
|
||||||
let _ = std::fs::remove_file(&store);
|
let _ = std::fs::remove_file(&store);
|
||||||
host.join().unwrap().unwrap();
|
host.join().unwrap().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user