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 async fn record_async(self: &std::sync::Arc<Self>, event: AuditEvent) {
308 if !self.is_enabled() {
309 return;
310 }
311 let sink = std::sync::Arc::clone(self);
312 let _ = tokio::task::spawn_blocking(move || sink.record(event)).await;
316 }
317
318 pub fn record(&self, event: AuditEvent) {
328 let Self::File {
329 path,
330 max_bytes,
331 state,
332 } = self
333 else {
334 return;
335 };
336
337 let line = match serde_json::to_string(&event) {
338 Ok(line) => line,
339 Err(e) => {
340 tracing::warn!(target: "audit", "cannot encode audit event: {e}");
341 return;
342 }
343 };
344
345 let Ok(mut state) = state.lock() else {
346 tracing::warn!(target: "audit", "audit log lock poisoned; event dropped");
347 return;
348 };
349
350 if let Some(limit) = max_bytes {
353 if state.written + line.len() as u64 + 1 > *limit && state.written > 0 {
354 if let Err(e) = rotate(path, &mut state) {
355 tracing::warn!(target: "audit", "cannot rotate audit log {}: {e}", path.display());
356 }
357 }
358 }
359
360 match writeln!(state.writer, "{line}").and_then(|()| state.writer.flush()) {
361 Ok(()) => state.written += line.len() as u64 + 1,
362 Err(e) => {
363 tracing::warn!(target: "audit", "cannot write audit log {}: {e}", path.display());
366 }
367 }
368 }
369}
370
371fn open_append(path: &Path) -> Result<(BufWriter<File>, u64)> {
373 let file = OpenOptions::new()
374 .create(true)
375 .append(true)
376 .open(path)
377 .map_err(|e| {
378 ShellTunnelError::Io(std::io::Error::new(
379 e.kind(),
380 format!("cannot open audit log {}: {e}", path.display()),
381 ))
382 })?;
383 let written = file.metadata().map(|m| m.len()).unwrap_or(0);
384 Ok((BufWriter::new(file), written))
385}
386
387fn rotate(path: &Path, state: &mut FileState) -> std::io::Result<()> {
389 state.writer.flush()?;
390
391 let rotated = path.with_extension(match path.extension() {
392 Some(ext) => format!("{}.1", ext.to_string_lossy()),
393 None => "1".to_string(),
394 });
395 std::fs::rename(path, &rotated)?;
397
398 let (writer, _) = open_append(path).map_err(std::io::Error::other)?;
399 state.writer = writer;
400 state.written = 0;
401 Ok(())
402}
403
404fn now_ms() -> u64 {
406 SystemTime::now()
407 .duration_since(UNIX_EPOCH)
408 .map(|d| d.as_millis() as u64)
409 .unwrap_or(0)
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 fn read_lines(path: &Path) -> Vec<AuditEvent> {
417 std::fs::read_to_string(path)
418 .unwrap()
419 .lines()
420 .map(|line| serde_json::from_str(line).expect("each line is one event"))
421 .collect()
422 }
423
424 #[test]
425 fn a_disabled_sink_records_nothing() {
426 let sink = AuditSink::Disabled;
427 assert!(!sink.is_enabled());
428 sink.record(AuditEvent::new("execute"));
429 }
430
431 #[test]
432 fn events_are_appended_one_per_line() {
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(AuditEvent::new("execute").with_command("echo one"));
438 sink.record(AuditEvent::new("execute").with_command("echo two"));
439
440 let events = read_lines(&path);
441 assert_eq!(events.len(), 2);
442 assert_eq!(events[0].command.as_deref(), Some("echo one"));
443 assert_eq!(events[1].command.as_deref(), Some("echo two"));
444 }
445
446 #[test]
447 fn reopening_appends_rather_than_truncating() {
448 let dir = tempfile::tempdir().unwrap();
449 let path = dir.path().join("audit.jsonl");
450
451 AuditSink::file(&path)
452 .unwrap()
453 .record(AuditEvent::new("execute").with_command("first run"));
454 AuditSink::file(&path)
455 .unwrap()
456 .record(AuditEvent::new("execute").with_command("second run"));
457
458 let events = read_lines(&path);
460 assert_eq!(events.len(), 2);
461 }
462
463 #[test]
464 fn an_execution_event_carries_who_what_and_outcome() {
465 let dir = tempfile::tempdir().unwrap();
466 let path = dir.path().join("audit.jsonl");
467 let sink = AuditSink::file(&path).unwrap();
468
469 sink.record(
470 AuditEvent::new("execute")
471 .with_identity(Some(Identity {
472 token_id: "tok-1".into(),
473 label: "operator".into(),
474 }))
475 .with_client("203.0.113.7:51000")
476 .with_route("POST /api/v1/execute")
477 .with_command("whoami")
478 .with_outcome(Some(0), false, 42),
479 );
480
481 let event = read_lines(&path).remove(0);
482 assert_eq!(event.kind, "execute");
483 assert_eq!(event.identity.unwrap().label, "operator");
484 assert_eq!(event.command.as_deref(), Some("whoami"));
485 assert_eq!(event.exit_code, Some(0));
486 assert_eq!(event.timed_out, Some(false));
487 assert_eq!(event.duration_ms, Some(42));
488 assert!(event.at_ms > 0);
489 }
490
491 #[test]
492 fn a_denial_records_why_without_the_token() {
493 let dir = tempfile::tempdir().unwrap();
494 let path = dir.path().join("audit.jsonl");
495 let sink = AuditSink::file(&path).unwrap();
496
497 sink.record(
498 AuditEvent::new("denied")
499 .with_client("198.51.100.4:40000")
500 .with_route("POST /api/v1/execute")
501 .with_denial(401, "invalid-token"),
502 );
503
504 let raw = std::fs::read_to_string(&path).unwrap();
505 let event = read_lines(&path).remove(0);
506 assert_eq!(event.status, Some(401));
507 assert_eq!(event.reason.as_deref(), Some("invalid-token"));
508 assert!(!raw.contains("Bearer"), "{raw}");
511 }
512
513 #[test]
514 fn a_bounded_log_rotates_instead_of_growing() {
515 let dir = tempfile::tempdir().unwrap();
516 let path = dir.path().join("audit.jsonl");
517 let sink = AuditSink::file_with_limit(&path, Some(200)).unwrap();
519
520 for i in 0..8 {
521 sink.record(AuditEvent::new("execute").with_command(format!("command number {i}")));
522 }
523
524 let current = std::fs::metadata(&path).unwrap().len();
525 assert!(
526 current <= 200,
527 "current file should stay under the limit: {current}"
528 );
529
530 let rotated = dir.path().join("audit.jsonl.1");
533 assert!(rotated.exists(), "one generation should be kept");
534 }
535
536 #[test]
537 fn an_unbounded_log_never_rotates() {
538 let dir = tempfile::tempdir().unwrap();
539 let path = dir.path().join("audit.jsonl");
540 let sink = AuditSink::file(&path).unwrap();
541
542 for i in 0..20 {
543 sink.record(AuditEvent::new("execute").with_command(format!("command {i}")));
544 }
545
546 assert_eq!(read_lines(&path).len(), 20);
547 assert!(!dir.path().join("audit.jsonl.1").exists());
548 }
549
550 #[test]
551 fn rotation_keeps_counting_from_an_existing_file() {
552 let dir = tempfile::tempdir().unwrap();
553 let path = dir.path().join("audit.jsonl");
554
555 AuditSink::file(&path)
558 .unwrap()
559 .record(AuditEvent::new("execute").with_command("x".repeat(150)));
560 let sink = AuditSink::file_with_limit(&path, Some(200)).unwrap();
561 sink.record(AuditEvent::new("execute").with_command("second"));
562
563 assert!(dir.path().join("audit.jsonl.1").exists());
564 }
565
566 #[test]
567 fn absent_fields_are_omitted_rather_than_null() {
568 let dir = tempfile::tempdir().unwrap();
569 let path = dir.path().join("audit.jsonl");
570 AuditSink::file(&path)
571 .unwrap()
572 .record(AuditEvent::new("execute"));
573
574 let raw = std::fs::read_to_string(&path).unwrap();
575 assert!(!raw.contains("null"), "{raw}");
576 }
577}