mirror of
https://github.com/telemt/telemt.git
synced 2026-11-05 23:32:00 +03:00
WEB Debug + Trace
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
@@ -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