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
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:
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:#}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user