Merge remote-tracking branch 'origin/main' into land/sweep-all
apple / swift (push) Successful in 1m13s
apple / screenshots (push) Successful in 6m28s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 54s
ci / rust (push) Failing after 6m24s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / bench (push) Successful in 7m12s
docker / deploy-docs (push) Successful in 15s
deb / build-publish (push) Successful in 9m0s
deb / build-publish-host (push) Successful in 9m22s
arch / build-publish (push) Successful in 17m34s
android / android (push) Successful in 18m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m56s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m12s
windows-host / package (push) Failing after 14m42s

This commit is contained in:
2026-07-20 00:46:41 +02:00
23 changed files with 1352 additions and 99 deletions
+42 -6
View File
@@ -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<String> = 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
+11
View File
@@ -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<String>,
/// 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<String>,
}
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<String>,
/// 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<String>,
/// 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<AppState>,
token: Option<String>,
plugin_token: Option<String>,
port: u16,
native: Option<Arc<crate::native_pairing::NativePairing>>,
stats: Arc<crate::stats_recorder::StatsRecorder>,
@@ -142,6 +152,7 @@ fn app(
stats,
gamestream_enabled,
token,
plugin_token,
port,
});
let (api_routes, api) = api_router_parts();
+54 -1
View File
@@ -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
+131
View File
@@ -42,6 +42,8 @@ fn test_app(state: Arc<AppState>, 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<AppState>, np: Arc<crate::native_pairing::NativePa
app(
state,
Some("test-secret".to_string()),
Some("plugin-secret".to_string()),
DEFAULT_PORT,
Some(np),
stats,
@@ -374,6 +377,133 @@ async fn bearer_token_is_enforced() {
);
}
/// The pure route gate for the plugin lane: exclusion-based, so spot-check both sides — the
/// surface a plugin legitimately uses, and every escalation carve-out.
#[test]
fn plugin_allowlist_excludes_escalation_routes() {
use axum::http::Method;
// The legitimate plugin surface stays open (including mutations — sessions, library, leases).
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/status"));
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/library"));
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/clients"));
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/plugins"));
assert!(auth::plugin_may_access(
&Method::PUT,
"/api/v1/plugins/rom-manager"
));
assert!(auth::plugin_may_access(
&Method::DELETE,
"/api/v1/plugins/rom-manager"
));
// Hooks: registration is command execution; even the read can expose webhook credentials.
assert!(!auth::plugin_may_access(&Method::GET, "/api/v1/hooks"));
assert!(!auth::plugin_may_access(&Method::PUT, "/api/v1/hooks"));
// Pairing administration + PIN visibility.
assert!(!auth::plugin_may_access(&Method::GET, "/api/v1/pair"));
assert!(!auth::plugin_may_access(&Method::POST, "/api/v1/pair/pin"));
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/native/pair"
));
assert!(!auth::plugin_may_access(
&Method::POST,
"/api/v1/native/pair/arm"
));
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/native/pending"
));
assert!(!auth::plugin_may_access(
&Method::POST,
"/api/v1/native/pending/1/approve"
));
assert!(!auth::plugin_may_access(
&Method::DELETE,
"/api/v1/clients/aabbcc"
));
assert!(!auth::plugin_may_access(
&Method::DELETE,
"/api/v1/native/clients/aabbcc"
));
// Another plugin's UI proxy secret.
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/plugins/x/ui-credential"
));
}
/// The plugin bearer lane end-to-end: scoped 403s on the carve-outs, 200s on the plugin surface,
/// and the same loopback confinement as the admin token.
#[tokio::test]
async fn plugin_token_lane_is_scoped_and_loopback_only() {
use axum::http::Method;
let app = test_app(test_state(), None); // admin "test-secret", plugin "plugin-secret"
let plugin_req = |method: Method, path: &str| {
axum::http::Request::builder()
.method(method)
.uri(path)
.header("authorization", "Bearer plugin-secret")
.body(Body::empty())
.unwrap()
};
// The plugin surface authenticates: status + the plugin directory (list and lease removal).
assert_eq!(
send(&app, plugin_req(Method::GET, "/api/v1/status"))
.await
.0,
StatusCode::OK
);
assert_eq!(
send(&app, plugin_req(Method::GET, "/api/v1/plugins"))
.await
.0,
StatusCode::OK
);
assert_eq!(
send(
&app,
plugin_req(Method::DELETE, "/api/v1/plugins/no-such-plugin")
)
.await
.0,
StatusCode::NO_CONTENT
);
// The carve-outs answer 403 (authenticated but not authorized), not 401.
for (method, path) in [
(Method::GET, "/api/v1/hooks"),
(Method::PUT, "/api/v1/hooks"),
(Method::GET, "/api/v1/pair"),
(Method::POST, "/api/v1/native/pair/arm"),
(Method::GET, "/api/v1/native/pending"),
(Method::DELETE, "/api/v1/clients/aabbcc"),
(Method::GET, "/api/v1/plugins/x/ui-credential"),
] {
let (status, body) = send(&app, plugin_req(method.clone(), path)).await;
assert_eq!(status, StatusCode::FORBIDDEN, "{method} {path}");
assert!(body["error"].as_str().unwrap().contains("plugin token"));
}
// A wrong token never reaches the lane.
let wrong = axum::http::Request::get("/api/v1/status")
.header("authorization", "Bearer plugin-wrong")
.body(Body::empty())
.unwrap();
assert_eq!(send(&app, wrong).await.0, StatusCode::UNAUTHORIZED);
// Loopback-only, exactly like the admin token: a LAN peer is refused before token compare.
let mut lan = plugin_req(Method::GET, "/api/v1/status");
lan.extensions_mut()
.insert(PeerAddr("192.168.1.50:40000".parse().unwrap()));
assert_eq!(send(&app, lan).await.0, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn host_info_reports_identity_and_ports() {
let app = test_app(test_state(), None);
@@ -544,6 +674,7 @@ async fn blank_token_rejected() {
let opts = Options {
bind: "127.0.0.1:0".parse().unwrap(),
token: Some(" ".into()),
plugin_token: None,
};
let err = run(test_state(), opts, None, test_stats(), false)
.await
+53 -29
View File
@@ -1,12 +1,19 @@
//! Management-API bearer token resolution.
//!
//! The mgmt API always serves HTTPS (the host's identity cert) and now always requires auth — even
//! on a loopback bind. This module guarantees a token always exists: an explicit
//! `PUNKTFUNK_MGMT_TOKEN` env wins (operator override, not persisted); otherwise the persisted
//! `~/.config/punktfunk/mgmt-token` is used; otherwise a fresh 32-byte hex token is generated and
//! persisted. The file is written in `PUNKTFUNK_MGMT_TOKEN=<hex>` 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=<hex>` 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<String> {
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<String> {
load_or_generate_impl(PLUGIN_ENV_VAR, PLUGIN_FILE)
}
fn load_or_generate_impl(env_var: &str, file: &str) -> Result<String> {
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<String> {
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=<token>` line (the form we write, also valid as an EnvironmentFile).
fn parse_token(contents: &str) -> Option<String> {
/// `<KEY>=<token>` line (the form we write, also valid as an EnvironmentFile).
fn parse_token(contents: &str, env_var: &str) -> Option<String> {
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=<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 `<KEY>=<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 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;
+230 -9
View File
@@ -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<String> {
}
}
/// `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: `<config_dir>\plugin-state`. A plugin persists its config +
/// cache under `plugin-state\<name>` (`@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: `<config_dir>\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\<plugin>\…`) 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\<name>` (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\<plugin>\…` 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");
}
+7
View File
@@ -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
+9 -6
View File
@@ -31,8 +31,9 @@ punktfunk-host plugins enable # turn the runner on (once)
<Tab value="Windows">
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
+7 -3
View File
@@ -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
+17 -2
View File
@@ -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
+28 -3
View File
@@ -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
`<exe-dir>\scripting\runner-cli.js` + `scripting-run.cmd` with the bun runtime at `<exe-dir>\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
+191
View File
@@ -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 - <exe-dir>\bun\bun.exe and
<exe-dir>\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 <exe-dir>\scripting\{runner-cli.js,scripting-run.cmd} + <exe-dir>\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\<name> (@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\<plugin>\ 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."
+21 -7
View File
@@ -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."
+4 -2
View File
@@ -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"
+47 -4
View File
@@ -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``<config_dir>/mgmt-token` |
| Token | `{ token }``PUNKTFUNK_MGMT_TOKEN` `PUNKTFUNK_PLUGIN_TOKEN``<config_dir>/plugin-token` `<config_dir>/mgmt-token` |
| TLS pin | `{ ca }``PUNKTFUNK_MGMT_CA` (path) → `<config_dir>/cert.pem` |
`<config_dir>` 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("<your-name>")`, **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"); // <config_dir>/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\<you>\…`. `pluginIngestDir("<your-name>")`
resolves an inbox (`<config_dir>/ingest/<name>`) 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"); // <config_dir>/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
+57 -5
View File
@@ -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 <config_dir>/mgmt-token)
// PUNKTFUNK_MGMT_CA (path; else <config_dir>/cert.pem when present)
// PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990)
// PUNKTFUNK_MGMT_TOKEN (admin override), else PUNKTFUNK_PLUGIN_TOKEN,
// else <config_dir>/plugin-token, else <config_dir>/mgmt-token
// PUNKTFUNK_MGMT_CA (path; else <config_dir>/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:
* `<config_dir>/plugin-state[/<name>]`.
*
* WHY this and not `<config_dir>/<name>` 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:
* `<config_dir>/ingest[/<name>]`.
*
* 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/<plugin>/…` 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/<name>` 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;
+4
View File
@@ -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,
+62 -13
View File
@@ -13,21 +13,47 @@ export const REGISTRY = "https://git.unom.io/api/packages/unom/npm/";
/** Where plugin packages install: `<config_dir>/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 `<config_dir>/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\<admin>\.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`. */
+9 -2
View File
@@ -8,7 +8,9 @@
//
// With a subcommand it manages plugin packages (the host CLI forwards `punktfunk-host plugins …`
// here):
// add <name…> install first-party (playnite, rom-manager) or punktfunk-plugin-* packages
// add <name…> install first-party plugins (playnite, rom-manager); anything resolving on
// the PUBLIC npm registry (punktfunk-plugin-*, foreign scopes) additionally
// needs --allow-public-registry
// remove <name…> 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,
+213 -3
View File
@@ -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<string, number> = {
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<string, string> = {
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<string, string | undefined> => {
const env: Record<string, string | undefined> = { ...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) {
+50
View File
@@ -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 <config_dir>/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 <config_dir>/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"));
});
});
+19 -3
View File
@@ -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", () => {
+86 -1
View File
@@ -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();