1use crate::authn_authz::{Access, AuditRecord, AuthInfo, Identity};
2use crate::disk_space::{MinFree, MonitoredPath};
3use crate::log::{mark_existing_logs_as_done_in_dir, OpenedFile};
4use anyhow::Context;
5use chrono::{DateTime, Utc};
6use parking_lot::Mutex;
7use serde::{Deserialize, Serialize};
8use std::io::Write;
9use std::path::PathBuf;
10use std::sync::OnceLock;
11use std::time::{Duration, Instant};
12use tokio::sync::mpsc::{channel, Receiver, Sender};
13use tokio::task::JoinHandle;
14use zstd::Encoder;
15
16static LOGGER: OnceLock<AcctLogger> = OnceLock::new();
17
18#[derive(Debug)]
19enum AcctCommand {
20 Record(AcctLogRecord),
21 Terminate,
23}
24
25struct AcctLogger {
26 tx: Sender<AcctCommand>,
27 task: Mutex<Option<JoinHandle<()>>>,
29}
30
31#[derive(Deserialize, Clone, Debug)]
32#[serde(deny_unknown_fields)]
33pub struct AuditLogParams {
34 pub log_dir: PathBuf,
36 #[serde(default = "AuditLogParams::default_max_file_size")]
38 pub max_file_size: u64,
39 #[serde(default = "AuditLogParams::default_back_pressure")]
43 pub back_pressure: usize,
44
45 #[serde(default = "AuditLogParams::default_compression_level")]
49 pub compression_level: i32,
50
51 #[serde(default, with = "duration_serde")]
52 pub max_segment_duration: Option<Duration>,
53
54 #[serde(default)]
55 pub min_free_space: MinFree,
56 #[serde(default)]
57 pub min_free_inodes: MinFree,
58
59 #[serde(default = "AuditLogParams::default_true")]
61 pub log_authz_allow: bool,
62
63 #[serde(default = "AuditLogParams::default_true")]
65 pub log_authz_deny: bool,
66
67 #[serde(default = "AuditLogParams::default_true")]
69 pub log_authn_ok: bool,
70
71 #[serde(default = "AuditLogParams::default_true")]
73 pub log_authn_fail: bool,
74}
75
76impl AuditLogParams {
77 pub fn default_max_file_size() -> u64 {
78 1_000_000_000
79 }
80 pub fn default_back_pressure() -> usize {
81 128_000
82 }
83 pub fn default_compression_level() -> i32 {
84 0 }
86
87 pub fn default_true() -> bool {
88 true
89 }
90
91 pub async fn init(&self) -> anyhow::Result<()> {
92 let (tx, rx) = channel(self.back_pressure);
93 let params = self.clone();
94 let task = kumo_server_runtime::spawn_on_main("acct logger", async move {
95 if let Err(err) = params.acct_logger(rx).await {
96 tracing::error!(
97 "AuditLogParams::acct_logger returned with error: {err}, \
98 audit logging will cease to function until the next restart!"
99 );
100 }
101 })?;
102
103 let logger = AcctLogger {
104 tx,
105 task: Mutex::new(Some(task)),
106 };
107 if LOGGER.set(logger).is_err() {
108 anyhow::bail!("AuditLogParams::init must not be called more than once");
109 }
110
111 Ok(())
112 }
113
114 async fn acct_logger(self, mut rx: Receiver<AcctCommand>) -> anyhow::Result<()> {
115 if !config::is_validating() {
116 if let Err(err) = mark_existing_logs_as_done_in_dir(&self.log_dir) {
117 tracing::error!("{err}");
118 }
119 std::fs::create_dir_all(&self.log_dir).ok();
120 MonitoredPath {
121 name: format!("log dir {}", self.log_dir.display()),
122 path: self.log_dir.clone(),
123 min_free_space: self.min_free_space,
124 min_free_inodes: self.min_free_inodes,
125 }
126 .register();
127 }
128
129 let mut expire_counter = 0u16;
130 let mut current_file: Option<OpenedFile> = None;
131
132 loop {
133 let deadline = current_file.as_ref().and_then(|f| f.expires);
134
135 let mut check_expire = false;
136 let record = match deadline {
137 Some(deadline) => tokio::select! {
138 cmd = rx.recv() => {
139 match cmd {
140 Some(AcctCommand::Record(r)) => Some(r),
141 Some(AcctCommand::Terminate) | None => return Ok(()),
142 }
143 }
144 _ = tokio::time::sleep_until(deadline.into()) => {
145 check_expire = true;
146 None
147 }
148 },
149 None => match rx.recv().await {
150 Some(AcctCommand::Record(r)) => Some(r),
151 Some(AcctCommand::Terminate) | None => return Ok(()),
152 },
153 };
154
155 expire_counter = expire_counter.wrapping_add(1);
156 if expire_counter == 10_000 {
157 check_expire = true;
158 }
159
160 if check_expire {
161 let now = Instant::now();
162 let do_expire = current_file
163 .as_ref()
164 .and_then(|f| f.expires.map(|exp| exp <= now))
165 .unwrap_or(false);
166
167 if do_expire {
168 current_file.take();
169 }
170 expire_counter = 0;
171 }
172
173 if let Some(record) = record {
174 let should_log = match &record {
175 AcctLogRecord::Authentication(authn) => {
176 if authn.success {
177 self.log_authn_ok
178 } else {
179 self.log_authn_fail
180 }
181 }
182 AcctLogRecord::Authorization(authz) => match authz.access {
183 Access::Allow => self.log_authz_allow,
184 Access::Deny => self.log_authz_deny,
185 },
186 };
187
188 if !should_log {
189 continue;
190 }
191
192 if let Err(err) = self.open_file_if_needed(&mut current_file) {
193 tracing::error!("{err}");
194 continue;
195 }
196
197 if let Err(err) = self.do_record(&mut current_file, record) {
198 tracing::error!("{err}");
199 continue;
200 }
201 }
202 }
203 }
204
205 fn open_file_if_needed(&self, current_file: &mut Option<OpenedFile>) -> anyhow::Result<()> {
206 if current_file.is_some() {
207 return Ok(());
208 }
209 let now = Utc::now();
210
211 let base_name = now.format("acct-%Y%m%d-%H%M%S%.f").to_string();
212
213 let name = self.log_dir.join(base_name);
214 let log_dir = name
218 .parent()
219 .expect("log_dir.join ensures we always have a parent");
220
221 let f = match std::fs::OpenOptions::new()
222 .append(true)
223 .create(true)
224 .open(&name)
225 {
226 Ok(f) => f,
227 Err(err) => {
228 if err.kind() == std::io::ErrorKind::NotFound {
229 match std::fs::create_dir_all(log_dir) {
230 Ok(_) => {
231 std::fs::OpenOptions::new()
233 .append(true)
234 .create(true)
235 .open(&name)
236 .with_context(|| format!("open log file {name:?}"))?
237 }
238 Err(dir_err) => {
239 anyhow::bail!(
240 "open log file {name:?}: failed: {err:#?}. \
241 Additionally, attempting to create dir {} failed: {dir_err:#?}",
242 log_dir.display()
243 );
244 }
245 }
246 } else {
247 anyhow::bail!("open log file {name:?}: failed: {err:#?}");
248 }
249 }
250 };
251
252 let file = OpenedFile {
253 file: Encoder::new(f, self.compression_level).context("set up zstd encoder")?,
254 name,
255 written: 0,
256 expires: self
257 .max_segment_duration
258 .map(|duration| Instant::now() + duration),
259 };
260
261 current_file.replace(file);
262 Ok(())
263 }
264
265 fn do_record(
266 &self,
267 current_file: &mut Option<OpenedFile>,
268 record: AcctLogRecord,
269 ) -> anyhow::Result<()> {
270 if let Some(file) = current_file {
271 let mut record_text = Vec::new();
272 serde_json::to_writer(&mut record_text, &record).context("serializing record")?;
273 record_text.push(b'\n');
274
275 file.file
276 .write_all(&record_text)
277 .with_context(|| format!("writing record to {}", file.name.display()))?;
278 file.written += record_text.len() as u64;
279
280 let need_rotate = file.written >= self.max_file_size
281 || file
282 .expires
283 .map(|exp| exp <= Instant::now())
284 .unwrap_or(false);
285
286 if need_rotate {
287 current_file.take();
288 }
289 }
290 Ok(())
291 }
292}
293
294#[derive(Serialize, Deserialize, Debug)]
295#[serde(tag = "type")]
296pub enum AcctLogRecord {
297 Authentication(AuthnAuditRecord),
298 Authorization(AuditRecord),
299}
300
301impl AcctLogRecord {
302 pub fn timestamp(&self) -> &DateTime<Utc> {
303 match self {
304 Self::Authentication(authn) => &authn.timestamp,
305 Self::Authorization(authz) => &authz.timestamp,
306 }
307 }
308
309 pub fn is_allow(&self) -> bool {
310 match self {
311 Self::Authentication(authn) => authn.success,
312 Self::Authorization(authz) => authz.access == Access::Allow,
313 }
314 }
315}
316
317async fn log_acct(record: AcctLogRecord) -> anyhow::Result<()> {
318 tracing::trace!("Audit: {record:?}");
319 if let Some(logger) = LOGGER.get() {
320 logger.tx.send(AcctCommand::Record(record)).await?;
321 }
322 Ok(())
323}
324
325pub async fn shutdown() {
330 let Some(logger) = LOGGER.get() else {
331 return;
332 };
333 logger.tx.send(AcctCommand::Terminate).await.ok();
334 let task = logger.task.lock().take();
335 if let Some(task) = task {
336 if let Err(err) = task.await {
337 tracing::error!("acct logger task failed: {err:#}");
338 }
339 }
340}
341
342pub async fn log_authz(record: AuditRecord) -> anyhow::Result<()> {
343 log_acct(AcctLogRecord::Authorization(record)).await
344}
345
346#[derive(Serialize, Deserialize, Debug)]
347pub struct AuthnAuditRecord {
348 pub timestamp: DateTime<Utc>,
350 pub attempted_identity: Identity,
352 pub success: bool,
354 pub auth_info: AuthInfo,
356}
357
358pub async fn log_authn(record: AuthnAuditRecord) -> anyhow::Result<()> {
359 log_acct(AcctLogRecord::Authentication(record)).await
360}
361
362#[cfg(test)]
363mod test {
364 use super::*;
365 use crate::authn_authz::IdentityContext;
366
367 fn params(log_dir: &std::path::Path, max_file_size: u64) -> AuditLogParams {
370 AuditLogParams {
371 log_dir: log_dir.to_path_buf(),
372 max_file_size,
373 back_pressure: 16,
374 compression_level: AuditLogParams::default_compression_level(),
375 max_segment_duration: None,
376 min_free_space: MinFree::default(),
377 min_free_inodes: MinFree::default(),
378 log_authz_allow: true,
379 log_authz_deny: true,
380 log_authn_ok: true,
381 log_authn_fail: true,
382 }
383 }
384
385 fn authn_record(identity: &str) -> AcctLogRecord {
386 AcctLogRecord::Authentication(AuthnAuditRecord {
387 timestamp: Utc::now(),
388 attempted_identity: Identity {
389 identity: identity.to_string(),
390 context: IdentityContext::SmtpAuthPlainAuthentication,
391 },
392 success: true,
393 auth_info: AuthInfo::default(),
394 })
395 }
396
397 fn read_identities(dir: &std::path::Path) -> anyhow::Result<Vec<Vec<String>>> {
400 let mut paths: Vec<_> = std::fs::read_dir(dir)?
401 .map(|entry| entry.map(|e| e.path()))
402 .collect::<Result<_, _>>()?;
403 paths.sort();
404 paths
405 .iter()
406 .map(|path| {
407 let data = zstd::stream::decode_all(std::fs::File::open(path)?)?;
408 String::from_utf8(data)?
409 .lines()
410 .map(|line| match serde_json::from_str(line)? {
411 AcctLogRecord::Authentication(authn) => {
412 Ok(authn.attempted_identity.identity)
413 }
414 other => anyhow::bail!("unexpected record {other:?}"),
415 })
416 .collect()
417 })
418 .collect()
419 }
420
421 #[tokio::test]
424 async fn shutdown_finalizes_open_segment() -> anyhow::Result<()> {
425 let dir = tempfile::tempdir()?;
426 params(dir.path(), AuditLogParams::default_max_file_size())
427 .init()
428 .await?;
429
430 let AcctLogRecord::Authentication(record) = authn_record("daniel") else {
431 unreachable!()
432 };
433 log_authn(record).await?;
434
435 shutdown().await;
436
437 k9::assert_equal!(
438 read_identities(dir.path())?,
439 vec![vec!["daniel".to_string()]]
440 );
441 Ok(())
442 }
443
444 #[test]
445 fn size_limit_rotates_segment() -> anyhow::Result<()> {
446 let dir = tempfile::tempdir()?;
447 let params = params(dir.path(), 1);
448 let mut current_file = None;
449
450 for identity in ["first", "second"] {
451 params.open_file_if_needed(&mut current_file)?;
452 params.do_record(&mut current_file, authn_record(identity))?;
453 assert!(
454 current_file.is_none(),
455 "segment must rotate after {identity}"
456 );
457 }
458
459 k9::assert_equal!(
460 read_identities(dir.path())?,
461 vec![vec!["first".to_string()], vec!["second".to_string()]]
462 );
463 Ok(())
464 }
465}