Files
punktfunk/crates/punktfunk-host/src/mgmt/native.rs
T
enricobuehler ecfa71212d
audit / bun-audit (push) Successful in 27s
windows-drivers / probe-and-proto (push) Successful in 49s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m10s
apple / swift (push) Successful in 1m18s
decky / build-publish (push) Successful in 34s
windows-drivers / driver-build (push) Successful in 1m37s
audit / cargo-audit (push) Successful in 3m1s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
ci / bench (push) Successful in 5m56s
windows-host / package (push) Failing after 5m17s
apple / screenshots (push) Successful in 4m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m2s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
arch / build-publish (push) Successful in 16m50s
android / android (push) Successful in 17m7s
docker / deploy-docs (push) Successful in 28s
deb / build-publish (push) Successful in 17m6s
ci / rust (push) Failing after 19m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m3s
flatpak / build-publish (push) Successful in 8m0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m14s
release / apple (push) Successful in 5m44s
chore: consolidate all in-progress parallel-session WIP
Wholesale commit of every uncommitted change across the tree, at the user's
explicit request — host refactor-campaign W1 (native.rs facade + native/ dir,
library/ + mgmt/ splits), Android, core. These streams were mid-flight and not
individually built/tested together; this supersedes the per-session HOLD
markers. Consolidating so everything lands on main in one pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 20:08:29 +02:00

