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
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:
@@ -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<()> {
|
fn real_main() -> Result<()> {
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
|
||||||
@@ -180,11 +203,16 @@ fn real_main() -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(
|
// Lightweight CLI commands (e.g. `plugins add`) get none of the host-startup noise below.
|
||||||
"punktfunk-host {} (punktfunk_core ABI v{})",
|
let management_cli = is_management_cli(&args);
|
||||||
env!("PUNKTFUNK_VERSION"),
|
|
||||||
punktfunk_core::ABI_VERSION
|
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
|
// 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.
|
// 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
|
// 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). 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.
|
// 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")]
|
#[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,
|
// 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
|
// 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() {
|
if opts.token.is_none() {
|
||||||
opts.token = Some(crate::mgmt_token::load_or_generate()?);
|
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
|
// 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
|
// 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
|
// default". This only LAN-exposes the read-only cert allowlist; the bearer-token admin surface
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ pub struct Options {
|
|||||||
/// Bearer token required on `/api/v1` (except `/health`). `None` ⇒ unauthenticated,
|
/// Bearer token required on `/api/v1` (except `/health`). `None` ⇒ unauthenticated,
|
||||||
/// which [`run`] only permits on loopback binds.
|
/// which [`run`] only permits on loopback binds.
|
||||||
pub token: Option<String>,
|
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 {
|
impl Default for Options {
|
||||||
@@ -63,6 +67,7 @@ impl Default for Options {
|
|||||||
Options {
|
Options {
|
||||||
bind: SocketAddr::from(([127, 0, 0, 1], DEFAULT_PORT)),
|
bind: SocketAddr::from(([127, 0, 0, 1], DEFAULT_PORT)),
|
||||||
token: None,
|
token: None,
|
||||||
|
plugin_token: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +86,9 @@ pub(crate) struct MgmtState {
|
|||||||
/// (native-only) host, where a Moonlight PIN can never arrive.
|
/// (native-only) host, where a Moonlight PIN can never arrive.
|
||||||
gamestream_enabled: bool,
|
gamestream_enabled: bool,
|
||||||
token: Option<String>,
|
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.
|
/// The port we serve on, echoed in [`PortMap`] so a client can persist a full endpoint map.
|
||||||
port: u16,
|
port: u16,
|
||||||
}
|
}
|
||||||
@@ -119,6 +127,7 @@ pub async fn run(
|
|||||||
let app = app(
|
let app = app(
|
||||||
state,
|
state,
|
||||||
Some(token),
|
Some(token),
|
||||||
|
opts.plugin_token.filter(|t| !t.trim().is_empty()),
|
||||||
opts.bind.port(),
|
opts.bind.port(),
|
||||||
native,
|
native,
|
||||||
stats,
|
stats,
|
||||||
@@ -131,6 +140,7 @@ pub async fn run(
|
|||||||
fn app(
|
fn app(
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
token: Option<String>,
|
token: Option<String>,
|
||||||
|
plugin_token: Option<String>,
|
||||||
port: u16,
|
port: u16,
|
||||||
native: Option<Arc<crate::native_pairing::NativePairing>>,
|
native: Option<Arc<crate::native_pairing::NativePairing>>,
|
||||||
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
||||||
@@ -142,6 +152,7 @@ fn app(
|
|||||||
stats,
|
stats,
|
||||||
gamestream_enabled,
|
gamestream_enabled,
|
||||||
token,
|
token,
|
||||||
|
plugin_token,
|
||||||
port,
|
port,
|
||||||
});
|
});
|
||||||
let (api_routes, api) = api_router_parts();
|
let (api_routes, api) = api_router_parts();
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
//! Auth gate for the management API `/api/v1` routes: paired client cert (mTLS, from anywhere)
|
//! 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 super::shared::*;
|
||||||
use crate::gamestream::tls::PeerAddr;
|
use crate::gamestream::tls::PeerAddr;
|
||||||
@@ -86,6 +93,27 @@ pub(crate) async fn require_auth(
|
|||||||
.and_then(|v| v.strip_prefix("Bearer "));
|
.and_then(|v| v.strip_prefix("Bearer "));
|
||||||
match presented {
|
match presented {
|
||||||
Some(token) if token_eq(token, expected) => next.run(req).await,
|
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(
|
_ => api_error(
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
"missing or invalid credentials (a paired client cert, or a bearer token)",
|
"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
|
/// 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
|
/// 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
|
/// that exposes a pairing PIN or the pending-approval queue requires the operator's bearer token, so
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ fn test_app(state: Arc<AppState>, token: Option<&str>) -> Router {
|
|||||||
app(
|
app(
|
||||||
state,
|
state,
|
||||||
Some(token.unwrap_or("test-secret").to_string()),
|
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,
|
DEFAULT_PORT,
|
||||||
None,
|
None,
|
||||||
stats,
|
stats,
|
||||||
@@ -57,6 +59,7 @@ fn test_app_native(state: Arc<AppState>, np: Arc<crate::native_pairing::NativePa
|
|||||||
app(
|
app(
|
||||||
state,
|
state,
|
||||||
Some("test-secret".to_string()),
|
Some("test-secret".to_string()),
|
||||||
|
Some("plugin-secret".to_string()),
|
||||||
DEFAULT_PORT,
|
DEFAULT_PORT,
|
||||||
Some(np),
|
Some(np),
|
||||||
stats,
|
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]
|
#[tokio::test]
|
||||||
async fn host_info_reports_identity_and_ports() {
|
async fn host_info_reports_identity_and_ports() {
|
||||||
let app = test_app(test_state(), None);
|
let app = test_app(test_state(), None);
|
||||||
@@ -544,6 +674,7 @@ async fn blank_token_rejected() {
|
|||||||
let opts = Options {
|
let opts = Options {
|
||||||
bind: "127.0.0.1:0".parse().unwrap(),
|
bind: "127.0.0.1:0".parse().unwrap(),
|
||||||
token: Some(" ".into()),
|
token: Some(" ".into()),
|
||||||
|
plugin_token: None,
|
||||||
};
|
};
|
||||||
let err = run(test_state(), opts, None, test_stats(), false)
|
let err = run(test_state(), opts, None, test_stats(), false)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
//! Management-API bearer token resolution.
|
//! Management-API bearer token resolution.
|
||||||
//!
|
//!
|
||||||
//! The mgmt API always serves HTTPS (the host's identity cert) and now always requires auth — even
|
//! 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
|
//! on a loopback bind. This module guarantees the tokens always exist: an explicit env var wins
|
||||||
//! `PUNKTFUNK_MGMT_TOKEN` env wins (operator override, not persisted); otherwise the persisted
|
//! (operator override, not persisted); otherwise the persisted file under the config dir is used;
|
||||||
//! `~/.config/punktfunk/mgmt-token` is used; otherwise a fresh 32-byte hex token is generated and
|
//! otherwise a fresh 32-byte hex token is generated and persisted. Files are written in
|
||||||
//! persisted. The file is written in `PUNKTFUNK_MGMT_TOKEN=<hex>` form (0600) so the bundled web
|
//! `KEY=<hex>` form (0600) so the bundled web console can source them directly as a systemd
|
||||||
//! console can source it directly as a systemd `EnvironmentFile` — a single source of truth shared
|
//! `EnvironmentFile` — a single source of truth shared between the host and its consumers.
|
||||||
//! between the host and the console with no copying.
|
//!
|
||||||
|
//! 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 anyhow::{Context, Result};
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
@@ -15,19 +22,32 @@ use std::path::Path;
|
|||||||
|
|
||||||
const ENV_VAR: &str = "PUNKTFUNK_MGMT_TOKEN";
|
const ENV_VAR: &str = "PUNKTFUNK_MGMT_TOKEN";
|
||||||
const FILE: &str = "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
|
/// Resolve the mgmt (full-admin) token (env > persisted file > generate+persist). Hex (not base64)
|
||||||
/// persisted `KEY=VALUE` line is safe to source from a shell / systemd `EnvironmentFile`.
|
/// so the persisted `KEY=VALUE` line is safe to source from a shell / systemd `EnvironmentFile`.
|
||||||
pub fn load_or_generate() -> Result<String> {
|
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();
|
let v = v.trim();
|
||||||
if !v.is_empty() {
|
if !v.is_empty() {
|
||||||
return Ok(v.to_string());
|
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 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);
|
return Ok(tok);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,29 +57,29 @@ pub fn load_or_generate() -> Result<String> {
|
|||||||
let dir = pf_paths::config_dir();
|
let dir = pf_paths::config_dir();
|
||||||
// Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config path.
|
// 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()))?;
|
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
|
||||||
write_token(&path, &token)?;
|
write_token(&path, env_var, &token)?;
|
||||||
tracing::info!(path = %path.display(), "generated and persisted management API token (owner-only)");
|
tracing::info!(path = %path.display(), "generated and persisted API token (owner-only)");
|
||||||
Ok(token)
|
Ok(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the token from the persisted file: accept either a bare token line or a
|
/// 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).
|
/// `<KEY>=<token>` line (the form we write, also valid as an EnvironmentFile).
|
||||||
fn parse_token(contents: &str) -> Option<String> {
|
fn parse_token(contents: &str, env_var: &str) -> Option<String> {
|
||||||
let line = contents.lines().find(|l| !l.trim().is_empty())?.trim();
|
let line = contents.lines().find(|l| !l.trim().is_empty())?.trim();
|
||||||
let tok = line
|
let tok = line
|
||||||
.strip_prefix("PUNKTFUNK_MGMT_TOKEN=")
|
.strip_prefix(env_var)
|
||||||
|
.and_then(|rest| rest.strip_prefix('='))
|
||||||
.unwrap_or(line)
|
.unwrap_or(line)
|
||||||
.trim();
|
.trim();
|
||||||
(!tok.is_empty()).then(|| tok.to_string())
|
(!tok.is_empty()).then(|| tok.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write `PUNKTFUNK_MGMT_TOKEN=<token>` to `path` as an owner-only secret — 0600 on Unix AND
|
/// Write `<KEY>=<token>` to `path` as an owner-only secret — 0600 on Unix AND DACL-locked to
|
||||||
/// DACL-locked to SYSTEM/Administrators on Windows. Routes through the shared `write_secret_file` so
|
/// SYSTEM/Administrators on Windows. Routes through the shared `write_secret_file` so both bearer
|
||||||
/// the mgmt bearer token (full admin authority) gets the SAME Windows lockdown as the host key; the
|
/// tokens get the SAME Windows lockdown as the host key; the bespoke `cfg(unix)`-only writer used
|
||||||
/// bespoke `cfg(unix)`-only writer used to leave it readable by any local user (security-review
|
/// to leave the mgmt token readable by any local user (security-review 2026-06-28 #2).
|
||||||
/// 2026-06-28 #2).
|
fn write_token(path: &Path, env_var: &str, token: &str) -> Result<()> {
|
||||||
fn write_token(path: &Path, token: &str) -> Result<()> {
|
let line = format!("{env_var}={token}\n");
|
||||||
let line = format!("PUNKTFUNK_MGMT_TOKEN={token}\n");
|
|
||||||
pf_paths::write_secret_file(path, line.as_bytes())
|
pf_paths::write_secret_file(path, line.as_bytes())
|
||||||
.with_context(|| format!("write {}", path.display()))
|
.with_context(|| format!("write {}", path.display()))
|
||||||
}
|
}
|
||||||
@@ -70,13 +90,17 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_bare_and_keyvalue_forms() {
|
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!(
|
assert_eq!(
|
||||||
parse_token("PUNKTFUNK_MGMT_TOKEN=deadbeef\n").as_deref(),
|
parse_token("PUNKTFUNK_MGMT_TOKEN=deadbeef\n", ENV_VAR).as_deref(),
|
||||||
Some("deadbeef")
|
Some("deadbeef")
|
||||||
);
|
);
|
||||||
assert_eq!(parse_token("\n \n"), None);
|
assert_eq!(
|
||||||
assert_eq!(parse_token("PUNKTFUNK_MGMT_TOKEN=\n"), None);
|
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]
|
#[test]
|
||||||
@@ -84,9 +108,9 @@ mod tests {
|
|||||||
let dir = std::env::temp_dir().join(format!("pf-mgmt-token-test-{}", std::process::id()));
|
let dir = std::env::temp_dir().join(format!("pf-mgmt-token-test-{}", std::process::id()));
|
||||||
let _ = fs::create_dir_all(&dir);
|
let _ = fs::create_dir_all(&dir);
|
||||||
let path = dir.join(FILE);
|
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();
|
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)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|||||||
@@ -14,9 +14,16 @@
|
|||||||
//! package present.
|
//! package present.
|
||||||
//!
|
//!
|
||||||
//! Windows needs elevation for both halves: the plugins dir lives under the ACL'd
|
//! 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
|
//! check up front and print one actionable line instead of letting `bun add` fail with a bare
|
||||||
//! EACCES.
|
//! 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 anyhow::{bail, Context, Result};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
@@ -71,13 +78,15 @@ USAGE:
|
|||||||
|
|
||||||
NAMES:
|
NAMES:
|
||||||
A bare first-party name resolves into the @punktfunk scope: `playnite` installs
|
A bare first-party name resolves into the @punktfunk scope: `playnite` installs
|
||||||
@punktfunk/plugin-playnite, `rom-manager` installs @punktfunk/plugin-rom-manager.
|
@punktfunk/plugin-playnite, `rom-manager` installs @punktfunk/plugin-rom-manager —
|
||||||
A scoped (@scope/pkg) or `punktfunk-plugin-*` name is used verbatim.
|
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:
|
NOTES:
|
||||||
Plugins run under the runner, which is OPT-IN — `plugins add` installs, `plugins enable`
|
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;
|
turns the runner on. Plugins are operator-installed code that runs with operator
|
||||||
install only plugins you trust.
|
privileges; install only plugins you trust.
|
||||||
"
|
"
|
||||||
);
|
);
|
||||||
#[cfg(target_os = "windows")]
|
#[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")]
|
#[cfg(target_os = "windows")]
|
||||||
fn enable() -> Result<()> {
|
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!(
|
powershell(&format!(
|
||||||
"Enable-ScheduledTask -TaskName {TASK} -ErrorAction Stop | Out-Null; \
|
"Enable-ScheduledTask -TaskName {TASK} -ErrorAction Stop | Out-Null; \
|
||||||
Start-ScheduledTask -TaskName {TASK} -ErrorAction Stop"
|
Start-ScheduledTask -TaskName {TASK} -ErrorAction Stop"
|
||||||
))?;
|
))?;
|
||||||
println!("Plugin runner enabled and started ({TASK}).");
|
println!("Plugin runner enabled and started ({TASK}, runs as LocalService).");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,17 +290,173 @@ fn disable() -> Result<()> {
|
|||||||
"Stop-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
|
"Stop-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
|
||||||
Disable-ScheduledTask -TaskName {TASK} -ErrorAction Stop | Out-Null"
|
Disable-ScheduledTask -TaskName {TASK} -ErrorAction Stop | Out-Null"
|
||||||
))?;
|
))?;
|
||||||
|
revoke_runner_secret_reads();
|
||||||
println!("Plugin runner stopped and disabled ({TASK}).");
|
println!("Plugin runner stopped and disabled ({TASK}).");
|
||||||
Ok(())
|
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")]
|
#[cfg(target_os = "windows")]
|
||||||
fn status() -> Result<()> {
|
fn status() -> Result<()> {
|
||||||
let out = powershell_output(&format!(
|
let out = powershell_output(&format!(
|
||||||
"$t = Get-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
|
"$t = Get-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
|
||||||
if ($null -eq $t) {{ 'missing' }} else {{ \
|
if ($null -eq $t) {{ 'missing' }} else {{ \
|
||||||
$i = Get-ScheduledTaskInfo -TaskName {TASK} -ErrorAction SilentlyContinue; \
|
\"$($t.State)|$($t.Principal.UserId)\" }}"
|
||||||
\"$($t.State)\" }}"
|
|
||||||
));
|
));
|
||||||
match out.as_deref().map(str::trim) {
|
match out.as_deref().map(str::trim) {
|
||||||
Some("missing") | None => {
|
Some("missing") | None => {
|
||||||
@@ -248,7 +466,10 @@ fn status() -> Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
Some(state) => {
|
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") {
|
if state.eq_ignore_ascii_case("Disabled") {
|
||||||
println!("\nEnable it with: punktfunk-host plugins enable");
|
println!("\nEnable it with: punktfunk-host plugins enable");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
`POST /api/v1/native/pending/{id}/approve` when you tap yes. The full API is documented at
|
||||||
[`/api/docs`](/api) on your host.
|
[`/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)
|
## Recipe: full controller passthrough (VirtualHere)
|
||||||
|
|
||||||
To get a controller's *native* features on the host — DualSense gyro, touchpad, adaptive
|
To get a controller's *native* features on the host — DualSense gyro, touchpad, adaptive
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ punktfunk-host plugins enable # turn the runner on (once)
|
|||||||
<Tab value="Windows">
|
<Tab value="Windows">
|
||||||
|
|
||||||
Run these from an **elevated** PowerShell — right-click **PowerShell** → **Run as administrator**.
|
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
|
The plugins directory lives under `%ProgramData%\punktfunk`, which is admin-owned. The runner task
|
||||||
task runs as SYSTEM.
|
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
|
```powershell
|
||||||
punktfunk-host plugins add playnite # or: rom-manager
|
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 disable` | Stop + disable the runner. |
|
||||||
| `punktfunk-host plugins status` | Is the runner enabled and running? |
|
| `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 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
|
always from Punktfunk's own package registry. Any other name (`punktfunk-plugin-*`, a foreign
|
||||||
the same way.
|
`@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.
|
> commands. Install only plugins you trust, from a registry you control.
|
||||||
|
|
||||||
## ROM Manager
|
## ROM Manager
|
||||||
|
|||||||
@@ -291,13 +291,17 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParam
|
|||||||
StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated
|
StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated
|
||||||
#endif
|
#endif
|
||||||
#ifdef WithScripting
|
#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:
|
; 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
|
; Best-effort (-ErrorAction SilentlyContinue): a task hiccup never fails the whole install. No braces
|
||||||
; in the command, so no Inno {{ }} escaping needed.
|
; in the command, so no Inno {{ }} escaping needed.
|
||||||
Filename: "powershell.exe"; \
|
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
|
StatusMsg: "Registering the punktfunk script runner (disabled; opt-in)..."; Flags: runhidden waituntilterminated
|
||||||
#endif
|
#endif
|
||||||
; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the
|
; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the
|
||||||
|
|||||||
@@ -10,8 +10,10 @@
|
|||||||
# you add scripts or install plugins. Turn it on once you have automation to run:
|
# you add scripts or install plugins. Turn it on once you have automation to run:
|
||||||
# systemctl --user enable --now punktfunk-scripting
|
# systemctl --user enable --now punktfunk-scripting
|
||||||
#
|
#
|
||||||
# Auto-wired like the console: a plugin's connect() reads the host's mgmt token + identity cert from
|
# Auto-wired like the console: a plugin's connect() reads the host's SCOPED plugin token + identity
|
||||||
# ~/.config/punktfunk/{mgmt-token,cert.pem} (written by the host's `serve`) — no env editing.
|
# 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]
|
[Unit]
|
||||||
Description=punktfunk plugin/script runner
|
Description=punktfunk plugin/script runner
|
||||||
Documentation=https://git.unom.io/unom/punktfunk
|
Documentation=https://git.unom.io/unom/punktfunk
|
||||||
@@ -29,6 +31,19 @@ RestartSec=2
|
|||||||
KillMode=mixed
|
KillMode=mixed
|
||||||
KillSignal=SIGTERM
|
KillSignal=SIGTERM
|
||||||
TimeoutStopSec=30
|
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]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
## Rebuild + redeploy everything
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-all.ps1
|
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
|
Thin wrapper: runs `deploy-host.ps1`, `build-web.ps1` then `build-scripting.ps1` in sequence — the
|
||||||
fails, `deploy-host.ps1` rolls itself back and throws, which stops this script before the web
|
web console and plugin runner are **always** included, so the host binary and the runner bundle
|
||||||
console step runs.
|
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
|
## Typical flow after pulling new code
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -1,26 +1,40 @@
|
|||||||
<#
|
<#
|
||||||
Rebuild + redeploy everything: the Windows host service AND the web management console.
|
Rebuild + redeploy everything: the Windows host service, the web management console AND the
|
||||||
Thin wrapper around deploy-host.ps1 + build-web.ps1 - see scripts\windows\README.md for what
|
plugin/script runner. Thin wrapper around deploy-host.ps1 + build-web.ps1 + build-scripting.ps1 -
|
||||||
each one does on its own (rollback behavior, build env, etc).
|
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
|
||||||
|
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
|
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'
|
$ErrorActionPreference = 'Stop'
|
||||||
$here = $PSScriptRoot
|
$here = $PSScriptRoot
|
||||||
|
|
||||||
Write-Host "=========================================="
|
Write-Host "=========================================="
|
||||||
Write-Host " 1/2 host service"
|
Write-Host " 1/3 host service"
|
||||||
Write-Host "=========================================="
|
Write-Host "=========================================="
|
||||||
& (Join-Path $here 'deploy-host.ps1')
|
& (Join-Path $here 'deploy-host.ps1')
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "=========================================="
|
Write-Host "=========================================="
|
||||||
Write-Host " 2/2 web console"
|
Write-Host " 2/3 web console"
|
||||||
Write-Host "=========================================="
|
Write-Host "=========================================="
|
||||||
& (Join-Path $here 'build-web.ps1')
|
& (Join-Path $here 'build-web.ps1')
|
||||||
|
|
||||||
Write-Host ""
|
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."
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ rem Enable it once you have scripts/plugins: Enable-ScheduledTask -TaskName Pun
|
|||||||
rem
|
rem
|
||||||
rem Lays out next to the installed payload: {app}\scripting\scripting-run.cmd + runner-cli.js and
|
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 {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 %ProgramData%\punktfunk\{scripts,plugins}; a plugin's connect() auto-wires to the host's SCOPED
|
||||||
rem token + identity cert in %ProgramData%\punktfunk\ (written by the host's `serve`). No env editing.
|
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
|
setlocal EnableExtensions
|
||||||
|
|
||||||
set "BUN=%~dp0..\bun\bun.exe"
|
set "BUN=%~dp0..\bun\bun.exe"
|
||||||
|
|||||||
+47
-4
@@ -106,7 +106,7 @@ Plus a real-world recipe:
|
|||||||
| What | Source |
|
| What | Source |
|
||||||
|---|---|
|
|---|---|
|
||||||
| URL | `{ url }` → `PUNKTFUNK_MGMT_URL` → `https://127.0.0.1:47990` |
|
| 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` |
|
| TLS pin | `{ ca }` → `PUNKTFUNK_MGMT_CA` (path) → `<config_dir>/cert.pem` |
|
||||||
|
|
||||||
`<config_dir>` is `~/.config/punktfunk` (Linux/macOS) or `%ProgramData%\punktfunk` (Windows) —
|
`<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;
|
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`).
|
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
|
The zero-config default is the host's **scoped plugin token** (`plugin-token`): the everyday
|
||||||
scripts on the host box (or through an SSH tunnel).
|
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
|
## 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.
|
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 UI in the console — `servePluginUi`
|
||||||
|
|
||||||
A plugin can surface a web UI **inside the punktfunk console** — no second password or port for the
|
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
|
Windows Task Scheduler: a task triggered *At log on* running
|
||||||
`bun C:\Users\me\punktfunk-scripts\myscript.ts` (the SDK reads
|
`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
|
## Compatibility
|
||||||
|
|
||||||
|
|||||||
+57
-5
@@ -2,9 +2,17 @@
|
|||||||
// identity cert, from the environment with file fallbacks — so `connect()` on the host machine
|
// identity cert, from the environment with file fallbacks — so `connect()` on the host machine
|
||||||
// needs zero configuration.
|
// needs zero configuration.
|
||||||
//
|
//
|
||||||
// PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990)
|
// PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990)
|
||||||
// PUNKTFUNK_MGMT_TOKEN (else <config_dir>/mgmt-token)
|
// PUNKTFUNK_MGMT_TOKEN (admin override), else PUNKTFUNK_PLUGIN_TOKEN,
|
||||||
// PUNKTFUNK_MGMT_CA (path; else <config_dir>/cert.pem when present)
|
// 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)
|
// 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,
|
// 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");
|
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 => {
|
const readIfExists = (p: string): string | undefined => {
|
||||||
try {
|
try {
|
||||||
return fs.readFileSync(p, "utf8");
|
return fs.readFileSync(p, "utf8");
|
||||||
@@ -73,11 +122,14 @@ export const resolveConfig = async (
|
|||||||
const token =
|
const token =
|
||||||
options?.token ??
|
options?.token ??
|
||||||
process.env.PUNKTFUNK_MGMT_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")) ?? "");
|
parseTokenFile(readIfExists(path.join(configDir(), "mgmt-token")) ?? "");
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"no management token: set PUNKTFUNK_MGMT_TOKEN, pass { token }, or run where " +
|
"no management token: set PUNKTFUNK_PLUGIN_TOKEN (or PUNKTFUNK_MGMT_TOKEN), pass " +
|
||||||
`the host's token file exists (${path.join(configDir(), "mgmt-token")})`,
|
"{ token }, or run where the host's token files exist " +
|
||||||
|
`(${path.join(configDir(), "plugin-token")})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const caPath = process.env.PUNKTFUNK_MGMT_CA;
|
const caPath = process.env.PUNKTFUNK_MGMT_CA;
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ import {
|
|||||||
export type { HostApi } from "./api.js";
|
export type { HostApi } from "./api.js";
|
||||||
export { HttpStatusError } from "./core.js";
|
export { HttpStatusError } from "./core.js";
|
||||||
export type { ConnectOptions } from "./config.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 {
|
export {
|
||||||
type PluginUiHandle,
|
type PluginUiHandle,
|
||||||
type PluginUiOptions,
|
type PluginUiOptions,
|
||||||
|
|||||||
+62
-13
@@ -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). */
|
/** Where plugin packages install: `<config_dir>/plugins` (matches runner.ts discovery). */
|
||||||
export const pluginsDirDefault = (): string => path.join(configDir(), "plugins");
|
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
|
* 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` scope (`playnite` → `@punktfunk/plugin-playnite`, `rom-manager` →
|
||||||
* `@punktfunk/plugin-rom-manager`); a scoped name (`@…/…`), the unscoped plugin convention
|
* `@punktfunk/plugin-rom-manager`); an `@punktfunk/…` name is used verbatim. Anything else —
|
||||||
* (`punktfunk-plugin-…`), or any name with a `/` is used verbatim.
|
* 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();
|
const n = name.trim();
|
||||||
if (!n) throw new Error("empty plugin name");
|
if (!n) throw new Error("empty plugin name");
|
||||||
if (n.startsWith("@")) return n; // already scoped, e.g. @punktfunk/plugin-playnite
|
if (!n.startsWith("@") && !n.includes("/") && !n.startsWith("punktfunk-plugin-")) {
|
||||||
if (n.startsWith("punktfunk-plugin-")) return n; // unscoped plugin convention, verbatim
|
return `@punktfunk/plugin-${n}`; // bare first-party name
|
||||||
if (n.includes("/")) return n; // some other registry path — trust it
|
}
|
||||||
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. */
|
/** Create the plugins dir (and parents) if needed. On Windows the ACL lockdown is the host's job. */
|
||||||
export const ensurePluginsDir = (dir = pluginsDirDefault()): string => {
|
export const ensurePluginsDir = (dir = pluginsDirDefault()): string => {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
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`. */
|
/** Plugins dir. Default `<config_dir>/plugins`. */
|
||||||
dir?: string;
|
dir?: string;
|
||||||
/** Line sink for progress. Default stdout. */
|
/** 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}`);
|
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
|
// `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.
|
// 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,
|
cwd: dir,
|
||||||
stdio: ["inherit", "inherit", "inherit"],
|
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. */
|
/** Install one or more plugins by friendly name or package. */
|
||||||
export const addPlugins = (names: string[], opts: PkgOpts = {}): void =>
|
export const addPlugins = (names: string[], opts: PkgOpts = {}): void => {
|
||||||
runBun("add", names.map(resolvePackage), opts);
|
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 =>
|
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 {
|
export interface InstalledPlugin {
|
||||||
/** npm package name, e.g. `@punktfunk/plugin-playnite` or `punktfunk-plugin-foo`. */
|
/** npm package name, e.g. `@punktfunk/plugin-playnite` or `punktfunk-plugin-foo`. */
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
//
|
//
|
||||||
// With a subcommand it manages plugin packages (the host CLI forwards `punktfunk-host plugins …`
|
// With a subcommand it manages plugin packages (the host CLI forwards `punktfunk-host plugins …`
|
||||||
// here):
|
// 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
|
// remove <name…> uninstall
|
||||||
// list list installed plugin packages
|
// list list installed plugin packages
|
||||||
//
|
//
|
||||||
@@ -44,7 +46,12 @@ const positionals = (): string[] => {
|
|||||||
return out;
|
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 = (
|
const runPkgOp = (
|
||||||
op: (names: string[], o: typeof pkgOpts) => void,
|
op: (names: string[], o: typeof pkgOpts) => void,
|
||||||
|
|||||||
+213
-3
@@ -13,13 +13,15 @@
|
|||||||
// background work is invisible to supervision; export a plugin to be supervised).
|
// 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.
|
// 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 {
|
import {
|
||||||
Cause,
|
Cause,
|
||||||
Duration,
|
Duration,
|
||||||
Effect,
|
Effect,
|
||||||
Schedule,
|
Schedule,
|
||||||
} from "effect";
|
} from "effect";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
@@ -53,9 +55,217 @@ export interface Unit {
|
|||||||
const defaultLog = (line: string) =>
|
const defaultLog = (line: string) =>
|
||||||
console.log(`${new Date().toISOString()} ${line}`);
|
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 => {
|
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 {
|
try {
|
||||||
const mode = fs.statSync(file).mode & 0o022;
|
const mode = fs.statSync(file).mode & 0o022;
|
||||||
if (mode !== 0) {
|
if (mode !== 0) {
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,12 +37,28 @@ describe("resolvePackage", () => {
|
|||||||
expect(resolvePackage("rom-manager")).toBe("@punktfunk/plugin-rom-manager");
|
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(
|
expect(resolvePackage("@punktfunk/plugin-playnite")).toBe(
|
||||||
"@punktfunk/plugin-playnite",
|
"@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", () => {
|
test("trims and rejects empty", () => {
|
||||||
|
|||||||
+86
-1
@@ -5,7 +5,12 @@ import { afterAll, describe, expect, test } from "bun:test";
|
|||||||
import { Effect, Fiber } from "effect";
|
import { Effect, Fiber } from "effect";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
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";
|
const TOKEN = "runner-token";
|
||||||
// Fixtures live under sdk/ so the generated plugin files can resolve "effect" and the SDK.
|
// 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", () => {
|
describe("supervision", () => {
|
||||||
test("async-fn plugin runs with a facade client; clean return completes", async () => {
|
test("async-fn plugin runs with a facade client; clean return completes", async () => {
|
||||||
const server = mockHost();
|
const server = mockHost();
|
||||||
|
|||||||
Reference in New Issue
Block a user