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, LogFormat};
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<LogFormat>,
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 -> "template"` entry (`{{namespace.path}}` references plus legacy `$var` interpolation).
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    fn reads_response_body(&self) -> bool {
281        crate::plugins::util::log_entry::reads_response_body(
282            self.log_format.as_ref(),
283            self.include_resp_body,
284        )
285    }
286
287    async fn execute(&self, ctx: Context) -> PluginResult {
288        let entry = build_entry(
289            &ctx,
290            self.log_format.as_ref(),
291            self.include_req_body,
292            self.include_resp_body,
293        );
294        let json_str = serde_json::to_string(&entry).unwrap_or_else(|_| "null".to_string());
295        let frame = build_syslog_frame(
296            priority(FACILITY_SYSLOG, SEVERITY_INFO),
297            &rfc3339_now(),
298            &ctx.request.host,
299            &self.app_name,
300            self.procid,
301            &json_str,
302        );
303        self.sink.push(Value::String(frame));
304        Ok(PluginOutput::success(ctx))
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use serde_json::json;
312
313    fn base_config() -> HashMap<String, Value> {
314        let mut c = HashMap::new();
315        c.insert("host".to_string(), json!("127.0.0.1"));
316        c
317    }
318
319    #[test]
320    fn priority_matches_apisix() {
321        // SYSLOG facility (5) + INFO severity (6) => 46.
322        assert_eq!(priority(FACILITY_SYSLOG, SEVERITY_INFO), 46);
323        assert_eq!(priority(0, 0), 0);
324        assert_eq!(priority(23, 7), 191);
325    }
326
327    #[test]
328    fn syslog_frame_has_expected_shape() {
329        let frame = build_syslog_frame(
330            46,
331            "2026-07-12T00:00:00.000Z",
332            "example.com",
333            "featherbit",
334            1234,
335            "{\"a\":1}",
336        );
337        assert_eq!(
338            frame,
339            "<46>1 2026-07-12T00:00:00.000Z example.com featherbit 1234 - - {\"a\":1}\n"
340        );
341    }
342
343    #[test]
344    fn syslog_frame_defaults_empty_fields_to_dash() {
345        let frame = build_syslog_frame(46, "T", "", "", 1, "m");
346        assert_eq!(frame, "<46>1 T - - 1 - - m\n");
347    }
348
349    #[test]
350    fn format_rfc3339_known_epoch() {
351        // 2021-01-01T00:00:00Z == 1609459200
352        assert_eq!(format_rfc3339(1_609_459_200, 0), "2021-01-01T00:00:00.000Z");
353        // Unix epoch.
354        assert_eq!(format_rfc3339(0, 5), "1970-01-01T00:00:00.005Z");
355    }
356
357    #[test]
358    fn payload_concatenates_frame_strings() {
359        let entries = vec![json!("a\n"), json!("b\n")];
360        assert_eq!(SyslogFlusher::payload(&entries), "a\nb\n");
361    }
362
363    #[test]
364    fn from_config_rejects_bad_sock_type_and_tls() {
365        let mut c = base_config();
366        c.insert("sock_type".to_string(), json!("sctp"));
367        assert!(SyslogPlugin::from_config(&c).is_err());
368
369        let mut c = base_config();
370        c.insert("tls".to_string(), json!(true));
371        assert!(SyslogPlugin::from_config(&c).err().unwrap().contains("TLS"));
372    }
373
374    #[tokio::test]
375    async fn from_config_ok_defaults_port_5140() {
376        assert!(SyslogPlugin::from_config(&base_config()).is_ok());
377    }
378}