use std::net::SocketAddr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use sysknife_types::RiskLevel;
use tokio::sync::mpsc;
pub const FORWARDER_QUEUE_DEPTH: usize = 4096;
pub const DROP_WARN_THRESHOLD: u64 = 8;
#[derive(Clone, Debug)]
pub enum AuditSinkSpec {
SyslogUdp {
host: SocketAddr,
facility: u8,
},
}
#[derive(Clone, Debug)]
pub struct AuditEvent {
pub seq: u64,
pub transaction_id: String,
pub action_name: String,
pub risk_level: RiskLevel,
pub summary: String,
pub approval_id: Option<String>,
pub created_at: String,
pub chain_hash: String,
pub key_id: String,
pub caller_role: Option<String>,
pub final_status: Option<String>,
}
#[derive(Clone)]
pub struct AuditForwarder {
sender: mpsc::Sender<AuditEvent>,
drops: Arc<AtomicU64>,
}
impl std::fmt::Debug for AuditForwarder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuditForwarder")
.field("queue_capacity", &self.sender.capacity())
.field("queue_max", &self.sender.max_capacity())
.field("drops", &self.drops.load(Ordering::Relaxed))
.finish()
}
}
impl AuditForwarder {
pub fn submit(&self, event: AuditEvent) {
match self.sender.try_send(event) {
Ok(()) => {
self.drops.store(0, Ordering::Relaxed);
}
Err(mpsc::error::TrySendError::Full(_)) => {
let prev = self.drops.fetch_add(1, Ordering::Relaxed);
if (prev + 1) == DROP_WARN_THRESHOLD {
eprintln!(
"[sysknife-daemon] audit-forward: queue full, dropping events \
(>= {DROP_WARN_THRESHOLD} consecutive); is the SIEM reachable?"
);
}
}
Err(mpsc::error::TrySendError::Closed(_)) => {
let prev = self.drops.fetch_add(1, Ordering::Relaxed);
if prev == 0 {
eprintln!(
"[sysknife-daemon] audit-forward: forwarder task is gone; \
further submits will be dropped silently"
);
}
}
}
}
#[cfg(test)]
pub fn drop_count(&self) -> u64 {
self.drops.load(Ordering::Relaxed)
}
}
pub fn spawn(spec: AuditSinkSpec) -> AuditForwarder {
let (tx, rx) = mpsc::channel(FORWARDER_QUEUE_DEPTH);
let drops = Arc::new(AtomicU64::new(0));
tokio::spawn(forwarder_task(spec, rx));
AuditForwarder { sender: tx, drops }
}
async fn forwarder_task(spec: AuditSinkSpec, mut rx: mpsc::Receiver<AuditEvent>) {
match spec {
AuditSinkSpec::SyslogUdp { host, facility } => {
let mut socket = open_udp(host).await;
let mut backoff_secs: u64 = INITIAL_BACKOFF_SECS;
while let Some(event) = rx.recv().await {
let frame = format_rfc5424(&event, facility);
if let Some(s) = &socket {
if let Err(e) = s.send_to(frame.as_bytes(), host).await {
eprintln!(
"[sysknife-daemon] audit-forward: UDP send to {host} failed: {e} \
— dropping socket and reopening (current event is lost)"
);
socket = None;
}
}
if socket.is_none() {
socket = open_udp(host).await;
if socket.is_none() {
tokio::time::sleep(Duration::from_secs(backoff_secs)).await;
backoff_secs = next_backoff_secs(backoff_secs);
} else {
backoff_secs = 1;
}
}
}
}
}
}
const MAX_BACKOFF_SECS: u64 = 60;
const INITIAL_BACKOFF_SECS: u64 = 1;
fn next_backoff_secs(prev: u64) -> u64 {
prev.saturating_mul(2).min(MAX_BACKOFF_SECS)
}
async fn open_udp(host: SocketAddr) -> Option<tokio::net::UdpSocket> {
let bind_addr = if host.is_ipv6() {
"[::]:0"
} else {
"0.0.0.0:0"
};
match tokio::net::UdpSocket::bind(bind_addr).await {
Ok(s) => Some(s),
Err(e) => {
eprintln!("[sysknife-daemon] audit-forward: UDP bind on {bind_addr} failed: {e}");
None
}
}
}
pub fn format_rfc5424(event: &AuditEvent, facility: u8) -> String {
let severity = 5u8;
let pri = (facility as u32) * 8 + severity as u32;
let hostname = read_hostname();
let app_name = "sysknife-daemon";
let procid = std::process::id();
let msgid = "AUDIT";
let risk = match event.risk_level {
RiskLevel::Low => "low",
RiskLevel::Medium => "medium",
RiskLevel::High => "high",
};
let terminal_status_param = match &event.final_status {
Some(s) => format!(" terminal_status=\"{}\"", sd_escape(s)),
None => String::new(),
};
let sd = format!(
"[sysknife@32473 \
seq=\"{}\" \
tx=\"{}\" \
action=\"{}\" \
risk=\"{}\" \
approval=\"{}\" \
role=\"{}\" \
chain_hash=\"{}\" \
key_id=\"{}\"{terminal_status_param}]",
event.seq,
sd_escape(&event.transaction_id),
sd_escape(&event.action_name),
risk,
sd_escape(event.approval_id.as_deref().unwrap_or("")),
sd_escape(event.caller_role.as_deref().unwrap_or("")),
sd_escape(&event.chain_hash),
sd_escape(&event.key_id),
);
let msg = format!("[{}] {}", event.action_name, event.summary);
format!(
"<{pri}>1 {ts} {host} {app} {pid} {msgid} {sd} {msg}",
ts = event.created_at,
host = hostname,
app = app_name,
pid = procid,
)
}
fn sd_escape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
']' => out.push_str("\\]"),
c if (0x20..=0x7E).contains(&(c as u32)) => out.push(c),
_ => {}
}
}
out
}
fn read_hostname() -> String {
let raw = std::fs::read_to_string("/proc/sys/kernel/hostname")
.map(|s| s.trim().to_string())
.unwrap_or_default();
if raw.is_empty() || raw.len() > 255 || !raw.bytes().all(|b| (0x21..=0x7E).contains(&b)) {
return "-".to_string();
}
raw
}
#[cfg(test)]
fn validate_hostname_for_test(raw: &str) -> String {
if raw.is_empty() || raw.len() > 255 || !raw.bytes().all(|b| (0x21..=0x7E).contains(&b)) {
return "-".to_string();
}
raw.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_event() -> AuditEvent {
AuditEvent {
seq: 42,
transaction_id: "tx-abc".to_string(),
action_name: "InstallFlatpak".to_string(),
risk_level: RiskLevel::Medium,
summary: "Install Firefox".to_string(),
approval_id: Some("appr-xyz".to_string()),
created_at: "2026-04-25T08:30:00Z".to_string(),
chain_hash: "deadbeef".to_string(),
key_id: "v1".to_string(),
caller_role: Some("Dev".to_string()),
final_status: None,
}
}
#[test]
fn hostname_with_space_is_rejected() {
assert_eq!(validate_hostname_for_test("edge node 7"), "-");
}
#[test]
fn hostname_with_control_byte_is_rejected() {
assert_eq!(validate_hostname_for_test("edge\x01node"), "-");
}
#[test]
fn hostname_with_non_ascii_is_rejected() {
assert_eq!(validate_hostname_for_test("ëdge"), "-");
}
#[test]
fn empty_hostname_falls_back_to_nilvalue() {
assert_eq!(validate_hostname_for_test(""), "-");
}
#[test]
fn legitimate_hostname_passes_through() {
assert_eq!(
validate_hostname_for_test("edge-node-7.example.com"),
"edge-node-7.example.com"
);
}
#[test]
fn hostname_over_255_bytes_is_rejected() {
let long = "a".repeat(256);
assert_eq!(validate_hostname_for_test(&long), "-");
}
#[test]
fn sd_escape_strips_del_and_c1_controls() {
let raw = "before\x7Fafter\u{0085}";
assert_eq!(sd_escape(raw), "beforeafter");
}
#[test]
fn sd_escape_strips_non_ascii_to_avoid_strict_siem_rejection() {
let raw = "café";
let escaped = sd_escape(raw);
assert!(escaped.bytes().all(|b| (0x20..=0x7E).contains(&b)));
}
#[test]
fn rfc5424_starts_with_pri_and_version() {
let frame = format_rfc5424(&sample_event(), 1);
assert!(frame.starts_with("<13>1 "));
}
#[test]
fn rfc5424_contains_sd_with_chain_hash_and_seq() {
let frame = format_rfc5424(&sample_event(), 1);
assert!(frame.contains("[sysknife@32473"));
assert!(frame.contains("seq=\"42\""));
assert!(frame.contains("chain_hash=\"deadbeef\""));
assert!(frame.contains("key_id=\"v1\""));
assert!(frame.contains("risk=\"medium\""));
assert!(frame.contains("role=\"Dev\""));
}
#[test]
fn rfc5424_message_section_contains_summary_and_action_tag() {
let frame = format_rfc5424(&sample_event(), 1);
assert!(frame.ends_with("[InstallFlatpak] Install Firefox"));
}
#[test]
fn sd_escape_handles_dquote_backslash_bracket() {
let raw = r#"name with "quotes", a \ backslash, and ] bracket"#;
let escaped = sd_escape(raw);
assert!(escaped.contains("\\\""));
assert!(escaped.contains("\\\\"));
assert!(escaped.contains("\\]"));
}
#[test]
fn sd_escape_strips_control_characters() {
let raw = "before\x01\x02\x1fafter";
let escaped = sd_escape(raw);
assert_eq!(escaped, "beforeafter");
}
#[test]
fn rfc5424_missing_approval_renders_empty_string() {
let mut e = sample_event();
e.approval_id = None;
let frame = format_rfc5424(&e, 1);
assert!(frame.contains("approval=\"\""));
}
#[test]
fn rfc5424_caller_role_with_quote_is_escaped() {
let mut e = sample_event();
e.caller_role = Some(r#"Dev"name"#.to_string());
let frame = format_rfc5424(&e, 1);
assert!(frame.contains("role=\"Dev\\\"name\""));
}
#[test]
fn rfc5424_facility_changes_pri() {
let frame = format_rfc5424(&sample_event(), 23);
assert!(frame.starts_with("<189>1 "));
}
#[test]
fn terminal_status_emitted_when_present() {
let mut e = sample_event();
e.final_status = Some("succeeded".to_string());
let frame = format_rfc5424(&e, 1);
assert!(
frame.contains("terminal_status=\"succeeded\""),
"frame missing terminal_status: {frame}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn forwarder_drops_on_closed_channel() {
let (tx, rx) = mpsc::channel::<AuditEvent>(1);
drop(rx);
let forwarder = AuditForwarder {
sender: tx,
drops: Arc::new(AtomicU64::new(0)),
};
forwarder.submit(sample_event());
assert_eq!(forwarder.drop_count(), 1);
}
#[tokio::test(flavor = "current_thread")]
async fn forwarder_drops_when_full_and_warns_at_threshold() {
let (tx, _rx) = mpsc::channel::<AuditEvent>(1);
let forwarder = AuditForwarder {
sender: tx,
drops: Arc::new(AtomicU64::new(0)),
};
forwarder.submit(sample_event()); assert_eq!(forwarder.drop_count(), 0);
for _ in 0..DROP_WARN_THRESHOLD {
forwarder.submit(sample_event());
}
assert_eq!(forwarder.drop_count(), DROP_WARN_THRESHOLD);
}
#[tokio::test(flavor = "current_thread")]
async fn forwarder_resets_drop_counter_after_successful_submit() {
let (tx, mut rx) = mpsc::channel::<AuditEvent>(1);
let forwarder = AuditForwarder {
sender: tx,
drops: Arc::new(AtomicU64::new(0)),
};
forwarder.submit(sample_event());
forwarder.submit(sample_event());
assert_eq!(forwarder.drop_count(), 1);
let _ = rx.recv().await;
forwarder.submit(sample_event());
assert_eq!(forwarder.drop_count(), 0);
}
#[tokio::test(flavor = "current_thread")]
async fn spawn_sends_udp_datagrams_to_listener() {
let listener = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let host: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
let forwarder = spawn(AuditSinkSpec::SyslogUdp { host, facility: 1 });
forwarder.submit(sample_event());
let mut buf = [0u8; 2048];
let len = tokio::time::timeout(Duration::from_secs(2), listener.recv(&mut buf))
.await
.expect("UDP recv timed out — forwarder did not emit")
.expect("UDP recv succeeded");
let frame = std::str::from_utf8(&buf[..len]).expect("frame is UTF-8");
assert!(frame.starts_with("<13>1 "), "unexpected frame: {frame}");
assert!(frame.contains("seq=\"42\""));
assert!(frame.contains("[InstallFlatpak] Install Firefox"));
}
#[test]
fn next_backoff_secs_doubles_then_caps() {
let mut seen = vec![INITIAL_BACKOFF_SECS];
let mut b = INITIAL_BACKOFF_SECS;
for _ in 0..32 {
b = next_backoff_secs(b);
seen.push(b);
if seen.len() >= 2
&& seen[seen.len() - 1] == MAX_BACKOFF_SECS
&& seen[seen.len() - 2] == MAX_BACKOFF_SECS
{
break;
}
}
for w in seen.windows(2) {
let (prev, next) = (w[0], w[1]);
if next == MAX_BACKOFF_SECS {
assert!(
prev.saturating_mul(2) >= MAX_BACKOFF_SECS,
"first cap step jumped from {prev} → {next} without doubling"
);
} else {
assert_eq!(next, prev * 2, "non-cap step must double: {prev} → {next}");
}
}
let n = seen.len();
assert!(n >= 2);
assert_eq!(seen[n - 1], MAX_BACKOFF_SECS);
assert_eq!(seen[n - 2], MAX_BACKOFF_SECS);
assert_eq!(seen[0], INITIAL_BACKOFF_SECS);
}
#[test]
fn rfc5424_round_trip_through_syslog_loose() {
let event = sample_event();
let facility = 1u8; let frame = format_rfc5424(&event, facility);
let parsed = syslog_loose::parse_message(&frame, syslog_loose::Variant::RFC5424);
assert_eq!(
parsed.facility.expect("frame has a facility"),
syslog_loose::SyslogFacility::LOG_USER,
"facility should round-trip as user-level (1)"
);
assert_eq!(
parsed.severity.expect("frame has a severity"),
syslog_loose::SyslogSeverity::SEV_NOTICE,
"audit events must be NOTICE-severity"
);
assert!(
matches!(parsed.protocol, syslog_loose::Protocol::RFC5424(1)),
"RFC 5424 frames carry version=1; got protocol={:?}",
parsed.protocol
);
assert_eq!(parsed.msgid, Some("AUDIT"));
assert_eq!(parsed.appname, Some("sysknife-daemon"));
let sd = parsed
.structured_data
.iter()
.find(|s| s.id == "sysknife@32473")
.expect("frame has the sysknife@32473 SD element");
let get = |key: &str| -> String {
sd.params
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| (*v).to_string())
.unwrap_or_else(|| panic!("SD element missing param {key}: {sd:?}"))
};
assert_eq!(get("seq"), event.seq.to_string());
assert_eq!(get("tx"), event.transaction_id);
assert_eq!(get("action"), event.action_name);
assert_eq!(get("risk"), "medium");
assert_eq!(get("approval"), event.approval_id.unwrap());
assert_eq!(get("role"), event.caller_role.unwrap());
assert_eq!(get("chain_hash"), event.chain_hash);
assert_eq!(get("key_id"), event.key_id);
}
#[tokio::test(flavor = "current_thread")]
async fn open_udp_ipv6_host_binds_ipv6_local_socket() {
let host: SocketAddr = "[::1]:514".parse().unwrap();
let Some(socket) = open_udp(host).await else {
return;
};
let local = socket.local_addr().expect("bound socket has a local addr");
assert!(
local.is_ipv6(),
"open_udp(IPv6 host) bound to non-IPv6 socket {local}"
);
}
}