46c0e0e483
apple / swift (push) Successful in 1m28s
release / apple (push) Successful in 6m9s
apple / screenshots (push) Successful in 4m39s
audit / bun-audit (push) Successful in 15s
audit / cargo-audit (push) Successful in 2m11s
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m10s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
ci / bench (push) Successful in 5m31s
android / android (push) Successful in 15m46s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
arch / build-publish (push) Successful in 11m16s
windows-host / package (push) Successful in 9m38s
deb / build-publish (push) Successful in 12m28s
flatpak / build-publish (push) Failing after 8m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m4s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
ci / rust (push) Successful in 22m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
docker / deploy-docs (push) Successful in 31s
hooks.json (RFC §6): commands and webhooks fired on host lifecycle events, managed over GET|PUT /api/v1/hooks (validated, applied immediately) and dispatched fire-and-forget by a bus-subscriber runner — hooks observe, never veto, and no operator code sits in any streaming path. - exec: detached sh -c with the event JSON on stdin + flat PF_EVENT_* env (the PF_STREAM_* vocabulary's sibling), per-hook timeout (default 30 s) with process-group kill, off-thread reap, per-hook debounce, bounded concurrency (8 in flight, excess dropped loudly). Windows runs hooks in the interactive user session (temp-file JSON argument; console-mode dev hosts get env + stdin like Unix). - webhook: POST the event JSON, TLS-verified, redirects never followed, no punktfunk credentials outbound; optional per-hook secret file yields X-Punktfunk-Signature: sha256=<hex HMAC> (fails closed if unreadable). - filters: exact-match client/fingerprint/plane/app + the same kind patterns as the SSE ?kinds= filter (shared crate::events::kind_matches). - hardening (RFC §9.1): hooks.json via the private-dir/secret-file helpers; a hook script path must be operator/root-owned and not group/world-writable or it is refused loudly (the sshd rule). - env mirrors PUNKTFUNK_ON_CONNECT_CMD / PUNKTFUNK_ON_DISCONNECT_CMD for the zero-config cases, beside PUNKTFUNK_RECOVER_SESSION_CMD. Live-verified on Linux: PUT config via API → library.changed fired a real script (env + stdin observed) and an HMAC webhook (receiver-verified signature); a chmod-777 script was refused. 342 host tests green (store/validation/filter/env-flatten/exec-timeout/ownership + routes), clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
284 lines
13 KiB
Rust
284 lines
13 KiB
Rust
//! Management REST API (plan §4) — the control-plane surface a control pane / CLI talks
|
|
//! to: host identity + capabilities, runtime status, paired-client management, the pairing
|
|
//! PIN flow, and session control. Control plane only — `tokio`/`axum` are permitted here;
|
|
//! the per-frame pipeline never touches this module.
|
|
//!
|
|
//! The API is versioned under `/api/v1` and described by an OpenAPI 3.1 document generated
|
|
//! at compile time with `utoipa` — `punktfunk-host openapi` prints it for client codegen, the
|
|
//! running server serves it at `/api/v1/openapi.json` plus interactive docs at `/api/docs`,
|
|
//! and a copy is checked in at `api/openapi.json` (a test fails if it drifts, like the
|
|
//! cbindgen header).
|
|
//!
|
|
//! Security: serves HTTPS with the host's identity cert and requires auth on every `/api/v1` route
|
|
//! except `/api/v1/health` — **always**, even on loopback. The listener binds **all interfaces by
|
|
//! default** so a paired native client can reach the read-only surface (host/status/clients and the
|
|
//! **game library**) over the LAN with no operator step — authenticated by its mTLS cert (the
|
|
//! `cert_may_access` allowlist). The **bearer-token admin surface** (pairing, unpair, session
|
|
//! control, library mutation, stats) is honored **only from a loopback peer**, so it is never
|
|
//! LAN-exposed: the web console BFF — the sole token holder (`--mgmt-token` / `PUNKTFUNK_MGMT_TOKEN`,
|
|
//! else auto-generated + persisted to `~/.config/punktfunk/mgmt-token`) — always connects over
|
|
//! loopback. Restore the old loopback-only listener with `--mgmt-bind 127.0.0.1:47990`. The OpenAPI
|
|
//! document and docs UI are served unauthenticated (the spec is public — it lives in this repo).
|
|
|
|
use crate::gamestream::{tls::serve_https, AppState};
|
|
use anyhow::{Context, Result};
|
|
use axum::{middleware, routing::get, Json, Router};
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
use utoipa::{Modify, OpenApi};
|
|
use utoipa_axum::{router::OpenApiRouter, routes};
|
|
use utoipa_scalar::{Scalar, Servable};
|
|
|
|
mod auth;
|
|
mod clients;
|
|
mod display;
|
|
mod events;
|
|
mod gpu;
|
|
mod hooks;
|
|
mod host;
|
|
mod library;
|
|
mod native;
|
|
mod session;
|
|
mod shared;
|
|
mod stats;
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
/// Default management port — adjacent to the GameStream block (47984…48010), and the same
|
|
/// number Sunshine users already associate with "the config UI".
|
|
pub const DEFAULT_PORT: u16 = 47990;
|
|
|
|
/// Management server options (CLI: `serve --mgmt-bind ADDR --mgmt-token TOKEN`).
|
|
#[derive(Clone, Debug)]
|
|
pub struct Options {
|
|
pub bind: SocketAddr,
|
|
/// Bearer token required on `/api/v1` (except `/health`). `None` ⇒ unauthenticated,
|
|
/// which [`run`] only permits on loopback binds.
|
|
pub token: Option<String>,
|
|
}
|
|
|
|
impl Default for Options {
|
|
fn default() -> Self {
|
|
Options {
|
|
bind: SocketAddr::from(([127, 0, 0, 1], DEFAULT_PORT)),
|
|
token: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Axum state for the management routes: the shared control-plane state + auth config.
|
|
pub(crate) 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>>,
|
|
/// Shared streaming-stats recorder — the same handle the streaming loops emit into, so an
|
|
/// operator can arm/stop a capture here and review/list/delete saved recordings.
|
|
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
|
/// Whether this host runs the GameStream/Moonlight-compat planes (`--gamestream`). Surfaced in
|
|
/// [`HostInfo`] so the web console can hide the Moonlight-only pairing UI on the secure default
|
|
/// (native-only) host, where a Moonlight PIN can never arrive.
|
|
gamestream_enabled: bool,
|
|
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). `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>>,
|
|
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
|
gamestream_enabled: bool,
|
|
) -> Result<()> {
|
|
// The mgmt API is HTTPS + token-authenticated ALWAYS (even on loopback): `parse_serve`
|
|
// guarantees a token (CLI flag / env / persisted ~/.config/punktfunk/mgmt-token / generated).
|
|
// A blank token is treated as none — fail loudly rather than ever serve unauthenticated.
|
|
let token = opts
|
|
.token
|
|
.filter(|t| !t.trim().is_empty())
|
|
.context("management API has no token — internal error: parse_serve must provide one")?;
|
|
// Serve over HTTPS with the host's persistent identity (the cert clients already pin) and
|
|
// OPTIONAL client-cert auth: a paired native client presents its cert (authorized by
|
|
// fingerprint, no token), a browser presents none and uses the bearer token. See `require_auth`.
|
|
let identity = crate::gamestream::cert::ServerIdentity::load_or_create()
|
|
.context("load host identity for the management API TLS")?;
|
|
let tls = crate::gamestream::tls::server_config_optional_client(
|
|
&identity.cert_pem,
|
|
&identity.key_pem,
|
|
)
|
|
.context("management API TLS config")?;
|
|
tracing::info!(
|
|
addr = %opts.bind,
|
|
auth = "mTLS (paired cert) or bearer (required)",
|
|
"management API listening over HTTPS (docs at /api/docs, spec at /api/v1/openapi.json)"
|
|
);
|
|
let app = app(
|
|
state,
|
|
Some(token),
|
|
opts.bind.port(),
|
|
native,
|
|
stats,
|
|
gamestream_enabled,
|
|
);
|
|
serve_https(opts.bind, app, tls).await
|
|
}
|
|
|
|
/// Compose the full management router (also used directly by the handler tests).
|
|
fn app(
|
|
state: Arc<AppState>,
|
|
token: Option<String>,
|
|
port: u16,
|
|
native: Option<Arc<crate::native_pairing::NativePairing>>,
|
|
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
|
gamestream_enabled: bool,
|
|
) -> Router {
|
|
let shared = Arc::new(MgmtState {
|
|
app: state,
|
|
native,
|
|
stats,
|
|
gamestream_enabled,
|
|
token,
|
|
port,
|
|
});
|
|
let (api_routes, api) = api_router_parts();
|
|
api_routes
|
|
.route_layer(middleware::from_fn_with_state(
|
|
shared.clone(),
|
|
auth::require_auth,
|
|
))
|
|
.with_state(shared)
|
|
.merge(Scalar::with_url("/api/docs", api.clone()))
|
|
.route(
|
|
"/api/v1/openapi.json",
|
|
get(move || {
|
|
let spec = api.clone();
|
|
async move { Json(spec) }
|
|
}),
|
|
)
|
|
}
|
|
|
|
/// The versioned API routes + the OpenAPI document collected from them. Single source of
|
|
/// truth for both the live server and the `openapi` subcommand.
|
|
fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|
OpenApiRouter::with_openapi(ApiDoc::openapi())
|
|
.nest(
|
|
"/api/v1",
|
|
OpenApiRouter::new()
|
|
.routes(routes!(host::get_health))
|
|
.routes(routes!(host::get_host_info))
|
|
.routes(routes!(host::list_compositors))
|
|
.routes(routes!(gpu::list_gpus))
|
|
.routes(routes!(gpu::set_gpu_preference))
|
|
.routes(routes!(display::get_display_settings))
|
|
.routes(routes!(display::set_display_settings))
|
|
.routes(routes!(display::get_display_state))
|
|
.routes(routes!(display::release_display))
|
|
.routes(routes!(display::set_display_layout))
|
|
.routes(routes!(
|
|
display::list_custom_presets,
|
|
display::create_custom_preset
|
|
))
|
|
.routes(routes!(
|
|
display::update_custom_preset,
|
|
display::delete_custom_preset
|
|
))
|
|
.routes(routes!(host::get_status))
|
|
.routes(routes!(host::get_local_summary))
|
|
.routes(routes!(clients::list_paired_clients))
|
|
.routes(routes!(clients::unpair_client))
|
|
.routes(routes!(clients::get_pairing_status))
|
|
.routes(routes!(clients::submit_pairing_pin))
|
|
.routes(routes!(native::get_native_pairing))
|
|
.routes(routes!(native::arm_native_pairing))
|
|
.routes(routes!(native::disarm_native_pairing))
|
|
.routes(routes!(native::list_native_clients))
|
|
.routes(routes!(native::unpair_native_client))
|
|
.routes(routes!(native::list_pending_devices))
|
|
.routes(routes!(native::approve_pending_device))
|
|
.routes(routes!(native::deny_pending_device))
|
|
.routes(routes!(session::stop_session))
|
|
.routes(routes!(session::request_idr))
|
|
.routes(routes!(library::get_library))
|
|
.routes(routes!(library::create_custom_game))
|
|
.routes(routes!(
|
|
library::update_custom_game,
|
|
library::delete_custom_game
|
|
))
|
|
.routes(routes!(library::get_library_art))
|
|
.routes(routes!(stats::stats_capture_start))
|
|
.routes(routes!(stats::stats_capture_stop))
|
|
.routes(routes!(stats::stats_capture_status))
|
|
.routes(routes!(stats::stats_capture_live))
|
|
.routes(routes!(stats::stats_recordings_list))
|
|
.routes(routes!(
|
|
stats::stats_recording_get,
|
|
stats::stats_recording_delete
|
|
))
|
|
.routes(routes!(stats::logs_get))
|
|
.routes(routes!(events::stream_events))
|
|
.routes(routes!(hooks::get_hooks, hooks::set_hooks)),
|
|
)
|
|
.split_for_parts()
|
|
}
|
|
|
|
/// The OpenAPI document as pretty JSON — what `punktfunk-host openapi` prints and what is
|
|
/// checked in at `api/openapi.json` for client codegen.
|
|
pub fn openapi_json() -> String {
|
|
let (_, api) = api_router_parts();
|
|
let mut json = api.to_pretty_json().expect("serialize OpenAPI document");
|
|
json.push('\n');
|
|
json
|
|
}
|
|
|
|
#[derive(OpenApi)]
|
|
#[openapi(
|
|
info(
|
|
title = "punktfunk management API",
|
|
description = "Control-plane API for managing a punktfunk streaming host: host \
|
|
capabilities, runtime status, paired clients, the pairing PIN flow, \
|
|
and session control. Authentication: HTTP bearer token, enforced on \
|
|
every route except `/api/v1/health` when the host is started with a \
|
|
management token (mandatory for non-loopback binds)."
|
|
),
|
|
modifiers(&SecurityAddon),
|
|
tags(
|
|
(name = "host", description = "Host identity, capabilities, and liveness"),
|
|
(name = "gpu", description = "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"),
|
|
(name = "display", description = "Virtual-display management policy: lifecycle (keep-alive), topology (primary/exclusive), conflict handling, identity, and layout"),
|
|
(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"),
|
|
(name = "library", description = "Game library: installed-store titles (Steam) plus user-curated custom entries"),
|
|
(name = "stats", description = "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"),
|
|
(name = "logs", description = "Host log stream: the newest in-memory log entries, cursor-paged for live following"),
|
|
(name = "events", description = "Host lifecycle events: an SSE stream (client/session/stream lifecycle, pairing, displays, library, host) with Last-Event-ID resume and server-side kind filters"),
|
|
(name = "hooks", description = "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"),
|
|
)
|
|
)]
|
|
struct ApiDoc;
|
|
|
|
/// Registers the `bearerAuth` scheme and applies it globally (utoipa has no first-class
|
|
/// "all operations" shorthand, hence a modifier).
|
|
struct SecurityAddon;
|
|
|
|
impl Modify for SecurityAddon {
|
|
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
|
use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityScheme};
|
|
openapi
|
|
.components
|
|
.get_or_insert_with(Default::default)
|
|
.add_security_scheme(
|
|
"bearerAuth",
|
|
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
|
|
);
|
|
openapi.security = Some(vec![utoipa::openapi::security::SecurityRequirement::new(
|
|
"bearerAuth",
|
|
Vec::<String>::new(),
|
|
)]);
|
|
}
|
|
}
|