362 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Native (punktfunk/1) pairing endpoints: arm/disarm a window, paired-device management, and
//! delegated approval of pending knocks. Split out of the `mgmt` facade (plan §W5).
use super::shared::*;
/// Native (punktfunk/1) pairing status. Unlike GameStream, the **host** mints the PIN (the SPAKE2
/// ceremony needs it client-side first), so the console **displays** `pin` for the user to enter on
/// their device — armed on demand for a short window.
#[derive(Serialize, ToSchema)]
pub(crate) struct NativePairStatus {
/// Whether the native host is running (the unified host started with `--native`).
enabled: bool,
/// True while a pairing window is open.
armed: bool,
/// The PIN to display while armed (null when disarmed).
#[schema(example = "1234")]
pin: Option<String>,
/// Seconds left in the window (null = disarmed, or armed with no expiry via the CLI flag).
expires_in_secs: Option<u64>,
/// Number of paired native clients.
paired_clients: u32,
}
/// Arm-native-pairing request body.
#[derive(Deserialize, ToSchema)]
pub(crate) struct ArmNativePairing {
/// Window length in seconds (default 120; clamped to 15600).
#[schema(example = 120)]
ttl_secs: Option<u32>,
/// Optional: bind the window to ONE device fingerprint (hex SHA-256, e.g. from a pending knock).
/// When set, only a pairing attempt from that fingerprint consumes the window — so an unpaired
/// LAN peer can neither pair nor burn a window armed for a specific device (security-review #9).
/// Omit for an unbound window (any device may use the PIN — trusted-LAN only).
#[schema(example = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")]
fingerprint: Option<String>,
}
/// A paired native (punktfunk/1) client.
#[derive(Serialize, ToSchema)]
pub(crate) struct NativeClient {
/// The name the client supplied when pairing.
#[schema(example = "Living Room iPad")]
name: String,
/// Hex SHA-256 of the client certificate — its stable id here.
fingerprint: String,
}
/// An unpaired device that tried to connect while the host requires pairing — awaiting
/// **delegated approval** (approve it here instead of fetching the host PIN out of band).
#[derive(Serialize, ToSchema)]
pub(crate) struct PendingDevice {
/// Id to address approve/deny (per-process; entries expire after ~10 minutes).
id: u32,
/// Best-effort device label (the client's own name, else fingerprint-derived).
#[schema(example = "Enrico's MacBook")]
name: String,
/// Hex SHA-256 of the device's certificate — what approval pins.
fingerprint: String,
/// Seconds since the device last knocked.
age_secs: u64,
}
/// Approve-pending-device request body. Send `{}` to keep the device's own name.
#[derive(Deserialize, ToSchema)]
pub(crate) struct ApprovePending {
/// Operator-chosen label for the device (defaults to the name it knocked with).
#[schema(example = "Living Room TV")]
name: Option<String>,
}
pub(crate) fn native_status(st: &MgmtState) -> NativePairStatus {
match &st.native {
Some(np) => {
let s = np.status();
NativePairStatus {
enabled: true,
armed: s.armed,
pin: s.pin,
expires_in_secs: s.expires_in_secs,
paired_clients: s.paired_clients,
}
}
None => NativePairStatus {
enabled: false,
armed: false,
pin: None,
expires_in_secs: None,
paired_clients: 0,
},
}
}
/// Native pairing status
///
/// The native (punktfunk/1) pairing window. Poll while armed to show the PIN + countdown.
/// `enabled: false` means this host runs GameStream only (no `--native`).
#[utoipa::path(
get,
path = "/native/pair",
tag = "native",
operation_id = "getNativePairing",
responses(
(status = OK, description = "Native pairing status", body = NativePairStatus),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_native_pairing(State(st): State<Arc<MgmtState>>) -> Json<NativePairStatus> {
Json(native_status(&st))
}
/// Arm native pairing
///
/// Opens a pairing window and mints a fresh PIN to display. The user enters it on their device
/// within `ttl_secs`; the device then appears in the native client list.
#[utoipa::path(
post,
path = "/native/pair/arm",
tag = "native",
operation_id = "armNativePairing",
request_body = ArmNativePairing,
responses(
(status = OK, description = "Pairing armed; the response carries the PIN to display", body = NativePairStatus),
(status = SERVICE_UNAVAILABLE, description = "Native host not available in this process", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn arm_native_pairing(
State(st): State<Arc<MgmtState>>,
ApiJson(req): ApiJson<ArmNativePairing>,
) -> Response {
let Some(np) = &st.native else {
return api_error(
StatusCode::SERVICE_UNAVAILABLE,
"native host not available in this process",
);
};
let ttl = req.ttl_secs.unwrap_or(120).clamp(15, 600);
// A bound window (operator selected a specific device) is DoS-proof: only that fingerprint can
// consume it (#9). An unbound window (no fingerprint) keeps the legacy any-device behavior.
let bound = req
.fingerprint
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_ascii_lowercase());
let bound_to_device = bound.is_some();
let _pin = np.arm_for(std::time::Duration::from_secs(ttl as u64), bound);
tracing::info!(
ttl_secs = ttl,
bound_to_device,
"management API: native pairing armed"
);
Json(native_status(&st)).into_response()
}
/// Disarm native pairing
///
/// Closes the pairing window immediately (no new ceremonies accepted).
#[utoipa::path(
delete,
path = "/native/pair",
tag = "native",
operation_id = "disarmNativePairing",
responses(
(status = NO_CONTENT, description = "Pairing disarmed"),
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn disarm_native_pairing(State(st): State<Arc<MgmtState>>) -> Response {
let Some(np) = &st.native else {
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
};
np.disarm();
StatusCode::NO_CONTENT.into_response()
}
/// List native paired clients
#[utoipa::path(
get,
path = "/native/clients",
tag = "native",
operation_id = "listNativeClients",
responses(
(status = OK, description = "Paired native clients", body = [NativeClient]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn list_native_clients(
State(st): State<Arc<MgmtState>>,
) -> Json<Vec<NativeClient>> {
let clients = match &st.native {
Some(np) => np
.list()
.into_iter()
.map(|c| NativeClient {
name: c.name,
fingerprint: c.fingerprint,
})
.collect(),
None => Vec::new(),
};
Json(clients)
}
/// Unpair a native client
///
/// Removes a punktfunk/1 client from the native trust store by fingerprint.
#[utoipa::path(
delete,
path = "/native/clients/{fingerprint}",
tag = "native",
operation_id = "unpairNativeClient",
params(
("fingerprint" = String, Path,
description = "Hex SHA-256 of the client certificate (case-insensitive)")
),
responses(
(status = NO_CONTENT, description = "Client unpaired"),
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
(status = NOT_FOUND, description = "No paired native client with that fingerprint", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn unpair_native_client(
State(st): State<Arc<MgmtState>>,
Path(fingerprint): Path<String>,
) -> Response {
let Some(np) = &st.native else {
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
};
match np.remove(&fingerprint) {
Ok(true) => {
tracing::info!(fingerprint, "management API: native client unpaired");
StatusCode::NO_CONTENT.into_response()
}
Ok(false) => api_error(
StatusCode::NOT_FOUND,
"no paired native client with that fingerprint",
),
Err(e) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("could not persist trust store: {e}"),
),
}
}
/// List devices awaiting pairing approval
///
/// Unpaired devices that tried to connect while the host requires pairing. Approve one to pair
/// it without a PIN (delegated approval); entries expire after ~10 minutes.
#[utoipa::path(
get,
path = "/native/pending",
tag = "native",
operation_id = "listPendingDevices",
responses(
(status = OK, description = "Devices awaiting approval (empty when none, or when the \
native host is not enabled)", body = Vec<PendingDevice>),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn list_pending_devices(
State(st): State<Arc<MgmtState>>,
) -> Json<Vec<PendingDevice>> {
let pending = st
.native
.as_ref()
.map(|np| np.pending())
.unwrap_or_default();
Json(
pending
.into_iter()
.map(|p| PendingDevice {
id: p.id,
name: p.name,
fingerprint: p.fingerprint,
age_secs: p.age_secs,
})
.collect(),
)
}
/// Approve a pending device
///
/// Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally
/// relabel it via the body; send `{}` to keep the name it knocked with.
#[utoipa::path(
post,
path = "/native/pending/{id}/approve",
tag = "native",
operation_id = "approvePendingDevice",
params(("id" = u32, Path, description = "Pending-request id from the pending list")),
request_body = ApprovePending,
responses(
(status = OK, description = "Device paired", body = NativeClient),
(status = NOT_FOUND, description = "No pending request with that id (expired?)", body = ApiError),
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the trust store", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn approve_pending_device(
State(st): State<Arc<MgmtState>>,
Path(id): Path<u32>,
ApiJson(req): ApiJson<ApprovePending>,
) -> Response {
let Some(np) = &st.native else {
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
};
match np.approve_pending(id, req.name.as_deref()) {
Ok(Some(client)) => {
tracing::info!(name = %client.name, fingerprint = %client.fingerprint,
"management API: pending device approved (delegated pairing)");
Json(NativeClient {
name: client.name,
fingerprint: client.fingerprint,
})
.into_response()
}
Ok(None) => api_error(
StatusCode::NOT_FOUND,
"no pending request with that id (it may have expired — have the device retry)",
),
Err(e) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("could not persist trust store: {e}"),
),
}
}
/// Deny a pending device
///
/// Drops the request. Not a blocklist — the device's next attempt knocks again.
#[utoipa::path(
post,
path = "/native/pending/{id}/deny",
tag = "native",
operation_id = "denyPendingDevice",
params(("id" = u32, Path, description = "Pending-request id from the pending list")),
responses(
(status = NO_CONTENT, description = "Request dropped"),
(status = NOT_FOUND, description = "No pending request with that id", body = ApiError),
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn deny_pending_device(
State(st): State<Arc<MgmtState>>,
Path(id): Path<u32>,
) -> Response {
let Some(np) = &st.native else {
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
};
if np.deny_pending(id) {
tracing::info!(id, "management API: pending device denied");
StatusCode::NO_CONTENT.into_response()
} else {
api_error(StatusCode::NOT_FOUND, "no pending request with that id")
}
}