zerodds_security_logging/
syslog.rs1use alloc::string::String;
23use alloc::vec::Vec;
24use std::net::{SocketAddr, UdpSocket};
25use std::sync::Mutex;
26
27use zerodds_security::logging::{LogLevel, LoggingPlugin};
28
29const FACILITY_LOCAL0: u8 = 16;
32
33fn severity_code(l: LogLevel) -> u8 {
34 l as u8
36}
37
38fn priority(level: LogLevel) -> u8 {
39 FACILITY_LOCAL0 * 8 + severity_code(level)
40}
41
42pub struct SyslogLoggingPlugin {
44 min_level: LogLevel,
45 socket: Mutex<UdpSocket>,
46 target: SocketAddr,
47 app_name: String,
48 hostname: String,
49}
50
51impl SyslogLoggingPlugin {
52 pub fn connect(
59 target: SocketAddr,
60 app_name: impl Into<String>,
61 hostname: impl Into<String>,
62 min_level: LogLevel,
63 ) -> std::io::Result<Self> {
64 let socket = UdpSocket::bind("0.0.0.0:0")?;
66 Ok(Self {
67 min_level,
68 socket: Mutex::new(socket),
69 target,
70 app_name: app_name.into(),
71 hostname: hostname.into(),
72 })
73 }
74}
75
76fn hex16(bytes: [u8; 16]) -> String {
77 let mut s = String::with_capacity(32);
78 for b in bytes {
79 s.push_str(&alloc::format!("{b:02x}"));
80 }
81 s
82}
83
84fn escape_msg(s: &str) -> String {
87 s.chars()
88 .map(|c| if c == '\r' || c == '\n' { ' ' } else { c })
89 .collect()
90}
91
92fn build_line(
93 level: LogLevel,
94 participant: [u8; 16],
95 category: &str,
96 message: &str,
97 app_name: &str,
98 hostname: &str,
99) -> Vec<u8> {
100 let pri = priority(level);
104 let line = alloc::format!(
105 "<{pri}>1 - {host} {app} - {cat} - participant={pid} {msg}",
106 host = if hostname.is_empty() { "-" } else { hostname },
107 app = if app_name.is_empty() { "-" } else { app_name },
108 cat = if category.is_empty() { "-" } else { category },
109 pid = hex16(participant),
110 msg = escape_msg(message),
111 );
112 line.into_bytes()
113}
114
115impl LoggingPlugin for SyslogLoggingPlugin {
116 fn log(&self, level: LogLevel, participant: [u8; 16], category: &str, message: &str) {
117 if level > self.min_level {
118 return;
119 }
120 let line = build_line(
121 level,
122 participant,
123 category,
124 message,
125 &self.app_name,
126 &self.hostname,
127 );
128 if let Ok(socket) = self.socket.lock() {
129 let _ = socket.send_to(&line, self.target);
132 }
133 }
134
135 fn plugin_class_id(&self) -> &str {
136 "DDS:Logging:syslog"
137 }
138}
139
140#[cfg(test)]
141#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
142mod tests {
143 use super::*;
144 use std::net::{SocketAddr, UdpSocket};
145 use std::time::Duration;
146
147 #[test]
148 fn priority_combines_facility_and_severity() {
149 assert_eq!(priority(LogLevel::Critical), 130);
151 assert_eq!(priority(LogLevel::Emergency), 128);
153 }
154
155 #[test]
156 fn build_line_rfc5424_shape() {
157 let line = build_line(
158 LogLevel::Warning,
159 [0xAB; 16],
160 "auth.fail",
161 "bad cert",
162 "zerodds",
163 "host1",
164 );
165 let s = std::str::from_utf8(&line).unwrap();
166 assert!(s.starts_with("<132>1 "), "PRI(132) + version(1), got {s}");
167 assert!(s.contains("host1"));
168 assert!(s.contains("zerodds"));
169 assert!(s.contains("auth.fail"));
170 assert!(s.contains("participant=abababab"));
171 assert!(s.ends_with("bad cert"));
172 }
173
174 #[test]
175 fn build_line_escapes_newlines() {
176 let line = build_line(
177 LogLevel::Error,
178 [0u8; 16],
179 "cat",
180 "multi\nline\rmsg",
181 "app",
182 "host",
183 );
184 let s = std::str::from_utf8(&line).unwrap();
185 assert!(!s.contains('\n'));
186 assert!(!s.contains('\r'));
187 }
188
189 #[test]
190 fn roundtrip_udp_receives_formatted_line() {
191 let receiver = UdpSocket::bind("127.0.0.1:0").unwrap();
193 receiver
194 .set_read_timeout(Some(Duration::from_secs(2)))
195 .unwrap();
196 let addr: SocketAddr = receiver.local_addr().unwrap();
197
198 let plugin =
199 SyslogLoggingPlugin::connect(addr, "zerodds", "test-host", LogLevel::Debug).unwrap();
200 plugin.log(
201 LogLevel::Critical,
202 [0x42; 16],
203 "audit.event",
204 "something happened",
205 );
206
207 let mut buf = [0u8; 1024];
208 let (n, _) = receiver.recv_from(&mut buf).expect("udp recv timeout");
209 let got = std::str::from_utf8(&buf[..n]).unwrap();
210 assert!(got.contains("audit.event"));
211 assert!(got.contains("participant=42424242"));
212 assert!(got.contains("something happened"));
213 }
214
215 #[test]
216 fn below_threshold_events_are_dropped_silently() {
217 let receiver = UdpSocket::bind("127.0.0.1:0").unwrap();
218 receiver
219 .set_read_timeout(Some(Duration::from_millis(200)))
220 .unwrap();
221 let addr: SocketAddr = receiver.local_addr().unwrap();
222
223 let plugin = SyslogLoggingPlugin::connect(addr, "app", "host", LogLevel::Error).unwrap();
224 plugin.log(LogLevel::Informational, [0u8; 16], "cat", "ignored");
225
226 let mut buf = [0u8; 1024];
227 assert!(
228 receiver.recv_from(&mut buf).is_err(),
229 "unter min_level muss nix gesendet werden"
230 );
231 }
232
233 #[test]
234 fn plugin_class_id_stable() {
235 let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
236 let plugin = SyslogLoggingPlugin::connect(addr, "x", "y", LogLevel::Debug).unwrap();
237 assert_eq!(plugin.plugin_class_id(), "DDS:Logging:syslog");
238 }
239}