use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use reqwest::header::{CONTENT_TYPE, HeaderName, HeaderValue};
use reqwest::{Client, StatusCode};
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use url::Url;
use super::counters::SinkCounters;
use super::{Queue, Record};
const BATCH_RECORDS: usize = 256;
const BATCH_INTERVAL: Duration = Duration::from_secs(2);
const BACKOFF: [Duration; 3] = [
Duration::from_millis(100),
Duration::from_millis(500),
Duration::from_secs(2),
];
const DRAIN_DEADLINE: Duration = Duration::from_secs(5);
pub(super) async fn run(
client: Client,
url: Url,
auth: Option<(HeaderName, HeaderValue)>,
mut queue: Queue,
drain: CancellationToken,
counters: Arc<SinkCounters>,
) {
let mut batch: Vec<Record> = Vec::new();
let mut deadline = Instant::now();
loop {
tokio::select! {
received = queue.recv() => match received {
Some(record) => {
if batch.is_empty() {
deadline = Instant::now() + BATCH_INTERVAL;
}
batch.push(record);
if batch.len() >= BATCH_RECORDS {
send(&client, &url, auth.as_ref(), &mut batch, &counters, &drain).await;
}
}
None => {
if !batch.is_empty() {
send(&client, &url, auth.as_ref(), &mut batch, &counters, &drain).await;
}
break;
}
},
() = tokio::time::sleep_until(deadline), if !batch.is_empty() => {
send(&client, &url, auth.as_ref(), &mut batch, &counters, &drain).await;
}
() = drain.cancelled() => {
let _ = tokio::time::timeout(DRAIN_DEADLINE, async {
while let Some(record) = queue.try_recv() {
batch.push(record);
if batch.len() >= BATCH_RECORDS {
send(&client, &url, auth.as_ref(), &mut batch, &counters, &drain).await;
}
}
if !batch.is_empty() {
send(&client, &url, auth.as_ref(), &mut batch, &counters, &drain).await;
}
})
.await;
let lost = (batch.len() + queue.len()) as u64;
if lost != 0 {
counters.lose(lost);
}
break;
}
}
}
}
async fn send(
client: &Client,
url: &Url,
auth: Option<&(HeaderName, HeaderValue)>,
batch: &mut Vec<Record>,
counters: &SinkCounters,
drain: &CancellationToken,
) {
let mut count = 0;
let mut body = String::new();
for record in batch.drain(..) {
if let Ok(line) = serde_json::to_string(&record) {
body.push_str(&line);
body.push('\n');
count += 1;
} else {
counters.lose(1);
}
}
let mut unsent = Unsent { count, counters };
let body = Bytes::from(body);
let mut attempt = 0;
loop {
let mut request = client
.post(url.clone())
.header(CONTENT_TYPE, "application/x-ndjson")
.body(body.clone());
if let Some((name, value)) = auth {
request = request.header(name.clone(), value.clone());
}
let retryable = match request.send().await {
Ok(response) if response.status().is_success() => {
unsent.delivered();
return;
}
Ok(response) => {
let status = response.status();
tracing::warn!(
status = status.as_u16(),
"the SIEM collector refused a batch of decision records"
);
status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS
}
Err(err) => {
tracing::warn!(
collector = %url.origin().ascii_serialization(),
error = %err.without_url(),
"a batch of decision records could not be sent to the SIEM collector"
);
true
}
};
if !retryable {
break;
}
let backoff = BACKOFF[attempt.min(BACKOFF.len() - 1)];
tokio::select! {
() = tokio::time::sleep(backoff) => {}
() = drain.cancelled() => return,
}
attempt += 1;
}
tracing::warn!(
records = count,
"a batch of decision records was dropped after the SIEM collector refused it"
);
}
struct Unsent<'a> {
count: u64,
counters: &'a SinkCounters,
}
impl Unsent<'_> {
fn delivered(&mut self) {
self.count = 0;
}
}
impl Drop for Unsent<'_> {
fn drop(&mut self) {
if self.count != 0 {
self.counters.lose(self.count);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn rl7_siem_drain_deadline_and_unsent_count_in_the_window() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap();
let hold = tokio::spawn(async move {
let mut held = Vec::new();
while let Ok((stream, _)) = listener.accept().await {
held.push(stream);
}
});
let total = BATCH_RECORDS + 44;
let (sink, queue) = crate::delivery::channel(total);
for _ in 0..total {
sink.push(crate::delivery::tests::decision());
}
let drain = CancellationToken::new();
drain.cancel();
let counters = Arc::new(SinkCounters::new());
run(
Client::new(),
url,
None,
queue,
drain,
Arc::clone(&counters),
)
.await;
hold.abort();
assert_eq!(counters.take_window(), total as u64);
}
#[tokio::test]
async fn run_returns_on_a_closed_queue_and_on_an_empty_drain() {
let url = Url::parse("http://127.0.0.1:9/").unwrap();
let counters = Arc::new(SinkCounters::new());
let (sink, queue) = crate::delivery::channel(1);
drop(sink);
let open = CancellationToken::new();
run(
Client::new(),
url.clone(),
None,
queue,
open,
Arc::clone(&counters),
)
.await;
let (_sink, queue) = crate::delivery::channel(1);
let drain = CancellationToken::new();
drain.cancel();
run(
Client::new(),
url,
None,
queue,
drain,
Arc::clone(&counters),
)
.await;
assert_eq!(counters.total(), 0);
}
#[tokio::test]
async fn a_closed_queue_sends_the_batch_in_hand() {
let collector = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(1)
.mount(&collector)
.await;
let url = Url::parse(&collector.uri()).unwrap();
let (sink, queue) = crate::delivery::channel(3);
for _ in 0..3 {
sink.push(crate::delivery::tests::decision());
}
drop(sink);
let counters = Arc::new(SinkCounters::new());
run(
Client::new(),
url,
None,
queue,
CancellationToken::new(),
Arc::clone(&counters),
)
.await;
assert_eq!(counters.total(), 0, "nothing was lost");
collector.verify().await;
}
#[test]
fn rl7_unsent_counts_an_undelivered_batch() {
let counters = SinkCounters::new();
drop(Unsent {
count: 5,
counters: &counters,
});
let mut delivered = Unsent {
count: 7,
counters: &counters,
};
delivered.delivered();
drop(delivered);
assert_eq!(counters.take_window(), 5);
}
}