featherbit/plugins/native/
splunk_hec_logging.rs1use std::collections::HashMap;
14use std::sync::Arc;
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16
17use async_trait::async_trait;
18use bytes::Bytes;
19use serde_json::{json, Value};
20
21use crate::batch::{BatchConfig, BatchFlusher, BatchSink, FlushError};
22use crate::context::Context;
23use crate::outbound::{OutboundClient, OutboundRequest};
24use crate::plugins::resources::PluginResources;
25use crate::plugins::util::log_entry::{build_entry, parse_log_format};
26use crate::plugins::{Plugin, PluginOutput, PluginResult};
27
28const DEFAULT_SOURCE: &str = "featherbit-splunk-hec-logging";
29const DEFAULT_SOURCETYPE: &str = "_json";
30
31fn wrap_event(entry: &Value, time: f64, source: &str) -> Value {
33 json!({
34 "time": time,
35 "source": source,
36 "sourcetype": DEFAULT_SOURCETYPE,
37 "event": entry,
38 })
39}
40
41fn build_splunk_body(entries: &[Value], time: f64, source: &str) -> Bytes {
44 let mut buf = String::new();
45 for e in entries {
46 let event = wrap_event(e, time, source);
47 buf.push_str(&serde_json::to_string(&event).unwrap_or_default());
48 }
49 Bytes::from(buf)
50}
51
52struct SplunkFlusher {
54 client: Arc<OutboundClient>,
55 uri: String,
56 token: String,
57 channel: Option<String>,
58 source: String,
59 ssl_verify: bool,
60 timeout: Duration,
61}
62
63#[async_trait]
64impl BatchFlusher for SplunkFlusher {
65 async fn flush(&self, entries: &[Value]) -> Result<(), FlushError> {
66 let now = SystemTime::now()
67 .duration_since(UNIX_EPOCH)
68 .map(|d| d.as_secs_f64())
69 .unwrap_or(0.0);
70 let body = build_splunk_body(entries, now, &self.source);
71
72 let mut headers = vec![
73 ("Content-Type".to_string(), "application/json".to_string()),
74 (
75 "Authorization".to_string(),
76 format!("Splunk {}", self.token),
77 ),
78 ];
79 if let Some(ch) = &self.channel {
80 headers.push(("X-Splunk-Request-Channel".to_string(), ch.clone()));
81 }
82
83 let req = OutboundRequest {
84 method: http::Method::POST,
85 url: self.uri.clone(),
86 headers,
87 body,
88 timeout: self.timeout,
89 ssl_verify: self.ssl_verify,
90 tls: None,
91 };
92 match self.client.request(req).await {
93 Ok(resp) if resp.status == 200 => Ok(()),
94 Ok(resp) => Err(FlushError {
95 message: format!("splunk HEC returned status {}", resp.status),
96 first_fail: None,
97 }),
98 Err(e) => Err(FlushError {
99 message: e.to_string(),
100 first_fail: None,
101 }),
102 }
103 }
104}
105
106pub struct SplunkHecLoggingPlugin {
108 sink: BatchSink,
109 log_format: Option<HashMap<String, Value>>,
110 include_req_body: bool,
111 include_resp_body: bool,
112}
113
114impl SplunkHecLoggingPlugin {
115 pub fn from_config(
144 config: &HashMap<String, Value>,
145 resources: &Arc<PluginResources>,
146 ) -> Result<Self, String> {
147 let endpoint = config.get("endpoint").and_then(|v| v.as_object());
148
149 let get_ep = |key: &str| -> Option<&Value> {
150 endpoint
151 .and_then(|e| e.get(key))
152 .or_else(|| config.get(key))
153 };
154
155 let uri = get_ep("uri")
156 .and_then(|v| v.as_str())
157 .filter(|s| !s.is_empty())
158 .ok_or_else(|| "splunk-hec-logging plugin requires 'endpoint.uri'".to_string())?
159 .to_string();
160 let token = get_ep("token")
161 .and_then(|v| v.as_str())
162 .filter(|s| !s.is_empty())
163 .ok_or_else(|| "splunk-hec-logging plugin requires 'endpoint.token'".to_string())?
164 .to_string();
165 let channel = get_ep("channel")
166 .and_then(|v| v.as_str())
167 .filter(|s| !s.is_empty())
168 .map(|s| s.to_string());
169 let timeout = Duration::from_secs(
170 get_ep("timeout")
171 .and_then(|v| v.as_u64())
172 .unwrap_or(10)
173 .max(1),
174 );
175
176 let source = config
177 .get("source")
178 .and_then(|v| v.as_str())
179 .filter(|s| !s.is_empty())
180 .unwrap_or(DEFAULT_SOURCE)
181 .to_string();
182
183 let ssl_verify = config
184 .get("ssl_verify")
185 .and_then(|v| v.as_bool())
186 .unwrap_or(true);
187
188 let log_format = parse_log_format(config)?;
189 let include_req_body = config
190 .get("include_req_body")
191 .and_then(|v| v.as_bool())
192 .unwrap_or(false);
193 let include_resp_body = config
194 .get("include_resp_body")
195 .and_then(|v| v.as_bool())
196 .unwrap_or(false);
197
198 let batch_cfg = BatchConfig::from_config(config)?;
199 let flusher = Arc::new(SplunkFlusher {
200 client: resources.outbound.clone(),
201 uri,
202 token,
203 channel,
204 source,
205 ssl_verify,
206 timeout,
207 });
208 let sink = BatchSink::spawn("splunk-hec-logging", batch_cfg, flusher);
209
210 Ok(Self {
211 sink,
212 log_format,
213 include_req_body,
214 include_resp_body,
215 })
216 }
217}
218
219#[async_trait]
220impl Plugin for SplunkHecLoggingPlugin {
221 fn plugin_type(&self) -> &str {
222 "splunk-hec-logging"
223 }
224
225 async fn execute(&self, ctx: Context, _named_inputs: &HashMap<String, Value>) -> PluginResult {
226 let entry = build_entry(
227 &ctx,
228 self.log_format.as_ref(),
229 self.include_req_body,
230 self.include_resp_body,
231 );
232 self.sink.push(entry);
233 Ok(PluginOutput {
234 context: ctx,
235 named_outputs: HashMap::new(),
236 })
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use serde_json::json;
244
245 fn cfg(v: Value) -> HashMap<String, Value> {
246 serde_json::from_value(v).unwrap()
247 }
248
249 #[test]
250 fn rejects_missing_uri_or_token() {
251 assert!(
253 SplunkHecLoggingPlugin::from_config(&HashMap::new(), &PluginResources::empty())
254 .is_err()
255 );
256 let c = cfg(json!({ "endpoint": { "uri": "https://splunk:8088/x" } }));
258 assert!(SplunkHecLoggingPlugin::from_config(&c, &PluginResources::empty()).is_err());
259 }
260
261 #[tokio::test]
262 async fn accepts_uri_and_token() {
263 let c = cfg(json!({ "endpoint": { "uri": "https://splunk:8088/x", "token": "tok" } }));
264 assert!(SplunkHecLoggingPlugin::from_config(&c, &PluginResources::empty()).is_ok());
265 }
266
267 #[test]
268 fn event_envelope_wraps_entry() {
269 let entry = json!({ "request": { "method": "GET" } });
270 let ev = wrap_event(&entry, 1.5, "src");
271 assert_eq!(ev["source"], "src");
272 assert_eq!(ev["sourcetype"], "_json");
273 assert_eq!(ev["time"], 1.5);
274 assert_eq!(ev["event"]["request"]["method"], "GET");
275 }
276
277 #[test]
278 fn body_concatenates_events_without_separator() {
279 let entries = vec![json!({"a": 1}), json!({"a": 2})];
280 let body = build_splunk_body(&entries, 0.0, DEFAULT_SOURCE);
281 let text = String::from_utf8(body.to_vec()).unwrap();
282 assert!(text.contains("}{"));
284 assert!(!text.contains("},{"));
285 assert_eq!(text.matches("\"event\"").count(), 2);
286 }
287}