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 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub file: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub bytes: Option<u64>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub entries: Option<u64>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub digest_ok: Option<bool>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub upload_id: Option<String>,
96}
97
98impl AuditEvent {
99 pub fn new(kind: impl Into<String>) -> Self {
101 Self {
102 at_ms: now_ms(),
103 kind: kind.into(),
104 identity: None,
105 client: None,
106 route: None,
107 command: None,
108 session_id: None,
109 exit_code: None,
110 timed_out: None,
111 duration_ms: None,
112 status: None,
113 reason: None,
114 file: None,
115 bytes: None,
116 entries: None,
117 digest_ok: None,
118 upload_id: None,
119 }
120 }
121
122 pub fn with_identity(mut self, identity: Option<Identity>) -> Self {
124 self.identity = identity;
125 self
126 }
127
128 pub fn with_client(mut self, client: impl Into<String>) -> Self {
130 self.client = Some(client.into());
131 self
132 }
133
134 pub fn with_route(mut self, route: impl Into<String>) -> Self {
136 self.route = Some(route.into());
137 self
138 }
139
140 pub fn with_command(mut self, command: impl Into<String>) -> Self {
142 self.command = Some(command.into());
143 self
144 }
145
146 pub fn with_session(mut self, session_id: u64) -> Self {
148 self.session_id = Some(session_id);
149 self
150 }
151
152 pub fn with_outcome(
154 mut self,
155 exit_code: Option<i32>,
156 timed_out: bool,
157 duration_ms: u64,
158 ) -> Self {
159 self.exit_code = exit_code;
160 self.timed_out = Some(timed_out);
161 self.duration_ms = Some(duration_ms);
162 self
163 }
164
165 pub fn with_denial(mut self, status: u16, reason: impl Into<String>) -> Self {
167 self.status = Some(status);
168 self.reason = Some(reason.into());
169 self
170 }
171
172 pub fn with_file(mut self, path: impl Into<String>, bytes: Option<u64>) -> Self {
178 self.file = Some(path.into());
179 self.bytes = bytes;
180 self
181 }
182
183 pub fn with_digest(mut self, verified: bool) -> Self {
185 self.digest_ok = Some(verified);
186 self
187 }
188
189 pub fn with_upload_id(mut self, id: impl Into<String>) -> Self {
201 self.upload_id = Some(id.into());
202 self
203 }
204}
205
206#[derive(Debug, Default)]
212pub enum AuditSink {
213 #[default]
215 Disabled,
216 File {
218 path: PathBuf,
220 max_bytes: Option<u64>,
222 state: Mutex<FileState>,
223 },
224}
225
226#[derive(Debug)]
228pub struct FileState {
229 writer: BufWriter<File>,
230 written: u64,
233}
234
235impl AuditSink {
236 pub fn file(path: impl AsRef<Path>) -> Result<Self> {
238 Self::file_with_limit(path, None)
239 }
240
241 pub fn file_with_limit(path: impl AsRef<Path>, max_bytes: Option<u64>) -> Result<Self> {
248 let path = path.as_ref().to_path_buf();
249 let (writer, written) = open_append(&path)?;
250 Ok(Self::File {
251 path,
252 max_bytes,
253 state: Mutex::new(FileState { writer, written }),
254 })
255 }
256
257 pub fn is_enabled(&self) -> bool {
259 matches!(self, Self::File { .. })
260 }
261
262 pub fn record(&self, event: AuditEvent) {
268 let Self::File {
269 path,
270 max_bytes,
271 state,
272 } = self
273 else {
274 return;
275 };
276
277 let line = match serde_json::to_string(&event) {
278 Ok(line) => line,
279 Err(e) => {
280 tracing::warn!(target: "audit", "cannot encode audit event: {e}");
281 return;
282 }
283 };
284
285 let Ok(mut state) = state.lock() else {
286 tracing::warn!(target: "audit", "audit log lock poisoned; event dropped");
287 return;
288 };
289
290 if let Some(limit) = max_bytes {
293 if state.written + line.len() as u64 + 1 > *limit && state.written > 0 {
294 if let Err(e) = rotate(path, &mut state) {
295 tracing::warn!(target: "audit", "cannot rotate audit log {}: {e}", path.display());
296 }
297 }
298 }
299
300 match writeln!(state.writer, "{line}").and_then(|()| state.writer.flush()) {
301 Ok(()) => state.written += line.len() as u64 + 1,
302 Err(e) => {
303 tracing::warn!(target: "audit", "cannot write audit log {}: {e}", path.display());
306 }
307 }
308 }
309}
310
311fn open_append(path: &Path) -> Result<(BufWriter<File>, u64)> {
313 let file = OpenOptions::new()
314 .create(true)
315 .append(true)
316 .open(path)
317 .map_err(|e| {
318 ShellTunnelError::Io(std::io::Error::new(
319 e.kind(),
320 format!("cannot open audit log {}: {e}", path.display()),
321 ))
322 })?;
323 let written = file.metadata().map(|m| m.len()).unwrap_or(0);
324 Ok((BufWriter::new(file), written))
325}
326
327fn rotate(path: &Path, state: &mut FileState) -> std::io::Result<()> {
329 state.writer.flush()?;
330
331 let rotated = path.with_extension(match path.extension() {
332 Some(ext) => format!("{}.1", ext.to_string_lossy()),
333 None => "1".to_string(),
334 });
335 std::fs::rename(path, &rotated)?;
337
338 let (writer, _) = open_append(path).map_err(std::io::Error::other)?;
339 state.writer = writer;
340 state.written = 0;
341 Ok(())
342}
343
344fn now_ms() -> u64 {
346 SystemTime::now()
347 .duration_since(UNIX_EPOCH)
348 .map(|d| d.as_millis() as u64)
349 .unwrap_or(0)
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 fn read_lines(path: &Path) -> Vec<AuditEvent> {
357 std::fs::read_to_string(path)
358 .unwrap()
359 .lines()
360 .map(|line| serde_json::from_str(line).expect("each line is one event"))
361 .collect()
362 }
363
364 #[test]
365 fn a_disabled_sink_records_nothing() {
366 let sink = AuditSink::Disabled;
367 assert!(!sink.is_enabled());
368 sink.record(AuditEvent::new("execute"));
369 }
370
371 #[test]
372 fn events_are_appended_one_per_line() {
373 let dir = tempfile::tempdir().unwrap();
374 let path = dir.path().join("audit.jsonl");
375 let sink = AuditSink::file(&path).unwrap();
376
377 sink.record(AuditEvent::new("execute").with_command("echo one"));
378 sink.record(AuditEvent::new("execute").with_command("echo two"));
379
380 let events = read_lines(&path);
381 assert_eq!(events.len(), 2);
382 assert_eq!(events[0].command.as_deref(), Some("echo one"));
383 assert_eq!(events[1].command.as_deref(), Some("echo two"));
384 }
385
386 #[test]
387 fn reopening_appends_rather_than_truncating() {
388 let dir = tempfile::tempdir().unwrap();
389 let path = dir.path().join("audit.jsonl");
390
391 AuditSink::file(&path)
392 .unwrap()
393 .record(AuditEvent::new("execute").with_command("first run"));
394 AuditSink::file(&path)
395 .unwrap()
396 .record(AuditEvent::new("execute").with_command("second run"));
397
398 let events = read_lines(&path);
400 assert_eq!(events.len(), 2);
401 }
402
403 #[test]
404 fn an_execution_event_carries_who_what_and_outcome() {
405 let dir = tempfile::tempdir().unwrap();
406 let path = dir.path().join("audit.jsonl");
407 let sink = AuditSink::file(&path).unwrap();
408
409 sink.record(
410 AuditEvent::new("execute")
411 .with_identity(Some(Identity {
412 token_id: "tok-1".into(),
413 label: "operator".into(),
414 }))
415 .with_client("203.0.113.7:51000")
416 .with_route("POST /api/v1/execute")
417 .with_command("whoami")
418 .with_outcome(Some(0), false, 42),
419 );
420
421 let event = read_lines(&path).remove(0);
422 assert_eq!(event.kind, "execute");
423 assert_eq!(event.identity.unwrap().label, "operator");
424 assert_eq!(event.command.as_deref(), Some("whoami"));
425 assert_eq!(event.exit_code, Some(0));
426 assert_eq!(event.timed_out, Some(false));
427 assert_eq!(event.duration_ms, Some(42));
428 assert!(event.at_ms > 0);
429 }
430
431 #[test]
432 fn a_denial_records_why_without_the_token() {
433 let dir = tempfile::tempdir().unwrap();
434 let path = dir.path().join("audit.jsonl");
435 let sink = AuditSink::file(&path).unwrap();
436
437 sink.record(
438 AuditEvent::new("denied")
439 .with_client("198.51.100.4:40000")
440 .with_route("POST /api/v1/execute")
441 .with_denial(401, "invalid-token"),
442 );
443
444 let raw = std::fs::read_to_string(&path).unwrap();
445 let event = read_lines(&path).remove(0);
446 assert_eq!(event.status, Some(401));
447 assert_eq!(event.reason.as_deref(), Some("invalid-token"));
448 assert!(!raw.contains("Bearer"), "{raw}");
451 }
452
453 #[test]
454 fn a_bounded_log_rotates_instead_of_growing() {
455 let dir = tempfile::tempdir().unwrap();
456 let path = dir.path().join("audit.jsonl");
457 let sink = AuditSink::file_with_limit(&path, Some(200)).unwrap();
459
460 for i in 0..8 {
461 sink.record(AuditEvent::new("execute").with_command(format!("command number {i}")));
462 }
463
464 let current = std::fs::metadata(&path).unwrap().len();
465 assert!(
466 current <= 200,
467 "current file should stay under the limit: {current}"
468 );
469
470 let rotated = dir.path().join("audit.jsonl.1");
473 assert!(rotated.exists(), "one generation should be kept");
474 }
475
476 #[test]
477 fn an_unbounded_log_never_rotates() {
478 let dir = tempfile::tempdir().unwrap();
479 let path = dir.path().join("audit.jsonl");
480 let sink = AuditSink::file(&path).unwrap();
481
482 for i in 0..20 {
483 sink.record(AuditEvent::new("execute").with_command(format!("command {i}")));
484 }
485
486 assert_eq!(read_lines(&path).len(), 20);
487 assert!(!dir.path().join("audit.jsonl.1").exists());
488 }
489
490 #[test]
491 fn rotation_keeps_counting_from_an_existing_file() {
492 let dir = tempfile::tempdir().unwrap();
493 let path = dir.path().join("audit.jsonl");
494
495 AuditSink::file(&path)
498 .unwrap()
499 .record(AuditEvent::new("execute").with_command("x".repeat(150)));
500 let sink = AuditSink::file_with_limit(&path, Some(200)).unwrap();
501 sink.record(AuditEvent::new("execute").with_command("second"));
502
503 assert!(dir.path().join("audit.jsonl.1").exists());
504 }
505
506 #[test]
507 fn absent_fields_are_omitted_rather_than_null() {
508 let dir = tempfile::tempdir().unwrap();
509 let path = dir.path().join("audit.jsonl");
510 AuditSink::file(&path)
511 .unwrap()
512 .record(AuditEvent::new("execute"));
513
514 let raw = std::fs::read_to_string(&path).unwrap();
515 assert!(!raw.contains("null"), "{raw}");
516 }
517}