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")]
81 pub output_bytes: Option<u64>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub status: Option<u16>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub reason: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub file: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub bytes: Option<u64>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub entries: Option<u64>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub digest_ok: Option<bool>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub upload_id: Option<String>,
108}
109
110impl AuditEvent {
111 pub fn new(kind: impl Into<String>) -> Self {
113 Self {
114 at_ms: now_ms(),
115 kind: kind.into(),
116 identity: None,
117 client: None,
118 route: None,
119 command: None,
120 session_id: None,
121 exit_code: None,
122 timed_out: None,
123 duration_ms: None,
124 output_bytes: None,
125 status: None,
126 reason: None,
127 file: None,
128 bytes: None,
129 entries: None,
130 digest_ok: None,
131 upload_id: None,
132 }
133 }
134
135 pub fn with_identity(mut self, identity: Option<Identity>) -> Self {
137 self.identity = identity;
138 self
139 }
140
141 pub fn with_client(mut self, client: impl Into<String>) -> Self {
143 self.client = Some(client.into());
144 self
145 }
146
147 pub fn with_route(mut self, route: impl Into<String>) -> Self {
149 self.route = Some(route.into());
150 self
151 }
152
153 pub fn with_command(mut self, command: impl Into<String>) -> Self {
155 self.command = Some(command.into());
156 self
157 }
158
159 pub fn with_session(mut self, session_id: u64) -> Self {
161 self.session_id = Some(session_id);
162 self
163 }
164
165 pub fn with_outcome(
167 mut self,
168 exit_code: Option<i32>,
169 timed_out: bool,
170 duration_ms: u64,
171 ) -> Self {
172 self.exit_code = exit_code;
173 self.timed_out = Some(timed_out);
174 self.duration_ms = Some(duration_ms);
175 self
176 }
177
178 pub fn with_truncated_output(mut self, truncated: bool, total_bytes: u64) -> Self {
184 if truncated {
185 self.output_bytes = Some(total_bytes);
186 }
187 self
188 }
189
190 pub fn with_denial(mut self, status: u16, reason: impl Into<String>) -> Self {
192 self.status = Some(status);
193 self.reason = Some(reason.into());
194 self
195 }
196
197 pub fn with_file(mut self, path: impl Into<String>, bytes: Option<u64>) -> Self {
203 self.file = Some(path.into());
204 self.bytes = bytes;
205 self
206 }
207
208 pub fn with_digest(mut self, verified: bool) -> Self {
210 self.digest_ok = Some(verified);
211 self
212 }
213
214 pub fn with_upload_id(mut self, id: impl Into<String>) -> Self {
226 self.upload_id = Some(id.into());
227 self
228 }
229}
230
231#[derive(Debug, Default)]
237pub enum AuditSink {
238 #[default]
240 Disabled,
241 File {
243 path: PathBuf,
245 max_bytes: Option<u64>,
247 state: Mutex<FileState>,
248 },
249}
250
251#[derive(Debug)]
253pub struct FileState {
254 writer: BufWriter<File>,
255 written: u64,
258}
259
260impl AuditSink {
261 pub fn file(path: impl AsRef<Path>) -> Result<Self> {
263 Self::file_with_limit(path, None)
264 }
265
266 pub fn file_with_limit(path: impl AsRef<Path>, max_bytes: Option<u64>) -> Result<Self> {
273 let path = path.as_ref().to_path_buf();
274 let (writer, written) = open_append(&path)?;
275 Ok(Self::File {
276 path,
277 max_bytes,
278 state: Mutex::new(FileState { writer, written }),
279 })
280 }
281
282 pub fn is_enabled(&self) -> bool {
284 matches!(self, Self::File { .. })
285 }
286
287 pub fn record(&self, event: AuditEvent) {
293 let Self::File {
294 path,
295 max_bytes,
296 state,
297 } = self
298 else {
299 return;
300 };
301
302 let line = match serde_json::to_string(&event) {
303 Ok(line) => line,
304 Err(e) => {
305 tracing::warn!(target: "audit", "cannot encode audit event: {e}");
306 return;
307 }
308 };
309
310 let Ok(mut state) = state.lock() else {
311 tracing::warn!(target: "audit", "audit log lock poisoned; event dropped");
312 return;
313 };
314
315 if let Some(limit) = max_bytes {
318 if state.written + line.len() as u64 + 1 > *limit && state.written > 0 {
319 if let Err(e) = rotate(path, &mut state) {
320 tracing::warn!(target: "audit", "cannot rotate audit log {}: {e}", path.display());
321 }
322 }
323 }
324
325 match writeln!(state.writer, "{line}").and_then(|()| state.writer.flush()) {
326 Ok(()) => state.written += line.len() as u64 + 1,
327 Err(e) => {
328 tracing::warn!(target: "audit", "cannot write audit log {}: {e}", path.display());
331 }
332 }
333 }
334}
335
336fn open_append(path: &Path) -> Result<(BufWriter<File>, u64)> {
338 let file = OpenOptions::new()
339 .create(true)
340 .append(true)
341 .open(path)
342 .map_err(|e| {
343 ShellTunnelError::Io(std::io::Error::new(
344 e.kind(),
345 format!("cannot open audit log {}: {e}", path.display()),
346 ))
347 })?;
348 let written = file.metadata().map(|m| m.len()).unwrap_or(0);
349 Ok((BufWriter::new(file), written))
350}
351
352fn rotate(path: &Path, state: &mut FileState) -> std::io::Result<()> {
354 state.writer.flush()?;
355
356 let rotated = path.with_extension(match path.extension() {
357 Some(ext) => format!("{}.1", ext.to_string_lossy()),
358 None => "1".to_string(),
359 });
360 std::fs::rename(path, &rotated)?;
362
363 let (writer, _) = open_append(path).map_err(std::io::Error::other)?;
364 state.writer = writer;
365 state.written = 0;
366 Ok(())
367}
368
369fn now_ms() -> u64 {
371 SystemTime::now()
372 .duration_since(UNIX_EPOCH)
373 .map(|d| d.as_millis() as u64)
374 .unwrap_or(0)
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 fn read_lines(path: &Path) -> Vec<AuditEvent> {
382 std::fs::read_to_string(path)
383 .unwrap()
384 .lines()
385 .map(|line| serde_json::from_str(line).expect("each line is one event"))
386 .collect()
387 }
388
389 #[test]
390 fn a_disabled_sink_records_nothing() {
391 let sink = AuditSink::Disabled;
392 assert!(!sink.is_enabled());
393 sink.record(AuditEvent::new("execute"));
394 }
395
396 #[test]
397 fn events_are_appended_one_per_line() {
398 let dir = tempfile::tempdir().unwrap();
399 let path = dir.path().join("audit.jsonl");
400 let sink = AuditSink::file(&path).unwrap();
401
402 sink.record(AuditEvent::new("execute").with_command("echo one"));
403 sink.record(AuditEvent::new("execute").with_command("echo two"));
404
405 let events = read_lines(&path);
406 assert_eq!(events.len(), 2);
407 assert_eq!(events[0].command.as_deref(), Some("echo one"));
408 assert_eq!(events[1].command.as_deref(), Some("echo two"));
409 }
410
411 #[test]
412 fn reopening_appends_rather_than_truncating() {
413 let dir = tempfile::tempdir().unwrap();
414 let path = dir.path().join("audit.jsonl");
415
416 AuditSink::file(&path)
417 .unwrap()
418 .record(AuditEvent::new("execute").with_command("first run"));
419 AuditSink::file(&path)
420 .unwrap()
421 .record(AuditEvent::new("execute").with_command("second run"));
422
423 let events = read_lines(&path);
425 assert_eq!(events.len(), 2);
426 }
427
428 #[test]
429 fn an_execution_event_carries_who_what_and_outcome() {
430 let dir = tempfile::tempdir().unwrap();
431 let path = dir.path().join("audit.jsonl");
432 let sink = AuditSink::file(&path).unwrap();
433
434 sink.record(
435 AuditEvent::new("execute")
436 .with_identity(Some(Identity {
437 token_id: "tok-1".into(),
438 label: "operator".into(),
439 }))
440 .with_client("203.0.113.7:51000")
441 .with_route("POST /api/v1/execute")
442 .with_command("whoami")
443 .with_outcome(Some(0), false, 42),
444 );
445
446 let event = read_lines(&path).remove(0);
447 assert_eq!(event.kind, "execute");
448 assert_eq!(event.identity.unwrap().label, "operator");
449 assert_eq!(event.command.as_deref(), Some("whoami"));
450 assert_eq!(event.exit_code, Some(0));
451 assert_eq!(event.timed_out, Some(false));
452 assert_eq!(event.duration_ms, Some(42));
453 assert!(event.at_ms > 0);
454 }
455
456 #[test]
457 fn a_denial_records_why_without_the_token() {
458 let dir = tempfile::tempdir().unwrap();
459 let path = dir.path().join("audit.jsonl");
460 let sink = AuditSink::file(&path).unwrap();
461
462 sink.record(
463 AuditEvent::new("denied")
464 .with_client("198.51.100.4:40000")
465 .with_route("POST /api/v1/execute")
466 .with_denial(401, "invalid-token"),
467 );
468
469 let raw = std::fs::read_to_string(&path).unwrap();
470 let event = read_lines(&path).remove(0);
471 assert_eq!(event.status, Some(401));
472 assert_eq!(event.reason.as_deref(), Some("invalid-token"));
473 assert!(!raw.contains("Bearer"), "{raw}");
476 }
477
478 #[test]
479 fn a_bounded_log_rotates_instead_of_growing() {
480 let dir = tempfile::tempdir().unwrap();
481 let path = dir.path().join("audit.jsonl");
482 let sink = AuditSink::file_with_limit(&path, Some(200)).unwrap();
484
485 for i in 0..8 {
486 sink.record(AuditEvent::new("execute").with_command(format!("command number {i}")));
487 }
488
489 let current = std::fs::metadata(&path).unwrap().len();
490 assert!(
491 current <= 200,
492 "current file should stay under the limit: {current}"
493 );
494
495 let rotated = dir.path().join("audit.jsonl.1");
498 assert!(rotated.exists(), "one generation should be kept");
499 }
500
501 #[test]
502 fn an_unbounded_log_never_rotates() {
503 let dir = tempfile::tempdir().unwrap();
504 let path = dir.path().join("audit.jsonl");
505 let sink = AuditSink::file(&path).unwrap();
506
507 for i in 0..20 {
508 sink.record(AuditEvent::new("execute").with_command(format!("command {i}")));
509 }
510
511 assert_eq!(read_lines(&path).len(), 20);
512 assert!(!dir.path().join("audit.jsonl.1").exists());
513 }
514
515 #[test]
516 fn rotation_keeps_counting_from_an_existing_file() {
517 let dir = tempfile::tempdir().unwrap();
518 let path = dir.path().join("audit.jsonl");
519
520 AuditSink::file(&path)
523 .unwrap()
524 .record(AuditEvent::new("execute").with_command("x".repeat(150)));
525 let sink = AuditSink::file_with_limit(&path, Some(200)).unwrap();
526 sink.record(AuditEvent::new("execute").with_command("second"));
527
528 assert!(dir.path().join("audit.jsonl.1").exists());
529 }
530
531 #[test]
532 fn absent_fields_are_omitted_rather_than_null() {
533 let dir = tempfile::tempdir().unwrap();
534 let path = dir.path().join("audit.jsonl");
535 AuditSink::file(&path)
536 .unwrap()
537 .record(AuditEvent::new("execute"));
538
539 let raw = std::fs::read_to_string(&path).unwrap();
540 assert!(!raw.contains("null"), "{raw}");
541 }
542}