Skip to main content

zerodds_security_logging/
syslog.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Syslog-RFC-5424-UDP-Backend.
5//!
6//! Schreibt Security-Events als RFC-5424-formatierte Nachrichten an
7//! einen Syslog-Collector via UDP (Port 514 default). Beispiel-Wire:
8//!
9//! ```text
10//! <14>1 2026-04-24T11:22:33Z zerodds - AUTH - - bad cert from peer
11//! ```
12//!
13//! `<14>` = Facility 1 (user) × 8 + Severity 6 (info) — wird aus dem
14//! `LogLevel` berechnet. Facility ist hart auf `LOCAL0` (16 × 8 = 128).
15//!
16//! # Nicht enthalten
17//!
18//! * TCP-Transport (RFC 5425) — folgt bei Bedarf.
19//! * Structured-Data-Blocks `[sd-id@...]` — wir nutzen `-` (nil).
20//! * TLS — die meisten Syslog-Deployments laufen im vertrauten Segment.
21
22use alloc::string::String;
23use alloc::vec::Vec;
24use std::net::{SocketAddr, UdpSocket};
25use std::sync::Mutex;
26
27use zerodds_security::logging::{LogLevel, LoggingPlugin};
28
29/// Facility-Code nach RFC 5424 §6.2.1. Wir fixen auf `LOCAL0` (16) —
30/// konventionell fuer Application-Security-Logs.
31const FACILITY_LOCAL0: u8 = 16;
32
33fn severity_code(l: LogLevel) -> u8 {
34    // RFC 5424 §6.2.1: gleiche Zahlen 0..7 wie LogLevel.
35    l as u8
36}
37
38fn priority(level: LogLevel) -> u8 {
39    FACILITY_LOCAL0 * 8 + severity_code(level)
40}
41
42/// UDP-basierter Syslog-Client.
43pub 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    /// Verbindet zu einem Syslog-Collector.
53    ///
54    /// # Errors
55    /// `io::Error` wenn das UDP-Socket nicht gebunden werden kann
56    /// (nicht das Connect — UDP ist connectionless; wir binden nur
57    /// lokal und senden dann per `send_to`).
58    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        // 0.0.0.0:0 = Kernel waehlt einen ephemeralen Port.
65        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
84/// Escapes fuer den MSG-Teil: RFC-5424 akzeptiert UTF-8, aber
85/// CR/LF muessen raus damit die Zeile im Collector nicht zerrissen wird.
86fn 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    // <PRI>VERSION TIMESTAMP HOSTNAME APP-NAME PROCID MSGID STRUCTURED-DATA MSG
101    // Wir lassen TIMESTAMP als "-" (nil), weil wir hier keinen
102    // globalen Time-Wrapper bauen wollen (Caller/Collector ergaenzt).
103    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            // send_to failt still wenn der Collector down ist —
130            // Security-Logs sollen App nicht crashen lassen.
131            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        // Facility LOCAL0 = 16. Severity Critical = 2. PRI = 16*8+2 = 130.
150        assert_eq!(priority(LogLevel::Critical), 130);
151        // Severity Emergency = 0 → PRI = 128.
152        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        // Kernel-ephemeralen Port binden, Syslog-Plugin zeigt dorthin.
192        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}