Files
signal-gateway/signal-gateway-log-ingest/src/syslog.rs
T

484 lines
16 KiB
Rust

//! Syslog listener for receiving RFC 5424 syslog messages over UDP and TCP.
//!
//! TCP transport follows RFC 6587, supporting both octet counting and
//! non-transparent framing methods.
use conf::Conf;
use signal_gateway::{Gateway, Level, LogMessage};
use std::{net::SocketAddr, str::FromStr, sync::Arc};
use syslog_rfc5424::{SyslogMessage, SyslogSeverity};
use tokio::{
io::{AsyncRead, AsyncReadExt, BufReader},
net::{TcpListener, TcpStream, UdpSocket},
};
use tracing::{error, info, trace};
/// Configuration for the syslog listener (supports both UDP and TCP).
#[derive(Clone, Conf, Debug)]
#[conf(serde)]
pub struct SyslogConfig {
/// Socket address to listen for syslog messages.
/// Both UDP and TCP listeners are started on this address.
/// Messages use RFC 5424 format; TCP transport follows RFC 6587.
#[conf(long, env)]
pub listen_addr: SocketAddr,
/// Structured data ID for tracing metadata (module, file, line) in syslog messages.
#[conf(long, env, default_value = "tracing-meta@64700")]
pub sd_id: String,
/// If true, use CR (\r) as the message delimiter for non-transparent framing instead of LF (\n).
/// See RFC 6587 (Syslog over TCP) section 3.4.2.
/// Default delimiters: NUL or LF. With this flag: NUL or CR.
#[conf(long, env)]
pub tcp_cr_is_delimiter: bool,
}
impl SyslogConfig {
/// Bind UDP and TCP sockets and start background tasks to handle incoming syslog messages.
///
/// Returns join handles for the background tasks.
pub async fn start_tasks(
&self,
gateway: Arc<Gateway>,
) -> std::io::Result<(tokio::task::JoinHandle<()>, tokio::task::JoinHandle<()>)> {
let udp_handle = self.start_udp_task(gateway.clone()).await?;
let tcp_handle = self.start_tcp_task(gateway).await?;
Ok((udp_handle, tcp_handle))
}
async fn start_udp_task(
&self,
gateway: Arc<Gateway>,
) -> std::io::Result<tokio::task::JoinHandle<()>> {
let udp_socket = UdpSocket::bind(self.listen_addr).await?;
info!("Listening for syslog UDP on {}", self.listen_addr);
let sd_id = self.sd_id.clone();
Ok(tokio::task::spawn(async move {
let mut buf = vec![0u8; 8192];
loop {
let Ok((len, _addr)) = udp_socket
.recv_from(&mut buf)
.await
.inspect_err(|err| error!("Error receiving syslog UDP packet: {err}"))
else {
continue;
};
let Ok(text) = std::str::from_utf8(&buf[0..len])
.inspect_err(|err| error!("Syslog UDP packet was not utf8: {err}"))
else {
continue;
};
let Ok(syslog_msg) = SyslogMessage::from_str(text).inspect_err(|err| {
error!("Syslog UDP packet was not valid syslog: {err}:\n{text}")
}) else {
continue;
};
let log_msg = syslog_to_log_message(syslog_msg, &sd_id);
gateway.handle_log_message(log_msg).await;
}
}))
}
async fn start_tcp_task(
&self,
gateway: Arc<Gateway>,
) -> std::io::Result<tokio::task::JoinHandle<()>> {
let tcp_listener = TcpListener::bind(self.listen_addr).await?;
info!("Listening for syslog TCP on {}", self.listen_addr);
let config = self.clone();
Ok(tokio::task::spawn(async move {
loop {
let Ok((stream, addr)) = tcp_listener
.accept()
.await
.inspect_err(|err| error!("Error accepting syslog TCP connection: {err}"))
else {
continue;
};
trace!("Accepted syslog TCP connection from {addr}");
let gateway = gateway.clone();
let config = config.clone();
// Spawn a task for each connection
tokio::spawn(async move {
if let Err(err) = handle_tcp_connection(stream, &gateway, &config).await {
error!("Syslog TCP connection from {addr} error: {err}");
} else {
trace!("Syslog TCP connection from {addr} closed");
}
});
}
}))
}
}
/// Handle a single TCP connection using RFC 6587 framing.
async fn handle_tcp_connection(
stream: TcpStream,
gateway: &Gateway,
config: &SyslogConfig,
) -> std::io::Result<()> {
let mut reader = BufReader::new(stream);
loop {
let Some(msg_bytes) =
read_framed_syslog_bytes(&mut reader, config.tcp_cr_is_delimiter).await?
else {
return Ok(()); // Clean EOF
};
// Parse and process the message
let Ok(text) = std::str::from_utf8(&msg_bytes)
.inspect_err(|err| error!("Syslog TCP message was not utf8: {err}"))
else {
continue;
};
let Ok(syslog_msg) = SyslogMessage::from_str(text)
.inspect_err(|err| error!("Syslog TCP message was not valid: {err}:\n{text}"))
else {
continue;
};
let log_msg = syslog_to_log_message(syslog_msg, &config.sd_id);
gateway.handle_log_message(log_msg).await;
}
}
/// Read a single framed syslog message using RFC 6587 framing detection.
///
/// Returns:
/// - `Ok(Some(bytes))` - A complete message was read
/// - `Ok(None)` - EOF reached (no more messages)
/// - `Err(InvalidData)` - Invalid framing (e.g., unexpected first byte)
/// - `Err(other)` - I/O error
async fn read_framed_syslog_bytes<R>(
reader: &mut BufReader<R>,
cr_is_delimiter: bool,
) -> std::io::Result<Option<Vec<u8>>>
where
R: AsyncRead + Unpin,
{
loop {
// Read first byte to determine framing method
let first_byte = match reader.read_u8().await {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e),
};
match first_byte {
// Whitespace: skip and continue
b' ' | b'\t' | b'\n' | b'\r' => continue,
// Octet counting: starts with non-zero digit
b'1'..=b'9' => {
return read_octet_counted_message(reader, first_byte)
.await
.map(Some);
}
// Non-transparent framing: starts with '<' (beginning of syslog PRI)
b'<' => {
return read_non_transparent_message(reader, first_byte, cr_is_delimiter)
.await
.map(Some);
}
// Invalid first byte
_ => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"invalid first byte 0x{:02x} ({:?})",
first_byte,
char::from(first_byte)
),
));
}
}
}
}
/// Read an octet-counted message (RFC 6587 section 3.4.1).
/// Format: MSG-LEN SP SYSLOG-MSG
async fn read_octet_counted_message<R>(
reader: &mut BufReader<R>,
first_digit: u8,
) -> std::io::Result<Vec<u8>>
where
R: AsyncRead + Unpin,
{
let mut len: usize = (first_digit - b'0') as usize;
// Read remaining digits until space
loop {
let b = reader.read_u8().await?;
match b {
b'0'..=b'9' => {
len = len
.checked_mul(10)
.and_then(|l| l.checked_add((b - b'0') as usize))
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"message length overflow",
)
})?;
}
b' ' => break,
_ => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("expected digit or space in octet count, got 0x{b:02x}"),
));
}
}
}
// Read exactly `len` bytes
let mut buf = vec![0u8; len];
reader.read_exact(&mut buf).await?;
Ok(buf)
}
/// Read a non-transparent framed message (RFC 6587 section 3.4.2).
/// Delimiter is NUL or LF by default, or NUL or CR if `tcp_cr_is_delimiter` is set.
/// Returns the message buffer on EOF (connection closed) as well as on delimiter.
async fn read_non_transparent_message<R>(
reader: &mut BufReader<R>,
first_byte: u8,
cr_is_delimiter: bool,
) -> std::io::Result<Vec<u8>>
where
R: AsyncRead + Unpin,
{
let mut msg = vec![first_byte];
let line_delimiter = if cr_is_delimiter { b'\r' } else { b'\n' };
loop {
let b = match reader.read_u8().await {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, // EOF
Err(e) => return Err(e),
};
if b == b'\0' || b == line_delimiter {
break;
}
msg.push(b);
}
Ok(msg)
}
/// Convert SyslogSeverity to our Level enum
fn severity_to_level(sev: SyslogSeverity) -> Level {
match sev {
SyslogSeverity::SEV_EMERG => Level::EMERGENCY,
SyslogSeverity::SEV_ALERT => Level::ALERT,
SyslogSeverity::SEV_CRIT => Level::CRITICAL,
SyslogSeverity::SEV_ERR => Level::ERROR,
SyslogSeverity::SEV_WARNING => Level::WARNING,
SyslogSeverity::SEV_NOTICE => Level::NOTICE,
SyslogSeverity::SEV_INFO => Level::INFO,
SyslogSeverity::SEV_DEBUG => Level::DEBUG,
}
}
/// Convert a SyslogMessage to a LogMessage, extracting structured data for tracing metadata
fn syslog_to_log_message(msg: SyslogMessage, sd_id: &str) -> LogMessage {
let level = severity_to_level(msg.severity);
let mut builder = LogMessage::builder(level, msg.msg);
if let Some(ts) = msg.timestamp {
builder = builder.timestamp(ts);
}
if let Some(nanos) = msg.timestamp_nanos {
builder = builder.timestamp_nanos(nanos);
}
if let Some(hostname) = msg.hostname {
builder = builder.hostname(hostname);
}
if let Some(appname) = msg.appname {
builder = builder.appname(appname);
}
// Extract tracing metadata from structured data
if let Some(sd_element) = msg.sd.find_sdid(sd_id) {
if let Some(module) = sd_element.get("module") {
builder = builder.module_path(module.clone());
}
if let Some(file) = sd_element.get("file") {
builder = builder.file(file.clone());
}
if let Some(line) = sd_element.get("line") {
builder = builder.line(line.clone());
}
}
builder.build()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_syslog_parsing() {
SyslogMessage::from_str(
"<12>1 2025-11-08T02:24:10.815221698+00:00 ip-172-31-5-8 app 92748 - - Dropped 3/4 reports",
)
.unwrap();
SyslogMessage::from_str(
"<12>1 2025-11-08T02:24:10.815221698+00:00 ip-172-31-5-8 app 92748 - - Dropped 3/4 reports",
)
.unwrap();
SyslogMessage::from_str(
"<12>1 2025-11-08T02:24:10.815+00:00 ip-172-31-5-8 app 92748 - - Dropped 3/4 reports due to staleness"
)
.unwrap();
}
#[tokio::test]
async fn test_framing_eof_returns_none() {
let data: &[u8] = b"";
let mut reader = BufReader::new(data);
let result = read_framed_syslog_bytes(&mut reader, false).await.unwrap();
assert_eq!(result, None);
}
#[tokio::test]
async fn test_framing_octet_counting() {
let data: &[u8] = b"5 hello";
let mut reader = BufReader::new(data);
let result = read_framed_syslog_bytes(&mut reader, false).await.unwrap();
assert_eq!(result, Some(b"hello".to_vec()));
}
#[tokio::test]
async fn test_framing_octet_counting_multidigit_length() {
let msg = "a]".repeat(50); // 100 bytes
let data = format!("100 {msg}");
let mut reader = BufReader::new(data.as_bytes());
let result = read_framed_syslog_bytes(&mut reader, false).await.unwrap();
assert_eq!(result, Some(msg.as_bytes().to_vec()));
}
#[tokio::test]
async fn test_framing_non_transparent_lf_delimiter() {
let data: &[u8] = b"<14>1 test message\n";
let mut reader = BufReader::new(data);
let result = read_framed_syslog_bytes(&mut reader, false).await.unwrap();
assert_eq!(result, Some(b"<14>1 test message".to_vec()));
}
#[tokio::test]
async fn test_framing_non_transparent_nul_delimiter() {
let data: &[u8] = b"<14>1 test message\0";
let mut reader = BufReader::new(data);
let result = read_framed_syslog_bytes(&mut reader, false).await.unwrap();
assert_eq!(result, Some(b"<14>1 test message".to_vec()));
}
#[tokio::test]
async fn test_framing_non_transparent_cr_delimiter() {
let data: &[u8] = b"<14>1 test message\r";
let mut reader = BufReader::new(data);
// With cr_is_delimiter=true, CR is a delimiter
let result = read_framed_syslog_bytes(&mut reader, true).await.unwrap();
assert_eq!(result, Some(b"<14>1 test message".to_vec()));
}
#[tokio::test]
async fn test_framing_non_transparent_cr_not_delimiter_by_default() {
let data: &[u8] = b"<14>1 test\rmessage\n";
let mut reader = BufReader::new(data);
// With cr_is_delimiter=false, CR is part of the message
let result = read_framed_syslog_bytes(&mut reader, false).await.unwrap();
assert_eq!(result, Some(b"<14>1 test\rmessage".to_vec()));
}
#[tokio::test]
async fn test_framing_non_transparent_eof_returns_buffer() {
// No delimiter, just EOF - should return the accumulated buffer
let data: &[u8] = b"<14>1 test message";
let mut reader = BufReader::new(data);
let result = read_framed_syslog_bytes(&mut reader, false).await.unwrap();
assert_eq!(result, Some(b"<14>1 test message".to_vec()));
}
#[tokio::test]
async fn test_framing_whitespace_skipping() {
let data: &[u8] = b" \n\t\r5 hello";
let mut reader = BufReader::new(data);
let result = read_framed_syslog_bytes(&mut reader, false).await.unwrap();
assert_eq!(result, Some(b"hello".to_vec()));
}
#[tokio::test]
async fn test_framing_whitespace_only_then_eof() {
let data: &[u8] = b" \n\t\r";
let mut reader = BufReader::new(data);
let result = read_framed_syslog_bytes(&mut reader, false).await.unwrap();
assert_eq!(result, None);
}
#[tokio::test]
async fn test_framing_invalid_first_byte() {
let data: &[u8] = b"xyz";
let mut reader = BufReader::new(data);
let result = read_framed_syslog_bytes(&mut reader, false).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
}
#[tokio::test]
async fn test_framing_zero_not_valid_start() {
// '0' is not a valid start for octet counting (must be 1-9)
let data: &[u8] = b"0 hello";
let mut reader = BufReader::new(data);
let result = read_framed_syslog_bytes(&mut reader, false).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
}
#[tokio::test]
async fn test_framing_multiple_messages() {
let data: &[u8] = b"5 hello<14>1 world\n3 foo";
let mut reader = BufReader::new(data);
let r1 = read_framed_syslog_bytes(&mut reader, false).await.unwrap();
assert_eq!(r1, Some(b"hello".to_vec()));
let r2 = read_framed_syslog_bytes(&mut reader, false).await.unwrap();
assert_eq!(r2, Some(b"<14>1 world".to_vec()));
let r3 = read_framed_syslog_bytes(&mut reader, false).await.unwrap();
assert_eq!(r3, Some(b"foo".to_vec()));
let r4 = read_framed_syslog_bytes(&mut reader, false).await.unwrap();
assert_eq!(r4, None);
}
#[tokio::test]
async fn test_framing_octet_counting_invalid_separator() {
// After digits, we expect a space, not another character
let data: &[u8] = b"5xhello";
let mut reader = BufReader::new(data);
let result = read_framed_syslog_bytes(&mut reader, false).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
}
}