use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::error::ShellTunnelError;
use crate::Result;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct Identity {
pub token_id: String,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AuditEvent {
pub at_ms: u64,
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<Identity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timed_out: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl AuditEvent {
pub fn new(kind: impl Into<String>) -> Self {
Self {
at_ms: now_ms(),
kind: kind.into(),
identity: None,
client: None,
route: None,
command: None,
session_id: None,
exit_code: None,
timed_out: None,
duration_ms: None,
status: None,
reason: None,
}
}
pub fn with_identity(mut self, identity: Option<Identity>) -> Self {
self.identity = identity;
self
}
pub fn with_client(mut self, client: impl Into<String>) -> Self {
self.client = Some(client.into());
self
}
pub fn with_route(mut self, route: impl Into<String>) -> Self {
self.route = Some(route.into());
self
}
pub fn with_command(mut self, command: impl Into<String>) -> Self {
self.command = Some(command.into());
self
}
pub fn with_session(mut self, session_id: u64) -> Self {
self.session_id = Some(session_id);
self
}
pub fn with_outcome(
mut self,
exit_code: Option<i32>,
timed_out: bool,
duration_ms: u64,
) -> Self {
self.exit_code = exit_code;
self.timed_out = Some(timed_out);
self.duration_ms = Some(duration_ms);
self
}
pub fn with_denial(mut self, status: u16, reason: impl Into<String>) -> Self {
self.status = Some(status);
self.reason = Some(reason.into());
self
}
}
#[derive(Debug, Default)]
pub enum AuditSink {
#[default]
Disabled,
File {
path: PathBuf,
max_bytes: Option<u64>,
state: Mutex<FileState>,
},
}
#[derive(Debug)]
pub struct FileState {
writer: BufWriter<File>,
written: u64,
}
impl AuditSink {
pub fn file(path: impl AsRef<Path>) -> Result<Self> {
Self::file_with_limit(path, None)
}
pub fn file_with_limit(path: impl AsRef<Path>, max_bytes: Option<u64>) -> Result<Self> {
let path = path.as_ref().to_path_buf();
let (writer, written) = open_append(&path)?;
Ok(Self::File {
path,
max_bytes,
state: Mutex::new(FileState { writer, written }),
})
}
pub fn is_enabled(&self) -> bool {
matches!(self, Self::File { .. })
}
pub fn record(&self, event: AuditEvent) {
let Self::File {
path,
max_bytes,
state,
} = self
else {
return;
};
let line = match serde_json::to_string(&event) {
Ok(line) => line,
Err(e) => {
tracing::warn!(target: "audit", "cannot encode audit event: {e}");
return;
}
};
let Ok(mut state) = state.lock() else {
tracing::warn!(target: "audit", "audit log lock poisoned; event dropped");
return;
};
if let Some(limit) = max_bytes {
if state.written + line.len() as u64 + 1 > *limit && state.written > 0 {
if let Err(e) = rotate(path, &mut state) {
tracing::warn!(target: "audit", "cannot rotate audit log {}: {e}", path.display());
}
}
}
match writeln!(state.writer, "{line}").and_then(|()| state.writer.flush()) {
Ok(()) => state.written += line.len() as u64 + 1,
Err(e) => {
tracing::warn!(target: "audit", "cannot write audit log {}: {e}", path.display());
}
}
}
}
fn open_append(path: &Path) -> Result<(BufWriter<File>, u64)> {
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| {
ShellTunnelError::Io(std::io::Error::new(
e.kind(),
format!("cannot open audit log {}: {e}", path.display()),
))
})?;
let written = file.metadata().map(|m| m.len()).unwrap_or(0);
Ok((BufWriter::new(file), written))
}
fn rotate(path: &Path, state: &mut FileState) -> std::io::Result<()> {
state.writer.flush()?;
let rotated = path.with_extension(match path.extension() {
Some(ext) => format!("{}.1", ext.to_string_lossy()),
None => "1".to_string(),
});
std::fs::rename(path, &rotated)?;
let (writer, _) = open_append(path).map_err(std::io::Error::other)?;
state.writer = writer;
state.written = 0;
Ok(())
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn read_lines(path: &Path) -> Vec<AuditEvent> {
std::fs::read_to_string(path)
.unwrap()
.lines()
.map(|line| serde_json::from_str(line).expect("each line is one event"))
.collect()
}
#[test]
fn a_disabled_sink_records_nothing() {
let sink = AuditSink::Disabled;
assert!(!sink.is_enabled());
sink.record(AuditEvent::new("execute"));
}
#[test]
fn events_are_appended_one_per_line() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let sink = AuditSink::file(&path).unwrap();
sink.record(AuditEvent::new("execute").with_command("echo one"));
sink.record(AuditEvent::new("execute").with_command("echo two"));
let events = read_lines(&path);
assert_eq!(events.len(), 2);
assert_eq!(events[0].command.as_deref(), Some("echo one"));
assert_eq!(events[1].command.as_deref(), Some("echo two"));
}
#[test]
fn reopening_appends_rather_than_truncating() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
AuditSink::file(&path)
.unwrap()
.record(AuditEvent::new("execute").with_command("first run"));
AuditSink::file(&path)
.unwrap()
.record(AuditEvent::new("execute").with_command("second run"));
let events = read_lines(&path);
assert_eq!(events.len(), 2);
}
#[test]
fn an_execution_event_carries_who_what_and_outcome() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let sink = AuditSink::file(&path).unwrap();
sink.record(
AuditEvent::new("execute")
.with_identity(Some(Identity {
token_id: "tok-1".into(),
label: "operator".into(),
}))
.with_client("203.0.113.7:51000")
.with_route("POST /api/v1/execute")
.with_command("whoami")
.with_outcome(Some(0), false, 42),
);
let event = read_lines(&path).remove(0);
assert_eq!(event.kind, "execute");
assert_eq!(event.identity.unwrap().label, "operator");
assert_eq!(event.command.as_deref(), Some("whoami"));
assert_eq!(event.exit_code, Some(0));
assert_eq!(event.timed_out, Some(false));
assert_eq!(event.duration_ms, Some(42));
assert!(event.at_ms > 0);
}
#[test]
fn a_denial_records_why_without_the_token() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let sink = AuditSink::file(&path).unwrap();
sink.record(
AuditEvent::new("denied")
.with_client("198.51.100.4:40000")
.with_route("POST /api/v1/execute")
.with_denial(401, "invalid-token"),
);
let raw = std::fs::read_to_string(&path).unwrap();
let event = read_lines(&path).remove(0);
assert_eq!(event.status, Some(401));
assert_eq!(event.reason.as_deref(), Some("invalid-token"));
assert!(!raw.contains("Bearer"), "{raw}");
}
#[test]
fn a_bounded_log_rotates_instead_of_growing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let sink = AuditSink::file_with_limit(&path, Some(200)).unwrap();
for i in 0..8 {
sink.record(AuditEvent::new("execute").with_command(format!("command number {i}")));
}
let current = std::fs::metadata(&path).unwrap().len();
assert!(
current <= 200,
"current file should stay under the limit: {current}"
);
let rotated = dir.path().join("audit.jsonl.1");
assert!(rotated.exists(), "one generation should be kept");
}
#[test]
fn an_unbounded_log_never_rotates() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let sink = AuditSink::file(&path).unwrap();
for i in 0..20 {
sink.record(AuditEvent::new("execute").with_command(format!("command {i}")));
}
assert_eq!(read_lines(&path).len(), 20);
assert!(!dir.path().join("audit.jsonl.1").exists());
}
#[test]
fn rotation_keeps_counting_from_an_existing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
AuditSink::file(&path)
.unwrap()
.record(AuditEvent::new("execute").with_command("x".repeat(150)));
let sink = AuditSink::file_with_limit(&path, Some(200)).unwrap();
sink.record(AuditEvent::new("execute").with_command("second"));
assert!(dir.path().join("audit.jsonl.1").exists());
}
#[test]
fn absent_fields_are_omitted_rather_than_null() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
AuditSink::file(&path)
.unwrap()
.record(AuditEvent::new("execute"));
let raw = std::fs::read_to_string(&path).unwrap();
assert!(!raw.contains("null"), "{raw}");
}
}