featherbit/plugins/native/
syslog.rs1use std::collections::HashMap;
25use std::sync::Arc;
26use std::time::Duration;
27
28use async_trait::async_trait;
29use serde_json::Value;
30use tokio::io::AsyncWriteExt;
31use tokio::net::{TcpStream, UdpSocket};
32
33use crate::batch::{BatchConfig, BatchFlusher, BatchSink, FlushError};
34use crate::context::Context;
35use crate::plugins::util::log_entry::{build_entry, parse_log_format};
36use crate::plugins::{Plugin, PluginOutput, PluginResult};
37
38const FACILITY_SYSLOG: u8 = 5;
40const SEVERITY_INFO: u8 = 6;
42
43fn priority(facility: u8, severity: u8) -> u16 {
45 facility as u16 * 8 + severity as u16
46}
47
48fn build_syslog_frame(
54 pri: u16,
55 timestamp: &str,
56 hostname: &str,
57 app_name: &str,
58 procid: u32,
59 msg: &str,
60) -> String {
61 let hostname = if hostname.is_empty() { "-" } else { hostname };
62 let app_name = if app_name.is_empty() { "-" } else { app_name };
63 format!("<{pri}>1 {timestamp} {hostname} {app_name} {procid} - - {msg}\n")
64}
65
66fn rfc3339_now() -> String {
69 let now = std::time::SystemTime::now()
70 .duration_since(std::time::UNIX_EPOCH)
71 .unwrap_or_default();
72 format_rfc3339(now.as_secs(), now.subsec_millis())
73}
74
75fn format_rfc3339(epoch_secs: u64, millis: u32) -> String {
78 let days = (epoch_secs / 86_400) as i64;
79 let secs_of_day = epoch_secs % 86_400;
80 let (hour, minute, second) = (
81 secs_of_day / 3600,
82 (secs_of_day % 3600) / 60,
83 secs_of_day % 60,
84 );
85
86 let z = days + 719_468;
88 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
89 let doe = z - era * 146_097;
90 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
91 let year = yoe + era * 400;
92 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
93 let mp = (5 * doy + 2) / 153;
94 let day = doy - (153 * mp + 2) / 5 + 1;
95 let month = if mp < 10 { mp + 3 } else { mp - 9 };
96 let year = if month <= 2 { year + 1 } else { year };
97
98 format!(
99 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
100 year, month, day, hour, minute, second, millis
101 )
102}
103
104#[derive(Clone, Copy, PartialEq, Eq, Debug)]
106enum SockType {
107 Tcp,
108 Udp,
109}
110
111struct SyslogFlusher {
114 host: String,
115 port: u16,
116 sock_type: SockType,
117 timeout: Duration,
118}
119
120impl SyslogFlusher {
121 fn payload(entries: &[Value]) -> String {
123 let mut out = String::new();
124 for entry in entries {
125 if let Some(s) = entry.as_str() {
126 out.push_str(s);
127 }
128 }
129 out
130 }
131
132 async fn send(&self, payload: &[u8]) -> Result<(), String> {
133 let addr = format!("{}:{}", self.host, self.port);
134 match self.sock_type {
135 SockType::Tcp => {
136 let mut stream = tokio::time::timeout(self.timeout, TcpStream::connect(&addr))
137 .await
138 .map_err(|_| format!("timed out connecting to syslog TCP {addr}"))?
139 .map_err(|e| format!("failed to connect to syslog TCP {addr}: {e}"))?;
140 tokio::time::timeout(self.timeout, stream.write_all(payload))
141 .await
142 .map_err(|_| format!("timed out sending to syslog TCP {addr}"))?
143 .map_err(|e| format!("failed to send to syslog TCP {addr}: {e}"))?;
144 let _ = stream.shutdown().await;
145 Ok(())
146 }
147 SockType::Udp => {
148 let socket = UdpSocket::bind("0.0.0.0:0")
149 .await
150 .map_err(|e| format!("failed to bind UDP socket: {e}"))?;
151 tokio::time::timeout(self.timeout, socket.send_to(payload, &addr))
152 .await
153 .map_err(|_| format!("timed out sending to syslog UDP {addr}"))?
154 .map_err(|e| format!("failed to send to syslog UDP {addr}: {e}"))?;
155 Ok(())
156 }
157 }
158 }
159}
160
161#[async_trait]
162impl BatchFlusher for SyslogFlusher {
163 async fn flush(&self, entries: &[Value]) -> Result<(), FlushError> {
164 let payload = Self::payload(entries);
165 self.send(payload.as_bytes())
166 .await
167 .map_err(|message| FlushError {
168 message,
169 first_fail: None,
170 })
171 }
172}
173
174pub struct SyslogPlugin {
176 sink: BatchSink,
177 log_format: Option<HashMap<String, Value>>,
178 include_req_body: bool,
179 include_resp_body: bool,
180 app_name: String,
181 procid: u32,
182}
183
184impl SyslogPlugin {
185 pub fn from_config(config: &HashMap<String, Value>) -> Result<Self, String> {
207 let host = config
208 .get("host")
209 .and_then(|v| v.as_str())
210 .filter(|s| !s.is_empty())
211 .ok_or("syslog: `host` is required")?
212 .to_string();
213 let port = config
214 .get("port")
215 .and_then(|v| v.as_u64())
216 .filter(|p| *p <= u16::MAX as u64)
217 .unwrap_or(5140) as u16;
218
219 if config.get("tls").and_then(|v| v.as_bool()).unwrap_or(false) {
220 return Err("syslog: TLS (`tls: true`) is not yet supported".to_string());
221 }
222
223 let sock_type = match config
224 .get("sock_type")
225 .and_then(|v| v.as_str())
226 .unwrap_or("tcp")
227 {
228 "tcp" => SockType::Tcp,
229 "udp" => SockType::Udp,
230 other => {
231 return Err(format!(
232 "syslog: `sock_type` must be tcp or udp, got {other}"
233 ))
234 }
235 };
236
237 let timeout_ms = config
239 .get("timeout")
240 .and_then(|v| v.as_u64())
241 .unwrap_or(3000);
242 let timeout = Duration::from_millis(timeout_ms.max(1));
243
244 let log_format = parse_log_format(config)?;
245 let include_req_body = config
246 .get("include_req_body")
247 .and_then(|v| v.as_bool())
248 .unwrap_or(false);
249 let include_resp_body = config
250 .get("include_resp_body")
251 .and_then(|v| v.as_bool())
252 .unwrap_or(false);
253
254 let batch_cfg = BatchConfig::from_config(config)?;
255 let flusher = Arc::new(SyslogFlusher {
256 host: host.clone(),
257 port,
258 sock_type,
259 timeout,
260 });
261 let sink = BatchSink::spawn(&format!("syslog:{host}:{port}"), batch_cfg, flusher);
262
263 Ok(Self {
264 sink,
265 log_format,
266 include_req_body,
267 include_resp_body,
268 app_name: "featherbit".to_string(),
269 procid: std::process::id(),
270 })
271 }
272}
273
274#[async_trait]
275impl Plugin for SyslogPlugin {
276 fn plugin_type(&self) -> &str {
277 "syslog"
278 }
279
280 async fn execute(&self, ctx: Context, _named_inputs: &HashMap<String, Value>) -> PluginResult {
281 let entry = build_entry(
282 &ctx,
283 self.log_format.as_ref(),
284 self.include_req_body,
285 self.include_resp_body,
286 );
287 let json_str = serde_json::to_string(&entry).unwrap_or_else(|_| "null".to_string());
288 let frame = build_syslog_frame(
289 priority(FACILITY_SYSLOG, SEVERITY_INFO),
290 &rfc3339_now(),
291 &ctx.request.host,
292 &self.app_name,
293 self.procid,
294 &json_str,
295 );
296 self.sink.push(Value::String(frame));
297 Ok(PluginOutput {
298 context: ctx,
299 named_outputs: HashMap::new(),
300 })
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use serde_json::json;
308
309 fn base_config() -> HashMap<String, Value> {
310 let mut c = HashMap::new();
311 c.insert("host".to_string(), json!("127.0.0.1"));
312 c
313 }
314
315 #[test]
316 fn priority_matches_apisix() {
317 assert_eq!(priority(FACILITY_SYSLOG, SEVERITY_INFO), 46);
319 assert_eq!(priority(0, 0), 0);
320 assert_eq!(priority(23, 7), 191);
321 }
322
323 #[test]
324 fn syslog_frame_has_expected_shape() {
325 let frame = build_syslog_frame(
326 46,
327 "2026-07-12T00:00:00.000Z",
328 "example.com",
329 "featherbit",
330 1234,
331 "{\"a\":1}",
332 );
333 assert_eq!(
334 frame,
335 "<46>1 2026-07-12T00:00:00.000Z example.com featherbit 1234 - - {\"a\":1}\n"
336 );
337 }
338
339 #[test]
340 fn syslog_frame_defaults_empty_fields_to_dash() {
341 let frame = build_syslog_frame(46, "T", "", "", 1, "m");
342 assert_eq!(frame, "<46>1 T - - 1 - - m\n");
343 }
344
345 #[test]
346 fn format_rfc3339_known_epoch() {
347 assert_eq!(format_rfc3339(1_609_459_200, 0), "2021-01-01T00:00:00.000Z");
349 assert_eq!(format_rfc3339(0, 5), "1970-01-01T00:00:00.005Z");
351 }
352
353 #[test]
354 fn payload_concatenates_frame_strings() {
355 let entries = vec![json!("a\n"), json!("b\n")];
356 assert_eq!(SyslogFlusher::payload(&entries), "a\nb\n");
357 }
358
359 #[test]
360 fn from_config_rejects_bad_sock_type_and_tls() {
361 let mut c = base_config();
362 c.insert("sock_type".to_string(), json!("sctp"));
363 assert!(SyslogPlugin::from_config(&c).is_err());
364
365 let mut c = base_config();
366 c.insert("tls".to_string(), json!(true));
367 assert!(SyslogPlugin::from_config(&c).err().unwrap().contains("TLS"));
368 }
369
370 #[tokio::test]
371 async fn from_config_ok_defaults_port_5140() {
372 assert!(SyslogPlugin::from_config(&base_config()).is_ok());
373 }
374}