SIGN IN SIGN UP

feat(api): SSE streaming endpoint and /snapshot JSON (#203)

* feat(api): add /events SSE stream and /snapshot JSON endpoints

Extends `all-smi api` mode with two new HTTP endpoints that share the
`schema: 1` Snapshot shape used by the `snapshot` CLI and the `record`
NDJSON format — one transport, one serializer, three delivery channels
(#193).

* `GET /snapshot` — one-shot JSON. Serves the last published frame;
  falls back to a fresh collection when stale > 2x interval.
  `?include=gpu,cpu,memory,chassis,process,storage` and `?pretty=1`
  supported. Content-Type `application/json`; emits `Cache-Control:
  no-store` and `X-Accel-Buffering: no`.
* `GET /events` — Server-Sent Events. Emits `event: snapshot` per
  collection cycle with the same JSON body. Supports `?include=`,
  `?throttle=N` (clamped to >= collection interval) and `?heartbeat=N`
  (default 30 s). Lagging receivers get `event: lag\ndata: {"dropped":N}`
  and resume with the next live frame. `Last-Event-ID` is accepted but
  never replays history.

Broadcast architecture: a single `tokio::sync::broadcast::Sender<Arc<
Snapshot>>` is fanned out to every SSE client. `FrameBus::publish` is
non-blocking wrt receivers — slow clients cannot stall the publisher;
the small buffer (16 frames) caps memory growth and surfaces gaps as
`lag` events. `bus.latest()` gives `/snapshot` lock-free access to the
most recent frame.

Wire-up: new `api/frame_bus.rs`, `api/server_state.rs` (composite
`ApiState` using `FromRef`), `api/collection_loop.rs` (extracted from
`server.rs`), `api/handlers/{events,snapshot,metrics_render}.rs`. Both
endpoints also work over the existing Unix domain socket transport.

Docs: README gains a "Streaming (SSE)" subsection; API.md documents
both endpoints. `examples/sse_client.html` ships a minimal browser
`EventSource` demo.

Tests: 9 integration tests covering happy-path streaming (≥3 frames in
5 s), include filter, throttle, lag event on slow receivers, /snapshot
pretty/include/stale-fallback branches, and 50 concurrent clients
holding their broadcast slots without stalling the publisher (tick
jitter <= interval + 40 ms).

Closes #193

* fix(security): harden SSE/snapshot endpoints per PR #203 review

Security review findings addressed on the /events and /snapshot
endpoints (issue #193):

CORS (CRITICAL):
- Replace wildcard Allow-Origin/Methods/Headers with a deny-by-default
  posture. Set ALL_SMI_API_CORS_ALLOWED_ORIGINS to a comma-separated
  allowlist for opt-in cross-origin access; `*` restores the legacy
  wildcard with a loud warning. Methods are now restricted to GET and
  OPTIONS, headers to the minimum needed for text/event-stream.

Process label truncation (CRITICAL/privacy):
- The Prometheus exporter already caps command/process_name/user label
  values at 256/128/128 bytes to mitigate scrape-response amplification
  and argv-embedded-secret exposure. The JSON /snapshot and SSE
  /events paths bypassed this cap. `filter_snapshot_value` now applies
  the same truncation via a shared `ProcessMetricExporter::
  truncate_for_label` helper so every wire-format surface inherits the
  guarantee.

/snapshot amplification DoS (HIGH):
- When the cached frame is stale or absent, every /snapshot request
  used to spawn its own DefaultSnapshotCollector. A burst of requests
  against a freshly-started server or a stalled collector could
  saturate the Tokio blocking pool. Added a fresh-collect mutex on
  FrameBus so concurrent callers serialize and share the winning
  collector's output.

SSE subscriber cap (HIGH):
- Unbounded /events subscriptions could exhaust file descriptors and
  broadcast-channel slots. Cap at 256 concurrent subscribers by
  default (ALL_SMI_API_MAX_SSE_SUBSCRIBERS env var); over-cap clients
  get 503 Service Unavailable with Retry-After: 5.

Misc hardening:
- `resolve_throttle` clamp(lo, hi) panicked when interval exceeded
  MAX_INTERVAL_SECS; saturate the floor so the handler never panics.
- Truncate Last-Event-ID before logging so a 1 MiB header value cannot
  inflate log lines.
- Fix XSS in examples/sse_client.html: render GPU fields via
  textContent rather than innerHTML so crafted GPU names cannot
  inject HTML into the demo page.

Docs:
- API.md documents the new ALL_SMI_API_CORS_ALLOWED_ORIGINS and
  ALL_SMI_API_MAX_SSE_SUBSCRIBERS env vars and the process-label cap
  guarantee.

Tests:
- 7 new regression tests covering process field truncation in the
  JSON path, clamp-panic guard, and single-flight lock. Full suite:
  cargo test --lib --features cli (928 passed), cargo test --test
  sse_events_test --features cli (11 passed), cargo clippy
  --all-targets --features cli (no warnings), cargo fmt --check.

* docs(api): add SSE/snapshot security notes to README Streaming section

Add a "Security notes for SSE/snapshot endpoints" subsection under the
existing "Streaming (SSE)" heading. Covers CORS opt-in
(ALL_SMI_API_CORS_ALLOWED_ORIGINS), SSE subscriber cap
(ALL_SMI_API_MAX_SSE_SUBSCRIBERS), process label truncation, and the
single-flight stale fallback in /snapshot.
J
Jeongkyu Shin committed
845d24a1478914e24e95258cedab828398aa2695
Parent: 12bf2b2
Committed by GitHub <noreply@github.com> on 4/20/2026, 8:41:59 PM