mirror of
https://github.com/telemt/telemt.git
synced 2026-09-06 10:36:06 +03:00
WEB Debug + Trace
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
+15
-2
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("&"),
|
||||
'<' => output.push_str("<"),
|
||||
'>' => output.push_str(">"),
|
||||
'"' => output.push_str("""),
|
||||
'\'' => output.push_str("'"),
|
||||
_ => 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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, "<script a='"'>&");
|
||||
}
|
||||
|
||||
#[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]"));
|
||||
}
|
||||
Reference in New Issue
Block a user