Files
punktfunk/crates/punktfunk-host/src/mgmt/plugins.rs
T
enricobuehler ec84b30eae feat(plugins): console-hosted plugin UI surface (host registry + SDK servePluginUi + console proxy/nav)
Implements planning/design/plugin-ui-surface.md (U1-U3):

- host: in-memory lease-based plugin registry (mgmt/plugins.rs) — PUT/GET/DELETE
  /api/v1/plugins + GET /plugins/{id}/ui-credential; bearer+loopback only (not on
  the mTLS read-only allowlist); plugins.changed event; port-only registration
  (proxy always dials 127.0.0.1); secret never in the listing.
- sdk: servePluginUi — loopback ephemeral bind + per-boot secret + constant-time
  check + /__health + static/SPA-fallback + register/renew(30s)/deregister via
  pf.request (skew-proof, D7). Example + tests.
- console: /plugin-ui/{id}/** reverse proxy (server-side secret injection, cookie
  strip, SSE streaming, stale-secret 401-retry) + credential cache; BFF denylist
  for the credential endpoint; dynamic Plugins nav (desktop + mobile) fed by a
  polled list; iframe-in-shell page with health probe, offline card, open-in-tab,
  deep-link sync. Dev-mode /plugin-ui middleware in vite.config.ts.

OpenAPI regen for the new endpoints follows in the next commit (built on Linux).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 09:48:37 +02:00

555 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Plugin registry (plugin-ui-surface design): an in-memory, lease-based directory of running
//! `punktfunk-plugin-*` processes and the loopback UI each one serves.
//!
//! A plugin (an out-of-process script under the scripting runner, RFC §8) that wants a UI serves
//! it on a **loopback** port behind a per-boot secret, then **registers** here — `{title, ui:{port,
//! secret, icon}}` — over the admin/loopback lane it already holds via the SDK. The web console
//! reads [`list_plugins`] to grow a nav entry and reverse-proxies to the port (fetching the secret
//! from [`get_ui_credential`] server-side, never exposing it to the browser). The host itself never
//! dials the plugin, never health-checks it, and never persists any of this: it is a phone book with
//! expiry.
//!
//! Lease model (design §3, D8): a registration lives for [`LEASE_TTL`]; the plugin renews with the
//! same idempotent `PUT` every 30 s. Expiry is **lazy** — a crashed plugin's entry simply stops
//! listing once stale; there is no reaper task and nothing to persist across a host restart (the
//! supervised plugin re-registers on its next tick). Every consumer dials `127.0.0.1:<port>` only —
//! a registration stores a *port*, never an address, so it can never point the proxy elsewhere (D5).
//!
//! Auth: these routes carry no special handling — they are outside the [`super::auth::cert_may_access`]
//! read-only allowlist, so the middleware confines them to a **bearer + loopback** peer like every
//! other mutation. LAN clients have no business here.
use super::shared::*;
use crate::events::{emit, EventKind};
use std::collections::HashMap;
use std::sync::{OnceLock, RwLock};
use std::time::{Duration, Instant};
/// How long a registration stays live after its last renewal. The SDK helper renews every 30 s, so
/// this tolerates two missed ticks before a plugin drops out of the listing.
const LEASE_TTL: Duration = Duration::from_secs(90);
// ---------------------------------------------------------------- wire shapes
/// A plugin's UI surface as it registers it. Carries the secret — this shape is only ever a request
/// body, never a response ([`PluginUiPublic`] is the secret-free view).
#[derive(Deserialize, ToSchema)]
pub(crate) struct PluginUi {
/// The **loopback** port the plugin serves its UI on. The host and console only ever dial
/// `127.0.0.1:<port>`; a registration can never carry a hostname.
pub port: u16,
/// Per-boot shared secret the console proxy must present (as `Authorization: Bearer`) on every
/// request to the plugin's UI server. Rotated whenever the plugin restarts.
pub secret: String,
/// Optional lucide icon name for the console nav entry (`^[a-z0-9-]{1,48}$`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
}
/// Register/renew body for `PUT /plugins/{id}`.
#[derive(Deserialize, ToSchema)]
pub(crate) struct PluginRegistration {
/// Human-readable title for the console nav entry (164 chars; control chars stripped).
pub title: String,
/// Optional plugin version, purely informational (≤32 chars).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
/// Present iff the plugin serves a UI surface. A registration with no `ui` is a liveness/phone-book
/// entry only (e.g. a future runner-management listing) and grows no nav entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui: Option<PluginUi>,
}
/// The secret-free view of a plugin's UI surface — what [`list_plugins`] returns to the browser.
#[derive(Serialize, ToSchema)]
pub(crate) struct PluginUiPublic {
pub port: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
}
/// One entry in `GET /plugins`. **Never carries the secret** — the browser learns a plugin exists
/// and has a UI, nothing that lets it reach the plugin directly (it goes through the console proxy).
#[derive(Serialize, ToSchema)]
pub(crate) struct PluginSummary {
pub id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ui: Option<PluginUiPublic>,
}
/// `GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).
/// This is the only endpoint that returns a secret; the console BFF denylists it from the browser.
#[derive(Serialize, ToSchema)]
pub(crate) struct UiCredential {
pub port: u16,
pub secret: String,
}
// ---------------------------------------------------------------- registry core
/// The stored UI surface (internal — parsed + validated, no wire derives).
#[derive(Clone, PartialEq)]
struct StoredUi {
port: u16,
secret: String,
icon: Option<String>,
}
/// One live registration. `expires_at` is a **monotonic** [`Instant`] (immune to wall-clock jumps).
struct Stored {
title: String,
version: Option<String>,
ui: Option<StoredUi>,
expires_at: Instant,
}
impl Stored {
/// Do the operator-visible fields match (ignoring the lease clock)? A pure lease renewal leaves
/// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon) does.
fn public_eq(&self, title: &str, version: &Option<String>, ui: &Option<StoredUi>) -> bool {
self.title == title && self.version == *version && self.ui == *ui
}
}
/// The process-wide plugin registry.
pub(crate) struct PluginRegistry {
inner: RwLock<HashMap<String, Stored>>,
}
/// A validated registration ready to store (title trimmed, ui fields checked).
struct Valid {
title: String,
version: Option<String>,
ui: Option<StoredUi>,
}
impl PluginRegistry {
fn new() -> Self {
Self {
inner: RwLock::new(HashMap::new()),
}
}
/// Insert or renew `id`. Returns `true` when an operator-visible field changed (new plugin,
/// restart, or re-scan) — the signal the caller emits `plugins.changed` on. A pure renewal
/// returns `false`.
fn upsert(&self, id: &str, v: Valid) -> bool {
let expires_at = Instant::now() + LEASE_TTL;
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
let changed = match map.get(id) {
// An *expired* prior entry counts as a change (it had stopped listing).
Some(prev) => !prev.is_live() || !prev.public_eq(&v.title, &v.version, &v.ui),
None => true,
};
map.insert(
id.to_string(),
Stored {
title: v.title,
version: v.version,
ui: v.ui,
expires_at,
},
);
changed
}
/// The current listing (sorted by title, then id), plus the ids of entries that had expired and
/// were pruned by this call — the caller emits `plugins.changed` for those. Prunes under the
/// write lock so a stale entry is reaped exactly once.
fn snapshot(&self) -> (Vec<PluginSummary>, Vec<String>) {
let now = Instant::now();
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
let mut expired: Vec<String> = map
.iter()
.filter(|(_, s)| now >= s.expires_at)
.map(|(id, _)| id.clone())
.collect();
for id in &expired {
map.remove(id);
}
expired.sort();
let mut live: Vec<PluginSummary> = map
.iter()
.map(|(id, s)| PluginSummary {
id: id.clone(),
title: s.title.clone(),
version: s.version.clone(),
ui: s.ui.as_ref().map(|u| PluginUiPublic {
port: u.port,
icon: u.icon.clone(),
}),
})
.collect();
live.sort_by(|a, b| a.title.cmp(&b.title).then_with(|| a.id.cmp(&b.id)));
(live, expired)
}
/// The `{port, secret}` for a live plugin's UI, or `None` if unknown/expired/UI-less. Does not
/// prune (a read path) — a stale entry is reaped by the next [`snapshot`](Self::snapshot).
fn credential(&self, id: &str) -> Option<UiCredential> {
let map = self.inner.read().unwrap_or_else(|e| e.into_inner());
let s = map.get(id)?;
if !s.is_live() {
return None;
}
let ui = s.ui.as_ref()?;
Some(UiCredential {
port: ui.port,
secret: ui.secret.clone(),
})
}
/// Remove `id`. Returns `true` if a **live** entry existed (a clean deregister); removing an
/// already-expired or unknown id returns `false` (nothing to announce).
fn remove(&self, id: &str) -> bool {
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
map.remove(id).is_some_and(|s| s.is_live())
}
}
impl Stored {
fn is_live(&self) -> bool {
Instant::now() < self.expires_at
}
}
/// The process-wide registry singleton (the [`crate::events::bus`] shape).
pub(crate) fn registry() -> &'static PluginRegistry {
static REG: OnceLock<PluginRegistry> = OnceLock::new();
REG.get_or_init(PluginRegistry::new)
}
// ---------------------------------------------------------------- validation
/// A plugin id: `definePlugin`'s kebab-case name (`^[a-z][a-z0-9-]*$`, ≤64) — the same regex the SDK
/// enforces, so a plugin's registration id always matches its package name.
fn valid_plugin_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id.as_bytes()[0].is_ascii_lowercase()
&& id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
/// Strip control characters (defense against a title/version smuggling terminal escapes or newlines
/// into a log line or the console nav), then trim.
fn sanitize(s: &str) -> String {
s.chars()
.filter(|c| !c.is_control())
.collect::<String>()
.trim()
.to_string()
}
/// Validate a registration body into the internal [`Valid`] form, or a human-readable reason.
fn validate(reg: PluginRegistration) -> Result<Valid, String> {
let title = sanitize(&reg.title);
if title.is_empty() {
return Err("title must not be empty".into());
}
if title.chars().count() > 64 {
return Err("title must be at most 64 characters".into());
}
let version = match reg.version {
Some(v) => {
let v = sanitize(&v);
if v.chars().count() > 32 {
return Err("version must be at most 32 characters".into());
}
(!v.is_empty()).then_some(v)
}
None => None,
};
let ui = match reg.ui {
Some(u) => Some(validate_ui(u)?),
None => None,
};
Ok(Valid { title, version, ui })
}
fn validate_ui(u: PluginUi) -> Result<StoredUi, String> {
if u.port < 1024 {
return Err("ui.port must be a non-privileged port (>= 1024)".into());
}
let n = u.secret.len();
if !(16..=128).contains(&n) {
return Err("ui.secret must be 16128 characters".into());
}
if !u
.secret
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
{
return Err("ui.secret must be [A-Za-z0-9_-]".into());
}
let icon = match u.icon {
Some(icon) => {
let ok = (1..=48).contains(&icon.len())
&& icon
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
if !ok {
return Err("ui.icon must be a lucide name ([a-z0-9-], 148 chars)".into());
}
Some(icon)
}
None => None,
};
Ok(StoredUi {
port: u.port,
secret: u.secret,
icon,
})
}
// ---------------------------------------------------------------- handlers
/// Register or renew a plugin
///
/// Upserts the plugin's directory entry and renews its lease (TTL 90 s). Idempotent: a plugin PUTs
/// this every ~30 s while it runs. The optional `ui` block declares a loopback UI surface the console
/// will proxy and add to its nav. Emits `plugins.changed` when an operator-visible field changed
/// (first registration, restart, or re-scan) — a pure renewal is silent.
#[utoipa::path(
put,
path = "/plugins/{id}",
tag = "plugins",
operation_id = "registerPlugin",
params(("id" = String, Path, description = "The plugin id (its `definePlugin` name: `[a-z][a-z0-9-]*`)")),
request_body = PluginRegistration,
responses(
(status = NO_CONTENT, description = "Registered / renewed"),
(status = BAD_REQUEST, description = "Invalid id or registration", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn register_plugin(
Path(id): Path<String>,
ApiJson(reg): ApiJson<PluginRegistration>,
) -> Response {
if !valid_plugin_id(&id) {
return api_error(
StatusCode::BAD_REQUEST,
"invalid plugin id (expected kebab-case `[a-z][a-z0-9-]*`, ≤64)",
);
}
let valid = match validate(reg) {
Ok(v) => v,
Err(e) => return api_error(StatusCode::BAD_REQUEST, &e),
};
if registry().upsert(&id, valid) {
tracing::info!(plugin = %id, "plugin registered");
emit(EventKind::PluginsChanged { id });
}
StatusCode::NO_CONTENT.into_response()
}
/// List registered plugins
///
/// The live plugin directory (lease not expired), sorted by title. **Secret-free**: each entry
/// reports its id, title, optional version, and — for plugins that serve one — a UI descriptor
/// (loopback port + icon). The console renders these as nav entries and proxies to the port; it
/// fetches the secret separately, server-side.
#[utoipa::path(
get,
path = "/plugins",
tag = "plugins",
operation_id = "listPlugins",
responses(
(status = OK, description = "Live plugin registrations", body = [PluginSummary]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn list_plugins() -> Json<Vec<PluginSummary>> {
let (plugins, expired) = registry().snapshot();
// Lazy expiry: an entry that aged out is reaped here (exactly once) and announced, so a consumer
// watching the event stream sees the departure even though nothing actively deregistered it.
for id in expired {
tracing::info!(plugin = %id, "plugin lease expired");
emit(EventKind::PluginsChanged { id });
}
Json(plugins)
}
/// Fetch a plugin UI's proxy credential
///
/// Returns `{port, secret}` for a live plugin's loopback UI — the console proxy's server-side lookup.
/// Bearer + loopback only (like every mutation), and additionally excluded from the console's browser
/// passthrough: the secret never reaches a browser.
#[utoipa::path(
get,
path = "/plugins/{id}/ui-credential",
tag = "plugins",
operation_id = "getPluginUiCredential",
params(("id" = String, Path, description = "The plugin id")),
responses(
(status = OK, description = "The proxy credential", body = UiCredential),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = NOT_FOUND, description = "No live plugin with that id, or it serves no UI", body = ApiError),
)
)]
pub(crate) async fn get_ui_credential(Path(id): Path<String>) -> Response {
match registry().credential(&id) {
Some(cred) => Json(cred).into_response(),
None => api_error(StatusCode::NOT_FOUND, "no live plugin UI with that id"),
}
}
/// Deregister a plugin
///
/// The clean-shutdown path: removes the plugin's directory entry immediately (the SDK helper calls
/// this from its scope finalizer on `SIGTERM`). Emits `plugins.changed` when a live entry was
/// removed. Idempotent — deleting an unknown/expired id is a no-op `204`.
#[utoipa::path(
delete,
path = "/plugins/{id}",
tag = "plugins",
operation_id = "deregisterPlugin",
params(("id" = String, Path, description = "The plugin id")),
responses(
(status = NO_CONTENT, description = "Deregistered (or already absent)"),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn delete_plugin(Path(id): Path<String>) -> Response {
if registry().remove(&id) {
tracing::info!(plugin = %id, "plugin deregistered");
emit(EventKind::PluginsChanged { id });
}
StatusCode::NO_CONTENT.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
fn reg(title: &str, port: u16, secret: &str) -> PluginRegistration {
PluginRegistration {
title: title.into(),
version: None,
ui: Some(PluginUi {
port,
secret: secret.into(),
icon: Some("gamepad-2".into()),
}),
}
}
const SECRET: &str = "abcdefghijklmnop0123"; // 20 chars, valid alphabet
#[test]
fn id_validation() {
assert!(valid_plugin_id("rom-manager"));
assert!(valid_plugin_id("a"));
assert!(valid_plugin_id("x9"));
assert!(!valid_plugin_id("")); // empty
assert!(!valid_plugin_id("9lives")); // must start with a letter
assert!(!valid_plugin_id("-lead")); // must start with a letter
assert!(!valid_plugin_id("Rom")); // no uppercase
assert!(!valid_plugin_id("rom_manager")); // no underscore
assert!(!valid_plugin_id(&"a".repeat(65))); // too long
}
#[test]
fn registration_validation() {
assert!(validate(reg("ROM Manager", 49321, SECRET)).is_ok());
// control chars stripped from the title
let v = validate(PluginRegistration {
title: "Ro\u{7}m\n".into(),
version: None,
ui: None,
})
.unwrap();
assert_eq!(v.title, "Rom");
// privileged port rejected
assert!(validate(reg("x", 80, SECRET)).is_err());
// short secret rejected
assert!(validate(reg("x", 49321, "tooshort")).is_err());
// bad secret alphabet rejected
assert!(validate(reg("x", 49321, "bad secret with spaces!!")).is_err());
// empty title rejected
assert!(validate(reg(" ", 49321, SECRET)).is_err());
}
#[test]
fn upsert_reports_change_but_not_renewal() {
let r = PluginRegistry::new();
// first registration is a change
assert!(r.upsert("p", validate(reg("Title", 49321, SECRET)).unwrap()));
// identical renewal is not
assert!(!r.upsert("p", validate(reg("Title", 49321, SECRET)).unwrap()));
// a new secret (restart) is a change
assert!(r.upsert(
"p",
validate(reg("Title", 49321, "ZZZZZZZZZZZZZZZZ")).unwrap()
));
// a new title (re-scan) is a change
assert!(r.upsert(
"p",
validate(reg("New Title", 49321, "ZZZZZZZZZZZZZZZZ")).unwrap()
));
}
#[test]
fn snapshot_lists_secret_free_sorted() {
let r = PluginRegistry::new();
r.upsert("zeta", validate(reg("Zeta", 50000, SECRET)).unwrap());
r.upsert("alpha", validate(reg("Alpha", 50001, SECRET)).unwrap());
let (plugins, expired) = r.snapshot();
assert!(expired.is_empty());
assert_eq!(
plugins.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(),
vec!["alpha", "zeta"] // sorted by title
);
// the summary type has no secret field at all — check the credential path carries it
assert_eq!(r.credential("alpha").unwrap().secret, SECRET);
assert_eq!(r.credential("alpha").unwrap().port, 50001);
}
#[test]
fn credential_absent_for_ui_less_and_unknown() {
let r = PluginRegistry::new();
r.upsert(
"headless",
validate(PluginRegistration {
title: "Headless".into(),
version: None,
ui: None,
})
.unwrap(),
);
assert!(r.credential("headless").is_none()); // registered but no UI
assert!(r.credential("nope").is_none()); // unknown
}
#[test]
fn expired_entries_drop_from_listing_and_credential() {
let r = PluginRegistry::new();
r.upsert("p", validate(reg("P", 49321, SECRET)).unwrap());
// Force the lease into the past.
{
let mut map = r.inner.write().unwrap();
map.get_mut("p").unwrap().expires_at =
Instant::now().checked_sub(Duration::from_secs(1)).unwrap();
}
assert!(r.credential("p").is_none()); // expired → no credential
let (plugins, expired) = r.snapshot();
assert!(plugins.is_empty());
assert_eq!(expired, vec!["p".to_string()]); // reaped + announced once
// second snapshot no longer reports it as freshly-expired
assert!(r.snapshot().1.is_empty());
}
#[test]
fn remove_reports_live_only() {
let r = PluginRegistry::new();
r.upsert("p", validate(reg("P", 49321, SECRET)).unwrap());
assert!(r.remove("p")); // live → announced
assert!(!r.remove("p")); // already gone → silent
}
}