use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use time::OffsetDateTime;
fn now_timestamp() -> String {
let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
let format = time::macros::format_description!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3][offset_hour sign:mandatory]:[offset_minute]"
);
now.format(&format).unwrap_or_else(|_| {
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
now.year(),
now.month() as u8,
now.day(),
now.hour(),
now.minute(),
now.second()
)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SecurityEventType {
RateLimitExceeded,
BlockedDomainQuery,
UpstreamFailure,
AclDenied,
MalformedQuery,
QueryTimeout,
}
impl SecurityEventType {
pub fn as_str(&self) -> &'static str {
match self {
Self::RateLimitExceeded => "rate_limit_exceeded",
Self::BlockedDomainQuery => "blocked_domain_query",
Self::UpstreamFailure => "upstream_failure",
Self::AclDenied => "acl_denied",
Self::MalformedQuery => "malformed_query",
Self::QueryTimeout => "query_timeout",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"rate_limit_exceeded" => Some(Self::RateLimitExceeded),
"blocked_domain_query" => Some(Self::BlockedDomainQuery),
"upstream_failure" => Some(Self::UpstreamFailure),
"acl_denied" => Some(Self::AclDenied),
"malformed_query" => Some(Self::MalformedQuery),
"query_timeout" => Some(Self::QueryTimeout),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryLogEntry {
pub timestamp: String,
pub query_id: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_ip: Option<IpAddr>,
pub protocol: String,
pub qname: String,
pub qtype: String,
pub qclass: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub rcode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub answer_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_time_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_time_us: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cached: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub answers: Option<Vec<String>>,
}
impl QueryLogEntry {
pub fn new(
query_id: u16,
protocol: &str,
qname: String,
qtype: String,
qclass: String,
) -> Self {
Self {
timestamp: now_timestamp(),
query_id,
client_ip: None,
protocol: protocol.to_string(),
qname,
qtype,
qclass,
rcode: None,
answer_count: None,
response_time_ms: None,
response_time_us: None,
cached: None,
upstream: None,
answers: None,
}
}
pub fn with_client_ip(mut self, ip: IpAddr) -> Self {
self.client_ip = Some(ip);
self
}
pub fn with_response(
mut self,
rcode: &str,
answer_count: usize,
response_time_ms: u64,
) -> Self {
self.rcode = Some(rcode.to_string());
self.answer_count = Some(answer_count);
self.response_time_ms = Some(response_time_ms);
self
}
pub fn with_response_time_us(mut self, response_time_us: u64) -> Self {
self.response_time_us = Some(response_time_us);
self
}
pub fn with_cached(mut self, cached: bool) -> Self {
self.cached = Some(cached);
self
}
pub fn with_upstream(mut self, upstream: &str) -> Self {
self.upstream = Some(upstream.to_string());
self
}
pub fn with_answers(mut self, answers: Vec<String>) -> Self {
self.answers = Some(answers);
self
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
pub fn to_text(&self) -> String {
let mut parts = vec![self.timestamp.clone(), format!("id={}", self.query_id)];
if let Some(ip) = self.client_ip {
parts.push(format!("client={}", ip));
}
parts.push(format!("proto={}", self.protocol));
parts.push(format!("qname={}", self.qname));
parts.push(format!("qtype={}", self.qtype));
if let Some(ref rcode) = self.rcode {
parts.push(format!("rcode={}", rcode));
}
if let Some(count) = self.answer_count {
parts.push(format!("answers={}", count));
}
if let Some(ms) = self.response_time_ms {
parts.push(format!("time={}ms", ms));
} else {
parts.push("time=-".to_string());
}
if let Some(cached) = self.cached {
parts.push(format!("cached={}", cached));
}
parts.join(" ")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AuditEvent {
Query(QueryLogEntry),
Security {
timestamp: String,
event_type: SecurityEventType,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
client_ip: Option<IpAddr>,
#[serde(skip_serializing_if = "Option::is_none")]
qname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
details: Option<serde_json::Value>,
},
}
impl AuditEvent {
pub fn security(event_type: SecurityEventType, message: impl Into<String>) -> Self {
Self::Security {
timestamp: now_timestamp(),
event_type,
message: message.into(),
client_ip: None,
qname: None,
details: None,
}
}
pub fn security_with_client(
event_type: SecurityEventType,
message: impl Into<String>,
client_ip: Option<IpAddr>,
qname: Option<String>,
) -> Self {
Self::Security {
timestamp: now_timestamp(),
event_type,
message: message.into(),
client_ip,
qname,
details: None,
}
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
match self {
AuditEvent::Security {
timestamp,
event_type,
message,
client_ip,
qname,
details: _,
} => {
let mut result = String::from("{");
result.push_str(&format!(
"\"timestamp\":{},",
serde_json::to_string(timestamp)?
));
result.push_str(&format!(
"\"event_type\":{},",
serde_json::to_string(event_type.as_str())?
));
result.push_str(&format!("\"message\":{},", serde_json::to_string(message)?));
if let Some(ip) = client_ip {
result.push_str(&format!(
"\"client_ip\":{},",
serde_json::to_string(&ip.to_string())?
));
}
if let Some(name) = qname {
result.push_str(&format!("\"qname\":{},", serde_json::to_string(name)?));
}
result.push_str("\"type\":\"security\"}");
Ok(result)
}
_ => serde_json::to_string(self),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
#[test]
fn test_security_event_type_as_str() {
assert_eq!(
SecurityEventType::RateLimitExceeded.as_str(),
"rate_limit_exceeded"
);
assert_eq!(
SecurityEventType::BlockedDomainQuery.as_str(),
"blocked_domain_query"
);
}
#[test]
fn test_security_event_type_from_str() {
assert_eq!(
SecurityEventType::parse("rate_limit_exceeded"),
Some(SecurityEventType::RateLimitExceeded)
);
assert_eq!(SecurityEventType::parse("unknown"), None);
}
#[test]
fn test_query_log_entry_to_json() {
let entry = QueryLogEntry::new(1234, "udp", "example.com".into(), "A".into(), "IN".into())
.with_client_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)))
.with_response("NOERROR", 2, 15);
let json = entry.to_json().unwrap();
assert!(json.contains("example.com"));
assert!(json.contains("192.168.1.100"));
assert!(json.contains("NOERROR"));
}
#[test]
fn test_query_log_entry_to_text() {
let entry = QueryLogEntry::new(1234, "udp", "example.com".into(), "A".into(), "IN".into())
.with_response("NOERROR", 2, 15);
let text = entry.to_text();
assert!(text.contains("id=1234"));
assert!(text.contains("qname=example.com"));
assert!(text.contains("rcode=NOERROR"));
assert!(text.contains("time=15ms"));
}
#[test]
fn test_audit_event_security() {
let event = AuditEvent::security(
SecurityEventType::RateLimitExceeded,
"Client exceeded 100 queries/minute",
);
let json = event.to_json().unwrap();
assert!(json.contains("rate_limit_exceeded"));
assert!(json.contains("exceeded 100"));
}
#[test]
fn test_audit_event_security_with_client() {
let event = AuditEvent::security_with_client(
SecurityEventType::BlockedDomainQuery,
"Query blocked by domain filter",
Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
Some("malware.example.com".into()),
);
let json = event.to_json().unwrap();
assert!(json.contains("blocked_domain_query"));
assert!(json.contains("10.0.0.1"));
assert!(json.contains("malware.example.com"));
}
}