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() {
|
||||
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;
|
||||
|
||||
+16
-5
@@ -2,9 +2,17 @@
|
||||
// identity cert, from the environment with file fallbacks — so `connect()` on the host machine
|
||||
// needs zero configuration.
|
||||
//
|
||||
// PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990)
|
||||
// PUNKTFUNK_MGMT_TOKEN (else <config_dir>/mgmt-token)
|
||||
// PUNKTFUNK_MGMT_CA (path; else <config_dir>/cert.pem when present)
|
||||
// PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990)
|
||||
// PUNKTFUNK_MGMT_TOKEN (admin override), else PUNKTFUNK_PLUGIN_TOKEN,
|
||||
// else <config_dir>/plugin-token, else <config_dir>/mgmt-token
|
||||
// PUNKTFUNK_MGMT_CA (path; else <config_dir>/cert.pem when present)
|
||||
//
|
||||
// Token precedence is deliberate: the host mints a capability-limited `plugin-token` for the
|
||||
// scripting runner (it cannot register hooks or administer pairing), and that is what a plugin's
|
||||
// zero-config connect() should hold — the full-admin `mgmt-token` is only a fallback for hosts
|
||||
// that predate the plugin token (and on Windows the runner's LocalService principal can't read it
|
||||
// at all). A script that legitimately needs the admin surface sets PUNKTFUNK_MGMT_TOKEN or passes
|
||||
// { token } explicitly.
|
||||
//
|
||||
// The CA is the host's own identity certificate — trusting exactly it (not the system roots)
|
||||
// IS the pin for the loopback hop. Per-runtime plumbing differs: Bun takes `tls.ca` on fetch,
|
||||
@@ -73,11 +81,14 @@ export const resolveConfig = async (
|
||||
const token =
|
||||
options?.token ??
|
||||
process.env.PUNKTFUNK_MGMT_TOKEN ??
|
||||
process.env.PUNKTFUNK_PLUGIN_TOKEN ??
|
||||
parseTokenFile(readIfExists(path.join(configDir(), "plugin-token")) ?? "") ??
|
||||
parseTokenFile(readIfExists(path.join(configDir(), "mgmt-token")) ?? "");
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"no management token: set PUNKTFUNK_MGMT_TOKEN, pass { token }, or run where " +
|
||||
`the host's token file exists (${path.join(configDir(), "mgmt-token")})`,
|
||||
"no management token: set PUNKTFUNK_PLUGIN_TOKEN (or PUNKTFUNK_MGMT_TOKEN), pass " +
|
||||
"{ token }, or run where the host's token files exist " +
|
||||
`(${path.join(configDir(), "plugin-token")})`,
|
||||
);
|
||||
}
|
||||
const caPath = process.env.PUNKTFUNK_MGMT_CA;
|
||||
|
||||
Reference in New Issue
Block a user