feat(host): unified host + native pairing over the management API

`serve --native` now runs the GameStream host AND the native punktfunk/1 (QUIC)
host in ONE process, sharing a single NativePairing handle with the management API
— so native pairing is operable from the web console instead of journalctl.

- gamestream::serve gains a native_port: spawns crate::m3::serve in the same
  runtime and passes the shared NativePairing to mgmt::run. Validated live: one
  process binds both RTSP 48010 and QUIC 9777.
- mgmt API: new `native` endpoints — GET /native/pair (status), POST
  /native/pair/arm (mint a fresh, time-limited PIN to DISPLAY), DELETE /native/pair
  (disarm), GET/DELETE /native/clients (list/unpair). GameStream-only hosts report
  enabled:false. OpenAPI regenerated (checked-in doc + drift test).
- main.rs: serve --native / --native-port flags.

The native host arms pairing on demand (the operator reads the PIN from the
console; the SPAKE2 ceremony is host-shows-PIN). New mgmt + native_pairing tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 09:50:33 +00:00
parent 5ca860533e
commit 19666ba57e
5 changed files with 680 additions and 17 deletions
+33 -5
View File
@@ -144,17 +144,30 @@ impl AppState {
}
}
/// Run the GameStream control plane (blocks): mDNS advertisement, the nvhttp servers, and
/// the management REST API.
pub fn serve(mgmt: crate::mgmt::Options) -> Result<()> {
/// Run the host (blocks): mDNS, the nvhttp servers, and the management REST API.
/// `native_port = Some(p)` makes this the **unified** host — it also runs the native punktfunk/1
/// QUIC server on `p` in the same process, sharing one [`crate::native_pairing`] handle with the
/// management API so the web console can arm pairing and show the PIN. `None` = GameStream only
/// (the mgmt API's native endpoints report `enabled: false`).
pub fn serve(mgmt: crate::mgmt::Options, native_port: Option<u16>) -> Result<()> {
let host = Host::detect()?;
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
let state = Arc::new(AppState::new(host, identity));
// The shared native-pairing handle exists only when we run the native host; it links the QUIC
// ceremony and the management API.
let native = match native_port {
Some(_) => Some(Arc::new(
crate::native_pairing::NativePairing::load_with(None, None, false)
.context("native pairing store")?,
)),
None => None,
};
tracing::info!(
hostname = %state.host.hostname,
uniqueid = %state.host.uniqueid,
ip = %state.host.local_ip,
"punktfunk GameStream host (P1.1: serverinfo + pairing + mDNS)"
native = native_port.is_some(),
"punktfunk host (GameStream P1.1: serverinfo + pairing + mDNS)"
);
let rt = tokio::runtime::Runtime::new().context("build tokio runtime")?;
rt.block_on(async move {
@@ -163,7 +176,22 @@ pub fn serve(mgmt: crate::mgmt::Options) -> Result<()> {
let _advert = mdns::advertise(&state.host).context("mDNS advertise")?;
rtsp::spawn(state.clone()).context("start RTSP server")?;
control::spawn(state.clone()).context("start ENet control server")?;
tokio::try_join!(nvhttp::run(state.clone()), crate::mgmt::run(state, mgmt))?;
match (native_port, native) {
(Some(port), Some(np)) => {
tracing::info!(port, "unified host: also serving native punktfunk/1 (QUIC)");
tokio::try_join!(
nvhttp::run(state.clone()),
crate::mgmt::run(state.clone(), mgmt, Some(np.clone())),
crate::m3::serve(crate::m3::native_serve_opts(port), np),
)?;
}
_ => {
tokio::try_join!(
nvhttp::run(state.clone()),
crate::mgmt::run(state, mgmt, None)
)?;
}
}
Ok(())
})
}
+18 -1
View File
@@ -115,7 +115,24 @@ fn fingerprint_hex(fp: &[u8; 32]) -> String {
/// served one at a time (the virtual output + NVENC are single-tenant); a client that
/// connects mid-session waits in the accept queue. A failed session logs and the loop
/// keeps serving — only endpoint-level failures are fatal.
async fn serve(opts: M3Options, np: Arc<NativePairing>) -> Result<()> {
/// Default options for the native host when the unified `serve --native` runs it in-process:
/// real virtual capture, persistent (no session/duration cut), pairing armed on demand via the
/// management API (the shared [`NativePairing`] starts disarmed).
pub(crate) fn native_serve_opts(port: u16) -> M3Options {
M3Options {
port,
source: M3Source::Virtual,
seconds: 7 * 24 * 3600, // per-session cap; large enough not to cut a live stream
frames: 0,
max_sessions: 0,
require_pairing: false,
allow_pairing: false,
pairing_pin: None,
paired_store: None,
}
}
pub(crate) async fn serve(opts: M3Options, np: Arc<NativePairing>) -> Result<()> {
let identity = crate::gamestream::cert::ServerIdentity::load_or_create()
.context("load host identity (~/.config/punktfunk)")?;
let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem)
+25 -5
View File
@@ -56,8 +56,12 @@ fn real_main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(String::as_str) {
// M2 GameStream host control plane (P1.1: mDNS + serverinfo) + management API.
Some("serve") => gamestream::serve(parse_serve(&args[1..])?),
// GameStream host control plane (P1.1: mDNS + serverinfo) + management API, and (with
// --native) the native punktfunk/1 host in the same process — the unified host.
Some("serve") => {
let (mgmt_opts, native_port) = parse_serve(&args[1..])?;
gamestream::serve(mgmt_opts, native_port)
}
// Print the management API's OpenAPI document (for client codegen).
Some("openapi") => {
print!("{}", mgmt::openapi_json());
@@ -220,9 +224,12 @@ fn input_test() -> Result<()> {
bail!("input-test requires Linux")
}
/// `serve` options — all about the management API; the GameStream ports are protocol-fixed.
fn parse_serve(args: &[String]) -> Result<mgmt::Options> {
/// `serve` options: the management API (GameStream ports are protocol-fixed) + whether to also run
/// the native punktfunk/1 host in-process (`--native`, the unified host). Returns the mgmt options
/// and the native QUIC port (`None` = GameStream only).
fn parse_serve(args: &[String]) -> Result<(mgmt::Options, Option<u16>)> {
let mut opts = mgmt::Options::default();
let mut native_port: Option<u16> = None;
let mut i = 0;
while i < args.len() {
let arg = args[i].as_str();
@@ -248,6 +255,16 @@ fn parse_serve(args: &[String]) -> Result<mgmt::Options> {
}
opts.token = Some(token);
}
// Also run the native punktfunk/1 (QUIC) host in this process — the unified host.
// Pairing is then armed on demand from the management API / web console.
"--native" => native_port = Some(native_port.unwrap_or(9777)),
"--native-port" => {
native_port = Some(
next()?
.parse()
.map_err(|_| anyhow::anyhow!("bad --native-port (want a port number)"))?,
)
}
"-h" | "--help" => {
print_usage();
std::process::exit(0);
@@ -262,7 +279,7 @@ fn parse_serve(args: &[String]) -> Result<mgmt::Options> {
.ok()
.filter(|t| !t.is_empty());
}
Ok(opts)
Ok((opts, native_port))
}
fn parse_m0(args: &[String]) -> Result<Options> {
@@ -378,6 +395,9 @@ SERVE OPTIONS:
--mgmt-bind <IP:PORT> management API address (default: 127.0.0.1:47990)
--mgmt-token <TOKEN> bearer token for the management API (or PUNKTFUNK_MGMT_TOKEN);
required when --mgmt-bind is not loopback
--native also run the native punktfunk/1 (QUIC) host in this process —
the unified host; pairing is armed from the management API/console
--native-port <PORT> native QUIC port (default 9777; implies --native)
M3-HOST OPTIONS:
--port <N> QUIC listen port (default: 9777)
+316 -6
View File
@@ -61,13 +61,21 @@ impl Default for Options {
/// Axum state for the management routes: the shared control-plane state + auth config.
struct MgmtState {
app: Arc<AppState>,
/// Native (punktfunk/1) pairing — shared with the QUIC host when the unified `serve --native`
/// runs it. `None` ⇒ GameStream-only host (the native endpoints report `enabled: false`).
native: Option<Arc<crate::native_pairing::NativePairing>>,
token: Option<String>,
/// The port we serve on, echoed in [`PortMap`] so a client can persist a full endpoint map.
port: u16,
}
/// Run the management API server (control plane; spawned alongside the nvhttp servers).
pub async fn run(state: Arc<AppState>, opts: Options) -> Result<()> {
/// Run the management API server (control plane; spawned alongside the nvhttp servers). `native`
/// is the shared punktfunk/1 pairing handle when the unified host runs the native QUIC server.
pub async fn run(
state: Arc<AppState>,
opts: Options,
native: Option<Arc<crate::native_pairing::NativePairing>>,
) -> Result<()> {
// A blank token is no token: it must neither satisfy the non-loopback guard below nor
// become a credential an empty `Authorization: Bearer ` header would match.
let token = opts.token.filter(|t| !t.trim().is_empty());
@@ -83,7 +91,7 @@ pub async fn run(state: Arc<AppState>, opts: Options) -> Result<()> {
auth = if token.is_some() { "bearer" } else { "none (loopback)" },
"management API listening (docs at /api/docs, spec at /api/v1/openapi.json)"
);
let app = app(state, token, opts.bind.port());
let app = app(state, token, opts.bind.port(), native);
axum_server::bind(opts.bind)
.serve(app.into_make_service())
.await
@@ -91,9 +99,15 @@ pub async fn run(state: Arc<AppState>, opts: Options) -> Result<()> {
}
/// Compose the full management router (also used directly by the handler tests).
fn app(state: Arc<AppState>, token: Option<String>, port: u16) -> Router {
fn app(
state: Arc<AppState>,
token: Option<String>,
port: u16,
native: Option<Arc<crate::native_pairing::NativePairing>>,
) -> Router {
let shared = Arc::new(MgmtState {
app: state,
native,
token,
port,
});
@@ -126,6 +140,11 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(unpair_client))
.routes(routes!(get_pairing_status))
.routes(routes!(submit_pairing_pin))
.routes(routes!(get_native_pairing))
.routes(routes!(arm_native_pairing))
.routes(routes!(disarm_native_pairing))
.routes(routes!(list_native_clients))
.routes(routes!(unpair_native_client))
.routes(routes!(stop_session))
.routes(routes!(request_idr)),
)
@@ -156,6 +175,7 @@ pub fn openapi_json() -> String {
(name = "host", description = "Host identity, capabilities, and liveness"),
(name = "clients", description = "Paired Moonlight client management"),
(name = "pairing", description = "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)"),
(name = "native", description = "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"),
(name = "session", description = "Active streaming session control"),
)
)]
@@ -323,6 +343,42 @@ struct SubmitPin {
pin: String,
}
/// 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)]
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)]
struct ArmNativePairing {
/// Window length in seconds (default 120; clamped to 15600).
#[schema(example = 120)]
ttl_secs: Option<u32>,
}
/// A paired native (punktfunk/1) client.
#[derive(Serialize, ToSchema)]
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,
}
/// Error envelope for every non-2xx response.
#[derive(Serialize, Deserialize, ToSchema)]
struct ApiError {
@@ -667,6 +723,168 @@ async fn submit_pairing_pin(
StatusCode::NO_CONTENT.into_response()
}
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),
)
)]
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 enabled (run `serve --native`)", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
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 enabled (run `serve --native`)",
);
};
let ttl = req.ttl_secs.unwrap_or(120).clamp(15, 600);
let _pin = np.arm(std::time::Duration::from_secs(ttl as u64));
tracing::info!(ttl_secs = ttl, "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),
)
)]
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),
)
)]
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),
)
)]
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}"),
),
}
}
/// Stop the active session
///
/// Kicks the connected client: stops the video/audio stream threads and clears the launch
@@ -739,7 +957,14 @@ mod tests {
}
fn test_app(state: Arc<AppState>, token: Option<&str>) -> Router {
app(state, token.map(String::from), DEFAULT_PORT)
app(state, token.map(String::from), DEFAULT_PORT, None)
}
fn test_app_native(
state: Arc<AppState>,
np: Arc<crate::native_pairing::NativePairing>,
) -> Router {
app(state, None, DEFAULT_PORT, Some(np))
}
async fn send(app: &Router, req: axum::http::Request<Body>) -> (StatusCode, serde_json::Value) {
@@ -957,7 +1182,7 @@ mod tests {
bind: "0.0.0.0:0".parse().unwrap(),
token: Some(" ".into()),
};
let err = run(test_state(), opts).await.unwrap_err();
let err = run(test_state(), opts, None).await.unwrap_err();
assert!(err.to_string().contains("not loopback"), "{err}");
}
@@ -1048,4 +1273,89 @@ mod tests {
cargo run -p punktfunk-host -- openapi > docs/api/openapi.json"
);
}
fn post_json(path: &str, body: serde_json::Value) -> axum::http::Request<Body> {
axum::http::Request::post(path)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap()
}
#[tokio::test]
async fn native_pairing_arm_show_and_unpair() {
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(std::env::temp_dir().join(format!("pf-mgmt-np-{}.json", std::process::id()))),
None,
false,
)
.unwrap(),
);
let app = test_app_native(test_state(), np.clone());
// Disarmed: enabled, not armed, no PIN.
let (s, b) = send(&app, get_req("/api/v1/native/pair")).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b["enabled"], true);
assert_eq!(b["armed"], false);
assert!(b["pin"].is_null());
// Arm → a PIN appears and is readable via status.
let (s, b) = send(
&app,
post_json(
"/api/v1/native/pair/arm",
serde_json::json!({"ttl_secs": 60}),
),
)
.await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b["armed"], true);
let pin = b["pin"].as_str().unwrap().to_string();
assert_eq!(pin.len(), 4);
let (_, b) = send(&app, get_req("/api/v1/native/pair")).await;
assert_eq!(b["pin"], pin);
assert!(b["expires_in_secs"].as_u64().unwrap() <= 60);
// The QUIC side would read the same live PIN.
assert_eq!(np.current_pin().as_deref(), Some(pin.as_str()));
// Pair a client out-of-band, then it shows in the list + can be unpaired.
np.add("Test Device", "abc123").unwrap();
let (s, b) = send(&app, get_req("/api/v1/native/clients")).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b[0]["name"], "Test Device");
assert_eq!(b[0]["fingerprint"], "abc123");
let del = axum::http::Request::delete("/api/v1/native/clients/ABC123")
.body(Body::empty())
.unwrap();
assert_eq!(send(&app, del).await.0, StatusCode::NO_CONTENT);
let missing = axum::http::Request::delete("/api/v1/native/clients/abc123")
.body(Body::empty())
.unwrap();
assert_eq!(send(&app, missing).await.0, StatusCode::NOT_FOUND);
// Disarm clears the window.
let del = axum::http::Request::delete("/api/v1/native/pair")
.body(Body::empty())
.unwrap();
assert_eq!(send(&app, del).await.0, StatusCode::NO_CONTENT);
let (_, b) = send(&app, get_req("/api/v1/native/pair")).await;
assert_eq!(b["armed"], false);
}
#[tokio::test]
async fn native_endpoints_report_disabled_without_native_host() {
let app = test_app(test_state(), None);
let (s, b) = send(&app, get_req("/api/v1/native/pair")).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b["enabled"], false);
// Arming a host that isn't running the native server is a 503.
let (s, _) = send(
&app,
post_json("/api/v1/native/pair/arm", serde_json::json!({})),
)
.await;
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
}
}
+288
View File
@@ -194,6 +194,213 @@
}
}
},
"/api/v1/native/clients": {
"get": {
"tags": [
"native"
],
"summary": "List native paired clients",
"operationId": "listNativeClients",
"responses": {
"200": {
"description": "Paired native clients",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NativeClient"
}
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/native/clients/{fingerprint}": {
"delete": {
"tags": [
"native"
],
"summary": "Unpair a native client",
"description": "Removes a punktfunk/1 client from the native trust store by fingerprint.",
"operationId": "unpairNativeClient",
"parameters": [
{
"name": "fingerprint",
"in": "path",
"description": "Hex SHA-256 of the client certificate (case-insensitive)",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Client unpaired"
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No paired native client with that fingerprint",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Native host not enabled",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/native/pair": {
"get": {
"tags": [
"native"
],
"summary": "Native pairing status",
"description": "The native (punktfunk/1) pairing window. Poll while armed to show the PIN + countdown.\n`enabled: false` means this host runs GameStream only (no `--native`).",
"operationId": "getNativePairing",
"responses": {
"200": {
"description": "Native pairing status",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NativePairStatus"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
},
"delete": {
"tags": [
"native"
],
"summary": "Disarm native pairing",
"description": "Closes the pairing window immediately (no new ceremonies accepted).",
"operationId": "disarmNativePairing",
"responses": {
"204": {
"description": "Pairing disarmed"
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Native host not enabled",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/native/pair/arm": {
"post": {
"tags": [
"native"
],
"summary": "Arm native pairing",
"description": "Opens a pairing window and mints a fresh PIN to display. The user enters it on their device\nwithin `ttl_secs`; the device then appears in the native client list.",
"operationId": "armNativePairing",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ArmNativePairing"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Pairing armed; the response carries the PIN to display",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NativePairStatus"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Native host not enabled (run `serve --native`)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/pair": {
"get": {
"tags": [
@@ -416,6 +623,22 @@
}
}
},
"ArmNativePairing": {
"type": "object",
"description": "Arm-native-pairing request body.",
"properties": {
"ttl_secs": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "Window length in seconds (default 120; clamped to 15600).",
"example": 120,
"minimum": 0
}
}
},
"AvailableCompositor": {
"type": "object",
"description": "A compositor backend the host can drive a virtual output on, and whether it's usable now.",
@@ -526,6 +749,67 @@
}
}
},
"NativeClient": {
"type": "object",
"description": "A paired native (punktfunk/1) client.",
"required": [
"name",
"fingerprint"
],
"properties": {
"fingerprint": {
"type": "string",
"description": "Hex SHA-256 of the client certificate — its stable id here."
},
"name": {
"type": "string",
"description": "The name the client supplied when pairing.",
"example": "Living Room iPad"
}
}
},
"NativePairStatus": {
"type": "object",
"description": "Native (punktfunk/1) pairing status. Unlike GameStream, the **host** mints the PIN (the SPAKE2\nceremony needs it client-side first), so the console **displays** `pin` for the user to enter on\ntheir device — armed on demand for a short window.",
"required": [
"enabled",
"armed",
"paired_clients"
],
"properties": {
"armed": {
"type": "boolean",
"description": "True while a pairing window is open."
},
"enabled": {
"type": "boolean",
"description": "Whether the native host is running (the unified host started with `--native`)."
},
"expires_in_secs": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Seconds left in the window (null = disarmed, or armed with no expiry via the CLI flag).",
"minimum": 0
},
"paired_clients": {
"type": "integer",
"format": "int32",
"description": "Number of paired native clients.",
"minimum": 0
},
"pin": {
"type": [
"string",
"null"
],
"description": "The PIN to display while armed (null when disarmed).",
"example": "1234"
}
}
},
"PairedClient": {
"type": "object",
"description": "A paired (certificate-pinned) Moonlight client.",
@@ -797,6 +1081,10 @@
"name": "pairing",
"description": "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)"
},
{
"name": "native",
"description": "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"
},
{
"name": "session",
"description": "Active streaming session control"