mirror of
https://github.com/telemt/telemt.git
synced 2026-09-05 18:16:06 +03:00
177 lines
6.0 KiB
Rust
177 lines
6.0 KiB
Rust
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)
|
|
}
|