feat(host/hooks): operator hooks — exec + webhooks on lifecycle events (M2a)
apple / swift (push) Successful in 1m28s
release / apple (push) Successful in 6m9s
apple / screenshots (push) Successful in 4m39s
audit / bun-audit (push) Successful in 15s
audit / cargo-audit (push) Successful in 2m11s
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m10s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
ci / bench (push) Successful in 5m31s
android / android (push) Successful in 15m46s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
arch / build-publish (push) Successful in 11m16s
windows-host / package (push) Successful in 9m38s
deb / build-publish (push) Successful in 12m28s
flatpak / build-publish (push) Failing after 8m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m4s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
ci / rust (push) Successful in 22m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
docker / deploy-docs (push) Successful in 31s

hooks.json (RFC §6): commands and webhooks fired on host lifecycle events,
managed over GET|PUT /api/v1/hooks (validated, applied immediately) and
dispatched fire-and-forget by a bus-subscriber runner — hooks observe,
never veto, and no operator code sits in any streaming path.

- exec: detached sh -c with the event JSON on stdin + flat PF_EVENT_* env
  (the PF_STREAM_* vocabulary's sibling), per-hook timeout (default 30 s)
  with process-group kill, off-thread reap, per-hook debounce, bounded
  concurrency (8 in flight, excess dropped loudly). Windows runs hooks in
  the interactive user session (temp-file JSON argument; console-mode dev
  hosts get env + stdin like Unix).
- webhook: POST the event JSON, TLS-verified, redirects never followed, no
  punktfunk credentials outbound; optional per-hook secret file yields
  X-Punktfunk-Signature: sha256=<hex HMAC> (fails closed if unreadable).
- filters: exact-match client/fingerprint/plane/app + the same kind
  patterns as the SSE ?kinds= filter (shared crate::events::kind_matches).
- hardening (RFC §9.1): hooks.json via the private-dir/secret-file
  helpers; a hook script path must be operator/root-owned and not
  group/world-writable or it is refused loudly (the sshd rule).
- env mirrors PUNKTFUNK_ON_CONNECT_CMD / PUNKTFUNK_ON_DISCONNECT_CMD for
  the zero-config cases, beside PUNKTFUNK_RECOVER_SESSION_CMD.

Live-verified on Linux: PUT config via API → library.changed fired a real
script (env + stdin observed) and an HMAC webhook (receiver-verified
signature); a chmod-777 script was refused. 342 host tests green
(store/validation/filter/env-flatten/exec-timeout/ownership + routes),
clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 21:52:05 +02:00
parent f7ca641d76
commit 46c0e0e483
12 changed files with 1255 additions and 8 deletions
Generated
+1
View File
@@ -3106,6 +3106,7 @@ dependencies = [
"ffmpeg-next", "ffmpeg-next",
"futures-util", "futures-util",
"hex", "hex",
"hmac",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-util", "hyper-util",
+203
View File
@@ -784,6 +784,98 @@
] ]
} }
}, },
"/api/v1/hooks": {
"get": {
"tags": [
"hooks"
],
"summary": "Get the hook configuration",
"description": "The operator's `hooks.json`: commands and webhooks fired on host lifecycle events. Empty\nwhen unconfigured.",
"operationId": "getHooks",
"responses": {
"200": {
"description": "The stored hook configuration",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HooksConfig"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
},
"put": {
"tags": [
"hooks"
],
"summary": "Replace the hook configuration",
"description": "Validates and persists a full `hooks.json` document (this is a whole-document PUT, not a\npatch). Applies from the next event — no restart. Hook commands run as the host user\n(interactive user session on Windows): treat this configuration as operator-privileged.",
"operationId": "setHooks",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HooksConfig"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Configuration stored; the new state",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HooksConfig"
}
}
}
},
"400": {
"description": "Structurally invalid configuration",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Configuration could not be persisted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/host": { "/api/v1/host": {
"get": { "get": {
"tags": [ "tags": [
@@ -3205,6 +3297,113 @@
} }
} }
}, },
"HookEntry": {
"type": "object",
"description": "One hook: fire `run` and/or `webhook` when an event matching `on` (+ `filter`) occurs.",
"required": [
"on"
],
"properties": {
"debounce_ms": {
"type": "integer",
"format": "int64",
"description": "Minimum interval between firings of this hook, in milliseconds. 0 = fire every time.",
"minimum": 0
},
"filter": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/HookFilter",
"description": "Exact-match constraints on the event's fields; every present field must match."
}
]
},
"hmac_secret_file": {
"type": [
"string",
"null"
],
"description": "File holding the webhook HMAC secret (`X-Punktfunk-Signature: sha256=<hex>`). The file\nshould be operator-owned and private; a world-readable secret is warned about."
},
"on": {
"type": "string",
"description": "Which events fire this hook: an exact kind (`stream.started`) or a `domain.*` prefix\n(`pairing.*`) — the same vocabulary as the SSE `?kinds=` filter."
},
"run": {
"type": [
"string",
"null"
],
"description": "Shell command to execute (detached, event JSON on stdin + `PF_EVENT_*` env)."
},
"timeout_s": {
"type": "integer",
"format": "int32",
"description": "Exec timeout in seconds (1600, default 30); the process group is killed on expiry.",
"minimum": 0
},
"webhook": {
"type": [
"string",
"null"
],
"description": "URL to POST the event JSON to."
}
}
},
"HookFilter": {
"type": "object",
"description": "Exact-match filters against an event's identity fields (RFC open-question 3: exact match\nonly — anything richer is what the SDK is for). Absent fields don't constrain; a filter\nfield set on an event kind that doesn't carry it (e.g. `client` on `host.started`) never\nmatches.",
"properties": {
"app": {
"type": [
"string",
"null"
],
"description": "Launched app id/title (`stream.*` events)."
},
"client": {
"type": [
"string",
"null"
],
"description": "Client/device name (for `session.*`: the short client label the Dashboard shows)."
},
"fingerprint": {
"type": [
"string",
"null"
],
"description": "Certificate fingerprint (hex, case-insensitive)."
},
"plane": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/Plane",
"description": "Protocol plane (`native` / `gamestream`)."
}
]
}
}
},
"HooksConfig": {
"type": "object",
"description": "The operator's hook configuration — the `hooks.json` document and the `/api/v1/hooks` body.",
"properties": {
"hooks": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HookEntry"
}
}
}
},
"HostEvent": { "HostEvent": {
"allOf": [ "allOf": [
{ {
@@ -4294,6 +4493,10 @@
{ {
"name": "events", "name": "events",
"description": "Host lifecycle events: an SSE stream (client/session/stream lifecycle, pairing, displays, library, host) with Last-Event-ID resume and server-side kind filters" "description": "Host lifecycle events: an SSE stream (client/session/stream lifecycle, pairing, displays, library, host) with Last-Event-ID resume and server-side kind filters"
},
{
"name": "hooks",
"description": "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"
} }
] ]
} }
+3
View File
@@ -59,6 +59,9 @@ tower = { version = "0.5", features = ["util"] }
# Stream combinators for the mgmt API's SSE event feed (`GET /api/v1/events`) — already in the # Stream combinators for the mgmt API's SSE event feed (`GET /api/v1/events`) — already in the
# tree transitively (axum/hyper) and as a Linux target dep; control plane only. # tree transitively (axum/hyper) and as a Linux target dep; control plane only.
futures-util = "0.3" futures-util = "0.3"
# Webhook signing (X-Punktfunk-Signature: sha256=<hex HMAC>) for operator hooks; pairs with
# the existing sha2. Already in the lockfile transitively.
hmac = "0.12"
rusty_enet = "0.4" rusty_enet = "0.4"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
+10
View File
@@ -87,6 +87,14 @@ pub struct HostConfig {
/// `systemctl restart display-manager` under a polkit rule — with auto-login enabled the restart brings /// `systemctl restart display-manager` under a polkit rule — with auto-login enabled the restart brings
/// the desktop back and the client's retry lands in it. Unset/empty = disabled (the default). /// the desktop back and the client's retry lands in it. Unset/empty = disabled (the default).
pub recover_session_cmd: Option<String>, pub recover_session_cmd: Option<String>,
/// `PUNKTFUNK_ON_CONNECT_CMD` — zero-config mirror of a `client.connected` hook
/// (`crate::hooks`): fired detached with the event JSON on stdin + `PF_EVENT_*` env when a
/// client connects, on either plane. The full hook surface (filters, webhooks, debounce)
/// lives in `hooks.json`. Unset/empty = disabled (the default).
pub on_connect_cmd: Option<String>,
/// `PUNKTFUNK_ON_DISCONNECT_CMD` — the `client.disconnected` sibling of
/// [`Self::on_connect_cmd`].
pub on_disconnect_cmd: Option<String>,
} }
impl HostConfig { impl HostConfig {
@@ -146,6 +154,8 @@ impl HostConfig {
}), }),
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD") recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
.filter(|s| !s.trim().is_empty()), .filter(|s| !s.trim().is_empty()),
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
on_disconnect_cmd: val("PUNKTFUNK_ON_DISCONNECT_CMD").filter(|s| !s.trim().is_empty()),
} }
} }
} }
+78 -1
View File
@@ -196,6 +196,77 @@ impl EventKind {
} }
} }
impl EventKind {
/// The client/device name this event carries, if any — the `filter.client` axis of hooks
/// and scripts. (For `session.*` this is the short client *label* the Dashboard shows —
/// cert-fingerprint prefix or peer IP — since that is what the event carries.)
pub fn client_name(&self) -> Option<&str> {
match self {
EventKind::ClientConnected { client }
| EventKind::ClientDisconnected { client, .. } => Some(&client.name),
EventKind::SessionStarted { session } | EventKind::SessionEnded { session } => {
Some(&session.client)
}
EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => {
Some(&stream.client)
}
EventKind::PairingPending { device }
| EventKind::PairingCompleted { device }
| EventKind::PairingDenied { device } => Some(&device.name),
_ => None,
}
}
/// The certificate fingerprint this event carries, if any.
pub fn fingerprint(&self) -> Option<&str> {
match self {
EventKind::ClientConnected { client }
| EventKind::ClientDisconnected { client, .. } => client.fingerprint.as_deref(),
EventKind::PairingPending { device }
| EventKind::PairingCompleted { device }
| EventKind::PairingDenied { device } => Some(&device.fingerprint),
_ => None,
}
}
/// The protocol plane this event carries, if any.
pub fn plane(&self) -> Option<Plane> {
match self {
EventKind::ClientConnected { client }
| EventKind::ClientDisconnected { client, .. } => Some(client.plane),
EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => {
Some(stream.plane)
}
EventKind::PairingPending { device }
| EventKind::PairingCompleted { device }
| EventKind::PairingDenied { device } => Some(device.plane),
_ => None,
}
}
/// The launched app id/title this event carries, if any.
pub fn app(&self) -> Option<&str> {
match self {
EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => {
stream.app.as_deref()
}
_ => None,
}
}
}
/// Does `pattern` select `kind`? Exact kind names (`stream.started`) or `domain.*` prefixes
/// matched on the dot boundary (`stream.*` matches `stream.started`, never `streamx.started`).
/// One vocabulary for the SSE `?kinds=` filter and the hooks `on:` field.
pub fn kind_matches(pattern: &str, kind: &str) -> bool {
match pattern.strip_suffix(".*") {
Some(prefix) => kind
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('.')),
None => pattern == kind,
}
}
/// Formats a mode as the wire's `WxH@Hz` string. /// Formats a mode as the wire's `WxH@Hz` string.
pub fn mode_str(width: u32, height: u32, hz: u32) -> String { pub fn mode_str(width: u32, height: u32, hz: u32) -> String {
format!("{width}x{height}@{hz}") format!("{width}x{height}@{hz}")
@@ -262,13 +333,19 @@ impl EventBus {
let _ = self.tx.send(ev); let _ = self.tx.send(ev);
} }
/// A live-tail-only subscription (no catch-up, no cursor) — for host-internal consumers
/// like the hook runner that only care about events from now on.
pub fn subscribe_live(&self) -> broadcast::Receiver<HostEvent> {
self.tx.subscribe()
}
/// Subscribe with a resume cursor: events with `seq > since` come back as catch-up, the /// Subscribe with a resume cursor: events with `seq > since` come back as catch-up, the
/// returned receiver carries everything after. `since = 0` means "from the ring start". /// returned receiver carries everything after. `since = 0` means "from the ring start".
pub fn subscribe(&self, since: u64) -> Subscription { pub fn subscribe(&self, since: u64) -> Subscription {
let ring = self.inner.lock().unwrap_or_else(|e| e.into_inner()); let ring = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let rx = self.tx.subscribe(); let rx = self.tx.subscribe();
let first_seq = ring.events.front().map_or(ring.next_seq, |e| e.seq); let first_seq = ring.events.front().map_or(ring.next_seq, |e| e.seq);
let dropped = since != 0 && since + 1 < first_seq; let dropped = since != 0 && since.saturating_add(1) < first_seq;
let catch_up = ring let catch_up = ring
.events .events
.iter() .iter()
@@ -242,6 +242,9 @@ pub fn serve(
// rustls needs a process-wide crypto provider before any TLS config is built. // rustls needs a process-wide crypto provider before any TLS config is built.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let native_opts = crate::native::native_serve_opts(&native); let native_opts = crate::native::native_serve_opts(&native);
// The hook runner consumes the live event tail for the host's lifetime — spawned BEFORE
// `host.started` is emitted so operator hooks observe the full lifecycle (RFC §6).
tokio::spawn(crate::hooks::runner());
// Lifecycle events (RFC §4): `host.started` as the serve planes come up; `host.stopping` // Lifecycle events (RFC §4): `host.started` as the serve planes come up; `host.stopping`
// when they wind down (clean end OR error exit) — the ring holds it for a consumer that // when they wind down (clean end OR error exit) — the ring holds it for a consumer that
// reconnects, and a graceful-signal path can move the emit earlier when one exists. // reconnects, and a graceful-signal path can move the emit earlier when one exists.
+843
View File
@@ -0,0 +1,843 @@
//! 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 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.
pub struct HooksStore {
path: PathBuf,
cur: Mutex<Option<HooksConfig>>,
}
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 cur = 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,
};
HooksStore {
path,
cur: Mutex::new(cur),
}
}
/// The stored configuration (empty when unconfigured) — the mgmt GET and the dispatcher.
pub fn get(&self) -> HooksConfig {
self.cur.lock().unwrap().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() {
crate::gamestream::create_private_dir(dir)?;
}
let tmp = self.path.with_extension("json.tmp");
crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(&cfg)?)?;
std::fs::rename(&tmp, &self.path)?;
*self.cur.lock().unwrap() = Some(cfg);
Ok(())
}
}
/// The process-wide hooks store (`<config_dir>/hooks.json`), loaded once on first access.
pub fn store() -> &'static HooksStore {
static STORE: OnceLock<HooksStore> = OnceLock::new();
STORE.get_or_init(|| HooksStore::load_from(crate::gamestream::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" => crate::config::config().on_connect_cmd.clone(),
"client.disconnected" => crate::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.
#[cfg(unix)]
fn run_hook_process(cmd: &str, event_json: &str, env: &[(String, String)], timeout: Duration) {
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;
}
};
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;
}
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;
}
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => {
tracing::warn!(cmd = %cmd, error = %e, "hook command wait failed");
return;
}
}
}
}
/// 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) {
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);
}
Err(e) => {
tracing::debug!(error = %format!("{e:#}"),
"interactive-session spawn unavailable — running hook in-console");
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;
while child.try_wait().ok().flatten().is_none() {
if Instant::now() >= deadline {
tracing::warn!(cmd = %cmd, "hook command timed out — killing it");
let _ = child.kill();
let _ = child.wait();
break;
}
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);
}
}
}
// ------------------------------------------------------------------------- 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);
});
}
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"),
}
}
// ------------------------------------------------------------------------- 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 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"
);
}
#[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());
}
}
+1
View File
@@ -50,6 +50,7 @@ mod gpu;
#[path = "linux/gpuclocks.rs"] #[path = "linux/gpuclocks.rs"]
mod gpuclocks; mod gpuclocks;
mod hdr; mod hdr;
mod hooks;
mod inject; mod inject;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "windows/install.rs"] #[path = "windows/install.rs"]
+4 -1
View File
@@ -34,6 +34,7 @@ mod clients;
mod display; mod display;
mod events; mod events;
mod gpu; mod gpu;
mod hooks;
mod host; mod host;
mod library; mod library;
mod native; mod native;
@@ -217,7 +218,8 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
stats::stats_recording_delete stats::stats_recording_delete
)) ))
.routes(routes!(stats::logs_get)) .routes(routes!(stats::logs_get))
.routes(routes!(events::stream_events)), .routes(routes!(events::stream_events))
.routes(routes!(hooks::get_hooks, hooks::set_hooks)),
) )
.split_for_parts() .split_for_parts()
} }
@@ -254,6 +256,7 @@ pub fn openapi_json() -> String {
(name = "stats", description = "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"), (name = "stats", description = "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"),
(name = "logs", description = "Host log stream: the newest in-memory log entries, cursor-paged for live following"), (name = "logs", description = "Host log stream: the newest in-memory log entries, cursor-paged for live following"),
(name = "events", description = "Host lifecycle events: an SSE stream (client/session/stream lifecycle, pairing, displays, library, host) with Last-Event-ID resume and server-side kind filters"), (name = "events", description = "Host lifecycle events: an SSE stream (client/session/stream lifecycle, pairing, displays, library, host) with Last-Event-ID resume and server-side kind filters"),
(name = "hooks", description = "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"),
) )
)] )]
struct ApiDoc; struct ApiDoc;
+1 -6
View File
@@ -72,12 +72,7 @@ impl KindFilter {
fn matches(&self, kind: &str) -> bool { fn matches(&self, kind: &str) -> bool {
match &self.0 { match &self.0 {
None => true, None => true,
Some(pats) => pats.iter().any(|p| match p.strip_suffix(".*") { Some(pats) => pats.iter().any(|p| crate::events::kind_matches(p, kind)),
Some(prefix) => kind
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('.')),
None => p == kind,
}),
} }
} }
} }
+57
View File
@@ -0,0 +1,57 @@
//! Hooks management endpoints (scripting-and-hooks RFC §6): read and replace the operator's
//! `hooks.json` — validated on write, applied immediately (the runner reads the store per
//! event, so no restart is needed).
use super::shared::*;
/// Get the hook configuration
///
/// The operator's `hooks.json`: commands and webhooks fired on host lifecycle events. Empty
/// when unconfigured.
#[utoipa::path(
get,
path = "/hooks",
tag = "hooks",
operation_id = "getHooks",
responses(
(status = OK, description = "The stored hook configuration", body = crate::hooks::HooksConfig),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_hooks() -> Json<crate::hooks::HooksConfig> {
Json(crate::hooks::store().get())
}
/// Replace the hook configuration
///
/// Validates and persists a full `hooks.json` document (this is a whole-document PUT, not a
/// patch). Applies from the next event — no restart. Hook commands run as the host user
/// (interactive user session on Windows): treat this configuration as operator-privileged.
#[utoipa::path(
put,
path = "/hooks",
tag = "hooks",
operation_id = "setHooks",
request_body = crate::hooks::HooksConfig,
responses(
(status = OK, description = "Configuration stored; the new state", body = crate::hooks::HooksConfig),
(status = BAD_REQUEST, description = "Structurally invalid configuration", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Configuration could not be persisted", body = ApiError),
)
)]
pub(crate) async fn set_hooks(ApiJson(cfg): ApiJson<crate::hooks::HooksConfig>) -> Response {
if let Err(e) = cfg.validate() {
return api_error(StatusCode::BAD_REQUEST, &e);
}
match crate::hooks::store().set(cfg) {
Ok(()) => {
tracing::info!("management API: hook configuration updated");
Json(crate::hooks::store().get()).into_response()
}
Err(e) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("persist hooks.json: {e:#}"),
),
}
}
+51
View File
@@ -1155,3 +1155,54 @@ async fn events_stream_connection_cap() {
.expect("infallible"); .expect("infallible");
assert_eq!(resp.status(), StatusCode::OK, "cap frees with the slots"); assert_eq!(resp.status(), StatusCode::OK, "cap frees with the slots");
} }
// ------------------------------------------------------------------ hooks
/// GET returns the (empty-when-unconfigured) config; PUT validation rejects structural errors
/// with the reason. A *successful* PUT is deliberately not exercised through the route — it
/// would write the developer's real config dir; persistence is unit-tested in `crate::hooks`
/// against a temp path.
#[tokio::test]
async fn hooks_get_shape_and_put_validation() {
let app = test_app(test_state(), None);
let (s, json) = send(&app, get_req("/api/v1/hooks")).await;
assert_eq!(s, StatusCode::OK);
assert!(json["hooks"].is_array());
let put = |body: serde_json::Value| {
axum::http::Request::put("/api/v1/hooks")
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.unwrap()
};
// Structurally invalid: an entry with no action.
let (s, json) = send(
&app,
put(serde_json::json!({"hooks": [{"on": "stream.started"}]})),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
assert!(
json["error"].as_str().unwrap().contains("run"),
"error names the problem: {json}"
);
// Non-http(s) webhook.
let (s, _) = send(
&app,
put(serde_json::json!({"hooks": [{"on": "pairing.*", "webhook": "ftp://x"}]})),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// Wrong bearer → 401 (the hooks surface is admin-lane).
let mut req = get_req("/api/v1/hooks");
req.headers_mut().insert(
axum::http::header::AUTHORIZATION,
axum::http::HeaderValue::from_static("Bearer wrong"),
);
let resp = app.clone().oneshot(req).await.expect("infallible");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}