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
+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"