diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 78eb80ab..1374f3e4 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -498,6 +498,10 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, b if opts.token.is_none() { opts.token = Some(crate::mgmt_token::load_or_generate()?); } + // The scripting runner's scoped credential: minted + persisted (plugin-token) alongside the + // admin token so a plugin's zero-config `connect()` picks it up — it authorizes the plugin + // surface but not hook registration or pairing administration (mgmt::auth::plugin_may_access). + opts.plugin_token = Some(crate::mgmt_token::load_or_generate_plugin()?); // Default the mgmt listener to ALL interfaces (not just loopback) so a paired native client can // fetch the game library over mTLS with no operator step — the whole point of "browse works by // default". This only LAN-exposes the read-only cert allowlist; the bearer-token admin surface diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index daba625d..8433ae8e 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -56,6 +56,10 @@ pub struct Options { /// Bearer token required on `/api/v1` (except `/health`). `None` ⇒ unauthenticated, /// which [`run`] only permits on loopback binds. pub token: Option, + /// The scripting runner's capability-limited bearer token (`plugin-token`): authorizes the + /// plugin surface only, never hook registration or pairing administration + /// (`auth::plugin_may_access`). Optional — `None` simply disables the lane. + pub plugin_token: Option, } impl Default for Options { @@ -63,6 +67,7 @@ impl Default for Options { Options { bind: SocketAddr::from(([127, 0, 0, 1], DEFAULT_PORT)), token: None, + plugin_token: None, } } } @@ -81,6 +86,9 @@ pub(crate) struct MgmtState { /// (native-only) host, where a Moonlight PIN can never arrive. gamestream_enabled: bool, token: Option, + /// The plugin lane's token (see [`Options::plugin_token`]). Checked only after the admin token + /// mismatches, and gated by `auth::plugin_may_access` per route. + plugin_token: Option, /// The port we serve on, echoed in [`PortMap`] so a client can persist a full endpoint map. port: u16, } @@ -119,6 +127,7 @@ pub async fn run( let app = app( state, Some(token), + opts.plugin_token.filter(|t| !t.trim().is_empty()), opts.bind.port(), native, stats, @@ -131,6 +140,7 @@ pub async fn run( fn app( state: Arc, token: Option, + plugin_token: Option, port: u16, native: Option>, stats: Arc, @@ -142,6 +152,7 @@ fn app( stats, gamestream_enabled, token, + plugin_token, port, }); let (api_routes, api) = api_router_parts(); diff --git a/crates/punktfunk-host/src/mgmt/auth.rs b/crates/punktfunk-host/src/mgmt/auth.rs index d70d6e21..2ac555d8 100644 --- a/crates/punktfunk-host/src/mgmt/auth.rs +++ b/crates/punktfunk-host/src/mgmt/auth.rs @@ -1,5 +1,12 @@ //! Auth gate for the management API `/api/v1` routes: paired client cert (mTLS, from anywhere) -//! or the bearer token (loopback peers only). Split out of the `mgmt` facade (plan §W5). +//! or a bearer token (loopback peers only). Split out of the `mgmt` facade (plan §W5). +//! +//! Three lanes, three authorities: +//! - **paired streaming cert** (mTLS, LAN) — the read-only [`cert_may_access`] allowlist. +//! - **plugin token** (bearer, loopback) — the scripting runner's capability-limited credential: +//! the admin surface MINUS hook registration and pairing administration +//! ([`plugin_may_access`]). +//! - **admin token** (bearer, loopback) — everything. use super::shared::*; use crate::gamestream::tls::PeerAddr; @@ -86,6 +93,27 @@ pub(crate) async fn require_auth( .and_then(|v| v.strip_prefix("Bearer ")); match presented { Some(token) if token_eq(token, expected) => next.run(req).await, + // The scripting runner's scoped lane: same loopback confinement as the admin token, but + // routes that would let a plugin escalate — registering hooks (arbitrary command + // execution as the host user) or administering pairing (admitting/ejecting devices, + // reading the PIN) — need the operator's admin token. Checked AFTER the admin token so + // equal tokens (operator misconfiguration) degrade to full access, never to a lockout. + Some(token) + if st + .plugin_token + .as_deref() + .is_some_and(|pt| token_eq(token, pt)) => + { + if plugin_may_access(req.method(), req.uri().path()) { + next.run(req).await + } else { + api_error( + StatusCode::FORBIDDEN, + "this route is not authorized for the plugin token — it requires the \ + operator's admin token", + ) + } + } _ => api_error( StatusCode::UNAUTHORIZED, "missing or invalid credentials (a paired client cert, or a bearer token)", @@ -93,6 +121,31 @@ pub(crate) async fn require_auth( } } +/// Which routes the scripting runner's **plugin token** may reach: the admin surface minus the +/// escalation routes. Exclusion-based (a plugin legitimately reads status/library/events, drives +/// sessions, and registers its UI lease), with these carve-outs: +/// - **hooks** — `hooks.json` runs operator commands on lifecycle events; writing it is arbitrary +/// command execution as the host user, and reading it can expose webhook credentials. +/// - **pairing administration** — arming/approving/denying/unpairing (and PIN visibility) decide +/// *which devices may stream*; a plugin defect must not be able to admit an attacker's device +/// or eject the operator's. +/// - **UI proxy credentials** — a plugin has no business reading another plugin's per-boot UI +/// secret; only the console proxy (admin token) needs it. +pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool { + let denied = path == "/api/v1/hooks" + || path == "/api/v1/pair" + || path.starts_with("/api/v1/pair/") + || path == "/api/v1/native/pair" + || path.starts_with("/api/v1/native/pair/") + || path == "/api/v1/native/pending" + || path.starts_with("/api/v1/native/pending/") + || (method == Method::DELETE + && (path.starts_with("/api/v1/clients/") + || path.starts_with("/api/v1/native/clients/"))) + || (path.starts_with("/api/v1/plugins/") && path.ends_with("/ui-credential")); + !denied +} + /// Which routes a paired *streaming* cert (mTLS, no bearer token) may reach: a small allowlist of /// safe, read-only status routes only. Deny-by-default — every state-changing route and every route /// that exposes a pairing PIN or the pending-approval queue requires the operator's bearer token, so diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index 1ff39da3..9a380ddd 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -42,6 +42,8 @@ fn test_app(state: Arc, token: Option<&str>) -> Router { app( state, Some(token.unwrap_or("test-secret").to_string()), + // The scoped plugin lane, exercised by the `plugin_token_*` tests below. + Some("plugin-secret".to_string()), DEFAULT_PORT, None, stats, @@ -57,6 +59,7 @@ fn test_app_native(state: Arc, np: Arc` form (0600) so the bundled web -//! console can source it directly as a systemd `EnvironmentFile` — a single source of truth shared -//! between the host and the console with no copying. +//! on a loopback bind. This module guarantees the tokens always exist: an explicit env var wins +//! (operator override, not persisted); otherwise the persisted file under the config dir is used; +//! otherwise a fresh 32-byte hex token is generated and persisted. Files are written in +//! `KEY=` form (0600) so the bundled web console can source them directly as a systemd +//! `EnvironmentFile` — a single source of truth shared between the host and its consumers. +//! +//! Two tokens, two authorities: +//! - **`mgmt-token`** (`PUNKTFUNK_MGMT_TOKEN`) — the operator/console token; authorizes the full +//! admin surface. +//! - **`plugin-token`** (`PUNKTFUNK_PLUGIN_TOKEN`) — the scripting runner's capability-limited +//! credential (`mgmt::auth::plugin_may_access`): everything a plugin legitimately needs, but not +//! hook registration or pairing administration. The SDK's `connect()` prefers this file, so a +//! defect in an operator plugin can't rewrite `hooks.json` or admit new devices. use anyhow::{Context, Result}; use rand::RngCore; @@ -15,19 +22,32 @@ use std::path::Path; const ENV_VAR: &str = "PUNKTFUNK_MGMT_TOKEN"; const FILE: &str = "mgmt-token"; +const PLUGIN_ENV_VAR: &str = "PUNKTFUNK_PLUGIN_TOKEN"; +const PLUGIN_FILE: &str = "plugin-token"; -/// Resolve the mgmt token (env > persisted file > generate+persist). Hex (not base64) so the -/// persisted `KEY=VALUE` line is safe to source from a shell / systemd `EnvironmentFile`. +/// Resolve the mgmt (full-admin) token (env > persisted file > generate+persist). Hex (not base64) +/// so the persisted `KEY=VALUE` line is safe to source from a shell / systemd `EnvironmentFile`. pub fn load_or_generate() -> Result { - if let Ok(v) = std::env::var(ENV_VAR) { + load_or_generate_impl(ENV_VAR, FILE) +} + +/// Resolve the scripting runner's scoped plugin token, same precedence as [`load_or_generate`]. +/// Persisted to `plugin-token` next to `mgmt-token`; on Windows `plugins enable` grants the +/// runner's LocalService principal read on exactly this file (and `cert.pem`) — never `mgmt-token`. +pub fn load_or_generate_plugin() -> Result { + load_or_generate_impl(PLUGIN_ENV_VAR, PLUGIN_FILE) +} + +fn load_or_generate_impl(env_var: &str, file: &str) -> Result { + if let Ok(v) = std::env::var(env_var) { let v = v.trim(); if !v.is_empty() { return Ok(v.to_string()); } } - let path = pf_paths::config_dir().join(FILE); + let path = pf_paths::config_dir().join(file); if let Ok(contents) = fs::read_to_string(&path) { - if let Some(tok) = parse_token(&contents) { + if let Some(tok) = parse_token(&contents, env_var) { return Ok(tok); } } @@ -37,29 +57,29 @@ pub fn load_or_generate() -> Result { let dir = pf_paths::config_dir(); // Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config path. pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?; - write_token(&path, &token)?; - tracing::info!(path = %path.display(), "generated and persisted management API token (owner-only)"); + write_token(&path, env_var, &token)?; + tracing::info!(path = %path.display(), "generated and persisted API token (owner-only)"); Ok(token) } /// Parse the token from the persisted file: accept either a bare token line or a -/// `PUNKTFUNK_MGMT_TOKEN=` line (the form we write, also valid as an EnvironmentFile). -fn parse_token(contents: &str) -> Option { +/// `=` line (the form we write, also valid as an EnvironmentFile). +fn parse_token(contents: &str, env_var: &str) -> Option { let line = contents.lines().find(|l| !l.trim().is_empty())?.trim(); let tok = line - .strip_prefix("PUNKTFUNK_MGMT_TOKEN=") + .strip_prefix(env_var) + .and_then(|rest| rest.strip_prefix('=')) .unwrap_or(line) .trim(); (!tok.is_empty()).then(|| tok.to_string()) } -/// Write `PUNKTFUNK_MGMT_TOKEN=` to `path` as an owner-only secret — 0600 on Unix AND -/// DACL-locked to SYSTEM/Administrators on Windows. Routes through the shared `write_secret_file` so -/// the mgmt bearer token (full admin authority) gets the SAME Windows lockdown as the host key; the -/// bespoke `cfg(unix)`-only writer used to leave it readable by any local user (security-review -/// 2026-06-28 #2). -fn write_token(path: &Path, token: &str) -> Result<()> { - let line = format!("PUNKTFUNK_MGMT_TOKEN={token}\n"); +/// Write `=` to `path` as an owner-only secret — 0600 on Unix AND DACL-locked to +/// SYSTEM/Administrators on Windows. Routes through the shared `write_secret_file` so both bearer +/// tokens get the SAME Windows lockdown as the host key; the bespoke `cfg(unix)`-only writer used +/// to leave the mgmt token readable by any local user (security-review 2026-06-28 #2). +fn write_token(path: &Path, env_var: &str, token: &str) -> Result<()> { + let line = format!("{env_var}={token}\n"); pf_paths::write_secret_file(path, line.as_bytes()) .with_context(|| format!("write {}", path.display())) } @@ -70,13 +90,17 @@ mod tests { #[test] fn parses_bare_and_keyvalue_forms() { - assert_eq!(parse_token("abc123\n").as_deref(), Some("abc123")); + assert_eq!(parse_token("abc123\n", ENV_VAR).as_deref(), Some("abc123")); assert_eq!( - parse_token("PUNKTFUNK_MGMT_TOKEN=deadbeef\n").as_deref(), + parse_token("PUNKTFUNK_MGMT_TOKEN=deadbeef\n", ENV_VAR).as_deref(), Some("deadbeef") ); - assert_eq!(parse_token("\n \n"), None); - assert_eq!(parse_token("PUNKTFUNK_MGMT_TOKEN=\n"), None); + assert_eq!( + parse_token("PUNKTFUNK_PLUGIN_TOKEN=deadbeef\n", PLUGIN_ENV_VAR).as_deref(), + Some("deadbeef") + ); + assert_eq!(parse_token("\n \n", ENV_VAR), None); + assert_eq!(parse_token("PUNKTFUNK_MGMT_TOKEN=\n", ENV_VAR), None); } #[test] @@ -84,9 +108,9 @@ mod tests { let dir = std::env::temp_dir().join(format!("pf-mgmt-token-test-{}", std::process::id())); let _ = fs::create_dir_all(&dir); let path = dir.join(FILE); - write_token(&path, "cafef00d").unwrap(); + write_token(&path, ENV_VAR, "cafef00d").unwrap(); let read = fs::read_to_string(&path).unwrap(); - assert_eq!(parse_token(&read).as_deref(), Some("cafef00d")); + assert_eq!(parse_token(&read, ENV_VAR).as_deref(), Some("cafef00d")); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; diff --git a/sdk/src/config.ts b/sdk/src/config.ts index a49781ff..370af65d 100644 --- a/sdk/src/config.ts +++ b/sdk/src/config.ts @@ -2,9 +2,17 @@ // identity cert, from the environment with file fallbacks — so `connect()` on the host machine // needs zero configuration. // -// PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990) -// PUNKTFUNK_MGMT_TOKEN (else /mgmt-token) -// PUNKTFUNK_MGMT_CA (path; else /cert.pem when present) +// PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990) +// PUNKTFUNK_MGMT_TOKEN (admin override), else PUNKTFUNK_PLUGIN_TOKEN, +// else /plugin-token, else /mgmt-token +// PUNKTFUNK_MGMT_CA (path; else /cert.pem when present) +// +// Token precedence is deliberate: the host mints a capability-limited `plugin-token` for the +// scripting runner (it cannot register hooks or administer pairing), and that is what a plugin's +// zero-config connect() should hold — the full-admin `mgmt-token` is only a fallback for hosts +// that predate the plugin token (and on Windows the runner's LocalService principal can't read it +// at all). A script that legitimately needs the admin surface sets PUNKTFUNK_MGMT_TOKEN or passes +// { token } explicitly. // // The CA is the host's own identity certificate — trusting exactly it (not the system roots) // IS the pin for the loopback hop. Per-runtime plumbing differs: Bun takes `tls.ca` on fetch, @@ -73,11 +81,14 @@ export const resolveConfig = async ( const token = options?.token ?? process.env.PUNKTFUNK_MGMT_TOKEN ?? + process.env.PUNKTFUNK_PLUGIN_TOKEN ?? + parseTokenFile(readIfExists(path.join(configDir(), "plugin-token")) ?? "") ?? parseTokenFile(readIfExists(path.join(configDir(), "mgmt-token")) ?? ""); if (!token) { throw new Error( - "no management token: set PUNKTFUNK_MGMT_TOKEN, pass { token }, or run where " + - `the host's token file exists (${path.join(configDir(), "mgmt-token")})`, + "no management token: set PUNKTFUNK_PLUGIN_TOKEN (or PUNKTFUNK_MGMT_TOKEN), pass " + + "{ token }, or run where the host's token files exist " + + `(${path.join(configDir(), "plugin-token")})`, ); } const caPath = process.env.PUNKTFUNK_MGMT_CA;