WEB Debug + Trace

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-08-25 12:59:41 +03:00
parent 0779cd0901
commit e774bc8c9a
48 changed files with 3744 additions and 435 deletions
+15 -2
View File
@@ -30,6 +30,7 @@ use crate::startup::StartupTracker;
use crate::stats::Stats;
use crate::transport::UpstreamManager;
use crate::transport::middle_proxy::MePool;
use crate::web::trace::WebTraceStore;
mod config_edit;
pub(crate) mod config_store;
@@ -47,6 +48,7 @@ mod runtime_stats;
mod runtime_watch;
mod runtime_zero;
mod users;
mod web_status;
use config_store::{
current_revision, ensure_expected_revision, load_config_for_reload, load_config_from_disk,
@@ -122,6 +124,7 @@ pub(super) struct ApiShared {
pub(super) proxy_shared: Arc<ProxySharedState>,
pub(super) reload_control: ReloadControl,
pub(super) active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
pub(super) web_trace: Arc<WebTraceStore>,
}
impl ApiShared {
@@ -155,6 +158,7 @@ impl ApiShared {
proxy_shared: runtime.proxy_shared.clone(),
reload_control: self.reload_control.clone(),
active_runtime: self.active_runtime.clone(),
web_trace: self.web_trace.clone(),
}
}
}
@@ -243,7 +247,8 @@ fn allowed_methods_for_path(path: &str) -> Option<&'static str> {
| "/v1/runtime/tls-fingerprints"
| "/v1/stats/users/active-ips"
| "/v1/stats/users/quota"
| "/v1/stats/users" => Some(ALLOW_GET),
| "/v1/stats/users"
| "/web-status" => Some(ALLOW_GET),
"/v1/system/reload" => Some(ALLOW_POST),
"/v1/users" => Some(ALLOW_GET_POST),
"/v1/config" => Some(ALLOW_GET_PATCH),
@@ -279,6 +284,7 @@ pub async fn serve(
reload_control: ReloadControl,
mut active_runtime_rx: watch::Receiver<Option<Arc<ArcSwap<RuntimeGeneration>>>>,
mut runtime_watch_rx: watch::Receiver<Option<RuntimeWatchState>>,
web_trace: Arc<WebTraceStore>,
) {
let active_runtime = loop {
if let Some(active_runtime) = active_runtime_rx.borrow().clone() {
@@ -312,7 +318,7 @@ pub async fn serve(
}
};
info!("API endpoint: http://{}/v1/*", listen);
info!("API endpoint: http://{}/v1/* and /web-status", listen);
let runtime_state = Arc::new(ApiRuntimeState {
process_started_at_epoch_secs,
@@ -344,6 +350,7 @@ pub async fn serve(
proxy_shared,
reload_control,
active_runtime,
web_trace,
});
spawn_runtime_watchers(
@@ -492,6 +499,12 @@ async fn handle(
let result: Result<Response<Full<Bytes>>, ApiFailure> = async {
match (method.as_str(), normalized_path) {
("GET", "/web-status") => Ok(web_status::render(
query.as_deref(),
&shared.web_trace,
&cfg.web.debug,
)
.await),
("GET", "/v1/health") => {
let revision = current_revision(&shared.config_path).await?;
let data = HealthData {
+1
View File
@@ -112,6 +112,7 @@ fn reload_routes_expose_only_documented_methods_and_ids() {
Some(ALLOW_GET)
);
assert_eq!(reload_status_route_id("/v1/system/reload/42"), Some(42));
assert_eq!(allowed_methods_for_path("/web-status"), Some(ALLOW_GET));
assert_eq!(
reload_status_route_id("/v1/system/reload/not-a-number"),
None
+540
View File
@@ -0,0 +1,540 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use base64::Engine as _;
use http_body_util::Full;
use hyper::body::Bytes;
use hyper::header::{self, HeaderValue};
use hyper::{Response, StatusCode};
use tokio::sync::OwnedSemaphorePermit;
use crate::config::WebDebugConfig;
use crate::web::trace::{
StoredTraceRecord, TraceRecord, TraceRecordKind, WebTraceStore,
};
const MAX_PAGE_BYTES: usize = 8 * 1024 * 1024;
const MAX_GROUPS: usize = 1024;
// Query parsing and matching remain independent from bounded HTML rendering.
mod query;
use query::{GroupBy, StatusQuery, client_ip, parse_query, record_matches};
struct GroupSummary {
count: usize,
latest_seq: u64,
}
struct RenderedPage {
html: String,
_permit: OwnedSemaphorePermit,
}
impl AsRef<[u8]> for RenderedPage {
fn as_ref(&self) -> &[u8] {
self.html.as_bytes()
}
}
/// Renders the authenticated server-side WEB debugging table.
pub(super) async fn render(
raw_query: Option<&str>,
store: &Arc<WebTraceStore>,
policy: &WebDebugConfig,
) -> Response<Full<Bytes>> {
store.apply_policy(policy);
let query = match parse_query(raw_query, policy) {
Ok(query) => query,
Err(error) => return html_error(StatusCode::BAD_REQUEST, "Invalid query", &error),
};
let Some(render_permit) = store.try_render_permit() else {
return html_error(
StatusCode::SERVICE_UNAVAILABLE,
"Renderer busy",
"Two WEB status pages are already rendering",
);
};
let now_millis = crate::web::trace::store_epoch_millis();
let since_millis = query
.record
.is_none()
.then(|| now_millis.saturating_sub(query.window_secs.saturating_mul(1000)))
.unwrap_or(0);
let records = store.snapshot_matching(|record| record_matches(record, &query, since_millis));
let status = store.status();
let mut html = String::with_capacity(MAX_PAGE_BYTES);
push_page_start(&mut html);
html.push_str("<h1>WEB status</h1>");
push_filter_form(&mut html, &query);
html.push_str("<section><h2>Store</h2><table><tbody>");
summary_row(&mut html, "debug enabled", yes_no(status.policy.enabled));
summary_row(&mut html, "body capture", body_mode(&status.policy));
summary_row(&mut html, "window seconds", &query.window_secs.to_string());
summary_row(
&mut html,
"records",
&format!("{} / {}", status.records, status.records_capacity),
);
summary_row(
&mut html,
"bytes",
&format!("{} / {}", status.used_bytes, status.bytes_capacity),
);
summary_row(&mut html, "matched", &records.len().to_string());
summary_row(
&mut html,
"contention drops",
&status.contention_drops.to_string(),
);
summary_row(&mut html, "evictions", &status.evictions.to_string());
summary_row(
&mut html,
"byte truncations",
&status.byte_truncations.to_string(),
);
summary_row(
&mut html,
"sequence range",
&format!(
"{} .. {}",
option_u64(status.earliest_seq),
option_u64(status.latest_seq)
),
);
html.push_str("</tbody></table></section>");
if !query.group_by.is_empty() {
push_groups(&mut html, &records, &query.group_by);
}
push_records(&mut html, &records, &query);
html.push_str("</main></body></html>");
truncate_page(&mut html);
retained_html_response(StatusCode::OK, html, render_permit)
}
fn push_page_start(html: &mut String) {
html.push_str("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>WEB status</title><style>body{font:14px system-ui,sans-serif;margin:0;background:#f5f7fa;color:#17202a}main{max-width:1600px;margin:auto;padding:20px}h1,h2{margin:.4em 0}section{background:#fff;border:1px solid #d9e0e7;border-radius:8px;padding:12px;margin:12px 0;overflow:auto}form{display:flex;flex-wrap:wrap;gap:8px;align-items:end}label{display:grid;gap:3px}input,select,button{font:inherit;padding:5px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #d9e0e7;padding:5px;text-align:left;vertical-align:top}th{background:#edf2f7;position:sticky;top:0}code,pre{font:12px ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}details{max-width:900px}.muted{color:#657786}.bad{color:#a00}</style></head><body><main>");
}
fn push_filter_form(html: &mut String, query: &StatusQuery) {
html.push_str("<section><h2>Filters</h2><form method=\"get\" action=\"/web-status\">");
input(html, "window_secs", &query.window_secs.to_string());
input(html, "ip", &query.ip.map(|value| value.to_string()).unwrap_or_default());
input(
html,
"session",
&query.session.map(|value| value.to_string()).unwrap_or_default(),
);
input(
html,
"user_agent",
query.user_agent.as_deref().unwrap_or_default(),
);
input(html, "key", query.key.as_deref().unwrap_or_default());
input(html, "limit", &query.limit.to_string());
html.push_str("<label>group_by<select name=\"group_by\" multiple size=\"4\">");
for group in [GroupBy::Ip, GroupBy::Session, GroupBy::UserAgent, GroupBy::Key] {
html.push_str("<option value=\"");
html.push_str(group.as_str());
if query.group_by.contains(&group) {
html.push_str("\" selected>");
} else {
html.push_str("\">");
}
html.push_str(group.as_str());
html.push_str("</option>");
}
html.push_str("</select></label><button type=\"submit\">Observe</button></form></section>");
}
fn input(html: &mut String, name: &str, value: &str) {
html.push_str("<label>");
escape(html, name);
html.push_str("<input name=\"");
escape(html, name);
html.push_str("\" value=\"");
escape(html, value);
html.push_str("\"></label>");
}
fn summary_row(html: &mut String, name: &str, value: &str) {
html.push_str("<tr><th>");
escape(html, name);
html.push_str("</th><td>");
escape(html, value);
html.push_str("</td></tr>");
}
fn push_groups(
html: &mut String,
records: &[Arc<StoredTraceRecord>],
groups: &[GroupBy],
) {
let mut summaries = BTreeMap::<Vec<String>, GroupSummary>::new();
let mut overflow = 0usize;
for stored in records {
let values = groups
.iter()
.map(|group| group_value(&stored.record, *group))
.collect::<Vec<_>>();
if let Some(summary) = summaries.get_mut(&values) {
summary.count += 1;
summary.latest_seq = summary.latest_seq.max(stored.record.seq);
} else if summaries.len() < MAX_GROUPS {
summaries.insert(
values,
GroupSummary {
count: 1,
latest_seq: stored.record.seq,
},
);
} else {
overflow += 1;
}
}
let mut summaries = summaries.into_iter().collect::<Vec<_>>();
summaries.sort_by(|(left_values, left), (right_values, right)| {
right
.count
.cmp(&left.count)
.then_with(|| left_values.cmp(right_values))
});
html.push_str("<section><h2>Groups</h2><table><thead><tr>");
for group in groups {
html.push_str("<th>");
html.push_str(group.as_str());
html.push_str("</th>");
}
html.push_str("<th>records</th><th>latest seq</th></tr></thead><tbody>");
for (values, summary) in summaries {
html.push_str("<tr>");
for value in values {
html.push_str("<td>");
escape(html, &value);
html.push_str("</td>");
}
html.push_str("<td>");
html.push_str(&summary.count.to_string());
html.push_str("</td><td>");
html.push_str(&summary.latest_seq.to_string());
html.push_str("</td></tr>");
if html.len() >= MAX_PAGE_BYTES / 2 {
break;
}
}
if overflow != 0 {
html.push_str("<tr><td colspan=\"6\" class=\"muted\">Additional groups omitted: ");
html.push_str(&overflow.to_string());
html.push_str("</td></tr>");
}
html.push_str("</tbody></table></section>");
}
fn group_value(record: &TraceRecord, group: GroupBy) -> String {
match group {
GroupBy::Ip => client_ip(record).map(|value| value.to_string()),
GroupBy::Session => record.identity.session_id.map(|value| value.to_string()),
GroupBy::UserAgent => record.user_agent.clone(),
GroupBy::Key => record.identity.key_fingerprint.clone(),
}
.unwrap_or_else(|| "-".to_string())
}
fn push_records(html: &mut String, records: &[Arc<StoredTraceRecord>], query: &StatusQuery) {
html.push_str("<section><h2>Records</h2><table><thead><tr><th>seq</th><th>time</th><th>kind</th><th>route/event</th><th>method</th><th>status</th><th>IP</th><th>session</th><th>user / key</th><th>User-Agent</th><th>details</th></tr></thead><tbody>");
let mut shown = 0usize;
for stored in records.iter().take(query.limit) {
if html.len() >= MAX_PAGE_BYTES.saturating_sub(64 * 1024) {
break;
}
push_record(html, &stored.record);
shown += 1;
}
if shown == 0 {
html.push_str("<tr><td colspan=\"11\" class=\"muted\">No matching records</td></tr>");
}
html.push_str("</tbody></table>");
if records.len() > shown && shown != 0 {
let before = records[shown - 1].record.seq;
html.push_str("<p><a href=\"");
escape(html, &pagination_url(query, before));
html.push_str("\">Next page</a></p>");
}
html.push_str("</section>");
}
fn push_record(html: &mut String, record: &TraceRecord) {
html.push_str("<tr><td><a href=\"/web-status?record=");
html.push_str(&record.seq.to_string());
html.push_str("\">");
html.push_str(&record.seq.to_string());
html.push_str("</a></td><td>");
escape(html, &format_time(record.epoch_millis));
let (kind, route, method, status) = match &record.kind {
TraceRecordKind::Http(http) => (
"http",
http.route.as_str(),
http.method.as_str(),
http.status.map(|value| value.to_string()).unwrap_or_else(|| "-".to_string()),
),
TraceRecordKind::Lifecycle(event) => (
"lifecycle",
event.event.as_str(),
"-",
event.reason.unwrap_or("-").to_string(),
),
};
for value in [kind, route, method, status.as_str()] {
html.push_str("</td><td>");
escape(html, value);
}
html.push_str("</td><td>");
escape(
html,
&client_ip(record)
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string()),
);
html.push_str("</td><td>");
escape(html, &option_u64(record.identity.session_id));
html.push_str("</td><td>");
escape(html, record.identity.user.as_deref().unwrap_or("-"));
html.push_str(" / ");
escape(
html,
record.identity.key_fingerprint.as_deref().unwrap_or("-"),
);
html.push_str("</td><td>");
escape(html, record.user_agent.as_deref().unwrap_or("-"));
html.push_str("</td><td><details><summary>request → response</summary>");
match &record.kind {
TraceRecordKind::Http(http) => {
html.push_str("<p><code>");
escape(html, &http.method);
html.push(' ');
escape(html, &http.path);
html.push_str("</code></p>");
push_headers(html, "request headers", &http.request_headers);
push_body(html, "request body", http.request_body.as_ref());
push_headers(html, "response headers", &http.response_headers);
push_body(html, "response body", http.response_body.as_ref());
if let Some(timings) = &http.timings {
html.push_str("<h3>timings</h3><pre>service/head accepted: 0 us\nrequest body: ");
html.push_str(&option_u64(timings.request_body_us));
html.push_str(" us\nresponse ready: ");
html.push_str(&option_u64(timings.response_ready_us));
html.push_str(" us\nresponse body consumed/polled: ");
html.push_str(&option_u64(timings.response_body_us));
html.push_str(" us\n(kernel flush and TCP ACK are not observed)</pre>");
}
if !http.frames.is_empty() {
html.push_str("<h3>frames</h3><table><tr><th>dir</th><th>type</th><th>stream/lane</th><th>payload</th><th>WINDOW</th><th>error</th></tr>");
for frame in &http.frames {
html.push_str("<tr>");
for value in [
frame.direction.as_str().to_string(),
frame.frame_type.unwrap_or("-").to_string(),
frame.stream_id.map(|v| v.to_string()).unwrap_or_else(|| "-".to_string()),
frame.payload_len.map(|v| v.to_string()).unwrap_or_else(|| "-".to_string()),
frame.window_delta.map(|v| v.to_string()).unwrap_or_else(|| "-".to_string()),
frame.parse_error.unwrap_or("-").to_string(),
] {
html.push_str("<td>");
escape(html, &value);
html.push_str("</td>");
}
html.push_str("</tr>");
}
html.push_str("</table>");
}
}
TraceRecordKind::Lifecycle(event) => {
html.push_str("<pre>event: ");
html.push_str(event.event.as_str());
html.push_str("\nstream: ");
html.push_str(&event.stream_id.map(|v| v.to_string()).unwrap_or_else(|| "-".to_string()));
html.push_str("\nreason: ");
html.push_str(event.reason.unwrap_or("-"));
html.push_str("</pre>");
}
}
html.push_str("</details></td></tr>");
}
fn push_headers(html: &mut String, title: &str, headers: &[crate::web::trace::TraceHeader]) {
html.push_str("<h3>");
escape(html, title);
html.push_str("</h3><pre>");
for header in headers {
escape(html, &header.name);
html.push_str(": ");
escape(html, header.value.as_deref().unwrap_or("[value omitted]"));
html.push('\n');
}
html.push_str("</pre>");
}
fn push_body(
html: &mut String,
title: &str,
body: Option<&crate::web::trace::TraceBodySnapshot>,
) {
html.push_str("<h3>");
escape(html, title);
html.push_str("</h3>");
let Some(body) = body else {
html.push_str("<p class=\"muted\">capture off</p>");
return;
};
html.push_str("<p>observed=");
html.push_str(&body.observed_bytes.to_string());
html.push_str(" captured=");
html.push_str(&body.captured.len().to_string());
html.push_str(" state=");
html.push_str(body.state.as_str());
html.push_str(" truncated=");
html.push_str(yes_no(body.truncated));
html.push_str("</p><pre>");
let available = MAX_PAGE_BYTES.saturating_sub(html.len()).saturating_sub(4096);
let raw_limit = available.saturating_mul(3) / 4;
let shown = body.captured.len().min(raw_limit);
base64::engine::general_purpose::STANDARD
.encode_string(&body.captured[..shown], html);
if shown < body.captured.len() {
html.push_str("\n[page output truncated]");
}
html.push_str("</pre>");
}
fn pagination_url(query: &StatusQuery, before_seq: u64) -> String {
let mut serializer = url::form_urlencoded::Serializer::new(String::from("/web-status?"));
serializer.append_pair("window_secs", &query.window_secs.to_string());
if let Some(ip) = query.ip {
serializer.append_pair("ip", &ip.to_string());
}
if let Some(session) = query.session {
serializer.append_pair("session", &session.to_string());
}
if let Some(user_agent) = &query.user_agent {
serializer.append_pair("user_agent", user_agent);
}
if let Some(key) = &query.key {
serializer.append_pair("key", key);
}
for group in &query.group_by {
serializer.append_pair("group_by", group.as_str());
}
serializer.append_pair("limit", &query.limit.to_string());
serializer.append_pair("before_seq", &before_seq.to_string());
serializer.finish()
}
fn format_time(epoch_millis: u64) -> String {
chrono::DateTime::from_timestamp_millis(epoch_millis as i64)
.map(|value| value.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
.unwrap_or_else(|| epoch_millis.to_string())
}
fn option_u64(value: Option<u64>) -> String {
value.map(|value| value.to_string()).unwrap_or_else(|| "-".to_string())
}
fn body_mode(policy: &WebDebugConfig) -> &'static str {
match policy.body_capture {
crate::config::WebDebugBodyCapture::Off => "off",
crate::config::WebDebugBodyCapture::Metadata => "metadata",
crate::config::WebDebugBodyCapture::Prefix => "prefix",
crate::config::WebDebugBodyCapture::Full => "full",
}
}
fn yes_no(value: bool) -> &'static str {
if value { "yes" } else { "no" }
}
fn escape(output: &mut String, value: &str) {
for character in value.chars() {
match character {
'&' => output.push_str("&amp;"),
'<' => output.push_str("&lt;"),
'>' => output.push_str("&gt;"),
'"' => output.push_str("&quot;"),
'\'' => output.push_str("&#39;"),
_ => output.push(character),
}
}
}
fn truncate_page(html: &mut String) {
const SUFFIX: &str = "[page output truncated]";
if html.len() <= MAX_PAGE_BYTES {
return;
}
let mut end = MAX_PAGE_BYTES.saturating_sub(SUFFIX.len());
while !html.is_char_boundary(end) {
end -= 1;
}
html.truncate(end);
html.push_str(SUFFIX);
}
fn html_error(status: StatusCode, title: &str, message: &str) -> Response<Full<Bytes>> {
let mut html = String::new();
push_page_start(&mut html);
html.push_str("<h1 class=\"bad\">");
escape(&mut html, title);
html.push_str("</h1><p>");
escape(&mut html, message);
html.push_str("</p></main></body></html>");
html_response(status, html)
}
fn html_response(status: StatusCode, html: String) -> Response<Full<Bytes>> {
html_bytes_response(status, Bytes::from(html))
}
fn retained_html_response(
status: StatusCode,
html: String,
permit: OwnedSemaphorePermit,
) -> Response<Full<Bytes>> {
html_bytes_response(
status,
Bytes::from_owner(RenderedPage {
html,
_permit: permit,
}),
)
}
fn html_bytes_response(status: StatusCode, html: Bytes) -> Response<Full<Bytes>> {
let mut response = Response::new(Full::new(html));
*response.status_mut() = status;
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response.headers_mut().insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static("default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"),
);
response.headers_mut().insert(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
);
response.headers_mut().insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
response
.headers_mut()
.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
response
}
#[cfg(test)]
#[path = "web_status/tests.rs"]
mod tests;
+179
View File
@@ -0,0 +1,179 @@
use std::collections::BTreeSet;
use std::net::IpAddr;
use crate::config::WebDebugConfig;
use crate::web::trace::TraceRecord;
const DEFAULT_LIMIT: usize = 200;
const MAX_LIMIT: usize = 1000;
/// Supported status-page grouping dimensions.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum GroupBy {
Ip,
Session,
UserAgent,
Key,
}
impl GroupBy {
fn parse(value: &str) -> Option<Self> {
match value {
"ip" => Some(Self::Ip),
"session" => Some(Self::Session),
"user_agent" => Some(Self::UserAgent),
"key" => Some(Self::Key),
_ => None,
}
}
/// Returns the canonical query and table label.
pub(super) const fn as_str(self) -> &'static str {
match self {
Self::Ip => "ip",
Self::Session => "session",
Self::UserAgent => "user_agent",
Self::Key => "key",
}
}
}
/// Validated bounded status-page filter and pagination state.
pub(super) struct StatusQuery {
pub(super) window_secs: u64,
pub(super) ip: Option<IpAddr>,
pub(super) session: Option<u64>,
pub(super) user_agent: Option<String>,
pub(super) key: Option<String>,
pub(super) group_by: Vec<GroupBy>,
pub(super) limit: usize,
pub(super) before_seq: Option<u64>,
pub(super) record: Option<u64>,
}
/// Parses a strict query without accepting unknown or ambiguous fields.
pub(super) fn parse_query(
raw: Option<&str>,
policy: &WebDebugConfig,
) -> Result<StatusQuery, String> {
let mut query = StatusQuery {
window_secs: policy.default_window_secs,
ip: None,
session: None,
user_agent: None,
key: None,
group_by: Vec::new(),
limit: DEFAULT_LIMIT,
before_seq: None,
record: None,
};
let mut seen = BTreeSet::new();
for (name, value) in url::form_urlencoded::parse(raw.unwrap_or_default().as_bytes()) {
let name = name.as_ref();
let value = value.as_ref();
if name != "group_by" && !seen.insert(name.to_string()) {
return Err(format!("{name} must not repeat"));
}
match name {
"window_secs" => {
query.window_secs = parse_positive_u64(value, "window_secs")?;
}
"ip" => {
let parsed = value
.parse::<IpAddr>()
.map_err(|_| "ip must be a canonical IP address".to_string())?;
if parsed.to_string() != value {
return Err("ip must use canonical formatting".to_string());
}
query.ip = Some(parsed);
}
"session" => query.session = Some(parse_positive_u64(value, "session")?),
"user_agent" => {
if value.is_empty() || value.len() > 512 {
return Err("user_agent must contain 1..512 bytes".to_string());
}
query.user_agent = Some(value.to_string());
}
"key" => {
if value.is_empty() || value.len() > 64 {
return Err("key must contain 1..64 bytes".to_string());
}
query.key = Some(value.to_string());
}
"group_by" => {
let group = GroupBy::parse(value)
.ok_or_else(|| "group_by must be ip, session, user_agent, or key".to_string())?;
if query.group_by.contains(&group) {
return Err("group_by values must not repeat".to_string());
}
query.group_by.push(group);
}
"limit" => {
query.limit = value
.parse::<usize>()
.ok()
.filter(|value| (1..=MAX_LIMIT).contains(value))
.ok_or_else(|| "limit must be within 1..1000".to_string())?;
}
"before_seq" => {
query.before_seq = Some(parse_positive_u64(value, "before_seq")?);
}
"record" => query.record = Some(parse_positive_u64(value, "record")?),
_ => return Err(format!("unknown query field `{name}`")),
}
}
if query.window_secs > policy.max_window_secs {
return Err(format!(
"window_secs must not exceed {}",
policy.max_window_secs
));
}
Ok(query)
}
fn parse_positive_u64(value: &str, field: &str) -> Result<u64, String> {
value
.parse::<u64>()
.ok()
.filter(|value| *value != 0)
.ok_or_else(|| format!("{field} must be a positive integer"))
}
/// Applies the complete filter predicate to one immutable record.
pub(super) fn record_matches(
record: &TraceRecord,
query: &StatusQuery,
since_millis: u64,
) -> bool {
!(record.epoch_millis < since_millis
|| query.before_seq.is_some_and(|before| record.seq >= before)
|| query.record.is_some_and(|seq| record.seq != seq)
|| query.ip.is_some_and(|ip| client_ip(record) != Some(ip))
|| query
.session
.is_some_and(|session| record.identity.session_id != Some(session))
|| query.user_agent.as_ref().is_some_and(|needle| {
record
.user_agent
.as_deref()
.is_none_or(|value| !contains_ascii_case_insensitive(value, needle))
})
|| query.key.as_ref().is_some_and(|key| {
record.identity.user.as_deref() != Some(key)
&& record.identity.key_fingerprint.as_deref() != Some(key)
}))
}
fn contains_ascii_case_insensitive(value: &str, needle: &str) -> bool {
let needle = needle.as_bytes();
needle.is_empty()
|| value
.as_bytes()
.windows(needle.len())
.any(|window| window.eq_ignore_ascii_case(needle))
}
/// Returns the trusted effective address or direct peer fallback.
pub(super) fn client_ip(record: &TraceRecord) -> Option<IpAddr> {
record.effective_ip.or(record.peer_ip)
}
+82
View File
@@ -0,0 +1,82 @@
use http_body_util::BodyExt as _;
use super::*;
use crate::web::trace::{TraceIdentity, TraceLifecycleEvent};
#[test]
fn query_rejects_noncanonical_ip_and_excessive_window() {
let policy = WebDebugConfig::default();
assert!(parse_query(Some("ip=2001%3A0db8%3A%3A1"), &policy).is_err());
assert!(parse_query(Some("window_secs=3601"), &policy).is_err());
assert!(parse_query(Some("session=1&session=2"), &policy).is_err());
}
#[test]
fn html_escaping_covers_active_markup_characters() {
let mut output = String::new();
escape(&mut output, "<script a='\"'>&");
assert_eq!(output, "&lt;script a=&#39;&quot;&#39;&gt;&amp;");
}
#[tokio::test]
async fn renderer_filters_groups_and_sets_control_plane_security_headers() {
let mut policy = WebDebugConfig::default();
policy.enabled = true;
let mut limits = crate::config::WebLimitsConfig::default();
limits.debug_records_capacity = 8;
limits.debug_bytes_global = 16 * 1024;
let store = WebTraceStore::new(policy.clone(), &limits);
store.record_lifecycle(
None,
Some("192.0.2.40".parse().unwrap()),
TraceIdentity {
session_id: Some(42),
user: Some("alice".to_string()),
key_fingerprint: Some("0123456789abcdef".to_string()),
},
TraceLifecycleEvent::SessionCreated,
None,
None,
);
let response = render(
Some("ip=192.0.2.40&session=42&key=0123456789abcdef&group_by=ip&group_by=key"),
&store,
&policy,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
assert!(response.headers().contains_key(header::CONTENT_SECURITY_POLICY));
let body = response.into_body().collect().await.unwrap().to_bytes();
let body = std::str::from_utf8(&body).unwrap();
assert!(body.contains("session_created"));
assert!(body.contains("0123456789abcdef"));
assert!(body.contains("192.0.2.40"));
}
#[tokio::test]
async fn render_permits_remain_owned_by_inflight_response_bodies() {
let policy = WebDebugConfig::default();
let limits = crate::config::WebLimitsConfig::default();
let store = WebTraceStore::new(policy.clone(), &limits);
let first = render(None, &store, &policy).await;
let second = render(None, &store, &policy).await;
let busy = render(None, &store, &policy).await;
assert_eq!(busy.status(), StatusCode::SERVICE_UNAVAILABLE);
drop(first);
let admitted = render(None, &store, &policy).await;
assert_eq!(admitted.status(), StatusCode::OK);
drop(second);
drop(admitted);
}
#[test]
fn page_truncation_preserves_utf8_boundary_and_cap() {
let mut html = "я".repeat(MAX_PAGE_BYTES);
truncate_page(&mut html);
assert!(html.len() <= MAX_PAGE_BYTES);
assert!(html.ends_with("[page output truncated]"));
}