diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 78eb80ab..11607fd8 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -168,6 +168,29 @@ fn main() { } } +/// A lightweight management/CLI subcommand — package/service/driver ops, spec/library dumps — as +/// opposed to a streaming/capture command. These never touch DXGI or run the host, so they skip the +/// startup banner and (on Windows) the GPU-preference hook, whose DPI-awareness probe otherwise +/// prints an alarming `SetProcessDpiAwarenessContext … "access denied"` WARN on a plain +/// `plugins add`. `service run` is the SCM-launched host itself, so it is explicitly NOT lightweight +/// (it must keep the hook — the hybrid-GPU ACCESS_LOST fix depends on it). +fn is_management_cli(args: &[String]) -> bool { + match args.first().map(String::as_str) { + Some("plugins") + | Some("driver") + | Some("web") + | Some("openapi") + | Some("library") + | Some("detect-conflicts") + | Some("-h") + | Some("--help") + | Some("help") + | None => true, + Some("service") => args.get(1).map(String::as_str) != Some("run"), + _ => false, + } +} + fn real_main() -> Result<()> { let args: Vec = std::env::args().skip(1).collect(); @@ -180,11 +203,16 @@ fn real_main() -> Result<()> { return Ok(()); } - tracing::info!( - "punktfunk-host {} (punktfunk_core ABI v{})", - env!("PUNKTFUNK_VERSION"), - punktfunk_core::ABI_VERSION - ); + // Lightweight CLI commands (e.g. `plugins add`) get none of the host-startup noise below. + let management_cli = is_management_cli(&args); + + if !management_cli { + tracing::info!( + "punktfunk-host {} (punktfunk_core ABI v{})", + env!("PUNKTFUNK_VERSION"), + punktfunk_core::ABI_VERSION + ); + } // Wire pf-vdisplay's display-lifecycle events into the SSE event bus (the subsystem crate emits a // neutral DisplayEvent; the orchestrator owns the bus type — plan §W6). Set once, ignore re-set. @@ -208,8 +236,12 @@ fn real_main() -> Result<()> { // render-adapter selection creates a DXGI factory during virtual-display setup, well before // capture). On a hybrid-GPU box this stops DXGI from reparenting the virtual output off the // capture GPU — the ACCESS_LOST churn fix. Idempotent (Once); harmless on non-hybrid boxes. + // Skipped for lightweight CLI commands (`plugins`, `openapi`, …): they never touch DXGI, and the + // hook's DPI-awareness probe prints a misleading "access denied" WARN that looks like a failure. #[cfg(target_os = "windows")] - crate::capture::dxgi::install_gpu_pref_hook(); + if !management_cli { + crate::capture::dxgi::install_gpu_pref_hook(); + } // NVIDIA clock hygiene (Linux, host subcommands only): install the P2-cap driver profile and, // under PUNKTFUNK_PIN_CLOCKS, hold the NVML core-clock floor for the host lifetime (reset on @@ -498,6 +530,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/crates/punktfunk-host/src/plugins.rs b/crates/punktfunk-host/src/plugins.rs index b35ff609..c0940497 100644 --- a/crates/punktfunk-host/src/plugins.rs +++ b/crates/punktfunk-host/src/plugins.rs @@ -14,9 +14,16 @@ //! package present. //! //! Windows needs elevation for both halves: the plugins dir lives under the ACL'd -//! `%ProgramData%\punktfunk` (see `pf_paths::create_private_dir`) and the task runs as SYSTEM. We +//! `%ProgramData%\punktfunk` (see `pf_paths::create_private_dir`) and the task is admin-owned. We //! check up front and print one actionable line instead of letting `bun add` fail with a bare //! EACCES. +//! +//! The task itself runs as **`NT AUTHORITY\LocalService`**, not SYSTEM: plugins are +//! operator-installed code, and a plugin defect must cost a throwaway service account, not the +//! most privileged principal on the box. `enable` converges the principal (migrating tasks an +//! older installer registered as SYSTEM) and grants LocalService read on exactly the two files +//! the runner's `connect()` needs — the scoped `plugin-token` and the TLS pin `cert.pem` — never +//! the full-admin `mgmt-token`. use anyhow::{bail, Context, Result}; use std::process::Command; @@ -71,13 +78,15 @@ USAGE: NAMES: A bare first-party name resolves into the @punktfunk scope: `playnite` installs - @punktfunk/plugin-playnite, `rom-manager` installs @punktfunk/plugin-rom-manager. - A scoped (@scope/pkg) or `punktfunk-plugin-*` name is used verbatim. + @punktfunk/plugin-playnite, `rom-manager` installs @punktfunk/plugin-rom-manager — + always from Punktfunk's own package registry. Any other name (`punktfunk-plugin-*`, + a foreign @scope) installs from the PUBLIC npm registry and is refused unless you + pass --allow-public-registry. NOTES: Plugins run under the runner, which is OPT-IN — `plugins add` installs, `plugins enable` - turns the runner on. Plugins are operator-installed code that runs as the host user; - install only plugins you trust. + turns the runner on. Plugins are operator-installed code that runs with operator + privileges; install only plugins you trust. " ); #[cfg(target_os = "windows")] @@ -212,13 +221,66 @@ fn systemctl_output(args: &[&str]) -> Option { } } +/// `NT AUTHORITY\LocalService` — the runner task's principal — in icacls SID form. +#[cfg(target_os = "windows")] +const LOCAL_SERVICE_SID: &str = "*S-1-5-19"; + +/// The two (and only two) secrets the runner needs to read to reach the mgmt API: the scoped +/// plugin token and the host identity cert it pins TLS against. `mgmt-token` (full admin) is +/// deliberately NOT here. +#[cfg(target_os = "windows")] +const RUNNER_SECRET_FILES: [&str; 2] = ["plugin-token", "cert.pem"]; + +/// The unit directories the runner imports code from. LocalService gets an inheritable +/// read+execute+write-attributes grant on these: bun's module loader opens unit files +/// requesting FILE_WRITE_ATTRIBUTES on top of read (plain `(RX)` makes every import die with +/// EPERM — found on-glass), and WA can only touch timestamps/readonly bits, never content — +/// the runner's own integrity check (`windowsSddlUnsafeReason`) treats it as harmless. +#[cfg(target_os = "windows")] +const RUNNER_UNIT_DIRS: [&str; 2] = ["plugins", "scripts"]; + +/// The runner's writable state root: `\plugin-state`. A plugin persists its config + +/// cache under `plugin-state\` (`@punktfunk/host`'s `pluginStateDir`), so LocalService needs +/// real **Modify** here — unlike the code dirs (RX,WA) and the secrets (R). This keeps the +/// three-way split crisp: code is read-only (a plugin can't rewrite itself), secrets are +/// read-only, only this one dir is writable. Inheritable so per-plugin subdirs the runner creates +/// carry the grant. Users stay read-only (config-dir default), so another non-admin still can't +/// tamper with a plugin's launch templates. +#[cfg(target_os = "windows")] +const RUNNER_STATE_DIRS: [&str; 1] = ["plugin-state"]; + +/// The plugin **ingest** inbox: `\ingest`. The INVERSE grant of `plugin-state` — +/// `BUILTIN\Users` gets **Modify**, so an app running as the interactive user (e.g. the Playnite +/// exporter, a Playnite extension) can drop data (`ingest\\…`) that the de-privileged +/// LocalService runner then READS (LocalService is a member of Users, so it inherits read here). +/// This is the one place a plugin can receive data produced by *another* account — the runner can +/// no longer traverse the interactive user's profile the way the old SYSTEM runner could. Scoped +/// to this one inbox: the rest of the config tree stays Users-read-only, so the widening is a +/// well-defined drop box, not a general write hole. (Accepted tradeoff: any local user can drop a +/// file here — trusted-single-user model, and the runner it feeds is only LocalService.) +#[cfg(target_os = "windows")] +const RUNNER_INGEST_DIRS: [&str; 1] = ["ingest"]; + +/// `BUILTIN\Users` (S-1-5-32-545) in icacls SID form — the ingest inbox's writer. +#[cfg(target_os = "windows")] +const USERS_SID: &str = "*S-1-5-32-545"; + #[cfg(target_os = "windows")] fn enable() -> Result<()> { + // Converge the task principal BEFORE starting it: the installer registers it as LocalService, + // but a task from an older install (or a hand-registered dev box) still runs as SYSTEM, and + // enabling that unmigrated would hand operator plugins the highest privilege on the box. + // Idempotent; -LogonType ServiceAccount needs no stored password. + powershell(&format!( + "$p = New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount; \ + Set-ScheduledTask -TaskName {TASK} -Principal $p -ErrorAction Stop | Out-Null" + ))?; + grant_runner_secret_reads(); powershell(&format!( "Enable-ScheduledTask -TaskName {TASK} -ErrorAction Stop | Out-Null; \ Start-ScheduledTask -TaskName {TASK} -ErrorAction Stop" ))?; - println!("Plugin runner enabled and started ({TASK})."); + println!("Plugin runner enabled and started ({TASK}, runs as LocalService)."); Ok(()) } @@ -228,17 +290,173 @@ fn disable() -> Result<()> { "Stop-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \ Disable-ScheduledTask -TaskName {TASK} -ErrorAction Stop | Out-Null" ))?; + revoke_runner_secret_reads(); println!("Plugin runner stopped and disabled ({TASK})."); Ok(()) } +/// Grant LocalService **read** on the runner's two secret files. Both are written by the host's +/// `serve` with a SYSTEM/Administrators-only DACL (`pf_paths::write_secret_file`), which the +/// de-privileged runner cannot read — this is the one, narrow widening it needs. `/grant:r` +/// replaces only LocalService's ACE, leaving the lockdown otherwise intact. Files the host hasn't +/// minted yet get an actionable note instead of a failed icacls: the grant re-runs on the next +/// `plugins enable`. NOTE: the host re-locks a secret's DACL whenever it rewrites the file (e.g. +/// a regenerated identity cert) — re-running `plugins enable` restores the grant. +#[cfg(target_os = "windows")] +fn grant_runner_secret_reads() { + let cfg = pf_paths::config_dir(); + for name in RUNNER_SECRET_FILES { + let path = cfg.join(name); + if !path.exists() { + println!( + "note: {} does not exist yet (the host writes it on first serve). Start the \ + host once, then run `punktfunk-host plugins enable` again so the runner can \ + authenticate.", + path.display() + ); + continue; + } + let ok = Command::new(icacls_path()) + .arg(&path) + .args(["/grant:r", &format!("{LOCAL_SERVICE_SID}:(R)")]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()); + if !ok { + eprintln!( + "warning: could not grant LocalService read on {} - the plugin runner may fail \ + to authenticate to the management API", + path.display() + ); + } + } + // The unit dirs: inheritable (RX,WA) so the runner can import what lives there (see + // RUNNER_UNIT_DIRS). Created here if absent — an elevated create inherits the config dir's + // protected DACL, and granting now means files the operator adds later are covered by + // inheritance rather than needing another `plugins enable`. + for name in RUNNER_UNIT_DIRS { + let dir = cfg.join(name); + if let Err(e) = std::fs::create_dir_all(&dir) { + eprintln!("warning: could not create {}: {e}", dir.display()); + continue; + } + let ok = Command::new(icacls_path()) + .arg(&dir) + .args(["/grant:r", &format!("{LOCAL_SERVICE_SID}:(OI)(CI)(RX,WA)")]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()); + if !ok { + eprintln!( + "warning: could not grant LocalService read on {} - the runner may fail to \ + import plugins/scripts from it", + dir.display() + ); + } + } + // The state root: inheritable Modify so plugins can persist config/cache under + // `plugin-state\` (see RUNNER_STATE_DIRS). This is the ONLY writable grant. + for name in RUNNER_STATE_DIRS { + let dir = cfg.join(name); + if let Err(e) = std::fs::create_dir_all(&dir) { + eprintln!("warning: could not create {}: {e}", dir.display()); + continue; + } + let ok = Command::new(icacls_path()) + .arg(&dir) + .args(["/grant:r", &format!("{LOCAL_SERVICE_SID}:(OI)(CI)(M)")]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()); + if !ok { + eprintln!( + "warning: could not grant LocalService write on {} - state-writing plugins \ + (config/cache) may fail to persist", + dir.display() + ); + } + } + // The ingest inbox: inheritable Modify for BUILTIN\Users, so an interactive-user app (the + // Playnite exporter) can drop `ingest\\…` for the LocalService runner to read (see + // RUNNER_INGEST_DIRS). The one Users-writable carve-out in the otherwise Users-read-only tree. + for name in RUNNER_INGEST_DIRS { + let dir = cfg.join(name); + if let Err(e) = std::fs::create_dir_all(&dir) { + eprintln!("warning: could not create {}: {e}", dir.display()); + continue; + } + let ok = Command::new(icacls_path()) + .arg(&dir) + .args(["/grant:r", &format!("{USERS_SID}:(OI)(CI)(M)")]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()); + if !ok { + eprintln!( + "warning: could not open the ingest inbox {} for writes - a plugin fed by an \ + interactive-user app (e.g. playnite) may see no data", + dir.display() + ); + } + } +} + +/// Best-effort removal of the LocalService read grants when the runner is switched off — the +/// mirror of [`grant_runner_secret_reads`]; `enable` re-grants. +#[cfg(target_os = "windows")] +fn revoke_runner_secret_reads() { + let cfg = pf_paths::config_dir(); + for name in RUNNER_SECRET_FILES + .iter() + .chain(RUNNER_UNIT_DIRS.iter()) + .chain(RUNNER_STATE_DIRS.iter()) + { + let path = cfg.join(name); + if !path.exists() { + continue; + } + let _ = Command::new(icacls_path()) + .arg(&path) + .args(["/remove:g", LOCAL_SERVICE_SID]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } + // The ingest inbox was opened to Users, not LocalService — remove that explicit grant (the + // inherited Users:RX from the config dir remains, so it reverts to read-only, not orphaned). + for name in RUNNER_INGEST_DIRS { + let path = cfg.join(name); + if !path.exists() { + continue; + } + let _ = Command::new(icacls_path()) + .arg(&path) + .args(["/remove:g", USERS_SID]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } +} + +/// Resolve icacls by full System32 path rather than PATH — same planted-binary reasoning as +/// [`powershell_path`]; matches `pf_paths`. +#[cfg(target_os = "windows")] +fn icacls_path() -> String { + std::env::var("SystemRoot") + .map(|r| format!(r"{r}\System32\icacls.exe")) + .unwrap_or_else(|_| "icacls".to_string()) +} + #[cfg(target_os = "windows")] fn status() -> Result<()> { let out = powershell_output(&format!( "$t = Get-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \ if ($null -eq $t) {{ 'missing' }} else {{ \ - $i = Get-ScheduledTaskInfo -TaskName {TASK} -ErrorAction SilentlyContinue; \ - \"$($t.State)\" }}" + \"$($t.State)|$($t.Principal.UserId)\" }}" )); match out.as_deref().map(str::trim) { Some("missing") | None => { @@ -248,7 +466,10 @@ fn status() -> Result<()> { ); } Some(state) => { - println!("runner: {TASK}\nstate: {state}"); + // "State|Principal" — the principal line makes the SYSTEM→LocalService migration + // verifiable at a glance (`plugins enable` converges a legacy SYSTEM task). + let (state, principal) = state.split_once('|').unwrap_or((state, "?")); + println!("runner: {TASK}\nstate: {state}\nruns as: {principal}"); if state.eq_ignore_ascii_case("Disabled") { println!("\nEnable it with: punktfunk-host plugins enable"); } diff --git a/docs-site/content/docs/automation.md b/docs-site/content/docs/automation.md index 2fe7cc49..8e87c6b8 100644 --- a/docs-site/content/docs/automation.md +++ b/docs-site/content/docs/automation.md @@ -157,6 +157,13 @@ The canonical "decide, don't just observe" pattern — approve pairing from your `POST /api/v1/native/pending/{id}/approve` when you tap yes. The full API is documented at [`/api/docs`](/api) on your host. +> A unit under the runner auto-connects with the host's **scoped plugin token**, which covers +> the everyday surface (status, library, sessions, events) but deliberately not **hook +> registration** or **pairing administration** — so a plugin defect can't admit new devices or +> install commands. A script that should administer pairing (like the approval pattern above) +> opts into the full-admin credential explicitly: set `PUNKTFUNK_MGMT_TOKEN` on the unit (e.g. +> a `systemctl --user edit punktfunk-scripting` drop-in) or pass `{ token }` to `connect()`. + ## Recipe: full controller passthrough (VirtualHere) To get a controller's *native* features on the host — DualSense gyro, touchpad, adaptive diff --git a/docs-site/content/docs/plugins.mdx b/docs-site/content/docs/plugins.mdx index af8fd616..b5e3fdce 100644 --- a/docs-site/content/docs/plugins.mdx +++ b/docs-site/content/docs/plugins.mdx @@ -31,8 +31,9 @@ punktfunk-host plugins enable # turn the runner on (once) Run these from an **elevated** PowerShell — right-click **PowerShell** → **Run as administrator**. -The plugins directory lives under `%ProgramData%\punktfunk`, which is admin-owned, and the runner -task runs as SYSTEM. +The plugins directory lives under `%ProgramData%\punktfunk`, which is admin-owned. The runner task +itself runs as the low-privilege `NT AUTHORITY\LocalService` account — `plugins enable` sets that +up (including read access to the runner's scoped API token). ```powershell punktfunk-host plugins add playnite # or: rom-manager @@ -62,11 +63,13 @@ The runner is **opt-in**: `plugins add` installs, `plugins enable` turns it on. | `punktfunk-host plugins disable` | Stop + disable the runner. | | `punktfunk-host plugins status` | Is the runner enabled and running? | -A bare name resolves to the first-party package — `playnite` installs `@punktfunk/plugin-playnite`. -A scoped (`@scope/pkg`) or `punktfunk-plugin-*` name is used verbatim, so third-party plugins work -the same way. +A bare name resolves to the first-party package — `playnite` installs `@punktfunk/plugin-playnite`, +always from Punktfunk's own package registry. Any other name (`punktfunk-plugin-*`, a foreign +`@scope/pkg`) would install from the **public npm registry** and is refused unless you add +`--allow-public-registry` — a guard against typos and look-alike packages pulling untrusted code +onto your host. -> Plugins are operator-installed code that runs as the host user — they can launch games and run +> Plugins are operator-installed code with operator privileges — they can launch games and run > commands. Install only plugins you trust, from a registry you control. ## ROM Manager diff --git a/packaging/windows/punktfunk-host.iss b/packaging/windows/punktfunk-host.iss index fde85ae8..0a6e60d7 100644 --- a/packaging/windows/punktfunk-host.iss +++ b/packaging/windows/punktfunk-host.iss @@ -291,13 +291,17 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParam StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated #endif #ifdef WithScripting -; Register the plugin/script runner's scheduled task (boot, SYSTEM, restart-on-failure) but leave it +; Register the plugin/script runner's scheduled task (boot, restart-on-failure) but leave it ; DISABLED - the runner is OPT-IN (inert until you add scripts/plugins). Enable it when ready: -; Enable-ScheduledTask -TaskName PunktfunkScripting +; punktfunk-host plugins enable +; Principal: NT AUTHORITY\LocalService, NOT SYSTEM - plugins are operator-installed code; a plugin +; defect must cost a throwaway service account, not the box's highest privilege. `plugins enable` +; grants LocalService read on the two secrets the runner needs (plugin-token, cert.pem) and +; converges tasks an older installer registered as SYSTEM. ; Best-effort (-ErrorAction SilentlyContinue): a task hiccup never fails the whole install. No braces ; in the command, so no Inno {{ }} escaping needed. Filename: "powershell.exe"; \ - Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$a=New-ScheduledTaskAction -Execute '{app}\scripting\scripting-run.cmd'; $t=New-ScheduledTaskTrigger -AtStartup; $p=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p -Settings $s -Force -ErrorAction SilentlyContinue | Out-Null; Disable-ScheduledTask -TaskName PunktfunkScripting -ErrorAction SilentlyContinue | Out-Null"""; \ + Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$a=New-ScheduledTaskAction -Execute '{app}\scripting\scripting-run.cmd'; $t=New-ScheduledTaskTrigger -AtStartup; $p=New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p -Settings $s -Force -ErrorAction SilentlyContinue | Out-Null; Disable-ScheduledTask -TaskName PunktfunkScripting -ErrorAction SilentlyContinue | Out-Null"""; \ StatusMsg: "Registering the punktfunk script runner (disabled; opt-in)..."; Flags: runhidden waituntilterminated #endif ; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the diff --git a/scripts/punktfunk-scripting.service b/scripts/punktfunk-scripting.service index 12f0103b..58087f7c 100644 --- a/scripts/punktfunk-scripting.service +++ b/scripts/punktfunk-scripting.service @@ -10,8 +10,10 @@ # you add scripts or install plugins. Turn it on once you have automation to run: # systemctl --user enable --now punktfunk-scripting # -# Auto-wired like the console: a plugin's connect() reads the host's mgmt token + identity cert from -# ~/.config/punktfunk/{mgmt-token,cert.pem} (written by the host's `serve`) — no env editing. +# Auto-wired like the console: a plugin's connect() reads the host's SCOPED plugin token + identity +# cert from ~/.config/punktfunk/{plugin-token,cert.pem} (written by the host's `serve`) — no env +# editing. The plugin token authorizes the plugin surface but not hook registration or pairing +# administration; a script that needs the admin surface sets PUNKTFUNK_MGMT_TOKEN explicitly. [Unit] Description=punktfunk plugin/script runner Documentation=https://git.unom.io/unom/punktfunk @@ -29,6 +31,19 @@ RestartSec=2 KillMode=mixed KillSignal=SIGTERM TimeoutStopSec=30 +# Sandbox: free hardening for well-behaved plugins. The filesystem is read-only outside the home +# directory (ReadWritePaths keeps plugin state, download dirs, and ~/.config/punktfunk writable); +# /tmp is private; no setuid re-escalation; sockets limited to what automation actually uses +# (loopback mgmt API, LAN/IPv6 webhooks, unix sockets). A plugin that must write OUTSIDE $HOME +# (e.g. a library on another mount) gets a drop-in: +# systemctl --user edit punktfunk-scripting → [Service]\nReadWritePaths=/mnt/games +# NOTE: the mount-namespace options (ProtectSystem/PrivateTmp) need unprivileged user namespaces +# for a *user* unit; on kernels/distros that restrict those, drop them via the same drop-in. +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ReadWritePaths=%h +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 [Install] WantedBy=default.target diff --git a/scripts/windows/README.md b/scripts/windows/README.md index a7c20574..4d9c5c77 100644 --- a/scripts/windows/README.md +++ b/scripts/windows/README.md @@ -55,15 +55,40 @@ powershell -ExecutionPolicy Bypass -File scripts\windows\build-web.ps1 this to iterate on the console against an installed host - `punktfunk-host.exe web setup` (or a fresh install) is what creates the task in the first place. +## Plugin/script runner + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\windows\build-scripting.ps1 +powershell -ExecutionPolicy Bypass -File scripts\windows\build-scripting.ps1 -EnableTask +``` + +`bun install && bun build src/runner-cli.ts --target=bun` in `sdk\` -> one self-contained +`runner-cli.js` (effect + the SDK inlined; the operator's plugin `import()` stays a runtime import, +gated on the same `attempt=` check CI and the `.deb` builder use), then lays it out as +`\scripting\runner-cli.js` + `scripting-run.cmd` with the bun runtime at `\bun\bun.exe`. + +**That layout is load-bearing.** `punktfunk-host plugins add/remove/list` forwards package ops to the +runner, and on Windows it resolves the runner *relative to the running exe* (`crates\punktfunk-host\src\plugins.rs`). +Since `deploy-host.ps1` runs the service out of `target\release`, a bundle sitting only in the +installed `{app}` leaves the freshly built exe reporting *"the plugin runner isn't installed"*. The +script deploys next to **every** host exe it finds - the built one and whatever the `PunktfunkHost` +service actually runs. + +The `PunktfunkScripting` task is registered **disabled** (opt-in) by the installer, so the script +stages the bundle but does not silently enable it. Pass `-EnableTask` on a box you are validating +plugins on (equivalent to `punktfunk-host plugins enable`). + ## Rebuild + redeploy everything ```powershell powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-all.ps1 +powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-all.ps1 -EnableScriptingTask ``` -Thin wrapper: runs `deploy-host.ps1` then `build-web.ps1` in sequence. If the host build/start -fails, `deploy-host.ps1` rolls itself back and throws, which stops this script before the web -console step runs. +Thin wrapper: runs `deploy-host.ps1`, `build-web.ps1` then `build-scripting.ps1` in sequence — the +web console and plugin runner are **always** included, so the host binary and the runner bundle +never drift apart. If the host build/start fails, `deploy-host.ps1` rolls itself back and throws, +which stops this script before the later steps run. ## Typical flow after pulling new code diff --git a/scripts/windows/build-scripting.ps1 b/scripts/windows/build-scripting.ps1 new file mode 100644 index 00000000..ae63a3ea --- /dev/null +++ b/scripts/windows/build-scripting.ps1 @@ -0,0 +1,191 @@ +<# + Rebuild the plugin/script runner bundle from the CURRENT sdk/ source and lay it out next to the + host exe, so `punktfunk-host plugins ...` works and the PunktfunkScripting task runs new code. + + powershell -ExecutionPolicy Bypass -File scripts\windows\build-scripting.ps1 + powershell -ExecutionPolicy Bypass -File scripts\windows\build-scripting.ps1 -EnableTask + + WHY the layout matters: the plugins CLI forwards package ops (add/remove/list) to the runner, and + on Windows it resolves the runner RELATIVE TO THE RUNNING EXE - \bun\bun.exe and + \scripting\runner-cli.js (crates\punktfunk-host\src\plugins.rs). deploy-host.ps1 runs the + service out of target\release, so a bundle sitting only in the installed {app} leaves the freshly + built exe reporting "the plugin runner isn't installed". We therefore deploy next to EVERY host exe + we can find: the built one, and whatever the PunktfunkHost service actually runs. + + The PunktfunkScripting task ships DISABLED (opt-in). We do not silently enable it - pass + -EnableTask on a dev box you are validating plugins on. +#> +param( + [switch]$EnableTask # enable + restart PunktfunkScripting (opt-in) +) +$ErrorActionPreference = 'Stop' +$repo = Split-Path (Split-Path $PSScriptRoot) # scripts\windows -> repo root +$sdk = Join-Path $repo 'sdk' +$task = 'PunktfunkScripting' + +# bun is both the bundler AND the runtime the runner ships on. Honor $env:BUN_EXE (what CI sets) +# before falling back to the dev box's portable copy - the same one build-web.ps1 uses. +$bun = $env:BUN_EXE +if (-not ($bun -and (Test-Path $bun))) { $bun = 'C:\Users\Public\bun\bin\bun.exe' } +if (-not (Test-Path $bun)) { throw "bun not found at $bun (set `$env:BUN_EXE to override)" } + +Write-Host "== punktfunk plugin/script runner deploy ==" +Write-Host "bun : $bun" + +# --- 1. build the self-contained bundle ------------------------------------------------------ +# --target=bun inlines effect + the SDK into ONE js; the operator's plugin import stays a runtime +# import. --ignore-scripts skips the `prepare` codegen (it wants ../api/openapi.json, not needed). +$stage = Join-Path $repo 'target\scripting' +New-Item -ItemType Directory -Force -Path $stage | Out-Null +$bundle = Join-Path $stage 'runner-cli.js' + +Push-Location $sdk +try { + Write-Host "bun install ..." + & $bun install --frozen-lockfile --ignore-scripts + if ($LASTEXITCODE -ne 0) { throw "sdk bun install failed (exit $LASTEXITCODE)" } + Write-Host "bun build src/runner-cli.ts -> $bundle" + & $bun build src/runner-cli.ts --target=bun "--outfile=$bundle" + if ($LASTEXITCODE -ne 0) { throw "runner bundle build failed (exit $LASTEXITCODE)" } +} finally { Pop-Location } + +# Same gate CI and the .deb builder use: 'attempt=' only survives when the dynamic plugin import was +# bundled as a RUNTIME import. If it is missing the bundle would load but never run a plugin. +if (-not (Select-String -Path $bundle -Pattern 'attempt=' -Quiet)) { + throw "runner bundle missing the dynamic plugin import - wrong build" +} +Write-Host "bundle : OK ($([math]::Round((Get-Item $bundle).Length / 1KB)) KB)" + +# --- 2. work out every host-exe dir that needs the payload ------------------------------------ +$targets = New-Object System.Collections.Generic.List[string] +function Add-Target([string]$exe) { + if (-not $exe) { return } + $dir = Split-Path $exe + if ($dir -and (Test-Path $dir) -and -not ($targets -contains $dir)) { $targets.Add($dir) | Out-Null } +} + +# a) the binary deploy-host.ps1 just built - what you invoke by hand to test the new CLI. +Add-Target (Join-Path $repo 'target\release\punktfunk-host.exe') + +# b) whatever the service actually runs (an installed {app} on a box set up via setup.exe). The +# binPath carries args and may be quoted, so pull the .exe out with a regex. +$qc = & sc.exe qc 'PunktfunkHost' 2>$null +if ($qc) { + $line = $qc | Select-String 'BINARY_PATH_NAME' | Select-Object -First 1 + if ($line -and "$line" -match '([A-Za-z]:\\[^"]*?punktfunk-host\.exe)') { Add-Target $Matches[1] } +} + +if ($targets.Count -eq 0) { throw "no punktfunk-host.exe found - run deploy-host.ps1 first." } + +# --- 3. lay out \scripting\{runner-cli.js,scripting-run.cmd} + \bun\bun.exe --- +# Stop a LIVE runner first: it holds bun.exe (and the bundle) open, so copying over them fails with a +# sharing violation. Step 4 restarts it. Reap stragglers by command line the way build-web.ps1 does - +# schtasks /end does not always take the child bun with it. +$existing = Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue +if ($existing -and $existing.State -eq 'Running') { + Write-Host "stopping $task (holds bun.exe open) ..." + & schtasks /end /tn $task 2>$null | Out-Null + Get-CimInstance Win32_Process -Filter "Name='bun.exe'" -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -match 'runner-cli\.js' } | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } + Start-Sleep 2 +} + +foreach ($dir in $targets) { + Write-Host "" + Write-Host "deploying -> $dir" + $scrDir = Join-Path $dir 'scripting' + $bunDir = Join-Path $dir 'bun' + New-Item -ItemType Directory -Force -Path $scrDir, $bunDir | Out-Null + + Copy-Item $bundle (Join-Path $scrDir 'runner-cli.js') -Force + Copy-Item (Join-Path $PSScriptRoot 'scripting-run.cmd') (Join-Path $scrDir 'scripting-run.cmd') -Force + # The runner import()s the operator's .ts plugins, so bun ships WITH it rather than being assumed + # on PATH - exactly what the installer does. Skip the copy when it is already the same build. + $bunDst = Join-Path $bunDir 'bun.exe' + if (-not (Test-Path $bunDst) -or (Get-Item $bunDst).Length -ne (Get-Item $bun).Length) { + Copy-Item $bun $bunDst -Force + } + Write-Host " scripting\runner-cli.js, scripting\scripting-run.cmd, bun\bun.exe" +} + +# --- 3.5 de-privilege: LocalService principal + secret read grants ---------------------------- +# The runner task runs as NT AUTHORITY\LocalService (NOT SYSTEM - a plugin defect must cost a +# throwaway account). Converge a task registered as SYSTEM by an older installer or by hand, and +# grant LocalService read on the two files the runner's connect() needs: the scoped plugin-token +# and the TLS-pin cert.pem. NEVER mgmt-token (full admin). Mirrors `punktfunk-host plugins enable`. +if ($existing) { + $principal = New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount + Set-ScheduledTask -TaskName $task -Principal $principal | Out-Null + Write-Host "" + Write-Host "task : $task principal -> NT AUTHORITY\LocalService" + $cfg = Join-Path $env:ProgramData 'punktfunk' + foreach ($secret in @('plugin-token', 'cert.pem')) { + $file = Join-Path $cfg $secret + if (Test-Path $file) { + & "$env:SystemRoot\System32\icacls.exe" $file /grant:r '*S-1-5-19:(R)' | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Host "warn : icacls grant failed on $file" } + } else { + Write-Host "note : $file not found - start the host once, then re-run (the runner" + Write-Host " needs LocalService read on it to authenticate)." + } + } + # Unit dirs get inheritable (RX,WA): bun's module loader opens unit files requesting + # FILE_WRITE_ATTRIBUTES on top of read - plain (RX) makes every import EPERM (found + # on-glass). WA can only touch timestamps/attribute bits, never content. + foreach ($unitDir in @('plugins', 'scripts')) { + $dirPath = Join-Path $cfg $unitDir + New-Item -ItemType Directory -Force -Path $dirPath | Out-Null + & "$env:SystemRoot\System32\icacls.exe" $dirPath /grant:r '*S-1-5-19:(OI)(CI)(RX,WA)' | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Host "warn : icacls grant failed on $dirPath" } + } + # State root gets inheritable Modify - the ONE writable grant, so a plugin can persist its + # config/cache under plugin-state\ (@punktfunk/host's pluginStateDir). Code dirs stay + # RX+WA, secrets stay R; only this dir is writable. + $stateDir = Join-Path $cfg 'plugin-state' + New-Item -ItemType Directory -Force -Path $stateDir | Out-Null + & "$env:SystemRoot\System32\icacls.exe" $stateDir /grant:r '*S-1-5-19:(OI)(CI)(M)' | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Host "warn : icacls grant failed on $stateDir" } + # Ingest inbox gets inheritable Modify for BUILTIN\Users - the INVERSE grant, so an + # interactive-user app (the Playnite exporter) can drop ingest\\ data the LocalService + # runner reads. The one Users-writable carve-out in the otherwise Users-read-only tree. + $ingestDir = Join-Path $cfg 'ingest' + New-Item -ItemType Directory -Force -Path $ingestDir | Out-Null + & "$env:SystemRoot\System32\icacls.exe" $ingestDir /grant:r '*S-1-5-32-545:(OI)(CI)(M)' | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Host "warn : icacls grant failed on $ingestDir" } +} + +# --- 4. the opt-in scheduled task ------------------------------------------------------------- +# Registered DISABLED by the installer; the runner is inert until there is automation to run. We only +# bounce it when it already exists, and only enable it when explicitly asked. ($existing was captured +# in step 3, BEFORE we stopped a running instance - so State still reflects how we found the box.) +Write-Host "" +if (-not $existing) { + Write-Host "note : the $task task is not registered on this box (installer registers it)." + Write-Host " The plugins CLI still works - it runs the runner directly." +} +elseif ($EnableTask) { + if ($existing.State -eq 'Disabled') { + Enable-ScheduledTask -TaskName $task | Out-Null + Write-Host "task : $task ENABLED (-EnableTask)" + } + & schtasks /end /tn $task 2>$null | Out-Null + Start-Sleep 2 + & schtasks /run /tn $task | Out-Null + Write-Host "task : $task restarted on the new bundle" +} +elseif ($existing.State -eq 'Disabled') { + Write-Host "note : $task is DISABLED (opt-in, as shipped) - the new bundle is staged but no" + Write-Host " runner is supervising plugins. Enable it for on-glass validation with:" + Write-Host " powershell -File scripts\windows\build-scripting.ps1 -EnableTask" + Write-Host " (or: punktfunk-host plugins enable)" +} +else { + & schtasks /end /tn $task 2>$null | Out-Null + Start-Sleep 2 + & schtasks /run /tn $task | Out-Null + Write-Host "task : $task restarted on the new bundle" +} + +Write-Host "" +Write-Host "DONE - plugin/script runner deployed." diff --git a/scripts/windows/deploy-all.ps1 b/scripts/windows/deploy-all.ps1 index e6fa9f5c..88fe1184 100644 --- a/scripts/windows/deploy-all.ps1 +++ b/scripts/windows/deploy-all.ps1 @@ -1,26 +1,40 @@ <# - Rebuild + redeploy everything: the Windows host service AND the web management console. - Thin wrapper around deploy-host.ps1 + build-web.ps1 - see scripts\windows\README.md for what - each one does on its own (rollback behavior, build env, etc). + Rebuild + redeploy everything: the Windows host service, the web management console AND the + plugin/script runner. Thin wrapper around deploy-host.ps1 + build-web.ps1 + build-scripting.ps1 - + see scripts\windows\README.md for what each one does on its own (rollback behavior, build env, etc). powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-all.ps1 + powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-all.ps1 -EnableScriptingTask + + All three modules are ALWAYS deployed - a host binary whose runner bundle is stale (or missing) + fails `punktfunk-host plugins add` outright, since the CLI forwards package ops to the runner it + finds next to the exe. Run from an elevated PowerShell. deploy-host.ps1 throws (and rolls itself back) on a failed - build/start, which stops this script before the web console step runs. + build/start, which stops this script before the later steps run. #> +param( + [switch]$EnableScriptingTask # forwarded to build-scripting.ps1 (opt-in task) +) $ErrorActionPreference = 'Stop' $here = $PSScriptRoot Write-Host "==========================================" -Write-Host " 1/2 host service" +Write-Host " 1/3 host service" Write-Host "==========================================" & (Join-Path $here 'deploy-host.ps1') Write-Host "" Write-Host "==========================================" -Write-Host " 2/2 web console" +Write-Host " 2/3 web console" Write-Host "==========================================" & (Join-Path $here 'build-web.ps1') Write-Host "" -Write-Host "DONE - host + web console redeployed." +Write-Host "==========================================" +Write-Host " 3/3 plugin/script runner" +Write-Host "==========================================" +& (Join-Path $here 'build-scripting.ps1') -EnableTask:$EnableScriptingTask + +Write-Host "" +Write-Host "DONE - host + web console + plugin runner redeployed." diff --git a/scripts/windows/scripting-run.cmd b/scripts/windows/scripting-run.cmd index 184eb3af..eda4d235 100644 --- a/scripts/windows/scripting-run.cmd +++ b/scripts/windows/scripting-run.cmd @@ -6,8 +6,10 @@ rem Enable it once you have scripts/plugins: Enable-ScheduledTask -TaskName Pun rem rem Lays out next to the installed payload: {app}\scripting\scripting-run.cmd + runner-cli.js and rem {app}\bun\bun.exe (so %~dp0 = {app}\scripting\). The runner discovers the operator's units under -rem %ProgramData%\punktfunk\{scripts,plugins}; a plugin's connect() auto-wires to the host's mgmt -rem token + identity cert in %ProgramData%\punktfunk\ (written by the host's `serve`). No env editing. +rem %ProgramData%\punktfunk\{scripts,plugins}; a plugin's connect() auto-wires to the host's SCOPED +rem plugin-token + identity cert in %ProgramData%\punktfunk\ (written by the host's `serve`). The +rem task runs as NT AUTHORITY\LocalService - `plugins enable` grants it read on exactly those two +rem files. No env editing. setlocal EnableExtensions set "BUN=%~dp0..\bun\bun.exe" diff --git a/sdk/README.md b/sdk/README.md index c6d1727e..06844c96 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -106,7 +106,7 @@ Plus a real-world recipe: | What | Source | |---|---| | URL | `{ url }` → `PUNKTFUNK_MGMT_URL` → `https://127.0.0.1:47990` | -| Token | `{ token }` → `PUNKTFUNK_MGMT_TOKEN` → `/mgmt-token` | +| Token | `{ token }` → `PUNKTFUNK_MGMT_TOKEN` → `PUNKTFUNK_PLUGIN_TOKEN` → `/plugin-token` → `/mgmt-token` | | TLS pin | `{ ca }` → `PUNKTFUNK_MGMT_CA` (path) → `/cert.pem` | `` is `~/.config/punktfunk` (Linux/macOS) or `%ProgramData%\punktfunk` (Windows) — @@ -115,8 +115,13 @@ the host's self-signed identity cert (chain-verified; the hostname check is waiv is deliberately CN-only, native clients pin its fingerprint). Bun and Node are first-class; other runtimes fall back to system trust (point your runtime's CA option at `cert.pem`). -The bearer token is the host's **admin** credential and is honored from loopback only — run -scripts on the host box (or through an SSH tunnel). +The zero-config default is the host's **scoped plugin token** (`plugin-token`): the everyday +surface — status, library, sessions, events, the plugin UI lease — but deliberately **not** hook +registration or pairing administration, so a plugin defect can't install commands or admit +devices. A script that needs the full admin surface opts in explicitly with +`PUNKTFUNK_MGMT_TOKEN` or `{ token }` (`mgmt-token` remains the fallback on hosts that predate +the plugin token). Both tokens are honored from loopback only — run scripts on the host box (or +through an SSH tunnel). ## Events @@ -150,6 +155,43 @@ export default definePlugin({ In v1 a plugin is a script you run (see below); the managed runner package is a later step. +### Persisting state — `pluginStateDir` + +A plugin that keeps config or a cache must write it under `pluginStateDir("")`, **not** +directly under the config dir: + +```ts +import { pluginStateDir } from "@punktfunk/host"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +const dir = pluginStateDir("rom-manager"); // /plugin-state/rom-manager +fs.mkdirSync(dir, { recursive: true }); +fs.writeFileSync(path.join(dir, "cache.json"), data); +``` + +This matters on Windows: the managed runner is de-privileged (`NT AUTHORITY\LocalService`) and the +config dir is locked read-only, so a write straight under it fails with `EPERM`. `punktfunk-host +plugins enable` grants the runner write on exactly `plugin-state` — the config dir and your plugin's +*code* stay read-only. On Linux the runner owns the whole config dir, so the same path is writable +with no special step. + +### Receiving data from an interactive-user app — `pluginIngestDir` + +If a plugin needs data produced by a **different account** — e.g. a desktop app running as the +logged-in user, like the Playnite exporter — it can't read it from that user's profile: the +de-privileged Windows runner can't traverse `C:\Users\\…`. `pluginIngestDir("")` +resolves an inbox (`/ingest/`) that `plugins enable` makes **user-writable**, so +your app drops a file there and the runner reads it: + +```ts +import { pluginIngestDir } from "@punktfunk/host"; +const inbox = pluginIngestDir("playnite"); // /ingest/playnite (your app writes here) +``` + +Treat what you read from it as lower trust than your own state — the inbox is writable by any local +user. + ### A plugin UI in the console — `servePluginUi` A plugin can surface a web UI **inside the punktfunk console** — no second password or port for the @@ -260,7 +302,8 @@ WantedBy=default.target Windows Task Scheduler: a task triggered *At log on* running `bun C:\Users\me\punktfunk-scripts\myscript.ts` (the SDK reads -`%ProgramData%\punktfunk\mgmt-token` — run the task as an account that can). +`%ProgramData%\punktfunk\plugin-token` — run the task as an account that can; the managed +runner's `plugins enable` grants its LocalService principal exactly that read). ## Compatibility diff --git a/sdk/src/config.ts b/sdk/src/config.ts index a49781ff..43eb4800 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, @@ -44,6 +52,47 @@ export const configDir = (): string => { return path.join(base, "punktfunk"); }; +/** + * The writable state directory a plugin should persist its config/cache into: + * `/plugin-state[/]`. + * + * WHY this and not `/` directly: on Windows the managed runner is de-privileged + * (runs as `NT AUTHORITY\LocalService`), and the config dir is locked to Users-read — so a plugin + * writing straight under it fails with EPERM. `punktfunk-host plugins enable` grants the runner + * **Modify** on exactly `plugin-state` (the config dir and the plugin *code* stay read-only), so + * this is the one place a supervised plugin can write. On Linux the runner is a `systemd --user` + * unit owning the whole config dir, so the path is writable there too — same code, no branch. + * + * `name` is a plugin's own kebab-case id; omit it for the shared root. The directory is NOT created + * here (the caller decides permissions/timing) — `fs.mkdirSync(pluginStateDir(name), {recursive: + * true})` from the runner inherits the granted ACL on Windows. + */ +export const pluginStateDir = (name?: string): string => { + const root = path.join(configDir(), "plugin-state"); + return name ? path.join(root, name) : root; +}; + +/** + * The ingest inbox a plugin reads data DROPPED BY ANOTHER ACCOUNT from: + * `/ingest[/]`. + * + * The mirror of {@link pluginStateDir}, and the answer to a problem the de-privileging creates on + * Windows: the LocalService runner can no longer traverse the interactive user's profile, so a + * plugin can't read a file an app running as *you* produced (e.g. the Playnite exporter's library + * JSON under your `%APPDATA%`). `punktfunk-host plugins enable` grants `BUILTIN\Users` **write** on + * exactly `ingest` — so your app drops `ingest//…` and the runner reads it there. On Linux + * the runner is a `systemd --user` unit owning the config dir, so a same-user producer writes here + * with no special step. + * + * The dir is NOT created here (a producer running as the interactive user creates its own + * `ingest/` subdir under the host-granted `ingest`). Treat anything read from it as + * lower-trust than your own state: the inbox is writable by any local user. + */ +export const pluginIngestDir = (name?: string): string => { + const root = path.join(configDir(), "ingest"); + return name ? path.join(root, name) : root; +}; + const readIfExists = (p: string): string | undefined => { try { return fs.readFileSync(p, "utf8"); @@ -73,11 +122,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; diff --git a/sdk/src/index.ts b/sdk/src/index.ts index c3ee4058..b492235f 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -30,6 +30,10 @@ import { export type { HostApi } from "./api.js"; export { HttpStatusError } from "./core.js"; export type { ConnectOptions } from "./config.js"; +// A plugin persists its state here — the one dir the de-privileged Windows runner may write. +export { pluginStateDir } from "./config.js"; +// A plugin reads cross-account data (dropped by an interactive-user app) from here. +export { pluginIngestDir } from "./config.js"; export { type PluginUiHandle, type PluginUiOptions, diff --git a/sdk/src/plugins.ts b/sdk/src/plugins.ts index c41b7793..fe066d4f 100644 --- a/sdk/src/plugins.ts +++ b/sdk/src/plugins.ts @@ -13,21 +13,47 @@ export const REGISTRY = "https://git.unom.io/api/packages/unom/npm/"; /** Where plugin packages install: `/plugins` (matches runner.ts discovery). */ export const pluginsDirDefault = (): string => path.join(configDir(), "plugins"); +export interface ResolveOptions { + /** + * Allow names that resolve on the PUBLIC npm registry (unscoped `punktfunk-plugin-*`, foreign + * scopes, arbitrary paths). Off by default: only the `@punktfunk` scope — pinned to the Gitea + * registry by [`ensureBunfig`] — installs without it, so a typo or a squatted look-alike + * package can't silently pull operator-privileged code from npmjs.org (the CLI flag is + * `--allow-public-registry`). + */ + allowPublicRegistry?: boolean; +} + /** * Resolve a friendly plugin name to its npm package. A bare first-party name maps into the * `@punktfunk` scope (`playnite` → `@punktfunk/plugin-playnite`, `rom-manager` → - * `@punktfunk/plugin-rom-manager`); a scoped name (`@…/…`), the unscoped plugin convention - * (`punktfunk-plugin-…`), or any name with a `/` is used verbatim. + * `@punktfunk/plugin-rom-manager`); an `@punktfunk/…` name is used verbatim. Anything else — + * the unscoped `punktfunk-plugin-…` convention, foreign scopes, registry paths — resolves on + * the public registry and is refused unless [`ResolveOptions.allowPublicRegistry`] is set. */ -export const resolvePackage = (name: string): string => { +export const resolvePackage = ( + name: string, + opts: ResolveOptions = {}, +): string => { const n = name.trim(); if (!n) throw new Error("empty plugin name"); - if (n.startsWith("@")) return n; // already scoped, e.g. @punktfunk/plugin-playnite - if (n.startsWith("punktfunk-plugin-")) return n; // unscoped plugin convention, verbatim - if (n.includes("/")) return n; // some other registry path — trust it - return `@punktfunk/plugin-${n}`; // bare first-party name + if (!n.startsWith("@") && !n.includes("/") && !n.startsWith("punktfunk-plugin-")) { + return `@punktfunk/plugin-${n}`; // bare first-party name + } + if (n.startsWith("@punktfunk/")) return n; // first-party scope, pinned to our registry + if (!opts.allowPublicRegistry) { + throw new Error( + `'${n}' would install from the PUBLIC npm registry, not Punktfunk's. Plugins run ` + + "with operator privileges - install only code you trust. If you mean it, re-run " + + "with --allow-public-registry.", + ); + } + return n; }; +/** Does this resolved package name install from Punktfunk's own (Gitea) registry? */ +const isFirstParty = (pkg: string): boolean => pkg.startsWith("@punktfunk/"); + /** Create the plugins dir (and parents) if needed. On Windows the ACL lockdown is the host's job. */ export const ensurePluginsDir = (dir = pluginsDirDefault()): string => { fs.mkdirSync(dir, { recursive: true }); @@ -66,7 +92,7 @@ export const ensureBunfig = (dir = pluginsDirDefault()): void => { } }; -export interface PkgOpts { +export interface PkgOpts extends ResolveOptions { /** Plugins dir. Default `/plugins`. */ dir?: string; /** Line sink for progress. Default stdout. */ @@ -82,7 +108,16 @@ const runBun = (action: "add" | "remove", pkgs: string[], opts: PkgOpts): void = log(`${action === "add" ? "installing" : "removing"} ${pkgs.join(", ")} in ${dir}`); // `process.execPath` is the bun running this file (the vendored one under the package), so a // system-wide bun on PATH is not required. Inherit stdio so `bun`'s progress reaches the user. - const res = Bun.spawnSync([process.execPath, action, ...pkgs], { + const args = [process.execPath, action, ...pkgs]; + // Windows: install file COPIES, never bun's default hardlinks. A hardlinked file's canonical + // path resolves into the installing admin's per-user bun cache + // (C:\Users\\.bun\install\cache\…), which the de-privileged LocalService runner cannot + // traverse — imports die with EPERM even though the plugins-dir DACL grants read (seen live + // on-glass). copyfile keeps the plugins tree self-contained under %ProgramData%. + if (action === "add" && process.platform === "win32") { + args.push("--backend=copyfile"); + } + const res = Bun.spawnSync(args, { cwd: dir, stdio: ["inherit", "inherit", "inherit"], }); @@ -92,12 +127,26 @@ const runBun = (action: "add" | "remove", pkgs: string[], opts: PkgOpts): void = }; /** Install one or more plugins by friendly name or package. */ -export const addPlugins = (names: string[], opts: PkgOpts = {}): void => - runBun("add", names.map(resolvePackage), opts); +export const addPlugins = (names: string[], opts: PkgOpts = {}): void => { + const pkgs = names.map((n) => resolvePackage(n, opts)); + const log = opts.log ?? ((l: string) => console.log(l)); + for (const pkg of pkgs.filter((p) => !isFirstParty(p))) { + log( + `[plugins] WARNING: ${pkg} installs from the public npm registry - it is not ` + + "published by Punktfunk. It will run with operator privileges.", + ); + } + runBun("add", pkgs, opts); +}; -/** Uninstall one or more plugins by friendly name or package. */ +/** Uninstall one or more plugins by friendly name or package. Removal is always safe — a name + * never gates on the registry it once came from. */ export const removePlugins = (names: string[], opts: PkgOpts = {}): void => - runBun("remove", names.map(resolvePackage), opts); + runBun( + "remove", + names.map((n) => resolvePackage(n, { allowPublicRegistry: true })), + opts, + ); export interface InstalledPlugin { /** npm package name, e.g. `@punktfunk/plugin-playnite` or `punktfunk-plugin-foo`. */ diff --git a/sdk/src/runner-cli.ts b/sdk/src/runner-cli.ts index 3a091a7e..d8762087 100644 --- a/sdk/src/runner-cli.ts +++ b/sdk/src/runner-cli.ts @@ -8,7 +8,9 @@ // // With a subcommand it manages plugin packages (the host CLI forwards `punktfunk-host plugins …` // here): -// add install first-party (playnite, rom-manager) or punktfunk-plugin-* packages +// add install first-party plugins (playnite, rom-manager); anything resolving on +// the PUBLIC npm registry (punktfunk-plugin-*, foreign scopes) additionally +// needs --allow-public-registry // remove uninstall // list list installed plugin packages // @@ -44,7 +46,12 @@ const positionals = (): string[] => { return out; }; -const pkgOpts = { dir: options.pluginsDir }; +const pkgOpts = { + dir: options.pluginsDir, + // Opt-in for names that resolve on the public npm registry (supply-chain gate in + // plugins.ts::resolvePackage). Boolean flag, so positionals() skips it on its own. + allowPublicRegistry: process.argv.includes("--allow-public-registry"), +}; const runPkgOp = ( op: (names: string[], o: typeof pkgOpts) => void, diff --git a/sdk/src/runner.ts b/sdk/src/runner.ts index fb58c965..02317038 100644 --- a/sdk/src/runner.ts +++ b/sdk/src/runner.ts @@ -13,13 +13,15 @@ // background work is invisible to supervision; export a plugin to be supervised). // // Trust model (RFC §9.4): a unit is code the operator chose to run — no sandbox is pretended. -// The same sshd rule as hooks applies: a world-writable unit file is refused loudly. +// The same sshd rule as hooks applies on BOTH platforms: a unit file a non-privileged principal +// could have written is refused loudly (mode bits on Unix, owner + DACL on Windows). import { Cause, Duration, Effect, Schedule, } from "effect"; +import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; import { pathToFileURL } from "node:url"; @@ -53,9 +55,217 @@ export interface Unit { const defaultLog = (line: string) => console.log(`${new Date().toISOString()} ${line}`); -/** The sshd rule (RFC §9.1/§9.4): refuse group/world-writable unit files, loudly. */ +// ---- unit-file trust (the sshd rule, both halves) --------------------------------------------- + +// SDDL access-mask bits that let a principal change the file's content or its protection: +// write/append data + EAs, DELETE (delete + recreate), WRITE_DAC / WRITE_OWNER (rewrite the +// protection itself), and the generic write/all bits. FILE_WRITE_ATTRIBUTES (0x100) is +// deliberately NOT here: it only toggles timestamps/readonly/hidden — never content — and the +// runner's own service principal legitimately holds it (bun's module loader opens unit files +// requesting RX+WA; `plugins enable` grants exactly that on the plugins/scripts dirs — counting +// WA as tampering would make the runner refuse every unit it is supposed to run). +const SDDL_WRITE_BITS = + 0x2 | 0x4 | 0x10 | 0x10000 | 0x40000 | 0x80000 | 0x10000000 | 0x40000000; + +// SDDL two-letter rights tokens → access-mask bits (generic, standard, file-specific, and the +// low object-rights aliases hex masks sometimes render as). Anything unrecognized is treated as +// write-capable — fail closed. +const SDDL_RIGHT_TOKENS: Record = { + GA: 0x10000000, + GX: 0x20000000, + GW: 0x40000000, + GR: 0x80000000, + RC: 0x20000, + SD: 0x10000, + WD: 0x40000, + WO: 0x80000, + FA: 0x1f01ff, + FR: 0x120089, + FW: 0x120116, + FX: 0x1200a0, + CC: 0x1, + DC: 0x2, + LC: 0x4, + SW: 0x8, + RP: 0x10, + WP: 0x20, + DT: 0x40, + LO: 0x80, + CR: 0x100, +}; + +// SDDL two-letter account abbreviations we may meet on a unit file, → full SIDs. +const SDDL_SID_ABBREV: Record = { + SY: "S-1-5-18", // NT AUTHORITY\SYSTEM + BA: "S-1-5-32-544", // BUILTIN\Administrators + OW: "S-1-3-4", // OWNER RIGHTS + CO: "S-1-3-0", // CREATOR OWNER + LS: "S-1-5-19", // NT AUTHORITY\LOCAL SERVICE + NS: "S-1-5-20", // NT AUTHORITY\NETWORK SERVICE + BU: "S-1-5-32-545", // BUILTIN\Users + AU: "S-1-5-11", // Authenticated Users + IU: "S-1-5-4", // INTERACTIVE + WD: "S-1-1-0", // Everyone +}; + +const TRUSTED_OWNER_SIDS = new Set([ + "S-1-5-18", // SYSTEM + "S-1-5-32-544", // Administrators + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", // TrustedInstaller +]); + +/** An SDDL rights field as an access mask; -1 when it can't be fully understood (fail closed). */ +const sddlRightsMask = (rights: string): number => { + if (rights.startsWith("0x") || rights.startsWith("0X")) { + const n = Number.parseInt(rights, 16); + return Number.isNaN(n) ? -1 : n; + } + if (rights.length % 2 !== 0) return -1; + let mask = 0; + for (let i = 0; i < rights.length; i += 2) { + const bits = SDDL_RIGHT_TOKENS[rights.slice(i, i + 2)]; + if (bits === undefined) return -1; + mask = (mask | bits) >>> 0; + } + return mask; +}; + +/** + * The Windows half of the sshd rule, as a pure function over the file's SDDL (exported for + * tests). Returns `null` when the descriptor is trustworthy — owner is + * SYSTEM/Administrators/TrustedInstaller (or `extraTrustedSid`, the account running the runner: + * the Unix rule's "your own file is fine") and no other principal holds a write-capable allow + * ACE — else a human-readable refusal reason. Unknown ACE shapes count as write-capable. + */ +export const windowsSddlUnsafeReason = ( + sddl: string, + extraTrustedSid?: string, +): string | null => { + const trusted = new Set(TRUSTED_OWNER_SIDS); + trusted.add("S-1-3-4"); // OWNER RIGHTS — constrained by the owner check below + if (extraTrustedSid) trusted.add(extraTrustedSid); + + const owner = /^O:(S-[0-9-]+|[A-Z]{2})/.exec(sddl)?.[1]; + const ownerSid = + owner === undefined + ? undefined + : owner.startsWith("S-") + ? owner + : SDDL_SID_ABBREV[owner]; + if (ownerSid === undefined || !trusted.has(ownerSid)) { + return `owner ${owner ?? "unknown"} is not SYSTEM/Administrators/TrustedInstaller`; + } + + const daclAt = sddl.indexOf("D:"); + if (daclAt < 0) return "no DACL in the security descriptor"; + // ACE format: (type;flags;rights;objectGuid;inheritGuid;sid[;condition]). SACL ACEs after + // "S:" match the regex too but are audit types, filtered by the allow-type check. + for (const [, ace] of sddl.slice(daclAt + 2).matchAll(/\(([^)]*)\)/g)) { + const [type, flags = "", rights = "", , , sid = ""] = ace.split(";"); + if (type !== "A" && type !== "XA") continue; // deny/audit ACEs only ever tighten + // Inherit-only ACEs are templates for children; they grant nothing on this file. Flags + // come in two-letter tokens — compare exactly, not by substring. + const flagTokens: string[] = flags.match(/.{2}/g) ?? []; + if (flagTokens.includes("IO")) continue; + const mask = sddlRightsMask(rights); + if (mask !== -1 && (mask & SDDL_WRITE_BITS) === 0) continue; // read-only ACE + const resolved = sid.startsWith("S-") ? sid : (SDDL_SID_ABBREV[sid] ?? sid); + if (!trusted.has(resolved)) { + return `${sid} can write it (only SYSTEM/Administrators may)`; + } + } + return null; +}; + +/** The SID this process runs as, fetched once (`undefined` when it can't be determined). */ +let processSidCache: string | undefined | false; +const processSid = (): string | undefined => { + if (processSidCache === undefined) { + const res = spawnSync( + windowsPowershell(), + [ + "-NoProfile", + "-NonInteractive", + "-Command", + "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value", + ], + { + encoding: "utf8", + windowsHide: true, + timeout: 15_000, + env: windowsPowershellEnv(), + }, + ); + const sid = res.status === 0 ? (res.stdout ?? "").trim() : ""; + processSidCache = /^S-[0-9-]+$/.test(sid) ? sid : false; + } + return processSidCache === false ? undefined : processSidCache; +}; + +// Full System32 path, never PATH — a planted powershell.exe must not run with our privileges +// (mirrors the host CLI's powershell_path, security-review 2026-07-17). +const windowsPowershell = (): string => + path.join( + process.env.SystemRoot ?? "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + +// Spawn env for Windows PowerShell 5.1 with PSModulePath STRIPPED (5.1 rebuilds its default). +// An inherited PSModulePath that includes a PowerShell 7 module dir (pwsh adds a machine-scope +// entry) makes 5.1 fail to autoload Microsoft.PowerShell.Security — type-data conflict +// ("AuditToString is already present") — so Get-Acl dies and every unit is refused. Seen live +// on the .173 host box; intermittent per spawn, deterministic once stripped. +const windowsPowershellEnv = (): Record => { + const env: Record = { ...process.env }; + delete env.PSModulePath; + return env; +}; + +/** Read a file's SDDL and apply [`windowsSddlUnsafeReason`]. Unreadable ACL ⇒ refuse. */ +const windowsFileIsSafe = (file: string, log: (l: string) => void): boolean => { + const escaped = file.replace(/'/g, "''"); + const res = spawnSync( + windowsPowershell(), + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `(Get-Acl -LiteralPath '${escaped}').Sddl`, + ], + { + encoding: "utf8", + windowsHide: true, + timeout: 15_000, + env: windowsPowershellEnv(), + }, + ); + const sddl = res.status === 0 ? (res.stdout ?? "").trim() : ""; + if (!sddl) { + log(`[runner] REFUSING ${file} — could not read its ACL`); + return false; + } + const reason = windowsSddlUnsafeReason(sddl, processSid()); + if (reason !== null) { + log( + `[runner] REFUSING ${file} — ${reason}. Reinstall the plugin with ` + + `\`punktfunk-host plugins add\`, or re-own the file to Administrators and strip ` + + `non-admin write ACEs (icacls).`, + ); + return false; + } + return true; +}; + +/** + * The sshd rule (RFC §9.1/§9.4): refuse a unit file anyone less privileged than the operator + * could have written — group/world-writable mode on Unix; on Windows, an owner outside + * SYSTEM/Administrators/TrustedInstaller or a write-capable ACE for a non-admin principal. + */ const fileIsSafe = (file: string, log: (l: string) => void): boolean => { - if (process.platform === "win32") return true; // config dir is DACL'd; ACL check is a follow-up + if (process.platform === "win32") return windowsFileIsSafe(file, log); try { const mode = fs.statSync(file).mode & 0o022; if (mode !== 0) { diff --git a/sdk/test/config.test.ts b/sdk/test/config.test.ts new file mode 100644 index 00000000..75fdd456 --- /dev/null +++ b/sdk/test/config.test.ts @@ -0,0 +1,50 @@ +// Connection/config resolution helpers. `pluginStateDir` is the writable location a supervised +// plugin persists into — the one dir the de-privileged Windows runner may write. +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as path from "node:path"; +import { pluginIngestDir, pluginStateDir } from "../src/config.js"; + +describe("pluginStateDir", () => { + let saved: string | undefined; + beforeEach(() => { + saved = process.env.PUNKTFUNK_CONFIG_DIR; + }); + afterEach(() => { + if (saved === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR; + else process.env.PUNKTFUNK_CONFIG_DIR = saved; + }); + + test("resolves /plugin-state[/name] and honors the config-dir override", () => { + process.env.PUNKTFUNK_CONFIG_DIR = path.join("/tmp", "pf-cfg"); + expect(pluginStateDir()).toBe(path.join("/tmp", "pf-cfg", "plugin-state")); + expect(pluginStateDir("rom-manager")).toBe( + path.join("/tmp", "pf-cfg", "plugin-state", "rom-manager"), + ); + }); + + test("the per-plugin dir is nested under the shared root", () => { + process.env.PUNKTFUNK_CONFIG_DIR = path.join("/tmp", "pf-cfg2"); + expect(pluginStateDir("x").startsWith(pluginStateDir())).toBe(true); + }); +}); + +describe("pluginIngestDir", () => { + let saved: string | undefined; + beforeEach(() => { + saved = process.env.PUNKTFUNK_CONFIG_DIR; + }); + afterEach(() => { + if (saved === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR; + else process.env.PUNKTFUNK_CONFIG_DIR = saved; + }); + + test("resolves /ingest[/name], distinct from plugin-state", () => { + process.env.PUNKTFUNK_CONFIG_DIR = path.join("/tmp", "pf-cfg3"); + expect(pluginIngestDir()).toBe(path.join("/tmp", "pf-cfg3", "ingest")); + expect(pluginIngestDir("playnite")).toBe( + path.join("/tmp", "pf-cfg3", "ingest", "playnite"), + ); + // the inbox (Users-write) is a different tree from state (LocalService-write) + expect(pluginIngestDir("playnite")).not.toBe(pluginStateDir("playnite")); + }); +}); diff --git a/sdk/test/plugins.test.ts b/sdk/test/plugins.test.ts index 0d8f8041..adc2554a 100644 --- a/sdk/test/plugins.test.ts +++ b/sdk/test/plugins.test.ts @@ -37,12 +37,28 @@ describe("resolvePackage", () => { expect(resolvePackage("rom-manager")).toBe("@punktfunk/plugin-rom-manager"); }); - test("passes through scoped, unscoped-convention, and pathed names verbatim", () => { + test("passes @punktfunk-scoped names through verbatim (our registry, no gate)", () => { expect(resolvePackage("@punktfunk/plugin-playnite")).toBe( "@punktfunk/plugin-playnite", ); - expect(resolvePackage("@someone/plugin-x")).toBe("@someone/plugin-x"); - expect(resolvePackage("punktfunk-plugin-custom")).toBe("punktfunk-plugin-custom"); + }); + + test("refuses public-registry names without allowPublicRegistry", () => { + expect(() => resolvePackage("punktfunk-plugin-custom")).toThrow( + /public/i, + ); + expect(() => resolvePackage("@someone/plugin-x")).toThrow(/public/i); + expect(() => resolvePackage("some/registry-path")).toThrow(/public/i); + }); + + test("passes public-registry names through with allowPublicRegistry", () => { + const allow = { allowPublicRegistry: true }; + expect(resolvePackage("punktfunk-plugin-custom", allow)).toBe( + "punktfunk-plugin-custom", + ); + expect(resolvePackage("@someone/plugin-x", allow)).toBe( + "@someone/plugin-x", + ); }); test("trims and rejects empty", () => { diff --git a/sdk/test/runner.test.ts b/sdk/test/runner.test.ts index 77a8d55c..ed529775 100644 --- a/sdk/test/runner.test.ts +++ b/sdk/test/runner.test.ts @@ -5,7 +5,12 @@ import { afterAll, describe, expect, test } from "bun:test"; import { Effect, Fiber } from "effect"; import * as fs from "node:fs"; import * as path from "node:path"; -import { discoverUnits, runner, superviseUnit } from "../src/runner.js"; +import { + discoverUnits, + runner, + superviseUnit, + windowsSddlUnsafeReason, +} from "../src/runner.js"; const TOKEN = "runner-token"; // Fixtures live under sdk/ so the generated plugin files can resolve "effect" and the SDK. @@ -104,6 +109,86 @@ describe("discovery", () => { }); }); +describe("windowsSddlUnsafeReason (the sshd rule's Windows half, pure)", () => { + // What a file under the host's ACL'd %ProgramData%\punktfunk actually looks like: owned by + // Administrators, protected DACL, admin/SYSTEM/OWNER-RIGHTS full + Users read-execute. + const LOCKED = + "O:BAG:SYD:PAI(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;OW)(A;;0x1200a9;;;BU)"; + + test("accepts the host's locked-down layout", () => { + expect(windowsSddlUnsafeReason(LOCKED)).toBeNull(); + }); + + test("accepts inherited allow ACEs spelled with token runs", () => { + expect( + windowsSddlUnsafeReason("O:SYG:SYD:(A;ID;FA;;;SY)(A;ID;FA;;;BA)(A;ID;FR;;;BU)"), + ).toBeNull(); + }); + + test("refuses an untrusted owner", () => { + expect( + windowsSddlUnsafeReason("O:BUG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)"), + ).toContain("owner"); + expect( + windowsSddlUnsafeReason( + "O:S-1-5-21-1111111111-2222222222-3333333333-1001G:SYD:(A;;FA;;;SY)", + ), + ).toContain("owner"); + }); + + test("accepts the running account as owner and writer (the Unix 'your own file' rule)", () => { + const me = "S-1-5-21-1111111111-2222222222-3333333333-1001"; + const sddl = `O:${me}G:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;${me})`; + expect(windowsSddlUnsafeReason(sddl)).toContain("owner"); + expect(windowsSddlUnsafeReason(sddl, me)).toBeNull(); + }); + + test("refuses write-capable ACEs for non-admin principals, by token and by hex", () => { + // BUILTIN\Users with modify (hex, as Windows renders 0x1301bf) + expect( + windowsSddlUnsafeReason(`${LOCKED}(A;;0x1301bf;;;BU)`), + ).toContain("BU"); + // Everyone with generic write + expect(windowsSddlUnsafeReason(`${LOCKED}(A;;GW;;;WD)`)).toContain("WD"); + // Authenticated Users with file-write + expect(windowsSddlUnsafeReason(`${LOCKED}(A;;FW;;;AU)`)).toContain("AU"); + // DELETE alone is enough (delete + recreate) + expect(windowsSddlUnsafeReason(`${LOCKED}(A;;SD;;;BU)`)).toContain("BU"); + // WRITE_DAC alone is enough (rewrite the protection) + expect(windowsSddlUnsafeReason(`${LOCKED}(A;;WD;;;BU)`)).toContain("BU"); + // an explicit user SID + expect( + windowsSddlUnsafeReason( + `${LOCKED}(A;;FA;;;S-1-5-21-1111111111-2222222222-3333333333-1001)`, + ), + ).toContain("S-1-5-21"); + }); + + test("accepts the runner principal's RX+WA grant, refuses real writes for it", () => { + // `plugins enable` grants LocalService (RX,WA) on the unit dirs — bun's module loader + // opens files requesting FILE_WRITE_ATTRIBUTES on top of read+execute (0x1201a9). + // WA can't alter content, so it must not read as tamper-capable… + expect( + windowsSddlUnsafeReason(`${LOCKED}(A;OICIID;0x1201a9;;;LS)`), + ).toBeNull(); + // …but actual write-data for the service principal is still refused. + expect(windowsSddlUnsafeReason(`${LOCKED}(A;;FW;;;LS)`)).toContain("LS"); + }); + + test("inherit-only ACEs and deny ACEs don't trip it; unknown shapes fail closed", () => { + // inherit-only: a template for children, grants nothing on this file + expect( + windowsSddlUnsafeReason(`${LOCKED}(A;OICIIO;FA;;;BU)`), + ).toBeNull(); + // deny ACEs only tighten + expect(windowsSddlUnsafeReason(`${LOCKED}(D;;FA;;;BU)`)).toBeNull(); + // unknown rights token → treated as write-capable + expect(windowsSddlUnsafeReason(`${LOCKED}(A;;ZZ;;;BU)`)).toContain("BU"); + // no DACL at all → refused + expect(windowsSddlUnsafeReason("O:BAG:SY")).toContain("DACL"); + }); +}); + describe("supervision", () => { test("async-fn plugin runs with a facade client; clean return completes", async () => { const server = mockHost();