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:
@@ -144,17 +144,30 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the GameStream control plane (blocks): mDNS advertisement, the nvhttp servers, and
|
/// Run the host (blocks): mDNS, the nvhttp servers, and the management REST API.
|
||||||
/// the management REST API.
|
/// `native_port = Some(p)` makes this the **unified** host — it also runs the native punktfunk/1
|
||||||
pub fn serve(mgmt: crate::mgmt::Options) -> Result<()> {
|
/// 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 host = Host::detect()?;
|
||||||
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
|
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
|
||||||
let state = Arc::new(AppState::new(host, identity));
|
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!(
|
tracing::info!(
|
||||||
hostname = %state.host.hostname,
|
hostname = %state.host.hostname,
|
||||||
uniqueid = %state.host.uniqueid,
|
uniqueid = %state.host.uniqueid,
|
||||||
ip = %state.host.local_ip,
|
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")?;
|
let rt = tokio::runtime::Runtime::new().context("build tokio runtime")?;
|
||||||
rt.block_on(async move {
|
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")?;
|
let _advert = mdns::advertise(&state.host).context("mDNS advertise")?;
|
||||||
rtsp::spawn(state.clone()).context("start RTSP server")?;
|
rtsp::spawn(state.clone()).context("start RTSP server")?;
|
||||||
control::spawn(state.clone()).context("start ENet control 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(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// connects mid-session waits in the accept queue. A failed session logs and the loop
|
||||||
/// keeps serving — only endpoint-level failures are fatal.
|
/// 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()
|
let identity = crate::gamestream::cert::ServerIdentity::load_or_create()
|
||||||
.context("load host identity (~/.config/punktfunk)")?;
|
.context("load host identity (~/.config/punktfunk)")?;
|
||||||
let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem)
|
let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem)
|
||||||
|
|||||||
@@ -56,8 +56,12 @@ fn real_main() -> Result<()> {
|
|||||||
|
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
match args.first().map(String::as_str) {
|
match args.first().map(String::as_str) {
|
||||||
// M2 GameStream host control plane (P1.1: mDNS + serverinfo) + management API.
|
// GameStream host control plane (P1.1: mDNS + serverinfo) + management API, and (with
|
||||||
Some("serve") => gamestream::serve(parse_serve(&args[1..])?),
|
// --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).
|
// Print the management API's OpenAPI document (for client codegen).
|
||||||
Some("openapi") => {
|
Some("openapi") => {
|
||||||
print!("{}", mgmt::openapi_json());
|
print!("{}", mgmt::openapi_json());
|
||||||
@@ -220,9 +224,12 @@ fn input_test() -> Result<()> {
|
|||||||
bail!("input-test requires Linux")
|
bail!("input-test requires Linux")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `serve` options — all about the management API; the GameStream ports are protocol-fixed.
|
/// `serve` options: the management API (GameStream ports are protocol-fixed) + whether to also run
|
||||||
fn parse_serve(args: &[String]) -> Result<mgmt::Options> {
|
/// 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 opts = mgmt::Options::default();
|
||||||
|
let mut native_port: Option<u16> = None;
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
let arg = args[i].as_str();
|
let arg = args[i].as_str();
|
||||||
@@ -248,6 +255,16 @@ fn parse_serve(args: &[String]) -> Result<mgmt::Options> {
|
|||||||
}
|
}
|
||||||
opts.token = Some(token);
|
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" => {
|
"-h" | "--help" => {
|
||||||
print_usage();
|
print_usage();
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
@@ -262,7 +279,7 @@ fn parse_serve(args: &[String]) -> Result<mgmt::Options> {
|
|||||||
.ok()
|
.ok()
|
||||||
.filter(|t| !t.is_empty());
|
.filter(|t| !t.is_empty());
|
||||||
}
|
}
|
||||||
Ok(opts)
|
Ok((opts, native_port))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_m0(args: &[String]) -> Result<Options> {
|
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-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);
|
--mgmt-token <TOKEN> bearer token for the management API (or PUNKTFUNK_MGMT_TOKEN);
|
||||||
required when --mgmt-bind is not loopback
|
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:
|
M3-HOST OPTIONS:
|
||||||
--port <N> QUIC listen port (default: 9777)
|
--port <N> QUIC listen port (default: 9777)
|
||||||
|
|||||||
@@ -61,13 +61,21 @@ impl Default for Options {
|
|||||||
/// Axum state for the management routes: the shared control-plane state + auth config.
|
/// Axum state for the management routes: the shared control-plane state + auth config.
|
||||||
struct MgmtState {
|
struct MgmtState {
|
||||||
app: Arc<AppState>,
|
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>,
|
token: Option<String>,
|
||||||
/// The port we serve on, echoed in [`PortMap`] so a client can persist a full endpoint map.
|
/// The port we serve on, echoed in [`PortMap`] so a client can persist a full endpoint map.
|
||||||
port: u16,
|
port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the management API server (control plane; spawned alongside the nvhttp servers).
|
/// Run the management API server (control plane; spawned alongside the nvhttp servers). `native`
|
||||||
pub async fn run(state: Arc<AppState>, opts: Options) -> Result<()> {
|
/// 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
|
// 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.
|
// become a credential an empty `Authorization: Bearer ` header would match.
|
||||||
let token = opts.token.filter(|t| !t.trim().is_empty());
|
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)" },
|
auth = if token.is_some() { "bearer" } else { "none (loopback)" },
|
||||||
"management API listening (docs at /api/docs, spec at /api/v1/openapi.json)"
|
"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)
|
axum_server::bind(opts.bind)
|
||||||
.serve(app.into_make_service())
|
.serve(app.into_make_service())
|
||||||
.await
|
.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).
|
/// 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 {
|
let shared = Arc::new(MgmtState {
|
||||||
app: state,
|
app: state,
|
||||||
|
native,
|
||||||
token,
|
token,
|
||||||
port,
|
port,
|
||||||
});
|
});
|
||||||
@@ -126,6 +140,11 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|||||||
.routes(routes!(unpair_client))
|
.routes(routes!(unpair_client))
|
||||||
.routes(routes!(get_pairing_status))
|
.routes(routes!(get_pairing_status))
|
||||||
.routes(routes!(submit_pairing_pin))
|
.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!(stop_session))
|
||||||
.routes(routes!(request_idr)),
|
.routes(routes!(request_idr)),
|
||||||
)
|
)
|
||||||
@@ -156,6 +175,7 @@ pub fn openapi_json() -> String {
|
|||||||
(name = "host", description = "Host identity, capabilities, and liveness"),
|
(name = "host", description = "Host identity, capabilities, and liveness"),
|
||||||
(name = "clients", description = "Paired Moonlight client management"),
|
(name = "clients", description = "Paired Moonlight client management"),
|
||||||
(name = "pairing", description = "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)"),
|
(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"),
|
(name = "session", description = "Active streaming session control"),
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
@@ -323,6 +343,42 @@ struct SubmitPin {
|
|||||||
pin: String,
|
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 15–600).
|
||||||
|
#[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.
|
/// Error envelope for every non-2xx response.
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
struct ApiError {
|
struct ApiError {
|
||||||
@@ -667,6 +723,168 @@ async fn submit_pairing_pin(
|
|||||||
StatusCode::NO_CONTENT.into_response()
|
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
|
/// Stop the active session
|
||||||
///
|
///
|
||||||
/// Kicks the connected client: stops the video/audio stream threads and clears the launch
|
/// 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 {
|
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) {
|
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(),
|
bind: "0.0.0.0:0".parse().unwrap(),
|
||||||
token: Some(" ".into()),
|
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}");
|
assert!(err.to_string().contains("not loopback"), "{err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1048,4 +1273,89 @@ mod tests {
|
|||||||
cargo run -p punktfunk-host -- openapi > docs/api/openapi.json"
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
"/api/v1/pair": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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 15–600).",
|
||||||
|
"example": 120,
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"AvailableCompositor": {
|
"AvailableCompositor": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "A compositor backend the host can drive a virtual output on, and whether it's usable now.",
|
"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": {
|
"PairedClient": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "A paired (certificate-pinned) Moonlight client.",
|
"description": "A paired (certificate-pinned) Moonlight client.",
|
||||||
@@ -797,6 +1081,10 @@
|
|||||||
"name": "pairing",
|
"name": "pairing",
|
||||||
"description": "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)"
|
"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",
|
"name": "session",
|
||||||
"description": "Active streaming session control"
|
"description": "Active streaming session control"
|
||||||
|
|||||||
Reference in New Issue
Block a user