Files
punktfunk/crates/punktfunk-host/src/hooks.rs
T
enricobuehlerandClaude Fable 5 11cca33300
android / android (push) Successful in 19m27s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m51s
windows-host / package (push) Successful in 18m48s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m58s
ci / rust (push) Failing after 14m6s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 58s
apple / swift (push) Successful in 1m21s
decky / build-publish (push) Successful in 20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / bench (push) Successful in 5m56s
apple / screenshots (push) Successful in 6m15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8m35s
docker / deploy-docs (push) Successful in 27s
deb / build-publish (push) Successful in 12m33s
arch / build-publish (push) Successful in 18m31s
fix(host/hooks): honor hand-edits to hooks.json without a restart
docs/automation.md promises 'changes apply immediately, no restart' for BOTH
config paths, but the store loaded hooks.json once (OnceLock) — only PUT
/api/v1/hooks applied live, and a hand-edited file silently did nothing until
the next host start. Since the file is the accessible path most users will
take, make it real: get() re-stats the file per event (mtime + length) and
re-reads it on change, with the same lenient contract as startup (missing =
no hooks, invalid = disabled loudly). set() records the identity it wrote so
the API path doesn't trigger a spurious reload.

Validated live on the 0.13.0 host (.21): full connect/disconnect lifecycle
fires exec hooks (client.*/session.*/stream.*) — the reload gap was found
setting that test up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 23:53:25 +02:00

