1use std::fs::{File, OpenOptions};
15use std::io::{BufWriter, Write};
16use std::path::{Path, PathBuf};
17use std::sync::Mutex;
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use serde::{Deserialize, Serialize};
21
22use crate::error::ShellTunnelError;
23use crate::Result;
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
27pub struct Identity {
28 pub token_id: String,
30 pub label: String,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36pub struct AuditEvent {
37 pub at_ms: u64,
40 pub kind: String,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub identity: Option<Identity>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub client: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub route: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub command: Option<String>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub session_id: Option<u64>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub exit_code: Option<i32>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub timed_out: Option<bool>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub duration_ms: Option<u64>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub status: Option<u16>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub reason: Option<String>,
76}
77
78impl AuditEvent {
79 pub fn new(kind: impl Into<String>) -> Self {
81 Self {
82 at_ms: now_ms(),
83 kind: kind.into(),
84 identity: None,
85 client: None,
86 route: None,
87 command: None,
88 session_id: None,
89 exit_code: None,
90 timed_out: None,
91 duration_ms: None,
92 status: None,
93 reason: None,
94 }
95 }
96
97 pub fn with_identity(mut self, identity: Option<Identity>) -> Self {
99 self.identity = identity;
100 self
101 }
102
103 pub fn with_client(mut self, client: impl Into<String>) -> Self {
105 self.client = Some(client.into());
106 self
107 }
108
109 pub fn with_route(mut self, route: impl Into<String>) -> Self {
111 self.route = Some(route.into());
112 self
113 }
114
115 pub fn with_command(mut self, command: impl Into<String>) -> Self {
117 self.command = Some(command.into());
118 self
119 }
120
121 pub fn with_session(mut self, session_id: u64) -> Self {
123 self.session_id = Some(session_id);
124 self
125 }
126
127 pub fn with_outcome(
129 mut self,
130 exit_code: Option<i32>,
131 timed_out: bool,
132 duration_ms: u64,
133 ) -> Self {
134 self.exit_code = exit_code;
135 self.timed_out = Some(timed_out);
136 self.duration_ms = Some(duration_ms);
137 self
138 }
139
140 pub fn with_denial(mut self, status: u16, reason: impl Into<String>) -> Self {
142 self.status = Some(status);
143 self.reason = Some(reason.into());
144 self
145 }
146}
147
148#[derive(Debug, Default)]
154pub enum AuditSink {
155 #[default]
157 Disabled,
158 File {
160 path: PathBuf,
162 max_bytes: Option<u64>,
164 state: Mutex<FileState>,
165 },
166}
167
168#[derive(Debug)]
170pub struct FileState {
171 writer: BufWriter<File>,
172 written: u64,
175}
176
177impl AuditSink {
178 pub fn file(path: impl AsRef<Path>) -> Result<Self> {
180 Self::file_with_limit(path, None)
181 }
182
183 pub fn file_with_limit(path: impl AsRef<Path>, max_bytes: Option<u64>) -> Result<Self> {
190 let path = path.as_ref().to_path_buf();
191 let (writer, written) = open_append(&path)?;
192 Ok(Self::File {
193 path,
194 max_bytes,
195 state: Mutex::new(FileState { writer, written }),
196 })
197 }
198
199 pub fn is_enabled(&self) -> bool {
201 matches!(self, Self::File { .. })
202 }
203
204 pub fn record(&self, event: AuditEvent) {
210 let Self::File {
211 path,
212 max_bytes,
213 state,
214 } = self
215 else {
216 return;
217 };
218
219 let line = match serde_json::to_string(&event) {
220 Ok(line) => line,
221 Err(e) => {
222 tracing::warn!(target: "audit", "cannot encode audit event: {e}");
223 return;
224 }
225 };
226
227 let Ok(mut state) = state.lock() else {
228 tracing::warn!(target: "audit", "audit log lock poisoned; event dropped");
229 return;
230 };
231
232 if let Some(limit) = max_bytes {
235 if state.written + line.len() as u64 + 1 > *limit && state.written > 0 {
236 if let Err(e) = rotate(path, &mut state) {
237 tracing::warn!(target: "audit", "cannot rotate audit log {}: {e}", path.display());
238 }
239 }
240 }
241
242 match writeln!(state.writer, "{line}").and_then(|()| state.writer.flush()) {
243 Ok(()) => state.written += line.len() as u64 + 1,
244 Err(e) => {
245 tracing::warn!(target: "audit", "cannot write audit log {}: {e}", path.display());
248 }
249 }
250 }
251}
252
253fn open_append(path: &Path) -> Result<(BufWriter<File>, u64)> {
255 let file = OpenOptions::new()
256 .create(true)
257 .append(true)
258 .open(path)
259 .map_err(|e| {
260 ShellTunnelError::Io(std::io::Error::new(
261 e.kind(),
262 format!("cannot open audit log {}: {e}", path.display()),
263 ))
264 })?;
265 let written = file.metadata().map(|m| m.len()).unwrap_or(0);
266 Ok((BufWriter::new(file), written))
267}
268
269fn rotate(path: &Path, state: &mut FileState) -> std::io::Result<()> {
271 state.writer.flush()?;
272
273 let rotated = path.with_extension(match path.extension() {
274 Some(ext) => format!("{}.1", ext.to_string_lossy()),
275 None => "1".to_string(),
276 });
277 std::fs::rename(path, &rotated)?;
279
280 let (writer, _) = open_append(path).map_err(std::io::Error::other)?;
281 state.writer = writer;
282 state.written = 0;
283 Ok(())
284}
285
286fn now_ms() -> u64 {
288 SystemTime::now()
289 .duration_since(UNIX_EPOCH)
290 .map(|d| d.as_millis() as u64)
291 .unwrap_or(0)
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 fn read_lines(path: &Path) -> Vec<AuditEvent> {
299 std::fs::read_to_string(path)
300 .unwrap()
301 .lines()
302 .map(|line| serde_json::from_str(line).expect("each line is one event"))
303 .collect()
304 }
305
306 #[test]
307 fn a_disabled_sink_records_nothing() {
308 let sink = AuditSink::Disabled;
309 assert!(!sink.is_enabled());
310 sink.record(AuditEvent::new("execute"));
311 }
312
313 #[test]
314 fn events_are_appended_one_per_line() {
315 let dir = tempfile::tempdir().unwrap();
316 let path = dir.path().join("audit.jsonl");
317 let sink = AuditSink::file(&path).unwrap();
318
319 sink.record(AuditEvent::new("execute").with_command("echo one"));
320 sink.record(AuditEvent::new("execute").with_command("echo two"));
321
322 let events = read_lines(&path);
323 assert_eq!(events.len(), 2);
324 assert_eq!(events[0].command.as_deref(), Some("echo one"));
325 assert_eq!(events[1].command.as_deref(), Some("echo two"));
326 }
327
328 #[test]
329 fn reopening_appends_rather_than_truncating() {
330 let dir = tempfile::tempdir().unwrap();
331 let path = dir.path().join("audit.jsonl");
332
333 AuditSink::file(&path)
334 .unwrap()
335 .record(AuditEvent::new("execute").with_command("first run"));
336 AuditSink::file(&path)
337 .unwrap()
338 .record(AuditEvent::new("execute").with_command("second run"));
339
340 let events = read_lines(&path);
342 assert_eq!(events.len(), 2);
343 }
344
345 #[test]
346 fn an_execution_event_carries_who_what_and_outcome() {
347 let dir = tempfile::tempdir().unwrap();
348 let path = dir.path().join("audit.jsonl");
349 let sink = AuditSink::file(&path).unwrap();
350
351 sink.record(
352 AuditEvent::new("execute")
353 .with_identity(Some(Identity {
354 token_id: "tok-1".into(),
355 label: "operator".into(),
356 }))
357 .with_client("203.0.113.7:51000")
358 .with_route("POST /api/v1/execute")
359 .with_command("whoami")
360 .with_outcome(Some(0), false, 42),
361 );
362
363 let event = read_lines(&path).remove(0);
364 assert_eq!(event.kind, "execute");
365 assert_eq!(event.identity.unwrap().label, "operator");
366 assert_eq!(event.command.as_deref(), Some("whoami"));
367 assert_eq!(event.exit_code, Some(0));
368 assert_eq!(event.timed_out, Some(false));
369 assert_eq!(event.duration_ms, Some(42));
370 assert!(event.at_ms > 0);
371 }
372
373 #[test]
374 fn a_denial_records_why_without_the_token() {
375 let dir = tempfile::tempdir().unwrap();
376 let path = dir.path().join("audit.jsonl");
377 let sink = AuditSink::file(&path).unwrap();
378
379 sink.record(
380 AuditEvent::new("denied")
381 .with_client("198.51.100.4:40000")
382 .with_route("POST /api/v1/execute")
383 .with_denial(401, "invalid-token"),
384 );
385
386 let raw = std::fs::read_to_string(&path).unwrap();
387 let event = read_lines(&path).remove(0);
388 assert_eq!(event.status, Some(401));
389 assert_eq!(event.reason.as_deref(), Some("invalid-token"));
390 assert!(!raw.contains("Bearer"), "{raw}");
393 }
394
395 #[test]
396 fn a_bounded_log_rotates_instead_of_growing() {
397 let dir = tempfile::tempdir().unwrap();
398 let path = dir.path().join("audit.jsonl");
399 let sink = AuditSink::file_with_limit(&path, Some(200)).unwrap();
401
402 for i in 0..8 {
403 sink.record(AuditEvent::new("execute").with_command(format!("command number {i}")));
404 }
405
406 let current = std::fs::metadata(&path).unwrap().len();
407 assert!(
408 current <= 200,
409 "current file should stay under the limit: {current}"
410 );
411
412 let rotated = dir.path().join("audit.jsonl.1");
415 assert!(rotated.exists(), "one generation should be kept");
416 }
417
418 #[test]
419 fn an_unbounded_log_never_rotates() {
420 let dir = tempfile::tempdir().unwrap();
421 let path = dir.path().join("audit.jsonl");
422 let sink = AuditSink::file(&path).unwrap();
423
424 for i in 0..20 {
425 sink.record(AuditEvent::new("execute").with_command(format!("command {i}")));
426 }
427
428 assert_eq!(read_lines(&path).len(), 20);
429 assert!(!dir.path().join("audit.jsonl.1").exists());
430 }
431
432 #[test]
433 fn rotation_keeps_counting_from_an_existing_file() {
434 let dir = tempfile::tempdir().unwrap();
435 let path = dir.path().join("audit.jsonl");
436
437 AuditSink::file(&path)
440 .unwrap()
441 .record(AuditEvent::new("execute").with_command("x".repeat(150)));
442 let sink = AuditSink::file_with_limit(&path, Some(200)).unwrap();
443 sink.record(AuditEvent::new("execute").with_command("second"));
444
445 assert!(dir.path().join("audit.jsonl.1").exists());
446 }
447
448 #[test]
449 fn absent_fields_are_omitted_rather_than_null() {
450 let dir = tempfile::tempdir().unwrap();
451 let path = dir.path().join("audit.jsonl");
452 AuditSink::file(&path)
453 .unwrap()
454 .record(AuditEvent::new("execute"));
455
456 let raw = std::fs::read_to_string(&path).unwrap();
457 assert!(!raw.contains("null"), "{raw}");
458 }
459}