1use std::fs::{File, OpenOptions};
6use std::io::Write;
7use std::path::PathBuf;
8use std::sync::Mutex;
9
10use serde::Serialize;
11
12use crate::error::Result;
13
14#[derive(Debug, Serialize)]
15pub struct Record<'a> {
16 pub ts: String,
17 pub session: &'a str,
18 pub host: &'a str,
19 pub tool: &'a str,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub command: Option<&'a str>,
22 #[serde(skip_serializing_if = "Option::is_none")]
23 pub path: Option<&'a str>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub target: Option<&'a str>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub bytes: Option<u64>,
28 pub outcome: &'a str,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub exit_code: Option<i32>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub error: Option<String>,
33 pub duration_ms: u128,
34}
35
36pub struct Audit {
37 dir: PathBuf,
38 session: String,
39 host: String,
40 file: Mutex<Option<(String, File)>>,
41}
42
43impl Audit {
44 pub fn open(dir: PathBuf, host: &str) -> Result<Self> {
45 std::fs::create_dir_all(&dir)?;
46 let session = format!(
47 "{}-{}",
48 chrono::Utc::now().format("%Y%m%dT%H%M%S"),
49 std::process::id()
50 );
51 Ok(Self {
52 dir,
53 session,
54 host: host.to_string(),
55 file: Mutex::new(None),
56 })
57 }
58
59 pub fn session(&self) -> &str {
60 &self.session
61 }
62
63 pub fn dir(&self) -> &PathBuf {
64 &self.dir
65 }
66
67 pub fn record<'a>(&'a self, mut rec: Record<'a>) {
71 rec.ts = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
72 rec.session = &self.session;
73 rec.host = &self.host;
74 let line = match serde_json::to_string(&rec) {
75 Ok(l) => l,
76 Err(e) => {
77 eprintln!("farhand: audit serialise failed: {e}");
78 return;
79 }
80 };
81 let day = chrono::Utc::now().format("%Y-%m-%d").to_string();
82 let mut guard = match self.file.lock() {
83 Ok(g) => g,
84 Err(p) => p.into_inner(),
85 };
86 let needs_open = !matches!(&*guard, Some((d, _)) if *d == day);
87 if needs_open {
88 let path = self.dir.join(format!("{day}.jsonl"));
89 match OpenOptions::new().create(true).append(true).open(&path) {
90 Ok(f) => *guard = Some((day, f)),
91 Err(e) => {
92 eprintln!("farhand: cannot open audit log {}: {e}", path.display());
93 return;
94 }
95 }
96 }
97 if let Some((_, f)) = &mut *guard {
98 if let Err(e) = writeln!(f, "{line}") {
99 eprintln!("farhand: audit write failed: {e}");
100 }
101 }
102 }
103}
104
105impl<'a> Record<'a> {
106 pub fn new(tool: &'a str) -> Self {
107 Self {
108 ts: String::new(),
109 session: "",
110 host: "",
111 tool,
112 command: None,
113 path: None,
114 target: None,
115 bytes: None,
116 outcome: "ok",
117 exit_code: None,
118 error: None,
119 duration_ms: 0,
120 }
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 #[test]
129 fn writes_one_line_per_record() {
130 let tmp = tempfile::tempdir().unwrap();
131 let audit = Audit::open(tmp.path().join("audit"), "devbox").unwrap();
132 let mut r = Record::new("remote_shell");
133 r.command = Some("ls");
134 r.exit_code = Some(0);
135 audit.record(r);
136 let mut r = Record::new("upload");
137 r.outcome = "denied";
138 r.error = Some("rule: private-key-block".into());
139 audit.record(r);
140 let files: Vec<_> = std::fs::read_dir(tmp.path().join("audit"))
141 .unwrap()
142 .collect();
143 assert_eq!(files.len(), 1);
144 let text = std::fs::read_to_string(files[0].as_ref().unwrap().path()).unwrap();
145 let lines: Vec<&str> = text.lines().collect();
146 assert_eq!(lines.len(), 2);
147 let v: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
148 assert_eq!(v["host"], "devbox");
149 assert_eq!(v["command"], "ls");
150 assert!(v["path"].is_null());
151 }
152}