1142 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Operator hooks: commands and webhooks fired on host lifecycle events
//! (scripting-and-hooks RFC §6, M2).
//!
//! `<config_dir>/hooks.json` holds a list of [`HookEntry`]s — *what to run on which event* —
//! managed over `GET|PUT /api/v1/hooks` and applied immediately (the runner reads the store
//! per event). The runner subscribes to the [`crate::events`] bus and dispatches matching
//! entries **fire-and-forget**: a hook observes; it can never veto or delay a connection,
//! stream, or pairing decision (decisions are made asynchronously through the API — RFC §6).
//!
//! Two actions:
//! - **`run`** — a shell command, executed detached with the event JSON on stdin plus flat
//! `PF_EVENT_*` env vars (the [`crate::stream_marker`] `PF_STREAM_*` vocabulary's sibling).
//! Per-hook timeout (default 30 s) kills the whole process group on expiry; reaped
//! off-thread (the `try_recover_session` recipe). On a SYSTEM-service Windows host the
//! command runs **in the interactive user session** (never SYSTEM); that path cannot carry
//! per-process env/stdin, so the event JSON lands in a temp file appended as the command's
//! last argument (a console-mode Windows host gets env + stdin like Unix).
//! - **`webhook`** — POST the event JSON to an operator URL. TLS-verified, redirects are not
//! followed, no punktfunk credentials are attached; an optional per-hook secret file yields
//! an `X-Punktfunk-Signature: sha256=<hex HMAC>` header so the receiver can authenticate us.
//!
//! Bounds (RFC §9.6): at most [`MAX_CONCURRENT_HOOKS`] hook executions in flight (excess
//! firings are dropped with a warning, never queued unboundedly), per-hook `debounce_ms`, the
//! exec timeout + process-group kill. Trust model (RFC §9.1): `hooks.json` is
//! operator-privileged config in the DACL'd/0700 config dir; before executing a hook whose
//! command is a script *path*, the host verifies the file is owned by the operator (or root)
//! and not group/world-writable — the sshd/sudoers rule — and refuses loudly otherwise.
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use utoipa::ToSchema;
/// Concurrent hook executions in flight (exec + webhook combined). Excess firings are dropped
/// with a warning — hooks are best-effort observers, and unbounded queueing is the failure
/// mode this cap exists to prevent.
const MAX_CONCURRENT_HOOKS: usize = 8;
/// Default and ceiling for the exec timeout.
const DEFAULT_TIMEOUT_S: u32 = 30;
const MAX_TIMEOUT_S: u32 = 600;
/// Outbound webhook timeout (connect + response).
const WEBHOOK_TIMEOUT: Duration = Duration::from_secs(10);
fn default_timeout_s() -> u32 {
DEFAULT_TIMEOUT_S
}
/// The operator's hook configuration — the `hooks.json` document and the `/api/v1/hooks` body.
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug, Default)]
pub struct HooksConfig {
#[serde(default)]
pub hooks: Vec<HookEntry>,
}
/// One hook: fire `run` and/or `webhook` when an event matching `on` (+ `filter`) occurs.
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
pub struct HookEntry {
/// Which events fire this hook: an exact kind (`stream.started`) or a `domain.*` prefix
/// (`pairing.*`) — the same vocabulary as the SSE `?kinds=` filter.
pub on: String,
/// Exact-match constraints on the event's fields; every present field must match.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filter: Option<HookFilter>,
/// Shell command to execute (detached, event JSON on stdin + `PF_EVENT_*` env).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run: Option<String>,
/// URL to POST the event JSON to.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webhook: Option<String>,
/// Exec timeout in seconds (1600, default 30); the process group is killed on expiry.
#[serde(default = "default_timeout_s")]
pub timeout_s: u32,
/// Minimum interval between firings of this hook, in milliseconds. 0 = fire every time.
#[serde(default)]
pub debounce_ms: u64,
/// File holding the webhook HMAC secret (`X-Punktfunk-Signature: sha256=<hex>`). The file
/// should be operator-owned and private; a world-readable secret is warned about.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<String>)]
pub hmac_secret_file: Option<PathBuf>,
}
/// Exact-match filters against an event's identity fields (RFC open-question 3: exact match
/// only — anything richer is what the SDK is for). Absent fields don't constrain; a filter
/// field set on an event kind that doesn't carry it (e.g. `client` on `host.started`) never
/// matches.
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug, Default)]
pub struct HookFilter {
/// Client/device name (for `session.*`: the short client label the Dashboard shows).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client: Option<String>,
/// Certificate fingerprint (hex, case-insensitive).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fingerprint: Option<String>,
/// Protocol plane (`native` / `gamestream`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plane: Option<crate::events::Plane>,
/// Launched app id/title (`stream.*` events).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app: Option<String>,
}
impl HookFilter {
fn matches(&self, kind: &crate::events::EventKind) -> bool {
if let Some(want) = &self.client {
if kind.client_name() != Some(want.as_str()) {
return false;
}
}
if let Some(want) = &self.fingerprint {
match kind.fingerprint() {
Some(fp) if fp.eq_ignore_ascii_case(want) => {}
_ => return false,
}
}
if let Some(want) = self.plane {
if kind.plane() != Some(want) {
return false;
}
}
if let Some(want) = &self.app {
if kind.app() != Some(want.as_str()) {
return false;
}
}
true
}
}
impl HooksConfig {
/// Validate for the mgmt PUT: structural errors are rejected (the config would silently do
/// nothing or something surprising); unknown kinds are accepted (additive event catalog).
pub fn validate(&self) -> Result<(), String> {
for (i, h) in self.hooks.iter().enumerate() {
let at = |msg: &str| format!("hooks[{i}]: {msg}");
if h.on.trim().is_empty() {
return Err(at("`on` must be an event kind or `domain.*` pattern"));
}
if h.run.as_deref().is_none_or(|r| r.trim().is_empty())
&& h.webhook.as_deref().is_none_or(|w| w.trim().is_empty())
{
return Err(at("needs `run` and/or `webhook`"));
}
if let Some(url) = h.webhook.as_deref().filter(|w| !w.trim().is_empty()) {
if !url.starts_with("https://") && !url.starts_with("http://") {
return Err(at("`webhook` must be an http(s):// URL"));
}
if webhook_host_is_internal(url) {
return Err(at(
"`webhook` must not target a loopback/link-local/metadata host",
));
}
// A signed webhook over plaintext http:// sends the HMAC'd event body in the clear.
// Warn rather than reject (an internal-only `http://` receiver may be intentional).
if h.hmac_secret_file.is_some() && url.starts_with("http://") {
tracing::warn!(
%url,
"webhook has an hmac_secret_file but is http:// — the signed body is sent in cleartext; prefer https://"
);
}
}
if h.timeout_s == 0 || h.timeout_s > MAX_TIMEOUT_S {
return Err(at(&format!("`timeout_s` must be 1{MAX_TIMEOUT_S}")));
}
}
Ok(())
}
}
// ------------------------------------------------------------------------- store
/// The persisted hooks store — the [`crate::vdisplay::policy::DisplayPolicyStore`] recipe:
/// private dir, temp-write + atomic rename, in-memory value changes only if the write succeeds.
///
/// A hand-edited `hooks.json` is honored WITHOUT a restart (the documented contract): [`get`]
/// re-stats the file and reloads when its identity (mtime + length) moved. The stat rides the
/// per-event dispatch, so the check costs one `metadata()` call per event, and a full re-read
/// happens only when the file actually changed.
///
/// [`get`]: HooksStore::get
pub struct HooksStore {
path: PathBuf,
cur: Mutex<StoreState>,
}
struct StoreState {
cfg: Option<HooksConfig>,
/// Identity of the file revision `cfg` was parsed from (mtime + length); `None` = the file
/// did not exist. `get` compares against a fresh stat to detect hand edits.
file_id: Option<(std::time::SystemTime, u64)>,
}
impl HooksStore {
/// Load from `path`. Missing file ⇒ no hooks; corrupt file ⇒ no hooks with a warning
/// (never fail host startup over a settings file).
pub fn load_from(path: PathBuf) -> Self {
let (cfg, file_id) = Self::read_disk(&path);
HooksStore {
path,
cur: Mutex::new(StoreState { cfg, file_id }),
}
}
/// The file's on-disk identity, `None` when it does not exist (or cannot be stat'd —
/// indistinguishable on purpose: both mean "no usable hooks file").
fn file_identity(path: &PathBuf) -> Option<(std::time::SystemTime, u64)> {
let meta = std::fs::metadata(path).ok()?;
Some((meta.modified().ok()?, meta.len()))
}
/// Read + validate the file. Same lenient contract as startup: missing ⇒ no hooks;
/// invalid/unreadable ⇒ no hooks with a warning naming the problem.
fn read_disk(path: &PathBuf) -> (Option<HooksConfig>, Option<(std::time::SystemTime, u64)>) {
let file_id = Self::file_identity(path);
let cfg = match std::fs::read(path) {
Ok(bytes) => match serde_json::from_slice::<HooksConfig>(&bytes) {
Ok(c) => {
if let Err(e) = c.validate() {
tracing::warn!(path = %path.display(),
"hooks.json invalid — hooks disabled until fixed: {e}");
None
} else {
Some(c)
}
}
Err(e) => {
tracing::warn!(path = %path.display(),
"hooks.json unreadable — hooks disabled until fixed: {e}");
None
}
},
Err(_) => None,
};
(cfg, file_id)
}
/// The stored configuration (empty when unconfigured) — the mgmt GET and the dispatcher.
/// Re-reads `hooks.json` first if it changed on disk since last load, so hand edits apply
/// on the next event, no restart ("changes apply immediately" — docs/automation.md).
pub fn get(&self) -> HooksConfig {
let mut st = self.cur.lock().unwrap();
let now_id = Self::file_identity(&self.path);
if now_id != st.file_id {
let (cfg, file_id) = Self::read_disk(&self.path);
tracing::info!(path = %self.path.display(), hooks = cfg.as_ref().map_or(0, |c| c.hooks.len()),
"hooks.json changed on disk — reloaded");
st.cfg = cfg;
st.file_id = file_id;
}
st.cfg.clone().unwrap_or_default()
}
/// Persist + adopt a new configuration (caller validates first). The in-memory value
/// changes only if the disk write succeeds.
pub fn set(&self, cfg: HooksConfig) -> Result<()> {
if let Some(dir) = self.path.parent() {
pf_paths::create_private_dir(dir)?;
}
let tmp = self.path.with_extension("json.tmp");
pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&cfg)?)?;
std::fs::rename(&tmp, &self.path)?;
let mut st = self.cur.lock().unwrap();
st.file_id = Self::file_identity(&self.path);
st.cfg = Some(cfg);
Ok(())
}
}
/// The process-wide hooks store (`<config_dir>/hooks.json`), loaded on first access and
/// re-loaded whenever the file changes on disk (see [`HooksStore::get`]).
pub fn store() -> &'static HooksStore {
static STORE: OnceLock<HooksStore> = OnceLock::new();
STORE.get_or_init(|| HooksStore::load_from(pf_paths::config_dir().join("hooks.json")))
}
// ------------------------------------------------------------------------- runner
/// The hook runner: a host-lifetime task consuming the live event tail and dispatching
/// matching hooks. Spawned by `serve()` before `host.started` is emitted, so hooks can
/// observe the full host lifetime. Lag (more events than the runner drained) skips the
/// missed events with a warning — fire-and-forget, never a queue that grows unboundedly.
pub async fn runner() {
let mut rx = crate::events::bus().subscribe_live();
let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_HOOKS));
let mut debounce: HashMap<u64, Instant> = HashMap::new();
loop {
match rx.recv().await {
Ok(ev) => dispatch(&ev, &sem, &mut debounce),
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(
missed = n,
"hook runner lagged — skipped events fire no hooks"
);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
}
}
}
/// Stable identity for a hook entry across config reloads (the debounce key): the hash of its
/// serialized form — an unchanged entry keeps its debounce window across a PUT.
fn entry_key(h: &HookEntry) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
serde_json::to_string(h)
.unwrap_or_default()
.hash(&mut hasher);
hasher.finish()
}
fn dispatch(
ev: &crate::events::HostEvent,
sem: &std::sync::Arc<tokio::sync::Semaphore>,
debounce: &mut HashMap<u64, Instant>,
) {
let kind = ev.kind.name();
let cfg = store().get();
for h in &cfg.hooks {
if !crate::events::kind_matches(&h.on, kind) {
continue;
}
if !h
.filter
.as_ref()
.unwrap_or(&HookFilter::default())
.matches(&ev.kind)
{
continue;
}
if h.debounce_ms > 0 {
let key = entry_key(h);
let now = Instant::now();
if debounce
.get(&key)
.is_some_and(|t| now.duration_since(*t) < Duration::from_millis(h.debounce_ms))
{
tracing::debug!(on = %h.on, kind, "hook debounced");
continue;
}
debounce.insert(key, now);
}
if let Some(cmd) = h.run.as_deref().filter(|c| !c.trim().is_empty()) {
fire_exec(cmd.to_string(), ev, h.timeout_s, sem);
}
if let Some(url) = h.webhook.as_deref().filter(|u| !u.trim().is_empty()) {
fire_webhook(url.to_string(), h.hmac_secret_file.clone(), ev, sem);
}
}
// The two env-var mirrors (`PUNKTFUNK_ON_CONNECT_CMD` / `PUNKTFUNK_ON_DISCONNECT_CMD`) —
// the zero-config siblings of `PUNKTFUNK_RECOVER_SESSION_CMD` for the simplest cases.
let mirror = match kind {
"client.connected" => pf_host_config::config().on_connect_cmd.clone(),
"client.disconnected" => pf_host_config::config().on_disconnect_cmd.clone(),
_ => None,
};
if let Some(cmd) = mirror {
fire_exec(cmd, ev, DEFAULT_TIMEOUT_S, sem);
}
}
// ------------------------------------------------------------------------- exec action
fn fire_exec(
cmd: String,
ev: &crate::events::HostEvent,
timeout_s: u32,
sem: &std::sync::Arc<tokio::sync::Semaphore>,
) {
let Ok(permit) = sem.clone().try_acquire_owned() else {
tracing::warn!(cmd = %cmd, "hook dropped — too many hook executions in flight");
return;
};
if let Err(e) = exec_path_check(&cmd) {
tracing::error!(cmd = %cmd, "REFUSING hook command — {e}");
return;
}
let json = serde_json::to_string(ev).unwrap_or_else(|_| "{}".to_string());
let env = flatten_env(ev);
let kind = ev.kind.name();
let timeout = Duration::from_secs(u64::from(timeout_s));
tracing::info!(cmd = %cmd, kind, "hook: running command");
// Detached execution + off-thread reap (the `try_recover_session` recipe): the streaming
// planes never wait on operator code. The permit rides along and frees on thread exit.
std::thread::spawn(move || {
run_hook_process(&cmd, &json, &env, timeout);
drop(permit);
});
}
/// The event flattened to `PF_EVENT_*` env vars: scalar leaves of the event JSON, path-joined
/// with `_` and uppercased (`client.name` → `PF_EVENT_CLIENT_NAME`), plus `PF_EVENT_JSON` with
/// the whole document. Values are control-char-stripped so a hostile device name can't smuggle
/// newlines into a naive shell consumer.
fn flatten_env(ev: &crate::events::HostEvent) -> Vec<(String, String)> {
fn walk(prefix: &str, v: &serde_json::Value, out: &mut Vec<(String, String)>) {
match v {
serde_json::Value::Object(map) => {
for (k, val) in map {
let key = k
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_uppercase()
} else {
'_'
}
})
.collect::<String>();
walk(&format!("{prefix}_{key}"), val, out);
}
}
serde_json::Value::Null => {}
serde_json::Value::String(s) => {
let clean: String = s.chars().filter(|c| !c.is_control()).collect();
out.push((prefix.to_string(), clean));
}
other => out.push((prefix.to_string(), other.to_string())),
}
}
let mut out = Vec::new();
if let Ok(v) = serde_json::to_value(ev) {
walk("PF_EVENT", &v, &mut out);
}
if let Ok(json) = serde_json::to_string(ev) {
out.push(("PF_EVENT_JSON".to_string(), json));
}
out
}
/// The sshd/sudoers rule (RFC §9.1): when the command's first token is a path to an existing
/// file, refuse to run it unless it is owned by the host user (or root) and not
/// group/world-writable — a world-writable hook script is privilege escalation bait. A bare
/// command name (`systemctl`, `curl`) is left to PATH.
#[cfg(unix)]
fn exec_path_check(cmd: &str) -> Result<(), String> {
use std::os::unix::fs::MetadataExt;
let Some(first) = cmd.split_whitespace().next() else {
return Err("empty command".into());
};
if !first.starts_with('/') {
return Ok(());
}
let meta = match std::fs::metadata(first) {
Ok(m) => m,
Err(_) => return Ok(()), // not an existing file — the shell will report it
};
if !meta.is_file() {
return Ok(());
}
// SAFETY: geteuid has no preconditions and touches no memory.
let euid = unsafe { libc::geteuid() };
if meta.uid() != euid && meta.uid() != 0 {
return Err(format!(
"{first} is owned by uid {} (host runs as uid {euid}) — hook scripts must be \
owned by the operator or root",
meta.uid()
));
}
if meta.mode() & 0o022 != 0 {
return Err(format!(
"{first} is group/world-writable (mode {:o}) — chmod go-w it first",
meta.mode() & 0o7777
));
}
Ok(())
}
#[cfg(not(unix))]
fn exec_path_check(_cmd: &str) -> Result<(), String> {
// Windows: hooks.json lives in the SYSTEM/Admins-DACL'd config dir and the command runs in
// the interactive user session (never SYSTEM) — the config itself is the trust boundary.
// A per-script ACL check is a hardening follow-up.
Ok(())
}
/// Run one hook command to completion (or timeout), blocking the reaper thread it runs on.
/// Returns whether the command ran to completion successfully (exit 0) — the prep machinery
/// gates each step's `undo` on it.
#[cfg(unix)]
fn run_hook_process(
cmd: &str,
event_json: &str,
env: &[(String, String)],
timeout: Duration,
) -> bool {
use std::io::Write;
use std::os::unix::process::CommandExt;
let mut c = std::process::Command::new("/bin/sh");
c.arg("-c")
.arg(cmd)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
// Its own process group, so the timeout can kill the whole tree the shell spawned.
.process_group(0);
c.envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())));
let mut child = match c.spawn() {
Ok(ch) => ch,
Err(e) => {
tracing::error!(cmd = %cmd, error = %e, "hook command failed to launch");
return false;
}
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(event_json.as_bytes());
// stdin drops (closes) here — a hook that never reads it is unaffected.
}
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => {
if !status.success() {
tracing::warn!(cmd = %cmd, %status, "hook command exited non-zero");
}
return status.success();
}
Ok(None) => {
if Instant::now() >= deadline {
tracing::warn!(cmd = %cmd, timeout_s = timeout.as_secs(),
"hook command timed out — killing its process group");
#[cfg(target_os = "linux")]
{
// SAFETY: kill(2) with a negative pid signals the process group we
// created via process_group(0); no memory is touched.
unsafe { libc::kill(-(child.id() as i32), libc::SIGKILL) };
}
#[cfg(not(target_os = "linux"))]
let _ = child.kill();
let _ = child.wait(); // reap — never leave a zombie
return false;
}
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => {
tracing::warn!(cmd = %cmd, error = %e, "hook command wait failed");
return false;
}
}
}
}
/// Windows: on a SYSTEM host the command must run in the interactive user session
/// ([`crate::interactive::spawn_in_active_session`], never SYSTEM) — that path can't carry
/// per-process env or stdin, so the event JSON is written to a private temp file whose path is
/// appended as the command's last argument. A console-mode host (dev) falls back to a plain
/// spawn with the full Unix-style context (env + stdin).
#[cfg(windows)]
fn run_hook_process(
cmd: &str,
event_json: &str,
env: &[(String, String)],
timeout: Duration,
) -> bool {
use std::io::Write;
let stamp = format!(
"pf-hook-{}-{}.json",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let json_path = std::env::temp_dir().join(stamp);
if std::fs::write(&json_path, event_json).is_err() {
tracing::warn!(cmd = %cmd, "hook: could not write event JSON temp file");
}
let cmdline = format!("{cmd} \"{}\"", json_path.display());
match crate::interactive::spawn_in_active_session(&cmdline, None) {
Ok(pid) => {
tracing::debug!(cmd = %cmd, pid, "hook command launched in the interactive session");
// No child handle on this path — wait out the timeout, then clean the temp file.
std::thread::sleep(timeout);
let _ = std::fs::remove_file(&json_path);
// Detached in the user session: completion/exit status is unobservable here —
// report "ran" (prep `undo`s stay armed).
true
}
Err(e) => {
tracing::debug!(error = %format!("{e:#}"),
"interactive-session spawn unavailable — running hook in-console");
let mut ok = false;
let mut c = std::process::Command::new("cmd.exe");
c.arg("/C")
.arg(cmd)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
c.envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())));
match c.spawn() {
Ok(mut child) => {
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(event_json.as_bytes());
}
let deadline = Instant::now() + timeout;
loop {
match child.try_wait().ok().flatten() {
Some(status) => {
ok = status.success();
break;
}
None if Instant::now() >= deadline => {
tracing::warn!(cmd = %cmd, "hook command timed out — killing it");
let _ = child.kill();
let _ = child.wait();
break;
}
None => std::thread::sleep(Duration::from_millis(100)),
}
}
}
Err(e) => tracing::error!(cmd = %cmd, error = %e, "hook command failed to launch"),
}
let _ = std::fs::remove_file(&json_path);
ok
}
}
}
// ------------------------------------------------------------------------- webhook action
fn fire_webhook(
url: String,
secret_file: Option<PathBuf>,
ev: &crate::events::HostEvent,
sem: &std::sync::Arc<tokio::sync::Semaphore>,
) {
let Ok(permit) = sem.clone().try_acquire_owned() else {
tracing::warn!(url = %url, "webhook dropped — too many hook executions in flight");
return;
};
let json = serde_json::to_string(ev).unwrap_or_else(|_| "{}".to_string());
let kind = ev.kind.name();
tracing::info!(url = %url, kind, "hook: posting webhook");
std::thread::spawn(move || {
post_webhook(&url, &json, secret_file.as_deref());
drop(permit);
});
}
/// True if `url`'s host is a clearly-illegitimate webhook target — loopback, link-local (which
/// includes the `169.254.169.254` cloud-metadata endpoint), the unspecified address, or `localhost`
/// — so a tampered/misguided hooks.json can't make the privileged host POST event data to its own
/// services or a metadata endpoint (direct-SSRF guard; security-review 2026-07-17). Deliberately does
/// NOT block RFC-1918 / ULA / `.local` — a webhook to another box on the operator's own LAN is a
/// legitimate self-hosting config. A best-effort textual + IP-literal check (no DNS resolution, so
/// not a full anti-rebinding defense; the operator-gated config already limits the threat).
fn webhook_host_is_internal(url: &str) -> bool {
// scheme://[userinfo@]host[:port]/... → the bare host.
let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
let authority = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
let hostport = authority
.rsplit_once('@')
.map(|(_, h)| h)
.unwrap_or(authority);
let host = if let Some(rest) = hostport.strip_prefix('[') {
rest.split(']').next().unwrap_or("") // [::1]:443 → ::1
} else {
hostport
.rsplit_once(':')
.map(|(h, _)| h)
.unwrap_or(hostport)
};
let host = host.trim().to_ascii_lowercase();
if host.is_empty() || host == "localhost" || host.ends_with(".localhost") {
return true;
}
match host.parse::<std::net::IpAddr>() {
Ok(std::net::IpAddr::V4(v4)) => {
v4.is_loopback() || v4.is_link_local() || v4.is_unspecified()
}
Ok(std::net::IpAddr::V6(v6)) => {
// Loopback (::1), unspecified (::), or link-local fe80::/10.
v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xffc0) == 0xfe80
}
Err(_) => false, // a resolvable hostname — not statically classifiable here
}
}
fn post_webhook(url: &str, json: &str, secret_file: Option<&std::path::Path>) {
// TLS is verified (ureq's default rustls roots); redirects are never followed, so a
// compromised receiver can't bounce the POST cross-origin (RFC §9.5).
let agent = ureq::builder()
.redirects(0)
.timeout(WEBHOOK_TIMEOUT)
.build();
let mut req = agent.post(url).set("Content-Type", "application/json");
if let Some(path) = secret_file {
match std::fs::read(path) {
Ok(secret) => {
use hmac::{Hmac, Mac};
let mut mac = match Hmac::<sha2::Sha256>::new_from_slice(&secret) {
Ok(m) => m,
Err(_) => {
tracing::error!(path = %path.display(), "webhook HMAC secret unusable");
return;
}
};
mac.update(json.as_bytes());
let sig = hex::encode(mac.finalize().into_bytes());
req = req.set("X-Punktfunk-Signature", &format!("sha256={sig}"));
}
Err(e) => {
// A configured-but-unreadable secret means the operator WANTS signing —
// failing open (unsigned POST) would defeat the receiver's authentication.
tracing::error!(path = %path.display(), error = %e,
"webhook HMAC secret unreadable — NOT posting unsigned");
return;
}
}
}
match req.send_string(json) {
Ok(resp) => tracing::debug!(url, status = resp.status(), "webhook delivered"),
Err(ureq::Error::Status(code, _)) => {
tracing::warn!(url, status = code, "webhook rejected by receiver")
}
Err(e) => tracing::warn!(url, error = %e, "webhook delivery failed"),
}
}
// ------------------------------------------------------------------------- per-app prep/undo
/// One per-app preparation step (RFC §6 — deliberate Sunshine `prep-cmd` parity): `do` runs
/// **synchronously before the app launches** (an HDR toggle or a MangoHud env change must land
/// first), `undo` runs at session end — reverse order across steps, best-effort, on every exit
/// path including a crash-unwind (RAII via [`PrepGuard`]).
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq)]
pub struct PrepCmd {
/// Command run before launch. Same execution recipe and ownership checks as hook `run`
/// commands (event-less: stdin is empty JSON, env carries the `PF_APP_*` context).
#[serde(rename = "do")]
pub run: String,
/// Command run after the session ends. Skipped when its `do` failed (it never took effect).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub undo: Option<String>,
}
/// Holds the armed `undo` commands for one session's prep steps; dropping it (session end,
/// error return, panic-unwind) runs them in reverse order on a detached thread — teardown
/// never blocks on operator code.
#[must_use = "dropping the guard immediately runs the undo commands"]
pub struct PrepGuard {
undo: Vec<String>,
env: Vec<(String, String)>,
}
/// Run a title's prep steps **synchronously, in order** (the caller is a launch path — this is
/// the one deliberate exception to fire-and-forget, because prep exists to happen *before* the
/// game). Each step gets the default hook timeout and the same ownership gate as hook
/// commands; a failed/refused `do` logs and continues (best-effort), and its `undo` stays
/// disarmed. Returns the guard that runs the armed `undo`s at drop.
pub fn run_prep(cmds: &[PrepCmd], env: &[(String, String)]) -> PrepGuard {
let timeout = Duration::from_secs(u64::from(DEFAULT_TIMEOUT_S));
let mut undo = Vec::new();
for c in cmds {
let cmd = c.run.trim();
if cmd.is_empty() {
continue;
}
if let Err(e) = exec_path_check(cmd) {
tracing::error!(cmd = %cmd, "REFUSING prep command — {e}");
continue;
}
tracing::info!(cmd = %cmd, "prep: running");
if run_hook_process(cmd, "{}", env, timeout) {
if let Some(u) = c.undo.as_deref().filter(|u| !u.trim().is_empty()) {
undo.push(u.to_string());
}
} else if c.undo.is_some() {
tracing::warn!(cmd = %cmd, "prep step failed — its undo is skipped");
}
}
PrepGuard {
undo,
env: env.to_vec(),
}
}
impl Drop for PrepGuard {
fn drop(&mut self) {
if self.undo.is_empty() {
return;
}
let undo = std::mem::take(&mut self.undo);
let env = std::mem::take(&mut self.env);
let timeout = Duration::from_secs(u64::from(DEFAULT_TIMEOUT_S));
// Detached: the drop site may be an async task or a panic-unwind — session teardown
// must not block on operator commands. Order (reverse of `do`) is preserved because
// the one thread runs them sequentially.
std::thread::spawn(move || {
for cmd in undo.iter().rev() {
if let Err(e) = exec_path_check(cmd) {
tracing::error!(cmd = %cmd, "REFUSING prep undo command — {e}");
continue;
}
tracing::info!(cmd = %cmd, "prep: running undo");
run_hook_process(cmd, "{}", &env, timeout);
}
});
}
}
// ------------------------------------------------------------------------- tests
#[cfg(test)]
mod tests {
use super::*;
use crate::events::{ClientRef, EventKind, HostEvent, Plane, StreamRef};
fn sample_event() -> HostEvent {
HostEvent {
seq: 7,
ts_ms: 1_700_000_000_000,
schema: 1,
kind: EventKind::StreamStarted {
stream: StreamRef {
mode: "2560x1440@120".into(),
hdr: true,
client: "Living Room TV".into(),
app: Some("steam:570".into()),
plane: Plane::Native,
},
},
}
}
#[test]
fn validation_rejects_structural_errors() {
let ok = HooksConfig {
hooks: vec![HookEntry {
on: "stream.*".into(),
filter: None,
run: Some("echo hi".into()),
webhook: None,
timeout_s: 30,
debounce_ms: 0,
hmac_secret_file: None,
}],
};
assert!(ok.validate().is_ok());
let mut bad = ok.clone();
bad.hooks[0].on = " ".into();
assert!(bad.validate().is_err(), "empty `on`");
let mut bad = ok.clone();
bad.hooks[0].run = None;
assert!(bad.validate().is_err(), "no action");
let mut bad = ok.clone();
bad.hooks[0].webhook = Some("ftp://nope".into());
assert!(bad.validate().is_err(), "non-http webhook");
let mut bad = ok.clone();
bad.hooks[0].timeout_s = 0;
assert!(bad.validate().is_err(), "zero timeout");
bad.hooks[0].timeout_s = 601;
assert!(bad.validate().is_err(), "over-ceiling timeout");
}
#[test]
fn store_roundtrips_and_survives_corruption() {
let path = std::env::temp_dir().join(format!(
"pf-hooks-test-{}-{:p}.json",
std::process::id(),
&0u8 as *const u8
));
let _ = std::fs::remove_file(&path);
let store = HooksStore::load_from(path.clone());
assert!(store.get().hooks.is_empty(), "unconfigured = no hooks");
let cfg = HooksConfig {
hooks: vec![HookEntry {
on: "pairing.pending".into(),
filter: Some(HookFilter {
plane: Some(Plane::Native),
..Default::default()
}),
run: None,
webhook: Some("https://ha.local/api/webhook/punktfunk".into()),
timeout_s: 30,
debounce_ms: 500,
hmac_secret_file: None,
}],
};
store.set(cfg).unwrap();
assert_eq!(store.get().hooks.len(), 1);
// A fresh load sees the persisted value.
let reload = HooksStore::load_from(path.clone());
assert_eq!(reload.get().hooks.len(), 1);
assert_eq!(reload.get().hooks[0].on, "pairing.pending");
// Corruption never breaks startup — it just disables hooks loudly.
std::fs::write(&path, b"{ not json").unwrap();
let corrupt = HooksStore::load_from(path.clone());
assert!(corrupt.get().hooks.is_empty());
let _ = std::fs::remove_file(&path);
}
#[test]
fn hand_edited_file_reloads_without_restart() {
let path = std::env::temp_dir().join(format!(
"pf-hooks-reload-test-{}-{:p}.json",
std::process::id(),
&0u8 as *const u8
));
let _ = std::fs::remove_file(&path);
let store = HooksStore::load_from(path.clone());
assert!(store.get().hooks.is_empty());
// The documented flow: the operator writes hooks.json by hand and the SAME running
// store honors it on the next event — no restart, no PUT.
std::fs::write(
&path,
br#"{"hooks":[{"on":"stream.started","run":"true"}]}"#,
)
.unwrap();
assert_eq!(store.get().hooks.len(), 1, "hand edit applies on next read");
assert_eq!(store.get().hooks[0].on, "stream.started");
// A second edit applies too (length differs, so same-second mtime granularity can't
// mask it).
std::fs::write(
&path,
br#"{"hooks":[{"on":"stream.started","run":"true"},{"on":"client.*","run":"true"}]}"#,
)
.unwrap();
assert_eq!(store.get().hooks.len(), 2, "second hand edit applies too");
// Deleting the file removes the hooks.
std::fs::remove_file(&path).unwrap();
assert!(store.get().hooks.is_empty(), "deleted file = no hooks");
}
#[test]
fn filters_constrain_and_missing_fields_never_match() {
let ev = sample_event();
let f = HookFilter {
client: Some("Living Room TV".into()),
app: Some("steam:570".into()),
plane: Some(Plane::Native),
..Default::default()
};
assert!(f.matches(&ev.kind));
let f = HookFilter {
client: Some("Bedroom".into()),
..Default::default()
};
assert!(!f.matches(&ev.kind));
let f = HookFilter {
plane: Some(Plane::Gamestream),
..Default::default()
};
assert!(!f.matches(&ev.kind));
// stream.* events carry no fingerprint — a fingerprint filter can't match them.
let f = HookFilter {
fingerprint: Some("ab12".into()),
..Default::default()
};
assert!(!f.matches(&ev.kind));
// Fingerprint matching is case-insensitive where the field exists.
let connected = EventKind::ClientConnected {
client: ClientRef {
name: "Deck".into(),
fingerprint: Some("AB12CD".into()),
plane: Plane::Native,
},
};
let f = HookFilter {
fingerprint: Some("ab12cd".into()),
..Default::default()
};
assert!(f.matches(&connected));
}
#[test]
fn env_flattening_is_shell_safe_and_complete() {
let ev = sample_event();
let env = flatten_env(&ev);
let get = |k: &str| {
env.iter()
.find(|(key, _)| key == k)
.map(|(_, v)| v.as_str())
};
assert_eq!(get("PF_EVENT_KIND"), Some("stream.started"));
assert_eq!(get("PF_EVENT_SEQ"), Some("7"));
assert_eq!(get("PF_EVENT_STREAM_MODE"), Some("2560x1440@120"));
assert_eq!(get("PF_EVENT_STREAM_HDR"), Some("true"));
assert_eq!(get("PF_EVENT_STREAM_CLIENT"), Some("Living Room TV"));
assert_eq!(get("PF_EVENT_STREAM_APP"), Some("steam:570"));
assert_eq!(get("PF_EVENT_STREAM_PLANE"), Some("native"));
assert!(get("PF_EVENT_JSON").unwrap().contains("\"seq\":7"));
// A hostile client name can't smuggle control chars into env consumers.
let mut evil = sample_event();
if let EventKind::StreamStarted { stream } = &mut evil.kind {
stream.client = "evil\nname\r\t".into();
}
let env = flatten_env(&evil);
let v = env
.iter()
.find(|(k, _)| k == "PF_EVENT_STREAM_CLIENT")
.map(|(_, v)| v.clone())
.unwrap();
assert_eq!(v, "evilname");
}
#[cfg(unix)]
#[test]
fn exec_runs_with_stdin_and_env_and_timeout_kills() {
// A hook that proves stdin + env delivery by writing both to a file.
let out = std::env::temp_dir().join(format!(
"pf-hook-exec-{}-{:p}.txt",
std::process::id(),
&0u8 as *const u8
));
let _ = std::fs::remove_file(&out);
let ev = sample_event();
let env = flatten_env(&ev);
let json = serde_json::to_string(&ev).unwrap();
run_hook_process(
&format!(
"printf '%s|' \"$PF_EVENT_KIND\" > {p}; cat >> {p}",
p = out.display()
),
&json,
&env,
Duration::from_secs(5),
);
let text = std::fs::read_to_string(&out).expect("hook wrote its file");
assert!(text.starts_with("stream.started|"), "env delivered: {text}");
assert!(text.contains("\"seq\":7"), "stdin delivered: {text}");
let _ = std::fs::remove_file(&out);
// Timeout: a sleeping hook is killed (process group) well before its sleep ends.
let started = Instant::now();
run_hook_process("sleep 30", &json, &env, Duration::from_secs(1));
assert!(
started.elapsed() < Duration::from_secs(5),
"timeout must kill the hook, not wait it out"
);
}
/// Prep semantics end to end: `do`s run in order before the guard exists, armed `undo`s run
/// in REVERSE order at drop, and a failed `do` disarms its own `undo` only.
#[cfg(unix)]
#[test]
fn prep_runs_do_in_order_and_undo_in_reverse() {
let out = std::env::temp_dir().join(format!(
"pf-prep-test-{}-{:p}.txt",
std::process::id(),
&0u8 as *const u8
));
let _ = std::fs::remove_file(&out);
let step = |do_tag: &str, undo_tag: Option<&str>| PrepCmd {
run: format!("echo {do_tag} >> {}", out.display()),
undo: undo_tag.map(|t| format!("echo {t} >> {}", out.display())),
};
let cmds = vec![
step("do-a", Some("undo-a")),
step("do-b", Some("undo-b")),
// A failing `do` must not arm its undo.
PrepCmd {
run: "false".into(),
undo: Some(format!("echo undo-never >> {}", out.display())),
},
];
let guard = run_prep(&cmds, &[]);
let text = std::fs::read_to_string(&out).expect("prep steps ran synchronously");
assert_eq!(text, "do-a\ndo-b\n", "dos run in order, before launch");
drop(guard);
// The undo thread is detached — poll for its completion.
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let text = std::fs::read_to_string(&out).unwrap_or_default();
if text.lines().count() >= 4 {
assert_eq!(
text, "do-a\ndo-b\nundo-b\nundo-a\n",
"undos run in reverse; the failed step's undo is skipped"
);
break;
}
assert!(Instant::now() < deadline, "undo thread never ran: {text}");
std::thread::sleep(Duration::from_millis(50));
}
// Give the skipped-undo a beat to (wrongly) appear, then assert it didn't.
std::thread::sleep(Duration::from_millis(200));
assert!(!std::fs::read_to_string(&out)
.unwrap()
.contains("undo-never"));
let _ = std::fs::remove_file(&out);
}
#[test]
fn prep_cmd_wire_shape() {
// The RFC's `{ "do": …, "undo": … }` spelling is the wire contract.
let c: PrepCmd = serde_json::from_str(r#"{"do":"a","undo":"b"}"#).unwrap();
assert_eq!(c.run, "a");
assert_eq!(c.undo.as_deref(), Some("b"));
let c: PrepCmd = serde_json::from_str(r#"{"do":"a"}"#).unwrap();
assert!(c.undo.is_none());
assert_eq!(serde_json::to_string(&c).unwrap(), r#"{"do":"a"}"#);
}
#[cfg(unix)]
#[test]
fn ownership_check_refuses_world_writable_scripts() {
use std::os::unix::fs::PermissionsExt;
let path = std::env::temp_dir().join(format!(
"pf-hook-own-{}-{:p}.sh",
std::process::id(),
&0u8 as *const u8
));
std::fs::write(&path, "#!/bin/sh\ntrue\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(exec_path_check(&format!("{} arg", path.display())).is_ok());
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o777)).unwrap();
assert!(
exec_path_check(&format!("{} arg", path.display())).is_err(),
"world-writable script must be refused"
);
let _ = std::fs::remove_file(&path);
// Bare command names are left to PATH; nonexistent paths are the shell's problem.
assert!(exec_path_check("systemctl suspend").is_ok());
assert!(exec_path_check("/nonexistent/definitely-not-here").is_ok());
}
}