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<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
||||
@@ -180,11 +203,16 @@ fn real_main() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"punktfunk-host {} (punktfunk_core ABI v{})",
|
||||
env!("PUNKTFUNK_VERSION"),
|
||||
punktfunk_core::ABI_VERSION
|
||||
);
|
||||
// Lightweight CLI commands (e.g. `plugins add`) get none of the host-startup noise below.
|
||||
let management_cli = is_management_cli(&args);
|
||||
|
||||
if !management_cli {
|
||||
tracing::info!(
|
||||
"punktfunk-host {} (punktfunk_core ABI v{})",
|
||||
env!("PUNKTFUNK_VERSION"),
|
||||
punktfunk_core::ABI_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
// Wire pf-vdisplay's display-lifecycle events into the SSE event bus (the subsystem crate emits a
|
||||
// neutral DisplayEvent; the orchestrator owns the bus type — plan §W6). Set once, ignore re-set.
|
||||
@@ -208,8 +236,12 @@ fn real_main() -> Result<()> {
|
||||
// render-adapter selection creates a DXGI factory during virtual-display setup, well before
|
||||
// capture). On a hybrid-GPU box this stops DXGI from reparenting the virtual output off the
|
||||
// capture GPU — the ACCESS_LOST churn fix. Idempotent (Once); harmless on non-hybrid boxes.
|
||||
// Skipped for lightweight CLI commands (`plugins`, `openapi`, …): they never touch DXGI, and the
|
||||
// hook's DPI-awareness probe prints a misleading "access denied" WARN that looks like a failure.
|
||||
#[cfg(target_os = "windows")]
|
||||
crate::capture::dxgi::install_gpu_pref_hook();
|
||||
if !management_cli {
|
||||
crate::capture::dxgi::install_gpu_pref_hook();
|
||||
}
|
||||
|
||||
// NVIDIA clock hygiene (Linux, host subcommands only): install the P2-cap driver profile and,
|
||||
// under PUNKTFUNK_PIN_CLOCKS, hold the NVML core-clock floor for the host lifetime (reset on
|
||||
@@ -498,6 +530,10 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, b
|
||||
if opts.token.is_none() {
|
||||
opts.token = Some(crate::mgmt_token::load_or_generate()?);
|
||||
}
|
||||
// The scripting runner's scoped credential: minted + persisted (plugin-token) alongside the
|
||||
// admin token so a plugin's zero-config `connect()` picks it up — it authorizes the plugin
|
||||
// surface but not hook registration or pairing administration (mgmt::auth::plugin_may_access).
|
||||
opts.plugin_token = Some(crate::mgmt_token::load_or_generate_plugin()?);
|
||||
// Default the mgmt listener to ALL interfaces (not just loopback) so a paired native client can
|
||||
// fetch the game library over mTLS with no operator step — the whole point of "browse works by
|
||||
// default". This only LAN-exposes the read-only cert allowlist; the bearer-token admin surface
|
||||
|
||||
@@ -56,6 +56,10 @@ pub struct Options {
|
||||
/// Bearer token required on `/api/v1` (except `/health`). `None` ⇒ unauthenticated,
|
||||
/// which [`run`] only permits on loopback binds.
|
||||
pub token: Option<String>,
|
||||
/// The scripting runner's capability-limited bearer token (`plugin-token`): authorizes the
|
||||
/// plugin surface only, never hook registration or pairing administration
|
||||
/// (`auth::plugin_may_access`). Optional — `None` simply disables the lane.
|
||||
pub plugin_token: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
@@ -63,6 +67,7 @@ impl Default for Options {
|
||||
Options {
|
||||
bind: SocketAddr::from(([127, 0, 0, 1], DEFAULT_PORT)),
|
||||
token: None,
|
||||
plugin_token: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +86,9 @@ pub(crate) struct MgmtState {
|
||||
/// (native-only) host, where a Moonlight PIN can never arrive.
|
||||
gamestream_enabled: bool,
|
||||
token: Option<String>,
|
||||
/// The plugin lane's token (see [`Options::plugin_token`]). Checked only after the admin token
|
||||
/// mismatches, and gated by `auth::plugin_may_access` per route.
|
||||
plugin_token: Option<String>,
|
||||
/// The port we serve on, echoed in [`PortMap`] so a client can persist a full endpoint map.
|
||||
port: u16,
|
||||
}
|
||||
@@ -119,6 +127,7 @@ pub async fn run(
|
||||
let app = app(
|
||||
state,
|
||||
Some(token),
|
||||
opts.plugin_token.filter(|t| !t.trim().is_empty()),
|
||||
opts.bind.port(),
|
||||
native,
|
||||
stats,
|
||||
@@ -131,6 +140,7 @@ pub async fn run(
|
||||
fn app(
|
||||
state: Arc<AppState>,
|
||||
token: Option<String>,
|
||||
plugin_token: Option<String>,
|
||||
port: u16,
|
||||
native: Option<Arc<crate::native_pairing::NativePairing>>,
|
||||
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
||||
@@ -142,6 +152,7 @@ fn app(
|
||||
stats,
|
||||
gamestream_enabled,
|
||||
token,
|
||||
plugin_token,
|
||||
port,
|
||||
});
|
||||
let (api_routes, api) = api_router_parts();
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
//! Auth gate for the management API `/api/v1` routes: paired client cert (mTLS, from anywhere)
|
||||
//! or the bearer token (loopback peers only). Split out of the `mgmt` facade (plan §W5).
|
||||
//! or a bearer token (loopback peers only). Split out of the `mgmt` facade (plan §W5).
|
||||
//!
|
||||
//! Three lanes, three authorities:
|
||||
//! - **paired streaming cert** (mTLS, LAN) — the read-only [`cert_may_access`] allowlist.
|
||||
//! - **plugin token** (bearer, loopback) — the scripting runner's capability-limited credential:
|
||||
//! the admin surface MINUS hook registration and pairing administration
|
||||
//! ([`plugin_may_access`]).
|
||||
//! - **admin token** (bearer, loopback) — everything.
|
||||
|
||||
use super::shared::*;
|
||||
use crate::gamestream::tls::PeerAddr;
|
||||
@@ -86,6 +93,27 @@ pub(crate) async fn require_auth(
|
||||
.and_then(|v| v.strip_prefix("Bearer "));
|
||||
match presented {
|
||||
Some(token) if token_eq(token, expected) => next.run(req).await,
|
||||
// The scripting runner's scoped lane: same loopback confinement as the admin token, but
|
||||
// routes that would let a plugin escalate — registering hooks (arbitrary command
|
||||
// execution as the host user) or administering pairing (admitting/ejecting devices,
|
||||
// reading the PIN) — need the operator's admin token. Checked AFTER the admin token so
|
||||
// equal tokens (operator misconfiguration) degrade to full access, never to a lockout.
|
||||
Some(token)
|
||||
if st
|
||||
.plugin_token
|
||||
.as_deref()
|
||||
.is_some_and(|pt| token_eq(token, pt)) =>
|
||||
{
|
||||
if plugin_may_access(req.method(), req.uri().path()) {
|
||||
next.run(req).await
|
||||
} else {
|
||||
api_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"this route is not authorized for the plugin token — it requires the \
|
||||
operator's admin token",
|
||||
)
|
||||
}
|
||||
}
|
||||
_ => api_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"missing or invalid credentials (a paired client cert, or a bearer token)",
|
||||
@@ -93,6 +121,31 @@ pub(crate) async fn require_auth(
|
||||
}
|
||||
}
|
||||
|
||||
/// Which routes the scripting runner's **plugin token** may reach: the admin surface minus the
|
||||
/// escalation routes. Exclusion-based (a plugin legitimately reads status/library/events, drives
|
||||
/// sessions, and registers its UI lease), with these carve-outs:
|
||||
/// - **hooks** — `hooks.json` runs operator commands on lifecycle events; writing it is arbitrary
|
||||
/// command execution as the host user, and reading it can expose webhook credentials.
|
||||
/// - **pairing administration** — arming/approving/denying/unpairing (and PIN visibility) decide
|
||||
/// *which devices may stream*; a plugin defect must not be able to admit an attacker's device
|
||||
/// or eject the operator's.
|
||||
/// - **UI proxy credentials** — a plugin has no business reading another plugin's per-boot UI
|
||||
/// secret; only the console proxy (admin token) needs it.
|
||||
pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
|
||||
let denied = path == "/api/v1/hooks"
|
||||
|| path == "/api/v1/pair"
|
||||
|| path.starts_with("/api/v1/pair/")
|
||||
|| path == "/api/v1/native/pair"
|
||||
|| path.starts_with("/api/v1/native/pair/")
|
||||
|| path == "/api/v1/native/pending"
|
||||
|| path.starts_with("/api/v1/native/pending/")
|
||||
|| (method == Method::DELETE
|
||||
&& (path.starts_with("/api/v1/clients/")
|
||||
|| path.starts_with("/api/v1/native/clients/")))
|
||||
|| (path.starts_with("/api/v1/plugins/") && path.ends_with("/ui-credential"));
|
||||
!denied
|
||||
}
|
||||
|
||||
/// Which routes a paired *streaming* cert (mTLS, no bearer token) may reach: a small allowlist of
|
||||
/// safe, read-only status routes only. Deny-by-default — every state-changing route and every route
|
||||
/// that exposes a pairing PIN or the pending-approval queue requires the operator's bearer token, so
|
||||
|
||||
@@ -42,6 +42,8 @@ fn test_app(state: Arc<AppState>, token: Option<&str>) -> Router {
|
||||
app(
|
||||
state,
|
||||
Some(token.unwrap_or("test-secret").to_string()),
|
||||
// The scoped plugin lane, exercised by the `plugin_token_*` tests below.
|
||||
Some("plugin-secret".to_string()),
|
||||
DEFAULT_PORT,
|
||||
None,
|
||||
stats,
|
||||
@@ -57,6 +59,7 @@ fn test_app_native(state: Arc<AppState>, np: Arc<crate::native_pairing::NativePa
|
||||
app(
|
||||
state,
|
||||
Some("test-secret".to_string()),
|
||||
Some("plugin-secret".to_string()),
|
||||
DEFAULT_PORT,
|
||||
Some(np),
|
||||
stats,
|
||||
@@ -374,6 +377,133 @@ async fn bearer_token_is_enforced() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The pure route gate for the plugin lane: exclusion-based, so spot-check both sides — the
|
||||
/// surface a plugin legitimately uses, and every escalation carve-out.
|
||||
#[test]
|
||||
fn plugin_allowlist_excludes_escalation_routes() {
|
||||
use axum::http::Method;
|
||||
|
||||
// The legitimate plugin surface stays open (including mutations — sessions, library, leases).
|
||||
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/status"));
|
||||
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/library"));
|
||||
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/clients"));
|
||||
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/plugins"));
|
||||
assert!(auth::plugin_may_access(
|
||||
&Method::PUT,
|
||||
"/api/v1/plugins/rom-manager"
|
||||
));
|
||||
assert!(auth::plugin_may_access(
|
||||
&Method::DELETE,
|
||||
"/api/v1/plugins/rom-manager"
|
||||
));
|
||||
|
||||
// Hooks: registration is command execution; even the read can expose webhook credentials.
|
||||
assert!(!auth::plugin_may_access(&Method::GET, "/api/v1/hooks"));
|
||||
assert!(!auth::plugin_may_access(&Method::PUT, "/api/v1/hooks"));
|
||||
|
||||
// Pairing administration + PIN visibility.
|
||||
assert!(!auth::plugin_may_access(&Method::GET, "/api/v1/pair"));
|
||||
assert!(!auth::plugin_may_access(&Method::POST, "/api/v1/pair/pin"));
|
||||
assert!(!auth::plugin_may_access(
|
||||
&Method::GET,
|
||||
"/api/v1/native/pair"
|
||||
));
|
||||
assert!(!auth::plugin_may_access(
|
||||
&Method::POST,
|
||||
"/api/v1/native/pair/arm"
|
||||
));
|
||||
assert!(!auth::plugin_may_access(
|
||||
&Method::GET,
|
||||
"/api/v1/native/pending"
|
||||
));
|
||||
assert!(!auth::plugin_may_access(
|
||||
&Method::POST,
|
||||
"/api/v1/native/pending/1/approve"
|
||||
));
|
||||
assert!(!auth::plugin_may_access(
|
||||
&Method::DELETE,
|
||||
"/api/v1/clients/aabbcc"
|
||||
));
|
||||
assert!(!auth::plugin_may_access(
|
||||
&Method::DELETE,
|
||||
"/api/v1/native/clients/aabbcc"
|
||||
));
|
||||
|
||||
// Another plugin's UI proxy secret.
|
||||
assert!(!auth::plugin_may_access(
|
||||
&Method::GET,
|
||||
"/api/v1/plugins/x/ui-credential"
|
||||
));
|
||||
}
|
||||
|
||||
/// The plugin bearer lane end-to-end: scoped 403s on the carve-outs, 200s on the plugin surface,
|
||||
/// and the same loopback confinement as the admin token.
|
||||
#[tokio::test]
|
||||
async fn plugin_token_lane_is_scoped_and_loopback_only() {
|
||||
use axum::http::Method;
|
||||
let app = test_app(test_state(), None); // admin "test-secret", plugin "plugin-secret"
|
||||
|
||||
let plugin_req = |method: Method, path: &str| {
|
||||
axum::http::Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.header("authorization", "Bearer plugin-secret")
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// The plugin surface authenticates: status + the plugin directory (list and lease removal).
|
||||
assert_eq!(
|
||||
send(&app, plugin_req(Method::GET, "/api/v1/status"))
|
||||
.await
|
||||
.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
send(&app, plugin_req(Method::GET, "/api/v1/plugins"))
|
||||
.await
|
||||
.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
send(
|
||||
&app,
|
||||
plugin_req(Method::DELETE, "/api/v1/plugins/no-such-plugin")
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
|
||||
// The carve-outs answer 403 (authenticated but not authorized), not 401.
|
||||
for (method, path) in [
|
||||
(Method::GET, "/api/v1/hooks"),
|
||||
(Method::PUT, "/api/v1/hooks"),
|
||||
(Method::GET, "/api/v1/pair"),
|
||||
(Method::POST, "/api/v1/native/pair/arm"),
|
||||
(Method::GET, "/api/v1/native/pending"),
|
||||
(Method::DELETE, "/api/v1/clients/aabbcc"),
|
||||
(Method::GET, "/api/v1/plugins/x/ui-credential"),
|
||||
] {
|
||||
let (status, body) = send(&app, plugin_req(method.clone(), path)).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "{method} {path}");
|
||||
assert!(body["error"].as_str().unwrap().contains("plugin token"));
|
||||
}
|
||||
|
||||
// A wrong token never reaches the lane.
|
||||
let wrong = axum::http::Request::get("/api/v1/status")
|
||||
.header("authorization", "Bearer plugin-wrong")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(send(&app, wrong).await.0, StatusCode::UNAUTHORIZED);
|
||||
|
||||
// Loopback-only, exactly like the admin token: a LAN peer is refused before token compare.
|
||||
let mut lan = plugin_req(Method::GET, "/api/v1/status");
|
||||
lan.extensions_mut()
|
||||
.insert(PeerAddr("192.168.1.50:40000".parse().unwrap()));
|
||||
assert_eq!(send(&app, lan).await.0, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_info_reports_identity_and_ports() {
|
||||
let app = test_app(test_state(), None);
|
||||
@@ -544,6 +674,7 @@ async fn blank_token_rejected() {
|
||||
let opts = Options {
|
||||
bind: "127.0.0.1:0".parse().unwrap(),
|
||||
token: Some(" ".into()),
|
||||
plugin_token: None,
|
||||
};
|
||||
let err = run(test_state(), opts, None, test_stats(), false)
|
||||
.await
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
//! Management-API bearer token resolution.
|
||||
//!
|
||||
//! The mgmt API always serves HTTPS (the host's identity cert) and now always requires auth — even
|
||||
//! on a loopback bind. This module guarantees a token always exists: an explicit
|
||||
//! `PUNKTFUNK_MGMT_TOKEN` env wins (operator override, not persisted); otherwise the persisted
|
||||
//! `~/.config/punktfunk/mgmt-token` is used; otherwise a fresh 32-byte hex token is generated and
|
||||
//! persisted. The file is written in `PUNKTFUNK_MGMT_TOKEN=<hex>` form (0600) so the bundled web
|
||||
//! console can source it directly as a systemd `EnvironmentFile` — a single source of truth shared
|
||||
//! between the host and the console with no copying.
|
||||
//! on a loopback bind. This module guarantees the tokens always exist: an explicit env var wins
|
||||
//! (operator override, not persisted); otherwise the persisted file under the config dir is used;
|
||||
//! otherwise a fresh 32-byte hex token is generated and persisted. Files are written in
|
||||
//! `KEY=<hex>` form (0600) so the bundled web console can source them directly as a systemd
|
||||
//! `EnvironmentFile` — a single source of truth shared between the host and its consumers.
|
||||
//!
|
||||
//! Two tokens, two authorities:
|
||||
//! - **`mgmt-token`** (`PUNKTFUNK_MGMT_TOKEN`) — the operator/console token; authorizes the full
|
||||
//! admin surface.
|
||||
//! - **`plugin-token`** (`PUNKTFUNK_PLUGIN_TOKEN`) — the scripting runner's capability-limited
|
||||
//! credential (`mgmt::auth::plugin_may_access`): everything a plugin legitimately needs, but not
|
||||
//! hook registration or pairing administration. The SDK's `connect()` prefers this file, so a
|
||||
//! defect in an operator plugin can't rewrite `hooks.json` or admit new devices.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rand::RngCore;
|
||||
@@ -15,19 +22,32 @@ use std::path::Path;
|
||||
|
||||
const ENV_VAR: &str = "PUNKTFUNK_MGMT_TOKEN";
|
||||
const FILE: &str = "mgmt-token";
|
||||
const PLUGIN_ENV_VAR: &str = "PUNKTFUNK_PLUGIN_TOKEN";
|
||||
const PLUGIN_FILE: &str = "plugin-token";
|
||||
|
||||
/// Resolve the mgmt token (env > persisted file > generate+persist). Hex (not base64) so the
|
||||
/// persisted `KEY=VALUE` line is safe to source from a shell / systemd `EnvironmentFile`.
|
||||
/// Resolve the mgmt (full-admin) token (env > persisted file > generate+persist). Hex (not base64)
|
||||
/// so the persisted `KEY=VALUE` line is safe to source from a shell / systemd `EnvironmentFile`.
|
||||
pub fn load_or_generate() -> Result<String> {
|
||||
if let Ok(v) = std::env::var(ENV_VAR) {
|
||||
load_or_generate_impl(ENV_VAR, FILE)
|
||||
}
|
||||
|
||||
/// Resolve the scripting runner's scoped plugin token, same precedence as [`load_or_generate`].
|
||||
/// Persisted to `plugin-token` next to `mgmt-token`; on Windows `plugins enable` grants the
|
||||
/// runner's LocalService principal read on exactly this file (and `cert.pem`) — never `mgmt-token`.
|
||||
pub fn load_or_generate_plugin() -> Result<String> {
|
||||
load_or_generate_impl(PLUGIN_ENV_VAR, PLUGIN_FILE)
|
||||
}
|
||||
|
||||
fn load_or_generate_impl(env_var: &str, file: &str) -> Result<String> {
|
||||
if let Ok(v) = std::env::var(env_var) {
|
||||
let v = v.trim();
|
||||
if !v.is_empty() {
|
||||
return Ok(v.to_string());
|
||||
}
|
||||
}
|
||||
let path = pf_paths::config_dir().join(FILE);
|
||||
let path = pf_paths::config_dir().join(file);
|
||||
if let Ok(contents) = fs::read_to_string(&path) {
|
||||
if let Some(tok) = parse_token(&contents) {
|
||||
if let Some(tok) = parse_token(&contents, env_var) {
|
||||
return Ok(tok);
|
||||
}
|
||||
}
|
||||
@@ -37,29 +57,29 @@ pub fn load_or_generate() -> Result<String> {
|
||||
let dir = pf_paths::config_dir();
|
||||
// Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config path.
|
||||
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
write_token(&path, &token)?;
|
||||
tracing::info!(path = %path.display(), "generated and persisted management API token (owner-only)");
|
||||
write_token(&path, env_var, &token)?;
|
||||
tracing::info!(path = %path.display(), "generated and persisted API token (owner-only)");
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Parse the token from the persisted file: accept either a bare token line or a
|
||||
/// `PUNKTFUNK_MGMT_TOKEN=<token>` line (the form we write, also valid as an EnvironmentFile).
|
||||
fn parse_token(contents: &str) -> Option<String> {
|
||||
/// `<KEY>=<token>` line (the form we write, also valid as an EnvironmentFile).
|
||||
fn parse_token(contents: &str, env_var: &str) -> Option<String> {
|
||||
let line = contents.lines().find(|l| !l.trim().is_empty())?.trim();
|
||||
let tok = line
|
||||
.strip_prefix("PUNKTFUNK_MGMT_TOKEN=")
|
||||
.strip_prefix(env_var)
|
||||
.and_then(|rest| rest.strip_prefix('='))
|
||||
.unwrap_or(line)
|
||||
.trim();
|
||||
(!tok.is_empty()).then(|| tok.to_string())
|
||||
}
|
||||
|
||||
/// Write `PUNKTFUNK_MGMT_TOKEN=<token>` to `path` as an owner-only secret — 0600 on Unix AND
|
||||
/// DACL-locked to SYSTEM/Administrators on Windows. Routes through the shared `write_secret_file` so
|
||||
/// the mgmt bearer token (full admin authority) gets the SAME Windows lockdown as the host key; the
|
||||
/// bespoke `cfg(unix)`-only writer used to leave it readable by any local user (security-review
|
||||
/// 2026-06-28 #2).
|
||||
fn write_token(path: &Path, token: &str) -> Result<()> {
|
||||
let line = format!("PUNKTFUNK_MGMT_TOKEN={token}\n");
|
||||
/// Write `<KEY>=<token>` to `path` as an owner-only secret — 0600 on Unix AND DACL-locked to
|
||||
/// SYSTEM/Administrators on Windows. Routes through the shared `write_secret_file` so both bearer
|
||||
/// tokens get the SAME Windows lockdown as the host key; the bespoke `cfg(unix)`-only writer used
|
||||
/// to leave the mgmt token readable by any local user (security-review 2026-06-28 #2).
|
||||
fn write_token(path: &Path, env_var: &str, token: &str) -> Result<()> {
|
||||
let line = format!("{env_var}={token}\n");
|
||||
pf_paths::write_secret_file(path, line.as_bytes())
|
||||
.with_context(|| format!("write {}", path.display()))
|
||||
}
|
||||
@@ -70,13 +90,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_bare_and_keyvalue_forms() {
|
||||
assert_eq!(parse_token("abc123\n").as_deref(), Some("abc123"));
|
||||
assert_eq!(parse_token("abc123\n", ENV_VAR).as_deref(), Some("abc123"));
|
||||
assert_eq!(
|
||||
parse_token("PUNKTFUNK_MGMT_TOKEN=deadbeef\n").as_deref(),
|
||||
parse_token("PUNKTFUNK_MGMT_TOKEN=deadbeef\n", ENV_VAR).as_deref(),
|
||||
Some("deadbeef")
|
||||
);
|
||||
assert_eq!(parse_token("\n \n"), None);
|
||||
assert_eq!(parse_token("PUNKTFUNK_MGMT_TOKEN=\n"), None);
|
||||
assert_eq!(
|
||||
parse_token("PUNKTFUNK_PLUGIN_TOKEN=deadbeef\n", PLUGIN_ENV_VAR).as_deref(),
|
||||
Some("deadbeef")
|
||||
);
|
||||
assert_eq!(parse_token("\n \n", ENV_VAR), None);
|
||||
assert_eq!(parse_token("PUNKTFUNK_MGMT_TOKEN=\n", ENV_VAR), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -84,9 +108,9 @@ mod tests {
|
||||
let dir = std::env::temp_dir().join(format!("pf-mgmt-token-test-{}", std::process::id()));
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let path = dir.join(FILE);
|
||||
write_token(&path, "cafef00d").unwrap();
|
||||
write_token(&path, ENV_VAR, "cafef00d").unwrap();
|
||||
let read = fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(parse_token(&read).as_deref(), Some("cafef00d"));
|
||||
assert_eq!(parse_token(&read, ENV_VAR).as_deref(), Some("cafef00d"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
@@ -14,9 +14,16 @@
|
||||
//! package present.
|
||||
//!
|
||||
//! Windows needs elevation for both halves: the plugins dir lives under the ACL'd
|
||||
//! `%ProgramData%\punktfunk` (see `pf_paths::create_private_dir`) and the task runs as SYSTEM. We
|
||||
//! `%ProgramData%\punktfunk` (see `pf_paths::create_private_dir`) and the task is admin-owned. We
|
||||
//! check up front and print one actionable line instead of letting `bun add` fail with a bare
|
||||
//! EACCES.
|
||||
//!
|
||||
//! The task itself runs as **`NT AUTHORITY\LocalService`**, not SYSTEM: plugins are
|
||||
//! operator-installed code, and a plugin defect must cost a throwaway service account, not the
|
||||
//! most privileged principal on the box. `enable` converges the principal (migrating tasks an
|
||||
//! older installer registered as SYSTEM) and grants LocalService read on exactly the two files
|
||||
//! the runner's `connect()` needs — the scoped `plugin-token` and the TLS pin `cert.pem` — never
|
||||
//! the full-admin `mgmt-token`.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::process::Command;
|
||||
@@ -71,13 +78,15 @@ USAGE:
|
||||
|
||||
NAMES:
|
||||
A bare first-party name resolves into the @punktfunk scope: `playnite` installs
|
||||
@punktfunk/plugin-playnite, `rom-manager` installs @punktfunk/plugin-rom-manager.
|
||||
A scoped (@scope/pkg) or `punktfunk-plugin-*` name is used verbatim.
|
||||
@punktfunk/plugin-playnite, `rom-manager` installs @punktfunk/plugin-rom-manager —
|
||||
always from Punktfunk's own package registry. Any other name (`punktfunk-plugin-*`,
|
||||
a foreign @scope) installs from the PUBLIC npm registry and is refused unless you
|
||||
pass --allow-public-registry.
|
||||
|
||||
NOTES:
|
||||
Plugins run under the runner, which is OPT-IN — `plugins add` installs, `plugins enable`
|
||||
turns the runner on. Plugins are operator-installed code that runs as the host user;
|
||||
install only plugins you trust.
|
||||
turns the runner on. Plugins are operator-installed code that runs with operator
|
||||
privileges; install only plugins you trust.
|
||||
"
|
||||
);
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -212,13 +221,66 @@ fn systemctl_output(args: &[&str]) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `NT AUTHORITY\LocalService` — the runner task's principal — in icacls SID form.
|
||||
#[cfg(target_os = "windows")]
|
||||
const LOCAL_SERVICE_SID: &str = "*S-1-5-19";
|
||||
|
||||
/// The two (and only two) secrets the runner needs to read to reach the mgmt API: the scoped
|
||||
/// plugin token and the host identity cert it pins TLS against. `mgmt-token` (full admin) is
|
||||
/// deliberately NOT here.
|
||||
#[cfg(target_os = "windows")]
|
||||
const RUNNER_SECRET_FILES: [&str; 2] = ["plugin-token", "cert.pem"];
|
||||
|
||||
/// The unit directories the runner imports code from. LocalService gets an inheritable
|
||||
/// read+execute+write-attributes grant on these: bun's module loader opens unit files
|
||||
/// requesting FILE_WRITE_ATTRIBUTES on top of read (plain `(RX)` makes every import die with
|
||||
/// EPERM — found on-glass), and WA can only touch timestamps/readonly bits, never content —
|
||||
/// the runner's own integrity check (`windowsSddlUnsafeReason`) treats it as harmless.
|
||||
#[cfg(target_os = "windows")]
|
||||
const RUNNER_UNIT_DIRS: [&str; 2] = ["plugins", "scripts"];
|
||||
|
||||
/// The runner's writable state root: `<config_dir>\plugin-state`. A plugin persists its config +
|
||||
/// cache under `plugin-state\<name>` (`@punktfunk/host`'s `pluginStateDir`), so LocalService needs
|
||||
/// real **Modify** here — unlike the code dirs (RX,WA) and the secrets (R). This keeps the
|
||||
/// three-way split crisp: code is read-only (a plugin can't rewrite itself), secrets are
|
||||
/// read-only, only this one dir is writable. Inheritable so per-plugin subdirs the runner creates
|
||||
/// carry the grant. Users stay read-only (config-dir default), so another non-admin still can't
|
||||
/// tamper with a plugin's launch templates.
|
||||
#[cfg(target_os = "windows")]
|
||||
const RUNNER_STATE_DIRS: [&str; 1] = ["plugin-state"];
|
||||
|
||||
/// The plugin **ingest** inbox: `<config_dir>\ingest`. The INVERSE grant of `plugin-state` —
|
||||
/// `BUILTIN\Users` gets **Modify**, so an app running as the interactive user (e.g. the Playnite
|
||||
/// exporter, a Playnite extension) can drop data (`ingest\<plugin>\…`) that the de-privileged
|
||||
/// LocalService runner then READS (LocalService is a member of Users, so it inherits read here).
|
||||
/// This is the one place a plugin can receive data produced by *another* account — the runner can
|
||||
/// no longer traverse the interactive user's profile the way the old SYSTEM runner could. Scoped
|
||||
/// to this one inbox: the rest of the config tree stays Users-read-only, so the widening is a
|
||||
/// well-defined drop box, not a general write hole. (Accepted tradeoff: any local user can drop a
|
||||
/// file here — trusted-single-user model, and the runner it feeds is only LocalService.)
|
||||
#[cfg(target_os = "windows")]
|
||||
const RUNNER_INGEST_DIRS: [&str; 1] = ["ingest"];
|
||||
|
||||
/// `BUILTIN\Users` (S-1-5-32-545) in icacls SID form — the ingest inbox's writer.
|
||||
#[cfg(target_os = "windows")]
|
||||
const USERS_SID: &str = "*S-1-5-32-545";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn enable() -> Result<()> {
|
||||
// Converge the task principal BEFORE starting it: the installer registers it as LocalService,
|
||||
// but a task from an older install (or a hand-registered dev box) still runs as SYSTEM, and
|
||||
// enabling that unmigrated would hand operator plugins the highest privilege on the box.
|
||||
// Idempotent; -LogonType ServiceAccount needs no stored password.
|
||||
powershell(&format!(
|
||||
"$p = New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount; \
|
||||
Set-ScheduledTask -TaskName {TASK} -Principal $p -ErrorAction Stop | Out-Null"
|
||||
))?;
|
||||
grant_runner_secret_reads();
|
||||
powershell(&format!(
|
||||
"Enable-ScheduledTask -TaskName {TASK} -ErrorAction Stop | Out-Null; \
|
||||
Start-ScheduledTask -TaskName {TASK} -ErrorAction Stop"
|
||||
))?;
|
||||
println!("Plugin runner enabled and started ({TASK}).");
|
||||
println!("Plugin runner enabled and started ({TASK}, runs as LocalService).");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -228,17 +290,173 @@ fn disable() -> Result<()> {
|
||||
"Stop-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
|
||||
Disable-ScheduledTask -TaskName {TASK} -ErrorAction Stop | Out-Null"
|
||||
))?;
|
||||
revoke_runner_secret_reads();
|
||||
println!("Plugin runner stopped and disabled ({TASK}).");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Grant LocalService **read** on the runner's two secret files. Both are written by the host's
|
||||
/// `serve` with a SYSTEM/Administrators-only DACL (`pf_paths::write_secret_file`), which the
|
||||
/// de-privileged runner cannot read — this is the one, narrow widening it needs. `/grant:r`
|
||||
/// replaces only LocalService's ACE, leaving the lockdown otherwise intact. Files the host hasn't
|
||||
/// minted yet get an actionable note instead of a failed icacls: the grant re-runs on the next
|
||||
/// `plugins enable`. NOTE: the host re-locks a secret's DACL whenever it rewrites the file (e.g.
|
||||
/// a regenerated identity cert) — re-running `plugins enable` restores the grant.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn grant_runner_secret_reads() {
|
||||
let cfg = pf_paths::config_dir();
|
||||
for name in RUNNER_SECRET_FILES {
|
||||
let path = cfg.join(name);
|
||||
if !path.exists() {
|
||||
println!(
|
||||
"note: {} does not exist yet (the host writes it on first serve). Start the \
|
||||
host once, then run `punktfunk-host plugins enable` again so the runner can \
|
||||
authenticate.",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let ok = Command::new(icacls_path())
|
||||
.arg(&path)
|
||||
.args(["/grant:r", &format!("{LOCAL_SERVICE_SID}:(R)")])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|s| s.success());
|
||||
if !ok {
|
||||
eprintln!(
|
||||
"warning: could not grant LocalService read on {} - the plugin runner may fail \
|
||||
to authenticate to the management API",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
// The unit dirs: inheritable (RX,WA) so the runner can import what lives there (see
|
||||
// RUNNER_UNIT_DIRS). Created here if absent — an elevated create inherits the config dir's
|
||||
// protected DACL, and granting now means files the operator adds later are covered by
|
||||
// inheritance rather than needing another `plugins enable`.
|
||||
for name in RUNNER_UNIT_DIRS {
|
||||
let dir = cfg.join(name);
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
eprintln!("warning: could not create {}: {e}", dir.display());
|
||||
continue;
|
||||
}
|
||||
let ok = Command::new(icacls_path())
|
||||
.arg(&dir)
|
||||
.args(["/grant:r", &format!("{LOCAL_SERVICE_SID}:(OI)(CI)(RX,WA)")])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|s| s.success());
|
||||
if !ok {
|
||||
eprintln!(
|
||||
"warning: could not grant LocalService read on {} - the runner may fail to \
|
||||
import plugins/scripts from it",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
// The state root: inheritable Modify so plugins can persist config/cache under
|
||||
// `plugin-state\<name>` (see RUNNER_STATE_DIRS). This is the ONLY writable grant.
|
||||
for name in RUNNER_STATE_DIRS {
|
||||
let dir = cfg.join(name);
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
eprintln!("warning: could not create {}: {e}", dir.display());
|
||||
continue;
|
||||
}
|
||||
let ok = Command::new(icacls_path())
|
||||
.arg(&dir)
|
||||
.args(["/grant:r", &format!("{LOCAL_SERVICE_SID}:(OI)(CI)(M)")])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|s| s.success());
|
||||
if !ok {
|
||||
eprintln!(
|
||||
"warning: could not grant LocalService write on {} - state-writing plugins \
|
||||
(config/cache) may fail to persist",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
// The ingest inbox: inheritable Modify for BUILTIN\Users, so an interactive-user app (the
|
||||
// Playnite exporter) can drop `ingest\<plugin>\…` for the LocalService runner to read (see
|
||||
// RUNNER_INGEST_DIRS). The one Users-writable carve-out in the otherwise Users-read-only tree.
|
||||
for name in RUNNER_INGEST_DIRS {
|
||||
let dir = cfg.join(name);
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
eprintln!("warning: could not create {}: {e}", dir.display());
|
||||
continue;
|
||||
}
|
||||
let ok = Command::new(icacls_path())
|
||||
.arg(&dir)
|
||||
.args(["/grant:r", &format!("{USERS_SID}:(OI)(CI)(M)")])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|s| s.success());
|
||||
if !ok {
|
||||
eprintln!(
|
||||
"warning: could not open the ingest inbox {} for writes - a plugin fed by an \
|
||||
interactive-user app (e.g. playnite) may see no data",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort removal of the LocalService read grants when the runner is switched off — the
|
||||
/// mirror of [`grant_runner_secret_reads`]; `enable` re-grants.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn revoke_runner_secret_reads() {
|
||||
let cfg = pf_paths::config_dir();
|
||||
for name in RUNNER_SECRET_FILES
|
||||
.iter()
|
||||
.chain(RUNNER_UNIT_DIRS.iter())
|
||||
.chain(RUNNER_STATE_DIRS.iter())
|
||||
{
|
||||
let path = cfg.join(name);
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let _ = Command::new(icacls_path())
|
||||
.arg(&path)
|
||||
.args(["/remove:g", LOCAL_SERVICE_SID])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
}
|
||||
// The ingest inbox was opened to Users, not LocalService — remove that explicit grant (the
|
||||
// inherited Users:RX from the config dir remains, so it reverts to read-only, not orphaned).
|
||||
for name in RUNNER_INGEST_DIRS {
|
||||
let path = cfg.join(name);
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let _ = Command::new(icacls_path())
|
||||
.arg(&path)
|
||||
.args(["/remove:g", USERS_SID])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve icacls by full System32 path rather than PATH — same planted-binary reasoning as
|
||||
/// [`powershell_path`]; matches `pf_paths`.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn icacls_path() -> String {
|
||||
std::env::var("SystemRoot")
|
||||
.map(|r| format!(r"{r}\System32\icacls.exe"))
|
||||
.unwrap_or_else(|_| "icacls".to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn status() -> Result<()> {
|
||||
let out = powershell_output(&format!(
|
||||
"$t = Get-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
|
||||
if ($null -eq $t) {{ 'missing' }} else {{ \
|
||||
$i = Get-ScheduledTaskInfo -TaskName {TASK} -ErrorAction SilentlyContinue; \
|
||||
\"$($t.State)\" }}"
|
||||
\"$($t.State)|$($t.Principal.UserId)\" }}"
|
||||
));
|
||||
match out.as_deref().map(str::trim) {
|
||||
Some("missing") | None => {
|
||||
@@ -248,7 +466,10 @@ fn status() -> Result<()> {
|
||||
);
|
||||
}
|
||||
Some(state) => {
|
||||
println!("runner: {TASK}\nstate: {state}");
|
||||
// "State|Principal" — the principal line makes the SYSTEM→LocalService migration
|
||||
// verifiable at a glance (`plugins enable` converges a legacy SYSTEM task).
|
||||
let (state, principal) = state.split_once('|').unwrap_or((state, "?"));
|
||||
println!("runner: {TASK}\nstate: {state}\nruns as: {principal}");
|
||||
if state.eq_ignore_ascii_case("Disabled") {
|
||||
println!("\nEnable it with: punktfunk-host plugins enable");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user