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