shell_tunnel/audit.rs
1//! Append-only audit trail.
2//!
3//! A remote shell without an audit trail leaves nothing to look at after an
4//! incident: the process logs are ephemeral, and the API returns results to the
5//! caller rather than recording them. This writes one JSON object per line —
6//! readable with `tail -f` or `jq`, appendable without a database, and never
7//! rewritten.
8//!
9//! What is deliberately *not* recorded: the bearer token itself. Events carry a
10//! per-registration `token_id` and the token's label, which identify the caller
11//! across a run without putting a credential in a file that is, by design, kept
12//! around and often shipped elsewhere.
13
14use 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/// Who made a request, in terms safe to write down.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
27pub struct Identity {
28 /// Stable-within-this-run identifier for the token used.
29 pub token_id: String,
30 /// The token's label (`operator`, `configured`, `legacy`, …).
31 pub label: String,
32}
33
34/// One recorded event.
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36pub struct AuditEvent {
37 /// Unix milliseconds. A number rather than a formatted string so the log
38 /// stays sortable without a date parser.
39 pub at_ms: u64,
40 /// What happened: `execute`, `denied`, …
41 pub kind: String,
42 /// Caller identity, when the request was authenticated.
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub identity: Option<Identity>,
45 /// Client address as the server saw it.
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub client: Option<String>,
48 /// Request method and path, for correlating with access logs.
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub route: Option<String>,
51 /// The command line, for execution events.
52 ///
53 /// This is the substance of the trail — "someone called POST /execute" says
54 /// almost nothing on its own. It also means a command that embeds a secret
55 /// puts that secret in the log, which is the trade an audit trail makes.
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub command: Option<String>,
58 /// Session the command ran in, when it was not a one-shot.
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub session_id: Option<u64>,
61 /// Process exit code, when the command completed.
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub exit_code: Option<i32>,
64 /// Whether the command hit its timeout.
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub timed_out: Option<bool>,
67 /// How long it took.
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub duration_ms: Option<u64>,
70 /// Bytes the command's output ran to, when more than the response carried.
71 ///
72 /// Present only on an execution whose output was capped, so its presence
73 /// *is* the signal: a trail entry without it describes a response that
74 /// carried everything. Recorded because the response itself is not kept —
75 /// without this, "why was that result short?" has no answer after the fact,
76 /// and a short answer is indistinguishable from a short command.
77 ///
78 /// Separate from `bytes`, which counts what a transfer moved. Reusing it
79 /// would make one field mean two things depending on `kind`.
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub output_bytes: Option<u64>,
82 /// HTTP status, for denial events.
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub status: Option<u16>,
85 /// Why a request was refused (`missing-token`, `invalid-token`, …).
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub reason: Option<String>,
88 /// Path a file operation touched, relative to the configured root.
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub file: Option<String>,
91 /// Bytes transferred. Present on terminal transfer events.
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub bytes: Option<u64>,
94 /// Entries a tree operation counted or removed. Present on `fs.delete`
95 /// events that acted on a directory.
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub entries: Option<u64>,
98 /// Whether the declared digest matched what arrived.
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub digest_ok: Option<bool>,
101 /// The upload session's id.
102 ///
103 /// Recorded on `upload.start` and on any event that cannot carry `file`
104 /// (see `with_upload_id`'s doc comment) so the two can still be joined
105 /// into one session's story.
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub upload_id: Option<String>,
108}
109
110impl AuditEvent {
111 /// Start an event of the given kind, stamped now.
112 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 /// Attach the caller's identity.
136 pub fn with_identity(mut self, identity: Option<Identity>) -> Self {
137 self.identity = identity;
138 self
139 }
140
141 /// Attach the client address.
142 pub fn with_client(mut self, client: impl Into<String>) -> Self {
143 self.client = Some(client.into());
144 self
145 }
146
147 /// Attach the request route.
148 pub fn with_route(mut self, route: impl Into<String>) -> Self {
149 self.route = Some(route.into());
150 self
151 }
152
153 /// Attach the command line that ran.
154 pub fn with_command(mut self, command: impl Into<String>) -> Self {
155 self.command = Some(command.into());
156 self
157 }
158
159 /// Attach the session the command ran in.
160 pub fn with_session(mut self, session_id: u64) -> Self {
161 self.session_id = Some(session_id);
162 self
163 }
164
165 /// Attach the outcome of an execution.
166 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 /// Record that an execution produced more output than was returned.
179 ///
180 /// A no-op when nothing was discarded, so callers can hand over both
181 /// figures unconditionally and the field stays a truncation signal rather
182 /// than a size that is present on every entry.
183 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 /// Attach a refusal.
191 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 /// Attach the file a transfer touched, and how many bytes moved.
198 ///
199 /// Recorded on terminal events only. A chunk-level trail would turn one
200 /// gigabyte-scale transfer into hundreds of lines and bury everything else
201 /// in the log.
202 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 /// Record whether the declared digest matched.
209 pub fn with_digest(mut self, verified: bool) -> Self {
210 self.digest_ok = Some(verified);
211 self
212 }
213
214 /// Attach the upload session's id.
215 ///
216 /// Every terminal upload event but one can name its subject through
217 /// `file` (the destination). The exception is `upload.orphaned`: a
218 /// `.part` staging file found at startup carries only the id encoded in
219 /// its own filename — the destination it was headed for lived in the
220 /// in-memory session that a restart already discarded, so there is
221 /// nothing left to attach as `file`. `upload_id` is how a reader
222 /// recovers that link anyway: it is also recorded on `upload.start`
223 /// (which does have `file`), so grepping the trail for one upload's id
224 /// still surfaces both ends of its story.
225 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/// Where audit events go.
232///
233/// Disabled unless a path is configured: writing to a file nobody asked for
234/// would be a surprising side effect, and the operator is the one who knows
235/// where such a file belongs.
236#[derive(Debug, Default)]
237pub enum AuditSink {
238 /// Nothing is recorded.
239 #[default]
240 Disabled,
241 /// Appended to a file, one JSON object per line.
242 File {
243 /// Path being appended to, kept for diagnostics.
244 path: PathBuf,
245 /// Size at which the file is rotated, if bounded.
246 max_bytes: Option<u64>,
247 state: Mutex<FileState>,
248 },
249}
250
251/// Open file plus what has been written to it.
252#[derive(Debug)]
253pub struct FileState {
254 writer: BufWriter<File>,
255 /// Bytes in the current file, tracked rather than stat-ed so rotation costs
256 /// nothing per event.
257 written: u64,
258}
259
260/// How large the trail is allowed to get before it rotates, unless told otherwise.
261///
262/// The trail is on by default whenever the server is reachable, so its size is
263/// not something an operator opts into thinking about — until 0.21.0 it had no
264/// bound at all, and one line per execution accumulating forever is a way to
265/// fill a disk. That is a *different* way to take a server down than running out
266/// of memory, and a trail that fills the disk stops recording, so "keep
267/// everything" does not actually keep everything.
268///
269/// The figure is derived rather than picked: an entry as the trail writes them
270/// runs about 200 bytes (an `execute` entry with identity and command measures
271/// 211, a `denied` entry 108–183). One generation is kept beside the live file,
272/// so this bounds the trail at 128 MiB on disk and roughly 670,000 entries
273/// retained — far more history than an incident needs to look back through, and
274/// small enough that it cannot be the thing that fills a disk.
275///
276/// `--audit-max-bytes 0` restores the old unbounded behaviour for an operator
277/// who would rather keep everything and manage the size themselves.
278pub const DEFAULT_MAX_BYTES: u64 = 64 * 1024 * 1024;
279
280/// The default must be a bound, not the opt-out.
281///
282/// Zero is the escape hatch meaning "never rotate" ([`Args::audit_rotation_limit`]),
283/// so a default of zero would silently switch rotation off for everyone while
284/// still reading as "bounded by default". Checked here rather than in a test
285/// because it is a property of the constant, and a test asserting it would be
286/// asserting something the compiler already knows.
287///
288/// [`Args::audit_rotation_limit`]: crate::cli::Args::audit_rotation_limit
289const _: () = assert!(DEFAULT_MAX_BYTES > 0);
290
291impl AuditSink {
292 /// Open `path` for appending, creating it if needed.
293 ///
294 /// Unbounded: rotation is [`file_with_limit`](Self::file_with_limit), and the
295 /// binary passes [`DEFAULT_MAX_BYTES`] through that. This constructor exists
296 /// for consumers that manage the file's size themselves.
297 pub fn file(path: impl AsRef<Path>) -> Result<Self> {
298 Self::file_with_limit(path, None)
299 }
300
301 /// Open `path`, rotating to `<path>.1` once it passes `max_bytes`.
302 ///
303 /// One generation is kept. A trail that grows without bound eventually
304 /// fills the disk it is meant to protect, and keeping several generations
305 /// would be a retention policy — which belongs to whoever runs the machine,
306 /// not to this process.
307 pub fn file_with_limit(path: impl AsRef<Path>, max_bytes: Option<u64>) -> Result<Self> {
308 let path = path.as_ref().to_path_buf();
309 let (writer, written) = open_append(&path)?;
310 Ok(Self::File {
311 path,
312 max_bytes,
313 state: Mutex::new(FileState { writer, written }),
314 })
315 }
316
317 /// Whether anything is being recorded.
318 pub fn is_enabled(&self) -> bool {
319 matches!(self, Self::File { .. })
320 }
321
322 /// Record one event from an async context, off the runtime's workers.
323 ///
324 /// [`record`](Self::record) opens, writes and flushes a file, which is
325 /// blocking work — the filesystem handlers already thread it into the
326 /// `spawn_blocking` bodies they are running in for that reason, so a slow
327 /// disk cannot starve the worker pool that also runs `/health` and the
328 /// accept loop. A handler that has no blocking body of its own has nowhere
329 /// to put it and used to call `record` straight from the runtime thread.
330 /// This is that missing half: same write, same ordering.
331 ///
332 /// What this buys and what it costs were both measured rather than reasoned
333 /// (`tests/blocking_pool.rs`). With every blocking thread held, `/health`
334 /// answered in 2.5 µs — the worker threads really are untouched. The same
335 /// run had this method take 2.96 s against 1.57 ms once a thread was free:
336 /// moving blocking work off the workers does not make it free, it moves it
337 /// onto a pool that is shared with every command in flight. So a burst of
338 /// concurrent commands does delay an audited response, and that is a
339 /// deliberate trade against blocking the accept loop, not an oversight.
340 ///
341 /// Awaited rather than detached, deliberately. Spawning and walking away
342 /// would return the response first and leave the entry to land whenever —
343 /// or not at all, if the process stops in between. An audit trail that
344 /// drops its last entries under load is untrustworthy exactly where it is
345 /// load-bearing, which is the same reason `record` flushes per event.
346 ///
347 /// The hop is skipped entirely when nothing is being recorded: with no
348 /// trail configured `record` returns immediately, and paying for a task
349 /// dispatch to do nothing would be a cost on every request of the default
350 /// configuration.
351 pub async fn record_async(self: &std::sync::Arc<Self>, event: AuditEvent) {
352 if !self.is_enabled() {
353 return;
354 }
355 let sink = std::sync::Arc::clone(self);
356 // A panic inside the blocking task would already have aborted the
357 // process through the panic hook; the join error is not actionable
358 // here and dropping it keeps this from being a second failure mode.
359 let _ = tokio::task::spawn_blocking(move || sink.record(event)).await;
360 }
361
362 /// Record one event.
363 ///
364 /// Flushed per event rather than buffered until convenient: a trail that
365 /// loses its last entries when the process dies is least trustworthy exactly
366 /// when it matters most.
367 ///
368 /// Blocking. From an async context use
369 /// [`record_async`](Self::record_async), or call this inside a
370 /// `spawn_blocking` body that is already running.
371 pub fn record(&self, event: AuditEvent) {
372 let Self::File {
373 path,
374 max_bytes,
375 state,
376 } = self
377 else {
378 return;
379 };
380
381 let line = match serde_json::to_string(&event) {
382 Ok(line) => line,
383 Err(e) => {
384 tracing::warn!(target: "audit", "cannot encode audit event: {e}");
385 return;
386 }
387 };
388
389 let Ok(mut state) = state.lock() else {
390 tracing::warn!(target: "audit", "audit log lock poisoned; event dropped");
391 return;
392 };
393
394 // Rotated before the write, so the limit bounds the file rather than
395 // being the point at which it is already over.
396 if let Some(limit) = max_bytes {
397 if state.written + line.len() as u64 + 1 > *limit && state.written > 0 {
398 if let Err(e) = rotate(path, &mut state) {
399 tracing::warn!(target: "audit", "cannot rotate audit log {}: {e}", path.display());
400 }
401 }
402 }
403
404 match writeln!(state.writer, "{line}").and_then(|()| state.writer.flush()) {
405 Ok(()) => state.written += line.len() as u64 + 1,
406 Err(e) => {
407 // Logged, not fatal: losing the trail should not take the server
408 // down, but it must not pass silently either.
409 tracing::warn!(target: "audit", "cannot write audit log {}: {e}", path.display());
410 }
411 }
412 }
413}
414
415/// Open a file for appending, reporting how much is already in it.
416fn open_append(path: &Path) -> Result<(BufWriter<File>, u64)> {
417 let file = OpenOptions::new()
418 .create(true)
419 .append(true)
420 .open(path)
421 .map_err(|e| {
422 ShellTunnelError::Io(std::io::Error::new(
423 e.kind(),
424 format!("cannot open audit log {}: {e}", path.display()),
425 ))
426 })?;
427 let written = file.metadata().map(|m| m.len()).unwrap_or(0);
428 Ok((BufWriter::new(file), written))
429}
430
431/// Move the current file aside and start a fresh one.
432fn rotate(path: &Path, state: &mut FileState) -> std::io::Result<()> {
433 state.writer.flush()?;
434
435 let rotated = path.with_extension(match path.extension() {
436 Some(ext) => format!("{}.1", ext.to_string_lossy()),
437 None => "1".to_string(),
438 });
439 // Replaces the previous generation: one is kept, deliberately.
440 std::fs::rename(path, &rotated)?;
441
442 let (writer, _) = open_append(path).map_err(std::io::Error::other)?;
443 state.writer = writer;
444 state.written = 0;
445 Ok(())
446}
447
448/// Milliseconds since the Unix epoch.
449fn now_ms() -> u64 {
450 SystemTime::now()
451 .duration_since(UNIX_EPOCH)
452 .map(|d| d.as_millis() as u64)
453 .unwrap_or(0)
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 fn read_lines(path: &Path) -> Vec<AuditEvent> {
461 std::fs::read_to_string(path)
462 .unwrap()
463 .lines()
464 .map(|line| serde_json::from_str(line).expect("each line is one event"))
465 .collect()
466 }
467
468 #[test]
469 fn a_disabled_sink_records_nothing() {
470 let sink = AuditSink::Disabled;
471 assert!(!sink.is_enabled());
472 sink.record(AuditEvent::new("execute"));
473 }
474
475 #[test]
476 fn events_are_appended_one_per_line() {
477 let dir = tempfile::tempdir().unwrap();
478 let path = dir.path().join("audit.jsonl");
479 let sink = AuditSink::file(&path).unwrap();
480
481 sink.record(AuditEvent::new("execute").with_command("echo one"));
482 sink.record(AuditEvent::new("execute").with_command("echo two"));
483
484 let events = read_lines(&path);
485 assert_eq!(events.len(), 2);
486 assert_eq!(events[0].command.as_deref(), Some("echo one"));
487 assert_eq!(events[1].command.as_deref(), Some("echo two"));
488 }
489
490 #[test]
491 fn reopening_appends_rather_than_truncating() {
492 let dir = tempfile::tempdir().unwrap();
493 let path = dir.path().join("audit.jsonl");
494
495 AuditSink::file(&path)
496 .unwrap()
497 .record(AuditEvent::new("execute").with_command("first run"));
498 AuditSink::file(&path)
499 .unwrap()
500 .record(AuditEvent::new("execute").with_command("second run"));
501
502 // A trail that restarts empty on every restart is not a trail.
503 let events = read_lines(&path);
504 assert_eq!(events.len(), 2);
505 }
506
507 #[test]
508 fn an_execution_event_carries_who_what_and_outcome() {
509 let dir = tempfile::tempdir().unwrap();
510 let path = dir.path().join("audit.jsonl");
511 let sink = AuditSink::file(&path).unwrap();
512
513 sink.record(
514 AuditEvent::new("execute")
515 .with_identity(Some(Identity {
516 token_id: "tok-1".into(),
517 label: "operator".into(),
518 }))
519 .with_client("203.0.113.7:51000")
520 .with_route("POST /api/v1/execute")
521 .with_command("whoami")
522 .with_outcome(Some(0), false, 42),
523 );
524
525 let event = read_lines(&path).remove(0);
526 assert_eq!(event.kind, "execute");
527 assert_eq!(event.identity.unwrap().label, "operator");
528 assert_eq!(event.command.as_deref(), Some("whoami"));
529 assert_eq!(event.exit_code, Some(0));
530 assert_eq!(event.timed_out, Some(false));
531 assert_eq!(event.duration_ms, Some(42));
532 assert!(event.at_ms > 0);
533 }
534
535 #[test]
536 fn a_denial_records_why_without_the_token() {
537 let dir = tempfile::tempdir().unwrap();
538 let path = dir.path().join("audit.jsonl");
539 let sink = AuditSink::file(&path).unwrap();
540
541 sink.record(
542 AuditEvent::new("denied")
543 .with_client("198.51.100.4:40000")
544 .with_route("POST /api/v1/execute")
545 .with_denial(401, "invalid-token"),
546 );
547
548 let raw = std::fs::read_to_string(&path).unwrap();
549 let event = read_lines(&path).remove(0);
550 assert_eq!(event.status, Some(401));
551 assert_eq!(event.reason.as_deref(), Some("invalid-token"));
552 // Probing is what these entries are for, and the credential that was
553 // tried must not end up in the file.
554 assert!(!raw.contains("Bearer"), "{raw}");
555 }
556
557 #[test]
558 fn a_bounded_log_rotates_instead_of_growing() {
559 let dir = tempfile::tempdir().unwrap();
560 let path = dir.path().join("audit.jsonl");
561 // Small enough that the second event cannot share the file.
562 let sink = AuditSink::file_with_limit(&path, Some(200)).unwrap();
563
564 for i in 0..8 {
565 sink.record(AuditEvent::new("execute").with_command(format!("command number {i}")));
566 }
567
568 let current = std::fs::metadata(&path).unwrap().len();
569 assert!(
570 current <= 200,
571 "current file should stay under the limit: {current}"
572 );
573
574 // The previous generation is kept, so the most recent history survives
575 // a rotation rather than being discarded.
576 let rotated = dir.path().join("audit.jsonl.1");
577 assert!(rotated.exists(), "one generation should be kept");
578 }
579
580 #[test]
581 fn an_unbounded_log_never_rotates() {
582 let dir = tempfile::tempdir().unwrap();
583 let path = dir.path().join("audit.jsonl");
584 let sink = AuditSink::file(&path).unwrap();
585
586 for i in 0..20 {
587 sink.record(AuditEvent::new("execute").with_command(format!("command {i}")));
588 }
589
590 assert_eq!(read_lines(&path).len(), 20);
591 assert!(!dir.path().join("audit.jsonl.1").exists());
592 }
593
594 #[test]
595 fn rotation_keeps_counting_from_an_existing_file() {
596 let dir = tempfile::tempdir().unwrap();
597 let path = dir.path().join("audit.jsonl");
598
599 // A restart must not forget how full the file already is, or the limit
600 // would only apply to whatever this process wrote.
601 AuditSink::file(&path)
602 .unwrap()
603 .record(AuditEvent::new("execute").with_command("x".repeat(150)));
604 let sink = AuditSink::file_with_limit(&path, Some(200)).unwrap();
605 sink.record(AuditEvent::new("execute").with_command("second"));
606
607 assert!(dir.path().join("audit.jsonl.1").exists());
608 }
609
610 #[test]
611 fn absent_fields_are_omitted_rather_than_null() {
612 let dir = tempfile::tempdir().unwrap();
613 let path = dir.path().join("audit.jsonl");
614 AuditSink::file(&path)
615 .unwrap()
616 .record(AuditEvent::new("execute"));
617
618 let raw = std::fs::read_to_string(&path).unwrap();
619 assert!(!raw.contains("null"), "{raw}");
620 }
621}