use std::{
io::Write,
net::{TcpStream, UdpSocket},
};
use async_trait::async_trait;
use chrono::Utc;
use parking_lot::Mutex;
use super::audit::{AuditEntry, AuditError, AuditExporter, AuditLevel, SyslogExportConfig};
const SD_ID: &str = "audit@49152";
const MAX_UDP_MSG_LEN: usize = 2048;
pub(crate) enum Transport {
Udp(UdpSocket),
Tcp(TcpStream),
}
impl Transport {
fn send(&mut self, msg: &[u8]) -> std::io::Result<()> {
match self {
Transport::Udp(sock) => {
let end = msg.len().min(MAX_UDP_MSG_LEN);
sock.send(&msg[..end])?;
Ok(())
},
Transport::Tcp(stream) => {
stream.write_all(msg)?;
stream.write_all(b"\n")?;
Ok(())
},
}
}
}
pub struct SyslogAuditExporter {
pub(crate) transport: Mutex<Transport>,
pub(crate) hostname: String,
}
impl SyslogAuditExporter {
pub fn new(config: &SyslogExportConfig) -> Result<Self, AuditError> {
let addr = format!("{}:{}", config.address, config.port);
let transport = if config.protocol.as_str() == "tcp" {
let stream = TcpStream::connect(&addr)
.map_err(|e| AuditError::Export(format!("syslog TCP connect to {addr}: {e}")))?;
Transport::Tcp(stream)
} else {
let sock = UdpSocket::bind("0.0.0.0:0")
.map_err(|e| AuditError::Export(format!("syslog UDP bind: {e}")))?;
sock.connect(&addr)
.map_err(|e| AuditError::Export(format!("syslog UDP connect to {addr}: {e}")))?;
Transport::Udp(sock)
};
let hostname = hostname::get()
.ok()
.and_then(|h| h.into_string().ok())
.unwrap_or_else(|| "fraiseql".to_string());
Ok(Self {
transport: Mutex::new(transport),
hostname,
})
}
pub(crate) const fn severity(level: AuditLevel) -> u8 {
match level {
AuditLevel::INFO => 6, AuditLevel::WARN => 4, AuditLevel::ERROR => 3, }
}
pub(crate) fn format_message(&self, entry: &AuditEntry) -> String {
let severity = Self::severity(entry.level);
let priority = 80 + u16::from(severity);
let timestamp = Utc::now().to_rfc3339();
let error_part = entry
.error
.as_deref()
.map_or(String::new(), |e| format!(" error=\"{}\"", escape_sd_value(e)));
let sd = format!(
"[{SD_ID} user=\"{}\" tenant=\"{}\" operation=\"{}\" level=\"{}\"{error_part}]",
entry.user_id,
entry.tenant_id,
escape_sd_value(&entry.operation),
entry.level.as_str(),
);
let query_preview = if entry.query.len() > 200 {
format!("{}...", &entry.query[..200])
} else {
entry.query.clone()
};
format!(
"<{priority}>1 {timestamp} {} fraiseql - AUDIT {sd} {query_preview}",
self.hostname,
)
}
}
#[async_trait]
impl AuditExporter for SyslogAuditExporter {
async fn export(&self, entry: &AuditEntry) -> Result<(), AuditError> {
let msg = self.format_message(entry);
self.transport
.lock()
.send(msg.as_bytes())
.map_err(|e| AuditError::Export(format!("syslog send: {e}")))?;
Ok(())
}
async fn flush(&self) -> Result<(), AuditError> {
Ok(())
}
}
pub(crate) fn escape_sd_value(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"").replace(']', "\\]")
}