f2a58f3a91
apple / swift (push) Successful in 1m15s
apple / screenshots (push) Successful in 4m37s
windows-host / package (push) Successful in 8m57s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 58s
ci / bench (push) Successful in 5m51s
decky / build-publish (push) Successful in 26s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 43s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 16s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
arch / build-publish (push) Successful in 16m51s
android / android (push) Successful in 17m6s
deb / build-publish (push) Successful in 17m13s
ci / rust (push) Successful in 18m41s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m31s
docker / deploy-docs (push) Successful in 10s
External game-library providers become first-class (RFC §8): a plugin
computes its desired title list and PUTs it — the host owns the diff.
- CustomEntry gains `provider` + `external_id` (API-set only; never on
manual entries). GameEntry surfaces `provider` for console attribution
and the new `GET /library?provider=` filter.
- PUT /api/v1/library/provider/{p}: atomic declarative reconcile keyed
on the provider's `external_id` — host ids stay stable across syncs,
orphans drop, manual entries and other providers are never touched,
an empty array clears the set. Validated: provider id [a-z0-9._-]
(`manual` reserved), unique non-empty external_ids.
- DELETE /api/v1/library/provider/{p}: clean uninstall, returns the
removed count.
- Ownership is unambiguous both ways: manual CRUD now returns 409 for a
provider-owned entry (MutateOutcome::ProviderOwned) instead of letting
an edit be silently clobbered at the next sync.
- library.changed now carries the mutating source (`manual` or the
provider id) — hooks and the SDK filter on it.
- Spec + SDK schemas regenerated; sdk/examples/provider-sync.ts is the
provider-plugin skeleton.
347 host tests green (pure reconcile: stable ids, orphan drop,
idempotence, bystanders untouched; name/payload validation; route 400s)
+ 11 SDK tests. Live-verified end to end THROUGH the SDK against a real
host: sync → filtered list → manual-delete 409 → re-sync with stable id
+ orphan drop → uninstall (removed=2), with three
library.changed(source=romm) events observed on the live stream.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
288 lines
13 KiB
Rust
288 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::reconcile_provider_entries,
|
|
library::delete_provider_entries
|
|
))
|
|
.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(),
|
|
)]);
|
|
}
|
|
}
|