Skip to main content

featherbit/plugins/native/
syslog.rs

1//! The `syslog` node — ships per-request access-log entries to a remote syslog
2//! server as RFC 5424 framed messages, over TCP or UDP, mirroring APISIX's
3//! `syslog` plugin (`apisix/plugins/syslog/init.lua`).
4//!
5//! Each entry is built with the shared [`build_entry`](crate::plugins::util::log_entry)
6//! helper, JSON-encoded, and wrapped in an RFC 5424 header
7//! (`<priority>1 timestamp hostname app-name procid - msg`) exactly as APISIX
8//! does via `apisix/utils/rfc5424.lua`. APISIX hard-codes facility `SYSLOG` (5)
9//! and severity `INFO` (6), giving priority `5*8+6 = 46`; the hostname is the
10//! request `Host` and the procid is the gateway process id. The framed strings
11//! are buffered in a [`BatchSink`] and flushed as a single concatenated payload
12//! over a fresh TCP or UDP socket per flush.
13//!
14//! Delivery is fire-and-forget on the request path — [`Plugin::execute`] pushes
15//! the framed message and returns the context unchanged — so place this node in
16//! the response pipeline **after the `upstream` node**.
17//!
18//! ## Deviations from APISIX
19//! - `tls` is **not yet supported**; `tls: true` is rejected at config load.
20//! - `flush_limit`, `drop_limit`, and `pool_size` are accepted for schema
21//!   compatibility but not honored; batching is governed by the shared
22//!   [`BatchConfig`] knobs instead.
23
24use 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
38/// Syslog facility `SYSLOG` (5), matching APISIX's hard-coded choice.
39const FACILITY_SYSLOG: u8 = 5;
40/// Syslog severity `INFO` (6), matching APISIX's hard-coded choice.
41const SEVERITY_INFO: u8 = 6;
42
43/// RFC 5424 priority value: `facility * 8 + severity`.
44fn priority(facility: u8, severity: u8) -> u16 {
45    facility as u16 * 8 + severity as u16
46}
47
48/// Builds one RFC 5424 syslog frame. Pure and independent of the clock/network
49/// (the timestamp is passed in) so it can be unit-tested against a known value.
50///
51/// Shape: `<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID - MSG\n`
52/// (MSGID and STRUCTURED-DATA are `-`, matching APISIX's encoder).
53fn 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
66/// Current time as an RFC 3339 "Zulu" timestamp (`YYYY-MM-DDTHH:MM:SS.mmmZ`),
67/// matching APISIX's `get_rfc3339_zulu_timestamp`.
68fn 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
75/// Formats an epoch-seconds/millis pair as an RFC 3339 Zulu timestamp using the
76/// civil-from-days algorithm (no external date crate).
77fn 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    // Howard Hinnant's days-from-civil, inverted.
87    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/// Transport for the syslog socket.
105#[derive(Clone, Copy, PartialEq, Eq, Debug)]
106enum SockType {
107    Tcp,
108    Udp,
109}
110
111/// [`BatchFlusher`] that concatenates the framed messages and sends them over
112/// TCP or UDP.
113struct SyslogFlusher {
114    host: String,
115    port: u16,
116    sock_type: SockType,
117    timeout: Duration,
118}
119
120impl SyslogFlusher {
121    /// Concatenates the buffered frame strings into one payload.
122    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
174/// The `syslog` plugin node.
175pub 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    /// Builds the plugin from node config.
186    ///
187    /// Config keys:
188    /// - `host` (string, **required**): syslog server hostname or IP.
189    /// - `port` (integer, default `5140`): syslog server port.
190    /// - `sock_type` (`"tcp"` | `"udp"`, default `"tcp"`): transport.
191    /// - `timeout` (integer ms, default `3000`): connect/send timeout.
192    /// - `tls` (bool, default `false`): **not yet supported** — `true` is rejected.
193    /// - `flush_limit`, `drop_limit`, `pool_size`: accepted, not honored.
194    /// - `log_format` (object): custom `name -> "$var"` entry.
195    /// - `include_req_body` / `include_resp_body` (bool, default `false`).
196    /// - batch keys — see [`BatchConfig::from_config`].
197    ///
198    /// ```yaml
199    /// - id: syslog
200    ///   type: syslog
201    ///   config:
202    ///     host: 127.0.0.1
203    ///     port: 5140
204    ///     sock_type: tcp
205    /// ```
206    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        // APISIX syslog `timeout` is in milliseconds.
238        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        // SYSLOG facility (5) + INFO severity (6) => 46.
318        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        // 2021-01-01T00:00:00Z == 1609459200
348        assert_eq!(format_rfc3339(1_609_459_200, 0), "2021-01-01T00:00:00.000Z");
349        // Unix epoch.
350        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}