sysknife_daemon/audit_forward.rs
1//! External audit log forwarding (RFC 5424 syslog over UDP).
2//!
3//! After each `transactions` row is recorded, the daemon hands a structured
4//! `AuditEvent` to a per-sink forwarder task over a bounded
5//! [`tokio::sync::mpsc::channel`]. The forwarder formats the event per the
6//! configured wire protocol and emits it. **Forwarding is fire-and-forget**:
7//! channel send failures (`try_send` returning `Full`/`Closed`) increment a
8//! drop counter and emit an `eprintln!` warning after `DROP_WARN_THRESHOLD`
9//! consecutive drops; the audit-log INSERT path itself never awaits or fails.
10//!
11//! ## Wire protocols
12//!
13//! Phase 1 ships **RFC 5424 syslog over UDP**, the de-facto on-host
14//! log-forwarding format. Direct ingestion works with Splunk, Elastic,
15//! IBM QRadar, and rsyslog; vendors that require a forwarder agent
16//! (Microsoft Sentinel via the Azure Monitor Agent on a Linux VM,
17//! Datadog/Chronicle via their own collectors) consume the same stream
18//! through that agent. CEF and NDJSON-over-TCP are designed-for in
19//! [`AuditSinkSpec`] and arrive in follow-up PRs.
20//!
21//! ## Reliability vs durability
22//!
23//! - **Local audit-log INSERT** (Ed25519-signed hash-chained, see
24//! [`audit_chain`](crate::audit_chain)) is the durable record.
25//! - **External forwarding** is best-effort. A SIEM outage, a routing flap, or
26//! a misconfigured collector must NEVER block daemon execution.
27//! - We do not retry the *send* in-process: a frame that the kernel could not
28//! hand to the SIEM is dropped and the next event continues. Bind failures
29//! *are* retried with exponential backoff (1s → 60s) so a transient outage
30//! does not poison the socket forever, but neither path tries to replay the
31//! missed event — the SIEM is the long-lived accumulator and the local
32//! hash-chained log will catch up the operator on the next ingest. (A
33//! future "tail watermark + replay" Phase 2 bridge can reconcile gaps from
34//! the local log.)
35
36use std::net::SocketAddr;
37use std::sync::atomic::{AtomicU64, Ordering};
38use std::sync::Arc;
39use std::time::Duration;
40
41use sysknife_types::RiskLevel;
42use tokio::sync::mpsc;
43
44/// Bounded queue depth between daemon and forwarder. Sized for ~5 minutes of
45/// audit events at the daemon's sustainable record rate (well below SIEM
46/// ingest); a sustained burst that overflows is a SIEM outage and we drop
47/// (with counter + WARN) rather than back-pressure the audit-log writer.
48pub const FORWARDER_QUEUE_DEPTH: usize = 4096;
49
50/// Emit a single WARN to stderr after this many consecutive `try_send` drops.
51/// Counter resets on a successful send.
52pub const DROP_WARN_THRESHOLD: u64 = 8;
53
54/// Configuration for one forwarding sink.
55#[derive(Clone, Debug)]
56pub enum AuditSinkSpec {
57 /// RFC 5424 syslog over UDP. Sends each event as a single datagram.
58 SyslogUdp {
59 /// Host:port of the receiver, e.g. `"siem.internal:514"`.
60 host: SocketAddr,
61 /// Syslog facility (default `1` = user-level messages).
62 facility: u8,
63 },
64}
65
66/// One audit event handed to the forwarder. Mirrors the chain content
67/// captured at INSERT time so SIEM rules can correlate by `transaction_id`
68/// and `request_hash` against the local hash-chained log.
69///
70/// `final_status` is `None` for the preview-time event (no terminal state yet)
71/// and `Some` for the execute-time event emitted after `update_status`. SOC
72/// analysts watching the SIEM can therefore tell from the same event stream
73/// whether an action ran, succeeded, failed, or was rolled back.
74#[derive(Clone, Debug)]
75pub struct AuditEvent {
76 pub seq: u64,
77 pub transaction_id: String,
78 pub action_name: String,
79 pub risk_level: RiskLevel,
80 pub summary: String,
81 pub approval_id: Option<String>,
82 pub created_at: String,
83 pub chain_hash: String,
84 pub key_id: String,
85 pub caller_role: Option<String>,
86 /// Which account the daemon attributed the call to, taken from the signed
87 /// row rather than from the connection state, so the SIEM sees what the chain
88 /// says. `None` only for rows written before the principal existed.
89 ///
90 /// Without this the whole point of the signed principal stops at the local
91 /// database: a SOC watching this stream would still see only "an Admin did
92 /// this", and could not see an attribution failure at all.
93 pub caller_principal: Option<String>,
94 pub final_status: Option<String>,
95}
96
97/// A handle the daemon writes to. Cheap to clone (Arc-wrapped sender +
98/// counter). Returns immediately on `submit`.
99#[derive(Clone)]
100pub struct AuditForwarder {
101 sender: mpsc::Sender<AuditEvent>,
102 drops: Arc<AtomicU64>,
103}
104
105impl std::fmt::Debug for AuditForwarder {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 f.debug_struct("AuditForwarder")
108 .field("queue_capacity", &self.sender.capacity())
109 .field("queue_max", &self.sender.max_capacity())
110 .field("drops", &self.drops.load(Ordering::Relaxed))
111 .finish()
112 }
113}
114
115impl AuditForwarder {
116 /// Submit an event for forwarding. Never blocks. Drops the event if the
117 /// channel is full or closed; consecutive drops emit a WARN after
118 /// [`DROP_WARN_THRESHOLD`].
119 pub fn submit(&self, event: AuditEvent) {
120 match self.sender.try_send(event) {
121 Ok(()) => {
122 // Reset drop counter on a successful submit so the next
123 // outage emits a fresh WARN.
124 self.drops.store(0, Ordering::Relaxed);
125 }
126 Err(mpsc::error::TrySendError::Full(_)) => {
127 let prev = self.drops.fetch_add(1, Ordering::Relaxed);
128 if (prev + 1) == DROP_WARN_THRESHOLD {
129 eprintln!(
130 "[sysknife-daemon] audit-forward: queue full, dropping events \
131 (>= {DROP_WARN_THRESHOLD} consecutive); is the SIEM reachable?"
132 );
133 }
134 }
135 Err(mpsc::error::TrySendError::Closed(_)) => {
136 // The forwarder task has shut down. One-time WARN to stderr;
137 // subsequent submits silently no-op.
138 let prev = self.drops.fetch_add(1, Ordering::Relaxed);
139 if prev == 0 {
140 eprintln!(
141 "[sysknife-daemon] audit-forward: forwarder task is gone; \
142 further submits will be dropped silently"
143 );
144 }
145 }
146 }
147 }
148
149 /// Total events dropped since the last successful submit. Test-only.
150 #[cfg(test)]
151 pub fn drop_count(&self) -> u64 {
152 self.drops.load(Ordering::Relaxed)
153 }
154}
155
156/// Spawn a forwarder task that consumes events from `rx` and sends them to
157/// `spec`. Returns the [`AuditForwarder`] handle the daemon writes to.
158///
159/// On task exit (channel closed by all senders dropping), the task returns
160/// silently. There is no graceful drain — pending events in the channel are
161/// dropped at shutdown. Audit durability is guaranteed by the local
162/// hash-chained log, not by the forwarder.
163pub fn spawn(spec: AuditSinkSpec) -> AuditForwarder {
164 let (tx, rx) = mpsc::channel(FORWARDER_QUEUE_DEPTH);
165 let drops = Arc::new(AtomicU64::new(0));
166 tokio::spawn(forwarder_task(spec, rx));
167 AuditForwarder { sender: tx, drops }
168}
169
170async fn forwarder_task(spec: AuditSinkSpec, mut rx: mpsc::Receiver<AuditEvent>) {
171 match spec {
172 AuditSinkSpec::SyslogUdp { host, facility } => {
173 let mut socket = open_udp(host).await;
174 // Exponential backoff on consecutive bind failures so a transient
175 // outage does not produce a tight retry loop that pegs a CPU.
176 // See `next_backoff_secs` for the doubling sequence and
177 // `MAX_BACKOFF_SECS` for why the cap lives where it does. Reset
178 // to `INITIAL_BACKOFF_SECS` on the first successful bind.
179 let mut backoff_secs: u64 = INITIAL_BACKOFF_SECS;
180 while let Some(event) = rx.recv().await {
181 let frame = format_rfc5424(&event, facility);
182 // **Event-drop semantics:** if the socket is `None` (bind has
183 // never succeeded, or a previous send failed and we have not
184 // yet rebound) the formatted frame is computed and then
185 // dropped. The local hash-chained audit log already has a
186 // durable copy of the same row, so the SIEM gap is by
187 // design — a future "tail watermark + replay" Phase 2 bridge
188 // can backfill from the local log.
189 if let Some(s) = &socket {
190 if let Err(e) = s.send_to(frame.as_bytes(), host).await {
191 eprintln!(
192 "[sysknife-daemon] audit-forward: UDP send to {host} failed: {e} \
193 — dropping socket and reopening (current event is lost)"
194 );
195 socket = None;
196 }
197 }
198 if socket.is_none() {
199 socket = open_udp(host).await;
200 if socket.is_none() {
201 // Bind failure: sleep with exponential backoff before
202 // we attempt again on the next event. Without this,
203 // a misconfigured host with a packed channel would
204 // pin a CPU at 100% on `eprintln!` syscalls.
205 tokio::time::sleep(Duration::from_secs(backoff_secs)).await;
206 backoff_secs = next_backoff_secs(backoff_secs);
207 } else {
208 backoff_secs = 1;
209 }
210 }
211 }
212 }
213 }
214}
215
216/// Maximum wait between consecutive bind-failure retries, in seconds.
217///
218/// Once the exponential backoff hits this ceiling it stays there. Without a
219/// cap, a long-running SIEM outage would silently grow the retry interval
220/// into hours (1h ≈ 3600s, the doubling sequence reaches that after only a
221/// few minutes) — operators would think forwarding had quietly stopped
222/// rather than seeing minute-by-minute reattempts. 60s is the smallest
223/// value where:
224///
225/// 1. The retry rate is well below the threshold that pegs a CPU on
226/// bind/`eprintln!` syscalls in a tight loop, and
227/// 2. A normally-functioning SIEM coming back online recovers within one
228/// ingest interval (most SIEMs ingest at ≤ 60s granularity already).
229const MAX_BACKOFF_SECS: u64 = 60;
230
231/// Initial wait after the first bind failure, in seconds. The doubling
232/// sequence resets to this value after every successful bind.
233const INITIAL_BACKOFF_SECS: u64 = 1;
234
235/// Compute the next exponential-backoff wait, capped at [`MAX_BACKOFF_SECS`].
236///
237/// Sequence: `1 → 2 → 4 → 8 → 16 → 32 → MAX → MAX → …`. Extracted from
238/// `forwarder_task` so the math can be unit-tested without driving a real
239/// tokio runtime.
240fn next_backoff_secs(prev: u64) -> u64 {
241 prev.saturating_mul(2).min(MAX_BACKOFF_SECS)
242}
243
244/// Bind a fresh ephemeral UDP socket whose address family matches `host`.
245///
246/// IPv4 host → bind on `0.0.0.0:0`; IPv6 host → bind on `[::]:0`. Without
247/// this, an operator with an IPv6-only SIEM (e.g. Sentinel at
248/// `[2001:db8::5]:514`) would never receive any events because every send
249/// would fail with `AddrNotAvailable`.
250///
251/// Returns `None` on bind failure — caller retries with backoff.
252async fn open_udp(host: SocketAddr) -> Option<tokio::net::UdpSocket> {
253 let bind_addr = if host.is_ipv6() {
254 "[::]:0"
255 } else {
256 "0.0.0.0:0"
257 };
258 match tokio::net::UdpSocket::bind(bind_addr).await {
259 Ok(s) => Some(s),
260 Err(e) => {
261 eprintln!("[sysknife-daemon] audit-forward: UDP bind on {bind_addr} failed: {e}");
262 None
263 }
264 }
265}
266
267/// Format `event` as a single RFC 5424 syslog message.
268///
269/// Layout (one line, no trailing newline — UDP datagrams don't need one):
270/// ```text
271/// <PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD@32473 ...] MSG
272/// ```
273///
274/// We hold to the spec's printable-USASCII rule for the structured-data
275/// (SD) section by escaping `]`, `"`, and `\` per §6.3.3.
276pub fn format_rfc5424(event: &AuditEvent, facility: u8) -> String {
277 // Severity 5 = NOTICE for audit events. PRI = facility * 8 + severity.
278 let severity = 5u8;
279 let pri = (facility as u32) * 8 + severity as u32;
280
281 let hostname = read_hostname();
282 let app_name = "sysknife-daemon";
283 let procid = std::process::id();
284 let msgid = "AUDIT";
285
286 let risk = match event.risk_level {
287 RiskLevel::Low => "low",
288 RiskLevel::Medium => "medium",
289 RiskLevel::High => "high",
290 };
291
292 // RFC 5424 §6.3 structured data.
293 //
294 // **PEN 32473 is RFC 5612's documentation/test PEN — it MUST be replaced
295 // before production deployment.** RFC 5424 §7.2.2 requires an
296 // IANA-assigned Private Enterprise Number on `SD-ID`s emitted in
297 // production frames. The SysKnife project does not yet hold a PEN; the
298 // 32473 reservation is used here only so the formatter is bytewise
299 // testable. Operators running a SIEM under a regulated framework should
300 // either patch `SD@32473` to their own PEN at build time or treat this
301 // as a known gap (see issue tracker).
302 //
303 // `terminal_status` is appended ONLY for the execute-time forward, so
304 // preview frames remain byte-for-byte unchanged.
305 let terminal_status_param = match &event.final_status {
306 Some(s) => format!(" terminal_status=\"{}\"", sd_escape(s)),
307 None => String::new(),
308 };
309 let sd = format!(
310 "[sysknife@32473 \
311 seq=\"{}\" \
312 tx=\"{}\" \
313 action=\"{}\" \
314 risk=\"{}\" \
315 approval=\"{}\" \
316 role=\"{}\" \
317 principal=\"{}\" \
318 chain_hash=\"{}\" \
319 key_id=\"{}\"{terminal_status_param}]",
320 event.seq,
321 sd_escape(&event.transaction_id),
322 sd_escape(&event.action_name),
323 risk,
324 sd_escape(event.approval_id.as_deref().unwrap_or("")),
325 sd_escape(event.caller_role.as_deref().unwrap_or("")),
326 sd_escape(event.caller_principal.as_deref().unwrap_or("")),
327 sd_escape(&event.chain_hash),
328 sd_escape(&event.key_id),
329 );
330
331 let msg = format!("[{}] {}", event.action_name, event.summary);
332
333 format!(
334 "<{pri}>1 {ts} {host} {app} {pid} {msgid} {sd} {msg}",
335 ts = event.created_at,
336 host = hostname,
337 app = app_name,
338 pid = procid,
339 )
340}
341
342/// Escape a value for inclusion inside a RFC 5424 SD-PARAM-VALUE per §6.3.3.
343///
344/// RFC 5424 §6 names PRINTUSASCII (`0x21..=0x7E`) as the preferred SD form;
345/// UTF-8 is technically allowed but every strict SIEM ingest pipeline we
346/// have surveyed (Splunk in strict mode, IBM QRadar, Microsoft Sentinel via
347/// the Azure Monitor Agent) rejects non-ASCII bytes inside SD-VALUE. We
348/// therefore drop **every** byte outside the printable-ASCII range and
349/// escape the three characters
350/// `]`, `"`, `\` per §6.3.3.
351///
352/// This also covers DEL (`0x7F`) and C1 controls (`0x80..=0x9F`).
353fn sd_escape(value: &str) -> String {
354 let mut out = String::with_capacity(value.len());
355 for ch in value.chars() {
356 match ch {
357 '"' => out.push_str("\\\""),
358 '\\' => out.push_str("\\\\"),
359 ']' => out.push_str("\\]"),
360 c if (0x20..=0x7E).contains(&(c as u32)) => out.push(c),
361 // Drop everything else (C0, DEL, C1, all non-ASCII Unicode).
362 _ => {}
363 }
364 }
365 out
366}
367
368/// Read the kernel hostname and validate it against RFC 5424 §6.2.4.
369///
370/// HOSTNAME must be a single token: 1..=255 bytes, all `PRINTUSASCII`
371/// (`0x21..=0x7E`), no embedded whitespace. Anything else (empty,
372/// embedded space, non-ASCII, control char) returns the NILVALUE `"-"`
373/// so the datagram remains parseable. Without this, a sysctl-set
374/// hostname containing a space (e.g. `"edge node"`) would corrupt every
375/// emitted frame and silently drop in strict SIEMs.
376fn read_hostname() -> String {
377 let raw = std::fs::read_to_string("/proc/sys/kernel/hostname")
378 .map(|s| s.trim().to_string())
379 .unwrap_or_default();
380 if raw.is_empty() || raw.len() > 255 || !raw.bytes().all(|b| (0x21..=0x7E).contains(&b)) {
381 return "-".to_string();
382 }
383 raw
384}
385
386/// Test-only hostname validator that accepts an externally-provided string,
387/// applies the same RFC 5424 §6.2.4 rule, and returns either the input or
388/// `"-"`. Lets tests cover the validation path without mutating
389/// `/proc/sys/kernel/hostname`.
390#[cfg(test)]
391fn validate_hostname_for_test(raw: &str) -> String {
392 if raw.is_empty() || raw.len() > 255 || !raw.bytes().all(|b| (0x21..=0x7E).contains(&b)) {
393 return "-".to_string();
394 }
395 raw.to_string()
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 fn sample_event() -> AuditEvent {
403 AuditEvent {
404 seq: 42,
405 transaction_id: "tx-abc".to_string(),
406 action_name: "InstallFlatpak".to_string(),
407 risk_level: RiskLevel::Medium,
408 summary: "Install Firefox".to_string(),
409 approval_id: Some("appr-xyz".to_string()),
410 created_at: "2026-04-25T08:30:00Z".to_string(),
411 chain_hash: "deadbeef".to_string(),
412 key_id: "v1".to_string(),
413 caller_role: Some("Dev".to_string()),
414 caller_principal: Some("uid:1000".to_string()),
415 final_status: None,
416 }
417 }
418
419 // ── Hostname validation ──────────────────────────────────────────────
420
421 #[test]
422 fn hostname_with_space_is_rejected() {
423 assert_eq!(validate_hostname_for_test("edge node 7"), "-");
424 }
425
426 #[test]
427 fn hostname_with_control_byte_is_rejected() {
428 assert_eq!(validate_hostname_for_test("edge\x01node"), "-");
429 }
430
431 #[test]
432 fn hostname_with_non_ascii_is_rejected() {
433 assert_eq!(validate_hostname_for_test("ëdge"), "-");
434 }
435
436 #[test]
437 fn empty_hostname_falls_back_to_nilvalue() {
438 assert_eq!(validate_hostname_for_test(""), "-");
439 }
440
441 #[test]
442 fn legitimate_hostname_passes_through() {
443 assert_eq!(
444 validate_hostname_for_test("edge-node-7.example.com"),
445 "edge-node-7.example.com"
446 );
447 }
448
449 #[test]
450 fn hostname_over_255_bytes_is_rejected() {
451 let long = "a".repeat(256);
452 assert_eq!(validate_hostname_for_test(&long), "-");
453 }
454
455 // ── SD-VALUE strict ASCII ────────────────────────────────────────────
456
457 #[test]
458 fn sd_escape_strips_del_and_c1_controls() {
459 let raw = "before\x7Fafter\u{0085}";
460 assert_eq!(sd_escape(raw), "beforeafter");
461 }
462
463 #[test]
464 fn sd_escape_strips_non_ascii_to_avoid_strict_siem_rejection() {
465 // Splunk/QRadar in strict mode reject non-ASCII inside SD-VALUE.
466 let raw = "café";
467 let escaped = sd_escape(raw);
468 assert!(escaped.bytes().all(|b| (0x20..=0x7E).contains(&b)));
469 }
470
471 // ── RFC 5424 framing ──────────────────────────────────────────────────
472
473 #[test]
474 fn rfc5424_starts_with_pri_and_version() {
475 let frame = format_rfc5424(&sample_event(), 1);
476 // facility=1, severity=5 → PRI = 13. Version = 1.
477 assert!(frame.starts_with("<13>1 "));
478 }
479
480 #[test]
481 fn rfc5424_contains_sd_with_chain_hash_and_seq() {
482 let frame = format_rfc5424(&sample_event(), 1);
483 assert!(frame.contains("[sysknife@32473"));
484 assert!(frame.contains("seq=\"42\""));
485 assert!(frame.contains("chain_hash=\"deadbeef\""));
486 assert!(frame.contains("key_id=\"v1\""));
487 assert!(frame.contains("risk=\"medium\""));
488 assert!(frame.contains("role=\"Dev\""));
489 }
490
491 #[test]
492 fn rfc5424_message_section_contains_summary_and_action_tag() {
493 let frame = format_rfc5424(&sample_event(), 1);
494 assert!(frame.ends_with("[InstallFlatpak] Install Firefox"));
495 }
496
497 #[test]
498 fn sd_escape_handles_dquote_backslash_bracket() {
499 let raw = r#"name with "quotes", a \ backslash, and ] bracket"#;
500 let escaped = sd_escape(raw);
501 assert!(escaped.contains("\\\""));
502 assert!(escaped.contains("\\\\"));
503 assert!(escaped.contains("\\]"));
504 }
505
506 #[test]
507 fn sd_escape_strips_control_characters() {
508 // Control bytes must not appear inside SD-VALUE.
509 let raw = "before\x01\x02\x1fafter";
510 let escaped = sd_escape(raw);
511 assert_eq!(escaped, "beforeafter");
512 }
513
514 #[test]
515 fn rfc5424_missing_approval_renders_empty_string() {
516 let mut e = sample_event();
517 e.approval_id = None;
518 let frame = format_rfc5424(&e, 1);
519 assert!(frame.contains("approval=\"\""));
520 }
521
522 #[test]
523 fn rfc5424_caller_role_with_quote_is_escaped() {
524 let mut e = sample_event();
525 e.caller_role = Some(r#"Dev"name"#.to_string());
526 let frame = format_rfc5424(&e, 1);
527 assert!(frame.contains("role=\"Dev\\\"name\""));
528 }
529
530 #[test]
531 fn rfc5424_facility_changes_pri() {
532 // facility=23 (local7), severity=5 → PRI = 189.
533 let frame = format_rfc5424(&sample_event(), 23);
534 assert!(frame.starts_with("<189>1 "));
535 }
536
537 #[test]
538 fn terminal_status_emitted_when_present() {
539 // Execute-time forward carries the terminal JobState. SOC analysts
540 // need the SD-PARAM in the same frame as the chain hash so they can
541 // pivot from "did the action run?" to "what happened?" without
542 // joining against a second log source.
543 let mut e = sample_event();
544 e.final_status = Some("succeeded".to_string());
545 let frame = format_rfc5424(&e, 1);
546 assert!(
547 frame.contains("terminal_status=\"succeeded\""),
548 "frame missing terminal_status: {frame}"
549 );
550 }
551
552 // ── AuditForwarder behaviour ─────────────────────────────────────────
553
554 #[tokio::test(flavor = "current_thread")]
555 async fn forwarder_drops_on_closed_channel() {
556 let (tx, rx) = mpsc::channel::<AuditEvent>(1);
557 // Drop the receiver to simulate a forwarder task that has exited.
558 drop(rx);
559 let forwarder = AuditForwarder {
560 sender: tx,
561 drops: Arc::new(AtomicU64::new(0)),
562 };
563 forwarder.submit(sample_event());
564 assert_eq!(forwarder.drop_count(), 1);
565 }
566
567 #[tokio::test(flavor = "current_thread")]
568 async fn forwarder_drops_when_full_and_warns_at_threshold() {
569 // Build a channel of capacity 1 so the second submit overflows.
570 let (tx, _rx) = mpsc::channel::<AuditEvent>(1);
571 let forwarder = AuditForwarder {
572 sender: tx,
573 drops: Arc::new(AtomicU64::new(0)),
574 };
575
576 // Fill the channel.
577 forwarder.submit(sample_event()); // queued — drop_count stays 0
578 assert_eq!(forwarder.drop_count(), 0);
579
580 // Now overflow DROP_WARN_THRESHOLD times.
581 for _ in 0..DROP_WARN_THRESHOLD {
582 forwarder.submit(sample_event());
583 }
584 assert_eq!(forwarder.drop_count(), DROP_WARN_THRESHOLD);
585 }
586
587 #[tokio::test(flavor = "current_thread")]
588 async fn forwarder_resets_drop_counter_after_successful_submit() {
589 let (tx, mut rx) = mpsc::channel::<AuditEvent>(1);
590 let forwarder = AuditForwarder {
591 sender: tx,
592 drops: Arc::new(AtomicU64::new(0)),
593 };
594 // First submit lands; second drops (full).
595 forwarder.submit(sample_event());
596 forwarder.submit(sample_event());
597 assert_eq!(forwarder.drop_count(), 1);
598 // Drain — frees a slot.
599 let _ = rx.recv().await;
600 // Next submit succeeds and resets the counter.
601 forwarder.submit(sample_event());
602 assert_eq!(forwarder.drop_count(), 0);
603 }
604
605 // ── End-to-end: spawn() actually emits over a UDP loopback ───────────
606
607 #[tokio::test(flavor = "current_thread")]
608 async fn spawn_sends_udp_datagrams_to_listener() {
609 // Bind a loopback listener first to learn the assigned port.
610 let listener = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
611 let port = listener.local_addr().unwrap().port();
612 let host: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
613
614 let forwarder = spawn(AuditSinkSpec::SyslogUdp { host, facility: 1 });
615 forwarder.submit(sample_event());
616
617 // Receive with a generous timeout.
618 let mut buf = [0u8; 2048];
619 let len = tokio::time::timeout(Duration::from_secs(2), listener.recv(&mut buf))
620 .await
621 .expect("UDP recv timed out — forwarder did not emit")
622 .expect("UDP recv succeeded");
623 let frame = std::str::from_utf8(&buf[..len]).expect("frame is UTF-8");
624 assert!(frame.starts_with("<13>1 "), "unexpected frame: {frame}");
625 assert!(frame.contains("seq=\"42\""));
626 assert!(frame.contains("[InstallFlatpak] Install Firefox"));
627 }
628
629 /// Backoff sequence regression: every consecutive bind failure must
630 /// double the wait, capped at [`MAX_BACKOFF_SECS`]. The dispatcher does
631 /// not currently expose a hook to drive the live `forwarder_task`
632 /// deterministically (it loops on real `recv().await`), but the
633 /// backoff math is extracted to `next_backoff_secs` so we can pin the
634 /// sequence as a pure-function test. A regression that turns the cap
635 /// off would silently grow retry intervals into hours during a long
636 /// SIEM outage; a regression that breaks doubling would peg a CPU on
637 /// a tight retry loop. Either way, this test fails first.
638 #[test]
639 fn next_backoff_secs_doubles_then_caps() {
640 // Walk the sequence from the documented start until it has saturated
641 // at the cap for two consecutive steps, so a future increase to
642 // MAX_BACKOFF_SECS doesn't silently make this assertion looser.
643 let mut seen = vec![INITIAL_BACKOFF_SECS];
644 let mut b = INITIAL_BACKOFF_SECS;
645 for _ in 0..32 {
646 b = next_backoff_secs(b);
647 seen.push(b);
648 if seen.len() >= 2
649 && seen[seen.len() - 1] == MAX_BACKOFF_SECS
650 && seen[seen.len() - 2] == MAX_BACKOFF_SECS
651 {
652 break;
653 }
654 }
655
656 // Doubling phase: every value before the cap is exactly 2× its
657 // predecessor.
658 for w in seen.windows(2) {
659 let (prev, next) = (w[0], w[1]);
660 if next == MAX_BACKOFF_SECS {
661 // Either we hit the cap by doubling (prev * 2 >= cap) or we
662 // are saturating (prev == cap). Both are valid.
663 assert!(
664 prev.saturating_mul(2) >= MAX_BACKOFF_SECS,
665 "first cap step jumped from {prev} → {next} without doubling"
666 );
667 } else {
668 assert_eq!(next, prev * 2, "non-cap step must double: {prev} → {next}");
669 }
670 }
671
672 // Cap is reached and held — the final two entries are both at the cap.
673 let n = seen.len();
674 assert!(n >= 2);
675 assert_eq!(seen[n - 1], MAX_BACKOFF_SECS);
676 assert_eq!(seen[n - 2], MAX_BACKOFF_SECS);
677 assert_eq!(seen[0], INITIAL_BACKOFF_SECS);
678 }
679
680 /// T8 — RFC 5424 round-trip through a real syslog parser.
681 ///
682 /// Substring `assert!(frame.contains("..."))` checks accept malformed
683 /// frames as long as they happen to contain the expected substring.
684 /// Round-trip the formatter's output through `syslog_loose`, which
685 /// implements RFC 5424's grammar, and assert each parsed field
686 /// matches the input event. A regression that breaks PRI / VERSION /
687 /// MSGID / SD-ID structure fails parsing here, not in production.
688 #[test]
689 fn rfc5424_round_trip_through_syslog_loose() {
690 let event = sample_event();
691 let facility = 1u8; // user-level
692 let frame = format_rfc5424(&event, facility);
693
694 let parsed = syslog_loose::parse_message(&frame, syslog_loose::Variant::RFC5424);
695
696 // PRI = facility * 8 + severity. Severity for audit events is 5
697 // (NOTICE) — pin both halves through the parser.
698 assert_eq!(
699 parsed.facility.expect("frame has a facility"),
700 syslog_loose::SyslogFacility::LOG_USER,
701 "facility should round-trip as user-level (1)"
702 );
703 assert_eq!(
704 parsed.severity.expect("frame has a severity"),
705 syslog_loose::SyslogSeverity::SEV_NOTICE,
706 "audit events must be NOTICE-severity"
707 );
708 assert!(
709 matches!(parsed.protocol, syslog_loose::Protocol::RFC5424(1)),
710 "RFC 5424 frames carry version=1; got protocol={:?}",
711 parsed.protocol
712 );
713 assert_eq!(parsed.msgid, Some("AUDIT"));
714 assert_eq!(parsed.appname, Some("sysknife-daemon"));
715
716 // Structured-data block: one SD element with id "sysknife@32473"
717 // and the audit fields as params.
718 let sd = parsed
719 .structured_data
720 .iter()
721 .find(|s| s.id == "sysknife@32473")
722 .expect("frame has the sysknife@32473 SD element");
723
724 let get = |key: &str| -> String {
725 sd.params
726 .iter()
727 .find(|(k, _)| *k == key)
728 .map(|(_, v)| (*v).to_string())
729 .unwrap_or_else(|| panic!("SD element missing param {key}: {sd:?}"))
730 };
731 assert_eq!(get("seq"), event.seq.to_string());
732 assert_eq!(get("tx"), event.transaction_id);
733 assert_eq!(get("action"), event.action_name);
734 assert_eq!(get("risk"), "medium");
735 assert_eq!(get("approval"), event.approval_id.unwrap());
736 assert_eq!(get("role"), event.caller_role.unwrap());
737 assert_eq!(get("chain_hash"), event.chain_hash);
738 assert_eq!(get("key_id"), event.key_id);
739 }
740
741 /// IPv6 bind path: `open_udp` must select `[::]:0` for an IPv6 host so
742 /// an IPv6-only SIEM (e.g. Sentinel at `[2001:db8::5]:514`) is reachable.
743 /// Skipped when the kernel has IPv6 disabled (e.g. some hardened CI
744 /// images with `net.ipv6.conf.all.disable_ipv6=1`); the assertion path
745 /// only runs when bind genuinely succeeds.
746 #[tokio::test(flavor = "current_thread")]
747 async fn open_udp_ipv6_host_binds_ipv6_local_socket() {
748 let host: SocketAddr = "[::1]:514".parse().unwrap();
749 let Some(socket) = open_udp(host).await else {
750 // IPv6 disabled in this environment — bind unavailable. The
751 // production guard is exercised by the IPv4 test above.
752 return;
753 };
754 let local = socket.local_addr().expect("bound socket has a local addr");
755 assert!(
756 local.is_ipv6(),
757 "open_udp(IPv6 host) bound to non-IPv6 socket {local}"
758 );
759 }
760}