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
+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": {
"get": {
"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": {
"allOf": [
{
@@ -4294,6 +4493,10 @@
{
"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)"
}
]
}