use std::borrow::Cow;
use std::io::Write;
use flowscope::Timestamp;
use crate::anomaly::Severity;
use crate::anomaly::key::Key;
use crate::anomaly::sink::AnomalySink;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SyslogFacility {
User,
Daemon,
Auth,
#[default]
Local0,
Local1,
Local2,
Local3,
Local4,
Local5,
Local6,
Local7,
}
impl SyslogFacility {
pub const fn code(self) -> u8 {
match self {
SyslogFacility::User => 1,
SyslogFacility::Daemon => 3,
SyslogFacility::Auth => 4,
SyslogFacility::Local0 => 16,
SyslogFacility::Local1 => 17,
SyslogFacility::Local2 => 18,
SyslogFacility::Local3 => 19,
SyslogFacility::Local4 => 20,
SyslogFacility::Local5 => 21,
SyslogFacility::Local6 => 22,
SyslogFacility::Local7 => 23,
}
}
}
const fn syslog_severity(sev: Severity) -> u8 {
match sev {
Severity::Critical => 2,
Severity::Error => 3,
Severity::Warning => 4,
Severity::Info => 6,
}
}
pub struct SyslogSink<W: Write + Send> {
writer: W,
app_name: Cow<'static, str>,
hostname: Cow<'static, str>,
procid: Cow<'static, str>,
facility: SyslogFacility,
enterprise_id: Cow<'static, str>,
scratch: String,
}
impl<W: Write + Send> SyslogSink<W> {
pub fn new(writer: W) -> Self {
Self {
writer,
app_name: Cow::Borrowed("netring"),
hostname: Cow::Borrowed("-"),
procid: Cow::Borrowed("-"),
facility: SyslogFacility::Local0,
enterprise_id: Cow::Borrowed("32473"),
scratch: String::with_capacity(256),
}
}
pub fn app_name(mut self, name: impl Into<Cow<'static, str>>) -> Self {
self.app_name = name.into();
self
}
pub fn hostname(mut self, host: impl Into<Cow<'static, str>>) -> Self {
self.hostname = host.into();
self
}
pub fn procid(mut self, procid: impl Into<Cow<'static, str>>) -> Self {
self.procid = procid.into();
self
}
pub fn facility(mut self, facility: SyslogFacility) -> Self {
self.facility = facility;
self
}
pub fn enterprise_id(mut self, pen: impl Into<Cow<'static, str>>) -> Self {
self.enterprise_id = pen.into();
self
}
pub fn into_inner(self) -> W {
self.writer
}
}
impl SyslogSink<std::io::Stdout> {
pub fn stdout() -> Self {
Self::new(std::io::stdout())
}
}
#[allow(clippy::too_many_arguments)]
fn render_rfc5424(
out: &mut String,
facility: SyslogFacility,
severity: Severity,
app_name: &str,
hostname: &str,
procid: &str,
enterprise_id: &str,
kind: &'static str,
ts: Timestamp,
key: Option<&dyn Key>,
observations: &[(&'static str, Cow<'_, str>)],
metrics: &[(&'static str, f64)],
) {
use std::fmt::Write as _;
out.clear();
let pri = (facility.code() as u16) * 8 + syslog_severity(severity) as u16;
let _ = write!(
out,
"<{pri}>1 {ts} {host} {app} {procid} {msgid} ",
ts = ts.to_iso8601(),
host = nilable(hostname),
app = nilable(app_name),
procid = nilable(procid),
msgid = nilable(kind),
);
let has_sd = key.is_some() || !observations.is_empty() || !metrics.is_empty();
if has_sd {
let _ = write!(out, "[netring@{enterprise_id}");
if let Some(k) = key {
out.push_str(" key=\"");
let mut keybuf = String::new();
let _ = write!(keybuf, "{k:?}");
push_sd_escaped(out, &keybuf);
out.push('"');
}
for (label, value) in observations {
let _ = write!(out, " {label}=\"");
push_sd_escaped(out, value);
out.push('"');
}
for (label, value) in metrics {
let _ = write!(out, " {label}=\"{value}\"");
}
out.push(']');
} else {
out.push('-');
}
let _ = write!(out, " {kind}");
for (label, value) in observations {
let _ = write!(out, " {label}={value}");
}
}
fn nilable(s: &str) -> &str {
if s.is_empty() { "-" } else { s }
}
fn push_sd_escaped(out: &mut String, s: &str) {
for ch in s.chars() {
if matches!(ch, '"' | '\\' | ']') {
out.push('\\');
}
out.push(ch);
}
}
impl<W: Write + Send> AnomalySink for SyslogSink<W> {
fn write(
&mut self,
kind: &'static str,
severity: Severity,
ts: Timestamp,
key: Option<&dyn Key>,
observations: &[(&'static str, Cow<'_, str>)],
metrics: &[(&'static str, f64)],
) {
render_rfc5424(
&mut self.scratch,
self.facility,
severity,
&self.app_name,
&self.hostname,
&self.procid,
&self.enterprise_id,
kind,
ts,
key,
observations,
metrics,
);
let _ = writeln!(self.writer, "{}", self.scratch);
}
fn flush(&mut self) -> Result<(), std::io::Error> {
self.writer.flush()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ts_fixed() -> Timestamp {
Timestamp::from_unix_f64(1_780_790_400.0)
}
fn render(
sev: Severity,
kind: &'static str,
obs: &[(&'static str, Cow<'_, str>)],
metrics: &[(&'static str, f64)],
) -> String {
let mut out = String::new();
render_rfc5424(
&mut out,
SyslogFacility::Local0,
sev,
"netring",
"host1",
"-",
"32473",
kind,
ts_fixed(),
None,
obs,
metrics,
);
out
}
#[test]
fn pri_is_facility_times_8_plus_severity() {
let line = render(Severity::Warning, "port_scan", &[], &[]);
assert!(line.starts_with("<132>1 "), "line = {line}");
let crit = render(Severity::Critical, "exfil", &[], &[]);
assert!(crit.starts_with("<130>1 "), "crit = {crit}");
}
#[test]
fn header_fields_and_no_sd_render_as_nilvalue() {
let line = render(Severity::Info, "heartbeat", &[], &[]);
assert_eq!(
line,
"<134>1 2026-06-07T00:00:00.000000000Z host1 netring - heartbeat - heartbeat"
);
}
#[test]
fn structured_data_carries_observations_and_metrics() {
let line = render(
Severity::Error,
"dns_burst",
&[("src", Cow::Borrowed("10.0.0.5"))],
&[("qps", 142.0)],
);
assert!(
line.contains("[netring@32473 src=\"10.0.0.5\" qps=\"142\"]"),
"line = {line}"
);
assert!(line.ends_with(" dns_burst src=10.0.0.5"), "line = {line}");
}
#[test]
fn sd_param_values_are_escaped() {
let mut out = String::new();
push_sd_escaped(&mut out, r#"a"b\c]d"#);
assert_eq!(out, r#"a\"b\\c\]d"#);
}
#[test]
fn sink_writes_one_line_per_anomaly() {
let mut sink = SyslogSink::new(Vec::<u8>::new()).hostname("h");
sink.write("k1", Severity::Info, ts_fixed(), None, &[], &[]);
sink.write("k2", Severity::Warning, ts_fixed(), None, &[], &[]);
let out = String::from_utf8(sink.into_inner()).unwrap();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains(" k1 "));
assert!(lines[1].starts_with("<132>1 ")); }
}