chore: consolidate all in-progress parallel-session WIP
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

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>
This commit is contained in:
2026-07-16 20:08:29 +02:00
parent 07e2836601
commit ecfa71212d
67 changed files with 9456 additions and 8062 deletions
+174
View File
@@ -0,0 +1,174 @@
//! Client/pairing-tagged management endpoints: paired Moonlight clients and the GameStream
//! pairing PIN flow. Split out of the `mgmt` facade (plan §W5).
use super::shared::*;
use sha2::{Digest, Sha256};
/// A paired (certificate-pinned) Moonlight client.
#[derive(Serialize, ToSchema)]
pub(crate) struct PairedClient {
/// Lowercase hex SHA-256 of the client certificate DER — the client's stable id here.
#[schema(example = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")]
fingerprint: String,
/// Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.
subject: Option<String>,
/// Certificate validity start (unix seconds).
not_before_unix: Option<i64>,
/// Certificate validity end (unix seconds).
not_after_unix: Option<i64>,
}
/// Pairing-flow status.
#[derive(Serialize, ToSchema)]
pub(crate) struct PairingStatus {
/// True while a pairing handshake is parked waiting for the user's PIN.
pin_pending: bool,
}
/// The PIN Moonlight displays during pairing.
#[derive(Deserialize, ToSchema)]
pub(crate) struct SubmitPin {
/// 116 ASCII digits (Moonlight shows 4).
#[schema(example = "1234")]
pin: String,
}
/// List paired clients
#[utoipa::path(
get,
path = "/clients",
tag = "clients",
operation_id = "listPairedClients",
responses(
(status = OK, description = "All certificate-pinned clients", body = [PairedClient]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn list_paired_clients(
State(st): State<Arc<MgmtState>>,
) -> Json<Vec<PairedClient>> {
let ders = st.app.paired.lock().unwrap().clone();
Json(ders.iter().map(|der| client_info(der)).collect())
}
pub(crate) fn client_info(der: &[u8]) -> PairedClient {
let fingerprint = hex::encode(Sha256::digest(der));
match x509_parser::parse_x509_certificate(der) {
Ok((_, x509)) => PairedClient {
fingerprint,
subject: Some(x509.subject().to_string()),
not_before_unix: Some(x509.validity().not_before.timestamp()),
not_after_unix: Some(x509.validity().not_after.timestamp()),
},
Err(_) => PairedClient {
fingerprint,
subject: None,
not_before_unix: None,
not_after_unix: None,
},
}
}
/// Unpair a client
///
/// Removes the client's certificate from the pairing store. Caveat: the nvhttp TLS layer
/// does not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed
/// client cert — a planned hardening step), so until that lands this removes the client
/// from the listing without severing its ability to reconnect.
#[utoipa::path(
delete,
path = "/clients/{fingerprint}",
tag = "clients",
operation_id = "unpairClient",
params(
("fingerprint" = String, Path,
description = "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)")
),
responses(
(status = NO_CONTENT, description = "Client unpaired"),
(status = BAD_REQUEST, description = "Malformed fingerprint", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = NOT_FOUND, description = "No paired client with that fingerprint", body = ApiError),
)
)]
pub(crate) async fn unpair_client(
State(st): State<Arc<MgmtState>>,
Path(fingerprint): Path<String>,
) -> Response {
if fingerprint.len() != 64 || !fingerprint.bytes().all(|b| b.is_ascii_hexdigit()) {
return api_error(
StatusCode::BAD_REQUEST,
"fingerprint must be the 64-char hex SHA-256 of the client certificate DER",
);
}
let mut paired = st.app.paired.lock().unwrap();
let before = paired.len();
paired.retain(|der| !hex::encode(Sha256::digest(der)).eq_ignore_ascii_case(&fingerprint));
if paired.len() < before {
tracing::info!(fingerprint, "management API: client unpaired");
StatusCode::NO_CONTENT.into_response()
} else {
api_error(
StatusCode::NOT_FOUND,
"no paired client with that fingerprint",
)
}
}
/// Pairing-flow status
///
/// Poll this to know when to prompt the user for the PIN Moonlight displays.
#[utoipa::path(
get,
path = "/pair",
tag = "pairing",
operation_id = "getPairingStatus",
responses(
(status = OK, description = "Whether a pairing handshake is waiting for a PIN", body = PairingStatus),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_pairing_status(State(st): State<Arc<MgmtState>>) -> Json<PairingStatus> {
Json(PairingStatus {
pin_pending: st.app.pairing.pin.awaiting_pin(),
})
}
/// Submit the pairing PIN
///
/// Delivers the PIN the Moonlight client is displaying, completing the out-of-band half
/// of the pairing handshake.
#[utoipa::path(
post,
path = "/pair/pin",
tag = "pairing",
operation_id = "submitPairingPin",
request_body = SubmitPin,
responses(
(status = NO_CONTENT, description = "PIN delivered to the waiting handshake"),
(status = BAD_REQUEST, description = "Malformed PIN or unparseable JSON body", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = CONFLICT, description = "No pairing handshake is waiting for a PIN", body = ApiError),
(status = UNSUPPORTED_MEDIA_TYPE, description = "Body is not application/json", body = ApiError),
(status = UNPROCESSABLE_ENTITY, description = "JSON body does not match the schema", body = ApiError),
)
)]
pub(crate) async fn submit_pairing_pin(
State(st): State<Arc<MgmtState>>,
ApiJson(req): ApiJson<SubmitPin>,
) -> Response {
let pin = req.pin.trim();
if pin.is_empty() || pin.len() > 16 || !pin.bytes().all(|b| b.is_ascii_digit()) {
return api_error(StatusCode::BAD_REQUEST, "pin must be 1-16 ASCII digits");
}
if !st.app.pairing.pin.awaiting_pin() {
// Refusing (rather than parking the PIN) prevents a stale PIN from silently
// satisfying a *future* pairing attempt.
return api_error(
StatusCode::CONFLICT,
"no pairing handshake is waiting for a PIN",
);
}
st.app.pairing.pin.submit(pin.to_string());
StatusCode::NO_CONTENT.into_response()
}