Skip to main content

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