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
+1 -6
View File
@@ -72,12 +72,7 @@ impl KindFilter {
fn matches(&self, kind: &str) -> bool {
match &self.0 {
None => true,
Some(pats) => pats.iter().any(|p| match p.strip_suffix(".*") {
Some(prefix) => kind
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('.')),
None => p == kind,
}),
Some(pats) => pats.iter().any(|p| crate::events::kind_matches(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");
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);
}