feat(security): scope the plugin runner to a capability-limited bearer token
The scripting runner used to hold the console's full-admin mgmt-token — a plugin defect could rewrite hooks.json (arbitrary command execution) or administer pairing. The host now mints a second persisted secret, plugin-token (PUNKTFUNK_PLUGIN_TOKEN), and require_auth grows a third lane for it: loopback-confined like the admin token, but plugin_may_access carves out /hooks (read AND write), everything under /pair, /native/pair, /native/pending, client unpair DELETEs, and other plugins' ui-credential. Everything a plugin legitimately does (status/library/events/sessions, its own UI lease) is untouched. The SDK's zero-config connect() now prefers PUNKTFUNK_PLUGIN_TOKEN / plugin-token over mgmt-token, so plugins pick the scoped credential up automatically; a script that genuinely needs the admin surface sets PUNKTFUNK_MGMT_TOKEN explicitly. Old hosts without a plugin-token fall back to mgmt-token unchanged. No OpenAPI change: the lane is auth-layer only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -498,6 +498,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;
|
||||||
|
|||||||
+16
-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,
|
||||||
@@ -73,11 +81,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user