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:
@@ -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.
|
||||
pub fn mode_str(width: u32, height: u32, hz: u32) -> String {
|
||||
format!("{width}x{height}@{hz}")
|
||||
@@ -262,13 +333,19 @@ impl EventBus {
|
||||
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
|
||||
/// returned receiver carries everything after. `since = 0` means "from the ring start".
|
||||
pub fn subscribe(&self, since: u64) -> Subscription {
|
||||
let ring = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let rx = self.tx.subscribe();
|
||||
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
|
||||
.events
|
||||
.iter()
|
||||
|
||||
Reference in New Issue
Block a user