What's new in v0.5.23
v0.5.23 ships the second of three UX surfaces above the daemon ack API introduced in v0.5.20. After the CLI helper landed in v0.5.22, the HTML report can now operate in a live mode that connects to a running daemon for interactive ack/revoke workflows directly from the browser. The static report shipped without --daemon-url keeps its pre-0.5.23 behavior: same panels, same strict default-src 'none' CSP, no network egress. The HTML output for --input X --output Y (no daemon flag) is fully equivalent to the v0.5.22 artifact for the same input.
The new flag is perf-sentinel report --daemon-url <URL>. When set, the generated HTML connects to the daemon on every operator interaction: per-finding Ack and Revoke buttons appear in the Findings table, a connection-status badge sits in the top bar, a new Acks tab lists daemon-side acknowledgments (paginated at the daemon's MAX_ACKS_RESPONSE = 1000 cap), a Show acknowledged filter toggles whether already-acked findings stay in the list, a manual refresh button re-fetches /api/status, /api/acks, and a Forget key button purges the in-memory X-API-Key when the operator wants to drop the session. The browser-side JavaScript stays textContent-only with document.createElement and addEventListener, no innerHTML, no setAttribute('on*', ...), no eval. The no_forbidden_apis_in_template test continues to enforce the rule on every build.
Authentication uses the same X-API-Key header the v0.5.20 daemon API expects on POST and DELETE /api/findings/{sig}/ack. The HTML never embeds the key (a CI artifact must not leak credentials). Instead, on the first 401 from a write call, an auth modal opens with a <input type="password">. The submitted value lands in sessionStorage under perf-sentinel.daemon.api-key, scoped to the tab and cleared at tab close. localStorage is not used (purges only at browser shutdown, not tab close, the wrong threat-model fit). When the daemon rotates its key, the next /api/status call returns 401, the cached value is dropped via sessionStorage.removeItem, and the operator is re-prompted on the next write. Every browser fetch is wrapped in an AbortController with a 10-second timeout so a hung daemon does not leave requests pending until the browser's default network timeout (multi-minute on Chrome).
The daemon side of the picture is the new [daemon.cors] config section. Default allowed_origins = [] means the layer is not wired and the daemon emits no Access-Control-Allow-Origin header, byte-for-byte preserving the pre-0.5.23 loopback-only posture. Wildcard mode ["*"] is intended for development. A non-wildcard list whitelists exact origins for production. Mixing wildcard with explicit origins (["*", "https://reports.example.com"]) is rejected at config load with a clear error: silently degrading to wildcard would let an operator believe the whitelist was tighter than it actually was. The CORS layer is scoped to the /api/* sub-router only, never to the OTLP /v1/traces ingest, the Prometheus /metrics endpoint, or the /health probe. A wildcard CORS configuration cannot let a browser POST traces or scrape metrics, only call the operator-facing query API. The cors_layer_does_not_leak_to_otlp_or_metrics_or_health_routes test mirrors the real router topology and asserts the absence of CORS headers on those three paths under wildcard mode, so a future axum upgrade or accidental refactor that flipped the merge order would break the build instead of regressing security.
The CORS allow-list is intentionally narrow: methods GET, POST, DELETE, OPTIONS, headers Content-Type and X-API-Key only. X-User-Id was dropped from the allow-list because the daemon does not enforce it server-side, the by field on a POST /api/findings/{sig}/ack body is operator-attested only. Access-Control-Max-Age is 120 seconds (2 minutes), short enough for a tightened whitelist to take effect on the next browser preflight without a forced refresh, long enough to amortize the OPTIONS roundtrip across a typical interaction (open report, click ack, click revoke). Access-Control-Allow-Credentials is not set: incompatible with AllowOrigin::any(), and unnecessary because the daemon auths via the X-API-Key header rather than cookies. Operators wanting CORS protection on the API surface must keep daemon_api_enabled = true. Setting cors_allowed_origins while disabling the API is rejected at config load to avoid the silent post-deploy "why isn't ack working" trap.
The Content-Security-Policy meta tag is now built per render. Static mode keeps the 0.5.22 directive verbatim (default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'). Live mode appends connect-src 'self' <daemon_url>, so the in-page JavaScript can reach the validated daemon URL plus any same-origin asset (a future template change adding a same-origin fetch will not silently break under the strict CSP). The daemon URL is run through validate_url upstream (the same validator the perf-sentinel ack --daemon flag uses since 0.5.22), so no CSP-breaking byte (single quote, semicolon, whitespace, curly brace) can land in the directive value. A debug_assert!(!csp.contains("{{")) in inject plus a const _: () = { ... } block enforcing the same invariant on the static CSP prefix at compile time guard the substitution pipeline against a future relaxation of the validator.
The validator itself is now pub(crate) and reused by three CLI subcommands: perf-sentinel ack --daemon (since 0.5.22), perf-sentinel report --daemon-url (new in 0.5.23), and perf-sentinel query --daemon. The latter previously did its own ad-hoc Uri::parse that did not reject userinfo, paths, query strings the way validate_url does, so a perf-sentinel query --daemon http://alice@host/v1/?debug=1 would silently misroute. The drift is fixed and locked in by the same per-class rejection tests the ack subcommand already covers.
Helm chart 0.2.26 ships in lockstep, bumping appVersion to 0.5.23 and the default daemon image tag to ghcr.io/robintra/perf-sentinel:0.5.23. No chart-level config change beyond the image tag, the new [daemon.cors] and --daemon-url flag are pure runtime knobs that do not touch the chart templates.
Added
- HTML report live mode: new
--daemon-url <URL>flag onperf-sentinel reportenables the live JavaScript boot path. Without the flag, the report stays static (current behavior preserved byte-for-byte, modulo a new placeholder substitution that produces the same final string). - Per-finding Ack and Revoke buttons in the Findings table, hidden in static mode via CSS (
body.ps-livereveals them). ClickingAckopens a modal with reason (required), expires (Never/24h/7d/30d), and an optionalbyinput; submit posts to/api/findings/{sig}/ack. ClickingRevokeconfirms (text-onlyglobalThis.confirm) then deletes the same endpoint. Show acknowledgedtoggle in the Findings panel toolbar, also hidden in static mode. Filters the static finding list against the live/api/acksset.- Acknowledgments panel as a new
Ackstab listing the daemon-side acks. Footer notes theMAX_ACKS_RESPONSE = 1000cap consistent with the CLIack listoutput. - Connection status badge in the top bar with three states:
Connected(green),Authentication required(orange),Disconnected(red). Driven by aGET /api/statusping at boot and on every refresh. - Manual refresh button that re-fetches
/api/status,/api/ackssimultaneously viaPromise.alland re-renders. Forget keybutton revealed when an X-API-Key sits in the live state. Clicking purges sessionStorage and re-pings the daemon. Hidden when no key is cached so the static mode stays clean.- Auth modal with a no-echo password input, opened on the first 401 from a write call. The key is held in
sessionStorageonly, neverlocalStorage, never the DOM, neverconsole.log. Cancelling the modal sets the badge toAuthentication requiredand disables further write attempts. AbortControllerfetch timeout of 10 seconds on every live-mode browser fetch. A hung daemon does not leave the request pending until the browser's default network timeout.[daemon.cors]config section withallowed_origins: array<string>(default[]). Wildcard mode["*"]for dev, exact-list mode for prod. Each non-wildcard entry must have a scheme, must not end with a trailing slash, and must not contain ASCII control characters. Mixed wildcard with explicit list is rejected at config load.- CORS layer scoped to
/api/*: built into the query API sub-router before merging into the outer router, so OTLP/v1/traces,/metricsand/healthare never reachable cross-origin even under wildcard mode. Allowed methods: GET, POST, DELETE, OPTIONS. Allowed headers: Content-Type, X-API-Key (X-User-Id explicitly excluded, the daemon does not enforce it).Access-Control-Max-Age: 120 seconds. NoAccess-Control-Allow-Credentials. - Cross-section config validation:
daemon_api_enabled = false + cors_allowed_origins != []is rejected at config load with an actionable error pointing at both knobs. Catches the silent "why isn't ack working post-deploy" trap. - Dynamic Content-Security-Policy built per render. Static mode keeps the 0.5.22 directive verbatim. Live mode appends
connect-src 'self' <daemon_url>. The URL passesvalidate_urlupstream so CSP-breaking bytes cannot reach the meta tag. - Compile-time invariant on
STATIC_CSPviaconst _: () = { ... while ... assert!(...) }block, enforcing that the static prefix never contains a{{byte sequence that could shadow placeholder substitution. The runtimedebug_assert!ininjectcovers the daemon-URL half. - Substitution-order safety in
inject(3-stepreplacen: JSON first, then CSP, then title). User-supplied data cannot shadow static placeholders, locked in byhostile_input_label_with_json_placeholder_does_not_double_substituteand friends. validate_urllifted topub(crate)incrates/sentinel-cli/src/ack.rsand reused byperf-sentinel report --daemon-urlandperf-sentinel query --daemon. The latter previously had ad-hoc validation that accepted userinfo, paths and query strings; the drift is fixed.docs/HTML-REPORT.md(English) anddocs/FR/HTML-REPORT-FR.md(French): new reference page covering live mode setup, daemon URL validation, auth flow, security caveats (X-API-Key in sessionStorage,script-src 'unsafe-inline', preflight DoS surface), and an 8-step manual smoke test.[daemon.cors]section indocs/CONFIGURATION.md+ FR mirror, documenting the scope-to-/api/*posture, the read-endpoint exposure note, the methods/headers/max-age decisions.Click Ack from HTML reportrow in thedocs/ACK-WORKFLOW.mddecision table + FR mirror, completing the three-axis UX overview (TOML / daemon CLI / daemon HTML).- 44 new tests across the workspace: 11 CORS layer tests in
daemon/listeners.rs::cors_tests, an end-to-end CORS scoping integration test that mirrors the real router topology, 8 CSP / payload tests inreport/html.rs, an IPv6 literal test, 8 config validation tests for the new[daemon.cors]section, plus the cross-section consistency check.
Changed
- Substitution order in
inject: JSON is now substituted first (no user-controlled data has been laid down yet, so the only{{REPORT_JSON}}occurrence is the static template marker), then CSP, then title. The previous order would have let a hostileinput_labelcontaining{{REPORT_JSON}}shadow the static placeholder oncereplacensaw the user data laid into the title. tower-httpfeatures extended with"cors"(was["decompression-gzip", "limit"]). No new top-level dependency, just an additional feature on a crate already in the daemon dep tree.- Helm chart
0.2.25to0.2.26,appVersion0.5.22to0.5.23, default daemon image tag points atghcr.io/robintra/perf-sentinel:0.5.23. Theartifacthub.io/imagesannotation is updated in lockstep.
Behavior
- Static HTML mode preserved byte-for-byte. A
perf-sentinel report --input X --output Ycall without--daemon-urlproduces the same artifact as 0.5.22 (modulo the now-templated CSP value, which renders to the identical string in static mode). - No HTTP-shape change on the daemon API. The three ack endpoints, the
/api/findings,/api/acks,/api/status,/api/correlations,/api/explain/*,/api/export/reportroutes all keep their 0.5.21/0.5.22 status codes and JSON shapes. The CORS layer is purely additive. - CORS layer absent by default. Empty
cors_allowed_originsmeans noAccess-Control-Allow-Originheader is emitted on responses. The loopback-only posture pre-0.5.23 is preserved for operators who do not opt in. - CORS layer scoped to
/api/*. Even under wildcard mode, OTLP/v1/traces, Prometheus/metricsand the/healthprobe never echo CORS headers. A browser cannot post traces or scrape metrics regardless ofallowed_origins. - Read-endpoint exposure documented. Whitelisting an origin grants browser-side read access to every finding, ack, and trace export the daemon retains. The new
[daemon.cors]doc section calls this out explicitly so operators do not whitelist origins they would not letcurl localhost:4318/api/findings. - CSP
connect-src 'self' <url>in live mode. Same-origin assets allowed (file:// gives'self' = null, https origin gives the document host), plus the validated daemon URL. - X-API-Key in sessionStorage only. Cleared at tab close. Never
localStorage. Never the DOM. Never logged. Thetemplate_does_not_leak_session_storage_to_local_storagetest enforces the rule. - Stale-key recovery on rotation. A 401 from
/api/statuspurges the cached key and sets the badge toAuthentication required. The next write call opens the auth modal with a clean slate. - CORS preflight DoS surface documented.
OPTIONSpreflight short-circuits before the X-API-Key check, so a rogue origin in the whitelist (or any origin under wildcard mode) can spam preflights past the auth boundary. Themax_age = 120scap reduces volume from legitimate browsers but does not help against a malicious script. Mitigation posture for 0.5.23 is to deploy the daemon behind a reverse proxy with per-IP rate limiting (nginxlimit_req, Caddyrate_limit, Cloudflare WAF) when exposing it cross-origin. A nativetower-governorintegration is tracked for a future release.
Documentation
- New
docs/HTML-REPORT.mdcovering live mode setup, the CORS prerequisite, the auth flow, the security caveats, and an 8-step manual smoke test (start daemon with CORS, generate live HTML, click Ack, click Revoke, restart daemon with[daemon.ack] api_key, exercise the auth modal, reload tab, verify session persistence). docs/CONFIGURATION.mdgains a[daemon.cors] allowed_originssection with the scope-to-/api/*documentation, the per-method allow-list, and the explicit caveat that read endpoints stay unauthenticated under CORS.docs/ACK-WORKFLOW.mddecision table extended with a third option ("Click Ack from HTML report"), placing it alongside the TOML and CLI options.- French mirrors at
docs/FR/HTML-REPORT-FR.md,docs/FR/CONFIGURATION-FR.md(extended),docs/FR/ACK-WORKFLOW-FR.md(extended). Same content with French accents and prose conventions.
Install
Prebuilt binaries (Linux amd64 / arm64, macOS arm64, Windows amd64):
curl -LO https://github.com/robintra/perf-sentinel/releases/download/v0.5.23/perf-sentinel-linux-amd64
chmod +x perf-sentinel-linux-amd64
sudo mv perf-sentinel-linux-amd64 /usr/local/bin/perf-sentinelLinux binaries are statically linked against musl and run on any distribution (Alpine, Debian, RHEL, Ubuntu any version) regardless of glibc version, and inside FROM scratch images.
From crates.io:
cargo install perf-sentinel --version 0.5.23Docker:
docker run --rm -p 4317:4317 -p 4318:4318 \
ghcr.io/robintra/perf-sentinel:0.5.23 watch --listen-address 0.0.0.0Helm chart 0.2.26 ships alongside, see the matching chart-v0.2.26 release for the chart-side details.
Full Changelog: v0.5.22...v0.5.23